feat: add display state management with serialization and mode change deferral, remove use of registry for fetching display paths

This commit is contained in:
Nguyen Duy
2026-05-14 15:18:07 +07:00
parent d14c62abe3
commit 352e9e20d4
2 changed files with 209 additions and 110 deletions
-18
View File
@@ -156,18 +156,6 @@ namespace ParsecVDisplay
return Status.UNKNOWN;
}
public static string GetDeviceDescription(uint devInst)
{
uint propType;
int length = 128 * sizeof(ushort);
var buffer = stackalloc byte[length];
Native.CM_Get_DevNode_PropertyW(devInst,
ref Native.DEVPROPKEY.Device_DeviceDesc, &propType, buffer, &length, 0);
return Marshal.PtrToStringUni((IntPtr)buffer);
}
public static DateTime GetDeviceLastArrival(uint devInst)
{
uint propType;
@@ -383,12 +371,6 @@ namespace ParsecVDisplay
pid = 102,
};
public static DEVPROPKEY Device_DeviceDesc = new DEVPROPKEY
{
fmtid = Guid.Parse("{A45C254E-DF1C-4EFD-8020-67D146A850E0}"),
pid = 2
};
public static DEVPROPKEY Device_DriverVersion = new DEVPROPKEY
{
fmtid = Guid.Parse("{A8B865DD-2E3D-4094-AD97-E593A70C75D6}"),
+209 -92
View File
@@ -4,7 +4,6 @@ using System.Drawing;
using System.Globalization;
using System.Linq;
using System.Runtime.InteropServices;
using Microsoft.Win32;
namespace ParsecVDisplay
{
@@ -51,6 +50,71 @@ namespace ParsecVDisplay
public override string ToString() => $"{Resolution} @ {RefreshRate}";
}
/// <summary>
/// Serializable snapshot of a display's mode + orientation. Used for
/// restoring displays across suspend/resume and across app sessions.
/// </summary>
public class State
{
public int Width;
public int Height;
public int Hz;
public Orientation Orientation;
public string Pack() => $"{Width}x{Height}@{Hz}/{(int)Orientation}";
public static bool TryUnpack(string s, out State state)
{
state = null;
if (string.IsNullOrWhiteSpace(s))
return false;
var m = System.Text.RegularExpressions.Regex.Match(s.Trim(),
@"^(\d+)x(\d+)@(\d+)/([0-3])$");
if (!m.Success)
return false;
state = new State
{
Width = int.Parse(m.Groups[1].Value),
Height = int.Parse(m.Groups[2].Value),
Hz = int.Parse(m.Groups[3].Value),
Orientation = (Orientation)int.Parse(m.Groups[4].Value),
};
return true;
}
}
public State Snapshot()
{
return new State
{
Width = CurrentMode?.Width ?? 0,
Height = CurrentMode?.Height ?? 0,
Hz = CurrentMode?.Hz ?? 0,
Orientation = CurrentOrientation,
};
}
public static string PackStates(List<State> states)
{
return string.Join(",", states.ConvertAll(s => s.Pack()));
}
public static List<State> UnpackStates(string packed)
{
var list = new List<State>();
if (string.IsNullOrWhiteSpace(packed))
return list;
foreach (var tok in packed.Split(','))
{
if (State.TryUnpack(tok, out var s))
list.Add(s);
}
return list;
}
public class ModeSet
{
public int Width;
@@ -62,9 +126,7 @@ namespace ParsecVDisplay
public int Identifier;
public int CloneOf;
public int Address;
public DateTime LastArrival;
public string Adapter;
public string AdapterInstance;
public DateTime AdapterArrival;
@@ -148,48 +210,83 @@ namespace ParsecVDisplay
}
public bool ChangeMode(int? width, int? height, int? hz, Orientation? orientation)
{
return ChangeMode(width, height, hz, orientation, defer: false);
}
/// <summary>
/// Apply a mode change. When <paramref name="defer"/> is true the change is
/// staged with CDS_NORESET — the caller must invoke <see cref="CommitChanges"/>
/// to apply all staged changes atomically. Returns true on DISP_CHANGE_SUCCESSFUL.
/// </summary>
public bool ChangeMode(int? width, int? height, int? hz, Orientation? orientation, bool defer)
{
var mode = new Native.DEVMODE();
mode.dmSize = (short)Marshal.SizeOf(typeof(Native.DEVMODE));
if (Native.EnumDisplaySettings(DeviceName, -1, ref mode))
if (!Native.EnumDisplaySettings(DeviceName, -1, ref mode))
return false;
// dmFields must explicitly enumerate the fields we are changing
mode.dmFields = 0;
if (width.HasValue)
{
if (width.HasValue)
{
mode.dmPelsWidth = width.Value;
mode.dmFields |= /*DM_PELSWIDTH*/ 0x80000;
}
if (height.HasValue)
{
mode.dmPelsHeight = height.Value;
mode.dmFields |= /*DM_PELSHEIGHT*/ 0x100000;
}
if (hz.HasValue)
{
mode.dmDisplayFrequency = hz.Value;
mode.dmFields |= /*DM_DISPLAYFREQUENCY*/ 0x400000;
}
if (orientation.HasValue)
{
var newDO = orientation.Value;
mode.dmDisplayOrientation = newDO;
if (((int)newDO + (int)CurrentOrientation) % 2 != 0)
{
int t = mode.dmPelsWidth;
mode.dmPelsWidth = mode.dmPelsHeight;
mode.dmPelsHeight = t;
}
}
return Native.ChangeDisplaySettingsEx(DeviceName,
ref mode, IntPtr.Zero, /*CDS_UPDATEREGISTRY*/ 0x1 | /*CDS_GLOBAL*/ /*0x8*/ 0, IntPtr.Zero) == 0;
mode.dmPelsWidth = width.Value;
mode.dmFields |= /*DM_PELSWIDTH*/ 0x80000;
}
return false;
if (height.HasValue)
{
mode.dmPelsHeight = height.Value;
mode.dmFields |= /*DM_PELSHEIGHT*/ 0x100000;
}
if (hz.HasValue)
{
mode.dmDisplayFrequency = hz.Value;
mode.dmFields |= /*DM_DISPLAYFREQUENCY*/ 0x400000;
}
if (orientation.HasValue)
{
var newDO = orientation.Value;
mode.dmDisplayOrientation = newDO;
mode.dmFields |= /*DM_DISPLAYORIENTATION*/ 0x80;
if (((int)newDO + (int)CurrentOrientation) % 2 != 0)
{
int t = mode.dmPelsWidth;
mode.dmPelsWidth = mode.dmPelsHeight;
mode.dmPelsHeight = t;
mode.dmFields |= 0x80000 | 0x100000;
}
}
uint flags = /*CDS_UPDATEREGISTRY*/ 0x1;
if (defer)
flags |= /*CDS_NORESET*/ 0x10000000;
int rc = Native.ChangeDisplaySettingsEx(DeviceName, ref mode, IntPtr.Zero, flags, IntPtr.Zero);
if (rc != 0 /* DISP_CHANGE_SUCCESSFUL */)
return false;
// Refresh local cache so subsequent ChangeMode calls see the new state
if (width.HasValue) CurrentMode.Width = width.Value;
if (height.HasValue) CurrentMode.Height = height.Value;
if (hz.HasValue) CurrentMode.Hz = hz.Value;
if (orientation.HasValue) CurrentOrientation = orientation.Value;
return true;
}
/// <summary>
/// Apply all pending CDS_NORESET changes atomically.
/// Returns true if the commit was accepted (DISP_CHANGE_SUCCESSFUL).
/// </summary>
public static bool CommitChanges()
{
return Native.ChangeDisplaySettingsEx(null, IntPtr.Zero, IntPtr.Zero, 0, IntPtr.Zero) == 0;
}
public void TakeScreenshot(string saveFile)
@@ -238,8 +335,6 @@ namespace ParsecVDisplay
var displayMap = new Dictionary<string, Display>(StringComparer.OrdinalIgnoreCase);
var cloneGroups = new List<Tuple<Display, Display>>();
var paths = GetDisplayPaths();
var dd = new Native.DISPLAY_DEVICE();
dd.cb = Marshal.SizeOf(typeof(Native.DISPLAY_DEVICE));
@@ -255,49 +350,48 @@ namespace ParsecVDisplay
if ((dd2.StateFlags & Native.DISPLAY_DEVICE_ATTACHED) == 0)
continue;
var pathIdx = paths.FindIndex(p => dd2.DeviceID.Contains(p.Replace('\\', '#')));
if (pathIdx < 0) continue;
// Derive device instance ID directly from monitor.DeviceID
// (interface path) — avoids reading HKLM\...\monitor\Enum.
if (!TryParseInstanceId(dd2.DeviceID, out var instanceId))
continue;
if (!displayMap.ContainsKey(paths[pathIdx]))
if (displayMap.ContainsKey(instanceId))
continue;
var display = new Display
{
var display = new Display
{
Active = (dd2.StateFlags & Native.DISPLAY_DEVICE_ACTIVE) != 0,
Address = ParseDisplayAddress(paths[pathIdx]),
DeviceName = dd.DeviceName,
DisplayName = ParseDisplayCode(dd2.DeviceID),
};
Active = (dd2.StateFlags & Native.DISPLAY_DEVICE_ACTIVE) != 0,
Address = ParseDisplayAddress(dd2.DeviceID),
DeviceName = dd.DeviceName,
DisplayName = ParseDisplayCode(dd2.DeviceID),
};
if (display.Active)
{
if (prevActiveDisplay == null)
{
prevActiveDisplay = display;
}
else
{
cloneGroups.Add(new Tuple<Display, Display>(prevActiveDisplay, display));
}
if (display.Active)
{
if (prevActiveDisplay == null)
prevActiveDisplay = display;
else
cloneGroups.Add(new Tuple<Display, Display>(prevActiveDisplay, display));
display.FetchAllModes();
}
Device.GetDeviceInstance(paths[pathIdx], out uint devInst);
display.LastArrival = Device.GetDeviceLastArrival(devInst);
Device.GetParentDeviceInstance(devInst, out uint parentInst, out display.AdapterInstance);
display.Adapter = Device.GetDeviceDescription(parentInst);
display.AdapterArrival = Device.GetDeviceLastArrival(parentInst);
displayMap.Add(paths[pathIdx], display);
paths.RemoveAt(pathIdx);
display.FetchAllModes();
}
if (Device.GetDeviceInstance(instanceId, out uint devInst))
{
Device.GetParentDeviceInstance(devInst, out uint parentInst, out display.AdapterInstance);
display.AdapterArrival = Device.GetDeviceLastArrival(parentInst);
}
displayMap.Add(instanceId, display);
}
}
var displays = displayMap.Values.ToList();
// Sort displays by adapter arrival time
// Sort displays by adapter arrival (older adapter → lower number),
// then by monitor Address (UID) within the same adapter — DeviceName
// would sort lexicographically (DISPLAY10 < DISPLAY2), which breaks
// numbering once the GDI ordinal crosses 9.
displays.Sort((a, b) =>
{
if (a.AdapterInstance == b.AdapterInstance)
@@ -320,31 +414,50 @@ namespace ParsecVDisplay
return displays;
}
static List<string> GetDisplayPaths()
/// <summary>
/// Convert a device interface path returned by EnumDisplayDevices with
/// EDD_GET_DEVICE_INTERFACE_NAME to a device instance ID accepted by
/// CM_Locate_DevNodeA. Example transform:
/// <code>
/// \\?\DISPLAY#PSCCDD0#5&amp;abc&amp;UID256#{e6f07b5f-...}
/// → DISPLAY\PSCCDD0\5&amp;abc&amp;UID256
/// </code>
/// </summary>
static bool TryParseInstanceId(string interfacePath, out string instanceId)
{
var paths = new List<string>();
instanceId = null;
if (string.IsNullOrEmpty(interfacePath))
return false;
using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Services\monitor\Enum", false))
{
if (key != null)
{
int count = Convert.ToInt32(key.GetValue("Count", 0));
int start = interfacePath.StartsWith(@"\\?\") ? 4 : 0;
int end = interfacePath.IndexOf("#{", start, StringComparison.Ordinal);
if (end < 0) end = interfacePath.Length;
if (end <= start) return false;
for (int i = 0; i < count; ++i)
{
var path = key.GetValue($"{i}");
paths.Add(Convert.ToString(path));
}
}
}
return paths;
instanceId = interfacePath.Substring(start, end - start).Replace('#', '\\');
return true;
}
/// <summary>
/// Parse the UID number out of a device interface path / device ID.
/// Walks contiguous digits after "UID" rather than relying on the
/// substring being numeric-to-end (handles trailing "#{guid}").
/// </summary>
static int ParseDisplayAddress(string path)
{
var index = path.LastIndexOf("uid", StringComparison.OrdinalIgnoreCase);
int.TryParse(path.Substring(index + 3), out var address);
if (string.IsNullOrEmpty(path))
return 0;
int i = path.IndexOf("UID", StringComparison.OrdinalIgnoreCase);
if (i < 0) return 0;
i += 3;
int end = i;
while (end < path.Length && path[end] >= '0' && path[end] <= '9')
end++;
int address;
int.TryParse(path.Substring(i, end - i), out address);
return address;
}
@@ -387,6 +500,10 @@ namespace ParsecVDisplay
public static extern int ChangeDisplaySettingsEx(string lpszDeviceName, ref DEVMODE lpDevMode,
IntPtr hwnd, uint dwflags, IntPtr lParam);
[DllImport("user32.dll", EntryPoint = "ChangeDisplaySettingsExA", CharSet = CharSet.Ansi)]
public static extern int ChangeDisplaySettingsEx(string lpszDeviceName, IntPtr lpDevMode,
IntPtr hwnd, uint dwflags, IntPtr lParam);
[StructLayout(LayoutKind.Sequential)]
public struct DEVMODE
{