Files
BT411/engine/MUNGA_L4/L4PADRIO.cpp
T
CydandClaude Opus 4.8 f338595685 Glass cockpit: per-display windows (BT_GLASS_PANELS)
Break the desktop glass cockpit's secondary displays out of the single
combined pad panel + D3D gauge strip into ONE window per pod display, each
carrying that display's surface with its RIO button bank, arranged
pod-faithfully around the main view.  New TU engine/MUNGA_L4/L4GLASSWIN.*
(BT_GLASS-only), selected by the -platform glass preset via the new runtime
gate BT_GLASS_PANELS (=0 falls back to the legacy single panel + dock).

- Surfaces are CPU-expanded from the shared gauge buffer
  (SVGA16::ExpandPlaneToBGRA, no D3D) and StretchDIBits'd in -- the D3D
  dev-composite path (dock / window / overlay) stands down while active.
- Buttons sit UNDER the imagery: big hidden click targets that reach into
  the display, with only a small protruding edge showing as a lamp light.
  Red MFD banks tile the top/bottom; radar rails run the sides + a bottom
  row; a Flight Controls window hosts the no-display banks.
- Windows: Heat / Engineering / Comm / Left Weapons / Right Weapons / radar
  + Flight Controls; edge-to-edge (no in-client margin/title; OS caption
  labels each).
- Fix blank surfaces: application/ghWnd are duplicate-defined and bind
  non-deterministically under /FORCE, so the fresh L4GLASSWIN TU read NULL.
  Reach the renderer/window via BTResolveGaugeRenderer/BTResolveMainWindow
  defined in btl4main.cpp off the real btl4App/hWnd pointers.  New gotcha:
  reconstruction-gotchas.md S6 duplicate-GLOBAL corollary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:43:40 -05:00

744 lines
20 KiB
C++

#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 <windows.h>
#include <xinput.h>
#include <stdlib.h>
#include <string.h>
#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];
memset(slewDelta, 0, sizeof(slewDelta));
memset(deflectHeld, 0, sizeof(deflectHeld));
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)
{
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)
{
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;
}
}
}
//
//-----------------------------------------------------------------
// 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];
}