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
+9
View File
@@ -268,7 +268,16 @@ target_sources(munga_engine PRIVATE
"engine/MUNGA_L4/L4RIOBANK.cpp"
"engine/MUNGA_L4/L4GLASSWIN.cpp"
"engine/MUNGA_L4/L4PLASMAWIN.cpp"
"engine/MUNGA_L4/L4KEYLIGHT.cpp"
)
# RGB keyboard lamp mirror (Windows Dynamic Lighting). C++/WinRT needs C++17
# AND conformance mode, while the rest of the project builds C++14 /permissive
# -- so this ONE file gets its own flags. Its interface (l4keylight.h) is
# scalars only, so nothing else has to change dialect. (RP412 additionally
# forced default struct packing here because its engine is /Zp1; BT411 sets no
# /Zp, so that part does not apply -- see the file header.)
set_source_files_properties("engine/MUNGA_L4/L4KEYLIGHT.cpp" PROPERTIES
COMPILE_OPTIONS "/std:c++17;/permissive-")
target_include_directories(munga_engine BEFORE PRIVATE
"${CMAKE_SOURCE_DIR}/engine/shim"
"${DXSDK}/Include")
+211 -4
View File
@@ -63,6 +63,211 @@ version skew (a stale steam exe shipped 2 days behind), tripled build time, and
([[reconstruction-gotchas]]); everything links always, so this class of trap no longer varies
by build flavor.
## Layout modes — ONE resolver (2026-07-26) [T2 four-mode boot-verified]
Where the five MFDs + radar go is a SEPARATE axis from the platform profile. Under a glass boot
(`BT_DEV_GAUGES` set) `btl4main.cpp` resolves it **once**, right after the profile putenvs land,
into `glassLayout`; the boot banner, the pad-panel decision and the window-sizing block all read
that one answer. Precedence, highest first:
| Env | Layout | Buttons live in |
|---|---|---|
| `BT_GLASS_PANELS`≠0 | per-display cockpit windows (the "exploded" view, `L4GLASSWIN`) | each display's own window |
| `BT_DEV_GAUGES_WINDOW` | the legacy separate MFD window | the single combined pad panel |
| `BT_DEV_GAUGES_DOCK` | the docked bottom gauge strip | the single combined pad panel |
| `BT_COCKPIT=0` | ...also the docked strip (the documented opt-out) | the single combined pad panel |
| (nothing) | **COCKPIT SURROUND — the glass default** | the main window (D3D-drawn lamps) |
The banner now names the live one: `[boot] platform profile: GLASS (PadRIO + plasma window)
[secondary displays: cockpit surround]`.
**Two bugs this replaced** (both were live until 2026-07-26): the mode was decided in TWO places
with duplicated precedence (the glass profile block picked `BT_PAD_PANEL`/`BT_GLASS_PANELS`; the
sizing block re-derived cockpit-vs-dock), and the banner read NEITHER — it announced "per-display
cockpit windows [BT_GLASS_PANELS]" for every glass boot, surround included. Worse, the profile
block turned `BT_COCKPIT=0` into `BT_GLASS_PANELS=1`, so the docked strip it is documented to
select was **unreachable under the glass profile**. `BT_GLASS_PANELS` is now explicit-only, and
dock/window modes auto-raise `BT_PAD_PANEL` so the 72-button field always has a home.
## ONE button-bank geometry — L4RIOBANK (2026-07-26) [T2 click-verified both modes]
The RIO button field around a pod display is drawn TWICE — composited into the main window
(surround, `L4VB16.cpp`) and one window per display (exploded, `L4GLASSWIN.cpp`) — and each
renderer used to carry its own copy of the geometry. They drifted badly: the same MFD button was
a 156×138 under-glass target in the exploded window and a **76×24 sliver entirely outside the
glass** in the surround. `engine/MUNGA_L4/L4RIOBANK.cpp` is now that geometry, once; both
renderers are consumers, and placement (WHERE each display goes) stays with each renderer because
that genuinely differs.
**The under-glass rule** (ported from RP412 `L4MFDVIEW`, which took it from the pod): a button
reaches **half the glass** in BEHIND the display and only a **lamp strip** clears the edge. The
renderer paints buttons first, imagery over them — so the lamp reads as a slim bar, practically
the whole display is the press target, and nothing is occluded (the picture wins every pixel it
covers). The strip scales off the display's SHORT axis against the native 480 (an MFD is 640×480
landscape, the map 480×640 portrait — both short-axis 480) with a 6px floor, so a half-scale
surround lamp is still a bar and not a smudge. The MFD per-column nudge against the unevenly
spaced DISPLAY/PROGRAM legends (`mfdColumnDX`) moved here too, so both renderers inherit the
same alignment.
**The map's foot row is pushed FIRST**, deliberately: the side columns now reach half the map
each, so they cover the foot band too, and the hit test takes the first match — foot-first is
what keeps 0x16/0x17/0x1F/0x1E reachable. That class of bug is why the verification below tests
first-hit reachability, not mere presence.
**Verified 2026-07-26 [T2]:** `BT_RIOBANK_LOG=1` dumps every bank's rects;
`scratchpad/checkbank.py` reports the per-bank census and proves no address is SHADOWED (has a
point no earlier button covers); `scratchpad/clickbank.py` then posts a real click at every
button's centre. Both modes: **72/72 addresses placed, zero shadowed, zero duplicated, 72/72
dispatched** (`[cockpit] CLICK` / `[glasswin] CLICK`), process survived 144 posted messages per
mode. Pod + dev profiles boot and simulate un-regressed. Surround BT_SHOT confirms the legends
are clear and the lamps read as edge strips.
## The cockpit SCALES — uniform-fit letterbox + `-fit` (2026-07-26) [T2 live]
The canvas is a fixed size and D3D9 stretched it into whatever the client area was, so a window
dragged to a different shape **squashed the instruments** — the projection was aspect-corrected
(task #20) but the panels, lamps and MFD glass were not. Now the canvas is fitted at ONE uniform
scale, centred, leftover black: the cockpit scales both ways and never distorts, whatever the
window.
- `BTCockpitFitRect(clientW, clientH, RECT*)` — integer math throughout, so the rect is stable
frame to frame (a float scale wobbles a pixel and the bars shimmer during a drag).
- D3D9 does the scaling at Present via a **destination rect**, which `D3DSWAPEFFECT_DISCARD`
forbids — so the WINDOWED swap effect becomes **`D3DSWAPEFFECT_COPY`** when the surround is up
and multisampling is off (COPY cannot multisample). `gBTCockpitLetterbox` records whether that
happened; when 0 the old full-client stretch stands, so an MSAA run still works.
- Both present sites take the rect (scene + the wait-screen overlay, which paints into the same
backbuffer). `WM_SIZE` invalidates so the bars repaint black — a COPY present only writes its
dest rect, so stale bar content would otherwise persist.
- **The click transform had to follow.** `BTCockpitMouseDown` mapped client→canvas against the
full client; under the letterbox that drifts the hit test off every button by the bar width.
It now runs the identical fit (and a click on a bar hits nothing).
- **World aspect** under the letterbox is the view rect's OWN aspect — the client no longer
enters into it.
- **`-fit`** (alias `-windowed-fullscreen`): borderless `WS_POPUP` over the monitor, canvas
letterboxed inside. Verified on a 3440×1440 ultrawide.
**Ordering trap found live:** the letterbox flag was first set at device creation, but the
first `WM_SIZE` arrives BEFORE the device exists — a `-fit` boot logged `aspect=3.14` (the
stretch formula) and applied it on frame 1, because nothing resizes the window again. The INTENT
is now decided in `btl4main` alongside `gBTGaugeCockpit`; `L4VIDEO` only confirms or withdraws it.
**Capture note:** `PrintWindow` returns an all-black frame for the borderless `-fit` window
(it works for the normal chrome'd one). Use a screen-region grab (`scratchpad/shot.ps1`) for
`-fit`, and don't read a black PrintWindow there as a render failure — it isn't.
Verified: wide (1684×661) and tall (744×961) drags both letterbox with zero distortion; **72/72
buttons still dispatch through the transform at 0.62× scale with a 389px bar**; exploded / dock /
pod / dev all keep DISCARD and boot un-regressed.
## Player-tunable displays — scale, map placement, environ.ini (2026-07-26) [T2 live]
The pod bolted its instruments down at one size; a desktop panel has room to trade viewscreen for
instrument, so the player scales them. All resolved ONCE (`BTCkResolvedSizes`, L4VB16.cpp) because
the surround recomputes its layout every frame.
| Env | Effect |
|---|---|
| `BT_MFD_SCALE` | all five MFDs, % of pod size (25-200, default 100) |
| `BT_MFD_SCALE_UL/_UC/_UR/_LL/_LR` | one display, overriding the group |
| `BT_RADAR_SCALE` | the portrait map |
| `BT_RADAR_POS` | `CENTER` (default) / `LEFT` / `RIGHT` / `MIDLEFT` / `MIDRIGHT` (+ `BOTTOM*`, `*CENTER` spellings) |
**The bands derive from the resolved sizes** — that is why the sizes could not stay constants: the
surround band a display hangs in has to grow with it or the canvas clips it. Each band is what its
LARGEST occupant needs (a corner MFD only claims `size - kCkOVL`, since it overlaps the view), and
the bottom band drops to just the lower MFDs when the map leaves that row for a side. Verified:
100% reproduces the historical `L276 R276 T223 B336` exactly; 150% gives `L436 R436 T343 B448`;
80% + MIDLEFT gives `L256 R212 T175 B148`.
**A corner map goes flush to the CANVAS edge**, not the view edge, and the lower MFD whose corner
it took sits beside it. Measuring off the view edge (first cut) overlapped them by the band width —
232px of map drawn over the Right Weapons MFD.
**Map legend grid — MEASURED, not inherited [T2].** The side columns must line up with the six
cells the map paints beside them (⊕MAP / ⊖MAP / IR / crouch / searchlight / display-mode =
0x10-0x15 — the imagery independently confirms our address map). `scratchpad/measurelegend.py` over
a native 480×640 capture gives **top 3, cell 102, pitch 107** on the 640 span. RP412's map is
`13 + 6×102 @ pitch 105` — same cell height, different top and pitch, so **its numbers do not
transfer**; ours are ours. Our old even division (`displayH/6 + 1 = 107`) had the pitch right by
luck and sat 3px high of the labels. Top and bottom are now scaled separately and subtracted, so
rounding cannot drift the buttons out of step with the labels down the column.
**`environ.ini` (the settings file) [T2].** ⚠ It was read ~300 lines into `WinMain`, AFTER the
platform-profile block had run its `getenv`s — so every setting the profile reads (`BT_PLATFORM`,
`BT_COCKPIT`, `BT_GLASS_PANELS`, `BT_DEV_GAUGES`, `L4CONTROLS`…) was **silently ignored from the
file** and only worked as a real env var. It also `putenv`'d comment lines verbatim, which would
have turned a shipped commented-out option into a variable literally named `#BT_MFD_SCALE`. Now:
loaded immediately after the first-breath boot line, comments and blanks skipped, and **the real
environment WINS** so a launcher `.bat` overrides the file rather than fighting it. A documented
default is written on first run when absent — the `bindings.txt` convention: untracked, so an
extract-over-top upgrade never clobbers a player's settings, and every option ships commented out
so a fresh install behaves exactly as before. Verified all three: first run writes it and applies
0 settings; uncommented file settings drive the layout (130% group + 60% UC + MIDRIGHT); a real
`BT_MFD_SCALE=90` overrides the file's 130 while the file's other settings still apply.
Verified: **72/72 placed, 0 shadowed, 72/72 dispatched** at baseline, at 150%/135%, and at
75% + BOTTOMRIGHT; exploded / dock / pod / dev boot un-regressed.
## Dead-button backlog CLOSED — DuckRequest was the last (2026-07-26) [T2 live]
The 2026-07-20 audit's "8 buttons dispatch a streamed message with no reconstructed handler" is
finished. ⚠ Only ONE of the eight was actually still missing when this pass started — generator
on/off, ToggleSeekVoltage, EjectAmmo, ToggleCooling and BalanceCoolant had all landed between
07-20 and 07-25 while `pod-hardware.md` / `open-questions.md` still called them dead
(`docs/INPUT_PATH_AUDIT.md` had already flagged the census as "stale in both directions").
**Check the code, not the census.**
**`Mech::DuckRequest` @0049fa00 (id 0x1a, RIO 0x13 — the manual's CROUCH button).** The binary's
entire body is two lines: press-only (`msg+0xc > 0`), then `duckState`(`mech+0x398`) = 1. It is a
one-shot REQUEST flag, not a posture toggle — the handler never clears it and the only other
writer in the whole binary is the mech reset (part_012.c:9439, the same reset that zeroes
`incomingLock`). Registered in `Mech::MessageHandlerEntries`; `[duck]` log line on press.
**Its consumer is a DATABINDING, not code [T1] — which is why the flag looked pointless.**
`duckState` has ZERO readers anywhere in the decomp, because it is published as attribute 0x37 and
consumed through the gauge system: `content/GAUGE/L4GAUGE.CFG` runs a 3-frame `bduck.pcc`
`oneOfSeveralPixInt` widget bound to `DuckState` on the map's legend column ("crouch mode: button
4"). Verified live: pressing 0x13 turns that legend icon grey→orange. **Do not invent a crouch
pose to "finish" this** — the flag plus the indicator IS the code side.
Bonus confirmation: those legend widgets sit at gauge offsets 537/430/322/215/108 — a **107
pitch**, independently corroborating the map legend grid measured from pixels in §Player-tunable
displays.
## RGB keyboard lamp mirror — L4KEYLIGHT (2026-07-26) [T2 live, claimed a real board]
Ported from RP412 (itself 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. Gate: `BT_KEYLIGHT=0` opts out; no Dynamic Lighting = one log line, then dormant.
**RP412's packing hazard does NOT apply here.** It compiles that file with forced default struct
packing because *its* engine is `/Zp1`, which would break the WinRT ABI. BT411's `BT_OPTS` is
`/permissive /W0 /wd4996 /EHsc /bigobj /MP`**no `/Zp`** — so only the dialect flags are needed
(`/std:c++17 /permissive-`, per-file in CMakeLists, 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 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).
Verified: `[keylight] mirroring 25 bound key(s)` + claimed this machine's 24-zone keyboard;
`BT_KEYLIGHT=0` and the pod profile produce zero keylight lines and run clean.
## Lamp flash decode was wrong — fixed (2026-07-26) [T1, from the L4RIO.h enum]
`BTLampBrightnessOf` (l4vb16.h) returned `max(state1, state2)` and blanked to 0 on the alternate
phase. The engine's own enum [T0] says otherwise: bits 0-1 = flash mode, bits 2-3 = **state 1**
brightness, bits 4-5 = **state 2** brightness (0 off / 1 dim / 3 bright) — solid shows state 1,
flashing ALTERNATES the two at 500/250/125 ms. The old formula agrees only when one state is Off,
which is why it survived: the Panic lamp is `flashFast+state1Off+state2Bright` and L4LAMP's other
pulse is off/dim. But **`L4LAMP.cpp:252` commands `flashFast + state1Dim + state2Bright`** — a
dim→bright pulse that rendered as a hard bright→off blink. RP412's `L4MFDVIEW::LampLevel` has the
faithful formula; that is now ours. THREE copies of the decode existed (l4vb16.h, L4GLASSWIN,
L4PADPANEL), all three with the bug; the two locals now forward to the one inline.
## The pre-mission WAITING SCREEN (2026-07-22) [T2]
Every pre-run state (seat wait, roster full, WaitingForEgg, LoadingMission, WaitingForLaunch)
paints an animated overlay -- Consolas green text + a 12-segment spinner phased on
@@ -199,8 +404,9 @@ copied from them.
dead panel: `MechRIOMapper`'s message ids were chained one enum too high (0x19-0x2a) — the
binary RIO table @0051dd30 re-registers the BASE aux/zoom ids 3..0x13 (Hotbox 0x1a); fixed
ungated + static_assert-locked (`btl4mppr.hpp`) → all 24 MFD-bank + 2 zoom buttons live
(52/72 panel buttons now work; 8 dead await handler reconstruction — Gitea backlog issue;
12 have no streamed mapping authored, authentically inert). **Keyboard = the ~20 core
(52/72 panel buttons worked at the time; the 8 dead ones are ALL wired as of 2026-07-26 —
`DuckRequest` was the last, see §Dead-button backlog closed; 12 have no streamed mapping
authored, authentically inert). **Keyboard = the ~20 core
gameplay actions on the CONTROLS.MAP keys; the PANEL covers every pod address by click**
(right-click = hold latch) — the merged default profile lives in `L4PADBINDINGS.cpp`
(auto-writes `content\bindings.txt`; W/S/A/D/Q/E/R/F/X drive, 1-4/Space/Ctrl fire, M/N/H/C =
@@ -243,8 +449,9 @@ copied from them.
(`SVGA16::ExpandPlaneToBGRA`, no D3D) and `StretchDIBits`'d in, so the **whole D3D
dev-composite path (dock / separate window / overlay) stands down** while these are up
(`BTDrawGaugeInset`/`BTGaugeWindowRenderAndPresent` early-return; no `gBTGaugeDockBottom`
strip). Runtime gate **`BT_GLASS_PANELS`** (default ON under `-platform glass`, `=0` falls
back to the single pad panel + docked gauges) — read by `L4PADRIO` (which panel to create),
strip). Runtime gate **`BT_GLASS_PANELS`** (⚠ was "default ON under `-platform glass`" — NO
LONGER TRUE; the surround is the glass default and panels are explicit-only, see §Layout modes
below) — read by `L4PADRIO` (which panel to create),
`L4VB16` (suppress compositing), and `btl4main` (skip the world-window dock strip). The
display↔bank map (spine of the feature): Heat 0x28-2F, Mfd2 0x20-27, Comm 0x30-37,
Mfd1 0x08-0F, Mfd3 0x00-07, sec 0x10-1B ([[pod-hardware]] §Bank→MFD). Verified: build +
+119
View File
@@ -0,0 +1,119 @@
# BattleTech 4.11 — controls reference
Two ways to fly: the **keyboard/pad** (the ~25 core actions) and the **cockpit panel**, where
every one of the pod's 72 illuminated buttons is mouse-clickable exactly where it sat in the
cabinet. Left-click presses; **right-click latches** a button held.
Rebind keys in `content\bindings.txt` (delete it to regenerate the defaults).
Cockpit layout and display sizes live in `content\environ.ini` (likewise self-documenting).
---
## Keyboard
| Key | Action | | Key | Action |
|---|---|---|---|---|
| W/S or ↑/↓ | throttle lever | | X or NumPad5 | all stop |
| A/D or ←/→ | turn pedals | | Alt | reverse thrust |
| Q/E | torso twist | | Shift | throttle up |
| R/F | torso aim | | NumPad 8/2/4/6 | torso aim/twist |
| 1 or Space | main trigger | | 2 | middle thumb |
| 3 or Ctrl | upper thumb (missiles) | | 4 | pinky |
| V | external camera | | B | look behind |
| M | control mode (BAS/MID/ADV) | | N | schematic |
| J/K/L | MFD preset pages | | H (hold) | coolant flush |
| C | cycle a coolant valve | | G (hold) + fire | regroup a weapon |
| / = | volume down/up | | F5F9 | generator select / mode |
**You spawn in BASIC control mode.** In BASIC the stick steers and the pedal keys do nothing.
Press **M** once for MID, where the pedals steer and the stick twists the torso — the full
experience. If "turning doesn't work", this is why.
Numpad aim keys need **NumLock ON**.
> Control mode (BAS/MID/ADV, the M key) is *not* the menu's experience setting
> (novice/standard/veteran/expert). That one changes the simulation — heat, damage — not your
> controls.
---
## The cockpit panel — all 72 pod buttons
Each display carries the bank of buttons mounted around it in the real cabinet. Click anywhere on
a display: the buttons reach *under* the glass, so the whole screen is a press target and only the
lit strip at the edge shows.
### Heat / coolant display — the loop controls
| Button | Action |
|---|---|
| `0x28` | **Balance coolant** — equalise every condenser valve |
| `0x29``0x2F` | the six **condenser valves** (Cond 6…1) and the **coolant flush** (`0x2C`, hold) |
⚠ The valve detents run 1 - 5 - 50 (max) - **0 (CLOSED)** - back to 1. One press past max shuts
that loop off and everything on it overheats. Boosting one loop starves the others — the supply
is shared.
### Weapon and engineering displays
| Bank | Display |
|---|---|
| `0x00``0x07` | Right Weapons (lower right) |
| `0x08``0x0F` | Left Weapons (lower left) |
| `0x20``0x27` | Engineering (upper centre) |
| `0x30``0x37` | Comm / target hotbox (upper right) — pilot select |
Each display's buttons are **page-gated**: on the Quad page the outer buttons jump straight to a
populated Eng page; on an Eng page they drive the subsystem shown — generator select AD, bus
mode, coolant toggle, trigger configure, and **eject** (tap to clear a jam, hold ~3 s to jettison
a burning ammo bay).
### Map / radar — the side columns
| Button | Action | | Button | Action |
|---|---|---|---|---|
| `0x10` | map zoom in | | `0x18` | cycle control mode (BAS/MID/ADV) |
| `0x11` | map zoom out | | `0x1A``0x1D` | generator AD on/off |
| `0x12` | thermal / IR sight | | `0x16`,`0x17`,`0x19`,`0x1E`,`0x1F` | no function (as shipped in 1995) |
| `0x13` | **crouch** | | | |
| `0x14` | searchlight | | | |
| `0x15` | cycle display mode | | | |
### Flight controls
| Button | Action |
|---|---|
| `0x3D` | panic / config lamp |
| `0x3F` | throttle head — reverse thrust |
| `0x40` | main trigger |
| `0x41``0x44` | hat: behind / torso centre / right / left |
| `0x45``0x47` | pinky, middle thumb, upper thumb — the mappable fire buttons |
| `0x38``0x3C`, `0x3E` | intercom and door lines — no function on a desktop |
---
## Controllers
An **Xbox controller** works out of the box.
**Flight sticks, HOTAS and pedals:** run `joyconfig.bat` once. A wizard asks you to move each
control in turn — stick, twist, throttle, fire buttons — writes the bindings, and drops you into
the solo menu to try them. It only rewrites its own section of `bindings.txt`, so hand edits
elsewhere in the file survive.
**RGB keyboards** with Windows Dynamic Lighting mirror the panel: any key you have bound to a
lamp button glows in the panel's own colours and flashes in step with the on-screen buttons. Turn
it off with `BT_KEYLIGHT=0` in `environ.ini`.
---
## Cockpit layout
Set these in `content\environ.ini`:
- **`BT_MFD_SCALE`** (and `_UL`/`_UC`/`_UR`/`_LL`/`_LR`) — size of the five MFDs, as a percentage
of their pod size. Turn them up if you want to actually read them while you fly.
- **`BT_RADAR_SCALE`**, **`BT_RADAR_POS`** — size the map, and move it out of the middle of the
road: `CENTER`, `LEFT`, `RIGHT`, `MIDLEFT`, `MIDRIGHT`.
- **`BT_GLASS_PANELS=1`** — break every display out into its own desktop window instead of the
composited cockpit. Good for reading one display at full size.
Run with **`-fit`** for a borderless window over the whole monitor. The cockpit scales to any
window shape at one uniform scale, so nothing ever stretches — a window that isn't the cockpit's
shape just gets black bars.
+246 -3
View File
@@ -385,9 +385,10 @@ on the pod monitor. This reuses the plasma-window CPU→window pattern and sides
D3D dev-composite bug family (additional-swap-chain lifetime, device-state save/restore,
depth-stencil mismatch, layered strobing).
**Gate: `BT_GLASS_PANELS`** (runtime env; `BTGlassPanelsActive()`). Default ON under
`-platform glass` (preset putenv in `btl4main.cpp`); `=0` restores the legacy single pad panel
+ docked gauge strip. Wired at three seams:
**Gate: `BT_GLASS_PANELS`** (runtime env; `BTGlassPanelsActive()`). **Was** default-ON under
`-platform glass` (preset putenv in `btl4main.cpp`) — as of 2026-07-26 the COCKPIT SURROUND is the
glass default and this gate is **explicit-only**; the one resolver + the full precedence table are
in `context/glass-cockpit.md` §Layout modes. Wired at three seams:
- `L4PADRIO` ctor: `BTGlassPanelsActive()``BTGlassPanels_Create()` instead of
`BTPadPanel_Create()`; dtor destroys both (safe no-ops).
- `L4VB16`: `BTDrawGaugeInset` / `BTGaugeWindowRenderAndPresent` early-return when
@@ -439,3 +440,245 @@ Verified after the fix (`BT_GLASS_LOG`): `[glass] 'Heat MFD' port=Heat mask=0x40
enable=2 ow=640 oh=480 nonzero=22227/307200`, `[glass] 'Secondary / Radar' port=sec mask=0x3f
pal=1 enable=3 ow=480 oh=640 nonzero=26206/307200` — surfaces resolve and carry live pixels.
`BT_GLASS_LOG` left in as a permanent per-surface resolve/pixel diagnostic (throttled).
## 2026-07-26 — Layout modes get ONE resolver; button geometry gets ONE owner (L4RIOBANK)
The RP412 replication pass, step 1 of the agreed plan (port the geometry, keep our renderers).
### The mode resolver (btl4main.cpp)
Where the secondary displays go was decided in TWO places with duplicated precedence -- the glass
profile block picked `BT_PAD_PANEL`/`BT_GLASS_PANELS`, the window-sizing block re-derived
cockpit-vs-dock -- and the boot banner read NEITHER, announcing "per-display cockpit windows
[BT_GLASS_PANELS]" for every glass boot including the surround default. The split had also
broken `BT_COCKPIT=0`: documented as the dock-bottom opt-out, the profile block converted it to
`BT_GLASS_PANELS=1`, so **the docked strip was unreachable under the glass profile**.
Now: one `glassLayout` resolved after the profile putenvs land, consumed by the banner, the
pad-panel decision and the sizing block. Precedence `BT_GLASS_PANELS` > `BT_DEV_GAUGES_WINDOW` >
`BT_DEV_GAUGES_DOCK` > `BT_COCKPIT=0` > surround default; `BT_GLASS_PANELS` is explicit-only now
(it was auto-set); dock/window auto-raise `BT_PAD_PANEL` so the 72-button field always has a home.
Banner: `[boot] platform profile: GLASS (PadRIO + plasma window) [secondary displays: <mode>]`.
Verified all four modes boot to the mode they name, plus pod/dev.
### L4RIOBANK -- the shared field
`engine/MUNGA_L4/L4RIOBANK.cpp/.h` (new TU). Both renderers were carrying their own copy of the
button geometry and had drifted: an MFD button was 156x138 reaching under the glass in the
exploded window, and a **76x24 sliver entirely OUTSIDE the glass** in the surround. One module
now owns it, both are consumers, placement stays per-renderer.
The under-glass rule, from RP412 `L4MFDVIEW` (which took it from the pod): reach half the glass
in behind the display, leave a lamp strip clearing the edge, paint buttons first and imagery
over. Strip scales off the display's SHORT axis vs the native 480 (MFD 640x480 landscape, map
480x640 portrait) with a 6px floor. The MFD per-column nudge against the DISPLAY/PROGRAM legends
moved here so both renderers inherit one alignment. The map's foot row is pushed FIRST because
the side columns now cover that band and the hit test takes the first match.
Retired: `L4GLASSWIN`'s `PlaceCellAt`/`PlaceLine`/`PlaceRect` and its `RedCellH`/`EdgeProtrude`/
`RailW`/`RailSlotAdd`/`RailGap`/`RedOffsetX`/`kRedColDX` constants; the surround's inline MFD/
radar/flight button loops. `kCkRedH`/`kCkLamp` survive only as band reserves for the canvas math.
### Lamp flash decode [T1 -> fixed]
`BTLampBrightnessOf` returned `max(state1,state2)` and blanked on the alternate phase. Per the
engine's own `RIO::LampState` enum [T0] solid shows state 1 and flashing ALTERNATES state 1 and
state 2. Agrees only when one state is Off -- true for Panic (`flashFast+state1Off+state2Bright`)
which is why it survived -- but `L4LAMP.cpp:252` commands `flashFast+state1Dim+state2Bright`, a
dim->bright pulse that rendered as bright->off. Three copies existed (l4vb16.h, L4GLASSWIN,
L4PADPANEL), all three wrong; the two locals now forward to the one fixed inline.
### Verification harness (new, reusable)
- `BT_RIOBANK_LOG=1` -- one-shot dump of every bank's buttons + bounds (capped at 16 banks so the
per-frame surround caller prints one pass).
- `scratchpad/checkbank.py <log>` -- per-bank census + **first-hit reachability**: an address
whose rect is entirely covered by earlier buttons in its bank is dead however big it looks, and
the overlapping under-glass banks make that a live hazard. Reports duplicates and shadowing.
- `scratchpad/clickbank.py <log> <title> --mode surround|exploded` -- posts a real
WM_LBUTTONDOWN/UP at every button's centre; the game's own CLICK forensics prove dispatch.
Result both modes: **72/72 placed, 0 shadowed, 0 duplicated, 72/72 dispatched**, process survived
144 posted messages each. Pod + dev profiles boot and simulate un-regressed. Surround `BT_SHOT`
confirms the legends are clear and the lamps read as edge strips; exploded `PrintWindow` captures
confirm surfaces still land under their banks.
## 2026-07-26 (later) — Phase 2: the cockpit scales (uniform-fit letterbox + -fit)
RP412 replication step 2. The canvas is a FIXED size and D3D9 stretched it into the client, so a
window dragged to a different shape squashed the instruments -- the projection was aspect-corrected
back in task #20, but the panels, lamps and MFD glass never were. Now: one uniform scale, centred,
leftover black.
**Mechanism.** `BTCockpitFitRect()` (L4VB16.cpp) computes the centred uniform-scale rect in integer
math -- a float scale wobbles by a pixel between frames and the bars shimmer during a drag. D3D9
applies it as a Present **destination rect**, which `D3DSWAPEFFECT_DISCARD` forbids, so the
WINDOWED swap effect becomes `D3DSWAPEFFECT_COPY` when the surround is up and multisampling is off
(COPY cannot multisample). `gBTCockpitLetterbox` records the outcome; 0 = the old full-client
stretch, which is what every non-cockpit mode and any MSAA run still gets.
Wired at five seams:
- `L4VIDEO` device setup: swap effect + confirm/withdraw the flag.
- Both Present sites: the scene present AND the wait-screen overlay present (it paints into the
same backbuffer, so it has to land in the same rect).
- `btl4main` WM_SIZE: `InvalidateRect` -- a COPY present only writes its dest rect, so stale bar
content persists without an erase (the class brush is black).
- `BTCockpitMouseDown`: client->canvas through the SAME fit. Mapping against the full client
drifts the hit test off every button by the bar width; a click on a bar now hits nothing.
- `BTWorldAspectOf`: under the letterbox the view's on-screen aspect is its OWN -- the client
drops out of the formula entirely.
**`-fit` / `-windowed-fullscreen`** (btl4main): borderless `WS_POPUP` over the monitor rect, canvas
letterboxed inside. Unknown tokens fall through `L4Application::ParseCommandLine`'s ladder to
`return True`, so no arg-parser change was needed.
### Ordering trap (found live, fixed)
The letterbox flag was originally set at device creation -- but **the first WM_SIZE beats the
device**. A `-fit` boot on the 3440x1440 ultrawide logged `[resize] client 3440x1440 aspect=3.14`
(the stretch formula), and since nothing resizes the window again, the per-frame
`gWindowAspect != appliedAspect` check applied it on frame 1. The INTENT is now decided in
btl4main alongside `gBTGaugeCockpit` (windowed cockpit + MULTISAMPLE unset/0); L4VIDEO only
confirms it or withdraws it. Re-verified: the boot line reads `aspect=1.8`.
### Capture note (for whoever verifies this next)
`PrintWindow` returns an all-black frame for the borderless `-fit` window, while working fine for
the normal chrome'd window. Use a screen-region grab (`scratchpad/shot.ps1`) for `-fit`. The
all-black PrintWindow is NOT a render failure -- the log showed 18 sim ticks and the screen grab
showed the full cockpit.
### Verified
- Wide drag 1684x661 and tall drag 744x961: canvas uniformly scaled and centred, bars on the long
axis, ZERO distortion (captures `fit_wide.png` / `fit_tall.png`).
- `-fit` on 3440x1440: borderless, centred, bars left/right, undistorted.
- **72/72 buttons still dispatch after a resize** -- clickbank.py now maps canvas->client through
its own copy of the fit (an independent check that the two transforms agree): at fit 906x661
with a 389px left bar, all 72 `[cockpit] CLICK` lines land.
- exploded / dock / pod / dev: DISCARD retained, boot + simulate un-regressed.
## 2026-07-26 (later still) — Phase 3: player-tunable displays + the settings file
RP412 replication step 3.
### Scaling (BTCkResolvedSizes, L4VB16.cpp)
`BT_MFD_SCALE` (all five), `BT_MFD_SCALE_UL/_UC/_UR/_LL/_LR` (per display, overriding the group),
`BT_RADAR_SCALE` -- percentages of the pod size, 25-200, resolved ONCE (the surround recomputes its
layout every frame, so reading the env per frame would be silly).
The load-bearing consequence: **the surround BANDS derive from the resolved sizes**. That is why
the sizes could not stay compile-time constants -- the band a display hangs in has to grow with it
or the canvas clips it. Each band is what its largest occupant needs; a corner MFD only claims
`size - kCkOVL` because it overlaps the view; the bottom band drops to just the lower MFDs when the
map leaves that row for a side. Verified 100% reproduces the historical `L276 R276 T223 B336`
byte-for-byte, 150% gives `L436 R436 T343 B448`, 80%+MIDLEFT gives `L256 R212 T175 B148`.
The flight label blocks now hang off their OWN lower MFD's scaled height + lamp strip, instead of
the constant -- a shrunken MFD used to leave the block floating in the gap and an enlarged one to
overlap it.
### Map placement (BT_RADAR_POS)
CENTER (default) / LEFT / RIGHT / MIDLEFT / MIDRIGHT, with the BOTTOM*/`*CENTER` spellings RP
accepts. Bottom corner: the map goes flush to the canvas edge and the lower MFD whose corner it
took slides beside it. Halfway up a side: it leaves the bottom row entirely and the bottom band
shrinks accordingly.
**First cut was wrong and the capture caught it:** the corner map was placed relative to the VIEW
edge, which overlapped the neighbouring MFD by the band width -- 232px of map drawn over the Right
Weapons display. Corner placement is measured off the CANVAS edge.
### The map legend grid -- measured, not inherited
The side columns have to line up with the six cells the map imagery paints beside them. Zooming a
native Secondary/Radar capture shows those cells are exactly our address map: MAP+ 0x10, MAP- 0x11,
IR 0x12 (ThermalSight lamp), crouch 0x13 (DuckRequest), searchlight 0x14, display-mode 0x15 -- the
art independently corroborating [[pod-hardware]].
`scratchpad/measurelegend.py` scans the capture for the legend's vertical rule and for our lamp
bars, and reports both. Ours: **top 3, cell 102, pitch 107** of the 640 span. RP412's map:
`13 + 6x102 @ 105`. Same cell height, different top and pitch -- confirming the deferral call in
Phase 1 was right, the numbers do NOT transfer. Our old even division (`displayH/6 + 1`) had the
pitch right by luck and sat 3px high of the labels; the grid is now pinned to the measurement, with
top and bottom scaled SEPARATELY and subtracted (RP's trick) so rounding cannot walk the buttons
out of step with the labels down a scaled column.
### environ.ini
Two defects, both fixed:
1. **Read far too late.** It sat ~300 lines into WinMain, after the platform-profile block had
already run its getenv()s -- so every setting the profile reads (BT_PLATFORM, BT_COCKPIT,
BT_GLASS_PANELS, BT_DEV_GAUGES, L4CONTROLS...) was silently ignored FROM THE FILE and only ever
worked as a real environment variable. Now loaded immediately after the first-breath boot line.
2. **putenv()'d comments verbatim**, so a commented-out option would have become an environment
variable literally named `#BT_MFD_SCALE`. Comments (`#`, `;`) and blanks are skipped, and lines
with no `=` are counted and ignored.
Added: the real environment WINS over the file (a launcher .bat overrides rather than fights), and
a fully documented default is written on first run when the file is absent -- the bindings.txt
convention (untracked via .gitignore, so extract-over-top never clobbers a player's settings; every
option ships commented out so a fresh install behaves exactly as before).
Verified live, all three paths: first run writes 3886 bytes and applies `0 setting(s)`; uncommenting
`BT_MFD_SCALE=130` + `BT_MFD_SCALE_UC=60` + `BT_RADAR_POS=MIDRIGHT` in the FILE drives the layout
(`displays UL 130% UC 60% ... radar on the right side, centred`); and `set BT_MFD_SCALE=90` beats
the file's 130 while the file's other two settings still apply.
### Verified
- 72/72 placed, 0 shadowed, 72/72 dispatched at baseline, at 150%/135%, and at 75% + BOTTOMRIGHT.
- exploded / dock / pod / dev boot and simulate un-regressed.
- Captures: p3_big2 (150%), p3_radleft2 (bottom-left + slid MFD), p3_radmidl (side-mounted map at
80%), p3_fromini (settings taken from the file).
## 2026-07-26 (final) — Phases 4-6: the dead-button backlog, the lamp mirror, the shipped doc
### Phase 4 — Mech::DuckRequest, and a census that lied in both directions
The 2026-07-20 audit listed 8 buttons with no reconstructed handler. **Only ONE was still
missing.** Generator on/off (`powersub.cpp`), ToggleSeekVoltage (`emitter.cpp`), EjectAmmo
(`projweap.cpp`), ToggleCooling (`heatfamily_reslice.cpp`) and BalanceCoolant (`mech.cpp`) had all
landed between 07-20 and 07-25 while `pod-hardware.md` and `open-questions.md` still called them
dead. `docs/INPUT_PATH_AUDIT.md:190` had already flagged this ("census stale in both directions")
and was right. Rule for next time: **grep the reconstruction, not the prose.**
`Mech::DuckRequest` @0049fa00 (id 0x1a, RIO 0x13 -- the manual's CROUCH button). Binary body, in
full:
if (0 < *(int *)(param_2 + 0xc)) { *(undefined4 *)(param_1 + 0x398) = 1; }
Press-only; sets `duckState` (`mech+0x398`, attribute 0x37). A one-shot REQUEST flag -- the
handler never clears it and the only other writer in the entire binary is the mech reset
(part_012.c:9439, the same reset that zeroes `incomingLock`, which is how that region was already
mapped). Registered in `Mech::MessageHandlerEntries`; `[duck]` log on press.
**Why the flag has no reader, and why that is correct.** `duckState` has ZERO readers anywhere in
the decomp. It is published as an ATTRIBUTE, so its consumer is a databinding:
`content/GAUGE/L4GAUGE.CFG` runs `oneOfSeveralPixInt(E, ModeAlwaysActive, bduck.pcc, 3, 1,
DuckState)` -- "crouch mode: button 4" on the map's legend column. Verified live by capturing the
Secondary/Radar window before and after a 0x13 press: the crouch icon goes grey -> orange. So the
handler is COMPLETE; a crouch pose invented here would be a stand-in for data we have not found
(no SQUAT clip name survives in the decomp or in content/, only DuckServo01.wav in AUDIO1.RES).
Bonus: those gauge widgets sit at offsets 537/430/322/215/108 -- a **107 pitch**, independently
corroborating the map legend grid measured from pixels in Phase 3.
Still open from that census: MechRIOMapper's own Keypress @004d2514 (id 0x19).
### Phase 5 — L4KEYLIGHT (RGB keyboard lamp mirror)
Ported from RP412 (itself a port of vRIO's KeyboardLampMirror). Keys bound to a lamp address in
bindings.txt glow with the panel palette -- yellow for the map's side columns (0x10-0x1F), red for
the rest -- flashing in step with the on-screen buttons (the LampLevel copy matches the FIXED
BTLampBrightnessOf from Phase 1). 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, 100 ms paint
loop, repaint only on change).
**The packing hazard did NOT transfer.** RP412 compiles this file with forced default struct
packing because its engine is `/Zp1`, which would break the WinRT ABI. BT411's `BT_OPTS` is
`/permissive /W0 /wd4996 /EHsc /bigobj /MP` -- no `/Zp` -- so only the dialect flags are needed:
`set_source_files_properties(... COMPILE_OPTIONS "/std:c++17;/permissive-")`, since the project
otherwise builds C++14 /permissive. The scalars-only interface is kept regardless.
Wired in `PadRIO`: map built from `bindings.keyBindings` (ActionButton binds only, first binding
per key wins, addresses < LampCount), fed from `PadRIO::SetLamp`, stopped in the dtor (which hands
the LEDs back to Windows). Gate `BT_KEYLIGHT=0`.
Verified live: `[keylight] mirroring 25 bound key(s)` + `+ LAPTOP-767CF84B (24 zones - board-wide
mirror)` -- it claimed this machine's real keyboard. `BT_KEYLIGHT=0` -> zero keylight lines, game
fine. Pod profile (no PadRIO) -> zero lines. Exploded -> works.
### Phase 6 — the shipped controls reference
`docs/CONTROLS.md`: the keyboard table plus **the full 72-button pod map**, grouped by the display
each bank surrounds, with the coolant-valve detent warning and the eject/jam procedure. mkdist
flattens it to ASCII as `CONTROLS.txt` at the zip root (the README's own flattening idiom).
`players/README.txt` gained pointers to it, to environ.ini, to -fit, and to the RGB mirror.
Verified: `mkdist.py` writes a 5149-byte pure-ASCII CONTROLS.txt into the zip.
+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();
+12
View File
@@ -104,6 +104,18 @@ control (stick, twist, throttle, fire buttons), writes the bindings,
and drops you into the solo menu to try them. Rebind keys in
content\bindings.txt (regenerates with defaults if deleted).
FULL CONTROLS REFERENCE: see CONTROLS.txt next to this file -- every one of
the pod's 72 cockpit buttons, what it does, and which display it sits on.
COCKPIT LAYOUT: content\environ.ini (written on first run, every option
documented in the file itself) sizes the MFDs and the map, moves the map out
of the middle of the road, or breaks each display into its own window. The
cockpit now scales to ANY window shape without distorting -- drag it, maximise
it, or launch with -fit for borderless full screen.
RGB KEYBOARDS: if yours supports Windows Dynamic Lighting, the keys you have
bound to cockpit buttons glow in the panel's colours and flash along with it.
This build: {VERSION}. Private -- do not redistribute.
{EXPIRE}Test builds STOP WORKING 14 days after they are made -- if the game
{EXPIRE}says it has expired, just grab the operator's newest zip.
+16
View File
@@ -119,6 +119,22 @@ def main():
lines = [l for l in lines if "{EXPIRE}" not in l]
readme = "".join(lines)
z.writestr(name + "/README.txt", readme)
# The full controls reference travels with the game, so players get the
# 72-button panel map without the repo. Flattened to ASCII so it reads
# correctly in Notepad -- the markdown source keeps its typography.
if os.path.exists("docs/CONTROLS.md"):
controls = open("docs/CONTROLS.md", encoding="utf-8").read()
for bad, good in ((u"", "-"), (u"", "-"), (u"", "'"),
(u"", "'"), (u"", '"'), (u"", '"'),
(u"×", "x"), (u"", "->"), (u"", "..."),
(u"", "Up"), (u"", "Down"),
(u"", "Left"), (u"", "Right"),
(u"", "!"), (u"·", "-")):
controls = controls.replace(bad, good)
controls = controls.encode("ascii", "replace").decode("ascii")
z.writestr(name + "/CONTROLS.txt", controls)
print(" controls reference -> CONTROLS.txt")
for p in bats: # bats at the zip ROOT (next to
arc = os.path.basename(p) # content\ + build\, per the guard)
z.write(p, name + "/" + arc)