2209 lines
78 KiB
C++
2209 lines
78 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
|
|
{
|
|
double perfMs; // [glassperf] paint ms this window, this second
|
|
int perfN; // [glassperf] paints this second
|
|
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.
|
|
|
|
// Dirty-skip (2026-08-09): the last change token the pump blitted for this
|
|
// window (plane checksum + lamp render). Zero-init (static storage); the pump
|
|
// re-blits only when the token differs, so unchanged panels cost nothing.
|
|
unsigned long lastToken;
|
|
int haveToken;
|
|
};
|
|
|
|
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; const char *wantId; int wantIndex; RECT rect; int found; };
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// STABLE PANEL IDENTITY (2026-08-08) -- the fix for "the monitors came back in a
|
|
// different order after I moved cables and rebooted".
|
|
//
|
|
// Windows renumbers \\.\DISPLAYn, and reorders the enumeration, when a panel is
|
|
// power-cycled or re-cabled -- even when the visible desktop arrangement is
|
|
// unchanged. So BOTH existing binding forms are boot-fragile: `monitor:2` is an
|
|
// enumeration index and `monitor:\.\DISPLAY4` is a GDI name Windows reassigns.
|
|
// (The same trap bites GameOS next door: its -tmon takes DirectDraw device
|
|
// indices, which are neither Windows monitor numbers nor stable -- and its
|
|
// NULL-device merge shifts every index down by one on top of that.)
|
|
//
|
|
// What IS stable is the panel's own hardware identity. EnumDisplayDevices on a
|
|
// display's MONITOR child returns a DeviceID like
|
|
// MONITOR\DEL4231\{4d36e96e-e325-11ce-bfc1-08002be10318}\0002
|
|
// or, with EDD_GET_DEVICE_INTERFACE_NAME,
|
|
// \\?\DISPLAY#DEL4231#5&1a2b3c&0&UID4353#{e6f07b5f-...}
|
|
// The `DEL4231` field is the EDID manufacturer + product code, and the UID/
|
|
// instance identifies the physical connector. Neither moves on a reboot.
|
|
//
|
|
// Fill `out` with that string for a \\.\DISPLAYn adapter name. Empty on failure
|
|
// (no monitor child / remote session) -- callers must treat "" as "no identity"
|
|
// and fall back, never as a match.
|
|
static void
|
|
MonitorStableId(const char *displayName, char *out, size_t outLen)
|
|
{
|
|
if (outLen == 0) return;
|
|
out[0] = '\0';
|
|
if (displayName == NULL || displayName[0] == '\0') return;
|
|
|
|
DISPLAY_DEVICEA mon;
|
|
memset(&mon, 0, sizeof(mon));
|
|
mon.cb = sizeof(mon);
|
|
// index 0 = the attached monitor child. The interface-name flag gives the
|
|
// richer path (includes the connector UID); it is Vista+, and if the call
|
|
// fails we retry without it for the plain MONITOR\... form.
|
|
if (!EnumDisplayDevicesA(displayName, 0, &mon, EDD_GET_DEVICE_INTERFACE_NAME))
|
|
{
|
|
memset(&mon, 0, sizeof(mon));
|
|
mon.cb = sizeof(mon);
|
|
if (!EnumDisplayDevicesA(displayName, 0, &mon, 0))
|
|
return;
|
|
}
|
|
strncpy(out, mon.DeviceID, outLen - 1);
|
|
out[outLen - 1] = '\0';
|
|
}
|
|
|
|
// Case-insensitive substring test -- the cfg quotes a FRAGMENT of the identity
|
|
// (usually just the EDID code, e.g. `id:DEL4231`) rather than the whole path,
|
|
// because the full string is long and contains characters a config file and a
|
|
// shell each mangle differently.
|
|
static int
|
|
IdContains(const char *haystack, const char *needle)
|
|
{
|
|
if (haystack == NULL || needle == NULL || *needle == '\0') return 0;
|
|
size_t hl = strlen(haystack), nl = strlen(needle);
|
|
if (nl > hl) return 0;
|
|
for (size_t i = 0; i + nl <= hl; ++i)
|
|
if (_strnicmp(haystack + i, needle, nl) == 0)
|
|
return 1;
|
|
return 0;
|
|
}
|
|
|
|
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;
|
|
// `id:<fragment>` -- match the panel's HARDWARE identity, not its
|
|
// current \\.\DISPLAYn or enumeration slot. Boot-stable; this is the
|
|
// form the pod should use.
|
|
if (sc->wantId != NULL)
|
|
{
|
|
char sid[256];
|
|
MonitorStableId(mi.szDevice, sid, sizeof(sid));
|
|
if (sid[0] != '\0' && IdContains(sid, sc->wantId))
|
|
hit = 1;
|
|
}
|
|
if (!hit && 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;
|
|
}
|
|
|
|
// BT_GLASS_IDS=1 -- dump every attached panel with its BOOT-STABLE identity, and
|
|
// a ready-to-paste `monitor:id:` fragment for glass_layout.cfg. This is the
|
|
// discovery half of the fix: run it once on the pod, copy the fragments into the
|
|
// cfg, and the assignment survives reboots and re-cabling.
|
|
//
|
|
// Prints the volatile identifiers too, deliberately side by side -- seeing
|
|
// \\.\DISPLAYn and the enumeration index MOVE between two runs while the id
|
|
// stays put is the proof that the id form is the right one.
|
|
struct MonDump { int index; };
|
|
|
|
static BOOL CALLBACK
|
|
MonDumpProc(HMONITOR mon, HDC, LPRECT, LPARAM param)
|
|
{
|
|
MonDump *d = (MonDump *)param;
|
|
MONITORINFOEXA mi;
|
|
memset(&mi, 0, sizeof(mi));
|
|
mi.cbSize = sizeof(mi);
|
|
if (GetMonitorInfoA(mon, (MONITORINFO *)&mi))
|
|
{
|
|
char sid[256];
|
|
MonitorStableId(mi.szDevice, sid, sizeof(sid));
|
|
|
|
// The EDID make+product sits between the first two separators of the
|
|
// DeviceID (MONITOR\DEL4231\... or \\?\DISPLAY#DEL4231#...). Offer it
|
|
// as the suggested fragment -- short, and unique when the panels are
|
|
// different models. Identical models need more (see the note below).
|
|
// Find it STRUCTURALLY rather than by position: an EDID PnP code is
|
|
// exactly 3 letters + 4 hex digits (AUO10ED, DEL4231). Walking the
|
|
// separators positionally broke on the interface-name form, whose
|
|
// `\\?\DISPLAY#...` prefix has a different number of leading segments
|
|
// than the plain `MONITOR\...` form -- and which form you get depends on
|
|
// whether EDD_GET_DEVICE_INTERFACE_NAME succeeded. Tokenising on all of
|
|
// \ # ? handles every variant the same way.
|
|
char frag[64];
|
|
frag[0] = '\0';
|
|
for (const char *t = sid; *t != '\0'; )
|
|
{
|
|
while (*t == '\\' || *t == '#' || *t == '?') ++t;
|
|
const char *e = t;
|
|
while (*e != '\0' && *e != '\\' && *e != '#' && *e != '?') ++e;
|
|
size_t n = (size_t)(e - t);
|
|
if (n == 7)
|
|
{
|
|
int ok = 1;
|
|
for (int i = 0; i < 3 && ok; ++i)
|
|
if (!isalpha((unsigned char)t[i])) ok = 0;
|
|
for (int i = 3; i < 7 && ok; ++i)
|
|
if (!isxdigit((unsigned char)t[i])) ok = 0;
|
|
if (ok)
|
|
{
|
|
memcpy(frag, t, 7);
|
|
frag[7] = '\0';
|
|
break;
|
|
}
|
|
}
|
|
t = e;
|
|
}
|
|
|
|
DEBUG_STREAM << "[glassid] index=" << d->index
|
|
<< " device=" << mi.szDevice
|
|
<< (((mi.dwFlags & MONITORINFOF_PRIMARY) != 0) ? " PRIMARY" : "")
|
|
<< " rect=" << (int)mi.rcMonitor.left << "," << (int)mi.rcMonitor.top
|
|
<< " " << (int)(mi.rcMonitor.right - mi.rcMonitor.left)
|
|
<< "x" << (int)(mi.rcMonitor.bottom - mi.rcMonitor.top)
|
|
<< "\n stable-id = " << (sid[0] ? sid : "(unavailable)")
|
|
<< "\n cfg form = monitor:id:" << (frag[0] ? frag : "<see stable-id>")
|
|
<< "\n" << std::flush;
|
|
}
|
|
++d->index;
|
|
return TRUE;
|
|
}
|
|
|
|
void
|
|
BTGlassDumpMonitorIds()
|
|
{
|
|
if (getenv("BT_GLASS_IDS") == NULL)
|
|
return;
|
|
DEBUG_STREAM << "[glassid] ---- attached panels, boot-STABLE identities ----\n"
|
|
<< "[glassid] index and device= move across reboots / power-cycles;\n"
|
|
<< "[glassid] stable-id does not. Bind the pod with monitor:id:<fragment>.\n"
|
|
<< "[glassid] If two panels are the SAME MODEL their EDID codes match --\n"
|
|
<< "[glassid] use a longer fragment from stable-id (the UID/instance tail\n"
|
|
<< "[glassid] differs per connector) so each line matches exactly one.\n"
|
|
<< std::flush;
|
|
MonDump d;
|
|
d.index = 0;
|
|
EnumDisplayMonitors(NULL, NULL, MonDumpProc, (LPARAM)&d);
|
|
DEBUG_STREAM << "[glassid] ---- " << d.index << " panel(s) ----\n" << std::flush;
|
|
}
|
|
|
|
// 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;
|
|
// `monitor:id:<fragment>` -- BOOT-STABLE hardware identity (preferred on the
|
|
// pod). Anything else keeps its historic meaning exactly: a leading digit is
|
|
// the enumeration index, otherwise a \\.\DISPLAYn device name (or its tail).
|
|
if (_strnicmp(spec, "id:", 3) == 0)
|
|
{
|
|
sc.wantId = spec + 3;
|
|
while (*sc.wantId == ' ' || *sc.wantId == '\t') ++sc.wantId;
|
|
}
|
|
else 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;
|
|
else if (sc.wantId != NULL)
|
|
{
|
|
// An id: binding that matched nothing is worth shouting about: the panel
|
|
// is unplugged, asleep, or the cfg fragment is wrong. Silently falling
|
|
// back to computed placement would put a picture on the wrong glass and
|
|
// look like the very bug this form exists to prevent.
|
|
DEBUG_STREAM << "[glasswin] monitor id '" << sc.wantId
|
|
<< "' matched NO attached panel -- check BT_GLASS_IDS=1 output"
|
|
<< "\n" << std::flush;
|
|
}
|
|
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;
|
|
}
|
|
|
|
// LAST-KNOWN GEOMETRY, kept ACROSS teardown (2026-08-07, #140).
|
|
//
|
|
// SaveLayout rewrites the whole file, so it can only be as complete as what it
|
|
// can see -- and it could only see LIVE windows. Destroy() nulls every hwnd and
|
|
// zeroes gWinCount, so any save that ran after a teardown wrote a file with
|
|
// every MFD and radar line MISSING. The plasma window survived that because
|
|
// external windows already cached a last-known rect (gExtern[].haveLast); the
|
|
// per-display windows had no such cache, which is exactly the reported
|
|
// signature: "all the MFDs and secondary lines missing, but plasma was still
|
|
// there". Give the glass windows the same guarantee, so the file is monotonic
|
|
// -- a save can update a line or add one, never drop one.
|
|
struct SavedGeom
|
|
{
|
|
char title[64];
|
|
RECT r;
|
|
int noFrame;
|
|
};
|
|
static SavedGeom gLastGeom[16];
|
|
static int gLastGeomCount = 0;
|
|
|
|
static void
|
|
RememberGeom(const char *title, const RECT &r, int noFrame)
|
|
{
|
|
if (title == NULL || title[0] == '\0')
|
|
return;
|
|
for (int i = 0; i < gLastGeomCount; ++i)
|
|
if (strcmp(gLastGeom[i].title, title) == 0)
|
|
{
|
|
gLastGeom[i].r = r; gLastGeom[i].noFrame = noFrame;
|
|
return;
|
|
}
|
|
if (gLastGeomCount >= (int)(sizeof(gLastGeom) / sizeof(gLastGeom[0])))
|
|
return;
|
|
SavedGeom &g = gLastGeom[gLastGeomCount++];
|
|
strncpy(g.title, title, sizeof(g.title) - 1);
|
|
g.title[sizeof(g.title) - 1] = '\0';
|
|
g.r = r; g.noFrame = noFrame;
|
|
}
|
|
|
|
// Write every window's 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. Live windows refresh the
|
|
// remembered geometry first; the FILE is then written from the remembered set,
|
|
// so a window that has already been torn down keeps its line.
|
|
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;
|
|
// 1. refresh the remembered geometry from whatever is currently alive
|
|
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;
|
|
RememberGeom(gw.title, r, gw.noFrame); // keep the hand-added option
|
|
}
|
|
// 2. write the remembered set -- including windows already torn down
|
|
for (int i = 0; i < gLastGeomCount; ++i)
|
|
{
|
|
const SavedGeom &g = gLastGeom[i];
|
|
fprintf(f, "%s=%ld,%ld,%ld,%ld%s\n", g.title,
|
|
(long)g.r.left, (long)g.r.top,
|
|
(long)(g.r.right - g.r.left), (long)(g.r.bottom - g.r.top),
|
|
g.noFrame ? ",noframe" : "");
|
|
++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);
|
|
// #140 receipt (ungated): `live=` is the diagnostic that matters. A save
|
|
// that runs with live=0 is the corruption case -- before the remembered-
|
|
// geometry cache it wrote a file containing ONLY the plasma line, which is
|
|
// what testers reported. It stays in the log so the FIELD can tell us which
|
|
// caller does that (teardown ordering, a drag after a round boundary, ...),
|
|
// which no bench here managed to reach.
|
|
DEBUG_STREAM << "[glasswin] saved " << wrote << " window position(s) to "
|
|
<< layoutFileName << " (live=" << gWinCount
|
|
<< " remembered=" << gLastGeomCount << ")\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;
|
|
// STRETCH MODE (perf, 2026-08-09): default COLORONCOLOR. HALFTONE runs a
|
|
// per-output-pixel resample filter; done synchronously for all 7 glass windows
|
|
// every ~16 Hz repaint (BTGlassPanels_Tick), it was the per-display-mode perf
|
|
// sink -- playtesters saw ~20 fps in the exploded panels vs ~130 fps in the
|
|
// cockpit surround (same scene, same machine). The MFDs are low-res pixel
|
|
// content, so nearest-neighbour reads crisp (and closer to the pod CRT).
|
|
// BT_GLASS_SMOOTH=1 restores HALFTONE for anyone who prefers smoothing to speed.
|
|
static int sSmooth = -1;
|
|
if (sSmooth < 0) sSmooth = getenv("BT_GLASS_SMOOTH") ? 1 : 0;
|
|
SetStretchBltMode(dc, sSmooth ? HALFTONE : COLORONCOLOR);
|
|
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)
|
|
{
|
|
// [glassperf] (#149): time EVERY panel paint. The blit is a
|
|
// HALFTONE StretchDIBits whose cost is strongly driver-dependent
|
|
// -- the whole point is to measure it on the machine that pays it.
|
|
LARGE_INTEGER t0, t1, fq;
|
|
QueryPerformanceCounter(&t0);
|
|
PaintGlass(window, w);
|
|
QueryPerformanceCounter(&t1);
|
|
QueryPerformanceFrequency(&fq);
|
|
w->perfMs += 1000.0 * (double)(t1.QuadPart - t0.QuadPart) / (double)fq.QuadPart;
|
|
++w->perfN;
|
|
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_GLASS_IDS=1: dump every panel's boot-stable identity before any window
|
|
// is placed, so the log shows what the cfg COULD bind to right next to what
|
|
// it actually did. No-op without the env -- playtesters see nothing new.
|
|
BTGlassDumpMonitorIds();
|
|
|
|
// 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()
|
|
{
|
|
// #140 receipt (ungated, one line per teardown): this function has two
|
|
// callers on the desktop path and the ORDER is what broke the layout file.
|
|
// A run that shows `entry #2 windows=0` is the double-destroy, on the
|
|
// record, without needing to catch the cfg mid-corruption.
|
|
{
|
|
static int s_destroyN = 0;
|
|
DEBUG_STREAM << "[glasswin] destroy entry #" << ++s_destroyN
|
|
<< " windows=" << gWinCount << "\n" << std::flush;
|
|
}
|
|
|
|
// Backstop for a clean teardown (WM_EXITSIZEMOVE already caught every
|
|
// finished drag); no-op unless mode==save. GUARDED on there being windows:
|
|
// this function has TWO callers (~PadRIO and ~LBE4ControlsManager), so on
|
|
// the desktop path it runs twice, and the second pass has nothing live to
|
|
// report. The remembered-geometry cache above already makes that harmless,
|
|
// but there is no reason to rewrite the file to say the same thing.
|
|
if (gWinCount > 0)
|
|
SaveLayout();
|
|
|
|
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.
|
|
//###########################################################################
|
|
|
|
//
|
|
// GLASS DIRTY-SKIP (2026-08-09): re-blit only the windows whose content changed.
|
|
// Each window is a plane of the ONE shared gauge pixelBuffer, so its change token is
|
|
// the masked plane checksum (SVGA16::PlaneChecksum over every port that can feed the
|
|
// window) combined with each button's RENDERED lamp brightness + held/latched state.
|
|
// Folding the flash BRIGHTNESS in (not the raw lamp state) means a flashing lamp
|
|
// repaints exactly when it toggles, and a static panel -- or a fully idle cockpit --
|
|
// skips its expand + StretchDIBits entirely. The plane checksum is the only added
|
|
// cost (once per window per ~16 Hz pump) and is far cheaper than the paint it saves.
|
|
//
|
|
static unsigned long
|
|
GlassWindowToken(GaugeRenderer *gr, GWin *w, unsigned long tick)
|
|
{
|
|
unsigned long token = 2166136261UL;
|
|
|
|
if (w->portPrimary != NULL && gr != NULL)
|
|
{
|
|
// Every plane that could feed the window (primary + Eng twin + RGB group +
|
|
// their twins) -- OR their masks so a change to ANY is caught; checksum once.
|
|
const char *ports[8];
|
|
int np = 0;
|
|
ports[np++] = w->portPrimary;
|
|
if (w->portAlt != NULL) ports[np++] = w->portAlt;
|
|
for (int gi = 0; gi < w->groupCount && np < 7; ++gi)
|
|
{
|
|
ports[np++] = w->groupPort[gi];
|
|
if (w->groupAlt[gi] != NULL && np < 8) ports[np++] = w->groupAlt[gi];
|
|
}
|
|
int combined = 0;
|
|
SVGA16 *svga = NULL;
|
|
for (int i = 0; i < np; ++i)
|
|
{
|
|
L4GraphicsPort *p =
|
|
static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(ports[i]));
|
|
if (p == NULL) continue;
|
|
combined |= p->GetBitMask();
|
|
if (svga == NULL) svga = static_cast<SVGA16*>(p->graphicsDisplay);
|
|
}
|
|
if (svga != NULL && combined != 0)
|
|
token ^= svga->PlaneChecksum(combined);
|
|
}
|
|
|
|
// Lamps: each button's RENDERED brightness + held/latched, so a flash toggle or a
|
|
// press repaints exactly the window it lives on.
|
|
for (int j = 0; j < w->buttonCount; ++j)
|
|
{
|
|
int addr = w->buttons[j].address;
|
|
unsigned long shade =
|
|
(unsigned long)LampBrightnessOf(PadRIO::GetLampState(addr), tick);
|
|
int held = (addr == pressedAddress) || latched[addr & 0x7F];
|
|
token = (token ^ ((unsigned long)(addr & 0xFF) << 4)
|
|
^ (shade << 1) ^ (unsigned long)held) * 16777619UL;
|
|
}
|
|
return token;
|
|
}
|
|
|
|
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;
|
|
|
|
// MERGED: Cyd's dirty-skip (glass-panel-perf) + the #149 [glassperf]
|
|
// telemetry. BT_GLASS_SWEEP=1 bypasses the skip entirely -- the legacy
|
|
// always-repaint escape hatch, kept so the field can A/B in one env var.
|
|
static const int sGlassPerf =
|
|
!(getenv("BT_PERF_LOG") && *getenv("BT_PERF_LOG") == '0');
|
|
static const int sLegacySweep =
|
|
(getenv("BT_GLASS_SWEEP") && *getenv("BT_GLASS_SWEEP") == '1');
|
|
LARGE_INTEGER gt0, gt1, gfq;
|
|
QueryPerformanceCounter(>0);
|
|
GaugeRenderer *gr = BTResolveGaugeRenderer();
|
|
int repainted = 0;
|
|
for (int i = 0; i < gWinCount; ++i)
|
|
{
|
|
GWin &w = gWins[i];
|
|
if (w.hwnd == NULL)
|
|
continue;
|
|
unsigned long token = GlassWindowToken(gr, &w, now);
|
|
if (!sLegacySweep && w.haveToken && token == w.lastToken)
|
|
continue; // gauges + lamps unchanged -> skip this window
|
|
w.lastToken = token;
|
|
w.haveToken = 1;
|
|
++repainted;
|
|
InvalidateRect(w.hwnd, NULL, FALSE);
|
|
UpdateWindow(w.hwnd); // synchronous paint, not the throttled queue
|
|
}
|
|
|
|
// BT_GLASS_DIRTY: report how many window-repaints the dirty-skip let through vs
|
|
// the old fixed gWinCount-per-pump, so the saving is visible.
|
|
static int sDirtyLog = -1;
|
|
if (sDirtyLog < 0) sDirtyLog = getenv("BT_GLASS_DIRTY") ? 1 : 0;
|
|
if (sDirtyLog)
|
|
{
|
|
static unsigned long sWin = 0;
|
|
static int sPumps = 0, sPaints = 0;
|
|
++sPumps; sPaints += repainted;
|
|
if (now - sWin >= 2000)
|
|
{
|
|
DEBUG_STREAM << "[glass-dirty] " << sPumps << " pumps -> " << sPaints
|
|
<< " window-repaints (was " << (sPumps * gWinCount) << " always-on)\n"
|
|
<< std::flush;
|
|
sWin = now; sPumps = 0; sPaints = 0;
|
|
}
|
|
}
|
|
QueryPerformanceCounter(>1);
|
|
QueryPerformanceFrequency(&gfq);
|
|
|
|
// [glassperf] (#149): one line per second -- the tick's total synchronous
|
|
// cost plus each window's own paint time. This runs INSIDE the render
|
|
// frame (L4VIDEO calls the tick), so on a machine where GDI serialises
|
|
// against D3D present, tickMs IS the per-frame tax and the per-window
|
|
// split names the guilty panel. Default ON like [segperf]; BT_PERF_LOG=0
|
|
// opts out.
|
|
static double sTickMs = 0.0; static int sTicks = 0; static int sPaintAcc = 0;
|
|
sPaintAcc += repainted;
|
|
static unsigned long sLastReport = 0;
|
|
sTickMs += 1000.0 * (double)(gt1.QuadPart - gt0.QuadPart) / (double)gfq.QuadPart;
|
|
++sTicks;
|
|
if (sGlassPerf && now - sLastReport >= 1000)
|
|
{
|
|
sLastReport = now;
|
|
DEBUG_STREAM << "[glassperf] ticks=" << sTicks << " tickMs=" << sTickMs
|
|
<< " paints=" << sPaintAcc;
|
|
for (int i = 0; i < gWinCount; ++i)
|
|
{
|
|
if (gWins[i].perfN > 0)
|
|
DEBUG_STREAM << " | " << gWins[i].title
|
|
<< " n=" << gWins[i].perfN << " ms=" << gWins[i].perfMs;
|
|
gWins[i].perfMs = 0.0; gWins[i].perfN = 0;
|
|
}
|
|
DEBUG_STREAM << "\n" << std::flush;
|
|
sTickMs = 0.0; sTicks = 0; sPaintAcc = 0;
|
|
}
|
|
}
|