Files
CydandClaude Fable 5 0ca6b5b01f RGB keyboard lamp mirror: vRIO Dynamic Lighting port, live in-game
The polish-backlog item, implemented from vRIO KeyboardLampMirror:
game-commanded RIO lamp states paint per-key RGB keyboards through
Windows Dynamic Lighting (WinRT LampArray). Keys bound to lamp
addresses in the active bindings profile glow with the panel palette
(red banks, yellow Secondary/Screen columns), flash modes use the
exact L4MFDVIEW formula so keyboard and on-screen buttons blink in
step, unbound keys are blacked out so the board reads as the button
field, and zone-lit keyboards fall back to a board-wide mirror of the
strongest lamp. Advantage over vRIO: Dynamic Lighting grants LEDs to
the FOREGROUND app - which is the game - so no Windows settings
dance.

Isolation: L4KEYLIGHT.cpp compiles /std:c++17 + DEFAULT packing +
conformance (per-file vcxproj settings; the engine /Zp1 would break
the WinRT ABI) with a scalars-only interface, and all WinRT work runs
on a private worker thread (watcher, claiming, 100ms paint loop).
On by default with a bindings map present; RP412KEYLIGHT=0 opts out;
missing Dynamic Lighting logs once and stays dormant.

Verified live on the dev laptop: claimed its 24-zone keyboard
(board-wide mirror) during a race; race cycling with per-race
start/stop of the mirror thread stays green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 10:49:35 -05:00

547 lines
14 KiB
C++

#include "mungal4.h"
#pragma hdrstop
#include "l4padrio.h"
#include "l4keylight.h"
#include <XInput.h>
#pragma comment(lib, "xinput9_1_0.lib")
//########################################################################
// Input helpers; the binding tables live in bindings.txt now
// (l4padbindings.cpp writes and parses the vRIO-format profile)
//########################################################################
namespace
{
Scalar StickValue(int raw, int dead_zone)
{
if (raw > -dead_zone && raw < dead_zone)
{
return (Scalar) 0;
}
Scalar value =
(raw > 0)
? (Scalar)(raw - dead_zone) / (Scalar)(32767 - dead_zone)
: (Scalar)(raw + dead_zone) / (Scalar)(32768 - dead_zone);
if (value > 1.0f) value = 1.0f;
if (value < -1.0f) value = -1.0f;
return value;
}
Scalar Clamp01(Scalar value)
{
if (value < 0.0f) return 0.0f;
if (value > 1.0f) return 1.0f;
return value;
}
Logical KeyDown(int virtual_key)
{
return (GetAsyncKeyState(virtual_key) & 0x8000) != 0;
}
void KeyLightLog(const char *line)
{
DEBUG_STREAM << line << "\n" << std::flush;
}
}
//########################################################################
//############################### PadRIO #################################
//########################################################################
PadRIO *PadRIO::activeInstance = NULL;
void
PadRIO::SetScreenButton(int unit, Logical pressed)
{
if (activeInstance != NULL && unit >= 0 && unit < buttonUnits)
{
activeInstance->screenButton[unit] = pressed ? 1 : 0;
}
}
int
PadRIO::GetLampState(int unit)
{
if (activeInstance != NULL && unit >= 0 && unit < lampCount)
{
return activeInstance->lampState[unit];
}
return 0;
}
PadRIO::PadRIO()
{
Check_Pointer(this);
queueHead = 0;
queueTail = 0;
lastPollTick = GetTickCount();
lastPadCheckTick = 0;
padIndex = -1;
padReported = False;
analogRequested = False;
throttleAccum = (Scalar) 0;
sentThrottle = sentLeftPedal = sentRightPedal = (Scalar) 0;
sentJoystickX = sentJoystickY = (Scalar) 0;
memset(buttonDown, 0, sizeof(buttonDown));
memset(keypadDown, 0, sizeof(keypadDown));
memset(lampState, 0, sizeof(lampState));
memset(screenButton, 0, sizeof(screenButton));
PadBindings_Load(&profile);
//
// RGB keyboard lamp mirror (Windows Dynamic Lighting): keys bound
// to lamp addresses glow with the panel. Yellow = the Secondary /
// Screen columns (0x10-0x1F), red = everything else, exactly like
// the physical panel and vRIO. RP412KEYLIGHT=0 opts out.
//
keyLightActive = False;
const char *keylight = getenv("RP412KEYLIGHT");
if (keylight == NULL || atoi(keylight) != 0)
{
int light_keys[PadBindingProfile::maxKeyButtons];
int light_addresses[PadBindingProfile::maxKeyButtons];
unsigned char light_yellow[PadBindingProfile::maxKeyButtons];
int light_count = 0;
for (int i = 0; i < profile.keyButtonCount; ++i)
{
int address = profile.keyButtons[i].address;
if (address >= buttonUnits)
{
continue; // keypads have no lamps
}
Logical duplicate = False;
for (int j = 0; j < light_count; ++j)
{
if (light_keys[j] == profile.keyButtons[i].virtualKey)
{
duplicate = True; // first binding wins
break;
}
}
if (duplicate)
{
continue;
}
light_keys[light_count] = profile.keyButtons[i].virtualKey;
light_addresses[light_count] = address;
light_yellow[light_count] =
(address >= 0x10 && address <= 0x1F) ? 1 : 0;
++light_count;
}
if (light_count > 0)
{
KeyLight_SetLogger(&KeyLightLog);
KeyLight_SetMap(light_keys, light_addresses, light_yellow, light_count);
KeyLight_Start();
keyLightActive = True;
}
}
invertX = False;
invertY = False;
const char *flip = getenv("L4PADFLIP");
if (flip != NULL)
{
if (strchr(flip, 'X') || strchr(flip, 'x'))
{
invertX = True;
}
if (strchr(flip, 'Y') || strchr(flip, 'y'))
{
invertY = True;
}
}
// Report as a v4.2 board, like vRIO does
MajorRevision = 4;
MinorRevision = 2;
activeInstance = this;
DEBUG_STREAM << "PadRIO: virtual RIO active (XInput pad + keyboard)\n" << std::flush;
}
PadRIO::~PadRIO()
{
Check_Pointer(this);
if (keyLightActive)
{
KeyLight_Stop();
keyLightActive = False;
}
if (activeInstance == this)
{
activeInstance = NULL;
}
}
Logical
PadRIO::TestInstance() const
{
return True;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// The controls manager drains events every frame; sampling lives here so
// button latency does not depend on the analog request cadence (which is
// 15 s outside of missions).
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Logical
PadRIO::GetNextEvent(RIOEvent *destinationPointer)
{
Check_Pointer(this);
Check_Pointer(destinationPointer);
PollInputs();
if (queueTail == queueHead)
{
return False;
}
*destinationPointer = eventQueue[queueTail];
queueTail = (queueTail + 1) % queueSize;
return True;
}
void
PadRIO::RequestAnalogUpdate()
{
Check_Pointer(this);
analogRequested = True;
}
void
PadRIO::GeneralReset()
{
Check_Pointer(this);
throttleAccum = (Scalar) 0;
Throttle = (Scalar) 0;
LeftPedal = (Scalar) 0;
RightPedal = (Scalar) 0;
JoystickX = (Scalar) 0;
JoystickY = (Scalar) 0;
analogRequested = True;
memset(lampState, 0, sizeof(lampState));
if (keyLightActive)
{
KeyLight_UpdateLamps(lampState, lampCount);
}
memset(keypadDown, 0, sizeof(keypadDown));
for (int i = 0; i < profile.keyButtonCount; ++i)
{
profile.keyButtons[i].latched = False;
profile.keyButtons[i].wasDown = False;
}
for (int i = 0; i < profile.padButtonCount; ++i)
{
profile.padButtons[i].latched = False;
profile.padButtons[i].wasDown = False;
}
}
void
PadRIO::ResetThrottle()
{
Check_Pointer(this);
throttleAccum = (Scalar) 0;
Throttle = (Scalar) 0;
analogRequested = True;
}
void
PadRIO::SetLamp(int lampNumber, int state)
{
Check_Pointer(this);
if (lampNumber >= 0 && lampNumber < lampCount)
{
lampState[lampNumber] = (unsigned char) state;
if (keyLightActive)
{
KeyLight_UpdateLamps(lampState, lampCount);
}
}
}
void
PadRIO::QueueEvent(const RIOEvent &an_event)
{
int next = (queueHead + 1) % queueSize;
if (next == queueTail)
{
// full: drop the oldest event
queueTail = (queueTail + 1) % queueSize;
}
eventQueue[queueHead] = an_event;
queueHead = next;
}
void
PadRIO::PollInputs()
{
unsigned long now = GetTickCount();
if (now - lastPollTick < 10)
{
return;
}
Scalar delta_t = (Scalar)(now - lastPollTick) / 1000.0f;
if (delta_t > 0.25f)
{
delta_t = 0.25f;
}
lastPollTick = now;
//---------------------------------------------------------------
// Find / keep the XInput pad. Probing empty slots is slow, so an
// absent pad is only re-probed every 3 seconds.
//---------------------------------------------------------------
XINPUT_STATE pad;
memset(&pad, 0, sizeof(pad));
Logical pad_live = False;
if (padIndex >= 0)
{
pad_live = (XInputGetState((DWORD) padIndex, &pad) == ERROR_SUCCESS);
if (!pad_live)
{
DEBUG_STREAM << "PadRIO: controller " << padIndex << " disconnected\n" << std::flush;
padIndex = -1;
}
}
if (padIndex < 0 && (now - lastPadCheckTick) >= 3000)
{
lastPadCheckTick = now;
for (DWORD i = 0; i < 4; ++i)
{
if (XInputGetState(i, &pad) == ERROR_SUCCESS)
{
padIndex = (int) i;
pad_live = True;
DEBUG_STREAM << "PadRIO: controller " << padIndex << " connected\n" << std::flush;
break;
}
}
if (padIndex < 0 && !padReported)
{
padReported = True;
DEBUG_STREAM << "PadRIO: no controller found - keyboard only\n" << std::flush;
}
}
//---------------------------------------------------------------
// Buttons: build the desired state from the binding profile
// (keyboard + pad, with toggle latches), merge the on-screen
// cockpit buttons, then diff against what we last reported.
// Keypad addresses (0x50-0x6F) collect separately - they become
// arcade KeyEvents, not button events.
//---------------------------------------------------------------
unsigned char desired[buttonUnits];
unsigned char keypadDesired[keypadUnits];
memset(desired, 0, sizeof(desired));
memset(keypadDesired, 0, sizeof(keypadDesired));
for (int i = 0; i < profile.keyButtonCount; ++i)
{
PadKeyButtonBinding *binding = &profile.keyButtons[i];
Logical down = KeyDown(binding->virtualKey);
if (binding->toggle && down && !binding->wasDown)
{
binding->latched = !binding->latched;
}
binding->wasDown = down;
if (binding->toggle ? binding->latched : down)
{
if (binding->address < buttonUnits)
{
desired[binding->address] = 1;
}
else if (binding->address >= 0x50 && binding->address < 0x50 + keypadUnits)
{
keypadDesired[binding->address - 0x50] = 1;
}
}
}
for (int i = 0; i < profile.padButtonCount; ++i)
{
PadPadButtonBinding *binding = &profile.padButtons[i];
Logical down = pad_live &&
(pad.Gamepad.wButtons & binding->padMask) != 0;
if (binding->toggle && down && !binding->wasDown)
{
binding->latched = !binding->latched;
}
binding->wasDown = down;
if (binding->toggle ? binding->latched : down)
{
if (binding->address < buttonUnits)
{
desired[binding->address] = 1;
}
else if (binding->address >= 0x50 && binding->address < 0x50 + keypadUnits)
{
keypadDesired[binding->address - 0x50] = 1;
}
}
}
for (int i = 0; i < buttonUnits; ++i)
{
if (screenButton[i])
{
desired[i] = 1;
}
}
for (int unit = 0; unit < buttonUnits; ++unit)
{
if (desired[unit] != buttonDown[unit])
{
buttonDown[unit] = desired[unit];
RIOEvent an_event;
an_event.Type = desired[unit] ? ButtonPressedEvent : ButtonReleasedEvent;
an_event.Data.Unit = unit;
QueueEvent(an_event);
}
}
//---------------------------------------------------------------
// Keypads: presses become the arcade RIO KeyEvents. Unit 0 is the
// pilot's internal keypad (0x50-0x5F), unit 1 the external
// operator keypad (0x60-0x6F); the key is the hex digit 0-15.
//---------------------------------------------------------------
for (int pad_key = 0; pad_key < keypadUnits; ++pad_key)
{
if (keypadDesired[pad_key] != keypadDown[pad_key])
{
keypadDown[pad_key] = keypadDesired[pad_key];
if (keypadDesired[pad_key])
{
RIOEvent an_event;
an_event.Type = KeyEvent;
an_event.Data.Keyboard.Unit = (pad_key >= 0x10) ? 1 : 0;
an_event.Data.Keyboard.Key = pad_key & 0x0F;
QueueEvent(an_event);
}
}
}
//---------------------------------------------------------------
// Axes, from the profile. 'deflect' sources sum into a springy
// position; 'rate' sources integrate the throttle (the pod's only
// sticky axis) by value per second.
//---------------------------------------------------------------
Scalar deflect[BindAxisCount];
Scalar rate[BindAxisCount];
memset(deflect, 0, sizeof(deflect));
memset(rate, 0, sizeof(rate));
for (int i = 0; i < profile.keyAxisCount; ++i)
{
const PadKeyAxisBinding *binding = &profile.keyAxes[i];
if (KeyDown(binding->virtualKey))
{
if (binding->mode == BindKeyRate)
{
rate[binding->axis] += binding->value;
}
else
{
deflect[binding->axis] += binding->value;
}
}
}
if (pad_live)
{
for (int i = 0; i < profile.padAxisCount; ++i)
{
const PadPadAxisBinding *binding = &profile.padAxes[i];
Scalar value = (Scalar) 0;
switch (binding->source)
{
case BindPadLeftStickX:
value = StickValue(pad.Gamepad.sThumbLX, (int)(binding->deadzone * 32767.0f));
break;
case BindPadLeftStickY:
value = StickValue(pad.Gamepad.sThumbLY, (int)(binding->deadzone * 32767.0f));
break;
case BindPadRightStickX:
value = StickValue(pad.Gamepad.sThumbRX, (int)(binding->deadzone * 32767.0f));
break;
case BindPadRightStickY:
value = StickValue(pad.Gamepad.sThumbRY, (int)(binding->deadzone * 32767.0f));
break;
case BindPadLeftTrigger:
value = (Scalar)(pad.Gamepad.bLeftTrigger) / 255.0f;
if (value <= binding->deadzone) value = (Scalar) 0;
break;
case BindPadRightTrigger:
value = (Scalar)(pad.Gamepad.bRightTrigger) / 255.0f;
if (value <= binding->deadzone) value = (Scalar) 0;
break;
}
if (binding->invert)
{
value = -value;
}
if (binding->rate > 0.0f)
{
rate[binding->axis] += value * binding->rate;
}
else
{
deflect[binding->axis] += value;
}
}
}
throttleAccum = Clamp01(throttleAccum + rate[BindAxisThrottle] * delta_t);
Scalar x = deflect[BindAxisJoystickX];
Scalar y = deflect[BindAxisJoystickY];
if (x > 1.0f) x = 1.0f;
if (x < -1.0f) x = -1.0f;
if (y > 1.0f) y = 1.0f;
if (y < -1.0f) y = -1.0f;
Throttle = Clamp01(throttleAccum + deflect[BindAxisThrottle]);
LeftPedal = Clamp01(deflect[BindAxisLeftPedal]);
RightPedal = Clamp01(deflect[BindAxisRightPedal]);
// The profile encodes the pod's stick sign convention; L4PADFLIP
// flips on top of it per axis.
JoystickX = invertX ? -x : x;
JoystickY = invertY ? -y : y;
//---------------------------------------------------------------
// Emit an analog event when asked to, or when anything moved
//---------------------------------------------------------------
Logical changed =
(Throttle != sentThrottle) ||
(LeftPedal != sentLeftPedal) ||
(RightPedal != sentRightPedal) ||
(JoystickX != sentJoystickX) ||
(JoystickY != sentJoystickY);
if (analogRequested || changed)
{
analogRequested = False;
sentThrottle = Throttle;
sentLeftPedal = LeftPedal;
sentRightPedal = RightPedal;
sentJoystickX = JoystickX;
sentJoystickY = JoystickY;
RIOEvent an_event;
an_event.Type = AnalogEvent;
an_event.Data.Unit = 0;
QueueEvent(an_event);
}
}