diff --git a/docs/TESTING.md b/docs/TESTING.md
index 7363cfb..fb3ff76 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -126,3 +126,33 @@ Pré-condições: app **fechado**; em `config.json` (na pasta de dados) acrescen
- [ ] 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: ______
- [ ] Com `"NativeAudio": true` e **sem** o `SoundVolumeView.exe` na pasta `tools`, tudo acima continua funcionando. Resultado: ______
+
+## Telas nativas, sem MultiMonitorTool (issue #91)
+
+Pré-condições: app **fechado**; em `config.json` acrescente `"NativeDisplays": true` e abra o app. A linha `Startup:` do log deve mostrar `displays=native`. Para voltar ao MultiMonitorTool, troque para `false`. Faça primeiro uma rodada com `false` e anote o que aparece, para comparar.
+
+### Lista e identidade
+
+- [ ] O mapa de telas mostra os mesmos monitores que com o MultiMonitorTool, com os mesmos nomes, resoluções e posições, inclusive a TV **desligada/desconectada**. Resultado: ______
+- [ ] A tela de jogo e as telas a esconder que já estavam salvas continuam marcadas (o ID estável é o mesmo). Resultado: ______
+- [ ] A lista de resoluções de cada tela continua igual. Resultado: ______
+
+### Sessão com "Desconectar"
+
+- [ ] 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: ______
+- [ ] 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: ______
+
+### Outras estratégias
+
+- [ ] "Cortinas pretas": as telas da mesa ficam pretas e voltam ao restaurar. Resultado: ______
+- [ ] "Desligar por DDC/CI" (monitor com DDC/CI ligado no menu dele): as telas da mesa apagam e voltam a acender ao restaurar. Resultado: ______
+
+### Janelas e recuperação
+
+- [ ] Big Picture ou Playnite abrindo na tela errada é movido para a TV. Resultado: ______
+- [ ] Fechar o app no meio da sessão e abrir de novo: "Restaurar setup" traz a mesa de volta a partir do backup. Resultado: ______
+- [ ] Um backup antigo, feito com o MultiMonitorTool, também é restaurado com `"NativeDisplays": true`. Resultado: ______
+- [ ] Com `"NativeDisplays": true` e **sem** o `MultiMonitorTool.exe` na pasta `tools`, tudo acima continua funcionando. Resultado: ______
diff --git a/src/ConsoleMode/App.xaml.cs b/src/ConsoleMode/App.xaml.cs
index fb29674..2e98729 100644
--- a/src/ConsoleMode/App.xaml.cs
+++ b/src/ConsoleMode/App.xaml.cs
@@ -77,7 +77,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args)
AppLog.Write("Controles: " + ControllerInput.DescribeDevices().ReplaceLineEndings(" | "));
ProtocolService.EnsureRegistered();
AppLog.Write($"Startup: exe={Environment.ProcessPath}, args={string.Join(' ', Environment.GetCommandLineArgs().Skip(1))}, " +
- $"mmt={AppPaths.HasMmt}, svv={AppPaths.HasSvv}, audio={(AudioService.UseNative ? "native" : "svv")}, rtss-cli={AppPaths.HasRtssCli}");
+ $"mmt={AppPaths.HasMmt}, displays={(MonitorService.UseNative ? "native" : "mmt")}, svv={AppPaths.HasSvv}, audio={(AudioService.UseNative ? "native" : "svv")}, rtss-cli={AppPaths.HasRtssCli}");
ViewModel = new MainViewModel();
_window = new MainWindow(ViewModel);
diff --git a/src/ConsoleMode/Models/Models.cs b/src/ConsoleMode/Models/Models.cs
index 3e84d23..42763cf 100644
--- a/src/ConsoleMode/Models/Models.cs
+++ b/src/ConsoleMode/Models/Models.cs
@@ -21,6 +21,8 @@ public sealed class AppConfig
public bool AudioAutoSwitch { get; set; }
/// Use Core Audio instead of SoundVolumeView (issue #91); off until verified on hardware.
public bool NativeAudio { get; set; }
+ /// Use Windows' display APIs instead of MultiMonitorTool (issue #91); off until verified on hardware.
+ public bool NativeDisplays { get; set; }
public int FpsLimit { get; set; }
public Dictionary MonitorModes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public bool HdrEnable { get; set; }
diff --git a/src/ConsoleMode/Native/CcdHelper.cs b/src/ConsoleMode/Native/CcdHelper.cs
index 81b3f9c..6263813 100644
--- a/src/ConsoleMode/Native/CcdHelper.cs
+++ b/src/ConsoleMode/Native/CcdHelper.cs
@@ -261,7 +261,7 @@ public static int SetHdrState(string gdiDeviceName, bool enable)
return DisplayConfigSetDeviceInfo(ref state);
}
- private static string? GetSourceGdiName(LUID adapterId, uint sourceId)
+ internal static string? GetSourceGdiName(LUID adapterId, uint sourceId)
{
var req = new SOURCE_DEVICE_NAME
{
diff --git a/src/ConsoleMode/Native/NativeDisplays.cs b/src/ConsoleMode/Native/NativeDisplays.cs
new file mode 100644
index 0000000..d9a70aa
--- /dev/null
+++ b/src/ConsoleMode/Native/NativeDisplays.cs
@@ -0,0 +1,339 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using ConsoleMode.Models;
+using ConsoleMode.Services;
+using static ConsoleMode.Native.CcdHelper;
+
+namespace ConsoleMode.Native;
+
+///
+/// Displays through Windows' own APIs (CCD, ChangeDisplaySettingsEx, DDC/CI via dxva2), replacing
+/// MultiMonitorTool (issue #91). Monitors are named by their GDI source (\\.\DISPLAYn) like before.
+///
+internal static class NativeDisplays
+{
+ /// A connected monitor and the video source it uses (or would use when enabled).
+ public sealed record Monitor(string GdiName, LUID AdapterId, uint SourceId, uint TargetId, bool IsActive,
+ int PositionX, int PositionY, string FriendlyName, string DevicePath);
+
+ private const uint QdcAllPaths = 0x1;
+ private const uint QdcOnlyActivePaths = 0x2;
+ private const uint PathActive = 0x1;
+ private const uint ModeIdxInvalid = 0xFFFFFFFF;
+ private const uint ModeInfoTypeSource = 1;
+ private const uint SdcApply = 0x80, SdcUseSuppliedDisplayConfig = 0x20, SdcSaveToDatabase = 0x200, SdcAllowChanges = 0x400;
+ private const uint DeviceInfoGetTargetName = 2;
+
+ // Target (adapter + target id) → source it last had while active, so re-enabling keeps the same \\.\DISPLAYn.
+ private static readonly Dictionary<(long Adapter, uint Target), uint> LastSource = [];
+ private static readonly object Gate = new();
+
+ public static List ListMonitors()
+ {
+ if (!Query(QdcAllPaths, out var paths, out var modes)) return [];
+
+ var targets = new List<(long Adapter, uint Target, LUID AdapterId)>();
+ var candidates = new Dictionary<(long, uint), List>();
+ var active = new Dictionary<(long, uint), int>();
+ for (var i = 0; i < paths.Length; i++)
+ {
+ ref var p = ref paths[i];
+ if (!p.targetInfo.targetAvailable) continue;
+ var key = (Key(p.targetInfo.adapterId), p.targetInfo.id);
+ if (!candidates.TryGetValue(key, out var list))
+ {
+ candidates[key] = list = [];
+ targets.Add((key.Item1, key.Item2, p.targetInfo.adapterId));
+ }
+ // Sources are per adapter; this path's source lives on the same adapter as the target.
+ if (!list.Contains(p.sourceInfo.id)) list.Add(p.sourceInfo.id);
+ if ((p.flags & PathActive) != 0 && !active.ContainsKey(key)) active[key] = i;
+ }
+
+ Dictionary<(long, uint), (long Adapter, uint Source)> assigned;
+ lock (Gate)
+ {
+ foreach (var (key, index) in active) LastSource[key] = paths[index].sourceInfo.id;
+ // A source is identified by its adapter too, so sources of different adapters never collide.
+ assigned = DisplayIdentity.AssignSources<(long, uint), (long Adapter, uint Source)>(targets.Select(t =>
+ {
+ var key = (t.Adapter, t.Target);
+ (long, uint)? activeSource = active.TryGetValue(key, out var idx) ? (t.Adapter, paths[idx].sourceInfo.id) : null;
+ (long, uint)? preferred = LastSource.TryGetValue(key, out var last) ? (t.Adapter, last) : null;
+ IReadOnlyList<(long, uint)> list = candidates[key].Select(s => (t.Adapter, s)).ToList();
+ return (key, activeSource, preferred, list);
+ }));
+ }
+
+ var result = new List();
+ foreach (var t in targets)
+ {
+ var key = (t.Adapter, t.Target);
+ if (!assigned.TryGetValue(key, out var source)) continue;
+ var sourceId = source.Source;
+ var gdi = GetSourceGdiName(t.AdapterId, sourceId);
+ if (string.IsNullOrWhiteSpace(gdi)) continue;
+
+ int x = 0, y = 0;
+ var isActive = active.TryGetValue(key, out var pathIndex);
+ if (isActive)
+ {
+ var modeIdx = paths[pathIndex].sourceInfo.modeInfoIdx;
+ if (modeIdx < modes.Length && modes[modeIdx].infoType == ModeInfoTypeSource)
+ {
+ x = modes[modeIdx].mode.sourceMode.position.x;
+ y = modes[modeIdx].mode.sourceMode.position.y;
+ }
+ }
+ var (friendly, devicePath) = GetTargetName(t.AdapterId, t.Target);
+ result.Add(new Monitor(gdi, t.AdapterId, sourceId, t.Target, isActive, x, y, friendly, devicePath));
+ }
+ return result;
+ }
+
+ /// Attaches inactive monitors (by the name gave them) to the desktop.
+ public static int Enable(IReadOnlyCollection gdiNames)
+ {
+ var wanted = ListMonitors()
+ .Where(m => !m.IsActive && gdiNames.Contains(m.GdiName, StringComparer.OrdinalIgnoreCase))
+ .ToList();
+ if (wanted.Count == 0) return 0;
+ if (!Query(QdcAllPaths, out var paths, out var modes)) return -1;
+
+ var selected = new List();
+ foreach (var p in paths)
+ if ((p.flags & PathActive) != 0) selected.Add(p);
+
+ foreach (var m in wanted)
+ {
+ var index = Array.FindIndex(paths, p =>
+ p.targetInfo.targetAvailable && p.targetInfo.id == m.TargetId && p.sourceInfo.id == m.SourceId &&
+ Key(p.targetInfo.adapterId) == Key(m.AdapterId));
+ if (index < 0)
+ {
+ AppLog.Write($"Telas: caminho para {m.GdiName} não encontrado");
+ continue;
+ }
+ var path = paths[index];
+ path.flags |= PathActive;
+ path.sourceInfo.modeInfoIdx = ModeIdxInvalid;
+ path.targetInfo.modeInfoIdx = ModeIdxInvalid;
+ selected.Add(path);
+ }
+
+ var code = SetDisplayConfig((uint)selected.Count, [.. selected], (uint)modes.Length, modes,
+ SdcApply | SdcUseSuppliedDisplayConfig | SdcAllowChanges | SdcSaveToDatabase);
+ AppLog.Write($"Telas: ativar {string.Join('+', wanted.Select(m => m.GdiName))} => {code}");
+ return code;
+ }
+
+ /// Detaches monitors from the desktop. Refuses to detach every active one.
+ public static int Disable(IReadOnlyCollection gdiNames)
+ {
+ if (!Query(QdcOnlyActivePaths, out var paths, out var modes)) return -1;
+ var remaining = 0;
+ var changed = false;
+ for (var i = 0; i < paths.Length; i++)
+ {
+ var name = GetSourceGdiName(paths[i].sourceInfo.adapterId, paths[i].sourceInfo.id);
+ if (name is not null && gdiNames.Contains(name, StringComparer.OrdinalIgnoreCase))
+ {
+ paths[i].flags = 0;
+ changed = true;
+ }
+ else remaining++;
+ }
+ if (!changed) return 0;
+ if (remaining == 0)
+ {
+ AppLog.Write($"Telas: desativar {string.Join('+', gdiNames)} deixaria o PC sem tela; ignorado");
+ return -2;
+ }
+ var code = SetDisplayConfig((uint)paths.Length, paths, (uint)modes.Length, modes,
+ SdcApply | SdcUseSuppliedDisplayConfig | SdcSaveToDatabase | SdcAllowChanges);
+ AppLog.Write($"Telas: desativar {string.Join('+', gdiNames)} => {code}");
+ return code;
+ }
+
+ ///
+ /// Queues a mode and/or position for a monitor (applied by );
+ /// zero or null keeps the current value.
+ ///
+ public static int QueueMode(string gdiName, int width, int height, int frequency, int bitsPerPixel, int? x, int? y)
+ {
+ var dm = new NativeWindows.DEVMODE { dmSize = (short)Marshal.SizeOf() };
+ if (!NativeWindows.EnumDisplaySettings(gdiName, NativeWindows.ENUM_CURRENT_SETTINGS, ref dm)) return -1;
+ dm.dmFields = 0;
+ if (width > 0 && height > 0)
+ {
+ dm.dmPelsWidth = width;
+ dm.dmPelsHeight = height;
+ dm.dmFields |= DmPelsWidth | DmPelsHeight;
+ }
+ if (frequency > 0) { dm.dmDisplayFrequency = frequency; dm.dmFields |= DmDisplayFrequency; }
+ if (bitsPerPixel > 0) { dm.dmBitsPerPel = bitsPerPixel; dm.dmFields |= DmBitsPerPel; }
+ if (x is not null && y is not null)
+ {
+ dm.dmPositionX = x.Value;
+ dm.dmPositionY = y.Value;
+ dm.dmFields |= DmPosition;
+ }
+ 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}");
+ return code;
+ }
+
+ /// Applies every change queued by at once.
+ public static int ApplyPending() => ChangeDisplaySettingsEx(null, 0, 0, 0, 0);
+
+ /// DDC/CI power (VCP 0xD6): on, or off (DPM) so it can still be woken over DDC.
+ public static bool SetPower(string gdiName, bool on)
+ {
+ var hMonitor = FindHMonitor(gdiName);
+ if (hMonitor == 0 || !GetNumberOfPhysicalMonitorsFromHMONITOR(hMonitor, out var count) || count == 0) return false;
+ var physical = new PHYSICAL_MONITOR[count];
+ if (!GetPhysicalMonitorsFromHMONITOR(hMonitor, count, physical)) return false;
+ try
+ {
+ var ok = true;
+ foreach (var p in physical) ok &= SetVCPFeature(p.hPhysicalMonitor, 0xD6, on ? 1u : 4u);
+ if (!ok) AppLog.Write($"Telas: DDC/CI {(on ? "ligar" : "desligar")} {gdiName} falhou");
+ return ok;
+ }
+ finally { DestroyPhysicalMonitors(count, physical); }
+ }
+
+ /// Moves the main windows of a process onto a rectangle.
+ public static void MoveProcessWindows(string processName, ScreenRect rect)
+ {
+ foreach (var p in Process.GetProcessesByName(processName))
+ {
+ using (p)
+ {
+ if (p.MainWindowHandle != 0)
+ NativeWindows.MoveWindowToRect(p.MainWindowHandle, rect.X, rect.Y, rect.Width, rect.Height);
+ }
+ }
+ }
+
+ private static bool Query(uint flags, out PATH_INFO[] paths, out MODE_INFO[] modes)
+ {
+ paths = [];
+ modes = [];
+ for (var attempt = 0; attempt < 3; attempt++)
+ {
+ if (GetDisplayConfigBufferSizes(flags, out var numPaths, out var numModes) != 0) return false;
+ paths = new PATH_INFO[numPaths];
+ modes = new MODE_INFO[numModes];
+ var err = QueryDisplayConfig(flags, ref numPaths, paths, ref numModes, modes, 0);
+ if (err == ErrorInsufficientBuffer) continue; // the setup changed between the two calls
+ if (err != 0) return false;
+ Array.Resize(ref paths, (int)numPaths);
+ Array.Resize(ref modes, (int)numModes);
+ return true;
+ }
+ return false;
+ }
+
+ private static (string Friendly, string DevicePath) GetTargetName(LUID adapterId, uint targetId)
+ {
+ var req = new TARGET_DEVICE_NAME
+ {
+ header = new DEVICE_INFO_HEADER
+ {
+ type = DeviceInfoGetTargetName,
+ size = (uint)Marshal.SizeOf(),
+ adapterId = adapterId,
+ id = targetId
+ }
+ };
+ return DisplayConfigGetDeviceInfo(ref req) == 0
+ ? (req.monitorFriendlyDeviceName ?? "", req.monitorDevicePath ?? "")
+ : ("", "");
+ }
+
+ private static nint FindHMonitor(string gdiName)
+ {
+ nint found = 0;
+ EnumDisplayMonitors(0, 0, (hMonitor, _, _, _) =>
+ {
+ var info = new MONITORINFOEX { cbSize = Marshal.SizeOf() };
+ if (GetMonitorInfo(hMonitor, ref info) && string.Equals(info.szDevice, gdiName, StringComparison.OrdinalIgnoreCase))
+ {
+ found = hMonitor;
+ return false;
+ }
+ return true;
+ }, 0);
+ return found;
+ }
+
+ 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 uint CdsUpdateRegistry = 0x1, CdsNoReset = 0x10000000;
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct TARGET_DEVICE_NAME
+ {
+ public DEVICE_INFO_HEADER header;
+ public uint flags;
+ public uint outputTechnology;
+ public ushort edidManufactureId;
+ public ushort edidProductCodeId;
+ public uint connectorInstance;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string monitorFriendlyDeviceName;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string monitorDevicePath;
+ }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct PHYSICAL_MONITOR
+ {
+ public nint hPhysicalMonitor;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string szPhysicalMonitorDescription;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct RECT { public int Left, Top, Right, Bottom; }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct MONITORINFOEX
+ {
+ public int cbSize;
+ public RECT rcMonitor;
+ public RECT rcWork;
+ public uint dwFlags;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string szDevice;
+ }
+
+ private delegate bool MonitorEnumProc(nint hMonitor, nint hdc, nint rect, nint data);
+
+ [DllImport("user32.dll")]
+ private static extern int DisplayConfigGetDeviceInfo(ref TARGET_DEVICE_NAME request);
+
+ [DllImport("user32.dll", CharSet = CharSet.Ansi)]
+ private static extern int ChangeDisplaySettingsEx(string? deviceName, ref NativeWindows.DEVMODE devMode, nint hwnd, uint flags, nint lParam);
+
+ [DllImport("user32.dll", CharSet = CharSet.Ansi)]
+ private static extern int ChangeDisplaySettingsEx(string? deviceName, nint devMode, nint hwnd, uint flags, nint lParam);
+
+ [DllImport("user32.dll")]
+ private static extern bool EnumDisplayMonitors(nint hdc, nint clip, MonitorEnumProc callback, nint data);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern bool GetMonitorInfo(nint hMonitor, ref MONITORINFOEX info);
+
+ [DllImport("dxva2.dll", SetLastError = true)]
+ private static extern bool GetNumberOfPhysicalMonitorsFromHMONITOR(nint hMonitor, out uint count);
+
+ [DllImport("dxva2.dll", SetLastError = true)]
+ private static extern bool GetPhysicalMonitorsFromHMONITOR(nint hMonitor, uint count, [Out] PHYSICAL_MONITOR[] monitors);
+
+ [DllImport("dxva2.dll", SetLastError = true)]
+ private static extern bool SetVCPFeature(nint hPhysicalMonitor, byte code, uint value);
+
+ [DllImport("dxva2.dll", SetLastError = true)]
+ private static extern bool DestroyPhysicalMonitors(uint count, PHYSICAL_MONITOR[] monitors);
+}
diff --git a/src/ConsoleMode/Services/ConsoleEngine.cs b/src/ConsoleMode/Services/ConsoleEngine.cs
index 9a11005..cd7d8bc 100644
--- a/src/ConsoleMode/Services/ConsoleEngine.cs
+++ b/src/ConsoleMode/Services/ConsoleEngine.cs
@@ -385,6 +385,7 @@ private AppConfig ResolveMonitorNames(AppConfig config)
AudioDeviceName = config.AudioDeviceName,
AudioAutoSwitch = config.AudioAutoSwitch,
NativeAudio = config.NativeAudio,
+ NativeDisplays = config.NativeDisplays,
FpsLimit = config.FpsLimit,
MonitorModes = modes,
HdrEnable = config.HdrEnable,
@@ -399,7 +400,7 @@ private AppConfig ResolveMonitorNames(AppConfig config)
private void MoveToFocus(string monitorName, nint[] handles, ScreenRect? rect)
{
if (rect is not null)
- Monitors.MoveWindowViaMmt(monitorName, rect, State.FullscreenMode == "playnite" ? "Playnite.FullscreenApp" : "steamwebhelper");
+ Monitors.MoveProcessWindows(monitorName, rect, State.FullscreenMode == "playnite" ? "Playnite.FullscreenApp" : "steamwebhelper");
if (rect is null) return;
foreach (var handle in handles)
diff --git a/src/ConsoleMode/Services/DisplayBackends.cs b/src/ConsoleMode/Services/DisplayBackends.cs
new file mode 100644
index 0000000..72fb992
--- /dev/null
+++ b/src/ConsoleMode/Services/DisplayBackends.cs
@@ -0,0 +1,244 @@
+using ConsoleMode.Models;
+using ConsoleMode.Native;
+using Microsoft.Win32;
+
+namespace ConsoleMode.Services;
+
+///
+/// The low-level display operations needs. MonitorService keeps the
+/// orchestration (retries, waits, fallbacks); a backend only runs single commands.
+///
+internal interface IDisplayBackend
+{
+ string Name { get; }
+ bool IsAvailable { get; }
+ List ListMonitors();
+ void SaveLayout(string path);
+ void LoadLayout(string path);
+ /// Layout backup sections keyed by the names monitors have now.
+ Dictionary> ResolveLayout(Dictionary> specs);
+ void Enable(IReadOnlyList names);
+ void Disable(IReadOnlyList names);
+ void SetPrimary(string name);
+ void SetMode(string name, int width, int height, int frequency, string? colors, int? x, int? y, bool primary);
+ void PowerOn(string name);
+ void PowerOff(string name);
+ void MoveProcessWindows(string monitorName, string processName, ScreenRect rect);
+}
+
+/// NirSoft MultiMonitorTool, the original backend.
+internal sealed class MmtDisplayBackend : IDisplayBackend
+{
+ public string Name => "mmt";
+ public bool IsAvailable => AppPaths.HasMmt;
+
+ private static int Run(params string[] args)
+ {
+ if (!AppPaths.HasMmt) throw new InvalidOperationException(LocalizationService.Get("MmtMissing", AppPaths.MmtPath));
+ return ProcessRunner.Run(AppPaths.MmtPath, args);
+ }
+
+ public List ListMonitors()
+ {
+ if (!AppPaths.HasMmt) return [];
+ var csvPath = Path.Combine(Path.GetTempPath(), $"consolemode_monitors_{Guid.NewGuid():N}.csv");
+ try
+ {
+ Run("/HideInactiveMonitors", "0", "/scomma", csvPath);
+ if (!File.Exists(csvPath)) return [];
+
+ var monitors = new List();
+ foreach (var row in CsvReader.Read(csvPath))
+ {
+ var name = row.Get("Name");
+ if (string.IsNullOrWhiteSpace(name)) continue;
+ var isActive = string.Equals(row.Get("Active"), "Yes", StringComparison.OrdinalIgnoreCase);
+ var maxResolution = row.Get("Maximum Resolution");
+ var resolution = row.Get("Resolution");
+ if (string.IsNullOrWhiteSpace(resolution))
+ resolution = string.IsNullOrWhiteSpace(maxResolution) ? "N/A" : maxResolution;
+
+ MonitorService.ParseRes(resolution, out var width, out var height);
+ MonitorService.ParseRes(maxResolution, out var maxW, out var maxH);
+ if (maxW <= 0 && width > 0) { maxW = width; maxH = height; }
+
+ monitors.Add(new MonitorInfo
+ {
+ Name = name,
+ Resolution = resolution,
+ MaximumResolution = maxResolution,
+ MaxWidth = maxW,
+ MaxHeight = maxH,
+ Width = width,
+ Height = height,
+ Frequency = row.Get("Frequency"),
+ Colors = row.Get("Colors"),
+ IsPrimary = string.Equals(row.Get("Primary"), "Yes", StringComparison.OrdinalIgnoreCase),
+ IsActive = isActive,
+ IsDisconnected = string.Equals(row.Get("Disconnected"), "Yes", StringComparison.OrdinalIgnoreCase) || !isActive,
+ MonitorName = row.Get("Monitor Name"),
+ ShortId = row.Get("Short Monitor ID"),
+ MonitorId = row.Get("Monitor ID"),
+ SerialNumber = row.Get("Monitor Serial Number"),
+ LeftTop = row.Get("Left-Top")
+ });
+ }
+ return monitors;
+ }
+ finally
+ {
+ try { File.Delete(csvPath); } catch { /* ignore */ }
+ }
+ }
+
+ public void SaveLayout(string path) => Run("/SaveConfig", path);
+ public void LoadLayout(string path) => Run("/LoadConfig", path);
+ public Dictionary> ResolveLayout(Dictionary> specs) => specs;
+ public void Enable(IReadOnlyList names) => Run(["/enable", .. names]);
+ public void Disable(IReadOnlyList names) => Run(["/disable", .. names]);
+ public void SetPrimary(string name) => Run("/SetPrimary", name);
+ public void PowerOn(string name) => Run("/TurnOn", name);
+ public void PowerOff(string name) => Run("/TurnOff", name);
+
+ public void SetMode(string name, int width, int height, int frequency, string? colors, int? x, int? y, bool primary)
+ {
+ var spec = primary ? $"Name={name} Primary=Yes" : $"Name={name}";
+ if (width > 0) spec += $" Width={width}";
+ if (height > 0) spec += $" Height={height}";
+ if (frequency > 0) spec += $" DisplayFrequency={frequency}";
+ if (!string.IsNullOrWhiteSpace(colors)) spec += $" BitsPerPixel={colors}";
+ if (x is not null) spec += $" PositionX={x}";
+ if (y is not null) spec += $" PositionY={y}";
+ Run("/SetMonitors", spec);
+ }
+
+ public void MoveProcessWindows(string monitorName, string processName, ScreenRect rect) =>
+ Run("/MoveWindow", monitorName,
+ "Process", processName,
+ "/WindowLeft", rect.X.ToString(),
+ "/WindowTop", rect.Y.ToString(),
+ "/WindowWidth", rect.Width.ToString(),
+ "/WindowHeight", rect.Height.ToString());
+}
+
+/// Windows' own display APIs (issue #91): CCD, ChangeDisplaySettingsEx and DDC/CI.
+internal sealed class NativeDisplayBackend : IDisplayBackend
+{
+ public string Name => "native";
+ public bool IsAvailable => true;
+
+ public List ListMonitors()
+ {
+ var result = new List();
+ foreach (var m in NativeDisplays.ListMonitors())
+ {
+ DisplayIdentity.TryParseDevicePath(m.DevicePath, out var hardwareId, out var instanceId);
+ var (driverKey, edid) = ReadRegistry(instanceId);
+
+ var current = m.IsActive ? NativeWindows.GetCurrentDisplayMode(m.GdiName) : null;
+ int maxW = edid.PreferredWidth, maxH = edid.PreferredHeight;
+ if (m.IsActive)
+ {
+ foreach (var mode in NativeWindows.EnumerateDisplayModes(m.GdiName))
+ {
+ if ((long)mode.Width * mode.Height <= (long)maxW * maxH) continue;
+ maxW = mode.Width;
+ maxH = mode.Height;
+ }
+ }
+ var width = current?.Width ?? maxW;
+ var height = current?.Height ?? maxH;
+ var maxText = maxW > 0 ? $"{maxW} X {maxH}" : "";
+
+ result.Add(new MonitorInfo
+ {
+ Name = m.GdiName,
+ Resolution = current is not null ? $"{current.Width} X {current.Height}" : maxText.Length > 0 ? maxText : "N/A",
+ MaximumResolution = maxText,
+ MaxWidth = maxW,
+ MaxHeight = maxH,
+ Width = width,
+ Height = height,
+ Frequency = current?.Frequency > 0 ? current.Frequency.ToString() : "",
+ Colors = current?.BitsPerPel > 0 ? current.BitsPerPel.ToString() : "",
+ IsPrimary = m.IsActive && m.PositionX == 0 && m.PositionY == 0,
+ IsActive = m.IsActive,
+ IsDisconnected = !m.IsActive,
+ MonitorName = !string.IsNullOrWhiteSpace(m.FriendlyName) ? m.FriendlyName : edid.Name,
+ ShortId = hardwareId,
+ MonitorId = DisplayIdentity.MonitorId(hardwareId, driverKey),
+ SerialNumber = edid.Serial,
+ LeftTop = m.IsActive ? $"{m.PositionX}, {m.PositionY}" : ""
+ });
+ }
+ return result;
+ }
+
+ private static (string? DriverKey, EdidInfo Edid) ReadRegistry(string instanceId)
+ {
+ var empty = new EdidInfo("", "", 0, 0);
+ if (string.IsNullOrWhiteSpace(instanceId)) return (null, empty);
+ try
+ {
+ using var key = Registry.LocalMachine.OpenSubKey($@"SYSTEM\CurrentControlSet\Enum\{instanceId}");
+ if (key is null) return (null, empty);
+ using var parameters = key.OpenSubKey("Device Parameters");
+ return (key.GetValue("Driver") as string, DisplayIdentity.ParseEdid(parameters?.GetValue("EDID") as byte[]));
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Telas: registro de {instanceId}: {ex.Message}");
+ return (null, empty);
+ }
+ }
+
+ public void SaveLayout(string path)
+ {
+ var entries = ListMonitors().Select(m =>
+ {
+ 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);
+ });
+ File.WriteAllText(path, DisplayIdentity.FormatLayout(entries));
+ }
+
+ public void LoadLayout(string path)
+ {
+ var specs = ResolveLayout(DisplayIdentity.ParseLayout(File.ReadAllLines(path)));
+ foreach (var (name, spec) in specs)
+ {
+ int.TryParse(spec.GetValueOrDefault("Width"), out var w);
+ int.TryParse(spec.GetValueOrDefault("Height"), out var h);
+ if (w <= 0 || h <= 0) continue;
+ int.TryParse(spec.GetValueOrDefault("DisplayFrequency"), out var f);
+ 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);
+ }
+ var code = NativeDisplays.ApplyPending();
+ AppLog.Write($"Telas: layout restaurado => {code}");
+ }
+
+ public Dictionary> ResolveLayout(Dictionary> specs) =>
+ DisplayIdentity.RemapLayoutNames(specs, ListMonitors().Select(m => (m.MonitorId, m.Name)));
+
+ public void Enable(IReadOnlyList names) => NativeDisplays.Enable(names);
+ public void Disable(IReadOnlyList names) => NativeDisplays.Disable(names);
+ public void SetPrimary(string name) => CcdHelper.SetPrimary(name);
+ public void PowerOn(string name) => NativeDisplays.SetPower(name, on: true);
+ public void PowerOff(string name) => NativeDisplays.SetPower(name, on: false);
+
+ public void SetMode(string name, int width, int height, int frequency, string? colors, int? x, int? y, bool primary)
+ {
+ int.TryParse(colors, out var bits);
+ NativeDisplays.QueueMode(name, width, height, frequency, bits, x, y);
+ NativeDisplays.ApplyPending();
+ if (primary) CcdHelper.SetPrimary(name);
+ }
+
+ public void MoveProcessWindows(string monitorName, string processName, ScreenRect rect) =>
+ NativeDisplays.MoveProcessWindows(processName, rect);
+}
diff --git a/src/ConsoleMode/Services/DisplayIdentity.cs b/src/ConsoleMode/Services/DisplayIdentity.cs
new file mode 100644
index 0000000..369eb3f
--- /dev/null
+++ b/src/ConsoleMode/Services/DisplayIdentity.cs
@@ -0,0 +1,187 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace ConsoleMode.Services;
+
+/// One monitor in the layout backup (MultiMonitorTool's .cfg format, kept for compatibility).
+public sealed record LayoutEntry(string Name, string MonitorId, string Serial, int BitsPerPixel,
+ int Width, int Height, int Frequency, int PositionX, int PositionY);
+
+/// What the monitor's EDID says about itself.
+public sealed record EdidInfo(string Name, string Serial, int PreferredWidth, int PreferredHeight);
+
+///
+/// Monitor identity and layout-file helpers for the native display backend (issue #91). They
+/// produce the same values MultiMonitorTool did, so saved settings and backups keep working.
+/// Pure, so it's tested.
+///
+public static class DisplayIdentity
+{
+ ///
+ /// \\?\DISPLAY#GSM5B7F#5&2d8e1c7&0&UID4352#{e6f07b5f-…} →
+ /// hardware ID GSM5B7F, device instance DISPLAY\GSM5B7F\5&2d8e1c7&0&UID4352.
+ ///
+ public static bool TryParseDevicePath(string? devicePath, out string hardwareId, out string instanceId)
+ {
+ hardwareId = instanceId = "";
+ if (string.IsNullOrWhiteSpace(devicePath)) return false;
+ var path = devicePath.StartsWith(@"\\?\", StringComparison.Ordinal) ? devicePath[4..] : devicePath;
+ var parts = path.Split('#');
+ if (parts.Length < 3 || parts[0].Length == 0 || parts[1].Length == 0 || parts[2].Length == 0) return false;
+ hardwareId = parts[1];
+ instanceId = $@"{parts[0]}\{parts[1]}\{parts[2]}";
+ return true;
+ }
+
+ ///
+ /// MultiMonitorTool's "Monitor ID" (the device ID Windows gives the monitor), e.g.
+ /// MONITOR\GSM5B7F\{4d36e96e-e325-11ce-bfc1-08002be10318}\0004; the last part is the
+ /// monitor's driver key. It's what MonitorInfo.StableId saves in config.json.
+ ///
+ public static string MonitorId(string hardwareId, string? driverKey) =>
+ string.IsNullOrWhiteSpace(hardwareId) || string.IsNullOrWhiteSpace(driverKey) ? "" : $@"MONITOR\{hardwareId}\{driverKey}";
+
+ /// Name (0xFC) and serial (0xFF) descriptors, and the preferred (native) resolution.
+ public static EdidInfo ParseEdid(byte[]? edid)
+ {
+ if (edid is null || edid.Length < 128) return new EdidInfo("", "", 0, 0);
+ string name = "", serial = "";
+ int width = 0, height = 0;
+ for (var offset = 54; offset <= 108; offset += 18)
+ {
+ if (edid[offset] != 0 || edid[offset + 1] != 0)
+ {
+ // Detailed timing; the first one is the preferred mode.
+ if (width == 0)
+ {
+ width = edid[offset + 2] | ((edid[offset + 4] & 0xF0) << 4);
+ height = edid[offset + 5] | ((edid[offset + 7] & 0xF0) << 4);
+ }
+ continue;
+ }
+ var text = DescriptorText(edid, offset);
+ switch (edid[offset + 3])
+ {
+ case 0xFC when name.Length == 0: name = text; break;
+ case 0xFF when serial.Length == 0: serial = text; break;
+ }
+ }
+ return new EdidInfo(name, serial, width, height);
+ }
+
+ private static string DescriptorText(byte[] edid, int offset)
+ {
+ var sb = new StringBuilder();
+ for (var i = offset + 5; i < offset + 18; i++)
+ {
+ if (edid[i] == 0x0A || edid[i] == 0) break;
+ sb.Append((char)edid[i]);
+ }
+ return sb.ToString().Trim();
+ }
+
+ ///
+ /// Picks a video source for every monitor. Active ones keep theirs; each inactive one gets its
+ /// preferred source (the one it last used) when free, else the first free candidate, so no two
+ /// monitors share a name (\\.\DISPLAYn) and a re-enabled monitor keeps its old one.
+ ///
+ public static Dictionary AssignSources(
+ IEnumerable<(TTarget Target, TSource? Active, TSource? Preferred, IReadOnlyList Candidates)> targets)
+ where TTarget : notnull where TSource : struct
+ {
+ var list = targets.ToList();
+ var result = new Dictionary();
+ var used = new HashSet();
+ foreach (var t in list.Where(t => t.Active is not null))
+ {
+ result[t.Target] = t.Active!.Value;
+ used.Add(t.Active.Value);
+ }
+ foreach (var t in list.Where(t => t.Active is null))
+ {
+ TSource? pick = t.Preferred is { } p && t.Candidates.Contains(p) && !used.Contains(p) ? p : null;
+ pick ??= t.Candidates.Where(c => !used.Contains(c)).Select(c => (TSource?)c).FirstOrDefault();
+ if (pick is not { } chosen) continue;
+ result[t.Target] = chosen;
+ used.Add(chosen);
+ }
+ return result;
+ }
+
+ public static string FormatLayout(IEnumerable entries)
+ {
+ var sb = new StringBuilder();
+ var i = 0;
+ foreach (var e in entries)
+ {
+ sb.Append($"[Monitor{i++}]\r\n");
+ sb.Append($"Name={e.Name}\r\n");
+ sb.Append($"MonitorID={e.MonitorId}\r\n");
+ sb.Append($"SerialNumber={e.Serial}\r\n");
+ sb.Append($"BitsPerPixel={e.BitsPerPixel}\r\n");
+ sb.Append($"Width={e.Width}\r\n");
+ 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($"PositionX={e.PositionX}\r\n");
+ sb.Append($"PositionY={e.PositionY}\r\n");
+ }
+ return sb.ToString();
+ }
+
+ /// Monitor sections of a layout file, keyed by Name (last one wins).
+ public static Dictionary> ParseLayout(IEnumerable lines)
+ {
+ var specs = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ Dictionary? current = null;
+ string? currentName = null;
+ foreach (var line in lines)
+ {
+ if (Regex.IsMatch(line, @"^\[Monitor\d+\]"))
+ {
+ if (currentName is not null && current is not null) specs[currentName] = current;
+ current = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ currentName = null;
+ continue;
+ }
+
+ var m = Regex.Match(line, @"^Name=(.+)$");
+ if (m.Success && current is not null)
+ {
+ currentName = m.Groups[1].Value.Trim();
+ current["Name"] = currentName;
+ continue;
+ }
+
+ var kv = Regex.Match(line, @"^(\w+)=(.+)$");
+ if (kv.Success && current is not null)
+ current[kv.Groups[1].Value] = kv.Groups[2].Value.Trim();
+ }
+
+ if (currentName is not null && current is not null) specs[currentName] = current;
+ return specs;
+ }
+
+ ///
+ /// 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
+ /// found keep their saved name.
+ ///
+ public static Dictionary> RemapLayoutNames(
+ Dictionary> specs, IEnumerable<(string MonitorId, string Name)> current)
+ {
+ var byId = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var (id, name) in current)
+ if (!string.IsNullOrWhiteSpace(id)) byId.TryAdd(id, name);
+
+ var result = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ foreach (var (savedName, spec) in specs)
+ {
+ var name = spec.TryGetValue("MonitorID", out var id) && byId.TryGetValue(id, out var now) ? now : savedName;
+ var copy = new Dictionary(spec, StringComparer.OrdinalIgnoreCase) { ["Name"] = name };
+ result[name] = copy;
+ }
+ return result;
+ }
+}
diff --git a/src/ConsoleMode/Services/MonitorService.cs b/src/ConsoleMode/Services/MonitorService.cs
index b945b6a..81aa76f 100644
--- a/src/ConsoleMode/Services/MonitorService.cs
+++ b/src/ConsoleMode/Services/MonitorService.cs
@@ -7,6 +7,8 @@ namespace ConsoleMode.Services;
public sealed class MonitorService
{
+ private static bool? _useNative;
+ private readonly IDisplayBackend _backend = UseNative ? new NativeDisplayBackend() : new MmtDisplayBackend();
private readonly object _gate = new();
private List? _cache;
private readonly Dictionary> _modesCache = new(StringComparer.OrdinalIgnoreCase);
@@ -17,10 +19,19 @@ public void ClearCache()
lock (_modesCache) _modesCache.Clear();
}
- public int InvokeMmt(params string[] args)
+ ///
+ /// Windows' own display APIs instead of MultiMonitorTool (issue #91). Off by default while it's
+ /// verified on real hardware: set "NativeDisplays": true in config.json and restart the app.
+ ///
+ public static bool UseNative => _useNative ??= LoadNativeFlag();
+
+ /// Display control works: the native backend is on, or MultiMonitorTool is present.
+ public static bool IsAvailable => UseNative || AppPaths.HasMmt;
+
+ private static bool LoadNativeFlag()
{
- if (!AppPaths.HasMmt) throw new InvalidOperationException(LocalizationService.Get("MmtMissing", AppPaths.MmtPath));
- return ProcessRunner.Run(AppPaths.MmtPath, args);
+ try { return ConfigService.Load().NativeDisplays; }
+ catch { return false; }
}
public IReadOnlyList GetMonitors(bool forceRefresh = false)
@@ -40,69 +51,22 @@ public string ResolveName(string idOrName) =>
private IReadOnlyList ReadMonitors(bool forceRefresh)
{
if (!forceRefresh && _cache is not null) return _cache;
- if (!AppPaths.HasMmt)
+ if (!_backend.IsAvailable)
{
_cache = [];
return _cache;
}
- var csvPath = Path.Combine(Path.GetTempPath(), $"consolemode_monitors_{Guid.NewGuid():N}.csv");
- try
- {
- InvokeMmt("/HideInactiveMonitors", "0", "/scomma", csvPath);
- if (!File.Exists(csvPath))
- {
- _cache = [];
- return _cache;
- }
-
- var rows = CsvReader.Read(csvPath);
- var winMap = BuildDisplayNumberMap(rows);
- var monitors = new List();
- foreach (var row in rows)
- {
- var name = row.Get("Name");
- if (string.IsNullOrWhiteSpace(name)) continue;
- var isActive = string.Equals(row.Get("Active"), "Yes", StringComparison.OrdinalIgnoreCase);
- var maxResolution = row.Get("Maximum Resolution");
- var resolution = row.Get("Resolution");
- if (string.IsNullOrWhiteSpace(resolution))
- resolution = string.IsNullOrWhiteSpace(maxResolution) ? "N/A" : maxResolution;
-
- ParseRes(resolution, out var width, out var height);
- ParseRes(maxResolution, out var maxW, out var maxH);
- if (maxW <= 0 && width > 0) { maxW = width; maxH = height; }
-
- monitors.Add(new MonitorInfo
- {
- Name = name,
- WindowsDisplayNumber = winMap.GetValueOrDefault(name),
- Resolution = resolution,
- MaximumResolution = maxResolution,
- MaxWidth = maxW,
- MaxHeight = maxH,
- Width = width,
- Height = height,
- Frequency = row.Get("Frequency"),
- Colors = row.Get("Colors"),
- IsPrimary = string.Equals(row.Get("Primary"), "Yes", StringComparison.OrdinalIgnoreCase),
- IsActive = isActive,
- IsDisconnected = string.Equals(row.Get("Disconnected"), "Yes", StringComparison.OrdinalIgnoreCase) || !isActive,
- MonitorName = row.Get("Monitor Name"),
- ShortId = row.Get("Short Monitor ID"),
- MonitorId = row.Get("Monitor ID"),
- SerialNumber = row.Get("Monitor Serial Number"),
- LeftTop = row.Get("Left-Top")
- });
- }
+ var monitors = _backend.ListMonitors();
+ // Windows' "Display n": active monitors first, in the order they were listed.
+ var n = 1;
+ var numbers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var m in monitors.OrderBy(m => m.IsActive ? 0 : 1))
+ if (!numbers.ContainsKey(m.Name)) numbers[m.Name] = n++;
+ foreach (var m in monitors) m.WindowsDisplayNumber = numbers[m.Name];
- _cache = [.. monitors.OrderBy(m => m.WindowsDisplayNumber > 0 ? m.WindowsDisplayNumber : 999).ThenBy(m => m.Name)];
- return _cache;
- }
- finally
- {
- try { File.Delete(csvPath); } catch { /* ignore */ }
- }
+ _cache = [.. monitors.OrderBy(m => m.WindowsDisplayNumber > 0 ? m.WindowsDisplayNumber : 999).ThenBy(m => m.Name)];
+ return _cache;
}
public string? GetPrimaryName()
@@ -114,7 +78,7 @@ private IReadOnlyList ReadMonitors(bool forceRefresh)
public void SaveBackup()
{
- InvokeMmt("/SaveConfig", AppPaths.BackupMonitorConfig);
+ _backend.SaveLayout(AppPaths.BackupMonitorConfig);
}
public void SaveBackupMeta(MonitorBackupMeta meta)
@@ -160,13 +124,13 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state)
}
var ctx = GetRestoreContext(state);
- var backupSpecs = GetBackupMonitorSpecs(AppPaths.BackupMonitorConfig);
+ var backupSpecs = _backend.ResolveLayout(GetBackupMonitorSpecs(AppPaths.BackupMonitorConfig));
AppLog.Write($"Restore: iniciando (primário={ctx.OriginalPrimary}, foco={ctx.FocusMonitor}, focoInativo={ctx.FocusWasInactive}, esconder={string.Join('+', ctx.HideMonitors)})");
if (ctx.HideStrategy == "turnOff" && ctx.HideMonitors.Count > 0)
{
foreach (var name in ctx.HideMonitors.Distinct())
- InvokeMmt("/TurnOn", name);
+ _backend.PowerOn(name);
Thread.Sleep(300);
}
@@ -182,14 +146,12 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state)
if (allDeviceNames.Count > 0)
{
- var enableArgs = new List { "/enable" };
- enableArgs.AddRange(allDeviceNames);
- InvokeMmt(enableArgs.ToArray());
+ _backend.Enable(allDeviceNames);
if (monitorsToVerify.Count > 0 && !WaitMonitorsActive(monitorsToVerify))
{
foreach (var name in monitorsToVerify)
- InvokeMmt("/enable", name);
+ _backend.Enable([name]);
if (!WaitMonitorsActive(monitorsToVerify, 3000))
{
AppLog.Write($"Restore: /enable não confirmou todos ({string.Join('+', monitorsToVerify)}); usando ExtendAll");
@@ -207,7 +169,7 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state)
result.Issues.Add($"Não foi possível restaurar o primário para {ctx.OriginalPrimary}");
}
- InvokeMmt("/LoadConfig", AppPaths.BackupMonitorConfig);
+ _backend.LoadLayout(AppPaths.BackupMonitorConfig);
Thread.Sleep(800);
if (!string.IsNullOrWhiteSpace(ctx.OriginalPrimary))
@@ -242,7 +204,7 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state)
public void SetPrimary(string monitorName)
{
- InvokeMmt("/SetPrimary", monitorName);
+ _backend.SetPrimary(monitorName);
if (WaitPrimary(monitorName, 2000)) return;
CcdHelper.SetPrimary(monitorName);
WaitPrimary(monitorName, 3000);
@@ -253,17 +215,15 @@ public void EnableMonitors(IReadOnlyList names, bool windowsEnable)
if (names.Count == 0) return;
if (windowsEnable)
{
- var args = new List { "/enable" };
- args.AddRange(names);
- InvokeMmt(args.ToArray());
+ _backend.Enable(names);
MarkActive(names);
return;
}
foreach (var name in names)
{
- InvokeMmt("/TurnOn", name);
- InvokeMmt("/enable", name);
+ _backend.PowerOn(name);
+ _backend.Enable([name]);
}
MarkActive(names);
}
@@ -276,7 +236,7 @@ public bool EnsureActive(string name)
{
if (WaitMonitorsActive([name], 3000)) return true;
AppLog.Write($"Ativar {name}: ainda inativo; repetindo /enable");
- InvokeMmt("/enable", name);
+ _backend.Enable([name]);
if (WaitMonitorsActive([name], 3000)) return true;
AppLog.Write($"Ativar {name}: /enable não confirmou; usando ExtendAll");
CcdHelper.ExtendAll();
@@ -286,15 +246,13 @@ public bool EnsureActive(string name)
public void DisableWindows(IReadOnlyList names)
{
if (names.Count == 0) return;
- var args = new List { "/disable" };
- args.AddRange(names);
- InvokeMmt(args.ToArray());
+ _backend.Disable(names);
}
public void DisableDdc(IReadOnlyList names)
{
foreach (var name in names)
- InvokeMmt("/TurnOff", name);
+ _backend.PowerOff(name);
}
public void ApplyFocusMode(string focusMonitor, SavedDisplayMode mode)
@@ -379,28 +337,11 @@ public bool UpdateFocusRect(string monitorName, ConsoleRuntimeState state, bool
return true;
}
- public void MoveWindowViaMmt(string monitorName, ScreenRect rect, string processName)
- {
- InvokeMmt(
- "/MoveWindow", monitorName,
- "Process", processName,
- "/WindowLeft", rect.X.ToString(),
- "/WindowTop", rect.Y.ToString(),
- "/WindowWidth", rect.Width.ToString(),
- "/WindowHeight", rect.Height.ToString());
- }
+ public void MoveProcessWindows(string monitorName, ScreenRect rect, string processName) =>
+ _backend.MoveProcessWindows(monitorName, processName, rect);
- private void SetMonitorMode(string name, int width, int height, int frequency, string? colors, int? x, int? y, bool primary)
- {
- var spec = primary ? $"Name={name} Primary=Yes" : $"Name={name}";
- if (width > 0) spec += $" Width={width}";
- if (height > 0) spec += $" Height={height}";
- if (frequency > 0) spec += $" DisplayFrequency={frequency}";
- if (!string.IsNullOrWhiteSpace(colors)) spec += $" BitsPerPixel={colors}";
- if (x is not null) spec += $" PositionX={x}";
- if (y is not null) spec += $" PositionY={y}";
- InvokeMmt("/SetMonitors", spec);
- }
+ private void SetMonitorMode(string name, int width, int height, int frequency, string? colors, int? x, int? y, bool primary) =>
+ _backend.SetMode(name, width, height, frequency, colors, x, y, primary);
private bool IsCurrentMode(string name, int width, int height, int frequency)
{
@@ -427,7 +368,7 @@ private bool RestorePrimaryWithRetry(string monitorName, Dictionary s
SetMonitorMode(monitorName, w, h, frequency, spec.GetValueOrDefault("BitsPerPixel"), null, null, true);
return;
}
- InvokeMmt("/SetPrimary", monitorName);
+ _backend.SetPrimary(monitorName);
}
private bool DisableWithRetry(string monitorName, int maxAttempts = 3)
{
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
- if (attempt == 1) InvokeMmt("/disable", monitorName);
+ if (attempt == 1) _backend.Disable([monitorName]);
else
{
var code = CcdHelper.DetachDisplay(monitorName);
@@ -513,53 +454,8 @@ private void MarkActive(IReadOnlyList names)
}
}
- private static Dictionary BuildDisplayNumberMap(List> rows)
- {
- var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
- var ordered = rows
- .Where(r => !string.IsNullOrWhiteSpace(r.Get("Name")))
- .OrderBy(r => string.Equals(r.Get("Active"), "Yes", StringComparison.OrdinalIgnoreCase) ? 0 : 1);
- var n = 1;
- foreach (var row in ordered)
- {
- var name = row.Get("Name");
- if (map.ContainsKey(name)) continue;
- map[name] = n++;
- }
- return map;
- }
-
- private static Dictionary> GetBackupMonitorSpecs(string path)
- {
- var specs = new Dictionary>(StringComparer.OrdinalIgnoreCase);
- Dictionary? current = null;
- string? currentName = null;
- foreach (var line in File.ReadAllLines(path))
- {
- if (Regex.IsMatch(line, @"^\[Monitor\d+\]"))
- {
- if (currentName is not null && current is not null) specs[currentName] = current;
- current = new Dictionary(StringComparer.OrdinalIgnoreCase);
- currentName = null;
- continue;
- }
-
- var m = Regex.Match(line, @"^Name=(.+)$");
- if (m.Success && current is not null)
- {
- currentName = m.Groups[1].Value.Trim();
- current["Name"] = currentName;
- continue;
- }
-
- var kv = Regex.Match(line, @"^(\w+)=(.+)$");
- if (kv.Success && current is not null)
- current[kv.Groups[1].Value] = kv.Groups[2].Value.Trim();
- }
-
- if (currentName is not null && current is not null) specs[currentName] = current;
- return specs;
- }
+ private static Dictionary> GetBackupMonitorSpecs(string path) =>
+ DisplayIdentity.ParseLayout(File.ReadAllLines(path));
private static bool IsBackupSpecActive(Dictionary spec)
{
@@ -568,7 +464,7 @@ private static bool IsBackupSpecActive(Dictionary spec)
return w > 0 && h > 0;
}
- private static void ParseRes(string? text, out int w, out int h)
+ internal static void ParseRes(string? text, out int w, out int h)
{
w = 0; h = 0;
if (string.IsNullOrWhiteSpace(text)) return;
@@ -592,7 +488,7 @@ private static void ParseRes(string? text, out int w, out int h)
};
}
- private static void ParseLeftTop(string? leftTop, out int? x, out int? y)
+ internal static void ParseLeftTop(string? leftTop, out int? x, out int? y)
{
x = y = null;
if (string.IsNullOrWhiteSpace(leftTop)) return;
diff --git a/src/ConsoleMode/ViewModels/MainViewModel.cs b/src/ConsoleMode/ViewModels/MainViewModel.cs
index 57bbf0b..7a53b7b 100644
--- a/src/ConsoleMode/ViewModels/MainViewModel.cs
+++ b/src/ConsoleMode/ViewModels/MainViewModel.cs
@@ -250,7 +250,7 @@ private static LoadResult LoadData(ConsoleEngine engine)
monitors.Add(new LoadedMonitor(monitor, engine.Monitors.GetDisplayModes(monitor.Name, monitor)));
if (list.Count == 0)
{
- error = AppPaths.HasMmt
+ error = MonitorService.IsAvailable
? LocalizationService.Get("NoDisplayTryRefresh")
: LocalizationService.Get("MmtMissing", AppPaths.MmtPath);
}
@@ -891,6 +891,7 @@ private AppConfig BuildConfig()
AudioDeviceName = auto ? "" : SelectedAudio?.Text ?? "",
AudioAutoSwitch = auto,
NativeAudio = _loadedConfig.NativeAudio,
+ NativeDisplays = _loadedConfig.NativeDisplays,
FpsLimit = ReadFpsLimit(),
HdrEnable = HdrEnable,
VrrEnable = VrrEnable,
diff --git a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
index 1a07711..64c310a 100644
--- a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
+++ b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
@@ -29,6 +29,7 @@
+
diff --git a/tests/ConsoleMode.Tests/DisplayIdentityTests.cs b/tests/ConsoleMode.Tests/DisplayIdentityTests.cs
new file mode 100644
index 0000000..e99b774
--- /dev/null
+++ b/tests/ConsoleMode.Tests/DisplayIdentityTests.cs
@@ -0,0 +1,97 @@
+using System.Text;
+using ConsoleMode.Services;
+
+namespace ConsoleMode.Tests;
+
+public sealed class DisplayIdentityTests
+{
+ [Fact]
+ public void Device_path_gives_the_hardware_id_and_the_device_instance()
+ {
+ Assert.True(DisplayIdentity.TryParseDevicePath(
+ @"\\?\DISPLAY#GSM5B7F#5&2d8e1c7&0&UID4352#{e6f07b5f-ee97-4a90-b076-33f57bf4eaa7}", out var hw, out var instance));
+ Assert.Equal("GSM5B7F", hw);
+ Assert.Equal(@"DISPLAY\GSM5B7F\5&2d8e1c7&0&UID4352", instance);
+
+ Assert.False(DisplayIdentity.TryParseDevicePath("", out _, out _));
+ Assert.False(DisplayIdentity.TryParseDevicePath(@"\\?\DISPLAY#GSM5B7F", out _, out _));
+ }
+
+ [Fact]
+ public void Monitor_id_has_MultiMonitorTools_format()
+ {
+ Assert.Equal(@"MONITOR\GSM5B7F\{4d36e96e-e325-11ce-bfc1-08002be10318}\0004",
+ DisplayIdentity.MonitorId("GSM5B7F", @"{4d36e96e-e325-11ce-bfc1-08002be10318}\0004"));
+ Assert.Equal("", DisplayIdentity.MonitorId("GSM5B7F", null));
+ }
+
+ [Fact]
+ public void Edid_gives_name_serial_and_native_resolution()
+ {
+ var edid = new byte[128];
+ // Detailed timing 3840 x 2160 (the preferred mode).
+ edid[54] = 0x08; edid[55] = 0xE8;
+ edid[56] = 0x00; edid[58] = 0xF0;
+ edid[59] = 0x70; edid[61] = 0x80;
+ WriteText(edid, 72, 0xFC, "LG TV SSCR2");
+ WriteText(edid, 90, 0xFF, "0x01010101");
+
+ var info = DisplayIdentity.ParseEdid(edid);
+
+ Assert.Equal("LG TV SSCR2", info.Name);
+ Assert.Equal("0x01010101", info.Serial);
+ Assert.Equal(3840, info.PreferredWidth);
+ Assert.Equal(2160, info.PreferredHeight);
+ Assert.Equal(new EdidInfo("", "", 0, 0), DisplayIdentity.ParseEdid(null));
+ }
+
+ [Fact]
+ public void Active_monitors_keep_their_source_and_inactive_ones_get_a_free_one()
+ {
+ IReadOnlyList all = [0, 1, 2];
+ var assigned = DisplayIdentity.AssignSources(
+ [
+ ("desk", 0, null, all),
+ ("tv", null, 0, all), // wants 0 (its last one) but the desk has it
+ ("side", null, 2, all), // gets its last one
+ ("projector", null, null, all)
+ ]);
+
+ Assert.Equal(0, assigned["desk"]);
+ Assert.Equal(2, assigned["side"]);
+ Assert.Equal(1, assigned["tv"]);
+ Assert.False(assigned.ContainsKey("projector")); // no source left
+ }
+
+ [Fact]
+ public void Layout_file_round_trips_and_follows_renamed_monitors()
+ {
+ var text = DisplayIdentity.FormatLayout(
+ [
+ new LayoutEntry(@"\\.\DISPLAY1", @"MONITOR\DEL4083\{x}\0001", "ABC", 32, 2560, 1440, 144, 0, 0),
+ new LayoutEntry(@"\\.\DISPLAY3", @"MONITOR\GSM5B7F\{x}\0004", "", 0, 0, 0, 0, 0, 0)
+ ]);
+ var specs = DisplayIdentity.ParseLayout(text.Split("\r\n"));
+
+ Assert.Equal("2560", specs[@"\\.\DISPLAY1"]["Width"]);
+ Assert.Equal("144", specs[@"\\.\DISPLAY1"]["DisplayFrequency"]);
+ Assert.Equal("0", specs[@"\\.\DISPLAY3"]["Width"]);
+
+ // The TV came back as DISPLAY2: its section follows it by MonitorID.
+ var remapped = DisplayIdentity.RemapLayoutNames(specs,
+ [
+ (@"MONITOR\DEL4083\{x}\0001", @"\\.\DISPLAY1"),
+ (@"MONITOR\GSM5B7F\{x}\0004", @"\\.\DISPLAY2")
+ ]);
+ Assert.True(remapped.ContainsKey(@"\\.\DISPLAY2"));
+ Assert.False(remapped.ContainsKey(@"\\.\DISPLAY3"));
+ Assert.Equal(@"\\.\DISPLAY2", remapped[@"\\.\DISPLAY2"]["Name"]);
+ }
+
+ private static void WriteText(byte[] edid, int offset, byte tag, string text)
+ {
+ edid[offset + 3] = tag;
+ 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;
+ }
+}