Files
BT411/game/reconstructed/btinput.cpp
T
arcattackandClaude Fable 5 6783619069 Gitea #9: the upper-MFD PRESET pages (3 MFDs x 5) live -- SetPresetMode
table re-decoded to the ModeMFD bits + desktop J/K/L page cycle

The preset system was unwired by ONE defect in the message layer: the
SetPresetMode @004d1b24 table @0051dbf0 had been transcribed from the
section dump as BIG-endian dwords ({0x1e,0x01000000} instead of
{0x1e,0x01}), so a preset press set a garbage high bit -- and for group 1
items 3-4 / group 2 items 0-2 stomped the LIVE NonMapping / Intercom /
ModeSecondary* bits -- while the real page bits never moved.  Ground
truth (section_dump.txt:72901-72908, little-endian + BTL4MODE.HPP [T0]):
set = ModeMFD{1,2,3}{Quad,Eng1-4} = 1<<(group*5+item), bits 0-14, fully
disjoint from the #6 secondary trio (bits 18-20); group 2 is MFD3, NOT a
duplicate of the secondary views.

What the 15 presets show (l4gauge.cfg): group = the MFD (Mfd1 lower left
/ Mfd2 upper center / Mfd3 lower right), item 0 = the btquad.pcx Quad
overview (up to 4 vehicleSubSystems cluster panels), items 1-4 = the
full-screen engineering-detail pages (bteng.pcx + prepEngr screens
group*4+1..4 + the cluster eng children: GENERATOR SELECT A-D, POWER
graph, COOLING loop, DAMAGE, ammo).  Empty screens are authored per mech
(Blackhawk: 3/11/12 empty).

Authentic dispatch (streamed "L4" .CTL EventMappings, BT_CTRLMAP_LOG
dump): each MFD owns the 8-button RIO bank around it, MODE-MASK-gated --
Mfd1 = 0x08-0x0F, Mfd2 = 0x20-0x27, Mfd3 = 0x00-0x07.  Quad page ->
direct-select buttons for the POPULATED eng pages (mapper msgs
Aux1Eng1-4 0x4-0x7 / Aux2* 0x9-0xC / Aux3* 0xE-0x11); eng page -> one
back-to-Quad button (0x3/0x8/0xD) + per-subsystem controls.  These
records already install and fire on desktop (btinput passes the live
manager mask), so the NUMPAD profile's 0x20-0x27 keys page MFD2
authentically.

Port wiring (the #6 pattern): keys J/K/L -> actions Mfd1/2/3Cycle ->
gBTPresetCycle -> L4MechControlsMapper::CyclePresetModeNow(group) -- a
documented desktop shim (24 mode-dependent pod buttons don't fit a
keyboard) that cycles Quad -> populated Eng pages -> Quad, visiting
exactly the pod-reachable set; the body is the authentic SetPresetMode.

Dev-composite: BTDrawGaugeSurfaces now draws the Eng1-3 planes at their
sibling cells and skips any mono plane whose channel is BlankColor,
honoring the mode-driven reconfigure (RemapGraphicsPort) -- each dev
cell shows the ACTIVE page like the pod monitor.  This supersedes and
removes the 2026-07-12 GAUGREND "frozen-dial" scaffold (forced all 15
page bits active under BT_DEV_GAUGES; it pinned the shared Eng plane on
the highest screen and ate the page flips).

Pixel-verified (BT_PRESET_TEST + BT_DEV_GAUGES_DOCK + BT_SHOT,
Blackhawk): all three MFDs page Quad -> SYSTEM NN eng details -> back to
Quad in lockstep with the [mode] preset mask log; group 0 skips the
authored-empty screen 3, group 2 skips 11/12.  Un-regressed: N display
cycle (0x450421->0x490421->0x510421, page bits intact), M control mode,
CONTROLS.MAP 52 bindings parse clean.

Diags: BT_MODE_LOG ([mode] preset), BT_PRESET_TEST=<frame>.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:30:35 -05:00

1064 lines
31 KiB
C++

//===========================================================================//
// File: btinput.cpp //
// Project: BattleTech Brick: BattleTech LBE Application //
// Contents: The desktop input binding engine (CONTROLS.MAP + XInput). //
//---------------------------------------------------------------------------//
// PORT ADDITION (2026-07-18, input-remap task): the pod reads its stick / //
// throttle / pedals / lamp buttons / keypads through the RIO serial board //
// (L4RIO -> LBE4ControlsManager). On a desktop none of that hardware //
// exists; this engine maps PC keys and an XInput gamepad onto the same //
// channels through a user-editable text file. See btinput.hpp for the //
// architecture and content/CONTROLS.MAP for the grammar + active profile. //
// //
// AUTHENTIC EMISSION CONVENTIONS (LBE4ControlsManager::ProcessRIOEvent, //
// L4CTRL.cpp:2470-2520 -- mirrored exactly): //
// button press: buttonGroup[a].Update(value = a+1, currentModeMask) //
// button release: buttonGroup[a].Update(value = -a-1, mask saved at press)//
// keypad key: keyboardGroup[unit].ForceUpdate('0'-'9'/'A'-'F', mask) //
// //
// The four pod JOYSTICK fire buttons (0x40 trigger / 0x45 pinky / 0x46 //
// thumb-low / 0x47 thumb-high) do NOT go through buttonGroup here: the //
// bring-up fire dispatcher (mech4.cpp weapon groups) consumes them as //
// levels, and double-driving both channels would double-fire once the //
// streamed ChooseButton mappings are exercised. They publish as //
// gBTInput.fire* levels instead. //
//===========================================================================//
#include <bt.hpp>
#include <btl4app.hpp> // application (the BTL4Application global)
#include <l4ctrl.hpp> // LBE4ControlsManager
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include "btinput.hpp"
BTInputState gBTInput = {0};
// The launcher's drive-input global (btl4main.cpp) -- read only for the
// forced-harness override in the stand-down check below.
struct BTDriveInput { float throttle; float turn; int forced; int fire; int fireForced; float forcedThrottle;
int keyFwd; int keyBack; int keyLeft; int keyRight; int allStop; };
extern BTDriveInput gBTDrive;
// FILE scope (C++ linkage!): a block-scope extern inside the extern "C"
// BTInputSuppressKey inherited C linkage -> _BTRIODevicePresent went
// UNRESOLVED, /FORCE bound the call to garbage (Sensor::DefaultData), and
// ANY typed key crashed the glass build (found via the backtick report).
extern int BTRIODevicePresent(void);
//=============================================================================
// Binding model
//=============================================================================
enum BTAxisID
{
BTAxisThrottle = 0,
BTAxisLeftPedal,
BTAxisRightPedal,
BTAxisPedals, // signed convenience alias: + = right pedal
BTAxisJoystickX, // the pod stick X = torso twist (Standard mode)
BTAxisJoystickY, // the pod stick Y = torso ELEVATION (pitch aim)
BTAxisCount
};
enum BTActionID
{
BTActNone = 0,
BTActViewToggle,
BTActLookBehind,
BTActAllStop,
BTActModeCycle,
BTActDisplayCycle, // Gitea #6: secondary schematic cycle (Damage/Critical/Heat)
BTActValve,
BTActFlush, // Gitea #7: HELD = coolant flush (InjectCoolant)
BTActConfigHold,
BTActGenerator1,
BTActGenerator2,
BTActGenerator3,
BTActGenerator4,
BTActReconnect,
BTActMfd1Cycle, // Gitea #9: cycle the lower-left MFD preset page
BTActMfd2Cycle, // Gitea #9: cycle the upper-center MFD preset page
BTActMfd3Cycle, // Gitea #9: cycle the lower-right MFD preset page
BTActCount
};
enum BTTargetKind
{
BTTargetButton = 0, // RIO buttonGroup address (or fire-level for 0x40..0x47)
BTTargetAxisDeflect, // hold the axis at <scale> while down, spring back
BTTargetAxisRate, // walk the axis at <scale>/second while down
BTTargetKeypad, // keyboardGroup[unit] key value
BTTargetPCKey, // keyboardGroup[KeyboardPC] character (authentic hotkeys)
BTTargetAction // port-side dev control
};
enum BTSourceKind
{
BTSourceKey = 0, // PC key (GetAsyncKeyState VK)
BTSourcePadButton, // XInput digital button (or trigger past 50%)
BTSourcePadAxis // XInput analog axis
};
struct BTBinding
{
int sourceKind;
int sourceCode; // VK / XINPUT button bit index / pad-axis id
int targetKind;
int targetCode; // button addr / axis id / keypad unit / char / action
int keypadValue; // BTTargetKeypad only
float scale; // deflect value or rate-per-second
float deadzone; // pad axes
int invert; // pad axes
int toggle; // buttons: press latches on/off
// runtime
int wasDown; // raw source state (toggle edge detection)
int lastEffective; // last emitted/latched state (edge -> emission)
int latched;
int pressMask; // mode mask captured at press (release uses it)
};
#define BTINPUT_MAX_BINDINGS 192
static BTBinding sBindings[BTINPUT_MAX_BINDINGS];
static int sBindingCount = 0;
static int sLoaded = 0;
// characters/VKs claimed by key bindings (legacy keyboard-feed suppression)
static unsigned char sSuppressChar[256]; // WM_CHAR values
static unsigned char sSuppressKeyUp[256]; // WM_KEYUP/-SYSKEYUP values (VK aliases)
static int sInputLog = -1;
#define BTINPUT_LOG(x) \
do { if (sInputLog < 0) sInputLog = getenv("BT_INPUT_LOG") ? 1 : 0; \
if (sInputLog) { DEBUG_STREAM << "[input] " << x << "\n" << std::flush; } } while (0)
//=============================================================================
// Name tables
//=============================================================================
struct BTName { const char *name; int code; };
static const BTName sKeyNames[] =
{
{"Space", ' '}, {"Enter", 0x0D}, {"Tab", 0x09}, {"Escape", 0x1B},
{"Shift", 0x10}, {"Ctrl", 0x11}, {"Alt", 0x12},
{"LShift", 0xA0}, {"RShift", 0xA1}, {"LCtrl", 0xA2}, {"RCtrl", 0xA3},
{"Up", 0x26}, {"Down", 0x28}, {"Left", 0x25}, {"Right", 0x27},
{"PageUp", 0x21}, {"PageDown", 0x22}, {"End", 0x23}, {"Home", 0x24},
{"Insert", 0x2D}, {"Delete", 0x2E}, {"Back", 0x08},
{"NumPad0", 0x60}, {"NumPad1", 0x61}, {"NumPad2", 0x62},
{"NumPad3", 0x63}, {"NumPad4", 0x64}, {"NumPad5", 0x65},
{"NumPad6", 0x66}, {"NumPad7", 0x67}, {"NumPad8", 0x68},
{"NumPad9", 0x69}, {"Multiply", 0x6A}, {"Add", 0x6B},
{"Subtract", 0x6D}, {"Decimal", 0x6E}, {"Divide", 0x6F},
{"OemMinus", 0xBD}, {"Oemplus", 0xBB}, {"Oemcomma", 0xBC},
{"OemPeriod", 0xBE}, {"OemQuestion", 0xBF}, {"OemTilde", 0xC0},
{"OemOpenBrackets", 0xDB}, {"OemPipe", 0xDC}, {"OemCloseBrackets", 0xDD},
{"OemSemicolon", 0xBA}, {"OemQuotes", 0xDE},
{0, 0}
};
// XInput wButtons bit positions (XINPUT_GAMEPAD_* values are single bits)
static const BTName sPadButtonNames[] =
{
{"DPadUp", 0x0001}, {"DPadDown", 0x0002},
{"DPadLeft", 0x0004}, {"DPadRight", 0x0008},
{"Start", 0x0010}, {"Back", 0x0020},
{"LeftThumb", 0x0040}, {"RightThumb", 0x0080},
{"LeftShoulder", 0x0100}, {"RightShoulder", 0x0200},
{"A", 0x1000}, {"B", 0x2000}, {"X", 0x4000}, {"Y", 0x8000},
// analog triggers usable as digital buttons (>= 50% = pressed);
// out-of-band codes above the 16-bit button mask
{"LeftTrigger", 0x10000}, {"RightTrigger", 0x20000},
{0, 0}
};
enum { BTPadLX = 0, BTPadLY, BTPadRX, BTPadRY, BTPadLT, BTPadRT, BTPadAxisCount };
static const BTName sPadAxisNames[] =
{
{"LeftStickX", BTPadLX}, {"LeftStickY", BTPadLY},
{"RightStickX", BTPadRX}, {"RightStickY", BTPadRY},
{"LeftTrigger", BTPadLT}, {"RightTrigger", BTPadRT},
{0, 0}
};
static const BTName sAxisNames[] =
{
{"Throttle", BTAxisThrottle},
{"LeftPedal", BTAxisLeftPedal}, {"RightPedal", BTAxisRightPedal},
{"Pedals", BTAxisPedals},
{"JoystickX", BTAxisJoystickX}, {"JoystickY", BTAxisJoystickY},
{0, 0}
};
static const BTName sActionNames[] =
{
{"ViewToggle", BTActViewToggle}, {"LookBehind", BTActLookBehind},
{"AllStop", BTActAllStop}, {"ModeCycle", BTActModeCycle},
{"DisplayCycle", BTActDisplayCycle},
{"Valve", BTActValve}, {"Flush", BTActFlush}, {"ConfigHold", BTActConfigHold},
{"Generator1", BTActGenerator1}, {"Generator2", BTActGenerator2},
{"Generator3", BTActGenerator3}, {"Generator4", BTActGenerator4},
{"Reconnect", BTActReconnect},
{"Mfd1Cycle", BTActMfd1Cycle}, {"Mfd2Cycle", BTActMfd2Cycle},
{"Mfd3Cycle", BTActMfd3Cycle},
{0, 0}
};
static int
LookupName(const BTName *table, const char *token)
{
for (int i = 0; table[i].name != 0; ++i)
{
if (_stricmp(table[i].name, token) == 0)
{
return table[i].code;
}
}
return -1;
}
static int
ParseKeyName(const char *token)
{
// single letter / digit-row Dn / function keys / named specials
if (token[1] == '\0' && isalpha((unsigned char)token[0]))
{
return toupper((unsigned char)token[0]); // VK == 'A'..'Z'
}
if ((token[0] == 'D' || token[0] == 'd')
&& isdigit((unsigned char)token[1]) && token[2] == '\0')
{
return '0' + (token[1] - '0'); // VK == '0'..'9'
}
if ((token[0] == 'F' || token[0] == 'f') && isdigit((unsigned char)token[1]))
{
int n = atoi(token + 1);
if (n >= 1 && n <= 12)
{
return 0x70 + n - 1; // VK_F1..VK_F12
}
}
return LookupName(sKeyNames, token);
}
//=============================================================================
// The compiled-in default profile: WASD CLASSIC -- byte-identical to
// content/CONTROLS.MAP as shipped. Deleting the file restores exactly this.
//=============================================================================
static const char *sDefaultProfile =
"key W axis Throttle rate 0.7\n"
"key S axis Throttle rate -0.7\n"
"key Up axis Throttle rate 0.7\n"
"key Down axis Throttle rate -0.7\n"
"key A axis LeftPedal deflect 1\n"
"key D axis RightPedal deflect 1\n"
"key Left axis LeftPedal deflect 1\n"
"key Right axis RightPedal deflect 1\n"
"key Q axis JoystickX deflect -1\n"
"key E axis JoystickX deflect 1\n"
"key R axis JoystickY deflect 1\n"
"key F axis JoystickY deflect -1\n"
"key X action AllStop\n"
"key D1 button 0x40\n"
"key Space button 0x40\n"
"key D2 button 0x46\n"
"key D3 button 0x47\n"
"key Ctrl button 0x47\n"
"key D4 button 0x45\n"
"key G action ConfigHold\n"
"key C action Valve\n"
"key H action Flush\n"
"key M action ModeCycle\n"
"key N action DisplayCycle\n"
"key J action Mfd1Cycle\n"
"key K action Mfd2Cycle\n"
"key L action Mfd3Cycle\n"
"key V action ViewToggle\n"
"key B action LookBehind\n"
"key F5 action Generator1\n"
"key F6 action Generator2\n"
"key F7 action Generator3\n"
"key F8 action Generator4\n"
"key F9 action Reconnect\n"
"padaxis LeftStickX axis Pedals deadzone 0.24\n"
"padaxis LeftStickY axis JoystickY deadzone 0.24\n"
"padaxis RightStickX axis JoystickX deadzone 0.24\n"
"padaxis RightStickY axis Throttle rate 0.75 deadzone 0.24\n"
"pad RightTrigger button 0x40\n"
"pad LeftTrigger button 0x47\n"
"pad RightShoulder button 0x46\n"
"pad LeftShoulder button 0x45\n"
"pad DPadUp button 0x42\n"
"pad DPadDown button 0x41\n"
"pad DPadLeft button 0x44\n"
"pad DPadRight button 0x43\n"
"pad B action AllStop\n"
"pad Y action ViewToggle\n"
"pad X action LookBehind\n"
"pad Start action ModeCycle\n"
"pad RightThumb action DisplayCycle\n"
"pad Back action Valve\n";
//=============================================================================
// Parser
//=============================================================================
static void
RegisterKeySuppression(int vk)
{
// WM_KEYUP/-SYSKEYUP deliver the raw VK as the key value (the engine
// keyboard feed, L4CTRL.cpp:1506) -- suppress it; this is what aliased
// F5's key-up (0x74) to the 't' pilot-select hotkey, and letter key-ups
// (uppercase) to developer fake events.
if (vk >= 0 && vk < 256)
{
sSuppressKeyUp[vk] = 1;
}
// WM_CHAR delivers typed characters: suppress both cases of a letter,
// the digit itself, space, and the base punctuation of the Oem keys.
if (vk >= 'A' && vk <= 'Z')
{
sSuppressChar[tolower(vk)] = 1;
sSuppressChar[vk] = 1;
}
else if (vk >= '0' && vk <= '9')
{
sSuppressChar[vk] = 1;
}
else if (vk == ' ')
{
sSuppressChar[' '] = 1;
}
else
{
static const struct { int vk; char ch; } oem[] =
{
{0xBD, '-'}, {0xBB, '='}, {0xBC, ','}, {0xBE, '.'},
{0xBF, '/'}, {0xC0, '`'}, {0xDB, '['}, {0xDC, '\\'},
{0xDD, ']'}, {0xBA, ';'}, {0xDE, '\''}, {0x0D, '\r'},
{0x09, '\t'}, {0x08, '\b'},
};
for (int i = 0; i < (int)(sizeof(oem) / sizeof(oem[0])); ++i)
{
if (oem[i].vk == vk)
{
sSuppressChar[(unsigned char)oem[i].ch] = 1;
}
}
}
}
static int
ParseLine(char *line, int line_number)
{
// strip comments + tokenize
char *hash = strchr(line, '#');
if (hash != 0)
{
*hash = '\0';
}
char *tokens[8];
int token_count = 0;
for (char *t = strtok(line, " \t\r\n");
t != 0 && token_count < 8;
t = strtok(0, " \t\r\n"))
{
tokens[token_count++] = t;
}
if (token_count == 0)
{
return 1; // blank / comment
}
if (token_count < 3 || sBindingCount >= BTINPUT_MAX_BINDINGS)
{
BTINPUT_LOG("CONTROLS.MAP line " << line_number << ": malformed (ignored)");
return 0;
}
BTBinding b;
memset(&b, 0, sizeof(b));
// ---- source ----
int arg = 2; // first token after <verb> <source>
if (_stricmp(tokens[0], "key") == 0)
{
b.sourceKind = BTSourceKey;
b.sourceCode = ParseKeyName(tokens[1]);
}
else if (_stricmp(tokens[0], "pad") == 0)
{
b.sourceKind = BTSourcePadButton;
b.sourceCode = LookupName(sPadButtonNames, tokens[1]);
}
else if (_stricmp(tokens[0], "padaxis") == 0)
{
b.sourceKind = BTSourcePadAxis;
b.sourceCode = LookupName(sPadAxisNames, tokens[1]);
}
else
{
BTINPUT_LOG("CONTROLS.MAP line " << line_number << ": unknown verb '"
<< tokens[0] << "'");
return 0;
}
if (b.sourceCode < 0)
{
BTINPUT_LOG("CONTROLS.MAP line " << line_number << ": unknown source '"
<< tokens[1] << "'");
return 0;
}
// ---- target ----
if (_stricmp(tokens[arg], "button") == 0 && arg + 1 < token_count)
{
b.targetKind = BTTargetButton;
b.targetCode = (int)strtol(tokens[arg + 1], 0, 0);
if (b.targetCode < 0 || b.targetCode > 0x47)
{
BTINPUT_LOG("CONTROLS.MAP line " << line_number
<< ": button address out of range (0x00-0x47)");
return 0;
}
arg += 2;
if (arg < token_count && _stricmp(tokens[arg], "toggle") == 0)
{
b.toggle = 1;
++arg;
}
}
else if (_stricmp(tokens[arg], "axis") == 0 && arg + 1 < token_count)
{
int axis = LookupName(sAxisNames, tokens[arg + 1]);
if (axis < 0)
{
BTINPUT_LOG("CONTROLS.MAP line " << line_number << ": unknown axis '"
<< tokens[arg + 1] << "'");
return 0;
}
b.targetCode = axis;
arg += 2;
b.targetKind = BTTargetAxisDeflect;
b.scale = 1.0f;
b.deadzone = 0.0f;
while (arg < token_count)
{
if (_stricmp(tokens[arg], "deflect") == 0 && arg + 1 < token_count)
{
b.targetKind = BTTargetAxisDeflect;
b.scale = (float)atof(tokens[arg + 1]);
arg += 2;
}
else if (_stricmp(tokens[arg], "rate") == 0 && arg + 1 < token_count)
{
b.targetKind = BTTargetAxisRate;
b.scale = (float)atof(tokens[arg + 1]);
arg += 2;
}
else if (_stricmp(tokens[arg], "deadzone") == 0 && arg + 1 < token_count)
{
b.deadzone = (float)atof(tokens[arg + 1]);
arg += 2;
}
else if (_stricmp(tokens[arg], "invert") == 0)
{
b.invert = 1;
++arg;
}
else
{
BTINPUT_LOG("CONTROLS.MAP line " << line_number
<< ": unknown axis option '" << tokens[arg] << "'");
return 0;
}
}
}
else if (_stricmp(tokens[arg], "keypad") == 0 && arg + 2 < token_count)
{
b.targetKind = BTTargetKeypad;
if (_stricmp(tokens[arg + 1], "pilot") == 0)
{
b.targetCode = LBE4ControlsManager::KeyboardPilot;
}
else if (_stricmp(tokens[arg + 1], "external") == 0)
{
b.targetCode = LBE4ControlsManager::KeyboardExternal;
}
else
{
BTINPUT_LOG("CONTROLS.MAP line " << line_number
<< ": keypad unit must be pilot|external");
return 0;
}
// the RIO converts keypad units 0-15 to '0'-'9','A'-'F'
// (L4CTRL.cpp:2526); accept the same range
int v = (int)strtol(tokens[arg + 2], 0, 0);
if (v < 0 || v > 15)
{
BTINPUT_LOG("CONTROLS.MAP line " << line_number
<< ": keypad value must be 0-15");
return 0;
}
b.keypadValue = (v <= 9) ? ('0' + v) : ('A' + v - 10);
arg += 3;
}
else if (_stricmp(tokens[arg], "pckey") == 0 && arg + 1 < token_count)
{
// send an authentic PC-keyboard hotkey character (the 1995
// KeypressMessageHandler dispatcher: '+'/'-' zoom, presets, ...)
b.targetKind = BTTargetPCKey;
b.targetCode = (tokens[arg + 1][1] == '\0')
? (unsigned char)tokens[arg + 1][0]
: (int)strtol(tokens[arg + 1], 0, 0);
arg += 2;
}
else if (_stricmp(tokens[arg], "action") == 0 && arg + 1 < token_count)
{
b.targetKind = BTTargetAction;
b.targetCode = LookupName(sActionNames, tokens[arg + 1]);
if (b.targetCode < 0)
{
BTINPUT_LOG("CONTROLS.MAP line " << line_number << ": unknown action '"
<< tokens[arg + 1] << "'");
return 0;
}
arg += 2;
}
else
{
BTINPUT_LOG("CONTROLS.MAP line " << line_number << ": unknown target '"
<< tokens[arg] << "'");
return 0;
}
if (b.sourceKind == BTSourceKey)
{
RegisterKeySuppression(b.sourceCode);
}
sBindings[sBindingCount++] = b;
return 1;
}
static void
LoadBindings()
{
sLoaded = 1;
sBindingCount = 0;
memset(sSuppressChar, 0, sizeof(sSuppressChar));
memset(sSuppressKeyUp, 0, sizeof(sSuppressKeyUp));
char *text = 0;
FILE *f = fopen("CONTROLS.MAP", "rb"); // cwd == content/
if (f != 0)
{
fseek(f, 0, SEEK_END);
long len = ftell(f);
fseek(f, 0, SEEK_SET);
if (len > 0 && len < 256 * 1024)
{
text = (char *)malloc((size_t)len + 1);
if (text != 0)
{
fread(text, 1, (size_t)len, f);
text[len] = '\0';
}
}
fclose(f);
}
const char *source = text != 0 ? text : sDefaultProfile;
char *work = _strdup(source);
int line_number = 0;
// strtok is used inside ParseLine, so split lines manually first
for (char *line = work, *next = 0; line != 0; line = next)
{
next = strchr(line, '\n');
if (next != 0)
{
*next++ = '\0';
}
++line_number;
ParseLine(line, line_number);
}
free(work);
BTINPUT_LOG("loaded " << sBindingCount << " binding(s) from "
<< (text != 0 ? "CONTROLS.MAP" : "built-in WASD-classic defaults"));
if (text != 0)
{
free(text);
}
}
//=============================================================================
// Device polling
//=============================================================================
// -- keyboard (dynamic, same pattern as the old shim: WndProc key messages
// are consumed by the engine keyboard reader, so poll async state) --
typedef short (__stdcall *BTAsyncFn)(int);
typedef void *(__stdcall *BTFgFn)(void);
typedef unsigned long (__stdcall *BTWtpFn)(void *, unsigned long *);
typedef unsigned long (__stdcall *BTPidFn)(void);
static BTAsyncFn sAsync = 0;
static BTFgFn sFg = 0;
static BTWtpFn sWtp = 0;
static BTPidFn sPid = 0;
// -- XInput (dynamic: xinput1_4.dll on Win8+, xinput9_1_0.dll everywhere) --
struct BTXInputGamepad
{
unsigned short wButtons;
unsigned char bLeftTrigger;
unsigned char bRightTrigger;
short sThumbLX;
short sThumbLY;
short sThumbRX;
short sThumbRY;
};
struct BTXInputStateRaw
{
unsigned long dwPacketNumber;
BTXInputGamepad Gamepad;
};
typedef unsigned long (__stdcall *BTXInputGetStateFn)(unsigned long, BTXInputStateRaw *);
static BTXInputGetStateFn sXInputGetState = 0;
static int sXInputTried = 0;
static int sPadConnected = 0;
static unsigned long sPadRecheckAt = 0; // GetTickCount throttle
static void
InitDevices()
{
HMODULE u = GetModuleHandleA("user32.dll");
HMODULE k = GetModuleHandleA("kernel32.dll");
if (u != 0 && k != 0)
{
sAsync = (BTAsyncFn)GetProcAddress(u, "GetAsyncKeyState");
sFg = (BTFgFn)GetProcAddress(u, "GetForegroundWindow");
sWtp = (BTWtpFn)GetProcAddress(u, "GetWindowThreadProcessId");
sPid = (BTPidFn)GetProcAddress(k, "GetCurrentProcessId");
}
if (!sXInputTried)
{
sXInputTried = 1;
static const char *dlls[] =
{ "xinput1_4.dll", "xinput1_3.dll", "xinput9_1_0.dll" };
for (int i = 0; i < 3 && sXInputGetState == 0; ++i)
{
HMODULE x = LoadLibraryA(dlls[i]);
if (x != 0)
{
sXInputGetState =
(BTXInputGetStateFn)GetProcAddress(x, "XInputGetState");
if (sXInputGetState != 0)
{
BTINPUT_LOG("XInput via " << dlls[i]);
}
}
}
}
}
static float
PadAxisValue(const BTXInputGamepad &pad, int axis)
{
switch (axis)
{
case BTPadLX: return pad.sThumbLX / 32767.0f;
case BTPadLY: return pad.sThumbLY / 32767.0f;
case BTPadRX: return pad.sThumbRX / 32767.0f;
case BTPadRY: return pad.sThumbRY / 32767.0f;
case BTPadLT: return pad.bLeftTrigger / 255.0f;
case BTPadRT: return pad.bRightTrigger / 255.0f;
}
return 0.0f;
}
//=============================================================================
// Emission (the authentic RIO event mirror)
//=============================================================================
static void
EmitButton(BTBinding &b, int down)
{
LBE4ControlsManager *controls =
(LBE4ControlsManager *)application->GetControlsManager();
if (controls == 0)
{
return;
}
int addr = b.targetCode;
// the four pod joystick fire buttons feed the bring-up fire channels
// as LEVELS (see file banner); handled in the poll aggregation.
if (addr >= 0x40 && addr <= 0x47 && addr != 0x41 && addr != 0x42
&& addr != 0x43 && addr != 0x44)
{
return;
}
if (down)
{
ControlsButton value = (ControlsButton)(addr + 1);
b.pressMask = application->GetModeManager()->GetModeMask();
controls->buttonGroup[addr].Update(&value, b.pressMask);
BTINPUT_LOG("button 0x" << std::hex << addr << std::dec << " PRESS");
}
else
{
ControlsButton value = (ControlsButton)(-addr - 1);
controls->buttonGroup[addr].Update(&value, b.pressMask);
BTINPUT_LOG("button 0x" << std::hex << addr << std::dec << " release");
}
}
static void
EmitKeypad(BTBinding &b)
{
LBE4ControlsManager *controls =
(LBE4ControlsManager *)application->GetControlsManager();
if (controls == 0)
{
return;
}
ControlsKey key = (ControlsKey)b.keypadValue;
controls->keyboardGroup[b.targetCode].ForceUpdate(
&key, application->GetModeManager()->GetModeMask());
BTINPUT_LOG("keypad unit " << b.targetCode << " key '"
<< (char)b.keypadValue << "'");
}
static void
EmitPCKey(BTBinding &b)
{
LBE4ControlsManager *controls =
(LBE4ControlsManager *)application->GetControlsManager();
if (controls == 0)
{
return;
}
ControlsKey key = (ControlsKey)b.targetCode;
controls->keyboardGroup[LBE4ControlsManager::KeyboardPC].ForceUpdate(
&key, application->GetModeManager()->GetModeMask());
BTINPUT_LOG("pckey 0x" << std::hex << b.targetCode << std::dec);
}
//=============================================================================
// The per-frame poll
//=============================================================================
void
BTInputPoll(float /*dt*/)
{
//
// STAND-DOWN (glass-cockpit merge, 2026-07-18): when an OPERATIONAL
// cockpit device owns the input path (serial RIO on the pod, PadRIO on
// a BT_GLASS build with L4CONTROLS=PAD), the binding engine yields the
// keyboard/pad completely -- otherwise both systems would read the
// same keys and double-emit buttons. Mirrors the mech4/mechmppr
// bridge convention (BTRIODevicePresent). BT_KEY_BRIDGE=1 forces the
// engine on for debugging, matching the bridges' force-on.
//
{
static const char *s_kbEnv = getenv("BT_KEY_BRIDGE");
int engine_owns = s_kbEnv ? (*s_kbEnv == '0')
: BTRIODevicePresent();
if (engine_owns && !gBTDrive.forced)
{
static int announced = 0;
if (!announced)
{
announced = 1;
DEBUG_STREAM << "[input] binding engine standing down: a "
"cockpit device owns the input path\n" << std::flush;
}
memset(&gBTInput, 0, sizeof(gBTInput));
return;
}
}
if (!sLoaded)
{
LoadBindings();
InitDevices();
}
// foreground-focus guard (BT_KEY_NOFOCUS=1 for automation harnesses)
int focused = 0;
if (sFg != 0 && sWtp != 0 && sPid != 0)
{
void *fg = sFg();
unsigned long fg_pid = 0;
if (fg != 0)
{
sWtp(fg, &fg_pid);
}
focused = (fg_pid == sPid());
}
static int sNoFocus = -1;
if (sNoFocus < 0)
{
const char *nf = getenv("BT_KEY_NOFOCUS");
sNoFocus = (nf != 0 && *nf == '1') ? 1 : 0;
}
if (sNoFocus)
{
focused = 1;
}
// pad state (rate-limit reconnect probing: XInputGetState on an EMPTY
// slot is expensive, ~ms -- probe disconnected pads at 1 Hz only)
BTXInputGamepad pad;
memset(&pad, 0, sizeof(pad));
if (sXInputGetState != 0)
{
unsigned long now = GetTickCount();
if (sPadConnected || now >= sPadRecheckAt)
{
BTXInputStateRaw raw;
memset(&raw, 0, sizeof(raw));
if (sXInputGetState(0, &raw) == 0) // ERROR_SUCCESS
{
if (!sPadConnected)
{
BTINPUT_LOG("gamepad CONNECTED");
}
sPadConnected = 1;
pad = raw.Gamepad;
}
else
{
if (sPadConnected)
{
BTINPUT_LOG("gamepad disconnected");
}
sPadConnected = 0;
sPadRecheckAt = now + 1000;
}
}
}
// ---- aggregate the frame ----
BTInputState next;
memset(&next, 0, sizeof(next));
float axisDeflect[BTAxisCount] = {0};
int axisActive[BTAxisCount] = {0};
int axisAbsolute[BTAxisCount] = {0};
float axisRate[BTAxisCount] = {0};
for (int i = 0; i < sBindingCount; ++i)
{
BTBinding &b = sBindings[i];
// -- source value --
int down = 0;
float analog = 0.0f;
if (b.sourceKind == BTSourceKey)
{
down = (focused && sAsync != 0
&& (sAsync(b.sourceCode) & 0x8000)) ? 1 : 0;
analog = (float)down;
}
else if (b.sourceKind == BTSourcePadButton)
{
if (!sPadConnected)
{
down = 0;
}
else if (b.sourceCode == 0x10000)
{
down = pad.bLeftTrigger >= 128;
}
else if (b.sourceCode == 0x20000)
{
down = pad.bRightTrigger >= 128;
}
else
{
down = (pad.wButtons & b.sourceCode) ? 1 : 0;
}
analog = (float)down;
}
else // BTSourcePadAxis
{
analog = sPadConnected ? PadAxisValue(pad, b.sourceCode) : 0.0f;
if (b.invert)
{
analog = -analog;
}
if (analog > -b.deadzone && analog < b.deadzone)
{
analog = 0.0f;
}
else if (b.deadzone > 0.0f && b.deadzone < 1.0f)
{
// rescale so the deadzone edge is 0 (no jump)
float sign = analog < 0.0f ? -1.0f : 1.0f;
analog = sign * ((analog * sign - b.deadzone)
/ (1.0f - b.deadzone));
}
down = analog != 0.0f;
}
// toggle latch: a PRESS edge flips the held state
if (b.toggle && b.sourceKind != BTSourcePadAxis)
{
if (down && !b.wasDown)
{
b.latched = !b.latched;
}
b.wasDown = down;
down = b.latched;
analog = (float)down;
}
// -- apply to target --
switch (b.targetKind)
{
case BTTargetButton:
{
int addr = b.targetCode;
if (down != b.lastEffective)
{
EmitButton(b, down);
}
b.lastEffective = down;
// fire-button levels (0x40/45/46/47) aggregate every frame
if (down)
{
if (addr == 0x40) next.fireTrigger = 1;
if (addr == 0x45) next.firePinky = 1;
if (addr == 0x46) next.fireThumbLow = 1;
if (addr == 0x47) next.fireThumbHigh = 1;
}
break;
}
case BTTargetAxisDeflect:
if (down)
{
axisDeflect[b.targetCode] += analog * b.scale;
axisActive[b.targetCode] = 1;
if (b.sourceKind == BTSourcePadAxis)
{
axisAbsolute[b.targetCode] = 1;
}
}
break;
case BTTargetAxisRate:
if (down)
{
axisRate[b.targetCode] += analog * b.scale;
}
break;
case BTTargetKeypad:
if (down && !b.lastEffective)
{
EmitKeypad(b);
}
b.lastEffective = down;
break;
case BTTargetPCKey:
if (down && !b.lastEffective)
{
EmitPCKey(b);
}
b.lastEffective = down;
break;
case BTTargetAction:
if (down)
{
switch (b.targetCode)
{
case BTActViewToggle: next.viewToggle = 1; break;
case BTActLookBehind: next.lookBehind = 1; break;
case BTActAllStop: next.allStop = 1; break;
case BTActModeCycle: next.modeCycle = 1; break;
case BTActDisplayCycle: next.displayCycle = 1; break;
case BTActValve: next.valve = 1; break;
case BTActFlush: next.flush = 1; break;
case BTActConfigHold: next.configHold = 1; break;
case BTActMfd1Cycle: next.mfdCycle[0] = 1; break;
case BTActMfd2Cycle: next.mfdCycle[1] = 1; break;
case BTActMfd3Cycle: next.mfdCycle[2] = 1; break;
case BTActGenerator1: next.genSel = 4; break;
case BTActGenerator2: next.genSel = 5; break;
case BTActGenerator3: next.genSel = 6; break;
case BTActGenerator4: next.genSel = 7; break;
case BTActReconnect: next.genSel = 8; break;
}
}
break;
}
}
// ---- fold the axis aliases + publish ----
// Pedals (signed alias): + = right pedal (turn right)
float turn = (axisDeflect[BTAxisRightPedal] - axisDeflect[BTAxisLeftPedal])
+ axisDeflect[BTAxisPedals];
int turn_active = axisActive[BTAxisRightPedal] || axisActive[BTAxisLeftPedal]
|| axisActive[BTAxisPedals];
int turn_abs = axisAbsolute[BTAxisPedals]
|| axisAbsolute[BTAxisLeftPedal] || axisAbsolute[BTAxisRightPedal];
if (turn > 1.0f) turn = 1.0f;
if (turn < -1.0f) turn = -1.0f;
float twist = axisDeflect[BTAxisJoystickX];
if (twist > 1.0f) twist = 1.0f;
if (twist < -1.0f) twist = -1.0f;
next.turnTarget = turn;
next.turnActive = turn_active;
next.turnAbsolute = turn_abs;
next.twistTarget = twist;
next.twistActive = axisActive[BTAxisJoystickX];
next.twistAbsolute = axisAbsolute[BTAxisJoystickX];
// stick Y = torso ELEVATION (pitch aim) -- every 1995 control mode routes
// it to Torso::SetAnalogElevationAxis (mechmppr InterpretControls)
float elev = axisDeflect[BTAxisJoystickY];
if (elev > 1.0f) elev = 1.0f;
if (elev < -1.0f) elev = -1.0f;
next.elevTarget = elev;
next.elevActive = axisActive[BTAxisJoystickY];
next.elevAbsolute = axisAbsolute[BTAxisJoystickY];
next.leverRate = axisRate[BTAxisThrottle];
gBTInput = next;
}
//=============================================================================
// Legacy keyboard-feed suppression (called from L4CTRL.cpp:1506 block)
//=============================================================================
extern "C" int
BTInputSuppressKey(unsigned int key_value, int is_char)
{
//
// Mirror of the poll's stand-down: with a cockpit device owning the
// input path the binding engine claims NOTHING -- the authentic 1995
// keyboard feed flows untouched (a claimed-but-unprocessed key would
// otherwise go dead).
//
{
static const char *s_kbEnv = getenv("BT_KEY_BRIDGE");
int engine_owns = s_kbEnv ? (*s_kbEnv == '0')
: BTRIODevicePresent();
if (engine_owns && !gBTDrive.forced)
{
return 0;
}
}
if (!sLoaded)
{
LoadBindings(); // the feed can run before the first poll
InitDevices();
}
if (key_value > 255)
{
return 0; // ALT_BIT-tagged values etc. -- never claimed
}
return is_char ? sSuppressChar[key_value] : sSuppressKeyUp[key_value];
}