diff --git a/.gitignore b/.gitignore index bca98ad..b5d5f05 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ dist/ # glass-cockpit dev layer: per-developer / per-machine runtime files content/bindings.txt +content/environ.ini content/frontend.egg content/steam_appid.txt content/fe_last.ini diff --git a/CLAUDE.md b/CLAUDE.md index f28e365..1b834f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ precise than anything you can infer. | Term / acronym definitions | `reference/glossary.yaml` | | The ORIGINAL 1995 player manual (controls, per-mech stats, coolant loops) | `reference/manual/Tesla40_BT_manual.pdf` (+ alignment audit in `context/pod-hardware.md` §Manual) | | The complete verbatim detail (fallback) | `docs/PROGRESS_LOG.md` (the old 2236-line CLAUDE.md) | -| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md`, `docs/KD_SCOREBOARD_PLAN.md` | +| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md`, `docs/KD_SCOREBOARD_PLAN.md`, `docs/DIST_LAYOUT_PLAN.md`, `docs/MOUSELOOK_PLAN.md` | --- diff --git a/CMakeLists.txt b/CMakeLists.txt index ab98ab5..741bed6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -265,9 +265,19 @@ target_sources(munga_engine PRIVATE "engine/MUNGA_L4/L4PADBINDINGS.cpp" "engine/MUNGA_L4/L4JOY.cpp" "engine/MUNGA_L4/L4PADPANEL.cpp" + "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/content/CONTROLS.MAP b/content/CONTROLS.MAP index 6f7808a..4a65e1d 100644 --- a/content/CONTROLS.MAP +++ b/content/CONTROLS.MAP @@ -43,61 +43,105 @@ # hotkey channel (no double dispatch); unbound keys keep their authentic # 1995 meaning (see pckey above). # -# ---- WASD CLASSIC (the shipped default) ---------------------------------- +# ---- THE KEYBOARD IS THE BUTTON BOARD ------------------------------------ +# Mirrors contentindings.txt exactly, so the glass (PadRIO) and desktop +# (btinput) boots feel identical -- the letter and number rows are the MFD +# button banks laid out where they sit on the panel, and flight lives on the +# numpad. G and B are the physical gap between the lower clusters and stay +# unbound. A bound key is REMOVED from the typed 1995 hotkey channel, so +# this board gives those up by design (see pckey above to rebind any back). -# Driving: W/S sweep the throttle lever (sticks, detent at zero); A/D are -# the turn pedals (spring back); Q/E twist the torso; R/F aim the torso -# up/down (elevation); X = all stop (recenters twist AND pitch). -key W axis Throttle rate 0.7 -key S axis Throttle rate -0.7 -key Up axis Throttle rate 0.7 -key Down axis Throttle rate -0.7 -key A axis LeftPedal deflect 1 -key D axis RightPedal deflect 1 -key Left axis LeftPedal deflect 1 -key Right axis RightPedal deflect 1 -key Q axis JoystickX deflect -1 -key E axis JoystickX deflect 1 -key R axis JoystickY deflect 1 -key F axis JoystickY deflect -1 -key X action AllStop - -# Fire: the pod joystick's four buttons. -key D1 button 0x40 # Main trigger (lasers group) -key Space button 0x40 -key D2 button 0x46 # Middle thumb (PPCs group) -key D3 button 0x47 # Upper thumb (missiles group) -key Ctrl button 0x47 -key D4 button 0x45 # Pinky (4th group) -key Alt button 0x3F # throttle-head = REVERSE THRUST - -# --- numpad aim cluster (torso twist/aim -- mirrors bindings.txt so the -# --- glass (PadRIO) and desktop (btinput) boots feel identical) --- -key NumPad8 axis JoystickY deflect 1 # torso aim up -key NumPad2 axis JoystickY deflect -1 # torso aim down -key NumPad4 axis JoystickX deflect -1 # torso twist left -key NumPad6 axis JoystickX deflect 1 # torso twist right -key NumPad7 axis LeftPedal deflect 1 # turn left (alt) -key NumPad9 axis RightPedal deflect 1 # turn right (alt) +# Flight: the numpad. 8/2/4/6 stick, 7/9 pedals, 5 all-stop, 0 trigger; +# Shift/Ctrl walk the throttle lever and it sticks; Alt is reverse thrust. +key NumPad8 axis JoystickY deflect 1 +key NumPad2 axis JoystickY deflect -1 +key NumPad4 axis JoystickX deflect -1 +key NumPad6 axis JoystickX deflect 1 +key NumPad7 axis LeftPedal deflect 1 +key NumPad9 axis RightPedal deflect 1 key NumPad5 action AllStop -key Shift axis Throttle rate 0.7 # throttle up (alt) +key Shift axis Throttle rate 0.7 +key Ctrl axis Throttle rate -0.7 +key Alt button 0x3F -# Systems. -key G action ConfigHold # hold + fire keys = regroup the weapon -key C action Valve # coolant valve cycle -key H action Flush # coolant flush (HOLD -- dumps reservoir coolant) -key M action ModeCycle # Basic -> Standard -> Veteran controls -key N action DisplayCycle # secondary schematic: Damage -> Critical -> Heat -key J action Mfd1Cycle # lower-left MFD page: Quad -> Eng detail pages -key K action Mfd2Cycle # upper-center MFD page (engineering) -key L action Mfd3Cycle # lower-right MFD page -key V action ViewToggle # cockpit <-> chase camera (dev) -key B action LookBehind # HOLD = the pod's rear-view button -key F5 action Generator1 -key F6 action Generator2 -key F7 action Generator3 -key F8 action Generator4 -key F9 action Reconnect +# The four mappable fire buttons. +key Space button 0x40 +key NumPad0 button 0x40 +key NumPad1 button 0x45 +key NumPad3 button 0x46 +key Decimal button 0x47 + +# The hat, on the arrows. 0x42 is TORSO CENTER, not a look (streamed +# mapping: elem 66 -> subsys 17 Torso attr 14; the other three are the +# mapper's LookBehind/Right/Left). +key Up button 0x42 +key Down button 0x41 +key Left button 0x44 +key Right button 0x43 + +# UPPER MFD banks: number row on top, QWERTY beneath. +# Heat / coolant (1-4, QWER = Condensers 1-6, flush, balance). +key D1 button 0x2F +key D2 button 0x2E +key D3 button 0x2D +key D4 button 0x2C +key Q button 0x2B +key W button 0x2A +key E button 0x29 +key R button 0x28 +# Engineering / Mfd2. +key D5 button 0x27 +key D6 button 0x26 +key D7 button 0x25 +key D8 button 0x24 +key T button 0x23 +key Y button 0x22 +key U button 0x21 +key I button 0x20 +# Comm / target hotbox. +key D9 button 0x37 +key D0 button 0x36 +key OemMinus button 0x35 +key Oemplus button 0x34 +key O button 0x33 +key P button 0x32 +key OemOpenBrackets button 0x31 +key OemCloseBrackets button 0x30 + +# LOWER MFD banks: home row on top, the row below beneath. +key A button 0x0F +key S button 0x0E +key D button 0x0D +key F button 0x0C +key Z button 0x0B +key X button 0x0A +key C button 0x09 +key V button 0x08 +key H button 0x07 +key J button 0x06 +key K button 0x05 +key L button 0x04 +key N button 0x03 +key M button 0x02 +key Oemcomma button 0x01 +key OemPeriod button 0x00 + +# The two columns flanking the map. +key F1 button 0x10 +key F2 button 0x11 +key F3 button 0x12 +key F4 button 0x13 +key F5 button 0x14 +key F6 button 0x15 +key F7 button 0x18 +key F8 button 0x19 +key F9 button 0x1A +key F10 button 0x1B +key F11 button 0x1C +key F12 button 0x1D + +# View toggle keeps its own key (V is a board button now). +key Oemtilde action ViewToggle # ---- Xbox controller ----------------------------------------------------- # Left stick steers (the pedals), right stick twists the torso and pushes diff --git a/context/build-and-run.md b/context/build-and-run.md index 3bafd05..e02d1ab 100644 --- a/context/build-and-run.md +++ b/context/build-and-run.md @@ -39,6 +39,26 @@ run\run.cmd [EGG] # default DEV.EGG; cd's to content\ and runs btl4.exe -eg - **cwd MUST be `content\`** — the engine resolves `BTL4.RES`, `VIDEO\`, `BTDPL.INI`, eggs relative to cwd (the `loadTables` gotcha: `L4VIDEO.cpp:849` `fopen("VIDEO\\REPLACEMATS.tbl")` is relative + unchecked → fread on NULL if cwd is wrong). Logs to `btl4.log` in `content\` (or `BT_LOG=`). [T2] + **Since 2026-07-26 the exe enforces this itself** (`BTEnsureContentDirectory`, btl4main.cpp): if + cwd has no `BTL4.RES` it probes from the exe's own directory (`..\..\content` for the shipped + layout, plus flattened / in-content cases) and `SetCurrentDirectory`s there, before the log file + opens. The boot line says so when it had to look. ⚠ **The landmine it defuses** (field report): + a bare `btl4.exe` launch from `build\Release` found no resources ("Resource file btl4.res + v1.0.0.0 is obsolete!"), wrote a stray `bindings.txt`/`environ.ini`/`btl4.log` NEXT TO THE EXE — + so the player's real ones in `content\` looked like they were never created — and killed the + mission generation the menu launched (the child inherits the parent's cwd). Invisible for years + because every launcher `cd`s to `content\` first; it became reachable once glass became the + desktop default and a zero-arg launch started opening the menu. +- **Why this is a BT411-only hazard [T1].** The 1995 pod shipped ONE folder — `BTL4OPT.EXE` sits + next to `BTL4.RES`/`VIDEO\`/`GAUGE\`/`AUDIO\` (it is still there in `content\`) — and RP411/RP412 + keep that shape (`pack-dist.ps1` copies the exe and every asset dir into one dist root). BT411's + two-folder split (`build\Release\btl4.exe` + `content\`) is not a design decision: `mkdist.py` + zips TRACKED REPO PATHS verbatim, so the dist inherited the repo's developer layout. In the + authentic single-folder shape cwd is right by construction and a double-click just works, which + is why the other games never hit this. **A proposal to ship the authentic shape is written up + in `docs/DIST_LAYOUT_PLAN.md` — PROPOSED, not implemented, awaiting a decision between the + authors.** The cwd guard stays either way (it protects the developer tree, where the split is + real and permanent). - Interactive: **WASD** drive; weapon groups (keyboard, task #43) **1/Space** = lasers, **2** = PPCs, **3/Ctrl** = missiles; **X** all-stop; **V** cockpit/chase view. Default egg = `DEV.EGG` (map=grass, time=day). Swap mech via the egg's `vehicle=` — **ALL 18 ModelList names diff --git a/context/gauges-hud.md b/context/gauges-hud.md index 75c4751..95518f2 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -92,17 +92,22 @@ viewport), and `gWindowAspect` = the view rect's on-screen aspect (`BTWorldAspec sizing (`btl4main.cpp`), the world viewport, the panel/button draw, the aspect, and the mouse hit-test. Constants: `SCALE=0.5, OVL=44 (corner overlap), LAMP=16 (protruding lamp edge), REDCELL=64 (hidden hit depth), RAILW=26`; `canvas = view + (552, 548)`. -- **Buttons** = the L4GLASSWIN geometry ×0.5 with the same address banks (Heat 0x2F, Mfd2 0x27, - Comm 0x37, Mfd1 0x0F, Mfd3 0x07 red 8-btn; radar rails 0x10-0x15/0x18-0x1D + bottom +- **Buttons** = `L4RIOBANK` (2026-07-26: the ONE geometry, shared with the exploded windows — see + [[glass-cockpit]] §ONE button-bank geometry) at half scale, same address banks (Heat 0x2F, + Mfd2 0x27, Comm 0x37, Mfd1 0x0F, Mfd3 0x07 red 8-btn; radar columns 0x10-0x15/0x18-0x1D + foot {0x16,0x17,0x1F,0x1E} yellow; flight 0x38-0x3F/0x40-0x47 blue, labeled). Full rect = hit target; - the surface draws OVER it so only the lamp edge shows (the PaintGlass painter trick). Mouse: + the surface draws OVER it so only the lamp strip shows (the PaintGlass painter trick). ⚠ Before + that date the surround's MFD lamps sat ENTIRELY outside the glass on a 24px band — a 76×24 + target where the exploded window gave 156×138; both now reach half the glass. Mouse: main WndProc `WM_L/RBUTTON` → `BTCockpitMouseDown/Up` (client→bb map, glass press/release/right- latch contract) → `PadRIO::SetScreenButton` (`#ifdef BT_GLASS`; dim/no-op in pod builds). Lamp brightness = `BTLampBrightnessOf` (shared inline in l4vb16.h) over `PadRIO::GetLampState`. -- **Env / precedence** (resolved once in btl4main → `gBTGaugeCockpit`): `BT_GLASS_PANELS=1` - (Cyd's per-display windows) stands cockpit down > explicit `BT_COCKPIT=1` > `BT_DEV_GAUGES_WINDOW` - (separate window) / `BT_DEV_GAUGES_DOCK` (legacy inset) opt-out > **cockpit default**. - `BT_COCKPIT=0` forces the dock-bottom strip. `-res W H` = the WORLD VIEW size (canvas clamped to +- **Env / precedence** (resolved ONCE in btl4main → `glassLayout` → `gBTGaugeCockpit`; the full + table lives in [[glass-cockpit]] §Layout modes): `BT_GLASS_PANELS`≠0 (the per-display windows) + stands cockpit down > `BT_DEV_GAUGES_WINDOW` (separate window) > `BT_DEV_GAUGES_DOCK` (legacy + inset) > `BT_COCKPIT=0` (also the dock strip) > **cockpit surround default**. ⚠ Before + 2026-07-26 `BT_COCKPIT=0` actually landed on the per-display windows — the dock strip was + unreachable under glass. `-res W H` = the WORLD VIEW size (canvas clamped to the work area). Green tint tunable via `BT_COCKPIT_TINT=RRGGBB` (default `0x27E8`). Labels are a lazy GDI-baked MANAGED atlas (survives device reset). Renders in ALL builds; only the PadRIO click/lamp seam is BT_GLASS-gated. Full detail: `docs/GAUGE_COMPOSITE.md`. diff --git a/context/glass-cockpit.md b/context/glass-cockpit.md index e5fbb4c..ae4a6e2 100644 --- a/context/glass-cockpit.md +++ b/context/glass-cockpit.md @@ -63,6 +63,258 @@ 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. + +## THE KEYBOARD IS THE BUTTON BOARD — new default keymap (2026-07-26) [T2] + +Ported from RP412's bindings design (user decision: make it the DEFAULT, not an alternate). The +letter and number rows are the MFD button banks **laid out where they sit on the panel**, and +flight moves to the NUMPAD so the board stays free: + +| Keys | Bank | +|---|---| +| `1 2 3 4` / `Q W E R` | Heat / coolant 0x2F-0x2C / 0x2B-0x28 | +| `5 6 7 8` / `T Y U I` | Engineering (Mfd2) 0x27-0x24 / 0x23-0x20 | +| `9 0 - =` / `O P [ ]` | Comm / target hotbox 0x37-0x34 / 0x33-0x30 | +| `A S D F` / `Z X C V` | Left Weapons (Mfd1) 0x0F-0x0C / 0x0B-0x08 | +| `H J K L` / `N M , .` | Right Weapons (Mfd3) 0x07-0x04 / 0x03-0x00 | +| `F1`-`F6` / `F7`-`F12` | the map's two columns 0x10-0x15 / 0x18-0x1D | +| numpad | 8/2/4/6 stick, 7/9 pedals, 5 all-stop, 0/1/3/. the four fire buttons | +| `Shift`/`Ctrl` · `Alt` · arrows · `` ` `` | throttle lever · reverse · look/torso-centre · view | + +`G` and `B` stay unbound — the physical gap between the lower clusters. **Coverage: 61 of 72 +addresses on the keyboard, and the 11 absent ones are exactly those with no authored function** +(0x16/0x17/0x1E/0x1F column gaps, 0x38-0x3E intercom/door). All 72 remain clickable. + +It lands on BT's addresses better than it deserves to: `1-4`+`QWER` are the ENTIRE coolant system +(Condensers 1-6, flush, balance), `F6`/`F7` the display/control-mode cycles, `F9`-`F12` Generators +A-D, `F4` the crouch button. + +⚠ **What it costs, deliberately.** A bound key is removed from the authentic 1995 typed-hotkey +channel to stop double-dispatch, and this board binds nearly everything — so `5` (Quad page), `z` +(Eng1), `t/y/u/i/o` (pilot select) and `+`/`-` (target zoom) are given up. Unbind a key to get its +1995 meaning back. + +⚠ **New rule: a bindings row WINS over a built-in convenience key** (`PadRIO::KeyHasBinding`). +`V` and `J/K/L` are board buttons now, so the view-toggle and preset-cycle polls stand down for +them and the view toggle lives on `` ` `` alone. Without this they would have fired BOTH. + +`content/CONTROLS.MAP` was rewritten to mirror the board (90 bindings, 0 parse complaints) so the +glass/pod/dev boots still feel identical — the 2026-07-21 settlement. An existing `bindings.txt` +is never overwritten, so a player only meets this board by deleting theirs. + +## Hat labels corrected — 0x42 is TORSO CENTER [T1, streamed mapping] + +`docs/INPUT_PATH_AUDIT.md` flagged the 0x41-0x44 labels as wrong and it was right. Settled from +`BT_CTRLMAP_LOG`: **0x42 → subsys 17 (Torso), attrID 14** — not a look at all; the other three go +to the mapper's look trio (0x44 → attrID 10 LookLeft, 0x43 → 11 LookRight, 0x41 → 12 LookBehind). +The shipped `.RES` has no "TORSO CENTER" string (its names are LookBehind/Down/Forward/Left/Right), +so the audit's phrasing was loose but its substance correct. Swept the three UI tables +(`L4GLASSWIN`, `L4PADPANEL`, `L4VB16`): 0x42 is now "Torso Ctr", 0x41/0x43/0x44 "Look Bk/R/L". + +## 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 +451,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 +496,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/context/open-questions.md b/context/open-questions.md index 9823ccb..9686dd6 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -169,17 +169,29 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR exactly is the all-roster-hosts-connected launch wait, and can absent seats stay `Opening` without stalling? Go/no-go = the 2-node late-attach spike in the plan doc. Phase 1 (round carousel) is console-only and has no engine unknowns. [[multiplayer]] -- **Glass panel dead-button backlog (input-coverage audit 2026-07-20) [T1 census].** 8 of the 72 - panel buttons dispatch a streamed message with NO reconstructed handler (silently ignored): - 0x13 → Mech `DuckRequest` @0049fa00 (CROUCH — `duckState` attr + SQUAT clips exist!), 0x28 → - Mech `BalanceCoolant` @0049f728, 0x1A-0x1D → Generator A-D `ToggleGeneratorOnOff` @004b1ed0, - 0x12 → ThermalSight toggle (pvision unported), 0x14 → Searchlight `ToggleLamp` (body - reconstructed, handler-set unwired — note the 1995 latent bug below: even wired, the light - never turns on in the original). Plus page-gated Eng-page meanings: `ToggleCooling` @004ad6f8 - (msg 3), `ToggleSeekVoltage` @004ba478/@004b8a48 (msg 0xb energy / 9 Myomers), `EjectAmmo` - @004bb9b8 (msg 0xb ammo weapons), and MechRIOMapper's own Keypress @004d2514 (id 0x19). - Filed as the Gitea "glass panel: unreconstructed button handlers backlog" issue; tables in - [[decomp-reference]] §2026-07-20; census in `docs/GLASS_COCKPIT.md`. +- **Glass panel dead-button backlog (input-coverage audit 2026-07-20) — ✅ CLOSED 2026-07-26.** + The 8 buttons that dispatched a streamed message with NO reconstructed handler are all wired + now; this entry stays as the index of where each one landed: + 0x13 → Mech `DuckRequest` @0049fa00 (**CROUCH — wired 2026-07-26**, the last one; see below), + 0x28 → Mech `BalanceCoolant` @0049f728 (2026-07-21, issue #20), + 0x1A-0x1D → Generator A-D `ToggleGeneratorOnOff` @004b1ed0 (`powersub.cpp`), + 0x12 → ThermalSight / 0x14 → Searchlight `ToggleLamp` (both 2026-07-25, #61), + `ToggleCooling` @004ad6f8 msg 3 (2026-07-20, `heatfamily_reslice.cpp`), + `ToggleSeekVoltage` @004ba478 msg 0xb energy (`emitter.cpp`) / @004b8a48 msg 9 Myomers, + `EjectAmmo` @004bb9b8 msg 0xb ammo (`projweap.cpp`). + Still open from that census: **MechRIOMapper's own Keypress @004d2514 (id 0x19)**. + Tables in [[decomp-reference]] §2026-07-20; census in `docs/GLASS_COCKPIT.md`. + ⚠ This entry was stale in BOTH directions for weeks (`docs/INPUT_PATH_AUDIT.md` flagged it) — + most of the list had been reconstructed while the prose still called it dead. Check the code, + not the census, before reconstructing anything here. +- **`DuckState` has no CODE consumer, and that is authentic [T1].** `Mech::DuckRequest` sets + `duckState`(attr 0x37, binary `mech+0x398`) to 1 and nothing else: the flag has exactly two + writers in the whole binary (that handler and the mech reset) and ZERO readers. Its consumer is + a DATABINDING — `content/GAUGE/L4GAUGE.CFG` drives a 3-frame `bduck.pcc` widget off `DuckState` + on the map's legend column, verified live (the crouch icon lights grey→orange on a press). So + the button is COMPLETE as a request flag + indicator. What is NOT known: whether the 1995 game + ever consumed it for posture/collision (no SQUAT clip name survives in the decomp or in + `content/`, only `DuckServo01.wav` in AUDIO1.RES). Do not invent a crouch pose to "finish" it. - **Searchlight-driven fog swap — STILL DEFERRED, but the "ORIGINAL 1995 LATENT BUG" premise is ❌ RETRACTED (2026-07-25, #61).** The arcade swaps fog between `fog=` (lights on) and `nosearchlightfog=` (off) via `PullFogRenderable` watching the Searchlight's `lightState`. diff --git a/context/pod-hardware.md b/context/pod-hardware.md index 2747f24..4da12ee 100644 --- a/context/pod-hardware.md +++ b/context/pod-hardware.md @@ -52,10 +52,12 @@ handler chain, so the Eng-page "Coolant" button now toggles the shown weapon's c sub 3 = Reservoir, 4-9 = Condenser1-6, 10-13 = GeneratorA-D): **0x2C = the COOLANT FLUSH button** (Reservoir InjectCoolant, hold-to-flush — works), **0x2F/0x2E/0x2D/0x2B/0x2A/0x29 = the per-condenser VALVE buttons** (MoveValve, Cond1-6 — work), **0x1A-0x1D = Generator A-D -ON/OFF** (`ToggleGeneratorOnOff` id 4, binary table @0050fb90 fn @004b1ed0 — DEAD until -reconstructed). Newly decoded from the binary message tables: **0x13 → Mech `DuckRequest` -(0x1a @0049fa00 — the manual's CROUCH button)**, **0x28 → Mech `BalanceCoolant` (0x16 -@0049f728)**, **0x12 → ThermalSight `ToggleLamp` (id 3, table @0x51120C fn @004b860c)** and **0x14 → +ON/OFF** (`ToggleGeneratorOnOff` id 4, binary table @0050fb90 fn @004b1ed0 — ✅ **WIRED**, +`powersub.cpp`). Newly decoded from the binary message tables: **0x13 → Mech `DuckRequest` +(0x1a @0049fa00 — the manual's CROUCH button)** — ✅ **WIRED 2026-07-26**, the last handler in +this census; press-only, sets `duckState` and the map legend's `bduck.pcc` widget lights (see +[[open-questions]] for why it has no code consumer) —, **0x28 → Mech `BalanceCoolant` (0x16 +@0049f728)** ✅ **WIRED 2026-07-21 (#20)**, **0x12 → ThermalSight `ToggleLamp` (id 3, table @0x51120C fn @004b860c)** and **0x14 → Searchlight + Searchlight2 `ToggleLamp` (id 3, table @0x51117C fn @004b838c)** — ✅ **BOTH WIRED 2026-07-25 (#61)**, previously default-constructed blackholes; verified live (0x14 → `lightState 0→1`, 0x12 → `thermalActive 0→1`). ⚠ The fn addresses were **swapped** in this file diff --git a/docs/CONTROLS.html b/docs/CONTROLS.html new file mode 100644 index 0000000..b593689 --- /dev/null +++ b/docs/CONTROLS.html @@ -0,0 +1,1062 @@ +BattleTech 4.11 — Controls + + + +
+ +
+

Virtual World Entertainment · Tesla II pod · BT 4.11

+

BattleTech
Controls Map

+

+ Every input the pod answers to. The keyboard is the button + board: the letter and number rows are the MFD banks laid out + where they sit on the panel, and flight moved to the number pad so the + board stays free. Every one of the 72 buttons is also a mouse target on + the cockpit itself. +

+

Generated from the default bindings.txt · all of it rebindable

+
+ +
+
+
Fly
+
Num 8 2 4 6stick · 7/9 pedals · 0 fires · 5 all-stop
+
+
+
Throttle
+
Shift / Ctrlwalks the lever; it sticks where you leave it
+
+
+
Reverse
+
Altthe throttle handle's own button
+
+
+
Control mode
+
F7BAS → MID → ADV. You spawn in BASIC
+
+
+ +
+

+ YOU SPAWN IN BASIC. In BASIC the stick steers and the + pedals do nothing. Press F7 once for MID, where the pedals steer + and the stick twists the torso — the full pod experience. If "turning + doesn't work", this is why, not a bug. (Control mode is not the menu's + experience setting; that one changes the simulation, not your controls.) +

+
+ +
+

Keyboard

+

+ Colour is the bank each key drives: green flies the mech, red is an MFD + button bank, amber is a column beside the map. Dark keys are unbound and + free to take. Hover a key to read what it does. +

+ +
+
+
+ flight & fire + MFD button banks + map columns + unbound +
+
+
+ Select a key + Hover a key to read what it drives. +
+ +
+

+ COOLANT VALVES BITE. The valve detents on + 1 2 3 Q W E 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. 4 is the flush (hold it), R re-balances every + valve. +

+
+
+ +
+

Where the keys live on the panel

+

+ Each MFD carries eight buttons: a four-wide top row and a four-wide row + beneath it. The keyboard rows run left to right across the upper + displays, then the lower pair — the same order your hand finds them in + the pod. +

+ +
+
+
+
1234
+
Heat / coolant0x2F – 0x2C
0x2B – 0x28
+
QWER
+
+
+
5678
+
Engineering0x27 – 0x24
0x23 – 0x20
+
TYUI
+
+
+
90-=
+
Comm / hotbox0x37 – 0x34
0x33 – 0x30
+
OP[]
+
+ +
+
ASDF
+
Left Weapons0x0F – 0x0C
0x0B – 0x08
+
ZXCV
+
+
+
+
F1F2F3F4F5F6
+
+ Map · radar + + zoom in / out
+ thermal · CROUCH
+ searchlight · display
+ 0x10 – 0x15 +
+
+
F7F8F9F10F11F12
+
+
+
+
HJKL
+
Right Weapons0x07 – 0x04
0x03 – 0x00
+
NM,.
+
+ +

+ G and B stay unbound on purpose: they are the physical gap between + the lower clusters. The right-hand map column is control mode (F7) + and Generators A–D (F9–F12); F8 is a column slot the pod never + wired. +

+
+
+ +
+ + + + + + + +
The weapon and engineering displays are page-gated
Quad pageThe outer buttons jump straight to a populated Eng page.
Eng pageGenerator select A–D, bus mode, coolant toggle, trigger configure, and eject.
Jams & firesA weapon gone quiet with ammo left has jammed — heat jams ballistics, authentically. Its Eng-page buttons flash. Tap eject to clear the round; hold ~3 s to jettison a burning bay before it detonates. An ammo bay fire gives you about ten seconds and it will kill you.
+
+
+ +
+

Gamepad

+

An Xbox-compatible pad drives all five axes and the grip buttons. Hover a control for its RIO address.

+ +
+ + + + + + LT · left pedal + RT · right pedal + LB · look left + RB · look right + + + + + STICK + + torso twist / aim + + + + + + + look · torso centre + + + THRTL + + throttle + + + + + + Y + X + B + A + + A · main trigger + X upper · B reverse · Y torso centre + + +
+
+ Select a control + Hover the diagram to read its binding. +
+ +

+ Flight sticks, HOTAS and pedals: run joyconfig.bat + once. A wizard asks you to move each control in turn, writes the + bindings, and drops you into the solo menu. It only rewrites its own + section of bindings.txt, so hand edits elsewhere survive. +

+
+ +
+

The mouse

+

+ Every button on every display is clickable and lights when the game + commands its lamp — and each reaches in under the glass + beside it, so the picture is the press target. The lit strip at the edge + is only the lamp. Right-click latches a button held. +

+ +
+
+
+
+ + press — top row + press — bottom row + +
+
+ One MFD. Four buttons above the glass and four below, each reaching + half the pane deep — so the two banks meet in the middle and + the whole face answers the mouse. Only a thin strip clears the edge + to show its lamp. +
+
+
+
+ +

Arranging the glass

+

+ The pod bolted its displays down at one size. On a desktop panel there is + room to trade viewscreen for instrument, so all six are yours to size — + and the map to place. Set these in content\environ.ini, + which the game writes on first run with every option documented. +

+ +
+
+
+
CENTERas the pod had it
+
+
+
+
LEFTbottom corner; the MFD slides inboard
+
+
+
+
RIGHTthe same, other side
+
+
+
+
MIDLEFThalfway up; leaves the bottom row alone
+
+
+
+
MIDRIGHThalfway up, right
+
+
+ +
+ + + + + + + + + +
environ.ini — display layout
BT_MFD_SCALEAll five MFDs, as a percentage of pod size. 25–200, default 100
BT_MFD_SCALE_xxAny one on its own — UL UC UR LL LR. Unset inherits the line above.
BT_RADAR_SCALEThe map alone.
BT_RADAR_POSCENTER · LEFT · RIGHT · MIDLEFT · MIDRIGHT
BT_GLASS_PANELS1 = break every display into its own desktop window instead of the composited cockpit.
+
+ +
+

+ Ask for too much and the layout wins. The surround + bands grow to fit whatever you ask for, and a map in a bottom corner + pushes the lower MFD beside it inboard — nothing overlaps and nothing + comes out stretched. +

+
+ +

Filling the screen

+

+ The cockpit fits itself to whatever window it gets at one uniform scale, + so drag it to any shape and nothing distorts — a window that isn't the + cockpit's shape just gets black bars. Launch with -fit for + borderless over the whole monitor. +

+
+ +
+

Rebinding

+

+ content\bindings.txt is written with the full default layout + on first run. Change a line, restart. Delete the file to restore these + defaults — worth doing after an update, since an existing file is never + overwritten. +

+ +
+
# grammar
+key <KEY>   button <addr>
+key <KEY>   axis <chan> deflect <+|-> <rate>
+key <KEY>   axis <chan> slew    <+|-> <rate>
+key <KEY>   axis <chan> set <value>
+pad <BTN>   button <addr>
+padaxis <SRC> axis <chan> [invert] [slew <r>]
+
+# deflect springs back, slew walks and sticks
+# (that is the throttle lever), set jumps
+ +
# examples
+key G button 0x0E      # regroup weapon 1 on G
+key B button 0x41      # look behind on B
+
+# put driving back on WASD if you prefer
+key W axis Throttle slew + 0.7
+key S axis Throttle slew - 0.7
+key A axis LeftPedal  deflect + 2.5
+key D axis RightPedal deflect + 2.5
+
+ +
+ + + + + + + + + + + +
Reference — channels, addresses, key names
ChannelsThrottle · LeftPedal · RightPedal · JoystickX · JoystickY · Turn
Buttons0x00 – 0x47
No function0x16 0x17 0x19 0x1E 0x1F 0x38–0x3E — the pod never wired these
Key namesA–Z · 0–9 · F1–F12 · NUMPAD0–9 · NUMPADDOT · UP DOWN LEFT RIGHT · SPACE · LSHIFT LCTRL LALT · MINUS EQUALS COMMA PERIOD LBRACKET RBRACKET BACKTICK
MouseMOUSE4 · MOUSE5 · MOUSEMIDDLE — buttons only; left/right also press cockpit buttons
PadA B X Y · DPAD_UP/DOWN/LEFT/RIGHT · LB RB · BACK START · LS RS
Pad axesLX LY RX RY LT RT
+
+ +
+

+ A BOUND KEY LOSES ITS 1995 MEANING. Binding a key + removes it from the authentic typed-hotkey channel so it cannot + double-dispatch — and this board binds nearly everything, so the old + typed hotkeys (5 = Quad page, z = Eng1, t/y/u/i/o = pilot select, +/− + = target zoom) are given up by design. Unbind a key to get its 1995 + meaning back. Same rule for the built-ins: ` is the view + toggle, and J/K/L cycle MFD preset pages only while unbound. +

+
+
+ +
+ BattleTech 4.11 + gitea.mysticmachines.com/VWE/BT411 + docs/CONTROLS.md · CONTROLS.txt ships with the game + RGB keyboards mirror the pod lamps · BT_KEYLIGHT=0 to disable +
+
+ + diff --git a/docs/CONTROLS.md b/docs/CONTROLS.md new file mode 100644 index 0000000..ece9804 --- /dev/null +++ b/docs/CONTROLS.md @@ -0,0 +1,114 @@ +# 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 — the keyboard IS the button board + +The letter and number rows are the MFD button banks, laid out where they sit on the panel. +Flight lives on the number pad so the board stays free. + +### Flight — the number pad + +| Key | Action | | Key | Action | +|---|---|---|---|---| +| NumPad 8 / 2 | torso aim up / down | | NumPad 7 / 9 | left / right pedal | +| NumPad 4 / 6 | torso twist left / right | | NumPad 5 | all stop | +| NumPad 0, Space | main trigger | | NumPad 1 / 3 / . | pinky / middle / upper | +| Shift / Ctrl | throttle up / down (the lever sticks) | | Alt | reverse thrust | +| ← ↓ → | look left / behind / right | | ↑ | torso centre | +| ` | cockpit / chase camera | | − / = *(see below)* | — | + +**You spawn in BASIC control mode.** In BASIC the stick steers and the pedals do nothing. +Press **F7** once for MID, where the pedals steer and the stick twists the torso — the full +pod experience. If "turning doesn't work", this is why. + +### The panel — letter and number rows + +``` + UPPER DISPLAYS Heat / coolant Engineering Comm / hotbox + top row 1 2 3 4 | 5 6 7 8 | 9 0 - = + bottom row Q W E R | T Y U I | O P [ ] + 0x2F..0x2C 0x27..0x24 0x37..0x34 + 0x2B..0x28 0x23..0x20 0x33..0x30 + + LOWER DISPLAYS Left Weapons Right Weapons + top row A S D F (G unbound) H J K L + bottom row Z X C V (B unbound) N M , . + 0x0F..0x0C 0x07..0x04 + 0x0B..0x08 0x03..0x00 + + COLUMNS beside the map + left F1 zoom+ F2 zoom- F3 thermal F4 CROUCH F5 lamp F6 display + right F7 control mode F8 - F9..F12 Generators A-D +``` + +G and B are deliberately unbound: they are the physical gap between the lower clusters. + +**1 2 3 Q W E are the coolant valves** and the 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, since the supply is shared. **4** is the flush (hold it) and **R** +re-balances every valve. + +The weapon and engineering displays are **page-gated**: on the Quad page the outer buttons jump +to a populated Eng page; on an Eng page they drive 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 before it detonates). + +### What a bound key gives up + +Binding a key removes it from the authentic 1995 typed-hotkey channel so it cannot +double-dispatch. This board binds nearly everything, so those hotkeys are given up by design: +`5` = MFD1 Quad page, `z` = MFD3 Eng1, `t/y/u/i/o` = pilot select, `+`/`-` = target zoom. +Unbind a key in `bindings.txt` to get its 1995 meaning back. Same rule for the built-ins: +**`** is the view toggle (V is a board button now), and J/K/L cycle the MFD preset pages only +while unbound. + +Addresses with no function at all — the pod never wired them — are `0x16` `0x17` `0x19` +`0x1E` `0x1F` and `0x38`-`0x3E`. + +## 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. + +**Mouse buttons** are bindable like any key — `MOUSE4`, `MOUSE5` (the side buttons), +`MOUSEMIDDLE`, and `MOUSELEFT`/`MOUSERIGHT`. The side and middle buttons are the free ones; left +and right are how you press cockpit buttons, so binding those fires on every panel click too. +Mouse *movement* is not bindable — see `docs/MOUSELOOK_PLAN.md`. + +``` +key MOUSE4 button 0x40 # side button = main trigger +key MOUSE5 button 0x46 # side button = middle thumb +``` + +**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/DIST_LAYOUT_PLAN.md b/docs/DIST_LAYOUT_PLAN.md new file mode 100644 index 0000000..f60c9d4 --- /dev/null +++ b/docs/DIST_LAYOUT_PLAN.md @@ -0,0 +1,162 @@ +# DIST_LAYOUT_PLAN — proposal: ship the authentic single-folder layout + +**Status: PROPOSED, NOT IMPLEMENTED.** Written 2026-07-26 for discussion between the authors. +Nothing in this document has been built. The seatbelt described in §7 *has* shipped (4.11.560); +this proposal is about removing the need for it. + +--- + +## 1. What prompted this + +Field report, 4.11.559: + +``` +PS C:\Games\BT411_4.11.559\build\Release> .\btl4.exe -fit +``` + +→ the mission console opens, then the game **crashes on Launch**, and the player's +`environ.ini` / `bindings.txt` appear to have never been created. + +Root cause: the engine resolves `BTL4.RES`, `VIDEO\`, `BTDPL.INI`, the eggs **and** both player +config files relative to **cwd**. Launched from `build\Release`, it found no resources +(`Resource file btl4.res v1.0.0.0 is obsolete!`), wrote stray copies of `bindings.txt` / +`environ.ini` / `btl4.log` next to the exe, and killed the mission generation the menu launched +(the child inherits the parent's cwd). + +The observation that matters more than the bug: **RP411, RP412 and the 1995 pod cannot hit this, +because they ship one folder.** + +## 2. Evidence that one folder is the authentic shape + +- **The 1995 pod.** `content\` in this repo *is* an original pod game directory: + `BTL4OPT.EXE`, `BTL4.RES`, `SETENV.BAT`, `32RTM.EXE`, `DPMI32VM.OVL`, `VIDEO\`, `GAUGE\`, + `AUDIO\` all sit together. The exe lived beside its resources. [T1] +- **RP411 / RP412.** `pack-dist.ps1` copies `rpl4opt.exe`, `AUDIO/`, `GAUGE/`, `VIDEO/`, + `RPL4.RES`, `RPDPL.INI`, the eggs, the DLLs, `environ.ini` and `bindings.txt` into ONE dist + root. A dist listing confirms it. [T1] +- **BT411.** `mkdist.py` zips **tracked repo paths verbatim** (`git ls-files content/` written as + `/content/...`, the exe as `/build/Release/btl4.exe`). The player therefore receives + the *developer tree*. [T1] + +So the split is not a decision anyone made about how the game should be installed — it is an +artifact of how the packer was written. In the authentic shape cwd is correct by construction and +a double-click just works. + +## 3. Proposed dist tree + +``` +BT411_4.11.NNN/ + btl4.exe <- was build/Release/btl4.exe + OpenAL32.dll d3dx9_43.dll msvcp140.dll vcruntime140.dll steam_api.dll + BTL4.RES BTDPL.INI CONTROLS.MAP *.EGG ... <- was content/* + AUDIO/ GAUGE/ VIDEO/ <- was content/*/ + play_solo.bat join.bat join_lan.bat joyconfig.bat play_steam.bat + README.txt CONTROLS.txt + (bindings.txt, environ.ini, volume.cfg, *.log appear here on first run) +``` + +Identical in shape to the pod's own directory and to RP's dist. + +## 4. What changes + +| File | Change | Size | +|---|---|---| +| `tools/mkdist.py` | rewrite archive paths: `content/X` → `X`, `build/Release/btl4.exe` → `btl4.exe`, DLLs → root. The redist/PDB/steam logic is unaffected. | ~15 lines | +| `players/*.bat` (5) | drop `cd content`; drop the `build\Release\` prefix; the `badpath` guard tests `BTL4.RES` instead of `content\OPERATOR.EGG` | ~4 lines each | +| `players/README.txt` | "next to the content and build folders" → "in the game folder"; upgrade section (see §6) | ~10 lines | +| `docs/CONTROLS.md` | `content\bindings.txt` → `bindings.txt` (3 mentions) | trivial | +| game code | **none required.** `BTEnsureContentDirectory` already probes the exe's own directory, so a single-folder install satisfies it on the first candidate. Log strings that say `content\environ.ini` become slightly wrong — cosmetic, worth a sweep. | ~5 strings | + +**The repo layout does NOT change.** `build/` is generated and `content/` stays where it is; only +the packer's output shape moves. Developers keep running `run\run.cmd` exactly as now. + +## 5. What it buys + +- The reported crash class disappears at the source rather than being caught by a guard. +- A double-clicked `btl4.exe` works — which is what a player tries first, and what the menu + front end was built to support. +- `bindings.txt` / `environ.ini` / `volume.cfg` / `btl4.log` land where the player can see them, + next to the game, instead of one folder down. +- One fewer way to run a **stale exe**: today `build\Release\btl4.exe` can survive an upgrade and + be launched by an old shortcut. (That has already cost us one debugging session — see + `docs/GLASS_COCKPIT.md` §2026-07-20 "STALE EXE, not code".) +- We stop shipping a directory named `build\` to players, which invites exactly the "run the exe + directly" instinct that caused this. + +## 6. The hard part — upgrades ⚠ + +The README tells players to extract the new zip **over** the existing folder and overwrite, so +their `bindings.txt` and `volume.cfg` survive. A layout change collides with that: + +| Hazard | Effect | +|---|---| +| Player's `content\bindings.txt` stays in the now-unused `content\` | Their key/joystick setup is silently ignored — and still visible on disk, so it looks like the game lost it | +| Old `build\Release\btl4.exe` remains | A shortcut or habit runs a **stale build** | +| Old `content\` tree remains | ~130 MB duplicated, and two copies of every asset | + +**But note the wrinkle worth checking first:** the zip root is *versioned* +(`BT411_4.11.560/…`), so extracting into `C:\Games\` produces a **new sibling folder**, not an +overlay — the reporter's own install is `C:\Games\BT411_4.11.559\`. The documented +extract-over-top only works if the player extracts the *inner folder's contents* onto the old +install, which is a fiddly manual step. **If in practice every version already lands in its own +folder, then bindings.txt already does not carry over today**, and the migration cost of this +change is close to zero. That should be confirmed before weighing the options below. + +### Migration options + +- **A. Versioned folder, no migration.** Rely on each version extracting fresh; README tells + players to copy `bindings.txt` (+ `volume.cfg`, `environ.ini`) from the old folder's `content\` + into the new folder's root. Simple, honest, one paragraph of README — but a manual step, and + wrong if anyone really is overlaying. +- **B. One-shot in-game migration.** On boot, if `\content\bindings.txt` exists and + `\bindings.txt` does not, copy the player files up and log it loudly. ~30 lines, + covers the overlay case invisibly. Does not solve the stale exe or the duplicated tree. +- **C. B + a loud stale-install warning.** Additionally, if `\build\Release\btl4.exe` + exists, log a warning and show it once in the front end ("an old copy of the game is still in + this folder — delete build\ and content\"). Most protective, most code. +- **D. Stable zip root** (`BT411/` instead of `BT411_/`) so extract-over-top genuinely + works, combined with B. Fixes the upgrade story properly but changes how testers file reports + (the folder no longer names the version — though the title bar and log head still do). + +**Recommendation: A if §6's wrinkle confirms versioned folders are the reality, else B+C.** +Either way the change is small; the risk is entirely in the migration, not in the layout. + +## 7. Relationship to the shipped seatbelt (4.11.560) + +`BTEnsureContentDirectory` (btl4main.cpp) already makes both layouts work: if cwd has no +`BTL4.RES` it probes `..\..\content`, `..\content`, `content`, and the exe dir itself. **It stays +either way** — it costs nothing, it protects the developer tree (where the split is real and +permanent), and it means this proposal can be adopted or rejected without any risk of regression. +If the layout changes, the first probe candidate simply becomes the hit. + +## 8. Verification plan (if adopted) + +1. `mkdist.py` → extract clean → **double-click `btl4.exe`** (no bat, no cwd help): menu opens, + Launch runs a mission, `bindings.txt`/`environ.ini`/`btl4.log` appear at the root. +2. Each of the five bats from a clean extract. +3. `btl4.exe -fit` bare, and from a shell sitting anywhere else on disk. +4. 2-node LAN via `join_lan.bat` (path assumptions in the join path are the least exercised). +5. Migration path per the chosen option: install 4.11.559 old-shape, customize `bindings.txt`, + upgrade, confirm the setup survives or that the README's step recovers it. +6. Pod profile boot (`SETENV.BAT` now sits at the root — confirm the cabinet contract in + `context/glass-cockpit.md` §ONE unified build still reads correctly). + +## 9. Open questions for the other author + +1. Do we actually have players *overlaying* installs, or does everyone get a fresh folder per + version? This decides §6 entirely. +2. Do the `.bat` launchers survive at all in a single-folder world? They still earn their keep + (launch forensics `#41`, the handoff wait, `BT_FE_SOLO`, `BT_LOG` naming) — but a player who + can now just double-click the exe may never touch them. +3. Should `environ.ini` / `bindings.txt` be **shipped** in the zip rather than written on first + run? RP ships both. We deliberately do not, so extract-over-top cannot clobber a player's + settings — but that reasoning weakens under option A/D. +4. Does the real pod cabinet care? Its `SETENV.BAT` + `BT_PLATFORM=pod` contract is + path-independent, but the pod's own install shape should be confirmed against Nick's notes + before we call the single folder "authentic" for the cabinet as well as the 1995 build. + +## Key Relationships + +- Caused by: `context/build-and-run.md` §cwd guard (the landmine + why BT411 differs) +- Touches: `tools/mkdist.py`, `players/*.bat`, `players/README.txt`, `docs/CONTROLS.md` +- Prior art: `C:\VWE\RP412\pack-dist.ps1` (single-folder dist), `content\BTL4OPT.EXE` (the pod's own) diff --git a/docs/GAUGE_COMPOSITE.md b/docs/GAUGE_COMPOSITE.md index 91f7bf4..db95a0b 100644 --- a/docs/GAUGE_COMPOSITE.md +++ b/docs/GAUGE_COMPOSITE.md @@ -843,9 +843,13 @@ below (view bottom clean). Buttons: the full rect is the click target, the surfa only the LAMP edge shows (PaintGlass painter trick). Address banks = L4GLASSWIN BuildMfd/BuildRadar/BuildFlight. -**Gating precedence** (resolved once → `gBTGaugeCockpit`): `BTGlassPanelsActive` (BT_GLASS_PANELS) -> `BT_COCKPIT=1` > `BT_DEV_GAUGES_WINDOW` / `BT_DEV_GAUGES_DOCK` > **cockpit default**. `BT_COCKPIT=0` -= dock-bottom. `DevGaugeDocked()` includes cockpit so the separate window stands down. Green tint +**Gating precedence** (2026-07-26: resolved ONCE in btl4main → `glassLayout` → `gBTGaugeCockpit`): +`BTGlassPanelsActive` (BT_GLASS_PANELS) > `BT_DEV_GAUGES_WINDOW` > `BT_DEV_GAUGES_DOCK` > +`BT_COCKPIT=0` > **cockpit surround default**. `BT_COCKPIT=0` = dock-bottom — ⚠ which it did NOT +do before that date: the glass profile block converted it to `BT_GLASS_PANELS=1`, so the dock +strip was unreachable under glass. Dock/window modes now auto-raise `BT_PAD_PANEL` (those layouts +carry no buttons of their own). +`DevGaugeDocked()` includes cockpit so the separate window stands down. Green tint `BT_COCKPIT_TINT=RRGGBB` (default 0x27E8 ≈ rgb(33,255,66)); radar stays palette amber. **Pod-build seam.** Panels + dim lamps render in ALL builds; only `PadRIO::SetScreenButton/ diff --git a/docs/GLASS_COCKPIT.md b/docs/GLASS_COCKPIT.md index a3a2b42..520b6dd 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,295 @@ 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. + +## 2026-07-26 (last) — the keyboard becomes the button board (RP412 bindings port) + +User decision: port RP412's bindings design and make it the DEFAULT (not an opt-in alternate), +plus a BT-flavoured interactive controls page. + +**The layout.** Letter and number rows = the MFD banks laid out where they sit on the panel; +flight to the numpad so the board stays free; F1-F12 = the map's two columns; G and B unbound as +the physical gap between the lower clusters. Expressed in BT's OWN grammar (slew/deflect/set, +the Turn channel) -- the bindings.txt format is a compatibility surface and was not touched. + +Coverage audit: **61 of 72 addresses on the keyboard**, and the 11 absent ones are exactly the +addresses with no authored function (0x16/0x17/0x1E/0x1F, 0x38-0x3E). 0x19 is bound (F8) purely +for column completeness. All 72 stay clickable on the panel. + +The scheme lands on BT's addresses unreasonably well: 1-4 + QWER are the whole coolant system +(Condensers 1-6, flush, balance), F6/F7 the display and control-mode cycles, F9-F12 Generators +A-D, F4 the crouch button reconstructed earlier today. + +**The deliberate cost.** A bound key is removed from the 1995 typed-hotkey channel, and this board +binds nearly everything -- so 5 (Quad), z (Eng1), t/y/u/i/o (pilot select) and +/- (zoom) go. +Documented in the file header, the markdown and the page. + +**New rule: a bindings row WINS over a built-in convenience key.** `PadRIO::KeyHasBinding` -- +V and J/K/L are board buttons now, so the view-toggle and preset-cycle polls stand down for them +(they poll GetAsyncKeyState directly and would otherwise have fired BOTH). View toggle lives on +BACKTICK alone. + +**CONTROLS.MAP mirrored** so glass/pod/dev still feel identical (the 2026-07-21 settlement): +90 bindings, 0 parse complaints under BT_INPUT_LOG. + +### Hat labels: 0x42 is TORSO CENTER [T1] +INPUT_PATH_AUDIT flagged 0x41-0x44 as mislabelled. Settled from the streamed mapping +(BT_CTRLMAP_LOG): `elem 66 -> subsys 17 attrID 14` = the TORSO subsystem, while 0x44/0x43/0x41 go +to the mapper's look trio (attrID 10/11/12 = LookLeft/LookRight/LookBehind). The .RES itself has +no "TORSO CENTER" string -- its names are LookBehind/Down/Forward/Left/Right -- so the audit's +wording was loose but its substance right. Swept L4GLASSWIN / L4PADPANEL / L4VB16. + +### The controls page +`docs/CONTROLS.html` -- interactive keyboard (hover any key for its address and meaning, with the +MFD clusters outlined), pad diagram, panel map, the under-glass press-target figure, the radar +placement thumbnails, and the environ.ini table. Modelled on RP412's page, rewritten for BT's +addresses and hazards (the coolant-valve detent warning, jams and ammo-bay fires, BASIC control +mode). mkdist wraps the fragment in a doctype/charset shell and ships it beside CONTROLS.txt. + +### Verified +- bindings.txt regenerates and loads: 74 keys, 0 parse errors; CONTROLS.MAP 90 bindings, 0 errors. +- 72/72 panel addresses still dispatch on click. +- surround / exploded / dock / pod / dev: boot + simulate, zero faults. +- Dist carries CONTROLS.txt + a 46 KB CONTROLS.html with doctype and charset. diff --git a/docs/MOUSELOOK_PLAN.md b/docs/MOUSELOOK_PLAN.md new file mode 100644 index 0000000..009b8c8 --- /dev/null +++ b/docs/MOUSELOOK_PLAN.md @@ -0,0 +1,130 @@ +# MOUSELOOK_PLAN — scope: mouse movement as a cockpit axis + +**Status: SCOPED, NOT IMPLEMENTED.** Written 2026-07-26 at the author's request, alongside the +mouse-BUTTON support that did ship (4.11.565). Read §2 before agreeing to build it — the hard +part is not reading the mouse. + +--- + +## 1. What shipped, and what this is instead + +**Shipped:** mouse *buttons*, as key names (`MOUSE4`, `MOUSE5`, `MOUSEMIDDLE`, `MOUSELEFT`, +`MOUSERIGHT`). Zero new machinery — Win32 hands mouse buttons out as virtual keys and the binding +path already stores VKs and polls `GetAsyncKeyState`, so five table entries were the whole change. + +**This document:** mouse *movement* driving `JoystickX`/`JoystickY` (torso twist and aim) — the +thing a modern player means by "mouse look". That is a real feature, not a table entry, for three +reasons that have nothing to do with reading the device. + +## 2. The three real problems + +### 2.1 The cursor is already spoken for ⚠ (the blocking one) + +The glass cockpit's whole premise is that **every one of the 72 pod buttons is a mouse target** — +left-click presses, right-click latches, and under the exploded layout there are seven separate +windows to click in. Mouse-look wants the opposite: a captured, hidden, recentred cursor that +never touches a button. + +These cannot both be true at once, so the feature is really a **mode question**, and the answer +decides the whole design: + +| Model | Feel | Cost | +|---|---|---| +| **A. Hold-to-look** (`key MOUSE5 mouselook` or a modifier) | Cursor stays free; press to steer, release to click buttons | No mode confusion, discoverable, but you cannot look and hold a trigger comfortably | +| **B. Toggle** (a key flips captured/free) | Full mouse-look while engaged | Needs a visible on-screen indicator or players get stuck "unable to click anything" | +| **C. Region** (look while the pointer is over the 3D view, click when over a display) | No mode at all | Fails in the exploded layout (displays are separate windows) and near the surround's corner MFDs, which overlap the view by design | +| **D. Always captured**, panel clicks via a "release" key | Cleanest for a shooter | Throws away the cockpit's defining interaction; **not recommended** | + +**Recommendation: A, with B as an option.** A costs nothing when unbound and cannot strand a +player. It is also the only one that behaves identically in the surround and exploded layouts. + +### 2.2 The pod's channels are POSITIONAL; a mouse is not + +`JoystickX`/`JoystickY` are absolute stick deflections in −1..1 that `MechControlsMapper` +interprets per control mode. A mouse produces *deltas*. Bridging that is a design choice, not a +conversion: + +- **Accumulate** (recommended): integrate deltas into the channel, clamp to ±1 — a "virtual + stick". Reuses every existing rule (composition, the #36 release edge, the gait detent). +- **Rate**: treat the delta as a velocity of torso angle. Feels better for aiming, but the mapper + has no rate input — it would mean a new channel semantic and a new path through the mapper. + Bigger and less authentic. + +With Accumulate, the stick does not spring back (a mouse has no centre). Options, all cheap: +a `spring <rate>` trailing option that decays toward 0, and/or a recentre key (0x42 torso centre +is already bound to the Up arrow). + +### 2.3 Control mode changes what it means + +BASIC steers with the stick; MID/ADV twist the torso with it and steer with the pedals +([[experience-levels]], [[pod-hardware]]). So mouse-look **steers the mech in BASIC and aims the +torso in MID/ADV** — authentic, but it will read as a bug to anyone who hasn't been told. The +docs must say so, and the feature is most useful in MID/ADV. + +## 3. Proposed shape (if we build it) + +New row type — the sanctioned way to extend the grammar (old builds skip unknown rows with a +logged warning, so a player's file stays forward-compatible): + +``` +mouseaxis <X|Y> axis <channel> [invert] [sensitivity <n>] [spring <rate>] [deadzone <f>] +key <KEY> mouselook # hold-to-look (model A) +``` + +Example default (shipped commented out): + +``` +#mouseaxis X axis JoystickX sensitivity 1.0 spring 0 +#mouseaxis Y axis JoystickY sensitivity 1.0 spring 0 invert +#key MOUSE5 mouselook +``` + +### Where the code goes + +| Piece | Where | Notes | +|---|---|---| +| Raw delta capture | `L4PADRIO` (new), fed from the window proc | **`WM_INPUT` (RawInput)**, not `WM_MOUSEMOVE`: relative motion, no cursor clamping at screen edges, no pointer acceleration. Register `HID_USAGE_GENERIC_MOUSE` with `RIDEV_INPUTSINK` off (foreground only). | +| Capture/hide | same | `SetCapture` + `ShowCursor(FALSE)` + recentre per frame while engaged; release on focus loss | +| Bindings | `L4PADBINDINGS` | `mouseAxisBindings[]` + the `mouselook` action, parallel to `padAxisBindings` | +| Channel write | `PadRIO::Poll` | Beside the pad/joy axis writes, with the SAME per-binding previous-value array so it inherits the #36 release edge and the #24 disconnect rule | +| Docs | `bindings.txt` header, `docs/CONTROLS.md`, `docs/CONTROLS.html` | including the control-mode caveat | + +Estimated size: ~250 lines plus docs. No engine or reconstruction changes — this is entirely in +the glass desktop layer, like PadRIO itself. + +## 4. Authenticity note + +**The pod had no mouse.** There is nothing to reconstruct here and no binary behaviour to be +faithful to — this is a desktop-convenience layer, exactly like PadRIO and the glass cockpit +itself. That is fine (the layer exists precisely so developers and players can fly without a +cabinet), but it means every decision above is a *design* decision and should be judged on feel, +not fidelity. It also means it must stay out of the pod build's path: gate it the way PadRIO is +gated, so `BT_PLATFORM=pod` never sees it. + +## 5. Verification plan + +1. `mouseaxis` rows parse; unknown-option rows warn and skip (grammar-compatibility rule). +2. Hold-to-look engages/releases; cursor visible again on release AND on focus loss (the #24 + disconnect rule, mouse edition). +3. Channel values track motion, clamp at ±1, and release cleanly to 0 (the #36 latch bug is + exactly the hazard here). +4. **Panel clicks still work** while a mouselook binding exists but is not engaged — in BOTH the + surround and exploded layouts. This is the regression that matters. +5. Control-mode sweep: BASIC steers, MID/ADV twists — confirmed against `[mppr]` trace. +6. Pod + dev profiles: no mouse path constructed at all. + +## 6. Open questions + +1. Model A or B (§2.1)? Everything else follows from it. +2. Should mouse-look imply a crosshair/reticle change, so the player can see it is engaged? +3. Does the exploded layout need its own answer, given the seven windows each have their own + client area and the main window may not have focus? +4. Is this wanted for the tester builds at all, or only for the operator/dev seat? It is not + pod-authentic, and the cockpit's mouse-click identity is a feature people have praised. + +## Key Relationships + +- Extends: [[glass-cockpit]] (PadRIO, the desktop input layer) · Constrained by: [[pod-hardware]] + (the RIO channel model), [[experience-levels]] (control modes) +- Prior art in-tree: `L4JOY` (DirectInput layer + the wizard) is the closest analogue for adding + a whole new input device cleanly. diff --git a/engine/MUNGA_L4/L4GLASSWIN.cpp b/engine/MUNGA_L4/L4GLASSWIN.cpp index 9a6387c..065cdad 100644 --- a/engine/MUNGA_L4/L4GLASSWIN.cpp +++ b/engine/MUNGA_L4/L4GLASSWIN.cpp @@ -13,6 +13,7 @@ #include "l4glasswin.h" #include "l4padrio.h" #include "l4vb16.h" +#include "l4riobank.h" // the shared button-bank geometry (with the surround) #include "../munga/gaugrend.h" #include <windows.h> @@ -41,23 +42,17 @@ int } //########################################################################### -// Layout constants (tunable -- Cyd flagged refinements to the exact button -// sub-positions + the yellow left/right split). +// Window constants. The BUTTON GEOMETRY (how far a bank reaches under the +// glass, the lamp strip, the column pitch, the per-column nudge against the +// imagery legends) moved to L4RIOBANK on 2026-07-26 so the cockpit surround +// draws the identical field; what is left here is this window's own chrome. //########################################################################### enum { - Margin = 0, // no padding around the edges (surface fills the window) - TitleH = 0, // no in-client title band (the OS caption labels the window) - CellW = 58, // default button cell (flight-controls window) + CellW = 58, // flight-controls cell (no glass behind it) CellH = 28, - RedCellH = 128, // red MFD buttons -- tall, extend ~100px into the display - EdgeProtrude = 10, // ...and this far OUT past the display edge Gap = 4, - RailW = 40, // radar side-rail button thickness (rotated 90deg, fill height) - RailSlotAdd = 1, // rail pitch = 640/6 + 1 = 107 -> 104px buttons + 3px gaps = 639 (fits 640) - RailGap = 3, // vertical gap between rail buttons (own value; red/horizontals use Gap) - RedOffsetX = 5, // nudge the red MFD buttons right to line up with the imagery MfdSurfW = 640, MfdSurfH = 480, // mono MFD surface at native 640x480 RadarSurfW = 480, RadarSurfH = 640, // portrait secondary/radar CRT at native (rotated 640x480) RepaintTimerId = 1, @@ -67,11 +62,6 @@ enum enum ColorClass { ClrRed, ClrYellow, ClrBlue }; -// Per-column horizontal nudge (px) for the 4 red MFD buttons, left->right, to line -// them up with the (unevenly-spaced) imagery legends. Tunable -- Cyd is dialing -// these in against DISPLAY / PROGRAM / etc. Applied on top of RedOffsetX. -static const int kRedColDX[4] = { -5, -4, 0, 0 }; - struct GButton { int address; @@ -121,8 +111,8 @@ struct NamedButton { int address; const char *name; }; static const NamedButton namedButtons[] = { { 0x3D, "Panic" }, { 0x3F, "Throttle" }, - { 0x40, "Main" }, { 0x41, "Hat Bk" }, { 0x42, "Hat Up" }, - { 0x43, "Hat R" }, { 0x44, "Hat L" }, { 0x45, "Pinky" }, + { 0x40, "Main" }, { 0x41, "Look Bk" }, { 0x42, "Torso Ctr" }, + { 0x43, "Look R" }, { 0x44, "Look L" }, { 0x45, "Pinky" }, { 0x46, "Middle"}, { 0x47, "Upper" }, }; enum { NamedButtonCount = sizeof(namedButtons) / sizeof(namedButtons[0]) }; @@ -137,71 +127,53 @@ static const char * } //########################################################################### -// Lamp decode (identical to L4PADPANEL::LampBrightnessOf -- RIOBase::LampState) +// Lamp decode: the ONE copy lives in l4vb16.h (BTLampBrightnessOf). This TU +// used to carry its own, as did L4PADPANEL -- three copies of the same +// formula, all three carrying the same dim-to-bright flash bug (see the +// header's 2026-07-26 note). //########################################################################### static int LampBrightnessOf(int state, unsigned long tick) { - int level1 = (state >> 2) & 0x3; - int level2 = (state >> 4) & 0x3; - int level = (level1 > level2) ? level1 : level2; - if (level == 0) - return 0; - int flash = state & 0x3; - if (flash != 0) - { - unsigned long half_period = (flash == 1) ? 500 : (flash == 2) ? 250 : 125; - if ((tick / half_period) & 1) - return 0; - } - return level; + return BTLampBrightnessOf(state, tick); } //########################################################################### // Layout builders //########################################################################### -static void - PlaceCellAt(GWin &w, int address, int x, int y, ColorClass color) -{ - GButton &b = w.buttons[w.buttonCount++]; - b.address = address; - b.color = color; - b.rect.left = x + 2; - b.rect.top = y + 2; - b.rect.right = x + CellW - 2; - b.rect.bottom = y + CellH - 2; -} +// (The local PlaceCellAt / PlaceLine / PlaceRect placers retired 2026-07-26 -- +// the geometry they carried now comes from L4RIOBANK, shared with the cockpit +// surround. AdoptBank below is the only thing that writes GButton rects.) +//--------------------------------------------------------------------------- +// Copy a laid-out bank into this window at the given offset. A glass window +// passes -bounds so the bank's bounding box lands at the client origin (the +// bank reaches OUTSIDE the display, so its rects arrive with coordinates +// left of / above it); the flight window, which has no glass, passes 0. +//--------------------------------------------------------------------------- static void - PlaceLine(GWin &w, int startAddr, int count, int step, - int x, int y, int horizontal, ColorClass color) + AdoptBank(GWin &w, const BTRioBank &bank, ColorClass color, int dx, int dy) { - for (int i = 0; i < count; ++i) + for (int i = 0; i < bank.buttonCount && + w.buttonCount < (int)(sizeof(w.buttons) / sizeof(w.buttons[0])); ++i) { - int cx = horizontal ? (x + i * CellW) : x; - int cy = horizontal ? y : (y + i * CellH); - PlaceCellAt(w, startAddr + i * step, cx, cy, color); + const BTRioButton &src = bank.buttons[i]; + GButton &b = w.buttons[w.buttonCount++]; + b.address = src.address; + b.color = (src.colorClass == 1) ? ClrYellow + : (src.colorClass == 2) ? ClrBlue : color; + b.rect.left = src.x + dx; + b.rect.top = src.y + dy; + b.rect.right = src.x + dx + src.w; + b.rect.bottom = src.y + dy + src.h; } } -// Place a button with an explicit size (custom-shaped cells: wide red buttons, -// tall rotated radar rails). -static void - PlaceRect(GWin &w, int address, int x, int y, int cw, int ch, ColorClass color) -{ - GButton &b = w.buttons[w.buttonCount++]; - b.address = address; - b.color = color; - b.rect.left = x; - b.rect.top = y; - b.rect.right = x + cw; - b.rect.bottom = y + ch; -} - // An MFD display: 8 red buttons split 4 above / 4 below the surface (top row = // the high 4 addresses descending, matching the L4PADPANEL cluster order). +// Geometry is L4RIOBANK's -- the under-glass rule, shared with the surround. static void BuildMfd(GWin &w, const char *title, const char *portP, const char *portA, int bankHi) { @@ -212,23 +184,19 @@ static void w.monoTint = MfdMonoTint; w.rotate = 0; - int surfX = Margin; - int surfY = Margin + TitleH + EdgeProtrude; // room for the top buttons to poke out + BTRioBankMetrics metrics; + BTRioBankMetricsFor(MfdSurfW, MfdSurfH, &metrics); - // Buttons OVERLAY the display: 4 tile the full width, reaching RedCellH INTO the - // display and EdgeProtrude OUT past the edge (top row + bottom row; hi addresses - // descending -- L4PADPANEL order). - int slotW = MfdSurfW / 4; - for (int i = 0; i < 4; ++i) - { - int x = surfX + i * slotW + RedOffsetX + kRedColDX[i]; - PlaceRect(w, bankHi - i, x, surfY - EdgeProtrude, slotW - Gap, RedCellH + EdgeProtrude, ClrRed); // top - PlaceRect(w, bankHi - 4 - i, x, surfY + MfdSurfH - RedCellH, slotW - Gap, RedCellH + EdgeProtrude, ClrRed); // bottom - } - SetRect(&w.surfaceRect, surfX, surfY, surfX + MfdSurfW, surfY + MfdSurfH); + BTRioBank bank; + BTRioBankLayout(BTRioBankMfd, 0, 0, MfdSurfW, MfdSurfH, + bankHi, 0, &metrics, 0, &bank); - w.clientW = Margin + MfdSurfW + Margin; - w.clientH = surfY + MfdSurfH + EdgeProtrude + Margin; + BTRioBankDump(title, &bank); + int dx = -bank.boundsX, dy = -bank.boundsY; + AdoptBank(w, bank, ClrRed, dx, dy); + SetRect(&w.surfaceRect, dx, dy, dx + MfdSurfW, dy + MfdSurfH); + w.clientW = bank.boundsW; + w.clientH = bank.boundsH; } // The secondary/radar display: 12 yellow Secondary buttons split left / right @@ -243,37 +211,23 @@ static void w.monoTint = -1; // palette w.rotate = gRadarRot; - int surfX = Margin + EdgeProtrude; // room for the left rail to poke out - int surfY = Margin + TitleH; - - // Rails: big clickable strips that reach RailW INTO the display (hidden under the - // imagery) and poke EdgeProtrude OUT past the side edge (the lamp light). - // Left 0x10..0x15, right 0x18 (top)..0x1D (bottom). (0x15 kept on the left rail - // -- Cyd listed 0x10-0x14; move it if wanted.) - int railCount = 6; - int slotH = RadarSurfH / railCount + RailSlotAdd; - int leftX = surfX - EdgeProtrude; - int rightX = surfX + RadarSurfW - RailW; - for (int i = 0; i < railCount; ++i) - { - int y = surfY + i * slotH; - PlaceRect(w, 0x10 + i, leftX, y, EdgeProtrude + RailW, slotH - RailGap, ClrYellow); - PlaceRect(w, 0x18 + i, rightX, y, RailW + EdgeProtrude, slotH - RailGap, ClrYellow); - } - SetRect(&w.surfaceRect, surfX, surfY, surfX + RadarSurfW, surfY + RadarSurfH); - - // Bottom row: 16, 17, 1F, 1E (left to right), BETWEEN the rails -- reaches CellH - // up into the display, pokes EdgeProtrude out the bottom. + // Left column 0x10-0x15, right 0x18-0x1D, foot row 0x16/0x17/0x1F/0x1E. + // (0x15 sits on the left column -- Cyd listed 0x10-0x14; move it if wanted.) static const int bottomAddrs[4] = { 0x16, 0x17, 0x1F, 0x1E }; - int botLeft = surfX + RailW; - int botSpan = RadarSurfW - 2 * RailW; - int botW = botSpan / 4; - int botY = surfY + RadarSurfH - CellH; - for (int i = 0; i < 4; ++i) - PlaceRect(w, bottomAddrs[i], botLeft + i * botW, botY, botW - Gap, CellH + EdgeProtrude, ClrYellow); - w.clientW = surfX + RadarSurfW + EdgeProtrude + Margin; - w.clientH = surfY + RadarSurfH + EdgeProtrude + Margin; + BTRioBankMetrics metrics; + BTRioBankMetricsFor(RadarSurfW, RadarSurfH, &metrics); + + BTRioBank bank; + BTRioBankLayout(BTRioBankRadar, 0, 0, RadarSurfW, RadarSurfH, + 0x10, 0x18, &metrics, bottomAddrs, &bank); + + BTRioBankDump("Secondary / Radar", &bank); + int dx = -bank.boundsX, dy = -bank.boundsY; + AdoptBank(w, bank, ClrYellow, dx, dy); + SetRect(&w.surfaceRect, dx, dy, dx + RadarSurfW, dy + RadarSurfH); + w.clientW = bank.boundsW; + w.clientH = bank.boundsH; } // The Flight Controls window: the no-display banks -- throttle/panic/door/icom @@ -286,15 +240,27 @@ static void w.portPrimary = NULL; w.showLabels = 1; // the only window that shows hex/name on its buttons - int topY = Margin + TitleH; - int col1 = Margin; - int col2 = Margin + CellW + Gap; + // No glass to hide behind, so these cells carry their own size (see + // l4riobank.h) -- two 1-wide columns, eight rows each. Cell minus the + // 4px gap keeps the historical CellW/CellH PITCH and the 2px inset the + // old PlaceCellAt drew, so the window is pixel-identical. + BTRioBankMetrics metrics; + metrics.strip = 0; + metrics.gap = Gap; + metrics.cellW = CellW - Gap; + metrics.cellH = CellH - Gap; - PlaceLine(w, 0x38, 8, +1, col1, topY, 0, ClrBlue); // throttle / panic / door / icom - PlaceLine(w, 0x40, 8, +1, col2, topY, 0, ClrBlue); // joystick + fire buttons + BTRioBank bank; + memset(&bank, 0, sizeof(bank)); + BTRioBankFlightGrid(2, 2, 0x38, 8, 1, 0, 0, &metrics, &bank); // throttle/panic/door/icom + BTRioBankFlightGrid(CellW + Gap + 2, 2, 0x40, 8, 1, 0, 0, &metrics, &bank); // joystick + fire - w.clientW = col2 + CellW + Margin; - w.clientH = topY + 8 * CellH + Margin; + BTRioBankDump("Flight Controls", &bank); + AdoptBank(w, bank, ClrBlue, 0, 0); + // no translation here (the cells are already at their 2px inset); the + // client is the historical two-column extent + w.clientW = CellW + Gap + CellW; + w.clientH = 8 * CellH; } //########################################################################### 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/L4PADBINDINGS.cpp b/engine/MUNGA_L4/L4PADBINDINGS.cpp index 6827a15..af68103 100644 --- a/engine/MUNGA_L4/L4PADBINDINGS.cpp +++ b/engine/MUNGA_L4/L4PADBINDINGS.cpp @@ -61,8 +61,42 @@ static const NamedValue keyNames[] = { "BACKTICK", VK_OEM_3 }, { "LBRACKET", VK_OEM_4 }, { "BACKSLASH", VK_OEM_5 }, { "RBRACKET", VK_OEM_6 }, { "QUOTE", VK_OEM_7 }, + // + // MOUSE BUTTONS (2026-07-26). Win32 hands these out as virtual keys, and + // the whole binding path already stores VKs and reads them with + // GetAsyncKeyState -- so they need no verb, no parser change and no new + // machinery, just a name. `key MOUSE4 button 0x40` works exactly like any + // other key row, axes included. + // + // ⚠ MOUSELEFT/MOUSERIGHT are how the player presses cockpit buttons (left + // = press, right = latch). The poll is focus-guarded but not click-aware, + // so binding either ALSO fires on every panel click. They are named here + // for completeness; the middle and side buttons are the free ones. + // + { "MOUSELEFT", VK_LBUTTON }, { "MOUSERIGHT", VK_RBUTTON }, + { "MOUSEMIDDLE", VK_MBUTTON }, + { "MOUSE4", VK_XBUTTON1 }, { "MOUSE5", VK_XBUTTON2 }, + { "MOUSEX1", VK_XBUTTON1 }, { "MOUSEX2", VK_XBUTTON2 }, }; +// +// A mouse button rather than a keyboard key? (Used where a binding only +// makes sense for a real key -- the RGB lamp mirror, which paints keycaps.) +// +static int + IsMouseVirtualKey(int virtualKey) +{ + return (virtualKey == VK_LBUTTON || virtualKey == VK_RBUTTON || + virtualKey == VK_MBUTTON || virtualKey == VK_XBUTTON1 || + virtualKey == VK_XBUTTON2) ? 1 : 0; +} + +int + BTPadBindingIsMouseKey(int virtualKey) +{ + return IsMouseVirtualKey(virtualKey); +} + static const NamedValue padButtonNames[] = { { "A", XINPUT_GAMEPAD_A }, @@ -161,20 +195,35 @@ static int // the 12 secondary-panel buttons, 0x00-0x0F the lower aux banks, 0x20-0x37 // the upper aux banks. Keypad unit 0 = the pilot's MFD keypad. // -// KEYBOARD RECONCILIATION (2026-07-20, input-coverage audit): the keyboard -// carries the ~20 core gameplay actions on the SAME keys the desktop -// CONTROLS.MAP uses (W/S/A/D drive, Q/E twist, R/F elevation, X all-stop, -// 1-4/Space/Ctrl fire, M/N mode/display cycle, H flush, C valve, B look -// behind, G configure, V view); FULL pod-address coverage lives on the -// on-screen panel (every one of the 72 buttons is one click away, -// right-click = hold latch). Keys DISPLACED from the previous default -// (each still reachable by editing bindings.txt or on the panel): -// - number row 1-0/-/= -> secondary-panel 0x10-0x1D (1-4 now fire) -// - QWERTY row Q..P -> pilot MFD keypad unit 0 (dropped) -// - arrow keys -> hat looks 0x41-0x44 (arrows now drive) -// - V/C/B -> fire 0x47/0x46/0x45 (now view/valve/behind) -// - LCTRL -> throttle slew down (now fire 0x47) -// The XInput pad section is unchanged. +// THE KEYBOARD IS THE BUTTON BOARD (2026-07-26, ported from RP412). +// +// Every one of the pod's 72 buttons is reachable from the keyboard, laid out +// WHERE IT SITS ON THE PANEL: the number row is the upper MFDs' top row and +// QWERTY the row beneath it, running left to right across Heat / Engineering / +// Comm; the home row and the row below it do the same for the two lower +// weapon MFDs; F1-F12 are the two columns flanking the map. G and B stay +// unbound on purpose -- they are the physical gap between the lower clusters. +// Flight moves to the NUMPAD (which already carried it as an alternative), so +// the letter field is free for the board. +// +// It lands on BT's own addresses better than it has any right to: 1-4 and +// QWER are the Heat display's two rows, which is the entire coolant system +// (Condensers 1-6, the flush and the balance button); F6/F7 are the display +// and control-mode cycles; F9-F12 are Generators A-D. +// +// WHAT THIS COSTS (the deliberate trade). A bound key is REMOVED from the +// authentic 1995 typed-hotkey channel to stop double-dispatch, and this board +// binds nearly the whole field -- so the typed hotkeys go: 5 = MFD1 Quad page, +// z = MFD3 Eng1, t/y/u/i/o = pilot select 3-7, +/- = target zoom. They remain +// reachable on the panel and by editing this file. The built-in convenience +// keys yield the same way: V and J/K/L are board buttons now, so PadRIO's +// view-toggle and preset-cycle polls stand down for them (KeyHasBinding) and +// the view toggle lives on BACKTICK alone. +// +// PREVIOUS DEFAULT (2026-07-20 - 2026-07-26) was the ~20-action CONTROLS.MAP +// mirror: W/S/A/D drive, Q/E twist, R/F elevation, 1-4/Space/Ctrl fire, +// M/N/H/C/G systems. An existing bindings.txt is never overwritten, so a +// player only meets this board by deleting theirs. //########################################################################### static const char *defaultProfileText = @@ -187,71 +236,134 @@ static const char *defaultProfileText = "# key <KEYNAME> axis <channel> set <value> jump/detent\n" "# pad <PADBTN> button <addr>\n" "# padaxis <PADAXIS> axis <channel> [invert] [slew <rate>]\n" +"# <KEYNAME> may be a MOUSE BUTTON: MOUSEMIDDLE, MOUSE4, MOUSE5 (the two\n" +"# side buttons; MOUSEX1/MOUSEX2 are aliases). MOUSELEFT and MOUSERIGHT\n" +"# also work but are how you press cockpit buttons -- bind them and they\n" +"# fire on every panel click too. Mouse MOVEMENT is not bindable.\n" "# Channels: Throttle JoystickX JoystickY LeftPedal RightPedal Turn\n" "# (Turn = signed composite: + drives the right pedal, - the left --\n" "# made for mapping a gamepad stick to turning)\n" "# Pad buttons: A B X Y LB RB BACK START LS RS DPAD_* Axes: LX LY RX RY LT RT\n" "#\n" -"# LAYOUT: the keyboard hosts the core gameplay actions on the desktop\n" -"# CONTROLS.MAP keys; the on-screen cockpit panel covers EVERY pod button\n" -"# by click (right-click = press-and-hold latch). A key bound here is\n" -"# REMOVED from the authentic 1995 typed-hotkey channel (no double\n" -"# dispatch); unbound keys keep their 1995 meaning -- 5 = MFD1 Quad page,\n" -"# z = MFD3 Eng1 page, t/y/u/i/o = pilot select 3-7, + / - = target zoom.\n" -"# Hardcoded (not rebindable here): ` or V = view toggle, J/K/L = cycle\n" -"# the Mfd1/Mfd2/Mfd3 preset page.\n" +"# LAYOUT: THE KEYBOARD IS THE POD'S BUTTON BOARD. The letter and number\n" +"# rows are the MFD button banks laid out where they sit on the panel, and\n" +"# flight lives on the numpad so the board stays free. Every one of the 72\n" +"# pod buttons is also one mouse click away on the cockpit itself\n" +"# (right-click = press-and-hold latch).\n" +"#\n" +"# A key bound here is REMOVED from the authentic 1995 typed-hotkey channel\n" +"# so it cannot double-dispatch -- and this board binds nearly everything,\n" +"# so those hotkeys (5 = MFD1 Quad, z = MFD3 Eng1, t/y/u/i/o = pilot select,\n" +"# +/- = target zoom) are given up by design. Unbind a key here to get its\n" +"# 1995 meaning back. Same rule for the built-ins: BACKTICK = view toggle\n" +"# (V is a board button now), and J/K/L cycle the Mfd1/2/3 preset pages ONLY\n" +"# while unbound.\n" "\n" -"# --- drive: W/S sweep the throttle lever (sticks; X = all-stop detent),\n" -"# --- A/D spring the turn pedals, Q/E twist the torso, R/F aim it up/down\n" -"key W axis Throttle slew + 0.7\n" -"key S axis Throttle slew - 0.7\n" -"key UP axis Throttle slew + 0.7\n" -"key DOWN axis Throttle slew - 0.7\n" -"key A axis LeftPedal deflect + 2.5\n" -"key D axis RightPedal deflect + 2.5\n" -"key LEFT axis LeftPedal deflect + 2.5\n" -"key RIGHT axis RightPedal deflect + 2.5\n" -"key Q axis JoystickX deflect - 2.5\n" -"key E axis JoystickX deflect + 2.5\n" -"key R axis JoystickY deflect + 2.5\n" -"key F axis JoystickY deflect - 2.5\n" -"key X axis Throttle set 0\n" -"\n" -"# --- numpad flight cluster (alternative stick/pedals/lever) ---\n" +"# --- flight: the numpad ------------------------------------------------\n" +"# 8/2/4/6 stick, 7/9 pedals, 5 all-stop detent, 0 main trigger.\n" +"# SHIFT/CTRL walk the throttle lever and it STICKS where you leave it;\n" +"# ALT is the throttle handle's reverse-thrust button.\n" "key NUMPAD8 axis JoystickY deflect + 2.5\n" "key NUMPAD2 axis JoystickY deflect - 2.5\n" "key NUMPAD4 axis JoystickX deflect - 2.5\n" "key NUMPAD6 axis JoystickX deflect + 2.5\n" "key NUMPAD7 axis LeftPedal deflect + 2.5\n" "key NUMPAD9 axis RightPedal deflect + 2.5\n" -"key LSHIFT axis Throttle slew + 0.7\n" "key NUMPAD5 axis Throttle set 0\n" -"\n" -"# --- fire groups (the joystick's four mappable buttons) + reverse + look\n" -"key 1 button 0x40\n" -"key SPACE button 0x40\n" -"key 2 button 0x46\n" -"key 3 button 0x47\n" -"key LCTRL button 0x47\n" -"key RCTRL button 0x47\n" -"key 4 button 0x45\n" +"key LSHIFT axis Throttle slew + 0.7\n" +"key RSHIFT axis Throttle slew + 0.7\n" +"key LCTRL axis Throttle slew - 0.7\n" +"key RCTRL axis Throttle slew - 0.7\n" "key LALT button 0x3F\n" -"key B button 0x41\n" +"key RALT button 0x3F\n" "\n" -"# --- systems (pod console buttons) ---\n" -"key M button 0x18 # cycle control mode (BAS/MID/ADV)\n" -"key N button 0x15 # cycle secondary schematic (Damage/Critical/Heat)\n" -"key H button 0x2C # HOLD = coolant flush (Reservoir InjectCoolant)\n" -"key C button 0x2F # cycle Condenser1 coolant valve (MoveValve)\n" -"key G button 0x0E # HOLD + fire key = regroup weapon 1 (Mfd1 Quad page)\n" +"# --- the four mappable fire buttons ------------------------------------\n" +"# The mouse's side buttons are free if you want them on the trigger:\n" +"# key MOUSE4 button 0x40\n" +"# key MOUSE5 button 0x46\n" +"# key MOUSEMIDDLE button 0x41\n" +"key SPACE button 0x40 # main trigger\n" +"key NUMPAD0 button 0x40 # main trigger\n" +"key NUMPAD1 button 0x45 # pinky\n" +"key NUMPAD3 button 0x46 # middle thumb\n" +"key NUMPADDOT button 0x47 # upper thumb\n" "\n" -"# --- Mfd2 bank on F5-F8 + F9 (page-gated: Quad page = select the Eng\n" -"# --- pages, Eng page = generator A-D select / F9 gen-mode toggle) ---\n" -"key F5 button 0x27\n" -"key F6 button 0x26\n" -"key F7 button 0x25\n" -"key F8 button 0x24\n" -"key F9 button 0x22\n" +"# --- the hat: look around, on the arrows -------------------------------\n" +"key UP button 0x42 # torso CENTER (the shipped .RES name)\n" +"key DOWN button 0x41 # look behind\n" +"key LEFT button 0x44 # look left\n" +"key RIGHT button 0x43 # look right\n" +"\n" +"# --- UPPER MFD banks: number row on top, QWERTY beneath ----------------\n" +"# Heat / coolant (0x28-0x2F) -- 1-4 and QWER ARE the coolant system:\n" +"# 1/2/3 Condenser 1-3 valve, 4 coolant FLUSH (hold), Q/W/E Condenser\n" +"# 4-6 valve, R balance coolant. The valve detents run 1-5-50-CLOSED.\n" +"key 1 button 0x2F\n" +"key 2 button 0x2E\n" +"key 3 button 0x2D\n" +"key 4 button 0x2C\n" +"key Q button 0x2B\n" +"key W button 0x2A\n" +"key E button 0x29\n" +"key R button 0x28\n" +"# Engineering / Mfd2 (0x20-0x27) -- page-gated: Quad page picks an Eng\n" +"# page, Eng page drives generator select A-D / bus mode / coolant.\n" +"key 5 button 0x27\n" +"key 6 button 0x26\n" +"key 7 button 0x25\n" +"key 8 button 0x24\n" +"key T button 0x23\n" +"key Y button 0x22\n" +"key U button 0x21\n" +"key I button 0x20\n" +"# Comm / target hotbox (0x30-0x37) -- pilot select.\n" +"key 9 button 0x37\n" +"key 0 button 0x36\n" +"key MINUS button 0x35\n" +"key EQUALS button 0x34\n" +"key O button 0x33\n" +"key P button 0x32\n" +"key LBRACKET button 0x31\n" +"key RBRACKET button 0x30\n" +"\n" +"# --- LOWER MFD banks: home row on top, the row below beneath -----------\n" +"# G and B are the PANEL GAP between the two lower clusters -- left\n" +"# unbound on purpose, so they keep their 1995 typed meaning.\n" +"# Left Weapons / Mfd1 (0x08-0x0F).\n" +"key A button 0x0F\n" +"key S button 0x0E\n" +"key D button 0x0D\n" +"key F button 0x0C\n" +"key Z button 0x0B\n" +"key X button 0x0A\n" +"key C button 0x09\n" +"key V button 0x08\n" +"# Right Weapons / Mfd3 (0x00-0x07).\n" +"key H button 0x07\n" +"key J button 0x06\n" +"key K button 0x05\n" +"key L button 0x04\n" +"key N button 0x03\n" +"key M button 0x02\n" +"key COMMA button 0x01\n" +"key PERIOD button 0x00\n" +"\n" +"# --- the two columns flanking the map, top to bottom -------------------\n" +"# Left (0x10-0x15): map zoom in/out, thermal IR, CROUCH, searchlight,\n" +"# cycle secondary display. Right (0x18-0x1D): cycle control mode\n" +"# (BAS/MID/ADV), unused, Generator A-D on/off.\n" +"key F1 button 0x10\n" +"key F2 button 0x11\n" +"key F3 button 0x12\n" +"key F4 button 0x13\n" +"key F5 button 0x14\n" +"key F6 button 0x15\n" +"key F7 button 0x18\n" +"key F8 button 0x19\n" +"key F9 button 0x1A\n" +"key F10 button 0x1B\n" +"key F11 button 0x1C\n" +"key F12 button 0x1D\n" "\n" "# --- XInput pad ---\n" "# POD-FAITHFUL (default): stick X = torso twist, triggers = pedals.\n" diff --git a/engine/MUNGA_L4/L4PADPANEL.cpp b/engine/MUNGA_L4/L4PADPANEL.cpp index 36d49f2..468fbba 100644 --- a/engine/MUNGA_L4/L4PADPANEL.cpp +++ b/engine/MUNGA_L4/L4PADPANEL.cpp @@ -21,6 +21,7 @@ #include "l4padpanel.h" #include "l4padrio.h" +#include "l4vb16.h" // BTLampBrightnessOf -- the one lamp decode #include <windows.h> #include <stdio.h> @@ -95,10 +96,10 @@ static const NamedButton namedButtons[] = { 0x3D, "Panic" }, { 0x3F, "Throttle" }, { 0x40, "Main" }, - { 0x41, "Hat Back" }, - { 0x42, "Hat Up" }, - { 0x43, "Hat Right" }, - { 0x44, "Hat Left" }, + { 0x41, "Look Back" }, + { 0x42, "Torso Ctr" }, + { 0x43, "Look Right" }, + { 0x44, "Look Left" }, { 0x45, "Pinky" }, { 0x46, "Middle" }, { 0x47, "Upper" }, @@ -191,33 +192,18 @@ static int } //########################################################################### -// Lamp decode (RIOBase::LampState): flash = low 2 bits (solid/slow/med/ -// fast), filament brightness = max of the state1 (>>2) and state2 (>>4) -// 2-bit fields (0 off / 1 dim / 3 bright). Flash blinks between the -// commanded brightness and off, half-periods 500/250/125 ms (vRIO). +// Lamp decode (RIOBase::LampState): the ONE copy lives in l4vb16.h +// (BTLampBrightnessOf) -- flash mode in the low 2 bits picks a 500/250/125 ms +// half-period and the two 2-bit brightness fields (state1 >>2, state2 >>4; +// 0 off / 1 dim / 3 bright) ALTERNATE across it. This TU used to carry its +// own max-then-blank copy, as did L4GLASSWIN; see the header's 2026-07-26 +// note for the dim-to-bright pulse all three got wrong. //########################################################################### static int LampBrightnessOf(int state, unsigned long tick) { - int level1 = (state >> 2) & 0x3; - int level2 = (state >> 4) & 0x3; - int level = (level1 > level2) ? level1 : level2; - if (level == 0) - { - return 0; - } - int flash = state & 0x3; - if (flash != 0) - { - unsigned long half_period = - (flash == 1) ? 500 : (flash == 2) ? 250 : 125; - if ((tick / half_period) & 1) - { - return 0; - } - } - return level; + return BTLampBrightnessOf(state, tick); } //########################################################################### diff --git a/engine/MUNGA_L4/L4PADRIO.cpp b/engine/MUNGA_L4/L4PADRIO.cpp index c7cca9a..40229a0 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,35 @@ 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. +// +// +// Does the loaded profile bind this virtual key? The built-in convenience +// keys (view toggle, preset cycles) consult this and stand down when the +// player's bindings claim the key -- see the note at their poll sites. +// +Logical + PadRIO::KeyHasBinding(int virtualKey) const +{ + for (int i = 0; i < bindings.keyBindingCount; ++i) + { + if (bindings.keyBindings[i].virtualKey == virtualKey) + { + return True; + } + } + return False; +} + +static void + KeyLightLog(const char *line) +{ + DEBUG_STREAM << "[keylight] " << (line ? line : "") << "\n" << std::flush; +} + static float NormalizeThumb(int value, int dead_zone) { @@ -170,6 +200,75 @@ 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; + } + // A keyboard lamp array has no mouse buttons to paint. + { + extern int BTPadBindingIsMouseKey(int virtualKey); + if (BTPadBindingIsMouseKey(binding.virtualKey)) + { + 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 +298,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) @@ -537,11 +641,17 @@ void // message-path-free) and consumed by the game's view-toggle block via // BTPadViewToggleEdge. // + // ⚠ A bindings.txt ROW WINS over these built-in convenience keys + // (2026-07-26). The default profile now hands the whole letter field to + // the MFD banks -- V is a Left Weapons button, J/K/L are Right Weapons -- + // and polling them here as well would fire BOTH the button and the + // built-in, which is the double-dispatch the typed-channel suppression + // exists to prevent. Bind the key, and the built-in stands down. { static int s_backtickWas = 0; int backtick_held = focused && (((GetAsyncKeyState(VK_OEM_3) & 0x8000) != 0) || - ((GetAsyncKeyState('V') & 0x8000) != 0)); + (!KeyHasBinding('V') && (GetAsyncKeyState('V') & 0x8000) != 0)); if (backtick_held && !s_backtickWas) { extern int gBTPadViewToggleEdges; @@ -560,7 +670,7 @@ void static const int s_presetKey[3] = { 'J', 'K', 'L' }; for (int g = 0; g < 3; ++g) { - int held = focused && + int held = focused && !KeyHasBinding(s_presetKey[g]) && (GetAsyncKeyState(s_presetKey[g]) & 0x8000) != 0; if (held && !s_presetWas[g]) { @@ -1038,6 +1148,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..ea0fda4 100644 --- a/engine/MUNGA_L4/L4PADRIO.h +++ b/engine/MUNGA_L4/L4PADRIO.h @@ -143,6 +143,17 @@ 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; + + // True when the loaded bindings claim this virtual key. The built-in + // convenience keys (` / V view toggle, J/K/L preset cycles) yield to an + // explicit row rather than double-dispatching with it. + Logical + KeyHasBinding(int virtualKey) const; + // // Typed-channel suppression tables (built from the loaded bindings + // the hardcoded view/preset keys; see SuppressKey). diff --git a/engine/MUNGA_L4/L4RIOBANK.cpp b/engine/MUNGA_L4/L4RIOBANK.cpp new file mode 100644 index 0000000..c196f22 --- /dev/null +++ b/engine/MUNGA_L4/L4RIOBANK.cpp @@ -0,0 +1,296 @@ +#include "mungal4.h" +#pragma hdrstop + +//########################################################################### +// L4RIOBANK -- the ONE button-bank geometry for a pod display. Design + +// the under-glass rule: l4riobank.h. Consumed by the cockpit surround +// (L4VB16.cpp, canvas space) and the exploded per-display windows +// (L4GLASSWIN.cpp, client space); neither owns a copy any more. +//########################################################################### + +#include "l4riobank.h" + +#include <string.h> +#include <stdlib.h> + +namespace +{ + //------------------------------------------------------------------- + // The lamp strip at NATIVE glass size, scaled down with the display + // but floored so a half-scale surround lamp is still a bar and not a + // smudge. RP412 L4MFDVIEW works at native only and hardcodes 10. + //------------------------------------------------------------------- + const int nativeShortAxis = 480; // MFD 640x480 / map 480x640 + const int nativeStrip = 10; + const int minimumStrip = 6; + const int defaultGap = 4; + + //------------------------------------------------------------------- + // Per-column horizontal nudge for the 4 MFD buttons, left->right, so + // they line up with the (unevenly spaced) DISPLAY / PROGRAM / ... + // legends painted in the imagery. Measured against the native 640 + // glass in L4GLASSWIN (RedOffsetX + kRedColDX) and scaled here, so + // both renderers inherit the same alignment. Tunable. + //------------------------------------------------------------------- + const int nativeMfdW = 640; + const int mfdColumnDX[4] = { 0, 1, 5, 5 }; + + //------------------------------------------------------------------- + // The map paints its own legend beside each side button (the six + // cells: MAP+, MAP-, IR, crouch, searchlight, display-mode), and the + // buttons have to line up with THAT grid, not with height/6. + // + // MEASURED off a native 480x640 capture (scratchpad/measurelegend.py + // over an exploded Secondary/Radar window): the first cell border sits + // 3 rows down and the six cells are 102 tall on a 107 pitch. RP412's + // map measures 13 + 6x102 on a 105 pitch -- same cell height, different + // top and pitch, so its numbers do NOT transfer; these are ours. + // + // Our previous even division (displayH/6 + 1 = 107) had the pitch right + // by luck and sat 3px high of the labels. + //------------------------------------------------------------------- + const int mapLegendSpan = 640; + const int mapLegendTop = 3; + const int mapLegendCell = 102; + const int mapLegendPitch = 107; + + int Scaled(int nativeValue, int actual, int nativeExtent) + { + if (nativeExtent <= 0) return nativeValue; + return (nativeValue * actual) / nativeExtent; + } + + void Push( + BTRioBank *bank, + int address, int x, int y, int w, int h, + int colorClass, int inert, const char *label + ) + { + if (bank->buttonCount >= BTRioBankMaxButtons) return; + if (w <= 0 || h <= 0) return; + BTRioButton *button = &bank->buttons[bank->buttonCount++]; + button->address = address; + button->x = x; + button->y = y; + button->w = w; + button->h = h; + button->colorClass = colorClass; + button->inert = inert; + button->label = label; + } + + void GrowBounds(BTRioBank *bank, int x, int y, int w, int h) + { + if (bank->boundsW == 0 && bank->boundsH == 0) + { + bank->boundsX = x; bank->boundsY = y; + bank->boundsW = w; bank->boundsH = h; + return; + } + int left = (x < bank->boundsX) ? x : bank->boundsX; + int top = (y < bank->boundsY) ? y : bank->boundsY; + int right = bank->boundsX + bank->boundsW; + int bottom = bank->boundsY + bank->boundsH; + if (x + w > right) right = x + w; + if (y + h > bottom) bottom = y + h; + bank->boundsX = left; + bank->boundsY = top; + bank->boundsW = right - left; + bank->boundsH = bottom - top; + } +} + +//########################################################################### + +void + BTRioBankMetricsFor(int displayW, int displayH, BTRioBankMetrics *out) +{ + if (out == 0) return; + + int shortAxis = (displayW < displayH) ? displayW : displayH; + + int strip = Scaled(nativeStrip, shortAxis, nativeShortAxis); + if (strip < minimumStrip) strip = minimumStrip; + + out->strip = strip; + out->gap = defaultGap; + + // The cells with no glass behind them (the flight bank, the map's foot + // row) are drawn in full, so they get a plain readable size scaled off + // the same axis rather than a derived depth. + out->cellW = Scaled(58, shortAxis, nativeShortAxis); + out->cellH = Scaled(28, shortAxis, nativeShortAxis); + if (out->cellW < 12) out->cellW = 12; + if (out->cellH < 12) out->cellH = 12; +} + +//########################################################################### + +void + BTRioBankLayout( + BTRioBankStyle style, + int originX, + int originY, + int displayW, + int displayH, + int anchorA, + int anchorB, + const BTRioBankMetrics *metrics, + const int *bottomAddrs, + BTRioBank *out + ) +{ + if (out == 0 || metrics == 0) return; + memset(out, 0, sizeof(*out)); + + const int strip = metrics->strip; + const int gap = metrics->gap; + + // the glass itself is always inside the bounds + GrowBounds(out, originX, originY, displayW, displayH); + + if (style == BTRioBankMfd) + { + //--------------------------------------------------------------- + // 4 above the glass, 4 below. Addresses descend from the anchor + // row-major (vRIO CockpitLayout::Mfd): top = anchorA-i, bottom = + // anchorA-4-i. Each button reaches HALF THE GLASS in and clears + // the edge by `strip`, so the two banks meet in the middle and + // every pixel of the display is some button's press target. + //--------------------------------------------------------------- + int depth = displayH / 2; + if (depth < strip) depth = strip; + + int slotW = displayW / 4; + for (int i = 0; i < 4; ++i) + { + int dx = Scaled(mfdColumnDX[i], displayW, nativeMfdW); + int x = originX + i * slotW + dx; + int w = slotW - gap; + + int topY = originY - strip; + int botY = originY + displayH - depth; + Push(out, anchorA - i, x, topY, w, depth + strip, 0, 0, 0); + Push(out, anchorA - 4 - i, x, botY, w, depth + strip, 0, 0, 0); + GrowBounds(out, x, topY, w, depth + strip); + GrowBounds(out, x, botY, w, depth + strip); + } + } + else if (style == BTRioBankRadar) + { + //--------------------------------------------------------------- + // The portrait map: 6 buttons down each side (anchorA left, + // anchorB right) plus an optional 4 along the foot. Same rule + // turned on its side -- each column reaches half the map in and + // leaves `strip` clearing the edge. + // + // The FOOT ROW IS PUSHED FIRST, deliberately: the columns now + // reach half the width each, so they cover the foot band too, + // and the hit test takes the first match. Foot-first keeps + // those four addresses reachable. + //--------------------------------------------------------------- + int depth = displayW / 2; + if (depth < strip) depth = strip; + + if (bottomAddrs != 0) + { + int bandH = metrics->cellH; + int bandY = originY + displayH - bandH; + int bandW = displayW / 4; + for (int i = 0; i < 4; ++i) + { + int x = originX + i * bandW; + Push(out, bottomAddrs[i], x, bandY, bandW - gap, + bandH + strip, 1, 0, 0); + GrowBounds(out, x, bandY, bandW - gap, bandH + strip); + } + } + + //--------------------------------------------------------------- + // Six cells down the height, on the map's MEASURED legend grid + // (see mapLegend* above). Top and bottom are scaled SEPARATELY + // and subtracted rather than scaling a height directly: rounding + // a height would let the buttons drift out of step with the + // labels a display or two down the column. + //--------------------------------------------------------------- + int leftX = originX - strip; + int rightX = originX + displayW - depth; + for (int i = 0; i < 6; ++i) + { + int cellTop = mapLegendTop + i * mapLegendPitch; + int top = (cellTop * displayH) / mapLegendSpan; + int bottom = ((cellTop + mapLegendCell) * displayH) / mapLegendSpan; + int h = bottom - top; + if (h < 1) h = 1; + int y = originY + top; + Push(out, anchorA + i, leftX, y, depth + strip, h, 1, 0, 0); + Push(out, anchorB + i, rightX, y, depth + strip, h, 1, 0, 0); + GrowBounds(out, leftX, y, depth + strip, h); + GrowBounds(out, rightX, y, depth + strip, h); + } + } +} + +//########################################################################### + +void + BTRioBankFlightGrid( + int originX, + int originY, + int baseAddress, + int count, + int columns, + const char *const *labels, + const int *inert, + const BTRioBankMetrics *metrics, + BTRioBank *out + ) +{ + if (out == 0 || metrics == 0 || columns <= 0) return; + + const int cw = metrics->cellW; + const int ch = metrics->cellH; + const int gap = metrics->gap; + + for (int i = 0; i < count; ++i) + { + int column = i % columns; + int row = i / columns; + int x = originX + column * (cw + gap); + int y = originY + row * (ch + gap); + Push(out, baseAddress + i, x, y, cw, ch, 2, + (inert != 0) ? inert[i] : 0, + (labels != 0) ? labels[i] : 0); + GrowBounds(out, x, y, cw, ch); + } +} + +//########################################################################### + +void + BTRioBankDump(const char *tag, const BTRioBank *bank) +{ + static int enabled = -1; + static int printed = 0; + + if (enabled < 0) + { + const char *e = getenv("BT_RIOBANK_LOG"); + enabled = (e != 0 && e[0] != '0') ? 1 : 0; + } + if (!enabled || bank == 0 || printed >= 16) return; + ++printed; + + DEBUG_STREAM << "[riobank] " << (tag ? tag : "?") + << " bounds=(" << bank->boundsX << "," << bank->boundsY << "," + << bank->boundsW << "," << bank->boundsH << ") " + << bank->buttonCount << " buttons\n"; + for (int i = 0; i < bank->buttonCount; ++i) + { + const BTRioButton &b = bank->buttons[i]; + DEBUG_STREAM << "[riobank] addr=0x" << std::hex << b.address << std::dec + << " rect=(" << b.x << "," << b.y << "," << b.w << "," << b.h << ")" + << " class=" << b.colorClass << "\n"; + } + DEBUG_STREAM << std::flush; +} diff --git a/engine/MUNGA_L4/L4VB16.cpp b/engine/MUNGA_L4/L4VB16.cpp index 2baf373..6dc1210 100644 --- a/engine/MUNGA_L4/L4VB16.cpp +++ b/engine/MUNGA_L4/L4VB16.cpp @@ -2,6 +2,7 @@ #pragma hdrstop #include "l4vb16.h" +#include "l4riobank.h" // the shared button-bank geometry (with the exploded windows) #include "../munga/gaugrend.h" #include "L4VIDEO.h" #include "DXUtils.h" @@ -195,41 +196,229 @@ int gBTCockpitCanvasW = 0; // backbuffer canvas dims (set by btl4main; the int gBTCockpitCanvasH = 0; // aspect calc needs them after a client resize) // Layout constants (backbuffer px). MFD = 640x480 native x 0.5; radar = 480x640 -// portrait x 0.5. The lamp EDGE (kCkLamp) protrudes past the surface; the FULL -// button rect reaches kCkRedCell INTO the display (hit-only, covered by the -// surface). User asked for larger/more-clickable buttons than the glass windows' -// 10px edge -> kCkLamp 16. +// portrait x 0.5. +// +// ⚠ 2026-07-26: the BUTTON rects no longer come from here -- L4RIOBANK owns +// that geometry now and the exploded per-display windows draw the identical +// field. What changed for the surround: its MFD lamps used to sit ENTIRELY +// outside the glass on a kCkRedH=24 strip (a 76x24 click target), while the +// same button in the exploded window reached 128px under the display. Both +// now follow the pod's under-glass rule -- half the glass deep, a scaled lamp +// strip clearing the edge. kCkTopBand still has to reserve room for the top +// row's protruding strip, so the band constants stay. enum { - kCkMFDW = 320, kCkMFDH = 240, + kCkMFDW = 320, kCkMFDH = 240, // the POD sizes (100%) -- see BTCkSizes kCkRADW = 240, kCkRADH = 320, kCkOVL = 44, // (int)(0.14f * 320): corner overlap into the view - kCkLamp = 16, // radar rail / bottom-cell protrusion - kCkRedH = 24, // MFD red lamp height -- sits fully OUTSIDE the surface - kCkRedGap = 3, // gap between the red lamp and the MFD screen edge - kCkRailW = 26, // radar side-rail width - kCkGap = 2, - kCkSideBand = kCkMFDW - kCkOVL, // 276 - kCkTopBand = (kCkMFDH - kCkOVL) + kCkRedGap + kCkRedH, // 223 (fits the top red lamps) - kCkBotBand = kCkRADH + kCkLamp // 336 (radar flush below view) + kCkLamp = 16, // legacy band reserve (radar rail / bottom cell) + kCkRedH = 24, // legacy band reserve above the top MFD row + kCkRedGap = 3, // gap between that reserve and the MFD screen edge + kCkGap = 2 }; +//===========================================================================// +// PLAYER-SCALED DISPLAYS (2026-07-26). +// +// The pod bolted its instruments down at one size; a desktop panel has room to +// trade viewscreen for instrument, so the player scales them -- BT_MFD_SCALE +// sets all five MFDs, BT_MFD_SCALE_UL/_UC/_UR/_LL/_LR override individually, +// BT_RADAR_SCALE the map. Percentages of the pod size, 25-200; anything +// unreadable or out of range falls back to the group setting, then to 100. +// +// Resolved ONCE (the surround recomputes its layout every frame and this reads +// the environment), and the SURROUND BANDS derive from the result -- which is +// the whole reason the sizes could not just be constants: the band a display +// hangs in has to grow with it or the canvas clips it. +//===========================================================================// +struct BTCkSizes +{ + int mfdW[5], mfdH[5]; // UL, UC, UR, LL, LR -- kCkMfdBank order + int radW, radH; + int sideBandL, sideBandR, topBand, botBand; +}; + +static int CkScalePercent(const char *name, int fallback) +{ + const char *text = getenv(name); + if (text == NULL) return fallback; + int percent = atoi(text); + if (percent <= 0) return fallback; + if (percent < 25) percent = 25; + if (percent > 200) percent = 200; + return percent; +} + +// Where the map sits. The pod had it dead centre under the viewscreen, which +// on a wide panel is exactly where the road is -- BT_RADAR_POS moves it out of +// the way. In a bottom corner it takes that corner and the lower MFD there +// slides inboard beside it; halfway up a side it leaves the bottom row +// entirely (and the bottom band shrinks to what the lower MFDs need). +enum BTCkRadarPos { CkRadarBottomCenter = 0, CkRadarBottomLeft, CkRadarBottomRight, + CkRadarMidLeft, CkRadarMidRight }; + +static int CkRadarPosition(void) +{ + static int cached = -1; + if (cached >= 0) return cached; + + static const struct { const char *name; int pos; } names[] = { + { "CENTER", CkRadarBottomCenter }, { "CENTRE", CkRadarBottomCenter }, + { "BOTTOM", CkRadarBottomCenter }, + { "LEFT", CkRadarBottomLeft }, { "BOTTOMLEFT", CkRadarBottomLeft }, + { "RIGHT", CkRadarBottomRight }, { "BOTTOMRIGHT", CkRadarBottomRight }, + { "MIDLEFT", CkRadarMidLeft }, { "LEFTCENTER", CkRadarMidLeft }, + { "LEFTCENTRE", CkRadarMidLeft }, + { "MIDRIGHT", CkRadarMidRight }, { "RIGHTCENTER", CkRadarMidRight }, + { "RIGHTCENTRE", CkRadarMidRight } + }; + + cached = CkRadarBottomCenter; + const char *text = getenv("BT_RADAR_POS"); + if (text != NULL) + for (int i = 0; i < (int)(sizeof(names) / sizeof(names[0])); i++) + if (_stricmp(text, names[i].name) == 0) { cached = names[i].pos; break; } + + static const char *described[] = { "bottom centre", "bottom left", "bottom right", + "left side, centred", "right side, centred" }; + DEBUG_STREAM << "[cockpit] radar on the " << described[cached] << "\n" << std::flush; + return cached; +} + +static int CkRadarOnASide(int pos) + { return (pos == CkRadarMidLeft || pos == CkRadarMidRight) ? 1 : 0; } + +static const BTCkSizes *BTCkResolvedSizes(void) +{ + static BTCkSizes s; + static int resolved = 0; + if (resolved) return &s; + resolved = 1; + + int group = CkScalePercent("BT_MFD_SCALE", 100); + static const char *perDisplay[5] = { + "BT_MFD_SCALE_UL", "BT_MFD_SCALE_UC", "BT_MFD_SCALE_UR", + "BT_MFD_SCALE_LL", "BT_MFD_SCALE_LR" + }; + int percent[5]; + for (int i = 0; i < 5; i++) + { + percent[i] = CkScalePercent(perDisplay[i], group); + s.mfdW[i] = kCkMFDW * percent[i] / 100; + s.mfdH[i] = kCkMFDH * percent[i] / 100; + } + int radarPercent = CkScalePercent("BT_RADAR_SCALE", 100); + s.radW = kCkRADW * radarPercent / 100; + s.radH = kCkRADH * radarPercent / 100; + + //------------------------------------------------------------------- + // The bands: each is whatever the LARGEST display hanging in it needs. + // A corner MFD overlaps the view by kCkOVL, so it only claims + // (its size - the overlap) of the band. + //------------------------------------------------------------------- + int radarPos = CkRadarPosition(); + + int leftMax = (s.mfdW[0] > s.mfdW[3]) ? s.mfdW[0] : s.mfdW[3]; // UL, LL + int rightMax = (s.mfdW[2] > s.mfdW[4]) ? s.mfdW[2] : s.mfdW[4]; // UR, LR + s.sideBandL = leftMax - kCkOVL; + s.sideBandR = rightMax - kCkOVL; + + // top: the corner MFDs claim (h - overlap); UC drops 75% of its height in + int topCorner = ((s.mfdH[0] > s.mfdH[2]) ? s.mfdH[0] : s.mfdH[2]) - kCkOVL; + int topCentre = (int)(0.75f * s.mfdH[1]); + s.topBand = (topCorner > topCentre) ? topCorner : topCentre; + s.topBand += kCkRedGap + kCkRedH; // room for the top lamp strip + + // bottom: the lower MFDs hang (h - overlap) below the view; the radar, + // when it is down there, hangs its full height plus its lamp strip + int botCorner = ((s.mfdH[3] > s.mfdH[4]) ? s.mfdH[3] : s.mfdH[4]) - kCkOVL; + s.botBand = botCorner; + if (!CkRadarOnASide(radarPos)) + { + int radarBand = s.radH + kCkLamp; + if (radarBand > s.botBand) s.botBand = radarBand; + } + + // a side-mounted radar has to fit BESIDE the view, in that side's band + if (CkRadarOnASide(radarPos)) + { + int *band = (radarPos == CkRadarMidLeft) ? &s.sideBandL : &s.sideBandR; + if (s.radW + kCkLamp > *band) *band = s.radW + kCkLamp; + } + + if (s.sideBandL < 1) s.sideBandL = 1; + if (s.sideBandR < 1) s.sideBandR = 1; + + DEBUG_STREAM << "[cockpit] displays UL " << percent[0] << "% UC " << percent[1] + << "% UR " << percent[2] << "% LL " << percent[3] << "% LR " << percent[4] + << "% radar " << radarPercent << "% bands L" << s.sideBandL + << " R" << s.sideBandR << " T" << s.topBand << " B" << s.botBand + << "\n" << std::flush; + return &s; +} + // Canvas = view region + the surround bands. void BTCockpitCanvasFor(int viewW, int viewH, int *canvasW, int *canvasH) { - if (canvasW) *canvasW = viewW + 2 * kCkSideBand; - if (canvasH) *canvasH = viewH + kCkTopBand + kCkBotBand; + const BTCkSizes *s = BTCkResolvedSizes(); + if (canvasW) *canvasW = viewW + s->sideBandL + s->sideBandR; + if (canvasH) *canvasH = viewH + s->topBand + s->botBand; +} + +//===========================================================================// +// LETTERBOX FIT -- one uniform scale, centred, leftover black. Contract + +// why the swap effect changes: l4vb16.h. +//===========================================================================// +int gBTCockpitLetterbox = 0; + +int BTCockpitFitRect(int clientW, int clientH, RECT *out) +{ + int cw = gBTCockpitCanvasW, ch = gBTCockpitCanvasH; + if (out == NULL || cw <= 0 || ch <= 0 || clientW <= 0 || clientH <= 0) + return 0; + + // The larger axis is the constraint; integer math throughout so the rect + // is stable frame to frame (a float scale wobbles by a pixel and the bars + // shimmer during a drag). + int w = clientW; + int h = (int)((__int64)clientW * ch / cw); + if (h > clientH) + { + h = clientH; + w = (int)((__int64)clientH * cw / ch); + } + if (w < 1) w = 1; + if (h < 1) h = 1; + + out->left = (clientW - w) / 2; + out->top = (clientH - h) / 2; + out->right = out->left + w; + out->bottom = out->top + h; + + return (w != clientW || h != clientH) ? 1 : 0; +} + +const RECT *BTCockpitPresentDest(void *hwnd, RECT *storage) +{ + if (!gBTCockpitLetterbox || !gBTGaugeCockpit || hwnd == NULL || storage == NULL) + return NULL; + RECT client; + if (!GetClientRect((HWND)hwnd, &client)) + return NULL; + if (!BTCockpitFitRect(client.right, client.bottom, storage)) + return NULL; // already an exact fit -- present full client + return storage; } // The five MFD banks: {cockpit surface slot, high address}. Top row = bankHi-i // (descending), bottom row = bankHi-4-i (L4GLASSWIN BuildMfd order + the // BTGlassPanels_Create address assignment). -static const struct { int slot, bankHi; } kCkMfdBank[5] = +static const struct { int slot, bankHi; const char *name; } kCkMfdBank[5] = { - { 0, 0x2F }, // Heat (UL) - { 1, 0x27 }, // Mfd2 (UC) - { 5, 0x37 }, // Comm (UR) - { 2, 0x0F }, // Mfd1 (LL) - { 3, 0x07 }, // Mfd3 (LR) + { 0, 0x2F, "surround/Heat MFD" }, // UL + { 1, 0x27, "surround/Engineering" }, // UC + { 5, 0x37, "surround/Comm MFD" }, // UR + { 2, 0x0F, "surround/Left Weapons" }, // LL + { 3, 0x07, "surround/Right Weapons" }, // LR }; static void CkPushBtn(BTCockpitLayout *L, int addr, int x, int y, int w, int h, @@ -247,66 +436,114 @@ void BTCockpitComputeLayout(int canvasW, int canvasH, BTCockpitLayout *out) memset(L, 0, sizeof(*L)); L->canvasW = canvasW; L->canvasH = canvasH; - int viewW = canvasW - 2 * kCkSideBand; - int viewH = canvasH - kCkTopBand - kCkBotBand; + const BTCkSizes *S = BTCkResolvedSizes(); + int viewW = canvasW - S->sideBandL - S->sideBandR; + int viewH = canvasH - S->topBand - S->botBand; if (viewW < 64) viewW = 64; if (viewH < 64) viewH = 64; - int vx = kCkSideBand, vy = kCkTopBand; + int vx = S->sideBandL, vy = S->topBand; L->viewX = vx; L->viewY = vy; L->viewW = viewW; L->viewH = viewH; // Surface dest rects (slots: 0 Heat, 1 Mfd2, 2 Mfd1, 3 Mfd3, 4 sec, 5 Comm). // Corner MFDs overlap the view corner by kCkOVL both axes; Mfd2 drops 25% of // its height into the top; sec is flush below (view bottom edge clean). + // Sizes are the player-scaled ones (BTCkResolvedSizes), indexed in + // kCkMfdBank order: 0 UL, 1 UC, 2 UR, 3 LL, 4 LR. int cx = vx + viewW / 2; // Heat TL - L->surfX[0] = vx - kCkMFDW + kCkOVL; L->surfY[0] = vy - kCkMFDH + kCkOVL; - L->surfW[0] = kCkMFDW; L->surfH[0] = kCkMFDH; + L->surfX[0] = vx - S->mfdW[0] + kCkOVL; L->surfY[0] = vy - S->mfdH[0] + kCkOVL; + L->surfW[0] = S->mfdW[0]; L->surfH[0] = S->mfdH[0]; // Mfd2 top-center (25% into view) - L->surfX[1] = cx - kCkMFDW / 2; L->surfY[1] = vy - (int)(0.75f * kCkMFDH); - L->surfW[1] = kCkMFDW; L->surfH[1] = kCkMFDH; + L->surfX[1] = cx - S->mfdW[1] / 2; L->surfY[1] = vy - (int)(0.75f * S->mfdH[1]); + L->surfW[1] = S->mfdW[1]; L->surfH[1] = S->mfdH[1]; // Mfd1 BL - L->surfX[2] = vx - kCkMFDW + kCkOVL; L->surfY[2] = vy + viewH - kCkOVL; - L->surfW[2] = kCkMFDW; L->surfH[2] = kCkMFDH; + L->surfX[2] = vx - S->mfdW[3] + kCkOVL; L->surfY[2] = vy + viewH - kCkOVL; + L->surfW[2] = S->mfdW[3]; L->surfH[2] = S->mfdH[3]; // Mfd3 BR L->surfX[3] = vx + viewW - kCkOVL; L->surfY[3] = vy + viewH - kCkOVL; - L->surfW[3] = kCkMFDW; L->surfH[3] = kCkMFDH; - // sec center, flush below - L->surfX[4] = cx - kCkRADW / 2; L->surfY[4] = vy + viewH; - L->surfW[4] = kCkRADW; L->surfH[4] = kCkRADH; + L->surfW[3] = S->mfdW[4]; L->surfH[3] = S->mfdH[4]; // Comm TR - L->surfX[5] = vx + viewW - kCkOVL; L->surfY[5] = vy - kCkMFDH + kCkOVL; - L->surfW[5] = kCkMFDW; L->surfH[5] = kCkMFDH; + L->surfX[5] = vx + viewW - kCkOVL; L->surfY[5] = vy - S->mfdH[2] + kCkOVL; + L->surfW[5] = S->mfdW[2]; L->surfH[5] = S->mfdH[2]; - // --- MFD red buttons: 4 top (bankHi-i) + 4 bottom (bankHi-4-i) --- - int slotW = kCkMFDW / 4; // 80 - for (int m = 0; m < 5; m++) + //------------------------------------------------------------------- + // sec (the map). Bottom centre as the pod had it, either bottom + // corner, or halfway up either side (BT_RADAR_POS). Only when it + // takes a bottom CORNER does a lower MFD have to move -- it slides + // inboard beside the map; on a side the map is out of that row's way + // already. + //------------------------------------------------------------------- { - int s = kCkMfdBank[m].slot, hi = kCkMfdBank[m].bankHi; - int sx = L->surfX[s], sy = L->surfY[s]; - for (int i = 0; i < 4; i++) + int radarPos = CkRadarPosition(); + L->surfW[4] = S->radW; + L->surfH[4] = S->radH; + // A corner map goes FLUSH to the canvas edge and the lower MFD whose + // corner it took sits BESIDE it -- measured off the canvas edge, not + // the view edge, or the two overlap by the band width (they did: + // 232px of map drawn over the Right Weapons MFD). + int canvasLeft = vx - S->sideBandL; + int canvasRight = vx + viewW + S->sideBandR; + switch (radarPos) { - int bx = sx + i * slotW; - // Lamps sit fully OUTSIDE the MFD (a small gap off the edge) so nothing - // occludes them or the DISPLAY/PROGRAM legends -- 4 above, 4 below. - CkPushBtn(L, hi - i, bx, sy - kCkRedGap - kCkRedH, slotW - kCkGap, kCkRedH, 0, 0, 0); - CkPushBtn(L, hi - 4 - i, bx, sy + kCkMFDH + kCkRedGap, slotW - kCkGap, kCkRedH, 0, 0, 0); + case CkRadarBottomLeft: + L->surfX[4] = canvasLeft; + L->surfY[4] = vy + viewH; + L->surfX[2] = L->surfX[4] + S->radW; // LL slides inboard + break; + case CkRadarBottomRight: + L->surfX[4] = canvasRight - S->radW; + L->surfY[4] = vy + viewH; + L->surfX[3] = L->surfX[4] - S->mfdW[4]; // LR slides inboard + break; + case CkRadarMidLeft: + L->surfX[4] = vx - S->radW + kCkOVL; + L->surfY[4] = vy + (viewH - S->radH) / 2; + break; + case CkRadarMidRight: + L->surfX[4] = vx + viewW - kCkOVL; + L->surfY[4] = vy + (viewH - S->radH) / 2; + break; + default: + L->surfX[4] = cx - S->radW / 2; + L->surfY[4] = vy + viewH; + break; } } - // --- radar yellow rails (6/side) + 4 bottom cells --- + // --- MFD buttons + radar columns: L4RIOBANK, the shared under-glass field --- + // Each bank is laid out at ITS display's scaled size, so the lamp strips and + // the under-glass reach track a resized display instead of drifting off it. { - int rx = L->surfX[4], ry = L->surfY[4]; - int slotH = kCkRADH / 6; - for (int i = 0; i < 6; i++) + for (int m = 0; m < 5; m++) { - int y = ry + i * slotH; - CkPushBtn(L, 0x10 + i, rx - kCkLamp, y, kCkLamp + kCkRailW, slotH - kCkGap, 1, 0, 0); - CkPushBtn(L, 0x18 + i, rx + kCkRADW - kCkRailW, y, kCkRailW + kCkLamp, slotH - kCkGap, 1, 0, 0); + int s = kCkMfdBank[m].slot; + BTRioBankMetrics mfdMetrics; + BTRioBankMetricsFor(L->surfW[s], L->surfH[s], &mfdMetrics); + + BTRioBank bank; + BTRioBankLayout(BTRioBankMfd, L->surfX[s], L->surfY[s], + L->surfW[s], L->surfH[s], kCkMfdBank[m].bankHi, 0, &mfdMetrics, 0, &bank); + BTRioBankDump(kCkMfdBank[m].name, &bank); + for (int i = 0; i < bank.buttonCount; i++) + { + const BTRioButton &b = bank.buttons[i]; + CkPushBtn(L, b.address, b.x, b.y, b.w, b.h, b.colorClass, b.inert, b.label); + } } + static const int bottomAddr[4] = { 0x16, 0x17, 0x1F, 0x1E }; - int botLeft = rx + kCkRailW, botSpan = kCkRADW - 2 * kCkRailW, botW = botSpan / 4; - for (int i = 0; i < 4; i++) - CkPushBtn(L, bottomAddr[i], botLeft + i * botW, ry + kCkRADH - 14, botW - kCkGap, 14 + kCkLamp, 1, 0, 0); + BTRioBankMetrics radarMetrics; + BTRioBankMetricsFor(L->surfW[4], L->surfH[4], &radarMetrics); + + BTRioBank radar; + BTRioBankLayout(BTRioBankRadar, L->surfX[4], L->surfY[4], + L->surfW[4], L->surfH[4], 0x10, 0x18, &radarMetrics, bottomAddr, &radar); + BTRioBankDump("surround/radar", &radar); + for (int i = 0; i < radar.buttonCount; i++) + { + const BTRioButton &b = radar.buttons[i]; + CkPushBtn(L, b.address, b.x, b.y, b.w, b.h, b.colorClass, b.inert, b.label); + } } // --- flight blue blocks: THROTTLE/AUX (0x38-0x3F) under Mfd1, JOYSTICK @@ -314,20 +551,36 @@ void BTCockpitComputeLayout(int canvasW, int canvasH, BTCockpitLayout *out) { static const char *thrLabel[8] = { 0, 0, 0, 0, 0, "Panic", 0, "Throttle" }; // 0x38..0x3F static const int thrInert[8] = { 1, 1, 1, 1, 1, 0, 1, 0 }; - static const char *joyLabel[8] = { "Main", "Hat Bk", "Hat Up", "Hat R", - "Hat L", "Pinky", "Middle", "Upper" }; // 0x40..0x47 - const int cw = 68, ch = 30, g = 4; - int blockW = 4 * cw + 3 * g; // 284 - int belowLamp = kCkMFDH + kCkRedGap + kCkRedH + 8; // clear the MFD's bottom red lamps - int thrX = L->surfX[2], thrY = L->surfY[2] + belowLamp; - int joyX = L->surfX[3] + kCkMFDW - blockW, joyY = L->surfY[3] + belowLamp; - for (int i = 0; i < 8; i++) + static const char *joyLabel[8] = { "Main", "Look Bk", "Torso Ctr", "Look R", + "Look L", "Pinky", "Middle", "Upper" }; // 0x40..0x47 + // These cells carry TEXT, so the surround runs them bigger than the + // exploded window's 58x28 -- the caller owns the cell size + // (l4riobank.h), only the grid walk is shared. + BTRioBankMetrics metrics; + metrics.strip = 0; + metrics.gap = 4; + metrics.cellW = 68; + metrics.cellH = 30; + + // Hang each block below ITS lower MFD, clearing that display's own + // (scaled) bottom lamp strip -- a shrunken MFD used to leave the block + // floating in the gap, an enlarged one to overlap it. + int blockW = 4 * metrics.cellW + 3 * metrics.gap; // 284 + int thrX = L->surfX[2]; + int thrY = L->surfY[2] + L->surfH[2] + kCkRedGap + kCkRedH + 8; + int joyX = L->surfX[3] + L->surfW[3] - blockW; + int joyY = L->surfY[3] + L->surfH[3] + kCkRedGap + kCkRedH + 8; + + BTRioBank flight; + memset(&flight, 0, sizeof(flight)); + BTRioBankFlightGrid(thrX, thrY, 0x38, 8, 4, thrLabel, thrInert, &metrics, &flight); + BTRioBankFlightGrid(joyX, joyY, 0x40, 8, 4, joyLabel, 0, &metrics, &flight); + BTRioBankDump("surround/flight", &flight); + for (int i = 0; i < flight.buttonCount; i++) { - int c = i % 4, r = i / 4; - CkPushBtn(L, 0x38 + i, thrX + c * (cw + g), thrY + r * (ch + g), cw, ch, 2, - thrInert[i], thrLabel[i] ? thrLabel[i] : ""); - CkPushBtn(L, 0x40 + i, joyX + c * (cw + g), joyY + r * (ch + g), cw, ch, 2, - 0, joyLabel[i]); + const BTRioButton &b = flight.buttons[i]; + CkPushBtn(L, b.address, b.x, b.y, b.w, b.h, b.colorClass, b.inert, + b.label ? b.label : ""); } } } @@ -959,8 +1212,33 @@ int BTCockpitMouseDown(int cx, int cy, int clientW, int clientH, int rightButton if (!gBTGaugeCockpit) return 0; int bbW = gBTCockpitCanvasW, bbH = gBTCockpitCanvasH; if (bbW <= 0 || bbH <= 0 || clientW <= 0 || clientH <= 0) return 0; - int bx = cx * bbW / clientW; - int by = cy * bbH / clientH; + + // Client -> canvas. Under the letterbox the canvas occupies a centred + // sub-rect, so the click has to come back through the SAME transform + // Present used -- mapping against the full client would drift the hit test + // off every button by the bar width. A click on a bar hits nothing. + int bx, by; + if (gBTCockpitLetterbox) + { + RECT fit; + if (BTCockpitFitRect(clientW, clientH, &fit)) + { + if (cx < fit.left || cx >= fit.right || cy < fit.top || cy >= fit.bottom) + return 0; + bx = (cx - fit.left) * bbW / (fit.right - fit.left); + by = (cy - fit.top) * bbH / (fit.bottom - fit.top); + } + else + { + bx = cx * bbW / clientW; + by = cy * bbH / clientH; + } + } + else + { + bx = cx * bbW / clientW; + by = cy * bbH / clientH; + } BTCockpitLayout L; BTCockpitComputeLayout(bbW, bbH, &L); diff --git a/engine/MUNGA_L4/L4VB16.h b/engine/MUNGA_L4/L4VB16.h index 229ed0e..f6dc0a1 100644 --- a/engine/MUNGA_L4/L4VB16.h +++ b/engine/MUNGA_L4/L4VB16.h @@ -53,6 +53,30 @@ struct BTCockpitLayout extern int gBTGaugeCockpit; // mode select; set once by btl4main extern int gBTCockpitCanvasW, gBTCockpitCanvasH; // backbuffer canvas dims (for the aspect calc) +// +// LETTERBOX FIT (2026-07-26). The cockpit canvas is a FIXED size -- D3D9 +// stretches it into whatever the client area is, so a window dragged to a +// different shape used to squash the instruments (the projection was +// aspect-corrected, but the panels, lamps and MFD glass were not). +// +// Now the canvas is fitted at ONE uniform scale, centred, with the leftover +// left black: the cockpit scales both ways and never distorts, whatever the +// window (drag, maximise, -fit borderless). D3D9 does the scaling at Present +// time 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 says +// whether that happened; when it is 0 the old full-client stretch stands, so +// an MSAA run still works, just without the letterbox. +// +extern int gBTCockpitLetterbox; // 1 = the swap chain can take a dest rect + +// The uniform-scale centred rect for a client area. Returns 1 when it differs +// from the full client (letterboxing needed), 0 when it fills it exactly. +int BTCockpitFitRect(int clientW, int clientH, RECT *out); + +// Present destination for the main device window, or NULL for the full client. +const RECT *BTCockpitPresentDest(void *hwnd, RECT *storage); + void BTCockpitCanvasFor(int viewW, int viewH, int *canvasW, int *canvasH); void BTCockpitComputeLayout(int canvasW, int canvasH, BTCockpitLayout *out); void BTDrawCockpitPanels(LPDIRECT3DDEVICE9 device); @@ -60,21 +84,30 @@ int BTCockpitMouseDown(int cx, int cy, int clientW, int clientH, int rightButto void BTCockpitMouseUp(void); // Lamp brightness decode (RIOBase::LampState) -- shared by the cockpit draw and -// the glass per-display windows. level = max of the two 2-bit fields; the low 2 -// bits pick a flash half-period (500/250/125 ms). Returns 0..3. +// the glass per-display windows. Returns 0 (off) / 1 (dim) / 3 (bright). +// +// The wire word is TWO brightness fields plus a flash mode (L4RIO.h [T0]): +// bits 0-1 solid=0 / flashSlow=1 / flashMed=2 / flashFast=3 +// bits 2-3 state 1 brightness (state1Off=0, state1Dim=1, state1Bright=3) +// bits 4-5 state 2 brightness (state2Off=0, state2Dim=1, state2Bright=3) +// Solid shows state 1; flashing ALTERNATES state 1 and state 2 at a +// 500/250/125 ms half-period. +// +// ⚠ FIXED 2026-07-26: this used to return max(state1,state2) and blank to 0 on +// the alternate phase. That agrees with the enum only when one state is Off -- +// true for the Panic lamp (L4CTRL.cpp flashFast+state1Off+state2Bright) and for +// L4LAMP's off/dim pulse, which is why it went unnoticed -- but L4LAMP.cpp:252 +// commands flashFast + state1Dim + state2Bright, a DIM-to-BRIGHT pulse that +// rendered as a hard BRIGHT-to-OFF blink. RP412's L4MFDVIEW LampLevel has the +// faithful formula; this is it. inline int BTLampBrightnessOf(int state, unsigned long tick) { int level1 = (state >> 2) & 0x3; int level2 = (state >> 4) & 0x3; - int level = (level1 > level2) ? level1 : level2; - if (level == 0) return 0; - int flash = state & 0x3; - if (flash != 0) - { - unsigned long half = (flash == 1) ? 500 : (flash == 2) ? 250 : 125; - if ((tick / half) & 1) return 0; - } - return level; + int flash = state & 0x3; + if (flash == 0) return level1; + unsigned long half = (flash == 1) ? 500 : (flash == 2) ? 250 : 125; + return ((tick / half) & 1) ? level2 : level1; } //######################################################################## diff --git a/engine/MUNGA_L4/L4VIDEO.cpp b/engine/MUNGA_L4/L4VIDEO.cpp index f560208..43ddfb9 100644 --- a/engine/MUNGA_L4/L4VIDEO.cpp +++ b/engine/MUNGA_L4/L4VIDEO.cpp @@ -3419,6 +3419,34 @@ DPLRenderer::DPLRenderer( mPresentParams.MultiSampleQuality--; } mPresentParams.SwapEffect = D3DSWAPEFFECT_DISCARD; + + // + // COCKPIT LETTERBOX (2026-07-26): the fixed canvas is fitted into the client + // at Present time via a destination rect, which DISCARD forbids -- COPY is + // the swap effect that takes one. Windowed + surround only, and only with + // multisampling off (COPY cannot multisample). Full contract: l4vb16.h. + // + // btl4main sets the INTENT (it has to: the first WM_SIZE beats the device + // here); this is where it becomes real, or gets withdrawn. + { + extern int gBTGaugeCockpit; + extern int gBTCockpitLetterbox; + if (!fullscreen && gBTGaugeCockpit && gBTCockpitLetterbox && + mPresentParams.MultiSampleType == D3DMULTISAMPLE_NONE) + mPresentParams.SwapEffect = D3DSWAPEFFECT_COPY; + else + gBTCockpitLetterbox = 0; // withdrawn -- the canvas stretches + // only meaningful when there IS a cockpit canvas to fit + if (gBTGaugeCockpit) + DEBUG_STREAM << "[cockpit] letterbox " << (gBTCockpitLetterbox ? "ON" : "off") + << " (swap effect " + << (mPresentParams.SwapEffect == D3DSWAPEFFECT_COPY ? "COPY" : "DISCARD") + << (mPresentParams.MultiSampleType != D3DMULTISAMPLE_NONE + ? "; multisampling on -- COPY unavailable, canvas stretches to the client" + : "") + << ")\n" << std::flush; + } + mPresentParams.hDeviceWindow = hWnd; mPresentParams.Flags = 0; mPresentParams.FullScreen_RefreshRateInHz = (fullscreen)?60:D3DPRESENT_RATE_DEFAULT; @@ -3462,8 +3490,30 @@ DPLRenderer::DPLRenderer( // while the cockpit-surround window is sized independently -- pinning to them // would shrink the backbuffer and break the composite.) // + // + // COCKPIT SURROUND: the backbuffer IS THE CANVAS, whatever the window + // happens to be (2026-07-26). Everything downstream assumes it -- + // BTApplyWorldViewport and BTDrawCockpitPanels lay out from the + // BACKBUFFER size, while the letterbox and the mouse hit-test map + // through gBTCockpitCanvasW/H. Pinning to the client instead is fine + // only while client == canvas, which is true for a normal boot and + // FALSE for -fit: the window is the whole monitor before the device + // exists, so the backbuffer came out 3440x1440 against a 1452x1059 + // canvas. Field report: tiny MFDs hugging the corners, an over-wide + // world view, the scene squashed horizontally as Present scaled the + // oversized backbuffer into the letterbox -- and, unseen, every button + // hit-tested in a different space from the one it was drawn in (the + // exact #50 divergence the note above warns about). + // + extern int gBTGaugeCockpit; + extern int gBTCockpitCanvasW, gBTCockpitCanvasH; RECT rc; - if (hWnd != NULL && GetClientRect(hWnd, &rc) + if (gBTGaugeCockpit && gBTCockpitCanvasW > 0 && gBTCockpitCanvasH > 0) + { + mPresentParams.BackBufferWidth = (UINT)gBTCockpitCanvasW; + mPresentParams.BackBufferHeight = (UINT)gBTCockpitCanvasH; + } + else if (hWnd != NULL && GetClientRect(hWnd, &rc) && rc.right > rc.left && rc.bottom > rc.top) { mPresentParams.BackBufferWidth = (UINT)(rc.right - rc.left); @@ -8799,7 +8849,11 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte // DIAG (turn-hitch hunt): draw CPU is _rt0..here; Present blocks on the GPU. LARGE_INTEGER _rt1; QueryPerformanceCounter(&_rt1); - hr = mDevice->Present(NULL, NULL, NULL, NULL); + // COCKPIT LETTERBOX: uniform-scale the canvas into the client (NULL = the + // old full-client stretch, which is what every non-cockpit run gets). + RECT _ckDestStorage; + const RECT *_ckDest = BTCockpitPresentDest(mPresentParams.hDeviceWindow, &_ckDestStorage); + hr = mDevice->Present(NULL, _ckDest, NULL, NULL); // The wait-screen overlay stands down PERMANENTLY the moment the first // REAL scene frame presents (user report 2026-07-22: the launch handoff // briefly interleaved idle-overlay presents with game frames = flicker). @@ -9125,7 +9179,12 @@ void DPLRenderer::ExecuteIdle() } backbuffer->Release(); } - HRESULT present_hr = mDevice->Present(NULL, NULL, NULL, NULL); + // same letterbox as the scene present -- the wait overlay paints into + // this backbuffer, so it has to land in the same rect + RECT wait_dest_storage; + const RECT *wait_dest = + BTCockpitPresentDest(mPresentParams.hDeviceWindow, &wait_dest_storage); + HRESULT present_hr = mDevice->Present(NULL, wait_dest, NULL, NULL); static int s_wait_path_logged = 0; if (!s_wait_path_logged) { @@ -9772,12 +9831,18 @@ static float BTWorldAspectOf(int client_w, int client_h) // ON-SCREEN aspect = (viewW/canvasW * client_w) / (viewH/canvasH * client_h). extern int gBTGaugeCockpit; extern int gBTCockpitCanvasW, gBTCockpitCanvasH; + extern int gBTCockpitLetterbox; if (gBTGaugeCockpit && gBTCockpitCanvasW > 0 && gBTCockpitCanvasH > 0) { BTCockpitLayout L; BTCockpitComputeLayout(gBTCockpitCanvasW, gBTCockpitCanvasH, &L); if (L.viewW > 0 && L.viewH > 0) { + // LETTERBOXED: the canvas is scaled UNIFORMLY, so the view's + // on-screen aspect is just its own -- the client no longer enters + // into it (2026-07-26). + if (gBTCockpitLetterbox) + return (float)L.viewW / (float)L.viewH; float w = (float)L.viewW * (float)client_w / (float)gBTCockpitCanvasW; float h = (float)L.viewH * (float)client_h / (float)gBTCockpitCanvasH; if (h > 0.0f) return w / h; 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/engine/MUNGA_L4/l4riobank.h b/engine/MUNGA_L4/l4riobank.h new file mode 100644 index 0000000..54fb5a6 --- /dev/null +++ b/engine/MUNGA_L4/l4riobank.h @@ -0,0 +1,149 @@ +//===========================================================================// +// File: l4riobank.h // +// Project: MUNGA_L4 Brick: cockpit RIO button-bank geometry // +// Contents: the ONE layout rule for the buttons around a pod display // +//---------------------------------------------------------------------------// +// Copyright (C) 1994-1995, Virtual World Entertainment, Inc. // +// PROPRIETARY AND CONFIDENTIAL // +//===========================================================================// + +#ifndef _L4RIOBANK_H_ +#define _L4RIOBANK_H_ + +//########################################################################### +// Every pod secondary display carries a bank of illuminated RIO pushbuttons +// mounted around its glass. We render that field TWICE -- once composited +// into the main window (the COCKPIT SURROUND, L4VB16.cpp) and once per +// display in its own desktop window (the EXPLODED view, L4GLASSWIN.cpp) -- +// and until 2026-07-26 each renderer carried its own copy of the geometry. +// They drifted: the exploded windows put the buttons UNDER the glass (a big +// click target, only a thin lamp strip clearing the edge) while the surround +// left the MFD lamps entirely outside the surface, so the same button was a +// 156x138 target in one mode and a 76x24 sliver in the other. +// +// This is that geometry, once. A caller supplies a display rect and the +// bank's anchor address(es); it gets back the button rects IN THAT SAME +// SPACE plus the bounding box the bank needs. Placement -- WHERE each +// display sits -- stays with the renderer, because that genuinely differs +// (one composited canvas vs seven desktop windows). +// +// THE UNDER-GLASS RULE (ported from RP412 L4MFDVIEW, which took it from the +// pod): a button reaches half the display in BEHIND the glass and only +// `strip` clears the edge. The renderer draws the buttons FIRST and the +// display imagery OVER them, so the lamp reads as a slim bar while +// practically the whole display is the press target. Nothing is occluded -- +// the picture wins every pixel it covers. +// +// Addresses per [[pod-hardware]] "The button space + lamps"; the anchors are +// the caller's -- this file never hardcodes a bank address. +//########################################################################### + +enum BTRioBankStyle +{ + BTRioBankMfd = 0, // 4 buttons above the glass + 4 below, addresses + // DESCENDING from anchorA row-major (0x2F..0x28) + BTRioBankRadar // 6 down each side (anchorA left, anchorB right) + // + an optional 4 along the bottom +}; + +enum +{ + BTRioBankMaxButtons = 24 +}; + +struct BTRioButton +{ + int address; // RIO buttonGroup address (0x00-0x47) + int x, y, w, h; // caller-space rect (the FULL hit target) + int colorClass; // 0 red (MFD) / 1 yellow (radar) / 2 blue (flight) + int inert; // 1 = no authored .CTL mapping -> drawn greyed + const char *label; // non-NULL -> the face is drawn + labelled +}; + +//--------------------------------------------------------------------------- +// How much of a button shows, and how the field is spaced. Both renderers +// work at different scales (the surround halves the glass; the exploded +// windows are native), so this is DERIVED from the display size -- the pod +// rule applied at whatever size you hand it. +//--------------------------------------------------------------------------- +struct BTRioBankMetrics +{ + int strip; // how much of a button clears the display edge (the lamp) + int gap; // gap between neighbouring buttons in a row/column + int cellW, cellH; // the labelled no-glass cell (flight bank, map foot row) +}; + +struct BTRioBank +{ + BTRioButton buttons[BTRioBankMaxButtons]; + int buttonCount; + + // The bank + display bounding box, in the caller's space. A window + // caller sizes its client to boundsW/H and passes an origin that puts + // boundsX/Y at zero; the composited caller ignores these and just uses + // the button rects, which are already in canvas space. + int boundsX, boundsY, boundsW, boundsH; +}; + +//--------------------------------------------------------------------------- +// The pod rule at a given display size. The lamp strip scales off the SHORT +// axis against the native 480 (an MFD is 640x480 landscape, the map 480x640 +// portrait -- both have a 480 short axis) and never drops below a readable +// floor, because a 5px lamp on a half-scale surround MFD is a smudge. +//--------------------------------------------------------------------------- +void BTRioBankMetricsFor(int displayW, int displayH, BTRioBankMetrics *out); + +//--------------------------------------------------------------------------- +// Lay out one display's bank. originX/Y is the DISPLAY's top-left; button +// rects come back around it, reaching `strip` outside the glass (so they can +// land at negative coordinates relative to the origin -- see boundsX/Y). +// anchorA/B bank base addresses (style-dependent; see BTRioBankStyle) +// bottomAddrs BTRioBankRadar only: the 4 foot-row addresses, or NULL +//--------------------------------------------------------------------------- +void BTRioBankLayout( + BTRioBankStyle style, + int originX, + int originY, + int displayW, + int displayH, + int anchorA, + int anchorB, + const BTRioBankMetrics *metrics, + const int *bottomAddrs, + BTRioBank *out +); + +//--------------------------------------------------------------------------- +// The no-display bank (throttle/panic 0x38-0x3F, joystick/fire 0x40-0x47): +// a grid of labelled cells, drawn in full since there is no glass to hide +// behind. APPENDS to `out` (so both columns share one bank) and grows its +// bounds; zero `out` before the first call. `labels`/`inert` are `count` +// long and may be NULL. +// +// These cells carry TEXT, so their size is the caller's call, not a scale of +// some glass -- fill metrics->cellW/cellH yourself (the surround runs bigger +// cells than the exploded window because its canvas is half-scale and the +// labels still have to read). +//--------------------------------------------------------------------------- +void BTRioBankFlightGrid( + int originX, + int originY, + int baseAddress, + int count, + int columns, + const char *const *labels, + const int *inert, + const BTRioBankMetrics *metrics, + BTRioBank *out +); + +//--------------------------------------------------------------------------- +// One-shot geometry dump: `BT_RIOBANK_LOG=1` prints every button's address and +// rect as the banks are built, so the field can be checked for coverage and +// address shadowing without driving a mouse. Capped at the first few banks so +// a per-frame caller (the surround recomputes its layout every draw) prints +// one pass and then goes quiet. +//--------------------------------------------------------------------------- +void BTRioBankDump(const char *tag, const BTRioBank *bank); + +#endif diff --git a/game/btl4main.cpp b/game/btl4main.cpp index d60e432..3d60afd 100644 --- a/game/btl4main.cpp +++ b/game/btl4main.cpp @@ -142,10 +142,18 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) // new client aspect so the scene doesn't stretch fat/skinny (the D3D9 // backbuffer stays at the configured size and is stretched into the // client area; rendering with the client aspect cancels the stretch). + // Under the cockpit LETTERBOX the canvas is scaled uniformly instead, + // so the aspect comes from the view rect -- BTWorldAspectOf handles it. if (wParam != SIZE_MINIMIZED && LOWORD(lParam) > 0 && HIWORD(lParam) > 0) { extern void L4NotifyWindowResized(int client_w, int client_h); L4NotifyWindowResized((int)LOWORD(lParam), (int)HIWORD(lParam)); + // Repaint the letterbox bars: a COPY present only writes its dest + // rect, so whatever the old bars held would otherwise persist. The + // class brush is black, so an erase is all it takes. + extern int gBTCockpitLetterbox; + if (gBTCockpitLetterbox) + InvalidateRect(hWnd, NULL, TRUE); } return 0; case WM_CLOSE: @@ -215,6 +223,276 @@ static LONG WINAPI return EXCEPTION_EXECUTE_HANDLER; // die (after the evidence is out) } +//===========================================================================// +// environ.ini -- the player's settings file (cwd = content\). One KEY=VALUE +// per line; the real environment always WINS, so a launcher .bat or a shell +// export overrides the file rather than fighting it. +// +// ⚠ Loaded EARLY (2026-07-26). It used to be read ~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 +// worked as a real env var. It also putenv()'d comment lines verbatim, which +// turned a commented-out option into an environment variable literally named +// "#BT_MFD_SCALE" -- harmless, but it meant shipping a self-documenting file +// would have littered the environment with junk. +//===========================================================================// +// +// The shipped default, written on first run when the file is absent -- the +// bindings.txt convention ([[glass-cockpit]] §bindings.txt is a COMPATIBILITY +// SURFACE): the player customizes it, it stays UNTRACKED, and an +// extract-over-top upgrade therefore never clobbers their settings. Every +// option ships COMMENTED OUT, so a fresh install applies nothing and behaves +// exactly as it did before the file existed. +// +static const char *kEnvironIniDefault = +"# ============================================================================\n" +"# environ.ini -- BattleTech 4.11 settings\n" +"# ============================================================================\n" +"# One KEY=VALUE per line, read at game start. Lines starting with # or ;\n" +"# are comments. A real environment variable always WINS over this file, so\n" +"# a launcher .bat can override anything here. Delete a line to fall back to\n" +"# the built-in default.\n" +"#\n" +"# Input bindings live in bindings.txt beside this file (written with the\n" +"# full documented layout on first run; delete it to restore defaults).\n" +"\n" +"# ---- The cockpit ------------------------------------------------------------\n" +"\n" +"# Where the five MFDs and the map go:\n" +"# (unset) the COCKPIT SURROUND -- instruments composited around\n" +"# the viewscreen in one window (the default)\n" +"# BT_GLASS_PANELS=1 one desktop window PER display, arranged around the\n" +"# game window (the 'exploded' view -- handy for reading\n" +"# or screenshotting a display at full size)\n" +"# BT_COCKPIT=0 the plain docked gauge strip along the bottom\n" +"#BT_GLASS_PANELS=1\n" +"#BT_COCKPIT=0\n" +"\n" +"# Size of the secondary displays in the cockpit surround, as a percentage of\n" +"# their pod size. The pod bolted them down at one size; on a big panel there\n" +"# is room to trade viewscreen for instrument, so turn these up if you want to\n" +"# actually read the other displays while you fly. 100 = as the pod had them.\n" +"# Range 25-200 (out-of-range or unreadable values fall back to the group\n" +"# setting, then to 100). The surround grows to fit whatever you ask for.\n" +"#\n" +"# BT_MFD_SCALE sets all five MFDs at once...\n" +"#BT_MFD_SCALE=100\n" +"# ...and any single display can override it:\n" +"# UL upper left (heat) UC upper center (engineering)\n" +"# UR upper right (comm) LL lower left (left weapons)\n" +"# LR lower right (right weapons)\n" +"#BT_MFD_SCALE_UL=100\n" +"#BT_MFD_SCALE_UC=100\n" +"#BT_MFD_SCALE_UR=100\n" +"#BT_MFD_SCALE_LL=100\n" +"#BT_MFD_SCALE_LR=100\n" +"\n" +"# The portrait map/radar, sized on its own.\n" +"#BT_RADAR_SCALE=100\n" +"\n" +"# Where the map sits:\n" +"# CENTER bottom centre, under the viewscreen, as the pod had it\n" +"# (default; BOTTOM and CENTRE mean the same)\n" +"# LEFT bottom left corner (or BOTTOMLEFT)\n" +"# RIGHT bottom right corner (or BOTTOMRIGHT)\n" +"# MIDLEFT left edge, halfway up (or LEFTCENTER)\n" +"# MIDRIGHT right edge, halfway up (or RIGHTCENTER)\n" +"# Anywhere but CENTER stops it sitting where the road is. In a bottom corner\n" +"# the lower MFD whose corner it takes slides inboard beside it; halfway up a\n" +"# side it leaves the bottom row entirely.\n" +"#BT_RADAR_POS=CENTER\n" +"\n" +"# Phosphor colour of the mono MFDs, RRGGBB. Default is a green tube.\n" +"#BT_COCKPIT_TINT=21FF42\n" +"\n" +"# ---- Display ----------------------------------------------------------------\n" +"\n" +"# The cockpit is fitted into the window at ONE uniform scale, centred, with\n" +"# the leftover black -- drag the window to any shape and nothing distorts.\n" +"# Launch with -fit (or -windowed-fullscreen) for a borderless window over the\n" +"# whole monitor.\n" +"\n" +"# Simulation/render frame rate, integer frames per second. The desktop\n" +"# default is 60; the arcade pods shipped at 25.\n" +"#TARGETFPS=60\n" +"\n" +"# Anti-aliasing sample count, passed straight to Direct3D 9: 0 = off, else\n" +"# 2..16 as the GPU supports. NOTE: turning this on disables the letterbox\n" +"# fit above (the two use incompatible swap modes) and the cockpit stretches\n" +"# to the window instead.\n" +"#MULTISAMPLE=0\n" +"\n" +"# Particle budget, integer. Default 8192.\n" +"#MAXPARTICLES=8192\n" +"\n" +"# ---- Optional ---------------------------------------------------------------\n" +"\n" +"# RGB keyboard lamp mirror (Windows Dynamic Lighting): keys bound to a lamp\n" +"# button in bindings.txt glow with the panel -- yellow for the map's side\n" +"# columns, red for the rest -- and flash in step with the on-screen buttons.\n" +"# Per-key keyboards light each bound key; zone-lit boards mirror the\n" +"# strongest lamp board-wide. Unset or nonzero = on; 0 = off. A machine\n" +"# without Dynamic Lighting logs once and stays dormant.\n" +"#BT_KEYLIGHT=0\n" +"\n" +"# ---- Multiplayer ------------------------------------------------------------\n" +"\n" +"# 1 = Steam networking (lobbies, FakeIP mesh). Needs the Steam client\n" +"# running; without it the game logs the reason and falls back to Winsock.\n" +"#BT_STEAM_NET=1\n"; + +//===========================================================================// +// CWD GUARD (2026-07-26, field report). +// +// The engine resolves BTL4.RES, VIDEO\, BTDPL.INI, the eggs AND both player +// config files (bindings.txt, environ.ini) RELATIVE TO CWD -- every launcher +// .bat does `cd content` first, so this was invisible. A BARE launch of the +// exe (double-click, or a shell sitting in build\Release) then: +// - found no resources ("Resource file btl4.res v1.0.0.0 is obsolete!"), +// - wrote a stray bindings.txt / environ.ini / btl4.log NEXT TO THE EXE +// (so the player's real ones in content\ looked like they never appeared), +// - and killed the mission generation the menu launched. +// Reported live: `.\btl4.exe -fit` from build\Release -> menu opens -> crash +// on Launch. +// +// So find content\ ourselves rather than trusting whoever started us. Runs +// BEFORE the log file opens, so the log lands in content\ too -- same place +// the launchers put it. Returns what happened for the boot line. +//===========================================================================// +static const char *gBTCwdFixNote = NULL; + +// +// -fit / -windowed-fullscreen, parsed EARLY: the miniconsole front end exits +// this process long before the window-creation code runs, and it rebuilds the +// mission child's command line from scratch -- so without this the flag was +// silently dropped the moment you launched from the menu (field report: +// `btl4.exe -fit` gave a borderless menu and then a windowed mission). +// btl4console.cpp reads it when composing the relaunch. +// +int gBTFitDisplay = 0; + +static void BTEnsureContentDirectory(void) +{ + if (GetFileAttributesA("BTL4.RES") != INVALID_FILE_ATTRIBUTES) + { + return; // already in the right place + } + + char exeDir[MAX_PATH]; + if (GetModuleFileNameA(NULL, exeDir, MAX_PATH) == 0) + { + return; + } + char *slash = strrchr(exeDir, '\\'); + if (slash == NULL) + { + return; + } + *slash = 0; + + // build\Release\btl4.exe -> ..\..\content is the shipped layout; the + // others cover a flattened install and an exe dropped into content\. + static const char *kProbe[] = { + "\\..\\..\\content", "\\..\\content", "\\content", + "", "\\..", "\\..\\.." + }; + for (int i = 0; i < (int)(sizeof(kProbe) / sizeof(kProbe[0])); ++i) + { + char candidate[MAX_PATH * 2]; + char resource[MAX_PATH * 2]; + sprintf_s(candidate, sizeof(candidate), "%s%s", exeDir, kProbe[i]); + sprintf_s(resource, sizeof(resource), "%s\\BTL4.RES", candidate); + if (GetFileAttributesA(resource) != INVALID_FILE_ATTRIBUTES) + { + if (SetCurrentDirectoryA(candidate)) + { + gBTCwdFixNote = "found the content directory from the exe path"; + } + return; + } + } + gBTCwdFixNote = "NO content directory found (BTL4.RES is nowhere near the " + "exe) -- resources and settings will not load"; +} + +static void BTWriteDefaultEnvironIni(void) +{ + FILE *probe; + if (fopen_s(&probe, "environ.ini", "r") == 0) // already there -- leave it alone + { + fclose(probe); + return; + } + // Only ever write it BESIDE THE RESOURCES. If the cwd guard could not + // find content\, writing here would scatter a stray settings file next to + // whatever the player happened to launch from -- which is exactly the + // confusion the guard exists to end. + if (GetFileAttributesA("BTL4.RES") == INVALID_FILE_ATTRIBUTES) + { + return; + } + FILE *out; + if (fopen_s(&out, "environ.ini", "w") != 0) + return; + fputs(kEnvironIniDefault, out); + fclose(out); + std::cout << "[boot] wrote a default content\\environ.ini (all options " + "commented out -- edit to taste)" << std::endl << std::flush; +} + +static void BTLoadEnvironIni(void) +{ + BTWriteDefaultEnvironIni(); + + FILE *file; + if (fopen_s(&file, "environ.ini", "r") != 0) + return; + + char line[1024]; + int applied = 0, skipped = 0; + while (fgets(line, sizeof(line), file)) + { + // strip EOL + trailing blanks + int n = (int)strlen(line); + while (n > 0 && (line[n-1] == '\n' || line[n-1] == '\r' || + line[n-1] == ' ' || line[n-1] == '\t')) + line[--n] = 0; + + const char *p = line; + while (*p == ' ' || *p == '\t') ++p; + if (*p == 0 || *p == '#' || *p == ';') // blank or comment + continue; + + const char *eq = strchr(p, '='); + if (eq == NULL || eq == p) // no key, or no '=' at all + { + ++skipped; + continue; + } + + // The real environment wins: only set what is not already set, so a + // launcher or a shell export beats the file. + char key[256]; + size_t klen = (size_t)(eq - p); + if (klen >= sizeof(key)) { ++skipped; continue; } + memcpy(key, p, klen); + key[klen] = 0; + while (klen > 0 && (key[klen-1] == ' ' || key[klen-1] == '\t')) + key[--klen] = 0; + if (getenv(key) != NULL) + continue; + + putenv(p); + ++applied; + } + fclose(file); + + std::cout << "[boot] environ.ini: " << applied << " setting(s) applied" + << (skipped ? ", " : "") << (skipped ? std::to_string(skipped) : std::string()) + << (skipped ? " line(s) ignored" : "") << std::endl << std::flush; +} + int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd) { // Boot tick for the relaunch storm damper (btl4console.cpp, issue #33): @@ -228,6 +506,10 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine // else so even boot crashes leave a stack in the log. SetUnhandledExceptionFilter(BTCrashFilter); + // Sit in content\ before ANYTHING resolves a relative path -- the log file + // below included, so a bare launch logs where the launchers log. + BTEnsureContentDirectory(); + // BT_CRASHTEST=1: deliberately AV after the log opens -- verifies the // crash filter's forensic path end-to-end on any machine. @@ -279,6 +561,26 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine << (getenv("BT_FE_LOOP") ? " (relaunched generation)" : " (first process)") << std::endl << std::flush; + // Say so when we had to go looking for content\ -- a player who launched + // the exe directly should see WHY their settings appeared where they did. + if (gBTCwdFixNote != NULL) + { + char here[MAX_PATH] = { 0 }; + GetCurrentDirectoryA(MAX_PATH, here); + std::cout << "[boot] cwd: " << gBTCwdFixNote << " -> " << here + << std::endl << std::flush; + } + + // The player's settings, before ANYTHING reads the environment (the + // platform profile, the joystick wizard, the layout resolver...). + BTLoadEnvironIni(); + + if (lpCmdLine && (strstr(lpCmdLine, "-fit") != 0 || + strstr(lpCmdLine, "-windowed-fullscreen") != 0)) + { + gBTFitDisplay = 1; + } + // BT_JOYCONFIG=1: the generic-joystick capture wizard (flight sticks / // HOTAS / pedals -- L4JOY.h). Console prompts detect which device/axis // the player moves for each pod control and write the joystick section @@ -490,21 +792,12 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine if (getenv("BT_DEV_GAUGES") == NULL) putenv("BT_DEV_GAUGES=1"); if (getenv("L4PLASMA") == NULL) putenv("L4PLASMA=SCREEN"); - // DEFAULT LAYOUT = the COCKPIT SURROUND (single window: the six gauge - // surfaces + clickable button lamps composited AROUND the centered 3D - // view). Opt out with BT_COCKPIT=0 (dock-bottom strip) or - // BT_GLASS_PANELS=1 (the per-display windows). When cockpit is the - // default the buttons live IN the main window, so no separate pad/panel - // window is created (leave BT_PAD_PANEL / BT_GLASS_PANELS unset). - int cockpitDefault = 1; - if (getenv("BT_COCKPIT") && getenv("BT_COCKPIT")[0] == '0') cockpitDefault = 0; - if (getenv("BT_GLASS_PANELS") && getenv("BT_GLASS_PANELS")[0] != '0') cockpitDefault = 0; - if (getenv("BT_DEV_GAUGES_WINDOW") || getenv("BT_DEV_GAUGES_DOCK")) cockpitDefault = 0; - if (!cockpitDefault) - { - if (getenv("BT_PAD_PANEL") == NULL) putenv("BT_PAD_PANEL=1"); - if (getenv("BT_GLASS_PANELS") == NULL) putenv("BT_GLASS_PANELS=1"); - } + // The SECONDARY-DISPLAY LAYOUT (surround / per-display windows / docked + // strip / separate window) is resolved once, below, after the shared + // defaults land -- it used to be half-decided here, which is how + // BT_COCKPIT=0 came to mean "per-display windows" despite being + // documented as the dock-bottom opt-out (the dock was unreachable under + // glass). This block now only sets the profile's env presets. } #endif else @@ -531,12 +824,64 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine if (getenv("BT_DEV_GAUGES") != NULL && getenv("L4GAUGE") == NULL) putenv("L4GAUGE=640x480x16"); + // ------------------------------------------------------------------------- + // SECONDARY-DISPLAY LAYOUT -- resolved ONCE, here, now that every profile + // putenv has landed. It used to be decided TWICE (the glass profile block + // picked BT_PAD_PANEL/BT_GLASS_PANELS; the window-sizing block further down + // re-derived cockpit-vs-dock from the same env with its own copy of the + // precedence) and the boot banner read NEITHER -- it announced "per-display + // cockpit windows" for every glass boot, including the surround default. + // The split also broke BT_COCKPIT=0: documented as the dock-bottom opt-out, + // it actually landed on the per-display windows, leaving the docked strip + // unreachable under the glass profile. + // + // One resolver, one answer, consumed by the banner, the pad-panel decision + // and the sizing block. Precedence: + // BT_GLASS_PANELS!=0 per-display cockpit windows (explicit only) + // BT_DEV_GAUGES_WINDOW the legacy separate MFD window + // BT_DEV_GAUGES_DOCK the docked bottom strip + // BT_COCKPIT=0 ...also the docked strip (the documented opt-out) + // (nothing) COCKPIT SURROUND -- the glass default + // ------------------------------------------------------------------------- + enum BTGlassLayout { GlassLayoutNone, GlassLayoutCockpit, GlassLayoutPanels, + GlassLayoutDock, GlassLayoutWindow }; + int glassLayout = GlassLayoutNone; + if (!fe_menu_mode && getenv("BT_DEV_GAUGES") != NULL) + { + int panels = 0; +#ifdef BT_GLASS + { extern int BTGlassPanelsActive(); panels = BTGlassPanelsActive(); } +#endif + const char *ck = getenv("BT_COCKPIT"); + if (panels) glassLayout = GlassLayoutPanels; + else if (getenv("BT_DEV_GAUGES_WINDOW")) glassLayout = GlassLayoutWindow; + else if (getenv("BT_DEV_GAUGES_DOCK")) glassLayout = GlassLayoutDock; + else if (ck != NULL && ck[0] == '0') glassLayout = GlassLayoutDock; + else glassLayout = GlassLayoutCockpit; + + // The surround and the per-display windows carry their own buttons; the + // dock strip and the separate MFD window don't, so the single combined + // pad panel supplies them (else the whole 72-button field is unclickable). + if ((glassLayout == GlassLayoutDock || glassLayout == GlassLayoutWindow) + && getenv("BT_PAD_PANEL") == NULL) + putenv("BT_PAD_PANEL=1"); + } + static const char *kGlassLayoutName[] = { + "no dev gauges", + "cockpit surround", + "per-display cockpit windows [BT_GLASS_PANELS]", + "docked gauge strip", + "separate MFD window" + }; + std::cout << "[boot] platform profile: " << (fe_menu_mode ? "MENU (front end -- no profile applied)" : gBTPlatformPod ? "POD (RIO cockpit input; multi-surface gauges/MFDs via pod hardware or explicit L4GAUGE)" - : gBTPlatformGlass ? "GLASS (PadRIO + per-display cockpit windows [BT_GLASS_PANELS] + plasma window)" - : "DEV (single window + keyboard)") - << std::endl << std::flush; + : gBTPlatformGlass ? "GLASS (PadRIO + plasma window)" + : "DEV (single window + keyboard)"); + if (!fe_menu_mode) + std::cout << " [secondary displays: " << kGlassLayoutName[glassLayout] << "]"; + std::cout << std::endl << std::flush; #ifdef BT_GLASS // @@ -656,22 +1001,7 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine } if (getenv("BT_FORCE_FIRE") != NULL) gBTDrive.fireForced = 1; - // Optional environ.ini overrides (same convention as RP). - FILE *file; - char line[1024]; - if (fopen_s(&file, "environ.ini", "r") == 0) - { - while (!feof(file)) - { - if (fgets(line, sizeof(line), file)) - { - for (int i = (int)strlen(line); i >= 0; i--) - if (line[i] == '\n' || line[i] == '\r') line[i] = 0; - putenv(line); - } - } - fclose(file); - } + // (environ.ini is loaded much earlier now -- see BTLoadEnvironIni.) // Version scheme: 4.10 = the 1995 arcade release; 4.11 = this win32 // reconstruction; build = git commit count, hash pins exact source @@ -783,25 +1113,11 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine extern int gBTCockpitCanvasW, gBTCockpitCanvasH; extern int BTGaugeStripHeightFor(int width); extern void BTCockpitCanvasFor(int viewW, int viewH, int *cw, int *ch); - // BT_GLASS_PANELS breaks the gauges into their own per-display windows, so - // the world window keeps its normal size (no cockpit / bottom strip). - int glassOwnsGauges = 0; -#ifdef BT_GLASS - { extern int BTGlassPanelsActive(); glassOwnsGauges = BTGlassPanelsActive(); } -#endif - if (!glassOwnsGauges && getenv("BT_DEV_GAUGES") != 0) + // The mode came from the ONE resolver above (glassLayout) -- BT_GLASS_PANELS + // keeps the world window at its normal size (the per-display windows own the + // gauges: no cockpit surround, no bottom strip). { - // Mode resolution (precedence): explicit BT_COCKPIT > separate-window / - // legacy-inset opt-out > COCKPIT SURROUND default under BT_DEV_GAUGES. - int cockpitOn; - const char *ck = getenv("BT_COCKPIT"); - if (ck != NULL) - cockpitOn = (ck[0] != '0'); - else - cockpitOn = (getenv("BT_DEV_GAUGES_WINDOW") == 0 && - getenv("BT_DEV_GAUGES_DOCK") == 0); - - if (cockpitOn) + if (glassLayout == GlassLayoutCockpit) { gBTGaugeCockpit = 1; // -res W H = the WORLD VIEW size in cockpit mode; default 900x500. @@ -822,10 +1138,24 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine winW = cw; winH = ch; gBTCockpitCanvasW = winW; // windowed backbuffer = client canvas gBTCockpitCanvasH = winH; + // + // LETTERBOX INTENT, decided HERE rather than at device creation: + // the first WM_SIZE arrives before the device exists, and the + // world-aspect calc needs to know whether the canvas will be + // scaled uniformly (aspect = the view's own) or stretched to + // the client (aspect = the stretched one). Deciding it late + // gave a -fit boot aspect=3.14 on an ultrawide -- applied on + // the first frame, since nothing resizes the window again. + // L4VIDEO clears the flag if it cannot actually get COPY. + { + extern int gBTCockpitLetterbox; + const char *ms = getenv("MULTISAMPLE"); + gBTCockpitLetterbox = (ms == NULL || atoi(ms) == 0) ? 1 : 0; + } std::cout << "[cockpit] view " << viewW << "x" << viewH << " canvas " << winW << "x" << winH << std::endl << std::flush; } - else if (getenv("BT_DEV_GAUGES_WINDOW") == 0) + else if (glassLayout == GlassLayoutDock) { gBTGaugeDockBottom = 1; // Readability default (user-reported: the strip at 800 wide is a @@ -843,9 +1173,42 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine } } } + // + // -fit (alias -windowed-fullscreen): borderless over the whole monitor. + // The cockpit canvas letterboxes inside it at one uniform scale, so a + // wider-than-canvas monitor gets black bars rather than a stretch -- the + // same deal a dragged window gets, just without the chrome. Only useful + // with a layout that HAS a canvas to fit; other modes keep their window. + // + extern int gBTFitDisplay; // parsed early (the menu exits before here) + int fitDisplay = gBTFitDisplay; + RECT wr = { 0, 0, winW, winH }; AdjustWindowRect(&wr, WS_OVERLAPPEDWINDOW, FALSE); + DWORD winStyle = WS_OVERLAPPEDWINDOW; + int winX = 0, winY = 0; + if (fitDisplay) + { + RECT mon = { 0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN) }; + MONITORINFO mi; + memset(&mi, 0, sizeof(mi)); + mi.cbSize = sizeof(mi); + POINT origin = { 0, 0 }; + HMONITOR hmon = MonitorFromPoint(origin, MONITOR_DEFAULTTOPRIMARY); + if (GetMonitorInfo(hmon, &mi)) + mon = mi.rcMonitor; + winStyle = WS_POPUP | WS_CLIPCHILDREN; + winX = mon.left; + winY = mon.top; + wr.left = 0; wr.top = 0; + wr.right = mon.right - mon.left; + wr.bottom = mon.bottom - mon.top; + std::cout << "[cockpit] -fit: borderless " << (wr.right - wr.left) << "x" + << (wr.bottom - wr.top) << " (canvas letterboxes inside)" + << std::endl << std::flush; + } + // // Window identity (MP dev): tag the title with the -net port so a player // running two nodes side-by-side can tell the windows apart when @@ -863,8 +1226,8 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine else swprintf(winTitle, 64, L"BattleTech %S", BT_VERSION_STRING); } - hWnd = CreateWindowEx(0, L"MainWndClass", winTitle, WS_OVERLAPPEDWINDOW, - 0, 0, wr.right - wr.left, wr.bottom - wr.top, + hWnd = CreateWindowEx(0, L"MainWndClass", winTitle, winStyle, + winX, winY, wr.right - wr.left, wr.bottom - wr.top, (HWND)NULL, (HMENU)NULL, hInstance, (LPVOID)NULL); if (!hWnd) return FALSE; diff --git a/game/glass/btl4console.cpp b/game/glass/btl4console.cpp index e090429..6dff2a3 100644 --- a/game/glass/btl4console.cpp +++ b/game/glass/btl4console.cpp @@ -189,6 +189,23 @@ void command_line[n++] = (WCHAR)*s; } } + // + // Carry -fit into the child. This command line is built from scratch, so + // a display flag the player passed US is not in `arguments` -- without + // this, `btl4.exe -fit` gave a borderless menu and then a WINDOWED + // mission (field report 2026-07-26). + // + { + extern int gBTFitDisplay; + if (gBTFitDisplay) + { + static const WCHAR fit[] = L" -fit"; + for (const WCHAR *s = fit; *s && n < MAX_PATH + 250; ++s) + { + command_line[n++] = *s; + } + } + } command_line[n] = 0; STARTUPINFOW startup; diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index 43f6dad..a2878f5 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -465,6 +465,7 @@ const Receiver::HandlerEntry MESSAGE_ENTRY(Mech, TakeDamage), MESSAGE_ENTRY(Mech, PlayerLink), MESSAGE_ENTRY(Mech, BalanceCoolant), // id 0x16 @0049f728 (issue #20) + MESSAGE_ENTRY(Mech, DuckRequest), // id 0x1a @0049fa00 (CROUCH, 2026-07-26) }; // @@ -486,6 +487,32 @@ void DEBUG_STREAM << "[valve] BALANCE (all valves -> 1)" << std::endl; } +// +// @0049fa00 -- DuckRequest (id 0x1a): the CROUCH button (RIO 0x13). The +// binary's whole body, verbatim: +// +// if (0 < *(int *)(message + 0xc)) *(undefined4 *)(this + 0x398) = 1; +// +// this+0x398 is duckState (attribute 0x37) -- the mech reset is the only other +// writer in the binary (part_012.c:9439, the same reset that zeroes +// incomingLock and sets distanceToMissile = FLT_MAX, which is how we already +// map that region). Nothing in the decompiled code READS it: it is published +// as an attribute, so its consumer is a databinding (the SQUAT clips), not a +// call. So this handler is complete as-is -- the flag IS the feature's code +// side, and a crouch implementation here would be a stand-in for data we have +// not yet identified. See context/pod-hardware.md. +// +void + Mech::DuckRequestMessageHandler(ReceiverDataMessageOf<int> *message) +{ + if (message->dataContents <= 0) // press only (msg+0xc) + { + return; + } + duckState = 1; + DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1" << std::endl << std::flush; +} + Receiver::MessageHandlerSet Mech::MessageHandlers( ELEMENTS(Mech::MessageHandlerEntries), diff --git a/game/reconstructed/mech.hpp b/game/reconstructed/mech.hpp index 8448b4c..2ba25b6 100644 --- a/game/reconstructed/mech.hpp +++ b/game/reconstructed/mech.hpp @@ -1193,6 +1193,22 @@ protected: enum { BalanceCoolantMessageID = 0x16 }; void BalanceCoolantMessageHandler(ReceiverDataMessageOf<int> *message); + // @0049fa00 -- "DuckRequest" (Mech handler table @0x50BDF8, id 0x1a; the + // pod's CROUCH button, RIO address 0x13 -- the manual gives it a whole + // section). The last unregistered id in the Mech table; until now a + // press was silently swallowed by Receiver::Receive. + // + // The binary's ENTIRE body is two lines: press-only (msg+0xc > 0), then + // duckState@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 (0). duckState has NO code reader + // anywhere in the decomp; it is published as attribute 0x37, so whatever + // consumes it consumes it through DATABINDING (the SQUAT clips), not + // through a call. Reconstructed exactly: setting the flag is the + // authentic behaviour, and inventing a crouch here would be a stand-in. + enum { DuckRequestMessageID = 0x1a }; + void DuckRequestMessageHandler(ReceiverDataMessageOf<int> *message); + // --- damage-routing support (mechdmg / mech4) ----------------------- // Typed access to the inherited Entity::damageZones[] (engine stores DamageZone*; // our entries are Mech__DamageZone, populated by the Mech ctor). Defined in 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/scratchpad/checkbank.py b/scratchpad/checkbank.py new file mode 100644 index 0000000..72e26c0 --- /dev/null +++ b/scratchpad/checkbank.py @@ -0,0 +1,111 @@ +"""Verify the RIO button field from a BT_RIOBANK_LOG=1 run. + +The banks now reach UNDER the glass and deliberately overlap (the map's foot +row sits inside both side columns' reach), so "the address is in the list" is +not enough -- the hit test takes the FIRST match, so an address whose rect is +entirely shadowed by earlier buttons is dead however big it looks. + +Reads btl4.log, groups by bank tag, and for each button asks: is there any +point in my rect that no EARLIER button in this bank covers? Reports the +address census per bank and any shadowed address. + + python checkbank.py <log> [--expect 0x00-0x47] +""" +import re +import sys +from collections import OrderedDict + +BANK_RE = re.compile(r"\[riobank\] (.+?) bounds=\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\) (\d+) buttons") +BTN_RE = re.compile(r"\[riobank\] addr=0x([0-9a-f]+) rect=\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\) class=(\d+)") + + +def parse(path): + banks = OrderedDict() + current = None + with open(path, "r", errors="replace") as handle: + for line in handle: + match = BANK_RE.search(line) + if match: + current = match.group(1) + # a re-run appends; keep the FIRST pass of each tag + banks.setdefault(current, []) + if banks[current]: + current = None # already captured this tag + continue + match = BTN_RE.search(line) + if match and current is not None: + addr = int(match.group(1), 16) + x, y, w, h = (int(match.group(i)) for i in range(2, 6)) + banks[current].append((addr, x, y, w, h, int(match.group(6)))) + return banks + + +def reachable(rect, earlier): + """Any point of `rect` not covered by an earlier rect? + + Sampled on the coordinate grid induced by the earlier rects' edges -- if a + gap exists at all, one of these sample points lands in it. + """ + _, x, y, w, h = rect[:5] + xs = {x, x + w - 1} + ys = {y, y + h - 1} + for _, ex, ey, ew, eh, _ in earlier: + for candidate in (ex - 1, ex, ex + ew - 1, ex + ew): + if x <= candidate < x + w: + xs.add(candidate) + for candidate in (ey - 1, ey, ey + eh - 1, ey + eh): + if y <= candidate < y + h: + ys.add(candidate) + for px in sorted(xs): + for py in sorted(ys): + hit = None + for button in earlier: + _, ex, ey, ew, eh, _ = button + if ex <= px < ex + ew and ey <= py < ey + eh: + hit = button + break + if hit is None: + return True + return False + + +def main(): + path = sys.argv[1] if len(sys.argv) > 1 else "btl4.log" + banks = parse(path) + if not banks: + print("no [riobank] lines -- was BT_RIOBANK_LOG=1 set?") + return 1 + + seen = set() + failures = [] + for tag, buttons in banks.items(): + addresses = [b[0] for b in buttons] + duplicates = {a for a in addresses if addresses.count(a) > 1} + shadowed = [] + for index, button in enumerate(buttons): + if not reachable(button, buttons[:index]): + shadowed.append(button[0]) + seen.update(addresses) + status = "ok" + if duplicates or shadowed: + status = "FAIL" + failures.append(tag) + print("%-22s %2d buttons 0x%02x-0x%02x %s" + % (tag, len(buttons), min(addresses), max(addresses), status)) + if duplicates: + print(" duplicate addresses: %s" + % ", ".join("0x%02x" % a for a in sorted(duplicates))) + if shadowed: + print(" SHADOWED (no first-hit point): %s" + % ", ".join("0x%02x" % a for a in sorted(shadowed))) + + print("\n%d distinct addresses across %d banks" % (len(seen), len(banks))) + missing = [a for a in range(0x00, 0x48) if a not in seen] + if missing: + print("not placed by any bank: %s" + % ", ".join("0x%02x" % a for a in missing)) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scratchpad/clickbank.py b/scratchpad/clickbank.py new file mode 100644 index 0000000..c0d6fb6 --- /dev/null +++ b/scratchpad/clickbank.py @@ -0,0 +1,161 @@ +"""Click every RIO button the layout claims, and check each one dispatched. + +Reads a BT_RIOBANK_LOG=1 dump for the rects, posts a real WM_LBUTTONDOWN/UP at +each button's centre, then compares the addresses the game LOGGED against the +addresses the layout placed. This is the end-to-end check the geometry dump +alone cannot give: it proves the hit test, the coordinate mapping and the RIO +dispatch agree with the rects. + + python clickbank.py <log> <window-title-substring> [--mode surround|exploded] + +surround: one window, buttons are in backbuffer canvas space (== client space + for a windowed run), so the centres go straight to the main window. +exploded: one window per bank, each with its own client space; the bank tag + names the window. +""" +import ctypes +import re +import sys +import time +from ctypes import wintypes + +user32 = ctypes.WinDLL("user32", use_last_error=True) + +WM_LBUTTONDOWN = 0x0201 +WM_LBUTTONUP = 0x0202 +MK_LBUTTON = 0x0001 + +BANK_RE = re.compile(r"\[riobank\] (.+?) bounds=") +BTN_RE = re.compile(r"\[riobank\] addr=0x([0-9a-f]+) rect=\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\)") + + +def parse(path): + banks, current, done = {}, None, set() + with open(path, "r", errors="replace") as handle: + for line in handle: + match = BANK_RE.search(line) + if match: + current = match.group(1) + if current in done: + current = None + else: + done.add(current) + banks[current] = [] + continue + match = BTN_RE.search(line) + if match and current is not None: + addr = int(match.group(1), 16) + x, y, w, h = (int(match.group(i)) for i in range(2, 6)) + banks[current].append((addr, x + w // 2, y + h // 2)) + return banks + + +def find_windows(substring): + found = [] + + @ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + def callback(hwnd, _): + length = user32.GetWindowTextLengthW(hwnd) + if length: + buffer = ctypes.create_unicode_buffer(length + 1) + user32.GetWindowTextW(hwnd, buffer, length + 1) + if substring.lower() in buffer.value.lower() and user32.IsWindowVisible(hwnd): + found.append((hwnd, buffer.value)) + return True + + user32.EnumWindows(callback, 0) + return found + + +class RECT(ctypes.Structure): + _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long), + ("right", ctypes.c_long), ("bottom", ctypes.c_long)] + + +def canvas_size(path): + """The cockpit canvas from the boot line, e.g. '[cockpit] view 900x500 canvas 1452x1059'.""" + with open(path, "r", errors="replace") as handle: + for line in handle: + match = re.search(r"\[cockpit\] view \d+x\d+ canvas (\d+)x(\d+)", line) + if match: + return int(match.group(1)), int(match.group(2)) + return None + + +def fit_rect(client_w, client_h, canvas_w, canvas_h): + """Same uniform-scale centred fit the game computes (BTCockpitFitRect).""" + w = client_w + h = client_w * canvas_h // canvas_w + if h > client_h: + h = client_h + w = client_h * canvas_w // canvas_h + return ((client_w - w) // 2, (client_h - h) // 2, max(w, 1), max(h, 1)) + + +def click(hwnd, x, y): + lparam = (y << 16) | (x & 0xFFFF) + user32.PostMessageW(hwnd, WM_LBUTTONDOWN, MK_LBUTTON, lparam) + time.sleep(0.02) + user32.PostMessageW(hwnd, WM_LBUTTONUP, 0, lparam) + time.sleep(0.02) + + +def main(): + log = sys.argv[1] + title = sys.argv[2] + mode = "surround" + if "--mode" in sys.argv: + mode = sys.argv[sys.argv.index("--mode") + 1] + + banks = parse(log) + if not banks: + print("no [riobank] lines in", log) + return 1 + + # exploded: each bank is its OWN window with its own caption, so match + # across every visible window rather than filtering by one title + windows = find_windows("" if mode == "exploded" else title) + if not windows: + print("no window matching", title) + return 1 + + # surround: the dump is in CANVAS space; if the window has been resized the + # canvas is letterboxed inside the client, so map through the same fit the + # game uses or every click lands off its button + transform = None + if mode == "surround": + canvas = canvas_size(log) + client = RECT() + user32.GetClientRect(windows[0][0], ctypes.byref(client)) + if canvas and client.right > 0 and client.bottom > 0: + ox, oy, fw, fh = fit_rect(client.right, client.bottom, canvas[0], canvas[1]) + if (fw, fh) != canvas: + transform = (ox, oy, fw, fh, canvas[0], canvas[1]) + print("client %dx%d, canvas %dx%d -> fit %dx%d at (%d,%d)" + % (client.right, client.bottom, canvas[0], canvas[1], fw, fh, ox, oy)) + + posted = set() + for tag, buttons in banks.items(): + if mode == "surround": + target = windows[0][0] + else: + # the bank tag is the window caption + match = [h for h, caption in windows if caption.strip() == tag.strip()] + if not match: + print(" no window for bank %r -- skipped" % tag) + continue + target = match[0] + for addr, cx, cy in buttons: + if transform is not None: + ox, oy, fw, fh, cw, ch = transform + cx = ox + cx * fw // cw + cy = oy + cy * fh // ch + click(target, cx, cy) + posted.add(addr) + print("posted clicks for %d addresses" % len(posted)) + print(" ".join("0x%02x" % a for a in sorted(posted))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scratchpad/measurelegend.py b/scratchpad/measurelegend.py new file mode 100644 index 0000000..d009b6a --- /dev/null +++ b/scratchpad/measurelegend.py @@ -0,0 +1,91 @@ +"""Measure the map's PAINTED legend grid, and compare the lamp bars against it. + +The side button columns should line up with the legend cells the map imagery +paints beside them. RP412 measured ITS map at 13 + 6x102 on a 105 pitch of 640 +-- a different game's art, so those numbers do not transfer. This finds ours. + +Two scans over a native Secondary/Radar capture: + * the LEGEND: horizontal rules of the six bordered cells in the map imagery + * the LAMPS: the runs of the yellow button strip at the window's left edge +and reports the offset between them, which is what a misalignment looks like. + + py measurelegend.py <capture.png> <caption_h> <lamp_x> <legend_x0> <legend_x1> +""" +import sys +from PIL import Image + + +def main(): + path = sys.argv[1] + caption = int(sys.argv[2]) if len(sys.argv) > 2 else 30 + lamp_x = int(sys.argv[3]) if len(sys.argv) > 3 else 12 + lx0 = int(sys.argv[4]) if len(sys.argv) > 4 else 22 + lx1 = int(sys.argv[5]) if len(sys.argv) > 5 else 118 + + image = Image.open(path).convert("RGB") + w, h = image.size + px = image.load() + print("capture %dx%d (client starts at y=%d)" % (w, h, caption)) + + # --- the legend: rows carrying a long horizontal ORANGE rule ------------- + def orange(p): + return p[0] > 110 and p[1] > 45 and p[2] < 90 and p[0] > p[2] + 60 + + rules = [] + for y in range(caption, h): + run = 0 + best = 0 + for x in range(lx0, min(lx1, w)): + if orange(px[x, y]): + run += 1 + best = max(best, run) + else: + run = 0 + if best >= (lx1 - lx0) * 0.55: + rules.append(y - caption) + + # collapse adjacent rows into single rules + grouped = [] + for y in rules: + if grouped and y - grouped[-1][-1] <= 2: + grouped[-1].append(y) + else: + grouped.append([y]) + rule_tops = [g[0] for g in grouped] + print("\nlegend rules (client y): %s" % rule_tops) + if len(rule_tops) >= 2: + print("gaps: %s" % [rule_tops[i+1] - rule_tops[i] + for i in range(len(rule_tops) - 1)]) + + # --- the lamps: runs of lit pixels in the button strip ------------------- + lit = [] + for y in range(caption, h): + p = px[lamp_x, y] + lit.append(p[0] > 55 and p[1] > 40 and p[2] < 90) + runs = [] + start = None + for i, on in enumerate(lit): + if on and start is None: + start = i + elif not on and start is not None: + if i - start >= 4: + runs.append((start, i - start)) + start = None + if start is not None and len(lit) - start >= 4: + runs.append((start, len(lit) - start)) + + print("\nlamp bars (client y, height): %s" % runs) + if len(runs) >= 2: + print("lamp pitch: %s" % [runs[i+1][0] - runs[i][0] + for i in range(len(runs) - 1)]) + + # --- compare ------------------------------------------------------------ + if rule_tops and runs: + print("\ncell top vs lamp top:") + for i, (top, height) in enumerate(runs[:len(rule_tops)]): + print(" lamp %d top=%4d nearest rule=%4d offset=%+d" + % (i, top, rule_tops[i], top - rule_tops[i])) + + +if __name__ == "__main__": + main() diff --git a/tools/mkdist.py b/tools/mkdist.py index 674469d..554f654 100644 --- a/tools/mkdist.py +++ b/tools/mkdist.py @@ -119,6 +119,35 @@ 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") + + # The same map as a page, for anyone who would rather look at the + # diagrams than read them. The source is a fragment (no doctype or + # <head>), so wrap it: without a doctype the browser drops into quirks + # mode, and without a charset the typography arrives as mojibake. + if os.path.exists("docs/CONTROLS.html"): + page = open("docs/CONTROLS.html", encoding="utf-8").read() + page = ('<!doctype html>\n<html lang="en">\n<head>\n' + '<meta charset="utf-8">\n' + '<meta name="viewport" content="width=device-width, initial-scale=1">\n' + '</head>\n<body>\n' + page + '\n</body>\n</html>\n') + z.writestr(name + "/CONTROLS.html", page.encode("utf-8")) + print(" controls page -> CONTROLS.html") 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)