From 58fd10fa02d415ab01266639b0175e054414fdeb Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Tue, 29 Sep 2026 09:41:10 -0300 Subject: [PATCH] fix(displays): keep a monitor's rotation in the layout backup The native layout code wrote DisplayOrientation=0 for every monitor and never applied the saved value, so a monitor turned to portrait (e.g. 1080x1920 at 90 degrees) came back in landscape after restoring the desk. MultiMonitorTool saved and restored it. - LayoutEntry gets Orientation (default 0); FormatLayout writes it. - SaveLayout reads DEVMODE.dmDisplayOrientation for active monitors. - LoadLayout passes the saved orientation to QueueMode, which sets DM_DISPLAYORIENTATION only when it differs from the current one, so setups that never rotate make the same call. - A backup without the key leaves the rotation alone. Found by running the alpha against the real 1.5 config and backup of a machine with a portrait monitor: a saved-and-rewritten backup differed in exactly that one line. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.en-US.md | 3 ++ CHANGELOG.md | 3 ++ docs/TESTING.md | 1 + src/ConsoleMode/Native/NativeDisplays.cs | 12 +++-- src/ConsoleMode/Native/NativeWindows.cs | 7 +++ src/ConsoleMode/Services/DisplayBackend.cs | 7 ++- src/ConsoleMode/Services/DisplayIdentity.cs | 9 ++-- .../ConsoleMode.Tests/DisplayIdentityTests.cs | 49 +++++++++++++++++++ 8 files changed, 83 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index b696c40..869c6b2 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -6,6 +6,9 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ### What's new - Settings → "Receive test versions (alpha and beta)": anyone can join the tests of upcoming versions from the app. Off by default; turning it on shows a warning that test versions can have bugs. +### Fixes +- Displays: a monitor turned to portrait (rotated) keeps its rotation in the layout backup, so restoring the desk puts it back in portrait. Before, the rotation was never saved or restored. (#108) + ### Behind the scenes - Publishing a stable release now also opens the update PR on winget (`winget install lippdev.ConsoleMode`). Test versions are skipped. (#106) diff --git a/CHANGELOG.md b/CHANGELOG.md index c189097..2741a2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ### Novidades - Ajustes → "Receber versões de teste (alpha e beta)": qualquer pessoa pode entrar nos testes das próximas versões pelo próprio app. Desligado por padrão; ao ligar, o app avisa que a versão pode ter bugs. +### Correções +- Telas: um monitor girado (em retrato) mantém a rotação no backup do layout, então restaurar a mesa devolve o monitor em retrato. Antes a rotação nunca era salva nem restaurada. (#108) + ### Por trás dos panos - Publicar uma versão estável agora também abre o PR de atualização no winget (`winget install lippdev.ConsoleMode`). Versões de teste ficam de fora. (#106) diff --git a/docs/TESTING.md b/docs/TESTING.md index ba03f50..67c130a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -142,6 +142,7 @@ Pré-condições: 1.6 instalada por cima da 1.5, com a configuração da 1.5 (te - [ ] TV começando **desligada**: "Jogar agora" liga a TV, ela vira a principal e as telas da mesa desligam. O log mostra `Telas: ativar …` e `Telas: desativar …` com `=> 0`. Resultado: ______ - [ ] Resolução/Hz salvos para a TV são aplicados. Resultado: ______ - [ ] Ao sair do Big Picture/Playnite, a mesa volta **exatamente** como estava: mesmas telas, principal, posições e resoluções. O log mostra `Telas: layout restaurado => 0`. Resultado: ______ +- [ ] Monitor **girado (retrato)**: depois de restaurar, ele volta girado, na mesma posição e resolução (ex.: 1080×1920 a 90°). O `backup_monitores.cfg` guarda `DisplayOrientation=1` para ele. Resultado: ______ - [ ] Repetir a sessão 3 vezes seguidas: nenhuma tela troca de nome (\\.\DISPLAYn) nem de posição entre uma sessão e outra. Resultado: ______ - [ ] Com a TV já **ligada** antes de começar: mesmo resultado. Resultado: ______ diff --git a/src/ConsoleMode/Native/NativeDisplays.cs b/src/ConsoleMode/Native/NativeDisplays.cs index d9a70aa..3fe403a 100644 --- a/src/ConsoleMode/Native/NativeDisplays.cs +++ b/src/ConsoleMode/Native/NativeDisplays.cs @@ -157,9 +157,9 @@ public static int Disable(IReadOnlyCollection gdiNames) /// /// Queues a mode and/or position for a monitor (applied by ); - /// zero or null keeps the current value. + /// zero or null keeps the current value; a negative orientation leaves the rotation alone. /// - public static int QueueMode(string gdiName, int width, int height, int frequency, int bitsPerPixel, int? x, int? y) + 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; @@ -178,6 +178,12 @@ public static int QueueMode(string gdiName, int width, int height, int frequency dm.dmPositionY = y.Value; dm.dmFields |= DmPosition; } + // Only when it differs from the current one: setups that never rotate keep the exact same call. + if (orientation >= 0 && orientation != dm.dmDisplayOrientation) + { + dm.dmDisplayOrientation = orientation; + dm.dmFields |= DmDisplayOrientation; + } if (dm.dmFields == 0) return 0; var code = ChangeDisplaySettingsEx(gdiName, ref dm, 0, CdsUpdateRegistry | CdsNoReset, 0); if (code != 0) AppLog.Write($"Telas: modo {width}x{height}@{frequency} em {gdiName} => {code}"); @@ -272,7 +278,7 @@ private static nint FindHMonitor(string gdiName) private static long Key(LUID id) => ((long)id.HighPart << 32) | id.LowPart; private const int ErrorInsufficientBuffer = 122; - private const int DmPosition = 0x20, DmBitsPerPel = 0x40000, DmPelsWidth = 0x80000, DmPelsHeight = 0x100000, DmDisplayFrequency = 0x400000; + private const int DmPosition = 0x20, DmDisplayOrientation = 0x80, DmBitsPerPel = 0x40000, DmPelsWidth = 0x80000, DmPelsHeight = 0x100000, DmDisplayFrequency = 0x400000; private const uint CdsUpdateRegistry = 0x1, CdsNoReset = 0x10000000; [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] diff --git a/src/ConsoleMode/Native/NativeWindows.cs b/src/ConsoleMode/Native/NativeWindows.cs index 4fa529d..472b14c 100644 --- a/src/ConsoleMode/Native/NativeWindows.cs +++ b/src/ConsoleMode/Native/NativeWindows.cs @@ -348,4 +348,11 @@ public static DisplayModeInfo[] EnumerateDisplayModes(string deviceName) return BuildDisplayModeInfo(dm.dmPelsWidth, dm.dmPelsHeight, dm.dmDisplayFrequency, dm.dmBitsPerPel, ConsoleMode.Services.LocalizationService.Get("CurrentModeSuffix")); } + + /// DEVMODE.dmDisplayOrientation of the current mode (0 landscape, 1 = 90°, 2 = 180°, 3 = 270°); 0 when unreadable. + public static int GetCurrentOrientation(string deviceName) + { + var dm = new DEVMODE { dmSize = (short)Marshal.SizeOf() }; + return EnumDisplaySettings(deviceName, ENUM_CURRENT_SETTINGS, ref dm) ? dm.dmDisplayOrientation : 0; + } } diff --git a/src/ConsoleMode/Services/DisplayBackend.cs b/src/ConsoleMode/Services/DisplayBackend.cs index d8c38a8..d6ee22f 100644 --- a/src/ConsoleMode/Services/DisplayBackend.cs +++ b/src/ConsoleMode/Services/DisplayBackend.cs @@ -82,7 +82,8 @@ public void SaveLayout(string path) var current = m.IsActive ? NativeWindows.GetCurrentDisplayMode(m.Name) : null; MonitorService.ParseLeftTop(m.LeftTop, out var x, out var y); return new LayoutEntry(m.Name, m.MonitorId, m.SerialNumber, current?.BitsPerPel ?? 0, - current?.Width ?? 0, current?.Height ?? 0, current?.Frequency ?? 0, x ?? 0, y ?? 0); + current?.Width ?? 0, current?.Height ?? 0, current?.Frequency ?? 0, x ?? 0, y ?? 0, + m.IsActive ? NativeWindows.GetCurrentOrientation(m.Name) : 0); }); File.WriteAllText(path, DisplayIdentity.FormatLayout(entries)); } @@ -99,7 +100,9 @@ public void LoadLayout(string path) int.TryParse(spec.GetValueOrDefault("BitsPerPixel"), out var bits); int? x = int.TryParse(spec.GetValueOrDefault("PositionX"), out var px) ? px : null; int? y = int.TryParse(spec.GetValueOrDefault("PositionY"), out var py) ? py : null; - NativeDisplays.QueueMode(name, w, h, f, bits, x, y); + // Missing (older backup) means: leave the rotation alone. + var orientation = int.TryParse(spec.GetValueOrDefault("DisplayOrientation"), out var o) ? o : -1; + NativeDisplays.QueueMode(name, w, h, f, bits, x, y, orientation); } var code = NativeDisplays.ApplyPending(); AppLog.Write($"Telas: layout restaurado => {code}"); diff --git a/src/ConsoleMode/Services/DisplayIdentity.cs b/src/ConsoleMode/Services/DisplayIdentity.cs index 369eb3f..27af1fb 100644 --- a/src/ConsoleMode/Services/DisplayIdentity.cs +++ b/src/ConsoleMode/Services/DisplayIdentity.cs @@ -3,9 +3,12 @@ namespace ConsoleMode.Services; -/// One monitor in the layout backup (MultiMonitorTool's .cfg format, kept for compatibility). +/// +/// One monitor in the layout backup (MultiMonitorTool's .cfg format, kept for compatibility). +/// Orientation is DEVMODE.dmDisplayOrientation: 0 landscape, 1 = 90°, 2 = 180°, 3 = 270°. +/// public sealed record LayoutEntry(string Name, string MonitorId, string Serial, int BitsPerPixel, - int Width, int Height, int Frequency, int PositionX, int PositionY); + int Width, int Height, int Frequency, int PositionX, int PositionY, int Orientation = 0); /// What the monitor's EDID says about itself. public sealed record EdidInfo(string Name, string Serial, int PreferredWidth, int PreferredHeight); @@ -123,7 +126,7 @@ public static string FormatLayout(IEnumerable entries) sb.Append($"Height={e.Height}\r\n"); sb.Append("DisplayFlags=0\r\n"); sb.Append($"DisplayFrequency={e.Frequency}\r\n"); - sb.Append("DisplayOrientation=0\r\n"); + sb.Append($"DisplayOrientation={e.Orientation}\r\n"); sb.Append($"PositionX={e.PositionX}\r\n"); sb.Append($"PositionY={e.PositionY}\r\n"); } diff --git a/tests/ConsoleMode.Tests/DisplayIdentityTests.cs b/tests/ConsoleMode.Tests/DisplayIdentityTests.cs index e99b774..774c3ab 100644 --- a/tests/ConsoleMode.Tests/DisplayIdentityTests.cs +++ b/tests/ConsoleMode.Tests/DisplayIdentityTests.cs @@ -88,6 +88,55 @@ public void Layout_file_round_trips_and_follows_renamed_monitors() Assert.Equal(@"\\.\DISPLAY2", remapped[@"\\.\DISPLAY2"]["Name"]); } + [Fact] + public void Layout_file_keeps_a_rotated_monitor_portrait() + { + // A monitor turned to portrait: 1080x1920 at 90 degrees. Restoring it needs the rotation too. + var text = DisplayIdentity.FormatLayout( + [ + new LayoutEntry(@"\\.\DISPLAY2", @"MONITOR\HWP3318\{x}\0005", "SERIAL1", 32, 1080, 1920, 60, 1920, -301, Orientation: 1) + ]); + var specs = DisplayIdentity.ParseLayout(text.Split("\r\n")); + + Assert.Equal("1", specs[@"\\.\DISPLAY2"]["DisplayOrientation"]); + Assert.Equal("1080", specs[@"\\.\DISPLAY2"]["Width"]); + Assert.Equal("1920", specs[@"\\.\DISPLAY2"]["Height"]); + } + + [Fact] + public void Layout_file_writes_landscape_when_no_orientation_is_given() + { + var text = DisplayIdentity.FormatLayout( + [new LayoutEntry(@"\\.\DISPLAY1", @"MONITOR\DEL4083\{x}\0001", "", 32, 1920, 1080, 60, 0, 0)]); + + Assert.Contains("DisplayOrientation=0\r\n", text); + } + + [Fact] + public void A_backup_made_by_1_5_is_written_back_unchanged() + { + // The shape of a real 1.5 backup (MultiMonitorTool): a portrait side monitor and a TV that was off. + string[] original = + [ + "[Monitor0]", @"Name=\\.\DISPLAY1", @"MonitorID=MONITOR\TCL1003\{4d36e96e-e325-11ce-bfc1-08002be10318}\0003", + "SerialNumber=", "BitsPerPixel=32", "Width=1920", "Height=1080", "DisplayFlags=0", "DisplayFrequency=240", + "DisplayOrientation=0", "PositionX=0", "PositionY=0", + "[Monitor1]", @"Name=\\.\DISPLAY2", @"MonitorID=MONITOR\HWP3318\{4d36e96e-e325-11ce-bfc1-08002be10318}\0005", + "SerialNumber=SN00000001", "BitsPerPixel=32", "Width=1080", "Height=1920", "DisplayFlags=0", "DisplayFrequency=60", + "DisplayOrientation=1", "PositionX=1920", "PositionY=-301", + "[Monitor2]", @"Name=\\.\DISPLAY3", @"MonitorID=MONITOR\GSM0001\{4d36e96e-e325-11ce-bfc1-08002be10318}\0002", + "SerialNumber=", "BitsPerPixel=0", "Width=0", "Height=0", "DisplayFlags=0", "DisplayFrequency=0", + "DisplayOrientation=0", "PositionX=0", "PositionY=0" + ]; + + var entries = DisplayIdentity.ParseLayout(original).Values.Select(s => new LayoutEntry( + s["Name"], s["MonitorID"], s.GetValueOrDefault("SerialNumber") ?? "", int.Parse(s["BitsPerPixel"]), + int.Parse(s["Width"]), int.Parse(s["Height"]), int.Parse(s["DisplayFrequency"]), + int.Parse(s["PositionX"]), int.Parse(s["PositionY"]), int.Parse(s["DisplayOrientation"]))).ToList(); + + Assert.Equal(original, DisplayIdentity.FormatLayout(entries).Split("\r\n", StringSplitOptions.RemoveEmptyEntries)); + } + private static void WriteText(byte[] edid, int offset, byte tag, string text) { edid[offset + 3] = tag;