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
+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.