Merge glass-cockpit-refit: cockpit scaling + one button geometry, the keyboard button board, RGB keylight, crouch, the cwd guard

Cyd's 9-commit branch, reviewed before merge (clean merge-tree, zero overlap
with the console/relay work that landed after his fork point; his L4VB16 refit
preserves the #48 plane-audit tripwires, and his lamp-decode fix corrects the
#47 flash rendering).  Four review findings are fixed in the follow-up commit:
the stale README controls table, the unguarded backtick view-toggle, the
environ.ini one-shot loophole, and volume-key documentation (the -/= -> PgUp/
PgDn move itself landed pre-merge so this branch's Comm-bank -/= bindings are
collision-free).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-26 13:13:08 -05:00
co-authored by Claude Opus 5
36 changed files with 4848 additions and 431 deletions
+1
View File
@@ -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
+1 -1
View File
@@ -85,7 +85,7 @@ precise than anything you can infer.
| Term / acronym definitions | `reference/glossary.yaml` |
| The ORIGINAL 1995 player manual (controls, per-mech stats, coolant loops) | `reference/manual/Tesla40_BT_manual.pdf` (+ alignment audit in `context/pod-hardware.md` §Manual) |
| The complete verbatim detail (fallback) | `docs/PROGRESS_LOG.md` (the old 2236-line CLAUDE.md) |
| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md`, `docs/KD_SCOREBOARD_PLAN.md` |
| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md`, `docs/KD_SCOREBOARD_PLAN.md`, `docs/DIST_LAYOUT_PLAN.md`, `docs/MOUSELOOK_PLAN.md` |
---
+10
View File
@@ -265,9 +265,19 @@ target_sources(munga_engine PRIVATE
"engine/MUNGA_L4/L4PADBINDINGS.cpp"
"engine/MUNGA_L4/L4JOY.cpp"
"engine/MUNGA_L4/L4PADPANEL.cpp"
"engine/MUNGA_L4/L4RIOBANK.cpp"
"engine/MUNGA_L4/L4GLASSWIN.cpp"
"engine/MUNGA_L4/L4PLASMAWIN.cpp"
"engine/MUNGA_L4/L4KEYLIGHT.cpp"
)
# RGB keyboard lamp mirror (Windows Dynamic Lighting). C++/WinRT needs C++17
# AND conformance mode, while the rest of the project builds C++14 /permissive
# -- so this ONE file gets its own flags. Its interface (l4keylight.h) is
# scalars only, so nothing else has to change dialect. (RP412 additionally
# forced default struct packing here because its engine is /Zp1; BT411 sets no
# /Zp, so that part does not apply -- see the file header.)
set_source_files_properties("engine/MUNGA_L4/L4KEYLIGHT.cpp" PROPERTIES
COMPILE_OPTIONS "/std:c++17;/permissive-")
target_include_directories(munga_engine BEFORE PRIVATE
"${CMAKE_SOURCE_DIR}/engine/shim"
"${DXSDK}/Include")
+96 -52
View File
@@ -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
+20
View File
@@ -39,6 +39,26 @@ run\run.cmd [EGG] # default DEV.EGG; cd's to content\ and runs btl4.exe -eg
- **cwd MUST be `content\`** — the engine resolves `BTL4.RES`, `VIDEO\`, `BTDPL.INI`, eggs relative
to cwd (the `loadTables` gotcha: `L4VIDEO.cpp:849` `fopen("VIDEO\\REPLACEMATS.tbl")` is relative +
unchecked → fread on NULL if cwd is wrong). Logs to `btl4.log` in `content\` (or `BT_LOG=<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. **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
+12 -7
View File
@@ -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`.
+258 -4
View File
@@ -63,6 +63,258 @@ version skew (a stale steam exe shipped 2 days behind), tripled build time, and
([[reconstruction-gotchas]]); everything links always, so this class of trap no longer varies
by build flavor.
## Layout modes — ONE resolver (2026-07-26) [T2 four-mode boot-verified]
Where the five MFDs + radar go is a SEPARATE axis from the platform profile. Under a glass boot
(`BT_DEV_GAUGES` set) `btl4main.cpp` resolves it **once**, right after the profile putenvs land,
into `glassLayout`; the boot banner, the pad-panel decision and the window-sizing block all read
that one answer. Precedence, highest first:
| Env | Layout | Buttons live in |
|---|---|---|
| `BT_GLASS_PANELS`≠0 | per-display cockpit windows (the "exploded" view, `L4GLASSWIN`) | each display's own window |
| `BT_DEV_GAUGES_WINDOW` | the legacy separate MFD window | the single combined pad panel |
| `BT_DEV_GAUGES_DOCK` | the docked bottom gauge strip | the single combined pad panel |
| `BT_COCKPIT=0` | ...also the docked strip (the documented opt-out) | the single combined pad panel |
| (nothing) | **COCKPIT SURROUND — the glass default** | the main window (D3D-drawn lamps) |
The banner now names the live one: `[boot] platform profile: GLASS (PadRIO + plasma window)
[secondary displays: cockpit surround]`.
**Two bugs this replaced** (both were live until 2026-07-26): the mode was decided in TWO places
with duplicated precedence (the glass profile block picked `BT_PAD_PANEL`/`BT_GLASS_PANELS`; the
sizing block re-derived cockpit-vs-dock), and the banner read NEITHER — it announced "per-display
cockpit windows [BT_GLASS_PANELS]" for every glass boot, surround included. Worse, the profile
block turned `BT_COCKPIT=0` into `BT_GLASS_PANELS=1`, so the docked strip it is documented to
select was **unreachable under the glass profile**. `BT_GLASS_PANELS` is now explicit-only, and
dock/window modes auto-raise `BT_PAD_PANEL` so the 72-button field always has a home.
## ONE button-bank geometry — L4RIOBANK (2026-07-26) [T2 click-verified both modes]
The RIO button field around a pod display is drawn TWICE — composited into the main window
(surround, `L4VB16.cpp`) and one window per display (exploded, `L4GLASSWIN.cpp`) — and each
renderer used to carry its own copy of the geometry. They drifted badly: the same MFD button was
a 156×138 under-glass target in the exploded window and a **76×24 sliver entirely outside the
glass** in the surround. `engine/MUNGA_L4/L4RIOBANK.cpp` is now that geometry, once; both
renderers are consumers, and placement (WHERE each display goes) stays with each renderer because
that genuinely differs.
**The under-glass rule** (ported from RP412 `L4MFDVIEW`, which took it from the pod): a button
reaches **half the glass** in BEHIND the display and only a **lamp strip** clears the edge. The
renderer paints buttons first, imagery over them — so the lamp reads as a slim bar, practically
the whole display is the press target, and nothing is occluded (the picture wins every pixel it
covers). The strip scales off the display's SHORT axis against the native 480 (an MFD is 640×480
landscape, the map 480×640 portrait — both short-axis 480) with a 6px floor, so a half-scale
surround lamp is still a bar and not a smudge. The MFD per-column nudge against the unevenly
spaced DISPLAY/PROGRAM legends (`mfdColumnDX`) moved here too, so both renderers inherit the
same alignment.
**The map's foot row is pushed FIRST**, deliberately: the side columns now reach half the map
each, so they cover the foot band too, and the hit test takes the first match — foot-first is
what keeps 0x16/0x17/0x1F/0x1E reachable. That class of bug is why the verification below tests
first-hit reachability, not mere presence.
**Verified 2026-07-26 [T2]:** `BT_RIOBANK_LOG=1` dumps every bank's rects;
`scratchpad/checkbank.py` reports the per-bank census and proves no address is SHADOWED (has a
point no earlier button covers); `scratchpad/clickbank.py` then posts a real click at every
button's centre. Both modes: **72/72 addresses placed, zero shadowed, zero duplicated, 72/72
dispatched** (`[cockpit] CLICK` / `[glasswin] CLICK`), process survived 144 posted messages per
mode. Pod + dev profiles boot and simulate un-regressed. Surround BT_SHOT confirms the legends
are clear and the lamps read as edge strips.
## The cockpit SCALES — uniform-fit letterbox + `-fit` (2026-07-26) [T2 live]
The canvas is a fixed size and D3D9 stretched it into whatever the client area was, so a window
dragged to a different shape **squashed the instruments** — the projection was aspect-corrected
(task #20) but the panels, lamps and MFD glass were not. Now the canvas is fitted at ONE uniform
scale, centred, leftover black: the cockpit scales both ways and never distorts, whatever the
window.
- `BTCockpitFitRect(clientW, clientH, RECT*)` — integer math throughout, so the rect is stable
frame to frame (a float scale wobbles a pixel and the bars shimmer during a drag).
- D3D9 does the scaling at Present via a **destination rect**, which `D3DSWAPEFFECT_DISCARD`
forbids — so the WINDOWED swap effect becomes **`D3DSWAPEFFECT_COPY`** when the surround is up
and multisampling is off (COPY cannot multisample). `gBTCockpitLetterbox` records whether that
happened; when 0 the old full-client stretch stands, so an MSAA run still works.
- Both present sites take the rect (scene + the wait-screen overlay, which paints into the same
backbuffer). `WM_SIZE` invalidates so the bars repaint black — a COPY present only writes its
dest rect, so stale bar content would otherwise persist.
- **The click transform had to follow.** `BTCockpitMouseDown` mapped client→canvas against the
full client; under the letterbox that drifts the hit test off every button by the bar width.
It now runs the identical fit (and a click on a bar hits nothing).
- **World aspect** under the letterbox is the view rect's OWN aspect — the client no longer
enters into it.
- **`-fit`** (alias `-windowed-fullscreen`): borderless `WS_POPUP` over the monitor, canvas
letterboxed inside. Verified on a 3440×1440 ultrawide.
**Ordering trap found live:** the letterbox flag was first set at device creation, but the
first `WM_SIZE` arrives BEFORE the device exists — a `-fit` boot logged `aspect=3.14` (the
stretch formula) and applied it on frame 1, because nothing resizes the window again. The INTENT
is now decided in `btl4main` alongside `gBTGaugeCockpit`; `L4VIDEO` only confirms or withdraws it.
**Capture note:** `PrintWindow` returns an all-black frame for the borderless `-fit` window
(it works for the normal chrome'd one). Use a screen-region grab (`scratchpad/shot.ps1`) for
`-fit`, and don't read a black PrintWindow there as a render failure — it isn't.
Verified: wide (1684×661) and tall (744×961) drags both letterbox with zero distortion; **72/72
buttons still dispatch through the transform at 0.62× scale with a 389px bar**; exploded / dock /
pod / dev all keep DISCARD and boot un-regressed.
## Player-tunable displays — scale, map placement, environ.ini (2026-07-26) [T2 live]
The pod bolted its instruments down at one size; a desktop panel has room to trade viewscreen for
instrument, so the player scales them. All resolved ONCE (`BTCkResolvedSizes`, L4VB16.cpp) because
the surround recomputes its layout every frame.
| Env | Effect |
|---|---|
| `BT_MFD_SCALE` | all five MFDs, % of pod size (25-200, default 100) |
| `BT_MFD_SCALE_UL/_UC/_UR/_LL/_LR` | one display, overriding the group |
| `BT_RADAR_SCALE` | the portrait map |
| `BT_RADAR_POS` | `CENTER` (default) / `LEFT` / `RIGHT` / `MIDLEFT` / `MIDRIGHT` (+ `BOTTOM*`, `*CENTER` spellings) |
**The bands derive from the resolved sizes** — that is why the sizes could not stay constants: the
surround band a display hangs in has to grow with it or the canvas clips it. Each band is what its
LARGEST occupant needs (a corner MFD only claims `size - kCkOVL`, since it overlaps the view), and
the bottom band drops to just the lower MFDs when the map leaves that row for a side. Verified:
100% reproduces the historical `L276 R276 T223 B336` exactly; 150% gives `L436 R436 T343 B448`;
80% + MIDLEFT gives `L256 R212 T175 B148`.
**A corner map goes flush to the CANVAS edge**, not the view edge, and the lower MFD whose corner
it took sits beside it. Measuring off the view edge (first cut) overlapped them by the band width —
232px of map drawn over the Right Weapons MFD.
**Map legend grid — MEASURED, not inherited [T2].** The side columns must line up with the six
cells the map paints beside them (⊕MAP / ⊖MAP / IR / crouch / searchlight / display-mode =
0x10-0x15 — the imagery independently confirms our address map). `scratchpad/measurelegend.py` over
a native 480×640 capture gives **top 3, cell 102, pitch 107** on the 640 span. RP412's map is
`13 + 6×102 @ pitch 105` — same cell height, different top and pitch, so **its numbers do not
transfer**; ours are ours. Our old even division (`displayH/6 + 1 = 107`) had the pitch right by
luck and sat 3px high of the labels. Top and bottom are now scaled separately and subtracted, so
rounding cannot drift the buttons out of step with the labels down the column.
**`environ.ini` (the settings file) [T2].** ⚠ It was read ~300 lines into `WinMain`, AFTER the
platform-profile block had run its `getenv`s — so every setting the profile reads (`BT_PLATFORM`,
`BT_COCKPIT`, `BT_GLASS_PANELS`, `BT_DEV_GAUGES`, `L4CONTROLS`…) was **silently ignored from the
file** and only worked as a real env var. It also `putenv`'d comment lines verbatim, which would
have turned a shipped commented-out option into a variable literally named `#BT_MFD_SCALE`. Now:
loaded immediately after the first-breath boot line, comments and blanks skipped, and **the real
environment WINS** so a launcher `.bat` overrides the file rather than fighting it. A documented
default is written on first run when absent — the `bindings.txt` convention: untracked, so an
extract-over-top upgrade never clobbers a player's settings, and every option ships commented out
so a fresh install behaves exactly as before. Verified all three: first run writes it and applies
0 settings; uncommented file settings drive the layout (130% group + 60% UC + MIDRIGHT); a real
`BT_MFD_SCALE=90` overrides the file's 130 while the file's other settings still apply.
Verified: **72/72 placed, 0 shadowed, 72/72 dispatched** at baseline, at 150%/135%, and at
75% + BOTTOMRIGHT; exploded / dock / pod / dev boot un-regressed.
## Dead-button backlog CLOSED — DuckRequest was the last (2026-07-26) [T2 live]
The 2026-07-20 audit's "8 buttons dispatch a streamed message with no reconstructed handler" is
finished. ⚠ Only ONE of the eight was actually still missing when this pass started — generator
on/off, ToggleSeekVoltage, EjectAmmo, ToggleCooling and BalanceCoolant had all landed between
07-20 and 07-25 while `pod-hardware.md` / `open-questions.md` still called them dead
(`docs/INPUT_PATH_AUDIT.md` had already flagged the census as "stale in both directions").
**Check the code, not the census.**
**`Mech::DuckRequest` @0049fa00 (id 0x1a, RIO 0x13 — the manual's CROUCH button).** The binary's
entire body is two lines: press-only (`msg+0xc > 0`), then `duckState`(`mech+0x398`) = 1. It is a
one-shot REQUEST flag, not a posture toggle — the handler never clears it and the only other
writer in the whole binary is the mech reset (part_012.c:9439, the same reset that zeroes
`incomingLock`). Registered in `Mech::MessageHandlerEntries`; `[duck]` log line on press.
**Its consumer is a DATABINDING, not code [T1] — which is why the flag looked pointless.**
`duckState` has ZERO readers anywhere in the decomp, because it is published as attribute 0x37 and
consumed through the gauge system: `content/GAUGE/L4GAUGE.CFG` runs a 3-frame `bduck.pcc`
`oneOfSeveralPixInt` widget bound to `DuckState` on the map's legend column ("crouch mode: button
4"). Verified live: pressing 0x13 turns that legend icon grey→orange. **Do not invent a crouch
pose to "finish" this** — the flag plus the indicator IS the code side.
Bonus confirmation: those legend widgets sit at gauge offsets 537/430/322/215/108 — a **107
pitch**, independently corroborating the map legend grid measured from pixels in §Player-tunable
displays.
## THE KEYBOARD IS THE BUTTON BOARD — new default keymap (2026-07-26) [T2]
Ported from RP412's bindings design (user decision: make it the DEFAULT, not an alternate). The
letter and number rows are the MFD button banks **laid out where they sit on the panel**, and
flight moves to the NUMPAD so the board stays free:
| Keys | Bank |
|---|---|
| `1 2 3 4` / `Q W E R` | Heat / coolant 0x2F-0x2C / 0x2B-0x28 |
| `5 6 7 8` / `T Y U I` | Engineering (Mfd2) 0x27-0x24 / 0x23-0x20 |
| `9 0 - =` / `O P [ ]` | Comm / target hotbox 0x37-0x34 / 0x33-0x30 |
| `A S D F` / `Z X C V` | Left Weapons (Mfd1) 0x0F-0x0C / 0x0B-0x08 |
| `H J K L` / `N M , .` | Right Weapons (Mfd3) 0x07-0x04 / 0x03-0x00 |
| `F1`-`F6` / `F7`-`F12` | the map's two columns 0x10-0x15 / 0x18-0x1D |
| numpad | 8/2/4/6 stick, 7/9 pedals, 5 all-stop, 0/1/3/. the four fire buttons |
| `Shift`/`Ctrl` · `Alt` · arrows · `` ` `` | throttle lever · reverse · look/torso-centre · view |
`G` and `B` stay unbound — the physical gap between the lower clusters. **Coverage: 61 of 72
addresses on the keyboard, and the 11 absent ones are exactly those with no authored function**
(0x16/0x17/0x1E/0x1F column gaps, 0x38-0x3E intercom/door). All 72 remain clickable.
It lands on BT's addresses better than it deserves to: `1-4`+`QWER` are the ENTIRE coolant system
(Condensers 1-6, flush, balance), `F6`/`F7` the display/control-mode cycles, `F9`-`F12` Generators
A-D, `F4` the crouch button.
⚠ **What it costs, deliberately.** A bound key is removed from the authentic 1995 typed-hotkey
channel to stop double-dispatch, and this board binds nearly everything — so `5` (Quad page), `z`
(Eng1), `t/y/u/i/o` (pilot select) and `+`/`-` (target zoom) are given up. Unbind a key to get its
1995 meaning back.
⚠ **New rule: a bindings row WINS over a built-in convenience key** (`PadRIO::KeyHasBinding`).
`V` and `J/K/L` are board buttons now, so the view-toggle and preset-cycle polls stand down for
them and the view toggle lives on `` ` `` alone. Without this they would have fired BOTH.
`content/CONTROLS.MAP` was rewritten to mirror the board (90 bindings, 0 parse complaints) so the
glass/pod/dev boots still feel identical — the 2026-07-21 settlement. An existing `bindings.txt`
is never overwritten, so a player only meets this board by deleting theirs.
## Hat labels corrected — 0x42 is TORSO CENTER [T1, streamed mapping]
`docs/INPUT_PATH_AUDIT.md` flagged the 0x41-0x44 labels as wrong and it was right. Settled from
`BT_CTRLMAP_LOG`: **0x42 → subsys 17 (Torso), attrID 14** — not a look at all; the other three go
to the mapper's look trio (0x44 → attrID 10 LookLeft, 0x43 → 11 LookRight, 0x41 → 12 LookBehind).
The shipped `.RES` has no "TORSO CENTER" string (its names are LookBehind/Down/Forward/Left/Right),
so the audit's phrasing was loose but its substance correct. Swept the three UI tables
(`L4GLASSWIN`, `L4PADPANEL`, `L4VB16`): 0x42 is now "Torso Ctr", 0x41/0x43/0x44 "Look Bk/R/L".
## RGB keyboard lamp mirror — L4KEYLIGHT (2026-07-26) [T2 live, claimed a real board]
Ported from RP412 (itself vRIO's `KeyboardLampMirror`). Keys bound to a lamp address in
`bindings.txt` glow with the panel palette through **Windows Dynamic Lighting** — yellow for the
map's side columns (0x10-0x1F), red for the rest — flashing in step with the on-screen buttons
(its `LampLevel` copy matches the FIXED `BTLampBrightnessOf`). Per-key boards light each bound
key; zone-lit boards mirror the strongest lamp board-wide. All WinRT runs on a private worker
thread. Gate: `BT_KEYLIGHT=0` opts out; no Dynamic Lighting = one log line, then dormant.
⚠ **RP412's packing hazard does NOT apply here.** It compiles that file with forced default struct
packing because *its* engine is `/Zp1`, which would break the WinRT ABI. BT411's `BT_OPTS` is
`/permissive /W0 /wd4996 /EHsc /bigobj /MP` — **no `/Zp`** — so only the dialect flags are needed
(`/std:c++17 /permissive-`, per-file in CMakeLists, since the project otherwise builds C++14
/permissive). The scalars-only interface is kept anyway so the isolation survives if packing is
ever added.
Wired in `PadRIO`: map from `bindings.keyBindings` (ActionButton binds only, first-binding-wins
per key), fed from `PadRIO::SetLamp`, stopped in the dtor (which hands the LEDs back to Windows).
Verified: `[keylight] mirroring 25 bound key(s)` + claimed this machine's 24-zone keyboard;
`BT_KEYLIGHT=0` and the pod profile produce zero keylight lines and run clean.
## Lamp flash decode was wrong — fixed (2026-07-26) [T1, from the L4RIO.h enum]
`BTLampBrightnessOf` (l4vb16.h) returned `max(state1, state2)` and blanked to 0 on the alternate
phase. The engine's own enum [T0] says otherwise: bits 0-1 = flash mode, bits 2-3 = **state 1**
brightness, bits 4-5 = **state 2** brightness (0 off / 1 dim / 3 bright) — solid shows state 1,
flashing ALTERNATES the two at 500/250/125 ms. The old formula agrees only when one state is Off,
which is why it survived: the Panic lamp is `flashFast+state1Off+state2Bright` and L4LAMP's other
pulse is off/dim. But **`L4LAMP.cpp:252` commands `flashFast + state1Dim + state2Bright`** — a
dim→bright pulse that rendered as a hard bright→off blink. RP412's `L4MFDVIEW::LampLevel` has the
faithful formula; that is now ours. THREE copies of the decode existed (l4vb16.h, L4GLASSWIN,
L4PADPANEL), all three with the bug; the two locals now forward to the one inline.
## The pre-mission WAITING SCREEN (2026-07-22) [T2]
Every pre-run state (seat wait, roster full, WaitingForEgg, LoadingMission, WaitingForLaunch)
paints an animated overlay -- Consolas green text + a 12-segment spinner phased on
@@ -199,8 +451,9 @@ copied from them.
dead panel: `MechRIOMapper`'s message ids were chained one enum too high (0x19-0x2a) — the
binary RIO table @0051dd30 re-registers the BASE aux/zoom ids 3..0x13 (Hotbox 0x1a); fixed
ungated + static_assert-locked (`btl4mppr.hpp`) → all 24 MFD-bank + 2 zoom buttons live
(52/72 panel buttons now work; 8 dead await handler reconstruction — Gitea backlog issue;
12 have no streamed mapping authored, authentically inert). **Keyboard = the ~20 core
(52/72 panel buttons worked at the time; the 8 dead ones are ALL wired as of 2026-07-26 —
`DuckRequest` was the last, see §Dead-button backlog closed; 12 have no streamed mapping
authored, authentically inert). **Keyboard = the ~20 core
gameplay actions on the CONTROLS.MAP keys; the PANEL covers every pod address by click**
(right-click = hold latch) — the merged default profile lives in `L4PADBINDINGS.cpp`
(auto-writes `content\bindings.txt`; W/S/A/D/Q/E/R/F/X drive, 1-4/Space/Ctrl fire, M/N/H/C =
@@ -243,8 +496,9 @@ copied from them.
(`SVGA16::ExpandPlaneToBGRA`, no D3D) and `StretchDIBits`'d in, so the **whole D3D
dev-composite path (dock / separate window / overlay) stands down** while these are up
(`BTDrawGaugeInset`/`BTGaugeWindowRenderAndPresent` early-return; no `gBTGaugeDockBottom`
strip). Runtime gate **`BT_GLASS_PANELS`** (default ON under `-platform glass`, `=0` falls
back to the single pad panel + docked gauges) — read by `L4PADRIO` (which panel to create),
strip). Runtime gate **`BT_GLASS_PANELS`** (⚠ was "default ON under `-platform glass`" — NO
LONGER TRUE; the surround is the glass default and panels are explicit-only, see §Layout modes
below) — read by `L4PADRIO` (which panel to create),
`L4VB16` (suppress compositing), and `btl4main` (skip the world-window dock strip). The
display↔bank map (spine of the feature): Heat 0x28-2F, Mfd2 0x20-27, Comm 0x30-37,
Mfd1 0x08-0F, Mfd3 0x00-07, sec 0x10-1B ([[pod-hardware]] §Bank→MFD). Verified: build +
+23 -11
View File
@@ -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),
0x28Mech `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`.
+6 -4
View File
@@ -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 @004b1ed0DEAD 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
+1062
View File
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
# BattleTech 4.11 — controls reference
Two ways to fly: the **keyboard/pad** (the ~25 core actions) and the **cockpit panel**, where
every one of the pod's 72 illuminated buttons is mouse-clickable exactly where it sat in the
cabinet. Left-click presses; **right-click latches** a button held.
Rebind keys in `content\bindings.txt` (delete it to regenerate the defaults).
Cockpit layout and display sizes live in `content\environ.ini` (likewise self-documenting).
---
## Keyboard — the keyboard IS the button board
The letter and number rows are the MFD button banks, laid out where they sit on the panel.
Flight lives on the number pad so the board stays free.
### Flight — the number pad
| Key | Action | | Key | Action |
|---|---|---|---|---|
| NumPad 8 / 2 | torso aim up / down | | NumPad 7 / 9 | left / right pedal |
| NumPad 4 / 6 | torso twist left / right | | NumPad 5 | all stop |
| NumPad 0, Space | main trigger | | NumPad 1 / 3 / . | pinky / middle / upper |
| Shift / Ctrl | throttle up / down (the lever sticks) | | Alt | reverse thrust |
| ← ↓ → | look left / behind / right | | ↑ | torso centre |
| ` | cockpit / chase camera | | / = *(see below)* | — |
**You spawn in BASIC control mode.** In BASIC the stick steers and the pedals do nothing.
Press **F7** once for MID, where the pedals steer and the stick twists the torso — the full
pod experience. If "turning doesn't work", this is why.
### The panel — letter and number rows
```
UPPER DISPLAYS Heat / coolant Engineering Comm / hotbox
top row 1 2 3 4 | 5 6 7 8 | 9 0 - =
bottom row Q W E R | T Y U I | O P [ ]
0x2F..0x2C 0x27..0x24 0x37..0x34
0x2B..0x28 0x23..0x20 0x33..0x30
LOWER DISPLAYS Left Weapons Right Weapons
top row A S D F (G unbound) H J K L
bottom row Z X C V (B unbound) N M , .
0x0F..0x0C 0x07..0x04
0x0B..0x08 0x03..0x00
COLUMNS beside the map
left F1 zoom+ F2 zoom- F3 thermal F4 CROUCH F5 lamp F6 display
right F7 control mode F8 - F9..F12 Generators A-D
```
G and B are deliberately unbound: they are the physical gap between the lower clusters.
**1 2 3 Q W E are the coolant valves** and the detents run 1 - 5 - 50 (max) - **0 (CLOSED)** -
back to 1. One press past max shuts that loop off and everything on it overheats; boosting one
loop starves the others, since the supply is shared. **4** is the flush (hold it) and **R**
re-balances every valve.
The weapon and engineering displays are **page-gated**: on the Quad page the outer buttons jump
to a populated Eng page; on an Eng page they drive generator select A-D, bus mode, coolant
toggle, trigger configure, and **eject** (tap to clear a jam, hold ~3 s to jettison a burning
ammo bay before it detonates).
### What a bound key gives up
Binding a key removes it from the authentic 1995 typed-hotkey channel so it cannot
double-dispatch. This board binds nearly everything, so those hotkeys are given up by design:
`5` = MFD1 Quad page, `z` = MFD3 Eng1, `t/y/u/i/o` = pilot select, `+`/`-` = target zoom.
Unbind a key in `bindings.txt` to get its 1995 meaning back. Same rule for the built-ins:
**`** is the view toggle (V is a board button now), and J/K/L cycle the MFD preset pages only
while unbound.
Addresses with no function at all — the pod never wired them — are `0x16` `0x17` `0x19`
`0x1E` `0x1F` and `0x38`-`0x3E`.
## Controllers
An **Xbox controller** works out of the box.
**Flight sticks, HOTAS and pedals:** run `joyconfig.bat` once. A wizard asks you to move each
control in turn — stick, twist, throttle, fire buttons — writes the bindings, and drops you into
the solo menu to try them. It only rewrites its own section of `bindings.txt`, so hand edits
elsewhere in the file survive.
**Mouse buttons** are bindable like any key — `MOUSE4`, `MOUSE5` (the side buttons),
`MOUSEMIDDLE`, and `MOUSELEFT`/`MOUSERIGHT`. The side and middle buttons are the free ones; left
and right are how you press cockpit buttons, so binding those fires on every panel click too.
Mouse *movement* is not bindable — see `docs/MOUSELOOK_PLAN.md`.
```
key MOUSE4 button 0x40 # side button = main trigger
key MOUSE5 button 0x46 # side button = middle thumb
```
**RGB keyboards** with Windows Dynamic Lighting mirror the panel: any key you have bound to a
lamp button glows in the panel's own colours and flashes in step with the on-screen buttons. Turn
it off with `BT_KEYLIGHT=0` in `environ.ini`.
---
## Cockpit layout
Set these in `content\environ.ini`:
- **`BT_MFD_SCALE`** (and `_UL`/`_UC`/`_UR`/`_LL`/`_LR`) — size of the five MFDs, as a percentage
of their pod size. Turn them up if you want to actually read them while you fly.
- **`BT_RADAR_SCALE`**, **`BT_RADAR_POS`** — size the map, and move it out of the middle of the
road: `CENTER`, `LEFT`, `RIGHT`, `MIDLEFT`, `MIDRIGHT`.
- **`BT_GLASS_PANELS=1`** — break every display out into its own desktop window instead of the
composited cockpit. Good for reading one display at full size.
Run with **`-fit`** for a borderless window over the whole monitor. The cockpit scales to any
window shape at one uniform scale, so nothing ever stretches — a window that isn't the cockpit's
shape just gets black bars.
+162
View File
@@ -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)
+7 -3
View File
@@ -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/
+296 -3
View File
@@ -385,9 +385,10 @@ on the pod monitor. This reuses the plasma-window CPU→window pattern and sides
D3D dev-composite bug family (additional-swap-chain lifetime, device-state save/restore,
depth-stencil mismatch, layered strobing).
**Gate: `BT_GLASS_PANELS`** (runtime env; `BTGlassPanelsActive()`). Default ON under
`-platform glass` (preset putenv in `btl4main.cpp`); `=0` restores the legacy single pad panel
+ docked gauge strip. Wired at three seams:
**Gate: `BT_GLASS_PANELS`** (runtime env; `BTGlassPanelsActive()`). **Was** default-ON under
`-platform glass` (preset putenv in `btl4main.cpp`) — as of 2026-07-26 the COCKPIT SURROUND is the
glass default and this gate is **explicit-only**; the one resolver + the full precedence table are
in `context/glass-cockpit.md` §Layout modes. Wired at three seams:
- `L4PADRIO` ctor: `BTGlassPanelsActive()``BTGlassPanels_Create()` instead of
`BTPadPanel_Create()`; dtor destroys both (safe no-ops).
- `L4VB16`: `BTDrawGaugeInset` / `BTGaugeWindowRenderAndPresent` early-return when
@@ -439,3 +440,295 @@ Verified after the fix (`BT_GLASS_LOG`): `[glass] 'Heat MFD' port=Heat mask=0x40
enable=2 ow=640 oh=480 nonzero=22227/307200`, `[glass] 'Secondary / Radar' port=sec mask=0x3f
pal=1 enable=3 ow=480 oh=640 nonzero=26206/307200` — surfaces resolve and carry live pixels.
`BT_GLASS_LOG` left in as a permanent per-surface resolve/pixel diagnostic (throttled).
## 2026-07-26 — Layout modes get ONE resolver; button geometry gets ONE owner (L4RIOBANK)
The RP412 replication pass, step 1 of the agreed plan (port the geometry, keep our renderers).
### The mode resolver (btl4main.cpp)
Where the secondary displays go was decided in TWO places with duplicated precedence -- the glass
profile block picked `BT_PAD_PANEL`/`BT_GLASS_PANELS`, the window-sizing block re-derived
cockpit-vs-dock -- and the boot banner read NEITHER, announcing "per-display cockpit windows
[BT_GLASS_PANELS]" for every glass boot including the surround default. The split had also
broken `BT_COCKPIT=0`: documented as the dock-bottom opt-out, the profile block converted it to
`BT_GLASS_PANELS=1`, so **the docked strip was unreachable under the glass profile**.
Now: one `glassLayout` resolved after the profile putenvs land, consumed by the banner, the
pad-panel decision and the sizing block. Precedence `BT_GLASS_PANELS` > `BT_DEV_GAUGES_WINDOW` >
`BT_DEV_GAUGES_DOCK` > `BT_COCKPIT=0` > surround default; `BT_GLASS_PANELS` is explicit-only now
(it was auto-set); dock/window auto-raise `BT_PAD_PANEL` so the 72-button field always has a home.
Banner: `[boot] platform profile: GLASS (PadRIO + plasma window) [secondary displays: <mode>]`.
Verified all four modes boot to the mode they name, plus pod/dev.
### L4RIOBANK -- the shared field
`engine/MUNGA_L4/L4RIOBANK.cpp/.h` (new TU). Both renderers were carrying their own copy of the
button geometry and had drifted: an MFD button was 156x138 reaching under the glass in the
exploded window, and a **76x24 sliver entirely OUTSIDE the glass** in the surround. One module
now owns it, both are consumers, placement stays per-renderer.
The under-glass rule, from RP412 `L4MFDVIEW` (which took it from the pod): reach half the glass
in behind the display, leave a lamp strip clearing the edge, paint buttons first and imagery
over. Strip scales off the display's SHORT axis vs the native 480 (MFD 640x480 landscape, map
480x640 portrait) with a 6px floor. The MFD per-column nudge against the DISPLAY/PROGRAM legends
moved here so both renderers inherit one alignment. The map's foot row is pushed FIRST because
the side columns now cover that band and the hit test takes the first match.
Retired: `L4GLASSWIN`'s `PlaceCellAt`/`PlaceLine`/`PlaceRect` and its `RedCellH`/`EdgeProtrude`/
`RailW`/`RailSlotAdd`/`RailGap`/`RedOffsetX`/`kRedColDX` constants; the surround's inline MFD/
radar/flight button loops. `kCkRedH`/`kCkLamp` survive only as band reserves for the canvas math.
### Lamp flash decode [T1 -> fixed]
`BTLampBrightnessOf` returned `max(state1,state2)` and blanked on the alternate phase. Per the
engine's own `RIO::LampState` enum [T0] solid shows state 1 and flashing ALTERNATES state 1 and
state 2. Agrees only when one state is Off -- true for Panic (`flashFast+state1Off+state2Bright`)
which is why it survived -- but `L4LAMP.cpp:252` commands `flashFast+state1Dim+state2Bright`, a
dim->bright pulse that rendered as bright->off. Three copies existed (l4vb16.h, L4GLASSWIN,
L4PADPANEL), all three wrong; the two locals now forward to the one fixed inline.
### Verification harness (new, reusable)
- `BT_RIOBANK_LOG=1` -- one-shot dump of every bank's buttons + bounds (capped at 16 banks so the
per-frame surround caller prints one pass).
- `scratchpad/checkbank.py <log>` -- per-bank census + **first-hit reachability**: an address
whose rect is entirely covered by earlier buttons in its bank is dead however big it looks, and
the overlapping under-glass banks make that a live hazard. Reports duplicates and shadowing.
- `scratchpad/clickbank.py <log> <title> --mode surround|exploded` -- posts a real
WM_LBUTTONDOWN/UP at every button's centre; the game's own CLICK forensics prove dispatch.
Result both modes: **72/72 placed, 0 shadowed, 0 duplicated, 72/72 dispatched**, process survived
144 posted messages each. Pod + dev profiles boot and simulate un-regressed. Surround `BT_SHOT`
confirms the legends are clear and the lamps read as edge strips; exploded `PrintWindow` captures
confirm surfaces still land under their banks.
## 2026-07-26 (later) — Phase 2: the cockpit scales (uniform-fit letterbox + -fit)
RP412 replication step 2. The canvas is a FIXED size and D3D9 stretched it into the client, so a
window dragged to a different shape squashed the instruments -- the projection was aspect-corrected
back in task #20, but the panels, lamps and MFD glass never were. Now: one uniform scale, centred,
leftover black.
**Mechanism.** `BTCockpitFitRect()` (L4VB16.cpp) computes the centred uniform-scale rect in integer
math -- a float scale wobbles by a pixel between frames and the bars shimmer during a drag. D3D9
applies it as a Present **destination rect**, which `D3DSWAPEFFECT_DISCARD` forbids, so the
WINDOWED swap effect becomes `D3DSWAPEFFECT_COPY` when the surround is up and multisampling is off
(COPY cannot multisample). `gBTCockpitLetterbox` records the outcome; 0 = the old full-client
stretch, which is what every non-cockpit mode and any MSAA run still gets.
Wired at five seams:
- `L4VIDEO` device setup: swap effect + confirm/withdraw the flag.
- Both Present sites: the scene present AND the wait-screen overlay present (it paints into the
same backbuffer, so it has to land in the same rect).
- `btl4main` WM_SIZE: `InvalidateRect` -- a COPY present only writes its dest rect, so stale bar
content persists without an erase (the class brush is black).
- `BTCockpitMouseDown`: client->canvas through the SAME fit. Mapping against the full client
drifts the hit test off every button by the bar width; a click on a bar now hits nothing.
- `BTWorldAspectOf`: under the letterbox the view's on-screen aspect is its OWN -- the client
drops out of the formula entirely.
**`-fit` / `-windowed-fullscreen`** (btl4main): borderless `WS_POPUP` over the monitor rect, canvas
letterboxed inside. Unknown tokens fall through `L4Application::ParseCommandLine`'s ladder to
`return True`, so no arg-parser change was needed.
### Ordering trap (found live, fixed)
The letterbox flag was originally set at device creation -- but **the first WM_SIZE beats the
device**. A `-fit` boot on the 3440x1440 ultrawide logged `[resize] client 3440x1440 aspect=3.14`
(the stretch formula), and since nothing resizes the window again, the per-frame
`gWindowAspect != appliedAspect` check applied it on frame 1. The INTENT is now decided in
btl4main alongside `gBTGaugeCockpit` (windowed cockpit + MULTISAMPLE unset/0); L4VIDEO only
confirms it or withdraws it. Re-verified: the boot line reads `aspect=1.8`.
### Capture note (for whoever verifies this next)
`PrintWindow` returns an all-black frame for the borderless `-fit` window, while working fine for
the normal chrome'd window. Use a screen-region grab (`scratchpad/shot.ps1`) for `-fit`. The
all-black PrintWindow is NOT a render failure -- the log showed 18 sim ticks and the screen grab
showed the full cockpit.
### Verified
- Wide drag 1684x661 and tall drag 744x961: canvas uniformly scaled and centred, bars on the long
axis, ZERO distortion (captures `fit_wide.png` / `fit_tall.png`).
- `-fit` on 3440x1440: borderless, centred, bars left/right, undistorted.
- **72/72 buttons still dispatch after a resize** -- clickbank.py now maps canvas->client through
its own copy of the fit (an independent check that the two transforms agree): at fit 906x661
with a 389px left bar, all 72 `[cockpit] CLICK` lines land.
- exploded / dock / pod / dev: DISCARD retained, boot + simulate un-regressed.
## 2026-07-26 (later still) — Phase 3: player-tunable displays + the settings file
RP412 replication step 3.
### Scaling (BTCkResolvedSizes, L4VB16.cpp)
`BT_MFD_SCALE` (all five), `BT_MFD_SCALE_UL/_UC/_UR/_LL/_LR` (per display, overriding the group),
`BT_RADAR_SCALE` -- percentages of the pod size, 25-200, resolved ONCE (the surround recomputes its
layout every frame, so reading the env per frame would be silly).
The load-bearing consequence: **the surround BANDS derive from the resolved sizes**. That is why
the sizes could not stay compile-time constants -- the band a display hangs in has to grow with it
or the canvas clips it. Each band is what its largest occupant needs; a corner MFD only claims
`size - kCkOVL` because it overlaps the view; the bottom band drops to just the lower MFDs when the
map leaves that row for a side. Verified 100% reproduces the historical `L276 R276 T223 B336`
byte-for-byte, 150% gives `L436 R436 T343 B448`, 80%+MIDLEFT gives `L256 R212 T175 B148`.
The flight label blocks now hang off their OWN lower MFD's scaled height + lamp strip, instead of
the constant -- a shrunken MFD used to leave the block floating in the gap and an enlarged one to
overlap it.
### Map placement (BT_RADAR_POS)
CENTER (default) / LEFT / RIGHT / MIDLEFT / MIDRIGHT, with the BOTTOM*/`*CENTER` spellings RP
accepts. Bottom corner: the map goes flush to the canvas edge and the lower MFD whose corner it
took slides beside it. Halfway up a side: it leaves the bottom row entirely and the bottom band
shrinks accordingly.
**First cut was wrong and the capture caught it:** the corner map was placed relative to the VIEW
edge, which overlapped the neighbouring MFD by the band width -- 232px of map drawn over the Right
Weapons display. Corner placement is measured off the CANVAS edge.
### The map legend grid -- measured, not inherited
The side columns have to line up with the six cells the map imagery paints beside them. Zooming a
native Secondary/Radar capture shows those cells are exactly our address map: MAP+ 0x10, MAP- 0x11,
IR 0x12 (ThermalSight lamp), crouch 0x13 (DuckRequest), searchlight 0x14, display-mode 0x15 -- the
art independently corroborating [[pod-hardware]].
`scratchpad/measurelegend.py` scans the capture for the legend's vertical rule and for our lamp
bars, and reports both. Ours: **top 3, cell 102, pitch 107** of the 640 span. RP412's map:
`13 + 6x102 @ 105`. Same cell height, different top and pitch -- confirming the deferral call in
Phase 1 was right, the numbers do NOT transfer. Our old even division (`displayH/6 + 1`) had the
pitch right by luck and sat 3px high of the labels; the grid is now pinned to the measurement, with
top and bottom scaled SEPARATELY and subtracted (RP's trick) so rounding cannot walk the buttons
out of step with the labels down a scaled column.
### environ.ini
Two defects, both fixed:
1. **Read far too late.** It sat ~300 lines into WinMain, after the platform-profile block had
already run its getenv()s -- so every setting the profile reads (BT_PLATFORM, BT_COCKPIT,
BT_GLASS_PANELS, BT_DEV_GAUGES, L4CONTROLS...) was silently ignored FROM THE FILE and only ever
worked as a real environment variable. Now loaded immediately after the first-breath boot line.
2. **putenv()'d comments verbatim**, so a commented-out option would have become an environment
variable literally named `#BT_MFD_SCALE`. Comments (`#`, `;`) and blanks are skipped, and lines
with no `=` are counted and ignored.
Added: the real environment WINS over the file (a launcher .bat overrides rather than fights), and
a fully documented default is written on first run when the file is absent -- the bindings.txt
convention (untracked via .gitignore, so extract-over-top never clobbers a player's settings; every
option ships commented out so a fresh install behaves exactly as before).
Verified live, all three paths: first run writes 3886 bytes and applies `0 setting(s)`; uncommenting
`BT_MFD_SCALE=130` + `BT_MFD_SCALE_UC=60` + `BT_RADAR_POS=MIDRIGHT` in the FILE drives the layout
(`displays UL 130% UC 60% ... radar on the right side, centred`); and `set BT_MFD_SCALE=90` beats
the file's 130 while the file's other two settings still apply.
### Verified
- 72/72 placed, 0 shadowed, 72/72 dispatched at baseline, at 150%/135%, and at 75% + BOTTOMRIGHT.
- exploded / dock / pod / dev boot and simulate un-regressed.
- Captures: p3_big2 (150%), p3_radleft2 (bottom-left + slid MFD), p3_radmidl (side-mounted map at
80%), p3_fromini (settings taken from the file).
## 2026-07-26 (final) — Phases 4-6: the dead-button backlog, the lamp mirror, the shipped doc
### Phase 4 — Mech::DuckRequest, and a census that lied in both directions
The 2026-07-20 audit listed 8 buttons with no reconstructed handler. **Only ONE was still
missing.** Generator on/off (`powersub.cpp`), ToggleSeekVoltage (`emitter.cpp`), EjectAmmo
(`projweap.cpp`), ToggleCooling (`heatfamily_reslice.cpp`) and BalanceCoolant (`mech.cpp`) had all
landed between 07-20 and 07-25 while `pod-hardware.md` and `open-questions.md` still called them
dead. `docs/INPUT_PATH_AUDIT.md:190` had already flagged this ("census stale in both directions")
and was right. Rule for next time: **grep the reconstruction, not the prose.**
`Mech::DuckRequest` @0049fa00 (id 0x1a, RIO 0x13 -- the manual's CROUCH button). Binary body, in
full:
if (0 < *(int *)(param_2 + 0xc)) { *(undefined4 *)(param_1 + 0x398) = 1; }
Press-only; sets `duckState` (`mech+0x398`, attribute 0x37). A one-shot REQUEST flag -- the
handler never clears it and the only other writer in the entire binary is the mech reset
(part_012.c:9439, the same reset that zeroes `incomingLock`, which is how that region was already
mapped). Registered in `Mech::MessageHandlerEntries`; `[duck]` log on press.
**Why the flag has no reader, and why that is correct.** `duckState` has ZERO readers anywhere in
the decomp. It is published as an ATTRIBUTE, so its consumer is a databinding:
`content/GAUGE/L4GAUGE.CFG` runs `oneOfSeveralPixInt(E, ModeAlwaysActive, bduck.pcc, 3, 1,
DuckState)` -- "crouch mode: button 4" on the map's legend column. Verified live by capturing the
Secondary/Radar window before and after a 0x13 press: the crouch icon goes grey -> orange. So the
handler is COMPLETE; a crouch pose invented here would be a stand-in for data we have not found
(no SQUAT clip name survives in the decomp or in content/, only DuckServo01.wav in AUDIO1.RES).
Bonus: those gauge widgets sit at offsets 537/430/322/215/108 -- a **107 pitch**, independently
corroborating the map legend grid measured from pixels in Phase 3.
Still open from that census: MechRIOMapper's own Keypress @004d2514 (id 0x19).
### Phase 5 — L4KEYLIGHT (RGB keyboard lamp mirror)
Ported from RP412 (itself a port of vRIO's KeyboardLampMirror). Keys bound to a lamp address in
bindings.txt glow with the panel palette -- yellow for the map's side columns (0x10-0x1F), red for
the rest -- flashing in step with the on-screen buttons (the LampLevel copy matches the FIXED
BTLampBrightnessOf from Phase 1). Per-key boards light each bound key; zone-lit boards mirror the
strongest lamp board-wide. All WinRT runs on a private worker thread (watcher, claim, 100 ms paint
loop, repaint only on change).
**The packing hazard did NOT transfer.** RP412 compiles this file with forced default struct
packing because its engine is `/Zp1`, which would break the WinRT ABI. BT411's `BT_OPTS` is
`/permissive /W0 /wd4996 /EHsc /bigobj /MP` -- no `/Zp` -- so only the dialect flags are needed:
`set_source_files_properties(... COMPILE_OPTIONS "/std:c++17;/permissive-")`, since the project
otherwise builds C++14 /permissive. The scalars-only interface is kept regardless.
Wired in `PadRIO`: map built from `bindings.keyBindings` (ActionButton binds only, first binding
per key wins, addresses < LampCount), fed from `PadRIO::SetLamp`, stopped in the dtor (which hands
the LEDs back to Windows). Gate `BT_KEYLIGHT=0`.
Verified live: `[keylight] mirroring 25 bound key(s)` + `+ LAPTOP-767CF84B (24 zones - board-wide
mirror)` -- it claimed this machine's real keyboard. `BT_KEYLIGHT=0` -> zero keylight lines, game
fine. Pod profile (no PadRIO) -> zero lines. Exploded -> works.
### Phase 6 — the shipped controls reference
`docs/CONTROLS.md`: the keyboard table plus **the full 72-button pod map**, grouped by the display
each bank surrounds, with the coolant-valve detent warning and the eject/jam procedure. mkdist
flattens it to ASCII as `CONTROLS.txt` at the zip root (the README's own flattening idiom).
`players/README.txt` gained pointers to it, to environ.ini, to -fit, and to the RGB mirror.
Verified: `mkdist.py` writes a 5149-byte pure-ASCII CONTROLS.txt into the zip.
## 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.
+130
View File
@@ -0,0 +1,130 @@
# MOUSELOOK_PLAN — scope: mouse movement as a cockpit axis
**Status: SCOPED, NOT IMPLEMENTED.** Written 2026-07-26 at the author's request, alongside the
mouse-BUTTON support that did ship (4.11.565). Read §2 before agreeing to build it — the hard
part is not reading the mouse.
---
## 1. What shipped, and what this is instead
**Shipped:** mouse *buttons*, as key names (`MOUSE4`, `MOUSE5`, `MOUSEMIDDLE`, `MOUSELEFT`,
`MOUSERIGHT`). Zero new machinery — Win32 hands mouse buttons out as virtual keys and the binding
path already stores VKs and polls `GetAsyncKeyState`, so five table entries were the whole change.
**This document:** mouse *movement* driving `JoystickX`/`JoystickY` (torso twist and aim) — the
thing a modern player means by "mouse look". That is a real feature, not a table entry, for three
reasons that have nothing to do with reading the device.
## 2. The three real problems
### 2.1 The cursor is already spoken for ⚠ (the blocking one)
The glass cockpit's whole premise is that **every one of the 72 pod buttons is a mouse target**
left-click presses, right-click latches, and under the exploded layout there are seven separate
windows to click in. Mouse-look wants the opposite: a captured, hidden, recentred cursor that
never touches a button.
These cannot both be true at once, so the feature is really a **mode question**, and the answer
decides the whole design:
| Model | Feel | Cost |
|---|---|---|
| **A. Hold-to-look** (`key MOUSE5 mouselook` or a modifier) | Cursor stays free; press to steer, release to click buttons | No mode confusion, discoverable, but you cannot look and hold a trigger comfortably |
| **B. Toggle** (a key flips captured/free) | Full mouse-look while engaged | Needs a visible on-screen indicator or players get stuck "unable to click anything" |
| **C. Region** (look while the pointer is over the 3D view, click when over a display) | No mode at all | Fails in the exploded layout (displays are separate windows) and near the surround's corner MFDs, which overlap the view by design |
| **D. Always captured**, panel clicks via a "release" key | Cleanest for a shooter | Throws away the cockpit's defining interaction; **not recommended** |
**Recommendation: A, with B as an option.** A costs nothing when unbound and cannot strand a
player. It is also the only one that behaves identically in the surround and exploded layouts.
### 2.2 The pod's channels are POSITIONAL; a mouse is not
`JoystickX`/`JoystickY` are absolute stick deflections in 1..1 that `MechControlsMapper`
interprets per control mode. A mouse produces *deltas*. Bridging that is a design choice, not a
conversion:
- **Accumulate** (recommended): integrate deltas into the channel, clamp to ±1 — a "virtual
stick". Reuses every existing rule (composition, the #36 release edge, the gait detent).
- **Rate**: treat the delta as a velocity of torso angle. Feels better for aiming, but the mapper
has no rate input — it would mean a new channel semantic and a new path through the mapper.
Bigger and less authentic.
With Accumulate, the stick does not spring back (a mouse has no centre). Options, all cheap:
a `spring <rate>` trailing option that decays toward 0, and/or a recentre key (0x42 torso centre
is already bound to the Up arrow).
### 2.3 Control mode changes what it means
BASIC steers with the stick; MID/ADV twist the torso with it and steer with the pedals
([[experience-levels]], [[pod-hardware]]). So mouse-look **steers the mech in BASIC and aims the
torso in MID/ADV** — authentic, but it will read as a bug to anyone who hasn't been told. The
docs must say so, and the feature is most useful in MID/ADV.
## 3. Proposed shape (if we build it)
New row type — the sanctioned way to extend the grammar (old builds skip unknown rows with a
logged warning, so a player's file stays forward-compatible):
```
mouseaxis <X|Y> axis <channel> [invert] [sensitivity <n>] [spring <rate>] [deadzone <f>]
key <KEY> mouselook # hold-to-look (model A)
```
Example default (shipped commented out):
```
#mouseaxis X axis JoystickX sensitivity 1.0 spring 0
#mouseaxis Y axis JoystickY sensitivity 1.0 spring 0 invert
#key MOUSE5 mouselook
```
### Where the code goes
| Piece | Where | Notes |
|---|---|---|
| Raw delta capture | `L4PADRIO` (new), fed from the window proc | **`WM_INPUT` (RawInput)**, not `WM_MOUSEMOVE`: relative motion, no cursor clamping at screen edges, no pointer acceleration. Register `HID_USAGE_GENERIC_MOUSE` with `RIDEV_INPUTSINK` off (foreground only). |
| Capture/hide | same | `SetCapture` + `ShowCursor(FALSE)` + recentre per frame while engaged; release on focus loss |
| Bindings | `L4PADBINDINGS` | `mouseAxisBindings[]` + the `mouselook` action, parallel to `padAxisBindings` |
| Channel write | `PadRIO::Poll` | Beside the pad/joy axis writes, with the SAME per-binding previous-value array so it inherits the #36 release edge and the #24 disconnect rule |
| Docs | `bindings.txt` header, `docs/CONTROLS.md`, `docs/CONTROLS.html` | including the control-mode caveat |
Estimated size: ~250 lines plus docs. No engine or reconstruction changes — this is entirely in
the glass desktop layer, like PadRIO itself.
## 4. Authenticity note
**The pod had no mouse.** There is nothing to reconstruct here and no binary behaviour to be
faithful to — this is a desktop-convenience layer, exactly like PadRIO and the glass cockpit
itself. That is fine (the layer exists precisely so developers and players can fly without a
cabinet), but it means every decision above is a *design* decision and should be judged on feel,
not fidelity. It also means it must stay out of the pod build's path: gate it the way PadRIO is
gated, so `BT_PLATFORM=pod` never sees it.
## 5. Verification plan
1. `mouseaxis` rows parse; unknown-option rows warn and skip (grammar-compatibility rule).
2. Hold-to-look engages/releases; cursor visible again on release AND on focus loss (the #24
disconnect rule, mouse edition).
3. Channel values track motion, clamp at ±1, and release cleanly to 0 (the #36 latch bug is
exactly the hazard here).
4. **Panel clicks still work** while a mouselook binding exists but is not engaged — in BOTH the
surround and exploded layouts. This is the regression that matters.
5. Control-mode sweep: BASIC steers, MID/ADV twists — confirmed against `[mppr]` trace.
6. Pod + dev profiles: no mouse path constructed at all.
## 6. Open questions
1. Model A or B (§2.1)? Everything else follows from it.
2. Should mouse-look imply a crosshair/reticle change, so the player can see it is engaged?
3. Does the exploded layout need its own answer, given the seven windows each have their own
client area and the main window may not have focus?
4. Is this wanted for the tester builds at all, or only for the operator/dev seat? It is not
pod-authentic, and the cockpit's mouse-click identity is a feature people have praised.
## Key Relationships
- Extends: [[glass-cockpit]] (PadRIO, the desktop input layer) · Constrained by: [[pod-hardware]]
(the RIO channel model), [[experience-levels]] (control modes)
- Prior art in-tree: `L4JOY` (DirectInput layer + the wizard) is the closest analogue for adding
a whole new input device cleanly.
+80 -114
View File
@@ -13,6 +13,7 @@
#include "l4glasswin.h"
#include "l4padrio.h"
#include "l4vb16.h"
#include "l4riobank.h" // the shared button-bank geometry (with the surround)
#include "../munga/gaugrend.h"
#include <windows.h>
@@ -41,23 +42,17 @@ int
}
//###########################################################################
// Layout constants (tunable -- Cyd flagged refinements to the exact button
// sub-positions + the yellow left/right split).
// Window constants. The BUTTON GEOMETRY (how far a bank reaches under the
// glass, the lamp strip, the column pitch, the per-column nudge against the
// imagery legends) moved to L4RIOBANK on 2026-07-26 so the cockpit surround
// draws the identical field; what is left here is this window's own chrome.
//###########################################################################
enum
{
Margin = 0, // no padding around the edges (surface fills the window)
TitleH = 0, // no in-client title band (the OS caption labels the window)
CellW = 58, // default button cell (flight-controls window)
CellW = 58, // flight-controls cell (no glass behind it)
CellH = 28,
RedCellH = 128, // red MFD buttons -- tall, extend ~100px into the display
EdgeProtrude = 10, // ...and this far OUT past the display edge
Gap = 4,
RailW = 40, // radar side-rail button thickness (rotated 90deg, fill height)
RailSlotAdd = 1, // rail pitch = 640/6 + 1 = 107 -> 104px buttons + 3px gaps = 639 (fits 640)
RailGap = 3, // vertical gap between rail buttons (own value; red/horizontals use Gap)
RedOffsetX = 5, // nudge the red MFD buttons right to line up with the imagery
MfdSurfW = 640, MfdSurfH = 480, // mono MFD surface at native 640x480
RadarSurfW = 480, RadarSurfH = 640, // portrait secondary/radar CRT at native (rotated 640x480)
RepaintTimerId = 1,
@@ -67,11 +62,6 @@ enum
enum ColorClass { ClrRed, ClrYellow, ClrBlue };
// Per-column horizontal nudge (px) for the 4 red MFD buttons, left->right, to line
// them up with the (unevenly-spaced) imagery legends. Tunable -- Cyd is dialing
// these in against DISPLAY / PROGRAM / etc. Applied on top of RedOffsetX.
static const int kRedColDX[4] = { -5, -4, 0, 0 };
struct GButton
{
int address;
@@ -121,8 +111,8 @@ struct NamedButton { int address; const char *name; };
static const NamedButton namedButtons[] =
{
{ 0x3D, "Panic" }, { 0x3F, "Throttle" },
{ 0x40, "Main" }, { 0x41, "Hat Bk" }, { 0x42, "Hat Up" },
{ 0x43, "Hat R" }, { 0x44, "Hat L" }, { 0x45, "Pinky" },
{ 0x40, "Main" }, { 0x41, "Look Bk" }, { 0x42, "Torso Ctr" },
{ 0x43, "Look R" }, { 0x44, "Look L" }, { 0x45, "Pinky" },
{ 0x46, "Middle"}, { 0x47, "Upper" },
};
enum { NamedButtonCount = sizeof(namedButtons) / sizeof(namedButtons[0]) };
@@ -137,71 +127,53 @@ static const char *
}
//###########################################################################
// Lamp decode (identical to L4PADPANEL::LampBrightnessOf -- RIOBase::LampState)
// Lamp decode: the ONE copy lives in l4vb16.h (BTLampBrightnessOf). This TU
// used to carry its own, as did L4PADPANEL -- three copies of the same
// formula, all three carrying the same dim-to-bright flash bug (see the
// header's 2026-07-26 note).
//###########################################################################
static int
LampBrightnessOf(int state, unsigned long tick)
{
int level1 = (state >> 2) & 0x3;
int level2 = (state >> 4) & 0x3;
int level = (level1 > level2) ? level1 : level2;
if (level == 0)
return 0;
int flash = state & 0x3;
if (flash != 0)
{
unsigned long half_period = (flash == 1) ? 500 : (flash == 2) ? 250 : 125;
if ((tick / half_period) & 1)
return 0;
}
return level;
return BTLampBrightnessOf(state, tick);
}
//###########################################################################
// Layout builders
//###########################################################################
static void
PlaceCellAt(GWin &w, int address, int x, int y, ColorClass color)
{
GButton &b = w.buttons[w.buttonCount++];
b.address = address;
b.color = color;
b.rect.left = x + 2;
b.rect.top = y + 2;
b.rect.right = x + CellW - 2;
b.rect.bottom = y + CellH - 2;
}
// (The local PlaceCellAt / PlaceLine / PlaceRect placers retired 2026-07-26 --
// the geometry they carried now comes from L4RIOBANK, shared with the cockpit
// surround. AdoptBank below is the only thing that writes GButton rects.)
//---------------------------------------------------------------------------
// Copy a laid-out bank into this window at the given offset. A glass window
// passes -bounds so the bank's bounding box lands at the client origin (the
// bank reaches OUTSIDE the display, so its rects arrive with coordinates
// left of / above it); the flight window, which has no glass, passes 0.
//---------------------------------------------------------------------------
static void
PlaceLine(GWin &w, int startAddr, int count, int step,
int x, int y, int horizontal, ColorClass color)
AdoptBank(GWin &w, const BTRioBank &bank, ColorClass color, int dx, int dy)
{
for (int i = 0; i < count; ++i)
for (int i = 0; i < bank.buttonCount &&
w.buttonCount < (int)(sizeof(w.buttons) / sizeof(w.buttons[0])); ++i)
{
int cx = horizontal ? (x + i * CellW) : x;
int cy = horizontal ? y : (y + i * CellH);
PlaceCellAt(w, startAddr + i * step, cx, cy, color);
const BTRioButton &src = bank.buttons[i];
GButton &b = w.buttons[w.buttonCount++];
b.address = src.address;
b.color = (src.colorClass == 1) ? ClrYellow
: (src.colorClass == 2) ? ClrBlue : color;
b.rect.left = src.x + dx;
b.rect.top = src.y + dy;
b.rect.right = src.x + dx + src.w;
b.rect.bottom = src.y + dy + src.h;
}
}
// Place a button with an explicit size (custom-shaped cells: wide red buttons,
// tall rotated radar rails).
static void
PlaceRect(GWin &w, int address, int x, int y, int cw, int ch, ColorClass color)
{
GButton &b = w.buttons[w.buttonCount++];
b.address = address;
b.color = color;
b.rect.left = x;
b.rect.top = y;
b.rect.right = x + cw;
b.rect.bottom = y + ch;
}
// An MFD display: 8 red buttons split 4 above / 4 below the surface (top row =
// the high 4 addresses descending, matching the L4PADPANEL cluster order).
// Geometry is L4RIOBANK's -- the under-glass rule, shared with the surround.
static void
BuildMfd(GWin &w, const char *title, const char *portP, const char *portA, int bankHi)
{
@@ -212,23 +184,19 @@ static void
w.monoTint = MfdMonoTint;
w.rotate = 0;
int surfX = Margin;
int surfY = Margin + TitleH + EdgeProtrude; // room for the top buttons to poke out
BTRioBankMetrics metrics;
BTRioBankMetricsFor(MfdSurfW, MfdSurfH, &metrics);
// Buttons OVERLAY the display: 4 tile the full width, reaching RedCellH INTO the
// display and EdgeProtrude OUT past the edge (top row + bottom row; hi addresses
// descending -- L4PADPANEL order).
int slotW = MfdSurfW / 4;
for (int i = 0; i < 4; ++i)
{
int x = surfX + i * slotW + RedOffsetX + kRedColDX[i];
PlaceRect(w, bankHi - i, x, surfY - EdgeProtrude, slotW - Gap, RedCellH + EdgeProtrude, ClrRed); // top
PlaceRect(w, bankHi - 4 - i, x, surfY + MfdSurfH - RedCellH, slotW - Gap, RedCellH + EdgeProtrude, ClrRed); // bottom
}
SetRect(&w.surfaceRect, surfX, surfY, surfX + MfdSurfW, surfY + MfdSurfH);
BTRioBank bank;
BTRioBankLayout(BTRioBankMfd, 0, 0, MfdSurfW, MfdSurfH,
bankHi, 0, &metrics, 0, &bank);
w.clientW = Margin + MfdSurfW + Margin;
w.clientH = surfY + MfdSurfH + EdgeProtrude + Margin;
BTRioBankDump(title, &bank);
int dx = -bank.boundsX, dy = -bank.boundsY;
AdoptBank(w, bank, ClrRed, dx, dy);
SetRect(&w.surfaceRect, dx, dy, dx + MfdSurfW, dy + MfdSurfH);
w.clientW = bank.boundsW;
w.clientH = bank.boundsH;
}
// The secondary/radar display: 12 yellow Secondary buttons split left / right
@@ -243,37 +211,23 @@ static void
w.monoTint = -1; // palette
w.rotate = gRadarRot;
int surfX = Margin + EdgeProtrude; // room for the left rail to poke out
int surfY = Margin + TitleH;
// Rails: big clickable strips that reach RailW INTO the display (hidden under the
// imagery) and poke EdgeProtrude OUT past the side edge (the lamp light).
// Left 0x10..0x15, right 0x18 (top)..0x1D (bottom). (0x15 kept on the left rail
// -- Cyd listed 0x10-0x14; move it if wanted.)
int railCount = 6;
int slotH = RadarSurfH / railCount + RailSlotAdd;
int leftX = surfX - EdgeProtrude;
int rightX = surfX + RadarSurfW - RailW;
for (int i = 0; i < railCount; ++i)
{
int y = surfY + i * slotH;
PlaceRect(w, 0x10 + i, leftX, y, EdgeProtrude + RailW, slotH - RailGap, ClrYellow);
PlaceRect(w, 0x18 + i, rightX, y, RailW + EdgeProtrude, slotH - RailGap, ClrYellow);
}
SetRect(&w.surfaceRect, surfX, surfY, surfX + RadarSurfW, surfY + RadarSurfH);
// Bottom row: 16, 17, 1F, 1E (left to right), BETWEEN the rails -- reaches CellH
// up into the display, pokes EdgeProtrude out the bottom.
// Left column 0x10-0x15, right 0x18-0x1D, foot row 0x16/0x17/0x1F/0x1E.
// (0x15 sits on the left column -- Cyd listed 0x10-0x14; move it if wanted.)
static const int bottomAddrs[4] = { 0x16, 0x17, 0x1F, 0x1E };
int botLeft = surfX + RailW;
int botSpan = RadarSurfW - 2 * RailW;
int botW = botSpan / 4;
int botY = surfY + RadarSurfH - CellH;
for (int i = 0; i < 4; ++i)
PlaceRect(w, bottomAddrs[i], botLeft + i * botW, botY, botW - Gap, CellH + EdgeProtrude, ClrYellow);
w.clientW = surfX + RadarSurfW + EdgeProtrude + Margin;
w.clientH = surfY + RadarSurfH + EdgeProtrude + Margin;
BTRioBankMetrics metrics;
BTRioBankMetricsFor(RadarSurfW, RadarSurfH, &metrics);
BTRioBank bank;
BTRioBankLayout(BTRioBankRadar, 0, 0, RadarSurfW, RadarSurfH,
0x10, 0x18, &metrics, bottomAddrs, &bank);
BTRioBankDump("Secondary / Radar", &bank);
int dx = -bank.boundsX, dy = -bank.boundsY;
AdoptBank(w, bank, ClrYellow, dx, dy);
SetRect(&w.surfaceRect, dx, dy, dx + RadarSurfW, dy + RadarSurfH);
w.clientW = bank.boundsW;
w.clientH = bank.boundsH;
}
// The Flight Controls window: the no-display banks -- throttle/panic/door/icom
@@ -286,15 +240,27 @@ static void
w.portPrimary = NULL;
w.showLabels = 1; // the only window that shows hex/name on its buttons
int topY = Margin + TitleH;
int col1 = Margin;
int col2 = Margin + CellW + Gap;
// No glass to hide behind, so these cells carry their own size (see
// l4riobank.h) -- two 1-wide columns, eight rows each. Cell minus the
// 4px gap keeps the historical CellW/CellH PITCH and the 2px inset the
// old PlaceCellAt drew, so the window is pixel-identical.
BTRioBankMetrics metrics;
metrics.strip = 0;
metrics.gap = Gap;
metrics.cellW = CellW - Gap;
metrics.cellH = CellH - Gap;
PlaceLine(w, 0x38, 8, +1, col1, topY, 0, ClrBlue); // throttle / panic / door / icom
PlaceLine(w, 0x40, 8, +1, col2, topY, 0, ClrBlue); // joystick + fire buttons
BTRioBank bank;
memset(&bank, 0, sizeof(bank));
BTRioBankFlightGrid(2, 2, 0x38, 8, 1, 0, 0, &metrics, &bank); // throttle/panic/door/icom
BTRioBankFlightGrid(CellW + Gap + 2, 2, 0x40, 8, 1, 0, 0, &metrics, &bank); // joystick + fire
w.clientW = col2 + CellW + Margin;
w.clientH = topY + 8 * CellH + Margin;
BTRioBankDump("Flight Controls", &bank);
AdoptBank(w, bank, ClrBlue, 0, 0);
// no translation here (the cells are already at their 2px inset); the
// client is the historical two-column extent
w.clientW = CellW + Gap + CellW;
w.clientH = 8 * CellH;
}
//###########################################################################
+406
View File
@@ -0,0 +1,406 @@
//===========================================================================//
// L4KEYLIGHT.cpp - Windows Dynamic Lighting keyboard mirror.
//
// Ported from RP412's L4KEYLIGHT (itself a port of vRIO's
// KeyboardLampMirror). Keys bound to a lamp address in bindings.txt glow
// with the panel palette, so the keyboard reads as the pod's button field.
//
// COMPILED APART FROM THE REST: /std:c++17 + conformance mode, per-file in
// CMakeLists (C++/WinRT needs both; the project builds C++14 /permissive).
// NOTE: RP412 also had to force DEFAULT struct packing here because its
// engine compiles /Zp1, which would break the WinRT ABI -- BT411 does NOT
// set /Zp (checked 2026-07-26: BT_OPTS is /permissive /W0 /wd4996 /EHsc
// /bigobj /MP), so that hazard does not apply. The scalars-only interface
// in l4keylight.h is kept anyway: it costs nothing and keeps this TU
// isolated if packing is ever added.
//
// Everything WinRT runs on a private worker thread: device watcher,
// keyboard claiming, and a 100 ms paint loop that mirrors the lamp bytes
// (the same flash formula as BTLampBrightnessOf, so the board and the
// on-screen cockpit buttons blink in step).
//===========================================================================//
#include <windows.h>
#include <atomic>
#include <mutex>
#include <thread>
#include <vector>
#include <cstring>
#include <cstdio>
#include <winrt/base.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Foundation.Collections.h>
#include <winrt/Windows.Devices.Enumeration.h>
#include <winrt/Windows.Devices.Lights.h>
#include <winrt/Windows.System.h>
#include <winrt/Windows.UI.h>
#pragma comment(lib, "windowsapp.lib")
#include "l4keylight.h"
namespace
{
using namespace winrt;
using namespace winrt::Windows::Devices::Enumeration;
using namespace winrt::Windows::Devices::Lights;
using winrt::Windows::UI::Color;
using winrt::Windows::System::VirtualKey;
struct KeyEntry
{
int virtualKey;
int address;
bool yellow;
};
struct ClaimedArray
{
hstring id;
LampArray array{ nullptr };
bool perKey = false;
bool baseCoated = false;
};
std::mutex gLock;
std::vector<KeyEntry> gMap;
unsigned char gLamps[64] = {};
void (*gLogger)(const char *) = nullptr;
std::atomic<bool> gRunning{ false };
std::thread gWorker;
void Log(const char *line)
{
void (*logger)(const char *);
{
std::lock_guard<std::mutex> hold(gLock);
logger = gLogger;
}
if (logger != nullptr)
{
logger(line);
}
}
void Logf(const char *format, ...)
{
char line[256];
va_list args;
va_start(args, format);
_vsnprintf_s(line, sizeof(line), _TRUNCATE, format, args);
va_end(args);
Log(line);
}
//---------------------------------------------------------------
// Lamp byte -> brightness level, animating the flash modes.
// MUST match BTLampBrightnessOf (l4vb16.h) exactly, so the board and
// the on-screen buttons blink together: solid shows state 1, flashing
// ALTERNATES state 1 and state 2 (RIO::LampState, L4RIO.h). Copied
// rather than included because this TU takes no engine headers.
//---------------------------------------------------------------
int LampLevel(int lamp_state)
{
int mode = lamp_state & 0x03;
int level1 = (lamp_state >> 2) & 0x03;
int level2 = (lamp_state >> 4) & 0x03;
if (mode == 0)
{
return level1;
}
static const int half_period[4] = { 0, 500, 250, 125 };
return ((GetTickCount() / half_period[mode]) & 1) ? level2 : level1;
}
//---------------------------------------------------------------
// The panel palette (vRIO's KeyboardLampMirror shades): red for
// the banks, yellow for Secondary/Screen; the off shade keeps the
// bound keys faintly visible so the board reads as a button field.
//---------------------------------------------------------------
Color Shade(int level, bool yellow)
{
Color color;
color.A = 255;
if (yellow)
{
if (level >= 3) { color.R = 245; color.G = 210; color.B = 60; }
else if (level >= 1) { color.R = 140; color.G = 118; color.B = 38; }
else { color.R = 70; color.G = 60; color.B = 24; }
}
else
{
if (level >= 3) { color.R = 230; color.G = 70; color.B = 70; }
else if (level >= 1) { color.R = 120; color.G = 50; color.B = 50; }
else { color.R = 64; color.G = 40; color.B = 40; }
}
return color;
}
bool SameColor(const Color &a, const Color &b)
{
return a.A == b.A && a.R == b.R && a.G == b.G && a.B == b.B;
}
//---------------------------------------------------------------
// The worker: watcher + claim + paint loop
//---------------------------------------------------------------
void Worker()
{
try
{
init_apartment();
}
catch (...)
{
// apartment already set on this thread; carry on
}
std::mutex claimedLock;
std::vector<ClaimedArray> claimed;
bool anySeen = false;
DeviceWatcher watcher{ nullptr };
try
{
watcher = DeviceInformation::CreateWatcher(LampArray::GetDeviceSelector());
watcher.Added([&](DeviceWatcher const &, DeviceInformation const &info)
{
try
{
LampArray array = LampArray::FromIdAsync(info.Id()).get();
if (array == nullptr ||
array.LampArrayKind() != LampArrayKind::Keyboard)
{
return; // mice / strips / cases stay untouched
}
anySeen = true;
ClaimedArray entry;
entry.id = info.Id();
entry.array = array;
entry.perKey = array.SupportsVirtualKeys();
{
std::lock_guard<std::mutex> hold(claimedLock);
claimed.push_back(entry);
}
Logf(entry.perKey
? "KeyLight: + %ls (%d LEDs, per-key)"
: "KeyLight: + %ls (%d zones - board-wide mirror)",
info.Name().c_str(), (int) array.LampCount());
}
catch (...)
{
Log("KeyLight: could not open a lamp array");
}
});
watcher.Removed([&](DeviceWatcher const &, DeviceInformationUpdate const &update)
{
std::lock_guard<std::mutex> hold(claimedLock);
for (size_t i = 0; i < claimed.size(); ++i)
{
if (claimed[i].id == update.Id())
{
claimed.erase(claimed.begin() + i);
Log("KeyLight: keyboard disconnected");
break;
}
}
});
watcher.Updated([](DeviceWatcher const &, DeviceInformationUpdate const &)
{
// required for the watcher to progress
});
watcher.Start();
}
catch (...)
{
Log("KeyLight: Dynamic Lighting unavailable on this system");
return;
}
//
// Paint loop: 100 ms cadence, repaint only on change
//
std::vector<Color> lastColors;
int waited = 0;
while (gRunning.load())
{
Sleep(50);
waited += 50;
if (waited < 100)
{
continue;
}
waited = 0;
std::vector<KeyEntry> map;
unsigned char lamps[64];
{
std::lock_guard<std::mutex> hold(gLock);
map = gMap;
memcpy(lamps, gLamps, sizeof(lamps));
}
std::vector<Color> colors(map.size());
std::vector<VirtualKey> keys(map.size());
int bestLevel = 0;
bool bestYellow = false;
bool changed = (lastColors.size() != map.size());
for (size_t i = 0; i < map.size(); ++i)
{
int address = map[i].address;
int level = (address >= 0 && address < 64)
? LampLevel(lamps[address]) : 0;
if (level > bestLevel)
{
bestLevel = level;
bestYellow = map[i].yellow;
}
colors[i] = Shade(level, map[i].yellow);
keys[i] = (VirtualKey) map[i].virtualKey;
if (!changed && !SameColor(colors[i], lastColors[i]))
{
changed = true;
}
}
std::lock_guard<std::mutex> hold(claimedLock);
bool freshClaim = false;
for (ClaimedArray &entry : claimed)
{
if (!entry.baseCoated)
{
freshClaim = true;
}
}
if (!changed && !freshClaim)
{
continue;
}
Color aggregate = Shade(bestLevel, bestYellow);
for (ClaimedArray &entry : claimed)
{
try
{
if (entry.perKey)
{
if (!entry.baseCoated)
{
Color black;
black.A = 255; black.R = 0; black.G = 0; black.B = 0;
entry.array.SetColor(black);
entry.baseCoated = true;
}
if (!map.empty())
{
entry.array.SetColorsForKeys(
array_view<Color const>(colors.data(), colors.data() + colors.size()),
array_view<VirtualKey const>(keys.data(), keys.data() + keys.size()));
}
}
else
{
entry.baseCoated = true;
entry.array.SetColor(aggregate);
}
}
catch (...)
{
// device wobble; the watcher handles removal
}
}
lastColors = colors;
}
//
// Releasing the arrays hands the LEDs back to Windows
//
try
{
watcher.Stop();
}
catch (...)
{
}
{
std::lock_guard<std::mutex> hold(claimedLock);
claimed.clear();
}
if (!anySeen)
{
Log("KeyLight: no Dynamic Lighting keyboard was found this session");
}
}
}
//########################################################################
// The scalar interface (safe across the packing boundary)
//########################################################################
void
KeyLight_SetLogger(void (*logger)(const char *line))
{
std::lock_guard<std::mutex> hold(gLock);
gLogger = logger;
}
void
KeyLight_SetMap(
const int *virtual_keys,
const int *addresses,
const unsigned char *yellow,
int count
)
{
std::lock_guard<std::mutex> hold(gLock);
gMap.clear();
gMap.reserve(count);
for (int i = 0; i < count; ++i)
{
KeyEntry entry;
entry.virtualKey = virtual_keys[i];
entry.address = addresses[i];
entry.yellow = (yellow[i] != 0);
gMap.push_back(entry);
}
}
void
KeyLight_UpdateLamps(const unsigned char *lamp_state, int count)
{
if (count > 64)
{
count = 64;
}
std::lock_guard<std::mutex> hold(gLock);
memcpy(gLamps, lamp_state, count);
}
void
KeyLight_Start()
{
if (gRunning.exchange(true))
{
return;
}
gWorker = std::thread(Worker);
}
void
KeyLight_Stop()
{
if (!gRunning.exchange(false))
{
return;
}
if (gWorker.joinable())
{
gWorker.join();
}
}
+175 -63
View File
@@ -61,8 +61,42 @@ static const NamedValue keyNames[] =
{ "BACKTICK", VK_OEM_3 }, { "LBRACKET", VK_OEM_4 },
{ "BACKSLASH", VK_OEM_5 }, { "RBRACKET", VK_OEM_6 },
{ "QUOTE", VK_OEM_7 },
//
// MOUSE BUTTONS (2026-07-26). Win32 hands these out as virtual keys, and
// the whole binding path already stores VKs and reads them with
// GetAsyncKeyState -- so they need no verb, no parser change and no new
// machinery, just a name. `key MOUSE4 button 0x40` works exactly like any
// other key row, axes included.
//
// ⚠ MOUSELEFT/MOUSERIGHT are how the player presses cockpit buttons (left
// = press, right = latch). The poll is focus-guarded but not click-aware,
// so binding either ALSO fires on every panel click. They are named here
// for completeness; the middle and side buttons are the free ones.
//
{ "MOUSELEFT", VK_LBUTTON }, { "MOUSERIGHT", VK_RBUTTON },
{ "MOUSEMIDDLE", VK_MBUTTON },
{ "MOUSE4", VK_XBUTTON1 }, { "MOUSE5", VK_XBUTTON2 },
{ "MOUSEX1", VK_XBUTTON1 }, { "MOUSEX2", VK_XBUTTON2 },
};
//
// A mouse button rather than a keyboard key? (Used where a binding only
// makes sense for a real key -- the RGB lamp mirror, which paints keycaps.)
//
static int
IsMouseVirtualKey(int virtualKey)
{
return (virtualKey == VK_LBUTTON || virtualKey == VK_RBUTTON ||
virtualKey == VK_MBUTTON || virtualKey == VK_XBUTTON1 ||
virtualKey == VK_XBUTTON2) ? 1 : 0;
}
int
BTPadBindingIsMouseKey(int virtualKey)
{
return IsMouseVirtualKey(virtualKey);
}
static const NamedValue padButtonNames[] =
{
{ "A", XINPUT_GAMEPAD_A },
@@ -161,20 +195,35 @@ static int
// the 12 secondary-panel buttons, 0x00-0x0F the lower aux banks, 0x20-0x37
// the upper aux banks. Keypad unit 0 = the pilot's MFD keypad.
//
// KEYBOARD RECONCILIATION (2026-07-20, input-coverage audit): the keyboard
// carries the ~20 core gameplay actions on the SAME keys the desktop
// CONTROLS.MAP uses (W/S/A/D drive, Q/E twist, R/F elevation, X all-stop,
// 1-4/Space/Ctrl fire, M/N mode/display cycle, H flush, C valve, B look
// behind, G configure, V view); FULL pod-address coverage lives on the
// on-screen panel (every one of the 72 buttons is one click away,
// right-click = hold latch). Keys DISPLACED from the previous default
// (each still reachable by editing bindings.txt or on the panel):
// - number row 1-0/-/= -> secondary-panel 0x10-0x1D (1-4 now fire)
// - QWERTY row Q..P -> pilot MFD keypad unit 0 (dropped)
// - arrow keys -> hat looks 0x41-0x44 (arrows now drive)
// - V/C/B -> fire 0x47/0x46/0x45 (now view/valve/behind)
// - LCTRL -> throttle slew down (now fire 0x47)
// The XInput pad section is unchanged.
// THE KEYBOARD IS THE BUTTON BOARD (2026-07-26, ported from RP412).
//
// Every one of the pod's 72 buttons is reachable from the keyboard, laid out
// WHERE IT SITS ON THE PANEL: the number row is the upper MFDs' top row and
// QWERTY the row beneath it, running left to right across Heat / Engineering /
// Comm; the home row and the row below it do the same for the two lower
// weapon MFDs; F1-F12 are the two columns flanking the map. G and B stay
// unbound on purpose -- they are the physical gap between the lower clusters.
// Flight moves to the NUMPAD (which already carried it as an alternative), so
// the letter field is free for the board.
//
// It lands on BT's own addresses better than it has any right to: 1-4 and
// QWER are the Heat display's two rows, which is the entire coolant system
// (Condensers 1-6, the flush and the balance button); F6/F7 are the display
// and control-mode cycles; F9-F12 are Generators A-D.
//
// WHAT THIS COSTS (the deliberate trade). A bound key is REMOVED from the
// authentic 1995 typed-hotkey channel to stop double-dispatch, and this board
// binds nearly the whole field -- so the typed hotkeys go: 5 = MFD1 Quad page,
// z = MFD3 Eng1, t/y/u/i/o = pilot select 3-7, +/- = target zoom. They remain
// reachable on the panel and by editing this file. The built-in convenience
// keys yield the same way: V and J/K/L are board buttons now, so PadRIO's
// view-toggle and preset-cycle polls stand down for them (KeyHasBinding) and
// the view toggle lives on BACKTICK alone.
//
// PREVIOUS DEFAULT (2026-07-20 - 2026-07-26) was the ~20-action CONTROLS.MAP
// mirror: W/S/A/D drive, Q/E twist, R/F elevation, 1-4/Space/Ctrl fire,
// M/N/H/C/G systems. An existing bindings.txt is never overwritten, so a
// player only meets this board by deleting theirs.
//###########################################################################
static const char *defaultProfileText =
@@ -187,71 +236,134 @@ static const char *defaultProfileText =
"# key <KEYNAME> axis <channel> set <value> jump/detent\n"
"# pad <PADBTN> button <addr>\n"
"# padaxis <PADAXIS> axis <channel> [invert] [slew <rate>]\n"
"# <KEYNAME> may be a MOUSE BUTTON: MOUSEMIDDLE, MOUSE4, MOUSE5 (the two\n"
"# side buttons; MOUSEX1/MOUSEX2 are aliases). MOUSELEFT and MOUSERIGHT\n"
"# also work but are how you press cockpit buttons -- bind them and they\n"
"# fire on every panel click too. Mouse MOVEMENT is not bindable.\n"
"# Channels: Throttle JoystickX JoystickY LeftPedal RightPedal Turn\n"
"# (Turn = signed composite: + drives the right pedal, - the left --\n"
"# made for mapping a gamepad stick to turning)\n"
"# Pad buttons: A B X Y LB RB BACK START LS RS DPAD_* Axes: LX LY RX RY LT RT\n"
"#\n"
"# LAYOUT: the keyboard hosts the core gameplay actions on the desktop\n"
"# CONTROLS.MAP keys; the on-screen cockpit panel covers EVERY pod button\n"
"# by click (right-click = press-and-hold latch). A key bound here is\n"
"# REMOVED from the authentic 1995 typed-hotkey channel (no double\n"
"# dispatch); unbound keys keep their 1995 meaning -- 5 = MFD1 Quad page,\n"
"# z = MFD3 Eng1 page, t/y/u/i/o = pilot select 3-7, + / - = target zoom.\n"
"# Hardcoded (not rebindable here): ` or V = view toggle, J/K/L = cycle\n"
"# the Mfd1/Mfd2/Mfd3 preset page.\n"
"# LAYOUT: THE KEYBOARD IS THE POD'S BUTTON BOARD. The letter and number\n"
"# rows are the MFD button banks laid out where they sit on the panel, and\n"
"# flight lives on the numpad so the board stays free. Every one of the 72\n"
"# pod buttons is also one mouse click away on the cockpit itself\n"
"# (right-click = press-and-hold latch).\n"
"#\n"
"# A key bound here is REMOVED from the authentic 1995 typed-hotkey channel\n"
"# so it cannot double-dispatch -- and this board binds nearly everything,\n"
"# so those hotkeys (5 = MFD1 Quad, z = MFD3 Eng1, t/y/u/i/o = pilot select,\n"
"# +/- = target zoom) are given up by design. Unbind a key here to get its\n"
"# 1995 meaning back. Same rule for the built-ins: BACKTICK = view toggle\n"
"# (V is a board button now), and J/K/L cycle the Mfd1/2/3 preset pages ONLY\n"
"# while unbound.\n"
"\n"
"# --- drive: W/S sweep the throttle lever (sticks; X = all-stop detent),\n"
"# --- A/D spring the turn pedals, Q/E twist the torso, R/F aim it up/down\n"
"key W axis Throttle slew + 0.7\n"
"key S axis Throttle slew - 0.7\n"
"key UP axis Throttle slew + 0.7\n"
"key DOWN axis Throttle slew - 0.7\n"
"key A axis LeftPedal deflect + 2.5\n"
"key D axis RightPedal deflect + 2.5\n"
"key LEFT axis LeftPedal deflect + 2.5\n"
"key RIGHT axis RightPedal deflect + 2.5\n"
"key Q axis JoystickX deflect - 2.5\n"
"key E axis JoystickX deflect + 2.5\n"
"key R axis JoystickY deflect + 2.5\n"
"key F axis JoystickY deflect - 2.5\n"
"key X axis Throttle set 0\n"
"\n"
"# --- numpad flight cluster (alternative stick/pedals/lever) ---\n"
"# --- flight: the numpad ------------------------------------------------\n"
"# 8/2/4/6 stick, 7/9 pedals, 5 all-stop detent, 0 main trigger.\n"
"# SHIFT/CTRL walk the throttle lever and it STICKS where you leave it;\n"
"# ALT is the throttle handle's reverse-thrust button.\n"
"key NUMPAD8 axis JoystickY deflect + 2.5\n"
"key NUMPAD2 axis JoystickY deflect - 2.5\n"
"key NUMPAD4 axis JoystickX deflect - 2.5\n"
"key NUMPAD6 axis JoystickX deflect + 2.5\n"
"key NUMPAD7 axis LeftPedal deflect + 2.5\n"
"key NUMPAD9 axis RightPedal deflect + 2.5\n"
"key LSHIFT axis Throttle slew + 0.7\n"
"key NUMPAD5 axis Throttle set 0\n"
"\n"
"# --- fire groups (the joystick's four mappable buttons) + reverse + look\n"
"key 1 button 0x40\n"
"key SPACE button 0x40\n"
"key 2 button 0x46\n"
"key 3 button 0x47\n"
"key LCTRL button 0x47\n"
"key RCTRL button 0x47\n"
"key 4 button 0x45\n"
"key LSHIFT axis Throttle slew + 0.7\n"
"key RSHIFT axis Throttle slew + 0.7\n"
"key LCTRL axis Throttle slew - 0.7\n"
"key RCTRL axis Throttle slew - 0.7\n"
"key LALT button 0x3F\n"
"key B button 0x41\n"
"key RALT button 0x3F\n"
"\n"
"# --- systems (pod console buttons) ---\n"
"key M button 0x18 # cycle control mode (BAS/MID/ADV)\n"
"key N button 0x15 # cycle secondary schematic (Damage/Critical/Heat)\n"
"key H button 0x2C # HOLD = coolant flush (Reservoir InjectCoolant)\n"
"key C button 0x2F # cycle Condenser1 coolant valve (MoveValve)\n"
"key G button 0x0E # HOLD + fire key = regroup weapon 1 (Mfd1 Quad page)\n"
"# --- the four mappable fire buttons ------------------------------------\n"
"# The mouse's side buttons are free if you want them on the trigger:\n"
"# key MOUSE4 button 0x40\n"
"# key MOUSE5 button 0x46\n"
"# key MOUSEMIDDLE button 0x41\n"
"key SPACE button 0x40 # main trigger\n"
"key NUMPAD0 button 0x40 # main trigger\n"
"key NUMPAD1 button 0x45 # pinky\n"
"key NUMPAD3 button 0x46 # middle thumb\n"
"key NUMPADDOT button 0x47 # upper thumb\n"
"\n"
"# --- Mfd2 bank on F5-F8 + F9 (page-gated: Quad page = select the Eng\n"
"# --- pages, Eng page = generator A-D select / F9 gen-mode toggle) ---\n"
"key F5 button 0x27\n"
"key F6 button 0x26\n"
"key F7 button 0x25\n"
"key F8 button 0x24\n"
"key F9 button 0x22\n"
"# --- the hat: look around, on the arrows -------------------------------\n"
"key UP button 0x42 # torso CENTER (the shipped .RES name)\n"
"key DOWN button 0x41 # look behind\n"
"key LEFT button 0x44 # look left\n"
"key RIGHT button 0x43 # look right\n"
"\n"
"# --- UPPER MFD banks: number row on top, QWERTY beneath ----------------\n"
"# Heat / coolant (0x28-0x2F) -- 1-4 and QWER ARE the coolant system:\n"
"# 1/2/3 Condenser 1-3 valve, 4 coolant FLUSH (hold), Q/W/E Condenser\n"
"# 4-6 valve, R balance coolant. The valve detents run 1-5-50-CLOSED.\n"
"key 1 button 0x2F\n"
"key 2 button 0x2E\n"
"key 3 button 0x2D\n"
"key 4 button 0x2C\n"
"key Q button 0x2B\n"
"key W button 0x2A\n"
"key E button 0x29\n"
"key R button 0x28\n"
"# Engineering / Mfd2 (0x20-0x27) -- page-gated: Quad page picks an Eng\n"
"# page, Eng page drives generator select A-D / bus mode / coolant.\n"
"key 5 button 0x27\n"
"key 6 button 0x26\n"
"key 7 button 0x25\n"
"key 8 button 0x24\n"
"key T button 0x23\n"
"key Y button 0x22\n"
"key U button 0x21\n"
"key I button 0x20\n"
"# Comm / target hotbox (0x30-0x37) -- pilot select.\n"
"key 9 button 0x37\n"
"key 0 button 0x36\n"
"key MINUS button 0x35\n"
"key EQUALS button 0x34\n"
"key O button 0x33\n"
"key P button 0x32\n"
"key LBRACKET button 0x31\n"
"key RBRACKET button 0x30\n"
"\n"
"# --- LOWER MFD banks: home row on top, the row below beneath -----------\n"
"# G and B are the PANEL GAP between the two lower clusters -- left\n"
"# unbound on purpose, so they keep their 1995 typed meaning.\n"
"# Left Weapons / Mfd1 (0x08-0x0F).\n"
"key A button 0x0F\n"
"key S button 0x0E\n"
"key D button 0x0D\n"
"key F button 0x0C\n"
"key Z button 0x0B\n"
"key X button 0x0A\n"
"key C button 0x09\n"
"key V button 0x08\n"
"# Right Weapons / Mfd3 (0x00-0x07).\n"
"key H button 0x07\n"
"key J button 0x06\n"
"key K button 0x05\n"
"key L button 0x04\n"
"key N button 0x03\n"
"key M button 0x02\n"
"key COMMA button 0x01\n"
"key PERIOD button 0x00\n"
"\n"
"# --- the two columns flanking the map, top to bottom -------------------\n"
"# Left (0x10-0x15): map zoom in/out, thermal IR, CROUCH, searchlight,\n"
"# cycle secondary display. Right (0x18-0x1D): cycle control mode\n"
"# (BAS/MID/ADV), unused, Generator A-D on/off.\n"
"key F1 button 0x10\n"
"key F2 button 0x11\n"
"key F3 button 0x12\n"
"key F4 button 0x13\n"
"key F5 button 0x14\n"
"key F6 button 0x15\n"
"key F7 button 0x18\n"
"key F8 button 0x19\n"
"key F9 button 0x1A\n"
"key F10 button 0x1B\n"
"key F11 button 0x1C\n"
"key F12 button 0x1D\n"
"\n"
"# --- XInput pad ---\n"
"# POD-FAITHFUL (default): stick X = torso twist, triggers = pedals.\n"
+12 -26
View File
@@ -21,6 +21,7 @@
#include "l4padpanel.h"
#include "l4padrio.h"
#include "l4vb16.h" // BTLampBrightnessOf -- the one lamp decode
#include <windows.h>
#include <stdio.h>
@@ -95,10 +96,10 @@ static const NamedButton namedButtons[] =
{ 0x3D, "Panic" },
{ 0x3F, "Throttle" },
{ 0x40, "Main" },
{ 0x41, "Hat Back" },
{ 0x42, "Hat Up" },
{ 0x43, "Hat Right" },
{ 0x44, "Hat Left" },
{ 0x41, "Look Back" },
{ 0x42, "Torso Ctr" },
{ 0x43, "Look Right" },
{ 0x44, "Look Left" },
{ 0x45, "Pinky" },
{ 0x46, "Middle" },
{ 0x47, "Upper" },
@@ -191,33 +192,18 @@ static int
}
//###########################################################################
// Lamp decode (RIOBase::LampState): flash = low 2 bits (solid/slow/med/
// fast), filament brightness = max of the state1 (>>2) and state2 (>>4)
// 2-bit fields (0 off / 1 dim / 3 bright). Flash blinks between the
// commanded brightness and off, half-periods 500/250/125 ms (vRIO).
// Lamp decode (RIOBase::LampState): the ONE copy lives in l4vb16.h
// (BTLampBrightnessOf) -- flash mode in the low 2 bits picks a 500/250/125 ms
// half-period and the two 2-bit brightness fields (state1 >>2, state2 >>4;
// 0 off / 1 dim / 3 bright) ALTERNATE across it. This TU used to carry its
// own max-then-blank copy, as did L4GLASSWIN; see the header's 2026-07-26
// note for the dim-to-bright pulse all three got wrong.
//###########################################################################
static int
LampBrightnessOf(int state, unsigned long tick)
{
int level1 = (state >> 2) & 0x3;
int level2 = (state >> 4) & 0x3;
int level = (level1 > level2) ? level1 : level2;
if (level == 0)
{
return 0;
}
int flash = state & 0x3;
if (flash != 0)
{
unsigned long half_period =
(flash == 1) ? 500 : (flash == 2) ? 250 : 125;
if ((tick / half_period) & 1)
{
return 0;
}
}
return level;
return BTLampBrightnessOf(state, tick);
}
//###########################################################################
+124 -2
View File
@@ -12,6 +12,7 @@
#include "l4glasswin.h"
#include "l4ctrl.h"
#include "l4joy.h"
#include "l4keylight.h" // RGB keyboard lamp mirror (scalars-only interface)
#include <windows.h>
#include <xinput.h>
@@ -63,6 +64,35 @@ int
// XInput normalization: thumbs to -1..1 past the stock deadzone, triggers
// to 0..1 past the stock threshold.
//
//
// The lamp mirror's log sink. It cannot use DEBUG_STREAM itself: its TU
// takes no engine headers (l4keylight.h is scalars only), so it hands us
// finished lines instead.
//
//
// Does the loaded profile bind this virtual key? The built-in convenience
// keys (view toggle, preset cycles) consult this and stand down when the
// player's bindings claim the key -- see the note at their poll sites.
//
Logical
PadRIO::KeyHasBinding(int virtualKey) const
{
for (int i = 0; i < bindings.keyBindingCount; ++i)
{
if (bindings.keyBindings[i].virtualKey == virtualKey)
{
return True;
}
}
return False;
}
static void
KeyLightLog(const char *line)
{
DEBUG_STREAM << "[keylight] " << (line ? line : "") << "\n" << std::flush;
}
static float
NormalizeThumb(int value, int dead_zone)
{
@@ -170,6 +200,75 @@ PadRIO::PadRIO():
flipStickAxes =
(getenv("L4PADFLIP") != NULL && *getenv("L4PADFLIP") != '0');
//
// RGB KEYBOARD LAMP MIRROR (Windows Dynamic Lighting, l4keylight.h):
// every key bound to a lamp ADDRESS glows with the panel palette --
// yellow for the Secondary/Screen columns (0x10-0x1F), red for the rest,
// exactly like the on-screen buttons. Keypad binds have no lamp, so
// they are skipped; a key bound twice takes its FIRST binding.
// BT_KEYLIGHT=0 opts out; a machine without Dynamic Lighting logs once
// and stays dormant.
//
keyLightActive = False;
{
const char *gate = getenv("BT_KEYLIGHT");
if (gate == NULL || atoi(gate) != 0)
{
static int lightKeys[192];
static int lightAddresses[192];
static unsigned char lightYellow[192];
int count = 0;
for (int k = 0; k < bindings.keyBindingCount && count < 192; ++k)
{
const PadBindingProfile::KeyBinding &binding = bindings.keyBindings[k];
if (binding.action.kind != PadBindingProfile::ActionButton)
{
continue; // axes/keypads carry no lamp
}
int address = binding.action.address;
if (address < 0 || address >= LampCount)
{
continue;
}
// A keyboard lamp array has no mouse buttons to paint.
{
extern int BTPadBindingIsMouseKey(int virtualKey);
if (BTPadBindingIsMouseKey(binding.virtualKey))
{
continue;
}
}
Logical duplicate = False;
for (int j = 0; j < count; ++j)
{
if (lightKeys[j] == binding.virtualKey)
{
duplicate = True; // first binding wins
break;
}
}
if (duplicate)
{
continue;
}
lightKeys[count] = binding.virtualKey;
lightAddresses[count] = address;
lightYellow[count] = (unsigned char)
((address >= 0x10 && address <= 0x1F) ? 1 : 0);
++count;
}
if (count > 0)
{
KeyLight_SetLogger(KeyLightLog);
KeyLight_SetMap(lightKeys, lightAddresses, lightYellow, count);
KeyLight_Start();
keyLightActive = True;
DEBUG_STREAM << "[keylight] mirroring " << count
<< " bound key(s) onto RGB keyboards\n" << std::flush;
}
}
}
//
// Never revision 0.0 -- some diagnostics print it; give the synthetic
// board a recognizable version.
@@ -199,6 +298,11 @@ PadRIO::PadRIO():
PadRIO::~PadRIO()
{
if (keyLightActive)
{
KeyLight_Stop(); // hands the LEDs back to Windows
keyLightActive = False;
}
BTGlassPanels_Destroy(); // safe no-op if the glass windows were never created
BTPadPanel_Destroy();
if (activeInstance == this)
@@ -537,11 +641,17 @@ void
// message-path-free) and consumed by the game's view-toggle block via
// BTPadViewToggleEdge.
//
// ⚠ A bindings.txt ROW WINS over these built-in convenience keys
// (2026-07-26). The default profile now hands the whole letter field to
// the MFD banks -- V is a Left Weapons button, J/K/L are Right Weapons --
// and polling them here as well would fire BOTH the button and the
// built-in, which is the double-dispatch the typed-channel suppression
// exists to prevent. Bind the key, and the built-in stands down.
{
static int s_backtickWas = 0;
int backtick_held = focused &&
(((GetAsyncKeyState(VK_OEM_3) & 0x8000) != 0) ||
((GetAsyncKeyState('V') & 0x8000) != 0));
(!KeyHasBinding('V') && (GetAsyncKeyState('V') & 0x8000) != 0));
if (backtick_held && !s_backtickWas)
{
extern int gBTPadViewToggleEdges;
@@ -560,7 +670,7 @@ void
static const int s_presetKey[3] = { 'J', 'K', 'L' };
for (int g = 0; g < 3; ++g)
{
int held = focused &&
int held = focused && !KeyHasBinding(s_presetKey[g]) &&
(GetAsyncKeyState(s_presetKey[g]) & 0x8000) != 0;
if (held && !s_presetWas[g])
{
@@ -1038,6 +1148,18 @@ void
if (lampNumber >= 0 && lampNumber < LampCount)
{
lampState[lampNumber] = state;
if (keyLightActive)
{
// The mirror keeps its own copy (its paint loop runs on a private
// thread and must not reach into the device); the first 64
// addresses are the whole lamp field.
unsigned char bytes[64];
for (int i = 0; i < 64; ++i)
{
bytes[i] = (unsigned char) lampState[i];
}
KeyLight_UpdateLamps(bytes, 64);
}
}
}
+11
View File
@@ -143,6 +143,17 @@ protected:
int
lampState[LampCount];
// RGB keyboard lamp mirror running? (l4keylight.h; BT_KEYLIGHT=0 off,
// also stays False when no bound key carries a lamp address)
Logical
keyLightActive;
// True when the loaded bindings claim this virtual key. The built-in
// convenience keys (` / V view toggle, J/K/L preset cycles) yield to an
// explicit row rather than double-dispatching with it.
Logical
KeyHasBinding(int virtualKey) const;
//
// Typed-channel suppression tables (built from the loaded bindings +
// the hardcoded view/preset keys; see SuppressKey).
+296
View File
@@ -0,0 +1,296 @@
#include "mungal4.h"
#pragma hdrstop
//###########################################################################
// L4RIOBANK -- the ONE button-bank geometry for a pod display. Design +
// the under-glass rule: l4riobank.h. Consumed by the cockpit surround
// (L4VB16.cpp, canvas space) and the exploded per-display windows
// (L4GLASSWIN.cpp, client space); neither owns a copy any more.
//###########################################################################
#include "l4riobank.h"
#include <string.h>
#include <stdlib.h>
namespace
{
//-------------------------------------------------------------------
// The lamp strip at NATIVE glass size, scaled down with the display
// but floored so a half-scale surround lamp is still a bar and not a
// smudge. RP412 L4MFDVIEW works at native only and hardcodes 10.
//-------------------------------------------------------------------
const int nativeShortAxis = 480; // MFD 640x480 / map 480x640
const int nativeStrip = 10;
const int minimumStrip = 6;
const int defaultGap = 4;
//-------------------------------------------------------------------
// Per-column horizontal nudge for the 4 MFD buttons, left->right, so
// they line up with the (unevenly spaced) DISPLAY / PROGRAM / ...
// legends painted in the imagery. Measured against the native 640
// glass in L4GLASSWIN (RedOffsetX + kRedColDX) and scaled here, so
// both renderers inherit the same alignment. Tunable.
//-------------------------------------------------------------------
const int nativeMfdW = 640;
const int mfdColumnDX[4] = { 0, 1, 5, 5 };
//-------------------------------------------------------------------
// The map paints its own legend beside each side button (the six
// cells: MAP+, MAP-, IR, crouch, searchlight, display-mode), and the
// buttons have to line up with THAT grid, not with height/6.
//
// MEASURED off a native 480x640 capture (scratchpad/measurelegend.py
// over an exploded Secondary/Radar window): the first cell border sits
// 3 rows down and the six cells are 102 tall on a 107 pitch. RP412's
// map measures 13 + 6x102 on a 105 pitch -- same cell height, different
// top and pitch, so its numbers do NOT transfer; these are ours.
//
// Our previous even division (displayH/6 + 1 = 107) had the pitch right
// by luck and sat 3px high of the labels.
//-------------------------------------------------------------------
const int mapLegendSpan = 640;
const int mapLegendTop = 3;
const int mapLegendCell = 102;
const int mapLegendPitch = 107;
int Scaled(int nativeValue, int actual, int nativeExtent)
{
if (nativeExtent <= 0) return nativeValue;
return (nativeValue * actual) / nativeExtent;
}
void Push(
BTRioBank *bank,
int address, int x, int y, int w, int h,
int colorClass, int inert, const char *label
)
{
if (bank->buttonCount >= BTRioBankMaxButtons) return;
if (w <= 0 || h <= 0) return;
BTRioButton *button = &bank->buttons[bank->buttonCount++];
button->address = address;
button->x = x;
button->y = y;
button->w = w;
button->h = h;
button->colorClass = colorClass;
button->inert = inert;
button->label = label;
}
void GrowBounds(BTRioBank *bank, int x, int y, int w, int h)
{
if (bank->boundsW == 0 && bank->boundsH == 0)
{
bank->boundsX = x; bank->boundsY = y;
bank->boundsW = w; bank->boundsH = h;
return;
}
int left = (x < bank->boundsX) ? x : bank->boundsX;
int top = (y < bank->boundsY) ? y : bank->boundsY;
int right = bank->boundsX + bank->boundsW;
int bottom = bank->boundsY + bank->boundsH;
if (x + w > right) right = x + w;
if (y + h > bottom) bottom = y + h;
bank->boundsX = left;
bank->boundsY = top;
bank->boundsW = right - left;
bank->boundsH = bottom - top;
}
}
//###########################################################################
void
BTRioBankMetricsFor(int displayW, int displayH, BTRioBankMetrics *out)
{
if (out == 0) return;
int shortAxis = (displayW < displayH) ? displayW : displayH;
int strip = Scaled(nativeStrip, shortAxis, nativeShortAxis);
if (strip < minimumStrip) strip = minimumStrip;
out->strip = strip;
out->gap = defaultGap;
// The cells with no glass behind them (the flight bank, the map's foot
// row) are drawn in full, so they get a plain readable size scaled off
// the same axis rather than a derived depth.
out->cellW = Scaled(58, shortAxis, nativeShortAxis);
out->cellH = Scaled(28, shortAxis, nativeShortAxis);
if (out->cellW < 12) out->cellW = 12;
if (out->cellH < 12) out->cellH = 12;
}
//###########################################################################
void
BTRioBankLayout(
BTRioBankStyle style,
int originX,
int originY,
int displayW,
int displayH,
int anchorA,
int anchorB,
const BTRioBankMetrics *metrics,
const int *bottomAddrs,
BTRioBank *out
)
{
if (out == 0 || metrics == 0) return;
memset(out, 0, sizeof(*out));
const int strip = metrics->strip;
const int gap = metrics->gap;
// the glass itself is always inside the bounds
GrowBounds(out, originX, originY, displayW, displayH);
if (style == BTRioBankMfd)
{
//---------------------------------------------------------------
// 4 above the glass, 4 below. Addresses descend from the anchor
// row-major (vRIO CockpitLayout::Mfd): top = anchorA-i, bottom =
// anchorA-4-i. Each button reaches HALF THE GLASS in and clears
// the edge by `strip`, so the two banks meet in the middle and
// every pixel of the display is some button's press target.
//---------------------------------------------------------------
int depth = displayH / 2;
if (depth < strip) depth = strip;
int slotW = displayW / 4;
for (int i = 0; i < 4; ++i)
{
int dx = Scaled(mfdColumnDX[i], displayW, nativeMfdW);
int x = originX + i * slotW + dx;
int w = slotW - gap;
int topY = originY - strip;
int botY = originY + displayH - depth;
Push(out, anchorA - i, x, topY, w, depth + strip, 0, 0, 0);
Push(out, anchorA - 4 - i, x, botY, w, depth + strip, 0, 0, 0);
GrowBounds(out, x, topY, w, depth + strip);
GrowBounds(out, x, botY, w, depth + strip);
}
}
else if (style == BTRioBankRadar)
{
//---------------------------------------------------------------
// The portrait map: 6 buttons down each side (anchorA left,
// anchorB right) plus an optional 4 along the foot. Same rule
// turned on its side -- each column reaches half the map in and
// leaves `strip` clearing the edge.
//
// The FOOT ROW IS PUSHED FIRST, deliberately: the columns now
// reach half the width each, so they cover the foot band too,
// and the hit test takes the first match. Foot-first keeps
// those four addresses reachable.
//---------------------------------------------------------------
int depth = displayW / 2;
if (depth < strip) depth = strip;
if (bottomAddrs != 0)
{
int bandH = metrics->cellH;
int bandY = originY + displayH - bandH;
int bandW = displayW / 4;
for (int i = 0; i < 4; ++i)
{
int x = originX + i * bandW;
Push(out, bottomAddrs[i], x, bandY, bandW - gap,
bandH + strip, 1, 0, 0);
GrowBounds(out, x, bandY, bandW - gap, bandH + strip);
}
}
//---------------------------------------------------------------
// Six cells down the height, on the map's MEASURED legend grid
// (see mapLegend* above). Top and bottom are scaled SEPARATELY
// and subtracted rather than scaling a height directly: rounding
// a height would let the buttons drift out of step with the
// labels a display or two down the column.
//---------------------------------------------------------------
int leftX = originX - strip;
int rightX = originX + displayW - depth;
for (int i = 0; i < 6; ++i)
{
int cellTop = mapLegendTop + i * mapLegendPitch;
int top = (cellTop * displayH) / mapLegendSpan;
int bottom = ((cellTop + mapLegendCell) * displayH) / mapLegendSpan;
int h = bottom - top;
if (h < 1) h = 1;
int y = originY + top;
Push(out, anchorA + i, leftX, y, depth + strip, h, 1, 0, 0);
Push(out, anchorB + i, rightX, y, depth + strip, h, 1, 0, 0);
GrowBounds(out, leftX, y, depth + strip, h);
GrowBounds(out, rightX, y, depth + strip, h);
}
}
}
//###########################################################################
void
BTRioBankFlightGrid(
int originX,
int originY,
int baseAddress,
int count,
int columns,
const char *const *labels,
const int *inert,
const BTRioBankMetrics *metrics,
BTRioBank *out
)
{
if (out == 0 || metrics == 0 || columns <= 0) return;
const int cw = metrics->cellW;
const int ch = metrics->cellH;
const int gap = metrics->gap;
for (int i = 0; i < count; ++i)
{
int column = i % columns;
int row = i / columns;
int x = originX + column * (cw + gap);
int y = originY + row * (ch + gap);
Push(out, baseAddress + i, x, y, cw, ch, 2,
(inert != 0) ? inert[i] : 0,
(labels != 0) ? labels[i] : 0);
GrowBounds(out, x, y, cw, ch);
}
}
//###########################################################################
void
BTRioBankDump(const char *tag, const BTRioBank *bank)
{
static int enabled = -1;
static int printed = 0;
if (enabled < 0)
{
const char *e = getenv("BT_RIOBANK_LOG");
enabled = (e != 0 && e[0] != '0') ? 1 : 0;
}
if (!enabled || bank == 0 || printed >= 16) return;
++printed;
DEBUG_STREAM << "[riobank] " << (tag ? tag : "?")
<< " bounds=(" << bank->boundsX << "," << bank->boundsY << ","
<< bank->boundsW << "," << bank->boundsH << ") "
<< bank->buttonCount << " buttons\n";
for (int i = 0; i < bank->buttonCount; ++i)
{
const BTRioButton &b = bank->buttons[i];
DEBUG_STREAM << "[riobank] addr=0x" << std::hex << b.address << std::dec
<< " rect=(" << b.x << "," << b.y << "," << b.w << "," << b.h << ")"
<< " class=" << b.colorClass << "\n";
}
DEBUG_STREAM << std::flush;
}
+350 -72
View File
@@ -2,6 +2,7 @@
#pragma hdrstop
#include "l4vb16.h"
#include "l4riobank.h" // the shared button-bank geometry (with the exploded windows)
#include "../munga/gaugrend.h"
#include "L4VIDEO.h"
#include "DXUtils.h"
@@ -195,41 +196,229 @@ int gBTCockpitCanvasW = 0; // backbuffer canvas dims (set by btl4main; the
int gBTCockpitCanvasH = 0; // aspect calc needs them after a client resize)
// Layout constants (backbuffer px). MFD = 640x480 native x 0.5; radar = 480x640
// portrait x 0.5. The lamp EDGE (kCkLamp) protrudes past the surface; the FULL
// button rect reaches kCkRedCell INTO the display (hit-only, covered by the
// surface). User asked for larger/more-clickable buttons than the glass windows'
// 10px edge -> kCkLamp 16.
// portrait x 0.5.
//
// ⚠ 2026-07-26: the BUTTON rects no longer come from here -- L4RIOBANK owns
// that geometry now and the exploded per-display windows draw the identical
// field. What changed for the surround: its MFD lamps used to sit ENTIRELY
// outside the glass on a kCkRedH=24 strip (a 76x24 click target), while the
// same button in the exploded window reached 128px under the display. Both
// now follow the pod's under-glass rule -- half the glass deep, a scaled lamp
// strip clearing the edge. kCkTopBand still has to reserve room for the top
// row's protruding strip, so the band constants stay.
enum {
kCkMFDW = 320, kCkMFDH = 240,
kCkMFDW = 320, kCkMFDH = 240, // the POD sizes (100%) -- see BTCkSizes
kCkRADW = 240, kCkRADH = 320,
kCkOVL = 44, // (int)(0.14f * 320): corner overlap into the view
kCkLamp = 16, // radar rail / bottom-cell protrusion
kCkRedH = 24, // MFD red lamp height -- sits fully OUTSIDE the surface
kCkRedGap = 3, // gap between the red lamp and the MFD screen edge
kCkRailW = 26, // radar side-rail width
kCkGap = 2,
kCkSideBand = kCkMFDW - kCkOVL, // 276
kCkTopBand = (kCkMFDH - kCkOVL) + kCkRedGap + kCkRedH, // 223 (fits the top red lamps)
kCkBotBand = kCkRADH + kCkLamp // 336 (radar flush below view)
kCkLamp = 16, // legacy band reserve (radar rail / bottom cell)
kCkRedH = 24, // legacy band reserve above the top MFD row
kCkRedGap = 3, // gap between that reserve and the MFD screen edge
kCkGap = 2
};
//===========================================================================//
// PLAYER-SCALED DISPLAYS (2026-07-26).
//
// The pod bolted its instruments down at one size; a desktop panel has room to
// trade viewscreen for instrument, so the player scales them -- BT_MFD_SCALE
// sets all five MFDs, BT_MFD_SCALE_UL/_UC/_UR/_LL/_LR override individually,
// BT_RADAR_SCALE the map. Percentages of the pod size, 25-200; anything
// unreadable or out of range falls back to the group setting, then to 100.
//
// Resolved ONCE (the surround recomputes its layout every frame and this reads
// the environment), and the SURROUND BANDS derive from the result -- which is
// the whole reason the sizes could not just be constants: the band a display
// hangs in has to grow with it or the canvas clips it.
//===========================================================================//
struct BTCkSizes
{
int mfdW[5], mfdH[5]; // UL, UC, UR, LL, LR -- kCkMfdBank order
int radW, radH;
int sideBandL, sideBandR, topBand, botBand;
};
static int CkScalePercent(const char *name, int fallback)
{
const char *text = getenv(name);
if (text == NULL) return fallback;
int percent = atoi(text);
if (percent <= 0) return fallback;
if (percent < 25) percent = 25;
if (percent > 200) percent = 200;
return percent;
}
// Where the map sits. The pod had it dead centre under the viewscreen, which
// on a wide panel is exactly where the road is -- BT_RADAR_POS moves it out of
// the way. In a bottom corner it takes that corner and the lower MFD there
// slides inboard beside it; halfway up a side it leaves the bottom row
// entirely (and the bottom band shrinks to what the lower MFDs need).
enum BTCkRadarPos { CkRadarBottomCenter = 0, CkRadarBottomLeft, CkRadarBottomRight,
CkRadarMidLeft, CkRadarMidRight };
static int CkRadarPosition(void)
{
static int cached = -1;
if (cached >= 0) return cached;
static const struct { const char *name; int pos; } names[] = {
{ "CENTER", CkRadarBottomCenter }, { "CENTRE", CkRadarBottomCenter },
{ "BOTTOM", CkRadarBottomCenter },
{ "LEFT", CkRadarBottomLeft }, { "BOTTOMLEFT", CkRadarBottomLeft },
{ "RIGHT", CkRadarBottomRight }, { "BOTTOMRIGHT", CkRadarBottomRight },
{ "MIDLEFT", CkRadarMidLeft }, { "LEFTCENTER", CkRadarMidLeft },
{ "LEFTCENTRE", CkRadarMidLeft },
{ "MIDRIGHT", CkRadarMidRight }, { "RIGHTCENTER", CkRadarMidRight },
{ "RIGHTCENTRE", CkRadarMidRight }
};
cached = CkRadarBottomCenter;
const char *text = getenv("BT_RADAR_POS");
if (text != NULL)
for (int i = 0; i < (int)(sizeof(names) / sizeof(names[0])); i++)
if (_stricmp(text, names[i].name) == 0) { cached = names[i].pos; break; }
static const char *described[] = { "bottom centre", "bottom left", "bottom right",
"left side, centred", "right side, centred" };
DEBUG_STREAM << "[cockpit] radar on the " << described[cached] << "\n" << std::flush;
return cached;
}
static int CkRadarOnASide(int pos)
{ return (pos == CkRadarMidLeft || pos == CkRadarMidRight) ? 1 : 0; }
static const BTCkSizes *BTCkResolvedSizes(void)
{
static BTCkSizes s;
static int resolved = 0;
if (resolved) return &s;
resolved = 1;
int group = CkScalePercent("BT_MFD_SCALE", 100);
static const char *perDisplay[5] = {
"BT_MFD_SCALE_UL", "BT_MFD_SCALE_UC", "BT_MFD_SCALE_UR",
"BT_MFD_SCALE_LL", "BT_MFD_SCALE_LR"
};
int percent[5];
for (int i = 0; i < 5; i++)
{
percent[i] = CkScalePercent(perDisplay[i], group);
s.mfdW[i] = kCkMFDW * percent[i] / 100;
s.mfdH[i] = kCkMFDH * percent[i] / 100;
}
int radarPercent = CkScalePercent("BT_RADAR_SCALE", 100);
s.radW = kCkRADW * radarPercent / 100;
s.radH = kCkRADH * radarPercent / 100;
//-------------------------------------------------------------------
// The bands: each is whatever the LARGEST display hanging in it needs.
// A corner MFD overlaps the view by kCkOVL, so it only claims
// (its size - the overlap) of the band.
//-------------------------------------------------------------------
int radarPos = CkRadarPosition();
int leftMax = (s.mfdW[0] > s.mfdW[3]) ? s.mfdW[0] : s.mfdW[3]; // UL, LL
int rightMax = (s.mfdW[2] > s.mfdW[4]) ? s.mfdW[2] : s.mfdW[4]; // UR, LR
s.sideBandL = leftMax - kCkOVL;
s.sideBandR = rightMax - kCkOVL;
// top: the corner MFDs claim (h - overlap); UC drops 75% of its height in
int topCorner = ((s.mfdH[0] > s.mfdH[2]) ? s.mfdH[0] : s.mfdH[2]) - kCkOVL;
int topCentre = (int)(0.75f * s.mfdH[1]);
s.topBand = (topCorner > topCentre) ? topCorner : topCentre;
s.topBand += kCkRedGap + kCkRedH; // room for the top lamp strip
// bottom: the lower MFDs hang (h - overlap) below the view; the radar,
// when it is down there, hangs its full height plus its lamp strip
int botCorner = ((s.mfdH[3] > s.mfdH[4]) ? s.mfdH[3] : s.mfdH[4]) - kCkOVL;
s.botBand = botCorner;
if (!CkRadarOnASide(radarPos))
{
int radarBand = s.radH + kCkLamp;
if (radarBand > s.botBand) s.botBand = radarBand;
}
// a side-mounted radar has to fit BESIDE the view, in that side's band
if (CkRadarOnASide(radarPos))
{
int *band = (radarPos == CkRadarMidLeft) ? &s.sideBandL : &s.sideBandR;
if (s.radW + kCkLamp > *band) *band = s.radW + kCkLamp;
}
if (s.sideBandL < 1) s.sideBandL = 1;
if (s.sideBandR < 1) s.sideBandR = 1;
DEBUG_STREAM << "[cockpit] displays UL " << percent[0] << "% UC " << percent[1]
<< "% UR " << percent[2] << "% LL " << percent[3] << "% LR " << percent[4]
<< "% radar " << radarPercent << "% bands L" << s.sideBandL
<< " R" << s.sideBandR << " T" << s.topBand << " B" << s.botBand
<< "\n" << std::flush;
return &s;
}
// Canvas = view region + the surround bands.
void BTCockpitCanvasFor(int viewW, int viewH, int *canvasW, int *canvasH)
{
if (canvasW) *canvasW = viewW + 2 * kCkSideBand;
if (canvasH) *canvasH = viewH + kCkTopBand + kCkBotBand;
const BTCkSizes *s = BTCkResolvedSizes();
if (canvasW) *canvasW = viewW + s->sideBandL + s->sideBandR;
if (canvasH) *canvasH = viewH + s->topBand + s->botBand;
}
//===========================================================================//
// LETTERBOX FIT -- one uniform scale, centred, leftover black. Contract +
// why the swap effect changes: l4vb16.h.
//===========================================================================//
int gBTCockpitLetterbox = 0;
int BTCockpitFitRect(int clientW, int clientH, RECT *out)
{
int cw = gBTCockpitCanvasW, ch = gBTCockpitCanvasH;
if (out == NULL || cw <= 0 || ch <= 0 || clientW <= 0 || clientH <= 0)
return 0;
// The larger axis is the constraint; integer math throughout so the rect
// is stable frame to frame (a float scale wobbles by a pixel and the bars
// shimmer during a drag).
int w = clientW;
int h = (int)((__int64)clientW * ch / cw);
if (h > clientH)
{
h = clientH;
w = (int)((__int64)clientH * cw / ch);
}
if (w < 1) w = 1;
if (h < 1) h = 1;
out->left = (clientW - w) / 2;
out->top = (clientH - h) / 2;
out->right = out->left + w;
out->bottom = out->top + h;
return (w != clientW || h != clientH) ? 1 : 0;
}
const RECT *BTCockpitPresentDest(void *hwnd, RECT *storage)
{
if (!gBTCockpitLetterbox || !gBTGaugeCockpit || hwnd == NULL || storage == NULL)
return NULL;
RECT client;
if (!GetClientRect((HWND)hwnd, &client))
return NULL;
if (!BTCockpitFitRect(client.right, client.bottom, storage))
return NULL; // already an exact fit -- present full client
return storage;
}
// The five MFD banks: {cockpit surface slot, high address}. Top row = bankHi-i
// (descending), bottom row = bankHi-4-i (L4GLASSWIN BuildMfd order + the
// BTGlassPanels_Create address assignment).
static const struct { int slot, bankHi; } kCkMfdBank[5] =
static const struct { int slot, bankHi; const char *name; } kCkMfdBank[5] =
{
{ 0, 0x2F }, // Heat (UL)
{ 1, 0x27 }, // Mfd2 (UC)
{ 5, 0x37 }, // Comm (UR)
{ 2, 0x0F }, // Mfd1 (LL)
{ 3, 0x07 }, // Mfd3 (LR)
{ 0, 0x2F, "surround/Heat MFD" }, // UL
{ 1, 0x27, "surround/Engineering" }, // UC
{ 5, 0x37, "surround/Comm MFD" }, // UR
{ 2, 0x0F, "surround/Left Weapons" }, // LL
{ 3, 0x07, "surround/Right Weapons" }, // LR
};
static void CkPushBtn(BTCockpitLayout *L, int addr, int x, int y, int w, int h,
@@ -247,66 +436,114 @@ void BTCockpitComputeLayout(int canvasW, int canvasH, BTCockpitLayout *out)
memset(L, 0, sizeof(*L));
L->canvasW = canvasW;
L->canvasH = canvasH;
int viewW = canvasW - 2 * kCkSideBand;
int viewH = canvasH - kCkTopBand - kCkBotBand;
const BTCkSizes *S = BTCkResolvedSizes();
int viewW = canvasW - S->sideBandL - S->sideBandR;
int viewH = canvasH - S->topBand - S->botBand;
if (viewW < 64) viewW = 64;
if (viewH < 64) viewH = 64;
int vx = kCkSideBand, vy = kCkTopBand;
int vx = S->sideBandL, vy = S->topBand;
L->viewX = vx; L->viewY = vy; L->viewW = viewW; L->viewH = viewH;
// Surface dest rects (slots: 0 Heat, 1 Mfd2, 2 Mfd1, 3 Mfd3, 4 sec, 5 Comm).
// Corner MFDs overlap the view corner by kCkOVL both axes; Mfd2 drops 25% of
// its height into the top; sec is flush below (view bottom edge clean).
// Sizes are the player-scaled ones (BTCkResolvedSizes), indexed in
// kCkMfdBank order: 0 UL, 1 UC, 2 UR, 3 LL, 4 LR.
int cx = vx + viewW / 2;
// Heat TL
L->surfX[0] = vx - kCkMFDW + kCkOVL; L->surfY[0] = vy - kCkMFDH + kCkOVL;
L->surfW[0] = kCkMFDW; L->surfH[0] = kCkMFDH;
L->surfX[0] = vx - S->mfdW[0] + kCkOVL; L->surfY[0] = vy - S->mfdH[0] + kCkOVL;
L->surfW[0] = S->mfdW[0]; L->surfH[0] = S->mfdH[0];
// Mfd2 top-center (25% into view)
L->surfX[1] = cx - kCkMFDW / 2; L->surfY[1] = vy - (int)(0.75f * kCkMFDH);
L->surfW[1] = kCkMFDW; L->surfH[1] = kCkMFDH;
L->surfX[1] = cx - S->mfdW[1] / 2; L->surfY[1] = vy - (int)(0.75f * S->mfdH[1]);
L->surfW[1] = S->mfdW[1]; L->surfH[1] = S->mfdH[1];
// Mfd1 BL
L->surfX[2] = vx - kCkMFDW + kCkOVL; L->surfY[2] = vy + viewH - kCkOVL;
L->surfW[2] = kCkMFDW; L->surfH[2] = kCkMFDH;
L->surfX[2] = vx - S->mfdW[3] + kCkOVL; L->surfY[2] = vy + viewH - kCkOVL;
L->surfW[2] = S->mfdW[3]; L->surfH[2] = S->mfdH[3];
// Mfd3 BR
L->surfX[3] = vx + viewW - kCkOVL; L->surfY[3] = vy + viewH - kCkOVL;
L->surfW[3] = kCkMFDW; L->surfH[3] = kCkMFDH;
// sec center, flush below
L->surfX[4] = cx - kCkRADW / 2; L->surfY[4] = vy + viewH;
L->surfW[4] = kCkRADW; L->surfH[4] = kCkRADH;
L->surfW[3] = S->mfdW[4]; L->surfH[3] = S->mfdH[4];
// Comm TR
L->surfX[5] = vx + viewW - kCkOVL; L->surfY[5] = vy - kCkMFDH + kCkOVL;
L->surfW[5] = kCkMFDW; L->surfH[5] = kCkMFDH;
L->surfX[5] = vx + viewW - kCkOVL; L->surfY[5] = vy - S->mfdH[2] + kCkOVL;
L->surfW[5] = S->mfdW[2]; L->surfH[5] = S->mfdH[2];
// --- MFD red buttons: 4 top (bankHi-i) + 4 bottom (bankHi-4-i) ---
int slotW = kCkMFDW / 4; // 80
for (int m = 0; m < 5; m++)
//-------------------------------------------------------------------
// sec (the map). Bottom centre as the pod had it, either bottom
// corner, or halfway up either side (BT_RADAR_POS). Only when it
// takes a bottom CORNER does a lower MFD have to move -- it slides
// inboard beside the map; on a side the map is out of that row's way
// already.
//-------------------------------------------------------------------
{
int s = kCkMfdBank[m].slot, hi = kCkMfdBank[m].bankHi;
int sx = L->surfX[s], sy = L->surfY[s];
for (int i = 0; i < 4; i++)
int radarPos = CkRadarPosition();
L->surfW[4] = S->radW;
L->surfH[4] = S->radH;
// A corner map goes FLUSH to the canvas edge and the lower MFD whose
// corner it took sits BESIDE it -- measured off the canvas edge, not
// the view edge, or the two overlap by the band width (they did:
// 232px of map drawn over the Right Weapons MFD).
int canvasLeft = vx - S->sideBandL;
int canvasRight = vx + viewW + S->sideBandR;
switch (radarPos)
{
int bx = sx + i * slotW;
// Lamps sit fully OUTSIDE the MFD (a small gap off the edge) so nothing
// occludes them or the DISPLAY/PROGRAM legends -- 4 above, 4 below.
CkPushBtn(L, hi - i, bx, sy - kCkRedGap - kCkRedH, slotW - kCkGap, kCkRedH, 0, 0, 0);
CkPushBtn(L, hi - 4 - i, bx, sy + kCkMFDH + kCkRedGap, slotW - kCkGap, kCkRedH, 0, 0, 0);
case CkRadarBottomLeft:
L->surfX[4] = canvasLeft;
L->surfY[4] = vy + viewH;
L->surfX[2] = L->surfX[4] + S->radW; // LL slides inboard
break;
case CkRadarBottomRight:
L->surfX[4] = canvasRight - S->radW;
L->surfY[4] = vy + viewH;
L->surfX[3] = L->surfX[4] - S->mfdW[4]; // LR slides inboard
break;
case CkRadarMidLeft:
L->surfX[4] = vx - S->radW + kCkOVL;
L->surfY[4] = vy + (viewH - S->radH) / 2;
break;
case CkRadarMidRight:
L->surfX[4] = vx + viewW - kCkOVL;
L->surfY[4] = vy + (viewH - S->radH) / 2;
break;
default:
L->surfX[4] = cx - S->radW / 2;
L->surfY[4] = vy + viewH;
break;
}
}
// --- radar yellow rails (6/side) + 4 bottom cells ---
// --- MFD buttons + radar columns: L4RIOBANK, the shared under-glass field ---
// Each bank is laid out at ITS display's scaled size, so the lamp strips and
// the under-glass reach track a resized display instead of drifting off it.
{
int rx = L->surfX[4], ry = L->surfY[4];
int slotH = kCkRADH / 6;
for (int i = 0; i < 6; i++)
for (int m = 0; m < 5; m++)
{
int y = ry + i * slotH;
CkPushBtn(L, 0x10 + i, rx - kCkLamp, y, kCkLamp + kCkRailW, slotH - kCkGap, 1, 0, 0);
CkPushBtn(L, 0x18 + i, rx + kCkRADW - kCkRailW, y, kCkRailW + kCkLamp, slotH - kCkGap, 1, 0, 0);
int s = kCkMfdBank[m].slot;
BTRioBankMetrics mfdMetrics;
BTRioBankMetricsFor(L->surfW[s], L->surfH[s], &mfdMetrics);
BTRioBank bank;
BTRioBankLayout(BTRioBankMfd, L->surfX[s], L->surfY[s],
L->surfW[s], L->surfH[s], kCkMfdBank[m].bankHi, 0, &mfdMetrics, 0, &bank);
BTRioBankDump(kCkMfdBank[m].name, &bank);
for (int i = 0; i < bank.buttonCount; i++)
{
const BTRioButton &b = bank.buttons[i];
CkPushBtn(L, b.address, b.x, b.y, b.w, b.h, b.colorClass, b.inert, b.label);
}
}
static const int bottomAddr[4] = { 0x16, 0x17, 0x1F, 0x1E };
int botLeft = rx + kCkRailW, botSpan = kCkRADW - 2 * kCkRailW, botW = botSpan / 4;
for (int i = 0; i < 4; i++)
CkPushBtn(L, bottomAddr[i], botLeft + i * botW, ry + kCkRADH - 14, botW - kCkGap, 14 + kCkLamp, 1, 0, 0);
BTRioBankMetrics radarMetrics;
BTRioBankMetricsFor(L->surfW[4], L->surfH[4], &radarMetrics);
BTRioBank radar;
BTRioBankLayout(BTRioBankRadar, L->surfX[4], L->surfY[4],
L->surfW[4], L->surfH[4], 0x10, 0x18, &radarMetrics, bottomAddr, &radar);
BTRioBankDump("surround/radar", &radar);
for (int i = 0; i < radar.buttonCount; i++)
{
const BTRioButton &b = radar.buttons[i];
CkPushBtn(L, b.address, b.x, b.y, b.w, b.h, b.colorClass, b.inert, b.label);
}
}
// --- flight blue blocks: THROTTLE/AUX (0x38-0x3F) under Mfd1, JOYSTICK
@@ -314,20 +551,36 @@ void BTCockpitComputeLayout(int canvasW, int canvasH, BTCockpitLayout *out)
{
static const char *thrLabel[8] = { 0, 0, 0, 0, 0, "Panic", 0, "Throttle" }; // 0x38..0x3F
static const int thrInert[8] = { 1, 1, 1, 1, 1, 0, 1, 0 };
static const char *joyLabel[8] = { "Main", "Hat Bk", "Hat Up", "Hat R",
"Hat L", "Pinky", "Middle", "Upper" }; // 0x40..0x47
const int cw = 68, ch = 30, g = 4;
int blockW = 4 * cw + 3 * g; // 284
int belowLamp = kCkMFDH + kCkRedGap + kCkRedH + 8; // clear the MFD's bottom red lamps
int thrX = L->surfX[2], thrY = L->surfY[2] + belowLamp;
int joyX = L->surfX[3] + kCkMFDW - blockW, joyY = L->surfY[3] + belowLamp;
for (int i = 0; i < 8; i++)
static const char *joyLabel[8] = { "Main", "Look Bk", "Torso Ctr", "Look R",
"Look L", "Pinky", "Middle", "Upper" }; // 0x40..0x47
// These cells carry TEXT, so the surround runs them bigger than the
// exploded window's 58x28 -- the caller owns the cell size
// (l4riobank.h), only the grid walk is shared.
BTRioBankMetrics metrics;
metrics.strip = 0;
metrics.gap = 4;
metrics.cellW = 68;
metrics.cellH = 30;
// Hang each block below ITS lower MFD, clearing that display's own
// (scaled) bottom lamp strip -- a shrunken MFD used to leave the block
// floating in the gap, an enlarged one to overlap it.
int blockW = 4 * metrics.cellW + 3 * metrics.gap; // 284
int thrX = L->surfX[2];
int thrY = L->surfY[2] + L->surfH[2] + kCkRedGap + kCkRedH + 8;
int joyX = L->surfX[3] + L->surfW[3] - blockW;
int joyY = L->surfY[3] + L->surfH[3] + kCkRedGap + kCkRedH + 8;
BTRioBank flight;
memset(&flight, 0, sizeof(flight));
BTRioBankFlightGrid(thrX, thrY, 0x38, 8, 4, thrLabel, thrInert, &metrics, &flight);
BTRioBankFlightGrid(joyX, joyY, 0x40, 8, 4, joyLabel, 0, &metrics, &flight);
BTRioBankDump("surround/flight", &flight);
for (int i = 0; i < flight.buttonCount; i++)
{
int c = i % 4, r = i / 4;
CkPushBtn(L, 0x38 + i, thrX + c * (cw + g), thrY + r * (ch + g), cw, ch, 2,
thrInert[i], thrLabel[i] ? thrLabel[i] : "");
CkPushBtn(L, 0x40 + i, joyX + c * (cw + g), joyY + r * (ch + g), cw, ch, 2,
0, joyLabel[i]);
const BTRioButton &b = flight.buttons[i];
CkPushBtn(L, b.address, b.x, b.y, b.w, b.h, b.colorClass, b.inert,
b.label ? b.label : "");
}
}
}
@@ -959,8 +1212,33 @@ int BTCockpitMouseDown(int cx, int cy, int clientW, int clientH, int rightButton
if (!gBTGaugeCockpit) return 0;
int bbW = gBTCockpitCanvasW, bbH = gBTCockpitCanvasH;
if (bbW <= 0 || bbH <= 0 || clientW <= 0 || clientH <= 0) return 0;
int bx = cx * bbW / clientW;
int by = cy * bbH / clientH;
// Client -> canvas. Under the letterbox the canvas occupies a centred
// sub-rect, so the click has to come back through the SAME transform
// Present used -- mapping against the full client would drift the hit test
// off every button by the bar width. A click on a bar hits nothing.
int bx, by;
if (gBTCockpitLetterbox)
{
RECT fit;
if (BTCockpitFitRect(clientW, clientH, &fit))
{
if (cx < fit.left || cx >= fit.right || cy < fit.top || cy >= fit.bottom)
return 0;
bx = (cx - fit.left) * bbW / (fit.right - fit.left);
by = (cy - fit.top) * bbH / (fit.bottom - fit.top);
}
else
{
bx = cx * bbW / clientW;
by = cy * bbH / clientH;
}
}
else
{
bx = cx * bbW / clientW;
by = cy * bbH / clientH;
}
BTCockpitLayout L;
BTCockpitComputeLayout(bbW, bbH, &L);
+44 -11
View File
@@ -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;
}
//########################################################################
+68 -3
View File
@@ -3419,6 +3419,34 @@ DPLRenderer::DPLRenderer(
mPresentParams.MultiSampleQuality--;
}
mPresentParams.SwapEffect = D3DSWAPEFFECT_DISCARD;
//
// COCKPIT LETTERBOX (2026-07-26): the fixed canvas is fitted into the client
// at Present time via a destination rect, which DISCARD forbids -- COPY is
// the swap effect that takes one. Windowed + surround only, and only with
// multisampling off (COPY cannot multisample). Full contract: l4vb16.h.
//
// btl4main sets the INTENT (it has to: the first WM_SIZE beats the device
// here); this is where it becomes real, or gets withdrawn.
{
extern int gBTGaugeCockpit;
extern int gBTCockpitLetterbox;
if (!fullscreen && gBTGaugeCockpit && gBTCockpitLetterbox &&
mPresentParams.MultiSampleType == D3DMULTISAMPLE_NONE)
mPresentParams.SwapEffect = D3DSWAPEFFECT_COPY;
else
gBTCockpitLetterbox = 0; // withdrawn -- the canvas stretches
// only meaningful when there IS a cockpit canvas to fit
if (gBTGaugeCockpit)
DEBUG_STREAM << "[cockpit] letterbox " << (gBTCockpitLetterbox ? "ON" : "off")
<< " (swap effect "
<< (mPresentParams.SwapEffect == D3DSWAPEFFECT_COPY ? "COPY" : "DISCARD")
<< (mPresentParams.MultiSampleType != D3DMULTISAMPLE_NONE
? "; multisampling on -- COPY unavailable, canvas stretches to the client"
: "")
<< ")\n" << std::flush;
}
mPresentParams.hDeviceWindow = hWnd;
mPresentParams.Flags = 0;
mPresentParams.FullScreen_RefreshRateInHz = (fullscreen)?60:D3DPRESENT_RATE_DEFAULT;
@@ -3462,8 +3490,30 @@ DPLRenderer::DPLRenderer(
// while the cockpit-surround window is sized independently -- pinning to them
// would shrink the backbuffer and break the composite.)
//
//
// COCKPIT SURROUND: the backbuffer IS THE CANVAS, whatever the window
// happens to be (2026-07-26). Everything downstream assumes it --
// BTApplyWorldViewport and BTDrawCockpitPanels lay out from the
// BACKBUFFER size, while the letterbox and the mouse hit-test map
// through gBTCockpitCanvasW/H. Pinning to the client instead is fine
// only while client == canvas, which is true for a normal boot and
// FALSE for -fit: the window is the whole monitor before the device
// exists, so the backbuffer came out 3440x1440 against a 1452x1059
// canvas. Field report: tiny MFDs hugging the corners, an over-wide
// world view, the scene squashed horizontally as Present scaled the
// oversized backbuffer into the letterbox -- and, unseen, every button
// hit-tested in a different space from the one it was drawn in (the
// exact #50 divergence the note above warns about).
//
extern int gBTGaugeCockpit;
extern int gBTCockpitCanvasW, gBTCockpitCanvasH;
RECT rc;
if (hWnd != NULL && GetClientRect(hWnd, &rc)
if (gBTGaugeCockpit && gBTCockpitCanvasW > 0 && gBTCockpitCanvasH > 0)
{
mPresentParams.BackBufferWidth = (UINT)gBTCockpitCanvasW;
mPresentParams.BackBufferHeight = (UINT)gBTCockpitCanvasH;
}
else if (hWnd != NULL && GetClientRect(hWnd, &rc)
&& rc.right > rc.left && rc.bottom > rc.top)
{
mPresentParams.BackBufferWidth = (UINT)(rc.right - rc.left);
@@ -8799,7 +8849,11 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
// DIAG (turn-hitch hunt): draw CPU is _rt0..here; Present blocks on the GPU.
LARGE_INTEGER _rt1; QueryPerformanceCounter(&_rt1);
hr = mDevice->Present(NULL, NULL, NULL, NULL);
// COCKPIT LETTERBOX: uniform-scale the canvas into the client (NULL = the
// old full-client stretch, which is what every non-cockpit run gets).
RECT _ckDestStorage;
const RECT *_ckDest = BTCockpitPresentDest(mPresentParams.hDeviceWindow, &_ckDestStorage);
hr = mDevice->Present(NULL, _ckDest, NULL, NULL);
// The wait-screen overlay stands down PERMANENTLY the moment the first
// REAL scene frame presents (user report 2026-07-22: the launch handoff
// briefly interleaved idle-overlay presents with game frames = flicker).
@@ -9125,7 +9179,12 @@ void DPLRenderer::ExecuteIdle()
}
backbuffer->Release();
}
HRESULT present_hr = mDevice->Present(NULL, NULL, NULL, NULL);
// same letterbox as the scene present -- the wait overlay paints into
// this backbuffer, so it has to land in the same rect
RECT wait_dest_storage;
const RECT *wait_dest =
BTCockpitPresentDest(mPresentParams.hDeviceWindow, &wait_dest_storage);
HRESULT present_hr = mDevice->Present(NULL, wait_dest, NULL, NULL);
static int s_wait_path_logged = 0;
if (!s_wait_path_logged)
{
@@ -9772,12 +9831,18 @@ static float BTWorldAspectOf(int client_w, int client_h)
// ON-SCREEN aspect = (viewW/canvasW * client_w) / (viewH/canvasH * client_h).
extern int gBTGaugeCockpit;
extern int gBTCockpitCanvasW, gBTCockpitCanvasH;
extern int gBTCockpitLetterbox;
if (gBTGaugeCockpit && gBTCockpitCanvasW > 0 && gBTCockpitCanvasH > 0)
{
BTCockpitLayout L;
BTCockpitComputeLayout(gBTCockpitCanvasW, gBTCockpitCanvasH, &L);
if (L.viewW > 0 && L.viewH > 0)
{
// LETTERBOXED: the canvas is scaled UNIFORMLY, so the view's
// on-screen aspect is just its own -- the client no longer enters
// into it (2026-07-26).
if (gBTCockpitLetterbox)
return (float)L.viewW / (float)L.viewH;
float w = (float)L.viewW * (float)client_w / (float)gBTCockpitCanvasW;
float h = (float)L.viewH * (float)client_h / (float)gBTCockpitCanvasH;
if (h > 0.0f) return w / h;
+53
View File
@@ -0,0 +1,53 @@
//===========================================================================//
// File: l4keylight.h //
// Project: MUNGA Brick: RGB keyboard lamp mirror //
// Contents: Windows Dynamic Lighting bridge (vRIO's KeyboardLampMirror) //
//---------------------------------------------------------------------------//
// Copyright (C) 1994-1995, Virtual World Entertainment, Inc. //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
#pragma once
//########################################################################
// Mirrors the game-commanded RIO lamp states onto per-key RGB keyboards
// through Windows Dynamic Lighting (the WinRT LampArray API), following
// vRIO's KeyboardLampMirror: keys bound to lamp addresses glow with the
// panel palette (red for the banks, yellow for the Secondary/Screen
// columns), flash modes blink at the panel's rates, other keys are
// blacked out so the keyboard reads as the button field. Zone-lit
// keyboards mirror the strongest lamp board-wide. Dynamic Lighting
// grants LED control to the FOREGROUND app - which is the game itself,
// so no Windows settings dance is needed.
//
// The implementation is C++/WinRT on a private worker thread, compiled
// with default struct packing (the engine builds /Zp1, which would
// break the WinRT ABI) - hence this interface is scalars only. All
// functions are safe to call when Dynamic Lighting is unavailable;
// failures log once and the mirror stays dormant.
//########################################################################
// Where the mirror's status lines go (the caller owns the sink).
void
KeyLight_SetLogger(void (*logger)(const char *line));
// The key map: parallel arrays of virtual keys, their RIO lamp
// addresses (0x00-0x47), and whether each paints yellow (Secondary /
// Screen columns) instead of red.
void
KeyLight_SetMap(
const int *virtual_keys,
const int *addresses,
const unsigned char *yellow,
int count
);
// Latest game-commanded lamp bytes (indexed by RIO lamp number).
void
KeyLight_UpdateLamps(const unsigned char *lamp_state, int count);
// Claim keyboards and start painting / release the LEDs to Windows.
void
KeyLight_Start();
void
KeyLight_Stop();
+149
View File
@@ -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
+418 -55
View File
@@ -142,10 +142,18 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
// new client aspect so the scene doesn't stretch fat/skinny (the D3D9
// backbuffer stays at the configured size and is stretched into the
// client area; rendering with the client aspect cancels the stretch).
// Under the cockpit LETTERBOX the canvas is scaled uniformly instead,
// so the aspect comes from the view rect -- BTWorldAspectOf handles it.
if (wParam != SIZE_MINIMIZED && LOWORD(lParam) > 0 && HIWORD(lParam) > 0)
{
extern void L4NotifyWindowResized(int client_w, int client_h);
L4NotifyWindowResized((int)LOWORD(lParam), (int)HIWORD(lParam));
// Repaint the letterbox bars: a COPY present only writes its dest
// rect, so whatever the old bars held would otherwise persist. The
// class brush is black, so an erase is all it takes.
extern int gBTCockpitLetterbox;
if (gBTCockpitLetterbox)
InvalidateRect(hWnd, NULL, TRUE);
}
return 0;
case WM_CLOSE:
@@ -215,6 +223,276 @@ static LONG WINAPI
return EXCEPTION_EXECUTE_HANDLER; // die (after the evidence is out)
}
//===========================================================================//
// environ.ini -- the player's settings file (cwd = content\). One KEY=VALUE
// per line; the real environment always WINS, so a launcher .bat or a shell
// export overrides the file rather than fighting it.
//
// ⚠ Loaded EARLY (2026-07-26). It used to be read ~300 lines into WinMain,
// after the platform-profile block had already run its getenv()s -- so every
// setting the profile reads (BT_PLATFORM, BT_COCKPIT, BT_GLASS_PANELS,
// BT_DEV_GAUGES, L4CONTROLS...) was silently ignored from the file and only
// worked as a real env var. It also putenv()'d comment lines verbatim, which
// turned a commented-out option into an environment variable literally named
// "#BT_MFD_SCALE" -- harmless, but it meant shipping a self-documenting file
// would have littered the environment with junk.
//===========================================================================//
//
// The shipped default, written on first run when the file is absent -- the
// bindings.txt convention ([[glass-cockpit]] §bindings.txt is a COMPATIBILITY
// SURFACE): the player customizes it, it stays UNTRACKED, and an
// extract-over-top upgrade therefore never clobbers their settings. Every
// option ships COMMENTED OUT, so a fresh install applies nothing and behaves
// exactly as it did before the file existed.
//
static const char *kEnvironIniDefault =
"# ============================================================================\n"
"# environ.ini -- BattleTech 4.11 settings\n"
"# ============================================================================\n"
"# One KEY=VALUE per line, read at game start. Lines starting with # or ;\n"
"# are comments. A real environment variable always WINS over this file, so\n"
"# a launcher .bat can override anything here. Delete a line to fall back to\n"
"# the built-in default.\n"
"#\n"
"# Input bindings live in bindings.txt beside this file (written with the\n"
"# full documented layout on first run; delete it to restore defaults).\n"
"\n"
"# ---- The cockpit ------------------------------------------------------------\n"
"\n"
"# Where the five MFDs and the map go:\n"
"# (unset) the COCKPIT SURROUND -- instruments composited around\n"
"# the viewscreen in one window (the default)\n"
"# BT_GLASS_PANELS=1 one desktop window PER display, arranged around the\n"
"# game window (the 'exploded' view -- handy for reading\n"
"# or screenshotting a display at full size)\n"
"# BT_COCKPIT=0 the plain docked gauge strip along the bottom\n"
"#BT_GLASS_PANELS=1\n"
"#BT_COCKPIT=0\n"
"\n"
"# Size of the secondary displays in the cockpit surround, as a percentage of\n"
"# their pod size. The pod bolted them down at one size; on a big panel there\n"
"# is room to trade viewscreen for instrument, so turn these up if you want to\n"
"# actually read the other displays while you fly. 100 = as the pod had them.\n"
"# Range 25-200 (out-of-range or unreadable values fall back to the group\n"
"# setting, then to 100). The surround grows to fit whatever you ask for.\n"
"#\n"
"# BT_MFD_SCALE sets all five MFDs at once...\n"
"#BT_MFD_SCALE=100\n"
"# ...and any single display can override it:\n"
"# UL upper left (heat) UC upper center (engineering)\n"
"# UR upper right (comm) LL lower left (left weapons)\n"
"# LR lower right (right weapons)\n"
"#BT_MFD_SCALE_UL=100\n"
"#BT_MFD_SCALE_UC=100\n"
"#BT_MFD_SCALE_UR=100\n"
"#BT_MFD_SCALE_LL=100\n"
"#BT_MFD_SCALE_LR=100\n"
"\n"
"# The portrait map/radar, sized on its own.\n"
"#BT_RADAR_SCALE=100\n"
"\n"
"# Where the map sits:\n"
"# CENTER bottom centre, under the viewscreen, as the pod had it\n"
"# (default; BOTTOM and CENTRE mean the same)\n"
"# LEFT bottom left corner (or BOTTOMLEFT)\n"
"# RIGHT bottom right corner (or BOTTOMRIGHT)\n"
"# MIDLEFT left edge, halfway up (or LEFTCENTER)\n"
"# MIDRIGHT right edge, halfway up (or RIGHTCENTER)\n"
"# Anywhere but CENTER stops it sitting where the road is. In a bottom corner\n"
"# the lower MFD whose corner it takes slides inboard beside it; halfway up a\n"
"# side it leaves the bottom row entirely.\n"
"#BT_RADAR_POS=CENTER\n"
"\n"
"# Phosphor colour of the mono MFDs, RRGGBB. Default is a green tube.\n"
"#BT_COCKPIT_TINT=21FF42\n"
"\n"
"# ---- Display ----------------------------------------------------------------\n"
"\n"
"# The cockpit is fitted into the window at ONE uniform scale, centred, with\n"
"# the leftover black -- drag the window to any shape and nothing distorts.\n"
"# Launch with -fit (or -windowed-fullscreen) for a borderless window over the\n"
"# whole monitor.\n"
"\n"
"# Simulation/render frame rate, integer frames per second. The desktop\n"
"# default is 60; the arcade pods shipped at 25.\n"
"#TARGETFPS=60\n"
"\n"
"# Anti-aliasing sample count, passed straight to Direct3D 9: 0 = off, else\n"
"# 2..16 as the GPU supports. NOTE: turning this on disables the letterbox\n"
"# fit above (the two use incompatible swap modes) and the cockpit stretches\n"
"# to the window instead.\n"
"#MULTISAMPLE=0\n"
"\n"
"# Particle budget, integer. Default 8192.\n"
"#MAXPARTICLES=8192\n"
"\n"
"# ---- Optional ---------------------------------------------------------------\n"
"\n"
"# RGB keyboard lamp mirror (Windows Dynamic Lighting): keys bound to a lamp\n"
"# button in bindings.txt glow with the panel -- yellow for the map's side\n"
"# columns, red for the rest -- and flash in step with the on-screen buttons.\n"
"# Per-key keyboards light each bound key; zone-lit boards mirror the\n"
"# strongest lamp board-wide. Unset or nonzero = on; 0 = off. A machine\n"
"# without Dynamic Lighting logs once and stays dormant.\n"
"#BT_KEYLIGHT=0\n"
"\n"
"# ---- Multiplayer ------------------------------------------------------------\n"
"\n"
"# 1 = Steam networking (lobbies, FakeIP mesh). Needs the Steam client\n"
"# running; without it the game logs the reason and falls back to Winsock.\n"
"#BT_STEAM_NET=1\n";
//===========================================================================//
// CWD GUARD (2026-07-26, field report).
//
// The engine resolves BTL4.RES, VIDEO\, BTDPL.INI, the eggs AND both player
// config files (bindings.txt, environ.ini) RELATIVE TO CWD -- every launcher
// .bat does `cd content` first, so this was invisible. A BARE launch of the
// exe (double-click, or a shell sitting in build\Release) then:
// - found no resources ("Resource file btl4.res v1.0.0.0 is obsolete!"),
// - wrote a stray bindings.txt / environ.ini / btl4.log NEXT TO THE EXE
// (so the player's real ones in content\ looked like they never appeared),
// - and killed the mission generation the menu launched.
// Reported live: `.\btl4.exe -fit` from build\Release -> menu opens -> crash
// on Launch.
//
// So find content\ ourselves rather than trusting whoever started us. Runs
// BEFORE the log file opens, so the log lands in content\ too -- same place
// the launchers put it. Returns what happened for the boot line.
//===========================================================================//
static const char *gBTCwdFixNote = NULL;
//
// -fit / -windowed-fullscreen, parsed EARLY: the miniconsole front end exits
// this process long before the window-creation code runs, and it rebuilds the
// mission child's command line from scratch -- so without this the flag was
// silently dropped the moment you launched from the menu (field report:
// `btl4.exe -fit` gave a borderless menu and then a windowed mission).
// btl4console.cpp reads it when composing the relaunch.
//
int gBTFitDisplay = 0;
static void BTEnsureContentDirectory(void)
{
if (GetFileAttributesA("BTL4.RES") != INVALID_FILE_ATTRIBUTES)
{
return; // already in the right place
}
char exeDir[MAX_PATH];
if (GetModuleFileNameA(NULL, exeDir, MAX_PATH) == 0)
{
return;
}
char *slash = strrchr(exeDir, '\\');
if (slash == NULL)
{
return;
}
*slash = 0;
// build\Release\btl4.exe -> ..\..\content is the shipped layout; the
// others cover a flattened install and an exe dropped into content\.
static const char *kProbe[] = {
"\\..\\..\\content", "\\..\\content", "\\content",
"", "\\..", "\\..\\.."
};
for (int i = 0; i < (int)(sizeof(kProbe) / sizeof(kProbe[0])); ++i)
{
char candidate[MAX_PATH * 2];
char resource[MAX_PATH * 2];
sprintf_s(candidate, sizeof(candidate), "%s%s", exeDir, kProbe[i]);
sprintf_s(resource, sizeof(resource), "%s\\BTL4.RES", candidate);
if (GetFileAttributesA(resource) != INVALID_FILE_ATTRIBUTES)
{
if (SetCurrentDirectoryA(candidate))
{
gBTCwdFixNote = "found the content directory from the exe path";
}
return;
}
}
gBTCwdFixNote = "NO content directory found (BTL4.RES is nowhere near the "
"exe) -- resources and settings will not load";
}
static void BTWriteDefaultEnvironIni(void)
{
FILE *probe;
if (fopen_s(&probe, "environ.ini", "r") == 0) // already there -- leave it alone
{
fclose(probe);
return;
}
// Only ever write it BESIDE THE RESOURCES. If the cwd guard could not
// find content\, writing here would scatter a stray settings file next to
// whatever the player happened to launch from -- which is exactly the
// confusion the guard exists to end.
if (GetFileAttributesA("BTL4.RES") == INVALID_FILE_ATTRIBUTES)
{
return;
}
FILE *out;
if (fopen_s(&out, "environ.ini", "w") != 0)
return;
fputs(kEnvironIniDefault, out);
fclose(out);
std::cout << "[boot] wrote a default content\\environ.ini (all options "
"commented out -- edit to taste)" << std::endl << std::flush;
}
static void BTLoadEnvironIni(void)
{
BTWriteDefaultEnvironIni();
FILE *file;
if (fopen_s(&file, "environ.ini", "r") != 0)
return;
char line[1024];
int applied = 0, skipped = 0;
while (fgets(line, sizeof(line), file))
{
// strip EOL + trailing blanks
int n = (int)strlen(line);
while (n > 0 && (line[n-1] == '\n' || line[n-1] == '\r' ||
line[n-1] == ' ' || line[n-1] == '\t'))
line[--n] = 0;
const char *p = line;
while (*p == ' ' || *p == '\t') ++p;
if (*p == 0 || *p == '#' || *p == ';') // blank or comment
continue;
const char *eq = strchr(p, '=');
if (eq == NULL || eq == p) // no key, or no '=' at all
{
++skipped;
continue;
}
// The real environment wins: only set what is not already set, so a
// launcher or a shell export beats the file.
char key[256];
size_t klen = (size_t)(eq - p);
if (klen >= sizeof(key)) { ++skipped; continue; }
memcpy(key, p, klen);
key[klen] = 0;
while (klen > 0 && (key[klen-1] == ' ' || key[klen-1] == '\t'))
key[--klen] = 0;
if (getenv(key) != NULL)
continue;
putenv(p);
++applied;
}
fclose(file);
std::cout << "[boot] environ.ini: " << applied << " setting(s) applied"
<< (skipped ? ", " : "") << (skipped ? std::to_string(skipped) : std::string())
<< (skipped ? " line(s) ignored" : "") << std::endl << std::flush;
}
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)
{
// Boot tick for the relaunch storm damper (btl4console.cpp, issue #33):
@@ -228,6 +506,10 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
// else so even boot crashes leave a stack in the log.
SetUnhandledExceptionFilter(BTCrashFilter);
// Sit in content\ before ANYTHING resolves a relative path -- the log file
// below included, so a bare launch logs where the launchers log.
BTEnsureContentDirectory();
// BT_CRASHTEST=1: deliberately AV after the log opens -- verifies the
// crash filter's forensic path end-to-end on any machine.
@@ -279,6 +561,26 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
<< (getenv("BT_FE_LOOP") ? " (relaunched generation)" : " (first process)")
<< std::endl << std::flush;
// Say so when we had to go looking for content\ -- a player who launched
// the exe directly should see WHY their settings appeared where they did.
if (gBTCwdFixNote != NULL)
{
char here[MAX_PATH] = { 0 };
GetCurrentDirectoryA(MAX_PATH, here);
std::cout << "[boot] cwd: " << gBTCwdFixNote << " -> " << here
<< std::endl << std::flush;
}
// The player's settings, before ANYTHING reads the environment (the
// platform profile, the joystick wizard, the layout resolver...).
BTLoadEnvironIni();
if (lpCmdLine && (strstr(lpCmdLine, "-fit") != 0 ||
strstr(lpCmdLine, "-windowed-fullscreen") != 0))
{
gBTFitDisplay = 1;
}
// BT_JOYCONFIG=1: the generic-joystick capture wizard (flight sticks /
// HOTAS / pedals -- L4JOY.h). Console prompts detect which device/axis
// the player moves for each pod control and write the joystick section
@@ -490,21 +792,12 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
if (getenv("BT_DEV_GAUGES") == NULL) putenv("BT_DEV_GAUGES=1");
if (getenv("L4PLASMA") == NULL) putenv("L4PLASMA=SCREEN");
// DEFAULT LAYOUT = the COCKPIT SURROUND (single window: the six gauge
// surfaces + clickable button lamps composited AROUND the centered 3D
// view). Opt out with BT_COCKPIT=0 (dock-bottom strip) or
// BT_GLASS_PANELS=1 (the per-display windows). When cockpit is the
// default the buttons live IN the main window, so no separate pad/panel
// window is created (leave BT_PAD_PANEL / BT_GLASS_PANELS unset).
int cockpitDefault = 1;
if (getenv("BT_COCKPIT") && getenv("BT_COCKPIT")[0] == '0') cockpitDefault = 0;
if (getenv("BT_GLASS_PANELS") && getenv("BT_GLASS_PANELS")[0] != '0') cockpitDefault = 0;
if (getenv("BT_DEV_GAUGES_WINDOW") || getenv("BT_DEV_GAUGES_DOCK")) cockpitDefault = 0;
if (!cockpitDefault)
{
if (getenv("BT_PAD_PANEL") == NULL) putenv("BT_PAD_PANEL=1");
if (getenv("BT_GLASS_PANELS") == NULL) putenv("BT_GLASS_PANELS=1");
}
// The SECONDARY-DISPLAY LAYOUT (surround / per-display windows / docked
// strip / separate window) is resolved once, below, after the shared
// defaults land -- it used to be half-decided here, which is how
// BT_COCKPIT=0 came to mean "per-display windows" despite being
// documented as the dock-bottom opt-out (the dock was unreachable under
// glass). This block now only sets the profile's env presets.
}
#endif
else
@@ -531,12 +824,64 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
if (getenv("BT_DEV_GAUGES") != NULL && getenv("L4GAUGE") == NULL)
putenv("L4GAUGE=640x480x16");
// -------------------------------------------------------------------------
// SECONDARY-DISPLAY LAYOUT -- resolved ONCE, here, now that every profile
// putenv has landed. It used to be decided TWICE (the glass profile block
// picked BT_PAD_PANEL/BT_GLASS_PANELS; the window-sizing block further down
// re-derived cockpit-vs-dock from the same env with its own copy of the
// precedence) and the boot banner read NEITHER -- it announced "per-display
// cockpit windows" for every glass boot, including the surround default.
// The split also broke BT_COCKPIT=0: documented as the dock-bottom opt-out,
// it actually landed on the per-display windows, leaving the docked strip
// unreachable under the glass profile.
//
// One resolver, one answer, consumed by the banner, the pad-panel decision
// and the sizing block. Precedence:
// BT_GLASS_PANELS!=0 per-display cockpit windows (explicit only)
// BT_DEV_GAUGES_WINDOW the legacy separate MFD window
// BT_DEV_GAUGES_DOCK the docked bottom strip
// BT_COCKPIT=0 ...also the docked strip (the documented opt-out)
// (nothing) COCKPIT SURROUND -- the glass default
// -------------------------------------------------------------------------
enum BTGlassLayout { GlassLayoutNone, GlassLayoutCockpit, GlassLayoutPanels,
GlassLayoutDock, GlassLayoutWindow };
int glassLayout = GlassLayoutNone;
if (!fe_menu_mode && getenv("BT_DEV_GAUGES") != NULL)
{
int panels = 0;
#ifdef BT_GLASS
{ extern int BTGlassPanelsActive(); panels = BTGlassPanelsActive(); }
#endif
const char *ck = getenv("BT_COCKPIT");
if (panels) glassLayout = GlassLayoutPanels;
else if (getenv("BT_DEV_GAUGES_WINDOW")) glassLayout = GlassLayoutWindow;
else if (getenv("BT_DEV_GAUGES_DOCK")) glassLayout = GlassLayoutDock;
else if (ck != NULL && ck[0] == '0') glassLayout = GlassLayoutDock;
else glassLayout = GlassLayoutCockpit;
// The surround and the per-display windows carry their own buttons; the
// dock strip and the separate MFD window don't, so the single combined
// pad panel supplies them (else the whole 72-button field is unclickable).
if ((glassLayout == GlassLayoutDock || glassLayout == GlassLayoutWindow)
&& getenv("BT_PAD_PANEL") == NULL)
putenv("BT_PAD_PANEL=1");
}
static const char *kGlassLayoutName[] = {
"no dev gauges",
"cockpit surround",
"per-display cockpit windows [BT_GLASS_PANELS]",
"docked gauge strip",
"separate MFD window"
};
std::cout << "[boot] platform profile: "
<< (fe_menu_mode ? "MENU (front end -- no profile applied)"
: gBTPlatformPod ? "POD (RIO cockpit input; multi-surface gauges/MFDs via pod hardware or explicit L4GAUGE)"
: gBTPlatformGlass ? "GLASS (PadRIO + per-display cockpit windows [BT_GLASS_PANELS] + plasma window)"
: "DEV (single window + keyboard)")
<< std::endl << std::flush;
: gBTPlatformGlass ? "GLASS (PadRIO + plasma window)"
: "DEV (single window + keyboard)");
if (!fe_menu_mode)
std::cout << " [secondary displays: " << kGlassLayoutName[glassLayout] << "]";
std::cout << std::endl << std::flush;
#ifdef BT_GLASS
//
@@ -656,22 +1001,7 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
}
if (getenv("BT_FORCE_FIRE") != NULL) gBTDrive.fireForced = 1;
// Optional environ.ini overrides (same convention as RP).
FILE *file;
char line[1024];
if (fopen_s(&file, "environ.ini", "r") == 0)
{
while (!feof(file))
{
if (fgets(line, sizeof(line), file))
{
for (int i = (int)strlen(line); i >= 0; i--)
if (line[i] == '\n' || line[i] == '\r') line[i] = 0;
putenv(line);
}
}
fclose(file);
}
// (environ.ini is loaded much earlier now -- see BTLoadEnvironIni.)
// Version scheme: 4.10 = the 1995 arcade release; 4.11 = this win32
// reconstruction; build = git commit count, hash pins exact source
@@ -783,25 +1113,11 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
extern int gBTCockpitCanvasW, gBTCockpitCanvasH;
extern int BTGaugeStripHeightFor(int width);
extern void BTCockpitCanvasFor(int viewW, int viewH, int *cw, int *ch);
// BT_GLASS_PANELS breaks the gauges into their own per-display windows, so
// the world window keeps its normal size (no cockpit / bottom strip).
int glassOwnsGauges = 0;
#ifdef BT_GLASS
{ extern int BTGlassPanelsActive(); glassOwnsGauges = BTGlassPanelsActive(); }
#endif
if (!glassOwnsGauges && getenv("BT_DEV_GAUGES") != 0)
// The mode came from the ONE resolver above (glassLayout) -- BT_GLASS_PANELS
// keeps the world window at its normal size (the per-display windows own the
// gauges: no cockpit surround, no bottom strip).
{
// Mode resolution (precedence): explicit BT_COCKPIT > separate-window /
// legacy-inset opt-out > COCKPIT SURROUND default under BT_DEV_GAUGES.
int cockpitOn;
const char *ck = getenv("BT_COCKPIT");
if (ck != NULL)
cockpitOn = (ck[0] != '0');
else
cockpitOn = (getenv("BT_DEV_GAUGES_WINDOW") == 0 &&
getenv("BT_DEV_GAUGES_DOCK") == 0);
if (cockpitOn)
if (glassLayout == GlassLayoutCockpit)
{
gBTGaugeCockpit = 1;
// -res W H = the WORLD VIEW size in cockpit mode; default 900x500.
@@ -822,10 +1138,24 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
winW = cw; winH = ch;
gBTCockpitCanvasW = winW; // windowed backbuffer = client canvas
gBTCockpitCanvasH = winH;
//
// LETTERBOX INTENT, decided HERE rather than at device creation:
// the first WM_SIZE arrives before the device exists, and the
// world-aspect calc needs to know whether the canvas will be
// scaled uniformly (aspect = the view's own) or stretched to
// the client (aspect = the stretched one). Deciding it late
// gave a -fit boot aspect=3.14 on an ultrawide -- applied on
// the first frame, since nothing resizes the window again.
// L4VIDEO clears the flag if it cannot actually get COPY.
{
extern int gBTCockpitLetterbox;
const char *ms = getenv("MULTISAMPLE");
gBTCockpitLetterbox = (ms == NULL || atoi(ms) == 0) ? 1 : 0;
}
std::cout << "[cockpit] view " << viewW << "x" << viewH
<< " canvas " << winW << "x" << winH << std::endl << std::flush;
}
else if (getenv("BT_DEV_GAUGES_WINDOW") == 0)
else if (glassLayout == GlassLayoutDock)
{
gBTGaugeDockBottom = 1;
// Readability default (user-reported: the strip at 800 wide is a
@@ -843,9 +1173,42 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
}
}
}
//
// -fit (alias -windowed-fullscreen): borderless over the whole monitor.
// The cockpit canvas letterboxes inside it at one uniform scale, so a
// wider-than-canvas monitor gets black bars rather than a stretch -- the
// same deal a dragged window gets, just without the chrome. Only useful
// with a layout that HAS a canvas to fit; other modes keep their window.
//
extern int gBTFitDisplay; // parsed early (the menu exits before here)
int fitDisplay = gBTFitDisplay;
RECT wr = { 0, 0, winW, winH };
AdjustWindowRect(&wr, WS_OVERLAPPEDWINDOW, FALSE);
DWORD winStyle = WS_OVERLAPPEDWINDOW;
int winX = 0, winY = 0;
if (fitDisplay)
{
RECT mon = { 0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN) };
MONITORINFO mi;
memset(&mi, 0, sizeof(mi));
mi.cbSize = sizeof(mi);
POINT origin = { 0, 0 };
HMONITOR hmon = MonitorFromPoint(origin, MONITOR_DEFAULTTOPRIMARY);
if (GetMonitorInfo(hmon, &mi))
mon = mi.rcMonitor;
winStyle = WS_POPUP | WS_CLIPCHILDREN;
winX = mon.left;
winY = mon.top;
wr.left = 0; wr.top = 0;
wr.right = mon.right - mon.left;
wr.bottom = mon.bottom - mon.top;
std::cout << "[cockpit] -fit: borderless " << (wr.right - wr.left) << "x"
<< (wr.bottom - wr.top) << " (canvas letterboxes inside)"
<< std::endl << std::flush;
}
//
// Window identity (MP dev): tag the title with the -net port so a player
// running two nodes side-by-side can tell the windows apart when
@@ -863,8 +1226,8 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
else
swprintf(winTitle, 64, L"BattleTech %S", BT_VERSION_STRING);
}
hWnd = CreateWindowEx(0, L"MainWndClass", winTitle, WS_OVERLAPPEDWINDOW,
0, 0, wr.right - wr.left, wr.bottom - wr.top,
hWnd = CreateWindowEx(0, L"MainWndClass", winTitle, winStyle,
winX, winY, wr.right - wr.left, wr.bottom - wr.top,
(HWND)NULL, (HMENU)NULL, hInstance, (LPVOID)NULL);
if (!hWnd) return FALSE;
+17
View File
@@ -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;
+27
View File
@@ -465,6 +465,7 @@ const Receiver::HandlerEntry
MESSAGE_ENTRY(Mech, TakeDamage),
MESSAGE_ENTRY(Mech, PlayerLink),
MESSAGE_ENTRY(Mech, BalanceCoolant), // id 0x16 @0049f728 (issue #20)
MESSAGE_ENTRY(Mech, DuckRequest), // id 0x1a @0049fa00 (CROUCH, 2026-07-26)
};
//
@@ -486,6 +487,32 @@ void
DEBUG_STREAM << "[valve] BALANCE (all valves -> 1)" << std::endl;
}
//
// @0049fa00 -- DuckRequest (id 0x1a): the CROUCH button (RIO 0x13). The
// binary's whole body, verbatim:
//
// if (0 < *(int *)(message + 0xc)) *(undefined4 *)(this + 0x398) = 1;
//
// this+0x398 is duckState (attribute 0x37) -- the mech reset is the only other
// writer in the binary (part_012.c:9439, the same reset that zeroes
// incomingLock and sets distanceToMissile = FLT_MAX, which is how we already
// map that region). Nothing in the decompiled code READS it: it is published
// as an attribute, so its consumer is a databinding (the SQUAT clips), not a
// call. So this handler is complete as-is -- the flag IS the feature's code
// side, and a crouch implementation here would be a stand-in for data we have
// not yet identified. See context/pod-hardware.md.
//
void
Mech::DuckRequestMessageHandler(ReceiverDataMessageOf<int> *message)
{
if (message->dataContents <= 0) // press only (msg+0xc)
{
return;
}
duckState = 1;
DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1" << std::endl << std::flush;
}
Receiver::MessageHandlerSet
Mech::MessageHandlers(
ELEMENTS(Mech::MessageHandlerEntries),
+16
View File
@@ -1193,6 +1193,22 @@ protected:
enum { BalanceCoolantMessageID = 0x16 };
void BalanceCoolantMessageHandler(ReceiverDataMessageOf<int> *message);
// @0049fa00 -- "DuckRequest" (Mech handler table @0x50BDF8, id 0x1a; the
// pod's CROUCH button, RIO address 0x13 -- the manual gives it a whole
// section). The last unregistered id in the Mech table; until now a
// press was silently swallowed by Receiver::Receive.
//
// The binary's ENTIRE body is two lines: press-only (msg+0xc > 0), then
// duckState@0x398 = 1. It is a one-shot REQUEST flag, not a posture
// toggle -- the handler never clears it, and the only other writer in
// the whole binary is the mech reset (0). duckState has NO code reader
// anywhere in the decomp; it is published as attribute 0x37, so whatever
// consumes it consumes it through DATABINDING (the SQUAT clips), not
// through a call. Reconstructed exactly: setting the flag is the
// authentic behaviour, and inventing a crouch here would be a stand-in.
enum { DuckRequestMessageID = 0x1a };
void DuckRequestMessageHandler(ReceiverDataMessageOf<int> *message);
// --- damage-routing support (mechdmg / mech4) -----------------------
// Typed access to the inherited Entity::damageZones[] (engine stores DamageZone*;
// our entries are Mech__DamageZone, populated by the Mech ctor). Defined in
+12
View File
@@ -104,6 +104,18 @@ control (stick, twist, throttle, fire buttons), writes the bindings,
and drops you into the solo menu to try them. Rebind keys in
content\bindings.txt (regenerates with defaults if deleted).
FULL CONTROLS REFERENCE: see CONTROLS.txt next to this file -- every one of
the pod's 72 cockpit buttons, what it does, and which display it sits on.
COCKPIT LAYOUT: content\environ.ini (written on first run, every option
documented in the file itself) sizes the MFDs and the map, moves the map out
of the middle of the road, or breaks each display into its own window. The
cockpit now scales to ANY window shape without distorting -- drag it, maximise
it, or launch with -fit for borderless full screen.
RGB KEYBOARDS: if yours supports Windows Dynamic Lighting, the keys you have
bound to cockpit buttons glow in the panel's colours and flash along with it.
This build: {VERSION}. Private -- do not redistribute.
{EXPIRE}Test builds STOP WORKING 14 days after they are made -- if the game
{EXPIRE}says it has expired, just grab the operator's newest zip.
+111
View File
@@ -0,0 +1,111 @@
"""Verify the RIO button field from a BT_RIOBANK_LOG=1 run.
The banks now reach UNDER the glass and deliberately overlap (the map's foot
row sits inside both side columns' reach), so "the address is in the list" is
not enough -- the hit test takes the FIRST match, so an address whose rect is
entirely shadowed by earlier buttons is dead however big it looks.
Reads btl4.log, groups by bank tag, and for each button asks: is there any
point in my rect that no EARLIER button in this bank covers? Reports the
address census per bank and any shadowed address.
python checkbank.py <log> [--expect 0x00-0x47]
"""
import re
import sys
from collections import OrderedDict
BANK_RE = re.compile(r"\[riobank\] (.+?) bounds=\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\) (\d+) buttons")
BTN_RE = re.compile(r"\[riobank\] addr=0x([0-9a-f]+) rect=\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\) class=(\d+)")
def parse(path):
banks = OrderedDict()
current = None
with open(path, "r", errors="replace") as handle:
for line in handle:
match = BANK_RE.search(line)
if match:
current = match.group(1)
# a re-run appends; keep the FIRST pass of each tag
banks.setdefault(current, [])
if banks[current]:
current = None # already captured this tag
continue
match = BTN_RE.search(line)
if match and current is not None:
addr = int(match.group(1), 16)
x, y, w, h = (int(match.group(i)) for i in range(2, 6))
banks[current].append((addr, x, y, w, h, int(match.group(6))))
return banks
def reachable(rect, earlier):
"""Any point of `rect` not covered by an earlier rect?
Sampled on the coordinate grid induced by the earlier rects' edges -- if a
gap exists at all, one of these sample points lands in it.
"""
_, x, y, w, h = rect[:5]
xs = {x, x + w - 1}
ys = {y, y + h - 1}
for _, ex, ey, ew, eh, _ in earlier:
for candidate in (ex - 1, ex, ex + ew - 1, ex + ew):
if x <= candidate < x + w:
xs.add(candidate)
for candidate in (ey - 1, ey, ey + eh - 1, ey + eh):
if y <= candidate < y + h:
ys.add(candidate)
for px in sorted(xs):
for py in sorted(ys):
hit = None
for button in earlier:
_, ex, ey, ew, eh, _ = button
if ex <= px < ex + ew and ey <= py < ey + eh:
hit = button
break
if hit is None:
return True
return False
def main():
path = sys.argv[1] if len(sys.argv) > 1 else "btl4.log"
banks = parse(path)
if not banks:
print("no [riobank] lines -- was BT_RIOBANK_LOG=1 set?")
return 1
seen = set()
failures = []
for tag, buttons in banks.items():
addresses = [b[0] for b in buttons]
duplicates = {a for a in addresses if addresses.count(a) > 1}
shadowed = []
for index, button in enumerate(buttons):
if not reachable(button, buttons[:index]):
shadowed.append(button[0])
seen.update(addresses)
status = "ok"
if duplicates or shadowed:
status = "FAIL"
failures.append(tag)
print("%-22s %2d buttons 0x%02x-0x%02x %s"
% (tag, len(buttons), min(addresses), max(addresses), status))
if duplicates:
print(" duplicate addresses: %s"
% ", ".join("0x%02x" % a for a in sorted(duplicates)))
if shadowed:
print(" SHADOWED (no first-hit point): %s"
% ", ".join("0x%02x" % a for a in sorted(shadowed)))
print("\n%d distinct addresses across %d banks" % (len(seen), len(banks)))
missing = [a for a in range(0x00, 0x48) if a not in seen]
if missing:
print("not placed by any bank: %s"
% ", ".join("0x%02x" % a for a in missing))
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+161
View File
@@ -0,0 +1,161 @@
"""Click every RIO button the layout claims, and check each one dispatched.
Reads a BT_RIOBANK_LOG=1 dump for the rects, posts a real WM_LBUTTONDOWN/UP at
each button's centre, then compares the addresses the game LOGGED against the
addresses the layout placed. This is the end-to-end check the geometry dump
alone cannot give: it proves the hit test, the coordinate mapping and the RIO
dispatch agree with the rects.
python clickbank.py <log> <window-title-substring> [--mode surround|exploded]
surround: one window, buttons are in backbuffer canvas space (== client space
for a windowed run), so the centres go straight to the main window.
exploded: one window per bank, each with its own client space; the bank tag
names the window.
"""
import ctypes
import re
import sys
import time
from ctypes import wintypes
user32 = ctypes.WinDLL("user32", use_last_error=True)
WM_LBUTTONDOWN = 0x0201
WM_LBUTTONUP = 0x0202
MK_LBUTTON = 0x0001
BANK_RE = re.compile(r"\[riobank\] (.+?) bounds=")
BTN_RE = re.compile(r"\[riobank\] addr=0x([0-9a-f]+) rect=\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\)")
def parse(path):
banks, current, done = {}, None, set()
with open(path, "r", errors="replace") as handle:
for line in handle:
match = BANK_RE.search(line)
if match:
current = match.group(1)
if current in done:
current = None
else:
done.add(current)
banks[current] = []
continue
match = BTN_RE.search(line)
if match and current is not None:
addr = int(match.group(1), 16)
x, y, w, h = (int(match.group(i)) for i in range(2, 6))
banks[current].append((addr, x + w // 2, y + h // 2))
return banks
def find_windows(substring):
found = []
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
def callback(hwnd, _):
length = user32.GetWindowTextLengthW(hwnd)
if length:
buffer = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buffer, length + 1)
if substring.lower() in buffer.value.lower() and user32.IsWindowVisible(hwnd):
found.append((hwnd, buffer.value))
return True
user32.EnumWindows(callback, 0)
return found
class RECT(ctypes.Structure):
_fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long),
("right", ctypes.c_long), ("bottom", ctypes.c_long)]
def canvas_size(path):
"""The cockpit canvas from the boot line, e.g. '[cockpit] view 900x500 canvas 1452x1059'."""
with open(path, "r", errors="replace") as handle:
for line in handle:
match = re.search(r"\[cockpit\] view \d+x\d+ canvas (\d+)x(\d+)", line)
if match:
return int(match.group(1)), int(match.group(2))
return None
def fit_rect(client_w, client_h, canvas_w, canvas_h):
"""Same uniform-scale centred fit the game computes (BTCockpitFitRect)."""
w = client_w
h = client_w * canvas_h // canvas_w
if h > client_h:
h = client_h
w = client_h * canvas_w // canvas_h
return ((client_w - w) // 2, (client_h - h) // 2, max(w, 1), max(h, 1))
def click(hwnd, x, y):
lparam = (y << 16) | (x & 0xFFFF)
user32.PostMessageW(hwnd, WM_LBUTTONDOWN, MK_LBUTTON, lparam)
time.sleep(0.02)
user32.PostMessageW(hwnd, WM_LBUTTONUP, 0, lparam)
time.sleep(0.02)
def main():
log = sys.argv[1]
title = sys.argv[2]
mode = "surround"
if "--mode" in sys.argv:
mode = sys.argv[sys.argv.index("--mode") + 1]
banks = parse(log)
if not banks:
print("no [riobank] lines in", log)
return 1
# exploded: each bank is its OWN window with its own caption, so match
# across every visible window rather than filtering by one title
windows = find_windows("" if mode == "exploded" else title)
if not windows:
print("no window matching", title)
return 1
# surround: the dump is in CANVAS space; if the window has been resized the
# canvas is letterboxed inside the client, so map through the same fit the
# game uses or every click lands off its button
transform = None
if mode == "surround":
canvas = canvas_size(log)
client = RECT()
user32.GetClientRect(windows[0][0], ctypes.byref(client))
if canvas and client.right > 0 and client.bottom > 0:
ox, oy, fw, fh = fit_rect(client.right, client.bottom, canvas[0], canvas[1])
if (fw, fh) != canvas:
transform = (ox, oy, fw, fh, canvas[0], canvas[1])
print("client %dx%d, canvas %dx%d -> fit %dx%d at (%d,%d)"
% (client.right, client.bottom, canvas[0], canvas[1], fw, fh, ox, oy))
posted = set()
for tag, buttons in banks.items():
if mode == "surround":
target = windows[0][0]
else:
# the bank tag is the window caption
match = [h for h, caption in windows if caption.strip() == tag.strip()]
if not match:
print(" no window for bank %r -- skipped" % tag)
continue
target = match[0]
for addr, cx, cy in buttons:
if transform is not None:
ox, oy, fw, fh, cw, ch = transform
cx = ox + cx * fw // cw
cy = oy + cy * fh // ch
click(target, cx, cy)
posted.add(addr)
print("posted clicks for %d addresses" % len(posted))
print(" ".join("0x%02x" % a for a in sorted(posted)))
return 0
if __name__ == "__main__":
sys.exit(main())
+91
View File
@@ -0,0 +1,91 @@
"""Measure the map's PAINTED legend grid, and compare the lamp bars against it.
The side button columns should line up with the legend cells the map imagery
paints beside them. RP412 measured ITS map at 13 + 6x102 on a 105 pitch of 640
-- a different game's art, so those numbers do not transfer. This finds ours.
Two scans over a native Secondary/Radar capture:
* the LEGEND: horizontal rules of the six bordered cells in the map imagery
* the LAMPS: the runs of the yellow button strip at the window's left edge
and reports the offset between them, which is what a misalignment looks like.
py measurelegend.py <capture.png> <caption_h> <lamp_x> <legend_x0> <legend_x1>
"""
import sys
from PIL import Image
def main():
path = sys.argv[1]
caption = int(sys.argv[2]) if len(sys.argv) > 2 else 30
lamp_x = int(sys.argv[3]) if len(sys.argv) > 3 else 12
lx0 = int(sys.argv[4]) if len(sys.argv) > 4 else 22
lx1 = int(sys.argv[5]) if len(sys.argv) > 5 else 118
image = Image.open(path).convert("RGB")
w, h = image.size
px = image.load()
print("capture %dx%d (client starts at y=%d)" % (w, h, caption))
# --- the legend: rows carrying a long horizontal ORANGE rule -------------
def orange(p):
return p[0] > 110 and p[1] > 45 and p[2] < 90 and p[0] > p[2] + 60
rules = []
for y in range(caption, h):
run = 0
best = 0
for x in range(lx0, min(lx1, w)):
if orange(px[x, y]):
run += 1
best = max(best, run)
else:
run = 0
if best >= (lx1 - lx0) * 0.55:
rules.append(y - caption)
# collapse adjacent rows into single rules
grouped = []
for y in rules:
if grouped and y - grouped[-1][-1] <= 2:
grouped[-1].append(y)
else:
grouped.append([y])
rule_tops = [g[0] for g in grouped]
print("\nlegend rules (client y): %s" % rule_tops)
if len(rule_tops) >= 2:
print("gaps: %s" % [rule_tops[i+1] - rule_tops[i]
for i in range(len(rule_tops) - 1)])
# --- the lamps: runs of lit pixels in the button strip -------------------
lit = []
for y in range(caption, h):
p = px[lamp_x, y]
lit.append(p[0] > 55 and p[1] > 40 and p[2] < 90)
runs = []
start = None
for i, on in enumerate(lit):
if on and start is None:
start = i
elif not on and start is not None:
if i - start >= 4:
runs.append((start, i - start))
start = None
if start is not None and len(lit) - start >= 4:
runs.append((start, len(lit) - start))
print("\nlamp bars (client y, height): %s" % runs)
if len(runs) >= 2:
print("lamp pitch: %s" % [runs[i+1][0] - runs[i][0]
for i in range(len(runs) - 1)])
# --- compare ------------------------------------------------------------
if rule_tops and runs:
print("\ncell top vs lamp top:")
for i, (top, height) in enumerate(runs[:len(rule_tops)]):
print(" lamp %d top=%4d nearest rule=%4d offset=%+d"
% (i, top, rule_tops[i], top - rule_tops[i]))
if __name__ == "__main__":
main()
+29
View File
@@ -119,6 +119,35 @@ def main():
lines = [l for l in lines if "{EXPIRE}" not in l]
readme = "".join(lines)
z.writestr(name + "/README.txt", readme)
# The full controls reference travels with the game, so players get the
# 72-button panel map without the repo. Flattened to ASCII so it reads
# correctly in Notepad -- the markdown source keeps its typography.
if os.path.exists("docs/CONTROLS.md"):
controls = open("docs/CONTROLS.md", encoding="utf-8").read()
for bad, good in ((u"", "-"), (u"", "-"), (u"", "'"),
(u"", "'"), (u"", '"'), (u"", '"'),
(u"×", "x"), (u"", "->"), (u"", "..."),
(u"", "Up"), (u"", "Down"),
(u"", "Left"), (u"", "Right"),
(u"", "!"), (u"·", "-")):
controls = controls.replace(bad, good)
controls = controls.encode("ascii", "replace").decode("ascii")
z.writestr(name + "/CONTROLS.txt", controls)
print(" controls reference -> CONTROLS.txt")
# The same map as a page, for anyone who would rather look at the
# diagrams than read them. The source is a fragment (no doctype or
# <head>), so wrap it: without a doctype the browser drops into quirks
# mode, and without a charset the typography arrives as mojibake.
if os.path.exists("docs/CONTROLS.html"):
page = open("docs/CONTROLS.html", encoding="utf-8").read()
page = ('<!doctype html>\n<html lang="en">\n<head>\n'
'<meta charset="utf-8">\n'
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
'</head>\n<body>\n' + page + '\n</body>\n</html>\n')
z.writestr(name + "/CONTROLS.html", page.encode("utf-8"))
print(" controls page -> CONTROLS.html")
for p in bats: # bats at the zip ROOT (next to
arc = os.path.basename(p) # content\ + build\, per the guard)
z.write(p, name + "/" + arc)