From aee26623afe88c5a75dd8df86f418fcb170384ed Mon Sep 17 00:00:00 2001 From: nextestudios Date: Wed, 30 Sep 2026 23:43:24 -0300 Subject: [PATCH 1/3] fix: restore the desk layout with the TV detached first Restore re-enabled every screen of the backup (including the ones that were off, the TV), applied the saved positions, and only then detached the TV. With the TV still part of the desktop, Windows kept the positions around it and re-flowed the other screens when it left, so a screen could end up beside where it was (stacked monitors sliding apart). Now only the screens that were on come back, the ones that were off are detached before any position is applied, and the saved layout is applied to a desktop that already has its final screens. The result is checked against the backup (new DisplayIdentity.LayoutMismatches) and retried up to three times; if the original screens didn't come back the TV stays on so the PC is never left without a picture. A monitor with no current mode is logged instead of silently skipped. Co-Authored-By: Claude Sonnet 5.5 --- docs/TESTING.md | 10 +++ src/ConsoleMode/Native/NativeDisplays.cs | 6 +- src/ConsoleMode/Services/DisplayIdentity.cs | 23 +++++ src/ConsoleMode/Services/MonitorService.cs | 85 +++++++++++++------ .../ConsoleMode.Tests/DisplayIdentityTests.cs | 46 ++++++++++ 5 files changed, 142 insertions(+), 28 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index 6918d61..8ed945d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -290,6 +290,16 @@ Pré-condições: 1.6 instalada por cima da 1.5, com a configuração da 1.5 (sa - [ ] Menu da sessão (Select + Y): o volume inicial é o do Windows; ◀/▶ muda de 5 em 5 e tira o mudo; A alterna o mudo. Resultado: ______ - [ ] Ao restaurar a mesa, o som volta para a saída de antes do modo console. Resultado: ______ +## Restaurar a mesa: posições das telas + +Pré-condições: pelo menos 3 telas (a TV desligada na mesa normal, mais duas telas empilhadas ou lado a lado, p. ex. uma ultrawide com outra logo abaixo, alinhadas à esquerda); anote o mapa em Configurações → Sistema → Tela antes de começar. + +- [ ] Estratégia "Desconectar": **Jogar agora** e depois **Voltar ao PC** (botão, menu da sessão e tray): o mapa em Configurações → Sistema → Tela volta **idêntico** ao anotado (cada tela no mesmo lugar, mesma tela principal, a TV desligada como antes). Resultado: ______ +- [ ] O `consolemode.log` mostra a ordem `Telas: desativar ` **antes** de `Telas: layout restaurado => 0` e não tem `posições fora do backup`. Se tiver, o texto diz quais telas e em qual tentativa a restauração acertou. Resultado: ______ +- [ ] Repetir 3 vezes seguidas: o layout não deriva a cada ciclo. Resultado: ______ +- [ ] TV que já estava ligada (como tela estendida) antes da sessão: depois de voltar, ela continua ligada no mesmo lugar. Resultado: ______ +- [ ] Se uma das telas originais não voltar (cabo solto): o log diz que a tela de jogo ficou ligada e o PC não fica sem imagem. Resultado: ______ + ## Telas com código próprio (issue #91) Pré-condições: 1.6 instalada por cima da 1.5, com a configuração da 1.5 (tela de jogo e telas a esconder escolhidas). Anote antes, na 1.5, como o mapa de telas aparece, para comparar. diff --git a/src/ConsoleMode/Native/NativeDisplays.cs b/src/ConsoleMode/Native/NativeDisplays.cs index d8a69ae..2c912d6 100644 --- a/src/ConsoleMode/Native/NativeDisplays.cs +++ b/src/ConsoleMode/Native/NativeDisplays.cs @@ -162,7 +162,11 @@ public static int Disable(IReadOnlyCollection gdiNames) public static int QueueMode(string gdiName, int width, int height, int frequency, int bitsPerPixel, int? x, int? y, int orientation = -1) { var dm = new NativeWindows.DEVMODE { dmSize = (short)Marshal.SizeOf() }; - if (!NativeWindows.EnumDisplaySettings(gdiName, NativeWindows.ENUM_CURRENT_SETTINGS, ref dm)) return -1; + if (!NativeWindows.EnumDisplaySettings(gdiName, NativeWindows.ENUM_CURRENT_SETTINGS, ref dm)) + { + AppLog.Write($"Telas: {gdiName} sem modo atual; posição e modo não aplicados"); + return -1; + } dm.dmFields = 0; if (width > 0 && height > 0) { diff --git a/src/ConsoleMode/Services/DisplayIdentity.cs b/src/ConsoleMode/Services/DisplayIdentity.cs index 8da9441..9e98ff1 100644 --- a/src/ConsoleMode/Services/DisplayIdentity.cs +++ b/src/ConsoleMode/Services/DisplayIdentity.cs @@ -166,6 +166,29 @@ public static Dictionary> ParseLayout(IEnumer return specs; } + /// + /// Monitors that were on in the backup (they have a size) whose current state is wrong: not active, or not + /// at the saved position. Used to check a restore and to retry it. Specs without a saved position are skipped. + /// + public static List LayoutMismatches( + IReadOnlyDictionary> specs, + IEnumerable<(string Name, bool IsActive, int X, int Y)> current) + { + var now = current.GroupBy(m => m.Name, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + var wrong = new List(); + foreach (var (name, spec) in specs) + { + int.TryParse(spec.GetValueOrDefault("Width"), out var width); + int.TryParse(spec.GetValueOrDefault("Height"), out var height); + if (width <= 0 || height <= 0) continue; // was off + if (!int.TryParse(spec.GetValueOrDefault("PositionX"), out var x) || + !int.TryParse(spec.GetValueOrDefault("PositionY"), out var y)) continue; + if (!now.TryGetValue(name, out var monitor) || !monitor.IsActive || monitor.X != x || monitor.Y != y) + wrong.Add(name); + } + return wrong; + } + /// /// Re-keys layout sections by the name each monitor has now, matched by MonitorID: a /// monitor that was disabled can come back as another \.\DISPLAYn. Sections whose monitor isn't diff --git a/src/ConsoleMode/Services/MonitorService.cs b/src/ConsoleMode/Services/MonitorService.cs index ef9c9f9..e1a40e5 100644 --- a/src/ConsoleMode/Services/MonitorService.cs +++ b/src/ConsoleMode/Services/MonitorService.cs @@ -113,21 +113,29 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state) Thread.Sleep(300); } - var allDeviceNames = backupSpecs.Values - .Select(s => s.GetValueOrDefault("Name") ?? "") - .Where(n => n.Length > 0) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); + // Screens that were on before the session come back; the ones that were off (the TV, usually) stay off. var monitorsToVerify = backupSpecs .Where(kv => IsBackupSpecActive(kv.Value)) .Select(kv => kv.Key) .ToList(); + var monitorsToDisable = backupSpecs + .Where(kv => !IsBackupSpecActive(kv.Value)) + .Select(kv => kv.Key) + .ToList(); + if (ctx.FocusWasInactive && !string.IsNullOrWhiteSpace(ctx.FocusMonitor) && + !monitorsToDisable.Contains(ctx.FocusMonitor, StringComparer.OrdinalIgnoreCase)) + { + monitorsToDisable.Add(ctx.FocusMonitor); + } - if (allDeviceNames.Count > 0) + // 1. Re-attach only the screens that were on. Enabling the ones that were off too (as 1.5's tool did) + // made Windows lay them out and then re-flow everything when they were detached again. + var restored = true; + if (monitorsToVerify.Count > 0) { - _backend.Enable(allDeviceNames); + _backend.Enable(monitorsToVerify); - if (monitorsToVerify.Count > 0 && !WaitMonitorsActive(monitorsToVerify)) + if (!WaitMonitorsActive(monitorsToVerify)) { foreach (var name in monitorsToVerify) _backend.Enable([name]); @@ -136,43 +144,55 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state) AppLog.Write($"Restore: /enable não confirmou todos ({string.Join('+', monitorsToVerify)}); usando ExtendAll"); CcdHelper.ExtendAll(); if (!WaitMonitorsActive(monitorsToVerify)) + { + restored = false; result.Issues.Add($"Nem todos os monitores foram reativados: {string.Join(", ", monitorsToVerify)}"); + } } } } - if (!string.IsNullOrWhiteSpace(ctx.OriginalPrimary)) + // 2. Detach the screens that were off BEFORE placing the others: while the TV is still part of the + // desktop, Windows keeps the positions around it and moves the other screens when it goes. If the + // originals didn't come back, the TV is the only picture left: leave it on. + if (restored) { - backupSpecs.TryGetValue(ctx.OriginalPrimary, out var primarySpec); - if (!RestorePrimaryWithRetry(ctx.OriginalPrimary, primarySpec)) - result.Issues.Add($"Não foi possível restaurar o primário para {ctx.OriginalPrimary}"); + foreach (var name in monitorsToDisable) + { + if (string.Equals(name, ctx.OriginalPrimary, StringComparison.OrdinalIgnoreCase)) continue; + if (!DisableWithRetry(name)) + result.Issues.Add($"Não foi possível desconectar {name}"); + } + } + else + { + AppLog.Write("Restore: telas originais não voltaram; a tela de jogo fica ligada para não ficar sem imagem"); } - _backend.LoadLayout(AppPaths.BackupMonitorConfig); - Thread.Sleep(800); - + // 3. Primary, then every position and mode of the backup in one go, and check that it stuck. if (!string.IsNullOrWhiteSpace(ctx.OriginalPrimary)) { backupSpecs.TryGetValue(ctx.OriginalPrimary, out var primarySpec); if (!RestorePrimaryWithRetry(ctx.OriginalPrimary, primarySpec)) - result.Issues.Add($"Primário não confirmado em {ctx.OriginalPrimary} após LoadConfig"); + result.Issues.Add($"Não foi possível restaurar o primário para {ctx.OriginalPrimary}"); } - var monitorsToDisable = backupSpecs - .Where(kv => !IsBackupSpecActive(kv.Value)) - .Select(kv => kv.Key) - .ToList(); - if (ctx.FocusWasInactive && !string.IsNullOrWhiteSpace(ctx.FocusMonitor) && - !monitorsToDisable.Contains(ctx.FocusMonitor, StringComparer.OrdinalIgnoreCase)) + const int layoutAttempts = 3; + for (var attempt = 1; attempt <= layoutAttempts; attempt++) { - monitorsToDisable.Add(ctx.FocusMonitor); + _backend.LoadLayout(AppPaths.BackupMonitorConfig); + Thread.Sleep(800); + var wrong = LayoutMismatches(backupSpecs); + if (wrong.Count == 0) break; + AppLog.Write($"Restore: posições fora do backup em {string.Join('+', wrong)} (tentativa {attempt}/{layoutAttempts})"); + if (attempt == layoutAttempts) result.Issues.Add($"Posição das telas diferente do backup: {string.Join(", ", wrong)}"); } - foreach (var name in monitorsToDisable) + if (!string.IsNullOrWhiteSpace(ctx.OriginalPrimary)) { - if (string.Equals(name, ctx.OriginalPrimary, StringComparison.OrdinalIgnoreCase)) continue; - if (!DisableWithRetry(name)) - result.Issues.Add($"Não foi possível desconectar {name}"); + backupSpecs.TryGetValue(ctx.OriginalPrimary, out var primarySpec); + if (!RestorePrimaryWithRetry(ctx.OriginalPrimary, primarySpec)) + result.Issues.Add($"Primário não confirmado em {ctx.OriginalPrimary} após restaurar o layout"); } ClearCache(); @@ -181,6 +201,17 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state) return result; } + /// Screens that were on in the backup whose position (or state) is not the saved one right now. + private List LayoutMismatches(Dictionary> backupSpecs) + { + var now = GetMonitors(true).Select(m => + { + ParseLeftTop(m.LeftTop, out var x, out var y); + return (m.Name, m.IsActive, x ?? 0, y ?? 0); + }); + return DisplayIdentity.LayoutMismatches(backupSpecs, now); + } + public void SetPrimary(string monitorName) { _backend.SetPrimary(monitorName); diff --git a/tests/ConsoleMode.Tests/DisplayIdentityTests.cs b/tests/ConsoleMode.Tests/DisplayIdentityTests.cs index 0e2ffb6..201ec0a 100644 --- a/tests/ConsoleMode.Tests/DisplayIdentityTests.cs +++ b/tests/ConsoleMode.Tests/DisplayIdentityTests.cs @@ -143,4 +143,50 @@ private static void WriteText(byte[] edid, int offset, byte tag, string text) var bytes = Encoding.ASCII.GetBytes(text + "\n"); for (var i = 0; i < 13; i++) edid[offset + 5 + i] = i < bytes.Length ? bytes[i] : (byte)0x20; } + + // The desk before the session: DISPLAY1 at the origin, DISPLAY2 right below it, the TV off. + private static Dictionary> DeskBackup() => DisplayIdentity.ParseLayout(DisplayIdentity.FormatLayout( + [ + new LayoutEntry(@"\\.\DISPLAY1", "A", "", 32, 2560, 1080, 144, 0, 0), + new LayoutEntry(@"\\.\DISPLAY2", "B", "", 32, 2560, 1440, 144, 0, 1080), + new LayoutEntry(@"\\.\DISPLAY3", "TV", "", 0, 0, 0, 0, 0, 0) + ]).Split("\r\n")); + + [Fact] + public void Layout_check_passes_when_every_screen_that_was_on_is_where_it_was() + { + var wrong = DisplayIdentity.LayoutMismatches(DeskBackup(), + [ + (@"\\.\DISPLAY1", true, 0, 0), + (@"\\.\DISPLAY2", true, 0, 1080), + (@"\\.\DISPLAY3", false, 0, 0) // the TV was off: its state is not part of the check + ]); + Assert.Empty(wrong); + } + + [Fact] + public void Layout_check_flags_a_screen_that_slid_aside() + { + var wrong = DisplayIdentity.LayoutMismatches(DeskBackup(), + [ + (@"\\.\DISPLAY1", true, 0, 0), + (@"\\.\DISPLAY2", true, -2560, 1080), + (@"\\.\DISPLAY3", false, 0, 0) + ]); + Assert.Equal(new[] { @"\\.\DISPLAY2" }, wrong); + } + + [Fact] + public void Layout_check_flags_a_screen_that_is_off_or_missing() + { + var wrong = DisplayIdentity.LayoutMismatches(DeskBackup(), [(@"\\.\DISPLAY1", false, 0, 0)]); + Assert.Equal(new[] { @"\\.\DISPLAY1", @"\\.\DISPLAY2" }, wrong.Order()); + } + + [Fact] + public void Layout_check_ignores_backups_without_a_position() + { + var specs = DisplayIdentity.ParseLayout(["[Monitor0]", @"Name=\\.\DISPLAY1", "Width=1920", "Height=1080"]); + Assert.Empty(DisplayIdentity.LayoutMismatches(specs, [(@"\\.\DISPLAY1", true, 500, 500)])); + } } From 96404db26581b072b777cd65d8ed2b1025b7fd6b Mon Sep 17 00:00:00 2001 From: nextestudios Date: Thu, 1 Oct 2026 00:00:55 -0300 Subject: [PATCH 2/3] fix: keep the TV detached after the layout is applied LoadLayout flushes the registry's display settings, which can bring a screen that was just detached (the TV) back. After applying the layout, detach again whatever should be off and is on, then put the positions back with CCD, which only touches active screens and never re-attaches one. The log now says which screens are meant to stay off and when one came back. Co-Authored-By: Claude Sonnet 5.5 --- src/ConsoleMode/Native/CcdHelper.cs | 31 ++++++++++++++ src/ConsoleMode/Services/DisplayBackend.cs | 1 + src/ConsoleMode/Services/MonitorService.cs | 48 +++++++++++++++++++++- 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/ConsoleMode/Native/CcdHelper.cs b/src/ConsoleMode/Native/CcdHelper.cs index 6263813..041818e 100644 --- a/src/ConsoleMode/Native/CcdHelper.cs +++ b/src/ConsoleMode/Native/CcdHelper.cs @@ -276,6 +276,37 @@ public static int SetHdrState(string gdiDeviceName, bool enable) return DisplayConfigGetDeviceInfo(ref req) != 0 ? null : req.viewGdiDeviceName; } + /// + /// Moves active screens to the given desktop positions in one CCD call. Only the paths that are active + /// right now are touched, so a screen that is detached stays detached (unlike the registry-based + /// ChangeDisplaySettingsEx flush, which can bring one back). 0 = nothing to change or applied. + /// + public static int SetPositions(IReadOnlyDictionary positions) + { + var err = GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, out var numPaths, out var numModes); + if (err != 0) return err; + var paths = new PATH_INFO[numPaths]; + var modes = new MODE_INFO[numModes]; + err = QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, ref numPaths, paths, ref numModes, modes, 0); + if (err != 0) return err; + + var changed = false; + for (var i = 0; i < numModes; i++) + { + if (modes[i].infoType != MODE_INFO_TYPE_SOURCE) continue; + var name = GetSourceGdiName(modes[i].adapterId, modes[i].id); + if (name is null || !positions.TryGetValue(name, out var target)) continue; + if (modes[i].mode.sourceMode.position.x == target.X && modes[i].mode.sourceMode.position.y == target.Y) continue; + modes[i].mode.sourceMode.position.x = target.X; + modes[i].mode.sourceMode.position.y = target.Y; + changed = true; + } + if (!changed) return 0; + + return SetDisplayConfig(numPaths, paths, numModes, modes, + SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_SAVE_TO_DATABASE | SDC_ALLOW_CHANGES); + } + public static int SetPrimary(string gdiDeviceName) { var err = GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, out var numPaths, out var numModes); diff --git a/src/ConsoleMode/Services/DisplayBackend.cs b/src/ConsoleMode/Services/DisplayBackend.cs index 79dc19f..46e8006 100644 --- a/src/ConsoleMode/Services/DisplayBackend.cs +++ b/src/ConsoleMode/Services/DisplayBackend.cs @@ -114,6 +114,7 @@ public Dictionary> ResolveLayout(Dictionary names) => NativeDisplays.Enable(names); public void Disable(IReadOnlyList names) => NativeDisplays.Disable(names); public void SetPrimary(string name) => CcdHelper.SetPrimary(name); + public int SetPositions(IReadOnlyDictionary positions) => CcdHelper.SetPositions(positions); public void PowerOn(string name) => NativeDisplays.SetPower(name, on: true); public void PowerOff(string name) => NativeDisplays.SetPower(name, on: false); diff --git a/src/ConsoleMode/Services/MonitorService.cs b/src/ConsoleMode/Services/MonitorService.cs index e1a40e5..84e7020 100644 --- a/src/ConsoleMode/Services/MonitorService.cs +++ b/src/ConsoleMode/Services/MonitorService.cs @@ -157,6 +157,7 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state) // originals didn't come back, the TV is the only picture left: leave it on. if (restored) { + AppLog.Write($"Restore: telas que voltam a ficar desligadas: {(monitorsToDisable.Count == 0 ? "nenhuma" : string.Join('+', monitorsToDisable))}"); foreach (var name in monitorsToDisable) { if (string.Equals(name, ctx.OriginalPrimary, StringComparison.OrdinalIgnoreCase)) continue; @@ -185,7 +186,36 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state) var wrong = LayoutMismatches(backupSpecs); if (wrong.Count == 0) break; AppLog.Write($"Restore: posições fora do backup em {string.Join('+', wrong)} (tentativa {attempt}/{layoutAttempts})"); - if (attempt == layoutAttempts) result.Issues.Add($"Posição das telas diferente do backup: {string.Join(", ", wrong)}"); + } + + // LoadLayout applies the registry's display settings, and a screen that was just detached (the TV) can + // come back with them: detach it again. Doing that can move the others, so the positions are put back + // by CCD, which only touches the screens that are on and never brings a detached one back. + if (restored) + { + for (var round = 1; round <= 2; round++) + { + var back = monitorsToDisable + .Where(n => !string.Equals(n, ctx.OriginalPrimary, StringComparison.OrdinalIgnoreCase) && IsActiveNow(n)) + .ToList(); + if (back.Count == 0) break; + AppLog.Write($"Restore: {string.Join('+', back)} voltou a ligar ao aplicar o layout; desconectando de novo ({round}/2)"); + foreach (var name in back) + { + if (!DisableWithRetry(name) && round == 2) + result.Issues.Add($"Não foi possível desconectar {name}"); + } + } + } + + var misplaced = LayoutMismatches(backupSpecs); + if (misplaced.Count > 0) + { + var code = _backend.SetPositions(SavedPositions(backupSpecs)); + AppLog.Write($"Restore: posições acertadas por CCD => {code}"); + Thread.Sleep(600); + misplaced = LayoutMismatches(backupSpecs); + if (misplaced.Count > 0) result.Issues.Add($"Posição das telas diferente do backup: {string.Join(", ", misplaced)}"); } if (!string.IsNullOrWhiteSpace(ctx.OriginalPrimary)) @@ -201,6 +231,22 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state) return result; } + private bool IsActiveNow(string name) => + GetMonitors(true).Any(m => m.IsActive && string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase)); + + /// Saved desktop position of every screen that was on. + private static Dictionary SavedPositions(Dictionary> backupSpecs) + { + var positions = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (name, spec) in backupSpecs) + { + if (!IsBackupSpecActive(spec)) continue; + if (int.TryParse(spec.GetValueOrDefault("PositionX"), out var x) && int.TryParse(spec.GetValueOrDefault("PositionY"), out var y)) + positions[name] = (x, y); + } + return positions; + } + /// Screens that were on in the backup whose position (or state) is not the saved one right now. private List LayoutMismatches(Dictionary> backupSpecs) { From 7a2b1757d7913738d0c5447f28117f4a188932ac Mon Sep 17 00:00:00 2001 From: nextestudios Date: Thu, 1 Oct 2026 00:08:49 -0300 Subject: [PATCH 3/3] docs(changelog): reference #134 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.en-US.md | 3 +++ CHANGELOG.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 4a7cc71..6b427aa 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -4,6 +4,9 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ## [Unreleased] +### Fixes +- Displays: going back to the PC restores the exact layout from before (the second monitor no longer slides aside) and keeps the TV off. Only the screens that were on come back, the TV is detached before the positions, and the result is checked against the backup and redone if needed. (#134) + ## [1.6.0-alpha.5] ### Fixes diff --git a/CHANGELOG.md b/CHANGELOG.md index 5074c8d..75539b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ## [Unreleased] +### Correções +- Telas: ao voltar para o PC o layout volta idêntico ao de antes (a tela 2 não desliza mais para o lado) e a TV continua desligada. Só voltam as telas que estavam ligadas, a TV é desconectada antes das posições, o resultado é conferido com o backup e refeito se preciso. (#134) + ## [1.6.0-alpha.5] ### Correções