Found while sweeping the shipped launchers for the PATH-shadowing bug class.
Previously recorded ONLY as row 10 of docs/INPUT_PATH_AUDIT.md and never
filed, so it was invisible on the tracker.
joyconfig.bat sets BOTH `BT_JOYCONFIG=1` and `BT_FE_SOLO=1`, so after the
wizard writes bindings.txt the boot continues into the FRONT END, which hands
off by CreateProcessW()ing the next generation with the CURRENT environment.
That clear-list (btl4console.cpp) covers only BT_FE_EGG/PODS/SECS/LOOP --
BT_JOYCONFIG is never cleared -- so the handed-off generation re-entered
BTJoyConfigWizard() and blocked on _getch() BEHIND the 3D window: an
unrecoverable hang, hitting exactly the players most likely to run that bat
(flight stick / HOTAS / pedals).
Fix: SetEnvironmentVariableA("BT_JOYCONFIG", NULL) immediately after the
wizard returns. Cleared in the WIN32 environment (what CreateProcess
inherits, matching the console's own pattern) rather than the CRT copy, so
exactly one generation ever runs it. Chosen over extending the console's
clear-list because it also covers any future relaunch path.
NOT YET REBUILT: the exe was locked by a live play session when this landed.
Must be rebuilt + verified (run joyconfig.bat, finish the wizard, confirm no
second "=== BattleTech joystick setup ===" banner and that the game proceeds
into the menu) before the next zip is cut.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
37 KiB
Input Path Audit — findings + fix triage
Status: PLANNED. No code changed. Written 2026-07-24 from a read-only 13-agent audit
(6 tracers -> adversarial verifiers -> triage). 206 input paths traced end to end and
cross-checked against 2,294 streamed control-mapping records decoded from all 18 mechs in
content/BTL4.RES, plus the live 121-record install dump in content/ctrlmap.log.
Provenance: workflow wf_628e421a-b0e.
Act on this AFTER the 2026-07-24 playtest. Work order is in section 6, split into safe-in-one-boot vs must-wait.
READ THIS FIRST — a retraction of my own diagnosis
While fixing reverse thrust earlier today I asserted that streamed BUTTON mappings are never
consumed because MechControlsMapper::AddOrErase is a Fail stub. That is false. All six
auditors and every adversarial verifier converged on the refutation independently:
LBE4ControlsManager::CreateStreamedMappingsinstalls button records itself (engine/MUNGA_L4/L4CTRL.cpp:2254-2262direct,:2327-2336event).content/ctrlmap.log:32shows reverse thrust's own record installing and resolving:rec 2 ... elem 63 ... attrID 6 -> +0xec=MechControlsMapper::reverseThrust.- The
Failbodies atmechmppr.cpp:1477-1500are the authentic base traps for the config-session vtable slots (+0x40/+0x44) and are overridden with real bodies byMechRIOMapper(btl4mppr.cpp:1275-1295) -- which is the mapper actually installed.
So "reconstruct the AddOrErase pair" would clear 0 of 2,294 records. The false claim is now
written into context/pod-hardware.md:121-135 AND into the source as a 25-line justifying
comment at mechmppr.cpp:894-919; both must be swept before they justify another stand-in
(CLAUDE.md correction mandate).
Consequences to settle:
- The reverse fix works (empirically: hold LALT ->
rev=1,speedDemand -61.5) butreverseThrustnow has two writers. Unknown #2 in section 5 is the exact experiment that decides whether the[T3]accommodation should be deleted outright. - The original "how did it break" answer is therefore still open. The strongest surviving
candidate is unknown #3: nothing in
engine/orgame/handlesWM_SYSKEYDOWN/WM_SYSCHAR/SC_KEYMENU, so a held ALT reachesDefWindowProc's menu-modal loop. That would also explain a previously working Alt going dead when window/message handling changed. Test it before believing any story.
Safety note for tonight's playtest
Section 2 finding #1: target designation (the 8 Comm-bank buttons 0x30-0x37 and typed
t y u i o) performs three raw 1995-offset writes *(int*)((int)pilotArray[0] + 0x284) on a
modern-layout object (mechmppr.cpp:1344, :1364, :1420). If sizeof(BTPlayer) < 0x288 this
is an out-of-bounds write -- i.e. heap corruption reachable by pressing t. Unknown #1 is the
one-line test that settles it. Designation is already broken either way, so nothing is lost by
avoiding t/y/u/i/o and the Comm bank tonight; if an unexplained crash shows up, this
is the first suspect.
BATTLETECH PORT — INPUT PATH AUDIT + FIX TRIAGE
Scope: desktop GLASS profile (the shipped default, btl4main.cpp:428-429 → L4CONTROLS=PAD,KEYBOARD). Static analysis only; nothing launched.
1. HEADLINE
134 bound-or-clickable input paths were traced end to end (40 key rows + 15 pad/padaxis rows + 7 hardcoded keys in content/bindings.txt, plus all 72 pod RIO addresses 0x00-0x47, every one of which is mouse-clickable), cross-checked against 2,294 streamed "L4" control-mapping records decoded statically from all 18 mechs in content/BTL4.RES and against the live 121-record install dump in content/ctrlmap.log. 22 dispatch routes are DEAD (no consumer, or a consumer that provably cannot act), 11 of them a player would notice tonight, plus 8 documentation defects that read as dead and 2 entire input layers that are dormant on every shipped launch (content/CONTROLS.MAP and the whole DirectInput/HOTAS grammar). Critically, the audit brief's own root cause is wrong and must be retracted: streamed BUTTON mappings are consumed — by the engine, at engine/MUNGA_L4/L4CTRL.cpp:2254-2262 (direct) and :2327-2336 (event) — and content/ctrlmap.log:32 proves reverse thrust's record installed and resolved (rec 2 ... elem 63 ... attrID 6 -> +0xec = MechControlsMapper::reverseThrust). MechControlsMapper::AddOrErase is the config-session regroup toggle on a different vtable slot, and the live mapper MechRIOMapper overrides it with real bodies (game/reconstructed/btl4mppr.cpp:1275-1295), so "reconstruct the AddOrErase pair" would clear zero of 2,294 records. The real dead-input generators are four different mechanisms, listed in §4. One finding is worse than dead: the target hotbox performs a raw 4-byte write at pilot+0x284 on a modern-layout object, reachable by pressing t.
2. DEAD AND A PLAYER WOULD NOTICE
Ranked by impact. Reverse thrust 0x3F (key LALT / pad B) is FIXED and confirmed live (mechmppr.cpp:912-919 + streamed rec 2) — it is listed in §5 only because the fix's stated cause is false and it now has two writers.
| # | Input | What it should do | Why it is dead | Fix | How to verify |
|---|---|---|---|---|---|
| 1 | Comm bank 0x30-0x37 (8 upper-right MFD buttons) + typed t y u i o |
Designate a pilot as your target → radar target box, callsign plate, hunting arrow, kill attribution | Handler runs but its whole effect is three raw 1995-offset writes *(int*)((int)pilotArray[0] + 0x284) = target (game/reconstructed/mechmppr.cpp:1344, :1364, :1420); in our layout that is not BTPlayer::objectiveMech, whose only typed writer is objectiveMech = 0 in the ctor (btplayer.cpp:1425) — and it may be past the end of the object |
S (neuter) / M (bridge) | Add DEBUG_STREAM << sizeof(BTPlayer) << " " << offsetof(BTPlayer,objectiveMech) at boot — it will not be 0x288/0x284. In-game: click 0x30 and confirm GetObjectiveMech() stays 0 and no radar target box appears |
| 2 | MFD2 ENG1 page bank — 0x27 0x26 0x25 0x24 0x22 0x21 = keys F5-F9 on that page |
Route Avionics power to generator A-D, gen mode, coolant cut | The receiver is Sensor (roster name "Avionics", streamed subsys 14) and Receiver::MessageHandlerSet Sensor::MessageHandlers; is default-constructed (sensor.cpp:103-104, handed to DefaultData at :129) → entryCount 0, no parent chain → Find() returns NullHandler for every id (engine/MUNGA/RECEIVER.h:376-380, 398-403) and Receiver::Receive drops it silently. Verified: content/ctrlmap.log:93-98 sends msgs 3-8 to subsys 14; the identical bank on ENG2 (subsys 15 Myomers) works because myomers.cpp:88-100 chains PoweredSubsystem::GetMessageHandlers(). 108 records across 18 mechs |
S | Press K until MFD2 shows an ENG page, then F5 with BT_FIRE_LOG=1: expect [gensel] Avionics -> GeneratorA (tapped) (powersub.cpp:569-571). Today: nothing |
| 3 | pad LT / RT (turn pedals) — and every pad-only session | Steer the legs | Not a wiring gap: the pedals are live (L4PADRIO.cpp:710-715 → L4CTRL.cpp:1438-1446 → btl4mppr.cpp:1337-1340 → :1399 pedalsPosition) but only Standard/Veteran read them (mechmppr.cpp:1034, :1061); the mapper boots in Basic (mechmppr.cpp:361) — and the pad has no binding for 0x18 CycleControlMode. Verified: content/bindings.txt:74-89 binds only 0x40 0x3F 0x47 0x42 0x44 0x43 0x41; no 0x18, no 0x15 |
S | BT_MPPR_TRACE=1, squeeze LT on a pad-only launch: pedals= moves, turn=0. Add pad BACK button 0x18, press it → [mode] control mode -> 1, then LT yaws |
| 4 | Fire dropout: releasing one alias of a held fire button (1+SPACE on 0x40; 3+LCTRL+RCTRL on 0x47; pad A/X on the same) |
Two keys on one address = two wires to one button | Edge state is per binding, not per address: previousKeyHeld[k] is indexed by binding index and EmitButton(addr, held) fires on every per-binding edge (engine/MUNGA_L4/L4PADRIO.cpp:580-595; same shape in the pad loop :665-685). The release writes -(addr+1) (L4CTRL.cpp:2635-2647) and ControlsUpdateManager::Update then suppresses the redundant press, so fire stays off until the held key is released and re-pressed |
M (shared plumbing — defer) | BT_FIRE_LOG=1: hold LCTRL (missiles firing), tap 3, release 3 → fire stops with LCTRL still down |
| 5 | 0x1A-0x1D Generator A-D ON/OFF (4 radar-rail buttons) |
Take a generator off line (heat/power management) | Generator : HeatSink (powersub.hpp:320-322) and no Generator::GetMessageHandlers() exists — powersub.cpp:1014 resolves to HeatSink's set {id 3 ToggleCooling}, so streamed msg 4 (ctrlmap.log:44-47) finds nothing. ToggleGeneratorOnOff @004b1ed0 is unreconstructed (0 hits in game/) |
M | Click 0x1A: today zero log. After: generator output voltage → 0 and its ENG-page bar follows |
| 6 | 0x13 CROUCH (manual p8, a full section) |
Duck to present a smaller target | Streamed subsys -1 msg 0x1a (ctrlmap.log:40) resolves to the Mech (ENTITY.cpp:608-615), but Mech::MessageHandlerEntries has exactly three entries — TakeDamage, PlayerLink, BalanceCoolant (mech.cpp:463-467). No id 0x1a. Assets exist (Mech::duckState attr 0x37 mech.cpp:861, SQUAT clips, DuckServo01.wav) |
M (reconstruct @0049fa00) |
Click 0x13 on a stationary mech: expect the squat clip + duckState 0→1 |
| 7 | Torso recenter from the keyboard (0x42 is pad/mouse only) |
Center a twisted torso — the manual's anti-disorientation control (p9) | 0x42 itself is LIVE (streamed subsys 17 attr 14 -> +0x208 = Torso::centerCommand, consumed torso.cpp:636-640) but content/bindings.txt:83,86 bind it only on pad Y/DPAD_UP. The port's keyboard path (gBTTorsoRecenter, mech4.cpp:2875) is fed from the zeroed gBTInput and its consumer sits inside the stood-down bridge (mechmppr.cpp:854-863, gate :655-660) |
S | Twist with E, press X: currentTwist does not return to 0. Add key X button 0x42 (two rows on one VK both fire — L4PADRIO.cpp:580-629 iterates all bindings) |
| 8 | Any clickable button latches if the mouse-up is lost (worst on 0x3F reverse, 0x40 trigger) |
A momentary click always releases | Neither WndProc handles WM_CAPTURECHANGED or WM_KILLFOCUS — grep over engine/ + game/ returns zero hits. Alt-tab or a system dialog between press and release leaves gCkPressed set and SetScreenButton(a,0) never runs |
M — trap: btl4main.cpp:137 calls ReleaseCapture() itself, which re-enters WM_CAPTURECHANGED; guard on gCkPressed != -1 (it is cleared before ReleaseCapture) |
Click-hold 0x3F, alt-tab, release outside the window: BT_PAD_LOG=1 shows no release line and rev stays 1 |
| 9 | Every HOTAS / flight stick / rudder (whole joydev/joyaxis/joybutton/joyhat grammar) |
Drive the pod channels from a DirectInput device | content/bindings.txt is 89 lines and has zero joy rows; the joy poll is gated on joyAxisBindingCount>0 || … (L4PADRIO.cpp:775-777) so BTJoyInit/BTJoyPoll are never called. PadBindingProfile::Load writes the default only when the file is absent (L4PADBINDINGS.cpp:708-713), and the file is gitignored (.gitignore:23) — so the zip ships this machine's stale file |
S now (ship without it / refresh it) · M later (version marker + additive merge) | content/jstest.log: bindings loaded: 40 keys, 10 pad buttons, 5 pad axes, 0 joy axes…; no [joy] line exists in any log in content/ |
| 10 | joyconfig.bat re-runs the wizard after it finishes -- ✅ FIXED + FILED AS #66 (2026-07-25) |
Run once, then play | BT_JOYCONFIG is never cleared (game/btl4main.cpp:287-291), and the front end relaunches the exe inheriting the environment (btl4console.cpp:138-174 clears only BT_FE_EGG/PODS/SECS/LOOP) → the mission process re-enters the wizard and blocks on _getch() behind the 3D window |
S (1 line) | Run joyconfig.bat, finish it, watch for a second === BattleTech joystick setup === banner |
| 11 | 0x14 Searchlight (+Searchlight2) |
Turn the light on for night maps | ✅ FIXED 2026-07-25 (#61). Searchlight::MessageHandlers was default-constructed, so the ToggleLamp body was unreachable; it now chains PowerWatcher::GetMessageHandlers() with id 3. My "wiring it changes nothing visible" verdict in this row was WRONG — it rested on the swapped attribution this same report flags in §Corrections: @004b860c (which writes commandedOn@0x1DC) is ThermalSight's handler, not Searchlight's. Searchlight's is @004b838c → toggles requestedOn@0x1E0, the field its own Performance reads. There was never a 1995 latent bug |
S — done | Verified live: BT_BTNTEST=0x14,400,900 → [light] requested ON then [light] reported lightState 0 -> 1. The lamp lights. (scratchpad/lamptest.py) |
| 12 | 0x12 Thermal sight |
IR view | ✅ handler + body FIXED 2026-07-25 (#61). ThermalSight::MessageHandlers was default-constructed AND it had no ToggleLamp at all — the exported body @004b860c had been transcribed into searchlight.cpp by mistake. Both now correct: id 3 → requestedOn@0x1DC, read by ThermalSightSimulation @004b8648. ⚠ Still no visible IR effect: ToggleGlobalThermalVision() is a marked no-op and the thermal render path (pvision palette flip) is unported, and IsLocallyViewed() returns False |
S done · L for the renderer | Verified live: BT_BTNTEST=0x12,400,900 → [light] thermal sight requested ON then reported thermalActive 0 -> 1. State machine + alarm authentic; screen unchanged (scratchpad/thermtest.py) |
| 13 | Numpad cluster with NumLock OFF | Aim the torso | L4PADRIO.cpp:583-584 reads VK_NUMPAD8..9 verbatim; with NumLock off Windows delivers VK_UP/VK_DOWN/VK_LEFT/VK_RIGHT, which bindings.txt:26,27,30,31 map to throttle and pedals — "aim up" accelerates the mech. NUMPAD5/7/9 → VK_CLEAR/HOME/PRIOR, unbound |
S (startup warning) · M (extended-key flag) | NumLock off, hold NUMPAD8 with BT_MPPR_TRACE=1: thr= rises |
| 14 | Duplicate rows on one axis add instead of aliasing (W+UP+LSHIFT = 3× lever rate; A+LEFT+NUMPAD7 = 3× pedal) |
One lever, one rate | slewDelta[channel] += rate*dt per binding with no per-channel fold (L4PADRIO.cpp:604-620). End state is identical (clamped :956-961, spring uses max :157-168) — only the ramp rate multiplies, so LSHIFT+W doubles throttle rate |
S but same file as #4 — bundle and defer | BT_GAIT_TRACE=1, hold W alone vs W+UP, compare lever delta/second |
2b. Reads as dead but is authentic — documentation defects only (fix the words, not the code)
| Input | Reality | Where the words are wrong |
|---|---|---|
keys 2 / 4 (0x46 / 0x45) |
LIVE-by-design. No mech's .CTL authors these two addresses (I independently confirmed the absence across all 18 tables) because they are the pod's spare mappable buttons (buttonType == MAPBTN, L4CTRL.cpp:126-128), filled by the regroup session: G→0x0E→ConfigureMappables (mechweap.cpp:190-229) → EnterConfiguration temp-maps 0x40/0x45/0x46/0x47 (btl4mppr.cpp:773-784) → ChooseButton → MechRIOMapper::AddOrErase real body (btl4mppr.cpp:1275-1295). Runtime-observed: content/btn2.log:377/534 [cfgmap] ENTER/EXIT, content/cfg.log:741 [rio-map] toggle btn=0x40 … LEFT. Do not seed a default group — that would diverge from the pod. |
players/README.txt:49-50 lists them as working triggers; content/bindings.txt:48 files them under "fire groups" |
| F6 / F8 | Page-gated, not dead: no mask-0x20 record on any mech, but elem 0x26 → msg 5 / 0x24 → msg 7 under the ENG masks (ctrlmap.log:94,96,101,103,109,111,117,119) with real SelectGeneratorB/D bodies |
bindings.txt:66-67 + L4PADBINDINGS.cpp:248-249 imply Quad-page meaning |
| F9 | On the Quad page it opens a weapon regroup session (msg 9) on 11 of 18 mechs and is absent on 7 (lok1/lok2/loki/mad2/madcat/owens/vulture). Gen-mode (msg 8) is ENG-page only. A held F9 makes all fire mappings dormant |
same comment block |
- / = |
Volume works (mech4.cpp:2756-2771, direct poll) but neither key is in bindings.txt, so PadRIO::BuildKeySuppression never claims them and the typed char also reaches case '-': ZoomTargetRangeOut() (btl4mppr.cpp:876). - lowers volume and zooms out; there is no key for zoom in |
undocumented |
| "Shift ... throttle up" | Only LSHIFT is bound (bindings.txt:45); RSHIFT is inert. Same for RALT vs reverse |
players/README.txt:48 |
typed 5 = MFD1 Quad |
Swallowed: key NUMPAD5 sets suppressChar['5'] via the VK_NUMPAD0..9 → '0'+n branch I verified at L4PADRIO.cpp:246-249 |
bindings.txt:18 promises it |
content/CONTROLS.MAP (47 rows, tracked & shipped) |
Never opened on the shipped profile — btinput.cpp:773-789 memsets gBTInput and returns before LoadBindings(). Live proof content/btn2.log:58 [input] binding engine standing down |
Its own header invites the player to edit it |
| A/D/arrows do not turn | Authentic: Basic steers with the stick, Standard/Veteran with the pedals | Correctly documented (README.txt:52-56) — leave it |
3. DEAD BUT COSMETIC / UNREACHABLE IN PRACTICE
Recorded so nobody re-discovers them:
- Not pod buttons at all, yet drawn clickable:
0x160x170x1E=LampTesla1/2/3, solid-state relay outputs written byL4APP.cpp:885-911(they even blink);0x1Fis named by neither the button nor lamp enum (L4CTRL.h:448-495,:273-281);0x38=LampIcomDecRelay(with a stale//ButtonPanic=0x38comment atL4CTRL.cpp:112— Panic moved to0x3D);0x39-0x3Bicom +0x3Cdoor, whose entire registration block is#if 0-ed (L4ICOM.cpp:902-949);0x3E=LampFloor. Placed atL4VB16.cpp:306-309/L4GLASSWIN.cpp:267-273. 0x19Secondary8 — a real illuminated pod button with no authored record on any mech. Authentic.0x3D"Panic" — its only registration is a lamp under mask0x200000(ModeEject,game/original/BT_L4/BTL4MODE.HPP:73), which can never be set becauseMech::GetMissionReviewMode()is areturn 0stub (btstubs.cpp:101-104). Drawn non-inert and labeled, so it looks functional. Same stub also makes the pilot keypad (keyboardGroup[KeyboardPilot], registered under0x200000atbtl4mppr.cpp:1341-1342) permanently undeliverable → the 32 panel keypad buttons0x50-0x6Fare inert in normal play.- Page-gated, correct: MFD1 Quad
0x0A/0x0B; MFD3 Quad0x00-0x03; MFD2 Quad0x24/0x26. Unauthored pages: MFD1 Eng3 (0x8), MFD3 Eng3/Eng4 (0x2000/0x4000). - Live but keyboard-unreachable (mouse-only):
0x28BalanceCoolant (handler landed,mech.cpp:467+:476-487);0x10/0x11radar zoom;0x0B/0x23/0x03ammo EJECT;0x43/0x44side looks; condensers 2-6 (0x29-0x2E). - Grammar targets nothing uses:
keypad <unit> <key>rows (0 rows shipped; consumer gated on0x200000); mappermsg 0x16 ToggleVoiceAssist(registered, no record targets it, and it raw-readsGetMech()+0x190— leave unbound). - Authentic 1995 typo, do not "fix":
L4CTRL.cpp:1716-1721publishesButtonThrottle1 - 1(0x3E) on press. Harmless — every consumer tests>0/<1. Legacy DirectInput path only. - Unregistered
Mechmessage ids with no button:0x15RealMaxSpeed @0049f604,0x17SetBurningState @0049f674,0x18ClearBurningState @0049f700,0x19EjectPilot @0049f854.EjectPilotis the manual's punch-out (-1000 score) and has no implementation at all — track inopen-questions.md, not here. ScalarLeftPedal/RightPedalare absent from every streamed table — correct: the mapper's ctor registers them (btl4mppr.cpp:1337-1340). "Not in the.CTL" never implies dead.
4. THE SYSTEMIC CAUSES (ranked)
First, the retraction. The brief's step 3 — "streamed BUTTON mappings are never consumed: both MechControlsMapper::AddOrErase overloads are Fail stubs" — is false, and all six auditors plus every adversarial verifier converged on that independently. LBE4ControlsManager::CreateStreamedMappings installs button records itself (L4CTRL.cpp:2254-2262 / :2327-2336); the Fail bodies at mechmppr.cpp:1477-1500 are the authentic base traps for the config-session vtable slots (+0x40/+0x44), overridden by real bodies in MechRIOMapper (btl4mppr.cpp:1275-1295) — the mapper btl4app.cpp:586/628 actually installs. Reconstructing that pair would clear 0 of 2,294 records. The false claim is written into context/pod-hardware.md:121-135 and into the source as a 25-line justifying comment at mechmppr.cpp:894-919; both must be swept before they justify another stand-in.
| Rank | Mechanism | Findings explained | The ONE fix |
|---|---|---|---|
| 1 | A default-constructed Receiver::MessageHandlerSet is a total blackhole. RECEIVER.h:376-380 leaves entryCount = 0 with no parent chain; Find() returns NullHandler for every id (:398-403) and Receiver::Receive drops it with no log, no [FAIL], no counter (RECEIVER.cpp:99-105) |
3 dead surfaces = 116 records (Sensor/Avionics 108, Searchlight, ThermalSight) and 4 more latent: gyro.cpp:205, mechtech.cpp:148, messmgr.cpp:102, torso.cpp:141 |
Give each class a GetMessageHandlers() function-local static chained to its base — the exact idiom at myomers.cpp:88-100 / powersub.cpp:131-152. Lint: grep -rn -B1 '^\t[A-Za-z0-9_]*::MessageHandlers;' game/reconstructed/*.cpp |
| 2 | Raw 1995 byte offsets inside a reconstructed handler. The handler runs, so nothing logs — it just reads/writes the wrong address. Our layout is provably not the binary's (btplayer.hpp declares levelFlag270@0x270 before advancedDamageOn2@0x268; the Entity base is ~0x1BC vs the binary's 0x300) |
The largest dead surface (hotbox, 8 buttons + 5 typed keys) plus latent siblings: btl4gau3.cpp:772,795,800, mechmppr.cpp:581,1391-1392 |
Typed bridge functions in the complete-type TU — the precedent already exists two files away (btplayer.cpp:1542, :1775) — plus static_assert on every offset a bridge replaces. Sweep with grep '\*(int \*)((int)' game/reconstructed |
| 3 | Three parallel desktop input layers with a stand-down line, and the setters sit on the opposite side of it from the consumers. btinput.cpp:773-789 memsets gBTInput whenever BTRIODevicePresent(), which PadRIO::IsOperational() makes unconditionally true (L4PADRIO.h:44-46); mech4.cpp:2733+ derives ~12 globals from that zeroed struct; mechmppr.cpp:648-870 consumes several of them inside a block gated on !BTRIODevicePresent() (:655-660) |
All 47 CONTROLS.MAP rows, the torso/elevation recenter (#7), gBTLookBehind/gBTPresetCycle/gBTGenSelKey/fire-level channels, and every BT_*_TEST harness (mech4.cpp:5352-5610) |
Retire one layer. Cheapest honest step: stop shipping CONTROLS.MAP, delete btinput's port-side action channels, and declare content/bindings.txt the single surface. Until then: only BT_BTNTEST (L4PADRIO.cpp:393-420) injects at the real seam — use it for all input verification |
| 4 | Edge state keyed per binding, not per RIO address — two independent loops (L4PADRIO.cpp:580-595 keys, :665-685 pad, :789-837 joy/hat) each keep their own state and all call EmitButton on shared addresses |
Fire dropout on 0x40/0x47, cross-device cancel (pad A vs key 1), early release on the duplicated 0x42/0x43/0x44 pad rows, additive axis rates |
One per-address hold count inside PadRIO::EmitButton (L4PADRIO.cpp:341): emit press on 0→1, release on 1→0. Fixes keyboard + pad + joy + hat + panel clicks at once, and hardens the gBTReverseHeld latch |
| 5 | Silent failure by design + invisible gating. An unmapped RIO address logs nothing; an unhandled message logs nothing; a click off the glass profile logs nothing; and the BTCockpitBtn::inert flag — documented at l4vb16.h:40 as "no authored mapping → drawn greyed" — is populated for exactly one block of 8 (L4VB16.cpp:316) and left 0 for the other 64 (I verified every CkPushBtn call) |
Why every one of these survived playtesting, plus the whole page-gating class (F6/F8/F9, MFD1 0x0A/0x0B, MFD3 0x00-0x03) and the Basic-mode pedal cliff |
See §7 — three log lines and one CurrentFilterStatus call |
Best ratio of findings-cleared to risk: cause #1, applied to Sensor alone. One function-local-static accessor in sensor.cpp, copied from myomers.cpp:88-100, revives 6 cockpit buttons and keys F5-F9 on the MFD2 ENG1 page = 108 of the 2,294 shipped records, touches no shared input plumbing, cannot regress anything currently working (the set is empty today, so nothing depends on its contents), and is verifiable in one boot with one keypress. Cause #4 has the widest reach but touches every input device at once — that is exactly the change you do not make on playtest day.
5. UNKNOWNS — the test script
| # | Question | Exact test | What answers it |
|---|---|---|---|
| 1 | Is the +0x284 hotbox write out of bounds (heap corruption) or merely the wrong member? |
Add one boot line printing sizeof(BTPlayer) and offsetof(BTPlayer, objectiveMech), or a deliberate static_assert(offsetof(BTPlayer,objectiveMech)==0x284) |
sizeof < 0x288 ⇒ out of bounds; every hotbox click and every t/y/u/i/o press corrupts the heap. ChooseDefaultPilot has no caller, so spawn is safe; UpdateCurrentPilot is guarded only by pilotArrayBuilt && pilotArray[0]!=0, and BuildPilotArray() runs every frame (btl4mppr.cpp:405) |
| 2 | Was the reverse fix necessary? Two writers now exist for reverseThrust |
Build with mechmppr.cpp:912-919 #if 0'd; BT_MPPR_TRACE=1 BT_PAD_LOG=1, throttle to ~0.5, hold LALT |
rev=64 ⇒ the streamed record is the live consumer and the accommodation is redundant (delete it, correct the comment). rev=0 ⇒ something upstream of ProcessRIOEvent eats 0x3F and the real cause is still unknown |
| 3 | Does a held LALT stall the message pump? (leading unproven candidate for the original report) |
Hold LALT for 3 s with BT_MPPR_TRACE=1 and watch whether the 0.5 s trace lines keep arriving; compare BT_BTNTEST=0x3F,600,1200 |
No WM_SYSKEYDOWN/WM_SYSCHAR/WM_SYSCOMMAND/SC_KEYMENU handler exists anywhere in engine/+game/, so ALT reaches DefWindowProc and its menu-modal loop. If the trace pauses, swallow SC_KEYMENU in btl4main.cpp's WndProc and/or move reverse off ALT |
| 4 | Per-mech coverage. Every record-based verdict rests on ONE mech (bhk1, 121 records — and content/ctrlmap.log:1 was captured on the DEV profile) |
One boot per mech with BT_CTRLMAP_LOG=1; grep '^\[ctrlmap\] rec'; diff against the bhk1 baseline. Re-run tonight's checks under BT_PLATFORM=glass |
Settles whether any chassis authors 0x45/0x46, whether 0x19 is unmapped everywhere, and the F9 7-of-18 split |
| 5 | Does the regroup session reach 0x45/0x46? (0x40 has been observed: content/cfg.log:741) |
BT_FIRE_LOG=1, hold G on the MFD1 Quad page, tap 2 |
Expect [cfgmap] ChooseButton toggle btn 0x46 then [rio-map] toggle btn=0x46 … JOINED; release G and tap 2 → the weapon fires |
| 6 | Do the pedals actually reach turnDemand? No pedals= line exists in any of the 509 logs |
BT_MPPR_TRACE=1, press M once, hold LEFT (and squeeze LT) |
pedals=1 turn=1 confirms the LeftPedal → pedalsPosition → turnDemand hop (currently T1-static only) |
| 7 | Can J/K/L park you on an unauthored page (whole bank dead)? |
BT_MODE_LOG=1 BT_CTRLMAP_LOG=1, cycle every page on every mech, cross-check each resulting mask against the record dump |
Any mask that never appears in the dump is a dead-bank page. Fix: make CyclePresetModeNow's populated[] test buttonGroup[bankHi].CurrentFilterStatus(pageMask) instead of the roster's auxScreenNumber |
| 8 | Can L4CONTROLS silently downgrade to the 9-record Thrustmaster stream? Both MOUSE and DIJOYSTICK set PrimaryThrustMaster (L4CTRL.cpp:816, :851) and the last matching token wins |
set L4CONTROLS=PAD,KEYBOARD,MOUSE and read btl4.log |
[ctrlmap] installing 'Thrustmaster' table: 9 mappings ⇒ throttle, reverse, all looks, torso center, every preset/zoom/mode/coolant/generator record vanish at once. Make PrimaryRIO non-demotable |
| 9 | Is the pilot keypad reachable in normal play? | Click a panel keypad key 0x50-0x5F with BT_KEY_LOG=1 in a mission and in mission review |
Registered only under 0x200000 (btl4mppr.cpp:1341-1342) and GetMissionReviewMode() is a return 0 stub ⇒ expect nothing in both cases |
6. RECOMMENDED ORDER OF WORK
SAFE BEFORE TONIGHT — small, isolated, each verifiable in one boot
Do them in this order; stop at any point and what you have is still shippable.
- Neuter the three raw
+0x284writes (mechmppr.cpp:1344,:1364,:1420) — comment them out or guard them behind the offset check. This removes a possible out-of-bounds write reachable by pressingtand changes nothing a player can see, because designation is already broken. Do not implement the typed bridge tonight: that would newly enable target designation and surface untested downstream consumers (btl4rdr.cpp:1188,btplayer.cpp:712kill attribution). Sensor::GetMessageHandlers()— one function-local static chained toPoweredSubsystem::GetMessageHandlers(), passed toSensor::DefaultData(sensor.cpp:129-134). Revives 6 buttons + F5-F9 on the MFD2 ENG1 page. Verify:Kto an ENG page, F5 →[gensel] Avionics -> GeneratorA.pad BACK button 0x18(and optionallypad START button 0x15) — a pad-only player can finally leave Basic and the triggers work.key X button 0x42andkey NUMPAD5 button 0x42— keyboard torso recenter.For 3 and 4: edit both
content/bindings.txtanddefaultProfileTextinL4PADBINDINGS.cpp:206-275, and change nothing else in the rows, so "delete the file to regenerate" stays lossless.- Clear
BT_JOYCONFIGafter the wizard (SetEnvironmentVariableA("BT_JOYCONFIG", NULL);atbtl4main.cpp:290, plus the clear-list inBTFE_RelaunchSelfAndExit). Stops joyconfig users hitting an apparent hang. - Ship-time, no code:
content/bindings.txtis gitignored, so the zip is packaging this machine's stale 89-line file with zero joy rows. Either omit it (the game regenerates the current default, joy grammar andTurnchannel included) or regenerate it here first. Do this even if you skip everything else — it is the difference between "a HOTAS does nothing" and "a HOTAS works afterjoyconfig.bat". inert = 1for the confirmed-dead panel addresses inBTCockpitComputeLayout—0x12 0x13 0x14 0x16 0x17 0x19 0x1A 0x1B 0x1C 0x1D 0x1E 0x1F, and flipthrInert[5](0x3D"Panic") from 0 to 1 (L4VB16.cpp:316). Data-only, and it stops testers filing the same reports.- Words:
players/README.txt— keys2/4are spare, empty until you assign a weapon withG; "Left Shift"; reverse needs Alt with the throttle up; target select ist y u i oor the radar hotbox; radar zoom / coolant balance / ammo EJECT are mouse-only (pressJ/K/Lto page first).content/bindings.txtheader +L4PADBINDINGS.cpp:248-254— F6/F8 are inert on the Quad page, F9 is a regroup hold there.content/CONTROLS.MAP— a one-line banner: NOT USED by the shipped launchers; editcontent\bindings.txt.
MUST WAIT — touches shared input plumbing or needs new decomp
- Per-address hold count in
PadRIO::EmitButton(fixes the fire dropout, cross-device cancel, and the duplicated pad rows). Highest reach, highest blast radius — not tonight. - Per-channel axis alias fold (max, not sum) — same file, bundle with the above.
- Focus guard for the pad and joy blocks (
L4PADRIO.cpp:662-763,:775-911never consultProcessHasFocus(), andL4JOYopens withDISCL_BACKGROUND). Relevant tonight only as an operator warning: alt-tabbing with a trigger or stick held keeps driving the mech. WM_CAPTURECHANGED/WM_KILLFOCUSrelease, with theReleaseCapturere-entrancy guard.- Deleting the reverse
[T3]accommodation — only after unknown #2. Generator::ToggleGeneratorOnOff(@004b1ed0) andMech::DuckRequest(@0049fa00) reconstructions. Note the trap:msg 4isSelectGeneratorAonPoweredSubsystem— chain Generator's set to HeatSink's, never PoweredSubsystem's, or those four buttons will do the wrong thing instead of nothing.- ThermalSight (handler + body + renderer).
- Suppression-table changes (
-/=, the numpad-digit branch) — they alter the typed 1995 channel; pair with bindingPAGEUP/PAGEDOWNto0x10/0x11so zoom does not simply disappear. bindings.txtversion marker + additive merge;joydev/token-ceiling parser fixes; DirectInput range read-back and hot-plug re-enumeration; wizard hat-slot bug.- Retiring
btinput/CONTROLS.MAP; the hotbox typed bridge;EnterControlsMode/ eject (Phase 8).
7. REGRESSION GUARD
The whole class exists because three failure modes produce zero output. Each has a cheap, already-supported fix.
- Unhandled-message trace — the single highest-value line in this report. In
Receiver::Receive(RECEIVER.cpp:99-105), whenFind()returnsNullHandler, log once per(class, id)pair using the receiver'sDerivationname (already available — it is whatClassDerivationscarries). This one line would have printed Sensor/Avionics, Searchlight, ThermalSight, crouch and all four generators on the first boot — 5 of the 11 player-visible findings, and it will catch the next one. ~5 lines,getenv-gated or always-on. - Bound-address consumer census at startup. At the end of
CreateStreamedMappings, walk the loadedPadBindingProfile's button addresses and, for each, call the engine's existingbuttonGroup[addr].CurrentFilterStatus(mask)(CONTROLS.h:263-264,CONTROLS.cpp:153-176) across the masks the game can actually be in; log one line per address with zero instances:[input] WARNING: bound button 0x46 (key 2) has no consumer on this mech. Catches the reverse-thrust shape (a bound address with nothing behind it) plus keys 2/4 and F6/F8 — at boot, on every mech, for free. - Drive
BTCockpitBtn::inertfromCurrentFilterStatusat draw time instead of the hardcoded0. The field is already documented for exactly this purpose (l4vb16.h:40) and the engine already computes the answer. It greys unauthored addresses and tracks MFD page gating automatically — a dead button becomes visible in a screenshot, and reverse thrust would have drawn grey the day it broke. Do the data-only version tonight (§6.7), the computed version after. - Promote two existing lines to always-on:
[ctrlmap] installing '<name>' table: N mappings(btl4app.cpp:672) — catches the Thrustmaster downgrade instantly; and add[cockpit] CLICK addr=0x.. DISCARDED (no RIO device — not the glass profile)inBTCockpitMouseDown, which turns the repeat-offender "held PROGRAM, nothing happened" trap into a log line. - Promote the auditors'
BTL4.RESdecoder totools/. Records are 24 bytes (controlsGroup, controlsElement, mappingType, subsystemID, modeMask, attributeID|messageIDperCONTROLS.h:783-803); type-19ControlMappingStreamper mech, validated againstcontent/ctrlmap.logat 121/121 records with zero mismatches. It makes "is this button mapped on this mech, on this page?" a two-second static query instead of a per-mech log hunt — and it already produced the authoritative answer, identical on all 18 mechs: MAPPED =0x00-0x15, 0x18, 0x1A-0x1D, 0x20-0x2F, 0x3F, 0x40-0x44, 0x47; NEVER MAPPED =0x16, 0x17, 0x19, 0x1E, 0x1F, 0x30-0x3E, 0x45, 0x46.
KB sweep owed in the same commits (per the CLAUDE.md correction mandate)
context/pod-hardware.md:121-135 (false AddOrErase premise — also in mechmppr.cpp:894-919, docs/GLASS_COCKPIT.md §2a); context/pod-hardware.md:50-61 (0x28 BalanceCoolant, weapon msg 0xb, Myomers msg 9 are all LIVE since 2026-07-21/23); the hat labels 0x41-0x44 (the shipped RES says 0x41 lookBehind, 0x42 TORSO CENTER, 0x43 lookRight, 0x44 lookLeft — fix content/CONTROLS.MAP:20-22, L4PADBINDINGS.cpp:159-160, pod-hardware.md); context/open-questions.md:172-182 (census stale in both directions — 0x28/ToggleCooling/ToggleSeekVoltage/EjectAmmo now live, hotbox never listed); btl4mppr.cpp:1332 calls 0x200000 "ModePreset1" — the surviving T0 header names it ModeEject (game/original/BT_L4/BTL4MODE.HPP:74, which is authoritative and not yet cited by any context file); searchlight.cpp:51/hud.cpp:282 table-address citations are +4 and mis-paired (real: Searchlight @0x51117C → @004b838c, ThermalSight @0x51120C → @004b860c) — ✅ SWEPT 2026-07-25 (#61) in searchlight.cpp/.hpp, thermalsight.cpp/.hpp, hud.cpp:282, btplayer.cpp:1957, context/decomp-reference.md, context/pod-hardware.md, docs/GLASS_COCKPIT.md; this swap had also manufactured a false "1995 latent bug" now retracted in context/subsystems.md, context/rendering.md, context/open-questions.md; stale //ButtonPanic=0x38 at L4CTRL.cpp:112.