Files
BT411/engine/MUNGA_L4/L4GLASSWIN.cpp
T
Joe DiPrimaandClaude Opus 5 654277bb7b pod: freeze the ALPHA-MR rig in environ.ini (BT_FIT, L4PLASMA=NONE)
The cart's display config was carried by a launcher .bat, so it only came up
right if the game was started one particular way.  Move it into the file the
engine already reads before anything touches the environment.

  content/environ.ini  <- scratchpad/pod/podprofile.ini, merged idempotently
                          between markers by scratchpad/pod/mergeprofile.ps1

Two gates were missing for that to be enough:

  BT_FIT=1      the env spelling of -fit, so the borderless main view does not
                depend on one launcher's command line (shortcut, scheduled
                task and autostart all have to produce the same rig)
  L4PLASMA=     NONE / OFF / 0 -> no marquee at all.  Leaving it unset does
                NOT work: the GLASS profile force-defaults it to SCREEN, which
                drops a desktop plasma window on the cab's glass.  The boot
                banner now reports the live state instead of always claiming
                "plasma window".

Also lands the bring-up engine work this depended on: monitor:<name|index>
layout binding (device-bound, not pixel-bound -- desktop rects move when a
display re-enumerates), ",bare" implying frameless, rotation-aware radar
surface sizing, BT_GAUGE_SEC_ROT accepting 0-3 (it silently forced 3 for
anything but 1), 180-degree ExpandPlaneToBGRA, and the BT_POD_CHANMAP /
BT_POD_IDENT / BT_POD_CHANTEST identification gates.

Verified on the cart, build 4.11.813, launcher carrying none of it:
  [boot] environ.ini: 9 setting(s) applied
  [boot] platform profile: GLASS (PadRIO; plasma off [L4PLASMA])
  [cockpit] -fit: borderless 800x600
  [glasswin] radar rotation 0 (none)
  ... all three surfaces on their intended \.\DISPLAYn
No [plasmawin] line in an otherwise-logging run = the ctor never ran.

KB: pod-hardware.md gains the ALPHA-MR section (mapping, the two wiring
deviations, the frozen profile, the session-0 remote-work traps) and its RGB
SPLIT "OPEN: which way is the cart wired" is now SETTLED -- it is splitter
wired, the composite is what lit it.  glass-cockpit.md documents monitor:
binding, BT_FIT and L4PLASMA=NONE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw7No5wLTpkaUgA3ANbtZZ
2026-08-06 17:40:39 -05:00

1802 lines
62 KiB
C++

#include "mungal4.h"
#pragma hdrstop
//###########################################################################
// L4GLASSWIN -- per-display glass cockpit windows. Design + layout rules:
// l4glasswin.h. Each pod secondary display is its OWN GDI window carrying
// the display surface (CPU-blit from the shared gauge buffer) with its RIO
// button bank placed around it; a Flight Controls window hosts the
// no-display banks. Modeled on L4PADPANEL (GDI cells + lamp decode) and
// L4PLASMAWIN (CPU pixelBuffer -> StretchDIBits), so no D3D is involved.
//###########################################################################
#include "l4glasswin.h"
#include "l4padrio.h"
#include "l4vb16.h"
#include "l4riobank.h" // the shared button-bank geometry (with the surround)
#include "../munga/gaugrend.h"
#include <windows.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
// The main game window (for pod-faithful placement) is reached via
// BTResolveMainWindow() (l4vb16.h) -- NOT the `ghWnd` global directly, which this
// newly-added engine TU can bind as the NULL /FORCE-duplicate copy.
//###########################################################################
// Mode gate
//###########################################################################
int
BTGlassPanelsActive()
{
static int v = -1;
if (v < 0)
{
const char *e = getenv("BT_GLASS_PANELS");
v = (e != NULL && e[0] != '0') ? 1 : 0;
}
return v;
}
//###########################################################################
// 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
{
CellW = 58, // flight-controls cell (no glass behind it)
CellH = 28,
Gap = 4,
MfdSurfW = 640, MfdSurfH = 480, // mono MFD surface at native 640x480
RadarSurfW = 480, RadarSurfH = 640, // portrait secondary/radar CRT at native (rotated 640x480)
RepaintTimerId = 1,
RepaintMilliseconds = 62, // == L4PADPANEL (half the fastest lamp half-period)
MfdMonoTint = 0x0021FF42 // standard phosphor green rgb(33,255,66);
// 0x00RRGGBB, env BT_COCKPIT_TINT overrides
};
//
// The mono-MFD phosphor tint for the glass-panel windows: the standard green
// (MfdMonoTint), or BT_COCKPIT_TINT=RRGGBB (the SAME env the cockpit surround
// honours -- L4VB16.cpp CkTint). Read once. ExpandPlaneToBGRA wants the tint
// as 0x00RRGGBB (its palette path builds Red<<16 | Green<<8 | Blue), so the
// hex is used verbatim -- no R5G6B5 packing (that is the surround's D3D path).
//
static unsigned long
GlassMfdTint()
{
static unsigned long t = 0;
if (t == 0)
{
const char *e = getenv("BT_COCKPIT_TINT");
if (e != NULL && strlen(e) >= 6)
t = (unsigned long)(strtoul(e, NULL, 16) & 0x00FFFFFF);
else
t = (unsigned long)MfdMonoTint;
if (t == 0)
t = (unsigned long)MfdMonoTint; // a literal "000000" -> green, not invisible
}
return t;
}
enum ColorClass { ClrRed, ClrYellow, ClrBlue };
struct GButton
{
int address;
ColorClass color;
RECT rect; // client-space
};
struct GWin
{
const char *title;
HWND hwnd;
// Surface (portPrimary == NULL -> no surface, e.g. Flight Controls).
const char *portPrimary;
const char *portAlt; // the Eng<n> preset plane sibling, or NULL
int monoTint; // -1 = palette-expand (radar); else BGRA mono tint
int rotate; // 0 / 1 / 3 (portrait secondary)
RECT surfaceRect;
GButton buttons[24];
int buttonCount;
int showLabels; // draw the hex/name on each button? (Flight Controls only)
int clientW, clientH;
int frameW, frameH;
int wantX, wantY;
int restored; // wantX/wantY came from glass_layout.cfg -> don't re-snap
int noFrame; // ",noframe" in the cfg -> WS_POPUP, drag by the surface
// RGB CHANNEL GROUP (BT_POD_RGB): on splitter-wired pod glass this window
// is one VGA PORT feeding up to three mono monitors through R/G/B. These
// are the OTHER surfaces sharing the port; each is composited into its own
// channel by BlitSurface. groupCount 0 = ordinary single-surface window.
const char *groupPort[3];
const char *groupAlt[3]; // the Eng<n> twin, or NULL
int groupCount;
char monitorName[40]; // the PHYSICAL monitor this window landed on
// (\.\DISPLAYn), stamped at creation; shown
// by BT_POD_IDENT so the cab can be mapped.
};
static GWin gWins[8];
static int gWinCount = 0;
static int pressedAddress = -1;
static unsigned char latched[128];
static int gRadarRot = 3; // BT_GAUGE_SEC_ROT (default CW, upright)
static bool sRepositionedToMain = false;
static unsigned long *gStage = NULL; // 640*480 BGRA staging (shared, main-thread)
static unsigned long *gStage2 = NULL; // RGB composite scratch (BT_POD_RGB)
static HFONT titleFont = NULL;
static HFONT buttonFont = NULL;
static HFONT subFont = NULL;
//###########################################################################
// Named handle/joystick buttons (the Flight Controls window)
//###########################################################################
struct NamedButton { int address; const char *name; };
static const NamedButton namedButtons[] =
{
{ 0x3D, "Panic" }, { 0x3F, "Throttle" },
{ 0x40, "Main" }, { 0x41, "Look Bk" }, { 0x42, "Torso Ctr" },
{ 0x43, "Look R" }, { 0x44, "Look L" }, { 0x45, "Pinky" },
{ 0x46, "Middle"}, { 0x47, "Upper" },
};
enum { NamedButtonCount = sizeof(namedButtons) / sizeof(namedButtons[0]) };
static const char *
PhysicalName(int address)
{
for (int i = 0; i < NamedButtonCount; ++i)
if (namedButtons[i].address == address)
return namedButtons[i].name;
return NULL;
}
//###########################################################################
// 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)
{
return BTLampBrightnessOf(state, tick);
}
//###########################################################################
// Layout builders
//###########################################################################
// (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
AdoptBank(GWin &w, const BTRioBank &bank, ColorClass color, int dx, int dy)
{
for (int i = 0; i < bank.buttonCount &&
w.buttonCount < (int)(sizeof(w.buttons) / sizeof(w.buttons[0])); ++i)
{
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
: (src.colorClass == 0) ? ClrRed // 0 = red (MFD, and the red Panic/Eject 0x3D)
: 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;
}
}
// 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)
{
memset(&w, 0, sizeof(w));
w.title = title;
w.portPrimary = portP;
w.portAlt = portA;
w.monoTint = (int)GlassMfdTint(); // standard green or BT_COCKPIT_TINT
w.rotate = 0;
BTRioBankMetrics metrics;
BTRioBankMetricsFor(MfdSurfW, MfdSurfH, &metrics);
BTRioBank bank;
BTRioBankLayout(BTRioBankMfd, 0, 0, MfdSurfW, MfdSurfH,
bankHi, 0, &metrics, 0, &bank);
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
// of the portrait surface (0x10-0x15 left, 0x16-0x1B right).
static void
BuildRadar(GWin &w)
{
memset(&w, 0, sizeof(w));
w.title = "Secondary / Radar";
w.portPrimary = "sec";
w.portAlt = NULL;
w.monoTint = -1; // palette
w.rotate = gRadarRot;
// 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 };
// The SURFACE SIZE follows the rotation: a quarter turn (1 or 3) transposes
// the 640x480 source to 480x640 portrait -- the pod's rotated CRT -- while
// 0 (none) and 2 (180) keep it LANDSCAPE. Sizing this independently of the
// rotation squashed the picture into the wrong aspect on a landscape
// replacement panel (crash cart, 2026-08-06).
const int quarterTurn = (gRadarRot == 1 || gRadarRot == 3);
const int surfW = quarterTurn ? RadarSurfW : RadarSurfH; // 480 : 640
const int surfH = quarterTurn ? RadarSurfH : RadarSurfW; // 640 : 480
BTRioBankMetrics metrics;
BTRioBankMetricsFor(surfW, surfH, &metrics);
BTRioBank bank;
BTRioBankLayout(BTRioBankRadar, 0, 0, surfW, surfH,
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 + surfW, dy + surfH);
w.clientW = bank.boundsW;
w.clientH = bank.boundsH;
}
// The Flight Controls window: the no-display banks -- throttle/panic/door/icom
// (0x38-0x3F) and joystick/fire (0x40-0x47), two neutral columns.
static void
BuildFlight(GWin &w)
{
memset(&w, 0, sizeof(w));
w.title = "Flight Controls";
w.portPrimary = NULL;
w.showLabels = 1; // the only window that shows hex/name on its buttons
// 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;
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
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;
}
//###########################################################################
// Placement -- pod-faithful ring around the main game window
//###########################################################################
static void
WorkArea(RECT *wa)
{
if (!SystemParametersInfoW(SPI_GETWORKAREA, 0, wa, 0))
{
wa->left = 0; wa->top = 0;
wa->right = GetSystemMetrics(SM_CXSCREEN);
wa->bottom = GetSystemMetrics(SM_CYSCREEN);
}
}
static bool
GetMainRect(RECT *out)
{
HWND mh = (HWND)BTResolveMainWindow();
if (mh != NULL && IsWindow(mh) && GetWindowRect(mh, out))
return true;
// Fallback before the main window exists: assume 800x600 centered.
RECT wa; WorkArea(&wa);
int cw = 800, ch = 600;
out->left = (wa.left + wa.right - cw) / 2;
out->top = (wa.top + wa.bottom - ch) / 2;
out->right = out->left + cw;
out->bottom = out->top + ch;
return false;
}
static void
ComputeLayout()
{
RECT m; GetMainRect(&m);
RECT wa; WorkArea(&wa);
int gap = 10;
int cx = (m.left + m.right) / 2;
int cy = (m.top + m.bottom) / 2;
for (int i = 0; i < gWinCount; ++i)
{
GWin &w = gWins[i];
if (w.restored)
continue; // sticky (BT_GLASS_LAYOUT) -- keep the saved spot
int fw = w.frameW, fh = w.frameH;
int x = m.left, y = m.top;
switch (i)
{
case 0: x = m.left; y = m.top - fh - gap; break; // Heat (UL)
case 1: x = cx - fw / 2; y = m.top - fh - gap; break; // Mfd2 (UC)
case 2: x = m.right - fw; y = m.top - fh - gap; break; // Comm (UR)
case 3: x = m.left - fw - gap; y = m.bottom - fh; break; // Mfd1 (LL)
case 4: x = m.right + gap; y = m.bottom - fh; break; // Mfd3 (LR)
case 5: x = cx - fw / 2; y = m.bottom + gap; break; // Radar (center)
case 6: x = m.left - fw - gap; y = cy - fh / 2; break; // Flight (left of seat)
}
// Clamp fully on-screen (small desktops -> panels pile at the edges).
if (x + fw > wa.right) x = wa.right - fw;
if (y + fh > wa.bottom) y = wa.bottom - fh;
if (x < wa.left) x = wa.left;
if (y < wa.top) y = wa.top;
w.wantX = x;
w.wantY = y;
}
}
static void
ApplyLayout()
{
for (int i = 0; i < gWinCount; ++i)
if (gWins[i].hwnd != NULL)
SetWindowPos(gWins[i].hwnd, HWND_TOPMOST, gWins[i].wantX, gWins[i].wantY,
0, 0, SWP_NOSIZE | SWP_NOACTIVATE);
}
//###########################################################################
// Sticky layout -- BT_GLASS_LAYOUT persists each window's on-screen position
// to glass_layout.cfg (cwd-relative, beside bindings.txt), so a developer can
// drag the ring into place once and have it survive the menu->mission->menu
// relaunch loop and future launches. Modes:
// off / 0 / unset -- computed pod-faithful ring only, no file I/O (default)
// load / restore -- restore saved positions (per-window fallback to
// computed); never write
// save / adjust / -- restore first, then re-save on finished-drag
// on / 1 (WM_EXITSIZEMOVE) and on teardown [the round trip]
// Position is restored; size stays deterministic from the window's content, so
// a later geometry change can't distort an old save. Windows carry WS_CAPTION
// in every mode, so they are always draggable -- the gate only controls whether
// a drag STICKS.
//
// PER-LINE OPTION ",noframe" (2026-07-29): append it to a window's line and
// that window loses its title bar and border (WS_POPUP) -- for a clean
// multi-monitor wall where the chrome is just noise.
//
// Heat MFD=1920,0,657,539,noframe
//
// THE WORKFLOW IS DELIBERATELY TWO-STAGE, and the option is the last step:
// 1. run with BT_GLASS_LAYOUT=save and DRAG the windows where you want them
// (they have title bars, so this is the normal sticky-layout flow),
// 2. quit,
// 3. hand-edit the cfg and append ,noframe to the ones you want bare.
// From then on those windows come up frameless AND PINNED -- with no caption
// there is nothing to drag them by, which is the point: the arrangement is
// finished. Re-frame a window by deleting the flag.
//
// The one thing it must not do is eat itself: SaveLayout rewrites the WHOLE
// file (any later drag of a FRAMED window triggers it), so it writes the flag
// back for every window that carries it.
//###########################################################################
enum { LayoutOff = 0, LayoutLoad = 1, LayoutSave = 2 };
static const char *layoutFileName = "glass_layout.cfg";
// POD SURFACE MODE (BT_POD_SURFACES=1, 2026-08-06) -- the REAL pod bring-up
// shape. On the actual cab each MFD is its own 640x480 panel with PHYSICAL
// buttons and lamps around it, so the desktop chrome the glass windows draw
// (the RIO button bank + the frame) is exactly wrong there: it would paint
// fake buttons onto a panel that has real ones behind glass. In pod mode a
// window is cropped to its SURFACE, frameless, and the bank is dropped:
// one clean picture per physical panel, positioned by glass_layout.cfg.
// The Flight Controls window (no surface at all) is not created.
// BT_POD_SURFACES=1 -> every display window goes bare
// glass_layout.cfg "<title>=x,y,bare" -> just that one (mixed rigs)
// BT_POD_CHANMAP -- per-cart channel override, e.g.
// BT_POD_CHANMAP=Comm=blue,Heat=red
// The 1995 config authors which colour line each surface rides (L4GAUGE.CFG),
// but a rebuilt cab can have its RGB splitter wired to different panels than
// the original -- on Nick's cart the coolant (Heat) and hot-box (Comm) screens
// came out swapped, which is a WIRING difference, not a software bug. Rather
// than edit authentic config, override the channel here, per rig.
// Returns 0/1/2 (red/green/blue) for a named port, or -1 if unmapped.
static int
PodChannelOverride(const char *portName)
{
static int loaded = 0;
static char names[8][20];
static int chans[8];
static int count = 0;
if (!loaded)
{
loaded = 1;
const char *e = getenv("BT_POD_CHANMAP");
while (e != NULL && *e && count < 8)
{
while (*e == ' ' || *e == ',') ++e;
const char *eq = strchr(e, '=');
if (eq == NULL) break;
int n = 0;
for (const char *p = e; p < eq && n < 19; ++p) names[count][n++] = *p;
names[count][n] = 0;
const char *v = eq + 1;
int ch = -1;
if (_strnicmp(v, "red", 3) == 0) ch = L4GraphicsPort::RedChannel;
else if (_strnicmp(v, "green", 5) == 0) ch = L4GraphicsPort::GreenChannel;
else if (_strnicmp(v, "blue", 4) == 0) ch = L4GraphicsPort::BlueChannel;
if (ch >= 0)
{
chans[count] = ch;
DEBUG_STREAM << "[glasswin] channel override: " << names[count]
<< " -> " << (ch == L4GraphicsPort::RedChannel ? "red"
: ch == L4GraphicsPort::GreenChannel ? "green" : "blue")
<< std::endl << std::flush;
++count;
}
const char *nx = strchr(v, ',');
if (nx == NULL) break;
e = nx + 1;
}
}
for (int i = 0; i < count; ++i)
if (portName != NULL && _stricmp(names[i], portName) == 0)
return chans[i];
return -1;
}
// The channel a surface actually rides on THIS rig: the override if given,
// else what the pod config authored.
static int
ResolvedChannelOf(const char *portName, L4GraphicsPort *port)
{
int ov = PodChannelOverride(portName);
if (ov >= 0)
return ov;
return (port != NULL) ? (port->GetEnableID() & 0x7F) : -1;
}
// BT_POD_IDENT=1 -- draw each surface's identity ON the glass (title, monitor,
// and in RGB mode the channel->surface map). The only reliable way to learn
// which physical cab panel a Windows display drives.
static int
PodIdentMode()
{
static int m = -1;
if (m < 0)
{
const char *e = getenv("BT_POD_IDENT");
m = (e != NULL && e[0] != 0 && e[0] != '0') ? 1 : 0;
}
return m;
}
// BT_POD_CHANTEST=<seconds> -- CHANNEL WALK, the instrument for mapping
// splitter-wired glass. On a cab, three mono CRTs share one VGA port through
// its R/G/B lines, so "which panel is which" cannot be read off Windows: the
// desktop sees ONE display for three monitors. This drives ONE channel at a
// time -- all-red, then all-green, then all-blue, N seconds each, with the
// channel name drawn huge -- so the answer is simply: watch which panel lights
// up, and when. Readable even over a rough livestream.
// Returns the dwell in seconds (0 = off).
static int
PodChanTestSeconds()
{
static int s = -1;
if (s < 0)
{
const char *e = getenv("BT_POD_CHANTEST");
s = (e != NULL && e[0] != 0) ? atoi(e) : 0;
if (s < 0) s = 0;
if (e != NULL && e[0] != 0 && s == 0) s = 3; // "1"/"on" -> 3s default
}
return s;
}
// Which channel is lit right now (0=red 1=green 2=blue), from the wall clock.
static int
PodChanTestPhase()
{
int dwell = PodChanTestSeconds();
if (dwell <= 0)
return -1;
return (int)((GetTickCount() / (DWORD)(dwell * 1000)) % 3);
}
// BT_POD_RGB=1 -- the pod's REAL video wiring (see pod-hardware.md §THE RGB
// SPLIT): one VGA port per window, its R/G/B analog lines split to three
// monochrome MFD monitors. Implies pod surface mode.
static int
PodRgbSplitMode()
{
static int m = -1;
if (m < 0)
{
const char *e = getenv("BT_POD_RGB");
m = (e != NULL && e[0] != 0 && e[0] != '0') ? 1 : 0;
}
return m;
}
static int
PodSurfaceMode()
{
static int m = -1;
if (m < 0)
{
const char *e = getenv("BT_POD_SURFACES");
m = (e != NULL && *e != '\0' && *e != '0') ? 1 : 0;
if (!m && PodRgbSplitMode())
m = 1; // the RGB split is pod glass -- always bare
if (m)
DEBUG_STREAM << "[glasswin] POD SURFACE MODE: bare surfaces, no "
"button banks (physical pod buttons assumed)\n" << std::flush;
}
return m;
}
// Crop a built window to its surface: drop the bank, move the picture to the
// client origin, go frameless. Called at the end of each Build* when pod mode
// is on (and from LoadLayout for a per-window ",bare").
static void
MakeBareSurface(GWin &w)
{
if (w.portPrimary == NULL) // Flight Controls -- nothing to show
{
w.clientW = w.clientH = 0;
w.buttonCount = 0;
return;
}
const int sw = w.surfaceRect.right - w.surfaceRect.left;
const int sh = w.surfaceRect.bottom - w.surfaceRect.top;
w.buttonCount = 0; // the pod's buttons are real
SetRect(&w.surfaceRect, 0, 0, sw, sh);
w.clientW = sw;
w.clientH = sh;
w.noFrame = 1;
}
// A framed window is a normal tool window; ",noframe" makes it a bare
// WS_POPUP -- no caption, no border, nothing but the display and its buttons.
static DWORD
GlassWindowStyle(const GWin &w)
{
return w.noFrame
? (DWORD)WS_POPUP
: (DWORD)(WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX);
}
static int
GlassLayoutMode()
{
static int m = -1;
if (m < 0)
{
const char *e = getenv("BT_GLASS_LAYOUT");
if (e == NULL || e[0] == '\0')
m = LayoutOff;
else if (_stricmp(e, "off") == 0 || _stricmp(e, "0") == 0)
m = LayoutOff;
else if (_stricmp(e, "load") == 0 || _stricmp(e, "restore") == 0)
m = LayoutLoad;
else // save / adjust / on / 1 / anything else truthy
m = LayoutSave;
if (m != LayoutOff)
DEBUG_STREAM << "[glasswin] BT_GLASS_LAYOUT="
<< (m == LayoutSave ? "save" : "load") << " (" << layoutFileName
<< ")\n" << std::flush;
}
return m;
}
// Find a window by its (stable) title. Titles carry no '=', so the cfg splits
// cleanly on the first '='.
static GWin *
FindGWinByTitle(const char *title)
{
for (int i = 0; i < gWinCount; ++i)
if (gWins[i].title != NULL && strcmp(gWins[i].title, title) == 0)
return &gWins[i];
return NULL;
}
//###########################################################################
// External windows that ride glass_layout.cfg (2026-08-04). A window created
// by ANOTHER TU -- the plasma window (L4PLASMAWIN) -- can register here so it
// shares the SAME position + ",noframe" persistence as the per-display windows:
// it queries its saved rect/flag on creation, and Save() writes its line too.
// Tiny (HWND + title + last-known rect); no dynamic state.
//###########################################################################
struct ExternWin
{
HWND hwnd;
char title[64];
int noFrame;
RECT last;
int haveLast;
};
static ExternWin gExtern[4];
static int gExternCount = 0;
// Read the saved rect + ",noframe" flag for a title (glass window OR extern).
// Returns 1 if the title has a line in the cfg. rect / noframe may be NULL.
int
BTGlassLayout_QueryWindow(const char *title, RECT *rect, int *noframe)
{
if (title == NULL || GlassLayoutMode() == LayoutOff)
return 0;
FILE *f = fopen(layoutFileName, "rt");
if (f == NULL)
return 0;
int found = 0;
char line[256];
while (fgets(line, sizeof(line), f) != NULL)
{
char *s = line;
while (*s == ' ' || *s == '\t') ++s;
if (*s == '#' || *s == '\r' || *s == '\n' || *s == '\0')
continue;
char *eq = strchr(s, '=');
if (eq == NULL)
continue;
*eq = '\0';
char *end = eq;
while (end > s && (end[-1] == ' ' || end[-1] == '\t')) --end;
*end = '\0';
if (strcmp(s, title) != 0)
continue;
int x = 0, y = 0, w = 0, h = 0;
if (sscanf(eq + 1, "%d,%d,%d,%d", &x, &y, &w, &h) < 2)
continue;
int nf = 0;
for (const char *opt = strchr(eq + 1, ','); opt != NULL; opt = strchr(opt + 1, ','))
{
const char *t = opt + 1;
while (*t == ' ' || *t == '\t') ++t;
if (_strnicmp(t, "noframe", 7) == 0) { nf = 1; break; }
}
if (rect) { rect->left = x; rect->top = y; rect->right = x + w; rect->bottom = y + h; }
if (noframe) *noframe = nf;
found = 1;
break;
}
fclose(f);
return found;
}
// Register an externally-created window so Save() writes its line. Loads its
// current ",noframe" flag from the cfg so a save round-trips the flag.
void
BTGlassLayout_RegisterExtern(HWND hwnd, const char *title)
{
if (hwnd == NULL || title == NULL)
return;
if (gExternCount >= (int)(sizeof(gExtern) / sizeof(gExtern[0])))
return;
ExternWin &e = gExtern[gExternCount++];
e.hwnd = hwnd;
strncpy(e.title, title, sizeof(e.title) - 1);
e.title[sizeof(e.title) - 1] = '\0';
e.noFrame = 0;
e.haveLast = 0;
BTGlassLayout_QueryWindow(title, NULL, &e.noFrame);
}
// Restore saved positions over the just-computed pod-faithful defaults. Called
//---------------------------------------------------------------------------
// MONITOR BINDING (2026-08-06) -- "monitor:<name-or-index>" instead of x,y.
//
// Absolute desktop coordinates do not survive a reboot on a pod: Windows
// renumbers the virtual desktop whenever adapters enumerate differently (a USB
// display adapter is especially prone), so a layout pinned to 800,122 can land
// on the wrong glass -- or off-screen -- after a power cycle. Binding to the
// MONITOR instead and resolving its rect at startup is stable across boots.
// Heat MFD=monitor:\.\DISPLAY4,bare (device name -- what the receipts print)
// Heat MFD=monitor:2,bare (index into the enumeration order)
// The surface is centred on that monitor; an exact-size panel fills it.
//---------------------------------------------------------------------------
struct MonScan { int index; const char *want; int wantIndex; RECT rect; int found; };
static BOOL CALLBACK
MonScanProc(HMONITOR mon, HDC, LPRECT, LPARAM param)
{
MonScan *sc = (MonScan *)param;
MONITORINFOEXA mi;
memset(&mi, 0, sizeof(mi));
mi.cbSize = sizeof(mi);
if (GetMonitorInfoA(mon, (MONITORINFO *)&mi))
{
int hit = 0;
if (sc->want != NULL)
{
// Match the full device name OR just its tail, so a hand-written
// cfg can say `monitor:DISPLAY4` and skip the \\.\ prefix entirely
// (backslashes in a config file are a foot-gun -- every shell,
// editor and copy-paste path mangles them differently).
size_t dl = strlen(mi.szDevice), wl = strlen(sc->want);
if (_stricmp(mi.szDevice, sc->want) == 0)
hit = 1;
else if (wl > 0 && wl <= dl
&& _stricmp(mi.szDevice + (dl - wl), sc->want) == 0)
hit = 1;
}
if (!hit && sc->wantIndex >= 0 && sc->index == sc->wantIndex)
hit = 1;
if (hit && !sc->found)
{
sc->rect = mi.rcMonitor;
sc->found = 1;
}
}
++sc->index;
return TRUE;
}
// Resolve "monitor:<spec>" to a rect. Returns 1 on success.
static int
ResolveMonitorSpec(const char *spec, RECT *out)
{
while (*spec == ' ' || *spec == ' ') ++spec;
MonScan sc;
memset(&sc, 0, sizeof(sc));
sc.wantIndex = -1;
if (spec[0] >= '0' && spec[0] <= '9')
sc.wantIndex = atoi(spec);
else
sc.want = spec;
EnumDisplayMonitors(NULL, NULL, MonScanProc, (LPARAM)&sc);
if (sc.found)
*out = sc.rect;
return sc.found;
}
// from Create AFTER ComputeLayout, so any window not named in the file keeps its
// computed spot. Restored windows are flagged so the WM_TIMER re-snap (which
// re-runs ComputeLayout once the main window appears) leaves them alone.
static void
LoadLayout(int quiet)
{
if (GlassLayoutMode() == LayoutOff)
return;
FILE *f = fopen(layoutFileName, "rt");
if (f == NULL)
{
if (!quiet)
DEBUG_STREAM << "[glasswin] no " << layoutFileName
<< " yet (using computed placement)\n" << std::flush;
return;
}
int restored = 0;
char line[256];
while (fgets(line, sizeof(line), f) != NULL)
{
char *s = line;
while (*s == ' ' || *s == '\t') ++s;
if (*s == '#' || *s == '\r' || *s == '\n' || *s == '\0')
continue;
char *eq = strchr(s, '=');
if (eq == NULL)
continue;
*eq = '\0';
// trim trailing space off the title
char *end = eq;
while (end > s && (end[-1] == ' ' || end[-1] == '\t')) --end;
*end = '\0';
int x = 0, y = 0, w = 0, h = 0;
int haveXY = (sscanf(eq + 1, "%d,%d,%d,%d", &x, &y, &w, &h) >= 2);
int viaMonitor = 0;
if (!haveXY)
{
// "monitor:<name|index>" -- boot-stable binding (see the resolver).
const char *v = eq + 1;
while (*v == ' ' || *v == '\t') ++v;
if (_strnicmp(v, "monitor:", 8) == 0 || _strnicmp(v, "mon:", 4) == 0)
{
char spec[64];
const char *p2 = strchr(v, ':') + 1;
int n2 = 0;
while (*p2 && *p2 != ',' && n2 < (int)sizeof(spec) - 1) spec[n2++] = *p2++;
spec[n2] = 0;
RECT mr;
if (ResolveMonitorSpec(spec, &mr))
{
x = mr.left; y = mr.top;
w = mr.right - mr.left; h = mr.bottom - mr.top;
haveXY = 1;
viaMonitor = 1;
}
else
DEBUG_STREAM << "[glasswin] monitor '" << spec
<< "' not found -- leaving computed placement\n" << std::flush;
}
}
if (!haveXY)
continue;
// Trailing options after the numbers, comma-separated. Unknown ones
// are ignored (so an older build reading a newer file just loses the
// option, never the line) -- the bindings.txt grammar rule.
int noframe = 0, bare = 0;
for (const char *opt = strchr(eq + 1, ','); opt != NULL; opt = strchr(opt + 1, ','))
{
const char *t = opt + 1;
while (*t == ' ' || *t == '\t') ++t;
if (_strnicmp(t, "noframe", 7) == 0)
noframe = 1;
else if (_strnicmp(t, "bare", 4) == 0)
bare = 1; // pod panel: surface only (see PodSurfaceMode)
}
GWin *gw = FindGWinByTitle(s);
if (gw == NULL)
continue;
// "bare" is a pod panel: it always implies frameless. (Without this a
// cfg line saying only ",bare" silently re-framed a window that pod
// surface mode had already cropped -- caught on the bench 2026-08-06.)
gw->noFrame = (noframe || bare) ? 1 : 0;
if (bare && gw->buttonCount > 0)
MakeBareSurface(*gw); // per-window pod panel (mixed rigs)
if (viaMonitor)
{
// x,y,w,h is the MONITOR rect -- centre this surface on it (an
// exact-size pod panel fills it edge to edge).
int sw = gw->clientW > 0 ? gw->clientW : (gw->surfaceRect.right - gw->surfaceRect.left);
int sh = gw->clientH > 0 ? gw->clientH : (gw->surfaceRect.bottom - gw->surfaceRect.top);
gw->wantX = x + ((w - sw) / 2);
gw->wantY = y + ((h - sh) / 2);
DEBUG_STREAM << "[glasswin] '" << gw->title
<< "' bound to monitor " << x << "," << y << " " << w << "x" << h
<< " -> window at " << gw->wantX << "," << gw->wantY
<< std::endl << std::flush;
}
else
{
gw->wantX = x;
gw->wantY = y;
}
gw->restored = 1;
++restored;
}
fclose(f);
if (!quiet)
DEBUG_STREAM << "[glasswin] restored " << restored << " window position(s) from "
<< layoutFileName << "\n" << std::flush;
}
// Write every window's current on-screen frame rect. Whole-file rewrite (it is
// tiny), so partial/hard kills never leave a half-written file for long. Called
// on finished-drag and on teardown in save mode.
static void
SaveLayout()
{
if (GlassLayoutMode() != LayoutSave)
return;
FILE *f = fopen(layoutFileName, "wt");
if (f == NULL)
{
DEBUG_STREAM << "[glasswin] could not write " << layoutFileName
<< "\n" << std::flush;
return;
}
fputs("# BT411 glass cockpit window layout (BT_GLASS_LAYOUT=save writes this\n"
"# on finished-drag/exit; =load restores it). <title>=<x>,<y>,<w>,<h>\n"
"#\n"
"# Happy with where everything sits? Quit, then append ,noframe to any\n"
"# line to bring that window up with no title bar or border:\n"
"# Heat MFD=1920,0,657,539,noframe\n"
"# A frameless window is also PINNED (no caption = nothing to drag it\n"
"# by), so do the arranging first. Delete the flag to get the frame\n"
"# back. Saves preserve it. The 'BattleTech - Plasma' window is in\n"
"# this list too -- it can be dragged, remembered and set ,noframe.\n",
f);
int wrote = 0;
for (int i = 0; i < gWinCount; ++i)
{
GWin &gw = gWins[i];
if (gw.hwnd == NULL || !IsWindow(gw.hwnd))
continue;
RECT r;
if (!GetWindowRect(gw.hwnd, &r))
continue;
fprintf(f, "%s=%ld,%ld,%ld,%ld%s\n", gw.title,
(long)r.left, (long)r.top,
(long)(r.right - r.left), (long)(r.bottom - r.top),
gw.noFrame ? ",noframe" : ""); // keep the hand-added option
++wrote;
}
// External windows (the plasma window) ride the same file. Cache the last
// good rect so a window torn down before this save still keeps its line.
for (int i = 0; i < gExternCount; ++i)
{
ExternWin &e = gExtern[i];
RECT r;
if (e.hwnd != NULL && IsWindow(e.hwnd) && GetWindowRect(e.hwnd, &r))
{
e.last = r; e.haveLast = 1;
}
else if (e.haveLast)
{
r = e.last; // window gone -> preserve its last-known line
}
else
continue;
fprintf(f, "%s=%ld,%ld,%ld,%ld%s\n", e.title,
(long)r.left, (long)r.top,
(long)(r.right - r.left), (long)(r.bottom - r.top),
e.noFrame ? ",noframe" : "");
++wrote;
}
fclose(f);
DEBUG_STREAM << "[glasswin] saved " << wrote << " window position(s) to "
<< layoutFileName << "\n" << std::flush;
}
// Public save trigger for registered external windows -- their WndProc calls
// this on WM_EXITSIZEMOVE / teardown. No-op unless BT_GLASS_LAYOUT=save.
void
BTGlassLayout_Save()
{
SaveLayout();
}
//###########################################################################
// Painting
//###########################################################################
static void
DrawLabelText(HDC dc, const char *text, HFONT font, RECT *rect,
COLORREF color, UINT format)
{
WCHAR wide[32];
int n = 0;
for (const char *s = text; *s && n < 31; ++s)
wide[n++] = (WCHAR)*s;
wide[n] = 0;
HFONT old_font = (HFONT)SelectObject(dc, font);
SetTextColor(dc, color);
DrawTextW(dc, wide, -1, rect, format);
SelectObject(dc, old_font);
}
static int sGlassLog = -1;
static unsigned long sBlitCalls = 0;
static void
BlitSurface(HDC dc, GWin *w)
{
if (sGlassLog < 0)
sGlassLog = getenv("BT_GLASS_LOG") ? 1 : 0;
int log = sGlassLog && ((sBlitCalls++ % 240) == 0);
// Reach the renderer via BTResolveGaugeRenderer (l4vb16.h; DEFINED in
// game/btl4main.cpp off the real btl4App pointer), NOT the `application` global
// directly -- a fresh engine TU can bind the NULL /FORCE-duplicate copy.
GaugeRenderer *gr = BTResolveGaugeRenderer();
if (gr == NULL)
{
if (log) DEBUG_STREAM << "[glass] '" << w->title << "' gauge renderer not ready\n" << std::flush;
return;
}
L4GraphicsPort *port = static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(w->portPrimary));
if (w->portAlt != NULL)
{
L4GraphicsPort *alt = static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(w->portAlt));
// Show whichever of the Mfd<n>/Eng<n> pair is the live (non-blank) plane,
// exactly like the pod monitor + BTDrawGaugeSurfaces (Gitea #9).
bool primaryBlank = (port == NULL) ||
(port->GetEnableID() == L4GraphicsPort::BlankColor);
if (primaryBlank && alt != NULL &&
alt->GetEnableID() != L4GraphicsPort::BlankColor)
port = alt;
else if (port == NULL)
port = alt;
}
if (port == NULL)
{
if (log) DEBUG_STREAM << "[glass] '" << w->title << "' port '" << w->portPrimary
<< "' NULL (not installed)\n" << std::flush;
return;
}
SVGA16 *svga = static_cast<SVGA16*>(port->graphicsDisplay);
if (svga == NULL || gStage == NULL)
{
if (log) DEBUG_STREAM << "[glass] '" << w->title << "' svga=" << (void*)svga
<< " stage=" << (void*)gStage << "\n" << std::flush;
return;
}
// Surface expand. BT_GLASS_MFD_PAL (experiment): render the mono MFD windows
// through the palette LUT (low byte, secondary palette) instead of the 1-bit
// mono tint, to compare. NB the MFD's own graphics live in a HIGH bit-plane
// the 256-entry palette can't index, so this shows the shared low-byte content.
int mask = port->GetBitMask();
int palId = port->paletteID;
int tint = w->monoTint;
static int sMfdPal = -1;
if (sMfdPal < 0) sMfdPal = getenv("BT_GLASS_MFD_PAL") ? 1 : 0;
if (sMfdPal && tint >= 0)
{
tint = -1; // palette-expand
mask = 0xFF; // index the low (palette) byte
palId = SVGA16::SecondaryPalette;
}
// RGB mode: the base surface rides a CHANNEL, so its tint is that pure
// primary -- resolved live (override first), never the build-time guess.
if (w->groupCount > 0 && tint >= 0)
{
switch (ResolvedChannelOf(w->portPrimary, port))
{
case L4GraphicsPort::RedChannel: tint = (int)0x00FF0000; break;
case L4GraphicsPort::GreenChannel: tint = (int)0x0000FF00; break;
case L4GraphicsPort::BlueChannel: tint = (int)0x000000FF; break;
default: break;
}
}
int ow = 0, oh = 0;
svga->ExpandPlaneToBGRA(mask, palId, tint, w->rotate, gStage, &ow, &oh);
// ---------------------------------------------------------------------
// RGB CHANNEL COMPOSITE (BT_POD_RGB, 2026-08-06) -- the AUTHENTIC pod
// output. The cab does not give each mono MFD its own video port: one
// VGA port's R/G/B analog lines are SPLIT to three monochrome monitors,
// and the game puts a different surface in each colour channel of one
// palettized framebuffer (L4GAUGE.CFG: Comm=red, Mfd2=green, Heat=blue
// on clut2; Mfd1=red, Mfd3=green on clut1 -- and
// L4GraphicsPort::BuildSecondaryColor writes exactly ONE component per
// port, which IS the split). So on splitter-wired glass this window is
// not one MFD: it is one VGA PORT, carrying up to three. Composite the
// group's planes into this frame, each in its own channel, and let the
// hardware separate them again.
//
// Channel comes from the port itself (GetEnableID), so the authored
// config -- including a page swap that hands the channel to an Eng
// plane -- decides, never a hardcoded table here.
// ---------------------------------------------------------------------
if (w->groupCount > 0 && ow > 0 && oh > 0)
{
const int n = ow * oh;
for (int gi = 0; gi < w->groupCount; ++gi)
{
L4GraphicsPort *gp =
static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(w->groupPort[gi]));
if (gp == NULL)
continue;
// honour the live Mfd<n>/Eng<n> page swap: take whichever twin
// currently owns a channel
if (gp->GetEnableID() == L4GraphicsPort::BlankColor
&& w->groupAlt[gi] != NULL)
{
L4GraphicsPort *ga =
static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(w->groupAlt[gi]));
if (ga != NULL && ga->GetEnableID() != L4GraphicsPort::BlankColor)
gp = ga;
}
const int ch = ResolvedChannelOf(w->groupPort[gi], gp);
unsigned long chMask;
switch (ch)
{
case L4GraphicsPort::RedChannel: chMask = 0x00FF0000ul; break;
case L4GraphicsPort::GreenChannel: chMask = 0x0000FF00ul; break;
case L4GraphicsPort::BlueChannel: chMask = 0x000000FFul; break;
default: continue; // blank / direct-colour -> contributes nothing
}
int gw = 0, gh = 0;
// -1 tint would palette-expand; pass white so the plane comes back
// as full-intensity mono, then keep only this port's channel.
svga->ExpandPlaneToBGRA(gp->GetBitMask(), gp->paletteID,
(int)0x00FFFFFF, w->rotate, gStage2, &gw, &gh);
if (gw != ow || gh != oh)
continue;
for (int i = 0; i < n; ++i)
gStage[i] |= (gStage2[i] & chMask);
}
if (log)
{
DEBUG_STREAM << "[glass] '" << w->title << "' RGB COMPOSITE of "
<< w->groupCount << " plane(s):";
for (int gi = 0; gi < w->groupCount; ++gi)
DEBUG_STREAM << " " << w->groupPort[gi];
DEBUG_STREAM << "\n" << std::flush;
}
}
if (log)
{
int nz = 0, n = ow * oh;
for (int i = 0; i < n; ++i) if (gStage[i] != 0) nz++;
DEBUG_STREAM << "[glass] '" << w->title << "' port=" << w->portPrimary
<< " mask=0x" << std::hex << port->GetBitMask() << std::dec
<< " pal=" << port->paletteID << " enable=" << port->GetEnableID()
<< " ow=" << ow << " oh=" << oh << " nonzero=" << nz << "/" << n
<< "\n" << std::flush;
}
if (ow <= 0 || oh <= 0)
return;
BITMAPINFO info;
memset(&info, 0, sizeof(info));
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = ow;
info.bmiHeader.biHeight = -oh; // top-down
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
info.bmiHeader.biCompression = BI_RGB;
const RECT &r = w->surfaceRect;
SetStretchBltMode(dc, HALFTONE);
SetBrushOrgEx(dc, 0, 0, NULL);
StretchDIBits(dc,
r.left, r.top, r.right - r.left, r.bottom - r.top,
0, 0, ow, oh,
gStage, &info, DIB_RGB_COLORS, SRCCOPY);
}
static void
PaintGlass(HWND window, GWin *w)
{
PAINTSTRUCT paint;
HDC winDC = BeginPaint(window, &paint);
unsigned long tick = GetTickCount();
RECT client;
GetClientRect(window, &client);
// Double-buffer the whole frame (== L4PADPANEL issue #13).
HDC dc = CreateCompatibleDC(winDC);
HBITMAP backing = CreateCompatibleBitmap(winDC,
client.right - client.left, client.bottom - client.top);
HBITMAP oldBacking = (HBITMAP)SelectObject(dc, backing);
HBRUSH background = CreateSolidBrush(RGB(24, 24, 24));
FillRect(dc, &client, background);
DeleteObject(background);
SetBkMode(dc, TRANSPARENT);
// (No in-client title -- the OS window caption labels each window.)
// Buttons are drawn FIRST (UNDER the display). Each is a big clickable rect;
// the surface blit further down masks the part inside the display, leaving only
// the bit that protrudes past the display edge visible -- a solid lamp light.
// (The Flight Controls window has no surface, so its buttons show in full.)
for (int i = 0; i < w->buttonCount; ++i)
{
const GButton &b = w->buttons[i];
const RECT &r = b.rect;
int held = (b.address == pressedAddress) || latched[b.address & 0x7F];
int shade = LampBrightnessOf(PadRIO::GetLampState(b.address), tick);
if (held)
shade = 3;
COLORREF fill, text_color;
if (b.color == ClrYellow)
{
fill = (shade == 3) ? RGB(245, 210, 60)
: (shade != 0) ? RGB(140, 118, 38)
: RGB(70, 60, 24);
text_color = RGB(0, 0, 0);
}
else if (b.color == ClrBlue)
{
fill = (shade == 3) ? RGB(205, 228, 255)
: (shade != 0) ? RGB(110, 135, 180)
: RGB(70, 86, 120);
text_color = RGB(0, 0, 0);
}
else // ClrRed (MFD)
{
fill = (shade == 3) ? RGB(230, 70, 70)
: (shade != 0) ? RGB(120, 50, 50)
: RGB(64, 40, 40);
text_color = RGB(255, 255, 255);
}
// Solid lamp fill + a bright border. Under the imagery, only the protruding
// edge shows once the surface is blitted on top.
{
COLORREF bright = (b.color == ClrYellow) ? RGB(245, 210, 60)
: (b.color == ClrBlue) ? RGB(205, 228, 255)
: RGB(230, 70, 70);
HBRUSH face = CreateSolidBrush(fill);
FillRect(dc, &r, face);
DeleteObject(face);
HPEN pen = CreatePen(PS_SOLID, 1, bright);
HGDIOBJ oldPen = SelectObject(dc, pen);
HGDIOBJ oldBr = SelectObject(dc, GetStockObject(NULL_BRUSH));
Rectangle(dc, r.left, r.top, r.right, r.bottom);
SelectObject(dc, oldBr);
SelectObject(dc, oldPen);
DeleteObject(pen);
}
// Labels only on the Flight Controls window; MFD/radar buttons are bare.
if (w->showLabels)
{
char hex[8];
sprintf(hex, "%02X", b.address);
const char *name = PhysicalName(b.address);
if (name != NULL)
{
RECT top_rect = { r.left, r.top + 1, r.right, (r.top + r.bottom) / 2 + 3 };
RECT bottom_rect = { r.left, (r.top + r.bottom) / 2, r.right, r.bottom - 1 };
DrawLabelText(dc, name, buttonFont, &top_rect, text_color,
DT_CENTER | DT_TOP | DT_SINGLELINE | DT_END_ELLIPSIS);
DrawLabelText(dc, hex, subFont, &bottom_rect, text_color,
DT_CENTER | DT_BOTTOM | DT_SINGLELINE);
}
else
{
RECT cell_rect = r;
DrawLabelText(dc, hex, buttonFont, &cell_rect, text_color,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
}
}
// Latch = gold outline; momentary held = white outline (== L4PADPANEL).
if (latched[b.address & 0x7F])
{
HBRUSH gold = CreateSolidBrush(RGB(255, 215, 0));
RECT o = r;
FrameRect(dc, &o, gold);
DeleteObject(gold);
}
else if (held)
{
RECT o = r;
FrameRect(dc, &o, (HBRUSH)GetStockObject(WHITE_BRUSH));
}
}
// Surface (imagery) LAST -- ON TOP of the buttons, masking their inside so only
// the protruding lamp edges show. (No-op for the Flight Controls window.)
if (w->portPrimary != NULL)
{
BlitSurface(dc, w);
HPEN pen = CreatePen(PS_SOLID, 1, RGB(70, 70, 70));
HPEN oldPen = (HPEN)SelectObject(dc, pen);
HBRUSH oldBrush = (HBRUSH)SelectObject(dc, GetStockObject(NULL_BRUSH));
Rectangle(dc, w->surfaceRect.left - 1, w->surfaceRect.top - 1,
w->surfaceRect.right + 1, w->surfaceRect.bottom + 1);
SelectObject(dc, oldBrush);
SelectObject(dc, oldPen);
DeleteObject(pen);
// (NO full-face flash overlay here either -- same wrongness as the
// L4VB16 one, removed 2026-08-03: the buttons are big rects tucked
// under the surface by design, and the protruding edge is the lamp.)
// ---- CHANNEL WALK (BT_POD_CHANTEST) ---------------------------------
// Overwrite the whole surface with ONE channel at full intensity, so
// exactly one monitor of a splitter-fed trio lights. Cycles R->G->B.
{
const int phase = PodChanTestPhase();
if (phase >= 0)
{
static const COLORREF chColor[3] =
{ RGB(255, 0, 0), RGB(0, 255, 0), RGB(0, 0, 255) };
static const char *chName[3] = { "RED", "GREEN", "BLUE" };
HBRUSH fill = CreateSolidBrush(chColor[phase]);
FillRect(dc, &w->surfaceRect, fill);
DeleteObject(fill);
// Name the channel AND what the pod authors onto it here, so a
// lit panel identifies itself completely: "GREEN = Mfd2".
const char *who = "-";
GaugeRenderer *cgr = BTResolveGaugeRenderer();
if (cgr != NULL)
{
const char *cand[4]; int nc = 0;
cand[nc++] = w->portPrimary;
for (int gi = 0; gi < w->groupCount && nc < 4; ++gi)
cand[nc++] = w->groupPort[gi];
for (int i = 0; i < nc; ++i)
{
L4GraphicsPort *pp =
static_cast<L4GraphicsPort*>(cgr->GetGraphicsPort(cand[i]));
if (pp != NULL && (pp->GetEnableID() & 0x7F) == phase)
{ who = cand[i]; break; }
}
}
char big[96];
sprintf(big, "%s %s = %s", w->monitorName, chName[phase], who);
RECT br = w->surfaceRect;
br.top += (br.bottom - br.top) / 3;
SetBkMode(dc, TRANSPARENT);
DrawLabelText(dc, big, titleFont, &br, RGB(0, 0, 0),
DT_CENTER | DT_SINGLELINE | DT_VCENTER);
}
}
// ---- PANEL IDENTIFY (BT_POD_IDENT=1) --------------------------------
// Pod bring-up: nobody can tell which PHYSICAL panel a Windows display
// is, and desktop coordinate order says nothing about where a panel
// sits in the cab -- guessing that mapping is exactly how it came out
// wrong. So label each surface ON ITS OWN GLASS with what it is, which
// monitor it came from, and (in RGB mode) the channel->surface map.
// Walk the pod, read the panels, write the layout from what you SAW.
if (PodIdentMode())
{
char line1[96], line2[160];
sprintf(line1, "%s", w->title);
if (w->groupCount > 0)
{
// name the channels so a splitter-fed trio is self-describing
char parts[128];
parts[0] = 0;
GaugeRenderer *igr = BTResolveGaugeRenderer();
const char *names[4]; int chans[4]; int nn = 0;
names[nn] = w->portPrimary; chans[nn] = -1; ++nn;
for (int gi = 0; gi < w->groupCount && nn < 4; ++gi)
{ names[nn] = w->groupPort[gi]; chans[nn] = -1; ++nn; }
for (int i = 0; i < nn; ++i)
{
const char *chName = "?";
if (igr != NULL)
{
L4GraphicsPort *pp =
static_cast<L4GraphicsPort*>(igr->GetGraphicsPort(names[i]));
if (pp != NULL)
switch (pp->GetEnableID() & 0x7F)
{
case L4GraphicsPort::RedChannel: chName = "RED"; break;
case L4GraphicsPort::GreenChannel: chName = "GREEN"; break;
case L4GraphicsPort::BlueChannel: chName = "BLUE"; break;
case L4GraphicsPort::AllChannels: chName = "RGB"; break;
default: chName = "blank"; break;
}
}
char one[48];
sprintf(one, "%s%s=%s", (i ? " " : ""), chName, names[i]);
if (strlen(parts) + strlen(one) < sizeof(parts) - 1)
strcat(parts, one);
}
sprintf(line2, "%s %s", w->monitorName, parts);
}
else
{
sprintf(line2, "%s surface=%s", w->monitorName,
w->portPrimary ? w->portPrimary : "-");
}
RECT lr = w->surfaceRect;
lr.bottom = lr.top + 64;
HBRUSH back = CreateSolidBrush(RGB(0, 0, 0));
FillRect(dc, &lr, back);
DeleteObject(back);
SetBkMode(dc, TRANSPARENT);
RECT t1 = lr; t1.top += 4; t1.bottom = t1.top + 30;
DrawLabelText(dc, line1, titleFont, &t1, RGB(255, 255, 0),
DT_LEFT | DT_SINGLELINE | DT_VCENTER);
RECT t2 = lr; t2.top += 32; t2.bottom = t2.top + 28;
DrawLabelText(dc, line2, buttonFont, &t2, RGB(0, 255, 255),
DT_LEFT | DT_SINGLELINE | DT_VCENTER);
}
}
BitBlt(winDC, 0, 0, client.right - client.left, client.bottom - client.top,
dc, 0, 0, SRCCOPY);
SelectObject(dc, oldBacking);
DeleteObject(backing);
DeleteDC(dc);
EndPaint(window, &paint);
}
//###########################################################################
// WndProc / input
//###########################################################################
static int
HitTest(GWin *w, int x, int y)
{
for (int i = 0; i < w->buttonCount; ++i)
{
const RECT &r = w->buttons[i].rect;
if (x >= r.left && x < r.right && y >= r.top && y < r.bottom)
return w->buttons[i].address;
}
return -1;
}
static LRESULT CALLBACK
GlassWndProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam)
{
GWin *w = (GWin*)GetWindowLongPtrW(window, GWLP_USERDATA);
switch (message)
{
case WM_PAINT:
if (w != NULL) { PaintGlass(window, w); return 0; }
break;
case WM_ERASEBKGND:
return 1; // PaintGlass fills the frame (double-buffered)
case WM_TIMER:
if (wparam == RepaintTimerId)
{
// One-shot: once the real main window is up, snap the ring onto it.
HWND mh = (HWND)BTResolveMainWindow();
if (!sRepositionedToMain && mh != NULL && IsWindow(mh))
{
ComputeLayout();
ApplyLayout();
sRepositionedToMain = true;
}
InvalidateRect(window, NULL, FALSE);
}
return 0;
case WM_LBUTTONDOWN:
if (w != NULL)
{
int a = HitTest(w, (int)(short)LOWORD(lparam), (int)(short)HIWORD(lparam));
if (a >= 0)
{
// crash forensics (always-on, flushed BEFORE dispatch): if a
// click kills the process, the last log line names the button.
DEBUG_STREAM << "[glasswin] CLICK '" << (w->title ? w->title : "?")
<< "' addr=0x" << std::hex << a << std::dec
<< " at(" << (int)(short)LOWORD(lparam) << ","
<< (int)(short)HIWORD(lparam) << ")"
<< (latched[a & 0x7F] ? " unlatch" : " press")
<< "\n" << std::flush;
if (latched[a & 0x7F])
{
latched[a & 0x7F] = 0;
PadRIO::SetScreenButton(a, 0);
}
else
{
pressedAddress = a;
SetCapture(window);
PadRIO::SetScreenButton(a, 1);
}
InvalidateRect(window, NULL, FALSE);
}
}
return 0;
case WM_LBUTTONUP:
if (pressedAddress >= 0)
{
PadRIO::SetScreenButton(pressedAddress, 0);
pressedAddress = -1;
ReleaseCapture();
InvalidateRect(window, NULL, FALSE);
}
return 0;
case WM_RBUTTONDOWN:
if (w != NULL)
{
int a = HitTest(w, (int)(short)LOWORD(lparam), (int)(short)HIWORD(lparam));
if (a >= 0)
{
DEBUG_STREAM << "[glasswin] CLICK '" << (w->title ? w->title : "?")
<< "' addr=0x" << std::hex << a << std::dec
<< " latch-toggle\n" << std::flush;
latched[a & 0x7F] = latched[a & 0x7F] ? 0 : 1;
PadRIO::SetScreenButton(a, latched[a & 0x7F]);
InvalidateRect(window, NULL, FALSE);
}
}
return 0;
case WM_EXITSIZEMOVE:
// The user finished dragging (or resizing) a window. In save/adjust
// mode, persist the whole ring so the layout survives a hard kill even
// if teardown never runs. No-op in load/off mode.
SaveLayout();
return 0;
case WM_CLOSE:
ShowWindow(window, SW_HIDE); // hide; the device owns the lifetime
return 0;
}
return DefWindowProcW(window, message, wparam, lparam);
}
//###########################################################################
// Create / destroy
//###########################################################################
void
BTGlassPanels_Create()
{
if (gWinCount != 0)
return; // already up
// BT_GAUGE_SEC_ROT: how far to turn the secondary/radar surface.
// 0 = none, 1 = 90 CCW, 2 = 180, 3 = 90 CW (default -- the pod's
// portrait CRT, user-verified upright).
// 0 and 2 keep the picture LANDSCAPE, which a modern replacement panel
// needs. (Until 2026-08-06 this read only '1' and forced everything else
// to 3, so BT_GAUGE_SEC_ROT=2 was silently ignored -- caught on the cart
// when a 180 request kept coming back portrait.)
{
const char *rv = getenv("BT_GAUGE_SEC_ROT");
if (rv != NULL && rv[0] >= '0' && rv[0] <= '3')
gRadarRot = rv[0] - '0';
else
gRadarRot = 3;
DEBUG_STREAM << "[glasswin] radar rotation " << gRadarRot
<< (gRadarRot == 3 ? " (90 CW)" : gRadarRot == 1 ? " (90 CCW)"
: gRadarRot == 2 ? " (180)" : " (none)")
<< std::endl << std::flush;
}
memset(latched, 0, sizeof(latched));
pressedAddress = -1;
sRepositionedToMain = false;
if (gStage == NULL)
gStage = new unsigned long[640 * 480];
if (gStage2 == NULL)
gStage2 = new unsigned long[640 * 480];
// Build the seven windows. Order fixes the ComputeLayout roles (0..6).
BuildMfd (gWins[0], "Heat MFD", "Heat", NULL, 0x2F); // UL 0x28-0x2F
BuildMfd (gWins[1], "Engineering", "Mfd2", "Eng2", 0x27); // UC 0x20-0x27
BuildMfd (gWins[2], "Comm MFD", "Comm", NULL, 0x37); // UR 0x30-0x37
BuildMfd (gWins[3], "Left Weapons", "Mfd1", "Eng1", 0x0F); // LL 0x08-0x0F
BuildMfd (gWins[4], "Right Weapons", "Mfd3", "Eng3", 0x07); // LR 0x00-0x07
BuildRadar(gWins[5]); // center 0x10-0x1B
BuildFlight(gWins[6]); // 0x38-0x47
gWinCount = 7;
// POD SURFACE MODE: crop every display to its picture and drop the Flight
// Controls pad (it is pure desktop chrome -- the cab has a real stick).
if (PodSurfaceMode())
{
for (int i = 0; i < gWinCount; ++i)
MakeBareSurface(gWins[i]);
if (gWins[6].portPrimary == NULL)
gWinCount = 6; // drop Flight Controls
}
// RGB SPLIT MODE (BT_POD_RGB): the cab's real wiring -- ONE VGA port per
// window, its R/G/B lines split to three mono monitors. Collapse the five
// separate MFD windows into the TWO ports the pod actually drives
// (L4GAUGE.CFG clut2 = Comm/Mfd2/Heat, clut1 = Mfd1/Mfd3), each window
// compositing its group into the colour channels; the radar keeps its own
// full-colour port. Channels are read live from each port, so a page swap
// to an Eng plane follows automatically.
if (PodRgbSplitMode())
{
GWin port2 = gWins[0]; // reuse the Heat window's shape
port2.title = "VGA Port A"; // Comm=red, Mfd2=green, Heat=blue
port2.portPrimary = "Heat"; // base plane (blue); group adds the rest
port2.portAlt = NULL;
port2.groupCount = 2;
port2.groupPort[0] = "Comm"; port2.groupAlt[0] = NULL;
port2.groupPort[1] = "Mfd2"; port2.groupAlt[1] = "Eng2";
port2.monoTint = (int)0x000000FF; // Heat rides BLUE
GWin port1 = gWins[3]; // the Mfd1 window's shape
port1.title = "VGA Port B"; // Mfd1=red, Mfd3=green (blue spare)
port1.portPrimary = "Mfd1";
port1.portAlt = "Eng1";
port1.groupCount = 1;
port1.groupPort[0] = "Mfd3"; port1.groupAlt[0] = "Eng3";
port1.monoTint = (int)0x00FF0000; // Mfd1 rides RED
GWin radar = gWins[5]; // unchanged: its own colour port
gWins[0] = port2;
gWins[1] = port1;
gWins[2] = radar;
gWinCount = 3;
DEBUG_STREAM << "[glasswin] RGB SPLIT MODE: 2 VGA ports + radar "
"(each port's R/G/B feeds three mono panels)\n" << std::flush;
}
titleFont = CreateFontW(-11, 0, 0, 0, FW_BOLD, 0, 0, 0,
DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, L"Segoe UI");
buttonFont = CreateFontW(-10, 0, 0, 0, FW_NORMAL, 0, 0, 0,
DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, L"Segoe UI");
subFont = CreateFontW(-9, 0, 0, 0, FW_NORMAL, 0, 0, 0,
DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, L"Segoe UI");
WNDCLASSW wc;
memset(&wc, 0, sizeof(wc));
wc.lpfnWndProc = GlassWndProc;
wc.hInstance = GetModuleHandleW(NULL);
wc.hCursor = LoadCursorW(NULL, (LPCWSTR)IDC_ARROW);
wc.lpszClassName = L"BTGlassWin";
RegisterClassW(&wc);
// The ",noframe" flag is per WINDOW and arrives with the layout file, so
// read that FIRST -- the frame size (and therefore ComputeLayout's ring
// placement) depends on which windows carry chrome.
LoadLayout(1); // quiet: this pass only needs the ",noframe" flags
for (int i = 0; i < gWinCount; ++i)
{
RECT fr = { 0, 0, gWins[i].clientW, gWins[i].clientH };
AdjustWindowRect(&fr, GlassWindowStyle(gWins[i]), FALSE);
gWins[i].frameW = fr.right - fr.left;
gWins[i].frameH = fr.bottom - fr.top;
}
ComputeLayout();
LoadLayout(0); // re-apply saved positions over the computed ring
for (int i = 0; i < gWinCount; ++i)
{
GWin &w = gWins[i];
WCHAR wtitle[64];
int n = 0;
for (const char *s = w.title; *s && n < 63; ++s) wtitle[n++] = (WCHAR)*s;
wtitle[n] = 0;
// Layered + topmost (== L4PADPANEL): DWM composites the panel independently
// of the game's D3D swap chain, ending GDI-vs-D3D presentation strobing.
w.hwnd = CreateWindowExW(
WS_EX_TOPMOST | WS_EX_LAYERED,
L"BTGlassWin", wtitle, GlassWindowStyle(w),
w.wantX, w.wantY, w.frameW, w.frameH,
NULL, NULL, GetModuleHandleW(NULL), NULL);
if (w.hwnd == NULL)
{
DEBUG_STREAM << "[glasswin] CreateWindow failed for '" << w.title
<< "'\n" << std::flush;
continue;
}
SetWindowLongPtrW(w.hwnd, GWLP_USERDATA, (LONG_PTR)&w);
SetLayeredWindowAttributes(w.hwnd, 0, 255, LWA_ALPHA);
SetTimer(w.hwnd, RepaintTimerId, RepaintMilliseconds, NULL);
ShowWindow(w.hwnd, SW_SHOWNOACTIVATE);
// PLACEMENT RECEIPT (pod bring-up, 2026-08-06): which PHYSICAL monitor
// each display actually landed on. On a real cab -- or any remote
// session -- nobody can see all seven surfaces at once, so the log is
// the only way to confirm the map without walking around the pod.
{
RECT wr = { 0, 0, 0, 0 };
GetWindowRect(w.hwnd, &wr);
HMONITOR mon = MonitorFromWindow(w.hwnd, MONITOR_DEFAULTTONEAREST);
MONITORINFOEXA mi;
memset(&mi, 0, sizeof(mi));
mi.cbSize = sizeof(mi);
const char *devName = "?";
int mx = 0, my = 0, mw = 0, mh = 0, primary = 0;
if (mon != NULL && GetMonitorInfoA(mon, (MONITORINFO *)&mi))
{
devName = mi.szDevice;
strncpy(w.monitorName, mi.szDevice, sizeof(w.monitorName) - 1);
w.monitorName[sizeof(w.monitorName) - 1] = 0;
mx = mi.rcMonitor.left; my = mi.rcMonitor.top;
mw = mi.rcMonitor.right - mi.rcMonitor.left;
mh = mi.rcMonitor.bottom - mi.rcMonitor.top;
primary = (mi.dwFlags & MONITORINFOF_PRIMARY) ? 1 : 0;
}
DEBUG_STREAM << "[glasswin] '" << w.title << "' surface="
<< (w.portPrimary ? w.portPrimary : "-")
<< " at " << wr.left << "," << wr.top
<< " " << (wr.right - wr.left) << "x" << (wr.bottom - wr.top)
<< (w.noFrame ? " bare" : " framed")
<< " -> monitor " << devName
<< " (" << mx << "," << my << " " << mw << "x" << mh
<< (primary ? ", PRIMARY)" : ")") << "\n" << std::flush;
}
}
DEBUG_STREAM << "[glasswin] per-display cockpit up (" << gWinCount
<< " windows: 5 MFD + radar + flight)\n" << std::flush;
}
void
BTGlassPanels_Destroy()
{
SaveLayout(); // backstop for a clean teardown (WM_EXITSIZEMOVE already
// caught every finished drag); no-op unless mode==save
for (int i = 0; i < gWinCount; ++i)
{
if (gWins[i].hwnd != NULL)
{
KillTimer(gWins[i].hwnd, RepaintTimerId);
DestroyWindow(gWins[i].hwnd);
gWins[i].hwnd = NULL;
}
}
gWinCount = 0;
if (titleFont) { DeleteObject(titleFont); titleFont = NULL; }
if (buttonFont) { DeleteObject(buttonFont); buttonFont = NULL; }
if (subFont) { DeleteObject(subFont); subFont = NULL; }
if (gStage) { delete[] gStage; gStage = NULL; }
}
//###########################################################################
// Per-frame repaint pump (2026-08-04, "indicators flash slowly unless focused").
//
// These windows repaint off a 62 ms WM_TIMER. Windows COALESCES/THROTTLES timer
// AND paint messages for a window that is in the BACKGROUND (not focused) -- so
// when the game window (or another app) holds focus, the panel's WM_TIMER slows
// to a crawl and the lamp FLASH (a repaint-driven animation: BTLampBrightnessOf
// alternates the shade off GetTickCount every paint) drags. Giving the panel
// focus un-throttles its timer, which is exactly what playtesters saw.
//
// Fix: drive the repaint from the MAIN render loop (BTGlassPanels_Tick, called
// once per frame from L4VIDEO), gated to the same ~16 Hz flash cadence. The main
// loop runs every frame while the game is up regardless of which window has focus,
// and InvalidateRect + UpdateWindow forces a SYNCHRONOUS WM_PAINT -- bypassing the
// throttled timer-message path entirely. The WM_TIMER stays (it still owns the
// one-shot re-snap); a focused window just repaints from whichever fires first.
//###########################################################################
void
BTGlassPanels_Tick()
{
if (gWinCount == 0)
return;
static unsigned long sLastPaint = 0;
unsigned long now = GetTickCount();
if (now - sLastPaint < (unsigned long)RepaintMilliseconds)
return;
sLastPaint = now;
for (int i = 0; i < gWinCount; ++i)
{
if (gWins[i].hwnd != NULL)
{
InvalidateRect(gWins[i].hwnd, NULL, FALSE);
UpdateWindow(gWins[i].hwnd); // synchronous paint, not the throttled queue
}
}
}