#include "mungal4.h" #pragma hdrstop //########################################################################### // L4PADRIO -- the hardware-less cockpit device (BT_GLASS only; this TU is // only in the build when the gate is on -- see CMakeLists.txt). // Design + input model: L4PADRIO.h. //########################################################################### #include "l4padrio.h" #include "l4padpanel.h" #include "l4glasswin.h" #include "l4ctrl.h" #include #include #include #include #pragma comment(lib, "xinput9_1_0.lib") PadRIO *PadRIO::activeInstance = NULL; // // Pending backtick/V view-toggle edges (set in Poll, consumed by the game's // view-toggle block through BTPadViewToggleEdge). // int gBTPadViewToggleEdges = 0; // // The desktop per-MFD preset-page cycle edges (J/K/L -> Mfd1/2/3), consumed // by L4MechControlsMapper::InterpretControls (btl4mppr.cpp step 3b -- the // same seam the dev-build mech4 poll feeds). Defined in mech4.cpp (always // compiled), so the glass TU externs it -- keyboard reconciliation // 2026-07-20: J/K/L are the CONTROLS.MAP muscle-memory keys and there is no // single pod button that "cycles" an MFD (the pod's bank buttons are // mode-mask-gated direct selects), so the cycle stays a port-side sender // with the authentic SetPresetMode body. // extern int gBTPresetCycle[3]; int BTPadViewToggleEdge(void) { if (gBTPadViewToggleEdges > 0) { --gBTPadViewToggleEdges; return 1; } return 0; } // // XInput normalization: thumbs to -1..1 past the stock deadzone, triggers // to 0..1 past the stock threshold. // static float NormalizeThumb(int value, int dead_zone) { float sign = (value < 0) ? -1.0f : 1.0f; float magnitude = (float)(value < 0 ? -value : value); if (magnitude <= (float)dead_zone) { return 0.0f; } if (magnitude > 32767.0f) { magnitude = 32767.0f; } return sign * (magnitude - dead_zone) / (32767.0f - dead_zone); } static float NormalizeTrigger(int value) { if (value <= XINPUT_GAMEPAD_TRIGGER_THRESHOLD) { return 0.0f; } return (float)(value - XINPUT_GAMEPAD_TRIGGER_THRESHOLD) / (float)(255 - XINPUT_GAMEPAD_TRIGGER_THRESHOLD); } // // The keyboard is live only while a window of THIS process is foreground // (the mech4.cpp focus-guard idiom) -- alt-tabbed developers must not // drive the mech. // static int ProcessHasFocus() { // // BT_KEY_NOFOCUS=1: automation harnesses read keys without focus // (the same override the btinput binding engine honors). // static int s_noFocus = -1; if (s_noFocus < 0) { const char *value = getenv("BT_KEY_NOFOCUS"); s_noFocus = (value != 0 && *value == '1') ? 1 : 0; } if (s_noFocus) { return 1; } HWND foreground = GetForegroundWindow(); if (foreground == NULL) { return 0; } DWORD process_id = 0; GetWindowThreadProcessId(foreground, &process_id); return process_id == GetCurrentProcessId(); } //########################################################################### // Construction //########################################################################### PadRIO::PadRIO(): RIOBase(), eventHead(0), eventTail(0), lastPollMilliseconds(0), lastPadProbeMilliseconds(0), padIndex(-1), previousPadButtons(0) { memset(previousKeyHeld, 0, sizeof(previousKeyHeld)); memset(channelValue, 0, sizeof(channelValue)); memset(lampState, 0, sizeof(lampState)); bindings.Load(); BuildKeySuppression(); // // Per-channel spring return rate = the fastest deflect rate bound to // the channel (a channel with no deflect bindings never auto-centers). // for (int c = 0; c < PadBindingProfile::ChannelCount; ++c) { channelReturnRate[c] = 0.0f; } for (int k = 0; k < bindings.keyBindingCount; ++k) { const PadBindingProfile::Action &action = bindings.keyBindings[k].action; if (action.kind == PadBindingProfile::ActionAxisDeflect) { float rate = action.rate < 0.0f ? -action.rate : action.rate; if (rate > channelReturnRate[action.channel]) { channelReturnRate[action.channel] = rate; } } } flipStickAxes = (getenv("L4PADFLIP") != NULL && *getenv("L4PADFLIP") != '0'); // // Never revision 0.0 -- some diagnostics print it; give the synthetic // board a recognizable version. // MajorRevision = 9; MinorRevision = 9; activeInstance = this; DEBUG_STREAM << "[padrio] PadRIO up (XInput probe pending; keyboard " << "live on focus; L4PADFLIP=" << flipStickAxes << ")\n" << std::flush; // // The on-screen cockpit buttons ride the device. BT_GLASS_PANELS (the // glass preset default) breaks each secondary display into its own window // with its RIO bank around it; otherwise the single combined pad panel // (BT_PAD_PANEL=1) is used. // if (BTGlassPanelsActive()) { BTGlassPanels_Create(); } else if (getenv("BT_PAD_PANEL") != NULL && *getenv("BT_PAD_PANEL") != '0') { BTPadPanel_Create(); } } PadRIO::~PadRIO() { BTGlassPanels_Destroy(); // safe no-op if the glass windows were never created BTPadPanel_Destroy(); if (activeInstance == this) { activeInstance = NULL; } } //########################################################################### // Typed-channel suppression (the btinput sSuppressChar/sSuppressKeyUp // pattern -- btinput.cpp AddSuppression -- rebuilt here because btinput // stands down whenever a cockpit device owns the input path). A bound key // must NOT also reach the 1995 in-cockpit keyboard dispatcher: 'w' selects // pilot 0, 'a'/'s'/'d'/'f'/'g' flip MFD2 preset pages, letter/numpad // KEY-UP VK values alias onto lowercase hotkeys (VK_F5==0x74=='t' = pilot // select 3, VK_NUMPAD2==0x62=='b' = MFD3 Quad, ...). Unbound keys keep // their authentic 1995 typed meaning. //########################################################################### void PadRIO::AddKeySuppression(int virtual_key) { // // WM_KEYUP delivers the raw VK; every consumer downstream compares // typed CHARACTERS, so the VK value itself is the alias to swallow. // if (virtual_key >= 0 && virtual_key < 256) { suppressKeyUp[virtual_key] = 1; } // // WM_CHAR delivers typed characters: both cases of a letter, the digit // itself (main row AND numpad -- VK_NUMPAD0..9 type '0'..'9'), space, // and the base punctuation of the Oem keys. // if (virtual_key >= 'A' && virtual_key <= 'Z') { suppressChar[virtual_key + ('a' - 'A')] = 1; suppressChar[virtual_key] = 1; } else if (virtual_key >= '0' && virtual_key <= '9') { suppressChar[virtual_key] = 1; } else if (virtual_key >= VK_NUMPAD0 && virtual_key <= VK_NUMPAD9) { suppressChar['0' + (virtual_key - VK_NUMPAD0)] = 1; } else if (virtual_key == VK_SPACE) { suppressChar[' '] = 1; suppressKeyUp[VK_SPACE] = 1; } else { static const struct { int vk; char ch; } oem[] = { { VK_OEM_MINUS, '-' }, { VK_OEM_PLUS, '=' }, { VK_OEM_COMMA, ',' }, { VK_OEM_PERIOD, '.' }, { VK_OEM_2, '/' }, { VK_OEM_3, '`' }, { VK_OEM_4, '[' }, { VK_OEM_5, '\\' }, { VK_OEM_6, ']' }, { VK_OEM_1, ';' }, { VK_OEM_7, '\'' }, { VK_RETURN, '\r' }, { VK_TAB, '\t' }, { VK_BACK, '\b' }, }; for (int i = 0; i < (int)(sizeof(oem) / sizeof(oem[0])); ++i) { if (oem[i].vk == virtual_key) { suppressChar[(unsigned char)oem[i].ch] = 1; } } } // // The generic modifier VK is what WM_KEYUP reports for L/R variants. // if (virtual_key == VK_LSHIFT || virtual_key == VK_RSHIFT) { suppressKeyUp[VK_SHIFT] = 1; } if (virtual_key == VK_LCONTROL || virtual_key == VK_RCONTROL) { suppressKeyUp[VK_CONTROL] = 1; } } void PadRIO::BuildKeySuppression() { memset(suppressChar, 0, sizeof(suppressChar)); memset(suppressKeyUp, 0, sizeof(suppressKeyUp)); for (int k = 0; k < bindings.keyBindingCount; ++k) { AddKeySuppression(bindings.keyBindings[k].virtualKey); } // // The hardcoded keys (Poll): backtick + V = view toggle, J/K/L = the // per-MFD preset-page cycle. // AddKeySuppression(VK_OEM_3); AddKeySuppression('V'); AddKeySuppression('J'); AddKeySuppression('K'); AddKeySuppression('L'); } int PadRIO::SuppressKey(unsigned int key_value, int is_char) { if (activeInstance == NULL || key_value > 255) { return 0; // no glass device / ALT_BIT-tagged value } return is_char ? activeInstance->suppressChar[key_value] : activeInstance->suppressKeyUp[key_value]; } //########################################################################### // Event queue //########################################################################### void PadRIO::PushEvent(const RIOEvent &event) { int next = (eventHead + 1) % EventQueueSize; if (next == eventTail) { DEBUG_STREAM << "[padrio] event queue overflow -- event dropped\n" << std::flush; return; } eventQueue[eventHead] = event; eventHead = next; } void PadRIO::EmitButton(int address, int pressed) { RIOEvent event; event.Type = pressed ? ButtonPressedEvent : ButtonReleasedEvent; event.Data.Unit = address; PushEvent(event); } void PadRIO::EmitKeypad(int unit, int key) { RIOEvent event; event.Type = KeyEvent; event.Data.Keyboard.Unit = unit; event.Data.Keyboard.Key = key; PushEvent(event); } //########################################################################### // The poll -- one pass per frame (time-gated so the manager's drain loop // terminates; an AnalogEvent is emitted every pass to keep the manager's // five-scalar push running, matching the serial board's analog cadence). //########################################################################### void PadRIO::Poll() { unsigned long now = timeGetTime(); float dt = (lastPollMilliseconds == 0) ? 0.0f : (float)(now - lastPollMilliseconds) * 0.001f; if (dt > 0.1f) { dt = 0.1f; // resumed from a stall -- don't slam the integrators } lastPollMilliseconds = now; // //----------------------------------------------------------------- // XInput: hot-plug probe every ~3 s, then read the connected pad. //----------------------------------------------------------------- // XINPUT_STATE pad_state; int pad_connected = 0; if (padIndex >= 0) { if (XInputGetState(padIndex, &pad_state) == ERROR_SUCCESS) { pad_connected = 1; } else { DEBUG_STREAM << "[padrio] XInput pad " << padIndex << " disconnected\n" << std::flush; padIndex = -1; previousPadButtons = 0; } } if (padIndex < 0 && (lastPadProbeMilliseconds == 0 || now - lastPadProbeMilliseconds >= 3000)) { lastPadProbeMilliseconds = now; for (int slot = 0; slot < 4; ++slot) { if (XInputGetState(slot, &pad_state) == ERROR_SUCCESS) { padIndex = slot; pad_connected = 1; DEBUG_STREAM << "[padrio] XInput pad found in slot " << slot << "\n" << std::flush; break; } } } // //----------------------------------------------------------------- // Keyboard bindings: edges fire button/keypad events; held keys // accumulate axis motion. All keys read as RELEASED without focus // so held buttons let go when the developer alt-tabs. //----------------------------------------------------------------- // int focused = ProcessHasFocus(); // // The backtick view toggle (per Cyd: ` = 1st/3rd person in the glass // cockpit) + V (the CONTROLS.MAP ViewToggle key -- keyboard // reconciliation 2026-07-20). Edge-detected here (async poll, // message-path-free) and consumed by the game's view-toggle block via // BTPadViewToggleEdge. // { static int s_backtickWas = 0; int backtick_held = focused && (((GetAsyncKeyState(VK_OEM_3) & 0x8000) != 0) || ((GetAsyncKeyState('V') & 0x8000) != 0)); if (backtick_held && !s_backtickWas) { extern int gBTPadViewToggleEdges; ++gBTPadViewToggleEdges; } s_backtickWas = backtick_held; } // // J/K/L: cycle the Mfd1/Mfd2/Mfd3 preset page (the CONTROLS.MAP keys; // the L4 mapper consumes gBTPresetCycle and runs the authentic // SetPresetMode body -- btl4mppr.cpp CyclePresetModeNow). // { static int s_presetWas[3] = { 0, 0, 0 }; static const int s_presetKey[3] = { 'J', 'K', 'L' }; for (int g = 0; g < 3; ++g) { int held = focused && (GetAsyncKeyState(s_presetKey[g]) & 0x8000) != 0; if (held && !s_presetWas[g]) { gBTPresetCycle[g] = 1; } s_presetWas[g] = held; } } float slewDelta[PadBindingProfile::ChannelCount]; int deflectHeld[PadBindingProfile::ChannelCount]; int slewHeld[PadBindingProfile::ChannelCount]; memset(slewDelta, 0, sizeof(slewDelta)); memset(deflectHeld, 0, sizeof(deflectHeld)); memset(slewHeld, 0, sizeof(slewHeld)); for (int k = 0; k < bindings.keyBindingCount; ++k) { const PadBindingProfile::KeyBinding &binding = bindings.keyBindings[k]; int held = focused && (GetAsyncKeyState(binding.virtualKey) & 0x8000) != 0; int was_held = previousKeyHeld[k]; previousKeyHeld[k] = (unsigned char)held; switch (binding.action.kind) { case PadBindingProfile::ActionButton: if (held != was_held) { EmitButton(binding.action.address, held); } break; case PadBindingProfile::ActionKeypad: if (held && !was_held) { EmitKeypad(binding.action.address, binding.action.key); } break; case PadBindingProfile::ActionAxisDeflect: if (held) { deflectHeld[binding.action.channel] = 1; channelValue[binding.action.channel] += binding.action.rate * dt; } break; case PadBindingProfile::ActionAxisSlew: if (held) { slewHeld[binding.action.channel] = 1; slewDelta[binding.action.channel] += binding.action.rate * dt; } break; case PadBindingProfile::ActionAxisSet: if (held && !was_held) { channelValue[binding.action.channel] = binding.action.rate; } break; } } // // Spring return: a deflect-managed channel with no deflect key held // re-centers at its fastest bound rate. // for (int c = 0; c < PadBindingProfile::ChannelCount; ++c) { channelValue[c] += slewDelta[c]; if (!deflectHeld[c] && channelReturnRate[c] > 0.0f) { float step = channelReturnRate[c] * dt; if (channelValue[c] > step) { channelValue[c] -= step; } else if (channelValue[c] < -step) { channelValue[c] += step; } else { channelValue[c] = 0.0f; } } } // //----------------------------------------------------------------- // Pad: button edges + axis writes (direct absolute past the // deadzone; slew axes integrate). //----------------------------------------------------------------- // if (pad_connected) { unsigned buttons = pad_state.Gamepad.wButtons; for (int b = 0; b < bindings.padButtonBindingCount; ++b) { const PadBindingProfile::PadButtonBinding &binding = bindings.padButtonBindings[b]; int held = (buttons & binding.buttonMask) != 0; int was_held = (previousPadButtons & binding.buttonMask) != 0; if (held == was_held) { continue; } if (binding.action.kind == PadBindingProfile::ActionButton) { EmitButton(binding.action.address, held); } else if (binding.action.kind == PadBindingProfile::ActionKeypad && held) { EmitKeypad(binding.action.address, binding.action.key); } } previousPadButtons = buttons; for (int a = 0; a < bindings.padAxisBindingCount; ++a) { const PadBindingProfile::PadAxisBinding &binding = bindings.padAxisBindings[a]; float raw = 0.0f; switch (binding.axis) { case PadBindingProfile::PadAxisLX: raw = NormalizeThumb(pad_state.Gamepad.sThumbLX, XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE); break; case PadBindingProfile::PadAxisLY: raw = NormalizeThumb(pad_state.Gamepad.sThumbLY, XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE); break; case PadBindingProfile::PadAxisRX: raw = NormalizeThumb(pad_state.Gamepad.sThumbRX, XINPUT_GAMEPAD_RIGHT_THUMB_DEADZONE); break; case PadBindingProfile::PadAxisRY: raw = NormalizeThumb(pad_state.Gamepad.sThumbRY, XINPUT_GAMEPAD_RIGHT_THUMB_DEADZONE); break; case PadBindingProfile::PadAxisLT: raw = NormalizeTrigger(pad_state.Gamepad.bLeftTrigger); break; case PadBindingProfile::PadAxisRT: raw = NormalizeTrigger(pad_state.Gamepad.bRightTrigger); break; } if (binding.invert) { raw = -raw; } if (binding.slew) { if (raw != 0.0f) { slewHeld[binding.channel] = 1; } channelValue[binding.channel] += raw * binding.slewRate * dt; } else if (raw != 0.0f) { // // Direct absolute: a deflected pad axis owns the channel; // centered (inside the deadzone) it leaves the keyboard // integration alone. // channelValue[binding.channel] = raw; } } } // //----------------------------------------------------------------- // GAIT DETENT (keyboard/pad accommodation, ported from the pod-build // lever -- mech4.cpp "GAIT DETENT", 2026-07-20 glass regression): the // gait SM has NO stable state for a demand between the walk cycle's // cap (walkStrideLength @0x534) and the run engage speed // (reverseSpeedMax @0x538) -- a lever PARKED in that band hunts // walk <-> run forever, retriggering the authored EngineShiftFwd/Rev // samples each swing. The pod's PHYSICAL throttle plausibly rested // only at mechanical notches [T4]; reproduce the pod-build bridge's // accommodation EXACTLY: when the throttle slew is AT REST (no slew // key held, no pad slew axis deflected), snap the lever out of the // dead band to the NEARER edge. Sweeping THROUGH the band while // held stays continuous -- an authentic moving lever, firing the one // authentic shift. The band arrives in lever units from the player // mech via the mech4.cpp seam (hi <= lo = no band known yet). //----------------------------------------------------------------- // { extern float gBTGaitDetentLo, gBTGaitDetentHi; // mech4.cpp seam const float band_lo = gBTGaitDetentLo; const float band_hi = gBTGaitDetentHi; float &lever = channelValue[PadBindingProfile::ChannelThrottle]; if (!slewHeld[PadBindingProfile::ChannelThrottle] && band_hi > band_lo && lever > band_lo && lever < band_hi) { const float snapped = (lever - band_lo < (band_hi - band_lo) * 0.5f) ? band_lo : band_hi; { static int s_dLog = 0; if (getenv("BT_GAIT_TRACE") && s_dLog++ < 40) DEBUG_STREAM << "[gaitdetent] pad lever " << lever << " in dead band [" << band_lo << "," << band_hi << ") -> " << snapped << "\n" << std::flush; } lever = snapped; } } // //----------------------------------------------------------------- // Clamp and publish the control surface. Throttle is the 0..1 // lever the mapper detents at 1.0; the rest are -1..1. //----------------------------------------------------------------- // for (int c = 0; c < PadBindingProfile::ChannelCount; ++c) { float low = (c == PadBindingProfile::ChannelThrottle) ? 0.0f : -1.0f; if (channelValue[c] < low) channelValue[c] = low; if (channelValue[c] > 1.0f) channelValue[c] = 1.0f; } // // PORT SIGN (user-reported inversion, closed with live sign algebra // 2026-07-18): the mapper interprets the WIRE convention -- stick // right = NEGATIVE JoystickX (the real RIO hardware / vRIO calibration // convention; the keyboard bridge compensates by negating once, // cb82d8c). Measured: wire stickX=-1 -> turnDemand=-1 = the same // demand the user-verified D-key-RIGHT produces -- so screen-sign // publish was inverted. X therefore publishes NEGATED; Y stays // screen-sign. L4PADFLIP still flips both on top. // float stick_sign = flipStickAxes ? -1.0f : 1.0f; Throttle = (Scalar)channelValue[PadBindingProfile::ChannelThrottle]; JoystickX = (Scalar)(stick_sign * -channelValue[PadBindingProfile::ChannelJoystickX]); JoystickY = (Scalar)(stick_sign * channelValue[PadBindingProfile::ChannelJoystickY]); LeftPedal = (Scalar)channelValue[PadBindingProfile::ChannelLeftPedal]; RightPedal = (Scalar)channelValue[PadBindingProfile::ChannelRightPedal]; // // The analog heartbeat: tells the manager to run the five-scalar // push this frame (LBE4ControlsManager::Execute gates the push on // new_RIO_values). // RIOEvent analog; analog.Type = AnalogEvent; analog.Data.Unit = 0; PushEvent(analog); } //########################################################################### // RIOBase surface //########################################################################### Logical PadRIO::GetNextEvent(RIOEvent *destinationPointer) { Check_Pointer(destinationPointer); if (eventTail == eventHead) { // // Queue drained: poll at most once per millisecond tick so the // manager's per-frame drain loop terminates (the poll always // enqueues the analog heartbeat). // unsigned long now = timeGetTime(); if (now == lastPollMilliseconds) { return False; } Poll(); } if (eventTail == eventHead) { return False; } *destinationPointer = eventQueue[eventTail]; eventTail = (eventTail + 1) % EventQueueSize; return True; } void PadRIO::SetLamp(int lampNumber, int state) { if (lampNumber >= 0 && lampNumber < LampCount) { lampState[lampNumber] = state; } } //########################################################################### // The on-screen panel entries //########################################################################### Logical PadRIO::IsActive() { return activeInstance != NULL; } void PadRIO::SetScreenButton(int unit, int pressed) { if (activeInstance == NULL) { return; } // // The keypad address space (0x50-0x6F, the vRIO panel's two 4x4 hex // keypads): press emits a keypad KeyEvent -- internal (0x50) on the // pilot unit, external (0x60) on the operator unit; keys have no // release event. // if (unit >= 0x50 && unit <= 0x6F) { if (pressed) { activeInstance->EmitKeypad((unit >= 0x60) ? 1 : 0, unit & 0x0F); } return; } if (unit < 0 || unit >= LBE4ControlsManager::ButtonCount) { return; } activeInstance->EmitButton(unit, pressed); } int PadRIO::GetLampState(int unit) { if (activeInstance == NULL || unit < 0 || unit >= LampCount) { return 0; } return activeInstance->lampState[unit]; }