diff --git a/CMakeLists.txt b/CMakeLists.txt index b74beea..741bed6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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") diff --git a/context/glass-cockpit.md b/context/glass-cockpit.md index e5fbb4c..c7d100c 100644 --- a/context/glass-cockpit.md +++ b/context/glass-cockpit.md @@ -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 + diff --git a/docs/CONTROLS.md b/docs/CONTROLS.md new file mode 100644 index 0000000..449f05b --- /dev/null +++ b/docs/CONTROLS.md @@ -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 | | F5–F9 | 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 A–D, 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 A–D 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. diff --git a/docs/GLASS_COCKPIT.md b/docs/GLASS_COCKPIT.md index a3a2b42..608f496 100644 --- a/docs/GLASS_COCKPIT.md +++ b/docs/GLASS_COCKPIT.md @@ -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: ]`. +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 ` -- 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 --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. diff --git a/engine/MUNGA_L4/L4KEYLIGHT.cpp b/engine/MUNGA_L4/L4KEYLIGHT.cpp new file mode 100644 index 0000000..d08aee7 --- /dev/null +++ b/engine/MUNGA_L4/L4KEYLIGHT.cpp @@ -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(); + } +} diff --git a/engine/MUNGA_L4/L4PADRIO.cpp b/engine/MUNGA_L4/L4PADRIO.cpp index c7cca9a..fabd8d6 100644 --- a/engine/MUNGA_L4/L4PADRIO.cpp +++ b/engine/MUNGA_L4/L4PADRIO.cpp @@ -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); + } } } diff --git a/engine/MUNGA_L4/L4PADRIO.h b/engine/MUNGA_L4/L4PADRIO.h index db441e8..b8b84b2 100644 --- a/engine/MUNGA_L4/L4PADRIO.h +++ b/engine/MUNGA_L4/L4PADRIO.h @@ -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). diff --git a/engine/MUNGA_L4/l4keylight.h b/engine/MUNGA_L4/l4keylight.h new file mode 100644 index 0000000..2476d0d --- /dev/null +++ b/engine/MUNGA_L4/l4keylight.h @@ -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(); diff --git a/players/README.txt b/players/README.txt index 91377ab..0ff9e82 100644 --- a/players/README.txt +++ b/players/README.txt @@ -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. diff --git a/tools/mkdist.py b/tools/mkdist.py index 674469d..88902be 100644 --- a/tools/mkdist.py +++ b/tools/mkdist.py @@ -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)