Glass cockpit refit: one mode resolver, one button geometry, and a cockpit that scales

Replicates the RP412 cockpit line into BT411's own architecture -- porting the
geometry and keeping our renderers, so BT_SHOT single-frame verification stays
intact.

MODE RESOLVER.  Where the secondary displays go was decided in TWO places with
duplicated precedence, and the boot banner read NEITHER -- it announced
"per-display cockpit windows" for every glass boot, surround included.  The
split had also broken BT_COCKPIT=0: documented as the dock-bottom opt-out, the
profile block converted it to BT_GLASS_PANELS=1, so the docked strip was
UNREACHABLE under the glass profile.  One resolver now, consumed by the banner,
the pad-panel decision and the window sizing; BT_GLASS_PANELS is explicit-only;
dock/window modes auto-raise BT_PAD_PANEL so the button field always has a home.

L4RIOBANK -- one button geometry.  Both renderers carried their own copy and had
drifted: an MFD button was 156x138 reaching under the glass in the exploded
window and a 76x24 sliver entirely OUTSIDE the glass in the surround.  One
module owns it now, both are consumers, placement stays per-renderer.  The pod's
under-glass rule (RP412 L4MFDVIEW): reach half the glass in behind the display,
leave a lamp strip clearing the edge, paint buttons first and imagery over --
so the lamp reads as a bar and practically the whole display is the press
target.  The strip scales off the display's SHORT axis (the map is portrait)
with a readable floor.  Retired L4GLASSWIN's three local placers and seven
layout constants.

LAMP FLASH DECODE was wrong [T1].  BTLampBrightnessOf returned max(state1,
state2) and blanked on the alternate phase; RIO::LampState (L4RIO.h [T0]) says
solid shows state 1 and flashing ALTERNATES the two.  Agrees only when one state
is Off -- true for the Panic lamp, which is why it survived -- but L4LAMP.cpp:252
commands flashFast + state1Dim + state2Bright, a dim->bright pulse that rendered
as a hard bright->off blink.  Three copies existed (l4vb16.h, L4GLASSWIN,
L4PADPANEL), all three wrong; the two locals now forward to the one fixed inline.

THE COCKPIT SCALES.  The fixed canvas was stretched into whatever the client
area was, so a window dragged to a different shape squashed the instruments (the
projection was aspect-corrected in task #20; the panels never were).  Now one
uniform scale, centred, leftover black.  D3D9 applies it as a Present
destination rect, which DISCARD forbids -- so the WINDOWED swap effect becomes
COPY when the surround is up and multisampling is off.  The click mapping had to
follow (mapping against the full client drifts the hit test off every button by
the bar width), as did the world aspect (under a uniform scale it is the view
rect's own).  -fit / -windowed-fullscreen: borderless over the monitor.

 - ordering trap: the first WM_SIZE beats the device, so a -fit boot logged
   aspect=3.14 and applied it on frame 1.  The letterbox INTENT is decided in
   btl4main; L4VIDEO only confirms or withdraws it.

PLAYER-TUNABLE DISPLAYS.  BT_MFD_SCALE (+ _UL/_UC/_UR/_LL/_LR), BT_RADAR_SCALE,
BT_RADAR_POS (CENTER/LEFT/RIGHT/MIDLEFT/MIDRIGHT).  The surround BANDS derive
from the resolved sizes -- that is why the sizes could not stay constants: the
band a display hangs in has to grow with it or the canvas clips it.  100%
reproduces the historical L276 R276 T223 B336 exactly.  A corner map goes flush
to the CANVAS edge and the lower MFD slides beside it (measuring off the view
edge overlapped them by 232px).

MAP LEGEND GRID -- measured, not inherited.  scratchpad/measurelegend.py over a
native capture: top 3, cell 102, pitch 107 of 640.  RP412's map is 13 + 6x102 @
105 -- same cell height, different top and pitch, so its numbers do NOT
transfer.  Our old even division had the pitch right by luck and sat 3px high of
the labels.

environ.ini.  It was read ~300 lines into WinMain, AFTER the platform-profile
block had run its getenv()s -- so every setting the profile reads was silently
ignored FROM THE FILE and only worked as a real env var.  It also putenv()'d
comments verbatim.  Now loaded immediately after the first-breath line, comments
skipped, the real environment WINS over the file, and a fully documented default
is written on first run (the bindings.txt convention: untracked, so
extract-over-top never clobbers a player's settings).

VERIFICATION HARNESS (new, reusable): BT_RIOBANK_LOG=1 dumps every bank;
checkbank.py proves no address is SHADOWED (an address whose rect is covered by
earlier buttons is dead however big it looks -- the overlapping under-glass banks
make that a live hazard); clickbank.py posts a real click at every button centre.

Verified: 72/72 placed, 0 shadowed, 72/72 dispatched in BOTH modes, after a
resize, at 150%/135%, and at 75%+BOTTOMRIGHT; wide/tall drags and -fit
undistorted on a 3440x1440; exploded/dock/pod/dev un-regressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-26 02:23:40 -05:00
co-authored by Claude Opus 5
parent 1cda880c6d
commit 61563c9efe
15 changed files with 1670 additions and 282 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
View File
@@ -265,6 +265,7 @@ target_sources(munga_engine PRIVATE
"engine/MUNGA_L4/L4PADBINDINGS.cpp"
"engine/MUNGA_L4/L4JOY.cpp"
"engine/MUNGA_L4/L4PADPANEL.cpp"
"engine/MUNGA_L4/L4RIOBANK.cpp"
"engine/MUNGA_L4/L4GLASSWIN.cpp"
"engine/MUNGA_L4/L4PLASMAWIN.cpp"
)
+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`.
+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/
+78 -112
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;
@@ -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;
}
//###########################################################################
+8 -22
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>
@@ -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);
}
//###########################################################################
+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;
}
+348 -70
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
@@ -316,18 +553,34 @@ void BTCockpitComputeLayout(int canvasW, int canvasH, BTCockpitLayout *out)
static const int thrInert[8] = { 1, 1, 1, 1, 1, 0, 1, 0 };
static const char *joyLabel[8] = { "Main", "Hat Bk", "Hat Up", "Hat R",
"Hat L", "Pinky", "Middle", "Upper" }; // 0x40..0x47
const int cw = 68, ch = 30, g = 4;
int blockW = 4 * cw + 3 * g; // 284
int belowLamp = kCkMFDH + kCkRedGap + kCkRedH + 8; // clear the MFD's bottom red lamps
int thrX = L->surfX[2], thrY = L->surfY[2] + belowLamp;
int joyX = L->surfX[3] + kCkMFDW - blockW, joyY = L->surfY[3] + belowLamp;
for (int i = 0; i < 8; i++)
// These cells carry TEXT, so the surround runs them bigger than the
// exploded window's 58x28 -- the caller owns the cell size
// (l4riobank.h), only the grid walk is shared.
BTRioBankMetrics metrics;
metrics.strip = 0;
metrics.gap = 4;
metrics.cellW = 68;
metrics.cellH = 30;
// Hang each block below ITS lower MFD, clearing that display's own
// (scaled) bottom lamp strip -- a shrunken MFD used to leave the block
// floating in the gap, an enlarged one to overlap it.
int blockW = 4 * metrics.cellW + 3 * metrics.gap; // 284
int thrX = L->surfX[2];
int thrY = L->surfY[2] + L->surfH[2] + kCkRedGap + kCkRedH + 8;
int joyX = L->surfX[3] + L->surfW[3] - blockW;
int joyY = L->surfY[3] + L->surfH[3] + kCkRedGap + kCkRedH + 8;
BTRioBank flight;
memset(&flight, 0, sizeof(flight));
BTRioBankFlightGrid(thrX, thrY, 0x38, 8, 4, thrLabel, thrInert, &metrics, &flight);
BTRioBankFlightGrid(joyX, joyY, 0x40, 8, 4, joyLabel, 0, &metrics, &flight);
BTRioBankDump("surround/flight", &flight);
for (int i = 0; i < flight.buttonCount; i++)
{
int c = i % 4, r = i / 4;
CkPushBtn(L, 0x38 + i, thrX + c * (cw + g), thrY + r * (ch + g), cw, ch, 2,
thrInert[i], thrLabel[i] ? thrLabel[i] : "");
CkPushBtn(L, 0x40 + i, joyX + c * (cw + g), joyY + r * (ch + g), cw, ch, 2,
0, joyLabel[i]);
const BTRioButton &b = flight.buttons[i];
CkPushBtn(L, b.address, b.x, b.y, b.w, b.h, b.colorClass, b.inert,
b.label ? b.label : "");
}
}
}
@@ -959,8 +1212,33 @@ int BTCockpitMouseDown(int cx, int cy, int clientW, int clientH, int rightButton
if (!gBTGaugeCockpit) return 0;
int bbW = gBTCockpitCanvasW, bbH = gBTCockpitCanvasH;
if (bbW <= 0 || bbH <= 0 || clientW <= 0 || clientH <= 0) return 0;
int bx = cx * bbW / clientW;
int by = cy * bbH / clientH;
// Client -> canvas. Under the letterbox the canvas occupies a centred
// sub-rect, so the click has to come back through the SAME transform
// Present used -- mapping against the full client would drift the hit test
// off every button by the bar width. A click on a bar hits nothing.
int bx, by;
if (gBTCockpitLetterbox)
{
RECT fit;
if (BTCockpitFitRect(clientW, clientH, &fit))
{
if (cx < fit.left || cx >= fit.right || cy < fit.top || cy >= fit.bottom)
return 0;
bx = (cx - fit.left) * bbW / (fit.right - fit.left);
by = (cy - fit.top) * bbH / (fit.bottom - fit.top);
}
else
{
bx = cx * bbW / clientW;
by = cy * bbH / clientH;
}
}
else
{
bx = cx * bbW / clientW;
by = cy * bbH / clientH;
}
BTCockpitLayout L;
BTCockpitComputeLayout(bbW, bbH, &L);
+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;
}
//########################################################################
+45 -2
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;
@@ -8799,7 +8827,11 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
// DIAG (turn-hitch hunt): draw CPU is _rt0..here; Present blocks on the GPU.
LARGE_INTEGER _rt1; QueryPerformanceCounter(&_rt1);
hr = mDevice->Present(NULL, NULL, NULL, NULL);
// COCKPIT LETTERBOX: uniform-scale the canvas into the client (NULL = the
// old full-client stretch, which is what every non-cockpit run gets).
RECT _ckDestStorage;
const RECT *_ckDest = BTCockpitPresentDest(mPresentParams.hDeviceWindow, &_ckDestStorage);
hr = mDevice->Present(NULL, _ckDest, NULL, NULL);
// The wait-screen overlay stands down PERMANENTLY the moment the first
// REAL scene frame presents (user report 2026-07-22: the launch handoff
// briefly interleaved idle-overlay presents with game frames = flicker).
@@ -9125,7 +9157,12 @@ void DPLRenderer::ExecuteIdle()
}
backbuffer->Release();
}
HRESULT present_hr = mDevice->Present(NULL, NULL, NULL, NULL);
// same letterbox as the scene present -- the wait overlay paints into
// this backbuffer, so it has to land in the same rect
RECT wait_dest_storage;
const RECT *wait_dest =
BTCockpitPresentDest(mPresentParams.hDeviceWindow, &wait_dest_storage);
HRESULT present_hr = mDevice->Present(NULL, wait_dest, NULL, NULL);
static int s_wait_path_logged = 0;
if (!s_wait_path_logged)
{
@@ -9772,12 +9809,18 @@ static float BTWorldAspectOf(int client_w, int client_h)
// ON-SCREEN aspect = (viewW/canvasW * client_w) / (viewH/canvasH * client_h).
extern int gBTGaugeCockpit;
extern int gBTCockpitCanvasW, gBTCockpitCanvasH;
extern int gBTCockpitLetterbox;
if (gBTGaugeCockpit && gBTCockpitCanvasW > 0 && gBTCockpitCanvasH > 0)
{
BTCockpitLayout L;
BTCockpitComputeLayout(gBTCockpitCanvasW, gBTCockpitCanvasH, &L);
if (L.viewW > 0 && L.viewH > 0)
{
// LETTERBOXED: the canvas is scaled UNIFORMLY, so the view's
// on-screen aspect is just its own -- the client no longer enters
// into it (2026-07-26).
if (gBTCockpitLetterbox)
return (float)L.viewW / (float)L.viewH;
float w = (float)L.viewW * (float)client_w / (float)gBTCockpitCanvasW;
float h = (float)L.viewH * (float)client_h / (float)gBTCockpitCanvasH;
if (h > 0.0f) return w / h;
+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
+318 -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,194 @@ static LONG WINAPI
return EXCEPTION_EXECUTE_HANDLER; // die (after the evidence is out)
}
//===========================================================================//
// environ.ini -- the player's settings file (cwd = content\). One KEY=VALUE
// per line; the real environment always WINS, so a launcher .bat or a shell
// export overrides the file rather than fighting it.
//
// ⚠ Loaded EARLY (2026-07-26). It used to be read ~300 lines into WinMain,
// after the platform-profile block had already run its getenv()s -- so every
// setting the profile reads (BT_PLATFORM, BT_COCKPIT, BT_GLASS_PANELS,
// BT_DEV_GAUGES, L4CONTROLS...) was silently ignored from the file and only
// worked as a real env var. It also putenv()'d comment lines verbatim, which
// turned a commented-out option into an environment variable literally named
// "#BT_MFD_SCALE" -- harmless, but it meant shipping a self-documenting file
// would have littered the environment with junk.
//===========================================================================//
//
// The shipped default, written on first run when the file is absent -- the
// bindings.txt convention ([[glass-cockpit]] §bindings.txt is a COMPATIBILITY
// SURFACE): the player customizes it, it stays UNTRACKED, and an
// extract-over-top upgrade therefore never clobbers their settings. Every
// option ships COMMENTED OUT, so a fresh install applies nothing and behaves
// exactly as it did before the file existed.
//
static const char *kEnvironIniDefault =
"# ============================================================================\n"
"# environ.ini -- BattleTech 4.11 settings\n"
"# ============================================================================\n"
"# One KEY=VALUE per line, read at game start. Lines starting with # or ;\n"
"# are comments. A real environment variable always WINS over this file, so\n"
"# a launcher .bat can override anything here. Delete a line to fall back to\n"
"# the built-in default.\n"
"#\n"
"# Input bindings live in bindings.txt beside this file (written with the\n"
"# full documented layout on first run; delete it to restore defaults).\n"
"\n"
"# ---- The cockpit ------------------------------------------------------------\n"
"\n"
"# Where the five MFDs and the map go:\n"
"# (unset) the COCKPIT SURROUND -- instruments composited around\n"
"# the viewscreen in one window (the default)\n"
"# BT_GLASS_PANELS=1 one desktop window PER display, arranged around the\n"
"# game window (the 'exploded' view -- handy for reading\n"
"# or screenshotting a display at full size)\n"
"# BT_COCKPIT=0 the plain docked gauge strip along the bottom\n"
"#BT_GLASS_PANELS=1\n"
"#BT_COCKPIT=0\n"
"\n"
"# Size of the secondary displays in the cockpit surround, as a percentage of\n"
"# their pod size. The pod bolted them down at one size; on a big panel there\n"
"# is room to trade viewscreen for instrument, so turn these up if you want to\n"
"# actually read the other displays while you fly. 100 = as the pod had them.\n"
"# Range 25-200 (out-of-range or unreadable values fall back to the group\n"
"# setting, then to 100). The surround grows to fit whatever you ask for.\n"
"#\n"
"# BT_MFD_SCALE sets all five MFDs at once...\n"
"#BT_MFD_SCALE=100\n"
"# ...and any single display can override it:\n"
"# UL upper left (heat) UC upper center (engineering)\n"
"# UR upper right (comm) LL lower left (left weapons)\n"
"# LR lower right (right weapons)\n"
"#BT_MFD_SCALE_UL=100\n"
"#BT_MFD_SCALE_UC=100\n"
"#BT_MFD_SCALE_UR=100\n"
"#BT_MFD_SCALE_LL=100\n"
"#BT_MFD_SCALE_LR=100\n"
"\n"
"# The portrait map/radar, sized on its own.\n"
"#BT_RADAR_SCALE=100\n"
"\n"
"# Where the map sits:\n"
"# CENTER bottom centre, under the viewscreen, as the pod had it\n"
"# (default; BOTTOM and CENTRE mean the same)\n"
"# LEFT bottom left corner (or BOTTOMLEFT)\n"
"# RIGHT bottom right corner (or BOTTOMRIGHT)\n"
"# MIDLEFT left edge, halfway up (or LEFTCENTER)\n"
"# MIDRIGHT right edge, halfway up (or RIGHTCENTER)\n"
"# Anywhere but CENTER stops it sitting where the road is. In a bottom corner\n"
"# the lower MFD whose corner it takes slides inboard beside it; halfway up a\n"
"# side it leaves the bottom row entirely.\n"
"#BT_RADAR_POS=CENTER\n"
"\n"
"# Phosphor colour of the mono MFDs, RRGGBB. Default is a green tube.\n"
"#BT_COCKPIT_TINT=21FF42\n"
"\n"
"# ---- Display ----------------------------------------------------------------\n"
"\n"
"# The cockpit is fitted into the window at ONE uniform scale, centred, with\n"
"# the leftover black -- drag the window to any shape and nothing distorts.\n"
"# Launch with -fit (or -windowed-fullscreen) for a borderless window over the\n"
"# whole monitor.\n"
"\n"
"# Simulation/render frame rate, integer frames per second. The desktop\n"
"# default is 60; the arcade pods shipped at 25.\n"
"#TARGETFPS=60\n"
"\n"
"# Anti-aliasing sample count, passed straight to Direct3D 9: 0 = off, else\n"
"# 2..16 as the GPU supports. NOTE: turning this on disables the letterbox\n"
"# fit above (the two use incompatible swap modes) and the cockpit stretches\n"
"# to the window instead.\n"
"#MULTISAMPLE=0\n"
"\n"
"# Particle budget, integer. Default 8192.\n"
"#MAXPARTICLES=8192\n"
"\n"
"# ---- Optional ---------------------------------------------------------------\n"
"\n"
"# RGB keyboard lamp mirror (Windows Dynamic Lighting): keys bound to a lamp\n"
"# button in bindings.txt glow with the panel -- yellow for the map's side\n"
"# columns, red for the rest -- and flash in step with the on-screen buttons.\n"
"# Per-key keyboards light each bound key; zone-lit boards mirror the\n"
"# strongest lamp board-wide. Unset or nonzero = on; 0 = off. A machine\n"
"# without Dynamic Lighting logs once and stays dormant.\n"
"#BT_KEYLIGHT=0\n"
"\n"
"# ---- Multiplayer ------------------------------------------------------------\n"
"\n"
"# 1 = Steam networking (lobbies, FakeIP mesh). Needs the Steam client\n"
"# running; without it the game logs the reason and falls back to Winsock.\n"
"#BT_STEAM_NET=1\n";
static void BTWriteDefaultEnvironIni(void)
{
FILE *probe;
if (fopen_s(&probe, "environ.ini", "r") == 0) // already there -- leave it alone
{
fclose(probe);
return;
}
FILE *out;
if (fopen_s(&out, "environ.ini", "w") != 0)
return;
fputs(kEnvironIniDefault, out);
fclose(out);
std::cout << "[boot] wrote a default content\\environ.ini (all options "
"commented out -- edit to taste)" << std::endl << std::flush;
}
static void BTLoadEnvironIni(void)
{
BTWriteDefaultEnvironIni();
FILE *file;
if (fopen_s(&file, "environ.ini", "r") != 0)
return;
char line[1024];
int applied = 0, skipped = 0;
while (fgets(line, sizeof(line), file))
{
// strip EOL + trailing blanks
int n = (int)strlen(line);
while (n > 0 && (line[n-1] == '\n' || line[n-1] == '\r' ||
line[n-1] == ' ' || line[n-1] == '\t'))
line[--n] = 0;
const char *p = line;
while (*p == ' ' || *p == '\t') ++p;
if (*p == 0 || *p == '#' || *p == ';') // blank or comment
continue;
const char *eq = strchr(p, '=');
if (eq == NULL || eq == p) // no key, or no '=' at all
{
++skipped;
continue;
}
// The real environment wins: only set what is not already set, so a
// launcher or a shell export beats the file.
char key[256];
size_t klen = (size_t)(eq - p);
if (klen >= sizeof(key)) { ++skipped; continue; }
memcpy(key, p, klen);
key[klen] = 0;
while (klen > 0 && (key[klen-1] == ' ' || key[klen-1] == '\t'))
key[--klen] = 0;
if (getenv(key) != NULL)
continue;
putenv(p);
++applied;
}
fclose(file);
std::cout << "[boot] environ.ini: " << applied << " setting(s) applied"
<< (skipped ? ", " : "") << (skipped ? std::to_string(skipped) : std::string())
<< (skipped ? " line(s) ignored" : "") << std::endl << std::flush;
}
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)
{
// Boot tick for the relaunch storm damper (btl4console.cpp, issue #33):
@@ -279,6 +475,10 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
<< (getenv("BT_FE_LOOP") ? " (relaunched generation)" : " (first process)")
<< std::endl << std::flush;
// The player's settings, before ANYTHING reads the environment (the
// platform profile, the joystick wizard, the layout resolver...).
BTLoadEnvironIni();
// BT_JOYCONFIG=1: the generic-joystick capture wizard (flight sticks /
// HOTAS / pedals -- L4JOY.h). Console prompts detect which device/axis
// the player moves for each pod control and write the joystick section
@@ -490,21 +690,12 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
if (getenv("BT_DEV_GAUGES") == NULL) putenv("BT_DEV_GAUGES=1");
if (getenv("L4PLASMA") == NULL) putenv("L4PLASMA=SCREEN");
// DEFAULT LAYOUT = the COCKPIT SURROUND (single window: the six gauge
// surfaces + clickable button lamps composited AROUND the centered 3D
// view). Opt out with BT_COCKPIT=0 (dock-bottom strip) or
// BT_GLASS_PANELS=1 (the per-display windows). When cockpit is the
// default the buttons live IN the main window, so no separate pad/panel
// window is created (leave BT_PAD_PANEL / BT_GLASS_PANELS unset).
int cockpitDefault = 1;
if (getenv("BT_COCKPIT") && getenv("BT_COCKPIT")[0] == '0') cockpitDefault = 0;
if (getenv("BT_GLASS_PANELS") && getenv("BT_GLASS_PANELS")[0] != '0') cockpitDefault = 0;
if (getenv("BT_DEV_GAUGES_WINDOW") || getenv("BT_DEV_GAUGES_DOCK")) cockpitDefault = 0;
if (!cockpitDefault)
{
if (getenv("BT_PAD_PANEL") == NULL) putenv("BT_PAD_PANEL=1");
if (getenv("BT_GLASS_PANELS") == NULL) putenv("BT_GLASS_PANELS=1");
}
// The SECONDARY-DISPLAY LAYOUT (surround / per-display windows / docked
// strip / separate window) is resolved once, below, after the shared
// defaults land -- it used to be half-decided here, which is how
// BT_COCKPIT=0 came to mean "per-display windows" despite being
// documented as the dock-bottom opt-out (the dock was unreachable under
// glass). This block now only sets the profile's env presets.
}
#endif
else
@@ -531,12 +722,64 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
if (getenv("BT_DEV_GAUGES") != NULL && getenv("L4GAUGE") == NULL)
putenv("L4GAUGE=640x480x16");
// -------------------------------------------------------------------------
// SECONDARY-DISPLAY LAYOUT -- resolved ONCE, here, now that every profile
// putenv has landed. It used to be decided TWICE (the glass profile block
// picked BT_PAD_PANEL/BT_GLASS_PANELS; the window-sizing block further down
// re-derived cockpit-vs-dock from the same env with its own copy of the
// precedence) and the boot banner read NEITHER -- it announced "per-display
// cockpit windows" for every glass boot, including the surround default.
// The split also broke BT_COCKPIT=0: documented as the dock-bottom opt-out,
// it actually landed on the per-display windows, leaving the docked strip
// unreachable under the glass profile.
//
// One resolver, one answer, consumed by the banner, the pad-panel decision
// and the sizing block. Precedence:
// BT_GLASS_PANELS!=0 per-display cockpit windows (explicit only)
// BT_DEV_GAUGES_WINDOW the legacy separate MFD window
// BT_DEV_GAUGES_DOCK the docked bottom strip
// BT_COCKPIT=0 ...also the docked strip (the documented opt-out)
// (nothing) COCKPIT SURROUND -- the glass default
// -------------------------------------------------------------------------
enum BTGlassLayout { GlassLayoutNone, GlassLayoutCockpit, GlassLayoutPanels,
GlassLayoutDock, GlassLayoutWindow };
int glassLayout = GlassLayoutNone;
if (!fe_menu_mode && getenv("BT_DEV_GAUGES") != NULL)
{
int panels = 0;
#ifdef BT_GLASS
{ extern int BTGlassPanelsActive(); panels = BTGlassPanelsActive(); }
#endif
const char *ck = getenv("BT_COCKPIT");
if (panels) glassLayout = GlassLayoutPanels;
else if (getenv("BT_DEV_GAUGES_WINDOW")) glassLayout = GlassLayoutWindow;
else if (getenv("BT_DEV_GAUGES_DOCK")) glassLayout = GlassLayoutDock;
else if (ck != NULL && ck[0] == '0') glassLayout = GlassLayoutDock;
else glassLayout = GlassLayoutCockpit;
// The surround and the per-display windows carry their own buttons; the
// dock strip and the separate MFD window don't, so the single combined
// pad panel supplies them (else the whole 72-button field is unclickable).
if ((glassLayout == GlassLayoutDock || glassLayout == GlassLayoutWindow)
&& getenv("BT_PAD_PANEL") == NULL)
putenv("BT_PAD_PANEL=1");
}
static const char *kGlassLayoutName[] = {
"no dev gauges",
"cockpit surround",
"per-display cockpit windows [BT_GLASS_PANELS]",
"docked gauge strip",
"separate MFD window"
};
std::cout << "[boot] platform profile: "
<< (fe_menu_mode ? "MENU (front end -- no profile applied)"
: gBTPlatformPod ? "POD (RIO cockpit input; multi-surface gauges/MFDs via pod hardware or explicit L4GAUGE)"
: gBTPlatformGlass ? "GLASS (PadRIO + per-display cockpit windows [BT_GLASS_PANELS] + plasma window)"
: "DEV (single window + keyboard)")
<< std::endl << std::flush;
: gBTPlatformGlass ? "GLASS (PadRIO + plasma window)"
: "DEV (single window + keyboard)");
if (!fe_menu_mode)
std::cout << " [secondary displays: " << kGlassLayoutName[glassLayout] << "]";
std::cout << std::endl << std::flush;
#ifdef BT_GLASS
//
@@ -656,22 +899,7 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
}
if (getenv("BT_FORCE_FIRE") != NULL) gBTDrive.fireForced = 1;
// Optional environ.ini overrides (same convention as RP).
FILE *file;
char line[1024];
if (fopen_s(&file, "environ.ini", "r") == 0)
{
while (!feof(file))
{
if (fgets(line, sizeof(line), file))
{
for (int i = (int)strlen(line); i >= 0; i--)
if (line[i] == '\n' || line[i] == '\r') line[i] = 0;
putenv(line);
}
}
fclose(file);
}
// (environ.ini is loaded much earlier now -- see BTLoadEnvironIni.)
// Version scheme: 4.10 = the 1995 arcade release; 4.11 = this win32
// reconstruction; build = git commit count, hash pins exact source
@@ -783,25 +1011,11 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
extern int gBTCockpitCanvasW, gBTCockpitCanvasH;
extern int BTGaugeStripHeightFor(int width);
extern void BTCockpitCanvasFor(int viewW, int viewH, int *cw, int *ch);
// BT_GLASS_PANELS breaks the gauges into their own per-display windows, so
// the world window keeps its normal size (no cockpit / bottom strip).
int glassOwnsGauges = 0;
#ifdef BT_GLASS
{ extern int BTGlassPanelsActive(); glassOwnsGauges = BTGlassPanelsActive(); }
#endif
if (!glassOwnsGauges && getenv("BT_DEV_GAUGES") != 0)
// The mode came from the ONE resolver above (glassLayout) -- BT_GLASS_PANELS
// keeps the world window at its normal size (the per-display windows own the
// gauges: no cockpit surround, no bottom strip).
{
// Mode resolution (precedence): explicit BT_COCKPIT > separate-window /
// legacy-inset opt-out > COCKPIT SURROUND default under BT_DEV_GAUGES.
int cockpitOn;
const char *ck = getenv("BT_COCKPIT");
if (ck != NULL)
cockpitOn = (ck[0] != '0');
else
cockpitOn = (getenv("BT_DEV_GAUGES_WINDOW") == 0 &&
getenv("BT_DEV_GAUGES_DOCK") == 0);
if (cockpitOn)
if (glassLayout == GlassLayoutCockpit)
{
gBTGaugeCockpit = 1;
// -res W H = the WORLD VIEW size in cockpit mode; default 900x500.
@@ -822,10 +1036,24 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
winW = cw; winH = ch;
gBTCockpitCanvasW = winW; // windowed backbuffer = client canvas
gBTCockpitCanvasH = winH;
//
// LETTERBOX INTENT, decided HERE rather than at device creation:
// the first WM_SIZE arrives before the device exists, and the
// world-aspect calc needs to know whether the canvas will be
// scaled uniformly (aspect = the view's own) or stretched to
// the client (aspect = the stretched one). Deciding it late
// gave a -fit boot aspect=3.14 on an ultrawide -- applied on
// the first frame, since nothing resizes the window again.
// L4VIDEO clears the flag if it cannot actually get COPY.
{
extern int gBTCockpitLetterbox;
const char *ms = getenv("MULTISAMPLE");
gBTCockpitLetterbox = (ms == NULL || atoi(ms) == 0) ? 1 : 0;
}
std::cout << "[cockpit] view " << viewW << "x" << viewH
<< " canvas " << winW << "x" << winH << std::endl << std::flush;
}
else if (getenv("BT_DEV_GAUGES_WINDOW") == 0)
else if (glassLayout == GlassLayoutDock)
{
gBTGaugeDockBottom = 1;
// Readability default (user-reported: the strip at 800 wide is a
@@ -843,9 +1071,44 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
}
}
}
//
// -fit (alias -windowed-fullscreen): borderless over the whole monitor.
// The cockpit canvas letterboxes inside it at one uniform scale, so a
// wider-than-canvas monitor gets black bars rather than a stretch -- the
// same deal a dragged window gets, just without the chrome. Only useful
// with a layout that HAS a canvas to fit; other modes keep their window.
//
int fitDisplay = 0;
if (lpCmdLine && (strstr(lpCmdLine, "-fit") != 0 ||
strstr(lpCmdLine, "-windowed-fullscreen") != 0))
fitDisplay = 1;
RECT wr = { 0, 0, winW, winH };
AdjustWindowRect(&wr, WS_OVERLAPPEDWINDOW, FALSE);
DWORD winStyle = WS_OVERLAPPEDWINDOW;
int winX = 0, winY = 0;
if (fitDisplay)
{
RECT mon = { 0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN) };
MONITORINFO mi;
memset(&mi, 0, sizeof(mi));
mi.cbSize = sizeof(mi);
POINT origin = { 0, 0 };
HMONITOR hmon = MonitorFromPoint(origin, MONITOR_DEFAULTTOPRIMARY);
if (GetMonitorInfo(hmon, &mi))
mon = mi.rcMonitor;
winStyle = WS_POPUP | WS_CLIPCHILDREN;
winX = mon.left;
winY = mon.top;
wr.left = 0; wr.top = 0;
wr.right = mon.right - mon.left;
wr.bottom = mon.bottom - mon.top;
std::cout << "[cockpit] -fit: borderless " << (wr.right - wr.left) << "x"
<< (wr.bottom - wr.top) << " (canvas letterboxes inside)"
<< std::endl << std::flush;
}
//
// Window identity (MP dev): tag the title with the -net port so a player
// running two nodes side-by-side can tell the windows apart when
@@ -863,8 +1126,8 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
else
swprintf(winTitle, 64, L"BattleTech %S", BT_VERSION_STRING);
}
hWnd = CreateWindowEx(0, L"MainWndClass", winTitle, WS_OVERLAPPEDWINDOW,
0, 0, wr.right - wr.left, wr.bottom - wr.top,
hWnd = CreateWindowEx(0, L"MainWndClass", winTitle, winStyle,
winX, winY, wr.right - wr.left, wr.bottom - wr.top,
(HWND)NULL, (HMENU)NULL, hInstance, (LPVOID)NULL);
if (!hWnd) return FALSE;
+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()