diff --git a/context/pod-hardware.md b/context/pod-hardware.md index 7e41158..4bcb3f5 100644 --- a/context/pod-hardware.md +++ b/context/pod-hardware.md @@ -254,6 +254,40 @@ desktops. Two pieces were added for the cab: once, and over CRD you cannot see the panels at all: the log IS the confirmation that a picture landed on the right glass. +### Boot-STABLE panel binding — `monitor:id:` (2026-08-08, staged, pod-untested) +**Windows renumbers displays.** Nick, after re-cabling + a reboot: *"the order changed … sometimes +they change when one gets turned off and back on, at least how windows SEEs them, even if the +visual desktop tool looks the same."* So the two original binding forms are both boot-fragile — +`monitor:2` is an enumeration index and `monitor:\.\DISPLAY4` is a GDI name Windows reassigns. +(Same trap next door in GameOS: its `-tmon` takes **DirectDraw device indices**, which are neither +Windows monitor numbers nor stable, and a NULL-device merge shifts every index down by one on top +— see Nick's `gos-displays.txt`.) + +**The fix: bind to the panel's own hardware identity.** `EnumDisplayDevices` on a display's MONITOR +child returns a DeviceID embedding the **EDID manufacturer + product code** and the connector +instance — neither moves across a reboot or a power-cycle. + +- **Discover:** run once on the pod with `BT_GLASS_IDS=1`. Every attached panel logs its + `stable-id` **and a ready-to-paste `cfg form`**: + ``` + [glassid] index=0 device=\\.\DISPLAY1 PRIMARY rect=0,0 1920x1080 + stable-id = \\?\DISPLAY#AUO10ED#4&31323a6c&1&UID265988#{e6f07b5f-…} + cfg form = monitor:id:AUO10ED + ``` + The volatile identifiers print beside the stable one deliberately: run it twice across a + power-cycle and `index`/`device` move while `stable-id` does not — that IS the proof. +- **Bind:** `Heat MFD=monitor:id:AUO10ED,bare` in `glass_layout.cfg`. +- **Identical panels** (the pod's mono MFDs are likely one model, so EDID codes collide): use a + longer fragment from `stable-id` — the `UID…`/instance tail differs per connector, so + `monitor:id:UID265988` picks exactly one. +- **An `id:` that matches nothing WARNS and falls back** to computed placement: + `[glasswin] monitor id 'X' matched NO attached panel`. Silence there would put a picture on the + wrong glass and look exactly like the bug this form exists to prevent. +- **Nothing changes by default.** No env, no `id:` → identical behaviour; `monitor:` + and raw `x,y` keep working. Playtester glass builds are untouched. [T2 verified on a 1-monitor + dev box — discovery, binding, centring and the mismatch warning; **T3 on the pod**, which was + offline: multi-panel disambiguation is unproven.] + **Runbook** (`tools/podprobe.ps1`, PowerShell, no install/admin — run it ON the pod PC): 1. Probe: GPUs, every monitor's virtual-desktop rect, EDID make/model (identifies the original panels), serial ports (the RIO board), session type, and a PROPOSED `glass_layout.cfg` that diff --git a/engine/MUNGA_L4/L4GLASSWIN.cpp b/engine/MUNGA_L4/L4GLASSWIN.cpp index 93fd88c..8d775ba 100644 --- a/engine/MUNGA_L4/L4GLASSWIN.cpp +++ b/engine/MUNGA_L4/L4GLASSWIN.cpp @@ -744,7 +744,70 @@ void // 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; }; +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) @@ -756,7 +819,17 @@ static BOOL CALLBACK if (GetMonitorInfoA(mon, (MONITORINFO *)&mi)) { int hit = 0; - if (sc->want != NULL) + // `id:` -- 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 @@ -781,6 +854,96 @@ static BOOL CALLBACK 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 : "") + << "\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:.\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:" to a rect. Returns 1 on success. static int ResolveMonitorSpec(const char *spec, RECT *out) @@ -789,13 +952,31 @@ static int MonScan sc; memset(&sc, 0, sizeof(sc)); sc.wantIndex = -1; - if (spec[0] >= '0' && spec[0] <= '9') + // `monitor:id:` -- 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; } @@ -1616,6 +1797,11 @@ void 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). diff --git a/engine/MUNGA_L4/l4glasswin.h b/engine/MUNGA_L4/l4glasswin.h index 5000a29..77e3bec 100644 --- a/engine/MUNGA_L4/l4glasswin.h +++ b/engine/MUNGA_L4/l4glasswin.h @@ -31,6 +31,12 @@ void void BTGlassPanels_Destroy(); +// BT_GLASS_IDS=1 -- log every attached panel's BOOT-STABLE hardware identity +// plus a ready-to-paste `monitor:id:` line for glass_layout.cfg. +// Called from BTGlassPanels_Create; no-op unless the env is set. +void + BTGlassDumpMonitorIds(); + // // Per-frame repaint pump. Call once per frame from the main render loop so the // per-display windows' lamp flash keeps animating even when they are in the