RGB keyboard lamp mirror + the shipped controls reference

KEYBOARD LAMP MIRROR (L4KEYLIGHT).  Ported from RP412, itself a port of vRIO's
KeyboardLampMirror.  Keys bound to a lamp address in bindings.txt glow with the
panel palette through Windows Dynamic Lighting -- yellow for the map's side
columns (0x10-0x1F), red for the rest -- flashing in step with the on-screen
buttons (its LampLevel copy matches the FIXED BTLampBrightnessOf).  Per-key
boards light each bound key; zone-lit boards mirror the strongest lamp
board-wide.  All WinRT runs on a private worker thread: watcher, claim, and a
100 ms paint loop that repaints only on change.

RP412's packing hazard does NOT transfer: it forces default struct packing there
because its engine is /Zp1, which would break the WinRT ABI.  BT411's BT_OPTS
carries no /Zp, so only the dialect flags are needed -- /std:c++17
/permissive- per-file, since the project otherwise builds C++14 /permissive.
The scalars-only interface is kept anyway so the isolation survives if packing
is ever added.

Wired in PadRIO: map built from bindings.keyBindings (ActionButton binds only,
first-binding-wins per key), fed from PadRIO::SetLamp, stopped in the dtor
(which hands the LEDs back to Windows).  BT_KEYLIGHT=0 opts out; a machine
without Dynamic Lighting logs once and stays dormant.

Verified live: claimed this machine's 24-zone keyboard and mirrors 25 bound
keys; BT_KEYLIGHT=0 and the pod profile produce zero keylight lines and run
clean.

SHIPPED CONTROLS REFERENCE.  docs/CONTROLS.md carries the keyboard table plus
the full 72-BUTTON POD MAP, grouped by the display each bank surrounds, with the
coolant-valve detent warning (1-5-50-CLOSED, one press past max shuts the loop)
and the jam/eject procedure.  mkdist flattens it to ASCII as CONTROLS.txt at the
zip root, the README's own idiom.  players/README.txt gained pointers to it, to
environ.ini, to -fit, and to the RGB mirror.

KB: context/glass-cockpit.md and docs/GLASS_COCKPIT.md updated for the whole
branch -- layout modes, L4RIOBANK and the under-glass rule, the letterbox fit
and its ordering trap, player-tunable displays, the measured legend grid, the
closed dead-button backlog, and this mirror.

Verified: five-mode regression (surround/exploded/dock/pod/dev) boots and
simulates with zero faults; mkdist writes a 5149-byte pure-ASCII CONTROLS.txt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-26 02:24:30 -05:00
co-authored by Claude Opus 5
parent 1debbeb240
commit 02b1b50e45
10 changed files with 1167 additions and 7 deletions
+406
View File
@@ -0,0 +1,406 @@
//===========================================================================//
// L4KEYLIGHT.cpp - Windows Dynamic Lighting keyboard mirror.
//
// Ported from RP412's L4KEYLIGHT (itself a port of vRIO's
// KeyboardLampMirror). Keys bound to a lamp address in bindings.txt glow
// with the panel palette, so the keyboard reads as the pod's button field.
//
// COMPILED APART FROM THE REST: /std:c++17 + conformance mode, per-file in
// CMakeLists (C++/WinRT needs both; the project builds C++14 /permissive).
// NOTE: RP412 also had to force DEFAULT struct packing here because its
// engine compiles /Zp1, which would break the WinRT ABI -- BT411 does NOT
// set /Zp (checked 2026-07-26: BT_OPTS is /permissive /W0 /wd4996 /EHsc
// /bigobj /MP), so that hazard does not apply. The scalars-only interface
// in l4keylight.h is kept anyway: it costs nothing and keeps this TU
// isolated if packing is ever added.
//
// Everything WinRT runs on a private worker thread: device watcher,
// keyboard claiming, and a 100 ms paint loop that mirrors the lamp bytes
// (the same flash formula as BTLampBrightnessOf, so the board and the
// on-screen cockpit buttons blink in step).
//===========================================================================//
#include <windows.h>
#include <atomic>
#include <mutex>
#include <thread>
#include <vector>
#include <cstring>
#include <cstdio>
#include <winrt/base.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Foundation.Collections.h>
#include <winrt/Windows.Devices.Enumeration.h>
#include <winrt/Windows.Devices.Lights.h>
#include <winrt/Windows.System.h>
#include <winrt/Windows.UI.h>
#pragma comment(lib, "windowsapp.lib")
#include "l4keylight.h"
namespace
{
using namespace winrt;
using namespace winrt::Windows::Devices::Enumeration;
using namespace winrt::Windows::Devices::Lights;
using winrt::Windows::UI::Color;
using winrt::Windows::System::VirtualKey;
struct KeyEntry
{
int virtualKey;
int address;
bool yellow;
};
struct ClaimedArray
{
hstring id;
LampArray array{ nullptr };
bool perKey = false;
bool baseCoated = false;
};
std::mutex gLock;
std::vector<KeyEntry> gMap;
unsigned char gLamps[64] = {};
void (*gLogger)(const char *) = nullptr;
std::atomic<bool> gRunning{ false };
std::thread gWorker;
void Log(const char *line)
{
void (*logger)(const char *);
{
std::lock_guard<std::mutex> hold(gLock);
logger = gLogger;
}
if (logger != nullptr)
{
logger(line);
}
}
void Logf(const char *format, ...)
{
char line[256];
va_list args;
va_start(args, format);
_vsnprintf_s(line, sizeof(line), _TRUNCATE, format, args);
va_end(args);
Log(line);
}
//---------------------------------------------------------------
// Lamp byte -> brightness level, animating the flash modes.
// MUST match BTLampBrightnessOf (l4vb16.h) exactly, so the board and
// the on-screen buttons blink together: solid shows state 1, flashing
// ALTERNATES state 1 and state 2 (RIO::LampState, L4RIO.h). Copied
// rather than included because this TU takes no engine headers.
//---------------------------------------------------------------
int LampLevel(int lamp_state)
{
int mode = lamp_state & 0x03;
int level1 = (lamp_state >> 2) & 0x03;
int level2 = (lamp_state >> 4) & 0x03;
if (mode == 0)
{
return level1;
}
static const int half_period[4] = { 0, 500, 250, 125 };
return ((GetTickCount() / half_period[mode]) & 1) ? level2 : level1;
}
//---------------------------------------------------------------
// The panel palette (vRIO's KeyboardLampMirror shades): red for
// the banks, yellow for Secondary/Screen; the off shade keeps the
// bound keys faintly visible so the board reads as a button field.
//---------------------------------------------------------------
Color Shade(int level, bool yellow)
{
Color color;
color.A = 255;
if (yellow)
{
if (level >= 3) { color.R = 245; color.G = 210; color.B = 60; }
else if (level >= 1) { color.R = 140; color.G = 118; color.B = 38; }
else { color.R = 70; color.G = 60; color.B = 24; }
}
else
{
if (level >= 3) { color.R = 230; color.G = 70; color.B = 70; }
else if (level >= 1) { color.R = 120; color.G = 50; color.B = 50; }
else { color.R = 64; color.G = 40; color.B = 40; }
}
return color;
}
bool SameColor(const Color &a, const Color &b)
{
return a.A == b.A && a.R == b.R && a.G == b.G && a.B == b.B;
}
//---------------------------------------------------------------
// The worker: watcher + claim + paint loop
//---------------------------------------------------------------
void Worker()
{
try
{
init_apartment();
}
catch (...)
{
// apartment already set on this thread; carry on
}
std::mutex claimedLock;
std::vector<ClaimedArray> claimed;
bool anySeen = false;
DeviceWatcher watcher{ nullptr };
try
{
watcher = DeviceInformation::CreateWatcher(LampArray::GetDeviceSelector());
watcher.Added([&](DeviceWatcher const &, DeviceInformation const &info)
{
try
{
LampArray array = LampArray::FromIdAsync(info.Id()).get();
if (array == nullptr ||
array.LampArrayKind() != LampArrayKind::Keyboard)
{
return; // mice / strips / cases stay untouched
}
anySeen = true;
ClaimedArray entry;
entry.id = info.Id();
entry.array = array;
entry.perKey = array.SupportsVirtualKeys();
{
std::lock_guard<std::mutex> hold(claimedLock);
claimed.push_back(entry);
}
Logf(entry.perKey
? "KeyLight: + %ls (%d LEDs, per-key)"
: "KeyLight: + %ls (%d zones - board-wide mirror)",
info.Name().c_str(), (int) array.LampCount());
}
catch (...)
{
Log("KeyLight: could not open a lamp array");
}
});
watcher.Removed([&](DeviceWatcher const &, DeviceInformationUpdate const &update)
{
std::lock_guard<std::mutex> hold(claimedLock);
for (size_t i = 0; i < claimed.size(); ++i)
{
if (claimed[i].id == update.Id())
{
claimed.erase(claimed.begin() + i);
Log("KeyLight: keyboard disconnected");
break;
}
}
});
watcher.Updated([](DeviceWatcher const &, DeviceInformationUpdate const &)
{
// required for the watcher to progress
});
watcher.Start();
}
catch (...)
{
Log("KeyLight: Dynamic Lighting unavailable on this system");
return;
}
//
// Paint loop: 100 ms cadence, repaint only on change
//
std::vector<Color> lastColors;
int waited = 0;
while (gRunning.load())
{
Sleep(50);
waited += 50;
if (waited < 100)
{
continue;
}
waited = 0;
std::vector<KeyEntry> map;
unsigned char lamps[64];
{
std::lock_guard<std::mutex> hold(gLock);
map = gMap;
memcpy(lamps, gLamps, sizeof(lamps));
}
std::vector<Color> colors(map.size());
std::vector<VirtualKey> keys(map.size());
int bestLevel = 0;
bool bestYellow = false;
bool changed = (lastColors.size() != map.size());
for (size_t i = 0; i < map.size(); ++i)
{
int address = map[i].address;
int level = (address >= 0 && address < 64)
? LampLevel(lamps[address]) : 0;
if (level > bestLevel)
{
bestLevel = level;
bestYellow = map[i].yellow;
}
colors[i] = Shade(level, map[i].yellow);
keys[i] = (VirtualKey) map[i].virtualKey;
if (!changed && !SameColor(colors[i], lastColors[i]))
{
changed = true;
}
}
std::lock_guard<std::mutex> hold(claimedLock);
bool freshClaim = false;
for (ClaimedArray &entry : claimed)
{
if (!entry.baseCoated)
{
freshClaim = true;
}
}
if (!changed && !freshClaim)
{
continue;
}
Color aggregate = Shade(bestLevel, bestYellow);
for (ClaimedArray &entry : claimed)
{
try
{
if (entry.perKey)
{
if (!entry.baseCoated)
{
Color black;
black.A = 255; black.R = 0; black.G = 0; black.B = 0;
entry.array.SetColor(black);
entry.baseCoated = true;
}
if (!map.empty())
{
entry.array.SetColorsForKeys(
array_view<Color const>(colors.data(), colors.data() + colors.size()),
array_view<VirtualKey const>(keys.data(), keys.data() + keys.size()));
}
}
else
{
entry.baseCoated = true;
entry.array.SetColor(aggregate);
}
}
catch (...)
{
// device wobble; the watcher handles removal
}
}
lastColors = colors;
}
//
// Releasing the arrays hands the LEDs back to Windows
//
try
{
watcher.Stop();
}
catch (...)
{
}
{
std::lock_guard<std::mutex> hold(claimedLock);
claimed.clear();
}
if (!anySeen)
{
Log("KeyLight: no Dynamic Lighting keyboard was found this session");
}
}
}
//########################################################################
// The scalar interface (safe across the packing boundary)
//########################################################################
void
KeyLight_SetLogger(void (*logger)(const char *line))
{
std::lock_guard<std::mutex> hold(gLock);
gLogger = logger;
}
void
KeyLight_SetMap(
const int *virtual_keys,
const int *addresses,
const unsigned char *yellow,
int count
)
{
std::lock_guard<std::mutex> hold(gLock);
gMap.clear();
gMap.reserve(count);
for (int i = 0; i < count; ++i)
{
KeyEntry entry;
entry.virtualKey = virtual_keys[i];
entry.address = addresses[i];
entry.yellow = (yellow[i] != 0);
gMap.push_back(entry);
}
}
void
KeyLight_UpdateLamps(const unsigned char *lamp_state, int count)
{
if (count > 64)
{
count = 64;
}
std::lock_guard<std::mutex> hold(gLock);
memcpy(gLamps, lamp_state, count);
}
void
KeyLight_Start()
{
if (gRunning.exchange(true))
{
return;
}
gWorker = std::thread(Worker);
}
void
KeyLight_Stop()
{
if (!gRunning.exchange(false))
{
return;
}
if (gWorker.joinable())
{
gWorker.join();
}
}
+90
View File
@@ -12,6 +12,7 @@
#include "l4glasswin.h"
#include "l4ctrl.h"
#include "l4joy.h"
#include "l4keylight.h" // RGB keyboard lamp mirror (scalars-only interface)
#include <windows.h>
#include <xinput.h>
@@ -63,6 +64,17 @@ int
// XInput normalization: thumbs to -1..1 past the stock deadzone, triggers
// to 0..1 past the stock threshold.
//
//
// The lamp mirror's log sink. It cannot use DEBUG_STREAM itself: its TU
// takes no engine headers (l4keylight.h is scalars only), so it hands us
// finished lines instead.
//
static void
KeyLightLog(const char *line)
{
DEBUG_STREAM << "[keylight] " << (line ? line : "") << "\n" << std::flush;
}
static float
NormalizeThumb(int value, int dead_zone)
{
@@ -170,6 +182,67 @@ PadRIO::PadRIO():
flipStickAxes =
(getenv("L4PADFLIP") != NULL && *getenv("L4PADFLIP") != '0');
//
// RGB KEYBOARD LAMP MIRROR (Windows Dynamic Lighting, l4keylight.h):
// every key bound to a lamp ADDRESS glows with the panel palette --
// yellow for the Secondary/Screen columns (0x10-0x1F), red for the rest,
// exactly like the on-screen buttons. Keypad binds have no lamp, so
// they are skipped; a key bound twice takes its FIRST binding.
// BT_KEYLIGHT=0 opts out; a machine without Dynamic Lighting logs once
// and stays dormant.
//
keyLightActive = False;
{
const char *gate = getenv("BT_KEYLIGHT");
if (gate == NULL || atoi(gate) != 0)
{
static int lightKeys[192];
static int lightAddresses[192];
static unsigned char lightYellow[192];
int count = 0;
for (int k = 0; k < bindings.keyBindingCount && count < 192; ++k)
{
const PadBindingProfile::KeyBinding &binding = bindings.keyBindings[k];
if (binding.action.kind != PadBindingProfile::ActionButton)
{
continue; // axes/keypads carry no lamp
}
int address = binding.action.address;
if (address < 0 || address >= LampCount)
{
continue;
}
Logical duplicate = False;
for (int j = 0; j < count; ++j)
{
if (lightKeys[j] == binding.virtualKey)
{
duplicate = True; // first binding wins
break;
}
}
if (duplicate)
{
continue;
}
lightKeys[count] = binding.virtualKey;
lightAddresses[count] = address;
lightYellow[count] = (unsigned char)
((address >= 0x10 && address <= 0x1F) ? 1 : 0);
++count;
}
if (count > 0)
{
KeyLight_SetLogger(KeyLightLog);
KeyLight_SetMap(lightKeys, lightAddresses, lightYellow, count);
KeyLight_Start();
keyLightActive = True;
DEBUG_STREAM << "[keylight] mirroring " << count
<< " bound key(s) onto RGB keyboards\n" << std::flush;
}
}
}
//
// Never revision 0.0 -- some diagnostics print it; give the synthetic
// board a recognizable version.
@@ -199,6 +272,11 @@ PadRIO::PadRIO():
PadRIO::~PadRIO()
{
if (keyLightActive)
{
KeyLight_Stop(); // hands the LEDs back to Windows
keyLightActive = False;
}
BTGlassPanels_Destroy(); // safe no-op if the glass windows were never created
BTPadPanel_Destroy();
if (activeInstance == this)
@@ -1038,6 +1116,18 @@ void
if (lampNumber >= 0 && lampNumber < LampCount)
{
lampState[lampNumber] = state;
if (keyLightActive)
{
// The mirror keeps its own copy (its paint loop runs on a private
// thread and must not reach into the device); the first 64
// addresses are the whole lamp field.
unsigned char bytes[64];
for (int i = 0; i < 64; ++i)
{
bytes[i] = (unsigned char) lampState[i];
}
KeyLight_UpdateLamps(bytes, 64);
}
}
}
+5
View File
@@ -143,6 +143,11 @@ protected:
int
lampState[LampCount];
// RGB keyboard lamp mirror running? (l4keylight.h; BT_KEYLIGHT=0 off,
// also stays False when no bound key carries a lamp address)
Logical
keyLightActive;
//
// Typed-channel suppression tables (built from the loaded bindings +
// the hardcoded view/preset keys; see SuppressKey).
+53
View File
@@ -0,0 +1,53 @@
//===========================================================================//
// File: l4keylight.h //
// Project: MUNGA Brick: RGB keyboard lamp mirror //
// Contents: Windows Dynamic Lighting bridge (vRIO's KeyboardLampMirror) //
//---------------------------------------------------------------------------//
// Copyright (C) 1994-1995, Virtual World Entertainment, Inc. //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
#pragma once
//########################################################################
// Mirrors the game-commanded RIO lamp states onto per-key RGB keyboards
// through Windows Dynamic Lighting (the WinRT LampArray API), following
// vRIO's KeyboardLampMirror: keys bound to lamp addresses glow with the
// panel palette (red for the banks, yellow for the Secondary/Screen
// columns), flash modes blink at the panel's rates, other keys are
// blacked out so the keyboard reads as the button field. Zone-lit
// keyboards mirror the strongest lamp board-wide. Dynamic Lighting
// grants LED control to the FOREGROUND app - which is the game itself,
// so no Windows settings dance is needed.
//
// The implementation is C++/WinRT on a private worker thread, compiled
// with default struct packing (the engine builds /Zp1, which would
// break the WinRT ABI) - hence this interface is scalars only. All
// functions are safe to call when Dynamic Lighting is unavailable;
// failures log once and the mirror stays dormant.
//########################################################################
// Where the mirror's status lines go (the caller owns the sink).
void
KeyLight_SetLogger(void (*logger)(const char *line));
// The key map: parallel arrays of virtual keys, their RIO lamp
// addresses (0x00-0x47), and whether each paints yellow (Secondary /
// Screen columns) instead of red.
void
KeyLight_SetMap(
const int *virtual_keys,
const int *addresses,
const unsigned char *yellow,
int count
);
// Latest game-commanded lamp bytes (indexed by RIO lamp number).
void
KeyLight_UpdateLamps(const unsigned char *lamp_state, int count);
// Claim keyboards and start painting / release the LEDs to Windows.
void
KeyLight_Start();
void
KeyLight_Stop();