From ec080cd61d6dc0f858d0ad40f354b10a171d99b5 Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Sat, 8 Aug 2026 14:20:38 -0500 Subject: [PATCH] pod displays: bind glass panels to EDID identity, not to Windows' shifting display numbers Nick, after re-cabling + rebooting the pod: "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." Both existing binding forms are boot-fragile: `monitor:2` is an ENUMERATION INDEX and `monitor:\.\DISPLAY4` is a GDI name Windows reassigns. Neither survives a panel power-cycle. (His gos-displays.txt shows the same trap next door in GameOS: -tmon takes DIRECTDRAW device indices -- not Windows monitor numbers -- and a NULL-device merge shifts every index down by one on top.) 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. DISCOVER BT_GLASS_IDS=1 logs every attached panel's stable-id AND a ready-to-paste `cfg form = monitor:id:`. It prints the VOLATILE identifiers alongside on purpose: run it either side of a power-cycle and index/device move while stable-id does not. BIND Heat MFD=monitor:id:AUO10ED,bare NOTHING CHANGES BY DEFAULT -- no env and no `id:` prefix means identical behaviour; `monitor:` and raw x,y keep working, so playtester glass builds are untouched. An `id:` that matches nothing WARNS and falls back to computed placement. Silence would put a picture on the wrong glass and look exactly like the bug this form exists to prevent. VERIFIED on a 1-monitor dev box (the pod is offline), end to end: * discovery printed stable-id = \?\DISPLAY#AUO10ED#4&31323a6c&1&UID265988#{e6f07b5f-...} cfg form = monitor:id:AUO10ED * `Heat MFD=monitor:id:AUO10ED,bare` resolved and CENTRED correctly [glasswin] 'Heat MFD' bound to monitor 0,0 1920x1080 -> window at 640,300 * a bogus id warned instead of misplacing. The EDID-code extractor is deliberately STRUCTURAL (3 letters + 4 hex digits, tokenising on \ # ?) rather than positional: the first cut walked separators by position and returned EMPTY for the `\?\DISPLAY#...` interface-name form, which is exactly the form this machine produces. Which form you get depends on whether EDD_GET_DEVICE_INTERFACE_NAME succeeds, so both must parse. STILL UNPROVEN [T3] -- the pod is offline: multi-panel disambiguation when several MFDs share one model (EDID codes collide). The documented answer is a longer fragment from stable-id, whose UID/instance tail differs per connector, but that needs the cab to confirm. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC --- context/pod-hardware.md | 34 ++++++ engine/MUNGA_L4/L4GLASSWIN.cpp | 192 ++++++++++++++++++++++++++++++++- engine/MUNGA_L4/l4glasswin.h | 6 ++ 3 files changed, 229 insertions(+), 3 deletions(-) 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