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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 16:01:47 -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
CydandClaude Opus 4.8 67074c80e2 Plasma window joins the glass_layout.cfg no-frame list
The desktop plasma window (L4PLASMAWIN, "BattleTech - Plasma") is created by a
different TU than the per-display panels, so it couldn't be a gWins[] entry and
sat outside the sticky-layout / ,noframe system.  Add a tiny extern-window
registry to L4GLASSWIN so it rides the same glass_layout.cfg:

  - BTGlassLayout_QueryWindow(title, rect, noframe) -- read a title's saved rect
    and ,noframe flag (used by the plasma on creation, BEFORE sizing, since
    WS_POPUP's frame extent differs from the framed tool window).
  - BTGlassLayout_RegisterExtern(hwnd, title) -- register an externally-created
    window so SaveLayout writes its line (with a last-known-rect cache so a
    teardown before the save still preserves the line).
  - BTGlassLayout_Save() -- public save trigger the plasma WndProc calls on
    WM_EXITSIZEMOVE / teardown.

L4PLASMAWIN now reads its saved rect+flag on create (WS_POPUP when ,noframe,
position restored), registers itself, and saves on finished-drag/teardown.  All
BT_GLASS-gated; the pod is untouched.  The plasma blits directly every frame, so
it has no WM_TIMER focus-throttle.

Verified: Release links clean; drag wrote "BattleTech - Plasma=321,222,528,167",
reload with ,noframe brought it up WS_POPUP (WS_CAPTION absent) at 321,222.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 19:38:29 -05:00
CydandClaude Opus 4.8 7d112ce309 Glass panels: flash at full rate when unfocused + red Panic/Eject button
Two glass-panel indicator fixes reported by playtesters.

1. FLASH SLOW UNLESS FOCUSED.  The per-display panel windows repaint off a
   62ms WM_TIMER, and Windows coalesces/throttles timer + paint messages for a
   window that is in the BACKGROUND (unfocused) -- so with the game window
   holding focus the panels' lamp flash (a repaint-driven animation) crawled,
   and giving a panel focus un-throttled it.  (My earlier surround fix drew in
   the main D3D frame and never touched these separate windows.)  New
   BTGlassPanels_Tick(), called once per frame from the main render loop
   (L4VIDEO), drives a synchronous InvalidateRect+UpdateWindow at the ~16Hz
   flash cadence -- the main loop runs every frame regardless of which window
   has focus, and the forced paint bypasses the throttled timer-message path.
   The WM_TIMER stays for its one-shot re-snap.

2. RED PANIC/EJECT.  0x3D is the Panic/Eject button; on the blue flight panel
   it was just another blue block.  The shared flight-grid geometry (L4RIOBANK)
   now tags 0x3D as colorClass 0 (red); L4GLASSWIN's AdoptBank maps colorClass
   0 -> ClrRed explicitly (the flight bank's ClrBlue default no longer swallows
   it), and the surround (L4VB16) already renders non-blue/yellow as red -- so
   the eject stands out red in both the surround and the exploded window.

Verified: Release links clean (40 tolerated /FORCE externals only); exploded
panels boot + run 13s no crash under the per-frame repaint pump; riobank dump
confirms 0x3d class=0 (red), neighbours class=2 (blue).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 19:28:11 -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
CydandClaude Opus 4.8 f99003cf50 Glass cockpit lamps: sweep every frame so lit buttons track the sim under load
Playtesters (all on the glass surround) reported the cockpit lighting
sometimes going slow or nonexistent while the 3D view stayed perfectly
smooth.  The RIO serial/lamp stack is byte-identical to pod-proven Red
Planet (diffed L4LAMP/L4RIO/L4SERIAL), so the wiring was fine -- the defect
is the glass UPDATE CADENCE:

The lit buttons draw every frame (BTDrawCockpitPanels, reading
PadRIO::GetLampState), but the lamp STATE sweep that fills that store
(LampManager::Update -> AssertNewLampValue -> SetLamp) rides the gauge
renderer's FOREGROUND turn, which fires only once per full gauge cycle
(foreground->background->copy).  The cycle can't reach the next foreground
turn until the throttled background gauge sweep drains the ~140-instrument
active list -- and that background task starves under MP load (issue #45,
"instruments freeze while fps stays healthy").  So the lamp sweep ran
~1x/second: buttons froze / flashes stalled while the view (a separate
per-frame foreground render) stayed smooth.

Fix: BTGlassSweepLamps() runs the lamp sweep EVERY frame on the dev/glass
composite path (L4VB16.cpp, in BTDrawGaugeInset), decoupled from the gauge
cycle.  It is cheap -- deduped state pushes over ~72 lamps, no raster.  The
pod never enters BTDrawGaugeInset (real gauge hardware), so its authentic
bandwidth-paced serial lamp cadence is untouched.  BT_GLASS_LAMP_SWEEP=0
restores the authentic once-per-cycle behaviour.

Verified: Release links clean (only the 40 tolerated /FORCE externals);
glass surround boots and runs, lamp sweep pushes state (43 [lamp] pushes,
buttons lit), ~60 fps, no crash.  The under-load win is structural (the
sweep is now an unconditional per-frame call); true MP-starvation repro
needs multiple nodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:41:04 -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
Joe DiPrimaandClaude Opus 5 08ca66e5b5 weapon SPEC AUDIT: the fleet's authored numbers in one pass -- missile delivery fully reconciled
"do we need to audit every possible weapon independently?" -- one dump answers
all of them.  [wspec]/[wspec-msl] (BT_SPEC_LOG) print every weapon's AUTHORED
damage/recycle/range/heat + missile count and post-divide per-missile at build.

THE KEY DISCOVERY: the catalog's FULL-NAME loadouts (madcat, thor, avatar,
sunder, vulture, loki, blkhawk, owens) author DIFFERENT weapons than the
4-char variants (mad1, thr1, ...) -- and the named ones are what players fly.
Every night-9 field number reconciles exactly:
    madcat/thor LRM15 = 50 total -> 3.33/missile (field: 3.33, bursts 3..15)
    avatar/sunder LRM10 = 35     -> 3.50          (field: 3.50, bursts 2..10)
    vulture LRM20 = 65           -> 3.25          (field: 3.25, bursts 5..20)
    loki SRM4 = 35               -> 8.75          (field: 8.75, bursts 1..4)
Missile delivery is FAITHFUL across the real fleet: per-missile = authored /
count (divide confirmed live post-ctor), delivered total = per-missile x the
binary's Random(n)+n/4 roll = 25..100% of listed, zone-scattered.

The "80+ point hits" recollection fits the data as an ALPHA: madcat full alpha
= 50 + 25 (AFC100) + 6 + 2 = 83.  A single 4.10 salvo cannot exceed 65
(vulture LRM20).  The "3-5 points" reports read our [projectile] IMPACT
per-missile diagnostic as a total.

Also: bac1 is not a valid vehicle (unhandled AV at btl4+0xac4cd on spawn --
noted for a guard someday); wspec-msl now prints AFTER the ctor divide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:31:36 -05:00
Joe DiPrimaandClaude Opus 5 47d9a61402 hit-location cylinder: raw-bytes audit of the shipped tables + chassis->table map + wedge orientation
Audit of Cyd's hit-model artifact against THE BINARY (user mandate: not vs the
port).  res29_scan.py parses the type-29 streams straight from BTL4.RES bytes:
18 streams / 8 distinct confirmed; the 7-wedge cells (BLH band 6, VUL band 4)
and the all-fixed BLH/OWN twist patterns are authored fact; every artifact
spot-check cell matched the raw bytes verbatim.  Chassis->table resolved by
live-dump matching (MadCat and Avatar ride DIFFERENT same-zone-set tables).
Authored slice names fix the wedge orientation: FRONT = the +Z arc, W1|W2 seam
= dead ahead -- corrects the #92 comment's W6-frontal claim.  Collision type-0
divert re-grounded at 0x4a0361/0x4a036d: rams never touch the cylinder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 12:52:20 -05:00
Joe DiPrimaandClaude Opus 5 07072fc9f0 hit-location GROUND TRUTH: all 8 chassis' authored cylinder tables, named + fingerprinted
For verifying Cyd's hit-location artifact (the platform serves it to this
session as an unreadable public artifact, so the diff base is published here
instead).  scratchpad/night9/hitmodel_groundtruth.txt: every chassis's 7
layers x per-layer slices with zone NAMES and percentages, from the live
BT_DMGTABLE_LOG dump joined against [zone-armor] names.

Structure fingerprints (rot = rotateWithTorso per layer 0..6):
    ava/lok/mad/snd/thr/vul   rot=0001111  (legs hull-fixed, torso bands twist)
    bhk/own                   rot=0000000  (chicken-walkers: NOTHING twists)
    slices: 8 everywhere EXCEPT bhk layer 6 = 7 and vul layer 4 = 7
Variants share tables: lok2==lok1, mad2==mad1 -- '8 distinct chassis' is exact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 12:27:19 -05:00
Joe DiPrimaandClaude Opus 5 9b1353bfbf ONE Explosion per salvo -- the port detonated every visual round, amplifying explosion sound + smoke + audio demand by the missile count
The user challenged the 7% clock story ("i think its not just a 7% discrepancy.
did you base your analysis in the decomp") -- correctly.  The stop-path trace
it forced found the real amplifier, and it is byte-grounded:

THE ARCADE (byte-verified in the #95 work): FireWeapon @004bcc60 spawns ONE
Missile entity per trigger -- no loop, missileCount never read at fire.  One
Missile -> one detonation (@004be078) -> ONE Explosion entity: one 4-voice
sound + one authored smoke package per salvo.  The cluster's N-fold nature is
delivered as DAMAGE (burstCount), not as N explosions.

THE PORT: the N-round visual ripple (a deliberate [T3] port visual; its rounds
are damage-0 tracers) called BTSpawnRoundDetonation at EVERY round's contact --
N full Explosion entities per salvo, N x BigExpInt x 4 voices, N x the
explosion PFX, N=2..20.  Measured in a 100s solo missile bench: 559 explosion
sound setups; BigExpInt 208.  On observer nodes the stale-point mirror bug
(fixed in da70bd5) DOUBLED the train again.

One infidelity, three field symptoms:
  * Oracle: "prolonged smoke and explosion including sound" -- a staggered
    train of 6-20 overlapping BigExpInt instances per salvo reads as one long
    explosion+roar (Rajel correctly tied it to #84)
  * Ronin: missile-impact smoke "so much it functioned as a smoke screen"
  * #32: the saturating audio class was 1005 Static3DPatchSource requested=4
    -- each surplus detonation reserved 4 more voices

FIX: BTProjectile carries salvoLead; both detonation sites (target contact +
world/terrain burst) gate on it.  Master salvo: lead = round 0 (the damage
carrier).  Mirror salvo: round 0, so observers see exactly one explosion per
salvo at the live target.  AC/laser single rounds default lead=1 (one round =
one explosion, authentic).  Non-lead rounds keep their small tracer end-puffs.

MEASURED A/B (same bench): 80 of 96 contacting rounds no longer spawn the
package; 16 leads ~= the salvo count.  BigExpInt setups 208 -> 72 (~18
packages for ~16 salvos + deaths = one per salvo).  LaserExplosion unchanged
in kind.  Splash was already lead-only (splashBurst) -- damage totals move
NOWHERE in this change.

Also corrected the rack-tube comment that claimed "the binary's Missile
entities each launch from their own rack tube... their 12 detonations" -- there
are no 12 binary missiles; the ripple is ours.

The 28Hz audio-clock calibration from the previous commit stands, but is
demoted to what it is: a small systemic correction, not the explanation for
the field reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 12:00:25 -05:00
Joe DiPrimaandClaude Opus 5 6dbeb3d266 wreck smoke dies WITH the wreck; the audio/effect clock is calibrated to the byte-proven 28
USER REPORT: "when you destroy a mech, it continues to smoke for a very long
time after the mech disappears... up to 30secs to a minute."

MEASURED (new [pfx] timed census with per-def particle attribution): the
death plume (psfx 1 DDTHSMK) was re-armed on a 10s cadence as a WORLD effect
(ownerTag=0) -- so neither the burial transition nor the respawn cleanup
(StopAllEntityEffects) could touch it.  The last-armed window kept emitting
over the empty spot for up to 10s after the hulk vanished, plus 6+-2s authored
particle lives, and back-to-back windows stretched the visible tail to the
reported range.  The re-arm itself is a marked PORT ADDITION [T3]; the binary
fired DDTHSMK once -- its 10s window + particle lives ended right around the
~17s hulk sink.  The authored design: the smoke dies WITH the burial.

FIX: the plume is spawned ATTACHED (BTStartPfxAttached, tagged to the dying
entity -- it also rides the sinking hulk now), and the burial transition calls
BTStopEntityPfx alongside the respawn cleanup that already did.  Verified over
a 175s run containing FOUR wreck events (the enemy kill + three player bay-
fire deaths): every plume's emission ends at its wreck's burial or respawn,
particles fade within ~8-11s, no orphan emitters remain.

THE COUPLING (user relay of Oracle: "prolonged smoke and explosion INCLUDING
SOUND... same lever?") -- CONFIRMED as a family: the binary's audio layer
times in FRAME COUNTS converted by the engine frame-rate global:
    AudioTime::Seconds_To_Frames == fmul [0x52140c]=28.0; fadd 0.5; round
    (@0x42c611, @0x42dd86 -- the 0x42xxxx cluster of the ~90 global consumers)
Our engine's DefaultRendererRate said 30: every audio duration, sequence
delay and compression window ran ~7% fast.  Calibrated to 28 [T1].  NOT yet
explained by this: a truly SUSTAINED/looping explosion sound (7% is not
"prolonged") -- the effect stop-path trace stays open on #114/#51.

Tooling: BTPfxParticle carries defIndex; [pfx] census (BT_PFX_LOG, 2s) prints
emitters + particles PER EFFECT SLOT with a timestamp; wrecksmoke.sh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 11:25:49 -05:00
Joe DiPrimaandClaude Opus 5 2df1369168 #116 THE REAL STEAM CAUSE: experience never rode the lobby -- joiners' pages were authored by the HOST as the "veteran" fallback
The user's question broke my first story: "did lynx host tho?"  He did (Oracle
joined LYNX's lobby that night) -- but more importantly the affected players'
logs read mode=steam, which gets the FULL menu, so the FeJoinOnly fix in
3109cfc patched a real gap (mode=join) that is NOT the path the affected trio
ran.  Oracle COULD see and cycle the selector; it still didn't matter.  Traced:

  * The steam lobby serialized exactly three member fields: nm / vh / cl.
    Experience never travelled.
  * The steam HOST rebuilds mission.pilots[] from the lobby roster
    (name/vehicle/color only), so every non-self page has EMPTY experience --
    and BTFeMission_WriteEgg stamps the "veteran" fallback on empty.
  * The steam JOINER writes NO egg at all (spec->eggPath[0]=0); his mission
    arrives from the HOST's console feed -- i.e. the host's egg, where HIS
    page says veteran no matter what his own console shows.
  * The host's own page keeps his menu choice only as slot-0 residue of the
    pre-roster self fill -- why BOTH hosts (Sauron AND Lynx) were clean while
    all three joiners heated in "standard".

FIX: experience is now published as lobby member data ("xp") beside nm/vh/cl,
unpacked into BTLobbyMember, and stamped onto each member's egg page in the
host's roster loop.  Both exported lobby entry points + PublishSelf gain the
parameter; the fe passes the local menu selection.  An older client in the
lobby simply has no "xp" -- the veteran fallback applies to THAT member only,
exactly the pre-fix behavior, so mixed-build lobbies do not break.

Per-player mixing (the original design, per the user: the sysop set each
user's tier; mixed matches were legal) now works over steam end to end: each
member's OWN choice -> lobby -> the host-authored egg page -> the console feed
-> that node's master player.

VERIFICATION BOUNDARY: builds both configs; the chain is code-traced; the
lobby legs need a real Steam session -- field re-test is a JOINER selecting
Standard and logging [exp] experience=1 heatModelOn=0 (plus the [fe] line on
both sides).  The 3109cfc join-trim selector stays: correct for mode=join.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:50:19 -05:00
Joe DiPrimaandClaude Opus 5 3109cfc49a #116: steam JOINERS were silently locked to VETERAN -- restore their experience selector
Oracle isolated it: "standard mode still having heat and leaks only occurred in
steam not in solo.  Sauron and Lynx did not have the issue but Conn Man, Rajel
and myself did."  The night-9 logs close the case:

    affected  (Oracle/ConnMan/Rajel):  [exp] experience=2 heatModelOn=1
    clean     (Sauron's std sessions): [exp] experience=1 heatModelOn=0

CAUSE: the glass front end's JOIN layout trimmed the menu to "the mech list +
the JOIN button; everything else is the operator's call" -- and the experience
selector's hidden default is 2 (veteran).  So HOSTS got whatever they picked
(Sauron hosted the lobbies all night -> his standard landed), while every
JOINER launched as veteran regardless of anything they did -- which is why
Oracle's cycling-the-settings experiment changed nothing, and why solo (full
menu) never showed the problem.  heat >= veteran, hence "standard but heating".

DESIGN CONFIRMED BY THE USER before fixing: experience is PER-PLAYER by the
original design -- the sysop set each user's tier and mixed-experience matches
were legal.  Architecture already supports it end to end: each node's master
player reads its OWN egg's experience (btMission+0xE4 -> BTPlayer @004c0bc8),
so per-node choice IS per-player choice.

FIX: the join layout gains the GroupExperience selector (the selection->egg
write path is the pre-existing host path, field-proven).  Also: the front end
now prints "[fe] pilot experience=<x> (join|host/solo)" at launch, so every
future field log answers this class of report without asking anyone.

VERIFICATION BOUNDARY, stated plainly: builds both configs; the write path is
shared with the host flow which the field already exercises; the visible
selector + the [fe] line need one join-mode launch to eyeball, and the field
re-test is Oracle running a standard steam match as a JOINER and seeing no
heat.

Tooling note recorded in test-harness.md: bash-heredoc python collapses one
backslash level even single-quoted -- a "\n" arrives as a real newline and
replaces silently no-op.  Build backslashes from bytes([92]); verify replaces
by length delta, not by the script saying "fixed".  (This burned four edit
rounds tonight and several earlier C2001 hunts.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:40:51 -05:00
Joe DiPrimaandClaude Opus 5 1bcee1f9eb #96: the effective-rate theory is corroborated -- upgrade the calibration provenance T3 -> T2
Frame-rate testimony from the morning follow-up, now on record beside the bytes:
  * Oracle, asked if the pod 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 pacer's own design agrees: fixed budget + background fill + NO
    overrun catch-up (APPMGR.cpp, T0) = "target 28 but allowed to slip", with
    the 18.2065 BIOS fallback anticipating degraded timing.

Also settles epilectrik's open confirmation ("frame rate is advancing at least
some of the subsystems -- i have to fully confirm that"): ALL of them --
UpdateManager::Execute runs every interesting master every frame,
Entity::PerformAndWatch runs every executable subsystem per entity tick, no
throttle at any layer.  The crisp rule: CADENCE is frame-linked for
everything; only terms missing a dt multiply are rate-SENSITIVE.

Corollary worth keeping: a choking 486 generated LESS heat/s exactly during
the biggest fights -- the slideshow was mercy.  Veteran memory of seek-4
endurance encodes the LOADED rate, which is why bracketing by their consensus
is the correct instrument rather than a concession.

Follow-up filed in the KB: sweep the reconstruction for OTHER dt-less per-tick
accumulators (known: particle trail density, rendering.md; the 15-sample
heatFilter window is rate-dependent smoothing, benign).  Each found one should
normalize against the same 28-nominal/bracketed-effective pair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:20:52 -05:00
Joe DiPrimaandClaude Opus 5 ac94ce6a31 #96: DISSIPATION AUDIT -- byte-faithful end to end; the cooling-too-weak hypothesis is DEAD
"no guesses. we want to decompile and find the right answer" -- done.  Every
function in the heat-removal chain read from the decomp and diffed against our
reconstruction:

  HeatSinkSimulation @004ad924   E += pending; T = E/mass; alarms       EXACT
  UpdateHeatLoad     @004ad7f0   radiated = T x coolant; clamp(0.002x)  EXACT
  ConductHeat        @004ad8ac   exchange into linked + rebalance       EXACT
  ComputeHeatFlow    @004ad9ec   (T1*mScale - (E1+E2+pend2)/den) * tau
                                 * (1 - e^(-dt*k*(cool/cap)*flow/den))  EXACT
  BalanceCoolant     @004ada94   radiated-equalising move, 3 clamps     EXACT
  Condenser sim      @004ae4d8   mScale = clamp>=1((1-zoneDmg)*refrig)  EXACT
  Reservoir sim      @004aef78   inject-only; thermally passive         EXACT

Every term is properly dt-scaled -- the dissipation side is rate-INDEPENDENT.
The only rate-dependent energy term in the whole thermal system remains the
myomer kinetic generation, i.e. the calibration knob is pointed at the right
factor and nothing on the cooling side can absorb the blame.

LIVE chain verification (new [heatflow] census, incl. a RADIATOR tag on the
bank's own Performance, which the base-sim census could not see):
  Myomers 490 -> Condenser5 420 -> bank 337 -> ambient (relaxing onto the
  300K setpoint).  The exit exists, runs, and holds the bank just above 300.

AND ONE REAL FINDING -- my night-8 "no per-chassis cooling variation" claim was
WRONG, and Oracle's stated mechanism is vindicated to the digit:
  bank k = 0.1 x HeatSinkCount x 231000:   Owens count=5  -> k=115,500
                                           Thor  count=13 -> k=300,300
The Thor sheds 2.6x the Owens' heat through the mech's ONLY ambient exit --
"a thor could run seek 4 for extended periods because of the number of
heatsinks" is exactly the authored model.  My hsparm chassis comparison had
printed RESOURCE constants BEFORE the ctor's count scaling; the dump now
carries a warning to stop the next person misreading it the same way.
(The KB was never wrong -- open-questions.md:593 documented the radiator
scaling when task #9 landed it.  The wrong claim lived in my #96 comment.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:12:46 -05:00
Joe DiPrimaandClaude Opus 5 c28555bdc9 #96: TRACE THE CLOCK ORIGIN -- the pod ran at 28 fps (byte-proven); myomer heat re-bracketed to 14 Hz effective
epilectrik: "the issue seems to be related to a tick rate that advances the
heat accumulation... i need to trace out what the clock origin was."  Traced.

THE CLOCK ORIGIN [T1]: 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)
The DOS main pushes it straight into the ApplicationManager ctor (0x401189:
push [0x52140c]), and ~90 sites across the image fmul/fdiv by it -- it is THE
per-frame<->per-second conversion scalar of the whole 1995 engine.  The engine
executes every interesting master every frame and every executable subsystem
every entity tick (UPDATE.cpp / ENTITY.cpp, T0 -- no throttle at any layer),
so subsystem Performance cadence == this rate: the pod's myomer tick was
NOMINALLY 28 Hz.  My previous 30 Hz reference (the i860 BOARD frame) was
wrong by 7%, not the 2x the veterans hear.

THE RESIDUAL IS EFFECTIVE RATE, NOT NOMINAL: Oracle, on the halved build,
still asks "If you could please halve the myomer heat rate again.  We will
bracket to something reasonable."  A 486 host missing beats halves the real
Performance cadence without changing any constant -- and the 18.2 fallback
existing at all says slow paths were expected.  Statics cannot settle the
in-pod effective rate, so his bracket is the right instrument:

    effective default = 28 [T1 nominal] x 0.5 [T3 testimony calib] = 14 Hz
    BT_MYO_HZ=<hz> still overrides absolutely for bracketing
    the resolved rate now logs under BT_MYO_LOG, tier tags inline

Measured (Owens, seek 4, flat out): peak T 1007 -> ~640, degradation never
reached -- the halving Oracle requested, delivered as one auditable knob
instead of a silent constant edit.

KB: decomp-reference.md gains the DAT_0052140c section (~90 consumer sites --
whenever the decomp shows an unexplained mul/div by _DAT_0052140c it is
per-second<->per-frame at 28); btl4main.cpp's "30 = pod authentic" pacer note
corrected (30 kept deliberately for display smoothness, the divergence now
documented).  Candidate follow-up recorded: the frame-rate-dependent particle
trail density (rendering.md) should normalize against the same 28, which may
bear on Ronin's smoke-density report (#114).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 09:39:00 -05:00
Joe DiPrimaandClaude Opus 5 c791fae0f8 KB: new topic test-harness.md -- the bench machinery + the verification doctrine
User mandate (2026-08-02): "do tests like this from now on" -- after the #110
grind bench, where the field composition (peer fire destroying an arm over the
wire) replaced the constructed proxy that had let #86 be called fixed while
players kept hitting it.

The topic carries two halves on purpose:

DOCTRINE -- what counts as VERIFIED:
  1. reproduce the REPORTED scenario, not a convenient adjacent one
  2. scalpel hooks (BT_SELF_DAMAGE_ZONE / BT_KILL_SUBSYS / BT_FORCE_*) locate
     defects; they support a "fixed" claim only with proven path-identity to
     the field composition -- and the field composition still gets run
  3. MP symptom -> two-node proof (master-side correctness says nothing about
     what a peer sees)
  4. visual symptom -> pixel proof (gotcha 23)
  5. coverage claims need the axis enumerated and measured (all gates, all
     chassis), because per-chassis behaviour lives in authored data
  6. an unexplained extra effect in a passing run means the run has not passed

MACHINERY -- the bench_common.sh contract (summarized, file = source of truth),
single-node and two-node skeletons (relay, ports, affinity, fire cadence,
GOTO_STOP standoff), process hygiene (stale-node taskkill first, never
double-background, teardown kill order, stale-exe tells), and log-reading rules
(capped diagnostics are not evidence of absence; alarm lines are not trends;
name the actor at every refusal; field logs have no gates set -- spawn-time
summaries ungated, per-frame traces gated).

Routed: Quick Lookup row, CLAUDE.md reasoning step 4, build-and-run parity
section, reconstruction-method Key Relationships.  checkctx CLEAN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 09:17:01 -05:00
Joe DiPrimaandClaude Opus 5 5d6ded73f0 #110: cross-pod verified BOTH ways -- the cascade replicates, and peer fire alone drives it
Two two-node benches close the last stated gap ("no two-node run has watched a
peer's view of an arm death"), the second at the user's insistence on the REAL
field composition after the first used the self-damage scalpel:

1. mp_armchain.sh (scalpel): B self-destroys dz_rarm mid-fight.  A's screen
   receives the whole cascade -- [zone-repl] dz_rarm lvl 0->1 AND the cascaded
   dz_rgun lvl 0->1 -- plus the respawn reset coming back clean (1->0).  B's
   post-cascade AFC pushes turned out to be the RESPAWN RE-ARM, correct
   behavior, not a gate failure.  (Also confirmed: the self-damage harness
   dispatches a real Entity::TakeDamageMessage -- the identical path a peer's
   round takes, so the scalpel and the field share every line from the handler
   down.)

2. mp_armgrind.sh (the field composition): stand-off at 180, NO self-damage
   anywhere, A grinding B with real cross-pod fire for six minutes.  On B:
     * 25 incremental arm-zone hits from A's weapons via the cylinder tables
       (dz_rgun reached 1.0 from pure accumulation -- 15 cascades on rehits)
     * dz_rarm finished by B's OWN AFC bay cooking off (AmmoBinAFC100 BAY FIRE
       350, half into dz_rarm) -- itself lit by A's fire crits: peer-originated
       end to end
     * [cascade] zone 9 -> AFC100 + AmmoBinAFC100 + Condenser6 force-failed,
       fire gate refusals logged
   On A: [zone-repl] dz_rarm 0->1 and dz_rgun 0->1 -- the shooter SEES the arm
   and pod die.  B symmetrically received A's torso deaths.

Also: DamageZone::ReadUpdateRecord gets a change-only [zone-repl] diagnostic
(BT_MP_LOG) -- the probe that made the peer side measurable; mp_armgrind.sh
opens with the stale-node taskkill the other benches already had (a leftover
node from an aborted launch joined a later lobby as a third instance).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 09:09:49 -05:00
Joe DiPrimaandClaude Opus 5 614849b4a6 #110 coverage: all 8 chassis + all 3 weapon-class gates verified through ZONE death
Answering "are you 100% positive this works for every weapon type" -- it was
not a yes when asked, and the gaps were closed with measurement rather than
assurance:

1. AUTHORED DATA, all eight chassis (zonesweep.sh; the [zone-armor] dump now
   carries the zone NAME):  every arm zone AND every missile-rack zone on every
   chassis authors descend=1 (ava/bhk/lok/mad/own/snd/thr/vul); every gun/rack
   zone lists 1-4 critical subsystems; doors are inert (0/0/0); bhk uniquely
   adds destroySibs=1 on its arm chain.  No chassis is missing the cascade
   authoring.

2. WEAPON-CLASS GATES, end to end through a ZONE death (not direct weapon
   destruction, which was #86's verification gap):
     ballistic  AFC100  (projweap gate1)  -- dz_rarm,    ava1   [prior commit]
     energy     PPC     (emitter gate)    -- dz_larm,    ava1   [prior commit]
     missile    NRK5_2  (projweap gate1)  -- dz_rmissle, own1   [this pass]
        [cascade] zone 12 -> NRK5_2 + AmmoBinNRK5_2 force-failed
        [ammo] NRK5_2 -> NoAmmo (gate1): destroyed=1
   All three fire-gate code paths are now exercised by the cascade.

3. One anomaly chased to ground rather than waved off: the Owens rack cascade
   also killed Avionics/HUD/Searchlight/ThermalSight -- zone 15 dz_searchlight,
   an authored CHILD SEGMENT of the missile rack (the sensor mast sits on the
   shoulder pod).  Authored skeleton + authored descend flag, walked faithfully.

Remaining surface NOT bench-verified, stated plainly: cross-pod -- the cascade
is master-authoritative (crit plugs bind on masters only) and the resulting
alarm states ride the existing weapon update records, but no two-node run has
watched a peer's view of an arm death.  The field re-test (Lynx/Conn Man's own
audit, in MP) covers exactly that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 08:43:33 -05:00
Joe DiPrimaandClaude Opus 5 06adaee523 #110: the zone destruction cascade is LIVE -- an arm now takes its gun pod with it
Lynx: "You can destroy an upper arm, and the gun pod is intact and can still
fire."  Conn Man's audit: three chassis, both arms, reproducible.  And the user
is right that this family was claimed fixed before: #86 gated fire on the
WEAPON's own destruction and was verified by destroying the weapon directly
(BT_KILL_SUBSYS) -- the field scenario was the ARM ZONE dying with the weapon
subsystem healthy, which that bench never reproduced.  Oracle's 693-night "I can
still fire destroyed energy weapons" was almost certainly this mechanism.

ROOT CAUSE -- a silent stub.  Mech__DamageZone::RecurseSegmentTable @0049cad4
was transcribed faithfully... against three local shim types whose iterators
unconditionally return NULL:
    struct SegmentIterator { DZRef *Next() { return 0; } };
    struct SegTableX       { SegmentRecord *operator[](int) { return 0; } };
The cascade fired on zone death (bench: 72 [cascade] lines), "descended", and
touched nothing.  Every line read correct; none of it did anything.

THE REAL WALK (decomp re-read, FUN_0049cad4): iterate the mech's segment table
(mech+0x300, vtbl+0x34 GetNth) to this zone's segment, then
    destroySiblingsOnDestruction (Wword 0x68): recurse every other zone on the
        SAME segment (seg+0xD0), setting graphic state 2 (Gone)
    descendOnDestruction (Wword 0x67): recurse every zone on every CHILD
        segment (seg+0xE8 -> child+0xD0) -- the arm takes the gun
The engine ALREADY HAS both structures: EntitySegment::damageZoneTable @0xD0 /
childIndexTable @0xE8 (TableOf is 0x18 -- byte-identical to the decomp offsets),
populated at stream time by JMOVER.cpp:373 [T0].  The stub was never necessary.
Implemented with the engine iterators via two new read accessors on
EntitySegment (SEGMENT.h); the binary's asymmetry is reproduced as-is (the
sibling loop checks graphic state != 1, the child loop does not; re-hits on a
1.0 zone re-run the cascade -- idempotent in effect).

VERIFIED (ava1, self-damage to one zone, autofire everything):
  dz_rarm -> [cascade] zone 9 -> zone 17; AFC100 + AmmoBinAFC100 + Condenser6
      ForceCriticalFailure'd; "[ammo] AFC100 -> NoAmmo (gate1): destroyed=1";
      torso LRM5/SRM2 keep firing (correct)
  dz_larm -> [cascade] zone 2 -> zone 6; PPC + Condenser4 force-failed;
      "[emitter] 'PPC' fire REFUSED (destroyed=1)" x4998 -- the emitter gate now
      logs refusals BY NAME (the FIRED line never named its weapon, which is how
      #86's verification gap survived)
  regression smoke: frame time 7.13/7.20ms, 0 asserts, 0 cascades in ordinary
      combat (they fire only on genuine zone deaths)

Authored data (now dumped under BT_DMG_LOG): descend=1 on exactly the four arm
zones (ava1: 2/6/9/17), destroySibs=0 everywhere -- so legs/torso behavior is
untouched by this change.

KB: combat-damage.md cascade section; reconstruction-gotchas #26 (the
silent-stub trap: a shim that returns EMPTY converts a reconstruction into
fiction that reads correct -- shims must Fail() loudly or log their emptiness).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 08:26:53 -05:00
Joe DiPrimaandClaude Opus 5 da70bd58b2 #84: the replicant salvo now HOMES AT THE LIVE TARGET -- the stale-point double detonation is gone
Oracle (674): "missile appear to register hit explosions twice, once where
target was and again where the target is".  Rajel (693): "once where the mech
was when I fired and on the expected impact".

MECHANISM, measured on two nodes (BT_PROJ_LOG "DET at" now logs EVERY contact
detonation, including damage-0 rounds, which were invisible before): the
replicant salvo mirror pushed rounds with NO entity handle at the FROZEN
fire-time aim point, and the pool's contact test is proximity to p.targetPos --
so mirror rounds "contacted" the empty air where the target USED to be and
detonated there, on every peer, every salvo (45 salvos -> 234 frozen-point
air bursts in a 200s bench).  The true impact appears at the live position
(the shooter's own homing rounds on his node; the victim's damage reaction on
peers) -- two sites per salvo, exactly as reported.

THE BINARY'S MODEL (CLASSMAP, Missile flight-entity cluster): missiles are
ENTITIES with authoritative/GHOST Performance variants (Projectile
PTR_LAB_005129e8 / 005129f4) and their own WriteUpdateRecord (@4bef4c, slot 7)
-- peers ran ghost missiles updated from the wire and saw the TRUE trajectory.
One explosion, correct place, every node.  The frozen-point mirror is the port
infidelity (the entity Missile itself stays blocked by the documented 2007
Entity-base mismatch; mislanch.cpp:301).

FIX: the MissileLauncher update record now carries the locked target's EntityID
(salvoTargetID; EntityID::Null = point fire).  The mirror resolves it on the
receiving node via HostManager::GetEntityPointer (engine T0, an index-socket
Find -- NULL-safe on any ID), refuses non-mechs via BTIsRegisteredMech, and
pushes the mirror rounds WITH the handle -- they re-lead on the live local
replicant exactly as the master's own rounds do, and detonate at the true
position.  Guards: record-length gate (a short record from an older build has
no ID field -- its tail would be garbage and must not resolve) + Null check +
registered-mech check.

VERIFIED (two nodes, 200s, missile autofire both ways):
  * every record arrives len=52/52 with sane aims ([mlrec] diag)
  * 22/46 salvos resolve (tgtID=3:22 -> the victim's local master) and their
    mirror rounds home: the frozen-air DET population fell 234 -> 102, the
    live-homing population rose to 155/207 per node
  * the unresolved remainder is BENIGN and correct: EntityID::Null point fire
    plus hostID=-1 LOCAL entities -- i.e. the victim's WRECK, which autofire
    keeps shooting after the kill; a wreck does not move, so the frozen aim IS
    its true position
Downstream expectations: Ronin's smoke-screen (#114) should drop by ~the mirror
half, and the audio census (#32) loses the duplicate Static3DPatchSource
explosion sounds on peers -- both to be read from the next field logs.

Also in this commit: [projectile] DET diagnostic (every contact detonation with
position/damage/handle/aim), [mlrec] record-arrival diagnostic (len/recID/
subsys/counter/aim, capped 200), scratchpad/night9/mp_double.sh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 02:29:58 -05:00
Joe DiPrimaandClaude Opus 5 3354db4bd1 #32: RETRACT the retention diagnosis; count what matters (steals + true drops); name the saturating class
The reopen said "the pool fills and never returns a source".  The 30s census in
the SAME field logs disproves it: free returns to ~227-230 between bursts and
reuses climbs ~20/s all session.  free=0 on the failure line is true by
DEFINITION at the instant of a failed acquire -- the third instance of the
counter-sampling trap (live=256-vs-6, then hsparm greps, now this), read off the
alarm line instead of the trend line.

WHAT THE LOGS ACTUALLY SHOW
  * The pool cycles; release-on-stop exists and works (the engine steal loop).
  * During firefights CONCURRENT demand exceeds 240 and the priority steal loop
    services each new sound by killing an old one -- continuously through
    combat (failures spread across every decile of every combat session).
  * Idle standing demand is ~13 sources.  My "each component holds its SourceSet
    to entity teardown, ~20-25 per mech" narrative was wrong.
  * The raw ACQUIRE FAILED line count (6.8k-19k per log) is NOISE: the steal
    loop retries after every failed attempt, so lines accumulate per EVENT and
    most events still play via a steal.  True drops were never counted.

CHANGES
  1. Census now carries steals= and drops= (drops = the steal loop ran dry and
     the sound NEVER played) plus a per-class drop histogram
     ("[audio] dropped by class: {class 1005 x4v: N} ...") -- all ungated, so
     the next field logs are decisive instead of suggestive.
  2. The ACQUIRE FAILED print is rate-limited to 1/30s and now names the
     requesting class + voice count.  19k-line log spam distorted this triage.
  3. BT_AUDIO_SOURCES=<n> now raises the POOL cap too (it previously raised the
     AL context budget while the pool stayed at 240, making the field
     experiment impossible to run).

MEASURED (9 mechs, missile autofire, 150s)
  * cap 240: peak 130 sources, 0 fails -- demand tracks SHOOTER count, not mech
    count; one shooter cannot saturate.  A 6-shooter lobby pins 240.
  * cap 48 (BT_AUDIO_SOURCES=64): saturation reproduced -- census
    steals=223 drops=455, histogram names the classes.
  * Dominant field requester (requested=4) = class 1005 Static3DPatchSource:
    world-placed effect sounds, i.e. EXPLOSIONS.  1001 DirectPatchSource x1v
    dominates drops at low cap; 1002 Dynamic3DPatchSource x3v present.

CONSEQUENCE FOR THE FIX ORDER: #84's stale-aim double detonation duplicates
exactly the saturating class on observer nodes.  Fix #84 FIRST, then re-read the
field census; only if it still saturates does the budget experiment
(BT_AUDIO_SOURCES with frame time measured) become the play.

KB: the wrong night-9 entry in open-questions.md replaced with the corrected
diagnosis; gotcha candidate noted -- an alarm-line counter is not a trend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 01:42:10 -05:00
Joe DiPrimaandClaude Opus 5 202ebbf84a KB + tracker: night-9 playtest (build 4.11.693)
CONFIRMED FIXED and closed: #95 (missile salvo damage), #97 (leak gauge 3 levels),
#98 (loop-6 lamp), #99 (leak alarm audible).

#95 deserves a note: a tester reported "LRMs still doing 3.3 damage", but that is
the PER-MISSILE figure.  382 impacts across three 693 logs show the cluster being
delivered correctly -- LRM20 3.25 x burst 5..20 (total up to 65), LRM15 3.33 x
3..15, LRM10 3.50 x 2..10 -- and those burst ranges match the binary's
Random(n)+n/4 roll exactly.

#32 REOPENED.  The pooling change removed the alloc/free churn (the ~10% frame
time is real) but every 693 field log still shows
    ACQUIRE FAILED (requested=4 live=240 pooled=240 free=0 ...)
19,182 fails (Sauron) / 15,333 (Rajel) / 6,818 (Ronin), high-water 225 of 240,
starting ~1% into every session.  `free=0` is the diagnosis: sources are never
returned.  This is precisely the risk flagged when the fix landed -- retention,
not pool size.  Players called audio "fine" the same night; the log disagrees.

#84 still present, but the surviving explosion is a DIFFERENT mechanism from the
bundled one that was fixed: "where the mech was when I fired" is the stale
fire-time aim point already recorded as an open in rendering.md, i.e. an MP
visual, not the message-manager path.  Needs a two-node bench logging the
OBSERVER -- the earlier two-node run verified damage delivery but never the visual.

New: #108 ghost mech (peer never sees the death; shots still register, so it is a
visual/replication desync, the inverse of #94), #109 panic/eject, #110 arm
parent/child linkage (gun pod survives its mount -- three chassis, both arms;
parentArtifactZone exists but only drives LOD), #111 death warp bubble shown to
peers, #112 AFC shotgun spread (may be authentic Ultra-AFC burst), #113 LRM burn
duration, #114 missile smoke as a smoke screen, #115 hot box, #116 Standard-vs-
Expert heat gating, #117 per-chassis cockpit bounce.

⚠ A MEMBER OF THE ORIGINAL TEAM IS NOW TESTING -- "Ronin" (log user torak).  He
worked on the 1995 game and has played the pods recently.  Treat his pod
comparisons as primary evidence above reconstruction inference.  First two
contributions recorded in the KB: per-chassis cockpit bounce was deliberate, and
NARC likely shipped as data without an implementation.

⚠ Conn Man ran the OLD build (.674) for all seven of his sessions, so his reports
are not evidence about 693.  His arm-linkage audit is still valuable -- nothing in
693 touched that path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 01:15:45 -05:00
Joe DiPrimaandClaude Opus 5 9623399ec4 gitignore: never commit player field logs (Steam IDs / machine names / usernames)
Field logs have been kept out by hand until now -- nothing enforced it, so a
stray 'git add -A' would have published player Steam IDs and machine names.
Ignore *.log/*.rar/*.zip under scratchpad/night*/ while leaving the bench
scripts in those directories committable, which is the existing convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:59:11 -05:00
Joe DiPrimaandClaude Opus 5 5a1a407ed1 pre-ship: resolve the new audio diagnostic gates ONCE instead of per frame
RunSequence and AudioIdleWatcher::Execute run per sequence per frame, so the
BT_ATTRBIND_LOG probes added while chasing #99 were calling getenv ~2700x/sec on
a 15-sequence mech.  Small, but this is the same path the source-pooling fix
(#32) just bought 10% of frame time in, and an unset diagnostic should cost
nothing.  Cached in function-static flags.

Pre-ship smoke (combat, 2 kills, a respawn, clean teardown):
  frame time 7.18 / 8.27 / 8.71 ms  -- matches the post-pooling 7.79ms baseline
  0 asserts, 0 access violations, 0 audio acquireFails

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:11:06 -05:00
Joe DiPrimaandClaude Opus 5 7c08e8bd03 #99: commit the missed audseq.h probe member (case-mismatched path in the previous commit)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:04:14 -05:00
Joe DiPrimaandClaude Opus 5 5a425320b7 #92: map the direct-fire hit->zone path; the FOOT is pickable over only 3% of a mech's height
Oracle, night 7: "Unable to damage the foot panels on Loki via direct fire from
front or side.  Was able to damage on Thor."

FIRST, THE MECHANISM -- it is not what the issue assumed (missing collision
geometry).  A direct-fire hit gets its zone one of two ways, decided at
Mech::TakeDamageMessageHandler by `invalidDamageZone`:

  AIMED   (invalidDamageZone=0): the zone rides in on the message from the
          per-part aim pick, MechSegmentPick (#73, btl4vid.cpp).
  UNAIMED (invalidDamageZone=1): DamageLookupTable::ResolveHit runs the authored
          cylinder lottery -- impact HEIGHT picks a layer, ANGLE picks a pie
          slice, and a weighted random roll picks the zone from that slice.

Measured live, both paths are in use: the Thor logged 16 unaimed hits
(invalidZone=1 zone=-1 -> ResolveHit) alongside aimed ones carrying real zones.

THE FINDING.  MechSegmentPick is a BOUNDING-SPHERE test whose primary key is
SMALLEST RADIUS WINS -- a larger sphere can never beat a smaller one the ray also
grazes.  The leg spheres, dumped in world space and IDENTICAL on both chassis:

    knee  centre y=1.868  r=1.505     spans y 0.364 .. 3.373
    toe   centre y=0.302  r=1.689     spans y -1.387 .. 1.991

The toe sphere is BIGGER than the knee's and they overlap heavily, so the toe can
only win where the ray misses the knee sphere outright -- i.e. below y=0.364.
Against a reference height of 11.16 that is a 0.36-unit window, **3.3% of the
mech's height**, and it sits right on the ground.  Everywhere else a shot at the
foot is credited to the LEG.  That is the reported symptom.

This is a PORT ARTIFACT, not authentic: btl4vid.cpp's own comment concedes the
sphere test approximates "the per-part semantic the 1995 mesh intersection
produced".  Real mesh intersection has no such interference -- aiming at the foot
mesh hits the foot.

NOT EXPLAINED, and stated plainly: the per-CHASSIS asymmetry.  Loki and Thor have
identical leg spheres, identical foot geometry (LOK_LFOT.BGF and THR_LFOT.BGF are
both 3082 bytes with the same token layout), and identical foot layers in their
damage tables (only the upper/cockpit layers differ).  So nothing found here says
the Loki should behave differently from the Thor.  The bench could not settle it
because BT_AIM moves the drawn RETICLE, not the pick ray -- there is currently no
harness to aim the pick at a chosen height.  That harness is the next step.

Diagnostics added:
  [pickgeom]   one-shot dump of every pick sphere in WORLD space (zone, r, centre)
  [pickcand]   which spheres a ray actually threaded, their perpendicular d, and
               which won -- the probe that makes "smallest wins" visible
  [dmgtable]   the whole authored DamageLookupTable: layers, slices, zone weights
  [dmgresolve] per hit: localY, heightRef, layer, theta, resolved zone
  [cylgate]    invalidDamageZone / table pointer / incoming zone at the gate
(the last three under BT_DMGTABLE_LOG, the first two under BT_PICK_LOG)
plus scratchpad/night8/footpick.sh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:47:30 -05:00
Joe DiPrimaandClaude Opus 5 8daac37e40 #99: VERIFY IN THE IMAGE -- the shared-alarm Stop is authentic; do not diverge
Answering "did you check the decomp or disassembly?" -- I had not.  I had checked
the authored data (BTL4.RES) and the MUNGA engine source and called it authentic
on source lineage alone.  Now verified in BTL4OPT.EXE:

  same sources compiled in : d:\tesla_bt\munga\AUDSEQ.CPP / AUDWTHR.CPP strings
  object factory           : @00466184  case 0x4b -> alloc 0x5C -> ctor @0043ccec
  vtable                   : 0x4ead48
  ReceiveControl           : @0043d3a4 -- switch on control IDs 1 / 2 / 9 / 12,
                             which is exactly our enum Start / Stop / Idle / Tempo
  Stop case                : @0043d3ca -> unconditional call StopSequence @0043d2d4
  StopSequence             : tests only its OWN isRunning (+0x28) then the Chase
                             loop -- NO refcount, NO awareness of other leak sources

Structurally identical to engine/MUNGA/AUDSEQ.cpp.  So the arcade behaved the same
way: with 19 subsystems sharing one alarm sequence, any one of them clearing its
leak silences the alarm while the others still leak.  AUTHENTIC -- do not "fix".

(The tempo bound check could not be used as an anchor: Verify() compiles out of the
retail build, so the AUDSEQ.CPP assert strings are present in .data but unreferenced
by code and invisible to the decomp export -- gotcha 21 again.  The object-factory
route via the numeric ClassID is what actually located the class.)

Recorded in context/gauges-hud.md with the full chain and the address table, so the
next person does not re-chase it or "fix" it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:32:33 -05:00
Joe DiPrimaandClaude Opus 5 e058b6acb7 #99: trace the coolant-leak alarm end to end -- it IS implemented; found the real defect
Oracle: "alarm is not sounding despite an active leak" ... "was sounding,
resolved a leak, but there was another".

RETRACTION FIRST.  I previously told the user the coolant alarm was "genuinely
unbuilt -- there is no alarm implementation to starve".  That was wrong, and it
was asserted without checking.  The alarm is fully implemented and the entire
chain works.  Traced live, in order:

  Condenser6.ReportLeak (= HeatSink::coolantActive) changes 0 -> 1
    -> [watchpoll] CHANGE on that exact address
    -> [matchfire] val=1 -> ctl 1  (StartAudioControlID)
    -> [seqstart] the alarm AudioControlSequence, events=25 looped=1
    -> [seqsend] ctl 8/16 (select), 6/1 (volume), 1/0 (START), 2/0 (STOP), ...
       a three-part looping alarm: two chirps then an 8s sustained tone.

The authored design, read from BTL4.RES: 19 subsystems per mech each bind TWO
AudioLogicalTriggers to their ReportLeak flag -- match 1 -> Start, match 0 ->
Stop -- and ALL of them drive ONE shared alarm sequence.

SO WHY WAS IT SILENT IN .674?  Almost certainly the audio source pool, fixed
after that build in ad9dfad.  Every player log from .674 is saturated with
ACQUIRE FAILED (3k-6.5k lines each, starting ~10% in and never recovering); an
alarm that cannot acquire an OpenAL source is silent.  The bench here shows 0
acquire failures on the current build.  ⚠ NOT PROVEN: this bench has no audio
device (pool census reads live=0 pooled=0), so the control chain is verified but
final playback is not.  Field confirmation needed.

THE SECOND SYMPTOM IS A REAL, REPRODUCED DEFECT AND IT IS IN THE AUTHORED DATA.
All 19 subsystems -- Condenser1-6, GeneratorA-D, Myomers, PPC_1/2, ERMLaser_1-3,
SRM6_1/2, Avionics -- share the single alarm sequence, and EACH one's leak-clear
sends an UNCONDITIONAL Stop.  So the moment any one of them stops leaking the
alarm goes silent, even while others are still leaking.  That is exactly what was
reported.  Since both the authoring and the MUNGA watcher code are authentic,
this is 1995 behaviour, and changing it is a deliberate divergence -- flagged for
a fidelity call rather than "fixed" unilaterally.

Diagnostics added (all under the existing BT_ATTRBIND_LOG gate):
  [seqcfg]  extended: the sequence's authored event list
  [seqstart] extended: events / looped / tempo / divisionsPerBeat
  [seqrun]  RunSequence entry state (isRunning / iterator / current event)
  [seqwait] an event exists but is not yet ready, with the time delta
  [seqsend] the sequence actually emitting a control
  [idlewatch] which components receive the idle tick that advances sequences
  [hsparm] extended with &ReportLeak so audio bindings can be correlated
plus scratchpad/night8/leakaudio.sh.

NOTE ON THE PROBES, because it cost real time twice today: [seqrun] first used a
single shared static counter, which the busy sequences consumed so the alarm
sequence never printed -- reading as "RunSequence is never called".  It is now
throttled PER SEQUENCE (runProbeCount).  A capped diagnostic that is silent is
not evidence of absence; the same trap produced a false "seqsend x0" and a false
"the trigger never fires" earlier in this investigation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:07:40 -05:00
Joe DiPrimaandClaude Opus 5 e6103d02b2 #97: leak gauge reaches all THREE levels -- the normalisation was dropped with an x87 expression
Players: "you can get up to three traingles which drains really quickly" ...
"only seeing one level (lowest) right now" (Oracle), corroborated by Draco
("3 triple delta being the top level").

The display was never the problem: BitMapInverseWipe is already built with
frames=3 and stacks three segments at two levels each (0..6).  The LEVEL
computation was wrong.

Our Execute rounded the raw leak rate:
    level = round(value)
The binary's @004c5d08 does NOT.  Ghidra renders the round as a bare
`FUN_004dcd94()` because it drops the x87 expression feeding __ftol -- exactly
KB gotcha 19, which we already had written down.  The real prologue, disassembled:

    fild  dword ptr [ebp-0x14]    ; ST0 = (float)fullWidth   (frames*2 = 6)
    fmul  dword ptr [ebx+0xb4]    ; ST0 *= value             (CoolantMassLeakRate)
    fdiv  dword ptr [ebx+0xb0]    ; ST0 /= third             (full-scale divisor)
    call  0x4dcd94                ; level = round(ST0)

i.e. level = round(fullWidth * leakRate / fullScale).

Without the normalisation the gauge rounded a value that never exceeds ~1.0
(coolantDraw = zoneDamage * heatLoad, and heatLoad is clamp(0.002*T, 0, 1) --
constants re-verified from the image, including the 80-bit extended 0.002).  So
level could only ever be 0, or 1 via the >0.0025 floor: ONE triangle, always, no
matter how bad the leak.  Three of the six condensers being mis-lamped (#98) hid
how systematic this was.

Also corrected on the way:
  * `third` was declared int but the binary FDIVs it -- it is a float.  The two
    call sites passed *(int *)(subsystem+0x150), i.e. the BIT PATTERN of a float
    read from a RAW OFFSET into our own layout (the databinding trap): at +0x150
    our layout has filterDecay, not the binary's field.  Now resolved through a
    complete-type bridge, BTHeatSinkLeakFullScale.
  * filterDecay was initialised 0.4f; @004b8fec writes 0.15f (param_1[0x54] =
    0x3e19999a).  It is written-once-never-read elsewhere in the port, so the
    correction is behaviourally safe and it IS the gauge's full scale.

Verified live: Condenser6 at 10% zone damage -> draw 0.0358 -> level 1 (one
triangle); the same formula reaches all three at heavier damage, and the mapping
spans draw 0.0026 (half of one) to 0.15 (three full).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:54:13 -05:00
Joe DiPrimaandClaude Opus 5 ee079083a3 #96: FIX -- normalise the myomer kinetic heat term to a reference frame rate
The previous commit only instrumented.  This is the behavioural change.

The binary's drive-heat integrator (FUN_004b8d18) multiplies its climb and accel
terms by the time slice but NOT the kinetic one:

    heat += ratio^2 * (1+dmg) * [ (1-accEff)*|vy|*m*g*dt
                                + (1-velEff)*(0.5*m*|v|^2)     <-- no dt
                                + (1-accEff)*|v|*|a|*m*dt ]

A per-TICK energy is a constant on a fixed-frame 1995 machine and a variable on a
modern PC: heat/second scales with frame rate, so two players on different
hardware -- or one player in a heavy scene versus an empty one -- get different
heat from identical throttle.  That is a defect independent of what the pod's
rate actually was.

The kinetic term is now multiplied by (dt * kPodFrameHz), which is exactly 1.0
when dt == 1/30 and makes the result machine-independent.  This is the one
deliberate divergence from a verbatim transcription of @004b8d18, and it is
marked as such in the source.

Measured, Owens at seek 4 flat out:
    before   crossed degradeT(1000) at ~27s, peaked 1213, settled ~710
    after    peak 1007, settled ~525
i.e. roughly halved at our measured ~59Hz, which is the direction the players
asked for ("too much IMO", "def need to tone down the myomer heat some").

⚠ CALIBRATION IS NOT PROVEN.  30Hz is the i860 BOARD frame ([T1],
rendering.md:177); the HOST simulation rate is not separately established, and no
fixed-timestep constant exists in the image (searched 1/15..1/60 and 30/60 Hz --
the arcade computed dt from a clock, as we do).  So the reference rate is the
best-documented value, not a proven one.  BT_MYO_HZ overrides it with no rebuild
so it can be calibrated by feel.

Precedent for the bug class, already logged in the KB: "trail density is
frame-rate-dependent (2/frame @60fps = 2x pod density)" (rendering.md:222).

NOT settled: whether the per-chassis ordering matches player memory (a Thor
sustaining seek 4 where a light chicken-walker cannot).  Post-fix benches put the
Thor HOTTER (crosses at 40s vs the Owens at 47s), but those runs are BT_GOTO
path-driven and the acceleration term is large and path-dependent, so they are
too noisy to conclude from.  The cooling data being identical across chassis IS
solid (measured, previous commit).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:34:44 -05:00
Joe DiPrimaandClaude Opus 5 9dd7d48b41 #96: instrument the myomer heat model -- per-mech profile CONFIRMED, overheat cause LOCATED
Two player claims, both now answered from the decomp + measurement rather than
inference.

THE EQUATION (FUN_004b8d18, constants read from the image: _DAT_004b8ee4=0.5,
_DAT_004b8ee8=0.0 (the fabs), _DAT_004b8eec=1.0):

  heat += ratio^2 * (1+damageLevel) *
          [ (1-accEff)*|vy|*m*g*dt        climb   POWER
          + (1-velEff)*(0.5*m*|v|^2)      kinetic ENERGY -- NO dt
          + (1-accEff)*|v|*|a|*m*dt ]     accel   POWER

Our implementation already reproduces this verbatim, dt-less term included.

CLAIM 1 -- "each mech has a unique heating profile".  TRUE, and working, but NOT
by the mechanism the player described.  Measured across thr1/own1/mad1/vul1:
  * the myomer record is IDENTICAL on every chassis
    (velEff 0.995, accEff 0.8, gears 3000/5000/7000/9999, rec 2,
     degradeT 1000, failT 2000, thermalMass 250000)
  * the heat-family COUNT is identical too -- 6 Condensers, 1 HeatSinkBank,
    1 Reservoir, 4 Generators on all four
  * every cooling parameter is byte-identical Thor vs Owens (condenser
    conductance 315000 / mass 420000, bank 231000 / 1.39e6, reservoir
    190000 / 3.42e6)
So there is NO authored per-chassis cooling variation.  The profile emerges from
the equation instead: heat ~ m*v^2, and light mechs are faster.  Measured at
seek 4, flat out:
    thr1  mass 70000  |v| 11.34  kinetic/tick 49026
    own1  mass 35000  |v| 17.22  kinetic/tick 57350
The Owens is HALF the mass and generates 17% MORE drive heat, because v^2 beats
m.  That reproduces the player's OUTCOME (a Thor sustains seek 4, a light
chicken-walker cannot) via speed, not heatsink count.

CLAIM 2 -- "it runs too hot".  The kinetic term carries NO dt: it adds an ENERGY
every TICK, so its contribution per SECOND scales with the tick rate.  Measured
dt here is ~0.017 (~59Hz) and variable.  The other two terms are power terms and
are rate-independent.  On flat ground the climb term is additionally dead --
gravity reads 0 (the carried "environment gravity unwired" open), so hills do not
heat at all right now.
NOT yet established: the 1995 tick rate the dt-less term was calibrated against.
Until that is pinned the OVERHEAT FACTOR is unquantified -- flagged, not guessed.

Adds three diagnostics, all under BT_MYO_LOG:
  [myoheat] now splits climb/kinetic/accel + dt + the kinetic share
  [myoparm] one line per myomer: efficiencies, gears, thermal thresholds
  [hsparm]  one line per heat subsystem: conductance + thermal mass
and three benches (myoheat/myoparm/myocmp) that produced the tables above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:14:55 -05:00
Joe DiPrimaandClaude Opus 5 56f15b569a #98: prove the lamp fix -- table pinned to the image, three condensers verified live
Follows the request to prove the whole fix rather than the one case I had.

1. TABLE, all indices: scratchpad/night8/lamptable_check.py asserts the
   reconstructed arrays against BTL4OPT.EXE and exits nonzero on drift.
       kBTCondenserLamp @0051d058  MATCH  07 2F 2E 2D 2B 2A 29
       kBTPlacementLamp @0051d070  MATCH  29 1A 1B 1C 1D
       kFixed (FUN_004cc148 switch) MATCH  2F 2E 2D 2C 2B 2A
   This covers condensers 2/3/5 without hunting zones for them: there is no
   per-index code path, only the array contents, and those are now pinned.

2. LOOKUP PATH, live: condensers 1, 4 and 6 resolve 0x2F, 0x2B and 0x29 --
   exactly the table.  Condenser 4 is the meaningful control: my reverted "fix"
   would have given 0x2C.  Myomers (eng-page path, 0x25/0x21) and SRM6_1 (quad
   button, 0xd) still annunciate, so other subsystem classes are unregressed.

3. GUARD: condenserNumber is parsed from the name's trailing digit
   (NameTrailingNumber, mirroring the binary's atoi), and every condenser in
   BTL4.RES is Condenser1..Condenser6 -- no Condenser0, no bare name.  So the
   1..6 guard covers every shipped case and slot 0 (0x7) is unreachable.  This
   was the real risk in changing `n >= 0` to `n >= 1`; it is closed.

4. PIXELS: leaklamp_pixel.{sh,py} capture a leaking run and an undamaged control
   run from the cockpit and difference their per-pixel temporal variance.  The
   leak is visibly real -- the COOLANT reservoir drains on screen (S 331->330
   while the control sits at 329).

⚠ WHAT THE PIXELS DID NOT SETTLE, and it is not a testing gap.  Lamp 0x29 is
ALSO kBTPlacementLamp[0]: DAT_0051d058[6] and DAT_0051d070[0] are the SAME int32
-- the two tables abut.  So condenser 6's lamp may not be its own loop button at
all, and slot 6 may be an overrun in the BINARY too (its read is unchecked).
Reproducing it is the faithful choice either way, and we now do exactly what the
binary computes -- but whether a pilot sees loop 6's own button light is a
question only someone who played the original can answer.  Asked on the issue.

CORRECTION: 0b98370 claimed @0051d058 "holds gauge-type name strings, so the
provenance is questionable".  That was MY bug -- a PE section-header field-order
mistake (unpacking VirtualSize/VirtualAddress/SizeOfRawData/PointerToRawData
then destructuring in a different order).  The original reconstruction's
provenance was accurate.  The checker in (1) uses the corrected reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:26:47 -05:00
Joe DiPrimaandClaude Opus 5 c50236a5e6 bench: shorten the leak-lamp run; verified condensers 1/4/6 resolve 0x2F/0x2B/0x29 (the binary's table), plus Myomers + SRM6 eng/quad lamps unregressed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:41:26 -05:00
Joe DiPrimaandClaude Opus 5 0b983703a4 #98: CORRECTION -- restore the byte-verified condenser lamp table; my previous mapping was a guess and was wrong
0254d9e re-mapped condensers onto the coolingLoop1..6 lamps on the reasoning
that "six condensers <-> six loops" and that the table's missing 0x2C was a
transcription slip.  It was not.  Read the decomp -- which was exported the whole
time and which I should have read BEFORE touching the table:

    FUN_004cc264(sub) { return *(int *)(&DAT_0051d058 + sub[0x1d4] * 4); }

indexed by condenserNumber, no bounds check.  And the bytes at 0051d058 are:

    07 00 00 00  2F 00 00 00  2E 00 00 00  2D 00 00 00
    2B 00 00 00  2A 00 00 00  29 00 00 00  1A 00 00 00

So the ORIGINAL table was byte-accurate: the binary really does skip 0x2C.  The
condenser lamps (2F 2E 2D 2B 2A 29) are a DIFFERENT set from the coolingLoop
lamps of FUN_004cc148 (2F 2E 2D 2C 2B 2A) -- overlapping but distinct.  My
"fix" therefore mis-mapped condensers 4, 5 AND 6, replacing a correct mapping
with a plausible-looking wrong one.

What IS a real port defect, and all that should have changed: the bounds check.
`n >= 0 && n < 6` is a 0-based bound on a 1-based index (live: 'Condenser6'
reports 6), so condenser 6 was rejected and annunciated NOTHING, where the
binary resolves it to 0x29.  The table is now 7 slots ending in 0x29 and the
guard admits 1..6 -- kept rather than dropped, so a stray authored number cannot
walk off the end into the adjacent per-placement table the way the binary's
unchecked read would.

Verified: Condenser6 leak -> [galarm] condition 2 -> lamp 0x29 FLASH, which is
what the binary computes for it.

Lesson, and it is the third time this week: I had a live-reproduced symptom and
inferred the cause from internal consistency instead of reading the available
decomp.  The symptom was real; the explanation was invented.  Gotcha 24 covers
over-generalising a verified fact -- this is its sibling: inventing a fact to
explain a verified symptom.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:23:24 -05:00
Joe DiPrimaandClaude Opus 5 0254d9ef34 #98: leaking condensers now flash the RIGHT button -- half of them flashed the wrong one, one flashed nothing
Field reports were all "intermittent": "the display buttons aren't always
flashing or lighting up on leaking components" (Oracle), "I'm getting leaks but
no indicators" (Lynx), "I am getting indicators sometimes" (Sauron).  It is not
intermittent -- it is per-condenser, and three of the six were wrong.

condenserNumber is 1-BASED (verified live: 'Condenser6' reports 6), which the
lamp table's own comment stated.  The guard was `n >= 0 && n < 6` -- a 0-based
bound -- and the table it indexed was missing 0x2C:

    condenser 1..3  -> 0x2F 0x2E 0x2D    correct
    condenser 4     -> 0x2B              WRONG (loop 5's button)
    condenser 5     -> 0x2A              WRONG (loop 6's button)
    condenser 6     -> rejected          NOTHING flashes

So a leak in loop 6 annunciated nowhere, loops 4-5 lit a neighbour's button, and
loops 1-3 were fine -- which from the cockpit reads exactly as "sometimes".

Condenser N drives cooling-loop N, whose six lamps are the same ones the
coolingLoop1..6 codes already resolve through, so the fix routes condensers
through that verified map (BTFixedLampOf(N-1)) and retires the duplicate,
partly-wrong table rather than patching it.

⚠ PROVENANCE: the retired table cited @0051d058 as byte-verified.  That address
holds gauge-type NAME STRINGS, not lamp ids, and a byte search for the six loop
ids as consecutive int32 finds nothing -- so neither the old table nor the new
mapping is byte-verified.  The correction rests on three checkable things: the
1-based numbering (live), the guard contradicting its own documented indexing,
and six condensers mapping onto the six cooling-loop lamps of the verified fixed
map.  [T2 -- behaviour verified, not byte-grounded.]

Also adds the missing diagnostic on the silent path: an alarm item that matched
its condition but resolved no lamp now names the subsystem and why, instead of
returning quietly.  That is what found this, and it immediately surfaced a
SECOND gap for someone to pick up: a destroyed HeatSink (condition 0) resolves
no lamp either, because it is neither Condenser nor Generator nor a
PoweredSubsystem with an aux screen.

Verified (scratchpad/night8/leaklamp.sh, BT_LAMP_LOG):
  before  [galarm] condition 2 ... sub 'Condenser6' -> NO LAMP RESOLVED
  after   [galarm] condition 2 ... -> lamp 0x2a FLASH

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:12:46 -05:00
Joe DiPrimaandClaude Opus 5 bb7a6abe70 #32: correct the stale "no field complaints since" note + record the frame-time A/B
Found while checking whether a player had reported this before: the code already
carried a prior analysis of this exact error which deliberately decided NOT to
act on it, concluding the 2026-07-23 atomic-delete fix (a999e5c) had settled it
with "no field complaints since", and that the remaining exhaustion was
transient and likely sub-perceptual.

That claim did not hold, and the note was still sitting there to mislead the next
reader.  All five 4.11.674 player logs are saturated: 3031/4657/5245/6275/6571
failures, first one ~10% into a match, still failing at 97%.

The prior note's core reasoning was about raising the source BUDGET -- more
voices mixing = more CPU during heavy combat -- and it is sound; BT_AUDIO_SOURCES
stays opt-in and unset.  But it does not apply to pooling, which changes no
budget: idle pooled sources are stopped and detached and cost nothing to mix.
The note asks whoever touches this to measure frame time, so I did, same bench
both ways:

    pre-fix   8.672 ms weighted avg over  9390 frames
    post-fix  7.794 ms weighted avg over 10047 frames

Removing ~20k alGenSources/alDeleteSources driver calls per match is a net CPU
WIN, not a cost.  Single run each, so indicative rather than definitive, but it
points the opposite way from the concern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 10:42:12 -05:00
Joe DiPrimaandClaude Opus 5 ad9dfade88 #32: POOL the OpenAL sources instead of creating and destroying one per sound
Every 4.11.674 player log is saturated with acquisition failures -- 3031, 4657,
5245, 6275, 6571 across five machines.  In Lynx's largest session the first
failure lands 9.3% in and they continue to 96.8%: once it starts it never
recovers for the rest of the match.

CAUSE: RequestAudioChannels called alGenSources() per sound event and
ReleaseSourceSet called alDeleteSources() on release -- create and destroy per
sound.  OpenAL sources are a scarce driver resource (OpenAL Soft caps a context
at 256) and a combat burst churned straight through the ceiling.

The two counters looked contradictory and were the tell: ACQUIRE FAILED always
printed live=256 while the 30s census printed live=6.  Same global, sampled at
different moments -- sources spike to the cap during a burst and drain back
between them.  Churn, not a steady leak.

FIX: generate sources once, up to a cap, and recycle them through a free list.
Release scrubs and parks instead of deleting.  Steady-state play performs no AL
allocation at all.

The scrub is load-bearing, not hygiene: a recycled source carries whatever the
previous owner set, and the engine sets AL_LOOPING per sound
(L4AUDLVL.cpp:327).  Hand a looping source to a one-shot and it plays forever --
which is the "sound stuck looping" family (#51, #5).  Every reusable property is
reset at the single point where a source changes owner.

VERIFIED (scratchpad/night8/audiopool.sh + the two-node mp_burst.sh):
  solo, sustained fire  : 0 failures, pooled 152, reuses 21998
  two nodes, 4 min      : 0 failures on both, pooled 148/149, reuses ~7000 each
⚠ HONEST LIMIT: the bench does NOT reproduce the field failure -- the PRE-fix
binary also scores 0 on it (peak 49 live), because solo/2-node combat is not
dense enough to reach 256.  So this verifies the pool works and allocates
nothing in steady state; it does NOT by itself prove the field failures are
gone.  The five field logs remain the "before".

Peak demand is set by how many audio COMPONENTS are alive (each reserves a
SourceSet of up to 25 voices and holds them), not by audible sounds: measured
high-water 138 solo, 149 two-node.  That scales with player count, so the cap is
set near the driver ceiling (240) and the pool now logs its high-water mark once
per 25-source band -- so the next playtest sizes this from field data instead of
a guess.  Growth also self-limits: if a driver offers fewer sources than the cap,
alGenSources simply fails, growth stops, and the pool recycles what it has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 10:29:04 -05:00
Joe DiPrimaandClaude Opus 5 3a22c334ac bench: two-node CROSS-POD damage verification for the #95 burst change
Projectile damage now Dispatches directly at the victim instead of going through
the shooter's SubsystemMessageManager, so cross-pod delivery had to be proven on
real nodes rather than assumed.

RESULT: cross-pod delivery intact, and the cluster count survives the wire.
Node A fired 38 missile rounds with bursts {1:5,2:6,3:6,4:7,5:7,6:7}; node B
received exactly the matching zone applications {2:12,3:18,4:28,5:35,6:42} --
i.e. rounds x burst, per burst band, exact.  135 of B's 142 explosive
applications carried burst > 1 (the manager path used to drop it to 1).
Energy stayed at burst 1 on both nodes; no crash on either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 05:28:45 -05:00
Joe DiPrimaandClaude Opus 5 c23d06cbb5 KB: correct the burstCount claim -- it is honoured by the CALLER, not cosmetic
The KB asserted as [T1] that "burstCount is cosmetic for zone damage".  Half of
that is byte-verified (DamageZone::TakeDamage @0041e4e0 really does ignore it);
the other half was an inference that inherited the [T1] tag and then justified
task #62's salvo-lead design -- which silently divided every missile salvo by its
missile count (gitea #95).

Corrected in both places it appeared (combat-damage.md:334 and :884): the CALLER,
Mech::TakeDamageMessageHandler @0x4a0423-0x4a04d8, applies TakeDamage burstCount
times and re-rolls the struck zone per burst.  burstCount = number of
applications; load-bearing for missile cluster count, splash falloff and the gyro
bounce.  Also records that the arcade Missile dispatches DIRECTLY at the victim
rather than through the message manager (whose consolidation drops burstCount).

New gotcha 24: a verified fact, over-generalised, becomes a wrong design premise.
Detection smell -- a "cosmetic/unused" claim about a field other code still
computes carefully.  Nobody spends instructions randomising a decorative value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 04:43:34 -05:00
Joe DiPrimaandClaude Opus 5 3b7c19c232 #95: REVERT the salvo-damage hack; deliver the cluster the way the binary does
Supersedes the mislanch change in efc3e9f, which multiplied the lead round by
missileCount.  That produced roughly the right average total but as ONE lump on
ONE zone, with no scatter and no variance -- not what the arcade does.

WHAT THE BINARY DOES (all byte-verified):
  * MissileLauncher ctor @004bcff0:  burstCount = missileCount;
                                     damageAmount /= missileCount
  * FireWeapon @004bcc60 spawns exactly ONE Missile -- no loop, missileCount is
    never read there.  The salvo IS one cluster round.
  * Missile::Perform rolls how many of the cluster connect right before it
    dispatches (part_013.c:10082):  b = Random(n) + n/4, clamped to n
    -- i.e. between a quarter of the salvo and all of it.
  * It then dispatches DIRECTLY at the struck entity (FUN_004be078:
    param_2->Dispatch(&msg)) -- NOT through the shooter's message manager.
  * Mech::TakeDamageMessageHandler @0x4a0439-0x4a04d8 applies the hit
    burstCount times, RE-ROLLING the struck zone per burst.

Our handler already implements that loop faithfully (mech.cpp, task #80) -- I
wrongly believed it was missing, because the KB asserts as [T1] that "burstCount
is cosmetic for zone damage".  That is half right: DamageZone::TakeDamage really
does ignore burstCount (verified @0041e4e0), but the HANDLER honours it by
calling that function repeatedly.  KB corrected separately.

The actual defect was that the projectile impact path hardcoded burstCount = 1,
and -- worse -- routed damage through the SubsystemMessageManager, whose
consolidation rebuilds the record from a stream carrying only {damageType,
damageAmount, subsystemID}.  DamageInformation has no burstCount field, so the
cluster count was DROPPED in transit no matter what the round carried.

So: dispatch projectile damage directly, as the binary does, carrying the rolled
burst.  This also retires the #84 double explosion architecturally -- the second
blast came from the manager's bundled explosion, and projectiles no longer go
through the manager at all.  The MarkRoundDetonated suppression added in efc3e9f
is therefore dead and is removed.

ALL-WEAPON-CLASS AUDIT (scratchpad/night8/allweap.{sh,py}), one run, all classes
firing at once:
  EXPLOSIVE (missile)  38 zone applications, bursts spread 1x2,2x6,3x9,4x4,5x5,
                       6x12 across ELEVEN zones -- the [n/4..n] roll plus the
                       per-burst zone re-roll, i.e. the cluster scattering
  ENERGY    (beam)      8 applications, burst 1  -- unchanged, no regression
  type4                 8 applications, burst 1  -- unchanged
  COLLISION             0 applications reached armour -- divert intact
Explosions: 10 projectile impacts produce no bundled blast; direct-fire still
queues its own (11 made).

NOT yet verified: cross-pod delivery.  Dispatch reroutes to a replicant on its
own (and the binary relies on exactly that), but the manager routing is gone, so
MP damage should get a two-node run before this ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 04:42:15 -05:00
Joe DiPrimaandClaude Opus 5 efc3e9ff1a #95/#84: missile salvos deliver their FULL authored damage, and stop double-exploding
THE SALVO DAMAGE (#95).  Players measured an LRM 15 landing "3ish points".  The
logs agreed: [projectile] IMPACT damage=3.33333 (Oracle, LRM15) and 3.5 (Rajel,
LRM10).  Those are right PER MISSILE -- 50/15 and 35/10 -- and the bench shows
why the salvo still under-delivers: each launcher pushes N rounds of which
exactly ONE carries damage.

Two individually-correct changes composed into an N-fold shortfall:

  * The ctor does what the binary's MissileLauncher ctor does (@0x3ac/@0x3d4):
        damageData.burstCount    = missileCount;
        damageData.damageAmount /= missileCount;
    The record holds the PER-MISSILE amount plus the count; the arcade
    reconstitutes amount x burstCount when it applies the hit.
  * Task #62 then correctly stopped the port applying the hit once per visual
    round (that was ~missileCount-x too lethal) by damaging only the lead round
    -- but handed it the already-divided amount.

Our DamageZone::TakeDamage is `damageLevel += amount * scale` and drops
burstCount, so the salvo delivered amount/missileCount.  Since the port collapses
the cluster to one damaging round, multiply the count back in there.  Bench: an
SRM6 salvo now lands amt=35 (the authored total) taking a zone 0 -> 0.556, where
it previously landed 5.83.

THE DOUBLE EXPLOSION (#84).  Oracle: "missile appear to register hit explosions
twice, once where target was and again where the target is."  There are two
spawn sites: BTSpawnRoundDetonation at the round's own impact point, and the
message manager's bundled explosion at the CONSOLIDATED point a frame later.
The duplicate was known and thought harmless -- "among a rippled volley it is
invisible" -- which held only while a salvo landed N detonations.  A projectile
now marks its weapon (MarkRoundDetonated) and the consolidation skips queueing a
second blast for it; direct-fire weapons never mark, so lasers/AC keep the
bundled explosion they rely on.  Bench: 4 missile impacts -> 4 SKIPPED, while 11
direct-fire hits still queue normally.

SWEPT CONTACT (#84 tail).  Contact was a 10-unit sphere sampled only at the END
of each step.  With the authored thruster live (#84) field rounds arrive at
v=955 -- a ~16 unit step at 60fps, larger than the radius -- so samples can
straddle the target.  (Pre-#84 rounds flew ~100-300 = a 1.7-5 unit step and could
never skip it: the velocity fix exposed this, it did not cause it.)  Now tests
the whole segment travelled and bursts at the point of NEAREST APPROACH, which
also stops the detonation being flung past the target at speed.

RETRACTION: I posted tunnelling as the leading explanation for the lost salvo.
The bench disproves it -- zero fizzles, and the "missing" rounds are the dmg=0
visual rounds of the cluster, which never registered damage by design.  The
sweep is kept as speed-independent robustness, not as the #95 fix.

Bench: scratchpad/night8/salvo.sh (BT_PROJ_LOG + BT_FIRE_LOG).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:59:44 -05:00
Joe DiPrimaandClaude Opus 5 839b040619 KB: night-8 playtest intelligence (build 4.11.674)
Player testimony worth keeping out of the issue tracker alone:

* SENSOR PANEL model, from VGL Lynx -- the last major system without one.
  Sensor damage flickers the HUD (corroborated by period footage), degrades
  radar/map, affects the searchlight, and possibly the hot box.  Gitea #105.
* COOLANT LEAK severity is a THREE-level display (1/2/3 triangles + alarm);
  we only ever produce the lowest.  Our leak rate is a continuous scalar, so
  either the banding is missing or the magnitude never leaves the bottom band.
* MYOMER heat calibration is CHASSIS-RELATIVE: a Thor sustains seek 4
  indefinitely, a chicken-walker cooks in under a minute.  That spread is the
  acceptance test -- a uniform "too hot" indicts the drive-heat coefficient
  rather than the sink capacities.
* 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.
* MP stability: 5-player match, no stalls or lag spikes -- bounds how often the
  carried cross-fire-stall / silent-node-crash opens occur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:09:56 -05:00
Joe DiPrima 137c151951 #87: mech armour PANELS now darken with damage -- the .DZM material system was loaded and unused
Players: "the actual enemy mech in external view is not showing darkened armor
panels".  We swapped destroyed LIMB meshes but never darkened a panel.

The 1995 game darkens armour through the MATERIALS.  MakeMechRenderables
(FUN_004cef28) built, per (damage zone, material), a watcher
FUN_004573e4(material, &zone->damageLevel, 0.1f) that snapshotted the materials
2026-07-31 19:52:55 -05:00
Joe DiPrimaandClaude Fable 5 5410371b0c #86: destroyed weapons can no longer fire -- the fire gates read a
never-written cell (the split-cell gotcha)

Both weapon fire gates (ProjectileWeapon gate 1 @4bbd36, Emitter
hard-failure @4baab9) test the binary's subsystem+0x40 for Destroyed(1).
In the 1995 layout that offset sits INSIDE the embedded status alarm
(statusAlarm@0x2C + level@+0x14 = 0x40) -- ONE cell, written by
ForceCriticalFailure when a zone's crit cascade kills the subsystem.  The
port models the same address as TWO members: the AlarmIndicator AND a
plain int simulationState@0x40.  Every destruction path writes the ALARM
(so the MFD draws its X correctly) while the gates read the plain int,
which nothing ever writes -- so a weapon on a blown-off arm showed
destroyed on every panel and kept firing and scoring, locally and on
peers (night-7: all three testers, screenshots of a missile leaving a
destroyed pod).

The tell had been sitting in our own logs for weeks:
  [ammo] SRM6_1 -> NoAmmo (gate1): destroyed=0 ...
on a mech whose launcher was X'd out.

Fix: both gates now read statusAlarm.GetLevel()==1 as well as the int.
Also added: the crit-cascade log names the destroyed subsystem, a
BT_SELF_DAMAGE_ZONE=dz_* named-zone bench mode, and BT_KILL_SUBSYS=<name>
(force ForceCriticalFailure on one named subsystem -- the exact call the
zone cascade makes, so a bench can ask 'the panel says dead, does it
still shoot?' without hunting for the zone that carries a given weapon).

Verified A/B in ONE run: before the kill both SRM6 launchers fired 2
salvos each; after, the destroyed launcher fired ZERO (gate log
destroyed=1) while its twin kept firing normally.

KB: new gotcha #22 (the SPLIT CELL -- one binary offset, two port
members, only one written; sibling of #1) + combat-damage entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 12:11:09 -05:00
Joe DiPrimaandClaude Fable 5 62fc8409b6 seek-4 verification harness (BT_FORCE_SEEK) + the full supercharge/overheat
arc measured live

BT_FORCE_SEEK=<idx> pins the seek gear in MyomersSimulation (env-gated
bench; the eng-page toggle stays the authentic path) -- the supercharge
loop was unreachable on a headless rig.

Measured (Black Hawk, WALLTEST, full autodrive at gear 4):
- cold: drive=1.42843 (exactly 0.9999/0.7), demand 87.85, actual run
  speed 53.7 u/s = 193 kph -- SUPERCHARGE (Oracle's pod figure: 182 for
  a 75-tonner; the 60t Black Hawk runs faster);
- heat at ratio^2 = 2.04: myomer T 531 -> 1765 (deep past the 1000
  degradation line);
- heatFactor governor: drive 1.43 -> 1.10 -> 0.62 (the supercharged mech
  slows BELOW normal while cooked), coolant pulls T back (1765 -> 740),
  drive recovers to ~0.77, oscillates -- the mech self-settles into a
  thermally-limited cruise: 'top speed for less than a minute' exactly;
- the hard freeze (drive -> 0: demand AND turn zeroed) is the same code
  path past failure temp, reachable when the coolant loop is compromised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 11:00:31 -05:00
Joe DiPrimaandClaude Fable 5 905a4a86c0 RETRACTION + FIX: the myomer seek->speed coupling IS in the 4.10 binary --
restored byte-grounded; the morning's removal was export-gap blindness

Player testimony (Oracle, pod veteran: seek-4 humanoid = 182 kph -- the
manual's printed Super Charged figure; overheat = 'freezing up') forced a
re-audit of the morning's 'no dynamic myomer->speed feed' verdict.  The
verdict was WRONG: a raw-image capstone sweep for FPU reads of [reg+0x31C]
found the consumer inside the UN-EXPORTED master-perf region -- the same
decomp gap that hid the #93 crash block.  Decoded @0x4a9cf2-0x4a9da4 [T1]:

  per frame: MAX speedEffect@0x31C over the myomer chain (+0x7AC)
             -> mech+0x79C
             mapper->speedDemand@0x128 *= it        (the SPEED coupling)
             if |drive| <= 1e-4 (@0x4ab16c):
                 mapper->turnDemand@0x12C = 0       (the FREEZE)

Everything the manual + the veterans describe emerges: gear 4 rides
speedEffect ~1.43 into the RegisterMaxOutput gait cap (base x 1.4284 --
the two mechanisms interlock: 61.5 x 1.43 = 87.9 vs cap 87.8 measured) =
SUPERCHARGE; myomer heatFactor degradation slows you; overheat freezes
speed AND turn; damage slows (speedEffect carries 1-damage -- #75's
original premise was RIGHT and its 'falsified' close was wrong).

Restored in mechmppr.cpp, byte-grounded and better than the pre-removal
draft: MAX over the chain (was first-found), NO 1.0 clamp (the old clamp
ate the overdrive), turn-freeze included.  BTMyomersSpeedEffectOf
(unclamped) replaces the deleted BTMyomersDriveOf.

Verified live: healthy drive=1.0 (no speed change), collision-rattled
myomers drive 0.999 -> 0.892 -> 0.742 with demand scaling in lockstep
(61.5 -> 54.9 -> 45.7).

KB corrected in place (subsystems WAVE 6, pod-hardware manual audit
retraction, open-questions resolved) + NEW GOTCHA #21: export-gap
blindness -- 'no callers in the decomp' is not 'no callers in the
binary'; sweep the raw image for the operand pattern before declaring a
data path dead, and when a primary source disagrees with your decomp
sweep, suspect your evidence first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 10:54:11 -05:00
Joe DiPrimaandClaude Fable 5 4704ab3d41 #84: missiles ACCELERATE like the binary -- the pool flew at constant
rack-eject speed; the authored MissileThruster was never applied

The binary Missile hosts a MissileThruster subsystem: authored
acceleration for BurnTime seconds after the MuzzleVelocity eject.
Authored values decoded from BTL4.RES (type-15 missile model records,
reached from the AmmoBin's ammoModelFile via the type-1 MODELLIST
indirection): SRM 2.5s @ 600 u/s^2, LRM 10s @ 300 (climb 50), Streak
3s @ 300 (turn 360deg/s), NARC 10s @ 300; splash 30 all (port constant
was already right); authored drag ~0.001 = negligible.

The port pool flew every round at CONSTANT |MuzzleVelocity| -- SRMs 100
u/s, LRMs 30 u/s (10x slower than authored) -- the entirety of the
night-7 "missiles are very slow" report, and the driver of the
"explosion before the missiles arrive" perception (the salvo-lead
detonates while the slow spread rounds straggle in).

Fix: BTMissileThrustOf (mech4.cpp) lazily parses + caches the RES
thruster table (ModelList ids aliased to their type-15 member's
burn/accel); both launch paths (master FireWeapon + the replicant salvo
mirror -- peers see the same speed) resolve through the launcher's bin
and pass burn/accel into the pool; the advance integrates speed +=
accel*dt while burn remains, heading preserved.  Ballistic rounds
(autocannon/gauss) pass 0/0 -- unchanged.  Impact log now prints
v=/burnLeft= evidence.

Verified live (solo, BT_SPAWN_ENEMY + BT_AF_MISSILE): thrust table 8
entries cached; Black Hawk Streak resolve burn=3 accel=300; impacts at
v=277 u/s (was a constant 100).  Fun authenticity note: the Black
Hawk's "SRM6" fires strk (STREAK) ammo per its authored bin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 10:41:39 -05:00
Joe DiPrimaandClaude Fable 5 ac7c46b87a #93: fix the end-of-round PerformAndWatch crash -- the STATIC projectile
pool held dangling entity pointers across round teardown

Root cause (disasm-pinned on the shipped 4.11.659 exe): RajelAran's crash
(call to 0x65676769 = ASCII 'igge', EBP-walk return btl4+0x2b91a) is the
tgt->Dispatch vtable call in BTUpdateProjectiles' NON-MECH impact branch
(mech4.cpp:1598; the site matches the disasm literally -- the 0x12/0x64
TakeDamageMessage ctor, the EntityID::Null ternary, the getenv gate after).

The trap: the mech branch is guarded by BTIsRegisteredMech(tgt) -- but at
round teardown a destroyed mech is DEREGISTERED, so a round still in
flight (the pool is a static array that outlives the round; missiles are
slow, #84) holding it as p.target now FAILS the mech check and falls into
the !BTIsRegisteredMech branch, which treats the freed mech as a cultural
icon and dispatches into freed memory.  The liveness check itself routed
the dangling pointer into the unguarded branch.  Freed heap reused by a
string -> vtable slot +0x10 read 'igge' -> call 0x65676769.  (The EBP
walker explains the stack shape: the faulting call pushed its return
address on ESP, but the walk reads [EBP+4] = PerformAndWatch's frame.)

Fix -- scrub at the source of truth:
- BTProjectilesDropEntity(e): every pool entry drops a dying entity from
  p.target/p.shooter; called from ~Mech and ~CulturalIcon (the only free
  paths for targetable entities).  Flight + impact code is already
  null-guarded on both fields.
- BTProjectilesClearAll(): kills all rounds at RunMissions exit (cross-
  mission hygiene for the static pool).

Verified: 6 short-mission cycles (45s missions with a spawned dummy +
autofire, mission expiring mid-combat) -- 0 exceptions, 6/6 clean
'RunMissions returned'.  The original crash was a heap-reuse race with no
deterministic repro; the scrub eliminates the dangling-pointer class by
construction.  (Bench note: blind autofire never launches missiles -- the
launcher needs a target lock -- so the exact in-flight race was not
re-created; the non-regression + the pinned mechanism carry the verdict.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 10:19:24 -05:00
Joe DiPrimaandClaude Fable 5 ba4af90d6b phase-13 log: night-7 session research state (collision economy, myomer system complete, coolant leaks, the seek 4.0-vs-4.10 drift) + open-questions entry for the seek player version-probe
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 09:55:42 -05:00
Joe DiPrimaandClaude Fable 5 c18929b23a KB: manual cross-check -- myomer seek 'Super Charge' top speed is 4.0-manual behavior DROPPED by the 4.10 binary (stat sheets: Normal 143 / Super Charged 182 / Gimped 40 kph; printed ratio 1.27 vs 4.10's vestigial gear ratio 1.43)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 09:34:42 -05:00
Joe DiPrimaandClaude Fable 5 58f49ed942 KB: the seek dial's real stake is TORSO TWIST (PowerWatcher zeroes twist while myomers not Ready); generator has no load model -- gears 1-3 identical on a healthy mech
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 09:25:54 -05:00
Joe DiPrimaandClaude Fable 5 cf8618bcb9 myomer system COMPLETE -- the 'WAVE 6 mover cutover' was a phantom built on
a misattribution; the authentic couplings are all live

THE CORRECTION [T1]: @004b9550/@004b95b8 ('ConnectToMover'/'Disconnect
FromMover') are MechWeapon::ConfigureMappables/ChooseButton -- their +0x31C
is the weapon TriggerState and **(mech+0x128) is the CONTROLS MAPPER's
button roster (the MECHWEAP.CPP assert string + the trigger-edge detector
@004b9608 sit adjacent in the image).  The binary has NO dynamic myomer->
speed feed: AvailableOutput has exactly TWO callers in the whole image --
RegisterMaxOutput (assembly) and the SeekVoltageGraph sampler -- and
speedEffect@0x31C is a published GAUGE attribute.

The authentic myomer system (now fully live):
1. drive heat (#85, this morning);
2. VITAL death -- myomers are the only authored vital=1 subsystem
   (BTL4.RES res+0x48 scan): destroying them kills the mech via the #80
   vital-crit path (this is also the ram-death mechanism -- myomers are a
   0.35-weight collision-rattle target);
3. the assembly-time gait cap: RegisterMaxOutput @004b8ef0 raises
   mech+0x7A0 to AvailableOutput(top gear) = base x ~1.4284 -- now called
   (idempotent max, per tick; the 0x358 layout lock leaves no latch room)
   with real OwnerBaseSpeed/OwnerMaxSpeed bridges; the old per-frame
   reverseSpeedMax2 heal that would have clobbered it is garbage-guarded;
4. the ENG-page power curve at correct absolute scale (real base speed);
5. electrical sourcing (low gears run on a browned-out generator).

REMOVED (inventions): the mechmppr `speedDemand *= speedEffect` demand
multiplier + BTMyomersDriveOf + the Myomers ConnectToMover/MoverAttach
family.  Damaged-but-alive myomers heat faster and drop the power curve
but do NOT slow the mech (gitea #75's premise is falsified by the binary;
the real damage slowdown is the LEG-damage gimp gait).  Seek gears change
heat/sourcing/graph -- never speed.

Verified live: a mech at myomer dmg=0.234 (speedEffect attr 0.766)
beelines at full commanded speed with dmgGain=1.234 heat; 0 faults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 09:17:31 -05:00
Joe DiPrimaandClaude Fable 5 31552c0444 #88 part 2: the central coolant pull-through -- HeatSink::DrawCoolant was a
return-0 stub; the Reservoir BAR now drops when a damaged mech leaks

The user watched the coolant panel's level bar during the leak bench and it
never moved -- correctly: a leaking subsystem drained only its own loop,
because the slot-14 top-up (DrawCoolant) was stubbed.  Disassembled the real
base @0x4add00: the draw RECURSES UP the sink linkage, scaling by
coolantFlowScale@0x15C per hop, terminating at the Reservoir's supply
(@0x4af3b0 -- clamp to [0, coolantLevel], drain the tank, return granted --
already faithfully ported as Reservoir::DrawCoolant).  The binary's terminal
hop is the bank's slot-14 override @0x4ae8b0 resolving its reservoir
connection @0x1D8 (the port's 'helper' member -- not vestigial after all);
the port's existing Reservoir-ctor Attach + Reservoir override terminate the
same chain equivalently (the extra bank hop scales by ctor-default 1.0).

Implemented the base recursion (null-guarded -- the binary calls through the
resolved link unguarded; authored topology always links) + a BT_COOL_LOG
[resdraw] probe at the supply.

Verified live: a destroyed myomer's damage leak pulled the central tank
6.0 -> 5.58 over ~30s ([resdraw] granted lines) -- Reservoir/CoolantMass,
the cockpit coolant bar, now visibly drops as a damaged mech bleeds
coolant, and DeathReset refills it on respawn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 08:47:55 -05:00
Joe DiPrimaandClaude Fable 5 a8a0042f28 #88: coolant leaks LIVE -- the #64 damageZone shadow was the single break
in the leak chain

HeatSink::UpdateCoolant prices the damage-driven leak from the QUALIFIED
engine member (Subsystem::damageZone @0xE0): coolantDraw = zoneDamage *
heatLoad, with the coolantActive hysteresis (= the ReportLeak attribute
the 19 authored leak watchers ride).  But the MechSubsystem ctor only
ever filled its re-declared SHADOW member (gitea #64, gotcha #1), so the
engine member stayed NULL forever, zoneDamage pinned 0, and a coolant
leak was STRUCTURALLY IMPOSSIBLE no matter how much damage landed --
Oracle's night-7 report exactly.  The #80 crit fix and the #83 collision
rattle write real subsystem-zone damage, but into an object the leak
reader could never see.

Fix: alias the ENGINE base member to the same zone in both MechSubsystem
ctors.  Every shadow reader keeps working (same object); the engine base
Subsystem::TakeDamage latent null-AV (#64 consequence 2) is disarmed;
the full #64 de-shadow sweep remains the long-term cleanup.

Verified live (solo, BT_COOL_LOG/BT_CRIT_LOG): collision rattle drove a
damaged Myomers to [cool] draw = dmg*heatLoad with the level draining;
sustained weapon fire rolled real crits ([critroll]) and produced 588
leak-pricing lines.  Authored routing extracted from BTL4.RES: collision
rattle targets HeatSinkBank 0.3 / Gyro 0.35 / Torso 0.25 / Myomers 0.35
(Condenser+Reservoir authored 0 for collisions); weapon crits select via
the zone's crit-entry list.  Known-remaining fidelity item (documented):
HeatSink::DrawCoolant (slot 14, central top-up) is still a return-0 TODO.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 08:26:40 -05:00
Joe DiPrimaandClaude Fable 5 1570983fde #85: myomer drive heat LIVE -- the five Mech motion operands were return-0
shims, so the reconstructed integrator @004b8d18 accumulated zero forever

The integrator, its gates and the heat plumbing were all reconstructed and
running -- but OwnerVelocityMag/OwnerDriveMag/OwnerVelocityY/OwnerMotionGain/
OwnerMass were cross-family compile shims returning 0.0f, so the myomers
priced ratio^2 * damageGain * 0 every tick: ice cold at any seek (Oracle's
night-7 panel-confirmed report).

Operands re-grounded against the raw disasm + decomp and mapped to NAMED
members via a complete-Mech-TU bridge (BTMechMyomerMotionSample, mech4.cpp),
per the databinding rule:
  +0x1C4 vec  -> localVelocity.linearMotion
  +0x82C vec  -> localAcceleration.linearMotion (the authentic 15-ring
                 smoothed AccelerationLastFrame the port already maintains)
  +0x20C      -> moverMass (the collision divert's cell; the old "motion
                 gain" label was a misread)
  *(+0x250)   -> environment gravity (FUN_00421e2c: vy -= **(+0x250)/tick),
                 via GetEnvironment()->gravityConstant
So the heat physics reads: 0.005 * 1/2 m v^2 (kinetic work, per-tick/no-dt
in the binary -- kept verbatim, frame-rate note documented) + 0.2 * m g
|vy| dt (climb power) + 0.2 * m |v||a| dt (acceleration power), all scaled
by ratio^2 and (1 + zone damage).  Restored the binary's fabs(v.y) (the
draft had signed vy -- descending would have COOLED).

Authored tuning extracted from BTL4.RES (18 mech records, one shared
tuning): VelocityEfficiency=0.995, AccelerationEfficiency=0.8, gears
0.3/0.5/0.7/0.9999 rec idx 2; myomer thermal profile 77/1000/2000,
conductance 1.9e5, thermalMass 2.5e5.

Live-verified (solo arena, BT_MYO_LOG + BT_HEAT_LOG): standstill generates
~0; hard circling drove Myomers T 77 -> 1225 (past the 1000 degradation
line) and the coolant loop conducted it back to ~520 equilibrium on
slowing; damageGain live at 1.048 from grind rattle.

Known-inert remainder (filed in open-questions): the climb term reads g=0
because Mover::localEnvironment is never populated in the port
(EnvironmentZone res type 23 unwired); the mover feed (speedEffect ->
locomotion) stays WAVE 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 08:14:05 -05:00
Joe DiPrimaandClaude Fable 5 37dd7f9663 collision-pricing audit: restore the binary's crash economy -- two fixes
A. KNOCKDOWN PACING: the 0.4s gBlockCooldown contact-hysteresis is gone.
It suppressed the crash knockdown for as long as contact was held, letting
the kinematic drive re-slam the obstacle at full commanded speed EVERY
FRAME -- continuous full-price crash self-damage, a healthy 65-tonner dead
in ~2-3s of grinding (the night-7 SAURON ram death, 110 priced frames at
|v|~34.7).  The binary needs no guard (crash threshold @0x4ab178 read from
the exe = 0.0, dispatch per blocked frame is authentic): the bmp clip
zeroes the drive, and a pressed mech re-triggers the knockdown at v>6.3
long BEFORE its grind can price anything -- the type-0 divert's 0.5-pt
free floor needs v>~18.5 at 65t.  Wall grinding is free taps + paced
staggers; only genuine fast arrivals pay.

B. RAW RAM DISPATCH: the *0.001 "ram economy normalization" (2026-07-12)
predated the #83 type-0 divert and became a double-normalization -- the
victim's divert scale (~4e-5) compounded with it, so NO physically
possible ram could clear the 0.5-pt free floor: rams were a no-op against
the victim while the rammer's own un-scaled crash path rattled for real.
The binary dispatches raw [T1 @part_012:15324]; the divert IS the
normalizer.  Also un-starves cultural-icon crunches (trucks now crush on
contact per their authored armor, not after ~19 bumps).

Bench (scratchpad/night7/mp_rampricing.sh):
- wall leg: 150s full-throttle wall push = 125 paced knockdown binds
  (~1.2s cadence, iv2 40-43), 4767 crash frames ALL under the free floor,
  0 rattle, 0 deaths (pre-fix: dead in seconds).
- ram leg: BT_GOTO=enemy + BT_GOTO_STOP=2 rams the peer; raw amount
  arrives byte-identical on the victim (66239.1 tx == rx), victim prices
  2.55 pts internal rattle (first ram damage to a victim in the port's
  history), pressed follow-ups free, 0 deaths both nodes, 108 knockdowns
  -> exactly 108 type-5 records on the observer (no skating regression).

KB swept: combat-damage.md (the 1.3e6 moverMass mis-attribution corrected
to the measured 60-90k tonnage scale), locomotion.md, rendering.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 02:48:35 -05:00
Joe DiPrimaandClaude Fable 5 054c3f2eee #94: replicant un-wreck now rides the DEATH-STATE EXIT edge -- the zone
falling-edge latch was blind to zone-less (collision) deaths

The peer wreck lifecycle listened to two different signals: wreck ON came
from the replicated death explosion (unconditional), wreck OFF from a
damage-zone falling edge (wasWrecked latch, armed only when a zone crossed
>= 1.0 on the observer).  The #83 collision damage prices as internal
rattle and never moves a zone, so a fresh mech killed by ramming could
NEVER un-wreck on peers -- the live respawned mech drove around wearing
the hulk (night-7 field report: SAURON's Loki, screenshotted by both
observers at the exact minute the log analysis places the ram death).

MechDeathHandler::Tick now tracks prevMode and fires the rebuild + warp
when the replicated MovementMode leaves the death modes {2,9} -- only
Mech::Reset ever does that, and the state rides every record header, so
the trigger is cause-agnostic.  The zone falling edge just refreshes the
cache.  Side effect fixed for free: the peer respawn warp vortex now
plays for zone-less deaths too (same gate).

Bench (scratchpad/night7/mp_zoneless.sh + the new
BT_SELF_DAMAGE_TYPE=collision harness option, which dispatches type-0
through the real TakeDamage divert): 14/14 zone-less deaths un-wrecked
(mode 9->1) with zero zone rises on the observer; explosive regression
leg 12/12 with zone destruction active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Zh7PTkFy4KwTzVighLR9J
2026-07-31 02:10:04 -05:00
Joe DiPrimaandClaude Fable 5 e91d447405 #82 FINAL LAYER: the peer body-channel trn-lock -- a circling limper skated because the peer's turn-arm had no walk-demand yield and the trn speed exit read a dead replicant mapper cell
The master limped clean (218x gimp-cycle records, zero trn) -- a _ReturnAddress
trap on SetAnimationState(4) proved ALL peer trn arming came from the port's own
mech4 turn-step block, not the type-3 reader.  Two dead ends, one root:
- mech4 peer arm: gated on !wantsWalk (standSpeed < bodyTargetSpeed), exits trn
  when walking demand appears -- the leg twin's authentic precedence
  (part_012.c:12013, the Standing speed test outranks the turn test).
- mech2 body case 4: bspd reads bodyTargetSpeed on ReplicantInstance; the local
  mapper's speedDemand is a dead cell on a peer (reads 0 forever -> no exit).
Verified 2-node timeline: arena circle replicant 218x state-24 / 0x state-4
(was 116x trn churn), grass circle 235-clean, knockdowns bounded, 0 deaths.
Diagnostics kept: BT_TRNTRAP ra-trap, BT_ANIMIND_CAP, [gimpfeed], [replgimp]
extension.  KB: locomotion.md trn-lock entry + the dead-mapper-cell lesson.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRjrTQpJd6u9XyfnUbTB3v
2026-07-30 18:26:15 -05:00
Joe DiPrimaandClaude Fable 5 14ff3519ac #83: the COLLISION DIVERT -- type-0 damage prices as internal rattle, never armor
The crash self-damage reconstruction (9c83a46) exposed its missing second
half: the binary's TakeDamage hub DIVERTS damageType 0 (@0x4a0368) into
FUN_0049ffcc -- an export-gap distributor recovered by raw disasm -- so
collision damage NEVER reaches the zone/armor loop.  Without the divert the
mover's raw kinetic-energy figure (60-ton mech: ~1000+ per wall tap, 90k+ on
a hard slam) fell into the WEAPON loop: 'tapped a wall and died instantly'.

The distributor, byte-anchored: gate on the owning player's advancedDamage
copy (+0x268 -- the manual's 'splash/collision damage' technician setting);
scale = (2000/moverMass) / (100 km/h in u/s)^2 / (1 - elasticity^2)
(tbyte 1/3.6 @0x4a0148; mass @+0x20c; elasticity @+0x244); scaled < 0.5 is
FREE; else Round(2x) sub-hits of 0.5 land on random INTERNAL subsystems
(HeatSink family / Gyroscope / Torso, drawn by collisionCriticalHitWeight
@0x10C) via ApplyDamageAndMeasure (@0x4ac07c).  Bridges: family derivations
(heat/gyro/torso.cpp), BTPlayerAdvancedDamageOn (btplayer.cpp).

Verified: bhk1 wall test -- 94k slam = 4.2 rattle pts in 8 sub-hits, taps
free (0.049 < 0.5), armor untouched.  A/B: arena wall-grind now 0 deaths,
27 rattle events, the limper STAYS GIMPED and peers' replicants re-enter the
gimp cycle between bounded staggers; grass circling unchanged (198 replicant
gimp entries).  [crashdmg]/[colldmg] probes document the pricing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:04:24 -05:00
Joe DiPrimaandClaude Fable 5 9c83a468db #82 ROOT FIX: reconstruct the crash SELF-DAMAGE -- wall-grinding now hurts,
the knockdown storm self-limits, peers keep the limp

The 'peers see a limping mech skating' report decomposed into a wall-grind
KNOCKDOWN STORM: a gimped mech held against a blocking solid re-crashes each
time its gg cycle rebuilds speed (the gg ceiling is authentically the clip's
natural speed -- bhk1 ~14.9 u/s, ceiling+divisor @0x544/0x548, loader formula
byte-matched -- so iv2 ~222 >> the 40.0 threshold @0x4ab184), and every
type-5 KNOCKDOWN broadcast floors all peers' replicants into knockdown/trn
churn: gliding + leg-lifts = skating.  Turning and crushable cars were
A/B-exonerated (grass circling: 198 replicant gimp entries, 17 crunches,
zero broadcasts).

What the binary has that the port lacked -- recovered from the EXPORT GAP by
raw disasm (scratchpad/night6/gap_4a9770.txt, @4aa89f-4aaab4) -- is the crash
branch's tail: fallDirection = worldToLocal(damageForce) (FUN_0040879c, M^T v,
un-normalized), fallScalar = -(fallDirection . localVelocity.linearMotion),
and a self-dispatched TakeDamageMessage{0x64, zone=-1, the engine-computed
collision Damage verbatim}.  A wall crash COSTS ARMOR, so the grind degrades
the mech instead of looping forever.  That dispatch was the long-standing
'DEFERRED' note in the response policy; now reconstructed.

Falsified en route (comments + KB corrected, do not revive): the stagger's
FUN_004a4c54(1)/(0x20) 'action-request flags' feed NO drive suppressor --
image-wide sweep + full gap disasm show word[this+0x18] is read only by the
net record emitter; the bmp clip applies NO root motion (adv=0 measured); the
'gimp cap 5.8' figure was the BACK-cycle stride printed under a misleading
label (now ggCapL/R; @0x52c has no binary consumer).

Verified (mp_gimpAB rigs): arena1 wall-grind now 4 bounded strikes, limper
dies of its crashes and respawns cleanly twice (START->RESET, no strand);
grass circling unchanged-clean (198 replicant gimp states, 0 knocks).
Rig: pid capture race fixed (a missed winpid left a stale node holding the
-net port -> null runs), per-config sweep added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:18:28 -05:00
Joe DiPrimaandClaude Fable 5 19fe1e5fa2 KB: the wall-grind knockdown storm IS the surviving #82 skating -- A/B-isolated
Four-config matrix (mp_gimpAB.sh, [animind] replicant-state verdict):
grass straight/circle = clean replicant limp (198 gimp entries; 17 car
crunches during it -- crushable path broadcasts nothing, cars exonerated);
arena1 straight = one wall strike, replicant recovers into the limp;
arena1 circle = wall GRIND -- the bmp stagger clip's ~6.5 u/s root motion
re-strikes the wall (6.5^2 > iv2 threshold 40), each rebind re-broadcasts
the type-5 knockdown (26x in the user's run), and every peer's replicant
lives in knockdown/recovery churn: gliding + trn leg-lifts = "skating".
Turning while gimped is innocent; single strikes are benign.

Authentic fix filed in open-questions: reconstruct the consumer of the
stagger's action-request flags (FUN_004a4c54 bits 1/0x20 -- the drive
suppressor while the bmp clip plays); the rebind guard at mech4.cpp:6861
is already marked bring-up-pending exactly that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:41:56 -05:00
Joe DiPrimaandClaude Fable 5 4e89baeff3 bench parity: local nodes now launch exactly like a player's shortcut
Local benches had drifted from the shipped launchers and every divergence
cost a debugging session:

  * no BT_START_INSIDE -> benches opened in the EXTERNAL CHASE camera while
    every player starts in the COCKPIT.  The first first-person look at a
    bench window was misread as a broken HUD.
  * novice bench eggs  -> every SHIPPED egg is expert; novice gates off the
    entire heat model, crits and jams, so bench combat was not field combat.
    (Verified: the aligned node now boots experience=3 simLive=1
    heatModelOn=1, where it used to boot 0/0/0.)
  * 1-LP affinity pins -> starved the gauge executive and produced a false
    "the comms panel never counts deaths" reading.  Two LPs per node keeps
    the documented single-box jitter fix without the starvation.

scratchpad/night6/bench_common.sh now owns the contract (bt_player_env,
bt_launch, bt_expert_egg, bt_novice_egg) copied verbatim from play_solo.bat,
with bt_assert_player_env warning if the shipped bat ever drifts.  All three
4-node benches source it.  The limp bench keeps a novice egg -- expert crits
the aimed leg and the mech turns-but-never-moves -- and now says so loudly.

Also: BT_SHOT_EVERY=<n> headless backbuffer capture (btl4vid.cpp), the tool
this was diagnosed with.  It must sit above the warp phase-0 early-out since
that fn is the per-frame alpha-pass hook.  OS screen-capture is off-limits:
a foreground-lock failure photographed the user's browser instead of the game.

KB: build-and-run.md gains the bench-parity rule + the capture diag;
cockpit-view.md gains the measured aspect/FOV finding (the surround's 2.05:1
view collapses vertical FOV to 31.5 deg vs the pod's 46.8, so fixed canopy
geometry covers 68.6% of the lower half -- NOT a regression: the shipped 643
binary reports identical geometry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:30:30 -05:00
Joe DiPrimaandClaude Fable 5 82f1ea1183 bench: symbolic leg-zone targeting, gimp-follow/goto-release, legal egg colors
- BT_SELF_DAMAGE_ZONE=leg resolves the mech's actual leg zone via the
  streamed LegDamageZone flags (new Mech__DamageZone::IsLegZone) -- numeric
  zone ids are per-mech-type and a wrong one aims at a vital (killed the
  limp bench's mech instead of gimping it: 2 ticks, death cycle, respawn
  healed, harness spent -- 3 minutes of healthy circling).
- BT_GOTO=gimp: beeline mode that prefers the nearest LIMPING peer (gimp
  half-states 3/4 ride the one-cell in every update record); stands and
  waits while nobody limps.  BT_GOTO_RELEASE=1 hands the mech back to the
  keyboard once the beeline arrives.
- MP.EGG/MP4.EGG/MP4L.EGG: color=Red and color=Blue are PATCH values, not
  colors (RES vehicletable colors: Black/Brown/Crimson/Green/Grey/Tan/
  White) -- the two pilots carrying them rendered as untextured pastel
  mechs.  Boreas Red->Crimson, Caicias Blue->Grey.  MP4L.EGG = the 4-pilot
  bench egg on the open grass map.

4-node limp verification (user-eyeballed, all instances): the gimped mech
step-drags with the authentic limp on every observer -- no skating (#82
holds in the 4-node overlap case).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:32:26 -05:00
Joe DiPrimaandClaude Fable 5 494f32efb4 #45 ROOT CAUSE + FIX: the gauge executive starves in MP -- panels froze at healthy fps
Instruments repaint via the background task pump, which authentically runs
ONLY in the frame's leftover time with a floor of ONE pump per frame; the
gauge renderer is 1 of ~7 round-robin tasks and each turn advances ONE gauge
of ~140 active, with the rate mask advancing once per full sweep.  On a busy
MP mission the foreground eats the whole frame budget, the floor becomes the
norm, and the whole instrument stack rotates once in MINUTES at perfect fps:
comms-panel K/D stuck at 0, recharge tickers frozen, while the tallies and
their replication underneath were exactly right.  Measured: 4-node bench at
70-90 fps gave the PilotList ~2 Execute turns in six minutes.

Fix, both env-tunable, authentic behavior restorable:
  BT_BG_MIN      (APPMGR.cpp, default 32, 0=authentic)  minimum background
                 pumps per frame regardless of slack;
  BT_GAUGE_BATCH (GAUGREND.cpp, default 32, 1=authentic) gauges advanced per
                 gauge-renderer turn (a visit is only a rate-mask check
                 unless the gauge is due).
Verified 4-node self-damage bench: sweeps 0.006/s -> 18-20/s, PilotList ~10
Exec/s, ALL FOUR panels tracked every death live (0->11) within ~1s, bg cost
2-4 ms/frame, respawn ledger clean (40 cycles, 0 swallow / 0 mismatch).

Also: BT_PERF now reports gaugeTurns/sweeps/active alongside bgTasks;
BT_AF_PERIOD now throttles the missile autofire group too (unthrottled spam
trips the documented FailureHeat all-weapons brick, which froze run 1 of the
cross-fire bench).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 11:22:47 -05:00
Joe DiPrimaandClaude Fable 5 bfa1b04990 #81 follow-ups: peer warp must not stomp the POV vortex; panel draw probe; mech3 ghost symbols
Two field observations from watching the 4-node stress, both run down:

1. "A respawn happened without the blue vortex" -- REAL.  The translocation
   effect is ONE global slot shared by the POV lifecycle and the world-anchored
   peer sphere (a port extension).  A peer's un-wreck arriving while the local
   pilot's own collapse/wait/expand was in flight overwrote gWarpPhase/gWarpPOV
   and killed the POV vortex.  Invisible before the respawn fix only because
   overlapping respawns barely existed; now they are routine.  Fix: the POV
   lifecycle owns the slot -- BTStartWarpEffect self-skips while it is active
   ([tloc] peer warp SKIPPED).  2-node bench: 20/20 POV collapse+expand pairs,
   9 peer spheres played, 11 correctly skipped; solo: 8/8 pairs unchanged.

2. "Comms panel counted no deaths" -- panel machinery CORRECT; the 4-node
   zeros were the bench's own CPU crush (4 core-pinned instances starved the
   PilotList to <0.6 Hz, so rows redrew minutes-stale).  Added the arbiter:
   [score] panel DRAW slot/pilot/kills/deaths edge log (BT_SCORE_LOG) -- the
   2-node rerun drew 0->9 / 0->11 live on both nodes, local AND replicated
   (SBMIRROR rows confirm owner->replicant tally flow on all 4 stress nodes).

Also: the mech3 offline-authoring stubs declared every <Subsystem>::DefaultData
as Entity__SharedData while the real statics are Simulation__SharedData (this
engine derives Entity FROM Simulation) -- ~20 ghost symbols /FORCE silently
resolved to garbage.  DefaultData half fixed (SubsystemDefaultData now returns
the true common base Simulation::SharedData); the CreateStreamedSubsystem stub
signatures remain wrong (nested SubsystemResource* + ResourceFile*), are
cold (no callers), and are tracked in open-questions + gotchas §6 stub-typedef
corollary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 10:10:29 -05:00
Joe DiPrimaandClaude Fable 5 830a976192 #81: 4-node stress PASS -- 56 overlapping death cycles, zero strands; DEATHS tally exact
Four simultaneous networked nodes, all dying every ~35s on staggered timers so
respawn handshakes overlap (the field condition that stranded 6 of 8 cycles):
56 cycles, every completed one START->RESET, 0 swallowed / 0 mismatch /
0 discarded / 0 ghost / 0 crash.  Scoreboard: every node's PLAYER_DEAD rows
show deaths=N tally=N in lockstep (13/13/15/15) -- exactly one increment per
death, so removing the duplicate VehicleDead dispatch did not drop the DEATHS
column and the latch release did not double it.  The kill-credit path evaluated
all 56 deaths and correctly declined each self-kill (NOCREDIT self=1); KILLS
uses the separate ScoreMessage path the fix never touched.

content/MP4.EGG: the 4-pilot bench egg (ports 1502/1602/1702/1802, novice).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 09:37:07 -05:00
Joe DiPrimaandClaude Fable 5 a357dc4265 #81 GHOST MECH FIXED: release the death latch + stop the duplicate VehicleDead
Two defects that were masking each other, both now fixed and benched.

1) THE LATCH NEVER RELEASED ON FAILURE.  The binary's FUN_004c012c tail is
   Post(...) ; *(this+0x290)=0 ; *(this+0x258)=0   (part_013.c:10519-10523).
   We had the Post and the suppressConsole and were missing the middle
   instruction, so deathPending cleared only on SUCCESS paths.  One failed
   respawn latched the pilot for the whole mission: every later death hit the
   dedup and was SWALLOWED, so the cycle could never restart -- a transient
   hiccup became a PERMANENT ghost (dead, un-Reset, still driveable, a burning
   wreck on every peer that sinks after ~18s and can never be drawn again).
   Binary evidence: +0x290 is written in exactly THREE places in all of
   BTL4OPT.EXE (0x0b75fb, 0x0bffe3, 0x0c0a05) and all three store a ZEROED
   register; there is no write of 1 -- or any non-zero, in any instruction form
   -- anywhere.  The dedup gate itself IS authentic (@004c05c4 does
   mov edx,[ebx+0x290]; test edx,edx; jne ret), so it is kept.

2) VehicleDeadMessage WAS DISPATCHED TWICE PER DEATH.  BTPostKillScore
   (btplayer.cpp:2263) sent a second one "to credit a death", but that message
   is the RESPAWN-CYCLE TRIGGER, not a scoreboard increment, and the tally is
   already credited by the handler's ++deathTally (:538).  Both fire inside the
   same death transition (BTPostKillScore at mech4.cpp:2006, the hardened
   authentic notify at :2110), so they were always paired.  Removed; the kill
   credit above it is untouched (it correctly uses a ScoreMessage).

THEY HID EACH OTHER: the duplicate made the latch look necessary, and the latch
made the duplicate invisible.  Every "death ... SWALLOWED" warning in the field
logs was just the latch deduping our own duplicate -- 8 of 8 deaths, a 100% base
rate, which is exactly why it correlated with nothing when tested.  Fixing
either alone makes things visibly worse (the first bench of fix 1 alone produced
a DOUBLE cycle: deathCount double-incremented, cycle 1's re-post gone stale and
tripping the drop-zone MISMATCH).  That is why earlier passes at #57/#55 kept
adding clear-sites instead of finding the root; the binary broke the tie.

VERIFIED
  solo: 17 consecutive death/respawn cycles, every one START->RESET,
        0 swallowed / 0 mismatch / 0 crash.  Pre-fix this strands permanently
        after cycle 1.
  MP  : two nodes over a real network path with cross-machine drop-zone replies
        (each node's request is answered by the OTHER machine) -- A 11 cycles,
        B 12, 0 swallowed / 0 mismatch / 0 discarded / 0 crash.
  harness: BT_SELF_DAMAGE_REPEAT=1 re-arms the self-damage bench after respawn
        so multiple cycles can be driven (a one-death harness can never
        exercise this fix).  scratchpad/night6/mp_ghost.sh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:49:27 -05:00
Joe DiPrimaandClaude Fable 5 d4ba91bd39 #81 THE ANSWER: deathPending is OUR invention -- the binary has no death latch
Read BT's own respawn code and checked it against BTL4OPT.EXE itself.

FAITHFUL: BT's Player::VehicleDeadMessageHandler (FUN_0042db80, part_003.c:12029)
gates on message->deathCount == player->deathCount AND player+0x40 != 1
(simulationState != DropZoneAcquiredState) -- exactly WinTesla's two gates --
then runs the same closest-DropZone search skipping "win*" zones, dispatches
AssignDropZone and re-posts to itself on a timer.  So the retry loop and the
cross-machine hunt are authentic 1995 behaviour, not a WinTesla artifact.

NOT FAITHFUL: BT's BTPlayer::VehicleDeadMessageHandler (FUN_004c012c,
part_013.c:10504) ENDS with *(param_1 + 0x290) = 0 -- it CLEARS the field our
reconstruction calls deathPending, at the end of every death.  Verified in the
raw binary: +0x290 is written in exactly three places in the whole executable
(0x0b75fb, 0x0bffe3, 0x0c0a05) and ALL THREE store a zeroed register (xor
ecx,ecx / xor eax,eax / xor edx,edx immediately before).  There is NO write of 1
or of any non-zero value to +0x290 anywhere in BTL4OPT.EXE.  Two are ctor/reset
sweeps; the middle one is the death handler, sitting right after the call to
Post and add esp,0x14, matching the decompiled tail exactly.

So 1995 has NO death-pending gate.  We invented it (btplayer.cpp:505) and then
needed six clear sites to patch the strandings it caused (:382 :469 :1431 :1442
:1461 :1532).  #57 and #55 are artifacts of that invention.  It is also what
makes a ghost PERMANENT: in BT a failed respawn is harmless (the 2s re-post
keeps hunting, the next death starts a clean cycle); in ours the first failure
latches the pilot and every later death is SWALLOWED forever -- exactly the
field signature of 8 cycles, 6 stranded, none recovering.

Fix proposed in the doc (match the binary: clear instead of latch, drop the
dedup gate) but NOT applied -- six sites depend on the latch and the dedup is
load-bearing, so it wants a deliberate two-node bench, not a 1am edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 08:31:29 -05:00
Joe DiPrimaandClaude Fable 5 bf0555ccf1 #81: every MP respawn is a cross-machine round trip -- one degraded peer ghosts everyone
Two-node bench with the fixed host:local labels shows each player's drop-zone
request is answered by the OTHER machine's DropZone: A asks, B grants; B asks, A
grants.  FindGroup("DropZones") iterates replicants of remotely-mastered zones
too and takes the geometrically closest, so a respawn is
  my request -> an arbitrary peer's DropZone -> that peer's reply -> back to me.
With 5 players that is 5 round trips through arbitrary peers, and ONE degraded
peer can strand everybody else's respawn.  That finally explains the otherwise
unexplained field datum that one machine stopped processing the death-transition
stream 57% into a match and never recovered (0 explosions/wreck swaps/burials
while four other machines logged 4/4/4) -- a node in that state cannot answer
anyone's respawn.  It also explains why solo is 100% reliable (in-process) and
why a healthy 2-node bench passes.

Also fixes the instrumentation before it costs a night: every [dz] line printed
"entity 1" because a player's LOCAL entity id is 1 on every machine -- with five
players the log would have said a respawn stalled but not WHOSE.  All [dz] lines
now print host:local (including the usedBy= owner of each busy slot).

Doc: two candidate fixes recorded (prefer a locally-mastered DropZone / make the
reply path tolerant of a deathCount that is ahead of ours), neither to be guessed
at -- the [ghost] DISCARDED line's mismatch direction decides it in one line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 01:20:13 -05:00
Joe DiPrimaandClaude Fable 5 0dbdad17f3 #81 ghost: light the SILENT DISCARD -- the only path a respawn can vanish on
Walked the respawn handshake end to end and measured the assumptions.  Two
eliminations and one find:

ELIMINATED: the engine hunt re-posts to itself every 2s forever while the mech
is dead (PLAYER.cpp:325-327; its only state-based exit never fires, see below),
and the DropZone re-grants the SAME slot to a repeat request from the same
requester+deathCount even while busy (DROPZONE.cpp:182-194).  So neither a
transient "no slot" nor a single lost reply can strand a respawn -- the request
keeps being re-sent and re-granted.  A permanent strand needs a permanent cause.

THE FIND: BTPlayer::DropZoneReplyMessageHandler ends
  if (!playerVehicle) ... else if (deathCount == message->deathCount) ... else { return; }
and that last branch was a BARE RETURN WITH NO LOG.  The drop zone grants a
spot, replies, the numbers disagree, the reply is dropped, deathPending stays
latched, the mech is never Reset -> permanent ghost, zero evidence.  That is
why 6 of 8 field cycles stranded silently.  Now always-on, printing the
DIRECTION of the mismatch: msgDeath < ours = genuinely stale (dropping is
right); msgDeath > ours = our counter is behind and we just threw away a LIVE
respawn -> points straight at #45 (the death tally does not replicate).
Deliberately NOT auto-recovered: guessing would be a stand-in, and the wrong
guess resets a mech that is still alive.

LANDMINE DOCUMENTED IN CODE: Set_Alarm_Level is an empty stub (btstubs.cpp:87),
so the death path's Set_Alarm_Level(this+0x2c,1) and the reply's (+0x2c,2) are
no-ops.  Their values decode against Player's enum as DropZoneAcquiredState(1)
and VehicleTranslocatedState(2), which makes "these should obviously be
SetSimulationState() calls" both attractive and CATASTROPHIC: the engine hunt is
gated on GetSimulationState() != DropZoneAcquiredState, so setting 1 on death
would stop AssignDropZone ever being dispatched and ghost EVERY pilot.  Measured
our simulationState at 0x24 (not 0x2c) and the write leaves it 0; in the binary
+0x2c is the Simulation-base alarm (the field the mech side calls graphicAlarm,
@0x4ac126 "owner alarm+0x2C -> level 9"), which our layout models only on Mech.

Verified solo: a healthy death+respawn logs the grant and the RESET and emits
ZERO discard/gate-off lines (no false positives).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 01:08:01 -05:00
Joe DiPrimaandClaude Fable 5 372cbfa344 docs: GHOST_MECH_ANALYSIS -- the field analysis, durable across sessions
The ghost and the "wreckage moving and shooting" are the SAME failure at two
ages: a stranded respawn (drop-zone reply never arrives) leaves the mech never
Reset and never repainted, and the render-side wreck swap is ONE-WAY -- so the
pilot drives a burning hulk that sinks (pure ~18s timer, btl4vid.cpp:1277) and
then cannot be drawn at all.

Records: the 8-cycle ledger (only 2 of 8 reached RESET; stranded correlates with
ghosting 8/8 and 10/10); the host did not ghost because he never died; the
SWALLOWED warning is BENIGN (100% base rate) and two earlier readings of mine
that were wrong (spurious resets = aggregation artifact, per-match player IDs);
the WinTesla-vs-1995 provenance caveat on DROPZONE.cpp; what the new
instrumentation already killed (slots=8, so dropzone=one is not the bottleneck);
and the four-separate-bugs breakdown of the "desync cluster" including the
machine that stopped processing the death-transition stream mid-match.

Raw per-machine logs + the verbatim agent findings stay in scratchpad/night6/
(uncommitted -- machine names + Steam identities).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 00:26:48 -05:00
Joe DiPrimaandClaude Fable 5 ba6756c40a drop-zone / respawn instrumentation: make the GHOST MECH visible in field logs
The ghost (a player who dies, never respawns, stays dead-but-driveable and is a
wreck on every peer) traces to a respawn stalling on drop-zone acquisition:
DropZone::AssignDropZoneMessageHandler grants a slot only if IsAvailable(), and
when none is free it reposts to ITSELF every 0.1s at MaxEventPriority and never
replies -- so deathPending is never cleared.  This whole subsystem was DARK: not
one drop-zone line in a full night of field logs.

ALWAYS-ON (no env gate -- rare, catastrophic, field-only, same rationale as the
#57/#59 guards; volume bounded and rate-limited):
  [dz] POOL   -- slots + downTime + proximity radius, once per mission
  [dz] GRANTED-- one per respawn, with the WAIT time; tags (GHOST RECOVERED)
                 when a long-stalled request finally lands (what testers see as
                 "it fixed itself")
  [dz] STALL / GHOST LIKELY -- escalating, per-waiter rate-limited, and it names
                 WHY each slot is busy (age + last user), which discriminates
                 cooldown saturation from proximity blocking -- different fixes
  [dzreq]     -- the REQUESTER half: re-try count for this death, and msgDeath vs
                 our own deathCount.  The engine gates the hunt on those being
                 equal AND the DropZone resend net is keyed on the same value, so
                 a mismatch breaks both silently (cf #45, tally replication).
BT_DROPZONE_LOG=1 adds the verbose per-request slot dump for bench work.

First run already overturned a theory: slots=8, so an 8-slot pool cannot be
cooldown-starved by 5 players (max 5 on cooldown at once) -- the shared
dropzone=one in eggmodel.py is NOT the bottleneck.  Suspicion moves to the
handshake (lost reply / deathCount mismatch).

Verified with NO env vars set, exactly as a tester runs it: 4 probe lines for a
full death+respawn cycle, all in the normal day log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 00:13:14 -05:00
Joe DiPrimaandClaude Fable 5 e1c15eb806 #82 peers see a limping mech SKATING: the mech erased its own gimp cell every frame
The binary's one mech+0x40 IS Simulation::simulationState, which rides EVERY
update record header -- so in 1995 a peer's replicant learned the gimp level on
every packet and its gait limped with no gimp-specific replication anywhere.
The port's Mech::PerformAndWatch wrote SetMovementMode(1) every frame ("ground,
non-death, non-airborne"), which erased the level once it was mirrored in for
the warning voice (#78): the wire carried 1, bystanders walked while sliding at
limp speed, and the voice sequence restarted on every damage event (1->4 edge
per tick) instead of announcing once.

- mech4: that per-frame write now writes the AUTHORITATIVE level
  (gimped ? 3/4 : 1) via a new alarm-only bridge BTMechGimpAlarmLevel -- which
  deliberately never consults the cell it feeds, so a respawn-cleared alarm
  cannot re-latch stale gimp out of it.
- BTMechGimpLevel: falls back to the replicated cell, with ONE-CELL precedence
  (a fall/death/limbo state wins, so the normal drivers and their death latch
  run -- what the binary's single cell enforced structurally).
- Reverted the #78 record guard: on a replicant the master's records are
  authoritative, so pinning 3/4 against them would keep a peer limping through
  a respawn.  Fixed at the writer instead.
- [simstomp] trap now scoped to the watched mech with a module-relative return
  address (symcrash-able) -- that is what named the writer.
- Also learned + recorded: zone damage levels replicate only when the EXPLOSION
  TABLE's tier is crossed (peer measured at 0.428 vs master 0.857), so peer-side
  damage state must never be inferred from them.

Verified two-node (scratchpad/night6/mp_skate.sh): the observer's replicant gets
sim=4 and its gait runs 23 -> 25 (wgr entry -> ggl limp cycle).  Field clue that
cracked it: "after respawning a peer DID see the limp" (epilectrik/SAURON).
KB: locomotion.md + gotcha #25.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 22:29:03 -05:00
Joe DiPrimaandClaude Fable 5 98907f45af hotfix: null-damageZone guard on the crit path (field crash, build 641, Conn Man's Owens)
First field session with real crits (#80) found a weapon subsystem with a
NULL damageZone: Mech__DamageZone::CriticalHit -> ApplyDamageAndMeasure ->
MechWeapon::TakeDamage +0xf (the engine base derefs the zone unguarded --
every 1995 subsystem shipped with one).  Stack symbolized from the field log;
the path was unreachable before tonight because ApplyDamageAndMeasure was a
stub until #80.

Guards at both choke points; a one-shot [crit] log NAMES the zoneless
subsystem when hit so the root cause (build that subsystem's zone) can be
fixed from the next field log.  Owens crit bench: no crash, guard inert on
zoned subsystems.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:41:02 -05:00
Joe DiPrimaandClaude Fable 5 1ac425860e particles.png recovered (#79, found by cyd) -- particle texture ships at last
content/VIDEO/particles.png (128x128 RGBA): the texture L4PARTICLES has tried
to load since the engine was written.  Boot-verified: [particles] device
objects created (max=8192, texture=loaded).  Every particle effect now draws
textured -- the authored look; the arcade pods displayed untextured quads
because the file never shipped.  Comments updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 20:53:07 -05:00
Joe DiPrima cfa52a28d8 Merge remote-tracking branch 'origin/glass-cockpit-refit' 2026-07-29 19:56:31 -05:00
Joe DiPrimaandClaude Fable 5 5b19cd6c3f demo/bench harness polish: BT_SELF_DAMAGE_DELAY + two harness bug fixes + the OBS limp demo bat
- BT_SELF_DAMAGE_DELAY=<s>: hold the harness off for n seconds (capture demos:
  healthy walking first).  Fixed its underflow bug (the countdown crossed below
  zero, which is also the "read the env" sentinel -- the delay re-armed forever).
- The one-death latch was fed by EVERY mech's update: a mission that generated
  any destroyed mech entity tripped it at frame one and the harness silently
  never fired (random per mission roll).  Now viewpoint-mech only.
- run\limpdemo.cmd: the OBS capture demo -- 30 s healthy auto-walk, then the
  leg crosses half on the final damage tick: klaxon-klaxon-"reverse disabled"
  + the limp, then unlimited limp footage (waypoint 8000,8000).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 19:47:20 -05:00
Joe DiPrimaandClaude Fable 5 18766a167b locomotion.md: correct the gimp-audio verdict -- the reverse-disabled voice is real and wired
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 19:23:22 -05:00
Joe DiPrimaandClaude Fable 5 1671b7d4b2 the REVERSE DISABLED voice (#78): SimulationState 3/4 IS the trigger -- mirror the gimp level into the engine cell
User + old-timers were right; the earlier "no voice exists" verdict was wrong.
The authored mech audio has state watchers on Entity.SimulationState==3/4
(the binary's one-cell mech+0x40) that start sequence notes 29,16,40 -- two
klaxon hits then Warnings01 zone 8 (key 40-41) = the "reverse disabled"
voice line the testers remember.  The port's cell split (graphicAlarm vs
engine simulationState, gotcha #23) meant the trigger value never arrived.

- mechdmg: mirror gimp 3/4 into SetSimulationState at the leg-half crossing
  (guarded: never stomps disabled/fall/dead states).  Voice verified playing
  end-to-end on the bench: SetupPatch bank2 patch113 note=40 ->
  Warnings01_z7.wav.
- mech.cpp: BT_GIMP_SAFE_BASE_READ on all seven Simulation::ReadUpdateRecord
  sites -- gimp is monotonic per life; a record captured pre-gimp must not
  stomp the cell (the loopback otherwise perpetuates the stale value and
  restarts the warning on every damage event).
- diagnostics (all env-gated): [statefire]/[startreq]/[animind]/[gimp-sim]/
  [simstomp]/[indstomp] + StateIndicator::DebugAudioWatcherCount + raised
  spatial-log caps.  These traced the whole chain and PROVED no SetState
  path stomps the cell.

OPEN (follow-up): a RAW writer (bypasses SetState entirely; invisible to the
indicator-level trap) resets the cell between damage events -- under the
bench's 1 Hz metronome harness it restarted the sequence before the 1.8 s
voice note; sporadic real-play damage is unaffected (one edge -> full
sequence).  Needs a cdb write-watchpoint session; candidates: a recon raw
+0x2c-equivalent write or a struct copy spanning it.

Also decoded en route: the AnimationState triggers on the limp states play
EngineShiftRev01 (the downshift foley) -- working, and NOT the voice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 19:22:01 -05:00
Joe DiPrimaandClaude Fable 5 7838df2924 locomotion.md: the gimp audio verdict (servo loop, no voice line) + rebind watch item
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:41:33 -05:00
Joe DiPrimaandClaude Fable 5 ed844456af audio: gated [statefire] diagnostic on AudioStateTrigger (BT_AUDIO_SPATIAL)
Logs animation-range state-trigger fires (trigState>=5, capped 60).  Used to
prove the #78 limp audio end-to-end: entering the gimp states fires the
authored strained-servo component (statefire trigState=23 old=7 new=23,
ctl Start) -- the 1995 wounded-leg sound, reachable for the first time now
that the limp gait lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:40:19 -05:00
Joe DiPrimaandClaude Fable 5 0fd33531a4 locomotion.md: record the gimped turn-step relocation find
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:45:51 -05:00
Joe DiPrimaandClaude Fable 5 95cf49d1b6 gimped turn-in-place (#78 field find): arm the trn step in the gimp leg driver
Field test: a gimped mech pivoted as a rotating statue.  The binary's
turn dispatcher (FUN_004a9b5c, master perf) runs OUTSIDE the driver
selection so it arms trn for gimped mechs too -- 71f4's case 4 exists to
advance it.  The port relocated that dispatcher into the NORMAL leg
driver's Standing case, which stops running when the gimp driver takes
over.  Mirror the arming (same gates + lockstep body arm) into
AdvanceLegAnimationGimp's Standing case.  No gimp-turn clip exists in the
RES -- a limping mech step-turns with the normal trn clip, authentic.

Field-verified: stepping pivots both directions while gimped, heading
sweeping, clean re-entry into the wgr/ggl limp on throttle-up.  [T2]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:44:13 -05:00
Joe DiPrimaandClaude Fable 5 3db1062eb6 limp demo bat + the glass controls doc fix (SHIFT is throttle, not W)
run\limp.cmd: double-click #78 demo -- novice copy of DEV.EGG (expert crits
can kill the myomers and fake a no-drive bug), 2x60 into a bhk1 leg zone via
BT_SELF_DAMAGE_TICKS, BT_KEY_NOFOCUS for the glass plasma-window focus trap,
and the REAL controls in the header.  build-and-run.md: the "WASD drive" line
predated the glass merge -- the default profile drives with the 1995 throttle
lever (SHIFT/CTRL slew + stick, ALT reverse).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:34:43 -05:00
Joe DiPrimaandClaude Fable 5 36f68718c2 the visible limp (#78): there was never a jump-jet clip set -- the 'Airborne' drivers ARE the gimp gait machines
The port had FUN_004a5bf8/71f4 fully reconstructed as AdvanceBody/Leg-
AnimationAirborne, gated on (MovementMode()==3||4) && jumpCapable@0x580 and
believed dead ("the test mech never jumps").  The binary says otherwise:
mech+0x40 in that gate is the graphicAlarm LEVEL (3=left-leg gimp, 4=right)
and +0x580 is hasGimpClips, set by the conditional loader block that probes
'wgl' and fills clip slots 22-27 (wgl/wgr/ggr/ggl/gsl/gsr) plus the four
measurements at 0x53c-0x548 (wg entry strides = speed caps, gg cycle strides).
Renamed the five jump* members + both drivers accordingly.

New: GimpBodyClipFinished @004a6344 / GimpLegClipFinished @004a7970 -- the
gimp transition machines, branched from the normal finished-callbacks.  Phase-
correct limp entry (left-gimp enters 0x16/wgl only from a RIGHT step, right-
gimp 0x17/wgr from a LEFT step), gg cycles at gimp cadence, gs exits, and the
demand clamp to the gimped side's speed cap (leg cb writes it back into the
mapper -- the binary's authentic slowdown; the T3 x0.5 stand-in in mechmppr is
retired, BT_GIMP_SPEED now defaults 1.0).  The binary's gimp machines have no
reverse entry -- the "reverse disabled" behavior is now binary-proven.

Reviving the dead drivers replayed two port-glue bugs (gotcha #24): the raw
*(controlSource) mapper read (null -> crash at first engagement) and the
missing alarm->member state re-sync (machine pinned in one run state).  Both
fixed; bench harness gained BT_SELF_DAMAGE_TICKS=<n> to hold a zone past
LegHalfStructure without destroying it.

Bench-verified (madcat, novice, zone 16): crossing -> alarm 4 -> wgr entry
from a left step -> 8k+ frames stable in the ggl limp cycle at cadence 14.77
(vs 18.5 walk / 22+ run) with raw demand still 50.  [T2]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 15:49:39 -05:00
Joe DiPrimaandClaude Fable 5 520f6eecd3 myomer damage reaches the wheels, legs gimp at half structure, reverse refuses -- and an ODR trap unmasked (#75/#78)
The speed-demand site (MechControlsMapper::InterpretControls) now applies the
drive scale the mover's feed roster applied in 1995: speedDemand multiplies by
the myomers' live speedEffect (gear ratio, thermal curve, 1 - zone damage, via
the BTMyomersDriveOf bridge) and, while GIMPED, by 0.5 [T3: VGL Lynx's
"roughly 50%", BT_GIMP_SPEED overrides]. The gimp states were already being
raised -- mechdmg sets graphicAlarm 3 (left) / 4 (right) when a LegDamageZone-
flagged zone crosses half structure -- but nothing downstream ever saw them.
While gimped, reverse input is refused ("reverse disabled"), matching the
old-timers' account; the pod's audio cue rides the alarm's watchers.

Bench, one trajectory, arithmetically exact: a deterministic right-leg ramp
(the new BT_SELF_DAMAGE_ZONE harness) crosses 0.5 and the demand goes
44.837 -> 6.726 = 44.837 x 0.5 (gimp) x 0.3 (a crit-chewed myomers from the
same ramp -- the #80 crits composing with #75's scale, unprompted).

The reason "nothing downstream ever saw them" is the real find of the night,
now gotcha #23: AlarmIndicator is typedef'd to DIFFERENT TYPES per header
family -- mech.hpp says ReconAlarm (4 bytes), heat.hpp says GaugeAlarm (0x54)
-- so Mech::graphicAlarm and EVERY member after it sit at different offsets
depending on a TU's include order. mechdmg wrote level 4 and read it back;
mechmppr read 0 from the same object, same expression. No compiler error can
catch it: each TU only ever sees one definition. Until the split is audited,
cross-TU reads of the gimp level go through BTMechGimpLevel (compiled in
mechdmg's TU) -- and the same split-brain explains why the port carries the
binary's ONE movementMode cell as two live members (engine simulationState vs
graphicAlarm level) that never meet.

Also landed en route: the Myomers un-powered self-repair observed healing in
the field logs at exactly 0.011 x the authored Explosive scale per tick --
the 2026-07-29 reversal confirmed live; and Mech message 0x15 "RealMaxSpeed"
raw-decoded (@0x49f604: sets mech+0x7a0 from the message unless the +0x7a4
latch holds -- a console-tunable top speed).

Open on #78, documented in locomotion.md: the Gimp animation clips (authored
keys in the binary's model-record parser; mech2's state enum vs mech3's
reverse-fix disagree about slots 0x12-0x17 -- reconcile before wiring) and
the audio-cue binding.

Diags: BT_SELF_DAMAGE_ZONE, BT_DRIVE_LOG, BT_GIMP_SPEED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:29:36 -05:00
Joe DiPrimaandClaude Fable 5 8a99972f22 aimed fire strikes the part under the crosshair: the per-segment pick (#73)
The port's target pick was a whole-mech bounding-box slab test, and every hit
-- aimed or not -- dispatched zone -1 into the victim's cylinder lottery. The
recovered 1995 model (the division-card scene intersection) struck a SEGMENT
and credited that segment's own damage zone. This is the port's equivalent.

At render-tree build, each segment's draw object and its PrimaryDamageZone --
authored per segment in the skeleton stream, read by JMOVER.cpp:290 -- are
recorded in MechRenderTree::segPick. The pick (BTL4VideoRenderer::
MechSegmentPick) ray-tests the per-segment bounding spheres on the live posed
skeleton, using the draw-cached mLocalToWorld (at most one frame stale, fine
for aiming).

Selection is SPECIFICITY-FIRST: among the spheres the ray pierces, the
smallest radius wins, normalized-distance tie-break. Both obvious rules were
measured failing the same way before this one: the torso mesh's sphere
(r~4.1 on the MadCat, vs shoulders at r~1.0) envelops nearly the whole mech,
so its front face is nearest for any aim AND any near-body ray normalizes to
~0 against it. Limb spheres nest inside the envelope; smallest-pierced picks
the most specific part on the aim line, and the torso wins only when no limb
is threaded -- the per-part semantic the pod's mesh test produced.

mech4.cpp tries the segment pick per candidate; the box PickRayHit survives
only as the fallback (no tree yet, wrecked, spectator), still carrying zone
-1 into the lottery, and a structure occlusion clears the zone. The winner's
zone rides MECH_TARGET_SUBIDX + targetReticle.targetDamageZone into
SendDamageMessage, so aimed hits now dispatch a real zone; the victim's
handler applies it directly (bursts 2+ still re-lottery, authentic per the
recovered @0x4a0230 loop).

Bench, the same L/C/R sweep that exposed the bug: aiming left now lands
36/41 hits on zone 2 = jointlshoulder -- the left arm -- with a 0.30 thread
score, where the same aim was a 6-way lottery spray before. The MadCat's
authored segment->zone map is rich (shoulders 2/9, guns 6/17, hip 1, six leg
zones, torso 0). Known approximations, flagged for field verification:
sphere bounds rather than triangles, and a torso-envelope graze credits the
torso where the pod's exact mesh test would have missed into air.

The field protocol is the one the testers already ran on night 6: stationary
mechs, short range, fire only at one arm -- the paper doll should now damage
THAT arm.

Diag: BT_PICK_LOG ([segpick] map at build, [pickwin] per pick).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 13:04:11 -05:00
Joe DiPrimaandClaude Fable 5 e110b10ac8 the #73 pick dig, part 2: there was never a software pick -- the division card cast the ray
The hunt for the un-decompiled pick writer ends with the reason no scan could
find it: it does not exist. In 1995 the pick was a DPL SCENE INTERSECTION run
by the video board. The evidence converges from five directions:

  - The WinTesla renderer still carries the result slots: dplHitInstance /
    dplHitDCS / dplHitGeoGroup / dplHitGeometry + vehicleReticle, NULL-inited
    in the ctor and never fed by the port.
  - The stubbed 1995-era renderable constructors each took dpl_isect_mode_obj
    ("type of intersections to do on this object") plus an intersection MASK
    -- every scene object was configured for ray queries.
  - Auric, quoted in the KB long before this dig: "the pod's division card
    cast from the view."
  - VGL Lynx's night-6 LOD warning ("the hit test may run against a different
    LOD") reads as firsthand knowledge: the ray tested the DRAWN geometry.
  - Exhaustive byte- and pseudocode-level scans: nothing in the binary writes
    rayIntersection/targetEntity/targetDamageZone. Game code only reads them,
    constructs them (the Mech ctor @0x4a1674 -- identified this dig, along
    with vtable +0x18/+0x1c = Mech::Read/WriteUpdateRecord, nine replication
    groups, reticle not among them), gates them (FUN_004afd10, the look-state
    machine: per-view crosshair positions, the pi rear case, the per-weapon
    rear-fire mask walk), and ships them to the board for drawing
    (FUN_00460a7c packages rayIntersection + elementMask into dpl).

What this means for #73: aimed fire in 1995 had PER-PART precision -- scene
ray, struck triangle on the active LOD, the DCS is the segment, the segment's
dzone is the credited zone. The cylinder lottery was only ever the UNAIMED
path. The port's whole-mech box pick funnels aimed fire through the unaimed
lottery, which is exactly what three testers documented on night 6: aim at
the arm, get the spray.

Fix design recorded in the KB: a per-SEGMENT ray test on the shooter side
(inverse(segmentWorld) * ray vs each segment BGF's local extent box, nearest
wins, its dzone dispatched as the aimed zone), falling back to box+lottery
when no segment resolves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:19:52 -05:00
Joe DiPrimaandClaude Fable 5 bcbc7cff12 the #73 pick dig, part 1: mech+0x36c is an embedded engine Reticle -- the writer is still at large
Chasing the un-decompiled targeting pick (the residual of #73). The hunt for
"who writes mech+0x37c/0x388/0x38c" kept coming back empty for a structural
reason now understood: those three fields are the tail of an embedded ENGINE
Reticle struct at mech+0x36c (RETICLE.h lays out position/state/pickPointingOn/
rayIntersection/targetEntity/targetDamageZone/elementMask, landing exactly on
0x37c/0x388/0x38c). Writers carry &mech->reticle and use small reticle-relative
offsets, invisible to any mech-relative displacement scan.

T0 corroboration, ENTITY3.h:131: "For BattleTech, damage zones are only valid
via reticle based weapons."

Ruled out as the writer: the engine Reticle itself (passive container -- ctor
and resource parse only); HudSimulation @0x4b7830 (holds &owner->reticle in esi
for its whole body but only READS the pick -- range caret, designator
transform -- and slews reticlePosition via the @0x4b7ed4 ease); the 0x482xxx
sites (mission-table target bookkeeping, address-of computations); the gyro
coefficient reads at 0x4b2c24/5c/6e (that class's own +0x370..0x38c table).

Still dark, with the candidates mapped: the un-exported 0x4a1674-0x4a2d48
stretch (reticle-touching at 0x4a16a5 in the Make-time init, 0x4a1f93, and a
state+pickPointing+base trio at 0x4a294a-61 near entry 0x4a2971), the two
unidentified Mech vtable overrides +0x18/@0x4a122c and +0x1c/@0x4a0c2c (both
big switch functions), and a few lone sites. One of these computes the
authentic pick; recovering it answers whether the pod tested the aim ray
against the mech's cylinder (as the damage table's geometry suggests) or a box
like the port's stand-in.

Recorded in combat-damage.md \xc2\xa7Targeting so the next session starts from the map
instead of the empty scans.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:09:01 -05:00
Joe DiPrimaandClaude Fable 5 a5fb96ae96 the crit system, complete: two gap functions recovered, the dead sink revived, and a wrong verdict reversed (#80)
The whole critical-hit pipeline was dark, three layers deep, and one of those
layers had fooled us into a false conclusion about the 1995 binary itself.

LAYER 1 -- the trigger, recovered from the un-exported gap. The Mech MESSAGE
TABLE at 0x50bdf8 ({id, name, handler} rows) names the real
Mech::TakeDamageMessageHandler at 0x4a0230 -- message 0x12 "TakeDamage" --
plus seven sibling handlers (PlayerLink, RealMaxSpeed, BalanceCoolant,
Set/ClearBurningState, EjectPilot, DuckRequest). Inside it, the crit chance
at 0x4a0164: p = clamp(0.7 * damageLevel^2 + 0.01, 0..1), gated on the
player's simLive flag (+0x25c -- novice never crits), rolled PER BURST on the
current zone, skipping a zone already burning. Chance is ~1% on fresh armour,
~18% at half-stripped, ~58% at 90% -- crits arrive exactly as armour fails.

The handler's application loop replaces the engine base's single call, which
ignored burstCount entirely (multi-burst damage under-applied (burst-1)x).
Faithful shape: per burst, crit-roll -> CriticalHit @0049ccc4 (which routes
half the amount through the armour internally and picks ONE critical
subsystem by criticalWeight) else zone->TakeDamage -- then RE-RUN the
cylinder lottery from the impact point for the next burst, stopping early
once the mech is disabled. Multi-burst damage sprays across zones by design.

LAYER 2 -- the sink. MechSubsystem::TakeDamage was an empty btstubs stand-in;
the real body is at 0x4ac0bc (CLASSMAP had that address mislabeled
"HandleMessage"): zone damage, then on level >= 1.0 the Destroyed alarm, the
PrintState gate, the 1.0 pin, and -- for a vital subsystem -- the owner
mech's graphicAlarm to level 9, the same fall/death level the leg path
raises. That is the #28 vital-subsystem kill machinery, now real.

LAYER 3 -- the one that rewrites yesterday. The subsystem ctor DID copy
armour points + per-type scales into the private zone -- through the
ReconDamageZone PROXY, whose fields sit at struct offsets +4/+8, not the
binary's +0x140/+0x144. The floats landed on the engine object's header and
the real damageScale[] stayed zero. The 2026-07-28 experiment that "proved"
subsystem zones cannot be damaged -- and that the Myomers un-powered
self-repair was dead code in the original -- was measuring exactly this port
bug. Both verdicts reversed: the binary ctor (0x4ac7bb) initializes the zone
from the resource keys WeaponDamagePoints (required) + CriticalHitScoreBonus
(required) + Collision/Ballistic/Explosive/Laser/EnergyDamagePoints, none of
which the CSS parsed. Now parsed (with the binary's own error strings), and
the ctor writes the engine's NAMED members -- layout-parity holds, so they
land on +0x140/+0x144 faithfully. The Myomers repair branch is LIVE, in 1995
and here. KB corrected and swept (combat-damage, subsystems WAVE 6,
myomers.cpp, CLASSMAP).

Live-verified twice: [subarmor] prints real parsed scales for every subsystem
at spawn (HeatSink pts=10 scale=0.1x5, Condensers pts=5 scale=0.2x5, ...);
[critroll] landed full-chain crits in both runs (zone -> weighted pick ->
subsystem's own zone driven to 1.0 -> Destroyed); mech death/respawn and the
ammo gates un-regressed; zero crashes/asserts. Honest gaps: burst>1 spraying
is transcribed but not yet exercised live (self-damage fires burst=1), the
damageType==0 COLLISION divert (@0x49ffcc) is documented-not-reconstructed,
and the id-0x16 damage/kill report messages to the players (the authentic
stats plumbing, decoded to field level in the KB) are deferred to the #45
work.

Diags: BT_CRIT_LOG ([subarmor] + [critroll]), the existing BT_DMG_LOG.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:08:33 -05:00
Joe DiPrimaandClaude Fable 5 f7cf9850b1 crit panel: the roll has no caller and the sink is a stub -- filed as #80
Night-6 observation (Conn Man, with screenshot): armor panel showing damage,
Critical damage display showing no crits. Investigated; it is a real port gap
in three layers, stacked on one authentic fact.

The authentic fact: the paper doll shows per-ZONE armor tint and the Critical
view is a per-SUBSYSTEM list. Different data by design -- the panel staying
dark while armor accumulates is correct right up until a subsystem takes
critical damage.

The gap: subsystem critical damage essentially cannot happen.

  1. Mech__DamageZone::CriticalHit @0049ccc4 -- the authored roll, half the
     hit to armour, half to one critical subsystem by criticalWeight -- has
     ZERO callers in the port. A raw byte-scan of the binary finds exactly
     one call site, @0x4a0461, and it sits in the un-exported decomp gap
     (nothing covers 0x4a03xx-0x4a05xx, the same dark region as the
     targeting-pick writer). The trigger conditions are unknown.
  2. MechSubsystem::TakeDamage is an empty bring-up stub (btstubs.cpp:179),
     so even a wired caller would measure a delta of zero through
     ApplyDamageAndMeasure.
  3. The only live crit sources are zone destruction (SendSubsystemDamage)
     and ammo cook-off (DistributeCriticalHit), which pin subsystem damage
     directly -- so the panel can light after a zone is destroyed outright,
     never from accumulating fire.

#28 (the vital-subsystem-crit death path) very likely shares this root and is
cross-linked. Recovery plan on #80: raw-disasm the 0x4a04xx container for the
trigger, dump MechSubsystem vtable 0050e210 slot +0x24 for the real TakeDamage
body, wire both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 09:06:28 -05:00
Joe DiPrimaandClaude Fable 5 a5dd0eabc5 issue #73 investigated: zone selection is a weighted lottery -- per-limb aiming never existed
Night-6 report: three players, controlled conditions, "fired only at the left
arm, damage credited all over the paper doll, no crits". Chased it to the
bottom and the bottom is the authored 1995 model, working as designed.

The zone a hit credits is chosen by dice, in binary-faithful code reading
binary-shipped tables (dmgtable.cpp, stream format byte-verified): impact
point -> height layer (floor(layerCount*y/heightRef)) -> pie slice
(atan2(z,x) around the vertical axis, optionally rotating with live torso
twist) -> DamageZonePercentTable::SelectZone() = RandomUnit() rolled against
cumulative percent thresholds (@0x49de14). Every slice carries an authored
DISTRIBUTION of zones. Aiming at a limb at best biases which slice you
strike; the zone inside it is a weighted roll. Pixel-precise limb damage
does not exist in Tesla 4.10.

Measured live to be sure the code read was real: solo dummy, walked to 8u,
aim pinned left/center/right across the silhouette, trigger held. 24-46
hits per aim point, each spread across 6+ zones (the percent tables), with
the distribution shifting by aim point (the slice selection responding).
Both halves of the model visibly working.

What remains genuinely open, and is now the whole of #73: the shooter's pick
point comes from Mech::PickRayHit, a whole-mech AABB slab test -- a port
stand-in, since the binary's 0x37c/0x388/0x38c writer sits in an un-exported
gap nobody has decompiled. Theta computed from a flat box FACE clusters
toward the slices facing the shooter, so flank slices (presumably arm-heavy)
may be under-reachable from frontal shots compared with the pod, which
plausibly intersected the mech's cylinder -- the damage table is literally
cylindrical. Deciding that needs a per-hit theta probe and a one-shot wheel
dump; if confirmed, the fix is a ray-vs-cylinder pick replacing the box slab.
Filed on #73 with the plan.

KB: combat-damage.md gets the lottery model as a load-bearing triage fact --
"damage landed somewhere I didn't aim" now has a documented base rate, and
crit expectations from aimed fire are probabilistic by design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 08:42:23 -05:00
Joe DiPrimaandClaude Fable 5 dca2586aa8 the Owens crash: a device reset that never waited for the device (#35)
Eight byte-identical field stacks from night 6, all one player, all in an
Owens: ParticleEngine::Destroy +0x11, access=0 target=0x0, from the plain
per-frame render path. Nothing in the stack touches weapons or the Owens.
Conn Man's Surface Pro 9 (Iris Xe, 128 MB shared) is simply the only GPU in
the fleet that ever actually LOSES the D3D9 device -- his two-trigger
missile+laser bursts are what provoke the timeout, not what crashes.

What crashed is our device-loss handling, which was wrong three ways at once,
in two inline copies (the scene Present and the wait-screen Present):

  1. On D3DERR_DEVICELOST it called Reset() IMMEDIATELY. Reset on a
     still-lost device ALWAYS fails, and V() only logs. There was no
     TestCooperativeLevel gate at all.
  2. It then ran ParticleEngine::Initialize against the lost device. The
     creates fail there and NULL their out-params -- proven, not assumed:
     the bench repro faults at target=0x0, not at a dangling address.
  3. The next lost frame called ParticleEngine::Destroy again, which
     Release()d those NULLs blind. Read of vtable at 0x0. Dead.

So: lost frame 1 tears down and leaves NULLs, lost frame 2 crashes. Two
frames, every time, deterministic -- which is exactly why all 8 field stacks
are byte-identical.

Reproduced before fixing. BT_DEVICELOST_TEST=<frame>,crashrepro runs the
field sequence on the bench; on the unfixed build it died at Destroy +0x11,
access=0 target=0x0, and symbolized to the same four frames as the field
logs. Same shape, same offsets-modulo-hook. That run also proved the
out-param-nulling assumption the whole diagnosis rested on.

The fix -- one shared DPLRenderer::BTResetLostDevice() replacing both inline
copies:

  - Destroy() is idempotent and null-safe, and nulls after release.
  - Reset() is gated on TestCooperativeLevel() != D3DERR_DEVICELOST; while
    the driver still says lost, skip the frame and retry.
  - The Reset HRESULT is checked; on failure, log and retry next frame
    instead of driving on.
  - On success, re-create via the new CreateDeviceObjects(), NOT
    Initialize(): Initialize memsets the installed-effects table, so every
    reset that DID succeed silently killed all particle effects for the rest
    of the mission. The quieter sibling bug, fixed by the same split.
  - Initialize checks its HRESULTs and defends MAXPARTICLES<=0; the draw
    paths guard the NULL buffer, and ExecuteParticles keeps draining
    particles while the engine is dormant so they cannot pile up.

Verified: the crashrepro shape now logs SURVIVED and play continues; three
forced full loss/reset cycles each log "[render] device reset OK"; a plain
run is assert-free.

Found while verifying, worth its own line: VIDEO\particles.png has NEVER
existed -- not in the tree, not in BTL4.RES, not anywhere in git history.
The texture load has failed on every machine since the engine was written,
and every billboard particle ever rendered was untextured quads via
SetTexture(0, NULL). RenderParticles deliberately does NOT gate on the
texture -- that would disable all particles everywhere; untextured IS the
shipped look. Filed separately; a real particle sheet is a content task.

The field verification that counts is Conn Man flying his exact crash
loadout on this build: instead of a dead process he should see at worst a
brief hitch and "[render] device reset OK" in his log. #35 stays open until
that happens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 08:21:41 -05:00
262 changed files with 257768 additions and 41380 deletions
+18
View File
@@ -43,3 +43,21 @@ matchlog_*.txt
# Operator control-channel secret -- NEVER commit (plaintext shared credential) # Operator control-channel secret -- NEVER commit (plaintext shared credential)
operator_secret.txt operator_secret.txt
# Player field logs: contain Steam IDs, machine names and usernames -- never commit.
# (bench .sh/.py under scratchpad/night*/ stay committable.)
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
+13 -5
View File
@@ -10,9 +10,12 @@
`BTL4OPT.EXE` binary on top of the working WinTesla engine. `BTL4OPT.EXE` binary on top of the working WinTesla engine.
**Repo of record:** the top-level `CMakeLists.txt` + `README.md` build `btl4.exe`. Layout: **Repo of record:** the top-level `CMakeLists.txt` + `README.md` build `btl4.exe`. Layout:
`engine/ game/ content/ docs/ reference/ tools/ context/`. `engine/ game/ content/ docs/ reference/ tools/ context/`.
**Current front:** `btl4.exe` runs a full single-player loop; the gauge system is complete; the **Current front (2026-08-06):** core gameplay reconstruction is COMPLETE — SP+MP loop, combat/
active work is reconstructing each subsystem's authentic behavior from the binary. Details + damage/scoring, locomotion (gait/gimp/CROUCH), night kit (searchlight), death/respawn, replication
what's-next: `context/project-overview.md`, `context/open-questions.md`, recent git log. all authentic + benched (builds 4.11.774→801). The decomp export was REBUILT 2026-08-06 (#60
closed: coverage 93.5%, dark code 41 KB — cite `@ADDR`, NOT `part_0NN.c:LINE`; the old export is
archived). Active work: the POLISH list. Details + what's-next: `context/project-overview.md`,
`context/open-questions.md`, recent git log.
--- ---
@@ -48,6 +51,7 @@ precise than anything you can infer.
3. **RULE: no stand-ins.** The full logic IS in the pseudocode; a "gap" is an unfilled stub, not a 3. **RULE: no stand-ins.** The full logic IS in the pseudocode; a "gap" is an unfilled stub, not a
hole. Never write placeholder logic — read the decomp. Bring-up scaffolding is marked + temporary. hole. Never write placeholder logic — read the decomp. Bring-up scaffolding is marked + temporary.
4. **Verify honestly.** `static_assert`-lock layouts; run env-gated; read `content\<stem>_YYYYMMDD.log`; cdb on crashes. 4. **Verify honestly.** `static_assert`-lock layouts; run env-gated; read `content\<stem>_YYYYMMDD.log`; cdb on crashes.
**Verify against the FIELD COMPOSITION, not a constructed proxy** — benches + the verified bar: `context/test-harness.md`.
Tag claims with the evidence tier; flag T3/T4. Tag claims with the evidence tier; flag T3/T4.
5. **Persist insights.** A genuinely new finding → add it to the right `context/*.md` (+ the `docs/` 5. **Persist insights.** A genuinely new finding → add it to the right `context/*.md` (+ the `docs/`
ledger for detail), with an evidence tier. Keep the knowledge base current — this is a mandate, ledger for detail), with an evidence tier. Keep the knowledge base current — this is a mandate,
@@ -58,6 +62,7 @@ precise than anything you can infer.
|---|---| |---|---|
| What the project is, engine, platform, goal | `context/project-overview.md` | | What the project is, engine, platform, goal | `context/project-overview.md` |
| Missing BT source, decompilation strategy | `context/source-completeness.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 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` | | 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 | | Build / run / debug / repo layout / env gates | `context/build-and-run.md` + `context/decomp-reference.md` §6 |
@@ -65,6 +70,7 @@ precise than anything you can infer.
| BGF geometry, LODs, CONN/PCONN, ramps | `context/bgf-format.md` | | BGF geometry, LODs, CONN/PCONN, ramps | `context/bgf-format.md` |
| A layout/linkage/databinding BUG | `context/reconstruction-gotchas.md` | | A layout/linkage/databinding BUG | `context/reconstruction-gotchas.md` |
| The reconstruction method / workflow | `context/reconstruction-method.md` | | The reconstruction method / workflow | `context/reconstruction-method.md` |
| **Running a BENCH / verifying a fix** — harness contract, 2-node pattern, what counts as VERIFIED | `context/test-harness.md` |
| Walking, gait, ground model, collision | `context/locomotion.md` | | Walking, gait, ground model, collision | `context/locomotion.md` |
| Subsystems, the factory, heat/weapons/power | `context/subsystems.md` | | Subsystems, the factory, heat/weapons/power | `context/subsystems.md` |
| Damage zones, targeting, firing, death | `context/combat-damage.md` | | Damage zones, targeting, firing, death | `context/combat-damage.md` |
@@ -85,7 +91,7 @@ precise than anything you can infer.
| Term / acronym definitions | `reference/glossary.yaml` | | Term / acronym definitions | `reference/glossary.yaml` |
| The ORIGINAL 1995 player manual (controls, per-mech stats, coolant loops) | `reference/manual/Tesla40_BT_manual.pdf` (+ alignment audit in `context/pod-hardware.md` §Manual) | | The ORIGINAL 1995 player manual (controls, per-mech stats, coolant loops) | `reference/manual/Tesla40_BT_manual.pdf` (+ alignment audit in `context/pod-hardware.md` §Manual) |
| The complete verbatim detail (fallback) | `docs/PROGRESS_LOG.md` (the old 2236-line CLAUDE.md) | | The complete verbatim detail (fallback) | `docs/PROGRESS_LOG.md` (the old 2236-line CLAUDE.md) |
| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md`, `docs/KD_SCOREBOARD_PLAN.md`, `docs/DIST_LAYOUT_PLAN.md`, `docs/MOUSELOOK_PLAN.md` | | 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` |
--- ---
@@ -148,7 +154,9 @@ bt411/
│ └── <topic>.md # one concept each │ └── <topic>.md # one concept each
├── reference/ ├── reference/
│ ├── glossary.yaml # terms/acronyms │ ├── 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 │ └── ghidra_scripts/ # the exporters
├── docs/ # PROGRESS_LOG.md (the full old CLAUDE.md) + detailed running ledgers ├── docs/ # PROGRESS_LOG.md (the full old CLAUDE.md) + detailed running ledgers
├── phases/ # restructuring / investigation logs ├── phases/ # restructuring / investigation logs
+28
View File
@@ -164,3 +164,31 @@ pad X action LookBehind
pad Start action ModeCycle pad Start action ModeCycle
pad RightThumb action DisplayCycle pad RightThumb action DisplayCycle
pad Back action Valve 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
@@ -185,7 +185,7 @@ role=Role::Default
dropzone=one dropzone=one
vehicle=ava1 vehicle=ava1
vehicleValue=1000 vehicleValue=1000
color=Red color=Crimson
[largebitmap] [largebitmap]
bitmap=BitMap::Large::Aeolus bitmap=BitMap::Large::Aeolus
[BitMap::Large::Aeolus] [BitMap::Large::Aeolus]
+64 -246
View File
@@ -1,8 +1,8 @@
[mission] [mission]
adventure=BattleTech adventure=BattleTech
map=arena1 map=cavern
scenario=freeforall scenario=freeforall
time=day time=night
weather=clear weather=clear
temperature=27 temperature=27
length=600 length=600
@@ -156,206 +156,95 @@ x=128
y=32 y=32
width=8 width=8
[pilots] [pilots]
pilot=10.99.0.1:1502 pilot=127.0.0.1:1502
pilot=10.99.0.2:1502 pilot=127.0.0.1:1602
pilot=10.99.0.3:1502 pilot=127.0.0.1:1702
pilot=10.99.0.4:1502 pilot=127.0.0.1:1802
[10.99.0.1:1502] [127.0.0.1:1502]
hostType=0 hostType=0
advancedDamage=1 advancedDamage=1
loadzones=1 loadzones=1
name=ALPHA name=Aeolus
bitmapindex=1 bitmapindex=1
experience=expert experience=novice
badge=VGL badge=VGL
patch=Yellow patch=Yellow
role=Role::Default role=Role::Default
dropzone=one dropzone=one
vehicle=madcat vehicle=bhk1
vehicleValue=1000 vehicleValue=1000
color=White color=White
[10.99.0.2:1502] [127.0.0.1:1602]
hostType=0 hostType=0
advancedDamage=1 advancedDamage=1
loadzones=1 loadzones=1
name=BRAVO name=Boreas
bitmapindex=2 bitmapindex=2
experience=expert experience=novice
badge=VGL badge=VGL
patch=Yellow patch=Yellow
role=Role::Default role=Role::Default
dropzone=one dropzone=one
vehicle=thor vehicle=ava1
vehicleValue=1000 vehicleValue=1000
color=Crimson color=Crimson
[10.99.0.3:1502] [127.0.0.1:1702]
hostType=0 hostType=0
advancedDamage=1 advancedDamage=1
loadzones=1 loadzones=1
name=CHARLIE name=Caicias
bitmapindex=3 bitmapindex=3
experience=expert experience=novice
badge=VGL badge=VGL
patch=Yellow patch=Yellow
role=Role::Default role=Role::Default
dropzone=one dropzone=one
vehicle=vulture vehicle=bhk1
vehicleValue=1000
color=Grey
[127.0.0.1:1802]
hostType=0
advancedDamage=1
loadzones=1
name=Deimos
bitmapindex=4
experience=novice
badge=VGL
patch=Yellow
role=Role::Default
dropzone=one
vehicle=ava1
vehicleValue=1000 vehicleValue=1000
color=Green color=Green
[10.99.0.4:1502]
hostType=0
advancedDamage=1
loadzones=1
name=DELTA
bitmapindex=4
experience=expert
badge=VGL
patch=Yellow
role=Role::Default
dropzone=one
vehicle=avatar
vehicleValue=1000
color=Tan
[largebitmap] [largebitmap]
bitmap=BitMap::Large::ALPHA bitmap=BitMap::Large::Aeolus
bitmap=BitMap::Large::BRAVO [BitMap::Large::Aeolus]
bitmap=BitMap::Large::CHARLIE
bitmap=BitMap::Large::DELTA
[BitMap::Large::ALPHA]
bitmap=00000000000000000000000000000000 bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000 bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000 bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000 bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000 bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000 bitmap=00000000000000000000000000000000
bitmap=000001F803C001FFE07807807E000000 bitmap=000003FE0000000001E0000000000000
bitmap=000001FC03C001FFF07807807F000000 bitmap=000007FF0000000001E0000000000000
bitmap=000001FC03C001FFF87807807F000000 bitmap=00000F8F8000000001E0000000000000
bitmap=000003FC03C001FFFC780780FF000000 bitmap=00000F078000000001E0000000000000
bitmap=000003DE03C001E07C780780F7800000 bitmap=00000F0781FF00FF81E0F0781FC00000
bitmap=000003DE03C001E03C780780F7800000 bitmap=00000F0783FF81FFC1E0F0783FE00000
bitmap=000007DE03C001E03C780781F7800000 bitmap=00000F0787C7C3E3E1E0F0783C700000
bitmap=0000078F03C001E07C780781E3C00000 bitmap=00000F078783C3C1E1E0F07878300000
bitmap=0000078F03C001FFFC7FFF81E3C00000 bitmap=00000F078783C3C1E1E0F07878000000
bitmap=00000F8F03C001FFF87FFF83E3C00000 bitmap=00000FFF8783C3C1E1E0F0787C000000
bitmap=00000F0783C001FFF07FFF83C1E00000 bitmap=00000FFF8783C3C1E1E0F0787F000000
bitmap=00000F0783C001FFC07FFF83C1E00000 bitmap=00000F0787FFC3C1E1E0F0783FC00000
bitmap=00001FFF83C001E000780787FFE00000 bitmap=00000F0787FFC3C1E1E0F0781FE00000
bitmap=00001FFFC3C001E000780787FFF00000 bitmap=00000F07878003C1E1E0F07807F00000
bitmap=00001FFFC3C001E000780787FFF00000 bitmap=00000F07878003C1E1E0F07801F00000
bitmap=00003E03C3C001E00078078F80F00000 bitmap=00000F07878003C1E1E0F07800F00000
bitmap=00003C01E3FFF1E00078078F00780000 bitmap=00000F078780C3C1E1E0F07860F00000
bitmap=00003C01E3FFF1E00078078F00780000 bitmap=00000F0787C1C3E3E1E078F871E00000
bitmap=00007C01E3FFF1E00078079F00780000 bitmap=00000F0783FF81FFC1E07FF83FE00000
bitmap=00007800F3FFF1E00078079E003C0000 bitmap=00000F0781FF00FF81E03F781FC00000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[BitMap::Large::BRAVO]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00003FFC07FFC007E0F001E03F800000
bitmap=00003FFE07FFE007F07803C0FFE00000
bitmap=00003FFF07FFF007F07803C1FFF00000
bitmap=00003FFF87FFF80FF07803C3FFF80000
bitmap=00003C0F8780F80F783C0787E0FC0000
bitmap=00003C078780780F783C0787803C0000
bitmap=00003C078780781F783C078F803E0000
bitmap=00003C0F0780F81E3C1E0F0F001E0000
bitmap=00003FFF07FFF01E3C1E0F0F001E0000
bitmap=00003FFE07FFF03E3C1E0F0F001E0000
bitmap=00003FFF07FFE03C1E0E0E0F001E0000
bitmap=00003FFF87FF803C1E0F1E0F001E0000
bitmap=00003C07C787C07FFE0F1E0F001E0000
bitmap=00003C03C783E07FFF071C0F803E0000
bitmap=00003C03C781E07FFF07BC07C07C0000
bitmap=00003C07C781F0F80F07BC07E0FC0000
bitmap=00003FFFC780F8F00783B803FFF80000
bitmap=00003FFF878078F00783F801FFF00000
bitmap=00003FFF07807DF00781F000FFE00000
bitmap=00003FFE07803FE003C1F0003F800000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[BitMap::Large::CHARLIE]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=007F00F00F00FC01FFF01E000F0FFFF0
bitmap=01FFC0F00F00FE01FFF81E000F0FFFF0
bitmap=03FFE0F00F00FE01FFFC1E000F0FFFF0
bitmap=07FFF0F00F01FE01FFFE1E000F0FFFF0
bitmap=0FC1F0F00F01EF01E03E1E000F0F0000
bitmap=0F00F8F00F01EF01E01E1E000F0F0000
bitmap=1F0060F00F03EF01E01E1E000F0F0000
bitmap=1E0000F00F03C781E03E1E000F0F0000
bitmap=1E0000FFFF03C781FFFC1E000F0FFFE0
bitmap=1E0000FFFF07C781FFFC1E000F0FFFE0
bitmap=1E0000FFFF0783C1FFF81E000F0FFFE0
bitmap=1E0000FFFF0783C1FFE01E000F0FFFE0
bitmap=1E0060F00F0FFFC1E1F01E000F0F0000
bitmap=1F00F8F00F0FFFE1E0F81E000F0F0000
bitmap=0F00F8F00F0FFFE1E0781E000F0F0000
bitmap=0FC3F0F00F1F01E1E07C1E000F0F0000
bitmap=07FFF0F00F1E00F1E03E1FFF8F0FFFF0
bitmap=03FFE0F00F1E00F1E01E1FFF8F0FFFF0
bitmap=01FFC0F00F3E00F1E01F1FFF8F0FFFF0
bitmap=007F00F00F3C0079E00F9FFF8F0FFFF0
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[BitMap::Large::DELTA]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=000007FF807FFF8F003FFFC1F8000000
bitmap=000007FFE07FFF8F003FFFC1FC000000
bitmap=000007FFF07FFF8F003FFFC1FC000000
bitmap=000007FFF07FFF8F003FFFC3FC000000
bitmap=00000780F878000F0000F003DE000000
bitmap=000007807878000F0000F003DE000000
bitmap=000007807C78000F0000F007DE000000
bitmap=000007803C78000F0000F0078F000000
bitmap=000007803C7FFF0F0000F0078F000000
bitmap=000007803C7FFF0F0000F00F8F000000
bitmap=000007803C7FFF0F0000F00F07800000
bitmap=000007803C7FFF0F0000F00F07800000
bitmap=000007803C78000F0000F01FFF800000
bitmap=000007807C78000F0000F01FFFC00000
bitmap=000007807878000F0000F01FFFC00000
bitmap=00000780F878000F0000F03E03C00000
bitmap=000007FFF07FFF8FFFC0F03C01E00000
bitmap=000007FFF07FFF8FFFC0F03C01E00000
bitmap=000007FFE07FFF8FFFC0F07C01E00000
bitmap=000007FF807FFF8FFFC0F07800F00000
bitmap=00000000000000000000000000000000 bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000 bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000 bitmap=00000000000000000000000000000000
@@ -366,87 +255,16 @@ x=128
y=32 y=32
width=8 width=8
[smallbitmap] [smallbitmap]
bitmap=BitMap::Small::ALPHA bitmap=BitMap::Small::Aeolus
bitmap=BitMap::Small::BRAVO [BitMap::Small::Aeolus]
bitmap=BitMap::Small::CHARLIE bitmap=00000000000000000000000000000000
bitmap=BitMap::Small::DELTA bitmap=0000000000000000003E00000C000000
[BitMap::Small::ALPHA] bitmap=006300000C00000000631F0F8CC63C00
bitmap=0000000000000000 bitmap=00633198CCC66200007F3198CCC66000
bitmap=0000000000000000 bitmap=00633F98CCC6780000633018CCC61E00
bitmap=0000000000000000 bitmap=00633018CCC6060000633098CCC64600
bitmap=00038C1F98C70000 bitmap=00631F0F8C7E3C000000000000000000
bitmap=00038C18D8C70000 bitmap=00000000000000000000000000000000
bitmap=0006CC18D8CD8000
bitmap=0006CC18D8CD8000
bitmap=0006CC18DFCD8000
bitmap=0006CC1F98CD8000
bitmap=000FEC1818DFC000
bitmap=000C6C1818D8C000
bitmap=000C6FD818D8C000
bitmap=0000000000000000
bitmap=0000000000000000
bitmap=0000000000000000
bitmap=0000000000000000
x=64
y=16
width=4
[BitMap::Small::BRAVO]
bitmap=0000000000000000
bitmap=0000000000000000
bitmap=0000000000000000
bitmap=000FCFC3B0678000
bitmap=000C6C6398CCC000
bitmap=000C6C66D8D86000
bitmap=000C6C66D8D86000
bitmap=000FCFC6CD986000
bitmap=000C6CC6CD986000
bitmap=000C6C6FED986000
bitmap=000C6C6C670CC000
bitmap=000FCC3C67078000
bitmap=0000000000000000
bitmap=0000000000000000
bitmap=0000000000000000
bitmap=0000000000000000
x=64
y=16
width=4
[BitMap::Small::CHARLIE]
bitmap=0000000000000000
bitmap=0000000000000000
bitmap=0000000000000000
bitmap=00798C71F9837E00
bitmap=00CD8C718D836000
bitmap=01818CD98D836000
bitmap=01818CD98D836000
bitmap=0181FCD9F9837E00
bitmap=01818CD999836000
bitmap=01818DFD8D836000
bitmap=00CD8D8D8D836000
bitmap=00798D8D87FB7E00
bitmap=0000000000000000
bitmap=0000000000000000
bitmap=0000000000000000
bitmap=0000000000000000
x=64
y=16
width=4
[BitMap::Small::DELTA]
bitmap=0000000000000000
bitmap=0000000000000000
bitmap=0000000000000000
bitmap=0003E3F61F9C0000
bitmap=00033306061C0000
bitmap=00031B0606360000
bitmap=00031B0606360000
bitmap=00031BF606360000
bitmap=00031B0606360000
bitmap=00031B06067F0000
bitmap=0003330606630000
bitmap=0003E3F7E6630000
bitmap=0000000000000000
bitmap=0000000000000000
bitmap=0000000000000000
bitmap=0000000000000000
x=64 x=64
y=16 y=16
width=4 width=4
+274
View File
@@ -0,0 +1,274 @@
[mission]
adventure=BattleTech
map=grass
scenario=freeforall
time=night
weather=clear
temperature=27
length=600
[ordinals]
bitmap=Ordinal::BitMap::1
bitmap=Ordinal::BitMap::2
bitmap=Ordinal::BitMap::3
bitmap=Ordinal::BitMap::4
[Ordinal::BitMap::1]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=000038000003C000001FF00000000F00
bitmap=0000F8000003C000003FF80000000F00
bitmap=0001F8000003C00000707C0000000F00
bitmap=0001F8000003C00000603C0000000F00
bitmap=00007801FC0FF00000003C3DF807FF00
bitmap=00007803FE0FF00000003C3FFC0FFF00
bitmap=00007803C703C00000003C3E3C1F0F00
bitmap=000078078303C0000000783C1E1E0F00
bitmap=000078078003C0000000783C1E1E0F00
bitmap=00007807C003C0000000F03C1E1E0F00
bitmap=00007807F003C0000001E03C1E1E0F00
bitmap=00007803FC03C0000003C03C1E1E0F00
bitmap=00007801FE03C0000007803C1E1E0F00
bitmap=000078007F03C000000F003C1E1E0F00
bitmap=000078001F03C000001E003C1E1E0F00
bitmap=000078000F03C000003C003C1E1E0F00
bitmap=000078060F03C0000078003C1E1E0F00
bitmap=000078071E03E0000078003C1E1F1F00
bitmap=00007803FE01F000007FFC3C1E0FFF00
bitmap=00007801FC00F000007FFC3C1E07EF00
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[Ordinal::BitMap::2]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=001FFF0000003C0000003C03C0780000
bitmap=001FFF0000003C0000007C03C0780000
bitmap=00000E0000003C000000FC03C0780000
bitmap=00003C0000003C000001BC03C0780000
bitmap=0000700F3E1FFC000003BC0FF07BF000
bitmap=0001E00F7E3FFC0000073C0FF07FF800
bitmap=0003800FFE7C3C0000063C03C07C7800
bitmap=0007F80FFE783C00000C3C03C0783C00
bitmap=0007FE0F80783C0000183C03C0783C00
bitmap=00001E0F00783C0000383C03C0783C00
bitmap=00000F0F00783C0000703C03C0783C00
bitmap=00000F0F00783C00007FFF03C0783C00
bitmap=00000F0F00783C00007FFF03C0783C00
bitmap=00000F0F00783C0000003C03C0783C00
bitmap=00000F0F00783C0000003C03C0783C00
bitmap=00000F0F00783C0000003C03C0783C00
bitmap=00180F0F00783C0000003C03C0783C00
bitmap=001C1F0F007C7C0000003C03E0783C00
bitmap=000FFE0F003FFC0000003C01F0783C00
bitmap=0007FC0F001FBC0000003C00F0783C00
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[Ordinal::BitMap::3]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=001FFF03C07800000000FC03C0780000
bitmap=001FFF03C07800000003FC03C0780000
bitmap=001E0003C07800000007C003C0780000
bitmap=001E0003C0780000000F0003C0780000
bitmap=001E000FF07BF000000F000FF07BF000
bitmap=001E000FF07FF800001E000FF07FF800
bitmap=001E0003C07C7800001E0003C07C7800
bitmap=001FFC03C0783C00001EFC03C0783C00
bitmap=001FFE03C0783C00001FFE03C0783C00
bitmap=00001F03C0783C00001F1F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00180F03C0783C00001E0F03C0783C00
bitmap=001C1F03E0783C00001F1F03E0783C00
bitmap=000FFE01F0783C00000FFE01F0783C00
bitmap=0007FC00F0783C000007FC00F0783C00
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[Ordinal::BitMap::4]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=001FFF03C07800000007FC03C0780000
bitmap=001FFF03C0780000000FFE03C0780000
bitmap=00000F03C0780000001F1F03C0780000
bitmap=00000F03C0780000001E0F03C0780000
bitmap=00000F0FF07BF000001E0F0FF07BF000
bitmap=00001F0FF07FF800001E0F0FF07FF800
bitmap=00001E03C07C7800001E0F03C07C7800
bitmap=00003E03C0783C00001E0F03C0783C00
bitmap=00003C03C0783C00000F1E03C0783C00
bitmap=00003C03C0783C000007FC03C0783C00
bitmap=00007803C0783C000007FC03C0783C00
bitmap=00007803C0783C00000F1E03C0783C00
bitmap=00007803C0783C00001E0F03C0783C00
bitmap=0000F003C0783C00001E0F03C0783C00
bitmap=0000F003C0783C00001E0F03C0783C00
bitmap=0000F003C0783C00001E0F03C0783C00
bitmap=0000F003C0783C00001E0F03C0783C00
bitmap=0000F003E0783C00000F1E03E0783C00
bitmap=0000F001F0783C00000FFE01F0783C00
bitmap=0000F000F0783C000007FC00F0783C00
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[pilots]
pilot=127.0.0.1:1502
pilot=127.0.0.1:1602
pilot=127.0.0.1:1702
pilot=127.0.0.1:1802
[127.0.0.1:1502]
hostType=0
advancedDamage=1
loadzones=1
name=Aeolus
bitmapindex=1
experience=novice
badge=VGL
patch=Yellow
role=Role::Default
dropzone=one
vehicle=bhk1
vehicleValue=1000
color=White
[127.0.0.1:1602]
hostType=0
advancedDamage=1
loadzones=1
name=Boreas
bitmapindex=2
experience=novice
badge=VGL
patch=Yellow
role=Role::Default
dropzone=one
vehicle=ava1
vehicleValue=1000
color=Crimson
[127.0.0.1:1702]
hostType=0
advancedDamage=1
loadzones=1
name=Caicias
bitmapindex=3
experience=novice
badge=VGL
patch=Yellow
role=Role::Default
dropzone=one
vehicle=bhk1
vehicleValue=1000
color=Grey
[127.0.0.1:1802]
hostType=0
advancedDamage=1
loadzones=1
name=Deimos
bitmapindex=4
experience=novice
badge=VGL
patch=Yellow
role=Role::Default
dropzone=one
vehicle=ava1
vehicleValue=1000
color=Green
[largebitmap]
bitmap=BitMap::Large::Aeolus
[BitMap::Large::Aeolus]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=000003FE0000000001E0000000000000
bitmap=000007FF0000000001E0000000000000
bitmap=00000F8F8000000001E0000000000000
bitmap=00000F078000000001E0000000000000
bitmap=00000F0781FF00FF81E0F0781FC00000
bitmap=00000F0783FF81FFC1E0F0783FE00000
bitmap=00000F0787C7C3E3E1E0F0783C700000
bitmap=00000F078783C3C1E1E0F07878300000
bitmap=00000F078783C3C1E1E0F07878000000
bitmap=00000FFF8783C3C1E1E0F0787C000000
bitmap=00000FFF8783C3C1E1E0F0787F000000
bitmap=00000F0787FFC3C1E1E0F0783FC00000
bitmap=00000F0787FFC3C1E1E0F0781FE00000
bitmap=00000F07878003C1E1E0F07807F00000
bitmap=00000F07878003C1E1E0F07801F00000
bitmap=00000F07878003C1E1E0F07800F00000
bitmap=00000F078780C3C1E1E0F07860F00000
bitmap=00000F0787C1C3E3E1E078F871E00000
bitmap=00000F0783FF81FFC1E07FF83FE00000
bitmap=00000F0781FF00FF81E03F781FC00000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[smallbitmap]
bitmap=BitMap::Small::Aeolus
[BitMap::Small::Aeolus]
bitmap=00000000000000000000000000000000
bitmap=0000000000000000003E00000C000000
bitmap=006300000C00000000631F0F8CC63C00
bitmap=00633198CCC66200007F3198CCC66000
bitmap=00633F98CCC6780000633018CCC61E00
bitmap=00633018CCC6060000633098CCC64600
bitmap=00631F0F8C7E3C000000000000000000
bitmap=00000000000000000000000000000000
x=64
y=16
width=4
[Role::Default]
model=dfltrole
[Role::NoReturn]
model=noretun
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+5 -1
View File
@@ -48,7 +48,11 @@ spec is `docs/ASSET_PIPELINE.md`. Full list: `docs/PROGRESS_LOG.md §5`.
## Collision / damage / subsystems ## Collision / damage / subsystems
- **`.SLD`** collision solids (Block/YCyl/Cone/Ramp/Wedge). Terrain is modeled AS collision volumes; - **`.SLD`** collision solids (Block/YCyl/Cone/Ramp/Wedge). Terrain is modeled AS collision volumes;
see [[locomotion]] ground model + [[combat-damage]]. [T1] see [[locomotion]] ground model + [[combat-damage]]. [T1]
- **`.DZM`** = INI damage-zone→material map (mech skin damage states). **`.DMG`/`.TBL`** damage - **`.DZM`** = INI damage-zone→material map (mech skin damage states) — `[dz_<zone>]` sections each
listing `material=<lib>:<name>_mtl`. One file per SKIN VARIANT (`dzm`/`dzms`/`dzmt`/`dzmo`/`dzma`/
`dzmb`/`dzmc`/`dzmd`). This is the ARMOUR-DARKENING registry: the renderer lerps every listed
material toward 0.1× as that zone's `damageLevel` runs 0→1. Loaded by MUNGA's `DamageZone` stream
ctor into `materialTable` (`GetMaterialList(skl_type)`); full chain in [[rendering]]. [T1] **`.DMG`/`.TBL`** damage
zones/tables, **`.SUB`** subsystems (weapon params — DischargeTime/RechargeRate/WeaponRange), zones/tables, **`.SUB`** subsystems (weapon params — DischargeTime/RechargeRate/WeaponRange),
**`.CTL`** control maps. [T1] **`.CTL`** control maps. [T1]
+57 -1
View File
@@ -79,7 +79,12 @@ run\run.cmd [EGG] # default DEV.EGG; cd's to content\ and runs btl4.exe -eg
in `docs/DIST_LAYOUT_PLAN.md` — PROPOSED, not implemented, awaiting a decision between the in `docs/DIST_LAYOUT_PLAN.md` — PROPOSED, not implemented, awaiting a decision between the
authors.** The cwd guard stays either way (it protects the developer tree, where the split is authors.** The cwd guard stays either way (it protects the developer tree, where the split is
real and permanent). real and permanent).
- Interactive: **WASD** drive; weapon groups (keyboard, task #43) **1/Space** = lasers, **2** = - Interactive: ⚠ under the GLASS profile (`content\bindings.txt`, the default since the cyd
merge) drive is the **1995 throttle-lever scheme**, NOT WASD: **SHIFT** = throttle up (lever
STICKS where you leave it), **CTRL** = throttle down, **ALT** = reverse thrust, **X** =
all-stop, **BACKTICK** = view toggle. ("WASD drive" was the pre-glass dev profile — telling a
glass user W is the confirmed way to watch a mech stand perfectly still, 2026-07-29.)
Weapon groups (keyboard, task #43) **1/Space** = lasers, **2** =
PPCs, **3/Ctrl** = missiles; **X** all-stop; **V** cockpit/chase view. Default egg = `DEV.EGG` PPCs, **3/Ctrl** = missiles; **X** all-stop; **V** cockpit/chase view. Default egg = `DEV.EGG`
(map=grass, time=day). Swap mech via the egg's `vehicle=` — **ALL 18 ModelList names (map=grass, time=day). Swap mech via the egg's `vehicle=` — **ALL 18 ModelList names
CERTIFIED playable (2026-07-18 vehicle sweep)**: the canonical 8 (avatar, bhk1/blkhawk, CERTIFIED playable (2026-07-18 vehicle sweep)**: the canonical 8 (avatar, bhk1/blkhawk,
@@ -87,6 +92,45 @@ run\run.cmd [EGG] # default DEV.EGG; cd's to content\ and runs btl4.exe -eg
mad1/mad2, own1, snd1, thr1, vul1); each boots a solo mission, spawns, animates, no mad1/mad2, own1, snd1, thr1, vul1); each boots a solo mission, spawns, animates, no
crash. The code path is mech-agnostic. [T2] crash. The code path is mech-agnostic. [T2]
## Local benches MUST launch like a player (bench parity, 2026-07-30)
> Parity is one rule of the harness. The full harness contract, the two-node
> pattern, and the VERIFICATION DOCTRINE (field composition over constructed
> proxies) live in [[test-harness]] — read that before writing any bench.
Every shipped launcher (`play_solo.bat`, `join*.bat`, `play_steam.bat`) sets the SAME three
things, and a local bench that omits them is not testing the game the field plays:
set BT_PLATFORM=glass set BT_START_INSIDE=1 set BT_DEV_GAUGES=1
plus a front-end flag (`BT_FE_SOLO` / `BT_FE_JOIN`), `cd content`, and NO `-res`.
**`BT_START_INSIDE=1` is the big one** — without it a bench opens in the EXTERNAL CHASE camera
while every player starts in the COCKPIT. Benches ran chase-only for weeks; the first
first-person look at one was misread as a broken HUD [T2].
**Experience:** every SHIPPED egg is `experience=expert`. A `novice` bench egg silences the whole
heat model, crits and jams (see [[experience-levels]]) — bench combat then is not field combat.
Only the aimed-leg gimp bench may use novice (expert crits the aimed leg and the mech
turns-but-never-moves); it must say so out loud.
**Affinity:** pin each node to TWO logical processors (`0x03/0x0C/0x30/0xC0`), not one. Disjoint
core sets keep single-box packet delivery even (the peer-shakiness fix, [[multiplayer]]); ONE LP
per node starves the gauge executive and fakes a "the comms panel never counts" reading [T2].
`scratchpad/night6/bench_common.sh` owns this contract (`bt_player_env` / `bt_launch` /
`bt_expert_egg` / `bt_novice_egg`, and `bt_assert_player_env` warns if the shipped bat drifts).
The 4-node benches (`mp4_panel.sh` respawn+scoreboard, `mp4_cross.sh` real cross-fire kills,
`mp4_limp.sh` gait replication) all source it. A `-net` node renders NOTHING until
`tools/btconsole.py` starts the mission — a solo launch is the quick visual check.
## Headless frame capture — `BT_SHOT_EVERY` (2026-07-30)
`BT_SHOT_EVERY=<n>` dumps the game's own backbuffer every n frames to
`<BT_SHOT_PREFIX|shot>_NNN.png` (btl4vid.cpp, top of `BTDrawTranslocationSpheres` — the per-frame
alpha-pass hook; it MUST stay above that function's phase-0 early-out). This is how rendering is
diagnosed without foregrounding a window, and it is the ONLY capture method to use: OS
screen-capture grabs whatever is actually on the user's screen (a foreground-lock failure once
photographed the user's browser instead of the game) and is off-limits.
Frames land mid-alpha-pass, so a process killed during a write leaves ONE truncated PNG — skip it.
## Debug (cdb x86) ## Debug (cdb x86)
`"C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe"`. Pattern for a faulting stack (cwd `"C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe"`. Pattern for a faulting stack (cwd
= content\): `-g -c ".lines;sxe av;g;kp 24;q"` with `BT_ASSERT_TO_DEBUGGER=1`. Debug CRT fills fresh = content\): `-g -c ".lines;sxe av;g;kp 24;q"` with `BT_ASSERT_TO_DEBUGGER=1`. Debug CRT fills fresh
@@ -138,6 +182,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 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. 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 ## Key Relationships
- Base: [[wintesla-port]] (the engine build recipe). - Base: [[wintesla-port]] (the engine build recipe).
- Verify loop: [[reconstruction-method]]; env gates: [[decomp-reference]] §6. - Verify loop: [[reconstruction-method]]; env gates: [[decomp-reference]] §6.
+72 -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 - 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 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 Madcat = `MAX_COP`). It is the torso segment's SkeletonType_A mesh. [T2]
EXACTLY ONE segment mesh (the `_cop`) — 25 others hidden. [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 - 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 `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] verts in BLX_COP sample nothing (planar-projection authoring leftover). [T1]
@@ -88,6 +101,36 @@ inverse(eyeWorld)`. No LookAt anywhere.
DPLEyeRenderable::Execute (order `Orient × R`, `D3DXMatrixInverse` on the live path — note the DPLEyeRenderable::Execute (order `Orient × R`, `D3DXMatrixInverse` on the live path — note the
ctor also holds a copy of this code but its view write is DEAD; patch the Execute). ctor also holds a copy of this code but its view write is DEAD; patch the Execute).
## ⚠ The canopy looks "obscured" at wide viewports — it is the ASPECT, not the canopy [T2]
`BTFovYFromHorizontal` (L4VIDEO.cpp:127) pins the HORIZONTAL fov at the authentic 60° and derives
the vertical from the LIVE aspect — correct at the pod's 4:3 (60°×46.8°), but the cockpit-surround
layout hands it a very wide, short world view and the vertical field collapses:
| viewport | aspect | vertical FOV |
|---|---|---|
| pod 800×600 | 1.33 | **46.8°** |
| surround default 900×500 | 1.80 | 35.6° |
| surround on a 1080p screen 900×440 | 2.05 | **31.5°** |
The surround height-clamps the view to fit the work area (`btl4main.cpp` ~1404: 900×500 → 900×440
on 1080p), so the smaller the screen the worse it gets. The canopy is FIXED world geometry, so a
1.5× vertical squeeze makes it eat 1.5× more of the view: measured on bhk1 at 900×440 the frame
covers **39% of the viewport — 9.5% of the upper half but 68.6% of the LOWER half**, i.e. the dash
rides up over the horizon and hides the ground where enemy mechs stand (user-reported as "can't
see to shoot"). At a true 4:3 world view the same build/mech/map renders a correct big-window
cockpit. Left/right skew is 6% (symmetric — nothing is tilted).
**Not a regression:** the shipped 4.11.643 binary reports identical geometry (`view 900x440
canvas 1452x999`), and the gauge-executive change is measurably innocent (canopy coverage 5759%
with it on vs 6061% off — scene variation).
**Verification method:** `BT_COP_PLATES=1` (kit off) gives a 100%-solid viewport vs 39% with the
stencil cut live — that is how to prove the punch kit is running. Canopy coverage = per-pixel diff
of a normal frame against a `BT_HIDE_COCKPIT=1` frame; a raw grey-pixel count also catches sky.
**Open decision (nothing changed yet):** cap the aspect used for fovY (wide windows would show
MORE horizontally instead of cropping vertical — identical to the pod at 4:3), or make the
surround's view taller (clamp width, costing surround width), or bench first-person work in the
dock layout (`BT_DEV_GAUGES_DOCK=1 -res 640 480`).
## Status per mech + the gyro (task #56 — gyro now LIVE) ## Status per mech + the gyro (task #56 — gyro now LIVE)
- **Madcat (MAX_COP): verified good on pure defaults** — dark domed frame (top arch + - **Madcat (MAX_COP): verified good on pure defaults** — dark domed frame (top arch +
@@ -200,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) - Feeds: [[gauges-hud]] (the HUD overlays this view) · [[subsystems]] (gyro, task #56)
- Gotchas: [[reconstruction-gotchas]] §14 (LookAt axis guess), §15 (per-patch edge counting) - 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] ## 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) 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 into `currentElevation` (authored limits/rates) but NOTHING consumed it — the
+482 -31
View File
@@ -69,6 +69,103 @@ player-only logic on `this == application->GetViewpointEntity()` (else a spawned
player input). [T1] player input). [T1]
## Targeting (mech offsets, [[decomp-reference]] §3) ## Targeting (mech offsets, [[decomp-reference]] §3)
**STRUCTURAL DISCOVERY (2026-07-29, the #73 pick dig): the target block is an embedded engine
`Reticle` at `mech+0x36c`** [T0 layout-verified]. `engine/MUNGA/RETICLE.h` lays out exactly:
`reticlePosition@0x36c` (Vector2D), `reticleState@0x374`, `pickPointingOn@0x378`,
**`rayIntersection@0x37c`**, **`targetEntity@0x388`**, **`targetDamageZone@0x38c`**,
`reticleElementMask@0x390`. This is why every "who writes 0x388" scan came back empty — writers
carry `&mech->reticle` (an `lea [mech+0x36c]`) and use small reticle-relative offsets.
`ENTITY3.h:131` states the model in T0 words: *"For BattleTech, damage zones are only valid via
reticle based weapons."* The engine `Reticle` is a **passive container** (ctor + resource parse
only — no pick math); `HudSimulation @0x4b7830` opens with `lea esi,[owner+0x36c]` and READS the
pick (range caret from `rayIntersection`, the designator transform) and slews `reticlePosition`
via `@0x4b7ed4`, but does not write the pick either. **The pick WRITER is BT code, still
unlocated** [T4 candidates]: the un-exported stretch `0x4a1674-0x4a2d48` (touches the reticle at
`0x4a16a5` — the Mech::Make-called init — and `0x4a1f93`, `0x4a294a/54/61` state+pickPointing+base
together near entry `0x4a2971`, which is called from `0x494483`), the two unidentified Mech vtable
overrides `+0x18=0x4a122c` / `+0x1c=0x4a0c2c` (both big switch functions), and lone sites
`0x4877a4` (byte read via the mission entity table `0x5015c8`), `0x4b0097`, `0x45fb14-24`.
**RESOLVED (same dig, deeper): there IS no software pick writer — the 1995 pick was a DPL SCENE
INTERSECTION** [T0 + T1 converging]. Evidence: (a) the WinTesla renderer still carries the result
members `dplHitInstance/dplHitDCS/dplHitGeoGroup/dplHitGeometry` + `vehicleReticle` (L4VIDEO.cpp
ctor, all NULL-init); (b) the stubbed 1995-era renderable constructors each took
**`dpl_isect_mode_obj` ("type of intersections to do on this object") + an intersection MASK** —
per-renderable intersection configuration against the scene; (c) Auric's account ("the pod's
division card cast from the view"); (d) VGL Lynx's LOD warning reads as firsthand knowledge — the
ray tested the DRAWN geometry, i.e. the active LOD's meshes; (e) exhaustive scans: nothing in the
binary or the pseudocode ever writes `rayIntersection/targetEntity/targetDamageZone` — game code
only reads, initializes (the Mech ctor @0x4a1674 constructs the embedded Reticle), gates
(`FUN_004afd10`, the look-state machine, toggles `pickPointingOn` per view with the π rear case),
and SUBMITS the reticle to the board for drawing (`FUN_00460a7c` packages +0x37c/+0x390 into dpl).
**Aimed fire in 1995 had PER-PART precision**: scene ray → struck triangle on the current LOD →
the DCS = the struck SEGMENT → its `dzone` (the SKL segment→zone map, `GetSegmentIndex@49db20`) =
`targetDamageZone`. The cylinder lottery (STEP 6) was only ever the UNAIMED path. The port's
whole-mech box pick + "STEP-6 zone under the boresight" funnels AIMED fire through the unaimed
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
JMOVER.cpp:290) are recorded in `MechRenderTree::segPick`; the pick ray-tests the per-segment
bounding spheres (`mCullCenter/mCullRadius`, world via the draw-cached `mLocalToWorld` — ≤1 frame
stale). **Selection is SPECIFICITY-FIRST: among pierced spheres the SMALLEST radius wins**
(normalized `d²/r²` tie-break). Both simpler rules were measured failing identically: the torso
mesh's sphere (r≈4.1 on the MadCat vs shoulders r≈1.0) envelops the mech, so nearest-entry always
faces you AND normalized distance rewards the giant sphere (a 0.45u-off-axis ray scores 0.012 vs
it). Limbs nest inside the envelope; smallest-pierced picks the most specific part on the aim line
— the per-part semantic the 1995 mesh intersection produced. `mech4.cpp` tries the segment pick
per candidate (box `PickRayHit` + zone 1 → the unaimed lottery survives only as fallback:
no tree / wrecked / structure occlusion), and the winner's zone rides `MECH_TARGET_SUBIDX` +
`targetReticle.targetDamageZone` into `SendDamageMessage` — aimed hits now dispatch a REAL zone.
Bench (L/C/R sweep, dummy at 8u): aim-left = **36/41 hits on zone 2 = `jointlshoulder`** with
thread score 0.30 (was a 6-way lottery spray); envelope grazes log score ≈0.99 and credit the
torso. The MadCat segment→zone map is authored and rich (shoulders 2/9, guns 6/17, hip 1, legs
3/5/8/10/16/19, torso 0). Known approximations for field verification: sphere bounds (not
per-triangle), the torso-envelope graze credits the torso where the pod's mesh test would miss
into air. Diag: `BT_PICK_LOG` (`[segpick]` the map at build, `[pickwin]` zone/score/t per pick).
`mech+0x37c` = target world Point3D; `mech+0x388` = target `Entity*` (the `HasActiveTarget()` gate); `mech+0x37c` = target world Point3D; `mech+0x388` = target `Entity*` (the `HasActiveTarget()` gate);
`mech+0x38c` = targeted sub-zone (1=whole). Weapons cache hasTarget/targetPoint/muzzlePoint, `mech+0x38c` = targeted sub-zone (1=whole). Weapons cache hasTarget/targetPoint/muzzlePoint,
refreshed each frame. [T1] refreshed each frame. [T1]
@@ -181,7 +278,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 fallback are all REMOVED. LMB fires lasers / RMB missiles (with SPACE/CTRL). ⚠ A view-selection
bug was fixed en route: every bug was fixed en route: every
renderable rebuild stomped `mCamera` back to the chase eye (btl4vid `mViewInside` now persists 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].** **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 The `BTGroundRayHit` "terrain" tier only samples the VISUAL heightfield, which on arena1 is a
@@ -271,6 +368,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 reconstruction (`BTPushProjectile` — the 2007 Entity is too small for the binary's raw integrator
offsets). [T2] 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] ## The RAM economy — CLOSED (2026-07-12) [T1 evidence, T3 normalization]
Three-layer story, measured live + decomp-verified: Three-layer story, measured live + decomp-verified:
1. **The armor economy is POINTS**: every zone streams `damageScale[5]` — 5 cells indexed by 1. **The armor economy is POINTS**: every zone streams `damageScale[5]` — 5 cells indexed by
@@ -279,17 +404,26 @@ Three-layer story, measured live + decomp-verified:
`damageLevel += amount × damageScale[type]`, 1.0 = destroyed — engine `DAMAGE.cpp:379` (arcade `damageLevel += amount × damageScale[type]`, 1.0 = destroyed — engine `DAMAGE.cpp:379` (arcade
`@0041e4e0`), called from `mechdmg.cpp:427`; the `[zone-armor]` + per-hit `[dmghit]` dumps, `@0041e4e0`), called from `mechdmg.cpp:427`; the `[zone-armor]` + per-hit `[dmghit]` dumps,
BT_DMG_LOG. Weapons author point-scale amounts (laser 11.77 ≈ 10 torso hits). **`burstCount` is BT_DMG_LOG. Weapons author point-scale amounts (laser 11.77 ≈ 10 torso hits). **`burstCount` is
NOT in this formula — zone damage IGNORES it** [T1]; one Damage message = one `amount×scale` NOT in this formula — `TakeDamage` itself IGNORES it** [T1]. ⚠ But one Damage message is **NOT**
application regardless of burstCount (burstCount is read only by the gyro-bounce math + the one application: `Mech::TakeDamageMessageHandler` calls `TakeDamage` **`burstCount` times**,
SplashDamage falloff). A cluster missile therefore lands its damageAmount ONCE — see the re-rolling the struck zone per burst (@0x4a0423-0x4a04d8; `mech.cpp`, task #80). `burstCount` =
SALVO-LEAD FIX below for why the port's N-round missiles must damage once per salvo. ✓ number of applications, and it is load-bearing (missile cluster count, splash falloff, gyro
2. **StaticBounce prices rams with the AUTHORED moverMass ≈ 1.3e6 units** → ~600×v² ≈ 59,000 bounce). A cluster missile lands its damageAmount once **per connecting missile** — the count is
points for a 10 m/s ram (measured: 59221@9.94, 398@0.81; `[collide-tx]` now logs mass/e). rolled at impact, `Random(n) + n/4` clamped to `n`. See the corrected SALVO note below. ✓
The binary DISPATCHES it raw (@part_012:15324-15358) — but in pod MP it landed on the local 2. **StaticBounce prices rams at ~600×v² raw** (measured: 59221@9.94, 66239@9.59, 398@0.81;
REPLICANT of the victim (the master never heard it; MECH.CPP:986 warns and proceeds) — `[collide-tx]` logs mass/e — mech moverMass is the tonnage-scale 60000-90000, NOT the old
**ram damage was network-inert in the pod**. Our task-#47 replicant forwarding (required "authored ≈1.3e6" mis-attribution; the ~24·v²·0.0005·mass factor is the head-on (vn·vp)
for MP weapon damage) surfaced it as a one-shot. Port: `BTDispatchCollisionDamage` algebra). The binary DISPATCHES it raw (@part_012:15324-15358) — but in pod MP it landed
normalizes ×1e-3 to the armor-point economy (bump = points, charge = tens) [T3]. on the local REPLICANT of the victim (the master never heard it; MECH.CPP:986 warns and
proceeds) — **ram damage was network-inert in the pod**. Our task-#47 replicant forwarding
surfaced it as a one-shot, and a ×1e-3 normalization was patched in (2026-07-12 [T3]).
**That normalization is GONE (pricing audit 2026-07-31): the #83 type-0 divert is the
authentic normalizer** — the victim's TakeDamage hub routes collision damage into
`DistributeCollisionDamage` (scale ≈ 3.9-4.5e-5), so with the 0.001 still applied no
physically possible ram could clear the divert's 0.5-pt free floor (rams were a no-op
against the victim). Raw restored [T1]: a 9.6 m/s ram = 2.55 pts of internal rattle on the
victim (bench-measured, `scratchpad/night7/mp_rampricing.sh`); pressed follow-up contact
prices under the floor = free.
3. **The contact EDGE**: the binary's bounce reversed the mover's velocity so the frame after 3. **The contact EDGE**: the binary's bounce reversed the mover's velocity so the frame after
a bump read as separating; our gait re-derives velocity per frame (still closing while a bump read as separating; our gait re-derives velocity per frame (still closing while
pressed / respawn-overlapped) → sustained contact re-priced full rams at 60Hz (the respawn pressed / respawn-overlapped) → sustained contact re-priced full rams at 60Hz (the respawn
@@ -299,6 +433,34 @@ Band effects: `MechDeathHandler::Tick` fires the CURRENT band descriptor on any
(the binary's changed-flag semantics, workflow-verified) — a damaged mech under fire smokes/ (the binary's changed-flag semantics, workflow-verified) — a damaged mech under fire smokes/
burns per hit; the earlier crossing-only gate was over-tight (the metronome was SHKWAVE). burns per hit; the earlier crossing-only gate was over-tight (the metronome was SHKWAVE).
## Missile THRUST -- FIXED (2026-07-31, issue #84) [T1 values / T2 verified]
The binary Missile hosts a **MissileThruster** subsystem: the round leaves the rack at the
launcher's authored MuzzleVelocity (SRM eject 100 u/s flat; LRM 30 u/s up-tilted) and then
ACCELERATES while BurnTime remains. Authored values (BTL4.RES type-15 missile model records
+0x44/+0x48, reached from the AmmoBin's `ammoModelFile` through the type-1 MODELLIST
indirection): **SRM 2.5s @ 600 u/s² (turn 120°/s) · LRM 10s @ 300 (turn 60°/s, climb 50) ·
Streak 3s @ 300 (turn 360°/s) · NARC 10s @ 300**; SplashRadius 30 for all (the port's constant
was right); authored drag ~0.001 = negligible. The port pool flew at CONSTANT eject speed --
LRMs 10× slower than authored -- which was the whole of the field "missiles are slow" report,
and the "explosion before the missiles arrive" perception (the salvo-lead detonates while the
slow spread rounds straggle). Fixed: `BTMissileThrustOf` (mech4.cpp) lazily caches the RES
thruster table (ModelList ids aliased to their type-15 member); both launch paths (master fire
+ the replicant salvo mirror) pass burn/accel into the pool, whose advance integrates
`speed += accel·dt` while burning. Verified live: Streak impacts at 277 u/s (was 100).
Note: the Black Hawk's "SRM6" fires **strk** (Streak) ammo per its bin -- the ammo model, not
the launcher name, decides the flight profile.
## Destroyed weapons kept FIRING -- FIXED (2026-07-31, issue #86) [T1/T2]
The fire gates (`ProjectileWeaponSimulation` gate 1 @4bbd36, `EmitterSimulation` hard-failure
@4baab9) test `subsystem+0x40` for Destroyed(1). In the binary that offset IS the status alarm's
level cell; the port splits it into `statusAlarm` + a never-written `int simulationState`, so the
gates read a dead cell -- a weapon on a destroyed mount showed its X on the MFD and kept firing
and SCORING (night-7: all three testers, with screenshots of a missile leaving a destroyed pod).
Fixed by reading BOTH cells in both gates; the full pattern is [[reconstruction-gotchas]] §22.
Verified A/B in one run (BT_KILL_SUBSYS bench hook -> ForceCriticalFailure, the same call the
zone crit-cascade makes): before the kill both SRM6 launchers fired 2 salvos each; after, the
destroyed launcher fired ZERO (gate log `destroyed=1`) while its twin kept firing.
## Ballistic damage type -- FIXED (2026-07-23, issue #27) [T2] ## Ballistic damage type -- FIXED (2026-07-23, issue #27) [T2]
Playtest matchlog forensics (2,591 applied-damage events over 2 rounds): the damageType Playtest matchlog forensics (2,591 applied-damage events over 2 rounds): the damageType
histogram had Collision/Explosive/Laser/Energy but **Ballistic (type 1) NEVER appeared**, histogram had Collision/Explosive/Laser/Energy but **Ballistic (type 1) NEVER appeared**,
@@ -359,20 +521,150 @@ death). ALL EIGHT proxy-view sites in mechsub.cpp swept to the engine view
`GetStatusFlags` (read the vptr as a float → always "intact") and `ApplyDamageAndMeasure` (the `GetStatusFlags` (read the vptr as a float → always "intact") and `ApplyDamageAndMeasure` (the
crit cascade's damage-measure read garbage). Gotcha §5 (alias fields), new archetype. crit cascade's damage-measure read garbage). Gotcha §5 (alias fields), new archetype.
## ⚠ A subsystem's PRIVATE zone cannot be damaged via `TakeDamage` (2026-07-28) [T0] ## ⚠ CORRECTED (2026-07-29, #80): subsystem private zones ARE damageable — the port was writing
Each `MechSubsystem` owns a private `DamageZone` at `+0xE0`, built by the **2-arg trivial ctor** ## their scales through the WRONG LAYOUT
`new DamageZone(this, 0)`. That ctor (`engine/MUNGA/DAMAGE.cpp:187-190`) zeroes **all five** The 2026-07-28 verdict here ("a subsystem's private zone cannot be damaged via `TakeDamage` — in
`damageScale[]` entries, `Reset()` never touches them, and the only other writer in the tree is the original too") was **wrong about the original**, and the experiment that "confirmed" it was
`Mech__DamageZone` — the mech's *streamed* zones (`mechdmg.cpp:246-253`), a different class. Since measuring a PORT bug. The truth [T1, raw disasm @0x4ac7bb + live-verified]:
`DamageZone::TakeDamage` is `damageLevel += damageAmount * damageScale[damageType]`, **any** - The binary's `MechSubsystem` resource ctor **initializes the private zone's armour**:
`TakeDamage` against a subsystem's own zone is arithmetically a no-op, whatever the amount or type. `defaultArmorPoints@0x140` ← resource `WeaponDamagePoints` (+0x44, REQUIRED key) and
Verified by experiment: a zone seeded to 0.6 and fed 0.011f for ~1500 ticks never moved. `damageScale[5]@0x144` ← the five per-damage-TYPE keys
The crit path works because it **bypasses this entirely** and writes `damageLevel` directly `Collision/Ballistic/Explosive/Laser/EnergyDamagePoints` (+0x30), normalized
(`DistributeCriticalHit` pins `*(this[0x38]+0x158) = 1.0f`; `ForceCriticalFailure` sets the state). `1/(scale·armorPoints)` exactly like the mech zones (without their extra ×0.5).
Practical consequence: if you are reconstructing something that "damages a subsystem", route it the - The PORT had this copy — but wrote it through the **`ReconDamageZone` proxy**, whose
way the crit path does — a `TakeDamage` call there will silently do nothing. This is also why `structureReference/armour[]` sit at struct offsets **+4/+8**, not the binary's +0x140/+0x144.
Myomers' Performance `@004b8bb9` (an un-powered self-repair) is dead code in the 1995 binary itself: The floats landed on the engine object's header and the real scales stayed at the engine ctor's
see [[subsystems]] WAVE 6. zeros — hence the frozen-damage experiment. Classic databinding trap, now on the engine side.
FIXED: the ctor writes the engine's NAMED members (layout-parity holds — `Mech__DamageZone`
locks its derived fields from 0x160 up).
- The port's CSS also never parsed the seven keys (now parsed; two are required with the binary's
own error strings).
Consequences: `MechSubsystem::TakeDamage @0x4ac0bc` (now real, was a stub) accumulates authored
per-type damage on subsystems; crits land and destroy subsystems; and **the Myomers un-powered
self-repair `@004b8bb9` was LIVE in 1995 and is live in the port now** — the earlier "dead code in
the original too" note in [[subsystems]] WAVE 6 is superseded. `DistributeCriticalHit`'s direct
`damageLevel = 1.0f` pin remains the ammo-explosion path, not the general mechanism.
## ⚠ ZONE SELECTION IS A WEIGHTED LOTTERY — pixel-precise limb damage does not exist (2026-07-29) [T1]
Issue #73 ("fired only at the left arm, damage credited elsewhere, no crits") is **largely the
authored 1995 model, not a bug**. The chain (`dmgtable.cpp`, binary-faithful, byte-verified stream
format): impact point → `WorldToLocal`**height layer** (`floor(layerCount·y/heightRef)`) →
**pie slice** (`atan2(local.z, local.x)`, optionally rotated by live torso twist —
`rotateWithTorso` per layer) → `DamageZonePercentTable::SelectZone()` = **`RandomUnit()` against
cumulative percent thresholds** (`@0x49de14`). Each slice sprays damage across an authored
*distribution* of zones — aiming at a limb at best biases WHICH SLICE you strike; the zone within
it is dice. Measured live (L/C/R aim sweep at 8u, `BT_DMG_LOG`): 24-46 hits per aim point spread
across 6+ zones each, with the distribution *shifting* by aim — the model working as designed.
Consequences for triage: reports of "damage landed somewhere I didn't aim" need this base rate
before being called bugs; crit expectations from aimed fire are likewise probabilistic.
**Residual genuine question [T3]:** the shooter's pick point comes from `Mech::PickRayHit` = a
**whole-mech AABB slab test** (a PORT stand-in — the binary's 0x37c/0x388/0x38c writer is in the
un-exported gap, never decompiled). Theta computed from a flat box FACE clusters toward the
facing slices, so flank slices may be under-reachable from frontal shots vs. the pod (which
plausibly intersected the mech's *cylinder* — the damage table is literally cylindrical).
Deciding whether that distortion is material needs a per-hit theta probe + a wheel dump (slices ×
percent tables, MadCat) — see #73 on the tracker.
## ✅ CRITS RECONSTRUCTED (2026-07-29, #80) — the section below records the GAP as found; all three
## 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 (⚠ 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
handler's application loop (binary @0x4a0423-0x4a04d8, now in `mech.cpp`): per burst — crit roll →
`CriticalHit @0049ccc4` (replaces the zone application; routes half through armour internally,
tally += the subsystem's `CriticalHitScoreBonus`) else `zone->TakeDamage`; then **re-run the
cylinder lottery from the impact point for the next burst** (multi-burst damage sprays), stopping
early once the mech is disabled. The engine base's single-application (which ignored `burstCount`
entirely — multi-burst under-applied (burst-1)×) is superseded. `MechSubsystem::TakeDamage
@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. 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 ONLY registered 0x16
receiver Verify-rejects type 0 → 1995 folded an UNINITIALIZED stack float into the shooter's
score on every non-lethal hit (real 1995 bug — @0x4c0200, the handler that accepts type 0, is
in NO table entry: dead code). Port sends it for wire fidelity, banks award 0.
- **C @0x4a06c0, tally>0 (kills included)** → type 1 DamageReceivedScore to the VICTIM's player.
Basis = INTENDED damage (burstCount×amount). senderMechID = the INFLICTOR. Feeds the received
penalty (`CalcDamageReceivedScore` returns the NEGATIVE) + the operator-console VTVDamaged line
(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]: 1995 pod scoring = **kill awards + received-damage penalties +
death costs. No per-hit inflicted credit** (the port's old per-hit crediting — and the #95 salvo
fix on top of it — were inventions riding the dead @0x4c0200 channel; both retired).
Kill award = `(victimKillBonus + tally) × killerRole.damageInflictedModifier ×
(victimAvgZoneDamage@0x354 × damageBias + 1.0) × (victimTonnage/killerTonnage)`; same-team kill in
a non-FFA game = `-friendlyFirePenalty` basis (inline strcmp of `teamName@0x20c`, gate
`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.
**The handler's OTHER damageType branch — `damageType==4` (Energy) = the PPC cockpit-sync glitch
[T1, 2026-08-06].** Between the collision divert and the burst loop sits a second type test
@`0x4a03f3`: `cmp [esi+0x2c],4 / jne 0x4a0423`. On a match it calls the gauge renderer's vtable
slot 19 with `((float)damageType × 0.2, 0)` = `(0.8f, 0)`, which detunes the **VGA CRTC Horizontal
Total by 9** for 0.8 s — every secondary cockpit display loses horizontal sync, the main VPX view
is untouched. `EnergyDamageType` is authored on **exactly the 14 PPC/ERPPC records and nothing
else**, so this is structurally PPC-exclusive. It fires **once per damage message** (outside the
burst loop). Full chain + addresses: [[gauges-hud]] §"PPC HIT = a deliberate CRTC horizontal-sync
DETUNE"; port spec: `phases/phase-14-ppc-sync-distortion.md`. **Implemented 2026-08-06** (branch `ppc-sync-distortion`).
## (HISTORICAL — the gap as found 2026-07-29, superseded above) [T1]
The authored crit machinery exists and is reconstructed — `Mech__DamageZone::CriticalHit @0049ccc4`
(half the damage to armour, half to ONE critical subsystem chosen by `criticalWeight`, capped by
`damagePercentage`) — but **nothing in the port calls it**. Raw byte-scan of the binary: exactly ONE
call site, `@0x4a0461`, inside the **un-exported gap** (no decomp covers `0x4a03xx-0x4a05xx` — the
same dark region as the `0x37c/0x388/0x38c` pick writer), so the crit TRIGGER CONDITIONS (per-hit
chance? threshold crossing?) are unknown and unreconstructed. Second layer:
`MechSubsystem::TakeDamage` is an **empty bring-up stub** (`btstubs.cpp:179`), so even a wired
caller would measure a delta of 0 through `ApplyDamageAndMeasure` (and the weapon-family overrides
chain to the engine base whose private-zone write is the zero-`damageScale` no-op — see the
subsystem-zone finding above). Net: **the Critical view lights ONLY on zone destruction
(`SendSubsystemDamage`, direct pin) or ammo cook-off (`DistributeCriticalHit`, direct pin), never
from accumulating fire.** The DISPLAY is fine — the paper doll shows ZONE ARMOR, the Critical panel
shows SUBSYSTEM crits (different data by design); the panel's near-permanent emptiness is the gap.
Field signature (night 6, Conn Man): "Armor panel damage. Critical damage display did not show any
crits." **#28 (vital-subsystem-crit death path) is very likely blocked by the same missing caller.**
Recovery: raw-disasm the `@0x4a04xx` container (#60-class gap work) for the trigger; dump
MechSubsystem vtable `0050e210` slot `+0x24` for the real TakeDamage body; wire both. Tracker: #80.
## Damage delivery + the real damage model ## Damage delivery + the real damage model
`Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)``target->Dispatch`. `Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)``target->Dispatch`.
@@ -671,7 +963,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 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. `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 `BTPostKillScore` (btplayer.cpp:1491) feeds the ScoreMessage `damageAmount` into the kill award
(`@0x4c02e4` → `(damageAmount + scoreAward) × roleScalar × teamMult × tonnageRatio`, `@0x4c052c`). (`@0x4c02e4` → `(damageAmount + scoreAward) × roleScalar × teamMult × tonnageRatio`, `@0x4c052c`).
The port passed a flat `kShotDamage=12` (mech4.cpp:1551), so every kill scored identically regardless The port passed a flat `kShotDamage=12` (mech4.cpp:1551), so every kill scored identically regardless
@@ -721,10 +1015,23 @@ cross-pod for a replicant victim on its own.
**⚠ THE SALVO-LEAD FIX (task #62 bug, found 2026-07-13 by live regression) — the N-round trap.** **⚠ THE SALVO-LEAD FIX (task #62 bug, found 2026-07-13 by live regression) — the N-round trap.**
**KEY FACT [T1]: `DamageZone::TakeDamage` (arcade `@0041e4e0` == WinTesla `DAMAGE.cpp:379`) is **KEY FACT [T1]: `DamageZone::TakeDamage` (arcade `@0041e4e0` == WinTesla `DAMAGE.cpp:379`) is
`damageLevel += damageAmount * damageScale[type]` and IGNORES `burstCount`.** So ONE arcade cluster `damageLevel += damageAmount * damageScale[type]` and IGNORES `burstCount`.**
Missile per trigger (damageAmount = authored/missileCount, burstCount = missileCount) applies its hit ⚠ **CORRECTION (2026-08-01, #95): do NOT generalise that into "burstCount is cosmetic for zone
EXACTLY ONCE to a zone — `burstCount` is cosmetic for zone damage (only the gyro-bounce math damage" — the earlier wording here said exactly that and it is WRONG.** `TakeDamage` ignores it;
`gyro.cpp:834` and the SplashDamage falloff `damage.burstCount/dist^exp` read it). The port the **CALLER honours it**. `Mech::TakeDamageMessageHandler` (@0x4a0423-0x4a04d8, reconstructed in
`mech.cpp` under task #80) loops `burstCount` times, calling `TakeDamage` once per burst and
**re-rolling the struck zone each iteration** (@0x4a04b9) — so a burst SPRAYS across zones.
`burstCount` is therefore "number of applications", and it is load-bearing for missiles (cluster
count), splash (distance falloff) and the gyro bounce alike.
So ONE arcade cluster Missile per trigger (damageAmount = authored/missileCount, burstCount =
missileCount) delivers its salvo through that loop, not in a single application. **How many of the
cluster connect is ROLLED at impact** in `Missile::Perform` right before dispatch
(part_013.c:10082): `b = Random(n) + n/4`, clamped to `n` — between a quarter of the salvo and all
of it. **And the arcade Missile dispatches DIRECTLY at the struck entity** (`FUN_004be078`:
`param_2->Dispatch(&msg)`) — it does NOT route through the shooter's `SubsystemMessageManager`,
whose consolidation would drop `burstCount` (`DamageInformation` carries only damageType +
subsystemID). Routing a projectile through the manager therefore silently deletes the cluster
count — that was the #95 bug, and it also produced the #84 double explosion. The port
re-expresses that ONE cluster as N flying `BTProjectile` rounds (visual tracers), and task #62 re-expresses that ONE cluster as N flying `BTProjectile` rounds (visual tracers), and task #62
damaged + splashed on EVERY round → **~`missileCount`× too lethal on BOTH the direct hit and the damaged + splashed on EVERY round → **~`missileCount`× too lethal on BOTH the direct hit and the
splash** (user-reported "missiles kill in 2 shots" — a mech that should take many salvos). Fix splash** (user-reported "missiles kill in 2 shots" — a mech that should take many salvos). Fix
@@ -747,6 +1054,150 @@ its decomp writers (4668/10512) read as a per-frame state toggle, not a clean co
port's `showDamageInflicted` label is itself a guess; port treats authored `SplashRadius>0` as the port's `showDamageInflicted` label is itself a guess; port treats authored `SplashRadius>0` as the
enable. See [[open-questions]]. enable. See [[open-questions]].
## Collision damage — the type-0 DIVERT + rattle distributor (#83, 2026-07-30) [T1 disasm/T2]
`Mech::TakeDamageMessageHandler` diverts `damageType==0` (@0x4a0368) into
`DistributeCollisionDamage` (FUN_0049ffcc, export gap, raw disasm `scratchpad/night6/
gap_49ffcc.txt`): collision damage **never reaches the zone/armor loop**. Gate: the owning
player's advancedDamage copy (+0x268, `BTPlayerAdvancedDamageOn` bridge) — the manual's
"splash/collision damage" technician setting. Pricing: `raw × (2000/moverMass)/(100·(1/3.6))²/
(1elasticity²)` (constants @0x4a0148=1/3.6 tbyte, 2000f, 0.5f threshold; mass@+0x20c,
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
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
same way. Measured on bhk1 (mass 60000, e=0.2): scale 4.5e-5; a 94k spawn-slam = 4.2 rattle
points; taps free. `[colldmg]` (BT_DMG_LOG) + `[crashdmg]` (always-on, rare) are the probes.
## The zone DESTRUCTION CASCADE is live -- an arm takes its gun (#110, 2026-08-02) [T1 decomp / T2 verified]
`Mech__DamageZone::RecurseSegmentTable @0049cad4`: when a zone reaches damageLevel
1.0 (non-leg, non-vital path in TakeDamage), the binary walks the mech's SEGMENT
TREE (mech+0x300, the engine `JointedMover::segmentTable` [T0]):
* `destroySiblingsOnDestruction` (streamed, Wword 0x68) -> every other zone on
the SAME segment (`EntitySegment::damageZoneTable`, seg+0xD0) recurses and is
set graphic state 2 (**Gone**);
* `descendOnDestruction` (Wword 0x67) -> every zone on every CHILD segment
(`childIndexTable` seg+0xE8 -> child's damageZoneTable) recurses -- **the arm
takes the gun pod with it**. Each recursed zone's `SendSubsystemDamage
@0049c9a8` pushes the unused crit allotment and `ForceCriticalFailure`s any
subsystem driven to 1.0 -- which the #86 fire gates then refuse.
Authored (measured, ava1): descend=1 on the four arm zones (2/6/9/17); everything
else 0. Verified live: dz_rarm destroyed -> zone 17 cascades -> AFC100 + bin +
Condenser6 force-failed, `AFC100 -> NoAmmo (gate1): destroyed=1`; dz_larm -> PPC
force-failed, `[emitter] 'PPC' fire REFUSED (destroyed=1)` x4998. Torso weapons
unaffected. Re-entry note: every further hit on a 1.0 zone re-runs the cascade
(no guard in the binary's child loop; only the sibling loop checks graphic state
!= 1) -- authentic, idempotent in effect.
⚠ The walk was a SILENT STUB until 2026-08-02 (three shim types whose iterators
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
STRAIGHT from BTL4.RES bytes with `scratchpad/night9/res29_scan.py` (no port
code involved; grammar = the binary ctors @0x49ea48/@0x49e5e4). Results:
* **18 streams, 8 distinct by content** (copies 3,3,2,2,2,2,2,2). All 7-layer.
* **Slice counts are authored, not fixed 8**: Black Hawk band 6 and the
Vulture/Battlemaster band 4 are **7-wedge** rings; everything else 8.
* rotateWithTorso: layers 3-6 on six tables; **all-zero on Black Hawk + Owens**.
* Chassis -> content hash (live-dump matched, dump-identical rounding):
ava1->1b9958b4, bhk1->5150f823, lok1->b0a0e692, **mad1->c02bfc7a**,
own1->f0f03c87, snd1->0e1a9ca0, thr1->0163740f, vul1->739b1a6b.
The two same-zone-set "Avatar/MadCat" tables ARE distinguishable by loader:
**the Mad Cat rides one (c02bfc7a), the Avatar the other (1b9958b4)**.
* **Wedge orientation, from the AUTHORED slice names** [T1 data]: W0/W7 =
"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.
* Resolver mechanics re-confirmed from the decomp (@0049eb54/@0049e678/
@0049de14): WorldToLocal; local y CLAMPED to [0, heightRef] BEFORE scaling
(heightRef = live collision cylinder, owner+0x2ec->+0xc); layer floor+clamp;
atan2(z,x) +2pi wrap, on-axis -> random angle; rotateWithTorso adds the live
torso heading (torso+0x1d8) BEFORE slicing; uniform roll vs ascending
cumulatives, -1 fallthrough. **Collision damage (type 0) NEVER reaches the
cylinder** -- diverted at the handler head (0x4a0361: test type; 0x4a036d:
call 0x49ffcc; jmp out) [T1 disasm].
## Key Relationships ## Key Relationships
- Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]]. - Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]].
- P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5. - P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5.
+224 -2
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 ## 4. Damage delivery
- `Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)``target->Dispatch(&msg)`. - `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] - `class Damage { damageType(enum Collision/Ballistic/Explosive/Laser/Energy), damageAmount, damageForce, surfaceNormal, impactPoint, burstCount }`. [T1]
- Weapon effect id: **"explode" = 13** (`Explosion::Make`). [T2] - 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] - `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.
--- ---
@@ -246,9 +263,36 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
### Binary message tables decoded 2026-07-20 (glass input audit) [T1] ### 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. 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 - **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 ClearBurningState@0049f700, **0x19 EjectPilot@0049f854**, **0x1a DuckRequest@0049fa00** (the
manual's CROUCH; streamed button 0x13 sends it — unreconstructed). 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]].
- **HeatableSubsystem** @0x50E41C: {3, "ToggleCooling"→@004ad6f8}. **Disassembled 2026-07-20** - **HeatableSubsystem** @0x50E41C: {3, "ToggleCooling"→@004ad6f8}. **Disassembled 2026-07-20**
(`tools/disas2.py 0x4ad6f8`): a per-subsystem coolant on/off TOGGLE (NOT a multi-level "cycle (`tools/disas2.py 0x4ad6f8`): a per-subsystem coolant on/off TOGGLE (NOT a multi-level "cycle
priority" — that's the emergent effect, not the mechanism): priority" — that's the emergent effect, not the mechanism):
@@ -319,6 +363,100 @@ 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 + 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, 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. 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.
- **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. 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]:** - **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); `@004bd394` AmmoBinSimulation (arm on heatAlarm FAILURE / detonate / cancel-on-Empty);
`@004bdb94` HandleMessage(1) = crit-induced arm; the FUSE = `Now().ticks + `@004bdb94` HandleMessage(1) = crit-induced arm; the FUSE = `Now().ticks +
@@ -409,6 +547,13 @@ default-ON (`'0'` disables).
| Var | Effect | | Var | Effect |
|---|---| |---|---|
| `BT_FORCE_THROTTLE` | auto-walk forward (no key) | | `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_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_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 | | `BT_ASSERT_TO_DEBUGGER` | route CRT asserts to the debugger, not a modal box |
@@ -427,10 +572,11 @@ default-ON (`'0'` disables).
| `BT_SECTOR_LOG` | SectorDisplay Make/Execute | | `BT_SECTOR_LOG` | SectorDisplay Make/Execute |
| `BT_NET_TRACE` | `[net-tx]/[net-rx]/[net-upd]` MP tracing | | `BT_NET_TRACE` | `[net-tx]/[net-rx]/[net-upd]` MP tracing |
| `BT_DEV_GAUGES` | render the 6 pod MFD surfaces in a separate dev window | | `BT_DEV_GAUGES` | render the 6 pod MFD surfaces in a separate dev window |
| `BT_GLASS_LAMP_SWEEP` | `0` = restore authentic once-per-gauge-cycle cockpit-lamp updates; default (on) sweeps the lamps EVERY frame in the dev/glass composite path so the lit buttons track the sim even when the throttled background gauge sweep starves under MP load (`L4VB16.cpp` `BTGlassSweepLamps`; the "lighting slow/nonexistent" fix — glass-only, pod serial cadence untouched) |
| `BT_AIM="x y"` | pin the reticle crosshair (reticle coords) — headless aim harness | | `BT_AIM="x y"` | pin the reticle crosshair (reticle coords) — headless aim harness |
| `BT_AIM_LOG` | `[pick]` ray/box/hit diagnostics (Mech::PickRayHit) | | `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_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_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_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]] | | `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]] |
@@ -473,6 +619,14 @@ default-ON (`'0'` disables).
| `BT_SELF_DAMAGE=<dps>` | dispatch an unaimed `TakeDamage` at your OWN mech once a second, through the real `Entity::Dispatch` path, so the whole RESPAWN family is bench-testable solo (nothing else can kill the local pilot: `BT_MP_FORCE_DMG` only targets replicants). **Latches off at first death** so everything after the respawn is the respawn's doing, not the harness still shooting you | | `BT_SELF_DAMAGE=<dps>` | dispatch an unaimed `TakeDamage` at your OWN mech once a second, through the real `Entity::Dispatch` path, so the whole RESPAWN family is bench-testable solo (nothing else can kill the local pilot: `BT_MP_FORCE_DMG` only targets replicants). **Latches off at first death** so everything after the respawn is the respawn's doing, not the harness still shooting you |
| `BT_POWER_DETACH_TEST=<name\|1>` | drop a subsystem's voltage link + force Auto, so the auto-hunt must recover it. `1` = first powered subsystem to tick; a NAME (`PPC_1`, `Myomers`) targets one, which is what proves FAILOVER to a different generator rather than a same-generator re-attach | | `BT_POWER_DETACH_TEST=<name\|1>` | drop a subsystem's voltage link + force Auto, so the auto-hunt must recover it. `1` = first powered subsystem to tick; a NAME (`PPC_1`, `Myomers`) targets one, which is what proves FAILOVER to a different generator rather than a same-generator re-attach |
| `BT_AUDIO_SOURCES=<n>` | request `n` OpenAL mono sources instead of the driver default (~256). **Opt-in on purpose** — the cap doubles as a governor, and with EFX reverb live a higher ceiling means more simultaneous voices mixing during heavy combat. Measure frame time. See [[wintesla-port]] | | `BT_AUDIO_SOURCES=<n>` | request `n` OpenAL mono sources instead of the driver default (~256). **Opt-in on purpose** — the cap doubles as a governor, and with EFX reverb live a higher ceiling means more simultaneous voices mixing during heavy combat. Measure frame time. See [[wintesla-port]] |
| `BT_SELF_DAMAGE_ZONE=<n>` | aim the BT_SELF_DAMAGE harness at an explicit zone (-1/unset = the lottery) -- ramps one zone deterministically past thresholds (#78 bench) |
| `BT_ARMOR_LOG` | mech ARMOUR-DARKENING trace (#87): per-zone `.DZM` list -> draw-op binding counts at tree build, any `unmatched` material name, and every change of a bound zone's level with the resulting brightness |
| `BT_ARMOR_FORCE=<0..1>` | pin EVERY bound armour zone to a fixed damage level, so the same scene can be rendered pristine and fully-damaged and diffed pixel-wise (`scratchpad/night7/armor_ab.sh`) |
| `BT_DRIVE_LOG` | the demand-site drive scale: `[drive] n/drive/mm/dmd/mech` ~1Hz (drive = myomers speedEffect x gimp; mm = the gimp level via BTMechGimpLevel) |
| `BT_GIMP_SPEED=<0..1>` | EXTRA gimp demand multiplier (default 1.0 = authentic since 2026-07-30: the real slowdown is the gimp gait machines' clamp to the wg clip speed — see [[locomotion]] §visible limp; the old 0.5 T3 stand-in is retired) |
| `BT_PICK_LOG` | #73 aimed-pick diagnostics: `[segpick]` the segment→zone map at tree build (index, name, zone, sphere), `[pickwin]` the winning zone/score/t per pick (score ~0 = threading the part core, ~1 = envelope graze) |
| `BT_CRIT_LOG` | #80 crit diagnostics: `[subarmor]` per-subsystem armour/scales/critBonus at ctor (proves the resource keys parsed + the zone got REAL scales), `[critroll]` per landed crit (zone, subsystem, its resulting own-zone level). NB the type-0x1e loader's `[crit]` tag is a different, older log |
| `BT_DEVICELOST_TEST=<frame>[,crashrepro]` | #35 bench hook. `<frame>` forces the D3D9 DEVICELOST branch at that render frame (+600/+1200 = 3 cycles), driving the REAL `BTResetLostDevice` recovery. `,crashrepro` runs the field null-teardown shape (double `ParticleEngine::Destroy`) — pre-fix this reproduced the field crash byte-for-byte (`Destroy +0x11`, `target=0x0`); post-fix it must log `SURVIVED`. See [[wintesla-port]] §Device-loss |
Full render/locomotion gates (BT_RAMP, BT_MATPRI, BT_CULL, BT_SHADOW_*, BT_LODSEL, BT_ADDLOD, Full render/locomotion gates (BT_RAMP, BT_MATPRI, BT_CULL, BT_SHADOW_*, BT_LODSEL, BT_ADDLOD,
BT_PUNCH, …) are catalogued in [[rendering]]. Warp visuals: [[translocation-warp]]. BT_PUNCH, …) are catalogued in [[rendering]]. Warp visuals: [[translocation-warp]].
@@ -491,6 +645,22 @@ recoil @4bc136-4bc19c (damage>3 → (0,0.6,1.5) × damage/16; `gyroRumbleTime
ex-"clipLoadGuard"). Byte tooling: `scratchpad/dis_4b2980.py` / `dis_range.py` over ex-"clipLoadGuard"). Byte tooling: `scratchpad/dis_4b2980.py` / `dis_range.py` over
`reference/decomp/section_dump.txt`. `reference/decomp/section_dump.txt`.
**ARMOUR-DARKENING addresses** [T1] (#87; full story: [[rendering]]): `.DZM` res compiler
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`,
232 bytes) with Perform **`FUN_00457784`** (lerp constant `@0x4579a4` = 1.0; damaged factor
`0x3dcccccd` = 0.1). Material accessors: `dpl_GetMaterialAmbient` `FUN_0048baf4` /
`Set` `FUN_0048ba98`, `Emissive` `FUN_0048bbb0`/`FUN_0048bb54`, `Diffuse` `FUN_0048bc6c`/
`FUN_0048bc10`, `Specular`(4) `FUN_0048bd2c`/`FUN_0048bccc`, `Opacity` `FUN_0048bdf0` (READ ONLY --
lerped but never pushed), flush `FUN_0048d4d4`; `dpl_SetMaterialRamp` `@0x48bfb0` (writes
`material+0x1c`; ONE caller, load-time only). Material lookup by name `FUN_00497678`. The UNUSED
sibling path: `dpl_Damagize` `FUN_004902b0` (tokens `{0x92,0x05,0x04}`), `SV_SPECIAL DAMAGE`
dispatch @0x4599ae -- **zero shipped BGFs carry a DAMAGE token**.
**PUNCH firmware decode** [T1] (the definitive mechanism, 2026-07-11): cmd 0x20 = **PUNCH firmware decode** [T1] (the definitive mechanism, 2026-07-11): cmd 0x20 =
vr_damage_action (VR_PROT.H enum); the i860 firmware content/VREND.MNG (code @0xf0400000) vr_damage_action (VR_PROT.H enum); the i860 firmware content/VREND.MNG (code @0xf0400000)
handler 'damageize' @0xf040f6f8 writes the token triple onto the geogroup's first THREE handler 'damageize' @0xf040f6f8 writes the token triple onto the geogroup's first THREE
@@ -515,6 +685,22 @@ Respawn SIM path: `DropZoneReply` `FUN_004bffd0` → `Mech::Reset` @0x4009fb74;
--- ---
## 7. Tools ## 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 - `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 Ghidra drops; folds known call targets + float constants). THE tool for any `FUN_` the
@@ -526,6 +712,42 @@ Respawn SIM path: `DropZoneReply` `FUN_004bffd0` → `Mech::Reset` @0x4009fb74;
--- ---
## THE ENGINE FRAME-RATE GLOBAL — `DAT_0052140c` = 28.0f (2026-08-02) [T1]
The DOS binary's per-frame<->per-second conversion scalar, set ONCE at startup:
0x401ace: mov [0x52140c], 0x41E00000 ; 28.0f (nominal rate)
0x401ada: mov [0x52140c], 0x4191A6E0 ; 18.2065f (BIOS-tick fallback, PIT/65536)
and passed straight into the ApplicationManager ctor by the DOS main
(0x401189: `push [0x52140c]`). ~90 code sites `fmul`/`fdiv` this global —
whenever the decomp shows an unexplained multiply/divide by `_DAT_0052140c`,
it is per-second <-> per-frame conversion at the pod's 28 Hz.
**The AUDIO/EFFECT FRAME CLOCK is this same rate.** `AudioTime::
Seconds_To_Frames` in the image is `fmul [0x52140c]; fadd 0.5; round` (e.g.
@0x42c611, @0x42dd86 — the 0x42xxxx audio-layer cluster of the ~90 consumers),
so every authored audio duration/delay counts frames at 28. WinTesla's
`DefaultRendererRate` said **30** — all audio timing ran ~7% fast until
corrected (2026-08-02). Oracle's coupled report ("prolonged smoke and
explosion including sound") was the lead: effect visuals and audio share this
clock family.
**So the pod's nominal simulation cadence was 28 fps, not the i860 board's
30 Hz — and it was UNLOCKED.** Corroborated testimony (2026-08-02): Oracle —
the pod rate was "flipbook" territory, heavy fights "a slide show"; Lynx
(original era) — "BT never had a locked frame rate in 1st release testing",
RP targeted 30, Tesla 3.0 managed ~20 on a good day. The engine pacer design
agrees: fixed budget, background fill, NO overrun catch-up (APPMGR.cpp T0) —
target 28, allowed to slip. Consequence: any per-tick dt-LESS accumulator ran
SLOWER during heavy combat on real pods; per-tick behaviors must be calibrated
against the loaded effective rate (veteran bracketing), not the constant. Every "per tick with no dt" accumulation in the binary (the myomer
kinetic heat term; candidate: particle trail density) was calibrated against
28 nominal — and less under load (the 18.2 fallback existing at all says slow
paths were expected; the EFFECTIVE in-pod rate is an open question, see
[[open-questions]]). Our port's frame pacer note in btl4main.cpp claimed 30
as "the pod's authentic rate" — corrected to 28.
## Key Relationships ## Key Relationships
- Feeds: [[subsystems]] (factory + ClassIDs), [[combat-damage]] (damage delivery), [[gauges-hud]] (attribute binding). - Feeds: [[subsystems]] (factory + ClassIDs), [[combat-damage]] (damage delivery), [[gauges-hud]] (attribute binding).
- Verified against: [[reconstruction-method]] (the decomp loop), [[reconstruction-gotchas]] (why raw offsets fail). - Verified against: [[reconstruction-method]] (the decomp loop), [[reconstruction-gotchas]] (why raw offsets fail).
+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] ## 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** @4bbfcc early-returns NO-JAM when 0 (projweap.cpp calls this `LiveFireEnabled`); **ThermalSight**
`ToggleLamp` @4b860c (thermalsight.hpp `ControlsAllowLights`) — ⚠ **CORRECTED 2026-07-25 (#61): `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 this was listed as *Searchlight's* gate. It is not. Raw disassembly of Searchlight's real handler
+158 -1
View File
@@ -11,8 +11,9 @@ 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)" - "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" - "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" - "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)" - "MP DEATHS resolved 2026-07-12 (observed-death tally + display clamp); remaining: verify multi-death tallies stay in sync across a long session (GAUGE_COMPOSITE.md)"
- "PPC `scrambleVideo` IMPLEMENTED 2026-08-06 (branch ppc-sync-distortion): a PPC hit scrambles every secondary display for 0.8 s (modern per-scanline shear stand-in for the CRTC Horizontal-Total detune). Trigger in Mech::TakeDamageMessageHandler (damageType==4), visual in SVGA16::FunkyVideo/ScrambleRowShift (DrawDevSurface + ExpandPlaneToBGRA), non-stacking latch in L4GaugeRenderer::SpecialEffect. Screenshot-verified; live PPC-fire confirmation + by-eye shear tuning (BT_SCRAMBLE_SHEAR/ROLL) left to playtesters. Spec: phases/phase-14-ppc-sync-distortion.md"
--- ---
# Cockpit Gauges / MFD HUD # Cockpit Gauges / MFD HUD
@@ -111,6 +112,21 @@ viewport), and `gWindowAspect` = the view rect's on-screen aspect (`BTWorldAspec
the work area). Green tint tunable via `BT_COCKPIT_TINT=RRGGBB` (default `0x27E8`). Labels are a the work area). Green tint tunable via `BT_COCKPIT_TINT=RRGGBB` (default `0x27E8`). Labels are a
lazy GDI-baked MANAGED atlas (survives device reset). Renders in ALL builds; only the PadRIO lazy GDI-baked MANAGED atlas (survives device reset). Renders in ALL builds; only the PadRIO
click/lamp seam is BT_GLASS-gated. Full detail: `docs/GAUGE_COMPOSITE.md`. click/lamp seam is BT_GLASS-gated. Full detail: `docs/GAUGE_COMPOSITE.md`.
-**Cockpit-lamp LATENCY under MP load (2026-08-04, glass playtest fix) [T2 runtime-verified].**
The lit cockpit buttons are drawn every frame (`BTDrawCockpitPanels`, reading `PadRIO::GetLampState`),
but the lamp STATE sweep that fills that store — `LampManager::Update``AssertNewLampValue`
`SetLamp` — authentically rides `GaugeRenderer::ExecuteForeground`, which fires only **once per full
gauge cycle** (foreground→background→copy). The cycle can't advance to the next foreground turn
until the THROTTLED background gauge sweep drains the ~140-instrument active list, and that
background task starves under load (issue #45 — "instruments freeze while fps stays healthy"). So on
a busy MP mission the lamp sweep ran ~1×/s: **buttons froze / flashes stalled while the 3D view (a
separate per-frame foreground render) stayed smooth** — the "lighting slow or nonexistent" reports
(all testers on glass surround). Fix: `BTGlassSweepLamps()` runs the lamp sweep EVERY frame on the
dev/glass composite path (`L4VB16.cpp`, in `BTDrawGaugeInset`) — cheap (deduped pushes, ~72 lamps),
decoupled from the gauge cycle. The pod never enters this path (real gauge hardware), so its
bandwidth-paced serial lamp cadence is untouched. Kill switch `BT_GLASS_LAMP_SWEEP=0`. NB the RIO
serial/lamp stack itself is byte-identical to pod-proven Red Planet (`L4LAMP.cpp`/`L4SERIAL.cpp`
diffed) — the defect was purely the glass update CADENCE, not the lamp wiring.
## MP gauge-window FREEZE = dangling bindings + permanent SEH disable (Gitea #12, 2026-07-19) [T2 log-convicted] ## MP gauge-window FREEZE = dangling bindings + permanent SEH disable (Gitea #12, 2026-07-19) [T2 log-convicted]
The live-MP "dev-gauges window froze entirely mid-session" incident (issue #12) is NOT a The live-MP "dev-gauges window froze entirely mid-session" incident (issue #12) is NOT a
@@ -682,6 +698,147 @@ BitMap-strip lamp drew `SetColor(0)` + `DrawBitMapOpaque(0,…)` = invisible. Hi
Also corrected: @004c552c is OneOfSeveralStates' **Execute** override (clamp ≥0, chain base), Also corrected: @004c552c is OneOfSeveralStates' **Execute** override (clamp ≥0, chain base),
not `BecameActive` — vtable-diffed. not `BecameActive` — vtable-diffed.
## The gauge EXECUTIVE — why panels froze in MP, and the fix (#45 root, 2026-07-30) [T0 engine / T2 verified]
Instruments repaint via the BACKGROUND task pump (`APPMGR.cpp RunMissions`): after sim+render, the
loop pumps `BackgroundTasks::Execute()` (ONE task per pump, ~7 tasks round-robin: net, events,
audio, GAUGES, …) **only until the frame deadline** — the gauge renderer's turn advances **one
gauge** of the active list (`ProcessOneActiveGauge`, ~140 active), and the 16-bit `GaugeRate`
mask advances once per FULL sweep (so "rate D = every 4th frame" is really every 4th SWEEP).
On a busy MP mission the foreground eats the whole frame budget → 1 pump/frame → a full
instrument rotation took MINUTES at perfectly healthy fps: comms-panel K/D stuck at 0, recharge
tickers frozen — while the underlying tallies (and their replication) were exactly right. This
is the real root of the field "panels don't update in MP" (#45's display half).
**Fix (both env-tunable):** `BT_BG_MIN` (APPMGR.cpp, default 32, 0=authentic) guarantees a
minimum pump floor per frame; `BT_GAUGE_BATCH` (GAUGREND.cpp, default 32, 1=authentic) advances
a batch of gauges per gauge turn (a visit is only a rate-mask check unless due). Verified
4-node: sweeps 0.006/s → **18-20/s**, PilotList ~10 Exec/s, all four panels tracked every death
within ~1s, bg cost 2-4 ms/frame. Diagnostics: `BT_PERF=1` → `[perf] … bgTasks/gaugeTurns/
sweeps/active` 1 Hz (APPMGR.cpp); `[score] panel DRAW` edge log (btl4gau3.cpp, BT_SCORE_LOG).
## The COOLANT-LEAK ALARM audio chain — fully built; the shared-Stop is AUTHENTIC (#99, 2026-08-01) [T1 disasm-verified]
The audible leak warning is **implemented and works end to end**; an earlier note
claiming it was "genuinely unbuilt" was wrong (asserted without checking).
**The chain**, traced live (all probes under `BT_ATTRBIND_LOG`):
`HeatSink::coolantActive` (published as the `ReportLeak` attribute) changes 0->1
-> `AttributeWatcherOf<Logical>::Execute` sees it (`[watchpoll] CHANGE`)
-> `AudioMatchOf<Logical>::SendNotificationOfChange` fires (`[matchfire] val=1 -> ctl 1`)
-> `AudioControlSequence::ReceiveControl(StartAudioControlID)` -> `StartSequence`
-> an `AudioIdleWatcher` ticks it each frame (`IdleAudioControlID` -> `RunSequence`)
-> `[seqsend]` emits the authored pattern: two chirps then an ~8s sustained tone, looped.
**The authored wiring (BTL4.RES): 19 subsystems, ONE shared alarm sequence.**
`Condenser1-6, GeneratorA-D, Myomers, PPC_1/2, ERMLaser_1-3, SRM6_1/2, Avionics`
each bind TWO `AudioLogicalTrigger`s to their own `ReportLeak` — match 1 -> ctl 1
(Start), match 0 -> ctl 2 (Stop) — and every one of them drives the SAME sequence.
**Consequence, and it is AUTHENTIC:** any single subsystem clearing its leak sends
an unconditional Stop, silencing the alarm even while others still leak; the
still-leaking ones never re-assert because their flag has not *changed*. Reported
from the field as a bug ("was sounding, resolved a leak, but there was another").
**Do NOT 'fix' it** — verified in the arcade image, not merely inferred from the
engine source lineage:
| what | where |
|---|---|
| same MUNGA sources compiled in | `d: esla_bt\munga\AUDSEQ.CPP` / `AUDWTHR.CPP` strings present |
| object factory | `@00466184` `case 0x4b` -> alloc **0x5C** -> ctor `@0043ccec` |
| vtable | `0x4ead48` |
| `ReceiveControl` | `@0043d3a4` — switch on control IDs **1 / 2 / 9 / 12** = Start / Stop / Idle / Tempo (exactly our enum) |
| Stop case | `@0043d3ca` -> unconditional `call StopSequence @0043d2d4` |
| `StopSequence` | tests only its OWN `isRunning` (`+0x28`), then the Chase loop — **no refcount, no awareness of other leak sources** |
So the arcade had the identical structure. Silence in 4.11.674 was a different
cause: the OpenAL source pool was exhausted in every player session (see the audio
pooling fix) — an alarm that cannot acquire a source is silent.
⚠ The bench cannot confirm audibility: it runs with no audio device (`live=0
pooled=0`), so the control chain is verified but final playback is not.
## PPC HIT = a deliberate CRTC horizontal-sync DETUNE on every secondary display (2026-08-06) [T1 disasm-verified]
**✅ IMPLEMENTED 2026-08-06** (branch `ppc-sync-distortion`; screenshot-verified —
every secondary MFD + the radar shear together, the out-the-window view stays
clean). Trigger + visual + non-stacking latch — see
`phases/phase-14-ppc-sync-distortion.md` for the port details and the
`BT_SCRAMBLE_*` tuning envs. Reported by playtesters as "being hit by a PPC makes
it look like all of the secondary CRTs were being degaussed" — main (VPX) view
unaffected, PPC strikes only. Both observations are exactly what the binary does.
The disasm chain below is the ground truth the port was built from.
**The gate is the damage TYPE, and only the PPC has it.** A `BTL4.RES`
subsystem census gives `damageType` 4 (`EnergyDamageType`) = **14 records,
every one PPC or ERPPC**; everything else is Ballistic (16), Explosive (30),
Laser (78). So a branch keyed on type 4 is structurally PPC-exclusive.
The chain, on the **VICTIM's** machine (all `@` from `BTL4OPT.EXE`,
md5 `a97075bcb5634d13263e9ad5a2b96fd0`):
1. `Mech::TakeDamageMessageHandler` @`0x4a0230`, branch @**`0x4a03f3`** — sits
between the collision divert and the burst loop, so it runs **once per
damage message**, not per burst:
```
004a03f3 mov ecx,[esi+0x2c] ; damage.damageType
004a03f6 cmp ecx,4 ; EnergyDamageType
004a03f9 jne 0x4a0423 ; everything else -> burst loop
004a03fb mov eax,[0x4efc94] ; the global `application`
004a0400 mov eax,[eax+0x4c] ; -> gauge renderer
004a0405 je 0x4a0423 ; null-guarded
004a0407 fild dword [esi+0x2c] ; (float)damageType == 4.0
004a040a fld xword [0x4a0c08] ; long double 0.2
004a0410 fmulp st(1) ; => 0.8
004a041d call dword [edx+0x4c] ; vtable slot 19, args (0.8f, 0)
```
⚠ The duration is **derived, not constant**: `(float)damageType × 0.2`.
2. Gauge-renderer vtable @`0x51cebc`, slot 19 (`+0x4c`) = @**`0x46ffcc`**
(an `L4GaugeRenderer` method — the 0x46xxxx MUNGA_L4 range, so the
capability is shared-engine; BT is what wires it to Energy damage).
Second arg must be 0 (`sub eax,1; jae ret`). Body: `svga16 = this[+0x1c52c]`
(null-guarded — same member RP fetches for its `FlashPalette`), then
`this[+0x1c534] = 1` (active) and `this[+0x1c538] = now + 0.8 s` in ticks.
3. @`0x46d840` — thin wrapper, drops `this`, forwards the flag.
4. @**`0x47d76d`** — the payload, straight VGA CRTC I/O:
```
out(0x3D4,0x11); v=in(0x3D5); out(0x3D5, v & 0x7F) ; unlock CRTC regs 0-7
out(0x3D4,0x00) ; CRTC 0 = HORIZONTAL TOTAL
if (arg==0) { out(0x3D5, saved); modified=0; } ; restore
else if (!modified) { modified=1; saved=in(0x3D5);
out(0x3D5, saved-9); } ; <<< shorten the scanline
out(0x3D4,0x11); out(0x3D5, v) ; restore write-protect
```
Globals: `modified` @`0x4fe0fe`, `saved` @`0x4fe0ff`. The `modified` latch
makes it **idempotent** — overlapping PPC hits do NOT stack, and a second
hit does not re-save an already-detuned value.
5. @**`0x47003c`** (per frame): `if (active && now >= expiry) { active = 0;
SVGADistortSync(svga16, 0); }` — restores the saved Horizontal Total.
**Why it reads as a degauss, and why only the secondaries.** CRTC register 0 is
the character-clock count per scanline — it *is* the horizontal scan frequency.
9 drives every attached monitor's horizontal oscillator off frequency: the
image shears/rolls/wobbles until it re-locks, then snaps back 0.8 s later. All
six secondary displays are derived by the VDB from that one VGA's timing, so
they glitch **together**; the main view comes off the Division VPX card on an
independent timing chain and is untouched. No relay, no VDB register, no
palette work — the VDB just propagates a deliberately corrupted sync. (The
`LampTesla1/2/3` "solid-state relays" in `L4CTRL.HPP` are NOT involved and are
driven by nothing in the surviving tree.)
⚠ **Do not confuse this with the `SVGA16::FlashPalette` pixel-mask cycler**
(`flashRate`/`mask[4]`, ports `0x302/0x30A/0x312`). That machinery is linked and
its per-frame cycler runs in BT, but `FlashPalette` @`0x46d5f4` has **zero call
sites and zero address-of references** in `BTL4OPT.EXE` — BT never arms it.
**RP does**: `RPL4OPT.EXE` @`0x4addce` calls `FlashPalette(palette 1 =
SecondaryPalette, rate 2.0, masks {FF,BF,7F,3F})` from its gauge-renderer ctor —
hardware-assisted alarm blinking by masking off the top two pixel bits. Same
pods, so it is an easy source of cross-game misattribution.
Scope note: callers of vtable slot 19 were not exhaustively enumerated (virtual
dispatch); the PPC site was found via the three `application+0x4c` uses
(`0x4a03fb` here, `0x4cc3be` / `0x4d1559` unrelated). The low-level path IS
exhaustive — @`0x47d76d` has exactly one caller, and @`0x46d840` exactly two
(set @`0x47002b`, restore @`0x470076`).
## Key Relationships ## Key Relationships
- Full history: `docs/GAUGE_COMPOSITE.md`; reticle recovery: `phases/phase-02-dpl2d-reticle.md`. - Full history: `docs/GAUGE_COMPOSITE.md`; reticle recovery: `phases/phase-02-dpl2d-reticle.md`.
- Uses: [[attribute-pointer]] + [[reconstruction-gotchas]]; reads [[subsystems]] state. - Uses: [[attribute-pointer]] + [[reconstruction-gotchas]]; reads [[subsystems]] state.
+14
View File
@@ -73,6 +73,7 @@ that one answer. Precedence, highest first:
| Env | Layout | Buttons live in | | Env | Layout | Buttons live in |
|---|---|---| |---|---|---|
| `BT_GLASS_PANELS`≠0 | per-display cockpit windows (the "exploded" view, `L4GLASSWIN`) | each display's own window | | `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_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_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 | | `BT_COCKPIT=0` | ...also the docked strip (the documented opt-out) | the single combined pad panel |
@@ -169,6 +170,19 @@ Verified live: two flagged windows came up caption-less while the other five kep
still dispatched on the frameless radar (`CLICK 'Secondary / Radar' addr=0x18`), and a still dispatched on the frameless radar (`CLICK 'Secondary / Radar' addr=0x18`), and a
finished-drag save round-tripped both flags back into the file. finished-drag save round-tripped both flags back into the file.
**The plasma window is in the list too (2026-08-04) [T2 round-trip-verified].** The desktop plasma
display (`L4PLASMAWIN`, title `BattleTech - Plasma`) is created by a DIFFERENT TU than the
per-display panels, so it can't be a `gWins[]` entry. Instead it registers with a small
extern-window registry in `L4GLASSWIN` (`BTGlassLayout_RegisterExtern`), and on creation it reads
its saved rect + `,noframe` via `BTGlassLayout_QueryWindow` (BEFORE sizing — `WS_POPUP`'s frame
extent differs). `SaveLayout` then writes the plasma's line alongside the panels (with a
last-known-rect cache so a teardown before the save still preserves its line), and the plasma
WndProc calls `BTGlassLayout_Save` on `WM_EXITSIZEMOVE`/teardown. So `BattleTech - Plasma=x,y,w,h`
appears in the cfg like any panel and honours `,noframe`. Verified: a drag wrote
`BattleTech - Plasma=321,222,…`; a reload with `,noframe` brought it up `WS_POPUP` (no `WS_CAPTION`)
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.
**Verified 2026-07-26 [T2]:** `BT_RIOBANK_LOG=1` dumps every bank's rects; **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 `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 point no earlier button covers); `scratchpad/clickbank.py` then posts a real click at every
+206 -5
View File
@@ -206,11 +206,72 @@ master mech needs a **CollisionAssistant** (GetCurrentCollisions iterates it unc
ship ZERO tiles (h never negative). [T2] ship ZERO tiles (h never negative). [T2]
## Knockdown / crash + the desync/stutter fixes ## Knockdown / crash + the desync/stutter fixes
Wall impacts iv²>40 (~6.3 u/s) bind the `bmp` stagger clip. Two documented bug fixes: (a) the Wall impacts iv²>40 (~6.3 u/s) bind the `bmp` stagger clip. The knockdown must stagger BOTH
knockdown must stagger BOTH channels (`SetBodyAnimation(0x20)` + `SetLegAnimation(0x20)`) or display channels (`SetBodyAnimation(0x20)` + `SetLegAnimation(0x20)`) or display + travel split permanently
+ travel split permanently (foot-slip); (b) a contact-hysteresis gate (0.4 s `gBlockCooldown`) (foot-slip). `localVelocity` is zeroed while crashed (spec-faithful) so a knocked mech can't keep
stops the knockdown re-firing on sustained/glancing contact. `localVelocity` is zeroed while crashed advancing. **The 0.4 s `gBlockCooldown` contact-hysteresis is GONE (pricing audit 2026-07-31)**: it
(spec-faithful) so a knocked mech can't keep advancing. [T2] held the knockdown off for as long as contact persisted, letting the drive pay full-commanded-speed
crash self-damage EVERY FRAME — a healthy mech dead in ~2-3 s of grinding (the night-7 SAURON ram
death). The binary needs no such gate: the clip zeroes the drive, and a pressed mech re-triggers
the knockdown at v>6.3 long before its grind can price anything (the type-0 divert's 0.5-pt floor
needs v>~18.5 at 65 t) — wall grinding = free taps + ~1.2 s-paced staggers, only fast ARRIVALS pay.
Bench: 150 s full-throttle wall push = 125 paced binds, 4,767 free-tap frames, 0 rattle, 0 deaths
(`scratchpad/night7/mp_rampricing.sh`). The mid-clip rebind guard (`legState != 0x20`) stays. [T2]
**⚠ The WALL-GRIND KNOCKDOWN STORM = the surviving "#82 peers see skating" — ROOT-CAUSED + FIXED
(2026-07-30) [T1 disasm / T2 verified].** Each stagger bind also broadcasts `ForceUpdate(0x20)`
the **type-5 KNOCKDOWN record** — and the replicant handler (`mech.cpp` case 5) plays the bmp clip
AND zeroes `projectedVelocity`. A gimped mech held against a wall re-crashes each time its gg cycle
rebuilds speed (the gg ceiling is authentically the CLIP'S NATURAL SPEED — bhk1 ≈ 14.9 u/s, ratio
divisor + accel clamp @0x544/0x548, loader formula byte-matched — so 14.9² ≈ 222 ≫ the 40.0 crash
threshold @0x4ab184): 26 broadcasts in one bench run, every peer's replicant in knockdown/recovery
churn — gliding + trn leg-lifts = "skating". 4-config A/B (`scratchpad/night6/mp_gimpAB.sh`,
`[animind]` inst=1 verdict): grass straight/circle clean (198 gimp entries; **17 car crunches
during it — crushable icons are the `0.00123f` sentinel path, gyro kick only, NO broadcast**);
arena1 single strike recovers (196); arena1 wall-grind = 0 gimp entries.
**What the port was missing is the crash SELF-DAMAGE *and its own pricing path*** (two halves;
the first alone one-shot mechs on wall taps — corrected same day): the blocking branch (export
gap @4aa89f-4aaab4, `scratchpad/night6/gap_4a9770.txt`) fills `fallDirection`=worldToLocal(
dmg.damageForce) (FUN_0040879c = Mᵀv, un-normalized), `fallScalar`=(fallDirection·localVelocity
.linearMotion), then **Dispatches `TakeDamageMessage{0x64, zone=1, the collision Damage
verbatim}` at its own mech** (was the long-standing "DEFERRED" note; mech4.cpp). CRUCIALLY the
TakeDamage hub then **DIVERTS damageType 0 (@0x4a0368 → FUN_0049ffcc, gap disasm
`gap_49ffcc.txt`) — collision damage NEVER touches zone armor.** It is the manual's
"splash/collision damage" technician setting: gated on the player's advancedDamage copy
(+0x268), priced `raw × (2000/mass)/(100/3.6)²/(1elasticity²)` (a 60-ton mech: ~4.5e-5 — a
wall tap prices ~0.05, and <0.5 is FREE), then split into 0.5-point RATTLE CRITS applied to
random internal subsystems (HeatSink family/Gyroscope/Torso, drawn by
`collisionCriticalHitWeight`@0x10C). See [[combat-damage]]. Verified: wall-grinding no longer
kills or heals the limper (0 deaths, 27 rattle events, mech stays gimped) and the peer's
replicant re-enters the gimp cycle between bounded staggers; grass circling unchanged (198).
**Falsified en route (do not revive):** (a) "the stagger's action-request flags feed a drive
suppressor" — image-wide + full-gap sweep shows `word[this+0x18]` is read ONLY by the net record
emitter; (b) "the bmp clip's ~6.5 u/s root motion presses back into the wall" — measured `adv=0
cycleSpeed=0` during state 0x20; the re-strike velocity is the REBUILT gg cycle; (c) "the gimp cap
is ±5.8 (so √40 sits just above it)" — that number is `gimpStrideLength`@0x350 (the BACK-cycle
figure, printed under a misleading label since renamed `ggCapL/R`); field @0x52c (`gimpSpeedMax`)
has NO binary consumer. Advance caps, loader fills, crunch path and crash branch are all verified
byte-faithful — the self-damage dispatch was the only gap. Replication detail in [[multiplayer]].
**The LAST #82 layer: the peer body-channel TRN-LOCK (root-caused + fixed 2026-07-30) [T2].**
After the storm fix, a CIRCLING limper still skated on peers (user repro): post-knockdown recovery
lands the peer at body-stand; the port's peer turn-step arming block (mech4.cpp `PerformAndWatch`,
needed because the peer no longer runs the leg channel that cross-arms trn) saw
`replMppr->turnDemand` (circling → always turning) and armed body state 4 — and NOTHING could exit:
(a) body case 4's speed exit read the LOCAL mapper's `speedDemand`, a **dead cell on a replicant**
(nothing writes it → 0 forever); (b) turning never stops for a circler. Peer pinned in trn
(advance-only churn = leg-lifty skating) while `bodyTargetSpeed` held the replicated demand
(~24 u/s full). Measured via `[replgimp]` (`bts=24.6 standSpeed=6.83 bodyState=4` in 168/171
samples) and a `_ReturnAddress` trap on `SetAnimationState(4)` (`[trntrap]`, BT_TRNTRAP → linker
map: ALL 60 hits from PerformAndWatch = the port's own arm, NOT the type-3 reader — the master
broadcasts clean gimp states). Fix pair, mirroring the leg twin's authentic precedence
(part_012.c:12013 — the Standing speed test outranks the turn test): (1) mech4 peer arm gated on
`!wantsWalk` (`standSpeed < bodyTargetSpeed || bts < 0`), exit trn when wantsWalk; (2) mech2 body
case 4 `bspd` reads `bodyTargetSpeed` on ReplicantInstance (the replicated demand — the same
number the peer Standing case walks on). Verified: arena circle replicant 218×state-24/0×state-4
(was 116×trn churn), grass circle 235-clean, knockdowns bounded, 0 deaths. **Lesson: on a
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.**
## Controls (`BT_REAL_CONTROLS`, default-on) ## Controls (`BT_REAL_CONTROLS`, default-on)
`MechControlsMapper` (mechmppr.cpp @004afbe0; btl4mppr.cpp mappers) interprets input → `speedDemand` `MechControlsMapper` (mechmppr.cpp @004afbe0; btl4mppr.cpp mappers) interprets input → `speedDemand`
@@ -250,6 +311,146 @@ 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). 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. 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 ## Key Relationships
- Detail: `docs/P3_LOCOMOTION.md`. Uses: [[asset-formats]] (SKL/ANI), [[decomp-reference]] (offsets). - Detail: `docs/P3_LOCOMOTION.md`. Uses: [[asset-formats]] (SKL/ANI), [[decomp-reference]] (offsets).
- Feeds: [[combat-damage]] (collision→damage), [[rendering]] (shadow/visual-conform). - Feeds: [[combat-damage]] (collision→damage), [[rendering]] (shadow/visual-conform).
## Myomer drive + the GIMP (limp) chain — COMPLETE 2026-07-30 (#75/#78)
The speed-demand site (`mechmppr.cpp` InterpretControls) applies the **drive scale**:
`speedDemand *= myomers.speedEffect`. The myomers factor is the wrapper's live 0..1
output (gear/thermal/1damage, via `BTMyomersDriveOf`); the authentic coupling attached
`&speedEffect` into the mover's feed roster, which the 2007 engine lacks — the multiplication at
the demand site is the port equivalent. The GIMP chain: `mechdmg` raises **graphicAlarm 3 (left)
/ 4 (right)** when a leg zone (the `LegDamageZone`-flagged zones; MadCat: 3/5/8 left, 10/16/19
right) crosses `LegHalfStructure` (0.5), and **reverse input is refused** ("reverse disabled",
`[gimp]` log — and the binary agrees: the gimp gait machines have NO standing→reverse entry).
⚠ Cross-TU reads of the gimp level MUST use `BTMechGimpLevel` (mechdmg.cpp) — see
[[reconstruction-gotchas]] §23 (the AlarmIndicator typedef split). Harness:
`BT_SELF_DAMAGE_ZONE=<n>` ramps one zone deterministically; `BT_DRIVE_LOG` prints
`[drive] n/drive/mm/dmd/mech`.
### The VISIBLE limp — the gimp gait machines (reconstructed + bench-verified 2026-07-30) [T2]
Bench (madcat, novice, `BT_SELF_DAMAGE=60 BT_SELF_DAMAGE_ZONE=16 BT_SELF_DAMAGE_TICKS=2`,
`BT_GOTO` walk): right-leg crossing → alarm 4 → body enters 0x17/wgr FROM A LEFT STEP
(phase-correct) → both channels settle in 0x19/ggl and hold it 8k+ frames at the gimp cadence
cap (cycle 14.77 = the wgr entry stride, vs 18.5 walk / 22+ run) while raw demand stays 50 —
the slowdown IS the gait machine, no demand multiply. Two revive-bugs fixed en route: see
[[reconstruction-gotchas]] §24.
The binary's limp was hiding behind a **"jump-jet" misread**: the port had `FUN_004a5bf8` /
`FUN_004a71f4` reconstructed as `AdvanceBody/LegAnimationAirborne`, gated on
`(MovementMode()==3||4) && jumpCapable@0x580` and thought DEAD (mechs never jump). Truth
[T1, raw part_012.c]: **mech+0x40 there is the graphicAlarm level (3=left gimp, 4=right)**
and **+0x580 is `hasGimpClips`** — set by the conditional loader block that probes `wgl` and
loads clip slots 22-27 (`wgl wgr ggr ggl gsl gsr`, +0x624..0x638) plus four measurements:
`gimpLeft/RightSpeedMax`@0x53c/0x540 (the wg entry clips' final keyframe strides) and
`gimpLeft/RightStrideLength`@0x544/0x548 (MeasureClipStride over the gg cycles). The five
`jump*`-named members were renamed accordingly (mech.hpp; there is no jump clip set).
Machinery (all four reconstructed in mech2.cpp, gates in mech4.cpp):
- **Drivers** `AdvanceBodyAnimationGimp`@004a5bf8 / `AdvanceLegAnimationGimp`@004a71f4
selected per-frame when `(gimpLevel 3|4) && hasGimpClips`; clamp the demand (body:
`bodyTargetSpeed`; leg: the LIVE mapper `speedDemand`, written back) to the gimped side's
speed cap and advance the gg cycles at gimp cadence. **This clamp IS the authentic gimp
slowdown** — ground speed is clip travel, so the limp cadence bounds it; the earlier T3
`×0.5` stand-in in mechmppr is retired (`BT_GIMP_SPEED` remains as an override, default 1.0).
- **Finished-callbacks** `GimpBodyClipFinished`@004a6344 / `GimpLegClipFinished`@004a7970
branched to from the tops of the normal `Body/LegClipFinished`. **Phase-correct entry**:
left-gimp enters 0x16/wgl only from a RIGHT step (walk-R cases 5/6/0xe), right-gimp enters
0x17/wgr from a LEFT step (7/0xf) — the machine forces one extra normal step if mid-wrong-foot.
Cycles: left = 0x18 (ggr clip, stride @0x544), right = 0x19 (ggl, @0x548); exits through the gs
transitions 0x1a/0x1b when demand dies. No reverse entry exists while gimped.
**Turn-in-place while gimped** (field find, `95cf49d`): the binary's trn dispatcher
(`FUN_004a9b5c`, master perf) runs OUTSIDE the driver selection, so it arms the turn step for
gimped mechs too — the port relocated it into the NORMAL leg driver's Standing case, which
stops running when the gimp driver takes over ("rotating statue"). The arming is now mirrored
in `AdvanceLegAnimationGimp`. No gimp-turn clip exists — a limping mech step-turns with the
normal `trn` clip [T1: the RES limp set is only wg/gg/gs].
**Gimp audio — the "REVERSE DISABLED" voice IS REAL and now plays** (`1671b7d`; the earlier
"no voice exists" verdict here was WRONG — user/old-timer pushback found it). The authored mech
audio has **state watchers on `Entity.SimulationState == 3/4`** (the binary's one-cell mech+0x40
= the gimp level) that start a sequence playing notes 29, 16, **40** — two klaxon hits then
Warnings01 zone 8 (`Warnings01_z7.wav`, key 40-41) = the spoken "reverse disabled". It never
fired in the port because of the SAME cell split that hid the limp (graphicAlarm vs engine
simulationState, gotcha #23) — the audio watches the half the gimp system never wrote. Fixed:
mechdmg mirrors gimp 3/4 into `SetSimulationState` at the leg-half crossing (guarded vs
disabled/fall/dead), and `BT_GIMP_SAFE_BASE_READ` stops pre-gimp replication records from
stomping the cell (gimp is monotonic per life). Verified: `SetupPatch bank2 patch113 note=40`.
Layered under it: the `Entity.AnimationState` triggers on limp entries 22/23 + stops 26/27
(+ walk-stands 8/9) play **EngineShiftRev01** (downshift foley), velocity-gained — a separate
working layer (earlier mis-ID'd here as "the" limp audio). The earlier "watcher rebind"
hazard was a bench artifact (the harness gimped the mech before the audio object loaded —
impossible in play). ⚠ Open: an unidentified RAW writer (bypasses SetState; indicator-level
trap never fired while the value changed) resets the sim cell between damage events — under a
1 Hz bench harness it restarted the warning before the 1.8 s voice note; sporadic real-play
damage is unaffected. Needs a cdb write-watchpoint dig (candidates: a recon raw +0x2c-family
write or a struct copy spanning it).
### The gimp cell is REPLICATED STATE — peers limped for free in 1995 (#82, fixed 2026-07-29)
Field: "peers see a limping mech **skating**" (first-person limp + voice correct; bystanders saw a
normal walk sliding at limp speed; a stop/start did not clear it). Root cause chain, all [T1/T2]:
- The binary's one `mech+0x40` **is** `Simulation::simulationState`, which
`Simulation::Write/ReadUpdateRecord` puts in **every update record header** — so a peer's
replicant learned the gimp level on every packet and its self-simulated gait limped with no
extra plumbing. There is no gimp-specific replication anywhere in the binary because none is
needed.
- A replicant NEVER re-derives the level: zone damage reaches it as a DamageZone update record
(`Entity::ReadDamageUpdateRecord``DamageZone::ReadUpdateRecord` writes `damageLevel` straight
from the wire), which never runs `Mech__DamageZone::TakeDamage` — where the leg-threshold
evaluation and the `graphicAlarm` 3/4 write live. ⚠ And zone levels replicate only when the
**explosion table's** damage tier is crossed (`EXPTBL.cpp:512-530`
`CrossedDamageLevelThreshold`) — coarse and lagging (measured: peer at 0.428 while the master
was at 0.857). **Never infer damage state on a peer from replicated zone levels.**
- The port's own `Mech::PerformAndWatch` wrote `SetMovementMode(1)` **every frame** ("ground,
non-death, non-airborne"), which erased the gimp level from that cell once it was mirrored in
for the voice — so the wire carried 1 and peers walked. Found with the scoped `[simstomp]` trap
(`g_btGimpWatchMech` + module-relative return address → `symcrash.py`).
Fix: that per-frame write now writes the authoritative level (`gimped ? 3/4 : 1`) via the
alarm-only bridge `BTMechGimpAlarmLevel` (never the cell it feeds, so a respawn-cleared alarm
can't re-latch a stale gimp). Verified two-node (`scratchpad/night6/mp_skate.sh`): the observer's
replicant receives `sim=4` and its gait runs `23 → 25` (wgr entry → ggl limp cycle). This also
made the voice fire exactly once per onset instead of restarting on every damage event, and
re-enabled the authentic leg-destroyed→fall gate (`mechdmg` MovementMode 3||4 [T1 task #60]),
which had been dead while the cell was pinned at 1.
Open: the load-time per-clip callbacks `PTR_LAB_0050d738/744` on the wg clips (uncarved LABs,
inert `Recon` placeholders in the port — likely footstep/cycle events, transitions work without
them).
+32 -4
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, 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 master emission threshold, emission gated on RunningMission, the console-must-LAUNCH fact, replicant
validity). [T2] 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 - **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 INLINE (`char[N]` at the binary offsets), not a `const char*` pointer (garbage cross-pod). Check
EVERY MakeMessage for pointer payloads. [T2] EVERY MakeMessage for pointer payloads. [T2]
@@ -282,6 +289,18 @@ engine/port member. Wire sizes verified live (0x14/0x20/0x2c/0x78). Key mechan
NO replicant VehicleDead) — the death SINK/burial gap (old item 3) is CLOSED. Respawn: NO replicant VehicleDead) — the death SINK/burial gap (old item 3) is CLOSED. Respawn:
`Force(0x1f)` burst snaps the replicant to the drop zone + un-wrecks it. Walking replicant `Force(0x1f)` burst snaps the replicant to the drop zone + un-wrecks it. Walking replicant
un-regressed (run states 10→12, legCycle tracking, with type-3 records flowing). Solo clean. un-regressed (run states 10→12, legCycle tracking, with type-3 records flowing). Solo clean.
- **The replicant UN-WRECK trigger is the DEATH-STATE EXIT edge, not a zone edge (#94 fix,
2026-07-31) [T2]**: `MechDeathHandler::Tick` (mechdmg.cpp) tracks `prevMode` and fires
`BTRebuildMechModel` + `BTStartWarpEffect` when the replicated `MovementMode()` LEAVES the death
modes {2,9} (only `Mech::Reset`'s `SetMovementMode(0)` + `ForceUpdate(0x1f)` ever does; the state
rides every record header). The previous trigger — a damage-zone falling-edge latch
(`wasWrecked`, `prev>=1.0 -> <1.0`) — was BLIND to ZONE-LESS deaths: the #83 collision damage
prices as internal rattle and never moves a zone, so a fresh mech killed by ramming left the
latch unarmed and the peer's wreck could never un-wreck — the live respawned mech drove around
wearing the hulk (night-7 field report: SAURON's Loki after the full-speed ram; both peers
screenshotted it). Bench (`scratchpad/night7/mp_zoneless.sh`, `BT_SELF_DAMAGE_TYPE=collision`
dispatches type-0 through the real TakeDamage divert): 14/14 zone-less deaths un-wrecked
(`mode 9->1`), zero zone-damage rises on the observer; explosive regression leg clean.
- Telemetry: `[mrec-tx]/[mrec-rx]` per non-pose record (BT_REPL_LOG). Note the type-1 rx line - Telemetry: `[mrec-tx]/[mrec-rx]` per non-pose record (BT_REPL_LOG). Note the type-1 rx line
prints a bogus simState (the damage record's own layout at +0xC) — cosmetic. prints a bogus simState (the damage record's own layout at +0xC) — cosmetic.
- New by-name members: `poseSyncLatch@0x77c` (dead-reckon re-base latch, consumed by - New by-name members: `poseSyncLatch@0x77c` (dead-reckon re-base latch, consumed by
@@ -676,7 +695,9 @@ Only the relay host needs port forwarding. Verified: scripted protocol suite in
2-node mission launched + stopped purely over the socket. 2-node mission launched + stopped purely over the socket.
## The interest-teardown audio crash (2026-07-24) [T2 stack-captured + fixed] ## The interest-teardown audio crash (2026-07-24) [T2 stack-captured + fixed]
THE layout-shifting release-only crash (weeks of field flakes, #35's prime suspect) finally THE layout-shifting release-only crash (weeks of field flakes; was #35's prime suspect until
2026-07-29 CLEARED it — #35 turned out to be the ParticleEngine device-reset null-deref, root-caused
from 8 symbolized field stacks and fixed; see [[wintesla-port]] §Device-loss) finally
hit a crash-filter build: `InterestManager::OrphanInterestOrigin → RemoveUninterestingEntity hit a crash-filter build: `InterestManager::OrphanInterestOrigin → RemoveUninterestingEntity
→ DestroyEntityAudioObjects → {Static,Dynamic}3DPatchSource::IsAudioSourceClipped` — AV → DestroyEntityAudioObjects → {Static,Dynamic}3DPatchSource::IsAudioSourceClipped` — AV
reading NULL+0x38: the authentic burning-mech death-silence check dereferences reading NULL+0x38: the authentic burning-mech death-silence check dereferences
@@ -800,7 +821,9 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on
absolute_time` → unbounded → UV precision loss → radial "spokes"), fixed with `fmodf`; that also absolute_time` → unbounded → UV precision loss → radial "spokes"), fixed with `fmodf`; that also
cleans the scrolling beam grit. See [[reconstruction-gotchas]] §13. PEER WARP IS WIRED + VISIBLE cleans the scrolling beam grit. See [[reconstruction-gotchas]] §13. PEER WARP IS WIRED + VISIBLE
(`160b78e`): an observer sees a peer's un-wreck warp via the world-anchored `BTStartWarpEffect` (`160b78e`): an observer sees a peer's un-wreck warp via the world-anchored `BTStartWarpEffect`
(mechdmg.cpp:1074, `ReplicantInstance`-gated; `simulationState` rides every record header). (`MechDeathHandler::Tick`, `ReplicantInstance`-gated; since the #94 fix it fires on the
death-state EXIT edge of the replicated `MovementMode` — cause-agnostic, so zone-less
collision deaths warp too; `simulationState` rides every record header).
REMAINING [T3, non-gating]: the peer sphere is anchored to the peer's WORLD position, not the REMAINING [T3, non-gating]: the peer sphere is anchored to the peer's WORLD position, not the
peer's authentic `DropZoneLocation` (that attribute isn't replicated) — a fidelity refinement. peer's authentic `DropZoneLocation` (that attribute isn't replicated) — a fidelity refinement.
Also confirm the COLLAPSE (death-side) warp fires on every MP death path (force-damage test logged Also confirm the COLLAPSE (death-side) warp fires on every MP death path (force-damage test logged
@@ -858,8 +881,13 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on
REMAINING note is closed. REMAINING note is closed.
5.**2-window driving — RESOLVED (tasks #48/#51).** Keyboard MP turning was restored at the root 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 (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 harnesses. DEATHS scoring lights via the kill/death report flow (2026-08-05: now the AUTHENTIC
reconstructed (task #52 — see item 3b). 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 ## Key Relationships
- Base: [[wintesla-port]] (L4NET). Depends on: [[locomotion]] (update writer), [[combat-damage]] - Base: [[wintesla-port]] (L4NET). Depends on: [[locomotion]] (update writer), [[combat-damage]]
+252 -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). 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 - Pod specifics for Phase 8: the 7-monitor driver setup, the RIO cockpit I/O protocol, current
Win10+wrapper pod config. Status: OPEN. 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 - **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 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 — ([[locomotion]]) only makes sense if the hardware discouraged parking the lever mid-band —
@@ -161,6 +175,32 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
(task #8 "landed" vs the kShotDamage=12 bring-up residue) — audit before reworking. (task #8 "landed" vs the kShotDamage=12 bring-up residue) — audit before reworking.
## Deferred subsystems / feeds (authentic path scoped, marked in code) ## Deferred subsystems / feeds (authentic path scoped, marked in code)
- **~~Myomer seek "SUPERCHARGE"~~ — ✅ RESOLVED same day: the players' testimony (Oracle:
seek-4 humanoid = 182 kph, overheat = freeze) prompted a re-audit that found the coupling in
the un-exported master-perf gap (@0x4a9cf2). The manual was right, 4.10 HAS supercharge, and
the port now implements it byte-grounded ([[subsystems]] WAVE 6 correction). LESSON: a
"no callers in the decomp" claim is only as good as the export coverage — sweep the raw image
for the operand pattern before declaring a data path dead ([[reconstruction-gotchas]]).
Original entry below for the record:** The 1995 manual (p20/p22 + stat sheets) says seek changes TOP SPEED (gear 4 =
"Supercharge", expert-only; Loki/Thor print Normal 143 / Super Charged 182 / Gimped 40 kph).
The 4.10 binary provably has NO runtime myomer→speed coupling (full data-flow closure [T1]:
demand fn @004afd10 all branches, all speedDemand writers, AvailableOutput's 2 callers, the
virtual slot-0x3C sweep, the single SpeedEffect attr ref, the 0x7A0 writer set — see
phases/phase-13). Printed ratio 1.27 vs 4.10's vestigial gear ratio 1.43 → documented
4.0→4.10 drift. The operator is asking SAURON/Oracle whether THEIR pod-era mechs sped up at
seek 4. If yes and wanted: resurrecting supercharge is an OPT-IN deviation — the plumbing
(gear ratio, 0x7A0 cap, ratio² heat, heatFactor overheat-to-zero) all exists; it needs one
authorized multiplication in the demand path. [[subsystems]] [[pod-hardware]]
- **Environment gravity unwired — the myomer CLIMB-heat term is inert (found 2026-07-31, #85).**
`Mover::localEnvironment` (MOVER.h:271) is declared and never populated anywhere in the port;
`GetEnvironment()->gravityConstant` is the named analog of the 1995 Mover's gravity POINTER at
`+0x250` (`FUN_00421e2c` does `vy -= **(+0x250)` per tick), which the myomer drive-heat
integrator uses for its `m·g·|vy|·dt` climb-work term. The bridge (`BTMechMyomerMotionSample`)
null-guards it, so climbing currently generates no myomer heat (flat maps: moot; cavern slopes:
a missing cost). EnvironmentZone resources (RES type 23) exist in BTL4.RES — wiring them (or at
least a constant-gravity Environment on mover birth) revives the term. Also relevant to any
future airborne/jump physics (`ApplyAirResistanceAndGravity` reads the same member).
[[subsystems]]
- **Revolving-door Phase 2 (true mid-mission drop-in) — PLANNED, gated on a spike** - **Revolving-door Phase 2 (true mid-mission drop-in) — PLANNED, gated on a spike**
(`docs/REVOLVING_DOOR_PLAN.md`, 2026-07-24). Open questions before any engine work: (`docs/REVOLVING_DOOR_PLAN.md`, 2026-07-24). Open questions before any engine work:
(a) can an `Entity::MakeMessage` be REGENERATED post-birth (serializing current state), or (a) can an `Entity::MakeMessage` be REGENERATED post-birth (serializing current state), or
@@ -184,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) — ⚠ 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, most of the list had been reconstructed while the prose still called it dead. Check the code,
not the census, before reconstructing anything here. not the census, before reconstructing anything here.
- **`DuckState` has no CODE consumer, and that is authentic [T1].** `Mech::DuckRequest` sets - **❌ RETRACTED (2026-08-06): "`DuckState` has no CODE consumer, and that is authentic [T1]".**
`duckState`(attr 0x37, binary `mech+0x398`) to 1 and nothing else: the flag has exactly two That verdict — and its corollary "no SQUAT clip survives; do not invent a crouch pose" — was
writers in the whole binary (that handler and the mech reset) and ZERO readers. Its consumer is EXPORT-GAP BLINDNESS ([[reconstruction-gotchas]] §20, incident 2), and the [T1] tag was
a DATABINDING — `content/GAUGE/L4GAUGE.CFG` drives a 3-frame `bduck.pcc` widget off `DuckState` unearned: "ZERO readers" was true of the *export*, not the binary. The reader is the master-perf
on the map's legend column, verified live (the crouch icon lights grey→orange on a press). So posture/duck block in the un-exported region (@0x4aa011-0x4aa0af, raw disasm), and the squat
the button is COMPLETE as a request flag + indicator. What is NOT known: whether the 1995 game clips DID ship — `<pfx>squ/sqd` + interior `squi/sqdi` for all 8 chassis live in **BTL4.RES**
ever consumed it for posture/collision (no SQUAT clip name survives in the decomp or in (the old claim searched decomp strings + loose `content/` files, never the RES TOC;
`content/`, only `DuckServo01.wav` in AUDIO1.RES). Do not invent a crouch pose to "finish" it. `DuckServo01.wav` was the tell). CROUCH is now fully reconstructed — request latch posture
- **Searchlight-driven fog swap — STILL DEFERRED, but the "ORIGINAL 1995 LATENT BUG" premise is arbiter (novice + myomer + mode gates) → squat/hold/rise, MP-replicated on the type-3 record
❌ RETRACTED (2026-07-25, #61).** The arcade swaps fog between `fog=` (lights on) and ([[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`. `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 **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** compared Searchlight's Performance (@004b841c, reads `requestedOn`@0x1E0) against **ThermalSight's**
@@ -203,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 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 export gap (#60) but was **recovered by raw disasm** (`scratchpad/dis838c.py`) — it toggles 0x1E0 and
carries no novice gate. All T1.] carries no novice gate. All T1.]
Searchlight's handler set is now WIRED and **verified live**: pad `0x14``requestedOn 0→1` Searchlight's handler set is 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 `lightState 0→1`; the fog watcher + beam cone landed 2026-08-05 (see the header above), so no
original" is void — a working fog swap is now plain FAITHFUL reconstruction. Remaining work is a remaining work rides this entry. (NB the 1995 searchlight carries NO novice gate — that
single item: construct `PullFogRenderable` at btl4vid.cpp `MakeMechRenderables` reticle-build/inside lockout is ThermalSight's; raw disasm @004b838c.)
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.)
- **`HandleMessage` is vtable slot 8/9 in the binary but NON-virtual across the reconstruction -- - **`HandleMessage` is vtable slot 8/9 in the binary but NON-virtual across the reconstruction --
FILED AS GITEA #65 FILED AS GITEA #65
(2026-07-25, found via #46).** Ten classes declare it (ammobin/heat×2/hud/mechsub/myomers/ (2026-07-25, found via #46).** Ten classes declare it (ammobin/heat×2/hud/mechsub/myomers/
@@ -668,6 +709,22 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
destroyed-subsystem dark-panel look. destroyed-subsystem dark-panel look.
## Locomotion / combat polish (non-gating) ## 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 - **✅ 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 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 (@part_012.c:10308-10320) was a no-op (`LoadLowDetailBody`/`LoadHighDetailBody` mislabeled the
@@ -761,6 +818,25 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
numbers no longer point at that code). numbers no longer point at that code).
## Multiplayer (Phase 7 / P6) ## Multiplayer (Phase 7 / P6)
- **#82 wall-grind storm — CLOSED 2026-07-30** (the "stagger drive-suppressor" theory that
briefly lived here was falsified: `word[this+0x18]` has no consumer but the record emitter).
The real missing piece was the crash SELF-DAMAGE dispatch (binary @4aa984-4aaab4), now
reconstructed — see [[locomotion]] §Knockdown. Residue actually still open: the crash's
`throttleState = fall-surface material` fill (binary `(**[mech+0x2f8])+0x24`; payload nicety
for the type-5 reader's surface-specific fall handling — needs the assistant-record map).
- **Cross-fire bench opens (2026-07-30, first real-weapons 4-node runs).** (a) COMBAT STALL: all
firing and dying ceased ~3 min in (run 2, throttled `BT_AF_PERIOD=7`) with everyone alive,
subsystems healthy, and fps fine — engagement/heat state needs a logging pass (`BT_GOTO_LOG`
+ heat probes); run 1's max-spam stall was the documented FailureHeat all-weapons brick.
(b) NODE CRASH: one instance died silently mid-run (no SEH line, no WER record; last log line
= audio census; yesterday's WER archive holds 3× `OpenAL32.dll` abort `0x40000015` and WER
dedups repeats — suspect the audio-pool thread). procdump harness now wired into
`scratchpad/night6/mp4_cross.sh`; did NOT recur under throttled fire. (c) VERIFIED GOOD: 4
deaths ↔ 4 kills credited to the right shooters (incl. a mutual kill), `NOCREDIT`=0, cross-
machine damage applied on victims' masters with correct attribution; one kill credit arrived
17 s late (`SCORE type=2`) — the deferred ScoreMessage reroute path, watch it.
- **Gitea #12 MP incident (2026-07-19) — root causes found; BOTH FIXES LANDED 2026-07-19 - **Gitea #12 MP incident (2026-07-19) — root causes found; BOTH FIXES LANDED 2026-07-19
(awaiting the human MP death-and-survive verification).** Findings [T1/T2 — see the #12 (awaiting the human MP death-and-survive verification).** Findings [T1/T2 — see the #12
comments + `scratchpad/incident_2157/`]: comments + `scratchpad/incident_2157/`]:
@@ -856,6 +932,28 @@ 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 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. 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.
## Rendering follow-ups (non-blocking) ## Rendering follow-ups (non-blocking)
- ~~Per-pilot mech PAINT (color/badge/patch)~~**DONE 2026-07-17, verified live** (crimson MadCat + - ~~Per-pilot mech PAINT (color/badge/patch)~~**DONE 2026-07-17, verified live** (crimson MadCat +
yellow VGL emblems + hip hazard stripes). Mechanics + the vehicletable color/badge/patch name yellow VGL emblems + hip hazard stripes). Mechanics + the vehicletable color/badge/patch name
@@ -879,6 +977,67 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
## Content build sub-project (low priority) ## Content build sub-project (low priority)
- Lab/other-build maps (`des`/`burnt`/`frstrm`) are source-only (`.map` in CONTENT/BT/MAPS/); would - Lab/other-build maps (`des`/`burnt`/`frstrm`) are source-only (`.map` in CONTENT/BT/MAPS/); would
need compiling into a RES via the DOS `btl4tool.exe`. The 8 RES maps cover testing. need compiling into a RES via the DOS `btl4tool.exe`. The 8 RES maps cover testing.
- **Offline subsystem-authoring dispatch is un-linked (known, cold, 2026-07-30).** mech3.cpp's
`Mech::CreateSubsystemStream` dispatches to 20 per-class `CreateStreamedSubsystem` stubs whose
guessed signatures (`void*`/`int`) don't match the real modules (nested `<X>::SubsystemResource*`
+ trailing `ResourceFile*`, e.g. mechtech.cpp:407) — all 20 are /FORCE-silenced unresolved
externals. No callers exist, so it cannot crash today; before wiring any authoring tool through
it, convert each to a per-module bridge fn in a complete-type TU (gotchas §6 stub-typedef
corollary). The parallel `DefaultData` half of the table WAS the same trap and is fixed
(`Simulation__SharedData`).
## Night-8 playtest intelligence (2026-07-31, build 4.11.674) [T2 player testimony]
**SENSOR PANEL — the model, from a player who ran the cabinets** (VGL Lynx; the last major system
without one; Gitea #105). Sensor damage degrades, in descending confidence: (1) the **HUD flickers**
— corroborated by period gameplay footage, and the distinctive tell to match; (2) **radar/map**
degraded or lost; (3) the **searchlight** (cf. #61, where Searchlight/ThermalSight ToggleLamp were
dead handlers); (4) the **hot box / thermal** suspected but unconfirmable — that feature is not
functional in our build. Next step is to find the sensor subsystem's damage consumers in the binary
and check which display paths it actually gates, rather than implementing from the description.
**COOLANT LEAK severity is a THREE-level display** (Oracle + Draco, Gitea #97): one/two/three
triangles, top level draining the reservoir fast, with an audible alarm. We only ever produce the
lowest. Our leak rate is a continuous scalar (`ownZone->damageLevel x heatLoad`, floor 0.0025,
ON hysteresis 0.003) — so either the banding into three display levels is missing, or the magnitude
never leaves the bottom band. Check whether severity is separately authored before deriving it.
**MYOMER heat calibration is CHASSIS-RELATIVE, not absolute** (Oracle, Gitea #96): a **Thor should
sustain seek 4 more or less indefinitely** on its heatsink count, while a light chicken-walker
overheats in well under a minute. This is the acceptance test for the drive-heat coefficient — the
spread should fall out of (drive heat / authored sink capacity) on its own, so a uniform "too hot"
across ALL chassis indicts the coefficient rather than the sinks.
**RAM damage: the binary's internal-only divert MATCHES what players see, and may be correct**
(Gitea #103). Oracle reported ram causing gyro + myomer damage and a myomer coolant leak but **no
armor damage**, with grinding inert — exactly `Mech::DistributeCollisionDamage`'s type-0 divert
(internals weighted Myomers/Gyro 0.35, HeatSinkBank 0.30, Torso 0.25; 0.5-pt free-tap floor).
Awaiting a player ruling on whether a hard ram ever damaged ARMOR in the original before treating
the absence as a defect.
**MP STABILITY datapoint** (RajelAran): a **5-player match with no egregious issues** — *"no
stability problems or massive lag spikes"*. Relevant to the carried cross-fire-stall and
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 ## Key Relationships
- Feeds from: every subsystem/render topic (their deferral notes collect here). - Feeds from: every subsystem/render topic (their deferral notes collect here).
@@ -929,3 +1088,77 @@ heat / FailureTemperature scaling is too aggressive, or the roach-motel (no reco
falls) is the wrong reading. NEXT: audit the SRM6 heatPerShot vs FailureTemperature vs the mech's falls) is the wrong reading. NEXT: audit the SRM6 heatPerShot vs FailureTemperature vs the mech's
dissipation, and whether the binary's gate-1 FailureHeat is a LATCH or clears when heat drops. dissipation, and whether the binary's gate-1 FailureHeat is a LATCH or clears when heat drops.
Trace it live with `BT_AMMO_LOG=1` (+ `BT_PROJ_LOG=1` for per-tick heat/recoil). Trace it live with `BT_AMMO_LOG=1` (+ `BT_PROJ_LOG=1` for per-tick heat/recoil).
## Night-9 playtest intelligence (build 4.11.693, 2026-08-01)
**A MEMBER OF THE ORIGINAL TEAM IS NOW TESTING.** "Ronin" (log user `torak`,
machine TOWER_OF_TERROR) worked on the 1995 game and has played the pods
recently, including in Japan. He is a **primary source** — treat his comparisons
against the pods as high-value evidence, above reconstruction inference. Two
contributions already:
* **Per-chassis cockpit BOUNCE was deliberate** [T1, testimony]: "all of the
chassis have different cockpit 'bounce' — this was made so to help give the
'mechs some individuality". If our port applies one shared bounce curve, a
deliberate identity cue is missing. (Gitea #117; `jointshakey` is the likely
carrier — its pick sphere radius already differs per chassis.)
* **NARC may never have been implemented** [T3, recollection]: the loadout data
shipped but he does not recall the weapon being built. Squares with what we
see — an authored record that resolves with no distinct behaviour to
reconstruct. Mike L. could confirm. (Gitea #104.)
**AUDIO #32, CORRECTED DIAGNOSIS (2026-08-02): saturation during combat, NOT
retention** [T2 — census-measured]. An earlier version of this entry claimed the
pool "fills and never returns a source"; that was read off the FAILURE line, where
`free=0` is true by definition (a third instance of the counter-sampling trap).
The 30-second census in the same logs disproves it: `free` returns to ~227-230
between bursts and `reuses` climbs ~20/s all session — the pool cycles fine, and
release-on-stop exists and works (the engine's steal loop:
AudioSourceStop/SuspendMaintenance -> ReleaseChannels -> ReleaseSourceSet).
* The real regime: during firefights CONCURRENT demand exceeds the 240 cap and
the priority steal loop services each new sound by killing an old one,
thousands of times per combat session. Idle standing demand is only ~13.
* The raw `ACQUIRE FAILED` line count (6.8k-19k/log) is NOISE — the steal loop
retries after every failed attempt, so lines pile up per event and most
events still play via a steal. It is now rate-limited (1/30s) and the census
carries the true metrics: `steals=` and `drops=` (drops = the steal loop ran
dry and the sound NEVER played), plus a per-class drop histogram.
* The saturating requester is class 1005 = **Static3DPatchSource, 4 voices** —
world-placed effect sounds, i.e. EXPLOSIONS/impacts. Demand tracks the
number of SHOOTERS (9-mech one-shooter bench peaks at 130 with 0 fails; a
6-shooter field lobby pins 240). **#84's double detonation doubles exactly
this class on observer nodes** — fix #84 first, then re-read the field
census before touching the audio budget.
* `BT_AUDIO_SOURCES=<n>` now raises the POOL cap too, not just the AL context
budget (it previously did only the latter, making the experiment
impossible). If post-#84 logs still saturate, that is the field experiment
— with frame time measured, since more concurrent voices = more mixing CPU.
**Reading the missile impact line.** `[projectile] IMPACT damage=X ... burst=N`
prints X = **per missile**; the delivered total is `X * N`. A tester reading X
alone will report LRMs as doing ~3.3 damage when the salvo is landing up to 65.
Field-verified across 382 impacts: LRM20 3.25 x 5..20, LRM15 3.33 x 3..15,
LRM10 3.50 x 2..10 — burst ranges matching the binary's `Random(n) + n/4` roll
exactly.
**Weapon damage values are published** in the original manuals (Oracle: "that is
published ... Manuals had that info and it was accurate") — but NOT in the 4.0
manual we have archived (confirmed by Lynx). A later manual, or the wayback
capture of the original site's final build notes, would give hard per-weapon
numbers to test the authored data against. Worth chasing: it converts weapon
tuning from taste into arithmetic.
**Standard vs Expert heat is unsettled** and it contaminates every heat report:
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.
+113 -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 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**, ON/OFF** (`ToggleGeneratorOnOff` id 4, binary table @0050fb90 fn @004b1ed0 — ✅ **WIRED**,
`powersub.cpp`). Newly decoded from the binary message tables: **0x13 → Mech `DuckRequest` `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 (0x1a @0049fa00 — the manual's CROUCH button)** — ✅ **COMPLETE 2026-08-06**: handler (07-26) +
this census; press-only, sets `duckState` and the map legend's `bduck.pcc` widget lights (see the master-perf posture CONSUMER + squat/rise clips + MP replication ([[locomotion]] §CROUCH;
[[open-questions]] for why it has no code consumer) —, **0x28 → Mech `BalanceCoolant` (0x16 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 → @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 Searchlight + Searchlight2 `ToggleLamp` (id 3, table @0x51117C fn @004b838c)** — ✅ **BOTH WIRED
2026-07-25 (#61)**, previously default-constructed blackholes; verified live (0x14 → 2026-07-25 (#61)**, previously default-constructed blackholes; verified live (0x14 →
@@ -183,6 +184,84 @@ 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` = 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] 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. Driving original splitter hardware needs a CHANNEL-COMPOSITE mode: extract three planes
into ONE 640x480 RGB image with pure (255,0,0)/(0,255,0)/(0,0,255) tints, one window per VGA
output. `SVGA16::ExpandPlaneToBGRA` already does the per-plane extraction with a tint, so the
composite is a small addition. OPEN: which way Nick's crash cart is wired (Windows shows three
separate 640x480 displays there, which suggests per-panel outputs via the Trigger 6 USB adapter,
not a splitter) — settle it by eye before building.
## 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.
**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=1 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.
## The 1995 player manual — alignment audit (2026-07-18) [T1, primary source] ## The 1995 player manual — alignment audit (2026-07-18) [T1, primary source]
`reference/manual/Tesla40_BT_manual.pdf` (34pp, from Nick). CONFIRMS the reconstruction on `reference/manual/Tesla40_BT_manual.pdf` (34pp, from Nick). CONFIRMS the reconstruction on
every checked control behavior: every checked control behavior:
@@ -208,9 +287,37 @@ every checked control behavior:
side button columns + **EJECT button** beside the joystick + per-mech COOLANT LOOP tables side button columns + **EJECT button** beside the joystick + per-mech COOLANT LOOP tables
(weapons+generators per loop 1-6) and stat sheets (tonnage/armor/reservoir liters/heat (weapons+generators per loop 1-6) and stat sheets (tonnage/armor/reservoir liters/heat
sinks/top speeds incl. "Super Charged"). sinks/top speeds incl. "Super Charged").
NEW LEADS (manual describes, port lacks input/UI): **CROUCH** (button by the secondary screen; - **MYOMER SEEK / "SUPER CHARGE" — ⚠ the "4.0→4.10 drift" verdict below was WRONG and is
`Mech::duckState` attr 0x37 + SQUAT clips exist, nothing drives them), **EJECT** (sounds exist: RETRACTED (same day).** 4.10 HAS the seek→speed coupling — in the un-exported master-perf
EjectButton01_z*.wav), hot-box viewscreen framing (the deferred PNAME marker chain). Per-mech gap (@0x4a9cf2: `speedDemand *= max myomer speedEffect`, turn-freeze at dead drive; see
[[subsystems]] WAVE 6 correction). The manual is ACCURATE for 4.10; Oracle's pod memory
(seek-4 humanoid = 182 kph = the printed figure) confirmed it and prompted the re-audit.
The paragraph below is retained as the record of the error (its BINARY facts — the two
exported AvailableOutput callers, the single attr ref — were correct but incomplete:
export-gap functions are invisible to decomp text sweeps).
~~ORIGINAL (WRONG) VERDICT:~~
Manual p20/p22: seek "translates to a lower or higher top speed", gear 4 is named
**Supercharge** (screenshot caption "Seek Level Is Set to Supercharge"), expert-only ("in
standard simulation mode, you are not able to supercharge"), and "overheated myomers can
reduce your 'Mech's speed to zero". Stat sheets print THREE top speeds (Loki/Thor: Normal
143 / **Super Charged 182** / **Gimped 40** kph). The 4.10 BINARY dropped the runtime
coupling entirely: the mapper demand @004afd10 has no myomer/seek term, `AvailableOutput`
has exactly two callers (assembly cap + graph), and the SpeedEffect attribute string has one
reference (its own table row). Even the printed ratio disagrees with 4.10's authored gears
(182/143 = 1.27 vs 0.9999/0.7 = 1.43) — the gear table and the assembly cap are vestiges.
In 4.10 the seek dial's live effects are heat (ratio² ≥ 1), the generator brown-out
threshold (whose real stake is TORSO TWIST — see [[subsystems]] WAVE 6), and the ENG graph.
"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~~ ✅ **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. stat tables = a systematic cross-check source for our streamed subsystem resources.
### Coolant-loop cross-check RESULT (2026-07-18) [T1] -- STRUCTURE strongly faithful ### 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}/`, they map into this repo (reconstructed BT → `game/reconstructed/`, engine → `engine/MUNGA{,_L4}/`,
content → `content/`, raw decomp → `reference/decomp/`). [T2] content → `content/`, raw decomp → `reference/decomp/`). [T2]
## Current state (2026-07) ## Current state (2026-08-06) — core gameplay reconstruction COMPLETE; polish phase
`btl4.exe` boots, renders the world + a skinned mech, and runs a full **drive → animate → target → Field-tested nightly at 4-8 pods over Steam (builds 4.11.622→801). Authentic + benched: the full
fire → damage → destroy → respawn** single-player loop (task #52). **2-node MP is verified SP+MP loop; per-panel mesh-true targeting/damage (+ crits, cylinder lottery, armour darkening);
end-to-end** — replication, cross-pod targeting/damage/kill, beam visuals, replicant gait (tasks the 1995 SCORING model (kill awards + received penalties + panic cost — the id-0x16 report tail);
#46-#51, [[multiplayer]]). All 8 cockpit canopies are authentic + the horizontal-FOV fix (task #55, death (blast/splash, wreck, burial) + respawn (full re-arm audit); locomotion (two-channel gait,
[[cockpit-view]]); the Gyroscope is live byte-exact with hit-bounce (task #56); the **gauge system gimp limp, knockdowns, CROUCH F4); the night kit (fog-swap SEARCHLIGHT F5 + beam cone); heat/
is complete** ([[gauges-hud]]). The engine/renderer/HAL are done (WinTesla); AUDIO backend is now REAL power/myomers (incl. the seek-4 freeze); replication (masters/replicants, kill/death columns,
(the repo's OpenAL32.dll + libsndfile-1.dll were both no-op STUBS — replaced with real OpenAL Soft + ghost+skate field detectors); the Steam wire seam + build gate; the operator console/relay
an in-tree WAV loader, 2026-07-15) and the 241-sample soundbank is cracked from AUDIO1/2.RES + loading; lifecycle; gauges/HUD; audio (real OpenAL backend + the AUDIO_FIDELITY trigger waves — footsteps/
the ONLY remaining audio gap is game-triggering (no AudioEntities fire PlayNote yet), so gameplay is gait/alarms/impacts live in the field). [T2]
still silent apart from a proof-of-life hook (see [[wintesla-port]] Audio). The active work **Infra ✅ 2026-08-06:** the #60 export gap census AND re-export shipped — coverage 87.3% →
is reconstructing each BT subsystem's authentic behavior from the binary. Remaining: pod-LAN 93.5%, dark code 90 → 41 KB, +205 functions, and the re-export independently confirmed the
config, Mech-level update records, per-subsystem waves. [T2] 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 ## Key Relationships
- Full detail: `docs/PROGRESS_LOG.md`. - Full detail: `docs/PROGRESS_LOG.md`.
+240
View File
@@ -121,6 +121,17 @@ mislanch.cpp's extern but not projweap.cpp's → first AUTOCANNON shot AV'd). **
bridge-signature change, `grep -rn "extern .*<name>"` and update every declaration; then grep bridge-signature change, `grep -rn "extern .*<name>"` and update every declaration; then grep
the fresh link output for the symbol name** — the pre-existing LNK2019 wall camouflages new the fresh link output for the symbol name** — the pre-existing LNK2019 wall camouflages new
entries if you only eyeball it. entries if you only eyeball it.
**Stub-typedef corollary (mech3 tool path, 2026-07-30):** a LOCAL stub TU that re-declares a
sibling class's STATIC MEMBER with the wrong typedef mangles to a **ghost symbol** — mech3.cpp
declared every `<Subsystem>::DefaultData` as `Mech::SharedData` (= `Entity__SharedData`) while
the real definitions are the inherited `Simulation__SharedData` (in THIS engine `Entity` derives
from `Simulation`; `Entity__SharedData : public Simulation::SharedData`, ENTITY3.h:9) → ~20
unresolved externals, all /FORCE-silenced, all cold (offline authoring dispatch has no callers).
Fixed for the DefaultData statics; the `CreateStreamedSubsystem` stub SIGNATURES are still wrong
(real ones take the class's NESTED `SubsystemResource*` + trailing `ResourceFile*`) and stay
unresolved-by-design until bridged per-module ([[open-questions]]). **Rule: when a "benign" LNK
wall exists, `tail` on the build output hides the fleet — always grep the FULL log; and verify a
stub's member TYPE against `dumpbin /symbols` of the defining obj, not against what looks right.**
**Duplicate-GLOBAL corollary (glass per-display windows, 2026-07-20):** a global DEFINED in two **Duplicate-GLOBAL corollary (glass per-display windows, 2026-07-20):** a global DEFINED in two
libs (the 1995 headers declare free globals without `inline`/`extern`, so `application`, libs (the 1995 headers declare free globals without `inline`/`extern`, so `application`,
`ghWnd`, … exist in BOTH `munga_engine` and `bt410_l4`) links under `/FORCE:MULTIPLE` with `ghWnd`, … exist in BOTH `munga_engine` and `bt410_l4`) links under `/FORCE:MULTIPLE` with
@@ -267,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 `==`. state transitions: charge/seek loops, snap comparisons, timers compared with `==`.
## 13. Verification gotchas (don't fool yourself) ## 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 - **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 kill shows `[gskip]=0` / "not built" even though the widget is fine — **wait for the gauge
@@ -532,6 +549,34 @@ segment tables) — the same class of latent overflow.
0xFEEEFEEE=freed). 0xFEEEFEEE=freed).
5. For exhaustive multi-function analysis: a read-only Workflow (understand), then implement hands-on. 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 ## Key Relationships
- Applies to: every topic that reconstructs a class ([[subsystems]], [[combat-damage]], [[gauges-hud]], [[locomotion]]). - Applies to: every topic that reconstructs a class ([[subsystems]], [[combat-damage]], [[gauges-hud]], [[locomotion]]).
- Uses: [[decomp-reference]] (offsets/ClassIDs), [[reconstruction-method]] (the loop). - Uses: [[decomp-reference]] (offsets/ClassIDs), [[reconstruction-method]] (the loop).
@@ -623,3 +668,198 @@ trusting what it is called — the reconstruction's names are reconstructions to
**Sweep note:** the same misnomer sits in all three headers; renaming is safe (no binary meaning **Sweep note:** the same misnomer sits in all three headers; renaming is safe (no binary meaning
attaches to the port's accessor name) but touches three size-locked classes, so do it deliberately. attaches to the port's accessor name) but touches three size-locked classes, so do it deliberately.
## 23. `AlarmIndicator` is a DIFFERENT TYPE per header family — Mech's layout diverges across TUs (2026-07-30)
Found chasing #78: `mechdmg.cpp` wrote `mech->graphicAlarm.SetLevel(4)` and read 4 back;
`mechmppr.cpp` read **0** from the *same object, same expression, same frame family*. Root cause:
```cpp
mech.hpp:67 typedef ReconAlarm AlarmIndicator; // 4 bytes {unsigned level}
heat.hpp:52 typedef GaugeAlarm AlarmIndicator; // 0x54 bytes (the real binary alarm)
```
`Mech::graphicAlarm` is declared `AlarmIndicator` — so a TU's include ORDER decides which type
that member is, and **every Mech member after it shifts by 0x50 between the two families**. A
write through one family's layout is invisible to a read through the other. This is the shadow
trap operating at the TYPEDEF level, where no compiler error can catch it (each TU only ever sees
one definition).
**Rule:** any cross-TU read/write of a Mech member declared past `graphicAlarm` must go through a
bridge compiled in a KNOWN TU (`BTMechGimpLevel` in mechdmg.cpp is the pattern) until the typedef
split is audited and unified. The audit itself — which TUs resolve `AlarmIndicator` to which
type, and which member traffic crosses families — is an open work item; the same split-brain also
explains why `MovementMode()` (the engine `simulationState`) and the KB's "movementMode IS the
graphicAlarm level" (task #1) never actually met in the port: they are two different cells, both
alive, written by different subsystems.
## 24. Reviving DEAD reconstructed code replays every port-glue fix it predates (2026-07-30)
The gimp gait drivers (`AdvanceBody/LegAnimationGimp`, ex-"Airborne") sat reconstructed-but-dead
for weeks while their gate read the wrong cell (gotcha #23's split-brain). The moment the gate
was fixed and they ran for the first time, they reproduced — within seconds of engagement — TWO
bugs the LIVE drivers had each been cured of long before:
1. **the raw `*(this->controlSource)` mapper read** (`controlSource@0x128` is never wired in the
port; the live drivers were converted to `MappingMapper()` — the dead one crashed at
`AdvanceLegAnimationGimp+0x16`, null deref, first live engagement);
2. **the missing alarm→member state re-sync** (`bodyAnimationState = bodyStateAlarm.GetLevel()`;
the binary has ONE cell, the recon has two — the ground drivers re-sync at :831/:1181; the
dead driver didn't, so the state member froze at its pre-gimp value and the machine pinned in
one run state for 8000+ frames).
**Rule:** before wiring a long-dead reconstructed function into a live path, diff it against its
LIVE sibling for the port-glue idioms (mapper access via `MappingMapper()`, alarm→member
re-syncs, null guards, `BTEnvOn` gates). The binary-faithful parts transplant cleanly; it is the
RECON-side glue that will be missing, because every glue fix landed only where code was running.
## 25. A SPLIT cell breaks REPLICATION silently — and per-frame writers erase the half you added (2026-07-29)
The gimp-level saga's second act (#82). When the port carries one binary cell as TWO members, the
damage isn't only cross-TU reads (gotcha #23) — it is **which half rides the wire**:
- `Simulation::simulationState` is replicated in EVERY update record header
(`Simulation::Write/ReadUpdateRecord`). Any binary state living in `mech+0x40` therefore
replicated for free. The port's parallel member (`graphicAlarm`) replicates **nowhere**, so every
behavior a peer derives from it silently becomes master-only — visible as "peers see something
different" bugs (here: a limping mech that skated on every other pod).
- Worse, the engine cell usually already has a **per-frame writer** with a narrower idea of what it
means (`Mech::PerformAndWatch`: `SetMovementMode(1)` = "ground, non-death, non-airborne"). The
moment you mirror extra semantics into it, that writer erases them 60×/second — and the symptom
is not a stuck value but a 1→N→1 oscillation whose edges retrigger anything watching (the
warning-voice sequence restarted on every damage tick and never reached its spoken note).
**Rules:**
1. Before mirroring into an engine cell, `grep` for its per-frame writers and teach them the new
value (write `gimped ? 3/4 : 1`, not a blind `1`).
2. Read the authority, never the cell you feed — an alarm-only bridge (`BTMechGimpAlarmLevel`)
keeps a respawn-cleared alarm from re-latching stale state out of the cell.
3. Don't "fix" a stomp by patching incoming records: on a replicant the master's records ARE the
authority, and pinning a local value against them makes state stick forever (that draft would
have kept peers limping through a respawn). Find the writer instead.
**Tool:** scope the trap. `StateIndicator`/`Simulation` state traps drown in subsystem churn —
filter to one watched object (`g_btGimpWatchMech`) and print the caller module-relative
(`btl4+0x…`) so `tools/symcrash.py` names it. That turned a two-hour guess into one line.
## §21 — Export-gap blindness: "no callers in the decomp" is NOT "no callers in the binary"
**(2026-07-31, the myomer seek retraction — the costliest wrong verdict to date.)** The Ghidra
export has GAPS (gitea #60): whole functions absent from `reference/decomp/all/*.c` — the
master-perf region 0x4a9770-0x4ab188, the 0x4a03xx crit caller, @004b838c, and others. A text
sweep of the decomp for callers/readers of a symbol **cannot see a consumer that lives in a
gap**. The myomer `speedEffect@0x31C` demand feed was declared dead on exactly such a sweep
("two AvailableOutput callers, one attribute reference, no raw readers") and an already-correct
port mechanism was deleted on that basis — while the real consumer sat un-exported at
@0x4a9cf2 (`speedDemand *= max myomer speedEffect` + the dead-drive turn freeze). Field
testimony (Oracle: pod seek-4 = 182 kph, the manual's printed figure) forced the re-audit.
**RULE: before declaring a data path dead, sweep the RAW IMAGE for the operand pattern** —
capstone over the CODE section for the addressing form (e.g. FPU reads of `[reg+0x31C]`, SIB
`[reg+reg*4+0x330]`). Minutes of scanning; it found in one pass what three decomp sweeps
missed. Corollary: when the decomp and a PRIMARY SOURCE (the manual, a pod veteran) disagree,
treat the disagreement as a hole in YOUR evidence first, not in theirs.
## §22 — The SPLIT CELL: one binary offset, two port members (only one gets written)
**(2026-07-31, gitea #86 "destroyed weapons keep firing".)** The binary's weapon fire gates test
`subsystem+0x40`. In the 1995 layout that offset is *inside* the embedded status alarm
(`statusAlarm@0x2C` + the indicator's level at `+0x14` = `0x40`) — **one cell**, written by
`ForceCriticalFailure` when a zone's crit-cascade kills the subsystem. The port models the same
address as TWO independent members: `AlarmIndicator statusAlarm` **and** a plain
`int simulationState@0x40`. Every destruction path writes the ALARM (so the MFD correctly draws
its X, the paper doll correctly greys the mount) while the fire gates read the plain int — which
nothing ever writes. Result: a weapon on a blown-off arm shows destroyed on every panel and
keeps firing and scoring, on the shooter's screen and on peers' (all three night-7 testers).
**Detection smell:** a diagnostic that prints the gate's own inputs and shows a state flag
reading 0 while the UI bound to "the same" state shows destroyed. (`[ammo] NoAmmo (gate1):
destroyed=0` on a mech with an X'd-out launcher was the tell — it sat in the logs for weeks.)
**Rule:** when a binary offset falls inside an embedded object in OUR layout, do not mirror it as
a sibling scalar — read it through the object that owns it, or (if a duplicate member already
exists) make every gate read BOTH and every writer write BOTH. Sibling of gotcha #1 (shadowed
base field): same failure shape — two cells where the binary has one, and the readers pick the
dead one.
## §23 — Scaling `D3DMATERIAL9` does NOTHING for BGF geometry (the vertex-colour diffuse source)
**(2026-07-31, gitea #87 "mech armour panels don't darken".)** The 1995 armour damage darkens a
mech by scaling its MATERIALS' colour terms (`dpl_SetMaterialAmbient/Emissive/Diffuse/Specular`).
The obvious port translation — scale the draw op's `D3DMATERIAL9` before `SetMaterial` — renders
**byte-identically**: measured **0 changed pixels** at a full 0.1x scale.
**Why:** D3D9 defaults `D3DRS_DIFFUSEMATERIALSOURCE` to **`D3DMCS_COLOR1`** (and `D3DRS_COLORVERTEX`
to TRUE), so when a vertex carries a diffuse colour the material's Diffuse is never consulted.
Every BGF vertex carries a baked colour (that IS the 1995 shading model — no-normal geometry is
unlit and coloured by vertex/ramp), so for essentially all world geometry `SetMaterial` is inert
for colour. The material only matters for lit, normal-bearing meshes.
**Fix pattern:** modulate the FINAL fragment instead — `D3DRS_TEXTUREFACTOR` + a
`MODULATE(CURRENT, TFACTOR)` on texture **stage 1** (unused in `d3d_OBJECT::Draw`), restored to
`D3DTOP_DISABLE` after the op. That darkens the result whatever the diffuse source was, and
equally covers the ramp-baked-texture path and pure-emissive batches.
**Detection smell:** a colour change that logs perfectly at the source and produces zero visible
difference. Diff two runs pixel-wise against a control region before believing a colour path
works — "the log says 0.1x" is not evidence that anything reached the screen.
## §24 — A verified fact, OVER-GENERALISED, becomes a wrong design premise
**(2026-08-01, gitea #95 "missiles land 3 points instead of 50".)** The KB carried this, tagged
**[T1]**: *"`DamageZone::TakeDamage` is `damageLevel += amount*scale` and IGNORES `burstCount` — so
`burstCount` is cosmetic for zone damage."* The first clause is **true and byte-verified**
(`@0041e4e0`). The second is an **inference** that was written down beside it, inherited the [T1]
tag, and then justified a design decision (task #62's salvo-lead model) that silently divided every
missile salvo by its missile count for months.
**What was actually true:** `TakeDamage` ignores `burstCount`; the **CALLER**
(`Mech::TakeDamageMessageHandler` @0x4a0423-0x4a04d8) honours it by calling `TakeDamage` that many
times, re-rolling the zone per burst. The consumer was one stack frame up — the same blind spot
shape as §21 (export-gap blindness), but with the evidence *present* and simply not followed
outward.
**Detection smell:** a "cosmetic"/"unused"/"vestigial" claim about a field that other code still
computes carefully. Here the binary randomises `burstCount` at impact (`Random(n) + n/4`) and the
splash code derives it from a distance falloff — nobody spends instructions rolling a decorative
value. **If a field is called dead, ask who still writes it, and why.**
**Rule:** tag the VERIFIED clause, not the paragraph. An inference sitting next to a [T1] fact is
still [T4] — split them, or the next reader (including you) will build on the guess.
## 26. The SILENT-STUB walk: empty iterators make a faithful-looking mechanism a no-op (#110)
`RecurseSegmentTable` was "reconstructed" against local shim types whose
iterators unconditionally returned NULL (`SegmentIterator::Next() { return 0; }`,
`SegTableX::operator[] { return 0; }`). Every line of the walk mirrored the
binary -- and none of it did anything: the destruction cascade fired, logged,
"descended", and touched no other zone. The symptom (gun pods surviving their
blown-off arms) was field-reported for weeks while the code READ correct.
The trap: a stub that returns EMPTY (rather than asserting/crashing) converts a
reconstruction into silent fiction, and the no-stand-ins rule cannot catch it by
reading the call site -- the stand-in hides in the TYPES. The engine already had
the real structures (`EntitySegment::damageZoneTable` / `childIndexTable`, T0,
byte-matching the decomp's seg+0xD0/+0xE8) -- the stub was never necessary.
Rules: (a) when a reconstructed mechanism runs but nothing downstream changes,
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.
+1
View File
@@ -58,4 +58,5 @@ the ground-model decode (10 agents), the alarm-unification (8), the gauge-widget
## Key Relationships ## Key Relationships
- Bug classes: [[reconstruction-gotchas]] (check FIRST). Reference data: [[decomp-reference]]. - Bug classes: [[reconstruction-gotchas]] (check FIRST). Reference data: [[decomp-reference]].
- Verification: [[test-harness]] — the bench contract + what counts as VERIFIED (field composition, not proxies).
- Why it's needed: [[source-completeness]]. - Why it's needed: [[source-completeness]].
+108 -4
View File
@@ -220,8 +220,13 @@ i860 firmware** `content/VREND.MNG` (same decode workflow as the task-#55 'damag
- Remaining [T3]: sfx cone shape (v_bias as up-bias), size ×0.5 + 1.25m textured floor + - Remaining [T3]: sfx cone shape (v_bias as up-bias), size ×0.5 + 1.25m textured floor +
hot-phase occlusion 0.45..0.9, burst scatter ×0.2; trail hot-phase occlusion 0.45..0.9, burst scatter ×0.2; trail
density is frame-rate-dependent (2/frame @60fps = 2× pod density); .PFX 'repeats' unconsumed density is frame-rate-dependent (2/frame @60fps = 2× pod density); .PFX 'repeats' unconsumed
(all shipped files author 1); replicant visual salvo chases the stale fire-time aim point (all shipped files author 1); replicant visual salvo FIXED (2026-08-02, #84): the
(no re-lead without a target handle). update record now carries the locked target's EntityID; the mirror resolves it
locally (HostManager::GetEntityPointer, engine T0) and homes at the LIVE
replicant, as the binary's GHOST missiles did (CLASSMAP: Projectile
PTR_LAB_005129e8 authoritative / 005129f4 ghost performances) -- peers now see
detonations at the true position. Point fire / local-only targets (wrecks,
hostID -1) still fly to the frozen aim, which is correct for them.
## Weapon beams (the unported dpl_* layer) ## Weapon beams (the unported dpl_* layer)
The `dpl_*` beam renderable was never ported → beams are drawn by `BTPushBeam`/`BTDrawBeams` The `dpl_*` beam renderable was never ported → beams are drawn by `BTPushBeam`/`BTDrawBeams`
@@ -255,8 +260,10 @@ streamed via INTEREST.cpp; ARENA1 = 124 icons, each with a damage zone, `Explosi
TakeDamageMessageHandler` → zone `damageLevel ≥ 1` → **Explosion entity** (Crunch variant for TakeDamageMessageHandler` → zone `damageLevel ≥ 1` → **Explosion entity** (Crunch variant for
collision) + `WaitingToBurn` (+`timeDelay`) → `BurningState` + `UpdateCollisionVolumes` (boxes collision) + `WaitingToBurn` (+`timeDelay`) → `BurningState` + `UpdateCollisionVolumes` (boxes
DELETED on `removeOnDeath`, else sunk by `destroyedYTranslation`). The mech-side crunch dispatch DELETED on `removeOnDeath`, else sunk by `destroyedYTranslation`). The mech-side crunch dispatch
(`mech4.cpp ProcessCollision``BTDispatchCollisionDamage`, ×0.001 ram economy) reaches icons (`mech4.cpp ProcessCollision``BTDispatchCollisionDamage`, RAW since the 2026-07-31 pricing
(≈19 walking-speed bumps kill the spawn-yard truck). What was BROKEN (the "no state change" audit — the ×0.001 era needed ≈19 walking-speed bumps to kill the spawn-yard truck; raw is the
binary's own dispatch [T1], so a mech contact now crunches a truck-scale icon outright, per the
authored icon armor). What was BROKEN (the "no state change"
report): the 2007 port fully stubbed **`StateInstanceSwitchRenderable`** ("STUBBED: DPL RB report): the 2007 port fully stubbed **`StateInstanceSwitchRenderable`** ("STUBBED: DPL RB
1/14/07") — the intact→rubble visual swap NEVER fired. Revived D3D9-native: the switch now 1/14/07") — the intact→rubble visual swap NEVER fired. Revived D3D9-native: the switch now
controls the draw component via the **`SetDrawObj` in-place drawable swap** (the mech controls the draw component via the **`SetDrawObj` in-place drawable swap** (the mech
@@ -319,6 +326,103 @@ Diags: `BT_CULT_LOG` (census/damage/death + `[cultvis]` + `[cultobj]` per-object
flags). MP: replicants transition via `ReadUpdateRecord` → same SetState watcher path (untested flags). MP: replicants transition via `ReadUpdateRecord` → same SetState watcher path (untested
cross-pod). cross-pod).
## Mech ARMOUR DARKENING (the .DZM material-damage system) — RECONSTRUCTED 2026-07-31 (Gitea #87) [T2]
A mech's armour panels **darken as their damage zone takes damage** — a per-zone, per-material
colour ramp-down, NOT a texture swap, a filter, or geometry. Players reported the port showing only
the limb swap ("the actual enemy mech in external view is not showing darkened armor panels").
**The 1995 chain** [T1]:
- **`.DZM`** (`VIDEO\<mech>SKIN.DZM`, 40 shipped) lists, per damage zone, the materials that zone
paints: `[dz_ltorso] material=avaskin:avat2_dz_ltorso_mtl`. One file per SKIN VARIANT — the
`dzm`/`dzms`/`dzmt`/`dzmo`/`dzma`/`dzmb`/`dzmc`/`dzmd` key family (same suffix set as the
`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
port all along**; only the consumer was missing.
- **`BTL4VideoRenderer::MakeMechRenderables` (`FUN_004cef28`)** walks the zones and builds, per
(zone, material), a 232-byte watcher **`FUN_004573e4(material, &zone->damageLevel, 0.1f)`**
(a 60-byte per-zone parent first, factor 0.04). Error string: *"BTL4VideoRenderer: Material name
<X> could not be found"* @0x51d6f8.
- The watcher snapshots the material's **16 authored colour floats** — Ambient(3), Emissive(3),
Diffuse(3), Specular+shininess(4), Opacity(3) — precomputes a DAMAGED set = **pristine x 0.1**
(`0x3dcccccd`), and **`FUN_00457784`** re-pushes `lerp(pristine, damaged, damageLevel)` whenever
the level changes (constant `@0x4579a4` = **1.0**). So a zone at full damage renders at **10%**
of authored brightness, continuously interpolated.
- **Faithful quirk:** Opacity is read and lerped but **never written back** — only 13 of the 16
values are pushed (`dpl_GetMaterialOpacity` has no matching Set call in the watcher).
**NOT the mechanism** [T2]: the engine also has `SV_SPECIAL DAMAGE <material>` /
**`dpl_Damagize` `FUN_004902b0`** (punch's sibling — same VPX cmd 0x20, tokens
`f_t_dam(0x92)/sca(0x05)/undamage(0x04)`, dispatch @0x4599ae; error *"SPECIAL GEOGROUP DAMAGE
couldn't find material"*). A scan of **all 879 shipped BGFs finds ZERO `DAMAGE` tokens** — the path
exists in the engine but no shipped asset uses it. Don't chase it.
**The port** (`game/reconstructed/btl4vid.cpp` `BindArmourDamage`/`TickArmourDamage`,
`engine/MUNGA_L4/L4D3D.cpp`): batches now carry their AUTHORED (pre-substitution) material name
(`BgfDrawBatch::matName` -> `L4DRAWOP::dzMatName`); binding resolves the .DZM lists onto real draw
ops once per tree build, and the per-frame tick (driven from `Mech::PerformAndWatch`, EVERY mech)
copies `zone->damageLevel` onto them. Two hard-won details:
- **Key the material list on `viewSkeleton`, not the build `skeletonType`.** Each skeleton variant
is painted from its own skin library (Black Hawk N = `blhskin:`, X = `blxskin:`) and
`ApplyViewSkeleton` reloads every segment mesh; keying on the build skeleton matched a list
belonging to geometry that is not on screen -> **zero bindings, no darkening**.
- **Scaling the `D3DMATERIAL9` is inert** — see gotcha **§23** (`DIFFUSEMATERIALSOURCE` defaults to
vertex colour). The darkening is applied as a `TEXTUREFACTOR` modulate on texture stage 1.
Rebinding runs after every geometry swap (`RemakeEntityRenderables`, `ApplyViewSkeleton`), which
also keeps `segPick` (the #73 aimed pick) off discarded meshes.
Bench: `scratchpad/night7/armor_darken.sh` (self/enemy/ext legs) + `armor_ab.sh` (forced-level
pixel A/B). Gates `BT_ARMOR_LOG` / `BT_ARMOR_FORCE=<0..1>`. Verified: 57 bindings on the enemy
mech, brightness tracking `1 - 0.9*level` exactly, hull 0.35x vs terrain control 0.98x at level 1.
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 ## Key Relationships
- Geometry/LOD: [[bgf-format]]. Base: [[wintesla-port]] (L4D3D). Shadow/visual-conform: [[locomotion]]. - Geometry/LOD: [[bgf-format]]. Base: [[wintesla-port]] (L4D3D). Shadow/visual-conform: [[locomotion]].
- Renders on: [[pod-hardware]] (main 3D view). - 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 = Plus `BTL4OPT.EXE` as a behavioral **oracle** (run under cdb / read the decomp). Result =
behavior-equivalent **reconstruction**, NOT original source. [T2] 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 ## Key Relationships
- Feeds: [[reconstruction-method]] (the loop that fills the gap), [[decomp-reference]]. - 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. - Route A closed 2026-07-19 (Nick holds nothing further); gated now only on reconstruction labor + the engine-closure backfill.
+102 -13
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 - **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). conducts to the central sink via the linked-sink roster).
- **WAVE 4** — standalone readouts: Sensor/Searchlight/ThermalSight/AmmoBin (de-shim, gate fixes). - **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 - ✅ **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 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` `PowerWatcher::GetMessageHandlers()` with its own id-3 `ToggleLamp`. **Verified live**: pad `0x14`
@@ -66,7 +69,69 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
change needs its own verification pass). change needs its own verification pass).
- **WAVE 5** — Torso (aim twist/elevation; joint-I/O reconstructed; the BLH record disables torso → - **WAVE 5** — Torso (aim twist/elevation; joint-I/O reconstructed; the BLH record disables torso →
faithfully no visible twist). faithfully no visible twist).
- **WAVE 6** — Myomers (mover-coupled; structural un-stub is INERT — no live mover/heat coupling). - **WAVE 6 — ✅ MYOMER SYSTEM COMPLETE (2026-07-31). The "mover cutover" concept is DEAD — it
was built on a misattribution.** `@004b9550/@004b95b8` (the "ConnectToMover/DisconnectFromMover"
bodies) are **MechWeapon::ConfigureMappables/ChooseButton** — their `+0x31C` is the weapon
TriggerState and `**(mech+0x128)` is the CONTROLS MAPPER's button roster (proven by the
MECHWEAP.CPP assert string + the trigger-edge detector @004b9608 adjacent).
**⚠ SAME-DAY CORRECTION (the seek audit): the binary DOES have a dynamic myomer→speed feed —
it lives in the un-exported master-perf gap** (@0x4a9cf2-0x4a9da4, raw capstone; the same gap
that hid the #93 crash block). Per frame: MAX `speedEffect@0x31C` over the myomer chain
(+0x7AC) → `mech+0x79C`**`mapper->speedDemand@0x128 *= it`**, and at |drive| ≤ 1e-4
(@0x4ab16c) the mapper's `turnDemand@0x12C` is ZEROED — dead/overheated myomers freeze speed
AND turn. The morning's "no feed" verdict was export-gap blindness (text sweeps cannot see
un-exported functions; the decisive tool was a raw-image capstone scan for FPU reads of
+0x31C). Field truth agrees: Oracle (pod veteran) — seek-4 humanoids ran 182 kph (the
manual's printed Super Charged figure), overheat = "freezing up". The port restored the
demand scale byte-grounded (mechmppr.cpp): MAX over the chain, UNCLAMPED (gear 4 rides ~1.43×
demand into the RegisterMaxOutput cap = SUPERCHARGE), turn-freeze included; damage slows
(speedEffect carries 1damage — gitea #75's original premise was RIGHT).
The rest of the myomer system, all live in the port:
1. **Heat** — the drive-heat integrator (#85, below).
2. **Vital death** — myomers are the ONLY `vital=1` subsystem in the authored records
(BTL4.RES `res+0x48` scan): destroying them KILLS the mech (the #80 vital-crit path). This
is also the collision ram-death mechanism (myomers are a 0.35-weight rattle target).
3. **The assembly cap**`RegisterMaxOutput @004b8ef0`: `mech+0x7A0 = max(cap,
AvailableOutput(topGear))` = base × ~1.4284, the gait rise-clamp both channels bound
against (part_012:12103/12334). Port: each myomer registers per tick (idempotent max();
the 0x358 layout lock leaves no latch room) — state-identical to once-at-assembly. The
old per-frame `reverseSpeedMax2 = reverseStrideLength` heal (which would have clobbered
it) is garbage-guarded only now. `Mech+0x34C` (base) = port `reverseStrideLength`,
`Mech+0x7A0` (cap) = port `reverseSpeedMax2` — both documented misnomers.
4. **The ENG-page power curve** — SeekVoltageResponse, now at the correct absolute scale
(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
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,
gears 1-3 are literally identical (heat ratio floors at 1, no speed coupling); gear 4 =
double heat + a tighter dropout threshold for nothing but the taller graph — a trap.
(A `speedDemand *= speedEffect` multiplier was removed in the morning commit and RESTORED,
byte-grounded and improved, the same day — see the correction above. Verified live: healthy
drive=1.0, collision-rattled myomers drive 0.89→0.74 with demand scaling in lockstep.)
**✅ MYOMER DRIVE HEAT LIVE (#85, 2026-07-31) [T2].** The five Mech motion operands of the
drive-heat integrator `@004b8d18` were `return 0.0f` cross-family shims, so the (fully
reconstructed) integrator accumulated `ratio² · damageGain · 0` every tick — myomers ice cold at
any seek (Oracle's night-7 panel-confirmed report). Operands re-grounded from the raw disasm +
the decomp and mapped to named members via the complete-TU bridge `BTMechMyomerMotionSample`
(mech4.cpp): `+0x1C4` vec = `localVelocity.linearMotion`; `+0x82C` vec =
`localAcceleration.linearMotion` (the authentic 15-ring smoothed AccelerationLastFrame);
`+0x20C` = `moverMass` (the collision divert's cell — the old "motion gain" label was wrong);
`*(+0x250)` = the environment **gravity pointer** (`FUN_00421e2c` does `vy -= **(+0x250)`/tick).
Physics: `heat += ratio²·(1+dmg)·[0.005·½mv² + 0.2·m·g·|vy|·dt + 0.2·m|v||a|·dt]` — kinetic work
+ climb power + acceleration power. The binary `fabs`es v.y (the port's old draft didn't).
**Authored tuning extracted from BTL4.RES** (all 18 mech variants share one record):
VelocityEfficiency=0.995, AccelerationEfficiency=0.8, seek gears 0.3/0.5/0.7/0.9999 rec idx 2;
myomer thermal profile startT=77 / degradation=1000 / failure=2000 / conductance=1.9e5 /
thermalMass=2.5e5. Live-verified (solo arena, BT_MYO_LOG): standstill ≈ 0 generation, hard
circling drove T 77→1225 (past degradation) and the coolant loop pulled it back to ~520
equilibrium on slowing. ⚠ Two carried caveats: (1) the kinetic work term has **no dt** in the
binary (per-tick accumulate, a 1995 fixed-frame assumption) → heat/s scales mildly with frame
rate; kept verbatim, field-calibrate against pod veterans. (2) the climb term is wired but
inert: `Mover::localEnvironment` is never populated in the port (EnvironmentZone res type 23
unwired), so g reads 0 — see [[open-questions]].
⚠ **The registered Performance is the WRAPPER @004b8b9c, not the inner integrator @004b8d18** ⚠ **The registered Performance is the WRAPPER @004b8b9c, not the inner integrator @004b8d18**
[T1, fixed 2026-07-28]. The ctor @004b8fec stores `[0x511620]` into activePerformance, and that [T1, fixed 2026-07-28]. The ctor @004b8fec stores `[0x511620]` into activePerformance, and that
pointer resolves to `0x4b8b9c`, whose first instruction is `call 0x4b0bd0` = pointer resolves to `0x4b8b9c`, whose first instruction is `call 0x4b0bd0` =
@@ -92,18 +157,16 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
thermal curve and zone damage; `@004b8ceb` run the inner integrator **only when `outputVoltage > 0`**. thermal curve and zone damage; `@004b8ceb` run the inner integrator **only when `outputVoltage > 0`**.
Verified live: healthy `outV=10000 speed=1`, un-powered `outV=0 speed=0`, and 96/96 torso samples at Verified live: healthy `outV=10000 speed=1`, un-powered `outV=0 speed=0`, and 96/96 torso samples at
`elec=4` across two death/respawn cycles. `elec=4` across two death/respawn cycles.
**`@004b8bb9` is DEAD CODE — in the original too** [T0]. It builds a `Damage` ✅ **CORRECTED 2026-07-29 (#80): `@004b8bb9` — the un-powered self-repair — is LIVE, in 1995 and
(type=`Explosive`, amount=`0xbc343958`≈-0.011f, impactPoint=`owner+0x100`, burst=1) and calls the now in the port.** The 2026-07-28 "dead code in the original too" verdict here was wrong about the
**zone's** `TakeDamage` (zone vtable `+0x18`, *not* the subsystem's `+0x24`). Reading it as original: the frozen-at-0.6 experiment was measuring a PORT bug (the subsystem ctor wrote the
"an un-powered myomer heals" is the evident intent, but it can never fire: a subsystem's private zone zone's armour/scales through the `ReconDamageZone` proxy at struct offsets +4/+8 instead of the
comes from the 2-arg `new DamageZone(this,0)`, and `engine/MUNGA/DAMAGE.cpp:187-190` zeroes **all five** engine members at +0x140/+0x144, so the real `damageScale[]` stayed zero). The binary ctor
`damageScale[]` entries; `Reset()` never touches them and the only other writer is `Mech__DamageZone` (`@0x4ac7bb`) initializes them from the resource keys `WeaponDamagePoints` + the five per-type
(the mech's *streamed* zones — `mechdmg.cpp:246-253`). Since `TakeDamage` is `...DamagePoints`; the port now does the same through the engine's named members, and the CSS
`damageLevel += amount * damageScale[type]`, the sum is always `+= amount * 0`. Confirmed by parses the keys. An un-powered, not-yet-destroyed myomer heals at `0.011 × damageScale[Explosive]`
experiment: seeded to 0.6 and held at NoVoltage ~1500 ticks, `damageLevel` never moved. per tick. The retracted "cannot damage a subsystem's own zone" rule is superseded — see
Reconstructed and deliberately NOT "fixed" — inventing a working repair would be made-up behavior. [[combat-damage]] §CORRECTED for the full mechanism (this also revived the crit sink, #80).
**General rule:** you cannot damage a subsystem's own zone through `DamageZone::TakeDamage`; the crit
path writes `damageLevel` directly (`DistributeCriticalHit` pins `*(this[0x38]+0x158) = 1.0f`).
Also un-stubbed here: `Myomers::DamageStructureLevel()` returned a hardcoded `0.0f`, which pinned Also un-stubbed here: `Myomers::DamageStructureLevel()` returned a hardcoded `0.0f`, which pinned
`AvailableOutput`'s `(1 - damage)` factor at 1 — a shot-up myomer drove exactly as well as a fresh `AvailableOutput`'s `(1 - damage)` factor at 1 — a shot-up myomer drove exactly as well as a fresh
one. Now routed to the base bridge `GetSubsystemDamageLevel()`; measured `dmg=0.6 → speed=0.4`. one. Now routed to the base bridge `GetSubsystemDamageLevel()`; measured `dmg=0.6 → speed=0.4`.
@@ -147,6 +210,32 @@ its electrical state. The full chain, byte-verified [T1]:
- **STILL DEAD:** factory loops 2-4 (heatable/weapon/damageable capability rosters) go through the - **STILL DEAD:** factory loops 2-4 (heatable/weapon/damageable capability rosters) go through the
`SubProxy` stub whose `IsDerivedFrom` returns 0 — they add NOTHING. See [[open-questions]]. `SubProxy` stub whose `IsDerivedFrom` returns 0 — they add NOTHING. See [[open-questions]].
## Coolant LEAKS (Gitea #88 fix, 2026-07-31) — the #64 shadow was the single break [T2]
`HeatSink::UpdateCoolant` (@004adbf8) prices the damage-driven leak: `coolantDraw =
ownZone->damageLevel × heatLoad`, floor 0.0025, and the `coolantActive@0x138` hysteresis
(ON >0.003) IS the `ReportLeak` attribute the 19 authored leak watchers (3-note warning) ride.
The read is the **qualified ENGINE member** `Subsystem::damageZone@0xE0` — which the port's
MechSubsystem ctor never assigned (it filled only its re-declared shadow, gitea #64 / gotcha #1),
so `zoneDamage` pinned 0 and a leak was **structurally impossible** regardless of damage. Fixed by
ALIASING the engine base member to the same zone in both MechSubsystem ctors (mechsub.cpp) — every
shadow reader is untouched (same object), the engine base `Subsystem::TakeDamage` null-AV is
disarmed, and the #64 full de-shadow sweep remains the long-term cleanup. Verified live: collision
rattle and weapon crits (`[critroll]`) both drive `[cool]` leak lines (draw ≈ dmg×heatLoad, level
draining, hysteresis arming). **Authored damage routing** (BTL4.RES): collision rattle targets
HeatSinkBank 0.3 / Gyro 0.35 / Torso 0.25 / Myomers 0.35 — Condenser + Reservoir are authored 0
for collisions; weapon crits select via the ZONE's crit-entry list.
**The central pull-through is ALSO live (same day):** `HeatSink::DrawCoolant` was a `return 0`
stub; the real base @0x4add00 (disassembled) RECURSES UP the sink linkage — `return
resolve(linkedSinks)->DrawCoolant(requested × coolantFlowScale@0x15C)` — terminating at the
Reservoir's supply (@0x4af3b0, already faithfully ported as `Reservoir::DrawCoolant`: clamp to
[0, coolantLevel], drain, return granted). Binary terminal hop = the bank's slot-14 override
@0x4ae8b0 resolving its reservoir connection @0x1D8 (the port's `helper` member — NOT vestigial);
the port terminates equivalently via the Reservoir-ctor Attach into the bank's `linkedSinks` +
the Reservoir override (the extra bank hop scales by its ctor-default flowScale 1.0 — a no-op).
Verified live (`[resdraw]`): a destroyed myomer's leak pulled the central Reservoir tank
6.0 → 5.58 over ~30 s — **`Reservoir/CoolantMass`, the cockpit coolant BAR, now visibly drops
as a damaged mech bleeds coolant**, and DeathReset refills it on respawn.
## The coolant FLUSH (Gitea #7, 2026-07-19) — Reservoir InjectCoolant end-to-end [T2 live-verified] ## The coolant FLUSH (Gitea #7, 2026-07-19) — Reservoir InjectCoolant end-to-end [T2 live-verified]
The manual-p24 coolant button (coolant MFD top-right; punch or HOLD): message id **4 "InjectCoolant"** The manual-p24 coolant button (coolant MFD top-right; punch or HOLD): message id **4 "InjectCoolant"**
on the Reservoir (handler table @**0x50e680**, one entry → @4aee70 — same per-receiver id space as the on the Reservoir (handler table @**0x50e680**, one entry → @4aee70 — same per-receiver id space as the
+210
View File
@@ -0,0 +1,210 @@
---
id: test-harness
title: "The Test Harness — how benches run, and what counts as verified"
status: living
source_sections: "scratchpad/night6/bench_common.sh (the contract); build-and-run.md §bench-parity; nights 6-9 bench scripts; the #86/#110 verification failure"
related_topics: [build-and-run, reconstruction-method, multiplayer, reconstruction-gotchas, experience-levels]
key_terms: [bench, harness, field-composition, scalpel]
open_questions: []
---
# The Test Harness — how benches run, and what counts as verified
Two subjects, deliberately in one file: the MACHINERY (how to launch and read a
bench) and the DOCTRINE (what a bench must show before a fix may be called
fixed). The doctrine exists because the machinery was used wrong more than once.
## THE DOCTRINE: verify the FIELD COMPOSITION, not a constructed proxy
**The archetype failure (#86#110).** "Weapons on damaged arms keep firing"
was fixed and verified by destroying the WEAPON directly (`BT_KILL_SUBSYS`).
The field scenario was the ARM ZONE dying while the weapon stayed healthy — a
path the bench never took. The claim "fixed" stood for two playtests while the
players kept hitting the bug; the actual defect (a stubbed cascade walk,
gotcha #26) sat exactly in the gap between the proxy and the field. **A fix
verified only against a reproduction you constructed is not verified.**
Rules, in order of application:
1. **Reproduce the reported scenario first** — the player's composition, not a
convenient adjacent one. If the report is "peer's arm shot off in MP", the
proof is a two-node run where PEER FIRE kills the arm (`mp_armgrind.sh`),
not a solo self-damage run.
2. **Scalpels are for isolation, not for verdicts.** Deterministic hooks
(`BT_SELF_DAMAGE_ZONE`, `BT_KILL_SUBSYS`, `BT_FORCE_SEEK`, `BT_ARMOR_FORCE`)
are the right way to LOCATE a defect. Before a scalpel result supports a
"fixed" claim, prove PATH IDENTITY to the field composition (e.g. the
self-damage harness dispatches a real `Entity::TakeDamageMessage` — the
same message + handler a peer round takes — so the scalpel shares every
line from the handler down; verified 2026-08-02, and then the field
composition was STILL run).
3. **If the symptom is multiplayer, the proof is two nodes.** Master-side
correctness says nothing about what a peer sees; replication is its own
layer (records, edge-detects, instance gates). #94, #84 and #110 each had a
peer-side half invisible to any solo bench.
4. **If the symptom is visual, the proof is pixels**`BT_SHOT_EVERY` frames
diffed against a control region, not a log line saying the value changed
(gotcha #23: a colour change that logs perfectly and renders nothing).
5. **Coverage claims need a sweep, not an instance.** "Works for every weapon
type / chassis" = enumerate the axis and measure each point (all 3 fire
gates through a zone death; all 8 chassis' authored cascade flags —
`zonesweep.sh`). Per-chassis behaviour usually lives in AUTHORED DATA, so
fixed code on one chassis proves nothing about the others.
6. **Chase every anomaly in a passing run to ground.** The Owens rack cascade
also killing the sensor suite looked like a walk bug; it was authored
skeleton data (the mast is a child of the rack). A pass with an
unexplained extra effect is not yet a pass.
## THE MACHINERY
### The contract: `scratchpad/night6/bench_common.sh`
The single source of truth for launching a node THE WAY A PLAYER'S SHORTCUT
DOES — read the file, its comments carry the incident history. Summary:
- `bt_player_env``BT_PLATFORM=glass`, `BT_START_INSIDE=1` (cockpit, not
chase cam), `BT_DEV_GAUGES=1`. Copied verbatim from the shipped .bats;
`bt_assert_player_env` warns on drift.
- `bt_launch <log> <egg> <affinity> [args]` — one node, env-scoped, PID
tracked in `/tmp/bt_bench_pids.$$`.
- `bt_expert_egg` — shipped eggs are ALL `experience=expert`; a novice egg
silences heat/crits/jams and is only legal for the gimp bench (which must
say so out loud). See [[experience-levels]].
- `bt_kill_ours` — teardown kills ONLY this run's PIDs (a blanket
`taskkill /IM` once shot down two live sessions).
### Single-node bench skeleton
```bash
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 2 # stale-node clear
sed "s/^map=.*/map=grass/; s/^time=.*/time=day/; 0,/^vehicle=.*/s//vehicle=$V/" MP.EGG > X.EGG
export BT_<gates>=... # diagnostics + scalpels
bt_launch x.log X.EGG 0x03
sleep <duration>; taskkill //F //IM btl4.exe; sleep 2 # then grep x.log
```
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
bt_expert_egg MP.EGG X.EGG
BT_MP_LOG=1 <gates> bt_launch x_b.log X.EGG 0x0C -net 1601 # node B first
sleep 2
BT_MP_LOG=1 <gates> bt_launch x_a.log X.EGG 0x03 -net 1501 # node A
sleep 5
python ../tools/btconsole.py X.EGG 127.0.0.1:1501 127.0.0.1:1601 & # the relay STARTS the mission
sleep <duration>; kill $relay; bt_kill_ours
```
- A `-net` node renders NOTHING until `btconsole.py` (the headless relay)
starts the mission — a black window is pre-mission, not a hang.
- Affinity: TWO logical processors per node, disjoint sets (`0x03`/`0x0C`);
one LP starves the gauge executive ([[multiplayer]] peer-shakiness fix).
- Give the SHOOTER dense fire (`BT_AF_PERIOD=3` + `BT_AF_MISSILE=1`) and the
victim sparse (`=9`) when one side must lose a grind. Unthrottled autofire
trips the FailureHeat all-weapons brick and combat dies.
- Combat drive: `BT_GOTO=enemy BT_GOTO_STOP=<units>` — 100 ends in a visual
ram scrum; 180 gives a standing exchange.
- Read each claim on the node that can see it: damage on the VICTIM's log,
replication on the OBSERVER's (`[zone-repl]`, `[mlrec]`, mirrored `DET`s).
### Process hygiene (every one of these cost a session)
- **Stale-node taskkill FIRST.** A leftover node joins the next lobby as a
third instance and poisons the run.
- **Never double-background.** `run_in_background` + an inner `&` orphans the
script mid-startup: nodes launch, the script's own cleanup never runs.
- **Teardown kill order closes windows one at a time** — a "crashed" window at
the end of a timed run is usually the bench's own taskkill.
- **Stale exe:** `LNK1104` / a bench ignoring new diagnostics = the previous
process still holds `btl4.exe`, or the build silently didn't run — check the
`btl4.vcxproj ->` line printed, then rerun.
- **Editing source through bash-heredoc python collapses `\` one level** even
single-quoted -- a `"\n"` you meant as backslash-n arrives as a real newline,
the "fix" silently no-ops (`good == bad`), and the C2001 hunt repeats. Build
backslashes as `bytes([92])`/`chr(92)`, and verify a replace by LENGTH DELTA
or a re-read, never by the script printing "fixed".
- Bench artifacts (`*.EGG` copies, `*_NNN.png`, bench logs) must be deleted
from `content/` before any dist cut; field logs are never committed.
### Reading logs without fooling yourself
- **A capped/throttled diagnostic that is silent is NOT evidence of absence**
— the #99 `[seqrun]` shared counter "proved" a sequence never ran; it was
throttle starvation. Per-object throttles for per-object questions.
- **Alarm-line counters are not trend lines**`free=0` printed AT the
failure is true by definition; the 30s census carries the trend (#32, three
separate times).
- **Log the actor's NAME at every refusal/decision** (`'PPC' fire REFUSED`);
anonymous counters (`FIRED #20`) cannot support attribution claims.
- Field logs have NO diagnostic gates set — a fix whose acceptance evidence
sits behind an env var is unverifiable in the field. Spawn-time summaries
(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).
## 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)
+9 -2
View File
@@ -120,8 +120,15 @@ Local player's own death → `BTStartWarpCollapsePOV()` (btplayer.cpp `VehicleDe
`deathCount==-1`, guarded `this==GetMissionPlayer()`). Respawn `DropZoneReply``BTStartWarpExpandPOV()` `deathCount==-1`, guarded `this==GetMissionPlayer()`). Respawn `DropZoneReply``BTStartWarpExpandPOV()`
(local-guarded). `BTWarpForceUnmask()` on mission-end / no-DropZones. **Peer warp IS wired + visible (local-guarded). `BTWarpForceUnmask()` on mission-end / no-DropZones. **Peer warp IS wired + visible
(`160b78e`):** an observer sees a peer's death/respawn warp — the replicant un-wreck fires the (`160b78e`):** an observer sees a peer's death/respawn warp — the replicant un-wreck fires the
world-anchored `BTStartWarpEffect(x,y,z)` at the peer's position (mechdmg.cpp:1074, gated to world-anchored `BTStartWarpEffect(x,y,z)` at the peer's position (`MechDeathHandler::Tick`,
`ReplicantInstance`; `simulationState` rides every update-record header so the observer tracks the since the #94 fix triggered by the death-state EXIT edge of the replicated `MovementMode`
cause-agnostic, covers zone-less collision deaths; gated to
`ReplicantInstance`; the effect machine is a SINGLE global slot, and since 2026-07-30 the local
pilot's own POV lifecycle OWNS it — `BTStartWarpEffect` self-skips while `gWarpPhase!=0 && gWarpPOV`
(`[tloc] peer warp SKIPPED`), because a peer's un-wreck landing mid-collapse/wait/expand used to
overwrite the machine and kill the POV vortex ("my respawn had no blue whirlwind"). Invisible
before the #81 respawn fix only because overlapping respawns barely existed; 2-node bench: 20/20
POV collapse+expand pairs, 9 peer spheres played, 11 correctly skipped; `simulationState` rides every update-record header so the observer tracks the
peer's state). The ONLY remaining nuance [T3, non-gating]: the observer's peer sphere is anchored to peer's state). The ONLY remaining nuance [T3, non-gating]: the observer's peer sphere is anchored to
the peer's WORLD position rather than the peer's authentic `DropZoneLocation` (that attribute isn't the peer's WORLD position rather than the peer's authentic `DropZoneLocation` (that attribute isn't
replicated) — a fidelity refinement, not a missing effect. replicated) — a fidelity refinement, not a missing effect.
+24
View File
@@ -16,6 +16,30 @@ on top of it. Full detail: `docs/PROGRESS_LOG.md §5b, §8`.
## What WinTesla already did (do NOT rebuild) ## What WinTesla already did (do NOT rebuild)
- **Renderer bypass: DONE**`MUNGA_L4/L4D3D.cpp` + `DXUtils` replace libDPL / the IG board with - **Renderer bypass: DONE**`MUNGA_L4/L4D3D.cpp` + `DXUtils` replace libDPL / the IG board with
Direct3D9. No `libdpl.lib` in the tree. (The early from-scratch D3D9 viewer was retired and removed.) [T1] Direct3D9. No `libdpl.lib` in the tree. (The early from-scratch D3D9 viewer was retired and removed.) [T1]
- **Device-loss: the #35 "Owens crash" — ROOT-CAUSED + FIXED 2026-07-29** [T2, bench-reproduced].
8 byte-identical field stacks (one player, Surface Pro 9 / **Iris Xe 128 MB** — the only GPU in
the fleet that ever actually loses the device): `ParticleEngine::Destroy +0x11`, `access=0
target=0x0`, from the per-frame render path. **Not a weapons bug** — the Owens two-trigger
missile+laser combo is merely what provokes the GPU timeout on that iGPU. The broken protocol
(both Present sites, inline copies): on `D3DERR_DEVICELOST` they called `Reset()` IMMEDIATELY —
which **always fails while still lost** (`V()` only logged) — then `ParticleEngine::Initialize()`
ran against the lost device, its creates failed and **NULLed the out-params** (verified: the bench
repro faulted at `target=0x0`, not a dangling address), and the NEXT lost frame's blind
`mVertBuffer->Release()` read a vtable at 0x0. FIX (`L4VIDEO.cpp` + `L4PARTICLES.cpp`): one shared
`DPLRenderer::BTResetLostDevice()` — release POOL_DEFAULT resources every lost frame (idempotent,
null-safe `Destroy`), **gate `Reset()` on `TestCooperativeLevel() != D3DERR_DEVICELOST`**, check
the Reset HRESULT, and on success re-create via **`CreateDeviceObjects()` — NOT `Initialize()`**,
whose `memset(mInstalledEffects)` silently killed every particle effect for the rest of the
mission on each reset that DID succeed (the quieter sibling bug). Draw paths guard the NULL buffer
(`ExecuteParticles` keeps draining particles while dormant). Bench: `BT_DEVICELOST_TEST` repro'd
the field crash byte-for-byte pre-fix, and post-fix the same shape logs `SURVIVED` + 3 forced
loss/reset cycles recover (`[render] device reset OK`).
**Discovery from the verify: `VIDEO\particles.png` has NEVER existed** — absent from the tree,
the RES, and all of git history — so `mParticleTexture` has been NULL on every machine since the
engine was written and **all billboard particles draw as untextured quads** (`SetTexture(0,NULL)`
= the shipped look). Deliberately NOT gated on in `RenderParticles` — that would disable all
particles everywhere. Filed on the tracker; a real texture (or the original particle sheet) is a
content task. Possibly related to the field "missile artifact looks wrong" observation.
- **Audio SOURCE BUDGET — the dropout complaint was fixed 2026-07-23; the leftover is NOT the same - **Audio SOURCE BUDGET — the dropout complaint was fixed 2026-07-23; the leftover is NOT the same
bug** [T2, 2026-07-28]. **The reported bug is closed**: `a999e5c` (per-source delete instead of the bug** [T2, 2026-07-28]. **The reported bug is closed**: `a999e5c` (per-source delete instead of the
spec-atomic bulk `alDeleteSources`) stopped the pool leaking, and there have been **no field spec-atomic bulk `alDeleteSources`) stopped the pool leaking, and there have been **no field
+267
View File
@@ -0,0 +1,267 @@
# The GHOST MECH / zombie-wreck failure — field analysis 2026-07-29
## ✅ FIXED 2026-07-30 — two defects that were masking each other
**1. The death latch never released on failure.** The binary's `FUN_004c012c` tail is
`Post(...)``*(this+0x290) = 0``*(this+0x258) = 0`. We had the `Post` and the
`suppressConsole` and were **missing the middle instruction**, so `deathPending` cleared only on
SUCCESS paths. One failed respawn latched the pilot for the rest of the mission — every later death
hit the dedup and was SWALLOWED, so the cycle could never restart. That is what made a transient
hiccup a PERMANENT ghost, and it matches the field signature exactly (8 cycles, 6 stranded, none
recovering). Fixed in `btplayer.cpp` (death-handler tail).
**2. `VehicleDeadMessage` was dispatched TWICE per death.** `BTPostKillScore` (`btplayer.cpp:2263`)
sent a second one "to credit a death" — but that message is the RESPAWN-CYCLE TRIGGER, not a
scoreboard increment, and the tally is already credited by the handler's `++deathTally` (`:538`).
Both fire inside the same death transition (`mech4.cpp:2006` and `:2110`), so they were always
paired. Removed.
**They hid each other:** the duplicate made the latch look necessary, and the latch made the
duplicate invisible — every `SWALLOWED` warning in the field logs was simply the latch deduping our
own duplicate (8 of 8 deaths, a 100 % base rate, which is why it correlated with nothing). Fixing
either alone makes things visibly worse, which is why earlier passes at #57/#55 kept adding
clear-sites instead of finding the root. The binary broke the tie.
**Verified (4-node stress, 2026-07-30):** four simultaneous networked nodes, all dying every ~35 s
on staggered timers so respawn handshakes overlap (the field condition): **56 death cycles, every
completed one `START``RESET`, 0 swallowed / 0 mismatch / 0 discarded / 0 ghost / 0 crash** across
all four. Scoreboard: every node's `PLAYER_DEAD` matchlog rows show `deaths=N tally=N` in lockstep,
one increment per death (13/13/15/15) — the DEATHS column the comms panel draws is exact; the
kill-credit path evaluated all 56 deaths and correctly declined each self-kill (`NOCREDIT self=1`),
KILLS untouched by the fix (separate `ScoreMessage` path). Bench: `scratchpad/night6/mp4_stress.sh`
+ `content/MP4.EGG`.
**Two observations from watching the 4-node run, both run down (2026-07-30):** (1) *"comms panel
counted no deaths"* — the panel machinery is CORRECT (2-node rerun with a draw-value probe: both
rows on both nodes drew 0→9/0→11 live, local + replicated); the 4-node zeros were the artificial
load (4 core-pinned instances → PilotList Execute starved to <0.6 Hz, panel minutes-stale). Probe
stays in (`[score] panel DRAW`, BT_SCORE_LOG). (2) *"a respawn had no blue vortex"* — REAL and now
fixed: the warp effect is one global slot and a PEER's un-wreck sphere (`BTStartWarpEffect`) could
stomp the local pilot's own POV vortex mid-lifecycle; fixed by POV-priority self-skip
(btl4vid.cpp; `context/translocation-warp.md`). Pre-fix this never showed because overlapping
respawns barely existed — the ghost fix CREATED the traffic that exposed it.
**Verified:** solo **17 consecutive death/respawn cycles**, every one `START``RESET`, 0 swallowed /
0 mismatch / 0 crash (pre-fix this strands permanently after cycle 1). Two-node MP over a real
network path with cross-machine drop-zone replies: A 11 cycles, B 12, 0 swallowed / 0 mismatch /
0 discarded / 0 crash. Bench: `scratchpad/night6/mp_ghost.sh`, `BT_SELF_DAMAGE_REPEAT=1`.
**Status of the analysis below:** the mechanism write-up that led to the fix. The instrumentation
(`[dz]`, `[dzreq]`, `[ghost]`) stays in — it is what will prove the fix in the field, and the
cross-machine dependency in §4 is still a real fragility even with the latch fixed. Field session: 2026-07-29 night, Steam MP, 5 players, build 4.11.642. Raw logs and the
verbatim agent findings are in `scratchpad/night6/` (uncommitted — they contain machine names and
Steam identities). Tracker: #81 (zombie wreck), #57 (deathPending latch), #45 (tally replication).
## The one-sentence version
A death whose **drop-zone reply never arrives** leaves the respawn cycle stranded, so the mech is
never reset and never repainted — and because the render-side wreck swap is **one-way**, the pilot
keeps driving a burning hulk that sinks out of the world after ~18 s and then cannot be drawn at
all. The "wreckage moving and shooting" report and the "invisible ghost" report are **the same
failure at two different ages**.
## Established from the field logs [T1]
- Exactly **8 death cycles** in the whole session. Every one has the same 3-line signature at
consecutive lines: `death cycle START``*** DESTROYED ***``WARNING: death ... SWALLOWED`.
- **Only 2 of 8 reached `RESET at drop zone`.** A stranded cycle predicts ghosting **perfectly**:
8/8 cycles, 10/10 player-matches, in both directions.
- Match 2: all four non-host players died, all four stranded, zero RESETs anywhere = "everybody
ghosted". **The host did not ghost because he never died** — his log holds no death cycle at all.
(There is nothing special about being host; an earlier reading of this was wrong.)
- Match 1: three deaths, two stranded, one completed. The completion is the control case, and it is
the "self-corrected for one player" the testers reported.
- ⚠ **The SWALLOWED warning is benign.** It fires on 8 of 8 deaths, including both clean
completions and a build-641 death, so its base rate is 100% and its correlation with ghosting is
zero. It is not even a second death — it is the same death re-entering the notify from inside the
cycle. Do not treat it as a signal (an earlier reading did).
- ⚠ **Player IDs are per-match**: node = lobby token octet + 1, host always `.1`. `player 3:1` is a
different human in every match. Any cross-machine identification must decode this first.
- ⚠ An earlier "three spurious RESETs on the host" finding was an **aggregation artifact** — one
log file spans ~18 sessions and each session legitimately logs one spawn RESET.
## The render mechanism [T1, code]
- `btl4vid.cpp:1255-1300` — the wreck sink is a **pure timer**, nothing about movement: 0.25 s
reveal, then `sink = -0.025·t²`, and at `-8.0` the hulk/debris/flames get `SetDrawObj(NULL)` plus
`[BTrender] wreck buried (sink complete)`. That threshold is reached at **t ≈ 18 s**.
- The wreck objects hang off the mech's own render tree as an offset, so **they travel with the
pilot** — hence a moving, burning, shooting wreck for the first ~18 s.
- `mech4.cpp:1827` — a healthy respawn calls `BTRebuildMechModel` on Reset precisely because "the
wreck swap is one-way on the render side". No reply → no Reset → no rebuild → the hulk sinks and
the player is invisible but still simulated and driveable.
- Peers apply the wreck swap correctly and in a bit-identical order across machines, so the ghost is
**not** an entity peers never learned about — it is an entity whose presentation is frozen dead
and then hidden.
## The stall itself [T0 WinTesla — NOT proven authentic]
`engine/MUNGA/DROPZONE.cpp`: a slot is granted only if `IsAvailable()` — busy for `DOWN_TIME = 5 s`
after use, and any other player's vehicle within 2 m marks it busy. **When no slot is free the
handler reposts the message to itself every 0.1 s at `MaxEventPriority` and never replies**, so the
requester's `deathPending` latch is never cleared.
**Provenance:** `engine/MUNGA` is **WinTesla**, the later Windows port of MUNGA taken from updated
Red Planet (which converted the Division hardware to software). **The 1995 game never ran it.** So
this behaviour is authoritative for what we compile, *not* for what the pods did. BT's own
`munga/dropzone.cpp` is **absent from the decomp export** — the 1995 assignment policy is an
unrecovered gap. Recovering it (byte-scan + windowed disasm, the technique that recovered
`FUN_004a6344`) is the open task.
## 2026-07-30 dig: the respawn handshake, walked end to end
Re-read the whole chain (death → +5 s re-post → engine hunt → DropZone grant → reply → Reset) and
**measured** the parts that were assumptions. Results, in order of importance:
**1. The engine hunt RETRIES EVERY 2 SECONDS, forever, while the mech is dead** [T0]
(`PLAYER.cpp:325-327` posts the message back to itself unconditionally; its only state-based exit is
`GetSimulationState() == DropZoneAcquiredState`, which never happens — see 3). The DropZone also has
a resend net: a repeat request from the same requester + same `deathCount` re-grants the **same**
slot even while it is busy (`DROPZONE.cpp:182-194`). **So "a transient no-slot" and "one lost reply"
are both ELIMINATED as causes** — the request keeps being re-sent and re-granted every 2 s. A
permanent strand needs a permanent reason.
**2. THE SILENT DISCARD — the one remaining unlit path, now instrumented** [T1]
`BTPlayer::DropZoneReplyMessageHandler` is `if (!playerVehicle) … else if (deathCount ==
message->deathCount) … else { return; }`. That final branch was a **bare return with no logging**:
the drop zone granted a spot and replied, the numbers disagreed, and the reply was dropped —
`deathPending` stays latched, the mech is never `Reset`, and the pilot is a permanent ghost **with
zero trace in the log**. That is exactly why 6 of 8 field cycles stranded leaving no evidence. Now
always-on (`[ghost] DROP-ZONE REPLY DISCARDED …`) and it prints the **direction** of the mismatch,
which decides the fix: `msgDeath < ours` = genuinely stale (dropping is correct); `msgDeath > ours` =
**our counter is behind and we threw away a live respawn** — which points straight at #45 (the death
tally does not replicate correctly). Deliberately NOT "recovered" yet: guessing the direction would
be a stand-in, and the wrong guess respawns a mech that is still alive.
**3. ⚠ LANDMINE — do not convert the two `Set_Alarm_Level` raw writes to `SetSimulationState()`.**
`Set_Alarm_Level` is an **empty stub** (`btstubs.cpp:87`), so the death path's
`Set_Alarm_Level(this+0x2c, 1)` and the reply path's `(this+0x2c, 2)` are **no-ops** today. Their
values decode perfectly against `Player`'s enum (`PLAYER.h:273-279`) as `DropZoneAcquiredState`(1)
and `VehicleTranslocatedState`(2) — `DropZoneAcquiredState = Entity::StateCount`, and `Entity` adds
no states, so it is **1**. That makes "obviously these should be `SetSimulationState` calls" a very
attractive and **catastrophic** fix: the engine hunt is gated on `GetSimulationState() !=
DropZoneAcquiredState`, so setting state 1 on death would stop `AssignDropZone` from ever being
dispatched and make **every** pilot ghost permanently.
Measured: our `BTPlayer::simulationState` is at **0x24**, not 0x2c, and the write leaves it at 0
(`[ghost] death raw-write(+0x2c,1): simState 0 -> 0 (hunt gate still open)`). In the binary `+0x2c`
is the **Simulation-base alarm** — the same field the mech side calls `graphicAlarm` (`@0x4ac126`:
"owner alarm+0x2C -> level 9"). Our layout models that only as a `Mech` member, so a `BTPlayer` has
nowhere to put it; the stub is harmless until we recover what reads a *Player's* alarm in the binary
(likely a cockpit/HUD respawn indicator). [T1 offsets, T3 purpose]
**4. EVERY MP RESPAWN IS A CROSS-MACHINE ROUND TRIP — and that is the single point of failure**
[T2, two-node bench `scratchpad/night6/mp_ghost.sh`]. Measured on a 2-node rig with the host:local
labels: each player's request was answered by **the OTHER machine's** DropZone.
```
mp_a.log: [dzreq] player 2:1 asking for a drop zone ... <- A's own player asks
mp_b.log: [dz] GRANTED slot 7 to 2:1 death#1 ... <- B answered it
mp_b.log: [dzreq] player 3:1 asking for a drop zone ... <- B's own player asks
mp_a.log: [dz] GRANTED slot 0 to 3:1 death#1 ... <- A answered it
```
`FindGroup("DropZones")` iterates **every** DropZone entity including replicants of remotely-mastered
ones and picks the geometrically closest, so a respawn is: *my request → an arbitrary peer's
DropZone → that peer's reply → back over the wire to me.* With five players that is five round trips
through arbitrary peers, and **one degraded peer can ghost everybody else.**
That dovetails exactly with the otherwise-unexplained field finding that **one machine stopped
processing the effect/death-transition stream 57 % into match 1 and never recovered** (0 explosions /
0 wreck swaps / 0 burials while the other four logged 4/4/4). A node in that state that owns the
closest drop zone cannot answer anyone's respawn — which is what "everybody ghosted" looks like.
It also explains why solo is 100 % reliable (the whole handshake is in-process) and why the
2-node bench passes (both nodes healthy, replies land, `deathCount` agrees).
**Revised theory of the ghost (two layers):**
- *Structural:* respawn depends on a cross-machine round trip through a peer that may be lagging,
stalled, or holding a stale view of my player. Not drop-zone starvation (`slots=8`) and not a
single lost reply (the hunt retries every 2 s forever).
- *Mechanism when the reply does arrive but is stale/mismatched:* the silent discard in 2 — the
reply's `deathCount` disagrees and it is dropped without a trace, which couples this to #45.
**Two candidate fixes, both needing the next field logs to choose between:**
(a) **Prefer a locally-mastered DropZone** when one exists, keeping the handshake in-process and
removing the remote dependency entirely — cheap and robust, but it changes which physical pad you
land on, so check it against the maps first. (b) **Make the reply path tolerant**: accept a granted
reply whose `deathCount` is *ahead* of ours (our counter is behind) instead of discarding it, and
never leave `deathPending` latched on a discard. ⚠ Neither should be guessed at blind — the
`[ghost] DROP-ZONE REPLY DISCARDED` line's mismatch direction decides it in one line of log.
## HOW THE BINARY HANDLES IT — `deathPending` is OUR INVENTION [T1, decisive]
Read BT's own respawn code (it *is* in the export, even though `munga/dropzone.cpp` is not) and
checked it against the raw binary.
**The hunt is faithful.** BT's `Player::VehicleDeadMessageHandler` = `FUN_0042db80`
(`part_003.c:12029`) gates on `message->deathCount == player->deathCount` **and**
`player+0x40 != 1` — i.e. `simulationState != DropZoneAcquiredState`, exactly WinTesla's two gates —
then runs the same closest-DropZone search skipping `"win*"` zones, dispatches `AssignDropZone`, and
**re-posts the message to itself** on a timer. So the retry loop and the cross-machine hunt are
authentic 1995 behaviour, not a WinTesla artifact.
**But the latch is not.** BT's `BTPlayer::VehicleDeadMessageHandler` = `FUN_004c012c`
(`part_013.c:10504`) ends with:
```
++deathCount; message->deathCount = deathCount; --lives;
updateModel |= 1; alarm(+0x2c, 1); save dropZoneLocation;
Post(HighPriority, self, message, Now()+delay);
*(param_1 + 0x290) = 0; // <-- the field our reconstruction calls deathPending
```
**The binary CLEARS that field at the end of every death.** Verified against `BTL4OPT.EXE` itself:
`+0x290` is written in exactly **three** places in the whole executable
(`file_off 0x0b75fb`, `0x0bffe3`, `0x0c0a05`), and **all three store a zeroed register**
`xor ecx,ecx` / `xor eax,eax` / `xor edx,edx` immediately before. There is **no write of 1, or of
any non-zero value, to `+0x290` anywhere in the binary.** Two of the three are constructor/reset
sweeps; the middle one is the death handler above (it sits right after the `call` to `Post` and
`add esp,0x14`, matching the decompiled tail exactly).
**So in 1995 there is no death-pending gate at all.** Our `btplayer.cpp:505` does
`deathPending = 1`, and six separate sites then have to clear it (`:382 :469 :1431 :1442 :1461
:1532`) — each one a patch for a different stranding scenario. Gitea **#57 and #55 are artifacts of
that invention**, not of the original design.
**And that is what makes a ghost PERMANENT.** In BT a failed respawn is harmless: the 2-second
re-post keeps hunting, and the next death starts a clean cycle. In ours the first failure latches
the pilot — every later death hits the dedup and is SWALLOWED ("deaths so far 1" forever), so the
pilot can never recover for the rest of the mission. That is precisely the field signature: 8 death
cycles, 6 stranded, none of them ever recovering.
**Proposed fix (faithful, and it removes the whole bug class):** match the binary — clear the field
at the end of the death handler instead of latching it, and drop the dedup gate that depends on it.
⚠ Six sites currently rely on that latch's existence, and the SWALLOWED dedup is load-bearing for
the "one death, one cycle" behaviour, so this wants a deliberate two-node bench rather than a
late-night edit. Caveat on the identification: `+0x290 ↔ deathPending` rests on our own
reconstruction's mapping (our compiled `BTPlayer` is only `0x28c` bytes, so the offsets cannot
match directly) — but the *behavioural* evidence (BT's death handler clears it; nothing in the
binary ever sets it) is read straight from the executable.
## What the instrumentation already killed
`slots=8` (measured, `[dz] POOL`). An 8-slot pool **cannot** be cooldown-starved by 5 players (at
most 5 slots on cooldown at once), so the shared `dropzone=one` in `tools/eggmodel.py:42` is **not**
the bottleneck. Suspicion moves to the handshake: a lost reply, or a `deathCount` mismatch — the
engine gates the hunt on `message->deathCount == deathCount` and the DropZone's resend safety net is
keyed on `lastDeathCount == message->deathCount`, so one mismatch breaks both silently (cf. #45).
## Instrumentation shipped (`ba6756c`) — always-on, no env var needed
| line | meaning |
|---|---|
| `[dz] POOL name= slots= downTime= proximityBlock=` | once per mission: the pool size |
| `[dz] GRANTED slot N to entity E death#D waited=Xs` | one per respawn; `waited` is the headline; tags `(GHOST RECOVERED)` past 15 s |
| `[dz] STALL` / `[dz] GHOST LIKELY` | escalating, rate-limited per waiter; names **why** each slot is busy (age + last user) — this discriminates cooldown saturation from proximity blocking |
| `[dzreq] player P asking ... try=N msgDeath= ourDeathCount=` | requester half; flags `*** MISMATCH ***` |
`BT_DROPZONE_LOG=1` adds a verbose per-request slot dump for bench work.
## Next actions, in order
1. **Recover BT's 1995 drop-zone policy** from `content/BTL4OPT.EXE` (decomp gap). The key question:
when no slot was available, did BT also repost forever and never reply? If yes, the stall is
authentic and our divergence is elsewhere; if no, implement what BT did.
2. **Read the next field logs' `[dz]`/`[dzreq]` lines** — they answer cooldown-vs-proximity and
lost-reply-vs-deathCount-mismatch directly.
3. Ask the testers one question that discriminates a second defect: **did a flaming mover stay
visible for more than ~20 s?** If yes, the sink tick is not running for that entity (updater
early-return, or a render-tree rebuild resetting `wreckAge`) — a second bug on top of this one.
4. Separately filed/needed: one machine **stopped processing the effect/death-transition stream**
57 % into a match and never recovered (0 explosions / 0 wreck swaps / 0 burials while the other
four logged 4/4/4) — that machine was playing a stale world, and it is why that player reported
the whole "desync cluster".
## The desync cluster is four separate things, not one bug [T2]
- **Throttle reads zero** — local, and present in the *good* sessions too. ⚠ Unresolved nuance: the
internal `thr` field is 0 in every field `[drive]` sample while mechs moved, but reads 0.8 in solo
benches; whether that is the same thing the player saw on his gauge is **not** established.
- **No weapon audio** — local audio-source starvation caused by the **map's ambient emitters**
(`arena2_morning` ≈ 10.6 effects/s × ~2.5 voices pins the 256-source pool); thousands of
`ACQUIRE FAILED ... pool is exhausted`. Unrelated to crits or the network.
- **Weapons fire without damage** — replication staleness. The shooter resolves damage and awards
its own score locally, so a shooter always *sees* hits even when the victim applies nothing.
- **Other mechs lagging/misplaced** — the same stale-world condition as item 4 above.
## Why tonight and not before
Nothing in 642 broke multiplayer. Critical hits landing for real (first time) made deaths far more
frequent, and every death is a chance for this stall — so a rare race became a common one. Fewer
players ⇒ fewer deaths ⇒ clean matches, which is exactly what the testers observed.
+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 | | 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 | | 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 | | 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) | | 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 | | 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/` | | 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/` |
+18
View File
@@ -404,3 +404,21 @@ 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 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 ram kills), and §Headline-6's fidelity question (the wrong-column slip is still
reproduced; it is merely no longer visible). 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).
+1 -1
View File
@@ -45,7 +45,7 @@ be "called on":
+ `IntegrateMotion`) in `PerformAndWatch`, retiring the Step-1/2 stand-in translation + free-standing + `IntegrateMotion`) in `PerformAndWatch`, retiring the Step-1/2 stand-in translation + free-standing
`gBodyAnim`. Gives the authentic locally-simulated + displayed-motion gait. `gBodyAnim`. Gives the authentic locally-simulated + displayed-motion gait.
**Deferred polish (post-core):** airborne/fall gaits (AdvanceBody/LegAnimationAirborne), torso-twist-to-target **Deferred polish (post-core):** ~~airborne/fall gaits (AdvanceBody/LegAnimationAirborne)~~ — RESOLVED 2026-07-30: those were the GIMP (limp) drivers, now `AdvanceBody/LegAnimationGimp`, live for #78 (see context/locomotion.md §visible limp); torso-twist-to-target
(Torso is FULLY reconstructed — TorsoSimulation twist/elevation @004b5cf0 + WriteJoints; needs wiring), gyro sway. (Torso is FULLY reconstructed — TorsoSimulation twist/elevation @004b5cf0 + WriteJoints; needs wiring), gyro sway.
**First-milestone recommendation:** Steps 1-2 give a visibly correct animation-driven walk/run (real speeds, **First-milestone recommendation:** Steps 1-2 give a visibly correct animation-driven walk/run (real speeds,
+23
View File
@@ -255,3 +255,26 @@ Keep all three unconditional (matchlog only arms on `-net`, `matchlog.cpp:66-76`
8. **Don't reconstruct dtor glue** while touching the heat/power chain terminus; the trailing base-dtor calls run the chain twice. 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]. 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).
+2
View File
@@ -108,6 +108,8 @@ The whole roster sits on the BT base chain `engine Subsystem → MechSubsystem
dampingConstant@0x1F4, ...; recon mislabels + leaves accumulators uninit). Reverted to the stub (no NaN to the root dampingConstant@0x1F4, ...; recon mislabels + leaves accumulators uninit). Reverted to the stub (no NaN to the root
joint). Remaining = a full ctor+integrator reconstruction from @004b3778 (bigger than the torso). Full detail: CLAUDE.md joint). Remaining = a full ctor+integrator reconstruction from @004b3778 (bigger than the torso). Full detail: CLAUDE.md
§10d "WAVE-5 GYROSCOPE". Also: the mech.cpp gyro↔torso cross-link write is broken (SubProxy::linkTarget→gyro+4) — commented out. §10d "WAVE-5 GYROSCOPE". Also: the mech.cpp gyro↔torso cross-link write is broken (SubProxy::linkTarget→gyro+4) — commented out.
- **WAVE 6 — ✅ COMPLETE (2026-07-31): the "mover cutover" was a phantom.** @004b9550/@004b95b8 are MechWeapon button-mapping functions (misattribution); the binary has NO dynamic myomer→speed feed (AvailableOutput's only callers: RegisterMaxOutput at assembly + the SeekVoltageGraph sampler; speedEffect@0x31C is a gauge attribute). Landed: real drive-heat operands (#85), RegisterMaxOutput → mech+0x7A0 gait cap (idempotent per-tick), real OwnerBaseSpeed (graph scale), the invented `speedDemand *= speedEffect` multiplier REMOVED, ConnectToMover/BTMyomersDriveOf deleted. Myomer damage does not slow a live mech; destruction kills (the only authored vital=1 subsystem). Full authority: context/subsystems.md WAVE 6.
(Historical entry below kept for the record:)
- **WAVE 6 — Mover-coupled: ◐ STRUCTURAL un-stub DONE + WIRED (gated `BT_MYOMERS` default ON; verified INERT, no locomotion/combat regression).** Myomers (0xBC6) constructs + ticks its real Performance via `CreateMyomersSubsystem`, but is INERT: `MyomersSimulation` early-returns on `OwnerAdvancedDamage()==False` (real gate = messmgr 0xBD3), and the mover feed (`MoverAttach`) + `SetOwnerMaxSpeed` are no-op stubs so the live JointedMover is never touched. De-shim dropped the `owner*`/`segmentFlags` shim fields (accessors return neutral defaults) to fit the exact-0x358 alloc (`sizeof` lock). ⚠ AUTHENTIC COUPLING DEFERRED: routing `SpeedEffect@0x31C` into the mover + real owner-motion accessors + the advanced-damage heat gate — this DRIVES THE LIVE MOVER, so reconcile with the gait cutover FIRST. BLH tick 26→27; `BT_MYOMERS=0` → Actuator stub. See CLAUDE.md §10d "WAVE 6". - **WAVE 6 — Mover-coupled: ◐ STRUCTURAL un-stub DONE + WIRED (gated `BT_MYOMERS` default ON; verified INERT, no locomotion/combat regression).** Myomers (0xBC6) constructs + ticks its real Performance via `CreateMyomersSubsystem`, but is INERT: `MyomersSimulation` early-returns on `OwnerAdvancedDamage()==False` (real gate = messmgr 0xBD3), and the mover feed (`MoverAttach`) + `SetOwnerMaxSpeed` are no-op stubs so the live JointedMover is never touched. De-shim dropped the `owner*`/`segmentFlags` shim fields (accessors return neutral defaults) to fit the exact-0x358 alloc (`sizeof` lock). ⚠ AUTHENTIC COUPLING DEFERRED: routing `SpeedEffect@0x31C` into the mover + real owner-motion accessors + the advanced-damage heat gate — this DRIVES THE LIVE MOVER, so reconcile with the gait cutover FIRST. BLH tick 26→27; `BT_MYOMERS=0` → Actuator stub. See CLAUDE.md §10d "WAVE 6".
- **WAVE 7 — Projectile/missile weapons:** ProjectileWeapon (0xBCD), MissileLauncher (0xBD0). **PARTIALLY BLOCKED:** case 0xBCE calls `@4bdcb4`, which has no CLASSMAP entry — decompile it first and resolve the 0xBCD/0xBCE/0xBD0 label tangle (Risk 1) before swapping these. - **WAVE 7 — Projectile/missile weapons:** ProjectileWeapon (0xBCD), MissileLauncher (0xBD0). **PARTIALLY BLOCKED:** case 0xBCE calls `@4bdcb4`, which has no CLASSMAP entry — decompile it first and resolve the 0xBCD/0xBCE/0xBD0 label tangle (Risk 1) before swapping these.
- **WAVE 8 — Message hub:** SubsystemMessageManager (0xBD3). Real, but **verify the cache-slot/tick-bypass interaction** (Risk 4) before enabling its tick. - **WAVE 8 — Message hub:** SubsystemMessageManager (0xBD3). Real, but **verify the cache-slot/tick-bypass interaction** (Risk 4) before enabling its tick.
+38 -1
View File
@@ -193,6 +193,33 @@ Background_Loop:
current_application.First(); current_application.First();
application = current_application.GetCurrent(); application = current_application.GetCurrent();
//
// BT MINIMUM BACKGROUND FLOOR (#45 root cause, 2026-07-30). Authentically
// the background tasks run ONLY in the frame's leftover time, with a floor
// of a single pump per frame -- fine on a 1995 pod (one app, designed
// slack), but on a busy MP mission the foreground eats the whole frame
// budget, the floor becomes the norm, and the GAUGE renderer (1 of ~7
// round-robin tasks, ONE gauge advanced per turn) starves: every cockpit
// instrument -- the comms-panel K/D columns, weapon recharge tickers --
// freezes at its last paint while the underlying values (and fps!) stay
// perfectly healthy. Measured: 4-node bench at 70-90 fps gave the
// PilotList ~2 Execute turns in SIX MINUTES. Guarantee a minimum number
// of pumps per frame so a full instrument rotation completes in ~1s
// regardless of slack; the added worst-case cost is bounded and small.
// BT_BG_MIN overrides (0 = authentic slack-only behavior).
//
{
static int s_bgMin = -1;
if (s_bgMin < 0)
{
const char *e = getenv("BT_BG_MIN");
s_bgMin = e ? atoi(e) : 32;
if (s_bgMin < 0) s_bgMin = 0;
if (s_bgMin > 256) s_bgMin = 256;
}
if (backgroundTasksRun < s_bgMin)
goto Background_Loop;
}
if (t2 < end_of_frame) if (t2 < end_of_frame)
{ {
@@ -219,10 +246,20 @@ Background_Loop:
if (Now() - s_lastP >= 1.0f) if (Now() - s_lastP >= 1.0f)
{ {
s_lastP = Now(); s_lastP = Now();
// gauge-executive rotation health (#45): turns = one-gauge
// Updates and sweeps = full active-list passes SINCE THE LAST
// PRINT (~1s); active = the instrument roster size. A frozen
// panel shows here as sweeps=0.
extern int gBTGaugeTurns, gBTGaugeSweeps, gBTGaugeActive;
DEBUG_STREAM << "[perf] frame=" << (float)(Now() - beginFrameTimestamp) DEBUG_STREAM << "[perf] frame=" << (float)(Now() - beginFrameTimestamp)
<< " fg=" << (float)(startBackground - beginFrameTimestamp) << " fg=" << (float)(startBackground - beginFrameTimestamp)
<< " bg=" << (float)(endBackground - startBackground) << " bg=" << (float)(endBackground - startBackground)
<< " bgTasks=" << backgroundTasksRun << "\n" << std::flush; << " bgTasks=" << backgroundTasksRun
<< " gaugeTurns=" << gBTGaugeTurns
<< " sweeps=" << gBTGaugeSweeps
<< " active=" << gBTGaugeActive << "\n" << std::flush;
gBTGaugeTurns = 0;
gBTGaugeSweeps = 0;
} }
} }
} }
+1 -1
View File
@@ -250,7 +250,7 @@ void
return; return;
} }
if (getenv("BT_AUDIO_SPATIAL") && message->controlID == StartAudioControlID) { if (getenv("BT_AUDIO_SPATIAL") && message->controlID == StartAudioControlID) {
static int s_st=0; if (s_st++<40) static int s_st=0; if (s_st++<5000)
DEBUG_STREAM << "[spatial] START request src=" << (void*)audio_source DEBUG_STREAM << "[spatial] START request src=" << (void*)audio_source
<< " vol=" << audio_source_volume_scale << "\n" << std::flush; } << " vol=" << audio_source_volume_scale << "\n" << std::flush; }
+33 -1
View File
@@ -243,6 +243,7 @@ void
audioComponentSocket.Add(audio_component); audioComponentSocket.Add(audio_component);
isLooped = is_looped; isLooped = is_looped;
runProbeCount = 0; // #99 diag
isRunning = False; isRunning = False;
startTime = AudioTime::Null; startTime = AudioTime::Null;
audioControlEventIterator = NULL; audioControlEventIterator = NULL;
@@ -393,7 +394,12 @@ void
AudioControlSequence::StartSequence() AudioControlSequence::StartSequence()
{ {
if (getenv("BT_ATTRBIND_LOG")) { static int s_ss=0; if (s_ss++<40) if (getenv("BT_ATTRBIND_LOG")) { static int s_ss=0; if (s_ss++<40)
DEBUG_STREAM << "[seqstart] seq=" << (void*)this << "\n" << std::flush; } { AudioControlEventIterator probe(&audioControlEventSocket);
DEBUG_STREAM << "[seqstart] seq=" << (void*)this
<< " events=" << (int)probe.GetSize()
<< " looped=" << (int)isLooped
<< " tempo=" << (int)tempo
<< " divPerBeat=" << (int)divisionsPerBeat << std::endl; } }
Check(this); Check(this);
@@ -467,6 +473,17 @@ void
// //
// If the sequence is not running, then return // If the sequence is not running, then return
// //
// #99 probe: PER-SEQUENCE throttle. A single shared counter here hid the
// alarm sequence entirely -- the busy sequences ate every slot.
static const int s_seqDiag = (getenv("BT_ATTRBIND_LOG") != 0); // hot path: resolve once
if (s_seqDiag && isLooped) { if (runProbeCount < 6)
{ ++runProbeCount;
DEBUG_STREAM << "[seqrun] seq=" << (void *)this
<< " isRunning=" << (int)isRunning
<< " iter=" << (void *)audioControlEventIterator
<< " cur=" << (void *)(audioControlEventIterator ? audioControlEventIterator->GetCurrent() : 0)
<< std::endl;
} }
if (!isRunning) if (!isRunning)
return; return;
@@ -478,6 +495,16 @@ void
Check(audioControlEventIterator); Check(audioControlEventIterator);
control_event = audioControlEventIterator->GetCurrent(); control_event = audioControlEventIterator->GetCurrent();
if (s_seqDiag) { static int s_nr=0;
if (control_event != NULL && !IsEventReady(control_event) && (s_nr++ % 500)==0)
{
AudioTime off(startTime);
off += CalculateEventSeconds(control_event);
DEBUG_STREAM << "[seqwait] seq=" << (void *)this
<< " eventSeconds=" << CalculateEventSeconds(control_event)
<< " readyAt-now=" << (Scalar)(off - AudioTime::Now())
<< std::endl;
} }
while ( while (
control_event != NULL && control_event != NULL &&
IsEventReady(control_event) IsEventReady(control_event)
@@ -513,6 +540,11 @@ void
// Send the controller to the connected audio component // Send the controller to the connected audio component
// //
Check(control_event); Check(control_event);
if (s_seqDiag) { static int s_se=0; if (s_se++<60)
DEBUG_STREAM << "[seqsend] seq=" << (void *)this
<< " -> comp=" << (void *)audioComponentSocket.GetCurrent()
<< " ctl=" << (int)control_event->audioControlID
<< "/" << control_event->audioControlValue << std::endl; }
control_event->Send(audioComponentSocket.GetCurrent()); control_event->Send(audioComponentSocket.GetCurrent());
// //
+2
View File
@@ -148,6 +148,8 @@ private:
Logical isLooped; Logical isLooped;
Logical isRunning; Logical isRunning;
int runProbeCount; // #99 diag: PER-SEQUENCE throttle for the [seqrun] probe
// (a shared static counter hid the alarm sequence entirely)
AudioTime startTime; AudioTime startTime;
AudioDivisionsPerBeat divisionsPerBeat; AudioDivisionsPerBeat divisionsPerBeat;
AudioTempo tempo; AudioTempo tempo;
+11
View File
@@ -112,6 +112,10 @@ void
{ {
Check(this); Check(this);
Check(audioComponentSocket.GetCurrent()); Check(audioComponentSocket.GetCurrent());
static const int s_idleDiag = (getenv("BT_ATTRBIND_LOG") != 0); // hot path: resolve once
if (s_idleDiag) { static int s_iw=0; if ((s_iw++ % 400)==0)
DEBUG_STREAM << "[idlewatch] #" << s_iw << " comp="
<< (void *)audioComponentSocket.GetCurrent() << std::endl; }
audioComponentSocket.GetCurrent()->ReceiveControl( audioComponentSocket.GetCurrent()->ReceiveControl(
IdleAudioControlID, IdleAudioControlID,
0.0f 0.0f
@@ -1079,6 +1083,13 @@ void
{ {
Check(&audioComponentSocket); Check(&audioComponentSocket);
Check(audioComponentSocket.GetCurrent()); Check(audioComponentSocket.GetCurrent());
if (triggerState >= 5 && getenv("BT_AUDIO_SPATIAL")) { static int s_sf=0; if (s_sf++<60)
DEBUG_STREAM << "[statefire] trigState=" << triggerState
<< " old=" << old_state << " new=" << new_state
<< " comp=" << (void*)audioComponentSocket.GetCurrent()
<< " compClass=" << (int)audioComponentSocket.GetCurrent()->GetClassID()
<< " ctl=" << (int)controlID << "/" << controlValue
<< "\n" << std::flush; }
#if 1 #if 1
audioComponentSocket.GetCurrent()->ReceiveControl( audioComponentSocket.GetCurrent()->ReceiveControl(
controlID, controlID,
+5
View File
@@ -450,6 +450,11 @@ CulturalIcon::~CulturalIcon()
break; break;
} }
} }
// #93: an icon can be a projectile's picked target -- scrub the static
// projectile pool so a round in flight never dispatches into a freed icon
// (same teardown race as the mech case; see mech4.cpp BTProjectilesDropEntity).
extern void BTProjectilesDropEntity(void *e);
BTProjectilesDropEntity(this);
} }
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// //
+12
View File
@@ -160,6 +160,18 @@ void
Check_Pointer(update_record); Check_Pointer(update_record);
Verify(damageZoneIndex == update_record->damageZoneIndex); Verify(damageZoneIndex == update_record->damageZoneIndex);
// #110 MP verification (BT): log a PEER-side zone state transition, so the
// two-node bench can prove the destruction cascade replicates. Change-only.
if (getenv("BT_MP_LOG")
&& (damageZoneGraphicState.GetState() != update_record->damageZoneGraphicState
|| (damageLevel < 1.0f && update_record->damageLevel >= 1.0f)))
{
DEBUG_STREAM << "[zone-repl] zone " << damageZoneIndex
<< " '" << (const char *)damageZoneName << "'"
<< " lvl " << damageLevel << "->" << update_record->damageLevel
<< " gstate " << damageZoneGraphicState.GetState()
<< "->" << update_record->damageZoneGraphicState << std::endl;
}
damageLevel = update_record->damageLevel; damageLevel = update_record->damageLevel;
damageZoneState.SetState(update_record->damageZoneState); damageZoneState.SetState(update_record->damageZoneState);
damageZoneGraphicState.SetState(update_record->damageZoneGraphicState); damageZoneGraphicState.SetState(update_record->damageZoneGraphicState);
+138
View File
@@ -137,6 +137,13 @@ DropZone::~DropZone()
} }
} }
// #81: print an EntityID as host:local. A player's LOCAL id is 1 on every
// machine, so printing only the local part made every one of five players read
// "entity 1" -- useless for telling WHO stalled. GetHostID() is non-const.
static inline int DZHost(const EntityID &id) { EntityID c(id); return (int)c.GetHostID(); }
static inline int DZLocal(const EntityID &id) { return (int)id; }
//############################################################################# //#############################################################################
// Dropzone assignment // Dropzone assignment
// //
@@ -170,6 +177,69 @@ void
Entity *entity = host->GetEntityPointer(message->requestingEntity); Entity *entity = host->GetEntityPointer(message->requestingEntity);
Check(entity); Check(entity);
// ---- BT_DROPZONE_LOG (2026-07-30) -------------------------------------
// The GHOST MECH investigation: a respawn strands forever when no slot is
// available, because the no-zone path below reposts this message to itself
// every 0.1 s at MaxEventPriority and NEVER replies -- so the player's
// deathPending latch is never cleared (Gitea #57/#81). This whole
// subsystem was previously DARK: not one log line about drop zones in a
// full night of field logs, which is why it hid. Log the pool state on
// every request, and the outcome, so saturation is measurable.
// Rate-limited: reposts are logged for the first few then once a second,
// because a single stranded player generates 10 reposts/second forever.
// The POOL CENSUS and the STALL WATCHDOG below are ALWAYS ON (no env gate):
// a respawn stall is rare, catastrophic and field-only, exactly like the
// Gitea #57/#59 guards. Volume is bounded -- one census line per mission,
// one line per grant (i.e. per respawn), and stall lines are rate-limited
// per waiting player. BT_DROPZONE_LOG adds the verbose per-request dump
// for bench work.
const Logical dzLog = (getenv("BT_DROPZONE_LOG") != 0);
static Logical s_census = False;
if (!s_census)
{
s_census = True;
DEBUG_STREAM << "[dz] POOL name='" << GetDropZoneName()
<< "' slots=" << dropZoneCount << " downTime=" << DOWN_TIME
<< "s proximityBlock=2m\n" << std::flush;
}
//
// Track how long THIS (requester, death) pair has been waiting. The engine
// reposts the same message to itself every 0.1 s while no slot is free, so
// the first sighting is the start of the stall.
//
enum { DZ_WAITERS = 8 };
static EntityID s_waitWho[DZ_WAITERS];
static int s_waitDeath[DZ_WAITERS];
static Time s_waitSince[DZ_WAITERS];
static Time s_waitLastLog[DZ_WAITERS];
static int s_waitUsed = 0;
int wslot = -1;
for (int w = 0; w < s_waitUsed; ++w)
if (s_waitWho[w] == message->requestingEntity
&& s_waitDeath[w] == message->deathCount)
{ wslot = w; break; }
if (wslot < 0 && s_waitUsed < DZ_WAITERS)
{
wslot = s_waitUsed++;
s_waitWho[wslot] = message->requestingEntity;
s_waitDeath[wslot] = message->deathCount;
s_waitSince[wslot] = Now();
s_waitLastLog[wslot].ticks = 0;
}
const Scalar waited = (wslot >= 0) ? (Scalar)(Now() - s_waitSince[wslot]) : 0.0f;
if (dzLog)
{
DEBUG_STREAM << "[dz] REQUEST from "
<< DZHost(message->requestingEntity) << ":"
<< DZLocal(message->requestingEntity)
<< " deathCount=" << message->deathCount
<< " waited=" << waited << "s slots[";
for (int d = 0; d < dropZoneCount; ++d)
DEBUG_STREAM << (d ? " " : "") << d << (IsAvailable(d) ? ":free" : ":BUSY");
DEBUG_STREAM << "]\n" << std::flush;
}
// //
//--------------------------------------------------------------------- //---------------------------------------------------------------------
// If we have allocated a dropzone within the last ten seconds for this // If we have allocated a dropzone within the last ten seconds for this
@@ -306,6 +376,44 @@ void
// //
if (!drop_zone) if (!drop_zone)
{ {
// NO SLOT: repost to ourselves in 0.1 s and reply to nobody. The
// requester's respawn is stalled until this eventually succeeds -- if it
// never does, that player is a permanent GHOST (dead, un-reset, still
// simulated and driveable, wearing the wreck on every peer).
// ALWAYS-ON STALL WATCHDOG. Escalating, rate-limited (first sighting,
// then every 5 s per waiting player), and it names WHY each slot is
// busy -- seconds since last use and who used it -- which is the
// actionable half: cooldown saturation (pool too small for the death
// rate) looks different from proximity blocking (mechs parked on the
// pad). After 15 s a stalled respawn is a confirmed GHOST: the player
// is dead, un-reset, still driveable, and a wreck on every peer.
if (wslot >= 0
&& (!s_waitLastLog[wslot].ticks || (Now() - s_waitLastLog[wslot]) > 5.0f))
{
s_waitLastLog[wslot] = Now();
DEBUG_STREAM << (waited >= 15.0f ? "[dz] GHOST LIKELY -- " : "[dz] STALL -- ")
<< DZHost(message->requestingEntity) << ":"
<< DZLocal(message->requestingEntity)
<< " death#" << message->deathCount
<< " has waited " << waited << "s for a slot; all "
<< dropZoneCount << " busy [";
for (int d = 0; d < dropZoneCount; ++d)
{
Scalar age = lastUsageTime[d].ticks ? (Scalar)(Now() - lastUsageTime[d]) : -1.0f;
DEBUG_STREAM << (d ? " " : "") << d << ":usedBy="
<< DZHost(lastUsedBy[d]) << ":" << DZLocal(lastUsedBy[d])
<< ",age=" << age << "s";
}
DEBUG_STREAM << "]\n" << std::flush;
}
if (dzLog)
{
static int s_rp = 0;
if (++s_rp <= 5)
DEBUG_STREAM << "[dz] repost #" << s_rp << " ("
<< DZHost(message->requestingEntity) << ":"
<< DZLocal(message->requestingEntity) << ")\n" << std::flush;
}
Time when=Now(); Time when=Now();
when += 0.1f; when += 0.1f;
application->Post(MaxEventPriority, this, message, when); application->Post(MaxEventPriority, this, message, when);
@@ -318,6 +426,36 @@ void
//-------------------------------------------------------------------- //--------------------------------------------------------------------
// //
Found_One: Found_One:
// ALWAYS ON -- one line per respawn. `waited` is the headline number: 0 is
// healthy, anything seconds-long means the pool is saturating, and a grant
// after a long wait is a ghost that RECOVERED (which is what testers see as
// "it fixed itself after a while").
{
static int s_grants = 0, s_stalledGrants = 0;
int slot = (int)(drop_zone - dropZones);
if (waited > 0.5f) ++s_stalledGrants;
++s_grants;
DEBUG_STREAM << "[dz] GRANTED slot " << slot << " to "
<< DZHost(message->requestingEntity) << ":"
<< DZLocal(message->requestingEntity)
<< " death#" << message->deathCount
<< " waited=" << waited << "s"
<< (waited >= 15.0f ? " (GHOST RECOVERED)" : "")
<< " [grants=" << s_grants << " stalled=" << s_stalledGrants << "]"
<< "\n" << std::flush;
// retire this waiter slot so the table does not fill up over a match
if (wslot >= 0)
{
for (int w = wslot; w + 1 < s_waitUsed; ++w)
{
s_waitWho[w] = s_waitWho[w+1];
s_waitDeath[w] = s_waitDeath[w+1];
s_waitSince[w] = s_waitSince[w+1];
s_waitLastLog[w] = s_waitLastLog[w+1];
}
--s_waitUsed;
}
}
ReplyMessage ReplyMessage
reply( reply(
message->replyMessageID, message->replyMessageID,
+34 -1
View File
@@ -3819,7 +3819,28 @@ Logical
case background: case background:
{ {
result = ProcessOneActiveGauge(); // BT GAUGE BATCH (#45, with the APPMGR minimum-pump floor): one gauge
// per turn is the authentic pod pacing, but a visit is just a
// rate-mask check unless the gauge is due, so on modern hardware it
// starves the rotation for no benefit -- with ~140 active instruments
// and ~4 gauge turns/frame a full sweep took ~1s and a D-rate gauge
// (the comms-panel PilotList) ran every ~4s. Advance a BATCH of
// gauges per turn so a sweep completes ~every frame and panel rows
// track the sim within a second. BT_GAUGE_BATCH=1 restores the
// authentic single-step.
static int s_batch = -1;
if (s_batch < 0)
{
const char *e = getenv("BT_GAUGE_BATCH");
s_batch = e ? atoi(e) : 32;
if (s_batch < 1) s_batch = 1;
if (s_batch > 1024) s_batch = 1024;
}
int n = s_batch;
do
{
result = ProcessOneActiveGauge();
} while (--n > 0 && result && taskMode == background);
break; break;
} }
@@ -3963,6 +3984,10 @@ void
Check_Fpu(); Check_Fpu();
} }
// BT gauge-executive counters (#45) -- definitions; sampled + reset by the
// BT_PERF probe in APPMGR.cpp.
int gBTGaugeTurns = 0, gBTGaugeSweeps = 0, gBTGaugeActive = 0;
// //
//=========================================================================== //===========================================================================
// ProcessOneActiveGauge // ProcessOneActiveGauge
@@ -3979,8 +4004,15 @@ Logical
GaugeBase GaugeBase
*base_pointer = activeIterator->ReadAndNext(); *base_pointer = activeIterator->ReadAndNext();
// BT gauge-executive counters (#45): read by the BT_PERF probe (APPMGR.cpp)
// to expose the instrument-rotation rate -- turns = single-gauge Updates,
// sweeps = full active-list passes (the rate mask advances once per sweep,
// so a "D-rate" gauge executes once per FOUR sweeps).
extern int gBTGaugeTurns, gBTGaugeSweeps, gBTGaugeActive;
if (base_pointer == NULL) if (base_pointer == NULL)
{ {
++gBTGaugeSweeps;
gBTGaugeActive = activeIterator->GetSize();
//-------------------------------------------------- //--------------------------------------------------
// We're done! // We're done!
// Bump the bit mask and index for the next pass // Bump the bit mask and index for the next pass
@@ -3997,6 +4029,7 @@ Logical
} }
else else
{ {
++gBTGaugeTurns;
//-------------------------------------------------- //--------------------------------------------------
// Process one gauge // Process one gauge
//-------------------------------------------------- //--------------------------------------------------
+6 -1
View File
@@ -13,7 +13,12 @@
//############################ Renderer ################################# //############################ Renderer #################################
//############################################################################# //#############################################################################
const RendererRate DefaultRendererRate = 30.0f; // 2026-08-02 (#96 clock trace): the DOS binary's audio/effect frame clock runs
// at the ENGINE FRAME RATE GLOBAL DAT_0052140c = 28.0f (0x401ace), not 30 --
// AudioTime::Seconds_To_Frames in the image is `fmul [0x52140c]; fadd 0.5`
// (e.g. @0x42c611), and every authored frame-count duration assumes 28.
// 30 here ran ALL audio timing ~7% fast. [T1]
const RendererRate DefaultRendererRate = 28.0f;
const RendererComplexity MaxRendererComplexity = 3.0f; const RendererComplexity MaxRendererComplexity = 3.0f;
const RendererComplexity MinRendererComplexity = 0.0f; const RendererComplexity MinRendererComplexity = 0.0f;
const RendererFrameBudget DefaultRendererFrameBudget = 0.14f; //0.12f; // 0.16f; const RendererFrameBudget DefaultRendererFrameBudget = 0.14f; //0.12f; // 0.16f;
+16
View File
@@ -48,6 +48,7 @@ ScenarioRole::ScenarioRole(const CString &role_name, const CString &model_file)
resourceAddress; resourceAddress;
killBonus = player_data->killBonus; killBonus = player_data->killBonus;
specialCaseDeathPenalty = player_data->specialCaseDeathPenalty; // BT 4.10 (restored)
returnFromDeath = player_data->returnFromDeath; returnFromDeath = player_data->returnFromDeath;
damageReceivedModifier = player_data->damageReceivedModifier; damageReceivedModifier = player_data->damageReceivedModifier;
damageInflictedModifier = player_data->damageInflictedModifier; damageInflictedModifier = player_data->damageInflictedModifier;
@@ -64,6 +65,7 @@ ScenarioRole::ScenarioRole(const CString &role_name, const CString &model_file)
damageBias = 0.0f; damageBias = 0.0f;
friendlyFirePenalty = 0.0f; friendlyFirePenalty = 0.0f;
killBonus = 0.0f; killBonus = 0.0f;
specialCaseDeathPenalty = 0.0f; // BT 4.10 (restored)
returnFromDeath = 0; returnFromDeath = 0;
} }
} }
@@ -151,6 +153,20 @@ Dump_And_Die:
return -1; 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( if(
!model_file->GetEntry( !model_file->GetEntry(
"gamedata", "gamedata",
+13 -1
View File
@@ -9,7 +9,12 @@ class NotationFile;
struct ScenarioRole__ModelResource 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; int returnFromDeath;
}; };
@@ -20,6 +25,7 @@ public:
protected: protected:
Scalar damageInflictedModifier, damageReceivedModifier, friendlyFirePenalty, damageBias, killBonus; Scalar damageInflictedModifier, damageReceivedModifier, friendlyFirePenalty, damageBias, killBonus;
Scalar specialCaseDeathPenalty; // BT 4.10 role+0x20 (restored 2026-08-02)
CString roleName; CString roleName;
int returnFromDeath; int returnFromDeath;
@@ -61,6 +67,12 @@ public:
return killBonus; return killBonus;
} }
Scalar GetSpecialCaseDeathPenalty() const // BT 4.10 role+0x20
{
Check(this);
return specialCaseDeathPenalty;
}
CString GetRoleName() const CString GetRoleName() const
{ {
Check(this); Check(this);
+11
View File
@@ -126,6 +126,17 @@ protected:
// Accessors // Accessors
// //
public: public:
// #110 (BT reconstruction): read access to the two streamed tables the
// binary's zone-destruction cascade walks (Mech__DamageZone::
// RecurseSegmentTable @0049cad4 -- seg+0xD0 damage zones, seg+0xE8 child
// indices). Read-only; population stays with JointedMover streaming.
IntegerTable&
GetDamageZoneTable()
{Check(this); return damageZoneTable;}
IntegerTable&
GetChildIndexTable()
{Check(this); return childIndexTable;}
void void
ModifySegment(Logical modified = True) ModifySegment(Logical modified = True)
{Check(this); segmentModified = modified;} {Check(this); segmentModified = modified;}
+11
View File
@@ -157,6 +157,17 @@ void
{ {
Check(this); Check(this);
Verify(new_state < stateCount); Verify(new_state < stateCount);
// #78 DIAG: trap ANY 3/4 -> <=1 transition on ANY indicator, with the
// caller's return address (symbolize via tools/symcrash.py). Catches
// writers that bypass Simulation::SetSimulationState.
if (getenv("BT_AUDIO_SPATIAL")
&& (currentState == 3 || currentState == 4) && new_state <= 1)
{
static int s_it=0; if (s_it++<40)
DEBUG_STREAM << "[indstomp] " << currentState << "->" << new_state
<< " ind=" << (void*)this
<< " ra=" << _ReturnAddress() << "\n" << std::flush;
}
// //
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
+19 -1
View File
@@ -2,6 +2,7 @@
#include "state.h" #include "state.h"
#include "receiver.h" #include "receiver.h"
#include <intrin.h> // _ReturnAddress (the #78 simstomp diag)
#include "time.h" #include "time.h"
#include "resource.h" #include "resource.h"
@@ -221,7 +222,24 @@ public:
{Check(this); return simulationState.GetOldState();} {Check(this); return simulationState.GetOldState();}
void void
SetSimulationState(unsigned new_state) SetSimulationState(unsigned new_state)
{Check(this); simulationState.SetState(new_state);} {Check(this);
// #78 DIAG: trap the gimp-cell downgrade (3/4 -> <=1) with the
// caller's return address (symbolize via tools/symcrash.py).
// #78/#82 DIAG: who resets the GIMP cell? Scoped to the one mech
// mechdmg just gimped (g_btGimpWatchMech) -- subsystems are
// Simulations too and their 3->0 churn drowned the earlier trap.
// ra is printed MODULE-RELATIVE (btl4+0xNNNN) for symcrash.py.
{
extern void *g_btGimpWatchMech;
extern void BTGimpStompTrap(void *sim, unsigned cur,
unsigned nw, void *ra);
unsigned _cur = simulationState.GetState();
if ((void*)this == g_btGimpWatchMech
&& (_cur == 3 || _cur == 4) && new_state != _cur)
BTGimpStompTrap((void*)this, _cur, new_state,
_ReturnAddress());
}
simulationState.SetState(new_state);}
StateIndicator StateIndicator
simulationState; simulationState;
+7
View File
@@ -47,6 +47,13 @@ public:
Check(&gaugeWatcherSocket); Check(&gaugeWatcherSocket);
gaugeWatcherSocket.Add(watcher); gaugeWatcherSocket.Add(watcher);
} }
int DebugAudioWatcherCount() // DEBUG (#78 audio flake): registered audio watchers
{
int n = 0;
SChainIteratorOf<Component*> it(audioWatcherSocket);
while (it.ReadAndNext() != NULL) ++n;
return n;
}
private: private:
SChainOf<Component*> audioWatcherSocket; SChainOf<Component*> audioWatcherSocket;
+335 -40
View File
@@ -395,12 +395,27 @@ void
// DPLIndependantEffect voices, and while the pool is pinned every new // DPLIndependantEffect voices, and while the pool is pinned every new
// acquire fails outright and that sound is silently dropped. // acquire fails outright and that sound is silently dropped.
// //
// ⚠ OFF BY DEFAULT, DELIBERATELY. Raising the cap is NOT a free win, and // ⚠ OFF BY DEFAULT, DELIBERATELY. Raising the cap is NOT a free win.
// the reported dropout was already fixed on 2026-07-23 by a999e5c (the //
// atomic-delete leak) -- no field complaints since. What remains in the // ⚠⚠ UPDATE 2026-08-01 (#32): the "no field complaints since" claim below
// night-5 logs is transient exhaustion at the peak of a firefight, where // did NOT hold. Every 4.11.674 player log is saturated -- 3031/4657/5245/
// a dropped voice competes with ~256 already sounding, so it is very // 6275/6571 failures across five machines, first failure ~10% into a match
// likely sub-perceptual. Against that: the 256 ceiling also acts as a // and still failing at 97%. The root cause was NOT the source budget at
// all: RequestAudioChannels was alGenSources'ing per sound event and
// ReleaseSourceSet alDeleteSources'ing on release, so combat CHURNED
// through the ceiling. Sources are now POOLED and recycled (see
// BTAudioPoolAcquire below), which fixes it WITHOUT touching this budget.
// Measured on the same bench: frame time went 8.67ms -> 7.79ms over ~10k
// frames, i.e. removing the churn is a net CPU WIN, so the governor
// argument below does not apply to pooling. The reasoning about raising
// the CAP (more voices mixing = more CPU) still stands on its own, which
// is why BT_AUDIO_SOURCES remains opt-in and unset by default.
//
// (Historical:) the 2026-07-23 fix a999e5c addressed the atomic-delete
// leak. What remained in the night-5 logs is transient exhaustion at the
// peak of a firefight, where a dropped voice competes with ~256 already
// sounding, so it is very likely sub-perceptual. Against that: the 256
// ceiling also acts as a
// GOVERNOR. The steal loop only steals when the incoming source outranks // GOVERNOR. The steal loop only steals when the incoming source outranks
// a running one, so a higher cap means many more voices mixing at once -- // a running one, so a higher cap means many more voices mixing at once --
// with EFX reverb + the lowpass chains live, that is real CPU, spent // with EFX reverb + the lowpass chains live, that is real CPU, spent
@@ -594,6 +609,12 @@ void
Check(this); Check(this);
Check(audio_source); Check(audio_source);
if (getenv("BT_AUDIO_SPATIAL")) { static int s_sr=0; if (s_sr++<5000)
DEBUG_STREAM << "[startreq] src=" << (void*)audio_source
<< " state=" << (int)audio_source->GetAudioSourceState()
<< " class=" << (int)audio_source->GetClassID()
<< "\n" << std::flush; }
// //
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
// Verify that the source is stopped // Verify that the source is stopped
@@ -619,6 +640,9 @@ void
); );
if (!resources_available) if (!resources_available)
{ {
if (getenv("BT_AUDIO_SPATIAL")) { static int s_rf=0; if (s_rf++<40)
DEBUG_STREAM << "[startreq] RESOURCE FAIL src=" << (void*)audio_source
<< "\n" << std::flush; }
// Audio-dropout fix: a failed acquisition can leave a PARTIAL set // Audio-dropout fix: a failed acquisition can leave a PARTIAL set
// (alGenSources succeeded for some slots before the pool ran dry). // (alGenSources succeeded for some slots before the pool ran dry).
// A dropped transient never plays and nothing else ever released it, // A dropped transient never plays and nothing else ever released it,
@@ -1241,6 +1265,13 @@ Logical
source_result = audio_source->GetAudioChannelSet(); source_result = audio_source->GetAudioChannelSet();
Check(source_result); Check(source_result);
// #32 census: identify the requester while RequestAudioChannels runs, so a
// failure can be attributed to a component CLASS without threading an
// argument through the virtual (audio is main-thread only).
extern int gBTAudioReqClass, gBTAudioReqVoices;
gBTAudioReqClass = (int)audio_source->GetClassID();
gBTAudioReqVoices = audio_source->GetAudioVoiceCount();
resources_available = resources_available =
RequestAudioChannels( RequestAudioChannels(
audio_source->GetAudioVoiceCount(), audio_source->GetAudioVoiceCount(),
@@ -1328,6 +1359,11 @@ Logical
// Else suspend the source // Else suspend the source
//-------------------------------------------------------------------- //--------------------------------------------------------------------
// //
{ // #32 census: a steal ends a RUNNING sound early -- count it
// ungated so field logs show how hard the mixer is fighting.
extern long gBTAudioSteals;
++gBTAudioSteals;
}
if ( if (
running_audio_source->GetAudioRenderType() == running_audio_source->GetAudioRenderType() ==
TransientAudioRenderType TransientAudioRenderType
@@ -1358,10 +1394,32 @@ Logical
iterator.Last(); iterator.Last();
running_audio_source = iterator.GetCurrent(); running_audio_source = iterator.GetCurrent();
} }
// #32 census: the steal loop is done and the request is STILL
// unsatisfied -- this sound is genuinely dropped. (The raw
// "ACQUIRE FAILED" print fires once per attempt INSIDE the steal loop,
// so its count wildly overstates real drops; this one does not.)
if (!resources_available)
{
extern long gBTAudioTrueDrops;
extern void BTAudioDropTally(int class_ID, int voices);
++gBTAudioTrueDrops;
BTAudioDropTally((int)audio_source->GetClassID(),
audio_source->GetAudioVoiceCount());
}
} }
return resources_available; return resources_available;
} }
//
// OpenAL source pool (gitea #32) -- defined below, used here.
//
extern Logical BTAudioPoolAcquire(ALuint *out);
extern void BTAudioPoolRelease(ALuint src);
extern int BTAudioPoolSize();
extern int BTAudioPoolFree();
extern long BTAudioPoolReuses();
// //
//############################################################################# //#############################################################################
// RequestAudioChannels // RequestAudioChannels
@@ -1392,9 +1450,17 @@ Logical
if (now_ms - s_censusAt > 30000) if (now_ms - s_censusAt > 30000)
{ {
s_censusAt = now_ms; s_censusAt = now_ms;
extern long gBTAudioSteals, gBTAudioTrueDrops;
DEBUG_STREAM << "[audio] source census: live=" << gBTAudioSourcesLive DEBUG_STREAM << "[audio] source census: live=" << gBTAudioSourcesLive
<< " pooled=" << BTAudioPoolSize()
<< " free=" << BTAudioPoolFree()
<< " reuses=" << BTAudioPoolReuses()
<< " acquireFails=" << gBTAudioAcquireFails << " acquireFails=" << gBTAudioAcquireFails
<< " steals=" << gBTAudioSteals
<< " drops=" << gBTAudioTrueDrops
<< std::endl << std::flush; << std::endl << std::flush;
extern void BTAudioDropCensus();
BTAudioDropCensus();
} }
} }
@@ -1405,35 +1471,55 @@ Logical
if (requested > AUDIO_SOURCESET_CAPACITY) if (requested > AUDIO_SOURCESET_CAPACITY)
requested = AUDIO_SOURCESET_CAPACITY; requested = AUDIO_SOURCESET_CAPACITY;
bool failed = true;
alGetError(); alGetError();
//
// SOURCE POOLING (gitea #32). This used to alGenSources() here and
// alDeleteSources() in ReleaseSourceSet -- i.e. CREATE and DESTROY OpenAL
// sources per sound event. Every 4.11.674 player log shows the result:
// thousands of acquisition failures each (3031-6571 across five machines),
// beginning ~10% into a match and never recovering, because a combat burst
// churns straight through OpenAL's hard per-context source limit (256).
// The 30s census reading `live=6` while the failure line read `live=256`
// was the tell: the same counter, sampled between bursts -- churn, not a
// steady leak.
//
// Sources are now generated ONCE and recycled through a free list, so
// steady-state demand costs no allocation at all and the driver cap is
// never approached.
//
for (int i = 0; i < requested; i++) for (int i = 0; i < requested; i++)
{ {
if (!alIsSource(source_request->sources[i])) if (alIsSource(source_request->sources[i]))
continue; // slot already holds one
ALuint src = 0;
if (!BTAudioPoolAcquire(&src))
{ {
alGenSources(1, source_request->sources + i); ++gBTAudioAcquireFails;
if (alIsSource(source_request->sources[i])) // Rate-limited: the night-9 field logs carried 6.8k-19k of these per
++gBTAudioSourcesLive; // player, and the count is NOISE -- the steal loop retries after every
// failed attempt, so lines pile up per EVENT, and most events still
// end in a successful steal. One line per 30s band keeps the signal
// (the census carries the real counters: steals + true drops).
extern int gBTAudioReqClass, gBTAudioReqVoices;
static unsigned long s_failNoteAt = 0;
unsigned long fail_now = GetTickCount();
if (fail_now - s_failNoteAt > 30000)
{
s_failNoteAt = fail_now;
DEBUG_STREAM << "[audio] ACQUIRE FAILED (requested=" << requested
<< " reqClass=" << gBTAudioReqClass
<< " reqVoices=" << gBTAudioReqVoices
<< " live=" << gBTAudioSourcesLive
<< " pooled=" << BTAudioPoolSize()
<< " free=" << BTAudioPoolFree()
<< " fails=" << gBTAudioAcquireFails
<< ") -- saturated this instant; the steal loop decides what plays"
<< std::endl << std::flush;
}
return False;
} }
} source_request->sources[i] = src;
ALenum error = alGetError();
if (error == AL_NO_ERROR)
{
failed = false;
}
if (failed)
{
++gBTAudioAcquireFails;
DEBUG_STREAM << "[audio] ACQUIRE FAILED (requested=" << requested
<< " live=" << gBTAudioSourcesLive
<< " fails=" << gBTAudioAcquireFails
<< ") -- the pool is exhausted; expect dropouts"
<< std::endl << std::flush;
return False;
} }
return True; return True;
@@ -1515,6 +1601,220 @@ Logical
long gBTAudioSourcesLive = 0; long gBTAudioSourcesLive = 0;
long gBTAudioAcquireFails = 0; long gBTAudioAcquireFails = 0;
//
// #32 census v2. The night-9 field logs proved the FIRST generation of these
// counters mislead under pressure: "ACQUIRE FAILED ... free=0" reads as
// permanent retention, but free is 0 at the instant of any failed acquire BY
// DEFINITION -- the 30s census showed free back at ~230 between bursts, i.e.
// the pool cycles and the mixer is simply saturated DURING combat. (Same
// counter-sampling trap as the original live=256-vs-live=6, second offence.)
// These count what actually matters:
// gBTAudioSteals -- a running sound was ended early to service a new one
// gBTAudioTrueDrops -- the steal loop ran dry and the sound NEVER PLAYED
// plus a per-component-class tally of the dropped, so the next field log names
// the class that saturates the mixer instead of leaving it to inference.
//
int gBTAudioReqClass = -1;
int gBTAudioReqVoices = 0;
long gBTAudioSteals = 0;
long gBTAudioTrueDrops = 0;
struct BTAudioDropBin { int classID; int voices; long count; };
static BTAudioDropBin gBTAudioDropBins[12];
static int gBTAudioDropBinCount = 0;
void BTAudioDropTally(int class_ID, int voices)
{
for (int i = 0; i < gBTAudioDropBinCount; ++i)
{
if (gBTAudioDropBins[i].classID == class_ID
&& gBTAudioDropBins[i].voices == voices)
{
++gBTAudioDropBins[i].count;
return;
}
}
if (gBTAudioDropBinCount < 12)
{
gBTAudioDropBins[gBTAudioDropBinCount].classID = class_ID;
gBTAudioDropBins[gBTAudioDropBinCount].voices = voices;
gBTAudioDropBins[gBTAudioDropBinCount].count = 1;
++gBTAudioDropBinCount;
}
}
// One line under the 30s census, only when something was dropped since boot:
// which component classes lost sounds, and how many.
void BTAudioDropCensus()
{
if (gBTAudioDropBinCount == 0)
return;
DEBUG_STREAM << "[audio] dropped by class:";
for (int i = 0; i < gBTAudioDropBinCount; ++i)
DEBUG_STREAM << " {class " << gBTAudioDropBins[i].classID
<< " x" << gBTAudioDropBins[i].voices
<< "v: " << gBTAudioDropBins[i].count << "}";
DEBUG_STREAM << std::endl << std::flush;
}
//#############################################################################
// OpenAL SOURCE POOL (gitea #32)
//#############################################################################
//
// The renderer used to create an AL source per sound event and destroy it on
// release. OpenAL sources are a scarce driver resource -- OpenAL Soft caps a
// context at 256 -- and creating them is not cheap, so combat bursts ran the
// context dry. Field evidence (4.11.674, five machines): 3031-6571 acquisition
// failures per player, first failure ~10% into a match, still failing at 97%.
//
// Sources are now generated once, on demand, up to kAudioPoolCap and then
// RECYCLED: release scrubs the source and returns it to the free list instead
// of deleting it. Steady-state play performs no AL allocation at all.
//
// THE SCRUB IS LOAD-BEARING. A recycled source carries whatever the previous
// owner set on it, and the engine sets AL_LOOPING per sound (L4AUDLVL.cpp:327).
// Hand a looping source to a one-shot and it plays forever -- exactly the
// "sound stuck looping" family (#51, #5). Every reusable property is reset
// here, at the single point where sources change owner.
//
// Not locked: the audio renderer runs on the main thread (only the network RX
// socket has its own). If that ever changes, this needs a mutex.
//
// The pool's ceiling. DEFAULT: just under OpenAL Soft's 256-source context
// default. BT_AUDIO_SOURCES=<n> raises the AL context budget at Initialize
// (above); the pool cap now FOLLOWS it -- before this, the env raised the
// context and the pool still stopped at 240, so the experiment was impossible
// to run in the field. kAudioPoolMax bounds the static free-list array.
static const int kAudioPoolMax = 1024;
static int kAudioPoolCap = 240;
static int BTAudioPoolCapResolve()
{
static int s_done = 0;
if (!s_done)
{
s_done = 1;
const char *sv = getenv("BT_AUDIO_SOURCES");
if (sv != 0)
{
int n = atoi(sv);
if (n >= 64 && n <= 4096)
{
// stay under the context grant with a small reserve
kAudioPoolCap = (n - 16 < kAudioPoolMax) ? (n - 16) : kAudioPoolMax;
DEBUG_STREAM << "[audio] source pool cap follows BT_AUDIO_SOURCES: "
<< kAudioPoolCap << std::endl << std::flush;
}
}
}
return kAudioPoolCap;
}
//
// PEAK DEMAND is set by how many audio COMPONENTS are alive, not by how many
// sounds are audible: each component reserves a SourceSet of up to
// AUDIO_SOURCESET_CAPACITY (25) voices and holds them until it is released.
// Measured high-water: 138 solo vs one enemy, 149 across two nodes. That grows
// with player count, so the cap is deliberately near the driver ceiling and the
// pool reports its high-water mark once, to size this from FIELD logs rather
// than from a guess. Growth also stops on its own if a driver offers fewer
// sources than the cap -- alGenSources failing simply ends growth and the pool
// recycles what it has.
static ALuint gAudioPoolFree[kAudioPoolMax];
static int gAudioPoolFreeCount = 0; // entries in gAudioPoolFree
static int gAudioPoolTotal = 0; // sources ever generated (<= cap)
static long gAudioPoolReuses = 0; // diagnostics
int BTAudioPoolSize() { return gAudioPoolTotal; }
int BTAudioPoolFree() { return gAudioPoolFreeCount; }
long BTAudioPoolReuses() { return gAudioPoolReuses; }
//
// Reset a source to a known-neutral state so nothing carries across owners.
//
static void
BTAudioScrubSource(ALuint src)
{
ALint state = AL_STOPPED;
alGetSourcei(src, AL_SOURCE_STATE, &state);
if (state == AL_PLAYING || state == AL_PAUSED)
alSourceStop(src);
alSourcei(src, AL_BUFFER, 0); // detach (no queued buffers here)
alSourcei(src, AL_LOOPING, AL_FALSE); // <-- the stuck-loop guard
alSourcef(src, AL_GAIN, 1.0f);
alSourcef(src, AL_PITCH, 1.0f);
alSourcei(src, AL_SOURCE_RELATIVE, AL_FALSE);
alSource3f(src, AL_POSITION, 0.0f, 0.0f, 0.0f);
alSource3f(src, AL_VELOCITY, 0.0f, 0.0f, 0.0f);
alSourcef(src, AL_MIN_GAIN, 0.0f);
alSourcef(src, AL_MAX_GAIN, 1.0f);
alGetError(); // swallow any property complaint
}
//
// Hand out a source: recycle first, generate only when the pool has never been
// that large. False = genuinely out (peak concurrent demand exceeded the cap).
//
Logical
BTAudioPoolAcquire(ALuint *out)
{
Check_Pointer(out);
while (gAudioPoolFreeCount > 0)
{
ALuint src = gAudioPoolFree[--gAudioPoolFreeCount];
if (alIsSource(src)) // paranoia: a context reset invalidates names
{
++gAudioPoolReuses;
*out = src;
return True;
}
--gAudioPoolTotal; // stale name: forget it
}
if (gAudioPoolTotal >= BTAudioPoolCapResolve())
return False;
ALuint src = 0;
alGetError();
alGenSources(1, &src);
if (alGetError() != AL_NO_ERROR || !alIsSource(src))
return False; // driver said no before our cap
++gAudioPoolTotal;
++gBTAudioSourcesLive;
{
// One line per new high-water band, so a field log shows how close a
// real match gets to the ceiling without spamming.
static int s_notified = 0;
if (gAudioPoolTotal >= s_notified + 25)
{
s_notified = gAudioPoolTotal;
DEBUG_STREAM << "[audio] source pool high-water: " << gAudioPoolTotal
<< " of " << kAudioPoolCap << std::endl << std::flush;
}
}
*out = src;
return True;
}
//
// Take a source back. Scrubbed and parked, NOT deleted.
//
void
BTAudioPoolRelease(ALuint src)
{
if (!alIsSource(src))
return;
BTAudioScrubSource(src);
if (gAudioPoolFreeCount < kAudioPoolMax)
{
gAudioPoolFree[gAudioPoolFreeCount++] = src;
return;
}
alDeleteSources(1, &src); // cannot happen (max == array size)
--gAudioPoolTotal;
--gBTAudioSourcesLive;
}
void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet) void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet)
{ {
// Audio-dropout fix: the old bulk alDeleteSources(count, sources) is // Audio-dropout fix: the old bulk alDeleteSources(count, sources) is
@@ -1524,19 +1824,14 @@ void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet)
// partial release leaked its real sources and the pool never recovered // partial release leaked its real sources and the pool never recovered
// ("audio cutting in and out toward the end of the match"). Delete each // ("audio cutting in and out toward the end of the match"). Delete each
// valid source individually and park the slot at 0 (never a valid name). // valid source individually and park the slot at 0 (never a valid name).
extern long gBTAudioSourcesLive; // SOURCE POOLING (#32): hand each source back to the free list instead of
// destroying it. BTAudioPoolRelease stops it, detaches its buffer and
// clears AL_LOOPING before parking it, so the next owner starts clean.
for (int i = 0; i < sourceSet.count; i++) for (int i = 0; i < sourceSet.count; i++)
{ {
if (alIsSource(sourceSet.sources[i])) if (sourceSet.sources[i] != 0)
{ BTAudioPoolRelease(sourceSet.sources[i]);
ALint state = AL_STOPPED; sourceSet.sources[i] = 0; // 0 is never a valid AL name
alGetSourcei(sourceSet.sources[i], AL_SOURCE_STATE, &state);
if (state == AL_PLAYING)
alSourceStop(sourceSet.sources[i]);
alDeleteSources(1, sourceSet.sources + i);
--gBTAudioSourcesLive;
}
sourceSet.sources[i] = 0;
} }
} }
+26
View File
@@ -2624,12 +2624,28 @@ void
Verify(rio_event.Data.Unit < ButtonCount); Verify(rio_event.Data.Unit < ButtonCount);
temp = rio_event.Data.Unit + 1; 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]); Check(&buttonGroup[rio_event.Data.Unit]);
buttonGroup[rio_event.Data.Unit].Update(&temp, mode_mask); buttonGroup[rio_event.Data.Unit].Update(&temp, mode_mask);
//---------------------------------------- //----------------------------------------
// Save 'pressed' mode mask for 'release' // Save 'pressed' mode mask for 'release'
//---------------------------------------- //----------------------------------------
buttonActivateModeMask[rio_event.Data.Unit] = mode_mask; 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; break;
case RIO::ButtonReleasedEvent: case RIO::ButtonReleasedEvent:
@@ -2670,6 +2686,16 @@ void
); );
Check(&keyboardGroup[rio_event.Data.Keyboard.Unit]); 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]. keyboardGroup[rio_event.Data.Keyboard.Unit].
ForceUpdate(&new_key, mode_mask); ForceUpdate(&new_key, mode_mask);
+77 -3
View File
@@ -393,6 +393,17 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName)
object->mDrawOps[i].lodNear = data.batches[i].lodNear; object->mDrawOps[i].lodNear = data.batches[i].lodNear;
object->mDrawOps[i].lodFar = data.batches[i].lodFar; object->mDrawOps[i].lodFar = data.batches[i].lodFar;
object->mDrawOps[i].lodDepthBias = data.batches[i].lodBias; object->mDrawOps[i].lodDepthBias = data.batches[i].lodBias;
// MECH ARMOUR DAMAGE (issue #87): carry the authored material name so the
// mech's .DZM zone->material lists can drive this batch's darkening.
{
const std::string &mn = data.batches[i].matName;
size_t n = mn.size();
if (n >= sizeof(object->mDrawOps[i].dzMatName))
n = sizeof(object->mDrawOps[i].dzMatName) - 1;
memcpy(object->mDrawOps[i].dzMatName, mn.c_str(), n);
object->mDrawOps[i].dzMatName[n] = '\0';
object->mDrawOps[i].dzDamageLevel = 0.0f;
}
const bool useRamp = (s_ramp && data.batches[i].hasRamp); const bool useRamp = (s_ramp && data.batches[i].hasRamp);
uint32_t c = data.batches[i].color; uint32_t c = data.batches[i].color;
@@ -432,8 +443,13 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName)
// drawAsSky) and vanishes -- DECLOUDS carries vertex alpha but must // drawAsSky) and vanishes -- DECLOUDS carries vertex alpha but must
// stay in the sky pass. // stay in the sky pass.
const bool vtxAlphaOp = data.batches[i].vertexAlpha && !isSkyObj; 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].vertexAlphaBlend = vtxAlphaOp;
object->mDrawOps[i].brightenAlpha = brightenOp ? data.batches[i].brightenFactor : 0.0f;
object->mDrawOps[i].drawAsDecal = false; object->mDrawOps[i].drawAsDecal = false;
object->mDrawOps[i].drawAsSky = isSkyObj; // sky dome -> PASS_SKY (fullbright) object->mDrawOps[i].drawAsSky = isSkyObj; // sky dome -> PASS_SKY (fullbright)
// PUNCH (dpl_Punchize; BT_PUNCH=0 disables): cutout batch -- black texels // PUNCH (dpl_Punchize; BT_PUNCH=0 disables): cutout batch -- black texels
@@ -1198,6 +1214,33 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
mDevice->SetMaterial(&drawOp->material); mDevice->SetMaterial(&drawOp->material);
// MECH ARMOUR DAMAGE (issue #87): the 1995 renderer kept a per-material
// watcher that lerped the material's colour terms from the authored values
// toward 0.1x as the owning damage zone's damageLevel ran 0->1
// (FUN_004573e4 precomputed pristine*0.1; FUN_00457784 re-pushed
// lerp(pristine, damaged, level) on every change, constant @0x4579a4 = 1.0).
//
// We cannot express that by scaling this op's D3DMATERIAL9: D3D9 defaults
// DIFFUSEMATERIALSOURCE to D3DMCS_COLOR1, and every BGF vertex carries a
// baked colour, so the material's Diffuse is never consulted for this
// geometry -- scaling it renders identically (measured: 0 changed pixels).
// Instead modulate the FINAL colour by k on texture stage 1, which darkens
// the result whatever the diffuse source was, and equally covers the
// ramp-baked-texture path (the mech's own case) and pure-emissive batches.
const bool damageTint = (drawOp->dzDamageLevel > 0.0f);
if (damageTint)
{
float d = drawOp->dzDamageLevel;
if (d > 1.0f) d = 1.0f;
const float k = 1.0f - (1.0f - kDamagedMaterialScale) * d; // lerp(1, 0.1, d)
mDevice->SetRenderState(D3DRS_TEXTUREFACTOR, D3DCOLOR_COLORVALUE(k, k, k, 1.0f));
mDevice->SetTextureStageState(1, D3DTSS_COLOROP, D3DTOP_MODULATE);
mDevice->SetTextureStageState(1, D3DTSS_COLORARG1, D3DTA_CURRENT);
mDevice->SetTextureStageState(1, D3DTSS_COLORARG2, D3DTA_TFACTOR);
mDevice->SetTextureStageState(1, D3DTSS_ALPHAOP, D3DTOP_SELECTARG1);
mDevice->SetTextureStageState(1, D3DTSS_ALPHAARG1, D3DTA_CURRENT);
}
#ifndef RP3_EMULATE #ifndef RP3_EMULATE
SetTextureScrolling(&(drawOp->texture), targetRenderFrame); SetTextureScrolling(&(drawOp->texture), targetRenderFrame);
SetTexture(drawOp->texture.texture); SetTexture(drawOp->texture.texture);
@@ -1241,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 // (SRCALPHA/INVSRCALPHA). Runs only in the blend pass (the op filter
// above routed it there; ALPHABLEND is on and z-write off pass-wide). // 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; 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) if (vtxAlphaCard)
{ {
mDevice->GetRenderState(D3DRS_LIGHTING, &sVLight); mDevice->GetRenderState(D3DRS_LIGHTING, &sVLight);
@@ -1254,13 +1299,35 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
mDevice->GetTextureStageState(0, D3DTSS_ALPHAARG2, &sVAa2); mDevice->GetTextureStageState(0, D3DTSS_ALPHAARG2, &sVAa2);
mDevice->SetRenderState(D3DRS_LIGHTING, FALSE); mDevice->SetRenderState(D3DRS_LIGHTING, FALSE);
mDevice->SetRenderState(D3DRS_SRCBLEND, D3DBLEND_SRCALPHA); 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, mDevice->SetTextureStageState(0, D3DTSS_COLOROP,
drawOp->texture.texture != NULL ? D3DTOP_MODULATE : D3DTOP_SELECTARG2); drawOp->texture.texture != NULL ? D3DTOP_MODULATE : D3DTOP_SELECTARG2);
mDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE); mDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE);
mDevice->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_DIFFUSE); mDevice->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_DIFFUSE);
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG2); mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG2);
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_DIFFUSE); mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_DIFFUSE);
}
} }
const bool copCut = (drawOp->copRole == 2 && iOp > 0 const bool copCut = (drawOp->copRole == 2 && iOp > 0
@@ -1337,6 +1404,13 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, sVAop); mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, sVAop);
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, sVAa2); mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, sVAa2);
} }
if (damageTint)
{
// stage 1 is otherwise unused in this draw path -- put it back to
// DISABLE so the next (undamaged) op is not tinted too.
mDevice->SetTextureStageState(1, D3DTSS_COLOROP, D3DTOP_DISABLE);
mDevice->SetTextureStageState(1, D3DTSS_ALPHAOP, D3DTOP_DISABLE);
}
if (drawOp->lodDepthBias != 0.0f) if (drawOp->lodDepthBias != 0.0f)
mDevice->SetRenderState(D3DRS_DEPTHBIAS, sDB); mDevice->SetRenderState(D3DRS_DEPTHBIAS, sDB);
} }
+386 -7
View File
@@ -115,6 +115,14 @@ struct GWin
int wantX, wantY; int wantX, wantY;
int restored; // wantX/wantY came from glass_layout.cfg -> don't re-snap int restored; // wantX/wantY came from glass_layout.cfg -> don't re-snap
int noFrame; // ",noframe" in the cfg -> WS_POPUP, drag by the surface int noFrame; // ",noframe" in the cfg -> WS_POPUP, drag by the surface
// RGB CHANNEL GROUP (BT_POD_RGB): on splitter-wired pod glass this window
// is one VGA PORT feeding up to three mono monitors through R/G/B. These
// are the OTHER surfaces sharing the port; each is composited into its own
// channel by BlitSurface. groupCount 0 = ordinary single-surface window.
const char *groupPort[3];
const char *groupAlt[3]; // the Eng<n> twin, or NULL
int groupCount;
}; };
static GWin gWins[8]; static GWin gWins[8];
@@ -124,7 +132,8 @@ static int pressedAddress = -1;
static unsigned char latched[128]; static unsigned char latched[128];
static int gRadarRot = 3; // BT_GAUGE_SEC_ROT (default CW, upright) static int gRadarRot = 3; // BT_GAUGE_SEC_ROT (default CW, upright)
static bool sRepositionedToMain = false; static bool sRepositionedToMain = false;
static unsigned long *gStage = NULL; // 640*480 BGRA staging (shared, main-thread) static unsigned long *gStage = NULL; // 640*480 BGRA staging (shared, main-thread)
static unsigned long *gStage2 = NULL; // RGB composite scratch (BT_POD_RGB)
static HFONT titleFont = NULL; static HFONT titleFont = NULL;
static HFONT buttonFont = NULL; static HFONT buttonFont = NULL;
@@ -190,7 +199,9 @@ static void
GButton &b = w.buttons[w.buttonCount++]; GButton &b = w.buttons[w.buttonCount++];
b.address = src.address; b.address = src.address;
b.color = (src.colorClass == 1) ? ClrYellow b.color = (src.colorClass == 1) ? ClrYellow
: (src.colorClass == 2) ? ClrBlue : color; : (src.colorClass == 2) ? ClrBlue
: (src.colorClass == 0) ? ClrRed // 0 = red (MFD, and the red Panic/Eject 0x3D)
: color;
b.rect.left = src.x + dx; b.rect.left = src.x + dx;
b.rect.top = src.y + dy; b.rect.top = src.y + dy;
b.rect.right = src.x + dx + src.w; b.rect.right = src.x + dx + src.w;
@@ -405,6 +416,69 @@ enum { LayoutOff = 0, LayoutLoad = 1, LayoutSave = 2 };
static const char *layoutFileName = "glass_layout.cfg"; static const char *layoutFileName = "glass_layout.cfg";
// POD SURFACE MODE (BT_POD_SURFACES=1, 2026-08-06) -- the REAL pod bring-up
// shape. On the actual cab each MFD is its own 640x480 panel with PHYSICAL
// buttons and lamps around it, so the desktop chrome the glass windows draw
// (the RIO button bank + the frame) is exactly wrong there: it would paint
// fake buttons onto a panel that has real ones behind glass. In pod mode a
// window is cropped to its SURFACE, frameless, and the bank is dropped:
// one clean picture per physical panel, positioned by glass_layout.cfg.
// The Flight Controls window (no surface at all) is not created.
// BT_POD_SURFACES=1 -> every display window goes bare
// glass_layout.cfg "<title>=x,y,bare" -> just that one (mixed rigs)
// BT_POD_RGB=1 -- the pod's REAL video wiring (see pod-hardware.md §THE RGB
// SPLIT): one VGA port per window, its R/G/B analog lines split to three
// monochrome MFD monitors. Implies pod surface mode.
static int
PodRgbSplitMode()
{
static int m = -1;
if (m < 0)
{
const char *e = getenv("BT_POD_RGB");
m = (e != NULL && e[0] != 0 && e[0] != '0') ? 1 : 0;
}
return m;
}
static int
PodSurfaceMode()
{
static int m = -1;
if (m < 0)
{
const char *e = getenv("BT_POD_SURFACES");
m = (e != NULL && *e != '\0' && *e != '0') ? 1 : 0;
if (!m && PodRgbSplitMode())
m = 1; // the RGB split is pod glass -- always bare
if (m)
DEBUG_STREAM << "[glasswin] POD SURFACE MODE: bare surfaces, no "
"button banks (physical pod buttons assumed)\n" << std::flush;
}
return m;
}
// Crop a built window to its surface: drop the bank, move the picture to the
// client origin, go frameless. Called at the end of each Build* when pod mode
// is on (and from LoadLayout for a per-window ",bare").
static void
MakeBareSurface(GWin &w)
{
if (w.portPrimary == NULL) // Flight Controls -- nothing to show
{
w.clientW = w.clientH = 0;
w.buttonCount = 0;
return;
}
const int sw = w.surfaceRect.right - w.surfaceRect.left;
const int sh = w.surfaceRect.bottom - w.surfaceRect.top;
w.buttonCount = 0; // the pod's buttons are real
SetRect(&w.surfaceRect, 0, 0, sw, sh);
w.clientW = sw;
w.clientH = sh;
w.noFrame = 1;
}
// A framed window is a normal tool window; ",noframe" makes it a bare // A framed window is a normal tool window; ",noframe" makes it a bare
// WS_POPUP -- no caption, no border, nothing but the display and its buttons. // WS_POPUP -- no caption, no border, nothing but the display and its buttons.
static DWORD static DWORD
@@ -449,6 +523,91 @@ static GWin *
return NULL; return NULL;
} }
//###########################################################################
// External windows that ride glass_layout.cfg (2026-08-04). A window created
// by ANOTHER TU -- the plasma window (L4PLASMAWIN) -- can register here so it
// shares the SAME position + ",noframe" persistence as the per-display windows:
// it queries its saved rect/flag on creation, and Save() writes its line too.
// Tiny (HWND + title + last-known rect); no dynamic state.
//###########################################################################
struct ExternWin
{
HWND hwnd;
char title[64];
int noFrame;
RECT last;
int haveLast;
};
static ExternWin gExtern[4];
static int gExternCount = 0;
// Read the saved rect + ",noframe" flag for a title (glass window OR extern).
// Returns 1 if the title has a line in the cfg. rect / noframe may be NULL.
int
BTGlassLayout_QueryWindow(const char *title, RECT *rect, int *noframe)
{
if (title == NULL || GlassLayoutMode() == LayoutOff)
return 0;
FILE *f = fopen(layoutFileName, "rt");
if (f == NULL)
return 0;
int found = 0;
char line[256];
while (fgets(line, sizeof(line), f) != NULL)
{
char *s = line;
while (*s == ' ' || *s == '\t') ++s;
if (*s == '#' || *s == '\r' || *s == '\n' || *s == '\0')
continue;
char *eq = strchr(s, '=');
if (eq == NULL)
continue;
*eq = '\0';
char *end = eq;
while (end > s && (end[-1] == ' ' || end[-1] == '\t')) --end;
*end = '\0';
if (strcmp(s, title) != 0)
continue;
int x = 0, y = 0, w = 0, h = 0;
if (sscanf(eq + 1, "%d,%d,%d,%d", &x, &y, &w, &h) < 2)
continue;
int nf = 0;
for (const char *opt = strchr(eq + 1, ','); opt != NULL; opt = strchr(opt + 1, ','))
{
const char *t = opt + 1;
while (*t == ' ' || *t == '\t') ++t;
if (_strnicmp(t, "noframe", 7) == 0) { nf = 1; break; }
}
if (rect) { rect->left = x; rect->top = y; rect->right = x + w; rect->bottom = y + h; }
if (noframe) *noframe = nf;
found = 1;
break;
}
fclose(f);
return found;
}
// Register an externally-created window so Save() writes its line. Loads its
// current ",noframe" flag from the cfg so a save round-trips the flag.
void
BTGlassLayout_RegisterExtern(HWND hwnd, const char *title)
{
if (hwnd == NULL || title == NULL)
return;
if (gExternCount >= (int)(sizeof(gExtern) / sizeof(gExtern[0])))
return;
ExternWin &e = gExtern[gExternCount++];
e.hwnd = hwnd;
strncpy(e.title, title, sizeof(e.title) - 1);
e.title[sizeof(e.title) - 1] = '\0';
e.noFrame = 0;
e.haveLast = 0;
BTGlassLayout_QueryWindow(title, NULL, &e.noFrame);
}
// Restore saved positions over the just-computed pod-faithful defaults. Called // Restore saved positions over the just-computed pod-faithful defaults. Called
// from Create AFTER ComputeLayout, so any window not named in the file keeps its // from Create AFTER ComputeLayout, so any window not named in the file keeps its
// computed spot. Restored windows are flagged so the WM_TIMER re-snap (which // computed spot. Restored windows are flagged so the WM_TIMER re-snap (which
@@ -493,16 +652,15 @@ static void
// Trailing options after the numbers, comma-separated. Unknown ones // Trailing options after the numbers, comma-separated. Unknown ones
// are ignored (so an older build reading a newer file just loses the // are ignored (so an older build reading a newer file just loses the
// option, never the line) -- the bindings.txt grammar rule. // option, never the line) -- the bindings.txt grammar rule.
int noframe = 0; int noframe = 0, bare = 0;
for (const char *opt = strchr(eq + 1, ','); opt != NULL; opt = strchr(opt + 1, ',')) for (const char *opt = strchr(eq + 1, ','); opt != NULL; opt = strchr(opt + 1, ','))
{ {
const char *t = opt + 1; const char *t = opt + 1;
while (*t == ' ' || *t == '\t') ++t; while (*t == ' ' || *t == '\t') ++t;
if (_strnicmp(t, "noframe", 7) == 0) if (_strnicmp(t, "noframe", 7) == 0)
{
noframe = 1; noframe = 1;
break; else if (_strnicmp(t, "bare", 4) == 0)
} bare = 1; // pod panel: surface only (see PodSurfaceMode)
} }
GWin *gw = FindGWinByTitle(s); GWin *gw = FindGWinByTitle(s);
@@ -511,6 +669,8 @@ static void
gw->wantX = x; gw->wantX = x;
gw->wantY = y; gw->wantY = y;
gw->noFrame = noframe; gw->noFrame = noframe;
if (bare && gw->buttonCount > 0)
MakeBareSurface(*gw); // per-window pod panel (mixed rigs)
gw->restored = 1; gw->restored = 1;
++restored; ++restored;
} }
@@ -545,7 +705,8 @@ static void
"# Heat MFD=1920,0,657,539,noframe\n" "# Heat MFD=1920,0,657,539,noframe\n"
"# A frameless window is also PINNED (no caption = nothing to drag it\n" "# A frameless window is also PINNED (no caption = nothing to drag it\n"
"# by), so do the arranging first. Delete the flag to get the frame\n" "# by), so do the arranging first. Delete the flag to get the frame\n"
"# back. Saves preserve it.\n", "# back. Saves preserve it. The 'BattleTech - Plasma' window is in\n"
"# this list too -- it can be dragged, remembered and set ,noframe.\n",
f); f);
int wrote = 0; int wrote = 0;
for (int i = 0; i < gWinCount; ++i) for (int i = 0; i < gWinCount; ++i)
@@ -562,11 +723,41 @@ static void
gw.noFrame ? ",noframe" : ""); // keep the hand-added option gw.noFrame ? ",noframe" : ""); // keep the hand-added option
++wrote; ++wrote;
} }
// External windows (the plasma window) ride the same file. Cache the last
// good rect so a window torn down before this save still keeps its line.
for (int i = 0; i < gExternCount; ++i)
{
ExternWin &e = gExtern[i];
RECT r;
if (e.hwnd != NULL && IsWindow(e.hwnd) && GetWindowRect(e.hwnd, &r))
{
e.last = r; e.haveLast = 1;
}
else if (e.haveLast)
{
r = e.last; // window gone -> preserve its last-known line
}
else
continue;
fprintf(f, "%s=%ld,%ld,%ld,%ld%s\n", e.title,
(long)r.left, (long)r.top,
(long)(r.right - r.left), (long)(r.bottom - r.top),
e.noFrame ? ",noframe" : "");
++wrote;
}
fclose(f); fclose(f);
DEBUG_STREAM << "[glasswin] saved " << wrote << " window position(s) to " DEBUG_STREAM << "[glasswin] saved " << wrote << " window position(s) to "
<< layoutFileName << "\n" << std::flush; << layoutFileName << "\n" << std::flush;
} }
// Public save trigger for registered external windows -- their WndProc calls
// this on WM_EXITSIZEMOVE / teardown. No-op unless BT_GLASS_LAYOUT=save.
void
BTGlassLayout_Save()
{
SaveLayout();
}
//########################################################################### //###########################################################################
// Painting // Painting
//########################################################################### //###########################################################################
@@ -652,6 +843,71 @@ static void
} }
int ow = 0, oh = 0; int ow = 0, oh = 0;
svga->ExpandPlaneToBGRA(mask, palId, tint, w->rotate, gStage, &ow, &oh); svga->ExpandPlaneToBGRA(mask, palId, tint, w->rotate, gStage, &ow, &oh);
// ---------------------------------------------------------------------
// RGB CHANNEL COMPOSITE (BT_POD_RGB, 2026-08-06) -- the AUTHENTIC pod
// output. The cab does not give each mono MFD its own video port: one
// VGA port's R/G/B analog lines are SPLIT to three monochrome monitors,
// and the game puts a different surface in each colour channel of one
// palettized framebuffer (L4GAUGE.CFG: Comm=red, Mfd2=green, Heat=blue
// on clut2; Mfd1=red, Mfd3=green on clut1 -- and
// L4GraphicsPort::BuildSecondaryColor writes exactly ONE component per
// port, which IS the split). So on splitter-wired glass this window is
// not one MFD: it is one VGA PORT, carrying up to three. Composite the
// group's planes into this frame, each in its own channel, and let the
// hardware separate them again.
//
// Channel comes from the port itself (GetEnableID), so the authored
// config -- including a page swap that hands the channel to an Eng
// plane -- decides, never a hardcoded table here.
// ---------------------------------------------------------------------
if (w->groupCount > 0 && ow > 0 && oh > 0)
{
const int n = ow * oh;
for (int gi = 0; gi < w->groupCount; ++gi)
{
L4GraphicsPort *gp =
static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(w->groupPort[gi]));
if (gp == NULL)
continue;
// honour the live Mfd<n>/Eng<n> page swap: take whichever twin
// currently owns a channel
if (gp->GetEnableID() == L4GraphicsPort::BlankColor
&& w->groupAlt[gi] != NULL)
{
L4GraphicsPort *ga =
static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(w->groupAlt[gi]));
if (ga != NULL && ga->GetEnableID() != L4GraphicsPort::BlankColor)
gp = ga;
}
const int ch = gp->GetEnableID();
unsigned long chMask;
switch (ch & 0x7F)
{
case L4GraphicsPort::RedChannel: chMask = 0x00FF0000ul; break;
case L4GraphicsPort::GreenChannel: chMask = 0x0000FF00ul; break;
case L4GraphicsPort::BlueChannel: chMask = 0x000000FFul; break;
default: continue; // blank / direct-colour -> contributes nothing
}
int gw = 0, gh = 0;
// -1 tint would palette-expand; pass white so the plane comes back
// as full-intensity mono, then keep only this port's channel.
svga->ExpandPlaneToBGRA(gp->GetBitMask(), gp->paletteID,
(int)0x00FFFFFF, w->rotate, gStage2, &gw, &gh);
if (gw != ow || gh != oh)
continue;
for (int i = 0; i < n; ++i)
gStage[i] |= (gStage2[i] & chMask);
}
if (log)
{
DEBUG_STREAM << "[glass] '" << w->title << "' RGB COMPOSITE of "
<< w->groupCount << " plane(s):";
for (int gi = 0; gi < w->groupCount; ++gi)
DEBUG_STREAM << " " << w->groupPort[gi];
DEBUG_STREAM << "\n" << std::flush;
}
}
if (log) if (log)
{ {
int nz = 0, n = ow * oh; int nz = 0, n = ow * oh;
@@ -809,6 +1065,10 @@ static void
SelectObject(dc, oldBrush); SelectObject(dc, oldBrush);
SelectObject(dc, oldPen); SelectObject(dc, oldPen);
DeleteObject(pen); DeleteObject(pen);
// (NO full-face flash overlay here either -- same wrongness as the
// L4VB16 one, removed 2026-08-03: the buttons are big rects tucked
// under the surface by design, and the protruding edge is the lamp.)
} }
BitBlt(winDC, 0, 0, client.right - client.left, client.bottom - client.top, BitBlt(winDC, 0, 0, client.right - client.left, client.bottom - client.top,
@@ -957,6 +1217,8 @@ void
sRepositionedToMain = false; sRepositionedToMain = false;
if (gStage == NULL) if (gStage == NULL)
gStage = new unsigned long[640 * 480]; gStage = new unsigned long[640 * 480];
if (gStage2 == NULL)
gStage2 = new unsigned long[640 * 480];
// Build the seven windows. Order fixes the ComputeLayout roles (0..6). // Build the seven windows. Order fixes the ComputeLayout roles (0..6).
BuildMfd (gWins[0], "Heat MFD", "Heat", NULL, 0x2F); // UL 0x28-0x2F BuildMfd (gWins[0], "Heat MFD", "Heat", NULL, 0x2F); // UL 0x28-0x2F
@@ -968,6 +1230,52 @@ void
BuildFlight(gWins[6]); // 0x38-0x47 BuildFlight(gWins[6]); // 0x38-0x47
gWinCount = 7; gWinCount = 7;
// POD SURFACE MODE: crop every display to its picture and drop the Flight
// Controls pad (it is pure desktop chrome -- the cab has a real stick).
if (PodSurfaceMode())
{
for (int i = 0; i < gWinCount; ++i)
MakeBareSurface(gWins[i]);
if (gWins[6].portPrimary == NULL)
gWinCount = 6; // drop Flight Controls
}
// RGB SPLIT MODE (BT_POD_RGB): the cab's real wiring -- ONE VGA port per
// window, its R/G/B lines split to three mono monitors. Collapse the five
// separate MFD windows into the TWO ports the pod actually drives
// (L4GAUGE.CFG clut2 = Comm/Mfd2/Heat, clut1 = Mfd1/Mfd3), each window
// compositing its group into the colour channels; the radar keeps its own
// full-colour port. Channels are read live from each port, so a page swap
// to an Eng plane follows automatically.
if (PodRgbSplitMode())
{
GWin port2 = gWins[0]; // reuse the Heat window's shape
port2.title = "VGA Port A"; // Comm=red, Mfd2=green, Heat=blue
port2.portPrimary = "Heat"; // base plane (blue); group adds the rest
port2.portAlt = NULL;
port2.groupCount = 2;
port2.groupPort[0] = "Comm"; port2.groupAlt[0] = NULL;
port2.groupPort[1] = "Mfd2"; port2.groupAlt[1] = "Eng2";
port2.monoTint = (int)0x000000FF; // Heat rides BLUE
GWin port1 = gWins[3]; // the Mfd1 window's shape
port1.title = "VGA Port B"; // Mfd1=red, Mfd3=green (blue spare)
port1.portPrimary = "Mfd1";
port1.portAlt = "Eng1";
port1.groupCount = 1;
port1.groupPort[0] = "Mfd3"; port1.groupAlt[0] = "Eng3";
port1.monoTint = (int)0x00FF0000; // Mfd1 rides RED
GWin radar = gWins[5]; // unchanged: its own colour port
gWins[0] = port2;
gWins[1] = port1;
gWins[2] = radar;
gWinCount = 3;
DEBUG_STREAM << "[glasswin] RGB SPLIT MODE: 2 VGA ports + radar "
"(each port's R/G/B feeds three mono panels)\n" << std::flush;
}
titleFont = CreateFontW(-11, 0, 0, 0, FW_BOLD, 0, 0, 0, titleFont = CreateFontW(-11, 0, 0, 0, FW_BOLD, 0, 0, 0,
DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, L"Segoe UI"); DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, L"Segoe UI");
buttonFont = CreateFontW(-10, 0, 0, 0, FW_NORMAL, 0, 0, 0, buttonFont = CreateFontW(-10, 0, 0, 0, FW_NORMAL, 0, 0, 0,
@@ -1023,6 +1331,37 @@ void
SetLayeredWindowAttributes(w.hwnd, 0, 255, LWA_ALPHA); SetLayeredWindowAttributes(w.hwnd, 0, 255, LWA_ALPHA);
SetTimer(w.hwnd, RepaintTimerId, RepaintMilliseconds, NULL); SetTimer(w.hwnd, RepaintTimerId, RepaintMilliseconds, NULL);
ShowWindow(w.hwnd, SW_SHOWNOACTIVATE); ShowWindow(w.hwnd, SW_SHOWNOACTIVATE);
// PLACEMENT RECEIPT (pod bring-up, 2026-08-06): which PHYSICAL monitor
// each display actually landed on. On a real cab -- or any remote
// session -- nobody can see all seven surfaces at once, so the log is
// the only way to confirm the map without walking around the pod.
{
RECT wr = { 0, 0, 0, 0 };
GetWindowRect(w.hwnd, &wr);
HMONITOR mon = MonitorFromWindow(w.hwnd, MONITOR_DEFAULTTONEAREST);
MONITORINFOEXA mi;
memset(&mi, 0, sizeof(mi));
mi.cbSize = sizeof(mi);
const char *devName = "?";
int mx = 0, my = 0, mw = 0, mh = 0, primary = 0;
if (mon != NULL && GetMonitorInfoA(mon, (MONITORINFO *)&mi))
{
devName = mi.szDevice;
mx = mi.rcMonitor.left; my = mi.rcMonitor.top;
mw = mi.rcMonitor.right - mi.rcMonitor.left;
mh = mi.rcMonitor.bottom - mi.rcMonitor.top;
primary = (mi.dwFlags & MONITORINFOF_PRIMARY) ? 1 : 0;
}
DEBUG_STREAM << "[glasswin] '" << w.title << "' surface="
<< (w.portPrimary ? w.portPrimary : "-")
<< " at " << wr.left << "," << wr.top
<< " " << (wr.right - wr.left) << "x" << (wr.bottom - wr.top)
<< (w.noFrame ? " bare" : " framed")
<< " -> monitor " << devName
<< " (" << mx << "," << my << " " << mw << "x" << mh
<< (primary ? ", PRIMARY)" : ")") << "\n" << std::flush;
}
} }
DEBUG_STREAM << "[glasswin] per-display cockpit up (" << gWinCount DEBUG_STREAM << "[glasswin] per-display cockpit up (" << gWinCount
@@ -1050,3 +1389,43 @@ void
if (subFont) { DeleteObject(subFont); subFont = NULL; } if (subFont) { DeleteObject(subFont); subFont = NULL; }
if (gStage) { delete[] gStage; gStage = NULL; } if (gStage) { delete[] gStage; gStage = NULL; }
} }
//###########################################################################
// Per-frame repaint pump (2026-08-04, "indicators flash slowly unless focused").
//
// These windows repaint off a 62 ms WM_TIMER. Windows COALESCES/THROTTLES timer
// AND paint messages for a window that is in the BACKGROUND (not focused) -- so
// when the game window (or another app) holds focus, the panel's WM_TIMER slows
// to a crawl and the lamp FLASH (a repaint-driven animation: BTLampBrightnessOf
// alternates the shade off GetTickCount every paint) drags. Giving the panel
// focus un-throttles its timer, which is exactly what playtesters saw.
//
// Fix: drive the repaint from the MAIN render loop (BTGlassPanels_Tick, called
// once per frame from L4VIDEO), gated to the same ~16 Hz flash cadence. The main
// loop runs every frame while the game is up regardless of which window has focus,
// and InvalidateRect + UpdateWindow forces a SYNCHRONOUS WM_PAINT -- bypassing the
// throttled timer-message path entirely. The WM_TIMER stays (it still owns the
// one-shot re-snap); a focused window just repaints from whichever fires first.
//###########################################################################
void
BTGlassPanels_Tick()
{
if (gWinCount == 0)
return;
static unsigned long sLastPaint = 0;
unsigned long now = GetTickCount();
if (now - sLastPaint < (unsigned long)RepaintMilliseconds)
return;
sLastPaint = now;
for (int i = 0; i < gWinCount; ++i)
{
if (gWins[i].hwnd != NULL)
{
InvalidateRect(gWins[i].hwnd, NULL, FALSE);
UpdateWindow(gWins[i].hwnd); // synchronous paint, not the throttled queue
}
}
}
+7 -2
View File
@@ -812,13 +812,18 @@ void
switch (type) switch (type)
{ {
case scrambleVideo: case scrambleVideo:
if (graphicsDisplay != NULL) // NON-STACKING LATCH (phase-14 fidelity): the binary latches on `modified`
// @0x4fe0fe -- a second PPC hit while the scramble is ALREADY live does NOT
// re-save the (already detuned) value and does NOT extend the window. This
// original unconditionally overwrote scrambleVideoTimeout, so rapid PPC fire
// STACKED the effect -- a divergence from the binary. Ignore re-arm while live.
if (graphicsDisplay != NULL && !scrambleVideoFlag)
{ {
scrambleVideoFlag = True; scrambleVideoFlag = True;
scrambleVideoTimeout = ((Scalar)Now()) + duration; scrambleVideoTimeout = ((Scalar)Now()) + duration;
Check(graphicsDisplay); Check(graphicsDisplay);
((SVGA16*) graphicsDisplay)->FunkyVideo(True); ((SVGA16*) graphicsDisplay)->FunkyVideo(True, duration); // duration -> the recovery envelope
} }
break; break;
+45
View File
@@ -466,6 +466,21 @@ void
<< (pressed ? "HELD" : "released") << "\n" << std::flush; << (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; RIOEvent event;
event.Type = pressed ? ButtonPressedEvent : ButtonReleasedEvent; event.Type = pressed ? ButtonPressedEvent : ButtonReleasedEvent;
event.Data.Unit = address; event.Data.Unit = address;
@@ -479,6 +494,9 @@ void
event.Type = KeyEvent; event.Type = KeyEvent;
event.Data.Keyboard.Unit = unit; event.Data.Keyboard.Unit = unit;
event.Data.Keyboard.Key = key; event.Data.Keyboard.Key = key;
if (getenv("BT_PAD_LOG"))
DEBUG_STREAM << "[padkey] unit " << unit << " key " << key
<< " queued\n" << std::flush;
PushEvent(event); PushEvent(event);
} }
@@ -524,6 +542,33 @@ void
DEBUG_STREAM << "[btntest] RELEASE 0x" << std::hex << s_btnAddr DEBUG_STREAM << "[btntest] RELEASE 0x" << std::hex << s_btnAddr
<< std::dec << " at poll " << s_poll << "\n" << std::flush; << 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;
}
}
} }
} }
+87 -8
View File
@@ -1,5 +1,7 @@
#include "l4particles.h" #include "l4particles.h"
#include "../munga/time.h" #include "../munga/time.h"
#include "../munga/style.h" // DEBUG_STREAM (#35 diagnostics)
#include <iostream>
LPDIRECT3DDEVICE9 ParticleEngine::mDevice = NULL; LPDIRECT3DDEVICE9 ParticleEngine::mDevice = NULL;
PARTICLE_EFFECT ParticleEngine::mInstalledEffects[MAX_PARTICLE_EFFECTS]; PARTICLE_EFFECT ParticleEngine::mInstalledEffects[MAX_PARTICLE_EFFECTS];
@@ -251,26 +253,90 @@ void ParticleEmitter::Execute()
void ParticleEngine::Destroy() void ParticleEngine::Destroy()
{ {
mVertBuffer->Release(); // #35 (the field "Owens crash"). This runs on the DEVICELOST path, and a
mParticleTexture->Release(); // device can stay lost for several frames -- so this must be idempotent
// and null-safe. It used to Release() blind: the first lost frame
// released the buffer, the failed re-create on the still-lost device left
// both statics NULL, and the SECOND lost frame's Release() read the
// vtable at 0x0 (8/8 field stacks byte-identical at Destroy +0x11;
// reproduced on the bench with BT_DEVICELOST_TEST=<n>,crashrepro).
if (mVertBuffer != NULL)
{
mVertBuffer->Release();
mVertBuffer = NULL;
}
if (mParticleTexture != NULL)
{
mParticleTexture->Release();
mParticleTexture = NULL;
}
} }
void ParticleEngine::Initialize(LPDIRECT3DDEVICE9 device) void ParticleEngine::Initialize(LPDIRECT3DDEVICE9 device)
{ {
mDevice = device; // Full STARTUP init only. The device-reset path must use
// CreateDeviceObjects() below -- coming through here would memset the
// installed-effects table and kill every particle effect for the rest of
// the mission (#35's quieter sibling).
memset(mInstalledEffects, 0, sizeof(mInstalledEffects)); memset(mInstalledEffects, 0, sizeof(mInstalledEffects));
ParticleEngine::mMaxParticleCount = atoi(getenv("MAXPARTICLES")); const char *max_env = getenv("MAXPARTICLES");
ParticleEngine::mMaxParticleCount = (max_env != NULL) ? atoi(max_env) : 0;
if (ParticleEngine::mMaxParticleCount <= 0)
ParticleEngine::mMaxParticleCount = 8192; // btl4main defaults the env; belt + braces
// create the vertex buffer that will store the four vertices we need for the billboards CreateDeviceObjects(device);
mDevice->CreateVertexBuffer(ParticleEngine::mMaxParticleCount * 6 * sizeof(L4BASICVERTEX), }
void ParticleEngine::CreateDeviceObjects(LPDIRECT3DDEVICE9 device)
{
// (Re)create the D3D resources. #35: CHECK the HRESULTs -- on a lost
// device (or an out-of-video-memory iGPU, the field machine is a 128 MB
// Iris Xe) these FAIL and null their out params; the old code drove
// straight on, and the NULLs then killed the next Destroy(). On failure
// we log and leave the engine dormant -- Execute/Render guard on the
// NULLs, particles drain but don't draw -- and the next successful reset
// brings it back.
mDevice = device;
mVertBuffer = NULL;
mParticleTexture = NULL;
// create the vertex buffer that will store the six vertices we need for the billboards
HRESULT hr = mDevice->CreateVertexBuffer(
ParticleEngine::mMaxParticleCount * 6 * sizeof(L4BASICVERTEX),
D3DUSAGE_DYNAMIC, D3DUSAGE_DYNAMIC,
L4BASICVERTEX_FVF, L4BASICVERTEX_FVF,
D3DPOOL_DEFAULT, D3DPOOL_DEFAULT,
&mVertBuffer, &mVertBuffer,
NULL); NULL);
if (FAILED(hr))
{
mVertBuffer = NULL; // belt + braces vs runtime behavior
DEBUG_STREAM << "[particles] CreateVertexBuffer FAILED hr=0x"
<< std::hex << (unsigned long)hr << std::dec
<< " (max=" << ParticleEngine::mMaxParticleCount
<< ") -- particles dormant until the next successful reset"
<< std::endl << std::flush;
}
D3DXCreateTextureFromFile(mDevice, L"VIDEO\\particles.png", &mParticleTexture); // NB: VIDEO\particles.png SHIPS as of 2026-07-29 (recovered by cyd,
// issue #79 -- 128x128 RGBA). Before that it had never shipped (absent
// from the tree, the RES, and all of git history), so this load failed
// on every machine since the engine was written and particles drew
// untextured -- which is therefore what the 1995 pods displayed. The failure is expected + logged once; rendering
// proceeds without the texture (SetTexture(0, NULL) = the shipped look).
hr = D3DXCreateTextureFromFile(mDevice, L"VIDEO\\particles.png", &mParticleTexture);
if (FAILED(hr))
mParticleTexture = NULL;
if (mVertBuffer != NULL)
{
DEBUG_STREAM << "[particles] device objects created (max="
<< ParticleEngine::mMaxParticleCount
<< ", texture=" << (mParticleTexture != NULL ? "loaded" : "MISSING (untextured quads -- the shipped look)")
<< ")" << std::endl << std::flush;
}
} }
void ParticleEngine::InstallEffect(int effectNumber, PARTICLE_EFFECT effect) void ParticleEngine::InstallEffect(int effectNumber, PARTICLE_EFFECT effect)
@@ -453,6 +519,12 @@ void ParticleEngine::ExecuteParticles(const D3DXMATRIX *view_matrix, Scalar time
} }
} }
// #35: with the device objects torn down (device lost mid-reset) the
// update/expire loop above must still run -- particles keep draining --
// but there is no buffer to build into.
if (mVertBuffer == NULL)
return;
// now we're going to sort the particle list based on distance to the camera // now we're going to sort the particle list based on distance to the camera
mergesort(&mParticlesHead, &mParticlesTail, view_matrix); mergesort(&mParticlesHead, &mParticlesTail, view_matrix);
@@ -484,7 +556,14 @@ void ParticleEngine::RenderParticles(const D3DXMATRIX *view_matrix, Scalar timeS
// variables get all screwed up when we don't run for a while // variables get all screwed up when we don't run for a while
ExecuteParticles(view_matrix, timeSlice); ExecuteParticles(view_matrix, timeSlice);
if (!mDevice || mTotalParticleCount <= 0) // (#35: the buffer is legitimately NULL while the device is lost. The
// TEXTURE is deliberately NOT part of this gate: VIDEO\particles.png was
// recovered only in 2026 (issue #79) -- before that mParticleTexture was
// NULL on every machine since the engine was written, and
// SetTexture(0, NULL) drawing untextured quads WAS the
// shipped look. Gating on it would silently disable all billboard
// particles everywhere.)
if (!mDevice || mVertBuffer == NULL || mTotalParticleCount <= 0)
return; return;
// setup stages for particle's texture // setup stages for particle's texture
+5
View File
@@ -136,6 +136,11 @@ class ParticleEngine
public: public:
static void Destroy(); static void Destroy();
static void Initialize(LPDIRECT3DDEVICE9 device); static void Initialize(LPDIRECT3DDEVICE9 device);
// #35: the device-RESET path re-creates ONLY the D3D objects. Initialize()
// is full startup init -- it also wipes the installed-effects table, which
// a mid-mission reset must never do (it silently killed every particle
// effect for the rest of the mission).
static void CreateDeviceObjects(LPDIRECT3DDEVICE9 device);
static void InstallEffect(int effectNumber, PARTICLE_EFFECT effect); static void InstallEffect(int effectNumber, PARTICLE_EFFECT effect);
static void RenderParticles(const D3DXMATRIX *view_matrix, Scalar timeSlice); static void RenderParticles(const D3DXMATRIX *view_matrix, Scalar timeSlice);
+42 -11
View File
@@ -18,6 +18,12 @@ static LRESULT CALLBACK
{ {
switch (message) switch (message)
{ {
case WM_EXITSIZEMOVE:
// Finished dragging (framed mode): persist the new position so it sticks
// -- same contract as the per-display panels. No-op unless save mode.
{ extern void BTGlassLayout_Save(); BTGlassLayout_Save(); }
return 0;
case WM_CLOSE: case WM_CLOSE:
ShowWindow(window, SW_HIDE); // hide only; the renderer owns it ShowWindow(window, SW_HIDE); // hide only; the renderer owns it
return 0; return 0;
@@ -52,6 +58,7 @@ PlasmaWindow::~PlasmaWindow()
{ {
if (window != NULL) if (window != NULL)
{ {
{ extern void BTGlassLayout_Save(); BTGlassLayout_Save(); } // backstop before the HWND goes
DestroyWindow((HWND)window); DestroyWindow((HWND)window);
window = NULL; window = NULL;
} }
@@ -75,20 +82,43 @@ void
window_class.lpszClassName = L"BTPlasmaWnd"; window_class.lpszClassName = L"BTPlasmaWnd";
RegisterClassW(&window_class); RegisterClassW(&window_class);
RECT frame = { 0, 0, plasmaWidth * scale, plasmaHeight * scale }; // glass_layout.cfg integration (2026-08-04): the plasma window rides the same
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX; // file as the per-display panels, so it can be dragged-and-remembered and set
AdjustWindowRect(&frame, style, FALSE); // frameless with ",noframe" -- BT_GLASS_LAYOUT selects load/save. Read its
// saved rect + flag BEFORE sizing (WS_POPUP has a different frame extent than
// the framed tool window).
extern int BTGlassLayout_QueryWindow(const char *title, RECT *rect, int *noframe);
extern void BTGlassLayout_RegisterExtern(HWND hwnd, const char *title);
RECT saved;
int noframe = 0;
int have_saved = BTGlassLayout_QueryWindow("BattleTech - Plasma", &saved, &noframe);
// DWORD style = noframe
// Bottom-right, TOPMOST -- under the (topmost, right-parked) button ? (DWORD)WS_POPUP // bare + pinned, same as a ",noframe" glass panel
// panel, clear of the game window (which would otherwise bury it). : (DWORD)(WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX);
//
RECT frame = { 0, 0, plasmaWidth * scale, plasmaHeight * scale };
AdjustWindowRect(&frame, style, FALSE);
int frame_width = frame.right - frame.left; int frame_width = frame.right - frame.left;
int frame_height = frame.bottom - frame.top; int frame_height = frame.bottom - frame.top;
int plasma_x = GetSystemMetrics(SM_CXSCREEN) - frame_width - 12;
int plasma_y = GetSystemMetrics(SM_CYSCREEN) - frame_height - 60; int plasma_x, plasma_y;
if (plasma_x < 0) plasma_x = 0; if (have_saved)
if (plasma_y < 0) plasma_y = 0; {
plasma_x = saved.left; // position restored; size stays native
plasma_y = saved.top;
}
else
{
//
// Default: bottom-right, TOPMOST -- under the (topmost, right-parked)
// button panel, clear of the game window (which would otherwise bury it).
//
plasma_x = GetSystemMetrics(SM_CXSCREEN) - frame_width - 12;
plasma_y = GetSystemMetrics(SM_CYSCREEN) - frame_height - 60;
if (plasma_x < 0) plasma_x = 0;
if (plasma_y < 0) plasma_y = 0;
}
window = CreateWindowExW( window = CreateWindowExW(
WS_EX_TOPMOST, WS_EX_TOPMOST,
@@ -102,6 +132,7 @@ void
DEBUG_STREAM << "[plasmawin] CreateWindow failed\n" << std::flush; DEBUG_STREAM << "[plasmawin] CreateWindow failed\n" << std::flush;
return; return;
} }
BTGlassLayout_RegisterExtern((HWND)window, "BattleTech - Plasma");
ShowWindow((HWND)window, SW_SHOWNOACTIVATE); ShowWindow((HWND)window, SW_SHOWNOACTIVATE);
} }
+4 -1
View File
@@ -258,7 +258,10 @@ void
int row = i / columns; int row = i / columns;
int x = originX + column * (cw + gap); int x = originX + column * (cw + gap);
int y = originY + row * (ch + gap); int y = originY + row * (ch + gap);
Push(out, baseAddress + i, x, y, cw, ch, 2, // Flight blocks are blue (colorClass 2), EXCEPT the Panic/Eject at 0x3D:
// red (colorClass 0) so the eject stands out on the blue panel.
int cc = (baseAddress + i == 0x3D) ? 0 : 2;
Push(out, baseAddress + i, x, y, cw, ch, cc,
(inert != 0) ? inert[i] : 0, (inert != 0) ? inert[i] : 0,
(labels != 0) ? labels[i] : 0); (labels != 0) ? labels[i] : 0);
GrowBounds(out, x, y, cw, ch); GrowBounds(out, x, y, cw, ch);
+287 -13
View File
@@ -615,17 +615,41 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
Word *source = pixelBuffer.Data.MapPointer; Word *source = pixelBuffer.Data.MapPointer;
Word *dest = (Word*)rect.pBits; Word *dest = (Word*)rect.pBits;
int postRowIncrement = (rect.Pitch / 2) - w; int postRowIncrement = (rect.Pitch / 2) - w;
// PPC sync-scramble (phase-14): map the SOURCE read through the recovery
// envelope (collapse toward a line -> broaden -> lock). Identity when not
// armed (ScrambleParams returns False). Output columns outside the collapsed
// band read index 0 (black), so the picture squeezes to a line and grows back.
double scl, shr, roff; int cx, shake;
Logical scr = ScrambleParams(w, &scl, &shr, &roff, &cx, &shake);
double invS = scr ? (1.0 / scl) : 1.0;
if (monoTint < 0) if (monoTint < 0)
{ {
// PALETTE surface (sec/radar) -- palette-LUT expand (== SVGA16::Update case 0). // PALETTE surface (sec/radar) -- palette-LUT expand (== SVGA16::Update case 0).
SVGA16Palette *pal = &palette[paletteID]; SVGA16Palette *pal = &palette[paletteID];
for (int y = 0; y < h; y++) for (int y = 0; y < h; y++)
{ {
int sry = y + shake; if (sry < 0) sry = 0; else if (sry >= h) sry = h - 1;
Word *srcRow = source + sry * w; // vertical shake bounces the row
double rowScroll = roff + shr * (double)y; // scroll + diagonal, per row
for (int x = 0; x < w; x++) for (int x = 0; x < w; x++)
{ {
PaletteTriplet *pe = &(pal->paletteData.Color[*source & mask]); Word px;
if (scr)
{
double srcBase = ((double)x - cx) * invS + cx; // collapse band
if (srcBase < 0.0 || srcBase >= w)
px = 0; // black outside the line
else
{
long sx = (long)(srcBase + rowScroll + 0.5);
sx %= w; if (sx < 0) sx += w; // roll SCROLLS within source
px = srcRow[sx];
}
}
else px = srcRow[x];
PaletteTriplet *pe = &(pal->paletteData.Color[px & mask]);
*dest = ((pe->Red >> 3) << 11) | ((pe->Green >> 2) << 5) | (pe->Blue >> 3); *dest = ((pe->Red >> 3) << 11) | ((pe->Green >> 2) << 5) | (pe->Blue >> 3);
dest++; source++; dest++;
} }
dest += postRowIncrement; dest += postRowIncrement;
} }
@@ -637,10 +661,27 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
Word tint = (Word) monoTint; Word tint = (Word) monoTint;
for (int y = 0; y < h; y++) for (int y = 0; y < h; y++)
{ {
int sry = y + shake; if (sry < 0) sry = 0; else if (sry >= h) sry = h - 1;
Word *srcRow = source + sry * w; // vertical shake bounces the row
double rowScroll = roff + shr * (double)y;
for (int x = 0; x < w; x++) for (int x = 0; x < w; x++)
{ {
*dest = (*source & mask) ? tint : 0; Word px;
dest++; source++; if (scr)
{
double srcBase = ((double)x - cx) * invS + cx;
if (srcBase < 0.0 || srcBase >= w)
px = 0;
else
{
long sx = (long)(srcBase + rowScroll + 0.5);
sx %= w; if (sx < 0) sx += w;
px = srcRow[sx];
}
}
else px = srcRow[x];
*dest = (px & mask) ? tint : 0;
dest++;
} }
dest += postRowIncrement; dest += postRowIncrement;
} }
@@ -712,16 +753,38 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
Word *base = pixelBuffer.Data.MapPointer; Word *base = pixelBuffer.Data.MapPointer;
SVGA16Palette *pal = &palette[paletteID]; SVGA16Palette *pal = &palette[paletteID];
// PPC sync-scramble (phase-14) -- the recovery envelope (collapse -> broaden ->
// lock), mapped in SOURCE space so it survives the rotation. Identity when not
// armed (ScrambleParams False). Reads outside the collapsed band -> index 0 (black).
double scl, shr, roff; int cx, shake;
Logical scr = ScrambleParams(w, &scl, &shr, &roff, &cx, &shake);
double invS = scr ? (1.0 / scl) : 1.0;
if (rotateQuadrant == 0) if (rotateQuadrant == 0)
{ {
// Native orientation, top-down straight copy. // Native orientation, top-down straight copy.
for (int y = 0; y < h; y++) for (int y = 0; y < h; y++)
{ {
Word *src = base + y * w; int sry = y + shake; if (sry < 0) sry = 0; else if (sry >= h) sry = h - 1;
Word *src = base + sry * w; // vertical shake bounces the row
unsigned long *d = dst + y * w; unsigned long *d = dst + y * w;
double rowScroll = roff + shr * (double)y;
for (int x = 0; x < w; x++) for (int x = 0; x < w; x++)
{ {
Word s = src[x]; Word s;
if (scr)
{
double srcBase = ((double)x - cx) * invS + cx; // collapse band
if (srcBase < 0.0 || srcBase >= w)
s = 0; // black outside the line
else
{
long sx = (long)(srcBase + rowScroll + 0.5);
sx %= w; if (sx < 0) sx += w; // roll SCROLLS within source
s = src[sx];
}
}
else s = src[x];
if (monoTint < 0) if (monoTint < 0)
{ {
PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]); PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]);
@@ -741,6 +804,8 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
// 90-degree rotation: output is transposed (ow = h, oh = w). rotate 3 = CW, // 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). // rotate 1 = CCW (the DrawDevSurface convention; BT_GAUGE_SEC_ROT picks it).
// The scramble maps the SOURCE column (sx keyed on source row sy) through the
// same envelope, so the radar recovers the same way the landscape MFDs do.
int ow = h, oh = w; int ow = h, oh = w;
for (int oy = 0; oy < oh; oy++) for (int oy = 0; oy < oh; oy++)
{ {
@@ -758,7 +823,21 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
sx = w - 1 - oy; sx = w - 1 - oy;
sy = ox; sy = ox;
} }
Word s = base[sy * w + sx]; int syS = sy + shake; if (syS < 0) syS = 0; else if (syS >= h) syS = h - 1; // shake
Word s;
if (scr)
{
double srcBase = ((double)sx - cx) * invS + cx; // collapse band (source col)
if (srcBase < 0.0 || srcBase >= w)
s = 0;
else
{
long msx = (long)(srcBase + roff + shr * (double)sy + 0.5);
msx %= w; if (msx < 0) msx += w; // roll SCROLLS within source
s = base[syS * w + msx];
}
}
else s = base[syS * w + sx];
if (monoTint < 0) if (monoTint < 0)
{ {
PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]); PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]);
@@ -1180,6 +1259,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). // 4) Flight-block labels (on top; the blue faces aren't covered by surfaces).
for (int i = 0; i < L.buttonCount; i++) for (int i = 0; i < L.buttonCount; i++)
{ {
@@ -1284,6 +1372,47 @@ void BTCockpitMouseUp(void)
} }
} }
//
// (glass lamp responsiveness, 2026-08) Sweep the cockpit lamp STATE every frame.
//
// The lamp sweep -- LampManager::Update -> AssertNewLampValue -> RIO/PadRIO::SetLamp,
// which is what lights the cockpit buttons -- authentically rides the gauge
// renderer's FOREGROUND turn (GaugeRenderer::ExecuteForeground, gaugrend.cpp), and
// 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 (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
// "lighting slow or nonexistent" playtest reports (all on glass desktops).
//
// The lamp sweep is CHEAP -- deduped state pushes over ~72 lamps, no raster -- so on
// the dev/glass composite path we run it EVERY frame here, decoupled from the gauge
// cycle, and the buttons track the sim regardless of background-sweep slack. The pod
// NEVER enters BTDrawGaugeInset (it drives real gauge hardware), so its authentic
// bandwidth-paced serial lamp cadence is untouched. BT_GLASS_LAMP_SWEEP=0 restores the
// authentic once-per-cycle behaviour.
//
static void
BTGlassSweepLamps()
{
static int s_on = -1;
if (s_on < 0)
{
const char *e = getenv("BT_GLASS_LAMP_SWEEP");
s_on = (e != NULL && e[0] == '0') ? 0 : 1;
}
if (!s_on) return;
GaugeRenderer *gr = BTResolveGaugeRenderer();
if (gr == NULL || application == NULL) return;
LampManager *lm = gr->GetLampManager();
ModeManager *mm = application->GetModeManager();
if (lm != NULL && mm != NULL)
lm->Update(mm->GetModeMask());
}
// //
// Free entry point the MAIN renderer calls (L4VIDEO DPLRenderer::ExecuteImplementation, // Free entry point the MAIN renderer calls (L4VIDEO DPLRenderer::ExecuteImplementation,
// just before EndScene). No-op unless BT_DEV_GAUGES is set. Docks the 6-surface panel // just before EndScene). No-op unless BT_DEV_GAUGES is set. Docks the 6-surface panel
@@ -1292,7 +1421,9 @@ void BTCockpitMouseUp(void)
// //
void BTDrawGaugeInset(LPDIRECT3DDEVICE9 device) void BTDrawGaugeInset(LPDIRECT3DDEVICE9 device)
{ {
if (!DevGaugeComposite() || !DevGaugeDocked() || device == NULL) return; if (!DevGaugeComposite()) return;
BTGlassSweepLamps(); // keep the cockpit lamps tracking the sim EVERY frame (see above)
if (!DevGaugeDocked() || device == NULL) return;
if (GlassPanelsOwnSurfaces()) return; // the per-display glass windows draw the surfaces if (GlassPanelsOwnSurfaces()) return; // the per-display glass windows draw the surfaces
if (gBTGaugeCockpit) if (gBTGaugeCockpit)
{ {
@@ -5343,6 +5474,9 @@ SVGA16::SVGA16(
BuildWindows(init_width,init_height,windowed, secondaryIndex, aux1Index, aux2Index); BuildWindows(init_width,init_height,windowed, secondaryIndex, aux1Index, aux2Index);
for (int _i = 0; _i < 10; _i++) // DEV-COMPOSITE: lazily created on first surface draw for (int _i = 0; _i < 10; _i++) // DEV-COMPOSITE: lazily created on first surface draw
mDevSurfaceTex[_i] = NULL; mDevSurfaceTex[_i] = NULL;
scrambleActive = False; // PPC sync-scramble (phase-14) -- armed by FunkyVideo
scrambleStartMs = 0;
scrambleDurationS = 0.0;
//STUBBED: VIDEO RB 1/15/07 //STUBBED: VIDEO RB 1/15/07
# if defined(DEBUG) # if defined(DEBUG)
Tell("SVGA16::SVGA16()\n"); Tell("SVGA16::SVGA16()\n");
@@ -6302,13 +6436,153 @@ void
Check_Fpu(); Check_Fpu();
} }
//
// PPC sync-scramble (phase-14), animated as a RECOVERY. The original
// (`//STUBBED: VIDEO RB 1/15/07`) detuned the VGA CRTC Horizontal Total so every
// secondary monitor lost horizontal lock for ~0.8 s. There is no CRTC here, so
// FunkyVideo records the start + duration and the per-surface expansions
// (DrawDevSurface / ExpandPlaneToBGRA) map the pixelBuffer read through the
// ScrambleParams envelope: at the hit the image COLLAPSES horizontally toward a
// line, then BROADENS back out as the tear + roll DECAY, and LOCKS to the full
// clean display at the end. The 0.8 s window + non-stacking latch stay in
// L4GaugeRenderer; the shape lives here.
//
void void
SVGA16::FunkyVideo(Logical on_off) SVGA16::FunkyVideo(Logical on_off, Scalar duration)
{ {
//STUBBED: VIDEO RB 1/15/07 if (on_off)
//Check(this); {
//SVGAFunkyVideo(on_off); scrambleActive = True;
//Check_Fpu(); scrambleStartMs = GetTickCount();
scrambleDurationS = (duration > 0.0f) ? (double)duration : 0.8; // the HOLD
}
// FunkyVideo(False) is a NO-OP: the card restoring the sync (at HOLD end) is
// where the RECOVERY begins, so ScrambleParams keeps running past the flag,
// self-terminating at hold+recovery. scrambleActive stays armed; the clock gates.
}
//
// Two-phase envelope (per the "hold the bad sync, THEN recover" direction):
// HOLD [0, hold] -- the card holds the detuned CRTC. Deep collapse (a line),
// and a WILDLY fast horizontal roll that decelerates over
// the hold (content scrolls, wrapping, so it can't be read).
// RECOVERY [hold, +] -- the sync is restored and the monitor re-locks: the line
// BROADENS back to full while the residual scroll + tear
// settle to zero, then LOCKS (returns False = clean).
// Fills the read-loop parameters: scale (collapse band width fraction), rollOff
// (SCROLL offset in px, wrapped within the source), shear (px/row diagonal),
// centerX. Tunables (read once): BT_SCRAMBLE_COLLAPSE (min band, deeper=smaller),
// BT_SCRAMBLE_ROLL (initial scroll px/sec -- "wildly high"), BT_SCRAMBLE_SHEAR
// (px/row), BT_SCRAMBLE_RECOVER (recovery seconds), BT_SCRAMBLE_DUR (hold seconds
// override). BT_SCRAMBLE_TEST loops it; BT_SCRAMBLE_CYCLE loops stepping the roll.
//
Logical
SVGA16::ScrambleParams(int width, double *scale, double *shear,
double *rollOff, int *centerX, int *shakeRow) const
{
*shakeRow = 0;
if (width <= 0)
return False;
static int sInit = 0, sTest = 0, sCycle = 0;
static double shearMax = 3.0, rollMax = 9000.0, collapse = 0.03,
recover = 0.5, holdEnv = 0.0, shakeAmp = 10.0;
if (!sInit)
{
sInit = 1;
sTest = (getenv("BT_SCRAMBLE_TEST") != NULL) ? 1 : 0;
sCycle = (getenv("BT_SCRAMBLE_CYCLE") != NULL) ? 1 : 0;
const char *e;
if ((e = getenv("BT_SCRAMBLE_SHEAR")) != NULL) shearMax = atof(e);
if ((e = getenv("BT_SCRAMBLE_ROLL")) != NULL) rollMax = atof(e);
if ((e = getenv("BT_SCRAMBLE_COLLAPSE")) != NULL) collapse = atof(e);
if ((e = getenv("BT_SCRAMBLE_RECOVER")) != NULL) recover = atof(e);
if ((e = getenv("BT_SCRAMBLE_DUR")) != NULL) holdEnv = atof(e);
if ((e = getenv("BT_SCRAMBLE_SHAKE")) != NULL) shakeAmp = atof(e);
if (collapse < 0.004) collapse = 0.004;
if (collapse > 1.0) collapse = 1.0;
if (recover < 0.05) recover = 0.05;
}
double hold = (holdEnv > 0.0) ? holdEnv
: (scrambleDurationS > 0.0 ? scrambleDurationS : 0.8);
double total = hold + recover;
double rlMax = rollMax;
double elapsed;
if (sTest || sCycle)
{
// Tuning: loop the whole effect (hold+recovery) + a short locked pause.
// CYCLE steps the initial roll speed each loop.
double loop = total + 0.6;
double t = (double)GetTickCount() * 0.001;
long n = (long)(t / loop);
double ph = t - (double)n * loop;
if (ph >= total)
return False; // locked pause between loops
elapsed = ph;
if (sCycle)
{
static const double kR[] = { 3000.0, 6000.0, 9000.0, 14000.0, 20000.0 };
int nS = (int)(sizeof(kR) / sizeof(kR[0]));
rlMax = kR[(int)(n % nS)];
static long lastN = -1;
if (n != lastN) { lastN = n;
DEBUG_STREAM << "[scramble-cycle] loop " << n << " rollMax=" << rlMax
<< "px/s (hold " << hold << "s + recover " << recover << "s)\n"
<< std::flush; }
}
}
else
{
if (!scrambleActive)
return False;
elapsed = ((double)(GetTickCount() - scrambleStartMs)) * 0.001;
if (elapsed >= total)
return False; // LOCKED -- clean display
}
if (elapsed < 0.0) elapsed = 0.0;
double sScale, sScroll, sShear;
if (elapsed < hold)
{
// HOLD: bad sync held. Deep collapse; roll starts wild and decelerates to a
// stop by the hold end. scroll = integral of v(t)=rlMax*(1-t/hold): the
// content scrolls fast then coasts to rest (rlMax*hold/2 accumulated).
sScale = collapse;
sScroll = rlMax * elapsed * (1.0 - elapsed / (2.0 * hold));
sShear = shearMax;
}
else
{
// RECOVERY: sync restored, monitor re-locks. Broaden line->full and settle
// the residual scroll + tear to zero (smoothstep), then it hits total = lock.
double tr = (elapsed - hold) / recover;
double b = tr * tr * (3.0 - 2.0 * tr);
sScale = collapse + (1.0 - collapse) * b;
sScroll = (rlMax * hold * 0.5) * (1.0 - b);
sShear = shearMax * (1.0 - b);
}
// SHAKE: a per-FRAME jitter (LCG, so all surfaces shake together within a frame
// but re-roll each frame), violent at the hit and fading through the recovery.
// Horizontal folds into the scroll; vertical bounces the source row (shakeRow).
double shakeEnv = (elapsed < hold)
? (1.0 - 0.5 * (elapsed / hold)) // 1.0 at impact -> 0.5 at hold end
: (0.5 * (1.0 - (elapsed - hold) / recover)); // 0.5 -> 0 through recovery
if (shakeEnv < 0.0) shakeEnv = 0.0;
unsigned int fr = (unsigned int)(GetTickCount() / 16); // ~per-frame index
unsigned int r = fr * 1103515245u + 12345u;
double jX = ((double)((r >> 16) & 0x7FFF) / 16384.0) - 1.0; // [-1,1)
r = r * 1103515245u + 12345u;
double jY = ((double)((r >> 16) & 0x7FFF) / 16384.0) - 1.0;
*scale = sScale;
*shear = sShear;
*rollOff = sScroll + shakeAmp * shakeEnv * jX; // horizontal shake -> scroll
*centerX = width / 2;
*shakeRow = (int)(shakeAmp * shakeEnv * jY + (jY >= 0.0 ? 0.5 : -0.5)); // vertical bounce
return True;
} }
//######################################################################## //########################################################################
+21 -1
View File
@@ -332,7 +332,27 @@ public:
UnflashPalette(int palette_number); UnflashPalette(int palette_number);
void void
FunkyVideo(Logical on_off); FunkyVideo(Logical on_off, Scalar duration = 0.0f);
// PPC sync-scramble (phase-14): the modern stand-in for the VGA CRTC
// Horizontal-Total detune, animated as a RECOVERY over the effect window -- at
// the hit the image collapses horizontally toward a line, then broadens back
// out as the tear + roll decay, and LOCKS to the full clean display at the end.
// FunkyVideo(on, dur) records the start + duration; ScrambleParams derives the
// per-frame envelope (returns True while active + fills scale/shear/rollOff/
// centerX); DrawDevSurface (surround) + ExpandPlaneToBGRA (glass) map the
// pixelBuffer read through it, so all secondary surfaces recover together and
// the main 3D view is untouched. Tunable by eye: BT_SCRAMBLE_SHEAR (max
// px/line), BT_SCRAMBLE_ROLL (max px/sec), BT_SCRAMBLE_COLLAPSE (min width
// fraction 0..1), BT_SCRAMBLE_DUR (anim seconds override). BT_SCRAMBLE_TEST
// loops the transition for tuning; BT_SCRAMBLE_CYCLE loops it stepping the
// max shear. (Exact look depended on each pod monitor's PLL -- not recoverable.)
Logical
ScrambleParams(int width, double *scale, double *shear,
double *rollOff, int *centerX, int *shakeRow) const;
Logical scrambleActive;
unsigned long scrambleStartMs;
double scrambleDurationS;
protected: protected:
+188 -38
View File
@@ -745,6 +745,7 @@ struct BTPfxEmitter
struct BTPfxParticle struct BTPfxParticle
{ {
D3DXVECTOR3 pos, vel, accel; D3DXVECTOR3 pos, vel, accel;
int defIndex; // census attribution (#114 / wreck-smoke tail)
float age, life; float age, life;
float rad, exp, dexp; float rad, exp, dexp;
float colorWarp, alphaWarp; // def-level warps, carried per particle float colorWarp, alphaWarp; // def-level warps, carried per particle
@@ -875,6 +876,39 @@ void BTStartPfxFrame(int effect_number, float x, float y, float z,
gBTPfxEmitters.push_back(e); gBTPfxEmitters.push_back(e);
} }
// #114/wreck-smoke census (BT_PFX_LOG): every emitter start + a 2s population
// line, so "what is still smoking 40s after the kill" is a log fact.
static void BTPfxCensus()
{
if (!getenv("BT_PFX_LOG")) return;
static unsigned long s_at = 0;
unsigned long now = GetTickCount();
if (now - s_at < 2000) return;
s_at = now;
int perDef[BT_PFX_SLOTS]; memset(perDef, 0, sizeof(perDef));
int emitters = 0;
for (size_t i = 0; i < gBTPfxEmitters.size(); ++i)
if (gBTPfxEmitters[i].active)
{
++emitters;
int di = (int)(gBTPfxEmitters[i].def - gBTPfxDefs);
if (di >= 0 && di < BT_PFX_SLOTS) ++perDef[di];
}
int partDef[BT_PFX_SLOTS]; memset(partDef, 0, sizeof(partDef));
for (size_t i = 0; i < gBTPfxParticles.size(); ++i)
{
int di = gBTPfxParticles[i].defIndex;
if (di >= 0 && di < BT_PFX_SLOTS) ++partDef[di];
}
DEBUG_STREAM << "[pfx] t=" << (now / 1000) << "s census: emitters=" << emitters
<< " particles=" << (int)gBTPfxParticles.size();
for (int d = 0; d < BT_PFX_SLOTS; ++d)
if (perDef[d] > 0) DEBUG_STREAM << " {E" << d << ":" << perDef[d] << "}";
for (int d = 0; d < BT_PFX_SLOTS; ++d)
if (partDef[d] > 0) DEBUG_STREAM << " {p" << d << ":" << partDef[d] << "}";
DEBUG_STREAM << std::endl;
}
void BTStartPfx(int effect_number, float x, float y, float z) void BTStartPfx(int effect_number, float x, float y, float z)
{ {
BTStartPfxFrame(effect_number, x, y, z, 0, 0, 0); BTStartPfxFrame(effect_number, x, y, z, 0, 0, 0);
@@ -964,6 +998,7 @@ static void BTPfxSpawn(const BTPfxEmitter &e)
// laser bursts during missile volleys) // laser bursts during missile volleys)
return; return;
BTPfxParticle p; BTPfxParticle p;
p.defIndex = (int)(e.def - gBTPfxDefs); // census attribution
// Sample in the effect's LOCAL frame, then orient into the world through // Sample in the effect's LOCAL frame, then orient into the world through
// the emitter's basis (the victim's localToWorld rows). The position // the emitter's basis (the victim's localToWorld rows). The position
// jitter pv is an isotropic scatter about the base offset -> symmetric // jitter pv is an isotropic scatter about the base offset -> symmetric
@@ -1181,6 +1216,7 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
} }
} }
} }
BTPfxCensus();
for (size_t ei = 0; ei < gBTPfxEmitters.size(); ++ei) for (size_t ei = 0; ei < gBTPfxEmitters.size(); ++ei)
{ {
BTPfxEmitter &e = gBTPfxEmitters[ei]; BTPfxEmitter &e = gBTPfxEmitters[ei];
@@ -4325,6 +4361,12 @@ void
fogFar = searchLightFogFar; fogFar = searchLightFogFar;
if(fogUpdating) 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))); mDevice->SetRenderState(D3DRS_FOGCOLOR, D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen), (int)(255 * fogBlue)));
// dpl_SetViewFog( // dpl_SetViewFog(
// dplMainView, // dplMainView,
@@ -4345,6 +4387,8 @@ void
fogFar = noSearchLightFogFar; fogFar = noSearchLightFogFar;
if(fogUpdating) 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))); mDevice->SetRenderState(D3DRS_FOGCOLOR, D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen), (int)(255 * fogBlue)));
// dpl_SetViewFog( // dpl_SetViewFog(
// dplMainView, // dplMainView,
@@ -8197,6 +8241,75 @@ static void BTVerifyCockpitCanvasAfterReset(LPDIRECT3DDEVICE9 device)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Execute Method, performs the rendering of one frame // Execute Method, performs the rendering of one frame
// //
//
//#############################################################################
// BTResetLostDevice -- the one true D3D9 device-loss recovery (#35).
//
// Both Present sites (the scene frame and the wait-screen idle frame) used to
// carry an inline copy of this, and both copies were wrong the same way:
// on DEVICELOST they called Reset() IMMEDIATELY. Reset() on a still-lost
// device always fails; V() only logged it; ParticleEngine::Initialize then ran
// against the lost device, its creates failed and NULLed the statics, and the
// NEXT lost frame's ParticleEngine::Destroy() read a vtable at 0x0 -- the
// field crash (8/8 byte-identical stacks at Destroy +0x11 on the one machine
// whose GPU ever actually loses the device, a 128 MB Iris Xe).
//
// Correct protocol: release the POOL_DEFAULT resources every lost frame
// (Destroy is idempotent now), but Reset ONLY once TestCooperativeLevel stops
// answering D3DERR_DEVICELOST; until then skip and retry next frame. On a
// successful Reset re-create ONLY the device objects -- NOT Initialize(),
// which memsets the installed-effects table and killed every particle effect
// for the rest of the mission on every reset that did succeed.
//#############################################################################
//
void DPLRenderer::BTResetLostDevice()
{
if (mWaitOverlaySurface != NULL)
{
mWaitOverlaySurface->Release(); // pre-Reset, like every non-MANAGED resource
mWaitOverlaySurface = NULL;
}
ParticleEngine::Destroy(); // idempotent + null-safe (#35)
HRESULT coop = mDevice->TestCooperativeLevel();
if (coop == D3DERR_DEVICELOST)
{
static unsigned long s_lostLogAt = 0;
unsigned long now_ms = GetTickCount();
if (now_ms - s_lostLogAt > 2000)
{
s_lostLogAt = now_ms;
DEBUG_STREAM << "[render] device LOST -- waiting for the driver "
"before Reset" << std::endl << std::flush;
}
return; // cannot reset yet; retry next frame
}
int bbCount = mPresentParams.BackBufferCount;
int bbWidth = mPresentParams.BackBufferWidth;
int bbHeight = mPresentParams.BackBufferHeight;
HRESULT reset_hr = mDevice->Reset(&mPresentParams);
mPresentParams.BackBufferCount = bbCount; // Reset writes actuals back;
mPresentParams.BackBufferWidth = bbWidth; // keep the app's own intent
mPresentParams.BackBufferHeight = bbHeight;
if (FAILED(reset_hr))
{
DEBUG_STREAM << "[render] device Reset FAILED hr=0x"
<< std::hex << (unsigned long)reset_hr << std::dec
<< " -- will retry next frame" << std::endl << std::flush;
return;
}
ParticleEngine::CreateDeviceObjects(mDevice); // NOT Initialize (effects table!)
this->SetCoreRenderStates();
BTVerifyCockpitCanvasAfterReset(mDevice); // Gitea #56 guard
DEBUG_STREAM << "[render] device reset OK (lost -> restored)"
<< std::endl << std::flush;
}
void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::InterestingEntityIterator* all_iterator) void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::InterestingEntityIterator* all_iterator)
{ {
Component *component; Component *component;
@@ -8847,6 +8960,17 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
extern void BTGaugeWindowRenderAndPresent(LPDIRECT3DDEVICE9 device); extern void BTGaugeWindowRenderAndPresent(LPDIRECT3DDEVICE9 device);
BTGaugeWindowRenderAndPresent(mDevice); BTGaugeWindowRenderAndPresent(mDevice);
#ifdef BT_GLASS
// Drive the per-display glass panels' repaint from the (always-running) main
// loop so their lamp flash keeps animating when they're in the background --
// Windows throttles an unfocused window's own WM_TIMER ("indicators flash
// slowly unless you focus that window"). No-op unless the panels are up.
{
extern void BTGlassPanels_Tick();
BTGlassPanels_Tick();
}
#endif
// DIAG (turn-hitch hunt): draw CPU is _rt0..here; Present blocks on the GPU. // DIAG (turn-hitch hunt): draw CPU is _rt0..here; Present blocks on the GPU.
LARGE_INTEGER _rt1; QueryPerformanceCounter(&_rt1); LARGE_INTEGER _rt1; QueryPerformanceCounter(&_rt1);
// COCKPIT LETTERBOX: uniform-scale the canvas into the client (NULL = the // COCKPIT LETTERBOX: uniform-scale the canvas into the client (NULL = the
@@ -8913,27 +9037,71 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
sAcc = 0.0; sFrames = 0; sMaxD = 0.0; sMaxP = 0.0; sAcc = 0.0; sFrames = 0; sMaxD = 0.0; sMaxP = 0.0;
} }
} }
// #35 BENCH HOOK (BT_DEVICELOST_TEST=<frame>[,crashrepro], off by default).
// A real device loss needs a GPU that actually hangs (Conn Man's Iris Xe);
// no bench machine here can produce one on demand. Two modes:
// <frame> force the DEVICELOST branch below at that render
// frame and twice more at +600/+1200 -- drives the
// REAL recovery code, not a copy of it.
// <frame>,crashrepro run the exact field sequence at that frame:
// teardown, a re-Initialize forced to fail
// (MAXPARTICLES=0 -> CreateVertexBuffer(0) fails ->
// NULL out-params), then a second teardown -- the
// two-lost-frames-in-a-row shape from the field logs.
{
static long s_dltFrame = -2; // -2 unparsed, -1 off
static long s_dltTick = 0;
static int s_dltRepro = 0;
if (s_dltFrame == -2)
{
const char *e = getenv("BT_DEVICELOST_TEST");
s_dltFrame = -1;
if (e != NULL && *e != '\0')
{
s_dltFrame = strtol(e, NULL, 10);
if (s_dltFrame <= 0) s_dltFrame = -1;
if (strstr(e, "crashrepro") != NULL) s_dltRepro = 1;
}
}
if (s_dltFrame > 0)
{
++s_dltTick;
if (s_dltRepro)
{
if (s_dltTick == s_dltFrame)
{
// The field null-teardown shape: Destroy releases + nulls,
// the second Destroy runs against the NULL statics -- the
// exact state the field crash died in. (The PRE-fix repro
// of 2026-07-29 used Destroy / MAXPARTICLES=0-forced-
// failed-Initialize / Destroy, and faulted at Destroy
// +0x11 with target=0x0, matching all 8 field stacks; the
// hardened Initialize defends against that injection now,
// so the double-Destroy is the surviving regression form.)
DEBUG_STREAM << "[dltest] crashrepro: Destroy / Destroy on NULL statics"
<< std::endl << std::flush;
ParticleEngine::Destroy();
ParticleEngine::Destroy(); // field crash site
DEBUG_STREAM << "[dltest] crashrepro SURVIVED; restoring device objects"
<< std::endl << std::flush;
ParticleEngine::CreateDeviceObjects(mDevice);
}
}
else if (s_dltTick >= s_dltFrame
&& ((s_dltTick - s_dltFrame) % 600) == 0
&& (s_dltTick - s_dltFrame) <= 1200)
{
DEBUG_STREAM << "[dltest] forcing DEVICELOST branch (cycle "
<< ((s_dltTick - s_dltFrame) / 600 + 1) << "/3)"
<< std::endl << std::flush;
hr = D3DERR_DEVICELOST;
}
}
}
if (hr == D3DERR_DEVICELOST) if (hr == D3DERR_DEVICELOST)
{ {
if (mWaitOverlaySurface != NULL) BTResetLostDevice(); // #35: the guarded recovery
{
mWaitOverlaySurface->Release(); // D3DPOOL_DEFAULT: pre-Reset
mWaitOverlaySurface = NULL;
}
int bbCount = mPresentParams.BackBufferCount;
int bbWidth = mPresentParams.BackBufferWidth;
int bbHeight = mPresentParams.BackBufferHeight;
ParticleEngine::Destroy();
V(mDevice->Reset(&mPresentParams));
ParticleEngine::Initialize(mDevice);
this->SetCoreRenderStates();
mPresentParams.BackBufferCount = bbCount;
mPresentParams.BackBufferWidth = bbWidth;
mPresentParams.BackBufferHeight = bbHeight;
BTVerifyCockpitCanvasAfterReset(mDevice); // Gitea #56 guard
} }
ticks = HiResNowTicks(); ticks = HiResNowTicks();
@@ -9199,27 +9367,9 @@ void DPLRenderer::ExecuteIdle()
BTWaitScreenPaint(wait_line1, wait_line2); BTWaitScreenPaint(wait_line1, wait_line2);
} }
if (present_hr == D3DERR_DEVICELOST) if (present_hr == D3DERR_DEVICELOST)
{
if (mWaitOverlaySurface != NULL)
{ {
mWaitOverlaySurface->Release(); // D3DPOOL_DEFAULT: must go pre-Reset BTResetLostDevice(); // #35: the guarded recovery
mWaitOverlaySurface = NULL;
} }
int bbCount = mPresentParams.BackBufferCount;
int bbWidth = mPresentParams.BackBufferWidth;
int bbHeight = mPresentParams.BackBufferHeight;
ParticleEngine::Destroy();
V(mDevice->Reset(&mPresentParams));
ParticleEngine::Initialize(mDevice);
this->SetCoreRenderStates();
mPresentParams.BackBufferCount = bbCount;
mPresentParams.BackBufferWidth = bbWidth;
mPresentParams.BackBufferHeight = bbHeight;
BTVerifyCockpitCanvasAfterReset(mDevice); // Gitea #56 guard
}
} }
} }
// //
+6
View File
@@ -306,6 +306,12 @@ public:
// //
dpl_ZONE* MakeNewZone(); dpl_ZONE* MakeNewZone();
// #35: the one true device-loss recovery -- both Present sites route here.
// Correct D3D9 protocol: release POOL_DEFAULT resources, then Reset ONLY
// once TestCooperativeLevel says the device is ready; a Reset while still
// lost always fails, and the old inline copies drove on past that failure.
void BTResetLostDevice();
void MarkDCSHiearchy(dpl_DCS *root_DCS, Entity *entity); void MarkDCSHiearchy(dpl_DCS *root_DCS, Entity *entity);
void FlushBitSliceTexture(unsigned int *local_storage); void FlushBitSliceTexture(unsigned int *local_storage);
void LoadBitSliceTexture(BitMap *bitmap_to_load, LPDIRECT3DTEXTURE9 local_storage); void LoadBitSliceTexture(BitMap *bitmap_to_load, LPDIRECT3DTEXTURE9 local_storage);
+62 -2
View File
@@ -350,6 +350,12 @@ struct MatInfo {
// EMISSIVE (tag 0x26): pure-emissive materials (diffuse black) render as an // EMISSIVE (tag 0x26): pure-emissive materials (diffuse black) render as an
// unlit glow -- tex x emissive (btpolar:pintBIceEmit_mtl, the polar ice). // unlit glow -- tex x emissive (btpolar:pintBIceEmit_mtl, the polar ice).
bool hasEmissive = false; 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}; float emissive[3] = {0,0,0};
}; };
@@ -495,12 +501,14 @@ struct MaterialResolver {
std::string name, texName, rampName; std::string name, texName, rampName;
MatInfo info; MatInfo info;
bool haveColor = false; bool haveColor = false;
float rawDiffuseR = -1.0f; // brighten-class factor carrier
for (const Chunk& ch : c.children) { for (const Chunk& ch : c.children) {
if (ch.id == TAG_NAME) name = chunkStr(ch); 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_MATERIAL_TEXTURE && ch.len > 1) texName = chunkStr(ch, 1);
else if (ch.id == TAG_RAMP_REF) rampName = chunkStr(ch); else if (ch.id == TAG_RAMP_REF) rampName = chunkStr(ch);
else if (ch.id == TAG_DIFFUSE && ch.len >= 12) { else if (ch.id == TAG_DIFFUSE && ch.len >= 12) {
info.color = packColor(rdF32(ch.data), rdF32(ch.data + 4), rdF32(ch.data + 8)); info.color = packColor(rdF32(ch.data), rdF32(ch.data + 4), rdF32(ch.data + 8));
rawDiffuseR = rdF32(ch.data);
haveColor = true; haveColor = true;
info.hasDiffuse = true; info.hasDiffuse = true;
} else if (ch.id == TAG_AMBIENT && ch.len >= 12 && !haveColor } 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; 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 // Resolve the ramp reference to its low/high colours: this
// file's own definitions first, then the cross-library // file's own definitions first, then the cross-library
// registry (mech skins reference 'softer' defined elsewhere). // registry (mech skins reference 'softer' defined elsewhere).
@@ -619,6 +633,9 @@ struct Builder {
uint32_t currentColor = 0xFFB0B0B8u; uint32_t currentColor = 0xFFB0B0B8u;
bool currentHasDiffuse = false; // material carried an explicit diffuse tag bool currentHasDiffuse = false; // material carried an explicit diffuse tag
std::string currentTex; std::string currentTex;
// authored (pre-substitution) "library:material_mtl" of the material in force --
// the .DZM armour-damage lists key on this (issue #87)
std::string currentMatName;
int currentTexChannel = 0; // BSL bit-slice (BMF tag 0x18) int currentTexChannel = 0; // BSL bit-slice (BMF tag 0x18)
bool currentTexScroll = false; // TEXTURE SPECIAL " SCROLL" (tag 0x2037) bool currentTexScroll = false; // TEXTURE SPECIAL " SCROLL" (tag 0x2037)
float currentTexScrollU = 0.0f, currentTexScrollV = 0.0f; float currentTexScrollU = 0.0f, currentTexScrollV = 0.0f;
@@ -637,10 +654,12 @@ struct Builder {
bool currentTSphere = false; // material is tsphere_mtl (translocation warp): ramp it despite normals 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 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) 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) 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) int currentCopRole = 0; // role of the pmesh being built (consumed by buildPmesh)
bool currentHasEmissive = false; bool currentHasEmissive = false;
float currentEmissive[3] = {0,0,0}; float currentEmissive[3] = {0,0,0};
float currentBrighten = 0.0f; // btfx brighten-class additive factor
// per-vertex scratch (normals accumulated from forward triangles only) // per-vertex scratch (normals accumulated from forward triangles only)
std::vector<float> px, py, pz, nx, ny, nz, uu, vv; std::vector<float> px, py, pz, nx, ny, nz, uu, vv;
@@ -775,7 +794,19 @@ struct Builder {
// value is not recoverable from the code (texture-less ramp = no texel // 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 // 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. // 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); }; 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)); vcol = 0xFF000000u | (CB(copFrameRGB(0))<<16) | (CB(copFrameRGB(1))<<8) | CB(copFrameRGB(2));
} }
@@ -872,6 +903,7 @@ struct Builder {
batch.color = pureEmissive ? currentColor // keep BLACK: L4D3D's batch.color = pureEmissive ? currentColor // keep BLACK: L4D3D's
: (useRamp ? rampTint : currentColor); // pure-emissive test keys on it : (useRamp ? rampTint : currentColor); // pure-emissive test keys on it
batch.texPath = currentTex; batch.texPath = currentTex;
batch.matName = currentMatName; // .DZM armour-damage key (issue #87)
batch.texChannel = currentTexChannel; batch.texChannel = currentTexChannel;
batch.texScroll = currentTexScroll; batch.texScroll = currentTexScroll;
batch.texScrollU = currentTexScrollU; batch.texScrollU = currentTexScrollU;
@@ -909,6 +941,7 @@ struct Builder {
batch.shadowMat = currentShadowMat; batch.shadowMat = currentShadowMat;
batch.hasEmissive = currentHasEmissive; batch.hasEmissive = currentHasEmissive;
for (int i = 0; i < 3; ++i) batch.emissive[i] = currentEmissive[i]; 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 // SUBMISSION-ORDER DEPTH BIAS (additive objects): the content layers
// EXACTLY-COPLANAR shells (AR02 LOD2: solid concrete + a coplanar // EXACTLY-COPLANAR shells (AR02 LOD2: solid concrete + a coplanar
// PUNCH-cutout overlay + an inner shell, plane separations // PUNCH-cutout overlay + an inner shell, plane separations
@@ -935,6 +968,28 @@ struct Builder {
else else
batch.lodBias = 0.0f; batch.lodBias = 0.0f;
mesh->batches.push_back(batch); 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()) { 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; 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) { for (uint32_t i = idxStart; i < idxStart + idxCount; ++i) {
@@ -1185,6 +1240,9 @@ struct Builder {
size_t maxn = ch.len - 1, n = 0; size_t maxn = ch.len - 1, n = 0;
while (n < maxn && s[n]) ++n; while (n < maxn && s[n]) ++n;
std::string full(s, n); std::string full(s, n);
// remember the AUTHORED name (before the paint callback rewrites
// it) -- the .DZM zone material lists are authored against it
currentMatName = full;
// baked ground-shadow material (basev:shadow_mtl etc.) // baked ground-shadow material (basev:shadow_mtl etc.)
currentShadowMat = false; currentShadowMat = false;
for (size_t si = 0; si + 6 <= full.size(); ++si) for (size_t si = 0; si + 6 <= full.size(); ++si)
@@ -1209,8 +1267,9 @@ struct Builder {
for (int i = 0; i < 3; ++i) { currentRampLo[i] = info.rampLo[i]; currentRampHi[i] = info.rampHi[i]; } for (int i = 0; i < 3; ++i) { currentRampLo[i] = info.rampLo[i]; currentRampHi[i] = info.rampHi[i]; }
currentHasEmissive = info.hasEmissive; currentHasEmissive = info.hasEmissive;
for (int i = 0; i < 3; ++i) currentEmissive[i] = info.emissive[i]; 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 // TRANSLOCATION WARP: override the material's coarse "sky" ramp with the
// NARROW LAVENDER ramp that matched the original (capture.png). The swirl // NARROW LAVENDER ramp that matched the original (capture.png). The swirl
// is the bintA cloud coloured lo->hi by luminance; a wide sky ramp // is the bintA cloud coloured lo->hi by luminance; a wide sky ramp
@@ -1379,6 +1438,7 @@ bool LoadBgfFile(const std::string& name, BgfData& out) {
// single-siding + dark-frame ramp reconstruction in emitTri/buildPmesh applies to // single-siding + dark-frame ramp reconstruction in emitTri/buildPmesh applies to
// these meshes only. // these meshes only.
b.meshIsCop = stemLower(name).find("_cop") != std::string::npos; b.meshIsCop = stemLower(name).find("_cop") != std::string::npos;
b.meshStem = stemLower(name);
b.res = &res; b.res = &res;
for (const Chunk& c : roots) b.collect(c); for (const Chunk& c : roots) b.collect(c);
b.finish(); b.finish();
+17
View File
@@ -44,6 +44,12 @@ struct BgfDrawBatch {
// alpha). The verts keep their authored RGBA gradient and the draw routes // alpha). The verts keep their authored RGBA gradient and the draw routes
// to the alpha-blend pass, unlit, colour = texture x vertex gradient. // to the alpha-blend pass, unlit, colour = texture x vertex gradient.
bool vertexAlpha = false; 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 // TEXTURE ALPHA CUTOUT (issue #3, the wreck scorch splat): an RGBA4444 BSL
// slice (texChannel >= 8) carries an AUTHORED alpha channel -- in the shipped // slice (texChannel >= 8) carries an AUTHORED alpha channel -- in the shipped
// content it is a BINARY 0/240 cutout mask (bexp9 = the scorch splat // content it is a BINARY 0/240 cutout mask (bexp9 = the scorch splat
@@ -106,6 +112,17 @@ struct BgfDrawBatch {
// routed through the mech-shadow pipeline (translucent dark, depth-biased, // routed through the mech-shadow pipeline (translucent dark, depth-biased,
// no z-write) instead. // no z-write) instead.
bool shadowMat = false; bool shadowMat = false;
// AUTHORED MATERIAL NAME ("library:material_mtl", e.g. "avaskin:avat2_dz_ltorso_mtl"),
// recorded PRE-substitution -- the per-pilot paint callback rewrites names, but the
// .DZM damage-zone material lists are authored against these base names.
//
// This is the key the mech ARMOUR-DARKENING system matches on (issue #87): the 1995
// BTL4VideoRenderer built one material-damage watcher per (zone, material) pair
// (FUN_004573e4) that lerped the material's colour terms from pristine toward 0.1x
// as that zone's damageLevel ran 0->1. Our draw path bakes colour into a
// D3DMATERIAL9 at load, so the equivalent modulation is applied per draw op --
// see L4D3D d3d_OBJECT::SetDamageScaleForMaterials.
std::string matName;
}; };
struct BgfData { struct BgfData {
+23
View File
@@ -71,6 +71,12 @@ struct L4RAMP
float r1, g1, b1; float r1, g1, b1;
}; };
// MECH ARMOUR DAMAGE (issue #87): the factor the 1995 material-damage watcher
// (FUN_004573e4) precomputed as the FULLY-DAMAGED colour -- pristine x 0.1, the
// literal 0x3dcccccd passed at every construction site. A zone at damageLevel 1
// renders its panels at 10% of the authored brightness.
const float kDamagedMaterialScale = 0.1f;
struct L4DRAWOP struct L4DRAWOP
{ {
D3DMATERIAL9 material; D3DMATERIAL9 material;
@@ -103,6 +109,11 @@ struct L4DRAWOP
// UNLIT, colour = texture x the authored per-vertex gradient, alpha = the // UNLIT, colour = texture x the authored per-vertex gradient, alpha = the
// per-vertex fade (SRCALPHA/INVSRCALPHA). // per-vertex fade (SRCALPHA/INVSRCALPHA).
bool vertexAlphaBlend; 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, // COCKPIT PUNCH STENCIL-CUT (task #55, i860-firmware-decoded): 0=normal,
// 1=aperture MASK (drawn stencil-only, paired with the following hull op), // 1=aperture MASK (drawn stencil-only, paired with the following hull op),
// 2=HULL (drawn stencil-rejected under the mask = window cutouts). // 2=HULL (drawn stencil-rejected under the mask = window cutouts).
@@ -112,6 +123,18 @@ struct L4DRAWOP
// coplanar duplicated surfaces resolve to the detail layer instead of // coplanar duplicated surfaces resolve to the detail layer instead of
// venetian-blind z-fighting (the board's submission-order rule, in D3D terms). // venetian-blind z-fighting (the board's submission-order rule, in D3D terms).
float lodDepthBias; float lodDepthBias;
// MECH ARMOUR DAMAGE (issue #87). The 1995 BTL4VideoRenderer bound one
// material-damage watcher per (damage zone, material) pair -- FUN_004573e4 --
// which lerped that material's colour terms from their authored values toward
// 0.1x as the zone's damageLevel ran 0->1 (FUN_00457784 re-pushed them whenever
// the level changed). The zone->material lists come from the per-mech .DZM
// files, already parsed into DamageZone::materialTable (MUNGA DAMAGE.cpp).
//
// Our draw path bakes colour into this D3DMATERIAL9 at load, so the equivalent
// modulation is applied at SetMaterial time from the level cached here.
// Both fields are memset-0 safe: empty name = not zone-mapped, level 0 = pristine.
char dzMatName[64]; // authored "library:material_mtl" for this batch
float dzDamageLevel; // owning zone's damageLevel [0..1] (0 = undamaged)
}; };
class d3d_OBJECT class d3d_OBJECT
+10
View File
@@ -31,6 +31,16 @@ void
void void
BTGlassPanels_Destroy(); BTGlassPanels_Destroy();
//
// 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
// background (Windows throttles a background window's own WM_TIMER; this drives a
// synchronous repaint at the flash cadence instead). No-op unless the windows
// are up. See the note at the definition (L4GLASSWIN.cpp).
//
void
BTGlassPanels_Tick();
// //
// True when BT_GLASS_PANELS mode is selected (default ON under `-platform // True when BT_GLASS_PANELS mode is selected (default ON under `-platform
// glass`, OFF otherwise). Read by the render loop / L4VB16 dev-composite to // glass`, OFF otherwise). Read by the render loop / L4VB16 dev-composite to
+15 -3
View File
@@ -1512,9 +1512,14 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
// (end_of_frame = now + 1/frameRate) degenerated and the game ran at a // (end_of_frame = now + 1/frameRate) degenerated and the game ran at a
// timer-quantized ~15 FPS (66-70ms frames measured) on ANY hardware. // timer-quantized ~15 FPS (66-70ms frames measured) on ANY hardware.
// Default 60; env TARGETFPS still overrides (clamped to sanity). // Default 60; env TARGETFPS still overrides (clamped to sanity).
int target_fps = 30; // the pod's authentic rate; frame work (~20-40ms in int target_fps = 30; // NOTE: the pod's byte-proven NOMINAL rate is 28
// this build) misses 60Hz beats -> jitter, but holds // (DAT_0052140c = 28.0f @0x401ace; 18.2065 BIOS
// 30 rock-steady. TARGETFPS env still overrides. // fallback @0x401ada -- decomp-reference.md). We
// keep 30 for display smoothness; anything that
// must match the pod's per-tick math normalizes
// itself (e.g. the myomer kinetic term). Frame
// work (~20-40ms) misses 60Hz beats -> jitter,
// holds 30 steady. TARGETFPS env overrides.
{ {
const char *tf = getenv("TARGETFPS"); const char *tf = getenv("TARGETFPS");
if (tf != 0) if (tf != 0)
@@ -1539,6 +1544,13 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
app_manager->RunMissions(); app_manager->RunMissions();
std::cout << "[boot] RunMissions returned (mission loop exited)." << std::endl << std::flush; std::cout << "[boot] RunMissions returned (mission loop exited)." << std::endl << std::flush;
// #93: the projectile pool is static -- kill any rounds still in flight so
// nothing dangles across the mission boundary (the per-entity dtor scrub
// covers the in-mission teardown race; this covers everything else).
{
extern void BTProjectilesClearAll(void);
BTProjectilesClearAll();
}
// MATCHLOG AUTO-UPLOAD (2026-07-22): in relay mode, send this peer's // MATCHLOG AUTO-UPLOAD (2026-07-22): in relay mode, send this peer's
// match forensic log back to the operator's relay (saved under the // match forensic log back to the operator's relay (saved under the
+22 -4
View File
@@ -742,8 +742,18 @@ static void
{ {
// JOIN trim: the mech list + the JOIN button; callsign is the edit // JOIN trim: the mech list + the JOIN button; callsign is the edit
// control (repositioned in BTFrontEnd_Run). Everything else is the // control (repositioned in BTFrontEnd_Run). Everything else is the
// operator's call. // operator's call -- EXCEPT experience, which is PER-PLAYER by the
y = MenuTopY; AddGroup(GroupVehicle, MenuCol2X, &y); // original design (the sysop set each user's tier; mixed-experience
// matches were legal), and each node's master reads its OWN egg's
// value, so mixing already works end to end.
//
// #116: this group was missing here, so joiners silently launched with
// the hidden menu default (veteran) no matter what they believed they
// selected -- "standard mode still having heat and leaks only occurred
// in steam": hosts got their pick, all three affected players were
// JOINERS, and the one clean player (Sauron) was the one HOSTING.
y = MenuTopY; AddGroup(GroupVehicle, MenuCol2X, &y);
y = MenuTopY; AddGroup(GroupExperience, MenuCol3X, &y);
AddButton(GroupLaunch, MenuCol5X, MenuClientH - 190, 220, 52); AddButton(GroupLaunch, MenuCol5X, MenuClientH - 190, 220, 52);
return; return;
} }
@@ -1173,6 +1183,8 @@ int
strcpy(self.vehicle, kVehicles[menu.selection[GroupVehicle]].key); strcpy(self.vehicle, kVehicles[menu.selection[GroupVehicle]].key);
strcpy(self.color, kColors[menu.selection[GroupColor]].key); strcpy(self.color, kColors[menu.selection[GroupColor]].key);
strcpy(self.experience, kExperience[menu.selection[GroupExperience]].key); strcpy(self.experience, kExperience[menu.selection[GroupExperience]].key);
printf("[fe] pilot experience=%s (%s)\n", self.experience,
FeJoinOnly() ? "join" : "host/solo");
strcpy(self.badge, kBadges[menu.selection[GroupBadge]].key); strcpy(self.badge, kBadges[menu.selection[GroupBadge]].key);
strcpy(self.patch, kPatches[menu.selection[GroupPatch]].key); strcpy(self.patch, kPatches[menu.selection[GroupPatch]].key);
strcpy(self.dropzone, kDropZones[menu.selection[GroupDropZone]].key); strcpy(self.dropzone, kDropZones[menu.selection[GroupDropZone]].key);
@@ -1187,7 +1199,7 @@ int
if (menu.steamAction == 1) if (menu.steamAction == 1)
{ {
BTLobbyRoster roster; BTLobbyRoster roster;
if (BTLobby_HostAndRoom(self.name, self.vehicle, self.color, if (BTLobby_HostAndRoom(self.name, self.vehicle, self.color, self.experience,
&roster, spec->steamMyToken, sizeof(spec->steamMyToken), &roster, spec->steamMyToken, sizeof(spec->steamMyToken),
spec->steamMap, sizeof(spec->steamMap)) != 0 || spec->steamMap, sizeof(spec->steamMap)) != 0 ||
roster.memberCount == 0) roster.memberCount == 0)
@@ -1208,6 +1220,12 @@ int
strncpy(pilot.color, strncpy(pilot.color,
member.color[0] ? member.color : kColors[0].key, member.color[0] ? member.color : kColors[0].key,
sizeof(pilot.color) - 1); sizeof(pilot.color) - 1);
// #116: the member's OWN experience choice rides the lobby now;
// absent (older build in the lobby) leaves the field empty and the
// egg writer's "veteran" fallback applies -- the old behavior,
// but now only for clients that never published a choice.
strncpy(pilot.experience, member.experience,
sizeof(pilot.experience) - 1);
sprintf(pilot.address, "%s:%d", sprintf(pilot.address, "%s:%d",
member.fakeAddress, member.gamePort); member.fakeAddress, member.gamePort);
if (!member.isSelf) if (!member.isSelf)
@@ -1226,7 +1244,7 @@ int
} }
if (menu.steamAction == 2) if (menu.steamAction == 2)
{ {
if (BTLobby_JoinAndWait(self.name, self.vehicle, self.color, if (BTLobby_JoinAndWait(self.name, self.vehicle, self.color, self.experience,
spec->steamMyToken, sizeof(spec->steamMyToken), spec->steamMyToken, sizeof(spec->steamMyToken),
spec->steamMap, sizeof(spec->steamMap)) != 0) spec->steamMap, sizeof(spec->steamMap)) != 0)
{ {
+164 -8
View File
@@ -13,6 +13,7 @@
#pragma pack(push, 8) #pragma pack(push, 8)
#include "steam/steam_api.h" #include "steam/steam_api.h"
#include "steam/isteammatchmaking.h" #include "steam/isteammatchmaking.h"
#include <btversion.h> // BUILD GATE (#108 confound): exact-build lobbies
#pragma pack(pop) #pragma pack(pop)
// //
@@ -48,6 +49,50 @@ static void
} }
#include <stdarg.h> #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 // Synchronous Steam call-result helper (manual polling -- no callback
// template machinery in this C-style TU). // template machinery in this C-style TU).
@@ -83,7 +128,8 @@ static int
static CSteamID currentLobby; static CSteamID currentLobby;
static void static void
PublishSelf(const char *pilot_name, const char *vehicle, const char *color) PublishSelf(const char *pilot_name, const char *vehicle, const char *color,
const char *experience)
{ {
// //
// Identity is implicit (the member's SteamID); the roster TOKENS are // Identity is implicit (the member's SteamID); the roster TOKENS are
@@ -93,6 +139,12 @@ static void
matchmaking->SetLobbyMemberData(currentLobby, "nm", pilot_name); matchmaking->SetLobbyMemberData(currentLobby, "nm", pilot_name);
matchmaking->SetLobbyMemberData(currentLobby, "vh", vehicle); matchmaking->SetLobbyMemberData(currentLobby, "vh", vehicle);
matchmaking->SetLobbyMemberData(currentLobby, "cl", color); matchmaking->SetLobbyMemberData(currentLobby, "cl", color);
matchmaking->SetLobbyMemberData(currentLobby, "xp", experience);
// 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 static int
@@ -118,6 +170,14 @@ static int
strncpy(out->color, strncpy(out->color,
matchmaking->GetLobbyMemberData(lobby, member, "cl"), matchmaking->GetLobbyMemberData(lobby, member, "cl"),
sizeof(out->color) - 1); sizeof(out->color) - 1);
{
// #116: experience rides the lobby too -- absent (an older build in
// the lobby) leaves it empty and the egg writer's "veteran" fallback
// applies, same as before this field existed.
const char *xp = matchmaking->GetLobbyMemberData(lobby, member, "xp");
if (xp != NULL)
strncpy(out->experience, xp, sizeof(out->experience) - 1);
}
out->isSelf = (member == SteamUser()->GetSteamID()); out->isSelf = (member == SteamUser()->GetSteamID());
if (out->name[0] == 0) if (out->name[0] == 0)
{ {
@@ -176,10 +236,17 @@ static void
int n = 0; int n = 0;
for (const char *s = member.name; *s && n < 60; ++s) name[n++] = (WCHAR)*s; for (const char *s = member.name; *s && n < 60; ++s) name[n++] = (WCHAR)*s;
name[n] = 0; 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.vehicle[0] ? member.vehicle : "mech",
member.color[0] ? member.color : "-", member.color[0] ? member.color : "-",
member.isSelf ? L" (you)" : L""); member.isSelf ? L" (you)" : L"",
build_ok ? L"" : L" [WRONG BUILD -- WILL NOT LAUNCH]");
} }
else else
{ {
@@ -312,6 +379,7 @@ static int
int int
BTLobby_HostAndRoom( BTLobby_HostAndRoom(
const char *pilot_name, const char *vehicle, const char *color, const char *pilot_name, const char *vehicle, const char *color,
const char *experience,
BTLobbyRoster *roster_out, BTLobbyRoster *roster_out,
char *my_token_out, int my_token_capacity, char *my_token_out, int my_token_capacity,
char *steam_map_out, int steam_map_capacity) char *steam_map_out, int steam_map_capacity)
@@ -335,7 +403,13 @@ int
} }
currentLobby = created.m_ulSteamIDLobby; currentLobby = created.m_ulSteamIDLobby;
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4", "1"); SteamMatchmaking()->SetLobbyData(currentLobby, "btl4", "1");
PublishSelf(pilot_name, vehicle, color); // 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);
LobbyLog("host: lobby up (%llu)", (unsigned long long)created.m_ulSteamIDLobby); LobbyLog("host: lobby up (%llu)", (unsigned long long)created.m_ulSteamIDLobby);
if (RunRoom(1) != 0) if (RunRoom(1) != 0)
@@ -360,6 +434,25 @@ int
{ {
continue; 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)) if ((pass == 0) == (member.isSelf != 0))
{ {
roster_out->members[roster_out->memberCount++] = member; roster_out->members[roster_out->memberCount++] = member;
@@ -403,18 +496,26 @@ int
int int
BTLobby_JoinAndWait( BTLobby_JoinAndWait(
const char *pilot_name, const char *vehicle, const char *color, const char *pilot_name, const char *vehicle, const char *color,
const char *experience,
char *my_token_out, int my_token_capacity, char *my_token_out, int my_token_capacity,
char *steam_map_out, int steam_map_capacity) char *steam_map_out, int steam_map_capacity)
{ {
if (BTSteamNet_Install() != 0 || !BTSteamNet_Active()) 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; return -1;
} }
ISteamMatchmaking *matchmaking = SteamMatchmaking(); ISteamMatchmaking *matchmaking = SteamMatchmaking();
matchmaking->AddRequestLobbyListStringFilter( matchmaking->AddRequestLobbyListStringFilter(
"btl4", "1", k_ELobbyComparisonEqual); "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(); SteamAPICall_t call = matchmaking->RequestLobbyList();
LobbyMatchList_t match_list; LobbyMatchList_t match_list;
memset(&match_list, 0, sizeof(match_list)); memset(&match_list, 0, sizeof(match_list));
@@ -422,7 +523,36 @@ int
LobbyMatchList_t::k_iCallback, 15000) != 0 || LobbyMatchList_t::k_iCallback, 15000) != 0 ||
match_list.m_nLobbiesMatching == 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; return -1;
} }
@@ -438,7 +568,25 @@ int
return -1; return -1;
} }
currentLobby = lobby; currentLobby = lobby;
PublishSelf(pilot_name, vehicle, color); // 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);
LobbyLog("join: in lobby, waiting for GO"); LobbyLog("join: in lobby, waiting for GO");
int result = RunRoom(0); int result = RunRoom(0);
@@ -475,7 +623,15 @@ int
LobbyLog("join: my token [%s], map [%s]", my_token_out, steam_map_out); LobbyLog("join: my token [%s], map [%s]", my_token_out, steam_map_out);
if (my_token_out[0] == 0) 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); SteamMatchmaking()->LeaveLobby(currentLobby);
+7
View File
@@ -29,6 +29,11 @@ struct BTLobbyMember
int gamePort; // token game port (1502) int gamePort; // token game port (1502)
char vehicle[16]; char vehicle[16];
char color[16]; char color[16];
char experience[16]; // #116: per-player by design (sysop-set
// in the pod; mixed matches legal) --
// without it on the wire, the host's
// egg authored every JOINER as the
// WriteEgg "veteran" fallback
int isSelf; int isSelf;
unsigned long long steamID; unsigned long long steamID;
}; };
@@ -46,6 +51,7 @@ struct BTLobbyRoster
int int
BTLobby_HostAndRoom( BTLobby_HostAndRoom(
const char *pilot_name, const char *vehicle, const char *color, const char *pilot_name, const char *vehicle, const char *color,
const char *experience,
BTLobbyRoster *roster_out, BTLobbyRoster *roster_out,
char *my_token_out, int my_token_capacity, char *my_token_out, int my_token_capacity,
char *steam_map_out, int steam_map_capacity); char *steam_map_out, int steam_map_capacity);
@@ -57,5 +63,6 @@ int
int int
BTLobby_JoinAndWait( BTLobby_JoinAndWait(
const char *pilot_name, const char *vehicle, const char *color, const char *pilot_name, const char *vehicle, const char *color,
const char *experience,
char *my_token_out, int my_token_capacity, char *my_token_out, int my_token_capacity,
char *steam_map_out, int steam_map_capacity); char *steam_map_out, int steam_map_capacity);
+10 -1
View File
@@ -14,7 +14,16 @@ Subsystem (MUNGA base — have source: RP/MUNGA/SUBSYSTM.HPP)
│ CollisionCriticalHitWeight, VideoObjectName, VitalSubsystem) — NO thermal. Couples subsystem→DamageZone@this+0xE0. │ CollisionCriticalHitWeight, VideoObjectName, VitalSubsystem) — NO thermal. Couples subsystem→DamageZone@this+0xE0.
│ members: statusAlarm@0x2C (this+0xb), simulationState@0x40, damageZone*@0xE0, refCount@0xF4, alarmModel@0xF8, │ members: statusAlarm@0x2C (this+0xb), simulationState@0x40, damageZone*@0xE0, refCount@0xF4, alarmModel@0xF8,
│ printSimulationState@0x104, criticalReference@0x108, collisionCriticalHitWeight@0x10C, vitalSubsystemIndex@0x110(-1). │ printSimulationState@0x104, criticalReference@0x108, collisionCriticalHitWeight@0x10C, vitalSubsystemIndex@0x110(-1).
│ methods: GetStatusFlags@4ac144 (structureLevel tier), HandleMessage@4ac0bc, ResetToInitialState@4ac1d4, ClearStatus@4ac22c, │ methods: GetStatusFlags@4ac144 (structureLevel tier), TakeDamage@4ac0bc (vtable +0x24; #80 CORRECTION -- was mislabeled
│ "HandleMessage": the body consumes a Damage&, hits the private zone, and on level>=1.0 raises Destroyed + the
│ vital-subsystem kill. HandleMessage's real address is unknown), ResetToInitialState@4ac1d4, ClearStatus@4ac22c,
#80 GAP RECOVERIES (raw disasm; none exported): Mech::TakeDamageMessageHandler@4a0230 (via the Mech MESSAGE TABLE
@50bdf8: rows {id,name,handler} -- 0x12 TakeDamage/4a0230, 0x14 PlayerLink/49f624, 0x15 RealMaxSpeed/49f604,
│ 0x16 BalanceCoolant/49f728, 0x17 SetBurningState/49f674, 0x18 ClearBurningState/49f700, 0x19 EjectPilot/49f854,
│ 0x1a DuckRequest/49fa00), crit-chance@4a0164 (p = clamp(0.7*lvl^2+0.01, 0..1), gate player+0x25c simLive),
│ collision-divert@49ffcc (damageType==0, unreconstructed), Mech vtable +0x18/@4a122c +0x1c/@4a0c2c (switch fns,
│ unidentified). Subsystem CSS keys @50e09d..50e15d: WeaponDamagePoints(req), CriticalHitScoreBonus(req),
│ Collision/Ballistic/Explosive/Laser/EnergyDamagePoints -> res+0x44/+0xE0/+0x30[5].
│ PrintState@4ac8c0 (DefaultState/Destroyed/Exploding), IsDamaged@4ac9c8 (mech bus down), ApplyDamageAndMeasure@4ac07c, │ PrintState@4ac8c0 (DefaultState/Destroyed/Exploding), IsDamaged@4ac9c8 (mech bus down), ApplyDamageAndMeasure@4ac07c,
│ DistributeCriticalHit@4ac274 ("ammo explosion damaging"), LookupStatusType@4ac194 (name table @50de74). │ DistributeCriticalHit@4ac274 ("ammo explosion damaging"), LookupStatusType@4ac194 (name table @50de74).
│ TechStatusType states @50df17: Destroyed/Damaged/CoolantLeaking/Overheating/AmmoBurning/Jammed/BadPower (count=7). │ TechStatusType states @50df17: Destroyed/Damaged/CoolantLeaking/Overheating/AmmoBurning/Jammed/BadPower (count=7).
+11
View File
@@ -631,3 +631,14 @@ Subsystem *CreateAmmoBinSubsystem(Mech *owner, int id, void *seg)
return (Subsystem *) new (Memory::Allocate(0x22c)) return (Subsystem *) new (Memory::Allocate(0x22c))
AmmoBin(owner, id, (AmmoBin::SubsystemResource *)seg); AmmoBin(owner, id, (AmmoBin::SubsystemResource *)seg);
} }
//###########################################################################
// #84 bridge -- the launcher resolves its round's MODEL RESOURCE ID through
// its connected bin (the binary Missile ctor receives the same id via the
// launch descriptor). Complete-AmmoBin TU accessor.
//###########################################################################
int BTAmmoBinModelFile(void *bin)
{
return (bin != 0) ? ((AmmoBin *)bin)->AmmoModelFileID() : -1;
}
+6
View File
@@ -260,6 +260,12 @@
// member. The 0x54 ammoAlarm (was an 8-byte HeatAlarm) lands ammoModelFile // member. The 0x54 ammoAlarm (was an 8-byte HeatAlarm) lands ammoModelFile
// at 0x1E8 -- exact binary layout, locked by AmmoBinLayoutCheck. // at 0x1E8 -- exact binary layout, locked by AmmoBinLayoutCheck.
int ammoModelFile; // @0x1E8 (word 0x7A) round model index int ammoModelFile; // @0x1E8 (word 0x7A) round model index
public:
// #84: the round's model RESOURCE ID (the type-15 record carrying the
// MissileThruster tail the binary Missile ctor copies) -- read by the
// launcher's fire path via the BTAmmoBinModelFile bridge.
int AmmoModelFileID() const { return ammoModelFile; }
protected:
int explosionModelFile; // @0x1EC (word 0x7B) cook-off explosion model index int explosionModelFile; // @0x1EC (word 0x7B) cook-off explosion model index
// @0x1F0..0x21C (words 0x7C..0x87): the cook-off DAMAGE record -- a real // @0x1F0..0x21C (words 0x7C..0x87): the cook-off DAMAGE record -- a real
+5
View File
@@ -82,6 +82,7 @@ enum BTActionID
BTActMfd1Cycle, // Gitea #9: cycle the lower-left MFD preset page BTActMfd1Cycle, // Gitea #9: cycle the lower-left MFD preset page
BTActMfd2Cycle, // Gitea #9: cycle the upper-center MFD preset page BTActMfd2Cycle, // Gitea #9: cycle the upper-center MFD preset page
BTActMfd3Cycle, // Gitea #9: cycle the lower-right MFD preset page BTActMfd3Cycle, // Gitea #9: cycle the lower-right MFD preset page
BTActEject, // PANIC/EJECT punch-out -> Mech msg 0x19 (@0049f854)
BTActCount BTActCount
}; };
@@ -204,6 +205,7 @@ static const BTName sActionNames[] =
{"Reconnect", BTActReconnect}, {"Reconnect", BTActReconnect},
{"Mfd1Cycle", BTActMfd1Cycle}, {"Mfd2Cycle", BTActMfd2Cycle}, {"Mfd1Cycle", BTActMfd1Cycle}, {"Mfd2Cycle", BTActMfd2Cycle},
{"Mfd3Cycle", BTActMfd3Cycle}, {"Mfd3Cycle", BTActMfd3Cycle},
{"Eject", BTActEject},
{0, 0} {0, 0}
}; };
@@ -288,6 +290,8 @@ static const char *sDefaultProfile =
"key L action Mfd3Cycle\n" "key L action Mfd3Cycle\n"
"key V action ViewToggle\n" "key V action ViewToggle\n"
"key B action LookBehind\n" "key B action LookBehind\n"
"key Back action Eject\n"
"pad LeftThumb action Eject\n"
"key F5 action Generator1\n" "key F5 action Generator1\n"
"key F6 action Generator2\n" "key F6 action Generator2\n"
"key F7 action Generator3\n" "key F7 action Generator3\n"
@@ -991,6 +995,7 @@ void
case BTActMfd1Cycle: next.mfdCycle[0] = 1; break; case BTActMfd1Cycle: next.mfdCycle[0] = 1; break;
case BTActMfd2Cycle: next.mfdCycle[1] = 1; break; case BTActMfd2Cycle: next.mfdCycle[1] = 1; break;
case BTActMfd3Cycle: next.mfdCycle[2] = 1; break; case BTActMfd3Cycle: next.mfdCycle[2] = 1; break;
case BTActEject: next.eject = 1; break;
case BTActGenerator1: next.genSel = 4; break; case BTActGenerator1: next.genSel = 4; break;
case BTActGenerator2: next.genSel = 5; break; case BTActGenerator2: next.genSel = 5; break;
case BTActGenerator3: next.genSel = 6; break; case BTActGenerator3: next.genSel = 6; break;
+1
View File
@@ -55,6 +55,7 @@ struct BTInputState
int valve; int valve;
int flush; // Gitea #7: coolant flush HELD (InjectCoolant) int flush; // Gitea #7: coolant flush HELD (InjectCoolant)
int configHold; 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 int genSel; // 0 = none, 4..7 = Generator A..D, 8 = reconnect
// Gitea #9: per-MFD preset-page cycle (desktop senders for SetPresetMode; // Gitea #9: per-MFD preset-page cycle (desktop senders for SetPresetMode;
// index 0/1/2 = Mfd1 lower-left / Mfd2 upper-center / Mfd3 lower-right) // index 0/1/2 = Mfd1 lower-left / Mfd2 upper-center / Mfd3 lower-right)
+56 -2
View File
@@ -163,7 +163,13 @@ static const int kBTQuadModeMask[12] = { 0x1,0x1,0x1,0x1, 0x20,0x20,0x20,0x20, 0
static const int kBTEngModeMask[12] = { 0x2,0x4,0x8,0x10, 0x40,0x80,0x100,0x200, 0x800,0x1000,0x2000,0x4000 }; static const int kBTEngModeMask[12] = { 0x2,0x4,0x8,0x10, 0x40,0x80,0x100,0x200, 0x800,0x1000,0x2000,0x4000 };
static const int kBTQuadLamp[12] = { 0xF,0xD,0xB,0x9, 0x27,0x25,0x23,0x21, 0x7,0x5,0x3,0x1 }; static const int kBTQuadLamp[12] = { 0xF,0xD,0xB,0x9, 0x27,0x25,0x23,0x21, 0x7,0x5,0x3,0x1 };
static const int kBTEngBankTop[12] = { 0xF,0xF,0xF,0xF, 0x27,0x27,0x27,0x27, 0x7,0x7,0x7,0x7 }; static const int kBTEngBankTop[12] = { 0xF,0xF,0xF,0xF, 0x27,0x27,0x27,0x27, 0x7,0x7,0x7,0x7 };
static const int kBTCondenserLamp[6] = { 0x7, 0x2F,0x2E,0x2D,0x2B,0x2A }; // [condenserNumber] // @0051d058 per-condenser lamps, BYTE-VERIFIED from the image (int32 each):
// 07 2F 2E 2D 2B 2A 29 ... indexed 1-BASED by condenserNumber (+0x1D4),
// so slot 0 is unused and condensers 1..6 -> 2F 2E 2D 2B 2A 29.
// Deliberately NOT the coolingLoop set of FUN_004cc148 (which has 0x2C and
// 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] static const int kBTPlacementLamp[5] = { 0x29, 0x1A,0x1B,0x1C,0x1D }; // [auxScreenPlacement]
// //
@@ -270,6 +276,14 @@ void
mem_stream->ReadBytes(&item_condition, sizeof(item_condition)); // (*stream+0x1c) mem_stream->ReadBytes(&item_condition, sizeof(item_condition)); // (*stream+0x1c)
mem_stream->ReadBytes(&lamp_code, sizeof(lamp_code)); 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) if ((int)the_condition != item_condition)
{ {
return; return;
@@ -287,8 +301,33 @@ void
{ {
if (the_condition == 2) // CoolantLeaking if (the_condition == 2) // CoolantLeaking
{ {
//
// #98 -- "I'm getting leaks but no indicators" / "the display
// buttons aren't ALWAYS flashing on leaking components".
//
// FUN_004cc264 is literally:
// return *(int *)(&DAT_0051d058 + sub[0x1d4] * 4);
// -- indexed by condenserNumber with NO bounds check. The table
// bytes at 0051d058 are, verified from the image:
// 07 2F 2E 2D 2B 2A 29 ... (int32 each)
// and condenserNumber is 1-BASED (live: 'Condenser6' reports 6), so
// slot 0 (0x7) is unused and condensers 1..6 map to
// 0x2F 0x2E 0x2D 0x2B 0x2A 0x29.
// NOTE these are NOT the coolingLoop1..6 lamps of FUN_004cc148
// (0x2F 0x2E 0x2D 0x2C 0x2B 0x2A) -- the condenser set skips 0x2C
// and ends on 0x29. They overlap but are different lamps; do not
// "correct" one into the other (I did, and it mis-mapped 4, 5 and 6).
//
// THE PORT BUG was only the bounds check: `n >= 0 && n < 6` is a
// 0-based bound on a 1-based index, so condenser 6 was rejected and
// annunciated NOTHING, while the binary resolves it to 0x29. Six
// condensers, one permanently silent -- which is what players read as
// "sometimes". The guard now admits 1..6 (kept, unlike the binary's
// unchecked read, so a stray authored number cannot walk off the
// table into the adjacent per-placement one).
//
int n = BTCondenserNumber(the_subsystem); int n = BTCondenserNumber(the_subsystem);
if (n >= 0 && n < 6) if (n >= 1 && n <= 6)
lamp_id = kBTCondenserLamp[n]; lamp_id = kBTCondenserLamp[n];
} }
} }
@@ -331,6 +370,21 @@ void
if (lamp_id < 0) if (lamp_id < 0)
{ {
// DIAGNOSTIC (#98): this is the SILENT path -- the item matched the
// condition but no lamp could be resolved for this subsystem, so nothing
// flashes and nothing was logged. Players see "leaking but no
// indicator". Name the subsystem and why it fell through, so the
// mapping gap is visible in a field log instead of being invisible.
if (BTLampLog())
DEBUG_STREAM << "[galarm] condition " << (int)the_condition
<< " code 0x" << std::hex << lamp_code << std::dec
<< " sub '" << (the_subsystem && the_subsystem->GetName()
? the_subsystem->GetName() : "?")
<< "' -> NO LAMP RESOLVED (condenser=" << BTSubsystemIsCondenser(the_subsystem)
<< " condenserNumber=" << (BTSubsystemIsCondenser(the_subsystem)
? BTCondenserNumber(the_subsystem) : -1)
<< " generator=" << BTSubsystemIsGenerator(the_subsystem)
<< ")" << std::endl << std::flush;
return; return;
} }
+21 -7
View File
@@ -980,6 +980,7 @@ GeneratorCluster::GeneratorCluster(
Scalar *currentTemp = (Scalar *)AttributePointerOf(subsystem_in, "CurrentTemperature"); Scalar *currentTemp = (Scalar *)AttributePointerOf(subsystem_in, "CurrentTemperature");
Scalar *degradeTemp = (Scalar *)AttributePointerOf(subsystem_in, "DegradationTemperature"); Scalar *degradeTemp = (Scalar *)AttributePointerOf(subsystem_in, "DegradationTemperature");
Scalar *failTemp = (Scalar *)AttributePointerOf(subsystem_in, "FailureTemperature"); Scalar *failTemp = (Scalar *)AttributePointerOf(subsystem_in, "FailureTemperature");
extern Scalar BTHeatSinkLeakFullScale(::Subsystem *sub); // heat.cpp (#97)
Scalar *coolantLeak = (Scalar *)AttributePointerOf(subsystem_in, "CoolantMassLeakRate"); Scalar *coolantLeak = (Scalar *)AttributePointerOf(subsystem_in, "CoolantMassLeakRate");
Scalar *outputVolt = (Scalar *)AttributePointerOf(subsystem_in, "OutputVoltage"); Scalar *outputVolt = (Scalar *)AttributePointerOf(subsystem_in, "OutputVoltage");
@@ -1006,7 +1007,7 @@ GeneratorCluster::GeneratorCluster(
// child 3: coolant-leak inverse-wipe (CoolantMassLeakRate); third=*(subsys+0x150), frames=3 // child 3: coolant-leak inverse-wipe (CoolantMassLeakRate); third=*(subsys+0x150), frames=3
leakGauge = new BitMapInverseWipe(rate3, mode_mask, renderer_in, graphics_port_number, leakGauge = new BitMapInverseWipe(rate3, mode_mask, renderer_in, graphics_port_number,
x, y + 0x27, leak_image, extra_color, leak_color_b, x, y + 0x27, leak_image, extra_color, leak_color_b,
*(int *)((char *)subsystem_in + 0x150), // third BTHeatSinkLeakFullScale(subsystem_in), // third: full-scale divisor (#97 bridge)
3, // frames 3, // frames
coolantLeak, "LeakGauge"); coolantLeak, "LeakGauge");
@@ -1460,6 +1461,7 @@ SubsystemCluster::SubsystemCluster(
void *linkedHeat = ResolveLink(heatSink); // FUN_00417ab4 void *linkedHeat = ResolveLink(heatSink); // FUN_00417ab4
void *linkTemp = AttributePointerOf(linkedHeat, "CurrentTemperature"); void *linkTemp = AttributePointerOf(linkedHeat, "CurrentTemperature");
void *linkDegrade = AttributePointerOf(linkedHeat, "DegradationTemperature"); void *linkDegrade = AttributePointerOf(linkedHeat, "DegradationTemperature");
extern Scalar BTHeatSinkLeakFullScale(::Subsystem *sub); // heat.cpp (#97)
void *coolantLeak = AttributePointerOf(subsystem_in, "CoolantMassLeakRate"); void *coolantLeak = AttributePointerOf(subsystem_in, "CoolantMassLeakRate");
coolingLoopB = new AnimatedSubsystemLamp(ChildRate(), eng_mode, renderer_in, // @004c70a4 coolingLoopB = new AnimatedSubsystemLamp(ChildRate(), eng_mode, renderer_in, // @004c70a4
@@ -1513,7 +1515,7 @@ SubsystemCluster::SubsystemCluster(
leakGauge = new BitMapInverseWipe(ChildRate(), eng_mode, renderer_in, // @004c5b7c leakGauge = new BitMapInverseWipe(ChildRate(), eng_mode, renderer_in, // @004c5b7c
engPort, 0x255, 0xe0, "eleak.pcc", 0, 0xff, engPort, 0x255, 0xe0, "eleak.pcc", 0, 0xff,
*(int *)((char *)subsystem_in + 0x150) /*third*/, 3 /*frames*/, BTHeatSinkLeakFullScale(subsystem_in) /*third*/, 3 /*frames*/,
(Scalar *)coolantLeak, "LeakGauge"); // BEST-EFFORT raw (subsys+0x150) (Scalar *)coolantLeak, "LeakGauge"); // BEST-EFFORT raw (subsys+0x150)
failedState = False; // @0xC4 this[0x31] (1 = destroyed) failedState = False; // @0xC4 this[0x31] (1 = destroyed)
@@ -2018,11 +2020,23 @@ BallisticWeaponCluster::BallisticWeaponCluster(
destroyedLamp = new TwoState(ChildRate(), eng_mode, renderer_in, owner_ID, 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 engPort, 0xc2, 0x85, "edestryd.pcc", 0, 0xff, (int *)&failedState, "TwoState"); // the destroyed X: bright when FAILED
// ejectWipe (BitMapInverseWipeScalar @004c61c8, eject-timer wipe reading // ejectWipe (BitMapInverseWipeScalar @004c61c8): the round-EJECT progress
// subsys+0x3f8). BRING-UP: the BitMapInverseWipeScalar class is not yet // sweep -- bteejtm.pcc as the weapon's PercentOfEject (@0x3F8) runs 0..1.
// declared/reconstructed in btl4gaug (only the non-Scalar BitMapInverseWipe); // Binary call (part_014.c:2312): FUN_004c61c8(., rate, eng_mode,
// tracked NULL until it lands. // renderer, ownerID, engPort, 0xF, 0, "bteejtm.pcc", 0, 0xFF,
ejectWipe = NULL; // @0x44 // 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() BallisticWeaponCluster::~BallisticWeaponCluster()
+21 -2
View File
@@ -547,8 +547,27 @@ void
// briefly redirected to Player::deathCount@0x200, but that is the respawn-handshake // briefly redirected to Player::deathCount@0x200, but that is the respawn-handshake
// identity (seeded -2), which is why it needed a display clamp; +0x280 was never // identity (seeded -2), which is why it needed a display clamp; +0x280 was never
// dead, only unwritten. Both counters now replicate owner->replicant. // dead, only unwritten. Both counters now replicate owner->replicant.
e.nameDisplay->Draw(&localView, (Scalar)BTPilotKills(pilot)); // KILLS (killCount) int drawnKills = BTPilotKills(pilot);
e.mechDisplay->Draw(&localView, (Scalar)BTPilotDeaths(pilot)); // DEATHS (deathTally @0x280) int drawnDeaths = BTPilotDeaths(pilot);
e.nameDisplay->Draw(&localView, (Scalar)drawnKills); // KILLS (killCount)
e.mechDisplay->Draw(&localView, (Scalar)drawnDeaths); // DEATHS (deathTally @0x280)
// DIAG (BT_SCORE_LOG): the exact value handed to the panel numerics, edge-
// logged per slot. This is the arbiter between "the tally moved but the
// panel drew stale zeros" (this line shows 0) and "the panel drew it but
// the on-screen surface didn't" (this line shows N).
if (getenv("BT_SCORE_LOG"))
{
static int s_lastK[8] = { -1,-1,-1,-1,-1,-1,-1,-1 };
static int s_lastD[8] = { -1,-1,-1,-1,-1,-1,-1,-1 };
if (s_lastK[currentSlot] != drawnKills || s_lastD[currentSlot] != drawnDeaths)
{
s_lastK[currentSlot] = drawnKills;
s_lastD[currentSlot] = drawnDeaths;
DEBUG_STREAM << "[score] panel DRAW slot " << currentSlot
<< " pilot " << pilot << " kills=" << drawnKills
<< " deaths=" << drawnDeaths << "\n" << std::flush;
}
}
} }
++currentSlot; ++currentSlot;
+124 -2
View File
@@ -2054,7 +2054,7 @@ Logical
BitMapInverseWipe::BitMapInverseWipe( BitMapInverseWipe::BitMapInverseWipe(
GaugeRate rate, ModeMask mode_mask, L4GaugeRenderer *renderer_in, GaugeRate rate, ModeMask mode_mask, L4GaugeRenderer *renderer_in,
int graphics_port_number, int x, int y, const char *image, int graphics_port_number, int x, int y, const char *image,
int color_a, int color_b, int third, int frames, int color_a, int color_b, Scalar third, int frames,
Scalar *value_pointer, const char *identification_string Scalar *value_pointer, const char *identification_string
): ):
GraphicGauge(rate, mode_mask, renderer_in, 0, graphics_port_number, GraphicGauge(rate, mode_mask, renderer_in, 0, graphics_port_number,
@@ -2107,7 +2107,21 @@ void BitMapInverseWipe::BecameActive() // @004c5cf4
// //
void BitMapInverseWipe::Execute() void BitMapInverseWipe::Execute()
{ {
int level = (int)(value + (value < 0.0f ? -0.5f : 0.5f)); // FUN_004dcd94 round // #97 -- the level is NORMALISED, which this reconstruction had lost.
// Ghidra renders the round as a bare `FUN_004dcd94()` because it drops the
// x87 expression feeding __ftol (KB gotcha #19). The real prologue is:
// fild [fullWidth] ; ST0 = (float)fullWidth (frames*2)
// fmul [this+0xb4] ; ST0 *= value (leak rate)
// fdiv [this+0xb0] ; ST0 /= third (full scale)
// call __ftol ; level = round(ST0)
// Without it the gauge rounded the RAW leak rate, which never exceeds ~1.0,
// so `level` could only ever be 0 or (via the floor below) 1 -- one triangle,
// always, no matter how bad the leak. Players: "you can get up to three
// triangles"; "only seeing one level (lowest) right now".
Scalar scaled = (third > 0.0f)
? ((Scalar)fullWidth * value / third)
: value;
int level = (int)(scaled + (scaled < 0.0f ? -0.5f : 0.5f)); // FUN_004dcd94 round
if (level < 0) level = 0; if (level < 0) level = 0;
if (level > fullWidth) level = fullWidth; if (level > fullWidth) level = fullWidth;
if (value > 0.0025f && level < 1) level = 1; // _DAT_0050e3d8 if (value > 0.0025f && level < 1) level = 1; // _DAT_0050e3d8
@@ -2138,6 +2152,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 // HeadingPointer @004c554c Make / @004c562c ctor
+35 -2
View File
@@ -522,7 +522,7 @@
BitMapInverseWipe( // @004c5b7c BitMapInverseWipe( // @004c5b7c
GaugeRate, ModeMask, L4GaugeRenderer *, int, GaugeRate, ModeMask, L4GaugeRenderer *, int,
int x, int y, const char *image, int x, int y, const char *image,
int color_a, int color_b, int third, int frames, int color_a, int color_b, Scalar third, int frames,
Scalar *value_pointer, const char *); Scalar *value_pointer, const char *);
~BitMapInverseWipe(); // @004c5c80 ~BitMapInverseWipe(); // @004c5c80
Logical TestInstance() const; Logical TestInstance() const;
@@ -537,10 +537,43 @@
int frameHeight; // @0xA4 this[0x29] int frameHeight; // @0xA4 this[0x29]
int frames; // @0xA0 this[0x28] int frames; // @0xA0 this[0x28]
int previousLevel; // @0xAC this[0x2B] int previousLevel; // @0xAC this[0x2B]
int third; // @0xB0 this[0x2C] Scalar third; // @0xB0 this[0x2C] full-scale divisor (fdiv, #97)
Scalar value; // @0xB4 this[0x2D] (connection) 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 // SegmentArc gauges -- needle / dial over a 270-degree arc. Derived from
// the MUNGA L4 arc primitives (FUN_004745e0 / FUN_00473f44). // the MUNGA L4 arc primitives (FUN_004745e0 / FUN_00473f44).
+21 -11
View File
@@ -28,7 +28,7 @@
// Function -> method map: // Function -> method map:
// L4MechControlsMapper ----------------------------- vtable @0051e440 // L4MechControlsMapper ----------------------------- vtable @0051e440
// @004d17ac ctor @004d1814 dtor // @004d17ac ctor @004d1814 dtor
// @004d196c InterpretControls (target-range ramp + review-mode watch) // @004d196c InterpretControls (target-range ramp + panic-arm watch)
// @004d1b64 ZoomTargetRangeIn @004d1b9c ZoomTargetRangeOut // @004d1b64 ZoomTargetRangeIn @004d1b9c ZoomTargetRangeOut
// @004d1acc NotifyOfControlModeChange (+0x48; forwards to base no-op @004b048c) // @004d1acc NotifyOfControlModeChange (+0x48; forwards to base no-op @004b048c)
// @004d1ae4 NotifyOfDisplayModeChange (+0x4C; the secondary-view mask swap // @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 // DAT_0051dcd0[8] = {0x37,0x36,0x35,0x34,0x33,0x32,0x31,0x30} hotbox buttons
// //
// ModeManager (app+0x50): +0x4 currentMode mask, +0x8 savedMode mask. // 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> #include <bt.hpp>
@@ -329,7 +329,7 @@ L4MechControlsMapper::MessageHandlerSet&
// //
// Chains to MechControlsMapper (FUN_004b02f0); stamps vtable &0051e440 and // Chains to MechControlsMapper (FUN_004b02f0); stamps vtable &0051e440 and
// initialises the target-range zoom (exponent 2.0 == 250*2^2 == 1000m) and the // initialises the target-range zoom (exponent 2.0 == 250*2^2 == 1000m) and the
// review-mode watcher. // panic-arm watcher (ejectPermitted edge).
// //
L4MechControlsMapper::L4MechControlsMapper( L4MechControlsMapper::L4MechControlsMapper(
Mech *owner, Mech *owner,
@@ -349,7 +349,7 @@ L4MechControlsMapper::MessageHandlerSet&
) )
{ {
(void)class_ID; (void)class_ID;
previousMissionReviewMode = 0; // this[0x6a] @0x1a8 previousEjectPermitted = 0; // this[0x6a] @0x1a8
targetRangeExponentDemand = 2.0f; // this[0x68] @0x1a0 targetRangeExponentDemand = 2.0f; // this[0x68] @0x1a0
targetRangeExponent = 2.0f; // this[0x69] @0x1a4 targetRangeExponent = 2.0f; // this[0x69] @0x1a4
Check_Fpu(); Check_Fpu();
@@ -374,8 +374,14 @@ L4MechControlsMapper::MessageHandlerSet&
// The L4-layer performance, run every frame ahead of the in-Mech mapper: // The L4-layer performance, run every frame ahead of the in-Mech mapper:
// 1. snap the throttle to a full-throttle detent, // 1. snap the throttle to a full-throttle detent,
// 2. (re)build the pilot roster, // 2. (re)build the pilot roster,
// 3. watch the Mech's mission-review flag and toggle the corresponding mode // 3. watch the Mech's EJECT-PERMISSION flag (ejectPermitted @0x414 --
// bit (0x200000) on the application mode manager, // 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 // 4. slew the smoothed target-range exponent toward the panel demand and
// push the resulting range (250 * 2^exponent) onto the Mech, // push the resulting range (250 * 2^exponent) onto the Mech,
// 5. delegate to MechControlsMapper::InterpretControls for the actual // 5. delegate to MechControlsMapper::InterpretControls for the actual
@@ -405,19 +411,19 @@ L4MechControlsMapper::MessageHandlerSet&
BuildPilotArray(); // FUN_004b0600 BuildPilotArray(); // FUN_004b0600
// //
// (3) Mission-review mode change watcher. // (3) Panic-arm watcher: edge-detect ejectPermitted -> mode 0x200000.
// //
Mech *mech = GetMech(); Mech *mech = GetMech();
int review_mode = mech->GetMissionReviewMode(); // (mech)+0x414 int eject_armed = mech->GetEjectPermitted(); // (mech)+0x414
if (review_mode != previousMissionReviewMode) // @0x1a8 if (eject_armed != previousEjectPermitted) // @0x1a8
{ {
previousMissionReviewMode = review_mode; previousEjectPermitted = eject_armed;
BTL4ModeManager *mode_manager = BTL4ModeManager *mode_manager =
(BTL4ModeManager*)application->GetModeManager(); // DAT_004efc94+0x50 (BTL4ModeManager*)application->GetModeManager(); // DAT_004efc94+0x50
// ModeManager API: Add/RemoveModeMask save the old mask into // ModeManager API: Add/RemoveModeMask save the old mask into
// previousModeMask (the recovered "savedMode = currentMode" step). // previousModeMask (the recovered "savedMode = currentMode" step).
if (review_mode == 0) if (eject_armed == 0)
{ {
mode_manager->RemoveModeMask(0x200000); mode_manager->RemoveModeMask(0x200000);
} }
@@ -425,6 +431,10 @@ L4MechControlsMapper::MessageHandlerSet&
{ {
mode_manager->AddModeMask(0x200000); 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: public:
enum { enum {
KeypressMessageID = MechControlsMapper::NextMessageID, // PINNED to the binary literals (FUN_004d266c passes 0x19/0x1a
StringMatchMessageID, // 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 NextMessageID
}; };
@@ -203,7 +212,9 @@
targetRangeExponentDemand, // @0x1a0 panel "zoom" demand (init 2.0, range 0..5) targetRangeExponentDemand, // @0x1a0 panel "zoom" demand (init 2.0, range 0..5)
targetRangeExponent; // @0x1a4 smoothed value -> mech target range (2^x) targetRangeExponent; // @0x1a4 smoothed value -> mech target range (2^x)
int 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 // 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 // was previously self-consistent at the wrong value (0x2a), so it
// worked by accident -- now it matches the binary. // worked by accident -- now it matches the binary.
// (Faithful-fidelity note: the binary RIO table also carries its OWN // (Faithful-fidelity note: the binary RIO table also carries its OWN
// Keypress id 0x19 -> @004d2514; our ctor still registers the shared // Keypress id: FIXED 2026-08-03 -- KeypressMessageID is now pinned to
// L4 Keypress (0x17, @004d1bf0) -- see the Gitea backlog issue.) // 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 { enum {
Aux1QuadMessageID = MechControlsMapper::Aux1QuadMessageID, // 3 Aux1QuadMessageID = MechControlsMapper::Aux1QuadMessageID, // 3
+6 -1
View File
@@ -369,8 +369,13 @@ BTL4PlaybackApplication::SharedData
// //
// Controls only matter in interactive ("scrub") review mode. // 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); Check(controlsManager);
controlsManager->Execute(); // vtbl+0x18 controlsManager->Execute(); // vtbl+0x18
File diff suppressed because it is too large Load Diff
+77
View File
@@ -706,7 +706,39 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
d3d_OBJECT *wreckDebrisObj; d3d_OBJECT *wreckDebrisObj;
d3d_OBJECT *wreckFlamesObj; d3d_OBJECT *wreckFlamesObj;
std::map<int, HierarchicalDrawComponent*> segRenderable; // slot -> joint renderable std::map<int, HierarchicalDrawComponent*> segRenderable; // slot -> joint renderable
// #73 (aimed per-part pick): each segment's drawn geometry + its
// damage zone. The 1995 pick was a dpl SCENE intersection against
// the rendered meshes; this map is the port's equivalent target set
// (per-segment bounding spheres on the live posed skeleton).
struct SegPick { d3d_OBJECT *obj; int zone; };
std::map<int, SegPick> segPick; // segment idx -> pickable
// ARMOUR DARKENING (issue #87): the resolved (object, damage zone)
// bindings for this mech -- one entry per draw object that carries at
// least one material listed in a zone's .DZM material list. Built once
// with the tree (BindArmourDamage), replayed each frame by
// TickArmourDamage. The binary's equivalent is the per-material
// watcher list it builds in MakeMechRenderables (FUN_004573e4).
struct DmgBind { d3d_OBJECT *obj; int op; int zone; };
std::vector<DmgBind> dmgBinds;
std::map<int, int> segGState; // slot -> last applied graphic state 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 char paintSerno; // the %serno% this mech was BUILT with
// (0 = none) -- ApplyViewSkeleton re-parses // (0 = none) -- ApplyViewSkeleton re-parses
// segment BGFs, so the paint substitutions // segment BGFs, so the paint substitutions
@@ -726,6 +758,27 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
int int
TickWreck(Entity *victim, float dt); 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
// by MUNGA's DamageZone stream ctor) against the draw ops of the segment
// geometry just loaded, recording one binding per match. TickArmourDamage
// copies each bound zone's live damageLevel onto those ops.
//
void
BindArmourDamage(Entity *entity, MechRenderTree &tree);
void
TickArmourDamage(Entity *entity);
// //
// VIEW TOGGLE: the player's mech builds BOTH cameras -- the authentic // VIEW TOGGLE: the player's mech builds BOTH cameras -- the authentic
// cockpit eyepoint (DPLEyeRenderable at 'siteeyepoint', the pod's only // cockpit eyepoint (DPLEyeRenderable at 'siteeyepoint', the pod's only
@@ -756,6 +809,30 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
void void
SwapToWreck(Entity *victim); SwapToWreck(Entity *victim);
// #73 -- the aimed PER-PART pick. The 1995 target pick was a dpl scene
// intersection run by the division card against the drawn geometry
// (dpl_isect_mode_obj per renderable; results at Instance/DCS/Geometry
// granularity), so aimed fire struck a SEGMENT and credited that
// segment's damage zone. This is the port's equivalent: ray-vs-sphere
// over the mech's per-segment draw objects (mCullCenter/mCullRadius,
// world via the draw-cached mLocalToWorld -- one frame stale, fine for
// aiming), nearest struck segment wins, returning its hit point and
// PrimaryDamageZone. Returns 0 when the tree is absent/wrecked (the
// caller falls back to the whole-mech box + the unaimed lottery).
int
MechSegmentPick(Entity *mech, const float ray_start[3],
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: protected:
// //
// Renderer-manager overrides // Renderer-manager overrides
+513 -109
View File
@@ -203,6 +203,35 @@ static_assert(offsetof(BTPlayer::MakeMessage, roleName) == 0x90,
static_assert(sizeof(BTPlayer::MakeMessage) == 0xD0, static_assert(sizeof(BTPlayer::MakeMessage) == 0xD0,
"BTPlayer::MakeMessage wire size must be 0xD0"); "BTPlayer::MakeMessage wire size must be 0xD0");
// Wire-format lock (2026-08-05): the combat score reports and the extended
// VehicleDead cross nodes raw (replicant-player Dispatch reroute), and the
// sender/receiver field map was transcribed from the binary's builds
// (@0x4a04da/@0x4a05d9/@0x4a06c0 and @0x4a07d4) -- lock every offset.
static_assert(offsetof(BTPlayer::ScoreMessage, scoreAward) == 0x1c,
"ScoreMessage::scoreAward must be at wire offset 0x1c");
static_assert(offsetof(BTPlayer::ScoreMessage, scoreType) == 0x20,
"ScoreMessage::scoreType must be at wire offset 0x20");
static_assert(offsetof(BTPlayer::ScoreMessage, damageAmount) == 0x24,
"ScoreMessage::damageAmount must be at wire offset 0x24");
static_assert(offsetof(BTPlayer::ScoreMessage, vitalHit) == 0x28,
"ScoreMessage::vitalHit must be at wire offset 0x28");
static_assert(offsetof(BTPlayer::ScoreMessage, zoneIndex) == 0x2c,
"ScoreMessage::zoneIndex must be at wire offset 0x2c");
static_assert(offsetof(BTPlayer::ScoreMessage, subsysID) == 0x30,
"ScoreMessage::subsysID must be at wire offset 0x30");
static_assert(offsetof(BTPlayer::ScoreMessage, senderMechID) == 0x34,
"ScoreMessage::senderMechID must be at wire offset 0x34");
static_assert(sizeof(BTPlayer::ScoreMessage) == 0x3c,
"ScoreMessage wire size must be 0x3c (binary build @0x4a052b)");
static_assert(offsetof(BTPlayer::VehicleDeadMessage, reserved28) == 0x28,
"VehicleDeadMessage::reserved28 must be at wire offset 0x28");
static_assert(offsetof(BTPlayer::VehicleDeadMessage, killedByPlayerID) == 0x2c,
"VehicleDeadMessage::killedByPlayerID must be at wire offset 0x2c");
static_assert(offsetof(BTPlayer::VehicleDeadMessage, killZone) == 0x34,
"VehicleDeadMessage::killZone must be at wire offset 0x34");
static_assert(sizeof(BTPlayer::VehicleDeadMessage) == 0x38,
"VehicleDeadMessage wire size must be 0x38 (binary build @0x4a07f6)");
// //
// LAYOUT LOCK (Gitea #48/#57). Our compiled BTPlayer is NOT the binary's -- the // LAYOUT LOCK (Gitea #48/#57). Our compiled BTPlayer is NOT the binary's -- the
// Entity base differs -- so the 1995 offsets must never be used as raw byte // Entity base differs -- so the 1995 offsets must never be used as raw byte
@@ -413,6 +442,42 @@ void
<< message->deathCount << " deathCount=" << deathCount << message->deathCount << " deathCount=" << deathCount
<< ")\n" << std::flush; << ")\n" << std::flush;
// ALWAYS-ON GHOST TRACE (#81 field diagnostics). The requester half of
// the drop-zone handshake, paired with the [dz] lines from the DropZone
// entity. Two things are worth recording on every re-post:
// * how many times we have re-posted for THIS death -- the respawn is
// supposed to complete on the first or second try, so a climbing
// count is a ghost forming;
// * msgDeath vs our own deathCount -- the engine gates the hunt on
// message->deathCount == deathCount, and the DropZone's re-send
// safety net is keyed on lastDeathCount == message->deathCount, so a
// MISMATCH silently breaks both (Gitea #45: the death tally is known
// not to replicate correctly). This is the cheapest way to see it.
{
static int s_tries = 0;
static int s_forDeath = -999;
if (s_forDeath != message->deathCount) { s_forDeath = message->deathCount; s_tries = 0; }
++s_tries;
// #81: the OTHER gate. The engine hunt (PLAYER.cpp:229) requires
// BOTH message->deathCount == deathCount AND simState !=
// DropZoneAcquiredState. Report both, so a stranded respawn names
// its own cause instead of being inferred.
if (GetSimulationState() == (unsigned)DropZoneAcquiredState)
DEBUG_STREAM << "[ghost] hunt GATED OFF: simState="
<< (int)GetSimulationState() << " == DropZoneAcquiredState"
<< " -- the engine will not dispatch AssignDropZone, so no"
<< " reply, no RESET, permanent ghost\n" << std::flush;
if (s_tries <= 3 || (s_tries % 5) == 0)
DEBUG_STREAM << "[dzreq] player " << BTMatchHostOf(GetEntityID())
<< ":" << (int)GetEntityID() << " asking for a drop zone"
<< " try=" << s_tries
<< " msgDeath=" << message->deathCount
<< " ourDeathCount=" << deathCount
<< (message->deathCount != deathCount ? " *** MISMATCH -- hunt gate + resend net both break ***" : "")
<< (s_tries >= 4 ? " (respawn not completing -- ghost forming)" : "")
<< "\n" << std::flush;
}
// //
// Respawning needs a drop zone. A dev mission without a "DropZones" // Respawning needs a drop zone. A dev mission without a "DropZones"
// group would hit the engine base's ACTIVE Check(dropzones) -> an // group would hit the engine base's ACTIVE Check(dropzones) -> an
@@ -551,6 +616,25 @@ void
DEBUG_STREAM << "[respawn] BUG (Gitea #59): the death path left this " DEBUG_STREAM << "[respawn] BUG (Gitea #59): the death path left this "
"player's watchers DELAYED -- ExecuteWatchers is dead for it now\n" "player's watchers DELAYED -- ExecuteWatchers is dead for it now\n"
<< std::flush; << std::flush;
// ⚠ LANDMINE, MEASURED 2026-07-30 (#81 dig). `Set_Alarm_Level` is an empty
// STUB (btstubs.cpp:87), so this write and its partner in
// DropZoneReplyMessageHandler (+0x2c, 2) are NO-OPS today.
//
// DO NOT "fix" them by calling SetSimulationState(). The values 1 and 2
// decode against Player's enum as DropZoneAcquiredState and
// VehicleTranslocatedState (PLAYER.h:273-279), which makes that conversion
// look obviously right -- and it would BREAK RESPAWN ENTIRELY: the engine's
// respawn hunt is gated on `GetSimulationState() != DropZoneAcquiredState`
// (PLAYER.cpp:231), so setting state 1 on death means AssignDropZone is
// never dispatched for that death and EVERY pilot ghosts permanently.
//
// +0x2c is not simulationState (ours is at 0x24, measured). In the binary
// it is the Simulation-base ALARM indicator -- the same field the mech side
// calls `graphicAlarm` (@0x4ac126 sets "owner alarm+0x2C -> level 9"). Our
// layout models that only as a Mech member, so a BTPlayer has nowhere to
// put it; the stub is therefore harmless until we find what reads a
// PLAYER's alarm in the binary (unrecovered; likely a cockpit/HUD respawn
// indicator). [T1 offsets, T3 purpose]
Set_Alarm_Level((char *)this + 0x2c, 1); // FUN_0041bbd8(this+0x2c, 1) Set_Alarm_Level((char *)this + 0x2c, 1); // FUN_0041bbd8(this+0x2c, 1)
// //
@@ -586,6 +670,64 @@ void
} }
// else: out of lives -> the +10s mission-review post (id 0x18). Deferred. // else: out of lives -> the +10s mission-review post (id 0x18). Deferred.
//
// THE DEATH SCORE COST (@004c07cd-0x4c0828, raw disasm 2026-08-02 -- this
// tail was missing from the #52 reconstruction). Gated on advancedDamageOn
// (+0x264) alone in the binary: self-award -SpecialCaseDeathPenalty
// (role+0x20, the 4.10-only role field restored to SCNROLE.h) as a type-1
// score message handed DIRECTLY to the engine base handler (@0042da20 ==
// PLAYER.cpp:138 `currentScore += scoreAward` + the vehicle
// RespondToScoreMessage no-op hook) -- NOT dispatched, so it bypasses the
// BT ScoreInflicted Verify. Shipped content authors the key nowhere, so
// the cost is 0 in the field today; a mission that authors it gets it.
// BINARY NUANCE [T4]: the 1995 engine cell (+0x1c8) is read by no BT-side
// scoreboard (they read +0x278), so the pod's penalty may never have been
// visible; our port has ONE currentScore, so it shows.
//
if (advancedDamageOn && scenarioRole != 0) // this+0x264 (binary derefs role unguarded)
{
BTPlayer::ScoreMessage death_cost(
BTPlayer::ScoreInflictedMessageID, // 0x16
sizeof(BTPlayer::ScoreMessage),
BTPlayer::ScoreMessage::DamageReceivedScore, // type 1
-scenarioRole->GetSpecialCaseDeathPenalty(), // scoreAward @+0x1c, NEGATED
0.0f,
EntityID::Null); // senderMechID (binary copies @0x522524)
Player::ScoreMessageHandler(&death_cost); // the DIRECT base call
}
//
// RELEASE THE DEATH LATCH (#81, the GHOST MECH fix -- 2026-07-30).
//
// The binary clears it right here, between the re-post and suppressConsole:
// FUN_004c012c's tail is Post(...) ; *(this+0x290) = 0 ; *(this+0x258) = 0
// (part_013.c:10519-10523). We had the Post and the suppressConsole and
// were missing the middle instruction, so `deathPending` -- which we DO set
// (:505, and the dedup gate that reads it IS authentic; the binary's own
// @004c05c4 does `mov edx,[ebx+0x290]; test edx,edx; jne ret`) -- was only
// ever cleared on the SUCCESS paths. One failed respawn therefore latched
// the pilot dead for the rest of the mission: every later death hit the
// dedup and was SWALLOWED, so the cycle could never restart. That is what
// turned a transient respawn hiccup into a PERMANENT ghost (dead,
// un-Reset, still driveable, a burning wreck on every peer that sinks out
// of the world after ~18 s and can never be drawn again).
//
// Field signature it explains exactly: 2026-07-29, 8 death cycles, 6
// stranded, NONE of them ever recovering.
//
// Binary evidence that the latch must not persist: `+0x290` is written in
// exactly THREE places in the whole of BTL4OPT.EXE (file offsets 0x0b75fb,
// 0x0bffe3, 0x0c0a05) and ALL THREE store a zeroed register (`xor` on the
// preceding instruction); there is no write of 1 -- or of any non-zero
// value, in any instruction form -- anywhere in the executable. So in 1995
// the gate exists but can never block. [T1]
//
// Ordering matters and is preserved: the re-entrant death that arrives
// while the death EFFECTS are being dispatched still lands while the latch
// is up, so the "one death, one cycle" dedup is untouched.
//
deathPending = 0; // this+0x290 (binary: FUN_004c012c tail)
suppressConsole = 0; // this+0x258 suppressConsole = 0; // this+0x258
} }
@@ -738,30 +880,45 @@ void
{ {
// //
// Points lost for damage we took (unless we hit ourselves). // Points lost for damage we took (unless we hit ourselves).
// sender null guard is PORT SAFETY: the binary derefs the registry
// lookup unguarded (@0x4c0453) -- an inflictor that died between
// shot and impact would have crashed the 1995 pod.
// //
if (sender_mech != our_mech && scenarioRole != 0) // scoring wave: guard the NULL bring-up role if (sender_mech != 0 && our_mech != 0 // port guards (dead-window straggler)
&& sender_mech != our_mech && scenarioRole != 0) // scoring wave: guard the NULL bring-up role
{ {
Scalar received = Scalar received =
scenarioRole->CalcDamageReceivedScore(message->damageAmount); // FUN_00429b94 scenarioRole->CalcDamageReceivedScore(message->damageAmount); // FUN_00429b94
award = (MECH_TONNAGE(our_mech) / MECH_TONNAGE(sender_mech)) * received; award = (MECH_TONNAGE(our_mech) / MECH_TONNAGE(sender_mech)) * received;
} }
// BINARY NUANCE [T1 raw disasm @0x4c0433]: on SELF-inflicted damage
// (sender == own vehicle) the 1995 arm skips the math and the common
// tail folds its UNINITIALIZED award local into currentScore --
// stack garbage, every fall/ram self-hit. The port keeps award=0.0,
// a deliberate safe deviation.
// //
// Tell the console (if any, and not suppressed) that our VTV // Tell the console (if any, and not suppressed) that our VTV
// was damaged. // was damaged. points_transfered CORRECTED 2026-08-05: the binary
// rounds THE COMPUTED AWARD here (raw disasm @0x4c04a7: fld
// [ebp-0xc]; call 0x4dcd94 -- an ST0-arg __ftol the decompiler
// rendered argless, which an earlier pass misread as Now()).
// //
Host *console_host = Host *console_host =
application->GetHostManager()->GetConsoleHost(); // FUN_00429078 application->GetHostManager()->GetConsoleHost(); // FUN_00429078
if (console_host && suppressConsole == 0) // this+0x258 BTPlayer *damager_player =
(sender_mech != 0) ? MECH_OWNING_PLAYER(sender_mech) : 0; // port guard (dummy shooter)
if (console_host && suppressConsole == 0 // this+0x258
&& damager_player != 0)
{ {
ConsolePlayerVTVDamagedMessage damaged_message( ConsolePlayerVTVDamagedMessage damaged_message(
ownerID, // this+0x18c ownerID, // this+0x18c
MECH_OWNING_PLAYER(sender_mech)->ownerID, // *(sender+0x190)+0x18c damager_player->ownerID, // *(sender+0x190)+0x18c
Round(message->damageAmount), // FUN_004078fc Round(message->damageAmount), // FUN_004078fc
message->pointSenderLo, // msg+0x2c message->zoneIndex, // msg+0x2c
message->pointSenderHi, // msg+0x28 message->vitalHit, // msg+0x28
(int)Now().ticks, // FUN_004dcd94 Round(award), // FUN_004dcd94(ST0)
message->auxID // msg+0x30 message->subsysID // msg+0x30
); );
application->SendMessage( // app+0x20, slot+0x18 application->SendMessage( // app+0x20, slot+0x18
@@ -775,6 +932,13 @@ void
case BTPlayer::ScoreMessage::KillScore: // 2 case BTPlayer::ScoreMessage::KillScore: // 2
{ {
// PORT SAFETY: an unresolvable victim mech (removed wreck racing the
// cross-node dispatch) or a severed own vehicle skips the arm; the
// binary derefs both unguarded (@0x4c037e).
if (sender_mech == 0 || our_mech == 0)
{
break;
}
// gauge scoring wave: the attacking mech's owner (NULL for the ownerless // gauge scoring wave: the attacking mech's owner (NULL for the ownerless
// BT_SPAWN_ENEMY dummy). Resolved once, guarded everywhere below. // BT_SPAWN_ENEMY dummy). Resolved once, guarded everywhere below.
BTPlayer *sender_owner = MECH_OWNING_PLAYER(sender_mech); // *(sender+0x190), NULL for the dummy BTPlayer *sender_owner = MECH_OWNING_PLAYER(sender_mech); // *(sender+0x190), NULL for the dummy
@@ -1411,6 +1575,36 @@ void
} }
else // stale / old message else // stale / old message
{ {
//
// #81 GHOST: THIS IS THE LAST PLACE A RESPAWN CAN VANISH WITHOUT A
// TRACE. The drop zone granted us a spot and dispatched the reply, but
// its deathCount does not match ours -- so the reply is dropped, the
// mech is never Reset, `deathPending` stays latched, and the pilot is a
// permanent GHOST: dead, un-reset, still simulated and driveable, and a
// burning wreck on every peer (which then sinks after ~18s and can
// never be drawn again, btl4vid.cpp:1277). Until now this branch was a
// bare `return` with no log at all, which is exactly why 6 of 8 field
// death cycles stranded on 2026-07-29 leaving no evidence.
//
// ALWAYS ON, and it prints the DIRECTION of the mismatch, which decides
// the fix: msgDeath < ours means the reply is genuinely stale (a
// superseded death) and dropping it is correct; msgDeath > ours means
// OUR counter is behind (a missed increment / a replicated write -- cf.
// #45, the death tally does not replicate correctly) and the reply is
// for a live death we should have honoured. Do not "recover" here
// until the field logs say which way it goes -- guessing would be a
// stand-in, and the wrong guess re-spawns a mech that is still alive.
//
DEBUG_STREAM << "[ghost] DROP-ZONE REPLY DISCARDED for player "
<< BTMatchHostOf(GetEntityID()) << ":" << (int)GetEntityID()
<< " -- msgDeath=" << message->deathCount
<< " ourDeathCount=" << deathCount
<< (message->deathCount < deathCount
? " (reply is STALE -- superseded death)"
: " *** OUR COUNTER IS BEHIND -- this was a live respawn and we just threw it away ***")
<< " deathPending=" << deathPending
<< (deathPending != 0 ? " <== LATCHED: this pilot is now a GHOST" : "")
<< "\n" << std::flush;
return; return;
} }
@@ -1516,6 +1710,23 @@ BTPlayer::BTPlayer(
<< " deathPending@0x" << (int)offsetof(BTPlayer, deathPending) << " deathPending@0x" << (int)offsetof(BTPlayer, deathPending)
<< std::dec << " <-- the raw write targets byte 0x284\n" << std::dec << " <-- the raw write targets byte 0x284\n"
<< std::flush; << std::flush;
// #81 GHOST DIG: the death path does Set_Alarm_Level(this+0x2c, 1)
// and the drop-zone reply does (this+0x2c, 2). Decoded against
// Player's enum those are DropZoneAcquiredState(1) and
// VehicleTranslocatedState(2) -- i.e. the BINARY's +0x2c is the
// Player's simulationState. The engine's respawn hunt is gated on
// `GetSimulationState() != DropZoneAcquiredState`
// (engine/MUNGA/PLAYER.cpp:229), so if that raw byte really is our
// simulationState, EVERY death marks "drop zone already acquired"
// and the hunt can never run -> permanent ghost. Print where our
// compiled layout actually puts it.
DEBUG_STREAM << "[layout] simulationState@0x" << std::hex
<< (int)((char *)&simulationState - (char *)this)
<< std::dec << " (raw death write targets 0x2c;"
<< " DropZoneAcquiredState=" << (int)DropZoneAcquiredState
<< " VehicleTranslocatedState=" << (int)VehicleTranslocatedState
<< ") liveState=" << (int)GetSimulationState() << "\n"
<< std::flush;
} }
} }
@@ -1695,6 +1906,22 @@ Logical
// these read the COMPILED named members the scoring handlers write, replacing the // these read the COMPILED named members the scoring handlers write, replacing the
// earlier raw-offset reads (pilot+0x27c / +0x200) that don't match our layout. // earlier raw-offset reads (pilot+0x27c / +0x200) that don't match our layout.
// //
// #83 (collision rattle): the technician "splash/collision damage" setting --
// the binary's collision distributor gates on the owning player's
// advancedDamage copy 2 (@0x268, FUN_0049ffcc @0x49ffde). TU-safe bridge.
int BTPlayerAdvancedDamageOn(void *player_v)
{
return (player_v != 0) ? ((BTPlayer *)player_v)->advancedDamageOn2 : 0;
}
// #89 death blast, second gate: suppressConsole (+0x258) is SET by the eject
// bookkeeping -- an EJECTED pilot's abandoned mech never blast-splashes
// (raw disasm @0x4a0ac8-0x4a0ad6).
int BTPlayerConsoleSuppressed(void *player_v)
{
return (player_v != 0) ? (int)((BTPlayer *)player_v)->suppressConsole : 0;
}
int BTPilotKills(void *pilot) int BTPilotKills(void *pilot)
{ {
int k = pilot ? ((BTPlayer *)pilot)->GetKillCount() : 0; int k = pilot ? ((BTPlayer *)pilot)->GetKillCount() : 0;
@@ -2015,124 +2242,247 @@ Logical BTResolveMessageBoard(Entity * /*tracked_mech*/, int *messageId, BitMap
} }
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// gauge scoring wave: PRODUCERS -- posted by the combat path (mech4.cpp) to feed // THE AUTHENTIC COMBAT REPORT SENDER (#45/#134 -- 2026-08-05)
// the scoreboard. The inflictor is always the viewpoint mech in bring-up, so the
// local (crediting) player is application->GetMissionPlayer(). Bridges (not inline
// in mech4.cpp) so the message construction lives in this complete-BTPlayer TU.
// //
void BTPostDamageScore(Entity *victim, Scalar damage) // Step 6: per-hit SCORE // Reconstructed from the raw disasm of the Mech::TakeDamageMessageHandler
// report tail @0x4a02f4-0x4a0890 (dark-gap region, not in the export). This
// RETIRES the bring-up producers BTPostDamageScore / BTPostKillScore (see the
// tombstone below). The binary's flow, transcribed:
//
// resolve: shooterEntity = registry find(msg->inflictingEntity) @0x4a033c
// shooterPlayer = shooterEntity+0x190 @0x4a034f
// victimPlayer = mech+0x190 @0x4a0358
// block A @0x4a04da (was ALIVE at entry, destroyed now):
// ScoreMessage{id 0x16, type 2 KillScore,
// scoreAward = the APPLIED damage tally,
// damageAmount = victim role's killBonus (role+0x1c),
// vitalHit, zone, subsysID,
// senderMechID = the VICTIM} -> the SHOOTER's player
// block B @0x4a05d9 (not newly killed, tally != 0):
// same shape, type 0 -- which the only registered 0x16 receiver
// (@0x4c02e4) VERIFY-rejects; 1995 then folded an UNINITIALIZED
// award. Sent for wire fidelity; our handler banks 0.
// block C @0x4a06c0 (tally > 0; runs after A too -- kills included):
// ScoreMessage{type 1 DamageReceivedScore,
// scoreAward = tally,
// damageAmount = INTENDED (burstCount x amount),
// senderMechID = the INFLICTOR} -> the victim's player
//
// Score-model consequence [T1]: shooters are credited ONLY for kills; victims
// are PENALIZED for damage received (CalcDamageReceivedScore returns the
// negative). Per-hit inflicted credit never existed in the 1995 pod.
//
// Port accommodations, all guarded: registry/player lookups null-checked (the
// binary derefs them raw); killBonus basis 0 for the ownerless dummy victim;
// a skipped kill credit keeps the #45 NOCREDIT matchlog forensics.
//
void BTMechPostCombatReports(
void *mech_v, // the VICTIM mech (the handler's this)
void *msg_v, // the TakeDamageMessage being handled
float damage_tally, // loop local_24: applied damage + crit bonuses
int vital_hit, // loop local_2c: a zone entered BurningState
int report_zone, // msg->damageZone at loop ENTRY (the binary
// reads msg+0x24, never rewritten mid-loop)
int newly_destroyed) // alive at entry && IsMechDestroyed() now
{ {
if (application == 0 || victim == 0) Mech *victim = (Mech *)mech_v;
Entity::TakeDamageMessage *msg = (Entity::TakeDamageMessage *)msg_v;
if (application == 0 || victim == 0 || msg == 0)
{ {
return; return;
} }
BTPlayer *local_player = (BTPlayer *)application->GetMissionPlayer();
if (local_player == 0)
{
return;
}
// ScoreInflicted (scoreType 0): senderMechID = the mech that TOOK the damage.
// -> ScoreInflictedMessageHandler: currentScore += tonnageRatio*CalcInflictedScore.
BTPlayer::ScoreMessage message(
BTPlayer::ScoreInflictedMessageID, // 0x16
sizeof(BTPlayer::ScoreMessage),
BTPlayer::ScoreMessage::DamageInflictedScore, // 0
0.0f, // scoreAward (unused for inflicted)
damage, // damageAmount
victim->GetEntityID()); // senderMechID = victim
local_player->Dispatch(&message);
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] +damage " << damage << " -> currentScore="
<< (Scalar)local_player->GetScore() << "\n" << std::flush;
}
void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP death) BTPlayer *shooter_player = 0;
{ Entity *shooter = 0;
if (application == 0 || victim == 0) extern int BTIsRegisteredMech(Entity *e);
{
return;
}
// KILL credit. Runs on the VICTIM's node -- the only node whose mech carries a
// populated `lastInflictingID` (damage is applied master-side only: 0 of 8800
// corpus DMG rows target a replicant, which is why every `DEATH inst=R` row reads
// `killer=0:0`). The credit is dispatched to the killer's Player, which HERE is a
// REPLICANT, so `Entity::Dispatch` reroutes it to the owning host
// (ENTITY.cpp:244-251) and `++killCount` lands on the killer's OWN machine.
//
// CORRECTED 2026-07-25 (Gitea #45). The previous banner claimed "every node
// maintains LOCAL score copies ... each node witnesses the death transition and
// tallies its own copies self-consistently". That was FALSE and it is what hid
// this bug: the reroute means exactly ONE node increments, no other node can
// observe the killer at all, and nothing carried the value back out -- so every
// remote pilot's KILLS read 0 on every other pod, all mission (corpus invariant:
// 0 of 18818 DMG rows are inst=R, so no other node can even attribute the kill;
// every DEATH inst=R row reads killer=0:0). The counters are
// now replicated owner->replicant instead; see Read/WriteUpdateRecord above.
BTPlayer *killer_player = 0;
if (application->GetHostManager() != 0) if (application->GetHostManager() != 0)
{ {
Entity *killer = application->GetHostManager()->GetEntityPointer( shooter = application->GetHostManager()->GetEntityPointer(
((Mech *)victim)->lastInflictingID); msg->inflictingEntity);
extern int BTIsRegisteredMech(Entity *e); if (shooter != 0 && BTIsRegisteredMech(shooter))
if (killer != 0 && killer != victim && BTIsRegisteredMech(killer)) shooter_player = (BTPlayer *)((Mech *)shooter)->GetPlayerLink();
killer_player = (BTPlayer *)((Mech *)killer)->GetPlayerLink();
} }
if (killer_player != 0) BTPlayer *victim_player = (BTPlayer *)victim->GetPlayerLink();
if (newly_destroyed)
{ {
// KillScore (scoreType 2): senderMechID MUST be the VICTIM (!= the //
// receiver's mech) so the handler credits `killCount++` -- a suicide // Block A: the KILL report, to the SHOOTER's player. On the victim's
// (killer == victim) never reaches here. // node that player is a replicant, so Entity::Dispatch reroutes to the
BTPlayer::ScoreMessage kill( // owning host (ENTITY.cpp:244) and the credit lands on the killer's own
Player::ScoreMessageID, // 0x12 // machine -- the #45 flow, unchanged. killer == victim (the eject
// charge) IS dispatched: the handler negates the award. That is the
// #134 panic-penalty path the old BTPostKillScore filtered out.
//
Scalar kill_bonus = 0.0f; // basis: victim role+0x1c
if (victim_player != 0 && victim_player->GetScenarioRole() != 0)
kill_bonus = victim_player->GetScenarioRole()->GetKillBonus();
if (shooter_player != 0)
{
BTPlayer::ScoreMessage kill(
Player::ScoreMessageID, // 0x16
sizeof(BTPlayer::ScoreMessage), // 0x3c
BTPlayer::ScoreMessage::KillScore, // 2
damage_tally, // +0x1c
kill_bonus, // +0x24
victim->GetEntityID(), // +0x34 the VICTIM
vital_hit, // +0x28
report_zone, // +0x2c
msg->inflictingSubsystemID); // +0x30
shooter_player->Dispatch(&kill);
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] *** KILL report *** -> shooterPlayer="
<< (void *)shooter_player << " tally=" << damage_tally
<< " bonus=" << kill_bonus << "\n" << std::flush;
}
else if (BTMatchLogActive())
{
// #45 forensics, carried from BTPostKillScore: WHY the credit was
// skipped -- missing killer entity, unregistered, or NULL link.
BTMatchLog("NOCREDIT",
"victim=%d:%d killerID=%d:%d found=%d registered=%d link=%d",
BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(),
BTMatchHostOf(msg->inflictingEntity), (int)msg->inflictingEntity,
(int)(shooter != 0),
(int)(shooter != 0 && BTIsRegisteredMech(shooter)),
(int)(shooter != 0 && ((Mech *)shooter)->GetPlayerLink() != 0));
}
}
else if (damage_tally != 0.0f && shooter_player != 0)
{
//
// Block B: the plain inflicted report. Wire fidelity only -- the 0x16
// handler Verify-rejects type 0 and banks award 0 (1995 banked an
// uninitialized stack float; see the handler's type-0 arm note).
//
BTPlayer::ScoreMessage inflicted(
Player::ScoreMessageID,
sizeof(BTPlayer::ScoreMessage), sizeof(BTPlayer::ScoreMessage),
BTPlayer::ScoreMessage::KillScore, // 2 BTPlayer::ScoreMessage::DamageInflictedScore, // 0
0.0f, // scoreAward (killBonus; 0 for bring-up) damage_tally, // +0x1c
damage, // damageAmount (killing-blow) damage_tally, // +0x24 basis = tally
victim->GetEntityID()); // senderMechID = victim victim->GetEntityID(),
killer_player->Dispatch(&kill); vital_hit, report_zone, msg->inflictingSubsystemID);
if (getenv("BT_SCORE_LOG")) shooter_player->Dispatch(&inflicted);
DEBUG_STREAM << "[score] *** KILL *** killerPlayer=" << (void *)killer_player
<< " killCount=" << killer_player->GetKillCount()
<< " deaths=" << killer_player->GetDeaths()
<< " score=" << (Scalar)killer_player->GetScore() << std::endl;
}
else if (BTMatchLogActive())
{
//
// #45 observability: the credit was SKIPPED. This used to be completely
// silent, which is why the bug survived so long -- the counter simply never
// moved and no log said why. Record which link in the chain broke so a
// single matchlog convicts it: a missing killer entity, an unregistered
// mech, a self-kill, or the NULL playerLink hazard.
//
Entity *k = (application->GetHostManager() != 0)
? application->GetHostManager()->GetEntityPointer(
((Mech *)victim)->lastInflictingID)
: 0;
extern int BTIsRegisteredMech(Entity *e);
BTMatchLog("NOCREDIT",
"victim=%d:%d killerID=%d:%d found=%d self=%d registered=%d link=%d",
BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(),
BTMatchHostOf(((Mech *)victim)->lastInflictingID),
(int)((Mech *)victim)->lastInflictingID,
(int)(k != 0), (int)(k == victim),
(int)(k != 0 && BTIsRegisteredMech(k)),
(int)(k != 0 && ((Mech *)k)->GetPlayerLink() != 0));
} }
// MP DEATH: credit a death to the VICTIM's own player. NULL for the solo if (damage_tally > 0.0f && victim_player != 0)
// BT_SPAWN_ENEMY dummy (GetPlayerLink()==0) -> skipped, so DEATHS stays 0 in
// solo (authentic -- DEATHS only lands on a real pilot in multiplayer).
BTPlayer *victim_player = (BTPlayer *)((Mech *)victim)->GetPlayerLink();
if (victim_player != 0)
{ {
Player::VehicleDeadMessage dead( //
Player::VehicleDeadMessageID, // 0x13 // Block C: the RECEIVED report, to the victim's own player -- kills
sizeof(Player::VehicleDeadMessage)); // included (the binary runs this after block A as well). Carries the
victim_player->Dispatch(&dead); // INTENDED damage (burstCount x amount, @0x4a06d2 fild/fmul) as the
// penalty basis and the INFLICTOR as senderMechID; feeds the score
// penalty and the operator-console VTVDamaged line.
//
int bursts = msg->damageData.burstCount;
if (bursts < 1)
bursts = 1; // port guard (mirrors the loop)
BTPlayer::ScoreMessage received(
Player::ScoreMessageID,
sizeof(BTPlayer::ScoreMessage),
BTPlayer::ScoreMessage::DamageReceivedScore, // 1
damage_tally, // +0x1c
(Scalar)bursts * msg->damageData.damageAmount, // +0x24 intended
msg->inflictingEntity, // +0x34 the INFLICTOR
vital_hit, report_zone, msg->inflictingSubsystemID);
victim_player->Dispatch(&received);
} }
} }
//
// The authentic VehicleDead sender (@0x4a07d4-0x4a0890): the respawn-cycle
// trigger, dispatched from the death tail of the SAME TakeDamage that killed
// the mech, to the mech's own player -- now carrying the killed-by player and
// the killing zone (the BT 0x38-byte message extension). Replaces the mech4
// death-transition dispatch site, which carried a [T3 -- the binary's exact
// sender is undecoded] flag; this IS that sender, decoded. The #55
// NULL-playerLink fallback and the DEAD_NOTIFY forensics move here intact.
//
void BTMechPostVehicleDead(void *mech_v, void *msg_v, int report_zone)
{
Mech *victim = (Mech *)mech_v;
Entity::TakeDamageMessage *msg = (Entity::TakeDamageMessage *)msg_v;
if (application == 0 || victim == 0)
{
return;
}
Player *owner = victim->GetPlayerLink();
// #55 (the David fix), moved intact: resolve the SAME object by the
// reverse link the binary's own respawn branch uses when the once-written
// playerLink was lost.
if (owner == 0)
{
Player *mission_player = (Player *)application->GetMissionPlayer();
if (mission_player != 0
&& mission_player->GetPlayerVehicle() == (Entity *)victim)
{
owner = mission_player;
}
}
BTMatchLog("DEAD_NOTIFY", "mech=%d:%d link=%p",
BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(),
(void *)owner);
if (owner == 0)
{
return;
}
// killed-by: the shooter PLAYER's own EntityID (@0x4a07d4 copies
// shooterPlayer+0x184); Null when the inflictor or its player is gone.
EntityID killed_by = EntityID::Null;
if (msg != 0 && application->GetHostManager() != 0)
{
Entity *shooter = application->GetHostManager()->GetEntityPointer(
msg->inflictingEntity);
extern int BTIsRegisteredMech(Entity *e);
if (shooter != 0 && BTIsRegisteredMech(shooter))
{
Player *shooter_player = ((Mech *)shooter)->GetPlayerLink();
if (shooter_player != 0)
killed_by = shooter_player->GetEntityID();
}
}
BTPlayer::VehicleDeadMessage vehicle_dead(
Player::VehicleDeadMessageID, // 0x17
sizeof(BTPlayer::VehicleDeadMessage), // 0x38
killed_by,
report_zone);
owner->Dispatch(&vehicle_dead);
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[death] VehicleDead(-1) dispatched to the owning player"
<< " (killedBy=" << BTMatchHostOf(killed_by) << ":" << (int)killed_by
<< " zone=" << report_zone << ")\n" << std::flush;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// TOMBSTONE (2026-08-05): BTPostDamageScore / BTPostKillScore -- the gauge
// scoring wave's bring-up producers -- are RETIRED, replaced by the authentic
// report tail above (BTMechPostCombatReports / BTMechPostVehicleDead, called
// from Mech::TakeDamageMessageHandler at the binary's exact positions).
// What changed for the scoreboard:
// - per-hit INFLICTED credit is GONE: it was a port invention riding the
// never-registered @0x4c0200 handler (the binary's BTPlayer table, byte-
// scanned at file 0x112dxx, has 6 entries and none binds it). 1995
// scoring = kill awards + received-damage penalties, nothing per-hit.
// This also retires the #95 salvo-credit fix that rode it.
// - suicides now DISPATCH the kill report and the handler NEGATES the
// award -- the #134 panic-eject penalty path the old filter blocked.
// - kill credit resolves the shooter from msg->inflictingEntity of the
// killing TakeDamage itself; lastInflictingID (stamped from the same
// field at handler entry) stays for the LOD router + effect orientation.
// - the cross-node reroute story is unchanged: the kill report still
// Dispatches to a replicant player and lands on the owner host
// (ENTITY.cpp:244-251); KILLS/DEATHS still replicate owner->replicant
// (Read/WriteUpdateRecord + the scoreboard heartbeat).
// - the #81 single-VehicleDead rule is unchanged: exactly one dispatch per
// death, now from the authentic tail instead of the death transition.
// The ScoreInflictedMessageID channel and its handler remain registered but
// idle (port infra; the binary's analog was dead code in the table).
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//############################################################################# //#############################################################################
// BTPlayerRoleLocksAdvanced -- complete-type bridge (task #12) // BTPlayerRoleLocksAdvanced -- complete-type bridge (task #12)
@@ -2221,3 +2571,57 @@ Entity *BTPlayerObjectiveMechOf(void *player)
return 0; return 0;
return (Entity *)((BTPlayer *)player)->GetObjectiveMech(); // @0x284 return (Entity *)((BTPlayer *)player)->GetObjectiveMech(); // @0x284
} }
//#############################################################################
// BTPlayerEjectBookkeeping -- complete-type bridge for Mech::EjectPilot
// (@0049f854, the PANIC/EJECT punch-out). The handler's two player-side
// writes, verbatim from the raw disasm:
// * suppressConsole (+0x258) = 1 -- the eject already notified the console
// (its own message, the tracked relay tail), so the DEATH that the
// returned charge causes must not double-notify;
// * the self-destruct amount = ScenarioRole::killBonus (role+0x1c -- the
// record map is byte-verified: reader @00429bec dest offsets + the role
// ctor's record copy [7]=rec[0]).
// Returns the charge; 0 with no player/role (binary derefs unguarded).
//#############################################################################
Scalar BTPlayerEjectBookkeeping(void *player_v)
{
BTPlayer *player = (BTPlayer *)player_v;
if (player == 0)
{
return 0.0f;
}
// The console EJECT NOTICE (@0049f88e-0x49f8cf): FUN_004c198c is the
// ConsolePlayerMechDeathWithoutHonorMessage ctor -- "death without honor"
// IS the punch-out record (4.10 wire id 0xF; our catalogue re-tags it 6,
// not load-bearing) -- sent to the console host through the same
// ConsoleClientID path as the VTVDamaged notify. The suppressConsole
// latch below is its other half: the eject notice REPLACES the death
// notify for this death.
Host *console_host =
application->GetHostManager()->GetConsoleHost(); // FUN_00429078
if (console_host != 0)
{
ConsolePlayerMechDeathWithoutHonorMessage eject_notice(
player->ownerID); // this+0x18c
application->SendMessage(
console_host->GetHostID(), // console_host+0xc
NetworkClient::ConsoleClientID, // 5
&eject_notice);
// Rare + forensic -- always log (the DumpAmmo precedent).
DEBUG_STREAM << "[eject] DeathWithoutHonor notice -> console host "
<< (int)console_host->GetHostID() << "\n" << std::flush;
}
else
{
DEBUG_STREAM << "[eject] no console host -- notice skipped\n" << std::flush;
}
player->suppressConsole = 1; // +0x258
const ScenarioRole *role = player->GetScenarioRole(); // +0x208
if (role == 0)
{
return 0.0f;
}
return role->GetKillBonus(); // role+0x1c
}
+89 -22
View File
@@ -57,14 +57,26 @@ class DropZone__ReplyMessage;
// generated the points. Parallels RPPlayer__ScoreMessage, but the BT // generated the points. Parallels RPPlayer__ScoreMessage, but the BT
// score types describe mech combat instead of score zones. // score types describe mech combat instead of score zones.
// //
// Observed message layout (param_2 in the handlers): // Message layout, sizeof 0x3C (field meanings CORRECTED 2026-08-05 from the
// +0x1c scoreAward (Scalar, base Player::ScoreMessage) // 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) // +0x20 scoreType (int)
// +0x24 damageAmount (Scalar) raw damage / point quantity // +0x24 damageAmount (Scalar) the scoring BASIS: type 0 = tally,
// +0x28 pointSenderHi (EntityID word) // type 1 = intended (burstCount x amount),
// +0x2c pointSenderLo (EntityID word) // type 2 = the VICTIM role's killBonus (role+0x1c)
// +0x30 auxID (EntityID / host word) // +0x28 vitalHit (int) a zone reached BurningState during
// +0x34 senderMechID (EntityID -- resolved to the inflicting Mech) // 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: class BTPlayer__ScoreMessage:
public Player::ScoreMessage public Player::ScoreMessage
@@ -72,11 +84,16 @@ class DropZone__ReplyMessage;
public: public:
// //
// Kind of scoring event. Recovered from the branch selector at // Kind of scoring event. Recovered from the branch selector at
// @004c02e4 (this->scoreType, message+0x20) and the dedicated // @004c02e4 (this->scoreType, message+0x20). NOTE: type 0 has NO
// inflicted-damage handler at @004c0200. // scoring arm in the binary -- @004c02e4 Verify-rejects it (line 662)
// and @004c0200, the only function that accepts it, appears in no
// handler-table entry (byte-scan 2026-08-05: the BTPlayer table at
// file 0x112dxx has exactly 6 entries, none binding it). 1995 pod
// scoring = kills + received-damage penalties; per-hit inflicted
// credit never existed.
// //
enum ScoreType { enum ScoreType {
DamageInflictedScore = 0, // to ScoreInflictedMessageHandler DamageInflictedScore = 0, // sent, but scores nothing (see above)
DamageReceivedScore = 1, // I took damage DamageReceivedScore = 1, // I took damage
KillScore = 2 // I destroyed / was destroyed KillScore = 2 // I destroyed / was destroyed
}; };
@@ -85,21 +102,19 @@ class DropZone__ReplyMessage;
scoreType; // +0x20 scoreType; // +0x20
Scalar 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 int
pointSenderHi; // +0x28 vitalHit; // +0x28 zone entered BurningState this message
int int
pointSenderLo; // +0x2c zoneIndex; // +0x2c struck damage zone
int int
auxID; // +0x30 subsysID; // +0x30 inflicting weapon subsystem
EntityID EntityID
senderMechID; // +0x34 inflicting mech (point sender) senderMechID; // +0x34 victim (types 0/2) / inflictor (type 1)
BTPlayer__ScoreMessage( BTPlayer__ScoreMessage(
Receiver::MessageID message_ID, Receiver::MessageID message_ID,
@@ -107,15 +122,63 @@ class DropZone__ReplyMessage;
int score_type, int score_type,
Scalar score_award, Scalar score_award,
Scalar damage_amount, 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), Player::ScoreMessage(message_ID, length, score_award),
scoreType(score_type), scoreType(score_type),
damageAmount(damage_amount), damageAmount(damage_amount),
vitalHit(vital_hit),
zoneIndex(zone_index),
subsysID(subsys_ID),
senderMechID(sender_mech_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 ######################### //##################### BTPlayer::MakeMessage #########################
//########################################################################### //###########################################################################
@@ -202,8 +265,9 @@ class DropZone__ReplyMessage;
// Player::ScoreMessage.) // Player::ScoreMessage.)
// //
public: public:
typedef BTPlayer__ScoreMessage ScoreMessage; typedef BTPlayer__ScoreMessage ScoreMessage;
typedef BTPlayer__MakeMessage MakeMessage; typedef BTPlayer__MakeMessage MakeMessage;
typedef BTPlayer__VehicleDeadMessage VehicleDeadMessage; // BT 0x38 ext (killed-by)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Message Support // Message Support
@@ -436,6 +500,9 @@ class DropZone__ReplyMessage;
friend int BTPlayerRoleLocksAdvanced(void *); // the FUN_004ac9c8 bridge (task #12): experienceLevel == 0 friend int BTPlayerRoleLocksAdvanced(void *); // the FUN_004ac9c8 bridge (task #12): experienceLevel == 0
friend int BTPlayerExperienceSimLive(void *); // +0x25c reader (issue #2): jams / lights / powersub short path friend int 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 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 // TARGET DESIGNATION (Gitea #48/#57): the mapper used to write the target
// RAW at `pilot + 0x284` -- the BINARY's objectiveMech offset, which on our // RAW at `pilot + 0x284` -- the BINARY's objectiveMech offset, which on our
// compiled object is `deathPending` (the respawn latch). These bridges // 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. // // body recovered from BTL4OPT.EXE (the binary oracle) / the RP analogue. //
// // // //
// == RUNTIME BRING-UP WORKLIST (replace these) == // // == RUNTIME BRING-UP WORKLIST (replace these) == //
// Mech::GetMissionReviewMode / IsAirborne / SetTargetRange / // // Mech::IsAirborne / SetTargetRange / //
// SetMappingSubsystem / RaiseStatusAlarm // // SetMappingSubsystem / RaiseStatusAlarm //
// Mech__DamageZone::LoadCriticalSubsystems // // Mech__DamageZone::LoadCriticalSubsystems //
// MechSubsystem::TakeDamage / OnAlarmChanged // // MechSubsystem::TakeDamage / OnAlarmChanged //
@@ -97,11 +97,11 @@ void Notify_Objective_Reached(int * /*objective_subsystem*/, Mech * /*mech*/)
// Mech method stubs. // Mech method stubs.
//===========================================================================// //===========================================================================//
// TODO(bring-up): reads mech+0x414 (mission-review playback flag). // (GetMissionReviewMode stub RETIRED 2026-08-03: it was a mislabel of
int Mech::GetMissionReviewMode() // mech+0x414 = ejectPermitted, now served by the inline GetEjectPermitted in
{ // mech.hpp.) The REAL mission-review mode is this GLOBAL (DAT_004fd550);
return 0; // 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). // TODO(bring-up): true while the mech is off the ground (jump-jet / fall state).
int Mech::IsAirborne() int Mech::IsAirborne()
@@ -175,10 +175,9 @@ void Mech::RaiseStatusAlarm(int /*alarm_id*/)
// MechSubsystem method stubs. // MechSubsystem method stubs.
//===========================================================================// //===========================================================================//
// TODO(bring-up): apply damage to a generic mech subsystem (virtual override). // (MechSubsystem::TakeDamage was a no-op stub here; it is now the real
void MechSubsystem::TakeDamage(Damage & /*damage*/) // @0x4ac0bc body in mechsub.cpp -- zone damage + destroyed alarms + the
{ // vital-subsystem kill. #80.)
}
// TODO(bring-up): react to a change in this subsystem's alarm level. // TODO(bring-up): react to a change in this subsystem's alarm level.
void MechSubsystem::OnAlarmChanged() void MechSubsystem::OnAlarmChanged()
+101 -2
View File
@@ -175,9 +175,14 @@ DamageZonePercentTable *
{ {
if (sliceCount <= 0) return 0; if (sliceCount <= 0) return 0;
Scalar thetaIn = theta;
if (rotateWithTorso) // binary this[3] != 0 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 >= TwoPi) theta -= TwoPi;
if (theta < 0.0f) theta += TwoPi; if (theta < 0.0f) theta += TwoPi;
} }
@@ -185,6 +190,19 @@ DamageZonePercentTable *
int index = (int)floorf(theta * (Scalar)sliceCount * (1.0f / TwoPi)); int index = (int)floorf(theta * (Scalar)sliceCount * (1.0f / TwoPi));
if (index < 0) index = 0; if (index < 0) index = 0;
if (index > sliceCount - 1) index = sliceCount - 1; 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]; return slices[index];
} }
@@ -211,6 +229,44 @@ DamageLookupTable::DamageLookupTable(
{ {
layers.push_back(new PieSlice(owner, stream)); layers.push_back(new PieSlice(owner, stream));
} }
// #92 -- "unable to damage the foot panels on Loki ... could on Thor".
// Direct-fire damage does NOT use the mesh or the aim pick: the impact's
// HEIGHT picks a layer, its ANGLE picks a slice, and a weighted random roll
// picks the zone from that slice. So a zone that appears in NO slice of the
// lowest layer is simply unreachable by direct fire. Dump the whole table.
if (getenv("BT_DMGTABLE_LOG"))
DumpTable();
}
//
// #92 diagnostic: print every layer / slice / weighted zone entry.
//
void DamageLookupTable::DumpTable() const
{
DEBUG_STREAM << "[dmgtable] layers=" << layerCount << std::endl;
for (int L = 0; L < (int)layers.size(); ++L)
{
const PieSlice *layer = layers[L];
DEBUG_STREAM << "[dmgtable] layer " << L
<< " rotateWithTorso=" << layer->DiagRotateWithTorso()
<< " slices=" << layer->DiagSliceCount() << std::endl;
for (int S = 0; S < layer->DiagSliceCount(); ++S)
{
const DamageZonePercentTable *leaf = layer->DiagSlice(S);
if (leaf == 0) continue;
DEBUG_STREAM << "[dmgtable] slice " << S << ":";
Scalar prev = 0.0f;
for (int E = 0; E < leaf->DiagEntryCount(); ++E)
{
Scalar cum = leaf->DiagCumulative(E);
DEBUG_STREAM << " z" << leaf->DiagZone(E)
<< "=" << (int)((cum - prev) * 100.0f + 0.5f) << "%";
prev = cum;
}
DEBUG_STREAM << std::endl;
}
}
} }
DamageLookupTable::~DamageLookupTable() // @0x49eadc DamageLookupTable::~DamageLookupTable() // @0x49eadc
@@ -239,6 +295,24 @@ int
Scalar heightRef = owner->CylinderReferenceHeight(); // owner+0x2ec[+0xc] Scalar heightRef = owner->CylinderReferenceHeight(); // owner+0x2ec[+0xc]
if (heightRef <= 0.0f) return -1; 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) --- // --- height -> layer (penetration depth) ---
Scalar depth = local.y; // binary local_2c Scalar depth = local.y; // binary local_2c
int layerIndex = (int)floorf((Scalar)layerCount * (depth / heightRef)); int layerIndex = (int)floorf((Scalar)layerCount * (depth / heightRef));
@@ -259,5 +333,30 @@ int
} }
DamageZonePercentTable *leaf = layer->SelectSlice(theta); // FUN_0049e678 DamageZonePercentTable *leaf = layer->SelectSlice(theta); // FUN_0049e678
return leaf ? leaf->SelectZone() : -1; // FUN_0049de14 int resolved = leaf ? leaf->SelectZone() : -1; // FUN_0049de14
// #92: which LAYER does a given impact height land in? A foot shot that
// lands in a layer above the foot layer can never roll a foot zone.
if (getenv("BT_DMGTABLE_LOG"))
{
static int s_rh = 0;
if (s_rh++ < 400)
{
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
<< " '" << (zname ? zname : "?") << "'" << std::endl;
}
}
return resolved;
} }
+11
View File
@@ -105,6 +105,11 @@ class Point3D;
// content-build authoring path -- is not part of the runtime port; the // content-build authoring path -- is not part of the runtime port; the
// shipped .RES is pre-built. Omitted here.) // shipped .RES is pre-built. Omitted here.)
// #92 diagnostics (read-only)
int DiagEntryCount() const { return (int)entries.size(); }
Scalar DiagCumulative(int i) const { return entries[i].cumulative; }
int DiagZone(int i) const { return entries[i].zoneIndex; }
protected: protected:
Mech *owner; // @0x0c Mech *owner; // @0x0c
std::vector<Entry> entries; // @0x14 (was ReconTable) -- sorted ascending std::vector<Entry> entries; // @0x14 (was ReconTable) -- sorted ascending
@@ -138,6 +143,11 @@ class Point3D;
// //
DamageZonePercentTable *SelectSlice(Scalar theta) const; DamageZonePercentTable *SelectSlice(Scalar theta) const;
// #92 diagnostics (read-only)
int DiagSliceCount() const { return (int)slices.size(); }
const DamageZonePercentTable *DiagSlice(int i) const { return slices[i]; }
int DiagRotateWithTorso() const { return rotateWithTorso; }
protected: protected:
Mech *owner; // @0x0c Mech *owner; // @0x0c
std::vector<DamageZonePercentTable*> std::vector<DamageZonePercentTable*>
@@ -172,6 +182,7 @@ class Point3D;
// the table is empty or the roll falls through (treated as a miss). // the table is empty or the roll falls through (treated as a miss).
// //
int ResolveHit(const Point3D &impact) const; int ResolveHit(const Point3D &impact) const;
void DumpTable() const; // #92 diagnostic
int LayerCount() const { return layerCount; } // bring-up verify int LayerCount() const { return layerCount; } // bring-up verify
+15 -1
View File
@@ -428,8 +428,22 @@ void
// simulationFlags@0x28, a latent kill-switch), own heatAlarm at FailureHeat // simulationFlags@0x28, a latent kill-switch), own heatAlarm at FailureHeat
// (this+0x184 == 2), or the owning mech disabled (FUN_0049fb54) -- a dead // (this+0x184 == 2), or the owning mech disabled (FUN_0049fb54) -- a dead
// mech's weapons drop everything. // mech's weapons drop everything.
if (simulationState == 1 || GetFaultState() == 2 || BTMechDestroyed((Entity *)owner)) // #86 FIX (2026-07-31): the binary's +0x40 IS the statusAlarm level cell
// (indicator @0x2C, level at +0x14 = +0x40 -- ONE cell); the port split
// them and the destruction path writes only the alarm, so X'd-out energy
// weapons on a blown-off arm kept firing. Read BOTH cells (see
// projweap.cpp gate 1 for the full note).
if (simulationState == 1 || statusAlarm.GetLevel() == 1
|| GetFaultState() == 2 || BTMechDestroyed((Entity *)owner))
{ {
// #110 verification: name the refusal, so "the destroyed PPC stays
// silent" is a log fact rather than an inference (the FIRED line does
// not name its weapon). Once per weapon per destruction is enough --
// keyed on the firing state actually being reset.
if (getenv("BT_DMG_LOG") && GetWeaponState() != 0)
DEBUG_STREAM << "[emitter] '" << (GetName() ? GetName() : "?")
<< "' fire REFUSED (destroyed=" << (int)(simulationState == 1
|| statusAlarm.GetLevel() == 1) << ")" << std::endl;
ResetFiringState(); // @004ba9a8 ResetFiringState(); // @004ba9a8
currentLevel = 0.0f; // 0x414 currentLevel = 0.0f; // 0x414
ComputeOutputVoltage(); // (*vtable+0x44)() ComputeOutputVoltage(); // (*vtable+0x44)()
+12
View File
@@ -1092,3 +1092,15 @@ void
{ {
ResetToInitialState(); // @004b2678 (takes no arg) ResetToInitialState(); // @004b2678 (takes no arg)
} }
//
// #83 bridge: the collision-damage distributor (mech.cpp, FUN_0049ffcc) tests
// roster members against this family's derivation chain (binary GUID 0x50fdc0).
// Lives here because Gyroscope is a complete type only in this TU.
//
Derivation *
BTGyroscopeFamily()
{
return &Gyroscope::ClassDerivations;
}
+186 -22
View File
@@ -359,22 +359,40 @@ Condenser::~Condenser()
#endif #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 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 // DeathReset (Gitea #55): the respawn sweep's entry for a Condenser.
// 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.]
// //
void void
Condenser::DeathReset(int reset_command) Condenser::DeathReset(int reset_command)
@@ -538,7 +556,10 @@ HeatSink::HeatSink(
thermalConductance = subsystem_resource->thermalConductance; // +0xF0 thermalConductance = subsystem_resource->thermalConductance; // +0xF0
heatFilter.Initialize(15, 0.0f); // FUN_0043ad4f(this+0x144, 0xF, 0) heatFilter.Initialize(15, 0.0f); // FUN_0043ad4f(this+0x144, 0xF, 0)
filterDecay = 0.4f; // @004b8fec stores 0.15f here (param_1[0x54] = 0x3e19999a). The value is
// otherwise unread in the port, but it IS the leak gauge's full-scale
// divisor: BitMapInverseWipe divides by subsystem+0x150 (#97).
filterDecay = 0.15f; // was 0.4f -- did not match the image
thermalMass = subsystem_resource->thermalMass; // +0xF4 thermalMass = subsystem_resource->thermalMass; // +0xF4
heatEnergy = thermalMass * startingTemperature; heatEnergy = thermalMass * startingTemperature;
coolantFlowScale = 1.0f; coolantFlowScale = 1.0f;
@@ -546,6 +567,26 @@ HeatSink::HeatSink(
pendingHeat = 0.0f; pendingHeat = 0.0f;
// #96 -- "each mech has a unique heating profile". The heat-family COUNT is
// identical on every chassis (6 condensers, 1 bank, 1 reservoir), so if
// per-mech cooling variation exists it must live in these authored values.
// Dump them so that is a measured fact rather than an assumption.
// ⚠ These are the RESOURCE values BEFORE any ctor scaling. The bank
// (AggregateHeatSink) multiplies conductance by 0.1 x HeatSinkCount right
// after this -- comparing chassis on THIS line alone mis-read the Owens and
// Thor banks as identical (they are 115.5k vs 300.3k scaled; #96).
if (getenv("BT_MYO_LOG"))
DEBUG_STREAM << "[hsparm] " << (GetName() ? GetName() : "?")
<< " startT=" << startingTemperature
<< " degradeT=" << degradationTemperature
<< " failT=" << failureTemperature
<< " conductance=" << thermalConductance
<< " thermalMass=" << thermalMass
// #99: correlate with [attrbind] -- is the audio watcher actually
// bound to THIS object's leak flag?
<< " &ReportLeak=" << (void *)&coolantActive
<< "\n" << std::flush;
// //
// A "master" heat sink (segment flagged 0x100, not a sub-/damaged copy) // A "master" heat sink (segment flagged 0x100, not a sub-/damaged copy)
// drives the per-frame thermal simulation. // drives the per-frame thermal simulation.
@@ -754,7 +795,7 @@ void
{ {
heatEnergy += pendingHeat; heatEnergy += pendingHeat;
currentTemperature = heatEnergy / thermalMass; currentTemperature = heatEnergy / thermalMass;
UpdateHeatLoad(); UpdateHeatLoad(time_slice); // 28 Hz filter cadence (#119)
// DIAG census (BT_HEAT_LOG, viewpoint mech): PER-INSTANCE 5-s timers -- // DIAG census (BT_HEAT_LOG, viewpoint mech): PER-INSTANCE 5-s timers --
// the old shared static timer aliased to whichever instance crossed the // the old shared static timer aliased to whichever instance crossed the
@@ -778,7 +819,25 @@ void
} }
pendingHeat = 0.0f; pendingHeat = 0.0f;
ConductHeat(time_slice); // FUN_004ad8ac // #96 dissipation audit: a 2s census of the chain -- prints from the SIM
// tick (not the exchange) so the TERMINUS bank, which has no link and never
// exchanges, is visible too. Gated: BT_HEAT_LOG.
if (getenv("BT_HEAT_LOG"))
{
static unsigned long s_hcAt = 0;
unsigned long now_ms = GetTickCount();
if (now_ms - s_hcAt > 2000)
s_hcAt = now_ms;
if (now_ms - s_hcAt < 40 && GetName() != 0)
DEBUG_STREAM << "[heatflow] " << GetName()
<< " T=" << currentTemperature
<< " E=" << heatEnergy
<< " pend=" << pendingHeat
<< " cool=" << coolantLevel << "/" << thermalCapacity
<< " flowScale=" << coolantFlowScale
<< " mScale=" << massScale << std::endl;
}
ConductHeat(time_slice); // FUN_004ad8ac
} }
if (HeatModelActive()) if (HeatModelActive())
@@ -809,8 +868,22 @@ void
// @004ad7f0 -- recompute the radiated/instantaneous heat and feed it through // @004ad7f0 -- recompute the radiated/instantaneous heat and feed it through
// the running-average filter to produce the smoothed heatLoad reading. // 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 void
HeatSink::UpdateHeatLoad() HeatSink::UpdateHeatLoad(Scalar time_slice)
{ {
radiatedHeat = currentTemperature * coolantLevel; radiatedHeat = currentTemperature * coolantLevel;
@@ -824,7 +897,31 @@ void
sample = HeatLoadMaximum; 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 heatLoad = heatFilter.Average(); // FUN_0043ae0b
} }
@@ -952,8 +1049,12 @@ void
// to empty every frame -- the opposite of the authentic near-static behavior. // to empty every frame -- the opposite of the authentic near-static behavior.
// An undamaged subsystem has damageLevel 0 -> coolantDraw 0 -> NO leak (the coolant // An undamaged subsystem has damageLevel 0 -> coolantDraw 0 -> NO leak (the coolant
// bars stay full on a pristine mech); the draw rises only as the heat sink / // bars stay full on a pristine mech); the draw rises only as the heat sink /
// condenser itself takes battle damage. (Read the qualified engine zone -- the // condenser itself takes battle damage. (#88 fix 2026-07-31: this qualified
// nearer MechSubsystem::damageZone is a shim SHADOW; see mechweap.cpp:252.) // engine-member read was NULL forever -- the MechSubsystem ctor only filled its
// re-declared SHADOW, so `zoneDamage` pinned 0 and a leak was structurally
// impossible no matter how much damage landed. The ctor now ALIASES the engine
// base member to the same zone (mechsub.cpp, the #64 half-fix), so this read
// sees the real crit/rattle damage.)
::DamageZone *ownZone = this->Subsystem::damageZone; // @0xE0 (word 0x38) ::DamageZone *ownZone = this->Subsystem::damageZone; // @0xE0 (word 0x38)
Scalar zoneDamage = (ownZone != 0) ? ownZone->damageLevel : 0.0f; // +0x158 Scalar zoneDamage = (ownZone != 0) ? ownZone->damageLevel : 0.0f; // +0x158
coolantDraw = zoneDamage * heatLoad; // *(this[0x38]+0x158) * this[0x48] coolantDraw = zoneDamage * heatLoad; // *(this[0x38]+0x158) * this[0x48]
@@ -997,14 +1098,30 @@ void
} }
// //
// vtable slot 14 (@vtable+0x38). Base sinks supply nothing on their own; // vtable slot 14 (@vtable+0x38) -- the base @0x4add00, disassembled + decoded
// a central-cooling-system subclass overrides this. // 2026-07-31 (the old `return 0` stub was why a leaking subsystem could never
// TODO: verify against the real slot-14 target (not captured in decomp). // pull from the central tank -- the Reservoir bar never moved on damage):
// linked = resolve(linkedSinks@0x164) ; FUN_00417ab4
// return linked->DrawCoolant(requested * coolantFlowScale@0x15C)
// i.e. the draw RECURSES UP the sink linkage, scaling per hop, and terminates
// at the Reservoir's own override (@0x4af3b0: clamp to [0, coolantLevel],
// drain the tank, return the granted amount). In the binary the terminal hop
// is the bank's slot-14 override @0x4ae8b0 resolving its reservoir connection
// @0x1D8; in the port the reservoir sits in the bank's linkedSinks (the
// Reservoir-ctor Attach) and Reservoir::DrawCoolant IS the @0x4af3b0 body, so
// the same chain terminates identically (the extra bank hop multiplies by its
// coolantFlowScale, ctor-default 1.0 -- a no-op). PORT GUARD: the binary
// calls through the resolved link UNGUARDED (authored topology always links);
// an unlinked port sink supplies 0 instead of faulting.
// //
Scalar Scalar
HeatSink::DrawCoolant(Scalar /*requested*/) HeatSink::DrawCoolant(Scalar requested)
{ {
return 0.0f; HeatSink *linked = (HeatSink *)linkedSinks.Resolve(); // FUN_00417ab4(this+0x59)
requested *= coolantFlowScale; // @0x15C
if (linked == 0)
return 0.0f;
return linked->DrawCoolant(requested); // vtbl+0x38 (virtual)
} }
@@ -1278,7 +1395,54 @@ int BTSubsystemIsCondenser(::Subsystem *sub)
{ {
return (sub != 0 && sub->IsDerivedFrom(*Condenser::GetClassDerivations())) ? 1 : 0; return (sub != 0 && sub->IsDerivedFrom(*Condenser::GetClassDerivations())) ? 1 : 0;
} }
//
// #97 bridge: the leak gauge's FULL-SCALE divisor. BitMapInverseWipe computes
// level = round(fullWidth * leakRate / thirdParam)
// and the binary sources thirdParam from subsystem+0x150 (@004b8fec writes
// 0.15f there). The gauge TU cannot see the HeatSink layout, and raw-reading
// +0x150 from there would land on whatever OUR layout puts at that offset --
// the databinding trap. Resolve it through the named member instead.
//
Scalar BTHeatSinkLeakFullScale(::Subsystem *sub)
{
if (sub == 0 || !sub->IsDerivedFrom(*HeatSink::GetClassDerivations()))
return 0.15f; // the authored default
Scalar fs = ((HeatSink *)sub)->LeakGaugeFullScale();
return (fs > 0.0f) ? fs : 0.15f; // never divide by zero
}
int BTCondenserNumber(::Subsystem *sub) int BTCondenserNumber(::Subsystem *sub)
{ {
return (sub != 0) ? ((Condenser *)sub)->condenserNumber : -1; // +0x1D4 return (sub != 0) ? ((Condenser *)sub)->condenserNumber : -1; // +0x1D4
} }
//
// #83 bridge: the collision-damage distributor (mech.cpp, FUN_0049ffcc) tests
// roster members against this family's derivation chain (binary GUID 0x50e590).
// Lives here because HeatSink is a complete type only in this TU.
//
Derivation *
BTHeatSinkFamily()
{
return HeatSink::GetClassDerivations();
}
//===========================================================================//
// BTHeatSinkBankCoolantFraction -- complete-type bridge for the EJECT-
// 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)
{
if (sub == 0 || (int)sub->GetClassID() != 0xBBE)
return 0;
HeatSink *bank = (HeatSink *)sub;
*out = bank->CoolantFractionOf();
return 1;
}
+12 -2
View File
@@ -512,8 +512,11 @@ inline int
// Internal model helpers // Internal model helpers
// //
public: 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 void
UpdateHeatLoad(); // @004ad7f0 UpdateHeatLoad(Scalar time_slice = -1.0f); // @004ad7f0
void void
ClearHeatFilter(); // @004ad884 ClearHeatFilter(); // @004ad884
void void
@@ -564,7 +567,14 @@ inline int
Scalar startingTemperature; // @0x13C resource +0xE4 (saved initial temp) Scalar startingTemperature; // @0x13C resource +0xE4 (saved initial temp)
Scalar thermalConductance; // @0x140 resource +0xF0 Scalar thermalConductance; // @0x140 resource +0xF0
HeatFilter heatFilter; // @0x144 15-sample running average (12 bytes -> 0x150) HeatFilter heatFilter; // @0x144 15-sample running average (12 bytes -> 0x150)
Scalar filterDecay; // @0x150 init 0.4f 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 thermalMass; // @0x154 resource +0xF4
Scalar heatEnergy; // @0x158 init = thermalMass * startingTemperature Scalar heatEnergy; // @0x158 init = thermalMass * startingTemperature
Scalar coolantFlowScale; // @0x15C init 1.0f (== "word57") Scalar coolantFlowScale; // @0x15C init 1.0f (== "word57")
+29 -1
View File
@@ -870,6 +870,14 @@ Scalar
} }
} }
coolantLevel -= supplied; coolantLevel -= supplied;
if (getenv("BT_COOL_LOG") && supplied > 0.0f)
{
static int s_n = 0;
if ((s_n++ % 60) == 0)
DEBUG_STREAM << "[resdraw] granted=" << supplied
<< " tank=" << coolantLevel << "/" << thermalCapacity
<< "\n" << std::flush;
}
return supplied; return supplied;
} }
@@ -1203,9 +1211,29 @@ void
{ {
if (HeatModelActive()) // FUN_004ad7d4 if (HeatModelActive()) // FUN_004ad7d4
{ {
// #96 dissipation audit: the bank runs THIS Performance, not the base
// HeatSinkSimulation, so the [heatflow] census there never saw it.
// Same census here, tagged RADIATOR -- if this line never prints, the
// mech has NO ambient heat exit and everything cooks.
if (getenv("BT_HEAT_LOG"))
{
static unsigned long s_rcAt = 0;
unsigned long now_ms = GetTickCount();
if (now_ms - s_rcAt > 2000)
{
s_rcAt = now_ms;
DEBUG_STREAM << "[heatflow] RADIATOR '" << (GetName() ? GetName() : "?")
<< "' T=" << currentTemperature
<< " E=" << heatEnergy
<< " pend=" << pendingHeat
<< " k=" << thermalConductance
<< " cool=" << coolantLevel << "/" << thermalCapacity
<< std::endl;
}
}
heatEnergy += pendingHeat; // [0x56] += [0x72] heatEnergy += pendingHeat; // [0x56] += [0x72]
currentTemperature = heatEnergy / thermalMass; // [0x45] = [0x56]/[0x55] currentTemperature = heatEnergy / thermalMass; // [0x45] = [0x56]/[0x55]
UpdateHeatLoad(); // FUN_004ad7f0 UpdateHeatLoad(time_slice); // FUN_004ad7f0 (28 Hz cadence, #119)
pendingHeat = 0.0f; pendingHeat = 0.0f;
Scalar target = 300.0f Scalar target = 300.0f
+469 -9
View File
@@ -82,6 +82,7 @@
// The torso twist is reached via a BRIDGE (BTGetTorsoTwist, defined in torso.cpp) // The torso twist is reached via a BRIDGE (BTGetTorsoTwist, defined in torso.cpp)
// for the same reason. // for the same reason.
#include "dmgtable.hpp" #include "dmgtable.hpp"
#include <GAUGREND.hpp> // GaugeRenderer::SpecialEffect (virtual) -- PPC scramble trigger
extern Scalar BTGetTorsoTwist(Subsystem *torso); // torso.cpp (Torso complete there) extern Scalar BTGetTorsoTwist(Subsystem *torso); // torso.cpp (Torso complete there)
// heat-bank ambient bridge (heatfamily_reslice.cpp, AggregateHeatSink complete // heat-bank ambient bridge (heatfamily_reslice.cpp, AggregateHeatSink complete
// there) -- mech.cpp cannot include the subsystem headers (local-stub collision) // there) -- mech.cpp cannot include the subsystem headers (local-stub collision)
@@ -466,6 +467,7 @@ const Receiver::HandlerEntry
MESSAGE_ENTRY(Mech, PlayerLink), MESSAGE_ENTRY(Mech, PlayerLink),
MESSAGE_ENTRY(Mech, BalanceCoolant), // id 0x16 @0049f728 (issue #20) MESSAGE_ENTRY(Mech, BalanceCoolant), // id 0x16 @0049f728 (issue #20)
MESSAGE_ENTRY(Mech, DuckRequest), // id 0x1a @0049fa00 (CROUCH, 2026-07-26) MESSAGE_ENTRY(Mech, DuckRequest), // id 0x1a @0049fa00 (CROUCH, 2026-07-26)
MESSAGE_ENTRY(Mech, EjectPilot), // id 0x19 @0049f854 (PANIC/EJECT, 2026-08-02)
}; };
// //
@@ -513,6 +515,137 @@ void
DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1" << std::endl << std::flush; DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1" << 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 Receiver::MessageHandlerSet
Mech::MessageHandlers( Mech::MessageHandlers(
ELEMENTS(Mech::MessageHandlerEntries), ELEMENTS(Mech::MessageHandlerEntries),
@@ -765,13 +898,119 @@ void
} }
// //
// Mech override of Entity::TakeDamageMessageHandler (binary @0x4a037a, the two // @0x4a0164 -- the CRIT CHANCE roll (#80; raw-disasm 2026-07-29 -- the single
// call sites into the glue @0x49ed0c). An unaimed hit arrives with // binary caller of Mech__DamageZone::CriticalHit lives in the handler below,
// invalidDamageZone set (damageZone < 0); resolve its zone from the cylinder // and both sat in the un-exported decomp gap).
// hit-location table (mech[0x111]) using the impact point, clear the flag, then
// hand off to the base handler which routes damageZones[zone]->TakeDamage. Aimed
// (reticle) hits carry a valid zone and pass straight through.
// //
// zone = mech->damageZones[zone_index]
// if (mech->player(+0x190)->simLive(+0x25c) == 0) return 0 ; novice: no crits
// x = zone->damageLevel^2 * 0.7 + 0.01 ; dbl pool @0x4a0208/0x4a0210
// clamp x to [0.0, 1.0] ; @0x4a0218..0x4a0228
// return RandomUnit() <= x ; FUN_00408050(0x521f5c)
//
// Chance is ~1% on pristine armour, ~18.5% at half-stripped, ~58% at 90% --
// crits become likely exactly as a zone's armour fails.
//
static int
BTMechCriticalChance(Mech *mech, int zone_index)
{
extern int BTPlayerExperienceSimLive(void *owner_mech); // btplayer.cpp (+0x25c)
if (!BTPlayerExperienceSimLive(mech))
return 0;
DamageZone *zone = (DamageZone *)mech->damageZones[zone_index];
double x = (double)zone->damageLevel * (double)zone->damageLevel * 0.7 + 0.01;
if (x < 0.0) x = 0.0;
else if (x > 1.0) x = 1.0;
return (RandomUnit() <= (Scalar)x) ? 1 : 0;
}
//
// Mech override of Entity::TakeDamageMessageHandler -- the REAL binary body is
// @0x4a0230 (#80: recovered from the un-exported gap via the Mech message
// table @0x50bdf8, row {0x12, "TakeDamage", 0x4a0230}; the old "@0x4a037a"
// note pointed into its middle). An unaimed hit arrives with
// invalidDamageZone set (damageZone < 0); resolve its zone from the cylinder
// hit-location table (mech[0x111]) using the impact point, clear the flag,
// then apply the burst loop below (which supersedes the engine base's single
// application). Aimed (reticle) hits carry a valid zone and pass straight to
// the loop. Binary blocks not yet reconstructed here: the damageType==0
// COLLISION divert (@0x4a0368 -> 0x49ffcc, its own distribution path) and the
// id-0x16 damage/kill reports (see the tail comment) [T3/T4 -- decoded in
// context/combat-damage.md].
//
//
// @0049ffcc -- the COLLISION-DAMAGE DISTRIBUTOR (#83; raw disasm
// scratchpad/night6/gap_49ffcc.txt -- an export-gap fn reached only from the
// TakeDamage hub's damageType==0 divert @0x4a0368). Collision damage is the
// manual's "splash/collision damage" TECHNICIAN SETTING: gated on the owning
// player's advancedDamage copy (+0x268, @0x49ffde), priced against a
// 100 km/h reference impact, and applied as 0.5-point rattle crits to random
// INTERNAL subsystems -- armor is never touched.
// scale = (2000 / moverMass) / (100 * 1/3.6)^2 / (1 - elasticity^2)
// (tbyte @0x4a0148 = 0.2777778 = 1/3.6 km/h->u/s; 2000f @0x4a0154;
// mass @+0x20c; elasticity @+0x244 -- Mover layout walk)
// amount = raw * scale; amount < 0.5f (@0x4a015c) -> FREE (taps cost nothing)
// n = Round(amount * 2) sub-hits (@0x4a0051) of amount/n each
// each landing on ONE roster subsystem drawn by cumulative
// collisionCriticalHitWeight (@0x10C) vs a [0,1) roll, restricted to the
// HeatSink family / Gyroscope / Torso (derivation GUIDs 0x50e590 / 0x50fdc0 /
// 0x510b08 -- all MechSubsystem-based, which is why +0x10C is valid on every
// eligible entry). An un-won roll (total weight < roll) lands nowhere --
// faithful: the binary does not normalize the weights.
//
void
Mech::DistributeCollisionDamage(Damage *dmg)
{
extern int BTPlayerAdvancedDamageOn(void *player_v); // btplayer.cpp (+0x268)
void *player = GetPlayerLink(); // mech+0x190 (ENTITY.h:430)
if (player == 0 || !BTPlayerAdvancedDamageOn(player)) // @0x49ffde the technician gate
return;
const Scalar c = 100.0f * 0.27777779f; // 100 km/h in u/s
Scalar denom = 1.0f - elasticityCoefficient * elasticityCoefficient; // @0x244
Scalar scale = ((2000.0f / moverMass) / (c * c)) / denom; // @0x49ffff-0x4a0027
dmg->damageAmount *= scale; // @0x4a0030
if (dmg->damageAmount < 0.5f) // @0x4a003c: a tap is FREE
return;
int n = Round(dmg->damageAmount * 2.0f); // @0x4a0051 (FUN_004dcd94)
if (n < 1)
n = 1;
dmg->damageAmount /= (Scalar)n; // @0x4a0065
if (getenv("BT_DMG_LOG"))
DEBUG_STREAM << "[colldmg] rattle " << (dmg->damageAmount * n)
<< " pts in " << n << " sub-hits (scale=" << scale << ")"
<< std::endl << std::flush;
extern Derivation *BTHeatSinkFamily(void); // heat.cpp (0x50e590)
extern Derivation *BTGyroscopeFamily(void); // gyro.cpp (0x50fdc0)
extern Derivation *BTTorsoFamily(void); // torso.cpp (0x510b08)
for (int i = 0; i < n; ++i) // @0x4a006b
{
Scalar threshold = RandomUnit(); // @0x4a007d (FUN_00408050)
Scalar acc = 0.0f;
for (int b = 0; b < GetSubsystemCount(); ++b) // @0x4a0124 (count @+0x124)
{
Subsystem *s = GetSubsystem(b); // roster @+0x128
if (s == 0)
continue;
if (!s->IsDerivedFrom(*BTHeatSinkFamily()) // @0x4a0092
&& !s->IsDerivedFrom(*BTGyroscopeFamily()) // @0x4a00b2
&& !s->IsDerivedFrom(*BTTorsoFamily())) // @0x4a00d2
continue;
acc += ((MechSubsystem *)s)->CollisionCritWeight(); // @0x4a00f2 (+0x10C)
if (acc < threshold) // @0x4a010a
continue;
((MechSubsystem *)s)->ApplyDamageAndMeasure(*dmg); // @0x4a0117 (FUN_004ac07c)
break; // @0x4a0121 -> next sub-hit
}
}
Check_Fpu();
}
void void
Mech::TakeDamageMessageHandler(TakeDamageMessage *message) Mech::TakeDamageMessageHandler(TakeDamageMessage *message)
{ {
@@ -825,7 +1064,51 @@ void
(float)(message->damageData.impactPoint.z - localOrigin.linearPosition.z)); (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.
//
const int deathBlastArmed = !IsMechDestroyed(); // [ebp-0x10], inverted
//
// The zone the reports + VehicleDead carry: msg+0x24 as of loop entry.
// The binary never rewrites msg+0x24 after the initial cylinder resolve;
// 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!) 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).
// 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);
goto death_tail;
}
// #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"))
{
static int s_g = 0;
if (s_g++ < 40)
DEBUG_STREAM << "[cylgate] invalidZone=" << (int)message->invalidDamageZone
<< " table=" << (void *)table
<< " incomingZone=" << message->damageZone
<< " type=" << (int)message->damageData.damageType << std::endl;
}
if (message->invalidDamageZone && table != 0) if (message->invalidDamageZone && table != 0)
{ {
int zone = table->ResolveHit(message->damageData.impactPoint); int zone = table->ResolveHit(message->damageData.impactPoint);
@@ -839,7 +1122,118 @@ void
<< message->damageData.impactPoint.z << ")\n" << std::flush; << message->damageData.impactPoint.z << ")\n" << std::flush;
} }
} }
Entity::TakeDamageMessageHandler(message); // base: damageZones[zone]->TakeDamage reportZone = message->damageZone; // post-resolve (@0x4a0396 write)
//
// @0x4a03f3 [T1] -- PPC/ERPPC SECONDARY-DISPLAY SCRAMBLE. EnergyDamageType
// (==4) is authored on exactly the 14 PPC/ERPPC subsystem records and nothing
// else, so this branch is structurally PPC-exclusive -- NO explicit weapon
// class check (the data authorship IS the gate). It sits OUTSIDE the burst
// loop below, so it fires ONCE per damage message, not per burst. The
// duration is DERIVED from the type ordinal, not a literal: (Scalar)4 * 0.2 ==
// 0.8s (binary loads long double 0.2 @0x4a0c08, fmulp). SpecialEffect ->
// L4GaugeRenderer scrambles every SECONDARY cockpit display (SVGA16 sync
// detune, FunkyVideo) for that window; the main 3D view is on an independent
// timing chain and is left clean. Spec: phases/phase-14-ppc-sync-distortion.md.
//
if (message->damageData.damageType == Damage::EnergyDamageType)
{
GaugeRenderer *gauges =
(application != 0) ? application->GetGaugeRenderer() : 0; // APP.h:355 -- named, not application+0x4c
if (gauges != 0) // the binary null-guards the renderer too (@0x4a0405)
gauges->SpecialEffect(GaugeRenderer::scrambleVideo,
(Scalar)message->damageData.damageType * 0.2f);
if (BTEnvOn("BT_DMG_LOG", 0))
DEBUG_STREAM << "[ppc-scramble] EnergyDamageType hit -> scrambleVideo for "
<< ((Scalar)message->damageData.damageType * 0.2f) << "s\n" << std::flush;
}
//
// #80 -- the faithful application loop (binary @0x4a0423-0x4a04d8), which
// REPLACES the engine-base single application. Three things the base
// never did:
// 1. BURSTS: the base applied the Damage once and ignored burstCount
// entirely (DamageZone::TakeDamage never reads it) -- multi-burst
// damage under-applied by (burstCount-1)x.
// 2. PER-BURST ZONE RE-ROLL: with bursts remaining, the binary re-runs
// the cylinder lottery from the same impact point (@0x4a04b9), so a
// burst SPRAYS across zones -- authentic scatter.
// 3. THE CRIT ROLL (@0x4a0450): per burst, on the current zone -- gated
// on the zone not already burning, on the player's simLive flag
// (novice never crits), and on chance = clamp(0.7*lvl^2 + 0.01, 0..1)
// rising quadratically as the zone's armour strips. A landed crit
// REPLACES the zone application for that burst (CriticalHit @0049ccc4
// already routes half the amount through the armour internally).
//
// The engine base's -1 guard is preserved: an unresolvable zone applies
// nothing (matches ENTITY.cpp:878; the binary would deref -1 -- it can't
// happen there because every mech ships a lookup table).
//
if (damageZones != 0 && message->damageZone >= 0
&& message->damageZone < damageZoneCount)
{
int zoneIndex = message->damageZone; // local_20
Scalar damageTally = 0.0f; // local_24 (the id-0x16 report tally)
int zoneDestroyed = 0; // local_2c
int burstsLeft = message->damageData.burstCount; // local_28
if (burstsLeft < 1)
burstsLeft = 1; // port guard (binary trusts >= 1)
for (;;)
{
Mech__DamageZone *zone =
(Mech__DamageZone *)damageZones[zoneIndex]; // this[0x120][idx]
Subsystem *critted = 0;
if (zone->GetDamageZoneState() != DamageZone::BurningState // zone state != 1
&& BTMechCriticalChance(this, zoneIndex)) // @0x4a0164 (roll below)
{
critted = zone->CriticalHit(message->damageData); // @0049ccc4
if (critted != 0)
{
damageTally +=
((MechSubsystem *)critted)->CriticalScoreBonus(); // +0x108
if (BTEnvOn("BT_CRIT_LOG", 0))
DEBUG_STREAM << "[critroll] zone=" << zoneIndex
<< " -> " << (critted->GetName() ? critted->GetName() : "?")
<< " subLvl=" << ((MechSubsystem *)critted)->GetSubsystemDamageLevel()
<< "\n" << std::flush;
}
}
if (critted == 0)
zone->TakeDamage(message->damageData); // zone vtbl+0x18 @0x4a0488
damageTally += message->damageData.damageAmount; // +0x30
if (zone->GetDamageZoneState() == DamageZone::BurningState)
zoneDestroyed = 1;
if (--burstsLeft == 0)
break;
DamageLookupTable *tbl = (DamageLookupTable *)damageLookupTable;
if (tbl != 0) // per-burst re-roll
zoneIndex = tbl->ResolveHit(message->damageData.impactPoint);
if (zoneIndex < 0 || zoneIndex >= damageZoneCount)
break; // port guard
if (IsDisabled()) // @0x4a04cc -- stop once dead
break;
}
message->damageZone = zoneIndex; // matchlog sees the LAST zone
//
// 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.
//
{
extern void BTMechPostCombatReports(void *, void *, float, int, int, int);
BTMechPostCombatReports((void *)this, (void *)message,
(float)damageTally, zoneDestroyed, reportZone,
deathBlastArmed && IsMechDestroyed());
}
}
// MP MATCH FORENSICS (matchlog.hpp): the victim-side authoritative damage // MP MATCH FORENSICS (matchlog.hpp): the victim-side authoritative damage
// application -- one line per applied TakeDamage with the resolved zone // application -- one line per applied TakeDamage with the resolved zone
@@ -867,6 +1261,45 @@ void
(float)message->damageData.impactPoint.y, (float)message->damageData.impactPoint.y,
(float)message->damageData.impactPoint.z); (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, but
// the death transition sets mode 9 synchronously with the structural flag
// on every path through here, so the edges coincide; this exact predicate
// is the one the #89 blast benches verified both ways.
death_tail:
if (deathBlastArmed && IsMechDestroyed())
{
extern void BTMechPostVehicleDead(void *, void *, int);
BTMechPostVehicleDead((void *)this, (void *)message, reportZone);
extern void BTApplyDeathSplash(void *mech_v);
BTApplyDeathSplash((void *)this);
}
} }
// //
@@ -1036,6 +1469,8 @@ Mech::Mech(
mechNameFilter.Initialize(); // FUN_00435a7c(this+0xdb) mechNameFilter.Initialize(); // FUN_00435a7c(this+0xdb)
masterAlarm = AlarmIndicator(0x21); // FUN_0041b9ec(this+0xe7,0x21) masterAlarm = AlarmIndicator(0x21); // FUN_0041b9ec(this+0xe7,0x21)
rearFiring = 0; // (task #68) ORed from the weapons below 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 // (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 // "no missile" far default (attr id 56), NOT a maxSpeed -- the old member
// is retired; distanceToMissile (init below) owns the slot's meaning. // is retired; distanceToMissile (init below) owns the slot's meaning.
@@ -1551,8 +1986,11 @@ Mech::Mech(
Wword(0x68) = model->cameraOffset; // FUN_00408440(this+0x1a0, rec+0xA8) Wword(0x68) = model->cameraOffset; // FUN_00408440(this+0x1a0, rec+0xA8)
Wword(0x130) = model->deathEffectResourceID; // rec+0x74 -> mech+0x4c0 Wword(0x130) = model->deathEffectResourceID; // rec+0x74 -> mech+0x4c0
Wword(0x148) = model->deathSplashDamage; // rec+0x78 -> mech+0x520 deathSplashDamage = model->deathSplashDamage; // rec+0x78 -> mech+0x520 (#89:
Wword(0x149) = model->deathSplashRadius; // rec+0x7C -> mech+0x524 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(0x195) = Wword(0x196) = 0;
Wword(0xfd) = 0; Wword(0xfd) = 0;
masterAlarm.SetLevel(0); // FUN_0041bbd8(this+0xe7,0) masterAlarm.SetLevel(0); // FUN_0041bbd8(this+0xe7,0)
@@ -1706,6 +2144,9 @@ Mech::Mech(
ramLastVictim = 0; // ram contact-edge state ramLastVictim = 0; // ram contact-edge state
ramContactLinger = 0.0f; ramContactLinger = 0.0f;
lastInflictingDamage = 0.0f; // task #60: killing-blow magnitude (set on hit) 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 if (GroundReal() && GetCollisionVolumeCount() > 0
&& collisionTemplate != 0 && collisionVolume != 0) && collisionTemplate != 0 && collisionVolume != 0)
{ {
@@ -1949,6 +2390,9 @@ Mech::~Mech()
extern void BTDeregisterMech(Entity *m); // task #46 live-mech registry extern void BTDeregisterMech(Entity *m); // task #46 live-mech registry
BTDeregisterMech((Entity *)this); BTDeregisterMech((Entity *)this);
extern void BTProjectilesDropEntity(void *e); // #93: scrub the STATIC projectile
BTProjectilesDropEntity(this); // pool -- a round in flight must not
// outlive its target/shooter pointer
extern void BTUnstashClipState(const Mech *m); // task #59 clip-set registry extern void BTUnstashClipState(const Mech *m); // task #59 clip-set registry
BTUnstashClipState(this); BTUnstashClipState(this);
@@ -2062,9 +2506,25 @@ Logical
// airborne 3,4) tracks the master automatically; the case-0 / tail edge // airborne 3,4) tracks the master automatically; the case-0 / tail edge
// tests below then see the old->current transition. // tests below then see the old->current transition.
// //
// (#78 history: a "gimp-monotonic record guard" briefly lived here, patching
// incoming records that carried a pre-gimp simulationState. It was treating a
// symptom -- the real cause was the mech's own per-frame SetMovementMode(1) in
// PerformAndWatch erasing the gimp level from that one cell every frame, found
// with the [simstomp] trap and fixed at source (mech4.cpp). The guard is gone
// because on a REPLICANT it would have been actively harmful: the master's
// records are authoritative, so pinning 3/4 against them would have kept a
// peer's mech limping forever through a respawn.)
void void
Mech::ReadUpdateRecord(Simulation::UpdateRecord *message) 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 // RECORD-CADENCE probe (BT_RXJIT): quantify how EVENLY records arrive (wall-clock
// ms between arrivals) -- the jitter that makes the peer's corrections random. // ms between arrivals) -- the jitter that makes the peer's corrections random.
if (getenv("BT_RXJIT") && GetInstance() == ReplicantInstance) if (getenv("BT_RXJIT") && GetInstance() == ReplicantInstance)
+95 -11
View File
@@ -357,7 +357,11 @@ struct ShotDescriptor
enum ResetMode { MissionReviewReset = 0 }; // reset-mode selector (Reset arg, value 0) enum ResetMode { MissionReviewReset = 0 }; // reset-mode selector (Reset arg, value 0)
void SetMappingSubsystem(Subsystem *mapper); // btl4app.cpp:567 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 SetTargetRange(Scalar range); // writes this+0x404 (btl4mppr.cpp:407)
void Reset(const Origin &origin, int mode); // btl4pb.cpp:555 (FUN_0049fb74) 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 // binary part_012.c:9938-9940 + 9974-9975). By-name access only; declared
// after the layout-locked fields so nothing shifts. // after the layout-locked fields so nothing shifts.
Scalar standingTemplateMaxY; // binary @0x518 collisionTemplate->maxY at ctor 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 duckedTemplateMaxY; // binary @0x51c 0.6 x standing (duck preset)
Scalar templateBottomLift; // binary @0x4b8 0.05 x (volume maxX-minX) 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 // `stateFlags`): OR of every mounted weapon's rearFiring flag -- "this
// mech carries a rear arsenal". The look-back view arms those weapons. // mech carries a rear arsenal". The look-back view arms those weapons.
int rearFiring; // @0x410 this[0x104] 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, // (task #68) the authored look-view angles (model record +0x50..0x5c,
// ctor converts degrees->radians; binary @0x564..0x570 -- previously // ctor converts degrees->radians; binary @0x564..0x570 -- previously
// parked in the Wword scratch bank). Consumed by the look-state // parked in the Wword scratch bank). Consumed by the look-state
@@ -1013,14 +1032,31 @@ protected:
Scalar gimpStrideLength; // @0x350 Scalar gimpStrideLength; // @0x350
AlarmIndicator legStateAlarm; // @0x39c AlarmIndicator legStateAlarm; // @0x39c
int legAnimationState; // @0x3b0 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 gimpSpeedMax; // @0x52c
Scalar standSpeed; // @0x530 Scalar standSpeed; // @0x530
Scalar walkStrideLength; // @0x534 Scalar walkStrideLength; // @0x534
Scalar reverseSpeedMax; // @0x538 Scalar reverseSpeedMax; // @0x538
Scalar jumpRunSpeedMax; // @0x53c // GIMP (limp-gait) measurements, filled by the conditional 'wgl' loader
Scalar jumpWalkSpeedMax; // @0x540 // block (FUN_004a80d4 raw :13705-13733). "Left/Right" = WHICH LEG IS
Scalar jumpRunStrideLength; // @0x544 // GIMPED (graphicAlarm level 3 = left, 4 = right). Speeds are the wg
Scalar jumpWalkStrideLength; // @0x548 // entry clips' final keyframe strides; strides are MeasureClipStride
// over the gg cycle clips. (These four + hasGimpClips@0x580 were
// mislabeled jump* -- there is no jump-jet clip set.)
Scalar gimpLeftSpeedMax; // @0x53c wgl final stride (mode-3 speed cap)
Scalar gimpRightSpeedMax; // @0x540 wgr final stride (mode-4 speed cap)
Scalar gimpLeftStrideLength; // @0x544 ggr cycle stride (mode 3 cycles state 0x18)
Scalar gimpRightStrideLength; // @0x548 ggl cycle stride (mode 4 cycles state 0x19)
Scalar walkingTurnRate; // @0x574 (WalkingTurnRate, rad/s) -- the master Scalar walkingTurnRate; // @0x574 (WalkingTurnRate, rad/s) -- the master
Scalar runningTurnRate; // @0x578 (RunningTurnRate, rad/s) perf turn-rate lerp Scalar runningTurnRate; // @0x578 (RunningTurnRate, rad/s) perf turn-rate lerp
Scalar groundCycleRate; // @0x5b8 Scalar groundCycleRate; // @0x5b8
@@ -1035,9 +1071,9 @@ protected:
Scalar globalTimeScale; // @0x5a8 Scalar globalTimeScale; // @0x5a8
Scalar idleStrideScale; // @0x5ac Scalar idleStrideScale; // @0x5ac
Scalar gimpCycleRate; // @0x5b0 Scalar gimpCycleRate; // @0x5b0
int jumpCapable; // @0x580 int hasGimpClips; // @0x580 set iff the model ships the wg/gg/gs clip set
int hasReverseGimpSet; // @0x584 int squatCapable; // @0x584 set iff squ/sqd ship
int hasCrashSet; // @0x588 int turnCapable; // @0x588 set iff trn ships
int deathAnimationLatched; // @0x650 int deathAnimationLatched; // @0x650
int legResetLatch; // @0x654 int legResetLatch; // @0x654
int bodyResetLatch; // @0x658 int bodyResetLatch; // @0x658
@@ -1084,13 +1120,25 @@ protected:
Scalar LegTransition(int next_state, Scalar adv_time, int move_joints); Scalar LegTransition(int next_state, Scalar adv_time, int move_joints);
Scalar AdvanceLegAnimation(Scalar time_slice); // @004a5028 Scalar AdvanceLegAnimation(Scalar time_slice); // @004a5028
Scalar AdvanceBodyAnimation(Scalar time_slice, int loop); Scalar AdvanceBodyAnimation(Scalar time_slice, int loop);
Scalar AdvanceBodyAnimationAirborne(Scalar time_slice, int loop); Scalar AdvanceBodyAnimationGimp(Scalar time_slice, int loop); // @004a5bf8 (was "Airborne" -- it's the LIMP driver)
Scalar AdvanceLegAnimationAirborne(Scalar time_slice); Scalar AdvanceLegAnimationGimp(Scalar time_slice); // @004a71f4
// GIMP finished-callbacks: BodyClipFinished/LegClipFinished tail-branch
// into these when (gimpLevel==3||4) && hasGimpClips. Same transition
// machine as the normal cbs plus: a top clamp of the demand to the
// gimp speed cap, phase-correct wg entries from the walk cases
// (left-gimp enters 0x16/wgl from a RIGHT step, right-gimp 0x17/wgr
// from a LEFT step -- forcing one extra normal step if mid-wrong-foot),
// the gg cycles 0x18/0x19 at gimp cadence, and gs exits 0x1a/0x1b.
Scalar GimpBodyClipFinished(Scalar carryover, int move_joints); // FUN_004a6344
Scalar GimpLegClipFinished(Scalar carryover); // FUN_004a7970
Logical IsDisabled(); // FUN_0049fb54 Logical IsDisabled(); // FUN_0049fb54
// --- death sequence (the un-exported master-perf death branch, rebuilt // --- death sequence (the un-exported master-perf death branch, rebuilt
// from its exported consumers + the RP VTV::DeathShutdown analog) --- // from its exported consumers + the RP VTV::DeathShutdown analog) ---
Logical IsMechDestroyed(); // damage-side death flag: graphicAlarm level >= 9 Logical IsMechDestroyed(); // damage-side death flag: graphicAlarm level >= 9
void UpdateDeathState(Scalar dt); // death freeze + subsystem DeathShutdown + wreck smoke 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) ------------------------------------------- // --- clip loaders (mech3) -------------------------------------------
ResourceDescription::ResourceID * ResourceDescription::ResourceID *
@@ -1119,7 +1167,15 @@ protected:
NotationFile *model_file, const char *model_name, NotationFile *model_file, const char *model_name,
NotationFile *model_notation, NotationFile *model_notation,
const ResourceDirectories *directories); const ResourceDirectories *directories);
static SharedData * // Returns the BASE shared-data type: the subsystem blocks are
// Simulation__SharedData and only Mech's own is the derived
// Entity__SharedData (ENTITY3.h:9 -- Entity__SharedData : public
// Simulation::SharedData), so the common type of the mixed table is
// the Simulation one. Declaring this as Mech::SharedData made every
// `&<Subsystem>::DefaultData` stub in mech3.cpp mangle to a symbol
// that does not exist -- ~20 unresolved externals /FORCE silently
// resolved to garbage (reconstruction-gotchas #3).
static Simulation::SharedData *
SubsystemDefaultData(const char *type_name); SubsystemDefaultData(const char *type_name);
// --- simulation / damage (mech4) ------------------------------------ // --- simulation / damage (mech4) ------------------------------------
@@ -1174,6 +1230,11 @@ protected:
// Mech override of Entity::TakeDamageMessageHandler: resolve an unaimed // Mech override of Entity::TakeDamageMessageHandler: resolve an unaimed
// (invalidDamageZone) hit's zone via the cylinder table, then base-route. // (invalidDamageZone) hit's zone via the cylinder table, then base-route.
void TakeDamageMessageHandler(TakeDamageMessage *message); void TakeDamageMessageHandler(TakeDamageMessage *message);
// @0049ffcc (#83): the COLLISION-damage distributor the handler's
// damageType==0 divert (@0x4a0368) routes to -- prices the mover's
// kinetic figure vs a 100 km/h reference and lands 0.5-point rattle
// crits on weighted random internal subsystems. Never touches armor.
void DistributeCollisionDamage(Damage *dmg);
// Mech override of Entity::PlayerLinkMessageHandler (@0049f624): the // Mech override of Entity::PlayerLinkMessageHandler (@0049f624): the
// base resolves mech->player; BT adds the REVERSE link // base resolves mech->player; BT adds the REVERSE link
// player->playerVehicle = this on EVERY node (replicants included -- // player->playerVehicle = this on EVERY node (replicants included --
@@ -1209,6 +1270,29 @@ protected:
enum { DuckRequestMessageID = 0x1a }; enum { DuckRequestMessageID = 0x1a };
void DuckRequestMessageHandler(ReceiverDataMessageOf<int> *message); 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) ----------------------- // --- damage-routing support (mechdmg / mech4) -----------------------
// Typed access to the inherited Entity::damageZones[] (engine stores DamageZone*; // Typed access to the inherited Entity::damageZones[] (engine stores DamageZone*;
// our entries are Mech__DamageZone, populated by the Mech ctor). Defined in // our entries are Mech__DamageZone, populated by the Mech ctor). Defined in
+460 -54
View File
@@ -19,8 +19,8 @@
// //
// @004a5028 Mech::AdvanceLegAnimation (1543 bytes) // @004a5028 Mech::AdvanceLegAnimation (1543 bytes)
// @004a5678 Mech::AdvanceBodyAnimation (1333 bytes) // @004a5678 Mech::AdvanceBodyAnimation (1333 bytes)
// @004a5bf8 Mech::AdvanceBodyAnimationAirborne(1792 bytes) // @004a5bf8 Mech::AdvanceBodyAnimationGimp(1792 bytes)
// @004a71f4 Mech::AdvanceLegAnimationAirborne (1840 bytes) // @004a71f4 Mech::AdvanceLegAnimationGimp (1840 bytes)
// //
// The embedded assert path on every one of them is // The embedded assert path on every one of them is
// "d:\tesla\bt\bt\MECH2.CPP" // "d:\tesla\bt\bt\MECH2.CPP"
@@ -90,6 +90,7 @@
// //
#include <bt.hpp> #include <bt.hpp>
#include <intrin.h> // _ReturnAddress -- the #82 trn-armer trap
#include <AUDCMP.hpp> // AudioComponent -- the foot-plant step-intensity broadcast #include <AUDCMP.hpp> // AudioComponent -- the foot-plant step-intensity broadcast
#include <AUDSRC.hpp> // AudioSource -- footstep-source identification #include <AUDSRC.hpp> // AudioSource -- footstep-source identification
#include <AUDLVL.hpp> // AudioResource::GetAudioLevelOfDetail #include <AUDLVL.hpp> // AudioResource::GetAudioLevelOfDetail
@@ -180,10 +181,10 @@ enum MechAnimationState
// @0x530 standSpeed "at rest" / minimum move speed threshold // @0x530 standSpeed "at rest" / minimum move speed threshold
// @0x534 walkStrideLength forward walk/run clip length (also speed cap) // @0x534 walkStrideLength forward walk/run clip length (also speed cap)
// @0x538 reverseSpeedMax speed cap while decelerating into Reverse (?) // @0x538 reverseSpeedMax speed cap while decelerating into Reverse (?)
// @0x53c jumpRunSpeedMax airborne speed cap, movementMode==3 // @0x53c gimpLeftSpeedMax airborne speed cap, movementMode==3
// @0x540 jumpWalkSpeedMax airborne speed cap, otherwise // @0x540 gimpRightSpeedMax airborne speed cap, otherwise
// @0x544 jumpRunStrideLength airborne clip length, movementMode==3 // @0x544 gimpLeftStrideLength airborne clip length, movementMode==3
// @0x548 jumpWalkStrideLength airborne clip length, otherwise // @0x548 gimpRightStrideLength airborne clip length, otherwise
// @0x598 motionEventName (string) cleared to "" on fall/reset // @0x598 motionEventName (string) cleared to "" on fall/reset
// @0x5a4 motionEventArmed (int) reset to 0 on fall/reset // @0x5a4 motionEventArmed (int) reset to 0 on fall/reset
// @0x5a8 globalTimeScale multiplies every clip increment // @0x5a8 globalTimeScale multiplies every clip increment
@@ -219,6 +220,16 @@ enum MechAnimationState
void void
Mech::SetLegAnimation(int state) 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, ...) legAnimation.SelectSequence( // FUN_004277a8(this+0x65c, ...)
animationClips[state], // *(this+0x5cc + state*4) animationClips[state], // *(this+0x5cc + state*4)
// The real leg finished-callback PTR_LAB_0050d6f0 == FUN_004a6928 // The real leg finished-callback PTR_LAB_0050d6f0 == FUN_004a6928
@@ -250,6 +261,39 @@ void
// StateIndicator::SetState's Verify(state<stateCount). [T2] // StateIndicator::SetState's Verify(state<stateCount). [T2]
if (state >= 0 && state < 0x21) if (state >= 0 && state < 0x21)
{ {
// DIAG (BT_TRNTRAP): who arms state 4 on a REPLICANT? Module-relative
// ra (symbolize: tools/symcrash.py) -- the #82 trn-lock armer hunt.
if (state == 4 && getenv("BT_TRNTRAP")
&& GetInstance() == ReplicantInstance)
{
static int s_tt = 0;
if (s_tt++ < 60)
{
char ttbuf[96];
sprintf(ttbuf, "[trntrap] mech=%d ra=btl4+0x%lx",
(int)GetEntityID(),
(unsigned long)_ReturnAddress()
- (unsigned long)GetModuleHandleA(0));
DEBUG_STREAM << ttbuf << std::endl << std::flush;
}
}
// #78 audio-flake diag: print the LIVE indicator address once per mech
// so a session's [attrbind] ptr can be checked against it (stale-bind
// hypothesis: watchers bound to a recreated mech's dead indicator).
if (getenv("BT_AUDIO_SPATIAL")) { static int s_ai=0;
// BT_ANIMIND_CAP: the 200-print budget hid every post-1-minute state
// (two #82 investigations mis-read the silence as "never entered") --
// raise it for state-timeline diagnosis.
static int s_aiCap = -1;
if (s_aiCap < 0) { const char *c = getenv("BT_ANIMIND_CAP"); s_aiCap = (c && *c) ? atoi(c) : 200; }
if (s_ai++ < 6 || (state >= 22 && state <= 27 && s_ai < s_aiCap)
|| (s_aiCap > 200 && s_ai < s_aiCap))
DEBUG_STREAM << "[animind] mech=" << (int)GetEntityID()
<< " &animationState=" << (void*)&animationState
<< " inst=" << (int)(GetInstance() == ReplicantInstance)
<< " state=" << state
<< " audioWatchers=" << animationState.DebugAudioWatcherCount()
<< "\n" << std::flush; }
animationState.SetState(state); animationState.SetState(state);
replicantAnimationState.SetState(state); replicantAnimationState.SetState(state);
} }
@@ -313,10 +357,16 @@ Scalar
Scalar Scalar
Mech::BodyClipFinished(Mech *m, unsigned /*a2*/, Scalar carryover, int mj) Mech::BodyClipFinished(Mech *m, unsigned /*a2*/, Scalar carryover, int mj)
{ {
// airborne branch (movementMode 3/4 && jumpCapable) -> FUN_004a6344 (deferred; safe // GIMP branch (FUN_004a6d8c top): a limping mech's body transitions run the
// no-op while grounded -- the test mech never jumps). // gimp machine instead. Mode = the graphicAlarm level (the binary's real
if ((m->MovementMode() == 3 || m->MovementMode() == 4) && m->jumpCapable) // mech+0x40: 3=left-leg gimp, 4=right) read via the mechdmg bridge --
return 0.0f; // NEVER graphicAlarm directly here (AlarmIndicator ODR split, gotcha #23).
{
extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (the TU-safe read)
const int gl = BTMechGimpLevel(m);
if ((gl == 3 || gl == 4) && m->hasGimpClips)
return m->GimpBodyClipFinished(carryover, mj);
}
const Scalar fcr = m->forwardCycleRate; // 0x344 const Scalar fcr = m->forwardCycleRate; // 0x344
const Scalar gts = m->globalTimeScale; // 0x5a8 const Scalar gts = m->globalTimeScale; // 0x5a8
@@ -454,9 +504,14 @@ Scalar
Scalar Scalar
Mech::LegClipFinished(Mech *m, unsigned /*a2*/, Scalar carryover, int mj) Mech::LegClipFinished(Mech *m, unsigned /*a2*/, Scalar carryover, int mj)
{ {
// airborne branch (movementMode 3/4 && jumpCapable) -> FUN_004a7970 (deferred). // GIMP branch (FUN_004a6928 top): route a limping mech's leg transitions
if ((m->MovementMode() == 3 || m->MovementMode() == 4) && m->jumpCapable) // into the gimp machine (mode = graphicAlarm level via bridge, gotcha #23).
return 0.0f; {
extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (the TU-safe read)
const int gl = BTMechGimpLevel(m);
if ((gl == 3 || gl == 4) && m->hasGimpClips)
return m->GimpLegClipFinished(carryover);
}
// The binary reads edx = *(mech+0x128) then [edx]+0x128: subsystemArray[0] // The binary reads edx = *(mech+0x128) then [edx]+0x128: subsystemArray[0]
// (the roster's ControlsMapper slot 0) -> speedDemand; null (no mapper) // (the roster's ControlsMapper slot 0) -> speedDemand; null (no mapper)
@@ -475,6 +530,8 @@ Scalar
return 0.0f; return 0.0f;
// slot10 state 2 (0x4a6b37): SetLevel(1). // slot10 state 2 (0x4a6b37): SetLevel(1).
case 2: 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; m->legStateAlarm.SetLevel(1); return 0.0f;
// slot2 (0x4a6b21) + slot1 state 32 (0x4a6b4d) + slot9 state 4 (0x4a6d6e): SetLevel(0). // slot2 (0x4a6b21) + slot1 state 32 (0x4a6b4d) + slot9 state 4 (0x4a6d6e): SetLevel(0).
case 3: case 4: case 8: case 9: case 20: case 21: case 3: case 4: case 8: case 9: case 20: case 21:
@@ -546,6 +603,250 @@ Scalar
} }
//###########################################################################
//###########################################################################
// GimpBodyClipFinished / GimpLegClipFinished (#78 visible limp)
//
// @004a6344 (body) / @004a7970 (leg) -- the GIMP transition machines, entered
// from the normal finished-callbacks when (gimpLevel 3|4) && hasGimpClips.
// Same walk/run/reverse alternation as the normal cbs, PLUS:
// - a top clamp of the demand to the gimped leg's speed cap (body: clamps
// bodyTargetSpeed@0x6b4; leg: clamps the LIVE mapper speedDemand and
// writes it back -- the binary's own "you can't outrun a shot leg");
// - PHASE-CORRECT limp entry from the walk cases: left-gimp (mode 3) enters
// 0x16/wgl only from a RIGHT step (5/6/0xe) -- the L case forces one more
// normal R step first; right-gimp (mode 4) mirrors into 0x17/wgr from the
// L case (7/0xf). The limp always starts on the correct foot.
// - the gg cycles 0x16/0x18 (left, ggr clip, stride @0x544) and 0x17/0x19
// (right, ggl, @0x548), continuing at gimp cadence or exiting through the
// gs transitions 0x1a/0x1b when the demand dies;
// - NO standing->reverse entry exists in the gimp machines (or drivers):
// the binary itself refuses REVERSE while gimped.
// Mode is the graphicAlarm level via the mechdmg bridge (gotcha #23).
//###########################################################################
//###########################################################################
Scalar
Mech::GimpBodyClipFinished(Scalar carryover, int mj)
{
extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (TU-safe)
const int mode = BTMechGimpLevel(this); // binary: this+0x40
const int state = bodyAnimationState; // 0x728
const Scalar fcr = forwardCycleRate; // 0x344
const Scalar gts = globalTimeScale; // 0x5a8
// Top clamp (0x4a6352): while in a moving cycle, cap the body target speed
// to the gimped side's entry-clip speed, then floor at zero.
if ((unsigned)(state - 6) < 2 || (unsigned)(state - 0x0c) < 2
|| (unsigned)(state - 0x12) < 2)
{
const Scalar cap = (mode == 3) ? gimpLeftSpeedMax : gimpRightSpeedMax; // 0x53c / 0x540
if (bodyTargetSpeed > cap) bodyTargetSpeed = cap;
if (bodyTargetSpeed < ZeroSpeed) bodyTargetSpeed = ZeroSpeed;
}
const Scalar cyc = bodyCycleSpeed; // 0x6b8
const Scalar tgt = bodyTargetSpeed; // 0x6b4 (post-clamp)
const Scalar T = carryover * gts; // plain end-tail time
int next;
switch (state)
{
default: // 0/1 + unmapped: parked
return 0.0f;
case 2:
bodyStateAlarm.SetLevel(1); return 0.0f;
case 3: case 4: case 8: case 9: case 0x14: case 0x15:
case 0x1a: case 0x1b: case 0x20:
bodyStateAlarm.SetLevel(0); return 0.0f;
// -- walk-R (0x4a6440): states 5,6,0xe -- the left-gimp entry point --
case 5: case 6: case 0xe:
{
bool cont = (tgt >= standSpeed) || ((cyc - fcr * carryover) >= standSpeed);
if (!cont) { next = 9; break; } // walk->stand L
bool up = (tgt > walkStrideLength) && ((fcr * carryover + cyc) > walkStrideLength);
if (up) { next = 0xb; break; } // toward run (dead once clamped)
int alt = 7; // normal alternation -> wwl
if (mode == 4) alt = 7; // right-gimp: one more normal step
if (mode == 3) alt = 0x16; // left-gimp: enter wgl on this R step
return BodyTransition(alt, carryover * cyc * gts / walkStrideLength, mj); // LAB_004a6505
}
// -- walk-L (0x4a63ff): states 7,0xf -- the right-gimp entry point --
case 7: case 0xf:
{
bool cont = (tgt >= standSpeed) || ((cyc - fcr * carryover) >= standSpeed);
if (!cont) { next = 8; break; } // walk->stand R
bool up = (tgt > walkStrideLength) && ((fcr * carryover + cyc) > walkStrideLength);
if (up) { next = 0xa; break; }
int alt = 6; // normal alternation -> wwr
if (mode == 3) alt = 6; // left-gimp: force back to the R step
if (mode == 4) alt = 0x17; // right-gimp: enter wgr on this L step
return BodyTransition(alt, carryover * cyc * gts / walkStrideLength, mj);
}
// -- run cycles (0x4a65f7 / 0x4a6663): 10/12 <-> 11/13 --
case 0xa: case 0xc:
{
bool cont = (tgt >= reverseSpeedMax) || ((cyc - fcr * carryover) >= reverseSpeedMax);
if (cont) // LAB_004a6648 run-cadence tail
return BodyTransition(0xd, carryover * cyc * gts / reverseStrideLength, mj);
next = 0xf; break; // run->walk R
}
case 0xb: case 0xd:
{
bool cont = (tgt >= reverseSpeedMax) || ((cyc - fcr * carryover) >= reverseSpeedMax);
if (cont)
return BodyTransition(0xc, carryover * cyc * gts / reverseStrideLength, mj);
next = 0xe; break;
}
// -- back cycles (0x4a66d5 / 0x4a6754): 0x10/0x12 <-> 0x11/0x13 --
case 0x10: case 0x12:
{
bool up = (tgt > gimpSpeedMax) && ((gimpCycleRate * carryover + cyc) > gimpSpeedMax);
if (up) { next = 0x15; break; } // back->stand L
Scalar t = carryover * cyc * gts / gimpStrideLength;
if (t <= 0.0f) t = -t; // 0x350 stored negative
return BodyTransition(0x13, t, mj);
}
case 0x11: case 0x13:
{
bool up = (tgt > gimpSpeedMax) && ((gimpCycleRate * carryover + cyc) > gimpSpeedMax);
if (up) { next = 0x14; break; }
Scalar t = carryover * cyc * gts / gimpStrideLength;
if (t <= 0.0f) t = -t;
return BodyTransition(0x12, t, mj);
}
// -- GIMP cycles (0x4a67cf / 0x4a6836): left 0x16/0x18, right 0x17/0x19 --
case 0x16: case 0x18:
{
bool cont = (tgt >= gimpLeftSpeedMax) || ((cyc - fcr * carryover) >= gimpLeftSpeedMax);
if (cont) // keep limping (ggr cycle)
return BodyTransition(0x18, carryover * cyc * gts / gimpLeftStrideLength, mj);
next = 0x1a; break; // demand died -> gsl exit
}
case 0x17: case 0x19:
{
bool cont = (tgt >= gimpRightSpeedMax) || ((cyc - fcr * carryover) >= gimpRightSpeedMax);
if (cont)
return BodyTransition(0x19, carryover * cyc * gts / gimpRightStrideLength, mj);
next = 0x1b; break; // -> gsr exit
}
}
return BodyTransition(next, T, mj); // shared plain tail
}
Scalar
Mech::GimpLegClipFinished(Scalar carryover)
{
extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (TU-safe)
const int mode = BTMechGimpLevel(this); // binary: this+0x40
const int state = legAnimationState; // 0x3b0
MechControlsMapper *mppr = MappingMapper(); // **(this+0x128)
// Top clamp (0x4a7995): cap the LIVE COMMANDED speedDemand itself while in
// a moving cycle, writing the clamp back -- this is the binary's authentic
// gimp slowdown (the mapper re-derives demand each tick; this cb re-caps
// it every clip end, and the gimp cycle cadence below enforces it anyway).
if (((unsigned)(state - 6) < 2 || (unsigned)(state - 0x0c) < 2
|| (unsigned)(state - 0x12) < 2) && mppr != 0)
{
const Scalar cap = (mode == 3) ? gimpLeftSpeedMax : gimpRightSpeedMax; // 0x53c / 0x540
if (mppr->speedDemand > cap) mppr->speedDemand = cap;
if (mppr->speedDemand < ZeroSpeed) mppr->speedDemand = ZeroSpeed;
}
const Scalar spd = (mppr != 0) ? mppr->speedDemand : 0.0f; // (binary derefs; port guards null)
const Scalar fcr = forwardCycleRate; // 0x344
const Scalar gts = globalTimeScale; // 0x5a8
const Scalar cyc = legCycleSpeed; // 0x348
const Scalar T = carryover * gts;
int next;
switch (state) // all leg tails mj=1 (binary)
{
default:
return 0.0f;
case 2:
legStateAlarm.SetLevel(1); return 0.0f;
case 3: case 4: case 8: case 9: case 0x14: case 0x15:
case 0x1a: case 0x1b: case 0x20:
legStateAlarm.SetLevel(0); return 0.0f;
// -- walk-R (0x4a7a52): states 5,6,0xe -- left-gimp entry --
case 5: case 6: case 0xe:
{
bool cont = (spd >= standSpeed) || ((cyc - fcr * carryover) >= standSpeed);
if (!cont) { next = 9; break; }
bool up = (spd > walkStrideLength) && ((fcr * carryover + cyc) > walkStrideLength);
if (up) { next = 0xb; break; }
int alt = 7;
if (mode == 4) alt = 7; // right-gimp: one more normal step
if (mode == 3) alt = 0x16; // left-gimp: enter wgl on this R step
return LegTransition(alt, carryover * cyc * gts / walkStrideLength, 1); // LAB_004a7b38
}
// -- walk-L (0x4a7bb5): states 7,0xf -- right-gimp entry --
case 7: case 0xf:
{
bool cont = (spd >= standSpeed) || ((cyc - fcr * carryover) >= standSpeed);
if (!cont) { next = 8; break; }
bool up = (spd > walkStrideLength) && ((fcr * carryover + cyc) > walkStrideLength);
if (up) { next = 0xa; break; }
int alt = 6;
if (mode == 3) alt = 6; // left-gimp: force back to the R step
if (mode == 4) alt = 0x17; // right-gimp: enter wgr on this L step
return LegTransition(alt, carryover * cyc * gts / walkStrideLength, 1);
}
// -- run cycles (0x4a7c33 / 0x4a7c93): --
case 0xa: case 0xc:
{
bool cont = (spd >= reverseSpeedMax) || ((cyc - fcr * carryover) >= reverseSpeedMax);
if (cont) // LAB_004a7c79
return LegTransition(0xd, carryover * cyc * gts / reverseStrideLength, 1);
next = 0xf; break;
}
case 0xb: case 0xd:
{
bool cont = (spd >= reverseSpeedMax) || ((cyc - fcr * carryover) >= reverseSpeedMax);
if (cont)
return LegTransition(0xc, carryover * cyc * gts / reverseStrideLength, 1);
next = 0xe; break;
}
// -- back cycles: 0x10/0x12 <-> 0x11/0x13 --
case 0x10: case 0x12:
{
bool up = (spd > gimpSpeedMax) && ((gimpCycleRate * carryover + cyc) > gimpSpeedMax);
if (up) { next = 0x15; break; }
Scalar t = carryover * cyc * gts / gimpStrideLength;
if (t <= 0.0f) t = -t;
return LegTransition(0x13, t, 1);
}
case 0x11: case 0x13:
{
bool up = (spd > gimpSpeedMax) && ((gimpCycleRate * carryover + cyc) > gimpSpeedMax);
if (up) { next = 0x14; break; }
Scalar t = carryover * cyc * gts / gimpStrideLength;
if (t <= 0.0f) t = -t;
return LegTransition(0x12, t, 1);
}
// -- GIMP cycles: left 0x16/0x18, right 0x17/0x19 --
case 0x16: case 0x18:
{
bool cont = (spd >= gimpLeftSpeedMax) || ((cyc - fcr * carryover) >= gimpLeftSpeedMax);
if (cont)
return LegTransition(0x18, carryover * cyc * gts / gimpLeftStrideLength, 1);
next = 0x1a; break;
}
case 0x17: case 0x19:
{
bool cont = (spd >= gimpRightSpeedMax) || ((cyc - fcr * carryover) >= gimpRightSpeedMax);
if (cont)
return LegTransition(0x19, carryover * cyc * gts / gimpRightStrideLength, 1);
next = 0x1b; break;
}
}
return LegTransition(next, T, 1); // shared plain tail (mj=1)
}
//########################################################################### //###########################################################################
//########################################################################### //###########################################################################
// AdvanceLegAnimation (channel A, ground) // AdvanceLegAnimation (channel A, ground)
@@ -567,8 +868,35 @@ Scalar
// alias (an AV); controlsMapper is the typed mirror of roster slot 0. A mech // alias (an AV); controlsMapper is the typed mirror of roster slot 0. A mech
// with no mapper reads demand 0 (idles) -- matching a zeroed binary roster. // with no mapper reads demand 0 (idles) -- matching a zeroed binary roster.
MechControlsMapper *mppr = MappingMapper(); // roster slot 0 (task #7) MechControlsMapper *mppr = MappingMapper(); // roster slot 0 (task #7)
// REPLICANT DEMAND FEED (#82 final root, 2026-07-30): a replicant's LOCAL
// mapper cell is a dead 0 (input never drives it), so every demand
// threshold in this machine failed on peers -- from STAND the walk-begin
// could never fire and the trn state churned on the turn signal forever
// ("lifting legs in an alternating turning fashion"). It never mattered
// while a peer was mid-cycle (clip-end chains keep cycles going, which is
// why straight-line/grass limpers replicated fine) -- it bit the moment a
// KNOCKDOWN recovery dropped the replicant to STAND. The binary replicant
// reads a LIVE demand here (its mapper cell replicates with the subsystem
// records); the port's replicated equivalent is bodyTargetSpeed (@0x6b4 --
// every update record's speedDemand writes it on RX). [gimpfeed]-probe
// proof: AdvanceLegAnimationGimp never even runs on replicants, so THIS
// driver is the peer's one and only leg machine.
Scalar commandedSpeed = Scalar commandedSpeed =
(mppr != 0) ? mppr->speedDemand : 0.0f; (GetInstance() == ReplicantInstance) ? bodyTargetSpeed
: (mppr != 0) ? mppr->speedDemand : 0.0f;
// DIAG (BT_GIMPFEED): 1 Hz -- the NORMAL driver is the only leg machine a
// replicant runs; print every threshold operand it sees.
if (getenv("BT_GIMPFEED") && GetInstance() == ReplicantInstance)
{
static Scalar s_gfAcc = 0.0f; s_gfAcc += time_slice;
if (s_gfAcc >= 1.0f) { s_gfAcc = 0.0f;
DEBUG_STREAM << "[gimpfeed] cmd=" << commandedSpeed
<< " bts=" << bodyTargetSpeed
<< " standSpeed=" << standSpeed
<< " state=" << (int)legStateAlarm.GetLevel()
<< " gl=" << ([](Mech *m){ extern int BTMechGimpLevel(void*); return BTMechGimpLevel(m); })(this)
<< std::endl << std::flush; }
}
Scalar distance = 0.0f; Scalar distance = 0.0f;
// binary: legAnimationState@0x3b0 IS legStateAlarm's level (one field; the // binary: legAnimationState@0x3b0 IS legStateAlarm's level (one field; the
@@ -664,7 +992,7 @@ Scalar
// narrow near-zero entry gate (the pre-#64b accommodation). [T3] // narrow near-zero entry gate (the pre-#64b accommodation). [T3]
const int trnIsRepl = (GetInstance() == ReplicantInstance); const int trnIsRepl = (GetInstance() == ReplicantInstance);
const Scalar trnEntryMax = trnIsRepl ? standSpeed * 0.25f : standSpeed; const Scalar trnEntryMax = trnIsRepl ? standSpeed * 0.25f : standSpeed;
if (hasCrashSet != 0 && mppr != 0 if (turnCapable != 0 && mppr != 0
&& commandedSpeed >= ZeroSpeed && commandedSpeed <= trnEntryMax && commandedSpeed >= ZeroSpeed && commandedSpeed <= trnEntryMax
&& (mppr->turnDemand > 0.05f && (mppr->turnDemand > 0.05f
|| mppr->turnDemand < -0.05f) || mppr->turnDemand < -0.05f)
@@ -985,7 +1313,15 @@ Scalar
// re-enter walk on the same frame. // re-enter walk on the same frame.
{ {
MechControlsMapper *bm = MappingMapper(); MechControlsMapper *bm = MappingMapper();
const Scalar bspd = (bm != 0) ? bm->speedDemand : 0.0f; // REPLICANT: the local mapper's speedDemand is a dead cell (nothing
// writes it on a peer -- reads 0 forever, so a peer that entered trn
// could NEVER take the speed exit = the #82 trn-lock). The peer's
// commanded speed is the REPLICATED demand (bodyTargetSpeed@0x6b4,
// stamped by every record RX) -- same source the peer Standing case
// walks on, so entry and exit judge the same number.
const Scalar bspd = (GetInstance() == ReplicantInstance)
? bodyTargetSpeed
: (bm != 0) ? bm->speedDemand : 0.0f;
if (standSpeed < bspd || bspd < ZeroSpeed) // walk / reverse (leg-symmetric) if (standSpeed < bspd || bspd < ZeroSpeed) // walk / reverse (leg-symmetric)
{ {
bodyStateAlarm.SetLevel(0); bodyStateAlarm.SetLevel(0);
@@ -1112,38 +1448,47 @@ Scalar
//########################################################################### //###########################################################################
//########################################################################### //###########################################################################
// AdvanceBodyAnimationAirborne (channel B, jump-capable) // AdvanceBodyAnimationGimp (channel B, limping)
// //
// @004a5bf8 (MECH2.CPP:0x2E8) // @004a5bf8 (MECH2.CPP:0x2E8)
// //
// Airborne variant of AdvanceBodyAnimation selected by Mech::IntegrateMotion // GIMP (limp-gait) variant of AdvanceBodyAnimation, selected when
// when jump jets are active. Adds a pre-clamp of bodyTargetSpeed to the jump // (gimpLevel 3|4) && hasGimpClips ("Airborne"/jump-jet was a misread -- the
// speed limits and handles the FallForward / FallBackward jump cycles // clip set is wg/gg/gs, #78). Adds a pre-clamp of bodyTargetSpeed to the
// (states 0x18/0x19); gimp-to-stand (0x16/0x17) and the lateral falls // gimped side's speed cap and drives the gg limp cycles (0x18 left / 0x19
// (0x1a/0x1b) join the normal advance group here rather than resetting. // right) at gimp cadence; the wg entries (0x16/0x17) and gs exits (0x1a/0x1b)
// join the plain advance group. Standing (case 0) only ever enters FORWARD
// walk -- the binary refuses reverse while gimped.
//########################################################################### //###########################################################################
//########################################################################### //###########################################################################
Scalar Scalar
Mech::AdvanceBodyAnimationAirborne(Scalar time_slice, int loop) Mech::AdvanceBodyAnimationGimp(Scalar time_slice, int loop)
{ {
Scalar distance = 0.0f; Scalar distance = 0.0f;
// RE-SYNC alarm -> state member (the binary's one cell is split in the
// recon, same as the ground drivers at :831/:1181). Without this the
// member freezes at its pre-gimp value the moment this driver takes over
// and the whole machine pins in one state (live-diagnosed 2026-07-30).
bodyAnimationState = (int)bodyStateAlarm.GetLevel();
// //
// While in any forward/reverse/gimp *moving* cycle, clamp the target to // While in any forward/reverse/gimp *moving* cycle, clamp the target to
// the jump speed limit for the current gait, then floor at zero. // the gimp speed limit for the current gait, then floor at zero.
// //
{ {
int state = bodyAnimationState; // this+0x728 int state = bodyAnimationState; // this+0x728
if ((unsigned)(state - 6) < 2 || (unsigned)(state - 0x0c) < 2 if ((unsigned)(state - 6) < 2 || (unsigned)(state - 0x0c) < 2
|| (unsigned)(state - 0x12) < 2) || (unsigned)(state - 0x12) < 2)
{ {
if (MovementMode() == 3) // run jump extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (gotcha #23)
if (BTMechGimpLevel(this) == 3) // left leg gimped
{ {
if (bodyTargetSpeed > jumpRunSpeedMax) bodyTargetSpeed = jumpRunSpeedMax; // 0x53c if (bodyTargetSpeed > gimpLeftSpeedMax) bodyTargetSpeed = gimpLeftSpeedMax; // 0x53c
} }
else // walk jump else // walk jump
{ {
if (bodyTargetSpeed > jumpWalkSpeedMax) bodyTargetSpeed = jumpWalkSpeedMax; // 0x540 if (bodyTargetSpeed > gimpRightSpeedMax) bodyTargetSpeed = gimpRightSpeedMax; // 0x540
} }
if (bodyTargetSpeed < ZeroSpeed) bodyTargetSpeed = ZeroSpeed; if (bodyTargetSpeed < ZeroSpeed) bodyTargetSpeed = ZeroSpeed;
} }
@@ -1259,7 +1604,8 @@ Scalar
case 0x18: case 0x19: // FallForward / FallBackward (jump) case 0x18: case 0x19: // FallForward / FallBackward (jump)
{ {
Scalar ratio; Scalar ratio;
if (MovementMode() == 3) // run jump: caps 0x53c / 0x544 extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (gotcha #23)
if (BTMechGimpLevel(this) == 3) // left-gimp cycle: caps 0x53c / 0x544
{ {
if (bodyTargetSpeed <= bodyCycleSpeed) if (bodyTargetSpeed <= bodyCycleSpeed)
{ {
@@ -1267,16 +1613,16 @@ Scalar
{ {
bodyCycleSpeed -= forwardCycleRate * time_slice; bodyCycleSpeed -= forwardCycleRate * time_slice;
if (bodyCycleSpeed < bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed; if (bodyCycleSpeed < bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
if (bodyCycleSpeed < jumpRunSpeedMax) bodyCycleSpeed = jumpRunSpeedMax; // 0x53c if (bodyCycleSpeed < gimpLeftSpeedMax) bodyCycleSpeed = gimpLeftSpeedMax; // 0x53c
} }
} }
else else
{ {
bodyCycleSpeed += forwardCycleRate * time_slice; bodyCycleSpeed += forwardCycleRate * time_slice;
if (bodyCycleSpeed > bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed; if (bodyCycleSpeed > bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
if (bodyCycleSpeed > jumpRunStrideLength) bodyCycleSpeed = jumpRunStrideLength; // 0x544 if (bodyCycleSpeed > gimpLeftStrideLength) bodyCycleSpeed = gimpLeftStrideLength; // 0x544
} }
ratio = bodyCycleSpeed / jumpRunStrideLength; // 0x544 ratio = bodyCycleSpeed / gimpLeftStrideLength; // 0x544
} }
else // walk jump: caps 0x540 / 0x548 else // walk jump: caps 0x540 / 0x548
{ {
@@ -1286,16 +1632,16 @@ Scalar
{ {
bodyCycleSpeed -= forwardCycleRate * time_slice; bodyCycleSpeed -= forwardCycleRate * time_slice;
if (bodyCycleSpeed < bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed; if (bodyCycleSpeed < bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
if (bodyCycleSpeed < jumpWalkSpeedMax) bodyCycleSpeed = jumpWalkSpeedMax; // 0x540 if (bodyCycleSpeed < gimpRightSpeedMax) bodyCycleSpeed = gimpRightSpeedMax; // 0x540
} }
} }
else else
{ {
bodyCycleSpeed += forwardCycleRate * time_slice; bodyCycleSpeed += forwardCycleRate * time_slice;
if (bodyCycleSpeed > bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed; if (bodyCycleSpeed > bodyTargetSpeed) bodyCycleSpeed = bodyTargetSpeed;
if (bodyCycleSpeed > jumpWalkStrideLength) bodyCycleSpeed = jumpWalkStrideLength; // 0x548 if (bodyCycleSpeed > gimpRightStrideLength) bodyCycleSpeed = gimpRightStrideLength; // 0x548
} }
ratio = bodyCycleSpeed / jumpWalkStrideLength; // 0x548 ratio = bodyCycleSpeed / gimpRightStrideLength; // 0x548
} }
distance = bodyAnimation.Advance( distance = bodyAnimation.Advance(
time_slice * ratio * globalTimeScale, loop); time_slice * ratio * globalTimeScale, loop);
@@ -1315,24 +1661,62 @@ Scalar
//########################################################################### //###########################################################################
//########################################################################### //###########################################################################
// AdvanceLegAnimationAirborne (channel A, jump-capable) // AdvanceLegAnimationGimp (channel A, limping)
// //
// @004a71f4 (MECH2.CPP:0x672) // @004a71f4 (MECH2.CPP:0x672)
// //
// Airborne variant of AdvanceLegAnimation. Like the ground version it reads // GIMP variant of AdvanceLegAnimation (see the body variant's note). Like
// the live commanded speed from the controls subsystem, but here it also // the ground version it reads the live commanded speed from the controls
// CLAMPS that source value to the jump speed limit (writing it back), and // subsystem, but here it also CLAMPS that source value to the gimped side's
// handles the FallForward / FallBackward jump cycles (0x18/0x19). No death // speed cap (writing it back -- the authentic "can't outrun a shot leg"),
// latch / footstep block; gimp-to-stand and lateral falls join the normal // and drives the gg limp cycles (0x18/0x19). No death latch / footstep
// advance group. // block; wg entries and gs exits join the normal advance group.
//########################################################################### //###########################################################################
//########################################################################### //###########################################################################
Scalar Scalar
Mech::AdvanceLegAnimationAirborne(Scalar time_slice) Mech::AdvanceLegAnimationGimp(Scalar time_slice)
{ {
ReconMotionSource *motionSource = *(ReconMotionSource **)(this->controlSource); // **(this+0x128) // Binary: **(this+0x128) + 0x128 = the mapper's live speedDemand. The
// port's controlSource@0x128 is NOT wired (null -> the first live gimp
// engagement crashed here, 2026-07-30 bench) -- use the roster idiom the
// ground driver + LegClipFinished use (MappingMapper), pointing the shim
// straight at the speedDemand cell.
MechControlsMapper *gimpMppr = MappingMapper();
static Scalar s_nullDemand = 0.0f; // no mapper -> demand 0, writes inert
// REPLICANT DEMAND FEED (#82 final root, 2026-07-30): a replicant's LOCAL
// mapper demand is a dead 0 (no input ever drives it), so every speed
// threshold in this machine read 0 on peers -- in particular the trn exit
// (standSpeed < commandedSpeed) could NEVER fire, and a gimped replicant
// that staggered through stand->trn was LOCKED in turn-in-place forever
// (the observed "lifting legs in an alternating turning fashion" skate;
// unblinded [animind] timeline: SIX transitions in 3 minutes, ending at
// state 4). The binary's replicant reads a LIVE demand here because the
// mapper's speedDemand cell replicates with the subsystem records; the
// port's replicated equivalent of that same value is bodyTargetSpeed
// (@0x6b4 -- every update record's speedDemand writes it on RX). Feed
// the machine from it on replicants; the gimp caps that write back
// through the source are transient there (the next record rewrites it),
// and masters keep the authentic live mapper cell.
ReconMotionSource *motionSource =
(GetInstance() == ReplicantInstance)
? (ReconMotionSource *)&bodyTargetSpeed
: gimpMppr
? (ReconMotionSource *)&gimpMppr->speedDemand
: (ReconMotionSource *)&s_nullDemand;
Scalar distance = 0.0f; Scalar distance = 0.0f;
int mode = MovementMode(); // this+0x40 = simulationState extern int BTMechGimpLevel(void *mech_v); // mechdmg.cpp (gotcha #23)
int mode = BTMechGimpLevel(this); // binary this+0x40 = gimp level
// DIAG (BT_GIMPFEED): 1 Hz -- what demand does this machine actually see?
if (getenv("BT_GIMPFEED") && GetInstance() == ReplicantInstance)
{
static Scalar s_gfAcc = 0.0f; s_gfAcc += time_slice;
if (s_gfAcc >= 1.0f) { s_gfAcc = 0.0f;
DEBUG_STREAM << "[gimpfeed] repl cmd=" << motionSource->commandedSpeed
<< " bts=" << bodyTargetSpeed << " state=" << legAnimationState
<< " standSpeed=" << standSpeed << " mode=" << mode << std::endl << std::flush; }
}
// RE-SYNC alarm -> state member (see AdvanceBodyAnimationGimp note).
legAnimationState = (int)legStateAlarm.GetLevel();
int state = legAnimationState; // this+0x3b0 int state = legAnimationState; // this+0x3b0
// //
@@ -1344,13 +1728,13 @@ Scalar
{ {
if (mode == 3) if (mode == 3)
{ {
if (motionSource->commandedSpeed > jumpRunSpeedMax) if (motionSource->commandedSpeed > gimpLeftSpeedMax)
motionSource->commandedSpeed = jumpRunSpeedMax; // 0x53c motionSource->commandedSpeed = gimpLeftSpeedMax; // 0x53c
} }
else else
{ {
if (motionSource->commandedSpeed > jumpWalkSpeedMax) if (motionSource->commandedSpeed > gimpRightSpeedMax)
motionSource->commandedSpeed = jumpWalkSpeedMax; // 0x540 motionSource->commandedSpeed = gimpRightSpeedMax; // 0x540
} }
if (motionSource->commandedSpeed < ZeroSpeed) if (motionSource->commandedSpeed < ZeroSpeed)
motionSource->commandedSpeed = ZeroSpeed; motionSource->commandedSpeed = ZeroSpeed;
@@ -1375,6 +1759,28 @@ Scalar
} }
if (motionSource->commandedSpeed <= standSpeed) // +0x128 <= 0x530 if (motionSource->commandedSpeed <= standSpeed) // +0x128 <= 0x530
{ {
// TURN-IN-PLACE while GIMPED (#78 field find, 2026-07-29: "rotating
// statue"). The binary's trn dispatcher (FUN_004a9b5c, master perf)
// runs OUTSIDE the driver selection, so it arms the turn step for
// gimped mechs too -- 71f4's case 4 exists to advance it. The port
// relocated that dispatcher into the NORMAL driver's Standing case,
// which stops running the moment this driver takes over. Mirror it
// (same gates + lockstep body arm; no reverse entry here -- the gimp
// machines refuse reverse).
const int trnIsRepl2 = (GetInstance() == ReplicantInstance);
const Scalar trnEntryMax2 = trnIsRepl2 ? standSpeed * 0.25f : standSpeed;
if (turnCapable != 0 && gimpMppr != 0
&& motionSource->commandedSpeed >= ZeroSpeed
&& motionSource->commandedSpeed <= trnEntryMax2
&& (gimpMppr->turnDemand > 0.05f
|| gimpMppr->turnDemand < -0.05f)
&& (trnIsRepl2 || bodyAnimationState == StandingAnimation))
{
SetLegAnimation(4); // trn, channel A
if (!trnIsRepl2)
SetBodyAnimation(4); // lockstep, masters
goto advance_normally;
}
distance = 0.0f; distance = 0.0f;
break; break;
} }
@@ -1477,16 +1883,16 @@ Scalar
{ {
legCycleSpeed -= forwardCycleRate * time_slice; legCycleSpeed -= forwardCycleRate * time_slice;
if (legCycleSpeed < motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed; if (legCycleSpeed < motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed;
if (legCycleSpeed < jumpRunSpeedMax) legCycleSpeed = jumpRunSpeedMax; if (legCycleSpeed < gimpLeftSpeedMax) legCycleSpeed = gimpLeftSpeedMax;
} }
} }
else else
{ {
legCycleSpeed += forwardCycleRate * time_slice; legCycleSpeed += forwardCycleRate * time_slice;
if (legCycleSpeed > motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed; if (legCycleSpeed > motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed;
if (legCycleSpeed > jumpRunStrideLength) legCycleSpeed = jumpRunStrideLength; if (legCycleSpeed > gimpLeftStrideLength) legCycleSpeed = gimpLeftStrideLength;
} }
ratio = legCycleSpeed / jumpRunStrideLength; // 0x544 ratio = legCycleSpeed / gimpLeftStrideLength; // 0x544
} }
else // walk jump else // walk jump
{ {
@@ -1496,16 +1902,16 @@ Scalar
{ {
legCycleSpeed -= forwardCycleRate * time_slice; legCycleSpeed -= forwardCycleRate * time_slice;
if (legCycleSpeed < motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed; if (legCycleSpeed < motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed;
if (legCycleSpeed < jumpWalkSpeedMax) legCycleSpeed = jumpWalkSpeedMax; if (legCycleSpeed < gimpRightSpeedMax) legCycleSpeed = gimpRightSpeedMax;
} }
} }
else else
{ {
legCycleSpeed += forwardCycleRate * time_slice; legCycleSpeed += forwardCycleRate * time_slice;
if (legCycleSpeed > motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed; if (legCycleSpeed > motionSource->commandedSpeed) legCycleSpeed = motionSource->commandedSpeed;
if (legCycleSpeed > jumpWalkStrideLength) legCycleSpeed = jumpWalkStrideLength; if (legCycleSpeed > gimpRightStrideLength) legCycleSpeed = gimpRightStrideLength;
} }
ratio = legCycleSpeed / jumpWalkStrideLength; // 0x548 ratio = legCycleSpeed / gimpRightStrideLength; // 0x548
} }
distance = legAnimation.Advance(time_slice * ratio * globalTimeScale, 1); distance = legAnimation.Advance(time_slice * ratio * globalTimeScale, 1);
} }

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