diff --git a/.gitignore b/.gitignore index bca98ad..b5d5f05 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ dist/ # glass-cockpit dev layer: per-developer / per-machine runtime files content/bindings.txt +content/environ.ini content/frontend.egg content/steam_appid.txt content/fe_last.ini diff --git a/CMakeLists.txt b/CMakeLists.txt index ab98ab5..b74beea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -265,6 +265,7 @@ target_sources(munga_engine PRIVATE "engine/MUNGA_L4/L4PADBINDINGS.cpp" "engine/MUNGA_L4/L4JOY.cpp" "engine/MUNGA_L4/L4PADPANEL.cpp" + "engine/MUNGA_L4/L4RIOBANK.cpp" "engine/MUNGA_L4/L4GLASSWIN.cpp" "engine/MUNGA_L4/L4PLASMAWIN.cpp" ) diff --git a/context/gauges-hud.md b/context/gauges-hud.md index 75c4751..95518f2 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -92,17 +92,22 @@ viewport), and `gWindowAspect` = the view rect's on-screen aspect (`BTWorldAspec sizing (`btl4main.cpp`), the world viewport, the panel/button draw, the aspect, and the mouse hit-test. Constants: `SCALE=0.5, OVL=44 (corner overlap), LAMP=16 (protruding lamp edge), REDCELL=64 (hidden hit depth), RAILW=26`; `canvas = view + (552, 548)`. -- **Buttons** = the L4GLASSWIN geometry ×0.5 with the same address banks (Heat 0x2F, Mfd2 0x27, - Comm 0x37, Mfd1 0x0F, Mfd3 0x07 red 8-btn; radar rails 0x10-0x15/0x18-0x1D + bottom +- **Buttons** = `L4RIOBANK` (2026-07-26: the ONE geometry, shared with the exploded windows — see + [[glass-cockpit]] §ONE button-bank geometry) at half scale, same address banks (Heat 0x2F, + Mfd2 0x27, Comm 0x37, Mfd1 0x0F, Mfd3 0x07 red 8-btn; radar columns 0x10-0x15/0x18-0x1D + foot {0x16,0x17,0x1F,0x1E} yellow; flight 0x38-0x3F/0x40-0x47 blue, labeled). Full rect = hit target; - the surface draws OVER it so only the lamp edge shows (the PaintGlass painter trick). Mouse: + the surface draws OVER it so only the lamp strip shows (the PaintGlass painter trick). ⚠ Before + that date the surround's MFD lamps sat ENTIRELY outside the glass on a 24px band — a 76×24 + target where the exploded window gave 156×138; both now reach half the glass. Mouse: main WndProc `WM_L/RBUTTON` → `BTCockpitMouseDown/Up` (client→bb map, glass press/release/right- latch contract) → `PadRIO::SetScreenButton` (`#ifdef BT_GLASS`; dim/no-op in pod builds). Lamp brightness = `BTLampBrightnessOf` (shared inline in l4vb16.h) over `PadRIO::GetLampState`. -- **Env / precedence** (resolved once in btl4main → `gBTGaugeCockpit`): `BT_GLASS_PANELS=1` - (Cyd's per-display windows) stands cockpit down > explicit `BT_COCKPIT=1` > `BT_DEV_GAUGES_WINDOW` - (separate window) / `BT_DEV_GAUGES_DOCK` (legacy inset) opt-out > **cockpit default**. - `BT_COCKPIT=0` forces the dock-bottom strip. `-res W H` = the WORLD VIEW size (canvas clamped to +- **Env / precedence** (resolved ONCE in btl4main → `glassLayout` → `gBTGaugeCockpit`; the full + table lives in [[glass-cockpit]] §Layout modes): `BT_GLASS_PANELS`≠0 (the per-display windows) + stands cockpit down > `BT_DEV_GAUGES_WINDOW` (separate window) > `BT_DEV_GAUGES_DOCK` (legacy + inset) > `BT_COCKPIT=0` (also the dock strip) > **cockpit surround default**. ⚠ Before + 2026-07-26 `BT_COCKPIT=0` actually landed on the per-display windows — the dock strip was + unreachable under glass. `-res W H` = the WORLD VIEW size (canvas clamped to the work area). Green tint tunable via `BT_COCKPIT_TINT=RRGGBB` (default `0x27E8`). Labels are a lazy GDI-baked MANAGED atlas (survives device reset). Renders in ALL builds; only the PadRIO click/lamp seam is BT_GLASS-gated. Full detail: `docs/GAUGE_COMPOSITE.md`. diff --git a/docs/GAUGE_COMPOSITE.md b/docs/GAUGE_COMPOSITE.md index 91f7bf4..db95a0b 100644 --- a/docs/GAUGE_COMPOSITE.md +++ b/docs/GAUGE_COMPOSITE.md @@ -843,9 +843,13 @@ below (view bottom clean). Buttons: the full rect is the click target, the surfa only the LAMP edge shows (PaintGlass painter trick). Address banks = L4GLASSWIN BuildMfd/BuildRadar/BuildFlight. -**Gating precedence** (resolved once → `gBTGaugeCockpit`): `BTGlassPanelsActive` (BT_GLASS_PANELS) -> `BT_COCKPIT=1` > `BT_DEV_GAUGES_WINDOW` / `BT_DEV_GAUGES_DOCK` > **cockpit default**. `BT_COCKPIT=0` -= dock-bottom. `DevGaugeDocked()` includes cockpit so the separate window stands down. Green tint +**Gating precedence** (2026-07-26: resolved ONCE in btl4main → `glassLayout` → `gBTGaugeCockpit`): +`BTGlassPanelsActive` (BT_GLASS_PANELS) > `BT_DEV_GAUGES_WINDOW` > `BT_DEV_GAUGES_DOCK` > +`BT_COCKPIT=0` > **cockpit surround default**. `BT_COCKPIT=0` = dock-bottom — ⚠ which it did NOT +do before that date: the glass profile block converted it to `BT_GLASS_PANELS=1`, so the dock +strip was unreachable under glass. Dock/window modes now auto-raise `BT_PAD_PANEL` (those layouts +carry no buttons of their own). +`DevGaugeDocked()` includes cockpit so the separate window stands down. Green tint `BT_COCKPIT_TINT=RRGGBB` (default 0x27E8 ≈ rgb(33,255,66)); radar stays palette amber. **Pod-build seam.** Panels + dim lamps render in ALL builds; only `PadRIO::SetScreenButton/ diff --git a/engine/MUNGA_L4/L4GLASSWIN.cpp b/engine/MUNGA_L4/L4GLASSWIN.cpp index 9a6387c..db2bfca 100644 --- a/engine/MUNGA_L4/L4GLASSWIN.cpp +++ b/engine/MUNGA_L4/L4GLASSWIN.cpp @@ -13,6 +13,7 @@ #include "l4glasswin.h" #include "l4padrio.h" #include "l4vb16.h" +#include "l4riobank.h" // the shared button-bank geometry (with the surround) #include "../munga/gaugrend.h" #include @@ -41,23 +42,17 @@ int } //########################################################################### -// Layout constants (tunable -- Cyd flagged refinements to the exact button -// sub-positions + the yellow left/right split). +// Window constants. The BUTTON GEOMETRY (how far a bank reaches under the +// glass, the lamp strip, the column pitch, the per-column nudge against the +// imagery legends) moved to L4RIOBANK on 2026-07-26 so the cockpit surround +// draws the identical field; what is left here is this window's own chrome. //########################################################################### enum { - Margin = 0, // no padding around the edges (surface fills the window) - TitleH = 0, // no in-client title band (the OS caption labels the window) - CellW = 58, // default button cell (flight-controls window) + CellW = 58, // flight-controls cell (no glass behind it) CellH = 28, - RedCellH = 128, // red MFD buttons -- tall, extend ~100px into the display - EdgeProtrude = 10, // ...and this far OUT past the display edge Gap = 4, - RailW = 40, // radar side-rail button thickness (rotated 90deg, fill height) - RailSlotAdd = 1, // rail pitch = 640/6 + 1 = 107 -> 104px buttons + 3px gaps = 639 (fits 640) - RailGap = 3, // vertical gap between rail buttons (own value; red/horizontals use Gap) - RedOffsetX = 5, // nudge the red MFD buttons right to line up with the imagery MfdSurfW = 640, MfdSurfH = 480, // mono MFD surface at native 640x480 RadarSurfW = 480, RadarSurfH = 640, // portrait secondary/radar CRT at native (rotated 640x480) RepaintTimerId = 1, @@ -67,11 +62,6 @@ enum enum ColorClass { ClrRed, ClrYellow, ClrBlue }; -// Per-column horizontal nudge (px) for the 4 red MFD buttons, left->right, to line -// them up with the (unevenly-spaced) imagery legends. Tunable -- Cyd is dialing -// these in against DISPLAY / PROGRAM / etc. Applied on top of RedOffsetX. -static const int kRedColDX[4] = { -5, -4, 0, 0 }; - struct GButton { int address; @@ -137,71 +127,53 @@ static const char * } //########################################################################### -// Lamp decode (identical to L4PADPANEL::LampBrightnessOf -- RIOBase::LampState) +// Lamp decode: the ONE copy lives in l4vb16.h (BTLampBrightnessOf). This TU +// used to carry its own, as did L4PADPANEL -- three copies of the same +// formula, all three carrying the same dim-to-bright flash bug (see the +// header's 2026-07-26 note). //########################################################################### static int LampBrightnessOf(int state, unsigned long tick) { - int level1 = (state >> 2) & 0x3; - int level2 = (state >> 4) & 0x3; - int level = (level1 > level2) ? level1 : level2; - if (level == 0) - return 0; - int flash = state & 0x3; - if (flash != 0) - { - unsigned long half_period = (flash == 1) ? 500 : (flash == 2) ? 250 : 125; - if ((tick / half_period) & 1) - return 0; - } - return level; + return BTLampBrightnessOf(state, tick); } //########################################################################### // Layout builders //########################################################################### -static void - PlaceCellAt(GWin &w, int address, int x, int y, ColorClass color) -{ - GButton &b = w.buttons[w.buttonCount++]; - b.address = address; - b.color = color; - b.rect.left = x + 2; - b.rect.top = y + 2; - b.rect.right = x + CellW - 2; - b.rect.bottom = y + CellH - 2; -} +// (The local PlaceCellAt / PlaceLine / PlaceRect placers retired 2026-07-26 -- +// the geometry they carried now comes from L4RIOBANK, shared with the cockpit +// surround. AdoptBank below is the only thing that writes GButton rects.) +//--------------------------------------------------------------------------- +// Copy a laid-out bank into this window at the given offset. A glass window +// passes -bounds so the bank's bounding box lands at the client origin (the +// bank reaches OUTSIDE the display, so its rects arrive with coordinates +// left of / above it); the flight window, which has no glass, passes 0. +//--------------------------------------------------------------------------- static void - PlaceLine(GWin &w, int startAddr, int count, int step, - int x, int y, int horizontal, ColorClass color) + AdoptBank(GWin &w, const BTRioBank &bank, ColorClass color, int dx, int dy) { - for (int i = 0; i < count; ++i) + for (int i = 0; i < bank.buttonCount && + w.buttonCount < (int)(sizeof(w.buttons) / sizeof(w.buttons[0])); ++i) { - int cx = horizontal ? (x + i * CellW) : x; - int cy = horizontal ? y : (y + i * CellH); - PlaceCellAt(w, startAddr + i * step, cx, cy, color); + const BTRioButton &src = bank.buttons[i]; + GButton &b = w.buttons[w.buttonCount++]; + b.address = src.address; + b.color = (src.colorClass == 1) ? ClrYellow + : (src.colorClass == 2) ? ClrBlue : color; + b.rect.left = src.x + dx; + b.rect.top = src.y + dy; + b.rect.right = src.x + dx + src.w; + b.rect.bottom = src.y + dy + src.h; } } -// Place a button with an explicit size (custom-shaped cells: wide red buttons, -// tall rotated radar rails). -static void - PlaceRect(GWin &w, int address, int x, int y, int cw, int ch, ColorClass color) -{ - GButton &b = w.buttons[w.buttonCount++]; - b.address = address; - b.color = color; - b.rect.left = x; - b.rect.top = y; - b.rect.right = x + cw; - b.rect.bottom = y + ch; -} - // An MFD display: 8 red buttons split 4 above / 4 below the surface (top row = // the high 4 addresses descending, matching the L4PADPANEL cluster order). +// Geometry is L4RIOBANK's -- the under-glass rule, shared with the surround. static void BuildMfd(GWin &w, const char *title, const char *portP, const char *portA, int bankHi) { @@ -212,23 +184,19 @@ static void w.monoTint = MfdMonoTint; w.rotate = 0; - int surfX = Margin; - int surfY = Margin + TitleH + EdgeProtrude; // room for the top buttons to poke out + BTRioBankMetrics metrics; + BTRioBankMetricsFor(MfdSurfW, MfdSurfH, &metrics); - // Buttons OVERLAY the display: 4 tile the full width, reaching RedCellH INTO the - // display and EdgeProtrude OUT past the edge (top row + bottom row; hi addresses - // descending -- L4PADPANEL order). - int slotW = MfdSurfW / 4; - for (int i = 0; i < 4; ++i) - { - int x = surfX + i * slotW + RedOffsetX + kRedColDX[i]; - PlaceRect(w, bankHi - i, x, surfY - EdgeProtrude, slotW - Gap, RedCellH + EdgeProtrude, ClrRed); // top - PlaceRect(w, bankHi - 4 - i, x, surfY + MfdSurfH - RedCellH, slotW - Gap, RedCellH + EdgeProtrude, ClrRed); // bottom - } - SetRect(&w.surfaceRect, surfX, surfY, surfX + MfdSurfW, surfY + MfdSurfH); + BTRioBank bank; + BTRioBankLayout(BTRioBankMfd, 0, 0, MfdSurfW, MfdSurfH, + bankHi, 0, &metrics, 0, &bank); - w.clientW = Margin + MfdSurfW + Margin; - w.clientH = surfY + MfdSurfH + EdgeProtrude + Margin; + BTRioBankDump(title, &bank); + int dx = -bank.boundsX, dy = -bank.boundsY; + AdoptBank(w, bank, ClrRed, dx, dy); + SetRect(&w.surfaceRect, dx, dy, dx + MfdSurfW, dy + MfdSurfH); + w.clientW = bank.boundsW; + w.clientH = bank.boundsH; } // The secondary/radar display: 12 yellow Secondary buttons split left / right @@ -243,37 +211,23 @@ static void w.monoTint = -1; // palette w.rotate = gRadarRot; - int surfX = Margin + EdgeProtrude; // room for the left rail to poke out - int surfY = Margin + TitleH; - - // Rails: big clickable strips that reach RailW INTO the display (hidden under the - // imagery) and poke EdgeProtrude OUT past the side edge (the lamp light). - // Left 0x10..0x15, right 0x18 (top)..0x1D (bottom). (0x15 kept on the left rail - // -- Cyd listed 0x10-0x14; move it if wanted.) - int railCount = 6; - int slotH = RadarSurfH / railCount + RailSlotAdd; - int leftX = surfX - EdgeProtrude; - int rightX = surfX + RadarSurfW - RailW; - for (int i = 0; i < railCount; ++i) - { - int y = surfY + i * slotH; - PlaceRect(w, 0x10 + i, leftX, y, EdgeProtrude + RailW, slotH - RailGap, ClrYellow); - PlaceRect(w, 0x18 + i, rightX, y, RailW + EdgeProtrude, slotH - RailGap, ClrYellow); - } - SetRect(&w.surfaceRect, surfX, surfY, surfX + RadarSurfW, surfY + RadarSurfH); - - // Bottom row: 16, 17, 1F, 1E (left to right), BETWEEN the rails -- reaches CellH - // up into the display, pokes EdgeProtrude out the bottom. + // Left column 0x10-0x15, right 0x18-0x1D, foot row 0x16/0x17/0x1F/0x1E. + // (0x15 sits on the left column -- Cyd listed 0x10-0x14; move it if wanted.) static const int bottomAddrs[4] = { 0x16, 0x17, 0x1F, 0x1E }; - int botLeft = surfX + RailW; - int botSpan = RadarSurfW - 2 * RailW; - int botW = botSpan / 4; - int botY = surfY + RadarSurfH - CellH; - for (int i = 0; i < 4; ++i) - PlaceRect(w, bottomAddrs[i], botLeft + i * botW, botY, botW - Gap, CellH + EdgeProtrude, ClrYellow); - w.clientW = surfX + RadarSurfW + EdgeProtrude + Margin; - w.clientH = surfY + RadarSurfH + EdgeProtrude + Margin; + BTRioBankMetrics metrics; + BTRioBankMetricsFor(RadarSurfW, RadarSurfH, &metrics); + + BTRioBank bank; + BTRioBankLayout(BTRioBankRadar, 0, 0, RadarSurfW, RadarSurfH, + 0x10, 0x18, &metrics, bottomAddrs, &bank); + + BTRioBankDump("Secondary / Radar", &bank); + int dx = -bank.boundsX, dy = -bank.boundsY; + AdoptBank(w, bank, ClrYellow, dx, dy); + SetRect(&w.surfaceRect, dx, dy, dx + RadarSurfW, dy + RadarSurfH); + w.clientW = bank.boundsW; + w.clientH = bank.boundsH; } // The Flight Controls window: the no-display banks -- throttle/panic/door/icom @@ -286,15 +240,27 @@ static void w.portPrimary = NULL; w.showLabels = 1; // the only window that shows hex/name on its buttons - int topY = Margin + TitleH; - int col1 = Margin; - int col2 = Margin + CellW + Gap; + // No glass to hide behind, so these cells carry their own size (see + // l4riobank.h) -- two 1-wide columns, eight rows each. Cell minus the + // 4px gap keeps the historical CellW/CellH PITCH and the 2px inset the + // old PlaceCellAt drew, so the window is pixel-identical. + BTRioBankMetrics metrics; + metrics.strip = 0; + metrics.gap = Gap; + metrics.cellW = CellW - Gap; + metrics.cellH = CellH - Gap; - PlaceLine(w, 0x38, 8, +1, col1, topY, 0, ClrBlue); // throttle / panic / door / icom - PlaceLine(w, 0x40, 8, +1, col2, topY, 0, ClrBlue); // joystick + fire buttons + BTRioBank bank; + memset(&bank, 0, sizeof(bank)); + BTRioBankFlightGrid(2, 2, 0x38, 8, 1, 0, 0, &metrics, &bank); // throttle/panic/door/icom + BTRioBankFlightGrid(CellW + Gap + 2, 2, 0x40, 8, 1, 0, 0, &metrics, &bank); // joystick + fire - w.clientW = col2 + CellW + Margin; - w.clientH = topY + 8 * CellH + Margin; + BTRioBankDump("Flight Controls", &bank); + AdoptBank(w, bank, ClrBlue, 0, 0); + // no translation here (the cells are already at their 2px inset); the + // client is the historical two-column extent + w.clientW = CellW + Gap + CellW; + w.clientH = 8 * CellH; } //########################################################################### diff --git a/engine/MUNGA_L4/L4PADPANEL.cpp b/engine/MUNGA_L4/L4PADPANEL.cpp index 36d49f2..1c4b7ac 100644 --- a/engine/MUNGA_L4/L4PADPANEL.cpp +++ b/engine/MUNGA_L4/L4PADPANEL.cpp @@ -21,6 +21,7 @@ #include "l4padpanel.h" #include "l4padrio.h" +#include "l4vb16.h" // BTLampBrightnessOf -- the one lamp decode #include #include @@ -191,33 +192,18 @@ static int } //########################################################################### -// Lamp decode (RIOBase::LampState): flash = low 2 bits (solid/slow/med/ -// fast), filament brightness = max of the state1 (>>2) and state2 (>>4) -// 2-bit fields (0 off / 1 dim / 3 bright). Flash blinks between the -// commanded brightness and off, half-periods 500/250/125 ms (vRIO). +// Lamp decode (RIOBase::LampState): the ONE copy lives in l4vb16.h +// (BTLampBrightnessOf) -- flash mode in the low 2 bits picks a 500/250/125 ms +// half-period and the two 2-bit brightness fields (state1 >>2, state2 >>4; +// 0 off / 1 dim / 3 bright) ALTERNATE across it. This TU used to carry its +// own max-then-blank copy, as did L4GLASSWIN; see the header's 2026-07-26 +// note for the dim-to-bright pulse all three got wrong. //########################################################################### static int LampBrightnessOf(int state, unsigned long tick) { - int level1 = (state >> 2) & 0x3; - int level2 = (state >> 4) & 0x3; - int level = (level1 > level2) ? level1 : level2; - if (level == 0) - { - return 0; - } - int flash = state & 0x3; - if (flash != 0) - { - unsigned long half_period = - (flash == 1) ? 500 : (flash == 2) ? 250 : 125; - if ((tick / half_period) & 1) - { - return 0; - } - } - return level; + return BTLampBrightnessOf(state, tick); } //########################################################################### diff --git a/engine/MUNGA_L4/L4RIOBANK.cpp b/engine/MUNGA_L4/L4RIOBANK.cpp new file mode 100644 index 0000000..c196f22 --- /dev/null +++ b/engine/MUNGA_L4/L4RIOBANK.cpp @@ -0,0 +1,296 @@ +#include "mungal4.h" +#pragma hdrstop + +//########################################################################### +// L4RIOBANK -- the ONE button-bank geometry for a pod display. Design + +// the under-glass rule: l4riobank.h. Consumed by the cockpit surround +// (L4VB16.cpp, canvas space) and the exploded per-display windows +// (L4GLASSWIN.cpp, client space); neither owns a copy any more. +//########################################################################### + +#include "l4riobank.h" + +#include +#include + +namespace +{ + //------------------------------------------------------------------- + // The lamp strip at NATIVE glass size, scaled down with the display + // but floored so a half-scale surround lamp is still a bar and not a + // smudge. RP412 L4MFDVIEW works at native only and hardcodes 10. + //------------------------------------------------------------------- + const int nativeShortAxis = 480; // MFD 640x480 / map 480x640 + const int nativeStrip = 10; + const int minimumStrip = 6; + const int defaultGap = 4; + + //------------------------------------------------------------------- + // Per-column horizontal nudge for the 4 MFD buttons, left->right, so + // they line up with the (unevenly spaced) DISPLAY / PROGRAM / ... + // legends painted in the imagery. Measured against the native 640 + // glass in L4GLASSWIN (RedOffsetX + kRedColDX) and scaled here, so + // both renderers inherit the same alignment. Tunable. + //------------------------------------------------------------------- + const int nativeMfdW = 640; + const int mfdColumnDX[4] = { 0, 1, 5, 5 }; + + //------------------------------------------------------------------- + // The map paints its own legend beside each side button (the six + // cells: MAP+, MAP-, IR, crouch, searchlight, display-mode), and the + // buttons have to line up with THAT grid, not with height/6. + // + // MEASURED off a native 480x640 capture (scratchpad/measurelegend.py + // over an exploded Secondary/Radar window): the first cell border sits + // 3 rows down and the six cells are 102 tall on a 107 pitch. RP412's + // map measures 13 + 6x102 on a 105 pitch -- same cell height, different + // top and pitch, so its numbers do NOT transfer; these are ours. + // + // Our previous even division (displayH/6 + 1 = 107) had the pitch right + // by luck and sat 3px high of the labels. + //------------------------------------------------------------------- + const int mapLegendSpan = 640; + const int mapLegendTop = 3; + const int mapLegendCell = 102; + const int mapLegendPitch = 107; + + int Scaled(int nativeValue, int actual, int nativeExtent) + { + if (nativeExtent <= 0) return nativeValue; + return (nativeValue * actual) / nativeExtent; + } + + void Push( + BTRioBank *bank, + int address, int x, int y, int w, int h, + int colorClass, int inert, const char *label + ) + { + if (bank->buttonCount >= BTRioBankMaxButtons) return; + if (w <= 0 || h <= 0) return; + BTRioButton *button = &bank->buttons[bank->buttonCount++]; + button->address = address; + button->x = x; + button->y = y; + button->w = w; + button->h = h; + button->colorClass = colorClass; + button->inert = inert; + button->label = label; + } + + void GrowBounds(BTRioBank *bank, int x, int y, int w, int h) + { + if (bank->boundsW == 0 && bank->boundsH == 0) + { + bank->boundsX = x; bank->boundsY = y; + bank->boundsW = w; bank->boundsH = h; + return; + } + int left = (x < bank->boundsX) ? x : bank->boundsX; + int top = (y < bank->boundsY) ? y : bank->boundsY; + int right = bank->boundsX + bank->boundsW; + int bottom = bank->boundsY + bank->boundsH; + if (x + w > right) right = x + w; + if (y + h > bottom) bottom = y + h; + bank->boundsX = left; + bank->boundsY = top; + bank->boundsW = right - left; + bank->boundsH = bottom - top; + } +} + +//########################################################################### + +void + BTRioBankMetricsFor(int displayW, int displayH, BTRioBankMetrics *out) +{ + if (out == 0) return; + + int shortAxis = (displayW < displayH) ? displayW : displayH; + + int strip = Scaled(nativeStrip, shortAxis, nativeShortAxis); + if (strip < minimumStrip) strip = minimumStrip; + + out->strip = strip; + out->gap = defaultGap; + + // The cells with no glass behind them (the flight bank, the map's foot + // row) are drawn in full, so they get a plain readable size scaled off + // the same axis rather than a derived depth. + out->cellW = Scaled(58, shortAxis, nativeShortAxis); + out->cellH = Scaled(28, shortAxis, nativeShortAxis); + if (out->cellW < 12) out->cellW = 12; + if (out->cellH < 12) out->cellH = 12; +} + +//########################################################################### + +void + BTRioBankLayout( + BTRioBankStyle style, + int originX, + int originY, + int displayW, + int displayH, + int anchorA, + int anchorB, + const BTRioBankMetrics *metrics, + const int *bottomAddrs, + BTRioBank *out + ) +{ + if (out == 0 || metrics == 0) return; + memset(out, 0, sizeof(*out)); + + const int strip = metrics->strip; + const int gap = metrics->gap; + + // the glass itself is always inside the bounds + GrowBounds(out, originX, originY, displayW, displayH); + + if (style == BTRioBankMfd) + { + //--------------------------------------------------------------- + // 4 above the glass, 4 below. Addresses descend from the anchor + // row-major (vRIO CockpitLayout::Mfd): top = anchorA-i, bottom = + // anchorA-4-i. Each button reaches HALF THE GLASS in and clears + // the edge by `strip`, so the two banks meet in the middle and + // every pixel of the display is some button's press target. + //--------------------------------------------------------------- + int depth = displayH / 2; + if (depth < strip) depth = strip; + + int slotW = displayW / 4; + for (int i = 0; i < 4; ++i) + { + int dx = Scaled(mfdColumnDX[i], displayW, nativeMfdW); + int x = originX + i * slotW + dx; + int w = slotW - gap; + + int topY = originY - strip; + int botY = originY + displayH - depth; + Push(out, anchorA - i, x, topY, w, depth + strip, 0, 0, 0); + Push(out, anchorA - 4 - i, x, botY, w, depth + strip, 0, 0, 0); + GrowBounds(out, x, topY, w, depth + strip); + GrowBounds(out, x, botY, w, depth + strip); + } + } + else if (style == BTRioBankRadar) + { + //--------------------------------------------------------------- + // The portrait map: 6 buttons down each side (anchorA left, + // anchorB right) plus an optional 4 along the foot. Same rule + // turned on its side -- each column reaches half the map in and + // leaves `strip` clearing the edge. + // + // The FOOT ROW IS PUSHED FIRST, deliberately: the columns now + // reach half the width each, so they cover the foot band too, + // and the hit test takes the first match. Foot-first keeps + // those four addresses reachable. + //--------------------------------------------------------------- + int depth = displayW / 2; + if (depth < strip) depth = strip; + + if (bottomAddrs != 0) + { + int bandH = metrics->cellH; + int bandY = originY + displayH - bandH; + int bandW = displayW / 4; + for (int i = 0; i < 4; ++i) + { + int x = originX + i * bandW; + Push(out, bottomAddrs[i], x, bandY, bandW - gap, + bandH + strip, 1, 0, 0); + GrowBounds(out, x, bandY, bandW - gap, bandH + strip); + } + } + + //--------------------------------------------------------------- + // Six cells down the height, on the map's MEASURED legend grid + // (see mapLegend* above). Top and bottom are scaled SEPARATELY + // and subtracted rather than scaling a height directly: rounding + // a height would let the buttons drift out of step with the + // labels a display or two down the column. + //--------------------------------------------------------------- + int leftX = originX - strip; + int rightX = originX + displayW - depth; + for (int i = 0; i < 6; ++i) + { + int cellTop = mapLegendTop + i * mapLegendPitch; + int top = (cellTop * displayH) / mapLegendSpan; + int bottom = ((cellTop + mapLegendCell) * displayH) / mapLegendSpan; + int h = bottom - top; + if (h < 1) h = 1; + int y = originY + top; + Push(out, anchorA + i, leftX, y, depth + strip, h, 1, 0, 0); + Push(out, anchorB + i, rightX, y, depth + strip, h, 1, 0, 0); + GrowBounds(out, leftX, y, depth + strip, h); + GrowBounds(out, rightX, y, depth + strip, h); + } + } +} + +//########################################################################### + +void + BTRioBankFlightGrid( + int originX, + int originY, + int baseAddress, + int count, + int columns, + const char *const *labels, + const int *inert, + const BTRioBankMetrics *metrics, + BTRioBank *out + ) +{ + if (out == 0 || metrics == 0 || columns <= 0) return; + + const int cw = metrics->cellW; + const int ch = metrics->cellH; + const int gap = metrics->gap; + + for (int i = 0; i < count; ++i) + { + int column = i % columns; + int row = i / columns; + int x = originX + column * (cw + gap); + int y = originY + row * (ch + gap); + Push(out, baseAddress + i, x, y, cw, ch, 2, + (inert != 0) ? inert[i] : 0, + (labels != 0) ? labels[i] : 0); + GrowBounds(out, x, y, cw, ch); + } +} + +//########################################################################### + +void + BTRioBankDump(const char *tag, const BTRioBank *bank) +{ + static int enabled = -1; + static int printed = 0; + + if (enabled < 0) + { + const char *e = getenv("BT_RIOBANK_LOG"); + enabled = (e != 0 && e[0] != '0') ? 1 : 0; + } + if (!enabled || bank == 0 || printed >= 16) return; + ++printed; + + DEBUG_STREAM << "[riobank] " << (tag ? tag : "?") + << " bounds=(" << bank->boundsX << "," << bank->boundsY << "," + << bank->boundsW << "," << bank->boundsH << ") " + << bank->buttonCount << " buttons\n"; + for (int i = 0; i < bank->buttonCount; ++i) + { + const BTRioButton &b = bank->buttons[i]; + DEBUG_STREAM << "[riobank] addr=0x" << std::hex << b.address << std::dec + << " rect=(" << b.x << "," << b.y << "," << b.w << "," << b.h << ")" + << " class=" << b.colorClass << "\n"; + } + DEBUG_STREAM << std::flush; +} diff --git a/engine/MUNGA_L4/L4VB16.cpp b/engine/MUNGA_L4/L4VB16.cpp index 2baf373..bc9e1e2 100644 --- a/engine/MUNGA_L4/L4VB16.cpp +++ b/engine/MUNGA_L4/L4VB16.cpp @@ -2,6 +2,7 @@ #pragma hdrstop #include "l4vb16.h" +#include "l4riobank.h" // the shared button-bank geometry (with the exploded windows) #include "../munga/gaugrend.h" #include "L4VIDEO.h" #include "DXUtils.h" @@ -195,41 +196,229 @@ int gBTCockpitCanvasW = 0; // backbuffer canvas dims (set by btl4main; the int gBTCockpitCanvasH = 0; // aspect calc needs them after a client resize) // Layout constants (backbuffer px). MFD = 640x480 native x 0.5; radar = 480x640 -// portrait x 0.5. The lamp EDGE (kCkLamp) protrudes past the surface; the FULL -// button rect reaches kCkRedCell INTO the display (hit-only, covered by the -// surface). User asked for larger/more-clickable buttons than the glass windows' -// 10px edge -> kCkLamp 16. +// portrait x 0.5. +// +// ⚠ 2026-07-26: the BUTTON rects no longer come from here -- L4RIOBANK owns +// that geometry now and the exploded per-display windows draw the identical +// field. What changed for the surround: its MFD lamps used to sit ENTIRELY +// outside the glass on a kCkRedH=24 strip (a 76x24 click target), while the +// same button in the exploded window reached 128px under the display. Both +// now follow the pod's under-glass rule -- half the glass deep, a scaled lamp +// strip clearing the edge. kCkTopBand still has to reserve room for the top +// row's protruding strip, so the band constants stay. enum { - kCkMFDW = 320, kCkMFDH = 240, + kCkMFDW = 320, kCkMFDH = 240, // the POD sizes (100%) -- see BTCkSizes kCkRADW = 240, kCkRADH = 320, kCkOVL = 44, // (int)(0.14f * 320): corner overlap into the view - kCkLamp = 16, // radar rail / bottom-cell protrusion - kCkRedH = 24, // MFD red lamp height -- sits fully OUTSIDE the surface - kCkRedGap = 3, // gap between the red lamp and the MFD screen edge - kCkRailW = 26, // radar side-rail width - kCkGap = 2, - kCkSideBand = kCkMFDW - kCkOVL, // 276 - kCkTopBand = (kCkMFDH - kCkOVL) + kCkRedGap + kCkRedH, // 223 (fits the top red lamps) - kCkBotBand = kCkRADH + kCkLamp // 336 (radar flush below view) + kCkLamp = 16, // legacy band reserve (radar rail / bottom cell) + kCkRedH = 24, // legacy band reserve above the top MFD row + kCkRedGap = 3, // gap between that reserve and the MFD screen edge + kCkGap = 2 }; +//===========================================================================// +// PLAYER-SCALED DISPLAYS (2026-07-26). +// +// The pod bolted its instruments down at one size; a desktop panel has room to +// trade viewscreen for instrument, so the player scales them -- BT_MFD_SCALE +// sets all five MFDs, BT_MFD_SCALE_UL/_UC/_UR/_LL/_LR override individually, +// BT_RADAR_SCALE the map. Percentages of the pod size, 25-200; anything +// unreadable or out of range falls back to the group setting, then to 100. +// +// Resolved ONCE (the surround recomputes its layout every frame and this reads +// the environment), and the SURROUND BANDS derive from the result -- which is +// the whole reason the sizes could not just be constants: the band a display +// hangs in has to grow with it or the canvas clips it. +//===========================================================================// +struct BTCkSizes +{ + int mfdW[5], mfdH[5]; // UL, UC, UR, LL, LR -- kCkMfdBank order + int radW, radH; + int sideBandL, sideBandR, topBand, botBand; +}; + +static int CkScalePercent(const char *name, int fallback) +{ + const char *text = getenv(name); + if (text == NULL) return fallback; + int percent = atoi(text); + if (percent <= 0) return fallback; + if (percent < 25) percent = 25; + if (percent > 200) percent = 200; + return percent; +} + +// Where the map sits. The pod had it dead centre under the viewscreen, which +// on a wide panel is exactly where the road is -- BT_RADAR_POS moves it out of +// the way. In a bottom corner it takes that corner and the lower MFD there +// slides inboard beside it; halfway up a side it leaves the bottom row +// entirely (and the bottom band shrinks to what the lower MFDs need). +enum BTCkRadarPos { CkRadarBottomCenter = 0, CkRadarBottomLeft, CkRadarBottomRight, + CkRadarMidLeft, CkRadarMidRight }; + +static int CkRadarPosition(void) +{ + static int cached = -1; + if (cached >= 0) return cached; + + static const struct { const char *name; int pos; } names[] = { + { "CENTER", CkRadarBottomCenter }, { "CENTRE", CkRadarBottomCenter }, + { "BOTTOM", CkRadarBottomCenter }, + { "LEFT", CkRadarBottomLeft }, { "BOTTOMLEFT", CkRadarBottomLeft }, + { "RIGHT", CkRadarBottomRight }, { "BOTTOMRIGHT", CkRadarBottomRight }, + { "MIDLEFT", CkRadarMidLeft }, { "LEFTCENTER", CkRadarMidLeft }, + { "LEFTCENTRE", CkRadarMidLeft }, + { "MIDRIGHT", CkRadarMidRight }, { "RIGHTCENTER", CkRadarMidRight }, + { "RIGHTCENTRE", CkRadarMidRight } + }; + + cached = CkRadarBottomCenter; + const char *text = getenv("BT_RADAR_POS"); + if (text != NULL) + for (int i = 0; i < (int)(sizeof(names) / sizeof(names[0])); i++) + if (_stricmp(text, names[i].name) == 0) { cached = names[i].pos; break; } + + static const char *described[] = { "bottom centre", "bottom left", "bottom right", + "left side, centred", "right side, centred" }; + DEBUG_STREAM << "[cockpit] radar on the " << described[cached] << "\n" << std::flush; + return cached; +} + +static int CkRadarOnASide(int pos) + { return (pos == CkRadarMidLeft || pos == CkRadarMidRight) ? 1 : 0; } + +static const BTCkSizes *BTCkResolvedSizes(void) +{ + static BTCkSizes s; + static int resolved = 0; + if (resolved) return &s; + resolved = 1; + + int group = CkScalePercent("BT_MFD_SCALE", 100); + static const char *perDisplay[5] = { + "BT_MFD_SCALE_UL", "BT_MFD_SCALE_UC", "BT_MFD_SCALE_UR", + "BT_MFD_SCALE_LL", "BT_MFD_SCALE_LR" + }; + int percent[5]; + for (int i = 0; i < 5; i++) + { + percent[i] = CkScalePercent(perDisplay[i], group); + s.mfdW[i] = kCkMFDW * percent[i] / 100; + s.mfdH[i] = kCkMFDH * percent[i] / 100; + } + int radarPercent = CkScalePercent("BT_RADAR_SCALE", 100); + s.radW = kCkRADW * radarPercent / 100; + s.radH = kCkRADH * radarPercent / 100; + + //------------------------------------------------------------------- + // The bands: each is whatever the LARGEST display hanging in it needs. + // A corner MFD overlaps the view by kCkOVL, so it only claims + // (its size - the overlap) of the band. + //------------------------------------------------------------------- + int radarPos = CkRadarPosition(); + + int leftMax = (s.mfdW[0] > s.mfdW[3]) ? s.mfdW[0] : s.mfdW[3]; // UL, LL + int rightMax = (s.mfdW[2] > s.mfdW[4]) ? s.mfdW[2] : s.mfdW[4]; // UR, LR + s.sideBandL = leftMax - kCkOVL; + s.sideBandR = rightMax - kCkOVL; + + // top: the corner MFDs claim (h - overlap); UC drops 75% of its height in + int topCorner = ((s.mfdH[0] > s.mfdH[2]) ? s.mfdH[0] : s.mfdH[2]) - kCkOVL; + int topCentre = (int)(0.75f * s.mfdH[1]); + s.topBand = (topCorner > topCentre) ? topCorner : topCentre; + s.topBand += kCkRedGap + kCkRedH; // room for the top lamp strip + + // bottom: the lower MFDs hang (h - overlap) below the view; the radar, + // when it is down there, hangs its full height plus its lamp strip + int botCorner = ((s.mfdH[3] > s.mfdH[4]) ? s.mfdH[3] : s.mfdH[4]) - kCkOVL; + s.botBand = botCorner; + if (!CkRadarOnASide(radarPos)) + { + int radarBand = s.radH + kCkLamp; + if (radarBand > s.botBand) s.botBand = radarBand; + } + + // a side-mounted radar has to fit BESIDE the view, in that side's band + if (CkRadarOnASide(radarPos)) + { + int *band = (radarPos == CkRadarMidLeft) ? &s.sideBandL : &s.sideBandR; + if (s.radW + kCkLamp > *band) *band = s.radW + kCkLamp; + } + + if (s.sideBandL < 1) s.sideBandL = 1; + if (s.sideBandR < 1) s.sideBandR = 1; + + DEBUG_STREAM << "[cockpit] displays UL " << percent[0] << "% UC " << percent[1] + << "% UR " << percent[2] << "% LL " << percent[3] << "% LR " << percent[4] + << "% radar " << radarPercent << "% bands L" << s.sideBandL + << " R" << s.sideBandR << " T" << s.topBand << " B" << s.botBand + << "\n" << std::flush; + return &s; +} + // Canvas = view region + the surround bands. void BTCockpitCanvasFor(int viewW, int viewH, int *canvasW, int *canvasH) { - if (canvasW) *canvasW = viewW + 2 * kCkSideBand; - if (canvasH) *canvasH = viewH + kCkTopBand + kCkBotBand; + const BTCkSizes *s = BTCkResolvedSizes(); + if (canvasW) *canvasW = viewW + s->sideBandL + s->sideBandR; + if (canvasH) *canvasH = viewH + s->topBand + s->botBand; +} + +//===========================================================================// +// LETTERBOX FIT -- one uniform scale, centred, leftover black. Contract + +// why the swap effect changes: l4vb16.h. +//===========================================================================// +int gBTCockpitLetterbox = 0; + +int BTCockpitFitRect(int clientW, int clientH, RECT *out) +{ + int cw = gBTCockpitCanvasW, ch = gBTCockpitCanvasH; + if (out == NULL || cw <= 0 || ch <= 0 || clientW <= 0 || clientH <= 0) + return 0; + + // The larger axis is the constraint; integer math throughout so the rect + // is stable frame to frame (a float scale wobbles by a pixel and the bars + // shimmer during a drag). + int w = clientW; + int h = (int)((__int64)clientW * ch / cw); + if (h > clientH) + { + h = clientH; + w = (int)((__int64)clientH * cw / ch); + } + if (w < 1) w = 1; + if (h < 1) h = 1; + + out->left = (clientW - w) / 2; + out->top = (clientH - h) / 2; + out->right = out->left + w; + out->bottom = out->top + h; + + return (w != clientW || h != clientH) ? 1 : 0; +} + +const RECT *BTCockpitPresentDest(void *hwnd, RECT *storage) +{ + if (!gBTCockpitLetterbox || !gBTGaugeCockpit || hwnd == NULL || storage == NULL) + return NULL; + RECT client; + if (!GetClientRect((HWND)hwnd, &client)) + return NULL; + if (!BTCockpitFitRect(client.right, client.bottom, storage)) + return NULL; // already an exact fit -- present full client + return storage; } // The five MFD banks: {cockpit surface slot, high address}. Top row = bankHi-i // (descending), bottom row = bankHi-4-i (L4GLASSWIN BuildMfd order + the // BTGlassPanels_Create address assignment). -static const struct { int slot, bankHi; } kCkMfdBank[5] = +static const struct { int slot, bankHi; const char *name; } kCkMfdBank[5] = { - { 0, 0x2F }, // Heat (UL) - { 1, 0x27 }, // Mfd2 (UC) - { 5, 0x37 }, // Comm (UR) - { 2, 0x0F }, // Mfd1 (LL) - { 3, 0x07 }, // Mfd3 (LR) + { 0, 0x2F, "surround/Heat MFD" }, // UL + { 1, 0x27, "surround/Engineering" }, // UC + { 5, 0x37, "surround/Comm MFD" }, // UR + { 2, 0x0F, "surround/Left Weapons" }, // LL + { 3, 0x07, "surround/Right Weapons" }, // LR }; static void CkPushBtn(BTCockpitLayout *L, int addr, int x, int y, int w, int h, @@ -247,66 +436,114 @@ void BTCockpitComputeLayout(int canvasW, int canvasH, BTCockpitLayout *out) memset(L, 0, sizeof(*L)); L->canvasW = canvasW; L->canvasH = canvasH; - int viewW = canvasW - 2 * kCkSideBand; - int viewH = canvasH - kCkTopBand - kCkBotBand; + const BTCkSizes *S = BTCkResolvedSizes(); + int viewW = canvasW - S->sideBandL - S->sideBandR; + int viewH = canvasH - S->topBand - S->botBand; if (viewW < 64) viewW = 64; if (viewH < 64) viewH = 64; - int vx = kCkSideBand, vy = kCkTopBand; + int vx = S->sideBandL, vy = S->topBand; L->viewX = vx; L->viewY = vy; L->viewW = viewW; L->viewH = viewH; // Surface dest rects (slots: 0 Heat, 1 Mfd2, 2 Mfd1, 3 Mfd3, 4 sec, 5 Comm). // Corner MFDs overlap the view corner by kCkOVL both axes; Mfd2 drops 25% of // its height into the top; sec is flush below (view bottom edge clean). + // Sizes are the player-scaled ones (BTCkResolvedSizes), indexed in + // kCkMfdBank order: 0 UL, 1 UC, 2 UR, 3 LL, 4 LR. int cx = vx + viewW / 2; // Heat TL - L->surfX[0] = vx - kCkMFDW + kCkOVL; L->surfY[0] = vy - kCkMFDH + kCkOVL; - L->surfW[0] = kCkMFDW; L->surfH[0] = kCkMFDH; + L->surfX[0] = vx - S->mfdW[0] + kCkOVL; L->surfY[0] = vy - S->mfdH[0] + kCkOVL; + L->surfW[0] = S->mfdW[0]; L->surfH[0] = S->mfdH[0]; // Mfd2 top-center (25% into view) - L->surfX[1] = cx - kCkMFDW / 2; L->surfY[1] = vy - (int)(0.75f * kCkMFDH); - L->surfW[1] = kCkMFDW; L->surfH[1] = kCkMFDH; + L->surfX[1] = cx - S->mfdW[1] / 2; L->surfY[1] = vy - (int)(0.75f * S->mfdH[1]); + L->surfW[1] = S->mfdW[1]; L->surfH[1] = S->mfdH[1]; // Mfd1 BL - L->surfX[2] = vx - kCkMFDW + kCkOVL; L->surfY[2] = vy + viewH - kCkOVL; - L->surfW[2] = kCkMFDW; L->surfH[2] = kCkMFDH; + L->surfX[2] = vx - S->mfdW[3] + kCkOVL; L->surfY[2] = vy + viewH - kCkOVL; + L->surfW[2] = S->mfdW[3]; L->surfH[2] = S->mfdH[3]; // Mfd3 BR L->surfX[3] = vx + viewW - kCkOVL; L->surfY[3] = vy + viewH - kCkOVL; - L->surfW[3] = kCkMFDW; L->surfH[3] = kCkMFDH; - // sec center, flush below - L->surfX[4] = cx - kCkRADW / 2; L->surfY[4] = vy + viewH; - L->surfW[4] = kCkRADW; L->surfH[4] = kCkRADH; + L->surfW[3] = S->mfdW[4]; L->surfH[3] = S->mfdH[4]; // Comm TR - L->surfX[5] = vx + viewW - kCkOVL; L->surfY[5] = vy - kCkMFDH + kCkOVL; - L->surfW[5] = kCkMFDW; L->surfH[5] = kCkMFDH; + L->surfX[5] = vx + viewW - kCkOVL; L->surfY[5] = vy - S->mfdH[2] + kCkOVL; + L->surfW[5] = S->mfdW[2]; L->surfH[5] = S->mfdH[2]; - // --- MFD red buttons: 4 top (bankHi-i) + 4 bottom (bankHi-4-i) --- - int slotW = kCkMFDW / 4; // 80 - for (int m = 0; m < 5; m++) + //------------------------------------------------------------------- + // sec (the map). Bottom centre as the pod had it, either bottom + // corner, or halfway up either side (BT_RADAR_POS). Only when it + // takes a bottom CORNER does a lower MFD have to move -- it slides + // inboard beside the map; on a side the map is out of that row's way + // already. + //------------------------------------------------------------------- { - int s = kCkMfdBank[m].slot, hi = kCkMfdBank[m].bankHi; - int sx = L->surfX[s], sy = L->surfY[s]; - for (int i = 0; i < 4; i++) + int radarPos = CkRadarPosition(); + L->surfW[4] = S->radW; + L->surfH[4] = S->radH; + // A corner map goes FLUSH to the canvas edge and the lower MFD whose + // corner it took sits BESIDE it -- measured off the canvas edge, not + // the view edge, or the two overlap by the band width (they did: + // 232px of map drawn over the Right Weapons MFD). + int canvasLeft = vx - S->sideBandL; + int canvasRight = vx + viewW + S->sideBandR; + switch (radarPos) { - int bx = sx + i * slotW; - // Lamps sit fully OUTSIDE the MFD (a small gap off the edge) so nothing - // occludes them or the DISPLAY/PROGRAM legends -- 4 above, 4 below. - CkPushBtn(L, hi - i, bx, sy - kCkRedGap - kCkRedH, slotW - kCkGap, kCkRedH, 0, 0, 0); - CkPushBtn(L, hi - 4 - i, bx, sy + kCkMFDH + kCkRedGap, slotW - kCkGap, kCkRedH, 0, 0, 0); + case CkRadarBottomLeft: + L->surfX[4] = canvasLeft; + L->surfY[4] = vy + viewH; + L->surfX[2] = L->surfX[4] + S->radW; // LL slides inboard + break; + case CkRadarBottomRight: + L->surfX[4] = canvasRight - S->radW; + L->surfY[4] = vy + viewH; + L->surfX[3] = L->surfX[4] - S->mfdW[4]; // LR slides inboard + break; + case CkRadarMidLeft: + L->surfX[4] = vx - S->radW + kCkOVL; + L->surfY[4] = vy + (viewH - S->radH) / 2; + break; + case CkRadarMidRight: + L->surfX[4] = vx + viewW - kCkOVL; + L->surfY[4] = vy + (viewH - S->radH) / 2; + break; + default: + L->surfX[4] = cx - S->radW / 2; + L->surfY[4] = vy + viewH; + break; } } - // --- radar yellow rails (6/side) + 4 bottom cells --- + // --- MFD buttons + radar columns: L4RIOBANK, the shared under-glass field --- + // Each bank is laid out at ITS display's scaled size, so the lamp strips and + // the under-glass reach track a resized display instead of drifting off it. { - int rx = L->surfX[4], ry = L->surfY[4]; - int slotH = kCkRADH / 6; - for (int i = 0; i < 6; i++) + for (int m = 0; m < 5; m++) { - int y = ry + i * slotH; - CkPushBtn(L, 0x10 + i, rx - kCkLamp, y, kCkLamp + kCkRailW, slotH - kCkGap, 1, 0, 0); - CkPushBtn(L, 0x18 + i, rx + kCkRADW - kCkRailW, y, kCkRailW + kCkLamp, slotH - kCkGap, 1, 0, 0); + int s = kCkMfdBank[m].slot; + BTRioBankMetrics mfdMetrics; + BTRioBankMetricsFor(L->surfW[s], L->surfH[s], &mfdMetrics); + + BTRioBank bank; + BTRioBankLayout(BTRioBankMfd, L->surfX[s], L->surfY[s], + L->surfW[s], L->surfH[s], kCkMfdBank[m].bankHi, 0, &mfdMetrics, 0, &bank); + BTRioBankDump(kCkMfdBank[m].name, &bank); + for (int i = 0; i < bank.buttonCount; i++) + { + const BTRioButton &b = bank.buttons[i]; + CkPushBtn(L, b.address, b.x, b.y, b.w, b.h, b.colorClass, b.inert, b.label); + } } + static const int bottomAddr[4] = { 0x16, 0x17, 0x1F, 0x1E }; - int botLeft = rx + kCkRailW, botSpan = kCkRADW - 2 * kCkRailW, botW = botSpan / 4; - for (int i = 0; i < 4; i++) - CkPushBtn(L, bottomAddr[i], botLeft + i * botW, ry + kCkRADH - 14, botW - kCkGap, 14 + kCkLamp, 1, 0, 0); + BTRioBankMetrics radarMetrics; + BTRioBankMetricsFor(L->surfW[4], L->surfH[4], &radarMetrics); + + BTRioBank radar; + BTRioBankLayout(BTRioBankRadar, L->surfX[4], L->surfY[4], + L->surfW[4], L->surfH[4], 0x10, 0x18, &radarMetrics, bottomAddr, &radar); + BTRioBankDump("surround/radar", &radar); + for (int i = 0; i < radar.buttonCount; i++) + { + const BTRioButton &b = radar.buttons[i]; + CkPushBtn(L, b.address, b.x, b.y, b.w, b.h, b.colorClass, b.inert, b.label); + } } // --- flight blue blocks: THROTTLE/AUX (0x38-0x3F) under Mfd1, JOYSTICK @@ -316,18 +553,34 @@ void BTCockpitComputeLayout(int canvasW, int canvasH, BTCockpitLayout *out) static const int thrInert[8] = { 1, 1, 1, 1, 1, 0, 1, 0 }; static const char *joyLabel[8] = { "Main", "Hat Bk", "Hat Up", "Hat R", "Hat L", "Pinky", "Middle", "Upper" }; // 0x40..0x47 - const int cw = 68, ch = 30, g = 4; - int blockW = 4 * cw + 3 * g; // 284 - int belowLamp = kCkMFDH + kCkRedGap + kCkRedH + 8; // clear the MFD's bottom red lamps - int thrX = L->surfX[2], thrY = L->surfY[2] + belowLamp; - int joyX = L->surfX[3] + kCkMFDW - blockW, joyY = L->surfY[3] + belowLamp; - for (int i = 0; i < 8; i++) + // These cells carry TEXT, so the surround runs them bigger than the + // exploded window's 58x28 -- the caller owns the cell size + // (l4riobank.h), only the grid walk is shared. + BTRioBankMetrics metrics; + metrics.strip = 0; + metrics.gap = 4; + metrics.cellW = 68; + metrics.cellH = 30; + + // Hang each block below ITS lower MFD, clearing that display's own + // (scaled) bottom lamp strip -- a shrunken MFD used to leave the block + // floating in the gap, an enlarged one to overlap it. + int blockW = 4 * metrics.cellW + 3 * metrics.gap; // 284 + int thrX = L->surfX[2]; + int thrY = L->surfY[2] + L->surfH[2] + kCkRedGap + kCkRedH + 8; + int joyX = L->surfX[3] + L->surfW[3] - blockW; + int joyY = L->surfY[3] + L->surfH[3] + kCkRedGap + kCkRedH + 8; + + BTRioBank flight; + memset(&flight, 0, sizeof(flight)); + BTRioBankFlightGrid(thrX, thrY, 0x38, 8, 4, thrLabel, thrInert, &metrics, &flight); + BTRioBankFlightGrid(joyX, joyY, 0x40, 8, 4, joyLabel, 0, &metrics, &flight); + BTRioBankDump("surround/flight", &flight); + for (int i = 0; i < flight.buttonCount; i++) { - int c = i % 4, r = i / 4; - CkPushBtn(L, 0x38 + i, thrX + c * (cw + g), thrY + r * (ch + g), cw, ch, 2, - thrInert[i], thrLabel[i] ? thrLabel[i] : ""); - CkPushBtn(L, 0x40 + i, joyX + c * (cw + g), joyY + r * (ch + g), cw, ch, 2, - 0, joyLabel[i]); + const BTRioButton &b = flight.buttons[i]; + CkPushBtn(L, b.address, b.x, b.y, b.w, b.h, b.colorClass, b.inert, + b.label ? b.label : ""); } } } @@ -959,8 +1212,33 @@ int BTCockpitMouseDown(int cx, int cy, int clientW, int clientH, int rightButton if (!gBTGaugeCockpit) return 0; int bbW = gBTCockpitCanvasW, bbH = gBTCockpitCanvasH; if (bbW <= 0 || bbH <= 0 || clientW <= 0 || clientH <= 0) return 0; - int bx = cx * bbW / clientW; - int by = cy * bbH / clientH; + + // Client -> canvas. Under the letterbox the canvas occupies a centred + // sub-rect, so the click has to come back through the SAME transform + // Present used -- mapping against the full client would drift the hit test + // off every button by the bar width. A click on a bar hits nothing. + int bx, by; + if (gBTCockpitLetterbox) + { + RECT fit; + if (BTCockpitFitRect(clientW, clientH, &fit)) + { + if (cx < fit.left || cx >= fit.right || cy < fit.top || cy >= fit.bottom) + return 0; + bx = (cx - fit.left) * bbW / (fit.right - fit.left); + by = (cy - fit.top) * bbH / (fit.bottom - fit.top); + } + else + { + bx = cx * bbW / clientW; + by = cy * bbH / clientH; + } + } + else + { + bx = cx * bbW / clientW; + by = cy * bbH / clientH; + } BTCockpitLayout L; BTCockpitComputeLayout(bbW, bbH, &L); diff --git a/engine/MUNGA_L4/L4VB16.h b/engine/MUNGA_L4/L4VB16.h index 229ed0e..f6dc0a1 100644 --- a/engine/MUNGA_L4/L4VB16.h +++ b/engine/MUNGA_L4/L4VB16.h @@ -53,6 +53,30 @@ struct BTCockpitLayout extern int gBTGaugeCockpit; // mode select; set once by btl4main extern int gBTCockpitCanvasW, gBTCockpitCanvasH; // backbuffer canvas dims (for the aspect calc) +// +// LETTERBOX FIT (2026-07-26). The cockpit canvas is a FIXED size -- D3D9 +// stretches it into whatever the client area is, so a window dragged to a +// different shape used to squash the instruments (the projection was +// aspect-corrected, but the panels, lamps and MFD glass were not). +// +// Now the canvas is fitted at ONE uniform scale, centred, with the leftover +// left black: the cockpit scales both ways and never distorts, whatever the +// window (drag, maximise, -fit borderless). D3D9 does the scaling at Present +// time via a destination rect, which D3DSWAPEFFECT_DISCARD forbids -- so the +// WINDOWED swap effect becomes D3DSWAPEFFECT_COPY when the surround is up and +// multisampling is off (COPY cannot multisample). gBTCockpitLetterbox says +// whether that happened; when it is 0 the old full-client stretch stands, so +// an MSAA run still works, just without the letterbox. +// +extern int gBTCockpitLetterbox; // 1 = the swap chain can take a dest rect + +// The uniform-scale centred rect for a client area. Returns 1 when it differs +// from the full client (letterboxing needed), 0 when it fills it exactly. +int BTCockpitFitRect(int clientW, int clientH, RECT *out); + +// Present destination for the main device window, or NULL for the full client. +const RECT *BTCockpitPresentDest(void *hwnd, RECT *storage); + void BTCockpitCanvasFor(int viewW, int viewH, int *canvasW, int *canvasH); void BTCockpitComputeLayout(int canvasW, int canvasH, BTCockpitLayout *out); void BTDrawCockpitPanels(LPDIRECT3DDEVICE9 device); @@ -60,21 +84,30 @@ int BTCockpitMouseDown(int cx, int cy, int clientW, int clientH, int rightButto void BTCockpitMouseUp(void); // Lamp brightness decode (RIOBase::LampState) -- shared by the cockpit draw and -// the glass per-display windows. level = max of the two 2-bit fields; the low 2 -// bits pick a flash half-period (500/250/125 ms). Returns 0..3. +// the glass per-display windows. Returns 0 (off) / 1 (dim) / 3 (bright). +// +// The wire word is TWO brightness fields plus a flash mode (L4RIO.h [T0]): +// bits 0-1 solid=0 / flashSlow=1 / flashMed=2 / flashFast=3 +// bits 2-3 state 1 brightness (state1Off=0, state1Dim=1, state1Bright=3) +// bits 4-5 state 2 brightness (state2Off=0, state2Dim=1, state2Bright=3) +// Solid shows state 1; flashing ALTERNATES state 1 and state 2 at a +// 500/250/125 ms half-period. +// +// ⚠ FIXED 2026-07-26: this used to return max(state1,state2) and blank to 0 on +// the alternate phase. That agrees with the enum only when one state is Off -- +// true for the Panic lamp (L4CTRL.cpp flashFast+state1Off+state2Bright) and for +// L4LAMP's off/dim pulse, which is why it went unnoticed -- but L4LAMP.cpp:252 +// commands flashFast + state1Dim + state2Bright, a DIM-to-BRIGHT pulse that +// rendered as a hard BRIGHT-to-OFF blink. RP412's L4MFDVIEW LampLevel has the +// faithful formula; this is it. inline int BTLampBrightnessOf(int state, unsigned long tick) { int level1 = (state >> 2) & 0x3; int level2 = (state >> 4) & 0x3; - int level = (level1 > level2) ? level1 : level2; - if (level == 0) return 0; - int flash = state & 0x3; - if (flash != 0) - { - unsigned long half = (flash == 1) ? 500 : (flash == 2) ? 250 : 125; - if ((tick / half) & 1) return 0; - } - return level; + int flash = state & 0x3; + if (flash == 0) return level1; + unsigned long half = (flash == 1) ? 500 : (flash == 2) ? 250 : 125; + return ((tick / half) & 1) ? level2 : level1; } //######################################################################## diff --git a/engine/MUNGA_L4/L4VIDEO.cpp b/engine/MUNGA_L4/L4VIDEO.cpp index f560208..58a3083 100644 --- a/engine/MUNGA_L4/L4VIDEO.cpp +++ b/engine/MUNGA_L4/L4VIDEO.cpp @@ -3419,6 +3419,34 @@ DPLRenderer::DPLRenderer( mPresentParams.MultiSampleQuality--; } mPresentParams.SwapEffect = D3DSWAPEFFECT_DISCARD; + + // + // COCKPIT LETTERBOX (2026-07-26): the fixed canvas is fitted into the client + // at Present time via a destination rect, which DISCARD forbids -- COPY is + // the swap effect that takes one. Windowed + surround only, and only with + // multisampling off (COPY cannot multisample). Full contract: l4vb16.h. + // + // btl4main sets the INTENT (it has to: the first WM_SIZE beats the device + // here); this is where it becomes real, or gets withdrawn. + { + extern int gBTGaugeCockpit; + extern int gBTCockpitLetterbox; + if (!fullscreen && gBTGaugeCockpit && gBTCockpitLetterbox && + mPresentParams.MultiSampleType == D3DMULTISAMPLE_NONE) + mPresentParams.SwapEffect = D3DSWAPEFFECT_COPY; + else + gBTCockpitLetterbox = 0; // withdrawn -- the canvas stretches + // only meaningful when there IS a cockpit canvas to fit + if (gBTGaugeCockpit) + DEBUG_STREAM << "[cockpit] letterbox " << (gBTCockpitLetterbox ? "ON" : "off") + << " (swap effect " + << (mPresentParams.SwapEffect == D3DSWAPEFFECT_COPY ? "COPY" : "DISCARD") + << (mPresentParams.MultiSampleType != D3DMULTISAMPLE_NONE + ? "; multisampling on -- COPY unavailable, canvas stretches to the client" + : "") + << ")\n" << std::flush; + } + mPresentParams.hDeviceWindow = hWnd; mPresentParams.Flags = 0; mPresentParams.FullScreen_RefreshRateInHz = (fullscreen)?60:D3DPRESENT_RATE_DEFAULT; @@ -8799,7 +8827,11 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte // DIAG (turn-hitch hunt): draw CPU is _rt0..here; Present blocks on the GPU. LARGE_INTEGER _rt1; QueryPerformanceCounter(&_rt1); - hr = mDevice->Present(NULL, NULL, NULL, NULL); + // COCKPIT LETTERBOX: uniform-scale the canvas into the client (NULL = the + // old full-client stretch, which is what every non-cockpit run gets). + RECT _ckDestStorage; + const RECT *_ckDest = BTCockpitPresentDest(mPresentParams.hDeviceWindow, &_ckDestStorage); + hr = mDevice->Present(NULL, _ckDest, NULL, NULL); // The wait-screen overlay stands down PERMANENTLY the moment the first // REAL scene frame presents (user report 2026-07-22: the launch handoff // briefly interleaved idle-overlay presents with game frames = flicker). @@ -9125,7 +9157,12 @@ void DPLRenderer::ExecuteIdle() } backbuffer->Release(); } - HRESULT present_hr = mDevice->Present(NULL, NULL, NULL, NULL); + // same letterbox as the scene present -- the wait overlay paints into + // this backbuffer, so it has to land in the same rect + RECT wait_dest_storage; + const RECT *wait_dest = + BTCockpitPresentDest(mPresentParams.hDeviceWindow, &wait_dest_storage); + HRESULT present_hr = mDevice->Present(NULL, wait_dest, NULL, NULL); static int s_wait_path_logged = 0; if (!s_wait_path_logged) { @@ -9772,12 +9809,18 @@ static float BTWorldAspectOf(int client_w, int client_h) // ON-SCREEN aspect = (viewW/canvasW * client_w) / (viewH/canvasH * client_h). extern int gBTGaugeCockpit; extern int gBTCockpitCanvasW, gBTCockpitCanvasH; + extern int gBTCockpitLetterbox; if (gBTGaugeCockpit && gBTCockpitCanvasW > 0 && gBTCockpitCanvasH > 0) { BTCockpitLayout L; BTCockpitComputeLayout(gBTCockpitCanvasW, gBTCockpitCanvasH, &L); if (L.viewW > 0 && L.viewH > 0) { + // LETTERBOXED: the canvas is scaled UNIFORMLY, so the view's + // on-screen aspect is just its own -- the client no longer enters + // into it (2026-07-26). + if (gBTCockpitLetterbox) + return (float)L.viewW / (float)L.viewH; float w = (float)L.viewW * (float)client_w / (float)gBTCockpitCanvasW; float h = (float)L.viewH * (float)client_h / (float)gBTCockpitCanvasH; if (h > 0.0f) return w / h; diff --git a/engine/MUNGA_L4/l4riobank.h b/engine/MUNGA_L4/l4riobank.h new file mode 100644 index 0000000..54fb5a6 --- /dev/null +++ b/engine/MUNGA_L4/l4riobank.h @@ -0,0 +1,149 @@ +//===========================================================================// +// File: l4riobank.h // +// Project: MUNGA_L4 Brick: cockpit RIO button-bank geometry // +// Contents: the ONE layout rule for the buttons around a pod display // +//---------------------------------------------------------------------------// +// Copyright (C) 1994-1995, Virtual World Entertainment, Inc. // +// PROPRIETARY AND CONFIDENTIAL // +//===========================================================================// + +#ifndef _L4RIOBANK_H_ +#define _L4RIOBANK_H_ + +//########################################################################### +// Every pod secondary display carries a bank of illuminated RIO pushbuttons +// mounted around its glass. We render that field TWICE -- once composited +// into the main window (the COCKPIT SURROUND, L4VB16.cpp) and once per +// display in its own desktop window (the EXPLODED view, L4GLASSWIN.cpp) -- +// and until 2026-07-26 each renderer carried its own copy of the geometry. +// They drifted: the exploded windows put the buttons UNDER the glass (a big +// click target, only a thin lamp strip clearing the edge) while the surround +// left the MFD lamps entirely outside the surface, so the same button was a +// 156x138 target in one mode and a 76x24 sliver in the other. +// +// This is that geometry, once. A caller supplies a display rect and the +// bank's anchor address(es); it gets back the button rects IN THAT SAME +// SPACE plus the bounding box the bank needs. Placement -- WHERE each +// display sits -- stays with the renderer, because that genuinely differs +// (one composited canvas vs seven desktop windows). +// +// THE UNDER-GLASS RULE (ported from RP412 L4MFDVIEW, which took it from the +// pod): a button reaches half the display in BEHIND the glass and only +// `strip` clears the edge. The renderer draws the buttons FIRST and the +// display imagery OVER them, so the lamp reads as a slim bar while +// practically the whole display is the press target. Nothing is occluded -- +// the picture wins every pixel it covers. +// +// Addresses per [[pod-hardware]] "The button space + lamps"; the anchors are +// the caller's -- this file never hardcodes a bank address. +//########################################################################### + +enum BTRioBankStyle +{ + BTRioBankMfd = 0, // 4 buttons above the glass + 4 below, addresses + // DESCENDING from anchorA row-major (0x2F..0x28) + BTRioBankRadar // 6 down each side (anchorA left, anchorB right) + // + an optional 4 along the bottom +}; + +enum +{ + BTRioBankMaxButtons = 24 +}; + +struct BTRioButton +{ + int address; // RIO buttonGroup address (0x00-0x47) + int x, y, w, h; // caller-space rect (the FULL hit target) + int colorClass; // 0 red (MFD) / 1 yellow (radar) / 2 blue (flight) + int inert; // 1 = no authored .CTL mapping -> drawn greyed + const char *label; // non-NULL -> the face is drawn + labelled +}; + +//--------------------------------------------------------------------------- +// How much of a button shows, and how the field is spaced. Both renderers +// work at different scales (the surround halves the glass; the exploded +// windows are native), so this is DERIVED from the display size -- the pod +// rule applied at whatever size you hand it. +//--------------------------------------------------------------------------- +struct BTRioBankMetrics +{ + int strip; // how much of a button clears the display edge (the lamp) + int gap; // gap between neighbouring buttons in a row/column + int cellW, cellH; // the labelled no-glass cell (flight bank, map foot row) +}; + +struct BTRioBank +{ + BTRioButton buttons[BTRioBankMaxButtons]; + int buttonCount; + + // The bank + display bounding box, in the caller's space. A window + // caller sizes its client to boundsW/H and passes an origin that puts + // boundsX/Y at zero; the composited caller ignores these and just uses + // the button rects, which are already in canvas space. + int boundsX, boundsY, boundsW, boundsH; +}; + +//--------------------------------------------------------------------------- +// The pod rule at a given display size. The lamp strip scales off the SHORT +// axis against the native 480 (an MFD is 640x480 landscape, the map 480x640 +// portrait -- both have a 480 short axis) and never drops below a readable +// floor, because a 5px lamp on a half-scale surround MFD is a smudge. +//--------------------------------------------------------------------------- +void BTRioBankMetricsFor(int displayW, int displayH, BTRioBankMetrics *out); + +//--------------------------------------------------------------------------- +// Lay out one display's bank. originX/Y is the DISPLAY's top-left; button +// rects come back around it, reaching `strip` outside the glass (so they can +// land at negative coordinates relative to the origin -- see boundsX/Y). +// anchorA/B bank base addresses (style-dependent; see BTRioBankStyle) +// bottomAddrs BTRioBankRadar only: the 4 foot-row addresses, or NULL +//--------------------------------------------------------------------------- +void BTRioBankLayout( + BTRioBankStyle style, + int originX, + int originY, + int displayW, + int displayH, + int anchorA, + int anchorB, + const BTRioBankMetrics *metrics, + const int *bottomAddrs, + BTRioBank *out +); + +//--------------------------------------------------------------------------- +// The no-display bank (throttle/panic 0x38-0x3F, joystick/fire 0x40-0x47): +// a grid of labelled cells, drawn in full since there is no glass to hide +// behind. APPENDS to `out` (so both columns share one bank) and grows its +// bounds; zero `out` before the first call. `labels`/`inert` are `count` +// long and may be NULL. +// +// These cells carry TEXT, so their size is the caller's call, not a scale of +// some glass -- fill metrics->cellW/cellH yourself (the surround runs bigger +// cells than the exploded window because its canvas is half-scale and the +// labels still have to read). +//--------------------------------------------------------------------------- +void BTRioBankFlightGrid( + int originX, + int originY, + int baseAddress, + int count, + int columns, + const char *const *labels, + const int *inert, + const BTRioBankMetrics *metrics, + BTRioBank *out +); + +//--------------------------------------------------------------------------- +// One-shot geometry dump: `BT_RIOBANK_LOG=1` prints every button's address and +// rect as the banks are built, so the field can be checked for coverage and +// address shadowing without driving a mouse. Capped at the first few banks so +// a per-frame caller (the surround recomputes its layout every draw) prints +// one pass and then goes quiet. +//--------------------------------------------------------------------------- +void BTRioBankDump(const char *tag, const BTRioBank *bank); + +#endif diff --git a/game/btl4main.cpp b/game/btl4main.cpp index d60e432..049e8cb 100644 --- a/game/btl4main.cpp +++ b/game/btl4main.cpp @@ -142,10 +142,18 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) // new client aspect so the scene doesn't stretch fat/skinny (the D3D9 // backbuffer stays at the configured size and is stretched into the // client area; rendering with the client aspect cancels the stretch). + // Under the cockpit LETTERBOX the canvas is scaled uniformly instead, + // so the aspect comes from the view rect -- BTWorldAspectOf handles it. if (wParam != SIZE_MINIMIZED && LOWORD(lParam) > 0 && HIWORD(lParam) > 0) { extern void L4NotifyWindowResized(int client_w, int client_h); L4NotifyWindowResized((int)LOWORD(lParam), (int)HIWORD(lParam)); + // Repaint the letterbox bars: a COPY present only writes its dest + // rect, so whatever the old bars held would otherwise persist. The + // class brush is black, so an erase is all it takes. + extern int gBTCockpitLetterbox; + if (gBTCockpitLetterbox) + InvalidateRect(hWnd, NULL, TRUE); } return 0; case WM_CLOSE: @@ -215,6 +223,194 @@ static LONG WINAPI return EXCEPTION_EXECUTE_HANDLER; // die (after the evidence is out) } +//===========================================================================// +// environ.ini -- the player's settings file (cwd = content\). One KEY=VALUE +// per line; the real environment always WINS, so a launcher .bat or a shell +// export overrides the file rather than fighting it. +// +// ⚠ Loaded EARLY (2026-07-26). It used to be read ~300 lines into WinMain, +// after the platform-profile block had already run its getenv()s -- so every +// setting the profile reads (BT_PLATFORM, BT_COCKPIT, BT_GLASS_PANELS, +// BT_DEV_GAUGES, L4CONTROLS...) was silently ignored from the file and only +// worked as a real env var. It also putenv()'d comment lines verbatim, which +// turned a commented-out option into an environment variable literally named +// "#BT_MFD_SCALE" -- harmless, but it meant shipping a self-documenting file +// would have littered the environment with junk. +//===========================================================================// +// +// The shipped default, written on first run when the file is absent -- the +// bindings.txt convention ([[glass-cockpit]] §bindings.txt is a COMPATIBILITY +// SURFACE): the player customizes it, it stays UNTRACKED, and an +// extract-over-top upgrade therefore never clobbers their settings. Every +// option ships COMMENTED OUT, so a fresh install applies nothing and behaves +// exactly as it did before the file existed. +// +static const char *kEnvironIniDefault = +"# ============================================================================\n" +"# environ.ini -- BattleTech 4.11 settings\n" +"# ============================================================================\n" +"# One KEY=VALUE per line, read at game start. Lines starting with # or ;\n" +"# are comments. A real environment variable always WINS over this file, so\n" +"# a launcher .bat can override anything here. Delete a line to fall back to\n" +"# the built-in default.\n" +"#\n" +"# Input bindings live in bindings.txt beside this file (written with the\n" +"# full documented layout on first run; delete it to restore defaults).\n" +"\n" +"# ---- The cockpit ------------------------------------------------------------\n" +"\n" +"# Where the five MFDs and the map go:\n" +"# (unset) the COCKPIT SURROUND -- instruments composited around\n" +"# the viewscreen in one window (the default)\n" +"# BT_GLASS_PANELS=1 one desktop window PER display, arranged around the\n" +"# game window (the 'exploded' view -- handy for reading\n" +"# or screenshotting a display at full size)\n" +"# BT_COCKPIT=0 the plain docked gauge strip along the bottom\n" +"#BT_GLASS_PANELS=1\n" +"#BT_COCKPIT=0\n" +"\n" +"# Size of the secondary displays in the cockpit surround, as a percentage of\n" +"# their pod size. The pod bolted them down at one size; on a big panel there\n" +"# is room to trade viewscreen for instrument, so turn these up if you want to\n" +"# actually read the other displays while you fly. 100 = as the pod had them.\n" +"# Range 25-200 (out-of-range or unreadable values fall back to the group\n" +"# setting, then to 100). The surround grows to fit whatever you ask for.\n" +"#\n" +"# BT_MFD_SCALE sets all five MFDs at once...\n" +"#BT_MFD_SCALE=100\n" +"# ...and any single display can override it:\n" +"# UL upper left (heat) UC upper center (engineering)\n" +"# UR upper right (comm) LL lower left (left weapons)\n" +"# LR lower right (right weapons)\n" +"#BT_MFD_SCALE_UL=100\n" +"#BT_MFD_SCALE_UC=100\n" +"#BT_MFD_SCALE_UR=100\n" +"#BT_MFD_SCALE_LL=100\n" +"#BT_MFD_SCALE_LR=100\n" +"\n" +"# The portrait map/radar, sized on its own.\n" +"#BT_RADAR_SCALE=100\n" +"\n" +"# Where the map sits:\n" +"# CENTER bottom centre, under the viewscreen, as the pod had it\n" +"# (default; BOTTOM and CENTRE mean the same)\n" +"# LEFT bottom left corner (or BOTTOMLEFT)\n" +"# RIGHT bottom right corner (or BOTTOMRIGHT)\n" +"# MIDLEFT left edge, halfway up (or LEFTCENTER)\n" +"# MIDRIGHT right edge, halfway up (or RIGHTCENTER)\n" +"# Anywhere but CENTER stops it sitting where the road is. In a bottom corner\n" +"# the lower MFD whose corner it takes slides inboard beside it; halfway up a\n" +"# side it leaves the bottom row entirely.\n" +"#BT_RADAR_POS=CENTER\n" +"\n" +"# Phosphor colour of the mono MFDs, RRGGBB. Default is a green tube.\n" +"#BT_COCKPIT_TINT=21FF42\n" +"\n" +"# ---- Display ----------------------------------------------------------------\n" +"\n" +"# The cockpit is fitted into the window at ONE uniform scale, centred, with\n" +"# the leftover black -- drag the window to any shape and nothing distorts.\n" +"# Launch with -fit (or -windowed-fullscreen) for a borderless window over the\n" +"# whole monitor.\n" +"\n" +"# Simulation/render frame rate, integer frames per second. The desktop\n" +"# default is 60; the arcade pods shipped at 25.\n" +"#TARGETFPS=60\n" +"\n" +"# Anti-aliasing sample count, passed straight to Direct3D 9: 0 = off, else\n" +"# 2..16 as the GPU supports. NOTE: turning this on disables the letterbox\n" +"# fit above (the two use incompatible swap modes) and the cockpit stretches\n" +"# to the window instead.\n" +"#MULTISAMPLE=0\n" +"\n" +"# Particle budget, integer. Default 8192.\n" +"#MAXPARTICLES=8192\n" +"\n" +"# ---- Optional ---------------------------------------------------------------\n" +"\n" +"# RGB keyboard lamp mirror (Windows Dynamic Lighting): keys bound to a lamp\n" +"# button in bindings.txt glow with the panel -- yellow for the map's side\n" +"# columns, red for the rest -- and flash in step with the on-screen buttons.\n" +"# Per-key keyboards light each bound key; zone-lit boards mirror the\n" +"# strongest lamp board-wide. Unset or nonzero = on; 0 = off. A machine\n" +"# without Dynamic Lighting logs once and stays dormant.\n" +"#BT_KEYLIGHT=0\n" +"\n" +"# ---- Multiplayer ------------------------------------------------------------\n" +"\n" +"# 1 = Steam networking (lobbies, FakeIP mesh). Needs the Steam client\n" +"# running; without it the game logs the reason and falls back to Winsock.\n" +"#BT_STEAM_NET=1\n"; + +static void BTWriteDefaultEnvironIni(void) +{ + FILE *probe; + if (fopen_s(&probe, "environ.ini", "r") == 0) // already there -- leave it alone + { + fclose(probe); + return; + } + FILE *out; + if (fopen_s(&out, "environ.ini", "w") != 0) + return; + fputs(kEnvironIniDefault, out); + fclose(out); + std::cout << "[boot] wrote a default content\\environ.ini (all options " + "commented out -- edit to taste)" << std::endl << std::flush; +} + +static void BTLoadEnvironIni(void) +{ + BTWriteDefaultEnvironIni(); + + FILE *file; + if (fopen_s(&file, "environ.ini", "r") != 0) + return; + + char line[1024]; + int applied = 0, skipped = 0; + while (fgets(line, sizeof(line), file)) + { + // strip EOL + trailing blanks + int n = (int)strlen(line); + while (n > 0 && (line[n-1] == '\n' || line[n-1] == '\r' || + line[n-1] == ' ' || line[n-1] == '\t')) + line[--n] = 0; + + const char *p = line; + while (*p == ' ' || *p == '\t') ++p; + if (*p == 0 || *p == '#' || *p == ';') // blank or comment + continue; + + const char *eq = strchr(p, '='); + if (eq == NULL || eq == p) // no key, or no '=' at all + { + ++skipped; + continue; + } + + // The real environment wins: only set what is not already set, so a + // launcher or a shell export beats the file. + char key[256]; + size_t klen = (size_t)(eq - p); + if (klen >= sizeof(key)) { ++skipped; continue; } + memcpy(key, p, klen); + key[klen] = 0; + while (klen > 0 && (key[klen-1] == ' ' || key[klen-1] == '\t')) + key[--klen] = 0; + if (getenv(key) != NULL) + continue; + + putenv(p); + ++applied; + } + fclose(file); + + std::cout << "[boot] environ.ini: " << applied << " setting(s) applied" + << (skipped ? ", " : "") << (skipped ? std::to_string(skipped) : std::string()) + << (skipped ? " line(s) ignored" : "") << std::endl << std::flush; +} + int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd) { // Boot tick for the relaunch storm damper (btl4console.cpp, issue #33): @@ -279,6 +475,10 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine << (getenv("BT_FE_LOOP") ? " (relaunched generation)" : " (first process)") << std::endl << std::flush; + // The player's settings, before ANYTHING reads the environment (the + // platform profile, the joystick wizard, the layout resolver...). + BTLoadEnvironIni(); + // BT_JOYCONFIG=1: the generic-joystick capture wizard (flight sticks / // HOTAS / pedals -- L4JOY.h). Console prompts detect which device/axis // the player moves for each pod control and write the joystick section @@ -490,21 +690,12 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine if (getenv("BT_DEV_GAUGES") == NULL) putenv("BT_DEV_GAUGES=1"); if (getenv("L4PLASMA") == NULL) putenv("L4PLASMA=SCREEN"); - // DEFAULT LAYOUT = the COCKPIT SURROUND (single window: the six gauge - // surfaces + clickable button lamps composited AROUND the centered 3D - // view). Opt out with BT_COCKPIT=0 (dock-bottom strip) or - // BT_GLASS_PANELS=1 (the per-display windows). When cockpit is the - // default the buttons live IN the main window, so no separate pad/panel - // window is created (leave BT_PAD_PANEL / BT_GLASS_PANELS unset). - int cockpitDefault = 1; - if (getenv("BT_COCKPIT") && getenv("BT_COCKPIT")[0] == '0') cockpitDefault = 0; - if (getenv("BT_GLASS_PANELS") && getenv("BT_GLASS_PANELS")[0] != '0') cockpitDefault = 0; - if (getenv("BT_DEV_GAUGES_WINDOW") || getenv("BT_DEV_GAUGES_DOCK")) cockpitDefault = 0; - if (!cockpitDefault) - { - if (getenv("BT_PAD_PANEL") == NULL) putenv("BT_PAD_PANEL=1"); - if (getenv("BT_GLASS_PANELS") == NULL) putenv("BT_GLASS_PANELS=1"); - } + // The SECONDARY-DISPLAY LAYOUT (surround / per-display windows / docked + // strip / separate window) is resolved once, below, after the shared + // defaults land -- it used to be half-decided here, which is how + // BT_COCKPIT=0 came to mean "per-display windows" despite being + // documented as the dock-bottom opt-out (the dock was unreachable under + // glass). This block now only sets the profile's env presets. } #endif else @@ -531,12 +722,64 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine if (getenv("BT_DEV_GAUGES") != NULL && getenv("L4GAUGE") == NULL) putenv("L4GAUGE=640x480x16"); + // ------------------------------------------------------------------------- + // SECONDARY-DISPLAY LAYOUT -- resolved ONCE, here, now that every profile + // putenv has landed. It used to be decided TWICE (the glass profile block + // picked BT_PAD_PANEL/BT_GLASS_PANELS; the window-sizing block further down + // re-derived cockpit-vs-dock from the same env with its own copy of the + // precedence) and the boot banner read NEITHER -- it announced "per-display + // cockpit windows" for every glass boot, including the surround default. + // The split also broke BT_COCKPIT=0: documented as the dock-bottom opt-out, + // it actually landed on the per-display windows, leaving the docked strip + // unreachable under the glass profile. + // + // One resolver, one answer, consumed by the banner, the pad-panel decision + // and the sizing block. Precedence: + // BT_GLASS_PANELS!=0 per-display cockpit windows (explicit only) + // BT_DEV_GAUGES_WINDOW the legacy separate MFD window + // BT_DEV_GAUGES_DOCK the docked bottom strip + // BT_COCKPIT=0 ...also the docked strip (the documented opt-out) + // (nothing) COCKPIT SURROUND -- the glass default + // ------------------------------------------------------------------------- + enum BTGlassLayout { GlassLayoutNone, GlassLayoutCockpit, GlassLayoutPanels, + GlassLayoutDock, GlassLayoutWindow }; + int glassLayout = GlassLayoutNone; + if (!fe_menu_mode && getenv("BT_DEV_GAUGES") != NULL) + { + int panels = 0; +#ifdef BT_GLASS + { extern int BTGlassPanelsActive(); panels = BTGlassPanelsActive(); } +#endif + const char *ck = getenv("BT_COCKPIT"); + if (panels) glassLayout = GlassLayoutPanels; + else if (getenv("BT_DEV_GAUGES_WINDOW")) glassLayout = GlassLayoutWindow; + else if (getenv("BT_DEV_GAUGES_DOCK")) glassLayout = GlassLayoutDock; + else if (ck != NULL && ck[0] == '0') glassLayout = GlassLayoutDock; + else glassLayout = GlassLayoutCockpit; + + // The surround and the per-display windows carry their own buttons; the + // dock strip and the separate MFD window don't, so the single combined + // pad panel supplies them (else the whole 72-button field is unclickable). + if ((glassLayout == GlassLayoutDock || glassLayout == GlassLayoutWindow) + && getenv("BT_PAD_PANEL") == NULL) + putenv("BT_PAD_PANEL=1"); + } + static const char *kGlassLayoutName[] = { + "no dev gauges", + "cockpit surround", + "per-display cockpit windows [BT_GLASS_PANELS]", + "docked gauge strip", + "separate MFD window" + }; + std::cout << "[boot] platform profile: " << (fe_menu_mode ? "MENU (front end -- no profile applied)" : gBTPlatformPod ? "POD (RIO cockpit input; multi-surface gauges/MFDs via pod hardware or explicit L4GAUGE)" - : gBTPlatformGlass ? "GLASS (PadRIO + per-display cockpit windows [BT_GLASS_PANELS] + plasma window)" - : "DEV (single window + keyboard)") - << std::endl << std::flush; + : gBTPlatformGlass ? "GLASS (PadRIO + plasma window)" + : "DEV (single window + keyboard)"); + if (!fe_menu_mode) + std::cout << " [secondary displays: " << kGlassLayoutName[glassLayout] << "]"; + std::cout << std::endl << std::flush; #ifdef BT_GLASS // @@ -656,22 +899,7 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine } if (getenv("BT_FORCE_FIRE") != NULL) gBTDrive.fireForced = 1; - // Optional environ.ini overrides (same convention as RP). - FILE *file; - char line[1024]; - if (fopen_s(&file, "environ.ini", "r") == 0) - { - while (!feof(file)) - { - if (fgets(line, sizeof(line), file)) - { - for (int i = (int)strlen(line); i >= 0; i--) - if (line[i] == '\n' || line[i] == '\r') line[i] = 0; - putenv(line); - } - } - fclose(file); - } + // (environ.ini is loaded much earlier now -- see BTLoadEnvironIni.) // Version scheme: 4.10 = the 1995 arcade release; 4.11 = this win32 // reconstruction; build = git commit count, hash pins exact source @@ -783,25 +1011,11 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine extern int gBTCockpitCanvasW, gBTCockpitCanvasH; extern int BTGaugeStripHeightFor(int width); extern void BTCockpitCanvasFor(int viewW, int viewH, int *cw, int *ch); - // BT_GLASS_PANELS breaks the gauges into their own per-display windows, so - // the world window keeps its normal size (no cockpit / bottom strip). - int glassOwnsGauges = 0; -#ifdef BT_GLASS - { extern int BTGlassPanelsActive(); glassOwnsGauges = BTGlassPanelsActive(); } -#endif - if (!glassOwnsGauges && getenv("BT_DEV_GAUGES") != 0) + // The mode came from the ONE resolver above (glassLayout) -- BT_GLASS_PANELS + // keeps the world window at its normal size (the per-display windows own the + // gauges: no cockpit surround, no bottom strip). { - // Mode resolution (precedence): explicit BT_COCKPIT > separate-window / - // legacy-inset opt-out > COCKPIT SURROUND default under BT_DEV_GAUGES. - int cockpitOn; - const char *ck = getenv("BT_COCKPIT"); - if (ck != NULL) - cockpitOn = (ck[0] != '0'); - else - cockpitOn = (getenv("BT_DEV_GAUGES_WINDOW") == 0 && - getenv("BT_DEV_GAUGES_DOCK") == 0); - - if (cockpitOn) + if (glassLayout == GlassLayoutCockpit) { gBTGaugeCockpit = 1; // -res W H = the WORLD VIEW size in cockpit mode; default 900x500. @@ -822,10 +1036,24 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine winW = cw; winH = ch; gBTCockpitCanvasW = winW; // windowed backbuffer = client canvas gBTCockpitCanvasH = winH; + // + // LETTERBOX INTENT, decided HERE rather than at device creation: + // the first WM_SIZE arrives before the device exists, and the + // world-aspect calc needs to know whether the canvas will be + // scaled uniformly (aspect = the view's own) or stretched to + // the client (aspect = the stretched one). Deciding it late + // gave a -fit boot aspect=3.14 on an ultrawide -- applied on + // the first frame, since nothing resizes the window again. + // L4VIDEO clears the flag if it cannot actually get COPY. + { + extern int gBTCockpitLetterbox; + const char *ms = getenv("MULTISAMPLE"); + gBTCockpitLetterbox = (ms == NULL || atoi(ms) == 0) ? 1 : 0; + } std::cout << "[cockpit] view " << viewW << "x" << viewH << " canvas " << winW << "x" << winH << std::endl << std::flush; } - else if (getenv("BT_DEV_GAUGES_WINDOW") == 0) + else if (glassLayout == GlassLayoutDock) { gBTGaugeDockBottom = 1; // Readability default (user-reported: the strip at 800 wide is a @@ -843,9 +1071,44 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine } } } + // + // -fit (alias -windowed-fullscreen): borderless over the whole monitor. + // The cockpit canvas letterboxes inside it at one uniform scale, so a + // wider-than-canvas monitor gets black bars rather than a stretch -- the + // same deal a dragged window gets, just without the chrome. Only useful + // with a layout that HAS a canvas to fit; other modes keep their window. + // + int fitDisplay = 0; + if (lpCmdLine && (strstr(lpCmdLine, "-fit") != 0 || + strstr(lpCmdLine, "-windowed-fullscreen") != 0)) + fitDisplay = 1; + RECT wr = { 0, 0, winW, winH }; AdjustWindowRect(&wr, WS_OVERLAPPEDWINDOW, FALSE); + DWORD winStyle = WS_OVERLAPPEDWINDOW; + int winX = 0, winY = 0; + if (fitDisplay) + { + RECT mon = { 0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN) }; + MONITORINFO mi; + memset(&mi, 0, sizeof(mi)); + mi.cbSize = sizeof(mi); + POINT origin = { 0, 0 }; + HMONITOR hmon = MonitorFromPoint(origin, MONITOR_DEFAULTTOPRIMARY); + if (GetMonitorInfo(hmon, &mi)) + mon = mi.rcMonitor; + winStyle = WS_POPUP | WS_CLIPCHILDREN; + winX = mon.left; + winY = mon.top; + wr.left = 0; wr.top = 0; + wr.right = mon.right - mon.left; + wr.bottom = mon.bottom - mon.top; + std::cout << "[cockpit] -fit: borderless " << (wr.right - wr.left) << "x" + << (wr.bottom - wr.top) << " (canvas letterboxes inside)" + << std::endl << std::flush; + } + // // Window identity (MP dev): tag the title with the -net port so a player // running two nodes side-by-side can tell the windows apart when @@ -863,8 +1126,8 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine else swprintf(winTitle, 64, L"BattleTech %S", BT_VERSION_STRING); } - hWnd = CreateWindowEx(0, L"MainWndClass", winTitle, WS_OVERLAPPEDWINDOW, - 0, 0, wr.right - wr.left, wr.bottom - wr.top, + hWnd = CreateWindowEx(0, L"MainWndClass", winTitle, winStyle, + winX, winY, wr.right - wr.left, wr.bottom - wr.top, (HWND)NULL, (HMENU)NULL, hInstance, (LPVOID)NULL); if (!hWnd) return FALSE; diff --git a/scratchpad/checkbank.py b/scratchpad/checkbank.py new file mode 100644 index 0000000..72e26c0 --- /dev/null +++ b/scratchpad/checkbank.py @@ -0,0 +1,111 @@ +"""Verify the RIO button field from a BT_RIOBANK_LOG=1 run. + +The banks now reach UNDER the glass and deliberately overlap (the map's foot +row sits inside both side columns' reach), so "the address is in the list" is +not enough -- the hit test takes the FIRST match, so an address whose rect is +entirely shadowed by earlier buttons is dead however big it looks. + +Reads btl4.log, groups by bank tag, and for each button asks: is there any +point in my rect that no EARLIER button in this bank covers? Reports the +address census per bank and any shadowed address. + + python checkbank.py [--expect 0x00-0x47] +""" +import re +import sys +from collections import OrderedDict + +BANK_RE = re.compile(r"\[riobank\] (.+?) bounds=\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\) (\d+) buttons") +BTN_RE = re.compile(r"\[riobank\] addr=0x([0-9a-f]+) rect=\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\) class=(\d+)") + + +def parse(path): + banks = OrderedDict() + current = None + with open(path, "r", errors="replace") as handle: + for line in handle: + match = BANK_RE.search(line) + if match: + current = match.group(1) + # a re-run appends; keep the FIRST pass of each tag + banks.setdefault(current, []) + if banks[current]: + current = None # already captured this tag + continue + match = BTN_RE.search(line) + if match and current is not None: + addr = int(match.group(1), 16) + x, y, w, h = (int(match.group(i)) for i in range(2, 6)) + banks[current].append((addr, x, y, w, h, int(match.group(6)))) + return banks + + +def reachable(rect, earlier): + """Any point of `rect` not covered by an earlier rect? + + Sampled on the coordinate grid induced by the earlier rects' edges -- if a + gap exists at all, one of these sample points lands in it. + """ + _, x, y, w, h = rect[:5] + xs = {x, x + w - 1} + ys = {y, y + h - 1} + for _, ex, ey, ew, eh, _ in earlier: + for candidate in (ex - 1, ex, ex + ew - 1, ex + ew): + if x <= candidate < x + w: + xs.add(candidate) + for candidate in (ey - 1, ey, ey + eh - 1, ey + eh): + if y <= candidate < y + h: + ys.add(candidate) + for px in sorted(xs): + for py in sorted(ys): + hit = None + for button in earlier: + _, ex, ey, ew, eh, _ = button + if ex <= px < ex + ew and ey <= py < ey + eh: + hit = button + break + if hit is None: + return True + return False + + +def main(): + path = sys.argv[1] if len(sys.argv) > 1 else "btl4.log" + banks = parse(path) + if not banks: + print("no [riobank] lines -- was BT_RIOBANK_LOG=1 set?") + return 1 + + seen = set() + failures = [] + for tag, buttons in banks.items(): + addresses = [b[0] for b in buttons] + duplicates = {a for a in addresses if addresses.count(a) > 1} + shadowed = [] + for index, button in enumerate(buttons): + if not reachable(button, buttons[:index]): + shadowed.append(button[0]) + seen.update(addresses) + status = "ok" + if duplicates or shadowed: + status = "FAIL" + failures.append(tag) + print("%-22s %2d buttons 0x%02x-0x%02x %s" + % (tag, len(buttons), min(addresses), max(addresses), status)) + if duplicates: + print(" duplicate addresses: %s" + % ", ".join("0x%02x" % a for a in sorted(duplicates))) + if shadowed: + print(" SHADOWED (no first-hit point): %s" + % ", ".join("0x%02x" % a for a in sorted(shadowed))) + + print("\n%d distinct addresses across %d banks" % (len(seen), len(banks))) + missing = [a for a in range(0x00, 0x48) if a not in seen] + if missing: + print("not placed by any bank: %s" + % ", ".join("0x%02x" % a for a in missing)) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scratchpad/clickbank.py b/scratchpad/clickbank.py new file mode 100644 index 0000000..c0d6fb6 --- /dev/null +++ b/scratchpad/clickbank.py @@ -0,0 +1,161 @@ +"""Click every RIO button the layout claims, and check each one dispatched. + +Reads a BT_RIOBANK_LOG=1 dump for the rects, posts a real WM_LBUTTONDOWN/UP at +each button's centre, then compares the addresses the game LOGGED against the +addresses the layout placed. This is the end-to-end check the geometry dump +alone cannot give: it proves the hit test, the coordinate mapping and the RIO +dispatch agree with the rects. + + python clickbank.py [--mode surround|exploded] + +surround: one window, buttons are in backbuffer canvas space (== client space + for a windowed run), so the centres go straight to the main window. +exploded: one window per bank, each with its own client space; the bank tag + names the window. +""" +import ctypes +import re +import sys +import time +from ctypes import wintypes + +user32 = ctypes.WinDLL("user32", use_last_error=True) + +WM_LBUTTONDOWN = 0x0201 +WM_LBUTTONUP = 0x0202 +MK_LBUTTON = 0x0001 + +BANK_RE = re.compile(r"\[riobank\] (.+?) bounds=") +BTN_RE = re.compile(r"\[riobank\] addr=0x([0-9a-f]+) rect=\((-?\d+),(-?\d+),(-?\d+),(-?\d+)\)") + + +def parse(path): + banks, current, done = {}, None, set() + with open(path, "r", errors="replace") as handle: + for line in handle: + match = BANK_RE.search(line) + if match: + current = match.group(1) + if current in done: + current = None + else: + done.add(current) + banks[current] = [] + continue + match = BTN_RE.search(line) + if match and current is not None: + addr = int(match.group(1), 16) + x, y, w, h = (int(match.group(i)) for i in range(2, 6)) + banks[current].append((addr, x + w // 2, y + h // 2)) + return banks + + +def find_windows(substring): + found = [] + + @ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + def callback(hwnd, _): + length = user32.GetWindowTextLengthW(hwnd) + if length: + buffer = ctypes.create_unicode_buffer(length + 1) + user32.GetWindowTextW(hwnd, buffer, length + 1) + if substring.lower() in buffer.value.lower() and user32.IsWindowVisible(hwnd): + found.append((hwnd, buffer.value)) + return True + + user32.EnumWindows(callback, 0) + return found + + +class RECT(ctypes.Structure): + _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long), + ("right", ctypes.c_long), ("bottom", ctypes.c_long)] + + +def canvas_size(path): + """The cockpit canvas from the boot line, e.g. '[cockpit] view 900x500 canvas 1452x1059'.""" + with open(path, "r", errors="replace") as handle: + for line in handle: + match = re.search(r"\[cockpit\] view \d+x\d+ canvas (\d+)x(\d+)", line) + if match: + return int(match.group(1)), int(match.group(2)) + return None + + +def fit_rect(client_w, client_h, canvas_w, canvas_h): + """Same uniform-scale centred fit the game computes (BTCockpitFitRect).""" + w = client_w + h = client_w * canvas_h // canvas_w + if h > client_h: + h = client_h + w = client_h * canvas_w // canvas_h + return ((client_w - w) // 2, (client_h - h) // 2, max(w, 1), max(h, 1)) + + +def click(hwnd, x, y): + lparam = (y << 16) | (x & 0xFFFF) + user32.PostMessageW(hwnd, WM_LBUTTONDOWN, MK_LBUTTON, lparam) + time.sleep(0.02) + user32.PostMessageW(hwnd, WM_LBUTTONUP, 0, lparam) + time.sleep(0.02) + + +def main(): + log = sys.argv[1] + title = sys.argv[2] + mode = "surround" + if "--mode" in sys.argv: + mode = sys.argv[sys.argv.index("--mode") + 1] + + banks = parse(log) + if not banks: + print("no [riobank] lines in", log) + return 1 + + # exploded: each bank is its OWN window with its own caption, so match + # across every visible window rather than filtering by one title + windows = find_windows("" if mode == "exploded" else title) + if not windows: + print("no window matching", title) + return 1 + + # surround: the dump is in CANVAS space; if the window has been resized the + # canvas is letterboxed inside the client, so map through the same fit the + # game uses or every click lands off its button + transform = None + if mode == "surround": + canvas = canvas_size(log) + client = RECT() + user32.GetClientRect(windows[0][0], ctypes.byref(client)) + if canvas and client.right > 0 and client.bottom > 0: + ox, oy, fw, fh = fit_rect(client.right, client.bottom, canvas[0], canvas[1]) + if (fw, fh) != canvas: + transform = (ox, oy, fw, fh, canvas[0], canvas[1]) + print("client %dx%d, canvas %dx%d -> fit %dx%d at (%d,%d)" + % (client.right, client.bottom, canvas[0], canvas[1], fw, fh, ox, oy)) + + posted = set() + for tag, buttons in banks.items(): + if mode == "surround": + target = windows[0][0] + else: + # the bank tag is the window caption + match = [h for h, caption in windows if caption.strip() == tag.strip()] + if not match: + print(" no window for bank %r -- skipped" % tag) + continue + target = match[0] + for addr, cx, cy in buttons: + if transform is not None: + ox, oy, fw, fh, cw, ch = transform + cx = ox + cx * fw // cw + cy = oy + cy * fh // ch + click(target, cx, cy) + posted.add(addr) + print("posted clicks for %d addresses" % len(posted)) + print(" ".join("0x%02x" % a for a in sorted(posted))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scratchpad/measurelegend.py b/scratchpad/measurelegend.py new file mode 100644 index 0000000..d009b6a --- /dev/null +++ b/scratchpad/measurelegend.py @@ -0,0 +1,91 @@ +"""Measure the map's PAINTED legend grid, and compare the lamp bars against it. + +The side button columns should line up with the legend cells the map imagery +paints beside them. RP412 measured ITS map at 13 + 6x102 on a 105 pitch of 640 +-- a different game's art, so those numbers do not transfer. This finds ours. + +Two scans over a native Secondary/Radar capture: + * the LEGEND: horizontal rules of the six bordered cells in the map imagery + * the LAMPS: the runs of the yellow button strip at the window's left edge +and reports the offset between them, which is what a misalignment looks like. + + py measurelegend.py +""" +import sys +from PIL import Image + + +def main(): + path = sys.argv[1] + caption = int(sys.argv[2]) if len(sys.argv) > 2 else 30 + lamp_x = int(sys.argv[3]) if len(sys.argv) > 3 else 12 + lx0 = int(sys.argv[4]) if len(sys.argv) > 4 else 22 + lx1 = int(sys.argv[5]) if len(sys.argv) > 5 else 118 + + image = Image.open(path).convert("RGB") + w, h = image.size + px = image.load() + print("capture %dx%d (client starts at y=%d)" % (w, h, caption)) + + # --- the legend: rows carrying a long horizontal ORANGE rule ------------- + def orange(p): + return p[0] > 110 and p[1] > 45 and p[2] < 90 and p[0] > p[2] + 60 + + rules = [] + for y in range(caption, h): + run = 0 + best = 0 + for x in range(lx0, min(lx1, w)): + if orange(px[x, y]): + run += 1 + best = max(best, run) + else: + run = 0 + if best >= (lx1 - lx0) * 0.55: + rules.append(y - caption) + + # collapse adjacent rows into single rules + grouped = [] + for y in rules: + if grouped and y - grouped[-1][-1] <= 2: + grouped[-1].append(y) + else: + grouped.append([y]) + rule_tops = [g[0] for g in grouped] + print("\nlegend rules (client y): %s" % rule_tops) + if len(rule_tops) >= 2: + print("gaps: %s" % [rule_tops[i+1] - rule_tops[i] + for i in range(len(rule_tops) - 1)]) + + # --- the lamps: runs of lit pixels in the button strip ------------------- + lit = [] + for y in range(caption, h): + p = px[lamp_x, y] + lit.append(p[0] > 55 and p[1] > 40 and p[2] < 90) + runs = [] + start = None + for i, on in enumerate(lit): + if on and start is None: + start = i + elif not on and start is not None: + if i - start >= 4: + runs.append((start, i - start)) + start = None + if start is not None and len(lit) - start >= 4: + runs.append((start, len(lit) - start)) + + print("\nlamp bars (client y, height): %s" % runs) + if len(runs) >= 2: + print("lamp pitch: %s" % [runs[i+1][0] - runs[i][0] + for i in range(len(runs) - 1)]) + + # --- compare ------------------------------------------------------------ + if rule_tops and runs: + print("\ncell top vs lamp top:") + for i, (top, height) in enumerate(runs[:len(rule_tops)]): + print(" lamp %d top=%4d nearest rule=%4d offset=%+d" + % (i, top, rule_tops[i], top - rule_tops[i])) + + +if __name__ == "__main__": + main()