From 61563c9efee22bc99c3cd3f80b45f6bb124c0199 Mon Sep 17 00:00:00 2001 From: Cyd Date: Sun, 26 Jul 2026 02:23:40 -0500 Subject: [PATCH 1/9] Glass cockpit refit: one mode resolver, one button geometry, and a cockpit that scales Replicates the RP412 cockpit line into BT411's own architecture -- porting the geometry and keeping our renderers, so BT_SHOT single-frame verification stays intact. MODE RESOLVER. Where the secondary displays go was decided in TWO places with duplicated precedence, and the boot banner read NEITHER -- it announced "per-display cockpit windows" for every glass boot, surround included. 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. One resolver now, consumed by the banner, the pad-panel decision and the window sizing; BT_GLASS_PANELS is explicit-only; dock/window modes auto-raise BT_PAD_PANEL so the button field always has a home. L4RIOBANK -- one button geometry. Both renderers carried their own copy 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 owns it now, both are consumers, placement stays per-renderer. The pod's under-glass rule (RP412 L4MFDVIEW): reach half the glass in behind the display, leave a lamp strip clearing the edge, paint buttons first and imagery over -- so the lamp reads as a bar and practically the whole display is the press target. The strip scales off the display's SHORT axis (the map is portrait) with a readable floor. Retired L4GLASSWIN's three local placers and seven layout constants. LAMP FLASH DECODE was wrong [T1]. BTLampBrightnessOf returned max(state1, state2) and blanked on the alternate phase; RIO::LampState (L4RIO.h [T0]) says solid shows state 1 and flashing ALTERNATES the two. Agrees only when one state is Off -- true for the Panic lamp, which is why it survived -- but L4LAMP.cpp:252 commands flashFast + state1Dim + state2Bright, a dim->bright pulse that rendered as a hard bright->off blink. Three copies existed (l4vb16.h, L4GLASSWIN, L4PADPANEL), all three wrong; the two locals now forward to the one fixed inline. THE COCKPIT SCALES. The fixed canvas was stretched into whatever the client area was, so a window dragged to a different shape squashed the instruments (the projection was aspect-corrected in task #20; the panels never were). Now one uniform scale, centred, leftover black. D3D9 applies it as a Present destination rect, which DISCARD forbids -- so the WINDOWED swap effect becomes COPY when the surround is up and multisampling is off. The click mapping had to follow (mapping against the full client drifts the hit test off every button by the bar width), as did the world aspect (under a uniform scale it is the view rect's own). -fit / -windowed-fullscreen: borderless over the monitor. - ordering trap: the first WM_SIZE beats the device, so a -fit boot logged aspect=3.14 and applied it on frame 1. The letterbox INTENT is decided in btl4main; L4VIDEO only confirms or withdraws it. PLAYER-TUNABLE DISPLAYS. BT_MFD_SCALE (+ _UL/_UC/_UR/_LL/_LR), BT_RADAR_SCALE, BT_RADAR_POS (CENTER/LEFT/RIGHT/MIDLEFT/MIDRIGHT). The surround BANDS derive from the resolved sizes -- that is why the sizes could not stay constants: the band a display hangs in has to grow with it or the canvas clips it. 100% reproduces the historical L276 R276 T223 B336 exactly. A corner map goes flush to the CANVAS edge and the lower MFD slides beside it (measuring off the view edge overlapped them by 232px). MAP LEGEND GRID -- measured, not inherited. scratchpad/measurelegend.py over a native capture: top 3, cell 102, pitch 107 of 640. RP412's map is 13 + 6x102 @ 105 -- same cell height, different top and pitch, so its numbers do NOT transfer. Our old even division had the pitch right by luck and sat 3px high of the labels. environ.ini. It was read ~300 lines into WinMain, AFTER the platform-profile block had run its getenv()s -- so every setting the profile reads was silently ignored FROM THE FILE and only worked as a real env var. It also putenv()'d comments verbatim. Now loaded immediately after the first-breath line, comments skipped, the real environment WINS over the file, and a fully documented default is written on first run (the bindings.txt convention: untracked, so extract-over-top never clobbers a player's settings). VERIFICATION HARNESS (new, reusable): BT_RIOBANK_LOG=1 dumps every bank; checkbank.py proves no address is SHADOWED (an address whose rect is covered by earlier buttons is dead however big it looks -- the overlapping under-glass banks make that a live hazard); clickbank.py posts a real click at every button centre. Verified: 72/72 placed, 0 shadowed, 72/72 dispatched in BOTH modes, after a resize, at 150%/135%, and at 75%+BOTTOMRIGHT; wide/tall drags and -fit undistorted on a 3440x1440; exploded/dock/pod/dev un-regressed. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + CMakeLists.txt | 1 + context/gauges-hud.md | 19 +- docs/GAUGE_COMPOSITE.md | 10 +- engine/MUNGA_L4/L4GLASSWIN.cpp | 190 ++++++--------- engine/MUNGA_L4/L4PADPANEL.cpp | 30 +-- engine/MUNGA_L4/L4RIOBANK.cpp | 296 +++++++++++++++++++++++ engine/MUNGA_L4/L4VB16.cpp | 418 +++++++++++++++++++++++++++------ engine/MUNGA_L4/L4VB16.h | 55 ++++- engine/MUNGA_L4/L4VIDEO.cpp | 47 +++- engine/MUNGA_L4/l4riobank.h | 149 ++++++++++++ game/btl4main.cpp | 373 ++++++++++++++++++++++++----- scratchpad/checkbank.py | 111 +++++++++ scratchpad/clickbank.py | 161 +++++++++++++ scratchpad/measurelegend.py | 91 +++++++ 15 files changed, 1670 insertions(+), 282 deletions(-) create mode 100644 engine/MUNGA_L4/L4RIOBANK.cpp create mode 100644 engine/MUNGA_L4/l4riobank.h create mode 100644 scratchpad/checkbank.py create mode 100644 scratchpad/clickbank.py create mode 100644 scratchpad/measurelegend.py 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/CMakeLists.txt b/CMakeLists.txt index ab98ab5..b74beea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -265,6 +265,7 @@ 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" ) 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/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/engine/MUNGA_L4/L4GLASSWIN.cpp b/engine/MUNGA_L4/L4GLASSWIN.cpp index 9a6387c..db2bfca 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 @@ -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; @@ -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/L4PADPANEL.cpp b/engine/MUNGA_L4/L4PADPANEL.cpp index 36d49f2..1c4b7ac 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 #include @@ -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/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 +#include + +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..bc9e1e2 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 @@ -316,18 +553,34 @@ void BTCockpitComputeLayout(int canvasW, int canvasH, BTCockpitLayout *out) 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++) + // 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..58a3083 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; @@ -8799,7 +8827,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 +9157,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 +9809,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/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..049e8cb 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,194 @@ 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"; + +static void BTWriteDefaultEnvironIni(void) +{ + FILE *probe; + if (fopen_s(&probe, "environ.ini", "r") == 0) // already there -- leave it alone + { + fclose(probe); + 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): @@ -279,6 +475,10 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine << (getenv("BT_FE_LOOP") ? " (relaunched generation)" : " (first process)") << std::endl << std::flush; + // The player's settings, before ANYTHING reads the environment (the + // platform profile, the joystick wizard, the layout resolver...). + BTLoadEnvironIni(); + // 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 +690,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 +722,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 +899,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 +1011,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 +1036,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 +1071,44 @@ 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. + // + int fitDisplay = 0; + if (lpCmdLine && (strstr(lpCmdLine, "-fit") != 0 || + strstr(lpCmdLine, "-windowed-fullscreen") != 0)) + fitDisplay = 1; + 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 +1126,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/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 [--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 [--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 +""" +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() From 1debbeb24039f8018737b79655ea1f8267add80e Mon Sep 17 00:00:00 2001 From: Cyd Date: Sun, 26 Jul 2026 02:23:58 -0500 Subject: [PATCH 2/9] Mech::DuckRequest -- the CROUCH button, and a dead-button census that lied The 2026-07-20 audit listed 8 panel buttons dispatching a streamed message with no reconstructed handler. Only ONE was still missing: generator on/off, ToggleSeekVoltage, EjectAmmo, ToggleCooling and BalanceCoolant 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 had already flagged the census as "stale in both directions" and was right -- swept both files, and recorded the rule: check the code, not the census. DuckRequest @0049fa00 (id 0x1a, RIO 0x13 -- the manual gives crouch a whole section). The binary's entire body: if (0 < *(int *)(param_2 + 0xc)) { *(undefined4 *)(param_1 + 0x398) = 1; } Press-only; sets duckState (mech+0x398, attribute 0x37). 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, which is how that region was already mapped). WHY THE FLAG HAS NO READER, AND WHY THAT IS CORRECT. duckState has ZERO readers anywhere in the decomp, because it is published as an ATTRIBUTE and consumed through DATABINDING: content/GAUGE/L4GAUGE.CFG runs a 3-frame bduck.pcc oneOfSeveralPixInt bound to 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. Still open from that census: MechRIOMapper's own Keypress @004d2514 (id 0x19). Co-Authored-By: Claude Opus 5 (1M context) --- context/open-questions.md | 34 +++++++++++++++++++++++----------- context/pod-hardware.md | 10 ++++++---- game/reconstructed/mech.cpp | 27 +++++++++++++++++++++++++++ game/reconstructed/mech.hpp | 16 ++++++++++++++++ 4 files changed, 72 insertions(+), 15 deletions(-) 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/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 *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 *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 *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 From 02b1b50e4562e088c2ee1433c7c8a03bd69794fb Mon Sep 17 00:00:00 2001 From: Cyd Date: Sun, 26 Jul 2026 02:24:30 -0500 Subject: [PATCH 3/9] RGB keyboard lamp mirror + the shipped controls reference KEYBOARD LAMP MIRROR (L4KEYLIGHT). Ported from RP412, itself a port of vRIO's KeyboardLampMirror. Keys bound to a lamp address in bindings.txt glow with the panel palette through Windows Dynamic Lighting -- yellow for the map's side columns (0x10-0x1F), red for the rest -- flashing in step with the on-screen buttons (its LampLevel copy matches the FIXED BTLampBrightnessOf). Per-key boards light each bound key; zone-lit boards mirror the strongest lamp board-wide. All WinRT runs on a private worker thread: watcher, claim, and a 100 ms paint loop that repaints only on change. RP412's packing hazard does NOT transfer: it forces default struct packing there because its engine is /Zp1, which would break the WinRT ABI. BT411's BT_OPTS carries no /Zp, so only the dialect flags are needed -- /std:c++17 /permissive- per-file, since the project otherwise builds C++14 /permissive. The scalars-only interface is kept anyway so the isolation survives if packing is ever added. Wired in PadRIO: map built from bindings.keyBindings (ActionButton binds only, first-binding-wins per key), fed from PadRIO::SetLamp, stopped in the dtor (which hands the LEDs back to Windows). BT_KEYLIGHT=0 opts out; a machine without Dynamic Lighting logs once and stays dormant. Verified live: claimed this machine's 24-zone keyboard and mirrors 25 bound keys; BT_KEYLIGHT=0 and the pod profile produce zero keylight lines and run clean. SHIPPED CONTROLS REFERENCE. docs/CONTROLS.md carries the keyboard table plus the full 72-BUTTON POD MAP, grouped by the display each bank surrounds, with the coolant-valve detent warning (1-5-50-CLOSED, one press past max shuts the loop) and the jam/eject procedure. mkdist flattens it to ASCII as CONTROLS.txt at the zip root, the README's own idiom. players/README.txt gained pointers to it, to environ.ini, to -fit, and to the RGB mirror. KB: context/glass-cockpit.md and docs/GLASS_COCKPIT.md updated for the whole branch -- layout modes, L4RIOBANK and the under-glass rule, the letterbox fit and its ordering trap, player-tunable displays, the measured legend grid, the closed dead-button backlog, and this mirror. Verified: five-mode regression (surround/exploded/dock/pod/dev) boots and simulates with zero faults; mkdist writes a 5149-byte pure-ASCII CONTROLS.txt. Co-Authored-By: Claude Opus 5 (1M context) --- CMakeLists.txt | 9 + context/glass-cockpit.md | 215 ++++++++++++++++- docs/CONTROLS.md | 119 ++++++++++ docs/GLASS_COCKPIT.md | 249 +++++++++++++++++++- engine/MUNGA_L4/L4KEYLIGHT.cpp | 406 +++++++++++++++++++++++++++++++++ engine/MUNGA_L4/L4PADRIO.cpp | 90 ++++++++ engine/MUNGA_L4/L4PADRIO.h | 5 + engine/MUNGA_L4/l4keylight.h | 53 +++++ players/README.txt | 12 + tools/mkdist.py | 16 ++ 10 files changed, 1167 insertions(+), 7 deletions(-) create mode 100644 docs/CONTROLS.md create mode 100644 engine/MUNGA_L4/L4KEYLIGHT.cpp create mode 100644 engine/MUNGA_L4/l4keylight.h diff --git a/CMakeLists.txt b/CMakeLists.txt index b74beea..741bed6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -268,7 +268,16 @@ target_sources(munga_engine PRIVATE "engine/MUNGA_L4/L4RIOBANK.cpp" "engine/MUNGA_L4/L4GLASSWIN.cpp" "engine/MUNGA_L4/L4PLASMAWIN.cpp" + "engine/MUNGA_L4/L4KEYLIGHT.cpp" ) +# RGB keyboard lamp mirror (Windows Dynamic Lighting). C++/WinRT needs C++17 +# AND conformance mode, while the rest of the project builds C++14 /permissive +# -- so this ONE file gets its own flags. Its interface (l4keylight.h) is +# scalars only, so nothing else has to change dialect. (RP412 additionally +# forced default struct packing here because its engine is /Zp1; BT411 sets no +# /Zp, so that part does not apply -- see the file header.) +set_source_files_properties("engine/MUNGA_L4/L4KEYLIGHT.cpp" PROPERTIES + COMPILE_OPTIONS "/std:c++17;/permissive-") target_include_directories(munga_engine BEFORE PRIVATE "${CMAKE_SOURCE_DIR}/engine/shim" "${DXSDK}/Include") diff --git a/context/glass-cockpit.md b/context/glass-cockpit.md index e5fbb4c..c7d100c 100644 --- a/context/glass-cockpit.md +++ b/context/glass-cockpit.md @@ -63,6 +63,211 @@ version skew (a stale steam exe shipped 2 days behind), tripled build time, and ([[reconstruction-gotchas]]); everything links always, so this class of trap no longer varies by build flavor. +## Layout modes — ONE resolver (2026-07-26) [T2 four-mode boot-verified] + +Where the five MFDs + radar go is a SEPARATE axis from the platform profile. Under a glass boot +(`BT_DEV_GAUGES` set) `btl4main.cpp` resolves it **once**, right after the profile putenvs land, +into `glassLayout`; the boot banner, the pad-panel decision and the window-sizing block all read +that one answer. Precedence, highest first: + +| Env | Layout | Buttons live in | +|---|---|---| +| `BT_GLASS_PANELS`≠0 | per-display cockpit windows (the "exploded" view, `L4GLASSWIN`) | each display's own window | +| `BT_DEV_GAUGES_WINDOW` | the legacy separate MFD window | the single combined pad panel | +| `BT_DEV_GAUGES_DOCK` | the docked bottom gauge strip | the single combined pad panel | +| `BT_COCKPIT=0` | ...also the docked strip (the documented opt-out) | the single combined pad panel | +| (nothing) | **COCKPIT SURROUND — the glass default** | the main window (D3D-drawn lamps) | + +The banner now names the live one: `[boot] platform profile: GLASS (PadRIO + plasma window) +[secondary displays: cockpit surround]`. + +⚠ **Two bugs this replaced** (both were live until 2026-07-26): the mode was decided in TWO places +with duplicated precedence (the glass profile block picked `BT_PAD_PANEL`/`BT_GLASS_PANELS`; the +sizing block re-derived cockpit-vs-dock), and the banner read NEITHER — it announced "per-display +cockpit windows [BT_GLASS_PANELS]" for every glass boot, surround included. Worse, the profile +block turned `BT_COCKPIT=0` into `BT_GLASS_PANELS=1`, so the docked strip it is documented to +select was **unreachable under the glass profile**. `BT_GLASS_PANELS` is now explicit-only, and +dock/window modes auto-raise `BT_PAD_PANEL` so the 72-button field always has a home. + +## ONE button-bank geometry — L4RIOBANK (2026-07-26) [T2 click-verified both modes] + +The RIO button field around a pod display is drawn TWICE — composited into the main window +(surround, `L4VB16.cpp`) and one window per display (exploded, `L4GLASSWIN.cpp`) — and each +renderer used to carry its own copy of the geometry. They drifted badly: the same MFD button was +a 156×138 under-glass target in the exploded window and a **76×24 sliver entirely outside the +glass** in the surround. `engine/MUNGA_L4/L4RIOBANK.cpp` is now that geometry, once; both +renderers are consumers, and placement (WHERE each display goes) stays with each renderer because +that genuinely differs. + +**The under-glass rule** (ported from RP412 `L4MFDVIEW`, which took it from the pod): a button +reaches **half the glass** in BEHIND the display and only a **lamp strip** clears the edge. The +renderer paints buttons first, imagery over them — so the lamp reads as a slim bar, practically +the whole display is the press target, and nothing is occluded (the picture wins every pixel it +covers). The strip scales off the display's SHORT axis against the native 480 (an MFD is 640×480 +landscape, the map 480×640 portrait — both short-axis 480) with a 6px floor, so a half-scale +surround lamp is still a bar and not a smudge. The MFD per-column nudge against the unevenly +spaced DISPLAY/PROGRAM legends (`mfdColumnDX`) moved here too, so both renderers inherit the +same alignment. + +⚠ **The map's foot row is pushed FIRST**, deliberately: the side columns now reach half the map +each, so they cover the foot band too, and the hit test takes the first match — foot-first is +what keeps 0x16/0x17/0x1F/0x1E reachable. That class of bug is why the verification below tests +first-hit reachability, not mere presence. + +**Verified 2026-07-26 [T2]:** `BT_RIOBANK_LOG=1` dumps every bank's rects; +`scratchpad/checkbank.py` reports the per-bank census and proves no address is SHADOWED (has a +point no earlier button covers); `scratchpad/clickbank.py` then posts a real click at every +button's centre. Both modes: **72/72 addresses placed, zero shadowed, zero duplicated, 72/72 +dispatched** (`[cockpit] CLICK` / `[glasswin] CLICK`), process survived 144 posted messages per +mode. Pod + dev profiles boot and simulate un-regressed. Surround BT_SHOT confirms the legends +are clear and the lamps read as edge strips. + +## The cockpit SCALES — uniform-fit letterbox + `-fit` (2026-07-26) [T2 live] + +The canvas is a fixed size and D3D9 stretched it into whatever the client area was, so a window +dragged to a different shape **squashed the instruments** — the projection was aspect-corrected +(task #20) but the panels, lamps and MFD glass were not. Now the canvas is fitted at ONE uniform +scale, centred, leftover black: the cockpit scales both ways and never distorts, whatever the +window. + +- `BTCockpitFitRect(clientW, clientH, RECT*)` — integer math throughout, so the rect is stable + frame to frame (a float scale wobbles a pixel and the bars shimmer during a drag). +- D3D9 does the scaling at Present via a **destination rect**, which `D3DSWAPEFFECT_DISCARD` + forbids — so the WINDOWED swap effect becomes **`D3DSWAPEFFECT_COPY`** when the surround is up + and multisampling is off (COPY cannot multisample). `gBTCockpitLetterbox` records whether that + happened; when 0 the old full-client stretch stands, so an MSAA run still works. +- Both present sites take the rect (scene + the wait-screen overlay, which paints into the same + backbuffer). `WM_SIZE` invalidates so the bars repaint black — a COPY present only writes its + dest rect, so stale bar content would otherwise persist. +- **The click transform had to follow.** `BTCockpitMouseDown` mapped client→canvas against the + full client; under the letterbox that drifts the hit test off every button by the bar width. + It now runs the identical fit (and a click on a bar hits nothing). +- **World aspect** under the letterbox is the view rect's OWN aspect — the client no longer + enters into it. +- **`-fit`** (alias `-windowed-fullscreen`): borderless `WS_POPUP` over the monitor, canvas + letterboxed inside. Verified on a 3440×1440 ultrawide. + +⚠ **Ordering trap found live:** the letterbox flag was first set at device creation, but the +first `WM_SIZE` arrives BEFORE the device exists — a `-fit` boot logged `aspect=3.14` (the +stretch formula) and applied it on frame 1, because nothing resizes the window again. The INTENT +is now decided in `btl4main` alongside `gBTGaugeCockpit`; `L4VIDEO` only confirms or withdraws it. + +⚠ **Capture note:** `PrintWindow` returns an all-black frame for the borderless `-fit` window +(it works for the normal chrome'd one). Use a screen-region grab (`scratchpad/shot.ps1`) for +`-fit`, and don't read a black PrintWindow there as a render failure — it isn't. + +Verified: wide (1684×661) and tall (744×961) drags both letterbox with zero distortion; **72/72 +buttons still dispatch through the transform at 0.62× scale with a 389px bar**; exploded / dock / +pod / dev all keep DISCARD and boot un-regressed. + +## Player-tunable displays — scale, map placement, environ.ini (2026-07-26) [T2 live] + +The pod bolted its instruments down at one size; a desktop panel has room to trade viewscreen for +instrument, so the player scales them. All resolved ONCE (`BTCkResolvedSizes`, L4VB16.cpp) because +the surround recomputes its layout every frame. + +| Env | Effect | +|---|---| +| `BT_MFD_SCALE` | all five MFDs, % of pod size (25-200, default 100) | +| `BT_MFD_SCALE_UL/_UC/_UR/_LL/_LR` | one display, overriding the group | +| `BT_RADAR_SCALE` | the portrait map | +| `BT_RADAR_POS` | `CENTER` (default) / `LEFT` / `RIGHT` / `MIDLEFT` / `MIDRIGHT` (+ `BOTTOM*`, `*CENTER` spellings) | + +**The bands derive from the resolved sizes** — that is why the sizes could not stay constants: the +surround band a display hangs in has to grow with it or the canvas clips it. Each band is what its +LARGEST occupant needs (a corner MFD only claims `size - kCkOVL`, since it overlaps the view), and +the bottom band drops to just the lower MFDs when the map leaves that row for a side. Verified: +100% reproduces the historical `L276 R276 T223 B336` exactly; 150% gives `L436 R436 T343 B448`; +80% + MIDLEFT gives `L256 R212 T175 B148`. + +⚠ **A corner map goes flush to the CANVAS edge**, not the view edge, and the lower MFD whose corner +it took sits beside it. Measuring off the view edge (first cut) overlapped them by the band width — +232px of map drawn over the Right Weapons MFD. + +**Map legend grid — MEASURED, not inherited [T2].** The side columns must line up with the six +cells the map paints beside them (⊕MAP / ⊖MAP / IR / crouch / searchlight / display-mode = +0x10-0x15 — the imagery independently confirms our address map). `scratchpad/measurelegend.py` over +a native 480×640 capture gives **top 3, cell 102, pitch 107** on the 640 span. RP412's map is +`13 + 6×102 @ pitch 105` — same cell height, different top and pitch, so **its numbers do not +transfer**; ours are ours. Our old even division (`displayH/6 + 1 = 107`) had the pitch right by +luck and sat 3px high of the labels. Top and bottom are now scaled separately and subtracted, so +rounding cannot drift the buttons out of step with the labels down the column. + +**`environ.ini` (the settings file) [T2].** ⚠ It was read ~300 lines into `WinMain`, AFTER the +platform-profile block had run its `getenv`s — so every setting the profile reads (`BT_PLATFORM`, +`BT_COCKPIT`, `BT_GLASS_PANELS`, `BT_DEV_GAUGES`, `L4CONTROLS`…) was **silently ignored from the +file** and only worked as a real env var. It also `putenv`'d comment lines verbatim, which would +have turned a shipped commented-out option into a variable literally named `#BT_MFD_SCALE`. Now: +loaded immediately after the first-breath boot line, comments and blanks skipped, and **the real +environment WINS** so a launcher `.bat` overrides the file rather than fighting it. A documented +default is written on first run when absent — the `bindings.txt` convention: untracked, so an +extract-over-top upgrade never clobbers a player's settings, and every option ships commented out +so a fresh install behaves exactly as before. Verified all three: first run writes it and applies +0 settings; uncommented file settings drive the layout (130% group + 60% UC + MIDRIGHT); a real +`BT_MFD_SCALE=90` overrides the file's 130 while the file's other settings still apply. + +Verified: **72/72 placed, 0 shadowed, 72/72 dispatched** at baseline, at 150%/135%, and at +75% + BOTTOMRIGHT; exploded / dock / pod / dev boot un-regressed. + +## Dead-button backlog CLOSED — DuckRequest was the last (2026-07-26) [T2 live] + +The 2026-07-20 audit's "8 buttons dispatch a streamed message with no reconstructed handler" is +finished. ⚠ Only ONE of the eight was actually still missing when this pass started — generator +on/off, ToggleSeekVoltage, EjectAmmo, ToggleCooling and BalanceCoolant had all landed between +07-20 and 07-25 while `pod-hardware.md` / `open-questions.md` still called them dead +(`docs/INPUT_PATH_AUDIT.md` had already flagged the census as "stale in both directions"). +**Check the code, not the census.** + +**`Mech::DuckRequest` @0049fa00 (id 0x1a, RIO 0x13 — the manual's CROUCH button).** The binary's +entire body is two lines: press-only (`msg+0xc > 0`), then `duckState`(`mech+0x398`) = 1. It is a +one-shot REQUEST flag, not a posture toggle — the handler never clears it and the only other +writer in the whole binary is the mech reset (part_012.c:9439, the same reset that zeroes +`incomingLock`). Registered in `Mech::MessageHandlerEntries`; `[duck]` log line on press. + +**Its consumer is a DATABINDING, not code [T1] — which is why the flag looked pointless.** +`duckState` has ZERO readers anywhere in the decomp, because it is published as attribute 0x37 and +consumed through the gauge system: `content/GAUGE/L4GAUGE.CFG` runs a 3-frame `bduck.pcc` +`oneOfSeveralPixInt` widget bound to `DuckState` on the map's legend column ("crouch mode: button +4"). Verified live: pressing 0x13 turns that legend icon grey→orange. **Do not invent a crouch +pose to "finish" this** — the flag plus the indicator IS the code side. + +Bonus confirmation: those legend widgets sit at gauge offsets 537/430/322/215/108 — a **107 +pitch**, independently corroborating the map legend grid measured from pixels in §Player-tunable +displays. + +## RGB keyboard lamp mirror — L4KEYLIGHT (2026-07-26) [T2 live, claimed a real board] + +Ported from RP412 (itself vRIO's `KeyboardLampMirror`). Keys bound to a lamp address in +`bindings.txt` glow with the panel palette through **Windows Dynamic Lighting** — yellow for the +map's side columns (0x10-0x1F), red for the rest — flashing in step with the on-screen buttons +(its `LampLevel` copy matches the FIXED `BTLampBrightnessOf`). Per-key boards light each bound +key; zone-lit boards mirror the strongest lamp board-wide. All WinRT runs on a private worker +thread. Gate: `BT_KEYLIGHT=0` opts out; no Dynamic Lighting = one log line, then dormant. + +⚠ **RP412's packing hazard does NOT apply here.** It compiles that file with forced default struct +packing because *its* engine is `/Zp1`, which would break the WinRT ABI. BT411's `BT_OPTS` is +`/permissive /W0 /wd4996 /EHsc /bigobj /MP` — **no `/Zp`** — so only the dialect flags are needed +(`/std:c++17 /permissive-`, per-file in CMakeLists, since the project otherwise builds C++14 +/permissive). The scalars-only interface is kept anyway so the isolation survives if packing is +ever added. + +Wired in `PadRIO`: map from `bindings.keyBindings` (ActionButton binds only, first-binding-wins +per key), fed from `PadRIO::SetLamp`, stopped in the dtor (which hands the LEDs back to Windows). +Verified: `[keylight] mirroring 25 bound key(s)` + claimed this machine's 24-zone keyboard; +`BT_KEYLIGHT=0` and the pod profile produce zero keylight lines and run clean. + +## Lamp flash decode was wrong — fixed (2026-07-26) [T1, from the L4RIO.h enum] + +`BTLampBrightnessOf` (l4vb16.h) returned `max(state1, state2)` and blanked to 0 on the alternate +phase. The engine's own enum [T0] says otherwise: bits 0-1 = flash mode, bits 2-3 = **state 1** +brightness, bits 4-5 = **state 2** brightness (0 off / 1 dim / 3 bright) — solid shows state 1, +flashing ALTERNATES the two at 500/250/125 ms. The old formula agrees only when one state is Off, +which is why it survived: the Panic lamp is `flashFast+state1Off+state2Bright` and L4LAMP's other +pulse is off/dim. But **`L4LAMP.cpp:252` commands `flashFast + state1Dim + state2Bright`** — a +dim→bright pulse that rendered as a hard bright→off blink. RP412's `L4MFDVIEW::LampLevel` has the +faithful formula; that is now ours. THREE copies of the decode existed (l4vb16.h, L4GLASSWIN, +L4PADPANEL), all three with the bug; the two locals now forward to the one inline. + ## The pre-mission WAITING SCREEN (2026-07-22) [T2] Every pre-run state (seat wait, roster full, WaitingForEgg, LoadingMission, WaitingForLaunch) paints an animated overlay -- Consolas green text + a 12-segment spinner phased on @@ -199,8 +404,9 @@ copied from them. dead panel: `MechRIOMapper`'s message ids were chained one enum too high (0x19-0x2a) — the binary RIO table @0051dd30 re-registers the BASE aux/zoom ids 3..0x13 (Hotbox 0x1a); fixed ungated + static_assert-locked (`btl4mppr.hpp`) → all 24 MFD-bank + 2 zoom buttons live - (52/72 panel buttons now work; 8 dead await handler reconstruction — Gitea backlog issue; - 12 have no streamed mapping authored, authentically inert). **Keyboard = the ~20 core + (52/72 panel buttons worked at the time; the 8 dead ones are ALL wired as of 2026-07-26 — + `DuckRequest` was the last, see §Dead-button backlog closed; 12 have no streamed mapping + authored, authentically inert). **Keyboard = the ~20 core gameplay actions on the CONTROLS.MAP keys; the PANEL covers every pod address by click** (right-click = hold latch) — the merged default profile lives in `L4PADBINDINGS.cpp` (auto-writes `content\bindings.txt`; W/S/A/D/Q/E/R/F/X drive, 1-4/Space/Ctrl fire, M/N/H/C = @@ -243,8 +449,9 @@ copied from them. (`SVGA16::ExpandPlaneToBGRA`, no D3D) and `StretchDIBits`'d in, so the **whole D3D dev-composite path (dock / separate window / overlay) stands down** while these are up (`BTDrawGaugeInset`/`BTGaugeWindowRenderAndPresent` early-return; no `gBTGaugeDockBottom` - strip). Runtime gate **`BT_GLASS_PANELS`** (default ON under `-platform glass`, `=0` falls - back to the single pad panel + docked gauges) — read by `L4PADRIO` (which panel to create), + strip). Runtime gate **`BT_GLASS_PANELS`** (⚠ was "default ON under `-platform glass`" — NO + LONGER TRUE; the surround is the glass default and panels are explicit-only, see §Layout modes + below) — read by `L4PADRIO` (which panel to create), `L4VB16` (suppress compositing), and `btl4main` (skip the world-window dock strip). The display↔bank map (spine of the feature): Heat 0x28-2F, Mfd2 0x20-27, Comm 0x30-37, Mfd1 0x08-0F, Mfd3 0x00-07, sec 0x10-1B ([[pod-hardware]] §Bank→MFD). Verified: build + diff --git a/docs/CONTROLS.md b/docs/CONTROLS.md new file mode 100644 index 0000000..449f05b --- /dev/null +++ b/docs/CONTROLS.md @@ -0,0 +1,119 @@ +# BattleTech 4.11 — controls reference + +Two ways to fly: the **keyboard/pad** (the ~25 core actions) and the **cockpit panel**, where +every one of the pod's 72 illuminated buttons is mouse-clickable exactly where it sat in the +cabinet. Left-click presses; **right-click latches** a button held. + +Rebind keys in `content\bindings.txt` (delete it to regenerate the defaults). +Cockpit layout and display sizes live in `content\environ.ini` (likewise self-documenting). + +--- + +## Keyboard + +| Key | Action | | Key | Action | +|---|---|---|---|---| +| W/S or ↑/↓ | throttle lever | | X or NumPad5 | all stop | +| A/D or ←/→ | turn pedals | | Alt | reverse thrust | +| Q/E | torso twist | | Shift | throttle up | +| R/F | torso aim | | NumPad 8/2/4/6 | torso aim/twist | +| 1 or Space | main trigger | | 2 | middle thumb | +| 3 or Ctrl | upper thumb (missiles) | | 4 | pinky | +| V | external camera | | B | look behind | +| M | control mode (BAS/MID/ADV) | | N | schematic | +| J/K/L | MFD preset pages | | H (hold) | coolant flush | +| C | cycle a coolant valve | | G (hold) + fire | regroup a weapon | +| − / = | volume down/up | | F5–F9 | generator select / mode | + +**You spawn in BASIC control mode.** In BASIC the stick steers and the pedal keys do nothing. +Press **M** once for MID, where the pedals steer and the stick twists the torso — the full +experience. If "turning doesn't work", this is why. + +Numpad aim keys need **NumLock ON**. + +> Control mode (BAS/MID/ADV, the M key) is *not* the menu's experience setting +> (novice/standard/veteran/expert). That one changes the simulation — heat, damage — not your +> controls. + +--- + +## The cockpit panel — all 72 pod buttons + +Each display carries the bank of buttons mounted around it in the real cabinet. Click anywhere on +a display: the buttons reach *under* the glass, so the whole screen is a press target and only the +lit strip at the edge shows. + +### Heat / coolant display — the loop controls +| Button | Action | +|---|---| +| `0x28` | **Balance coolant** — equalise every condenser valve | +| `0x29`–`0x2F` | the six **condenser valves** (Cond 6…1) and the **coolant flush** (`0x2C`, hold) | + +⚠ The valve detents run 1 - 5 - 50 (max) - **0 (CLOSED)** - back to 1. One press past max shuts +that loop off and everything on it overheats. Boosting one loop starves the others — the supply +is shared. + +### Weapon and engineering displays +| Bank | Display | +|---|---| +| `0x00`–`0x07` | Right Weapons (lower right) | +| `0x08`–`0x0F` | Left Weapons (lower left) | +| `0x20`–`0x27` | Engineering (upper centre) | +| `0x30`–`0x37` | Comm / target hotbox (upper right) — pilot select | + +Each display's buttons are **page-gated**: on the Quad page the outer buttons jump straight to a +populated Eng page; on an Eng page they drive the subsystem shown — generator select A–D, bus +mode, coolant toggle, trigger configure, and **eject** (tap to clear a jam, hold ~3 s to jettison +a burning ammo bay). + +### Map / radar — the side columns +| Button | Action | | Button | Action | +|---|---|---|---|---| +| `0x10` | map zoom in | | `0x18` | cycle control mode (BAS/MID/ADV) | +| `0x11` | map zoom out | | `0x1A`–`0x1D` | generator A–D on/off | +| `0x12` | thermal / IR sight | | `0x16`,`0x17`,`0x19`,`0x1E`,`0x1F` | no function (as shipped in 1995) | +| `0x13` | **crouch** | | | | +| `0x14` | searchlight | | | | +| `0x15` | cycle display mode | | | | + +### Flight controls +| Button | Action | +|---|---| +| `0x3D` | panic / config lamp | +| `0x3F` | throttle head — reverse thrust | +| `0x40` | main trigger | +| `0x41`–`0x44` | hat: behind / torso centre / right / left | +| `0x45`–`0x47` | pinky, middle thumb, upper thumb — the mappable fire buttons | +| `0x38`–`0x3C`, `0x3E` | intercom and door lines — no function on a desktop | + +--- + +## Controllers + +An **Xbox controller** works out of the box. + +**Flight sticks, HOTAS and pedals:** run `joyconfig.bat` once. A wizard asks you to move each +control in turn — stick, twist, throttle, fire buttons — writes the bindings, and drops you into +the solo menu to try them. It only rewrites its own section of `bindings.txt`, so hand edits +elsewhere in the file survive. + +**RGB keyboards** with Windows Dynamic Lighting mirror the panel: any key you have bound to a +lamp button glows in the panel's own colours and flashes in step with the on-screen buttons. Turn +it off with `BT_KEYLIGHT=0` in `environ.ini`. + +--- + +## Cockpit layout + +Set these in `content\environ.ini`: + +- **`BT_MFD_SCALE`** (and `_UL`/`_UC`/`_UR`/`_LL`/`_LR`) — size of the five MFDs, as a percentage + of their pod size. Turn them up if you want to actually read them while you fly. +- **`BT_RADAR_SCALE`**, **`BT_RADAR_POS`** — size the map, and move it out of the middle of the + road: `CENTER`, `LEFT`, `RIGHT`, `MIDLEFT`, `MIDRIGHT`. +- **`BT_GLASS_PANELS=1`** — break every display out into its own desktop window instead of the + composited cockpit. Good for reading one display at full size. + +Run with **`-fit`** for a borderless window over the whole monitor. The cockpit scales to any +window shape at one uniform scale, so nothing ever stretches — a window that isn't the cockpit's +shape just gets black bars. diff --git a/docs/GLASS_COCKPIT.md b/docs/GLASS_COCKPIT.md index a3a2b42..608f496 100644 --- a/docs/GLASS_COCKPIT.md +++ b/docs/GLASS_COCKPIT.md @@ -385,9 +385,10 @@ on the pod monitor. This reuses the plasma-window CPU→window pattern and sides D3D dev-composite bug family (additional-swap-chain lifetime, device-state save/restore, depth-stencil mismatch, layered strobing). -**Gate: `BT_GLASS_PANELS`** (runtime env; `BTGlassPanelsActive()`). Default ON under -`-platform glass` (preset putenv in `btl4main.cpp`); `=0` restores the legacy single pad panel -+ docked gauge strip. Wired at three seams: +**Gate: `BT_GLASS_PANELS`** (runtime env; `BTGlassPanelsActive()`). ⚠ **Was** default-ON under +`-platform glass` (preset putenv in `btl4main.cpp`) — as of 2026-07-26 the COCKPIT SURROUND is the +glass default and this gate is **explicit-only**; the one resolver + the full precedence table are +in `context/glass-cockpit.md` §Layout modes. Wired at three seams: - `L4PADRIO` ctor: `BTGlassPanelsActive()` → `BTGlassPanels_Create()` instead of `BTPadPanel_Create()`; dtor destroys both (safe no-ops). - `L4VB16`: `BTDrawGaugeInset` / `BTGaugeWindowRenderAndPresent` early-return when @@ -439,3 +440,245 @@ Verified after the fix (`BT_GLASS_LOG`): `[glass] 'Heat MFD' port=Heat mask=0x40 enable=2 ow=640 oh=480 nonzero=22227/307200`, `[glass] 'Secondary / Radar' port=sec mask=0x3f pal=1 enable=3 ow=480 oh=640 nonzero=26206/307200` — surfaces resolve and carry live pixels. `BT_GLASS_LOG` left in as a permanent per-surface resolve/pixel diagnostic (throttled). + +## 2026-07-26 — Layout modes get ONE resolver; button geometry gets ONE owner (L4RIOBANK) + +The RP412 replication pass, step 1 of the agreed plan (port the geometry, keep our renderers). + +### The mode resolver (btl4main.cpp) +Where the secondary displays go was decided in TWO places with duplicated precedence -- the glass +profile block picked `BT_PAD_PANEL`/`BT_GLASS_PANELS`, the window-sizing block re-derived +cockpit-vs-dock -- and the boot banner read NEITHER, announcing "per-display cockpit windows +[BT_GLASS_PANELS]" for every glass boot including the surround default. The split had also +broken `BT_COCKPIT=0`: documented as the dock-bottom opt-out, the profile block converted it to +`BT_GLASS_PANELS=1`, so **the docked strip was unreachable under the glass profile**. + +Now: one `glassLayout` resolved after the profile putenvs land, consumed by the banner, the +pad-panel decision and the sizing block. Precedence `BT_GLASS_PANELS` > `BT_DEV_GAUGES_WINDOW` > +`BT_DEV_GAUGES_DOCK` > `BT_COCKPIT=0` > surround default; `BT_GLASS_PANELS` is explicit-only now +(it was auto-set); dock/window auto-raise `BT_PAD_PANEL` so the 72-button field always has a home. +Banner: `[boot] platform profile: GLASS (PadRIO + plasma window) [secondary displays: ]`. +Verified all four modes boot to the mode they name, plus pod/dev. + +### L4RIOBANK -- the shared field +`engine/MUNGA_L4/L4RIOBANK.cpp/.h` (new TU). Both renderers were carrying their own copy of the +button geometry and had drifted: an MFD button was 156x138 reaching under the glass in the +exploded window, and a **76x24 sliver entirely OUTSIDE the glass** in the surround. One module +now owns it, both are consumers, placement stays per-renderer. + +The under-glass rule, from RP412 `L4MFDVIEW` (which took it from the pod): reach half the glass +in behind the display, leave a lamp strip clearing the edge, paint buttons first and imagery +over. Strip scales off the display's SHORT axis vs the native 480 (MFD 640x480 landscape, map +480x640 portrait) with a 6px floor. The MFD per-column nudge against the DISPLAY/PROGRAM legends +moved here so both renderers inherit one alignment. The map's foot row is pushed FIRST because +the side columns now cover that band and the hit test takes the first match. + +Retired: `L4GLASSWIN`'s `PlaceCellAt`/`PlaceLine`/`PlaceRect` and its `RedCellH`/`EdgeProtrude`/ +`RailW`/`RailSlotAdd`/`RailGap`/`RedOffsetX`/`kRedColDX` constants; the surround's inline MFD/ +radar/flight button loops. `kCkRedH`/`kCkLamp` survive only as band reserves for the canvas math. + +### Lamp flash decode [T1 -> fixed] +`BTLampBrightnessOf` returned `max(state1,state2)` and blanked on the alternate phase. Per the +engine's own `RIO::LampState` enum [T0] solid shows state 1 and flashing ALTERNATES state 1 and +state 2. Agrees only when one state is Off -- true for Panic (`flashFast+state1Off+state2Bright`) +which is why it survived -- but `L4LAMP.cpp:252` commands `flashFast+state1Dim+state2Bright`, a +dim->bright pulse that rendered as bright->off. Three copies existed (l4vb16.h, L4GLASSWIN, +L4PADPANEL), all three wrong; the two locals now forward to the one fixed inline. + +### Verification harness (new, reusable) +- `BT_RIOBANK_LOG=1` -- one-shot dump of every bank's buttons + bounds (capped at 16 banks so the + per-frame surround caller prints one pass). +- `scratchpad/checkbank.py ` -- per-bank census + **first-hit reachability**: an address + whose rect is entirely covered by earlier buttons in its bank is dead however big it looks, and + the overlapping under-glass banks make that a live hazard. Reports duplicates and shadowing. +- `scratchpad/clickbank.py --mode surround|exploded` -- posts a real + WM_LBUTTONDOWN/UP at every button's centre; the game's own CLICK forensics prove dispatch. + +Result both modes: **72/72 placed, 0 shadowed, 0 duplicated, 72/72 dispatched**, process survived +144 posted messages each. Pod + dev profiles boot and simulate un-regressed. Surround `BT_SHOT` +confirms the legends are clear and the lamps read as edge strips; exploded `PrintWindow` captures +confirm surfaces still land under their banks. + +## 2026-07-26 (later) — Phase 2: the cockpit scales (uniform-fit letterbox + -fit) + +RP412 replication step 2. The canvas is a FIXED size and D3D9 stretched it into the client, so a +window dragged to a different shape squashed the instruments -- the projection was aspect-corrected +back in task #20, but the panels, lamps and MFD glass never were. Now: one uniform scale, centred, +leftover black. + +**Mechanism.** `BTCockpitFitRect()` (L4VB16.cpp) computes the centred uniform-scale rect in integer +math -- a float scale wobbles by a pixel between frames and the bars shimmer during a drag. D3D9 +applies it as a Present **destination rect**, which `D3DSWAPEFFECT_DISCARD` forbids, so the +WINDOWED swap effect becomes `D3DSWAPEFFECT_COPY` when the surround is up and multisampling is off +(COPY cannot multisample). `gBTCockpitLetterbox` records the outcome; 0 = the old full-client +stretch, which is what every non-cockpit mode and any MSAA run still gets. + +Wired at five seams: +- `L4VIDEO` device setup: swap effect + confirm/withdraw the flag. +- Both Present sites: the scene present AND the wait-screen overlay present (it paints into the + same backbuffer, so it has to land in the same rect). +- `btl4main` WM_SIZE: `InvalidateRect` -- a COPY present only writes its dest rect, so stale bar + content persists without an erase (the class brush is black). +- `BTCockpitMouseDown`: client->canvas through the SAME fit. Mapping against the full client + drifts the hit test off every button by the bar width; a click on a bar now hits nothing. +- `BTWorldAspectOf`: under the letterbox the view's on-screen aspect is its OWN -- the client + drops out of the formula entirely. + +**`-fit` / `-windowed-fullscreen`** (btl4main): borderless `WS_POPUP` over the monitor rect, canvas +letterboxed inside. Unknown tokens fall through `L4Application::ParseCommandLine`'s ladder to +`return True`, so no arg-parser change was needed. + +### Ordering trap (found live, fixed) +The letterbox flag was originally set at device creation -- but **the first WM_SIZE beats the +device**. A `-fit` boot on the 3440x1440 ultrawide logged `[resize] client 3440x1440 aspect=3.14` +(the stretch formula), and since nothing resizes the window again, the per-frame +`gWindowAspect != appliedAspect` check applied it on frame 1. The INTENT is now decided in +btl4main alongside `gBTGaugeCockpit` (windowed cockpit + MULTISAMPLE unset/0); L4VIDEO only +confirms it or withdraws it. Re-verified: the boot line reads `aspect=1.8`. + +### Capture note (for whoever verifies this next) +`PrintWindow` returns an all-black frame for the borderless `-fit` window, while working fine for +the normal chrome'd window. Use a screen-region grab (`scratchpad/shot.ps1`) for `-fit`. The +all-black PrintWindow is NOT a render failure -- the log showed 18 sim ticks and the screen grab +showed the full cockpit. + +### Verified +- Wide drag 1684x661 and tall drag 744x961: canvas uniformly scaled and centred, bars on the long + axis, ZERO distortion (captures `fit_wide.png` / `fit_tall.png`). +- `-fit` on 3440x1440: borderless, centred, bars left/right, undistorted. +- **72/72 buttons still dispatch after a resize** -- clickbank.py now maps canvas->client through + its own copy of the fit (an independent check that the two transforms agree): at fit 906x661 + with a 389px left bar, all 72 `[cockpit] CLICK` lines land. +- exploded / dock / pod / dev: DISCARD retained, boot + simulate un-regressed. + +## 2026-07-26 (later still) — Phase 3: player-tunable displays + the settings file + +RP412 replication step 3. + +### Scaling (BTCkResolvedSizes, L4VB16.cpp) +`BT_MFD_SCALE` (all five), `BT_MFD_SCALE_UL/_UC/_UR/_LL/_LR` (per display, overriding the group), +`BT_RADAR_SCALE` -- percentages of the pod size, 25-200, resolved ONCE (the surround recomputes its +layout every frame, so reading the env per frame would be silly). + +The load-bearing consequence: **the surround BANDS derive from the resolved sizes**. That is why +the sizes could not stay compile-time constants -- the band a display hangs in has to grow with it +or the canvas clips it. Each band is what its largest occupant needs; a corner MFD only claims +`size - kCkOVL` because it overlaps the view; the bottom band drops to just the lower MFDs when the +map leaves that row for a side. Verified 100% reproduces the historical `L276 R276 T223 B336` +byte-for-byte, 150% gives `L436 R436 T343 B448`, 80%+MIDLEFT gives `L256 R212 T175 B148`. + +The flight label blocks now hang off their OWN lower MFD's scaled height + lamp strip, instead of +the constant -- a shrunken MFD used to leave the block floating in the gap and an enlarged one to +overlap it. + +### Map placement (BT_RADAR_POS) +CENTER (default) / LEFT / RIGHT / MIDLEFT / MIDRIGHT, with the BOTTOM*/`*CENTER` spellings RP +accepts. Bottom corner: the map goes flush to the canvas edge and the lower MFD whose corner it +took slides beside it. Halfway up a side: it leaves the bottom row entirely and the bottom band +shrinks accordingly. + +⚠ **First cut was wrong and the capture caught it:** the corner map was placed relative to the VIEW +edge, which overlapped the neighbouring MFD by the band width -- 232px of map drawn over the Right +Weapons display. Corner placement is measured off the CANVAS edge. + +### The map legend grid -- measured, not inherited +The side columns have to line up with the six cells the map imagery paints beside them. Zooming a +native Secondary/Radar capture shows those cells are exactly our address map: MAP+ 0x10, MAP- 0x11, +IR 0x12 (ThermalSight lamp), crouch 0x13 (DuckRequest), searchlight 0x14, display-mode 0x15 -- the +art independently corroborating [[pod-hardware]]. + +`scratchpad/measurelegend.py` scans the capture for the legend's vertical rule and for our lamp +bars, and reports both. Ours: **top 3, cell 102, pitch 107** of the 640 span. RP412's map: +`13 + 6x102 @ 105`. Same cell height, different top and pitch -- confirming the deferral call in +Phase 1 was right, the numbers do NOT transfer. Our old even division (`displayH/6 + 1`) had the +pitch right by luck and sat 3px high of the labels; the grid is now pinned to the measurement, with +top and bottom scaled SEPARATELY and subtracted (RP's trick) so rounding cannot walk the buttons +out of step with the labels down a scaled column. + +### environ.ini +Two defects, both fixed: +1. **Read far too late.** It sat ~300 lines into WinMain, after the platform-profile block had + already run its getenv()s -- so every setting the profile reads (BT_PLATFORM, BT_COCKPIT, + BT_GLASS_PANELS, BT_DEV_GAUGES, L4CONTROLS...) was silently ignored FROM THE FILE and only ever + worked as a real environment variable. Now loaded immediately after the first-breath boot line. +2. **putenv()'d comments verbatim**, so a commented-out option would have become an environment + variable literally named `#BT_MFD_SCALE`. Comments (`#`, `;`) and blanks are skipped, and lines + with no `=` are counted and ignored. + +Added: the real environment WINS over the file (a launcher .bat overrides rather than fights), and +a fully documented default is written on first run when the file is absent -- the bindings.txt +convention (untracked via .gitignore, so extract-over-top never clobbers a player's settings; every +option ships commented out so a fresh install behaves exactly as before). + +Verified live, all three paths: first run writes 3886 bytes and applies `0 setting(s)`; uncommenting +`BT_MFD_SCALE=130` + `BT_MFD_SCALE_UC=60` + `BT_RADAR_POS=MIDRIGHT` in the FILE drives the layout +(`displays UL 130% UC 60% ... radar on the right side, centred`); and `set BT_MFD_SCALE=90` beats +the file's 130 while the file's other two settings still apply. + +### Verified +- 72/72 placed, 0 shadowed, 72/72 dispatched at baseline, at 150%/135%, and at 75% + BOTTOMRIGHT. +- exploded / dock / pod / dev boot and simulate un-regressed. +- Captures: p3_big2 (150%), p3_radleft2 (bottom-left + slid MFD), p3_radmidl (side-mounted map at + 80%), p3_fromini (settings taken from the file). + +## 2026-07-26 (final) — Phases 4-6: the dead-button backlog, the lamp mirror, the shipped doc + +### Phase 4 — Mech::DuckRequest, and a census that lied in both directions +The 2026-07-20 audit listed 8 buttons with no reconstructed handler. **Only ONE was still +missing.** Generator on/off (`powersub.cpp`), ToggleSeekVoltage (`emitter.cpp`), EjectAmmo +(`projweap.cpp`), ToggleCooling (`heatfamily_reslice.cpp`) and BalanceCoolant (`mech.cpp`) had all +landed between 07-20 and 07-25 while `pod-hardware.md` and `open-questions.md` still called them +dead. `docs/INPUT_PATH_AUDIT.md:190` had already flagged this ("census stale in both directions") +and was right. Rule for next time: **grep the reconstruction, not the prose.** + +`Mech::DuckRequest` @0049fa00 (id 0x1a, RIO 0x13 -- the manual's CROUCH button). Binary body, in +full: + + if (0 < *(int *)(param_2 + 0xc)) { *(undefined4 *)(param_1 + 0x398) = 1; } + +Press-only; sets `duckState` (`mech+0x398`, attribute 0x37). A one-shot REQUEST flag -- the +handler never clears it and the only other writer in the entire binary is the mech reset +(part_012.c:9439, the same reset that zeroes `incomingLock`, which is how that region was already +mapped). Registered in `Mech::MessageHandlerEntries`; `[duck]` log on press. + +**Why the flag has no reader, and why that is correct.** `duckState` has ZERO readers anywhere in +the decomp. It is published as an ATTRIBUTE, so its consumer is a databinding: +`content/GAUGE/L4GAUGE.CFG` runs `oneOfSeveralPixInt(E, ModeAlwaysActive, bduck.pcc, 3, 1, +DuckState)` -- "crouch mode: button 4" on the map's legend column. Verified live by capturing the +Secondary/Radar window before and after a 0x13 press: the crouch icon goes grey -> orange. So the +handler is COMPLETE; a crouch pose invented here would be a stand-in for data we have not found +(no SQUAT clip name survives in the decomp or in content/, only DuckServo01.wav in AUDIO1.RES). + +Bonus: those gauge widgets sit at offsets 537/430/322/215/108 -- a **107 pitch**, independently +corroborating the map legend grid measured from pixels in Phase 3. + +Still open from that census: MechRIOMapper's own Keypress @004d2514 (id 0x19). + +### Phase 5 — L4KEYLIGHT (RGB keyboard lamp mirror) +Ported from RP412 (itself a port of vRIO's KeyboardLampMirror). Keys bound to a lamp address in +bindings.txt glow with the panel palette -- yellow for the map's side columns (0x10-0x1F), red for +the rest -- flashing in step with the on-screen buttons (the LampLevel copy matches the FIXED +BTLampBrightnessOf from Phase 1). Per-key boards light each bound key; zone-lit boards mirror the +strongest lamp board-wide. All WinRT runs on a private worker thread (watcher, claim, 100 ms paint +loop, repaint only on change). + +**The packing hazard did NOT transfer.** RP412 compiles this file with forced default struct +packing because its engine is `/Zp1`, which would break the WinRT ABI. BT411's `BT_OPTS` is +`/permissive /W0 /wd4996 /EHsc /bigobj /MP` -- no `/Zp` -- so only the dialect flags are needed: +`set_source_files_properties(... COMPILE_OPTIONS "/std:c++17;/permissive-")`, since the project +otherwise builds C++14 /permissive. The scalars-only interface is kept regardless. + +Wired in `PadRIO`: map built from `bindings.keyBindings` (ActionButton binds only, first binding +per key wins, addresses < LampCount), fed from `PadRIO::SetLamp`, stopped in the dtor (which hands +the LEDs back to Windows). Gate `BT_KEYLIGHT=0`. + +Verified live: `[keylight] mirroring 25 bound key(s)` + `+ LAPTOP-767CF84B (24 zones - board-wide +mirror)` -- it claimed this machine's real keyboard. `BT_KEYLIGHT=0` -> zero keylight lines, game +fine. Pod profile (no PadRIO) -> zero lines. Exploded -> works. + +### Phase 6 — the shipped controls reference +`docs/CONTROLS.md`: the keyboard table plus **the full 72-button pod map**, grouped by the display +each bank surrounds, with the coolant-valve detent warning and the eject/jam procedure. mkdist +flattens it to ASCII as `CONTROLS.txt` at the zip root (the README's own flattening idiom). +`players/README.txt` gained pointers to it, to environ.ini, to -fit, and to the RGB mirror. +Verified: `mkdist.py` writes a 5149-byte pure-ASCII CONTROLS.txt into the zip. diff --git a/engine/MUNGA_L4/L4KEYLIGHT.cpp b/engine/MUNGA_L4/L4KEYLIGHT.cpp new file mode 100644 index 0000000..d08aee7 --- /dev/null +++ b/engine/MUNGA_L4/L4KEYLIGHT.cpp @@ -0,0 +1,406 @@ +//===========================================================================// +// L4KEYLIGHT.cpp - Windows Dynamic Lighting keyboard mirror. +// +// Ported from RP412's L4KEYLIGHT (itself a port of vRIO's +// KeyboardLampMirror). Keys bound to a lamp address in bindings.txt glow +// with the panel palette, so the keyboard reads as the pod's button field. +// +// COMPILED APART FROM THE REST: /std:c++17 + conformance mode, per-file in +// CMakeLists (C++/WinRT needs both; the project builds C++14 /permissive). +// NOTE: RP412 also had to force DEFAULT struct packing here because its +// engine compiles /Zp1, which would break the WinRT ABI -- BT411 does NOT +// set /Zp (checked 2026-07-26: BT_OPTS is /permissive /W0 /wd4996 /EHsc +// /bigobj /MP), so that hazard does not apply. The scalars-only interface +// in l4keylight.h is kept anyway: it costs nothing and keeps this TU +// isolated if packing is ever added. +// +// Everything WinRT runs on a private worker thread: device watcher, +// keyboard claiming, and a 100 ms paint loop that mirrors the lamp bytes +// (the same flash formula as BTLampBrightnessOf, so the board and the +// on-screen cockpit buttons blink in step). +//===========================================================================// + +#include <windows.h> + +#include <atomic> +#include <mutex> +#include <thread> +#include <vector> +#include <cstring> +#include <cstdio> + +#include <winrt/base.h> +#include <winrt/Windows.Foundation.h> +#include <winrt/Windows.Foundation.Collections.h> +#include <winrt/Windows.Devices.Enumeration.h> +#include <winrt/Windows.Devices.Lights.h> +#include <winrt/Windows.System.h> +#include <winrt/Windows.UI.h> + +#pragma comment(lib, "windowsapp.lib") + +#include "l4keylight.h" + +namespace +{ + using namespace winrt; + using namespace winrt::Windows::Devices::Enumeration; + using namespace winrt::Windows::Devices::Lights; + using winrt::Windows::UI::Color; + using winrt::Windows::System::VirtualKey; + + struct KeyEntry + { + int virtualKey; + int address; + bool yellow; + }; + + struct ClaimedArray + { + hstring id; + LampArray array{ nullptr }; + bool perKey = false; + bool baseCoated = false; + }; + + std::mutex gLock; + std::vector<KeyEntry> gMap; + unsigned char gLamps[64] = {}; + void (*gLogger)(const char *) = nullptr; + + std::atomic<bool> gRunning{ false }; + std::thread gWorker; + + void Log(const char *line) + { + void (*logger)(const char *); + { + std::lock_guard<std::mutex> hold(gLock); + logger = gLogger; + } + if (logger != nullptr) + { + logger(line); + } + } + + void Logf(const char *format, ...) + { + char line[256]; + va_list args; + va_start(args, format); + _vsnprintf_s(line, sizeof(line), _TRUNCATE, format, args); + va_end(args); + Log(line); + } + + //--------------------------------------------------------------- + // Lamp byte -> brightness level, animating the flash modes. + // MUST match BTLampBrightnessOf (l4vb16.h) exactly, so the board and + // the on-screen buttons blink together: solid shows state 1, flashing + // ALTERNATES state 1 and state 2 (RIO::LampState, L4RIO.h). Copied + // rather than included because this TU takes no engine headers. + //--------------------------------------------------------------- + int LampLevel(int lamp_state) + { + int mode = lamp_state & 0x03; + int level1 = (lamp_state >> 2) & 0x03; + int level2 = (lamp_state >> 4) & 0x03; + + if (mode == 0) + { + return level1; + } + + static const int half_period[4] = { 0, 500, 250, 125 }; + return ((GetTickCount() / half_period[mode]) & 1) ? level2 : level1; + } + + //--------------------------------------------------------------- + // The panel palette (vRIO's KeyboardLampMirror shades): red for + // the banks, yellow for Secondary/Screen; the off shade keeps the + // bound keys faintly visible so the board reads as a button field. + //--------------------------------------------------------------- + Color Shade(int level, bool yellow) + { + Color color; + color.A = 255; + if (yellow) + { + if (level >= 3) { color.R = 245; color.G = 210; color.B = 60; } + else if (level >= 1) { color.R = 140; color.G = 118; color.B = 38; } + else { color.R = 70; color.G = 60; color.B = 24; } + } + else + { + if (level >= 3) { color.R = 230; color.G = 70; color.B = 70; } + else if (level >= 1) { color.R = 120; color.G = 50; color.B = 50; } + else { color.R = 64; color.G = 40; color.B = 40; } + } + return color; + } + + bool SameColor(const Color &a, const Color &b) + { + return a.A == b.A && a.R == b.R && a.G == b.G && a.B == b.B; + } + + //--------------------------------------------------------------- + // The worker: watcher + claim + paint loop + //--------------------------------------------------------------- + void Worker() + { + try + { + init_apartment(); + } + catch (...) + { + // apartment already set on this thread; carry on + } + + std::mutex claimedLock; + std::vector<ClaimedArray> claimed; + bool anySeen = false; + + DeviceWatcher watcher{ nullptr }; + try + { + watcher = DeviceInformation::CreateWatcher(LampArray::GetDeviceSelector()); + + watcher.Added([&](DeviceWatcher const &, DeviceInformation const &info) + { + try + { + LampArray array = LampArray::FromIdAsync(info.Id()).get(); + if (array == nullptr || + array.LampArrayKind() != LampArrayKind::Keyboard) + { + return; // mice / strips / cases stay untouched + } + anySeen = true; + ClaimedArray entry; + entry.id = info.Id(); + entry.array = array; + entry.perKey = array.SupportsVirtualKeys(); + { + std::lock_guard<std::mutex> hold(claimedLock); + claimed.push_back(entry); + } + Logf(entry.perKey + ? "KeyLight: + %ls (%d LEDs, per-key)" + : "KeyLight: + %ls (%d zones - board-wide mirror)", + info.Name().c_str(), (int) array.LampCount()); + } + catch (...) + { + Log("KeyLight: could not open a lamp array"); + } + }); + watcher.Removed([&](DeviceWatcher const &, DeviceInformationUpdate const &update) + { + std::lock_guard<std::mutex> hold(claimedLock); + for (size_t i = 0; i < claimed.size(); ++i) + { + if (claimed[i].id == update.Id()) + { + claimed.erase(claimed.begin() + i); + Log("KeyLight: keyboard disconnected"); + break; + } + } + }); + watcher.Updated([](DeviceWatcher const &, DeviceInformationUpdate const &) + { + // required for the watcher to progress + }); + watcher.Start(); + } + catch (...) + { + Log("KeyLight: Dynamic Lighting unavailable on this system"); + return; + } + + // + // Paint loop: 100 ms cadence, repaint only on change + // + std::vector<Color> lastColors; + int waited = 0; + while (gRunning.load()) + { + Sleep(50); + waited += 50; + if (waited < 100) + { + continue; + } + waited = 0; + + std::vector<KeyEntry> map; + unsigned char lamps[64]; + { + std::lock_guard<std::mutex> hold(gLock); + map = gMap; + memcpy(lamps, gLamps, sizeof(lamps)); + } + + std::vector<Color> colors(map.size()); + std::vector<VirtualKey> keys(map.size()); + int bestLevel = 0; + bool bestYellow = false; + bool changed = (lastColors.size() != map.size()); + for (size_t i = 0; i < map.size(); ++i) + { + int address = map[i].address; + int level = (address >= 0 && address < 64) + ? LampLevel(lamps[address]) : 0; + if (level > bestLevel) + { + bestLevel = level; + bestYellow = map[i].yellow; + } + colors[i] = Shade(level, map[i].yellow); + keys[i] = (VirtualKey) map[i].virtualKey; + if (!changed && !SameColor(colors[i], lastColors[i])) + { + changed = true; + } + } + + std::lock_guard<std::mutex> hold(claimedLock); + bool freshClaim = false; + for (ClaimedArray &entry : claimed) + { + if (!entry.baseCoated) + { + freshClaim = true; + } + } + if (!changed && !freshClaim) + { + continue; + } + + Color aggregate = Shade(bestLevel, bestYellow); + for (ClaimedArray &entry : claimed) + { + try + { + if (entry.perKey) + { + if (!entry.baseCoated) + { + Color black; + black.A = 255; black.R = 0; black.G = 0; black.B = 0; + entry.array.SetColor(black); + entry.baseCoated = true; + } + if (!map.empty()) + { + entry.array.SetColorsForKeys( + array_view<Color const>(colors.data(), colors.data() + colors.size()), + array_view<VirtualKey const>(keys.data(), keys.data() + keys.size())); + } + } + else + { + entry.baseCoated = true; + entry.array.SetColor(aggregate); + } + } + catch (...) + { + // device wobble; the watcher handles removal + } + } + lastColors = colors; + } + + // + // Releasing the arrays hands the LEDs back to Windows + // + try + { + watcher.Stop(); + } + catch (...) + { + } + { + std::lock_guard<std::mutex> hold(claimedLock); + claimed.clear(); + } + if (!anySeen) + { + Log("KeyLight: no Dynamic Lighting keyboard was found this session"); + } + } +} + +//######################################################################## +// The scalar interface (safe across the packing boundary) +//######################################################################## + +void + KeyLight_SetLogger(void (*logger)(const char *line)) +{ + std::lock_guard<std::mutex> hold(gLock); + gLogger = logger; +} + +void + KeyLight_SetMap( + const int *virtual_keys, + const int *addresses, + const unsigned char *yellow, + int count + ) +{ + std::lock_guard<std::mutex> hold(gLock); + gMap.clear(); + gMap.reserve(count); + for (int i = 0; i < count; ++i) + { + KeyEntry entry; + entry.virtualKey = virtual_keys[i]; + entry.address = addresses[i]; + entry.yellow = (yellow[i] != 0); + gMap.push_back(entry); + } +} + +void + KeyLight_UpdateLamps(const unsigned char *lamp_state, int count) +{ + if (count > 64) + { + count = 64; + } + std::lock_guard<std::mutex> hold(gLock); + memcpy(gLamps, lamp_state, count); +} + +void + KeyLight_Start() +{ + if (gRunning.exchange(true)) + { + return; + } + gWorker = std::thread(Worker); +} + +void + KeyLight_Stop() +{ + if (!gRunning.exchange(false)) + { + return; + } + if (gWorker.joinable()) + { + gWorker.join(); + } +} diff --git a/engine/MUNGA_L4/L4PADRIO.cpp b/engine/MUNGA_L4/L4PADRIO.cpp index c7cca9a..fabd8d6 100644 --- a/engine/MUNGA_L4/L4PADRIO.cpp +++ b/engine/MUNGA_L4/L4PADRIO.cpp @@ -12,6 +12,7 @@ #include "l4glasswin.h" #include "l4ctrl.h" #include "l4joy.h" +#include "l4keylight.h" // RGB keyboard lamp mirror (scalars-only interface) #include <windows.h> #include <xinput.h> @@ -63,6 +64,17 @@ int // XInput normalization: thumbs to -1..1 past the stock deadzone, triggers // to 0..1 past the stock threshold. // +// +// The lamp mirror's log sink. It cannot use DEBUG_STREAM itself: its TU +// takes no engine headers (l4keylight.h is scalars only), so it hands us +// finished lines instead. +// +static void + KeyLightLog(const char *line) +{ + DEBUG_STREAM << "[keylight] " << (line ? line : "") << "\n" << std::flush; +} + static float NormalizeThumb(int value, int dead_zone) { @@ -170,6 +182,67 @@ PadRIO::PadRIO(): flipStickAxes = (getenv("L4PADFLIP") != NULL && *getenv("L4PADFLIP") != '0'); + // + // RGB KEYBOARD LAMP MIRROR (Windows Dynamic Lighting, l4keylight.h): + // every key bound to a lamp ADDRESS glows with the panel palette -- + // yellow for the Secondary/Screen columns (0x10-0x1F), red for the rest, + // exactly like the on-screen buttons. Keypad binds have no lamp, so + // they are skipped; a key bound twice takes its FIRST binding. + // BT_KEYLIGHT=0 opts out; a machine without Dynamic Lighting logs once + // and stays dormant. + // + keyLightActive = False; + { + const char *gate = getenv("BT_KEYLIGHT"); + if (gate == NULL || atoi(gate) != 0) + { + static int lightKeys[192]; + static int lightAddresses[192]; + static unsigned char lightYellow[192]; + int count = 0; + for (int k = 0; k < bindings.keyBindingCount && count < 192; ++k) + { + const PadBindingProfile::KeyBinding &binding = bindings.keyBindings[k]; + if (binding.action.kind != PadBindingProfile::ActionButton) + { + continue; // axes/keypads carry no lamp + } + int address = binding.action.address; + if (address < 0 || address >= LampCount) + { + continue; + } + Logical duplicate = False; + for (int j = 0; j < count; ++j) + { + if (lightKeys[j] == binding.virtualKey) + { + duplicate = True; // first binding wins + break; + } + } + if (duplicate) + { + continue; + } + lightKeys[count] = binding.virtualKey; + lightAddresses[count] = address; + lightYellow[count] = (unsigned char) + ((address >= 0x10 && address <= 0x1F) ? 1 : 0); + ++count; + } + if (count > 0) + { + KeyLight_SetLogger(KeyLightLog); + KeyLight_SetMap(lightKeys, lightAddresses, lightYellow, count); + KeyLight_Start(); + keyLightActive = True; + DEBUG_STREAM << "[keylight] mirroring " << count + << " bound key(s) onto RGB keyboards\n" << std::flush; + } + } + } + // // Never revision 0.0 -- some diagnostics print it; give the synthetic // board a recognizable version. @@ -199,6 +272,11 @@ PadRIO::PadRIO(): PadRIO::~PadRIO() { + if (keyLightActive) + { + KeyLight_Stop(); // hands the LEDs back to Windows + keyLightActive = False; + } BTGlassPanels_Destroy(); // safe no-op if the glass windows were never created BTPadPanel_Destroy(); if (activeInstance == this) @@ -1038,6 +1116,18 @@ void if (lampNumber >= 0 && lampNumber < LampCount) { lampState[lampNumber] = state; + if (keyLightActive) + { + // The mirror keeps its own copy (its paint loop runs on a private + // thread and must not reach into the device); the first 64 + // addresses are the whole lamp field. + unsigned char bytes[64]; + for (int i = 0; i < 64; ++i) + { + bytes[i] = (unsigned char) lampState[i]; + } + KeyLight_UpdateLamps(bytes, 64); + } } } diff --git a/engine/MUNGA_L4/L4PADRIO.h b/engine/MUNGA_L4/L4PADRIO.h index db441e8..b8b84b2 100644 --- a/engine/MUNGA_L4/L4PADRIO.h +++ b/engine/MUNGA_L4/L4PADRIO.h @@ -143,6 +143,11 @@ protected: int lampState[LampCount]; + // RGB keyboard lamp mirror running? (l4keylight.h; BT_KEYLIGHT=0 off, + // also stays False when no bound key carries a lamp address) + Logical + keyLightActive; + // // Typed-channel suppression tables (built from the loaded bindings + // the hardcoded view/preset keys; see SuppressKey). diff --git a/engine/MUNGA_L4/l4keylight.h b/engine/MUNGA_L4/l4keylight.h new file mode 100644 index 0000000..2476d0d --- /dev/null +++ b/engine/MUNGA_L4/l4keylight.h @@ -0,0 +1,53 @@ +//===========================================================================// +// File: l4keylight.h // +// Project: MUNGA Brick: RGB keyboard lamp mirror // +// Contents: Windows Dynamic Lighting bridge (vRIO's KeyboardLampMirror) // +//---------------------------------------------------------------------------// +// Copyright (C) 1994-1995, Virtual World Entertainment, Inc. // +// PROPRIETARY AND CONFIDENTIAL // +//===========================================================================// + +#pragma once + +//######################################################################## +// Mirrors the game-commanded RIO lamp states onto per-key RGB keyboards +// through Windows Dynamic Lighting (the WinRT LampArray API), following +// vRIO's KeyboardLampMirror: keys bound to lamp addresses glow with the +// panel palette (red for the banks, yellow for the Secondary/Screen +// columns), flash modes blink at the panel's rates, other keys are +// blacked out so the keyboard reads as the button field. Zone-lit +// keyboards mirror the strongest lamp board-wide. Dynamic Lighting +// grants LED control to the FOREGROUND app - which is the game itself, +// so no Windows settings dance is needed. +// +// The implementation is C++/WinRT on a private worker thread, compiled +// with default struct packing (the engine builds /Zp1, which would +// break the WinRT ABI) - hence this interface is scalars only. All +// functions are safe to call when Dynamic Lighting is unavailable; +// failures log once and the mirror stays dormant. +//######################################################################## + +// Where the mirror's status lines go (the caller owns the sink). +void + KeyLight_SetLogger(void (*logger)(const char *line)); + +// The key map: parallel arrays of virtual keys, their RIO lamp +// addresses (0x00-0x47), and whether each paints yellow (Secondary / +// Screen columns) instead of red. +void + KeyLight_SetMap( + const int *virtual_keys, + const int *addresses, + const unsigned char *yellow, + int count + ); + +// Latest game-commanded lamp bytes (indexed by RIO lamp number). +void + KeyLight_UpdateLamps(const unsigned char *lamp_state, int count); + +// Claim keyboards and start painting / release the LEDs to Windows. +void + KeyLight_Start(); +void + KeyLight_Stop(); diff --git a/players/README.txt b/players/README.txt index 91377ab..0ff9e82 100644 --- a/players/README.txt +++ b/players/README.txt @@ -104,6 +104,18 @@ control (stick, twist, throttle, fire buttons), writes the bindings, and drops you into the solo menu to try them. Rebind keys in content\bindings.txt (regenerates with defaults if deleted). +FULL CONTROLS REFERENCE: see CONTROLS.txt next to this file -- every one of +the pod's 72 cockpit buttons, what it does, and which display it sits on. + +COCKPIT LAYOUT: content\environ.ini (written on first run, every option +documented in the file itself) sizes the MFDs and the map, moves the map out +of the middle of the road, or breaks each display into its own window. The +cockpit now scales to ANY window shape without distorting -- drag it, maximise +it, or launch with -fit for borderless full screen. + +RGB KEYBOARDS: if yours supports Windows Dynamic Lighting, the keys you have +bound to cockpit buttons glow in the panel's colours and flash along with it. + This build: {VERSION}. Private -- do not redistribute. {EXPIRE}Test builds STOP WORKING 14 days after they are made -- if the game {EXPIRE}says it has expired, just grab the operator's newest zip. diff --git a/tools/mkdist.py b/tools/mkdist.py index 674469d..88902be 100644 --- a/tools/mkdist.py +++ b/tools/mkdist.py @@ -119,6 +119,22 @@ def main(): lines = [l for l in lines if "{EXPIRE}" not in l] readme = "".join(lines) z.writestr(name + "/README.txt", readme) + + # The full controls reference travels with the game, so players get the + # 72-button panel map without the repo. Flattened to ASCII so it reads + # correctly in Notepad -- the markdown source keeps its typography. + if os.path.exists("docs/CONTROLS.md"): + controls = open("docs/CONTROLS.md", encoding="utf-8").read() + for bad, good in ((u"—", "-"), (u"–", "-"), (u"‘", "'"), + (u"’", "'"), (u"“", '"'), (u"”", '"'), + (u"×", "x"), (u"→", "->"), (u"…", "..."), + (u"↑", "Up"), (u"↓", "Down"), + (u"←", "Left"), (u"→", "Right"), + (u"⚠", "!"), (u"·", "-")): + controls = controls.replace(bad, good) + controls = controls.encode("ascii", "replace").decode("ascii") + z.writestr(name + "/CONTROLS.txt", controls) + print(" controls reference -> CONTROLS.txt") for p in bats: # bats at the zip ROOT (next to arc = os.path.basename(p) # content\ + build\, per the guard) z.write(p, name + "/" + arc) From 8408165d92516075809794fbe2a607bdc8141547 Mon Sep 17 00:00:00 2001 From: Cyd <cyd@falloutshelterarcade.com> Date: Sun, 26 Jul 2026 02:41:46 -0500 Subject: [PATCH 4/9] Find content\ ourselves: a bare exe launch no longer scatters config and dies Field report: `.\btl4.exe -fit` from build\Release opens the mission console, then the mission crashes on Launch. ROOT CAUSE (pre-existing landmine, newly reachable). 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 never showed. A bare launch of the exe therefore 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 had never been created -- and killed the mission generation the menu launched (the child inherits the parent's cwd). Recent work made that path inviting rather than obscure: glass is the desktop default, a zero-arg launch opens the menu, and -fit is documented as something you pass on the command line. FIX: BTEnsureContentDirectory() runs before anything resolves a relative path (the log file included, so a bare launch logs where the launchers log). If cwd has no BTL4.RES, probe from the exe's own directory -- ..\..\content for the shipped layout, plus the flattened and in-content cases -- and SetCurrentDirectory there. Says so in the boot line when it had to go looking. Belt and braces: the environ.ini writer refuses to write unless BTL4.RES is beside it, so a failed probe cannot scatter a settings file either. ALSO: -fit was silently dropped when the menu relaunched into a mission. The front end rebuilds the child's command line from scratch, and the flag was parsed at window creation -- code the menu process exits long before reaching. Parsed early into gBTFitDisplay now, and appended to the relaunch. Verified: bare `btl4.exe -fit` from build\Release leaves ZERO files next to the exe, writes environ.ini + btl4.log into content\, and the mission child's exact command line (-net 1501 -platform glass) from that same wrong cwd now logs "cwd: found the content directory from the exe path", loads resources and enters RunMissions instead of dying. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- game/btl4main.cpp | 108 +++++++++++++++++++++++++++++++++++-- game/glass/btl4console.cpp | 17 ++++++ 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/game/btl4main.cpp b/game/btl4main.cpp index 049e8cb..3d60afd 100644 --- a/game/btl4main.cpp +++ b/game/btl4main.cpp @@ -342,6 +342,80 @@ static const char *kEnvironIniDefault = "# 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; @@ -350,6 +424,14 @@ static void BTWriteDefaultEnvironIni(void) 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; @@ -424,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. @@ -475,10 +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 @@ -1078,10 +1180,8 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine // 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. // - int fitDisplay = 0; - if (lpCmdLine && (strstr(lpCmdLine, "-fit") != 0 || - strstr(lpCmdLine, "-windowed-fullscreen") != 0)) - fitDisplay = 1; + extern int gBTFitDisplay; // parsed early (the menu exits before here) + int fitDisplay = gBTFitDisplay; RECT wr = { 0, 0, winW, winH }; AdjustWindowRect(&wr, WS_OVERLAPPEDWINDOW, 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; From 78a98dafc790f0a2345a7f772f9c9feee353cb84 Mon Sep 17 00:00:00 2001 From: Cyd <cyd@falloutshelterarcade.com> Date: Sun, 26 Jul 2026 02:42:32 -0500 Subject: [PATCH 5/9] KB: record the cwd landmine + why the folder layout differs from RP/4.10 The 1995 pod and RP411/RP412 ship ONE folder (BTL4OPT.EXE sits beside BTL4.RES to this day, in content\); BT411's build\Release + content\ split is an artifact of mkdist zipping tracked repo paths, not a decision -- and it is the reason this class of bug exists here and nowhere else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- context/build-and-run.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/context/build-and-run.md b/context/build-and-run.md index 3bafd05..ac4d686 100644 --- a/context/build-and-run.md +++ b/context/build-and-run.md @@ -39,6 +39,23 @@ 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=<file>`). [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. - 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 From a7bcf1cbabc2549d984663be29c2dcb888fc7bbc Mon Sep 17 00:00:00 2001 From: Cyd <cyd@falloutshelterarcade.com> Date: Sun, 26 Jul 2026 02:49:30 -0500 Subject: [PATCH 6/9] Proposal: ship the authentic single-folder dist layout (docs only, NOT implemented) The bare-launch crash was a symptom; the cause is that BT411 hands players a developer tree. mkdist zips tracked repo paths verbatim, so the dist inherited build\Release\btl4.exe + content\ -- while the 1995 pod (BTL4OPT.EXE sits beside BTL4.RES to this day, in content\) and RP411/RP412 all ship ONE folder, where cwd is right by construction and a double-click just works. docs/DIST_LAYOUT_PLAN.md sets out the proposed tree, the file-by-file change (~50 lines total, no game code), what it buys, and the part that actually needs a decision: the upgrade story. Four migration options with a recommendation, plus the wrinkle that decides between them -- the zip root is VERSIONED, so every version may already land in its own folder, in which case the documented extract-over-top has never worked and the migration cost is near zero. Four open questions listed for the other author, including whether the .bat launchers still earn their keep and whether the pod cabinet's own install shape should be confirmed against Nick's notes before we call one folder authentic for the cabinet too. Not implemented pending that discussion. The 4.11.560 cwd guard stands either way and makes both layouts work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CLAUDE.md | 2 +- context/build-and-run.md | 5 +- docs/DIST_LAYOUT_PLAN.md | 162 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 docs/DIST_LAYOUT_PLAN.md diff --git a/CLAUDE.md b/CLAUDE.md index f28e365..ede7e2e 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` | --- diff --git a/context/build-and-run.md b/context/build-and-run.md index ac4d686..e02d1ab 100644 --- a/context/build-and-run.md +++ b/context/build-and-run.md @@ -55,7 +55,10 @@ run\run.cmd [EGG] # default DEV.EGG; cd's to content\ and runs btl4.exe -eg 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. + 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/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 + `<name>/content/...`, the exe as `<name>/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 `<exe dir>\content\bindings.txt` exists and + `<exe dir>\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 `<exe dir>\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_<version>/`) 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) From 56d9971b0e546e70335d03c657ea8976b14de265 Mon Sep 17 00:00:00 2001 From: Cyd <cyd@falloutshelterarcade.com> Date: Sun, 26 Jul 2026 02:58:34 -0500 Subject: [PATCH 7/9] -fit: pin the backbuffer to the CANVAS, not the client (the display was wrong) Field report with a screenshot: `btl4.exe -fit` renders tiny MFDs hugging the corners, an over-wide world view, and a horizontally squashed scene. CAUSE. L4VIDEO pins the windowed backbuffer to the CLIENT RECT (Gitea #56, so a device Reset cannot silently re-derive it). That is correct precisely while client == canvas -- true on a normal boot, and FALSE under -fit, which sizes the window to the whole monitor BEFORE the device exists. The backbuffer came out 3440x1440 against a 1452x1059 canvas, and everything downstream disagreed: - BTApplyWorldViewport and BTDrawCockpitPanels lay out from the BACKBUFFER, so the canvas they drew was 3440 wide: bands unchanged, MFDs still 320x240 (hence tiny), view rect 2888x881 instead of 900x500; - the world projection used the aspect of the INTENDED view (1.8) inside a 3.28 viewport, stretching the scene; - Present then scaled that oversized backbuffer into the 1974x1440 letterbox, squashing it horizontally by 0.574; - and every button was hit-tested through gBTCockpitCanvasW/H while being drawn in backbuffer space -- the exact #50 divergence the pinning comment warns about, unnoticed because nothing had clicked under -fit. FIX: when the cockpit surround is up, the backbuffer IS the canvas, whatever the window is doing. Pin to gBTCockpitCanvasW/H and let the letterbox do the scaling -- which is what the rest of the design already assumed. Drag-resize was always right for this reason (the device is created while client == canvas and a later resize does not recreate it); only -fit, which changes the client first, exposed it. MY EARLIER VERIFICATION WAS WRONG. I called -fit good from a screenshot that was centred and letterboxed, without checking that the backbuffer matched the canvas or that a single button could be clicked. Both are checked now. Verified: -fit on 3440x1440 renders correct proportions (captured), and 72/72 buttons dispatch under -fit; surround/exploded/dock/pod/dev all boot and simulate with zero faults. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- engine/MUNGA_L4/L4VIDEO.cpp | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/engine/MUNGA_L4/L4VIDEO.cpp b/engine/MUNGA_L4/L4VIDEO.cpp index 58a3083..43ddfb9 100644 --- a/engine/MUNGA_L4/L4VIDEO.cpp +++ b/engine/MUNGA_L4/L4VIDEO.cpp @@ -3490,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); From 5f88a4eeb2c28f39711c25483004a4301c8a253c Mon Sep 17 00:00:00 2001 From: Cyd <cyd@falloutshelterarcade.com> Date: Sun, 26 Jul 2026 03:23:27 -0500 Subject: [PATCH 8/9] The keyboard becomes the button board (RP412 bindings port) + a controls page Ported RP412's bindings design and made it the DEFAULT: the letter and number rows are the MFD button banks laid out WHERE THEY SIT ON THE PANEL, flight moves to the numpad so the board stays free, F1-F12 are the map's two columns, and G/B stay unbound as the physical gap between the lower clusters. Expressed in BT's OWN grammar (slew/deflect/set, the Turn channel) -- bindings.txt is a documented compatibility surface and was not touched. Coverage: 61 of 72 addresses on the keyboard, and the 11 absent ones are exactly those the pod never wired (0x16/0x17/0x1E/0x1F column gaps, 0x38-0x3E intercom/door). All 72 stay clickable on the panel. It lands on BT's addresses unreasonably well: 1-4 + QWER are the ENTIRE 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 authentic 1995 typed-hotkey channel so it cannot 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. 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, and those polls read GetAsyncKeyState directly -- without this they would have fired BOTH the button and the built-in. View toggle lives on BACKTICK alone. CONTROLS.MAP rewritten to mirror the board so glass/pod/dev still feel identical (the 2026-07-21 settlement): 90 bindings, 0 parse complaints. HAT LABELS CORRECTED [T1]. INPUT_PATH_AUDIT flagged 0x41-0x44 and was right. Settled from the streamed mapping (BT_CTRLMAP_LOG): elem 66 -> subsys 17 attrID 14, i.e. 0x42 is the TORSO subsystem, not a look; 0x44/0x43/0x41 are the mapper's LookLeft/LookRight/LookBehind (attrID 10/11/12). The .RES has no "TORSO CENTER" string -- its names are LookBehind/Down/Forward/Left/Right -- so the audit's wording was loose but its substance correct. Swept L4GLASSWIN, L4PADPANEL and L4VB16. docs/CONTROLS.html: interactive keyboard (hover a key for its address and meaning, MFD clusters outlined), pad diagram, panel map, the under-glass press-target figure, radar placement thumbnails and the environ.ini table -- modelled on RP412's page, rewritten for BT's addresses and hazards. mkdist wraps the fragment in a doctype/charset shell and ships it beside CONTROLS.txt. Verified: bindings.txt regenerates and loads (74 keys, 0 errors); CONTROLS.MAP 90 bindings, 0 errors; 72/72 panel addresses still dispatch; surround / exploded / dock / pod / dev boot and simulate with zero faults. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- content/CONTROLS.MAP | 148 ++-- context/glass-cockpit.md | 47 ++ docs/CONTROLS.html | 1061 +++++++++++++++++++++++++++++ docs/CONTROLS.md | 113 ++- docs/GLASS_COCKPIT.md | 50 ++ engine/MUNGA_L4/L4GLASSWIN.cpp | 4 +- engine/MUNGA_L4/L4PADBINDINGS.cpp | 196 ++++-- engine/MUNGA_L4/L4PADPANEL.cpp | 8 +- engine/MUNGA_L4/L4PADRIO.cpp | 28 +- engine/MUNGA_L4/L4PADRIO.h | 6 + engine/MUNGA_L4/L4VB16.cpp | 4 +- tools/mkdist.py | 13 + 12 files changed, 1489 insertions(+), 189 deletions(-) create mode 100644 docs/CONTROLS.html 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/glass-cockpit.md b/context/glass-cockpit.md index c7d100c..ae4a6e2 100644 --- a/context/glass-cockpit.md +++ b/context/glass-cockpit.md @@ -235,6 +235,53 @@ Bonus confirmation: those legend widgets sit at gauge offsets 537/430/322/215/10 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 diff --git a/docs/CONTROLS.html b/docs/CONTROLS.html new file mode 100644 index 0000000..3ef800c --- /dev/null +++ b/docs/CONTROLS.html @@ -0,0 +1,1061 @@ +<title>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
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 index 449f05b..136a058 100644 --- a/docs/CONTROLS.md +++ b/docs/CONTROLS.md @@ -9,84 +9,69 @@ Cockpit layout and display sizes live in `content\environ.ini` (likewise self-do --- -## Keyboard +## 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 | |---|---|---|---|---| -| W/S or ↑/↓ | throttle lever | | X or NumPad5 | all stop | -| A/D or ←/→ | turn pedals | | Alt | reverse thrust | -| Q/E | torso twist | | Shift | throttle up | -| R/F | torso aim | | NumPad 8/2/4/6 | torso aim/twist | -| 1 or Space | main trigger | | 2 | middle thumb | -| 3 or Ctrl | upper thumb (missiles) | | 4 | pinky | -| V | external camera | | B | look behind | -| M | control mode (BAS/MID/ADV) | | N | schematic | -| J/K/L | MFD preset pages | | H (hold) | coolant flush | -| C | cycle a coolant valve | | G (hold) + fire | regroup a weapon | -| − / = | volume down/up | | F5–F9 | generator select / mode | +| 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 pedal keys do nothing. -Press **M** once for MID, where the pedals steer and the stick twists the torso — the full -experience. If "turning doesn't work", this is why. +**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. -Numpad aim keys need **NumLock ON**. +### The panel — letter and number rows -> Control mode (BAS/MID/ADV, the M key) is *not* the menu's experience setting -> (novice/standard/veteran/expert). That one changes the simulation — heat, damage — not your -> controls. +``` + 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 -## The cockpit panel — all 72 pod buttons + 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 +``` -Each display carries the bank of buttons mounted around it in the real cabinet. Click anywhere on -a display: the buttons reach *under* the glass, so the whole screen is a press target and only the -lit strip at the edge shows. +G and B are deliberately unbound: they are the physical gap between the lower clusters. -### Heat / coolant display — the loop controls -| Button | Action | -|---|---| -| `0x28` | **Balance coolant** — equalise every condenser valve | -| `0x29`–`0x2F` | the six **condenser valves** (Cond 6…1) and the **coolant flush** (`0x2C`, hold) | +**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 valve detents run 1 - 5 - 50 (max) - **0 (CLOSED)** - back to 1. One press past max shuts -that loop off and everything on it overheats. Boosting one loop starves the others — the supply -is shared. +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). -### Weapon and engineering displays -| Bank | Display | -|---|---| -| `0x00`–`0x07` | Right Weapons (lower right) | -| `0x08`–`0x0F` | Left Weapons (lower left) | -| `0x20`–`0x27` | Engineering (upper centre) | -| `0x30`–`0x37` | Comm / target hotbox (upper right) — pilot select | +### What a bound key gives up -Each display's buttons are **page-gated**: on the Quad page the outer buttons jump straight to a -populated Eng page; on an Eng page they drive the subsystem shown — generator select A–D, bus -mode, coolant toggle, trigger configure, and **eject** (tap to clear a jam, hold ~3 s to jettison -a burning ammo bay). +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. -### Map / radar — the side columns -| Button | Action | | Button | Action | -|---|---|---|---|---| -| `0x10` | map zoom in | | `0x18` | cycle control mode (BAS/MID/ADV) | -| `0x11` | map zoom out | | `0x1A`–`0x1D` | generator A–D on/off | -| `0x12` | thermal / IR sight | | `0x16`,`0x17`,`0x19`,`0x1E`,`0x1F` | no function (as shipped in 1995) | -| `0x13` | **crouch** | | | | -| `0x14` | searchlight | | | | -| `0x15` | cycle display mode | | | | - -### Flight controls -| Button | Action | -|---|---| -| `0x3D` | panic / config lamp | -| `0x3F` | throttle head — reverse thrust | -| `0x40` | main trigger | -| `0x41`–`0x44` | hat: behind / torso centre / right / left | -| `0x45`–`0x47` | pinky, middle thumb, upper thumb — the mappable fire buttons | -| `0x38`–`0x3C`, `0x3E` | intercom and door lines — no function on a desktop | - ---- +Addresses with no function at all — the pod never wired them — are `0x16` `0x17` `0x19` +`0x1E` `0x1F` and `0x38`-`0x3E`. ## Controllers diff --git a/docs/GLASS_COCKPIT.md b/docs/GLASS_COCKPIT.md index 608f496..520b6dd 100644 --- a/docs/GLASS_COCKPIT.md +++ b/docs/GLASS_COCKPIT.md @@ -682,3 +682,53 @@ each bank surrounds, with the coolant-valve detent warning and the eject/jam pro 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/engine/MUNGA_L4/L4GLASSWIN.cpp b/engine/MUNGA_L4/L4GLASSWIN.cpp index db2bfca..065cdad 100644 --- a/engine/MUNGA_L4/L4GLASSWIN.cpp +++ b/engine/MUNGA_L4/L4GLASSWIN.cpp @@ -111,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]) }; diff --git a/engine/MUNGA_L4/L4PADBINDINGS.cpp b/engine/MUNGA_L4/L4PADBINDINGS.cpp index 6827a15..08cf9ce 100644 --- a/engine/MUNGA_L4/L4PADBINDINGS.cpp +++ b/engine/MUNGA_L4/L4PADBINDINGS.cpp @@ -161,20 +161,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 = @@ -192,66 +207,121 @@ static const char *defaultProfileText = "# 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" +"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 1c4b7ac..468fbba 100644 --- a/engine/MUNGA_L4/L4PADPANEL.cpp +++ b/engine/MUNGA_L4/L4PADPANEL.cpp @@ -96,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" }, diff --git a/engine/MUNGA_L4/L4PADRIO.cpp b/engine/MUNGA_L4/L4PADRIO.cpp index fabd8d6..13b2c00 100644 --- a/engine/MUNGA_L4/L4PADRIO.cpp +++ b/engine/MUNGA_L4/L4PADRIO.cpp @@ -69,6 +69,24 @@ int // 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) { @@ -615,11 +633,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; @@ -638,7 +662,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]) { diff --git a/engine/MUNGA_L4/L4PADRIO.h b/engine/MUNGA_L4/L4PADRIO.h index b8b84b2..ea0fda4 100644 --- a/engine/MUNGA_L4/L4PADRIO.h +++ b/engine/MUNGA_L4/L4PADRIO.h @@ -148,6 +148,12 @@ protected: 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/L4VB16.cpp b/engine/MUNGA_L4/L4VB16.cpp index bc9e1e2..6dc1210 100644 --- a/engine/MUNGA_L4/L4VB16.cpp +++ b/engine/MUNGA_L4/L4VB16.cpp @@ -551,8 +551,8 @@ 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 + 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. diff --git a/tools/mkdist.py b/tools/mkdist.py index 88902be..554f654 100644 --- a/tools/mkdist.py +++ b/tools/mkdist.py @@ -135,6 +135,19 @@ def main(): 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 + # ), 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 = ('\n\n\n' + '\n' + '\n' + '\n\n' + page + '\n\n\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) From 9d536294e4b7bd0bbd8c566283c2d28a733a9e24 Mon Sep 17 00:00:00 2001 From: Cyd Date: Sun, 26 Jul 2026 09:06:58 -0500 Subject: [PATCH 9/9] Mouse buttons are bindable; mouse LOOK scoped (not built) BUTTONS (shipped). Win32 hands mouse buttons out as virtual keys, and the binding path already stores VKs and polls GetAsyncKeyState -- so this needed no verb, no parser change and no new machinery, just names: key MOUSE4 button 0x40 # side buttons key MOUSE5 button 0x46 key MOUSEMIDDLE button 0x41 MOUSELEFT/MOUSERIGHT are named too, with a warning in the file header and the docs: they are how the player presses cockpit buttons (left = press, right = latch), and the poll is focus-guarded but not click-aware, so binding either ALSO fires on every panel click. The side and middle buttons are the free ones. Axes work as well (`key MOUSE4 axis Throttle slew + 0.7`). One guard: the RGB lamp mirror skips mouse VKs when building its map -- a keyboard lamp array has no mouse buttons to paint (BTPadBindingIsMouseKey). Verified live: a bindings.txt carrying two mouse rows loads 76 keys (74 + 2), and a real injected XBUTTON1 press produced `[emitter] FIRED #1`. Clean A/B -- the earlier run whose SendInput failed the struct-size check logged no fire at all. Shipped commented-out examples in the default profile so the capability is discoverable. LOOK (scoped only). docs/MOUSELOOK_PLAN.md. Mouse MOVEMENT is a real feature, not a table entry, and the hard part is not reading the device: - The cursor is already spoken for. The glass cockpit's premise is that all 72 buttons are mouse targets; mouse-look wants a captured, hidden, recentred cursor. Both cannot be true, so this is a MODE question first -- four models compared, recommending hold-to-look (costs nothing when unbound, cannot strand a player, behaves the same in surround and exploded layouts). - The channels are POSITIONAL (JoystickX/Y are -1..1 deflections the mapper reads per control mode); a mouse gives deltas. Accumulate-into-a-virtual- stick reuses every existing rule; a rate model would need new mapper semantics. - Control mode changes what it MEANS: BASIC steers with the stick, MID/ADV twist the torso -- so mouse-look steers in one and aims in the other. Proposed grammar (new row type, so old builds skip it with a warning), where the code goes, ~250 lines, and a verification plan whose load-bearing item is "panel clicks still work when a mouselook binding exists but is not engaged". Also flagged: the pod had NO mouse, so nothing here is reconstruction -- every choice is design, judged on feel, and it must stay out of the pod build's path. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- docs/CONTROLS.html | 1 + docs/CONTROLS.md | 10 +++ docs/MOUSELOOK_PLAN.md | 130 ++++++++++++++++++++++++++++++ engine/MUNGA_L4/L4PADBINDINGS.cpp | 42 ++++++++++ engine/MUNGA_L4/L4PADRIO.cpp | 8 ++ 6 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 docs/MOUSELOOK_PLAN.md diff --git a/CLAUDE.md b/CLAUDE.md index ede7e2e..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`, `docs/DIST_LAYOUT_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/docs/CONTROLS.html b/docs/CONTROLS.html index 3ef800c..b593689 100644 --- a/docs/CONTROLS.html +++ b/docs/CONTROLS.html @@ -743,6 +743,7 @@ 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 diff --git a/docs/CONTROLS.md b/docs/CONTROLS.md index 136a058..ece9804 100644 --- a/docs/CONTROLS.md +++ b/docs/CONTROLS.md @@ -82,6 +82,16 @@ control in turn — stick, twist, throttle, fire buttons — writes the bindings 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`. 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 ` 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 axis [invert] [sensitivity ] [spring ] [deadzone ] +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/L4PADBINDINGS.cpp b/engine/MUNGA_L4/L4PADBINDINGS.cpp index 08cf9ce..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 }, @@ -202,6 +236,10 @@ static const char *defaultProfileText = "# key axis set jump/detent\n" "# pad button \n" "# padaxis axis [invert] [slew ]\n" +"# 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" @@ -240,6 +278,10 @@ static const char *defaultProfileText = "key RALT button 0x3F\n" "\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" diff --git a/engine/MUNGA_L4/L4PADRIO.cpp b/engine/MUNGA_L4/L4PADRIO.cpp index 13b2c00..40229a0 100644 --- a/engine/MUNGA_L4/L4PADRIO.cpp +++ b/engine/MUNGA_L4/L4PADRIO.cpp @@ -230,6 +230,14 @@ PadRIO::PadRIO(): { 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) {