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
1802 lines
62 KiB
C++
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
|
|
}
|
|
}
|
|
}
|