Input remap: CONTROLS.MAP binding engine + XInput gamepad support

The pod's controls reach the game through the RIO serial board; on a
desktop that hardware is a keyboard shim of hardcoded GetAsyncKeyState
reads.  New binding engine (game/reconstructed/btinput.cpp) maps PC keys
and an XInput pad onto the authentic channels through a user-editable
file (content/CONTROLS.MAP, community-suggested grammar, corrected):

  key/pad -> button <addr>   real buttonGroup emissions, mirroring the
                             RIO convention exactly (press a+1 w/ mode
                             mask saved, release -a-1 w/ saved mask;
                             L4CTRL.cpp:2470-2520) -- aux/preset banks,
                             hotbox, panic, reverse thrust all reachable
  key/pad -> axis            Throttle rate (lever), pedals/JoystickX
                             deflect (turn/twist) into the existing
                             virtual-controls integrators (feel, gait
                             detent, spring-centering all unchanged)
  keypad pilot|external <n>  keyboardGroup key values ('0'-'F')
  pckey <char>               any authentic 1995 typed hotkey
  action <name>              port dev controls (view, all-stop, ...)

Keys claimed by a binding are SUPPRESSED from the legacy WM_CHAR/KEYUP
feed -- ends the historic double-dispatch ('w' drove AND selected pilot
0; F5's key-up value 0x74 aliased to the 't' hotkey; letter key-ups fed
the developer fake-event dispatcher).  Unbound keys keep their authentic
meaning.  The dual-use 'V' is split: V = view toggle, B = look behind.

Default profile = WASD classic (compiled-in twin; delete the file to
restore).  CONTROLS_NUMPAD.MAP ships the corrected community layout
(keypads are NOT buttons 0x50-0x6F -- that space doesn't exist; no
clickable cockpit exists so everything needs a binding; keyboard fire
buttons added; 0x36/0x37 are hotbox not 'config'; missiles moved off
Ctrl).  XInput loads dynamically (1_4 -> 9_1_0), disconnected-pad
probing rate-limited; pad sticks write the axes as absolute positions
(the spring is physical), triggers/buttons per the file.

Verified live: bindings load (43), W and NumPad8 drive (speedDemand 0 ->
61.5), X all-stop (spd -> 0), aux-bank emission (D1 -> [input] 0x2f
PRESS/release under the numpad profile), suppression both directions
(posted 'r' reaches [keych], posted 'w' swallowed), full-keyspace
WM_CHAR+WM_KEYUP fuzz x3 survived with sim advancing, XInput graceful
with no pad, 2-node relay session un-regressed (full ladder + UDP).
Pad-in-hand testing still needs a physical controller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-18 13:09:36 -05:00
co-authored by Claude Fable 5
parent 0fcd0c3106
commit 980c9cd7e5
10 changed files with 1360 additions and 84 deletions
+1 -1
View File
@@ -72,7 +72,7 @@ precise than anything you can infer.
| First-person cockpit canopy (*_cop) + the eyepoint camera | `context/cockpit-view.md` |
| Cockpit gauges / MFD HUD | `context/gauges-hud.md` |
| Multiplayer, replication, netcode | `context/multiplayer.md` |
| Pod hardware, monitors, RIO, MFD surfaces | `context/pod-hardware.md` |
| Pod hardware, monitors, RIO, MFD surfaces, input remap (CONTROLS.MAP/XInput) | `context/pod-hardware.md` |
| Content archives, maps, where data lives | `context/content-archives.md` |
| Offsets, ClassIDs, addresses, resource types, env vars | `context/decomp-reference.md` |
| What we don't know / deferred systems | `context/open-questions.md` |
+1
View File
@@ -238,6 +238,7 @@ target_compile_options(munga_engine PRIVATE ${BT_OPTS})
add_library(bt410_l4 STATIC
"game/reconstructed/ammobin.cpp"
"game/reconstructed/btdirect.cpp"
"game/reconstructed/btinput.cpp"
"game/reconstructed/btl4app.cpp"
"game/reconstructed/btl4galm.cpp"
"game/reconstructed/btl4gau2.cpp"
+100
View File
@@ -0,0 +1,100 @@
# ==========================================================================
# BT411 CONTROLS.MAP -- desktop input bindings (keyboard + XInput gamepad)
# ==========================================================================
# One binding per line, '#' starts a comment, keywords are case-insensitive.
# Loaded at game start; DELETE THIS FILE to restore these exact defaults
# (the same profile is compiled in). Alternate profile: CONTROLS_NUMPAD.MAP
# (copy it over this file to use it).
#
# key <name> button <addr> [toggle]
# key <name> axis <axis> deflect <n>
# key <name> axis <axis> rate <n-per-second>
# key <name> keypad pilot|external <0-15>
# key <name> pckey <char>
# key <name> action <name>
# pad <button> button <addr> | action <name> | pckey <char> | keypad ...
# padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n-per-second>]
#
# <addr> RIO button address 0x00-0x47 (hex or decimal):
# 0x00-0x0F aux lower right/left banks 0x10-0x1B secondary panel
# 0x20-0x37 aux upper center/left/right (presets + target hotbox)
# 0x3D Panic 0x3F throttle-head (REVERSE THRUST)
# 0x40 Main trigger 0x41-0x44 hat 0x45 Pinky 0x46 Middle 0x47 Upper
# <axis> Throttle | LeftPedal | RightPedal | Pedals (signed, + = right)
# | JoystickX (torso twist) | JoystickY
# <name> A-Z, D0-D9 (digit row), F1-F12, NumPad0-NumPad9, Up, Down, Left,
# Right, Space, Enter, Shift, Ctrl, Alt, PageUp, PageDown, Home,
# End, Insert, Delete, OemMinus, Oemplus, Oemcomma, OemPeriod,
# OemOpenBrackets, OemCloseBrackets, ...
# <button> A B X Y DPadUp DPadDown DPadLeft DPadRight Start Back
# LeftShoulder RightShoulder LeftThumb RightThumb
# LeftTrigger RightTrigger (as digital, >= 50% pressed)
# <src> LeftStickX LeftStickY RightStickX RightStickY
# LeftTrigger RightTrigger
# action ViewToggle LookBehind AllStop ModeCycle Valve ConfigHold
# Generator1-4 Reconnect
# pckey sends an authentic 1995 PC-keyboard hotkey (the in-cockpit
# dispatcher): '+'/'-' = target zoom, 'w'..'o' = pilot select,
# 'a','b','c','d','f','g','s','v','x','z' = display presets, ...
#
# 'deflect' holds the axis there while the key is down and springs back on
# release; 'rate' walks the axis by <n> per second and the position sticks
# (the throttle lever). A key bound here is REMOVED from the legacy typed-
# hotkey channel (no double dispatch); unbound keys keep their authentic
# 1995 meaning (see pckey above).
#
# ---- WASD CLASSIC (the shipped default) ----------------------------------
# Driving: W/S sweep the throttle lever (sticks, detent at zero); A/D are
# the turn pedals (spring back); Q/E twist the torso; X = all stop.
key W axis Throttle rate 0.7
key S axis Throttle rate -0.7
key Up axis Throttle rate 0.7
key Down axis Throttle rate -0.7
key A axis LeftPedal deflect 1
key D axis RightPedal deflect 1
key Left axis LeftPedal deflect 1
key Right axis RightPedal deflect 1
key Q axis JoystickX deflect -1
key E axis JoystickX deflect 1
key X action AllStop
# Fire: the pod joystick's four buttons.
key D1 button 0x40 # Main trigger (lasers group)
key Space button 0x40
key D2 button 0x46 # Middle thumb (PPCs group)
key D3 button 0x47 # Upper thumb (missiles group)
key Ctrl button 0x47
key D4 button 0x45 # Pinky (4th group)
# Systems.
key G action ConfigHold # hold + fire keys = regroup the weapon
key C action Valve # coolant valve cycle
key M action ModeCycle # Basic -> Standard -> Veteran controls
key V action ViewToggle # cockpit <-> chase camera (dev)
key B action LookBehind # HOLD = the pod's rear-view button
key F5 action Generator1
key F6 action Generator2
key F7 action Generator3
key F8 action Generator4
key F9 action Reconnect
# ---- Xbox controller -----------------------------------------------------
# Left stick steers (the pedals), right stick twists the torso and pushes
# the throttle lever; triggers fire.
padaxis LeftStickX axis Pedals deadzone 0.24
padaxis RightStickX axis JoystickX deadzone 0.24
padaxis RightStickY axis Throttle rate 0.75 deadzone 0.24
pad RightTrigger button 0x40 # Main
pad LeftTrigger button 0x47 # Upper (missiles)
pad RightShoulder button 0x46 # Middle (PPCs)
pad LeftShoulder button 0x45 # Pinky
pad DPadUp button 0x42 # hat up
pad DPadDown button 0x41 # hat back
pad DPadLeft button 0x44 # hat left
pad DPadRight button 0x43 # hat right
pad B action AllStop
pad Y action ViewToggle
pad X action LookBehind
pad Start action ModeCycle
pad Back action Valve
+99
View File
@@ -0,0 +1,99 @@
# ==========================================================================
# BT411 CONTROLS_NUMPAD.MAP -- the community-suggested numpad layout
# (corrected). To use it: copy this file over CONTROLS.MAP.
# Grammar + address reference: see CONTROLS.MAP.
# ==========================================================================
# Corrections vs. the original suggestion:
# - keypads are NOT button addresses 0x50-0x6F (that space doesn't exist);
# use `keypad pilot|external <0-15>` when those panels get mapped
# - there is no clickable on-screen cockpit -- every function you want
# must be bound here
# - keyboard fire buttons added (the suggestion only had Main; Pinky/
# Middle/Upper were pad-only, stranding weapons regrouped onto them)
# - Ctrl throttle-down kept, but missiles moved off Ctrl to NumPad3
# - 0x36/0x37 are target-hotbox buttons (pilot select), not "config 1/2"
# - port actions (view, all-stop, mode cycle, valve, generators) bound
#
# ---- Driving: number pad ------------------------------------------------
key NumPad8 axis Throttle rate 0.75 # lever up
key NumPad2 axis Throttle rate -0.75 # lever down
key NumPad4 axis LeftPedal deflect 1 # turn left
key NumPad6 axis RightPedal deflect 1 # turn right
key NumPad7 axis JoystickX deflect -1 # torso twist left
key NumPad9 axis JoystickX deflect 1 # torso twist right
key NumPad5 action AllStop
key Shift axis Throttle rate 0.75 # throttle up (alt)
key Ctrl axis Throttle rate -0.75 # throttle down (alt)
key Alt button 0x3F # throttle-head = REVERSE THRUST
# ---- Fire: numpad cluster + space ---------------------------------------
key Space button 0x40 # Main trigger
key NumPad0 button 0x40
key NumPad1 button 0x45 # Pinky
key NumPad3 button 0x47 # Upper (missiles)
key Decimal button 0x46 # Middle (PPCs)
# ---- Joystick hat on the arrows -----------------------------------------
key Up button 0x42 # Hat Up
key Down button 0x41 # Hat Back
key Left button 0x44 # Hat Left
key Right button 0x43 # Hat Right
# ---- Systems ------------------------------------------------------------
key G action ConfigHold
key Enter action ModeCycle
key V action ViewToggle
key B action LookBehind
key F5 action Generator1
key F6 action Generator2
key F7 action Generator3
key F8 action Generator4
key F9 action Reconnect
key F10 action Valve
# ---- Upper MFD banks: number row = the top aux row, QWERTY row = the row
# ---- under it, left to right across the Left / Center / Right panels.
# ---- (These buttons drive the preset/eng-data selection; the letters
# ---- lose their 1995 typed-hotkey meaning once bound here.)
key D1 button 0x2F
key D2 button 0x2E
key D3 button 0x2D
key D4 button 0x2C
key D5 button 0x27
key D6 button 0x26
key D7 button 0x25
key D8 button 0x24
key D9 button 0x37 # hotbox (target select)
key D0 button 0x36 # hotbox
key OemMinus button 0x35 # hotbox
key Oemplus button 0x34 # hotbox
key Q button 0x2B
key W button 0x2A
key E button 0x29
key R button 0x28
key T button 0x23
key Y button 0x22
key U button 0x21
key I button 0x20
key O button 0x33 # hotbox
key P button 0x32 # hotbox
key OemOpenBrackets button 0x31 # hotbox
key OemCloseBrackets button 0x30 # hotbox
# ---- Xbox controller (same as the default profile) ----------------------
padaxis LeftStickX axis Pedals deadzone 0.24
padaxis RightStickX axis JoystickX deadzone 0.24
padaxis RightStickY axis Throttle rate 0.75 deadzone 0.24
pad RightTrigger button 0x40
pad LeftTrigger button 0x47
pad RightShoulder button 0x46
pad LeftShoulder button 0x45
pad DPadUp button 0x42
pad DPadDown button 0x41
pad DPadLeft button 0x44
pad DPadRight button 0x43
pad B action AllStop
pad Y action ViewToggle
pad X action LookBehind
pad Start action ModeCycle
pad Back action Valve
+1
View File
@@ -267,6 +267,7 @@ default-ON (`'0'` disables).
| `BT_RELAY=<host:port\|auto>` | **D1 relay mode** — dial the relay/operator console instead of the P2P mesh; `auto` = LAN UDP-broadcast discovery on udp/15999 ([[multiplayer]] D1) |
| `BT_SELF=<[pilots] tag>` | claim a specific roster seat in relay mode; absent/`auto` = the relay assigns one (SEAT_REQUEST) |
| `BT_RELAY_TCP_ONLY=1` | disable the relay UDP channel (all unreliable traffic rides the TCP relay conn) |
| `BT_INPUT_LOG` | binding-engine trace: CONTROLS.MAP load, button/keypad emissions, pad connect ([[pod-hardware]] §Desktop input remap) |
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]].
+32
View File
@@ -25,6 +25,38 @@ to keyboard/gamepad on a dev box; real wiring is a pod bring-up task (Phase 8).
model — `LBE4ControlsManager` groups are fed by all devices (RIO on the pod, DirectInput on dev);
the `MechControlsMapper` interprets them ([[locomotion]]). [T2]
### The button space + lamps [T0, L4CTRL.h enum]
buttonGroup addresses 0x00-0x47: `AuxLowerRight/Left 1-8` (0x00-0x0F), `Secondary1-12` (0x10-0x1B),
`AuxUpperCenter/Left/Right 1-8` (0x20-0x37 — the preset/eng "display eng data" banks; **0x30-0x37 =
the target HOTBOX**, pilot select), icom/door 0x39-0x3C, `Panic` 0x3D (the config-mode lamp),
`Throttle1` 0x3F (throttle-head = REVERSE THRUST), joystick cluster 0x40-0x47 (Main trigger 0x40,
hat 0x41-0x44, Pinky/ThumbLow/ThumbHigh 0x45-0x47 — the four MAPPABLE fire buttons). "Lamp buttons"
are literal: physical illuminated pushbuttons in panels around the screens; the RIO `LampRequest`
protocol lights them (`MakeLinkedLamp`), reports burned-out bulbs (`RIODeadLamps` failure page,
per-lamp names). A lit button = active. Keypads are NOT buttons: `keyboardGroup[KeyboardPilot/
KeyboardExternal]` carry key VALUES ('0'-'9','A'-'F', L4CTRL.cpp:2526). RIO event convention:
press = `buttonGroup[a].Update(a+1, modeMask)` (mask saved), release = `Update(-a-1, savedMask)`
(L4CTRL.cpp:2470-2520).
### Desktop input remap — CONTROLS.MAP + XInput (2026-07-18) [T2 live]
`game/reconstructed/btinput.cpp` + `content/CONTROLS.MAP` (WASD-classic default, compiled-in twin;
`content/CONTROLS_NUMPAD.MAP` = the corrected community numpad profile). Grammar: `key <name>
button <addr>|axis <axis> deflect|rate <n>|keypad pilot|external <0-15>|pckey <char>|action
<name>`; `pad <button>` same targets; `padaxis <src> axis ... [invert] [deadzone d] [rate n]`.
Axes: Throttle (lever, rate-walks + sticks), LeftPedal/RightPedal/Pedals (turn, springs),
JoystickX (torso twist). Emissions mirror the RIO conventions above exactly; the four fire
buttons (0x40/45/46/47) publish as levels to the bring-up fire channels instead of buttonGroup
(avoids double-fire once the streamed ChooseButton mappings are exercised). XInput loads
dynamically (xinput1_4 → 9_1_0; disconnected-pad probing rate-limited to 1 Hz). **Keys claimed by
a binding are SUPPRESSED from the legacy WM_CHAR/KEYUP feed** (`BTInputSuppressKey`, hook at
L4CTRL.cpp keyboard read) — this ended the historic DOUBLE-DISPATCH: 'w' drove AND selected pilot
0; F5's key-up value 0x74 aliased to the 't' hotkey; letter key-ups (uppercase) fed the developer
fake-event dispatcher. Unbound keys keep their authentic 1995 dispatcher meaning (reachable from
any key via `pckey`). The old dual-use 'V' (view toggle + look-behind) is split: V = ViewToggle,
B = LookBehind. Verified live: bindings load, W/NumPad8 drive (spd rises), X all-stop, aux-button
emission ([input] 0x2f PRESS), suppression both directions ('r' delivered / 'w' swallowed),
full-keyspace WM_CHAR+WM_KEYUP fuzz survived. Diag: `BT_INPUT_LOG`.
### The pod throttle is ANALOG-CONTINUOUS, not notched (verified end-to-end) [T1]
The authentic pod throttle path — traced 2026-07 (task #50 throttle-fidelity question):
1. **Hardware → RIO:** serial `AnalogReply` packet → `Ranger("Throttle", 0, 800, .05)` — raw counts
+14
View File
@@ -8,6 +8,15 @@
#include "..\munga\appmgr.h"
#include "dxutils.h"
//
// BT port (input remap): keys claimed by a CONTROLS.MAP binding are consumed
// by the binding engine (game/reconstructed/btinput.cpp) and must NOT also
// reach the key-command channel -- that was the historic double-dispatch
// ('w' drove AND selected pilot 0; F5's key-up value 0x74 aliased to the
// 't' hotkey). The symbol resolves from the game lib at the btl4 exe link.
//
extern "C" int BTInputSuppressKey(unsigned int key_value, int is_char);
// #define LOCAL_TEST
#if defined(LOCAL_TEST)
@@ -1527,6 +1536,11 @@ void
{
// swallowed: navigation key, not a character command
}
else if (BTInputSuppressKey((unsigned int)keyValue,
msg.message == WM_CHAR))
{
// swallowed: remapped by CONTROLS.MAP
}
else
{
keyboardGroup[KeyboardPC].ForceUpdate(&keyValue, mode_mask);
+978
View File
@@ -0,0 +1,978 @@
//===========================================================================//
// 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};
//=============================================================================
// Binding model
//=============================================================================
enum BTAxisID
{
BTAxisThrottle = 0,
BTAxisLeftPedal,
BTAxisRightPedal,
BTAxisPedals, // signed convenience alias: + = right pedal
BTAxisJoystickX, // the pod stick X = torso twist (Standard mode)
BTAxisJoystickY, // parsed + tracked; no consumer yet
BTAxisCount
};
enum BTActionID
{
BTActNone = 0,
BTActViewToggle,
BTActLookBehind,
BTActAllStop,
BTActModeCycle,
BTActValve,
BTActConfigHold,
BTActGenerator1,
BTActGenerator2,
BTActGenerator3,
BTActGenerator4,
BTActReconnect,
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},
{"Valve", BTActValve}, {"ConfigHold", BTActConfigHold},
{"Generator1", BTActGenerator1}, {"Generator2", BTActGenerator2},
{"Generator3", BTActGenerator3}, {"Generator4", BTActGenerator4},
{"Reconnect", BTActReconnect},
{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 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 M action ModeCycle\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 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 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*/)
{
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 BTActValve: next.valve = 1; break;
case BTActConfigHold: next.configHold = 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];
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)
{
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];
}
+68
View File
@@ -0,0 +1,68 @@
//#############################################################################
// btinput.hpp -- the desktop input binding engine (CONTROLS.MAP + XInput).
//
// The pod's controls are a fixed physical device set (stick, throttle lever,
// pedals, ~44 lamp buttons, keypads) reaching the game through the
// LBE4ControlsManager push model. On a desktop there is no RIO, so this
// engine maps PC keys and an XInput gamepad onto the SAME channels:
//
// axes -> the virtual-controls integrators in mech4.cpp (lever / turn
// stick / torso twist), exactly where the BT_KEY_BRIDGE writes
// the analog values the RIO would have produced
// buttons -> real buttonGroup[addr] ForceUpdates (aux banks, hotbox,
// panic, throttle-head...) so the AUTHENTIC handlers fire;
// the four joystick fire buttons (0x40/45/46/47) drive the
// bring-up fire channels instead (see btinput.cpp note)
// keypads -> keyboardGroup[KeyboardPilot/KeyboardExternal] key values
// pckey -> keyboardGroup[KeyboardPC] (any authentic '+'-style hotkey)
// actions -> the port-side dev controls (view toggle, all-stop, ...)
//
// Bindings load from content/CONTROLS.MAP at first poll; if the file is
// absent the compiled-in WASD-classic profile (identical text) applies.
// Keys claimed by a binding are SUPPRESSED from the legacy WM_CHAR/-KEYUP
// keyboard feed (BTInputSuppressKey), ending the historic double-dispatch
// (W = drive AND pilot-select, F5 keyup aliasing to 't' = pilot 3, ...).
//#############################################################################
#ifndef BTINPUT_HPP
#define BTINPUT_HPP
struct BTInputState
{
// analog demands (consumed by the mech4 virtual-controls block)
float leverRate; // throttle lever sweep, per second (+fwd/-back)
float turnTarget; // turn-stick deflection target [-1,1]
int turnActive; // any turn input held (else spring-center)
float twistTarget; // torso-twist deflection target [-1,1]
int twistActive;
int turnAbsolute; // pad stick overrides the integrator outright
int twistAbsolute;
// the four pod joystick fire buttons (levels)
int fireTrigger; // 0x40 Main
int firePinky; // 0x45
int fireThumbLow; // 0x46 Middle
int fireThumbHigh; // 0x47 Upper
// port-side actions (levels; consumers keep their own edge detectors)
int viewToggle;
int lookBehind;
int allStop;
int modeCycle;
int valve;
int configHold;
int genSel; // 0 = none, 4..7 = Generator A..D, 8 = reconnect
};
extern BTInputState gBTInput;
// Poll everything (keys + pad), evaluate the binding table, fire button /
// keypad edges into the controls manager. Call ONCE per sim frame from the
// player drive block. Handles the foreground-focus guard (BT_KEY_NOFOCUS)
// internally.
void BTInputPoll(float dt);
// The legacy keyboard feed asks before dispatching a typed character /
// key-up value: nonzero = this key is claimed by a binding, swallow it.
extern "C" int BTInputSuppressKey(unsigned int key_value, int is_char);
#endif // BTINPUT_HPP
+66 -83
View File
@@ -155,6 +155,7 @@
#include <messmgr.hpp> // SubsystemMessageManager (task #7 consolidated delivery)
#include <mechweap.hpp> // MechWeapon::GetExplosionResourceID (per-round detonation)
#include <mislanch.hpp> // MissileLauncher::ClassDerivations (splash-radius gate)
#include "btinput.hpp" // the CONTROLS.MAP + XInput binding engine
#if !defined(PLAYER_HPP)
# include <player.hpp> // Player::VehicleDeadMessage -- the death->respawn notification (task #52)
#endif
@@ -2527,76 +2528,45 @@ void
const float kStickRate = 2.5f; // stick deflect per second
const float kStickCenterRate = 5.0f; // stick auto-center per second
// POLL the real key state. WndProc key messages are unusable: the
// POLL the input state. WndProc key messages are unusable: the
// engine keyboard reader (L4CTRL.cpp:1506) GetMessage()s every
// WM_KEYUP/WM_CHAR out of the queue for its key-command channel, so
// only KEYDOWNs ever reached the WndProc and key state latched on
// forever. GetAsyncKeyState is immune; the foreground guard keeps
// background typing from driving the mech.
// WM_KEYUP/WM_CHAR out of the queue for its key-command channel,
// so the binding engine polls GetAsyncKeyState (+ XInput); the
// foreground guard (BT_KEY_NOFOCUS override) lives inside it.
{
typedef short (__stdcall *AsyncFn)(int);
typedef void *(__stdcall *FgFn)(void);
typedef unsigned long (__stdcall *WtpFn)(void *, unsigned long *);
typedef unsigned long (__stdcall *PidFn)(void);
static AsyncFn pAsync = 0; static FgFn pFg = 0;
static WtpFn pWtp = 0; static PidFn pPid = 0;
if (pAsync == 0)
{
HMODULE u = GetModuleHandleA("user32.dll");
HMODULE k = GetModuleHandleA("kernel32.dll");
pAsync = (AsyncFn)GetProcAddress(u, "GetAsyncKeyState");
pFg = (FgFn)GetProcAddress(u, "GetForegroundWindow");
pWtp = (WtpFn)GetProcAddress(u, "GetWindowThreadProcessId");
pPid = (PidFn)GetProcAddress(k, "GetCurrentProcessId");
}
int focused = 0;
if (pFg && pWtp && pPid)
{
void *fg = pFg();
unsigned long fgPid = 0;
if (fg) pWtp(fg, &fgPid);
focused = (fgPid == pPid());
}
// TEST HOOK (BT_KEY_NOFOCUS=1): accept keys without foreground focus --
// automation harnesses (SendInput from a background shell) can't grant
// real foreground; interactive play never needs this.
static int sNoFocus = -1;
if (sNoFocus < 0) { const char *nf = getenv("BT_KEY_NOFOCUS"); sNoFocus = (nf && *nf == '1') ? 1 : 0; }
if (sNoFocus) focused = 1;
if (pAsync)
{
const int dn = 0x8000;
gBTDrive.keyFwd = focused && ((pAsync('W') | pAsync(0x26 /*VK_UP*/)) & dn) ? 1 : 0;
gBTDrive.keyBack = focused && ((pAsync('S') | pAsync(0x28 /*VK_DOWN*/)) & dn) ? 1 : 0;
gBTDrive.keyLeft = focused && ((pAsync('A') | pAsync(0x25 /*VK_LEFT*/)) & dn) ? 1 : 0;
gBTDrive.keyRight = focused && ((pAsync('D') | pAsync(0x27 /*VK_RIGHT*/)) & dn) ? 1 : 0;
// WEAPON GROUPS (task #43, KEYBOARD only per user): three fire
// channels like three pod buttons -- 1/SPACE = lasers, 2 = PPCs,
// 3/CTRL = missiles. (Interim; the authentic system is the
// ConfigureMappables/ChooseButton mapper channels.)
gBTLaserKey = focused && ((pAsync('1') | pAsync(0x20 /*VK_SPACE*/)) & dn) ? 1 : 0;
gBTPPCKey = focused && (pAsync('2') & dn) ? 1 : 0;
gBTMissileKey = focused && ((pAsync('3') | pAsync(0x11 /*VK_CONTROL*/)) & dn) ? 1 : 0;
// The pod's 4TH fire button (Pinky, 0x45) -- previously
// unmapped on desktop, so any weapon the authored groups
// put there (the Avatar's NARC etc.) was unreachable.
gBTPinkyKey = focused && (pAsync('4') & dn) ? 1 : 0;
// task #6: HOLD 'G' opens the config session on the selected
// weapon (BT_CONFIG_SLOT, default: the first weapon); while
// held, the fire keys TOGGLE its group membership.
gBTConfigKey = focused && (pAsync('G') & dn) ? 1 : 0;
// task #12: F5..F8 assign the selected weapon to Generator
// A..D; F9 toggles Manual/Auto reconnect.
gBTGenSelKey =
(focused && (pAsync(0x74 /*F5*/) & dn)) ? 4
: (focused && (pAsync(0x75 /*F6*/) & dn)) ? 5
: (focused && (pAsync(0x76 /*F7*/) & dn)) ? 6
: (focused && (pAsync(0x77 /*F8*/) & dn)) ? 7
: (focused && (pAsync(0x78 /*F9*/) & dn)) ? 8
: 0;
// task #13: 'C' cycles the coolant valve (BT_VALVE_SLOT
// picks the condenser roster slot; default = Condenser1).
gBTValveKey = focused && (pAsync('C') & dn) ? 1 : 0;
// INPUT REMAP (2026-07-18): the raw GetAsyncKeyState reads
// moved behind the binding engine (btinput.cpp) -- keys +
// XInput pad resolve through content/CONTROLS.MAP (built-in
// WASD-classic profile when the file is absent). RIO lamp
// buttons / keypads / authentic pckey hotkeys are emitted
// into the controls manager inside the poll; the fire
// channels, actions, and analog demands publish through
// gBTInput. Everything below the reads (the lever/stick/
// twist integrators, detents, edge latches) is UNCHANGED.
BTInputPoll(dt);
gBTDrive.keyFwd = gBTInput.leverRate > 0.0001f ? 1 : 0;
gBTDrive.keyBack = gBTInput.leverRate < -0.0001f ? 1 : 0;
gBTDrive.keyLeft = (gBTInput.turnActive && gBTInput.turnTarget < 0.0f) ? 1 : 0;
gBTDrive.keyRight = (gBTInput.turnActive && gBTInput.turnTarget > 0.0f) ? 1 : 0;
// WEAPON GROUPS (task #43): the four pod joystick fire
// buttons -> the bring-up fire channels (0x40 Main = laser
// group, 0x46 ThumbLow = PPCs, 0x47 ThumbHigh = missiles,
// 0x45 Pinky = the 4th group). (Interim; the authentic
// system is the ConfigureMappables/ChooseButton mapper.)
gBTLaserKey = gBTInput.fireTrigger;
gBTPPCKey = gBTInput.fireThumbLow;
gBTMissileKey = gBTInput.fireThumbHigh;
gBTPinkyKey = gBTInput.firePinky;
// task #6: HOLD the ConfigHold action opens the config
// session on the selected weapon; while held, the fire
// keys TOGGLE its group membership.
gBTConfigKey = gBTInput.configHold;
// task #12: Generator1-4 actions assign the selected weapon
// to Generator A..D; Reconnect toggles Manual/Auto.
gBTGenSelKey = gBTInput.genSel;
// task #13: the Valve action cycles the coolant valve.
gBTValveKey = gBTInput.valve;
// TORSO CONTROLS (2026-07-13): 'M' cycles the control mode
// (Basic -> Standard -> Veteran -- the pod console button,
// CycleControlModeMessageHandler); Q/E deflect the torso
@@ -2604,19 +2574,23 @@ void
// become the pedals). Ramped like the turn stick.
{
static int sPrevM = 0;
const int mNow = focused && (pAsync('M') & dn) ? 1 : 0;
const int mNow = gBTInput.modeCycle;
if (mNow && !sPrevM) gBTModeCycle = 1; // edge -> one cycle
sPrevM = mNow;
// (task #68) 'V' HELD = look behind (the pod's rear-view
// button; releases back to the forward view). Rear-mounted
// (task #68) LookBehind action HELD = the pod's rear-view
// button (releases back to the forward view). Rear-mounted
// weapons (blackhawk/owens back racks) fire only in it.
gBTLookBehind = focused && (pAsync('V') & dn) ? 1 : 0;
const int tw = (focused && (pAsync('E') & dn) ? 1 : 0)
- (focused && (pAsync('Q') & dn) ? 1 : 0);
gBTLookBehind = gBTInput.lookBehind;
static float sTwist = 0.0f;
if (tw != 0)
if (gBTInput.twistAbsolute)
{
sTwist += tw * kStickRate * dt;
// pad stick: the axis IS the stick position (the
// spring is in the physical stick, not software)
sTwist = gBTInput.twistTarget;
}
else if (gBTInput.twistActive)
{
sTwist += gBTInput.twistTarget * kStickRate * dt;
if (sTwist > 1.0f) sTwist = 1.0f;
if (sTwist < -1.0f) sTwist = -1.0f;
}
@@ -2642,7 +2616,7 @@ void
// edge detector from the drive all-stop below -- both
// fire on the same press.
static int sPrevXT = 0;
const int xtNow = focused && (pAsync('X') & dn) ? 1 : 0;
const int xtNow = gBTInput.allStop;
if (xtNow && !sPrevXT)
{
sTwist = 0.0f;
@@ -2655,13 +2629,15 @@ void
// damage dispatcher + the beam-visual keepalive)
gBTDrive.fire = (gBTLaserKey || gBTPPCKey || gBTMissileKey || gBTPinkyKey) ? 1 : 0;
static int sPrevX = 0;
const int xNow = focused && (pAsync('X') & dn) ? 1 : 0;
const int xNow = gBTInput.allStop;
if (xNow && !sPrevX) gBTDrive.allStop = 1; // edge -> one all-stop
sPrevX = xNow;
// V: toggle the view between the authentic COCKPIT eyepoint
// (the pod's only view) and the external chase camera.
// ViewToggle action: the authentic COCKPIT eyepoint (the
// pod's only view) <-> the external chase camera. Split
// from LookBehind (2026-07-18): the old shim had BOTH on
// 'V', so holding rear-view also flipped the camera.
static int sPrevV = 0, sViewInside = 0;
const int vNow = focused && (pAsync('V') & dn) ? 1 : 0;
const int vNow = gBTInput.viewToggle;
if (vNow && !sPrevV)
{
sViewInside = !sViewInside;
@@ -2849,7 +2825,9 @@ void
}
}
}
float sweep = ((fwd ? 1.0f : 0.0f) - (back ? 1.0f : 0.0f)) * kLeverRate * dt;
// the configured rate (CONTROLS.MAP `axis Throttle rate n`) IS the
// sweep speed; kLeverRate above stays as the documented default
float sweep = gBTInput.leverRate * dt;
if (sweep != 0.0f && !sDetent)
{
const float prev = sLever;
@@ -2866,8 +2844,13 @@ void
if (sLever < -1.0f) sLever = -1.0f;
}
const float want = (gBTDrive.keyRight ? 1.0f : 0.0f) - (gBTDrive.keyLeft ? 1.0f : 0.0f);
if (want != 0.0f)
const float want = gBTInput.turnActive ? gBTInput.turnTarget : 0.0f;
if (gBTInput.turnAbsolute)
{
// pad stick: the axis IS the stick position (physically sprung)
sStick = want;
}
else if (want != 0.0f)
{
sStick += want * kStickRate * dt;
if (sStick > 1.0f) sStick = 1.0f;