diff --git a/DS4Windows/DS4Control/ControlService.cs b/DS4Windows/DS4Control/ControlService.cs
index f6989fc..bc58ac2 100644
--- a/DS4Windows/DS4Control/ControlService.cs
+++ b/DS4Windows/DS4Control/ControlService.cs
@@ -1667,6 +1667,24 @@ public bool Start(bool showlog = true)
StartupDiag($"Viiper status probe end ready={viiperStatus.Ready} helper={viiperStatus.ViiperInstalled} usbip={viiperStatus.UsbipInstalled} server={viiperStatus.ServerRunning}");
LogDebug(viiperStatus.StartupLogLine);
+ // Lifecycle invariant (d): a session that dies hard leaves its
+ // backend and pads running, and the next session - this one -
+ // correctly refuses to touch them. That refusal must not be
+ // silent, or the user is left with a stale virtual controller
+ // and no lead. One warning line, pointing at the card that can
+ // act on it.
+ ViiperUnownedBackendReport unownedBackend =
+ ViiperSetupManager.AssessUnownedBackend(viiperStatus.ServerRunning);
+ if (unownedBackend.State == ViiperUnownedBackendState.UnownedInUse)
+ {
+ LogDebug("A VIIPER backend " + ProductInfo.ProductName +
+ " does not manage is running and holding " +
+ unownedBackend.DescribeHoldings() +
+ ". If these are leftovers of a session that did not exit cleanly, " +
+ "Settings > VIIPER Virtual Controller Support > Backend process can stop it.",
+ true);
+ }
+
DS4Devices.isExclusiveMode = getUseExclusiveMode(); //Re-enable Exclusive Mode
StartupDiag($"UpdateHidHiddenAttributes begin exclusive={DS4Devices.isExclusiveMode}");
diff --git a/DS4Windows/DS4Control/Viiper/ViiperBackendLifecycle.cs b/DS4Windows/DS4Control/Viiper/ViiperBackendLifecycle.cs
index 3eca031..97cfe25 100644
--- a/DS4Windows/DS4Control/Viiper/ViiperBackendLifecycle.cs
+++ b/DS4Windows/DS4Control/Viiper/ViiperBackendLifecycle.cs
@@ -348,15 +348,34 @@ public static ViiperBackendStopDecision Leave(string reason) =>
/// reason at all leaves the backend running, because a backend left running
/// costs a few megabytes while a backend killed under a live consumer takes
/// that consumer's controller away.
+ ///
+ /// The census also cannot see Windows. It is the backend's
+ /// own bookkeeping, and a devnode Windows still shows after the backend
+ /// has forgotten it — a phantom — would pass it. So the final idle
+ /// verdict takes a second opinion from the PnP tree (invariant (c)'s
+ /// "prove exact-device absence"), supplied as pnpCrossCheck and
+ /// judged by the same rule as everything else here: anything short of
+ /// proven absence leaves the backend running.
///
public static class ViiperBackendStopPolicy
{
+ ///
+ /// Lifecycle invariant (c)'s second opinion: after the census has
+ /// proven the backend idle, ask Windows whether it agrees that no
+ /// usbip-attached device remains. Invoked only when every census gate
+ /// has already passed — it is a cross-check on the final "idle"
+ /// verdict, not a first probe — and judged fail-closed: a check that
+ /// reports devices, cannot answer, returns nothing, or throws all
+ /// leave the backend running. Null means no cross-check was requested,
+ /// which existing callers and tests rely on.
+ ///
public static ViiperBackendStopDecision Decide(
bool settingEnabled,
ViiperOwnedBackend ownedBackend,
bool backendProcessAlive,
ViiperBackendCensus census,
- IReadOnlyCollection ourLiveDevices)
+ IReadOnlyCollection ourLiveDevices,
+ Func pnpCrossCheck = null)
{
if (!settingEnabled)
{
@@ -425,6 +444,52 @@ public static ViiperBackendStopDecision Decide(
bus.ToString(CultureInfo.InvariantCulture)))));
}
+ if (pnpCrossCheck != null)
+ {
+ // The census is the backend's own view of what it hosts. A
+ // devnode Windows still shows after the backend has forgotten
+ // it — the phantom the old fork's present-only probe existed
+ // for — is invisible to it, so the final idle verdict gets a
+ // second opinion from the PnP tree. Every way this check can
+ // fall short of "proven absent" resolves to leaving the
+ // backend running.
+ ViiperPnpAbsenceProof proof;
+ try
+ {
+ proof = pnpCrossCheck();
+ }
+ catch (Exception ex)
+ {
+ return ViiperBackendStopDecision.Leave(
+ "the PnP cross-check threw " + ex.GetType().Name +
+ ": " + ex.Message);
+ }
+
+ if (proof == null)
+ {
+ return ViiperBackendStopDecision.Leave(
+ "the PnP cross-check returned no result");
+ }
+
+ if (proof.Verdict == ViiperPnpAbsenceVerdict.DevicesPresent)
+ {
+ return ViiperBackendStopDecision.Leave(string.Format(
+ CultureInfo.InvariantCulture,
+ "the backend reports itself idle, but Windows still shows {0} device(s) attached through the usbip-win2 controller ({1})",
+ proof.Devices.Count, string.Join("; ", proof.Devices)));
+ }
+
+ if (proof.Verdict != ViiperPnpAbsenceVerdict.ProvenAbsent)
+ {
+ return ViiperBackendStopDecision.Leave(
+ "could not prove at the Windows PnP level that no usbip-attached device remains (" +
+ proof.Detail + ")");
+ }
+
+ return ViiperBackendStopDecision.Stop(
+ "we started it, it is hosting no buses or devices, and Windows shows no device attached through the usbip-win2 controller");
+ }
+
return ViiperBackendStopDecision.Stop(
"we started it and it is hosting no buses or devices");
}
diff --git a/DS4Windows/DS4Control/Viiper/ViiperPnpAbsenceProbe.cs b/DS4Windows/DS4Control/Viiper/ViiperPnpAbsenceProbe.cs
new file mode 100644
index 0000000..bad1374
--- /dev/null
+++ b/DS4Windows/DS4Control/Viiper/ViiperPnpAbsenceProbe.cs
@@ -0,0 +1,444 @@
+/*
+DS4Windows
+Copyright (C) 2026 DS4Windows contributors
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace DS4Windows
+{
+ /// What the PnP cross-check could establish.
+ public enum ViiperPnpAbsenceVerdict
+ {
+ ///
+ /// Windows shows no device attached through the usbip-win2 controller
+ /// — either the controller hosts nothing, or the controller itself is
+ /// not present, in which case nothing can be attached through it.
+ ///
+ ProvenAbsent,
+
+ ///
+ /// Windows still shows at least one devnode attached through the
+ /// usbip-win2 controller.
+ ///
+ DevicesPresent,
+
+ ///
+ /// The device tree could not be read far enough to answer. Not
+ /// absence: "cannot tell" and "gone" are different verdicts, and only
+ /// one of them permits a stop.
+ ///
+ Unproven,
+ }
+
+ ///
+ /// The answer to "does Windows agree that no usbip-attached device
+ /// remains?".
+ ///
+ /// This exists for lifecycle invariant (c): prove exact-device
+ /// absence before releasing the final protection. The backend census
+ /// () is the backend's own view of
+ /// what it hosts; a devnode Windows still shows after the backend has
+ /// forgotten it — the phantom case the old fork's present-only SetupAPI
+ /// probe was written for — is invisible to it. This type carries the
+ /// second opinion, taken from the PnP tree itself.
+ ///
+ public sealed class ViiperPnpAbsenceProof
+ {
+ private ViiperPnpAbsenceProof(ViiperPnpAbsenceVerdict verdict,
+ string detail, IReadOnlyList devices)
+ {
+ Verdict = verdict;
+ Detail = detail ?? string.Empty;
+ Devices = devices ?? Array.Empty();
+ }
+
+ public ViiperPnpAbsenceVerdict Verdict { get; }
+
+ ///
+ /// Plain-language support for the verdict: what proved absence, or why
+ /// nothing could be proven. Empty for ,
+ /// where is the evidence.
+ ///
+ public string Detail { get; }
+
+ ///
+ /// One entry per device Windows still shows attached through the
+ /// controller: the device instance ID, plus its problem code when it
+ /// has one — a phantom devnode reads "(problem 24)" here, which is
+ /// exactly the state that must not be mistaken for absence.
+ ///
+ public IReadOnlyList Devices { get; }
+
+ public static ViiperPnpAbsenceProof Absent(string detail) =>
+ new ViiperPnpAbsenceProof(ViiperPnpAbsenceVerdict.ProvenAbsent,
+ detail, null);
+
+ public static ViiperPnpAbsenceProof Present(
+ IReadOnlyList devices) =>
+ new ViiperPnpAbsenceProof(ViiperPnpAbsenceVerdict.DevicesPresent,
+ null, devices);
+
+ public static ViiperPnpAbsenceProof Unproven(string reason) =>
+ new ViiperPnpAbsenceProof(ViiperPnpAbsenceVerdict.Unproven,
+ string.IsNullOrEmpty(reason) ? "unknown error" : reason, null);
+
+ public override string ToString()
+ {
+ switch (Verdict)
+ {
+ case ViiperPnpAbsenceVerdict.ProvenAbsent:
+ return "absent (" + Detail + ")";
+ case ViiperPnpAbsenceVerdict.DevicesPresent:
+ return string.Format(CultureInfo.InvariantCulture,
+ "{0} device(s) present: {1}", Devices.Count,
+ string.Join("; ", Devices));
+ default:
+ return "unproven (" + Detail + ")";
+ }
+ }
+ }
+
+ ///
+ /// Seam over "ask Windows what is attached through the usbip-win2
+ /// controller". The real implementation walks the PnP tree; tests inject
+ /// a fake.
+ ///
+ public interface IViiperPnpAbsenceProbe
+ {
+ ViiperPnpAbsenceProof Probe();
+ }
+
+ ///
+ /// Proves usbip-device absence from the Configuration Manager device tree.
+ ///
+ /// Why the tree and not a device-ID filter. A virtual pad
+ /// attached over USB/IP carries the same USB\VID_054C&PID_0CE6
+ /// identity as a real one on a physical port, and the personas VIIPER can
+ /// host make the ID list a moving target. Position is the stable fact:
+ /// everything attached through usbip-win2 — and nothing else — lives under
+ /// its emulated host controller. So the probe finds every present devnode
+ /// whose hardware ID matches the controller
+ /// () and
+ /// walks its subtree: root hubs are descended into, and every non-hub node
+ /// found is reported as an attached device, without descending further —
+ /// a composite pad's interface and HID children are that same device, not
+ /// additional ones.
+ ///
+ /// What counts as present. Membership in the tree, not
+ /// health. A devnode with a problem code — including the problem-24
+ /// "device not there" phantom that outlived teardown in the old fork — is
+ /// still a devnode Windows can see, so it is reported (with its problem
+ /// code) rather than skipped. A devnode whose status cannot be read is
+ /// likewise reported: unreadable is not absent.
+ ///
+ /// Failure shape. Never throws. Any error — enumeration,
+ /// tree walk, ID read — becomes ,
+ /// and the caller's policy treats that exactly like "present": the stop
+ /// does not happen. The only cheap verdict here is the fail-closed
+ /// one.
+ ///
+ public sealed class CmTreePnpAbsenceProbe : IViiperPnpAbsenceProbe
+ {
+ private const int ErrorNoMoreItems = 259;
+ private const uint CrSuccess = 0;
+ private const uint CrNoSuchDevnode = 0x0000000D;
+ private static readonly IntPtr InvalidHandleValue = new IntPtr(-1);
+
+ // Root hubs enumerate as USB\ROOT_HUB / ROOT_HUB20 / ROOT_HUB30; the
+ // prefix match covers all three without naming a controller
+ // generation.
+ private const string RootHubInstanceIdPrefix = @"USB\ROOT_HUB";
+
+ // MAX_DEVICE_ID_LEN, plus the terminator.
+ private const int DeviceIdBufferLength = 201;
+
+ public ViiperPnpAbsenceProof Probe()
+ {
+ try
+ {
+ return ProbeCore();
+ }
+ catch (Exception ex)
+ {
+ return ViiperPnpAbsenceProof.Unproven(
+ ex.GetType().Name + ": " + ex.Message);
+ }
+ }
+
+ private static ViiperPnpAbsenceProof ProbeCore()
+ {
+ List controllers = FindControllers(out string failure);
+ if (failure != null)
+ {
+ return ViiperPnpAbsenceProof.Unproven(failure);
+ }
+
+ if (controllers.Count == 0)
+ {
+ return ViiperPnpAbsenceProof.Absent(
+ "the usbip-win2 host controller (" +
+ ViiperDriverManifest.UdeHostControllerHardwareId +
+ ") is not present, so nothing can be attached through it");
+ }
+
+ List devices = new List();
+ string walkFailure = CollectAttachedDevices(controllers, devices);
+ if (walkFailure != null)
+ {
+ return ViiperPnpAbsenceProof.Unproven(walkFailure);
+ }
+
+ return devices.Count > 0
+ ? ViiperPnpAbsenceProof.Present(devices)
+ : ViiperPnpAbsenceProof.Absent(
+ "the usbip-win2 host controller is present and hosts no attached device");
+ }
+
+ ///
+ /// Every present devnode whose hardware IDs include the usbip-win2 UDE
+ /// controller ID. All of them, not the first: a device under a second
+ /// controller instance would otherwise be invisible to a probe whose
+ /// whole point is proving absence.
+ ///
+ private static List FindControllers(out string failure)
+ {
+ failure = null;
+ List found = new List();
+
+ IntPtr deviceInfoSet = SetupDiGetClassDevsWithLastError(IntPtr.Zero,
+ null, 0,
+ NativeMethods.DIGCF_PRESENT | NativeMethods.DIGCF_ALLCLASSES);
+ if (deviceInfoSet == InvalidHandleValue)
+ {
+ failure = "SetupDiGetClassDevs could not enumerate present devices (error " +
+ Marshal.GetLastWin32Error().ToString(CultureInfo.InvariantCulture) + ")";
+ return found;
+ }
+
+ try
+ {
+ for (int index = 0; ; index++)
+ {
+ var deviceInfo = new NativeMethods.SP_DEVINFO_DATA
+ {
+ cbSize = Marshal.SizeOf(),
+ };
+ if (!SetupDiEnumDeviceInfoWithLastError(deviceInfoSet, index,
+ ref deviceInfo))
+ {
+ int error = Marshal.GetLastWin32Error();
+ if (error == ErrorNoMoreItems)
+ {
+ break;
+ }
+
+ failure = "SetupDiEnumDeviceInfo failed while locating the usbip-win2 controller (error " +
+ error.ToString(CultureInfo.InvariantCulture) + ")";
+ return found;
+ }
+
+ if (HasControllerHardwareId(deviceInfoSet, ref deviceInfo))
+ {
+ found.Add((uint)deviceInfo.DevInst);
+ }
+ }
+ }
+ finally
+ {
+ NativeMethods.SetupDiDestroyDeviceInfoList(deviceInfoSet);
+ }
+
+ return found;
+ }
+
+ private static bool HasControllerHardwareId(IntPtr deviceInfoSet,
+ ref NativeMethods.SP_DEVINFO_DATA deviceInfo)
+ {
+ ulong propertyType = 0;
+ int requiredSize = 0;
+ if (NativeMethods.SetupDiGetDeviceProperty(deviceInfoSet,
+ ref deviceInfo, ref NativeMethods.DEVPKEY_Device_HardwareIds,
+ ref propertyType, null, 0, ref requiredSize, 0))
+ {
+ return false;
+ }
+
+ if (requiredSize <= 0)
+ {
+ return false;
+ }
+
+ byte[] buffer = new byte[requiredSize];
+ if (!NativeMethods.SetupDiGetDeviceProperty(deviceInfoSet,
+ ref deviceInfo, ref NativeMethods.DEVPKEY_Device_HardwareIds,
+ ref propertyType, buffer, buffer.Length, ref requiredSize, 0))
+ {
+ return false;
+ }
+
+ string raw = Encoding.Unicode.GetString(buffer);
+ foreach (string id in raw.Split('\0',
+ StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (string.Equals(id.Trim(),
+ ViiperDriverManifest.UdeHostControllerHardwareId,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Walks the subtree of each controller. Returns null on success —
+ /// with holding one entry per attached
+ /// device — or the reason the walk could not be completed.
+ ///
+ private static string CollectAttachedDevices(List controllers,
+ List devices)
+ {
+ // Nodes whose children still need visiting: the controllers
+ // themselves and any root hub found under them. Non-hub nodes are
+ // recorded and not descended into.
+ Stack pending = new Stack();
+ foreach (uint controller in controllers)
+ {
+ pending.Push(controller);
+ }
+
+ while (pending.Count > 0)
+ {
+ uint parent = pending.Pop();
+ uint result = CM_Get_Child(out uint node, parent, 0);
+ if (result == CrNoSuchDevnode)
+ {
+ continue;
+ }
+
+ if (result != CrSuccess)
+ {
+ return "CM_Get_Child returned CONFIGRET " +
+ result.ToString(CultureInfo.InvariantCulture);
+ }
+
+ while (true)
+ {
+ string instanceId = GetDeviceInstanceId(node);
+ if (instanceId == null)
+ {
+ return "CM_Get_Device_ID failed for a devnode under the usbip-win2 controller";
+ }
+
+ if (instanceId.StartsWith(RootHubInstanceIdPrefix,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ pending.Push(node);
+ }
+ else
+ {
+ devices.Add(DescribeDevice(node, instanceId));
+ }
+
+ uint sibling = CM_Get_Sibling(out uint next, node, 0);
+ if (sibling == CrNoSuchDevnode)
+ {
+ break;
+ }
+
+ if (sibling != CrSuccess)
+ {
+ return "CM_Get_Sibling returned CONFIGRET " +
+ sibling.ToString(CultureInfo.InvariantCulture);
+ }
+
+ node = next;
+ }
+ }
+
+ return null;
+ }
+
+ private static string GetDeviceInstanceId(uint devInst)
+ {
+ var buffer = new StringBuilder(DeviceIdBufferLength);
+ uint result = CM_Get_Device_ID(devInst, buffer, buffer.Capacity, 0);
+ if (result != CrSuccess)
+ {
+ return null;
+ }
+
+ string id = buffer.ToString().Trim();
+ return string.IsNullOrEmpty(id) ? null : id;
+ }
+
+ private static string DescribeDevice(uint devInst, string instanceId)
+ {
+ uint result = CM_Get_DevNode_Status(out _, out uint problem,
+ devInst, 0);
+ if (result != CrSuccess)
+ {
+ // A node whose status cannot be read still exists; say so
+ // rather than pretending it is healthy or absent.
+ return instanceId + " (status unreadable)";
+ }
+
+ return problem == 0
+ ? instanceId
+ : string.Format(CultureInfo.InvariantCulture,
+ "{0} (problem {1})", instanceId, problem);
+ }
+
+ // Local declarations with SetLastError, for the same reason
+ // SetupApiDriverPackageInspector carries its own: the legacy
+ // declarations lose the Win32 error, which makes normal
+ // ERROR_NO_MORE_ITEMS termination indistinguishable from a failure.
+ [DllImport("setupapi.dll", EntryPoint = "SetupDiGetClassDevsW",
+ CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern IntPtr SetupDiGetClassDevsWithLastError(
+ IntPtr classGuid, string enumerator, int hwndParent, int flags);
+
+ [DllImport("setupapi.dll", EntryPoint = "SetupDiEnumDeviceInfo",
+ SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool SetupDiEnumDeviceInfoWithLastError(
+ IntPtr deviceInfoSet, int memberIndex,
+ ref NativeMethods.SP_DEVINFO_DATA deviceInfoData);
+
+ [DllImport("cfgmgr32.dll")]
+ private static extern uint CM_Get_Child(out uint childDevInst,
+ uint devInst, uint flags);
+
+ [DllImport("cfgmgr32.dll")]
+ private static extern uint CM_Get_Sibling(out uint siblingDevInst,
+ uint devInst, uint flags);
+
+ [DllImport("cfgmgr32.dll", CharSet = CharSet.Unicode,
+ EntryPoint = "CM_Get_Device_IDW")]
+ private static extern uint CM_Get_Device_ID(uint devInst,
+ StringBuilder buffer, int bufferLength, uint flags);
+
+ [DllImport("cfgmgr32.dll")]
+ private static extern uint CM_Get_DevNode_Status(out uint status,
+ out uint problemNumber, uint devInst, uint flags);
+ }
+}
diff --git a/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs b/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs
index 65382d6..d851f9a 100644
--- a/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs
+++ b/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs
@@ -598,8 +598,10 @@ private static void RecordOwnership(Process process)
///
/// Receives one line describing what was decided and why.
/// Test seam; defaults to the live API.
+ /// Test seam; defaults to the real PnP tree walk.
public static ViiperBackendStopMethod StopOwnedBackendOnExit(
- Action log = null, IViiperBackendCensusSource censusSource = null)
+ Action log = null, IViiperBackendCensusSource censusSource = null,
+ IViiperPnpAbsenceProbe pnpProbe = null)
{
ViiperOwnedBackend owned = OwnedBackend;
Process process = owned?.TryResolve();
@@ -613,9 +615,16 @@ public static ViiperBackendStopMethod StopOwnedBackendOnExit(
.TakeCensus();
}
+ // Handed to the policy as a deferred call so the SetupAPI walk
+ // only runs when the census has already proven the backend
+ // idle - it is the cross-check on the final verdict, not a
+ // routine exit cost.
+ Func pnpCrossCheck = () =>
+ (pnpProbe ?? new CmTreePnpAbsenceProbe()).Probe();
+
ViiperBackendStopDecision decision = ViiperBackendStopPolicy.Decide(
Global.StopViiperBackendOnExit, owned, alive, census,
- ViiperOwnedDeviceRegistry.Snapshot());
+ ViiperOwnedDeviceRegistry.Snapshot(), pnpCrossCheck);
if (!decision.ShouldStop)
{
@@ -652,6 +661,170 @@ public static ViiperBackendStopMethod StopOwnedBackendOnExit(
}
}
+ ///
+ /// Classifies the backend on the API port for the Settings card and
+ /// the startup log: is it ours, somebody's, or a leftover — and what
+ /// is it holding. Read-only.
+ ///
+ ///
+ /// Pass the ping result if one was just taken (the Settings refresh
+ /// has it in hand); null probes again.
+ ///
+ /// Test seam; defaults to the live API.
+ public static ViiperUnownedBackendReport AssessUnownedBackend(
+ bool? serverResponding = null,
+ IViiperBackendCensusSource censusSource = null)
+ {
+ bool responding;
+ try
+ {
+ responding = serverResponding ?? CanPingServer();
+ }
+ catch
+ {
+ responding = false;
+ }
+
+ ViiperOwnedBackend owned = OwnedBackend;
+ bool alive = false;
+ if (owned != null)
+ {
+ Process resolved = owned.TryResolve();
+ alive = resolved != null;
+ try { resolved?.Dispose(); } catch { }
+ }
+
+ ViiperBackendCensus census = null;
+ if (responding && !(owned != null && alive))
+ {
+ census = (censusSource ?? new ViiperApiBackendCensusSource())
+ .TakeCensus();
+ }
+
+ return ViiperUnownedBackendPolicy.Assess(responding, owned, alive,
+ census, ViiperOwnedDeviceRegistry.Snapshot());
+ }
+
+ ///
+ /// The user-initiated stop of a backend this session does not own —
+ /// the (d) affordance, and deliberately not a lifecycle change: it
+ /// runs only from an explicit click, after the card has shown what
+ /// the backend is holding.
+ ///
+ /// The gate re-runs at commit time. Whatever the card said when
+ /// the button was clicked, the state that counts is the one read
+ /// here, so a backend that has started serving this session's own
+ /// pads — or whose census stopped answering — refuses rather than
+ /// proceeds. Stopping the process is the clean unplug path for
+ /// anything still attached to it: the USB/IP peer disappears and the
+ /// driver surprise-removes the devices, the same order VIIPER's own
+ /// exit produces.
+ ///
+ /// Receives one line describing what happened.
+ /// Test seam; defaults to the live API.
+ /// Test seam; defaults to the socket table.
+ /// Test seam; null re-pings at commit time.
+ public static ViiperUnownedBackendStopOutcome StopUnownedBackend(
+ Action log = null,
+ IViiperBackendCensusSource censusSource = null,
+ Func listenerPidSource = null,
+ bool? serverResponding = null)
+ {
+ ViiperUnownedBackendReport report =
+ AssessUnownedBackend(serverResponding, censusSource);
+ if (!report.OffersStop)
+ {
+ ViiperUnownedBackendStopOutcome refused =
+ ViiperUnownedBackendStopOutcome.Refused(
+ DescribeStopRefusal(report));
+ log?.Invoke("VIIPER unowned backend not stopped: " +
+ refused.Reason + ".");
+ return refused;
+ }
+
+ int? processId;
+ try
+ {
+ processId = (listenerPidSource ??
+ ViiperBackendProcessLocator.FindApiListenerProcessId)();
+ }
+ catch
+ {
+ processId = null;
+ }
+
+ if (processId == null)
+ {
+ ViiperUnownedBackendStopOutcome refused =
+ ViiperUnownedBackendStopOutcome.Refused(
+ "could not identify the process listening on port " +
+ ApiPort.ToString(CultureInfo.InvariantCulture));
+ log?.Invoke("VIIPER unowned backend not stopped: " +
+ refused.Reason + ".");
+ return refused;
+ }
+
+ Process process = null;
+ try
+ {
+ string identity;
+ try
+ {
+ process = Process.GetProcessById(processId.Value);
+ identity = string.Format(CultureInfo.InvariantCulture,
+ "{0} (pid {1})", process.ProcessName, process.Id);
+ }
+ catch (Exception ex)
+ {
+ ViiperUnownedBackendStopOutcome refused =
+ ViiperUnownedBackendStopOutcome.Refused(
+ "the listening process (pid " + processId.Value +
+ ") could not be opened: " + ex.Message);
+ log?.Invoke("VIIPER unowned backend not stopped: " +
+ refused.Reason + ".");
+ return refused;
+ }
+
+ ViiperBackendStopResult result = ViiperBackendStopper.Stop(
+ process, BackendStopGracePeriod);
+ ViiperUnownedBackendStopOutcome outcome =
+ ViiperUnownedBackendStopOutcome.From(result, identity);
+ log?.Invoke(string.Format(CultureInfo.InvariantCulture,
+ "VIIPER unowned backend stop ({0}; was holding {1}): {2}.",
+ identity, report.DescribeHoldings(), result.Detail));
+ return outcome;
+ }
+ finally
+ {
+ try { process?.Dispose(); } catch { }
+ }
+ }
+
+ private static string DescribeStopRefusal(
+ ViiperUnownedBackendReport report)
+ {
+ switch (report.State)
+ {
+ case ViiperUnownedBackendState.NoBackend:
+ return "no backend is running";
+ case ViiperUnownedBackendState.ManagedByThisApp:
+ return "the running backend is managed by this session; " +
+ "it stops with the app when the exit setting allows";
+ case ViiperUnownedBackendState.UnownedServingThisApp:
+ return "the backend is serving this session's own " +
+ "controller(s); disconnect them first";
+ case ViiperUnownedBackendState.UnownedInUse
+ when report.ServesThisApp:
+ return "the backend is serving this session's own " +
+ "controller(s) alongside others; disconnect them first";
+ case ViiperUnownedBackendState.UnownedUnreadable:
+ return "what the backend is holding could not be read (" +
+ report.Detail + ")";
+ default:
+ return "the backend's state changed while the request was in flight";
+ }
+ }
+
private static bool CanPingServer()
{
string response = ViiperApiProbe.Request("ping", timeoutMilliseconds: 1000);
diff --git a/DS4Windows/DS4Control/Viiper/ViiperUnownedBackend.cs b/DS4Windows/DS4Control/Viiper/ViiperUnownedBackend.cs
new file mode 100644
index 0000000..1007c25
--- /dev/null
+++ b/DS4Windows/DS4Control/Viiper/ViiperUnownedBackend.cs
@@ -0,0 +1,469 @@
+/*
+DS4Windows
+Copyright (C) 2026 DS4Windows contributors
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Runtime.InteropServices;
+
+namespace DS4Windows
+{
+ ///
+ /// What the running VIIPER backend is, from this process's point of view.
+ ///
+ /// This classification exists for lifecycle invariant (d)'s
+ /// follow-up. If this application dies hard while owning a backend it
+ /// started, the backend and any attached virtual pad survive; the next
+ /// session sees a backend it did not start and — correctly — refuses to
+ /// touch it on exit. That refusal is the safe half of the design. The
+ /// missing half was telling the user, who is otherwise left with a stale
+ /// virtual controller and no in-app explanation. These states drive that
+ /// diagnostics card.
+ ///
+ public enum ViiperUnownedBackendState
+ {
+ /// Nothing answered on the API port.
+ NoBackend,
+
+ ///
+ /// The backend that is running is the one this session started; the
+ /// exit path manages it and the card has nothing to warn about.
+ ///
+ ManagedByThisApp,
+
+ ///
+ /// A backend this session did not start is running and hosting
+ /// nothing: no devices, no buses.
+ ///
+ UnownedIdle,
+
+ ///
+ /// A backend this session did not start is running, and everything it
+ /// hosts is a live device of this session — the normal shape when the
+ /// user runs VIIPER themselves and this application attaches to it.
+ ///
+ UnownedServingThisApp,
+
+ ///
+ /// A backend this session did not start is hosting devices (or
+ /// registered buses) this session cannot account for. Leftovers of a
+ /// session that died hard look exactly like another consumer's live
+ /// devices from here; only the user knows which it is, which is why
+ /// this state gets a description and a button rather than an
+ /// automatic action.
+ ///
+ UnownedInUse,
+
+ ///
+ /// A backend this session did not start is running, and the census
+ /// that would say what it hosts failed. Nothing is offered on this
+ /// state: consent to stop a backend means consent to what it is
+ /// holding, and that could not be read.
+ ///
+ UnownedUnreadable,
+ }
+
+ ///
+ /// The evidence behind a , in the
+ /// units the card renders: which registered devices are this session's,
+ /// which are not, and which buses hold no device at all.
+ ///
+ public sealed class ViiperUnownedBackendReport
+ {
+ public ViiperUnownedBackendReport(ViiperUnownedBackendState state,
+ IReadOnlyList foreignDevices,
+ IReadOnlyList ourDevices,
+ IReadOnlyList emptyBuses,
+ string detail)
+ {
+ State = state;
+ ForeignDevices = foreignDevices ?? Array.Empty();
+ OurDevices = ourDevices ?? Array.Empty();
+ EmptyBuses = emptyBuses ?? Array.Empty();
+ Detail = detail ?? string.Empty;
+ }
+
+ public ViiperUnownedBackendState State { get; }
+
+ /// Registered devices this session cannot account for.
+ public IReadOnlyList ForeignDevices { get; }
+
+ /// Registered devices that are this session's live pads.
+ public IReadOnlyList OurDevices { get; }
+
+ /// Registered buses hosting no device at all.
+ public IReadOnlyList EmptyBuses { get; }
+
+ ///
+ /// Supporting text: the census failure for
+ /// , empty
+ /// otherwise.
+ ///
+ public string Detail { get; }
+
+ ///
+ /// True when stopping the backend would take one of this session's
+ /// own live controllers down with it.
+ ///
+ public bool ServesThisApp => OurDevices.Count > 0;
+
+ ///
+ /// Whether the card may offer its stop button. Policy, not
+ /// presentation: a stop is offered only when the user can be shown
+ /// exactly what they would be stopping (idle, or in use with the
+ /// holdings listed) and none of it is this session's own live
+ /// controller. An unreadable census offers nothing — uninformed
+ /// consent is not consent.
+ ///
+ public bool OffersStop =>
+ State == ViiperUnownedBackendState.UnownedIdle ||
+ (State == ViiperUnownedBackendState.UnownedInUse && !ServesThisApp);
+
+ /// One line for the log, matching what the card shows.
+ public string DescribeHoldings()
+ {
+ List parts = new List();
+ if (ForeignDevices.Count > 0)
+ {
+ parts.Add(string.Format(CultureInfo.InvariantCulture,
+ "{0} device(s) not created by this session: {1}",
+ ForeignDevices.Count,
+ string.Join("; ", ForeignDevices)));
+ }
+
+ if (OurDevices.Count > 0)
+ {
+ parts.Add(string.Format(CultureInfo.InvariantCulture,
+ "{0} of this session's device(s): {1}",
+ OurDevices.Count, string.Join("; ", OurDevices)));
+ }
+
+ if (EmptyBuses.Count > 0)
+ {
+ parts.Add(string.Format(CultureInfo.InvariantCulture,
+ "{0} empty bus(es): {1}", EmptyBuses.Count,
+ string.Join(", ", EmptyBuses.Select(bus =>
+ bus.ToString(CultureInfo.InvariantCulture)))));
+ }
+
+ return parts.Count == 0 ? "nothing registered"
+ : string.Join("; ", parts);
+ }
+ }
+
+ ///
+ /// Classifies the running backend. Pure: every input is handed in, so
+ /// every state is reachable from a test.
+ ///
+ public static class ViiperUnownedBackendPolicy
+ {
+ /// Whether the API ping answered.
+ /// This session's ownership record, if any.
+ ///
+ /// Whether that record still resolves to a live process. A record
+ /// whose process is gone confers nothing: whatever is answering the
+ /// port now is somebody else.
+ ///
+ ///
+ /// What the backend says it hosts. Only consulted for a responding,
+ /// unowned backend; pass null otherwise.
+ ///
+ ///
+ /// The devices this session currently holds, from
+ /// .
+ ///
+ public static ViiperUnownedBackendReport Assess(
+ bool serverResponding,
+ ViiperOwnedBackend ownedBackend,
+ bool ownedBackendAlive,
+ ViiperBackendCensus census,
+ IReadOnlyCollection ourLiveDevices)
+ {
+ if (!serverResponding)
+ {
+ return new ViiperUnownedBackendReport(
+ ViiperUnownedBackendState.NoBackend, null, null, null, null);
+ }
+
+ if (ownedBackend != null && ownedBackendAlive)
+ {
+ return new ViiperUnownedBackendReport(
+ ViiperUnownedBackendState.ManagedByThisApp,
+ null, null, null, ownedBackend.ToString());
+ }
+
+ if (census == null || !census.Succeeded)
+ {
+ return new ViiperUnownedBackendReport(
+ ViiperUnownedBackendState.UnownedUnreadable,
+ null, null, null,
+ census?.FailureReason ?? "no census taken");
+ }
+
+ HashSet ours = ourLiveDevices == null
+ ? new HashSet()
+ : new HashSet(ourLiveDevices);
+
+ List foreign = census.Devices
+ .Where(device => !ours.Contains(device)).ToList();
+ List oursPresent = census.Devices
+ .Where(device => ours.Contains(device)).ToList();
+
+ // A bus that hosts devices is described by those devices; the
+ // extra signal worth naming is a bus with nothing on it, which is
+ // registered state all the same.
+ HashSet busesWithDevices = new HashSet(
+ census.Devices.Select(device => device.BusId));
+ List emptyBuses = census.Buses
+ .Where(bus => !busesWithDevices.Contains(bus)).ToList();
+
+ if (foreign.Count > 0 || emptyBuses.Count > 0)
+ {
+ return new ViiperUnownedBackendReport(
+ ViiperUnownedBackendState.UnownedInUse,
+ foreign, oursPresent, emptyBuses, null);
+ }
+
+ if (oursPresent.Count > 0)
+ {
+ return new ViiperUnownedBackendReport(
+ ViiperUnownedBackendState.UnownedServingThisApp,
+ null, oursPresent, null, null);
+ }
+
+ return new ViiperUnownedBackendReport(
+ ViiperUnownedBackendState.UnownedIdle, null, null, null, null);
+ }
+ }
+
+ ///
+ /// What came of a user-initiated stop of an unowned backend: either it
+ /// was refused before anything was touched, with the reason, or the
+ /// stopper ran and this carries its result.
+ ///
+ public sealed class ViiperUnownedBackendStopOutcome
+ {
+ private ViiperUnownedBackendStopOutcome(bool attempted,
+ ViiperBackendStopMethod method, string reason,
+ string processIdentity)
+ {
+ Attempted = attempted;
+ Method = method;
+ Reason = reason ?? string.Empty;
+ ProcessIdentity = processIdentity ?? string.Empty;
+ }
+
+ /// False when the gate refused before touching anything.
+ public bool Attempted { get; }
+
+ public ViiperBackendStopMethod Method { get; }
+
+ /// The refusal reason, or the stopper's detail line.
+ public string Reason { get; }
+
+ /// "name (pid N)" of the process that was stopped, when one was.
+ public string ProcessIdentity { get; }
+
+ public bool Succeeded => Attempted &&
+ (Method == ViiperBackendStopMethod.Graceful ||
+ Method == ViiperBackendStopMethod.Killed);
+
+ public static ViiperUnownedBackendStopOutcome Refused(string reason) =>
+ new ViiperUnownedBackendStopOutcome(false,
+ ViiperBackendStopMethod.None, reason, null);
+
+ public static ViiperUnownedBackendStopOutcome From(
+ ViiperBackendStopResult result, string processIdentity) =>
+ new ViiperUnownedBackendStopOutcome(true,
+ result?.Method ?? ViiperBackendStopMethod.None,
+ result?.Detail, processIdentity);
+ }
+
+ ///
+ /// One row of the IPv4 listener table, reduced to what the locator needs.
+ ///
+ public readonly struct ViiperTcpListenerRow
+ {
+ public ViiperTcpListenerRow(uint localAddressNetworkOrder,
+ int localPort, uint state, int owningProcessId)
+ {
+ LocalAddressNetworkOrder = localAddressNetworkOrder;
+ LocalPort = localPort;
+ State = state;
+ OwningProcessId = owningProcessId;
+ }
+
+ /// The dwLocalAddr DWORD exactly as the table carries it.
+ public uint LocalAddressNetworkOrder { get; }
+
+ /// Host-order port.
+ public int LocalPort { get; }
+
+ /// MIB_TCP_STATE; 2 is LISTEN.
+ public uint State { get; }
+
+ public int OwningProcessId { get; }
+ }
+
+ ///
+ /// Finds the process behind the VIIPER API port.
+ ///
+ /// A backend this session did not start left no process handle
+ /// behind, so stopping it needs an identity, and the only honest one is
+ /// "the process that owns the listening socket the API answered on". Name
+ /// matching would find any viiper.exe, including one serving a different
+ /// port; the socket table names the one that is actually this
+ /// backend.
+ ///
+ /// Split OS-side / pure-side like the rest of this area: the table
+ /// read is a P/Invoke, the row selection is a function of rows.
+ ///
+ public static class ViiperBackendProcessLocator
+ {
+ private const uint MibTcpStateListen = 2;
+ private const uint LoopbackNetworkOrder = 0x0100007F; // 127.0.0.1
+ private const uint AnyAddress = 0; // 0.0.0.0
+ private const int AfInet = 2;
+ private const int TcpTableOwnerPidListener = 3;
+ private const int ErrorInsufficientBuffer = 122;
+ private const int NoError = 0;
+
+ ///
+ /// The process id listening on the API port, or null when it cannot
+ /// be established. Null is an answer: the caller reports "could not
+ /// identify the process" instead of guessing.
+ ///
+ public static int? FindApiListenerProcessId()
+ {
+ try
+ {
+ return FindListenerProcessId(ViiperSetupManager.ApiPort,
+ ReadIpv4Listeners());
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Selects the listener for . Loopback binding
+ /// is preferred, then the wildcard address, then anything else
+ /// claiming the port — the API host is 127.0.0.1, so the closer the
+ /// binding is to that, the stronger the identification.
+ ///
+ public static int? FindListenerProcessId(int port,
+ IEnumerable rows)
+ {
+ if (rows == null)
+ {
+ return null;
+ }
+
+ List candidates = rows
+ .Where(row => row.State == MibTcpStateListen &&
+ row.LocalPort == port)
+ .ToList();
+ if (candidates.Count == 0)
+ {
+ return null;
+ }
+
+ foreach (uint preferred in new[] { LoopbackNetworkOrder, AnyAddress })
+ {
+ foreach (ViiperTcpListenerRow row in candidates)
+ {
+ if (row.LocalAddressNetworkOrder == preferred)
+ {
+ return row.OwningProcessId;
+ }
+ }
+ }
+
+ return candidates[0].OwningProcessId;
+ }
+
+ private static List ReadIpv4Listeners()
+ {
+ List rows = new List();
+
+ int size = 0;
+ int result = GetExtendedTcpTable(IntPtr.Zero, ref size, false,
+ AfInet, TcpTableOwnerPidListener, 0);
+ if (result != ErrorInsufficientBuffer || size <= 0)
+ {
+ return rows;
+ }
+
+ IntPtr table = Marshal.AllocHGlobal(size);
+ try
+ {
+ result = GetExtendedTcpTable(table, ref size, false, AfInet,
+ TcpTableOwnerPidListener, 0);
+ if (result != NoError)
+ {
+ return rows;
+ }
+
+ int count = Marshal.ReadInt32(table);
+ IntPtr rowPtr = IntPtr.Add(table, 4);
+ int rowSize = Marshal.SizeOf();
+ for (int i = 0; i < count; i++)
+ {
+ MibTcpRowOwnerPid row =
+ Marshal.PtrToStructure(rowPtr);
+ rows.Add(new ViiperTcpListenerRow(row.LocalAddr,
+ DecodePort(row.LocalPort), row.State,
+ unchecked((int)row.OwningPid)));
+ rowPtr = IntPtr.Add(rowPtr, rowSize);
+ }
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(table);
+ }
+
+ return rows;
+ }
+
+ ///
+ /// dwLocalPort carries the port in network byte order in its low two
+ /// bytes; the swap is spelled out rather than routed through socket
+ /// helpers so the units are visible here.
+ ///
+ public static int DecodePort(uint dwLocalPort) =>
+ (int)(((dwLocalPort & 0xFF) << 8) | ((dwLocalPort >> 8) & 0xFF));
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct MibTcpRowOwnerPid
+ {
+ public uint State;
+ public uint LocalAddr;
+ public uint LocalPort;
+ public uint RemoteAddr;
+ public uint RemotePort;
+ public uint OwningPid;
+ }
+
+ [DllImport("iphlpapi.dll", SetLastError = true)]
+ private static extern int GetExtendedTcpTable(IntPtr pTcpTable,
+ ref int pdwSize, [MarshalAs(UnmanagedType.Bool)] bool bOrder,
+ int ulAf, int tableClass, uint reserved);
+ }
+}
diff --git a/DS4Windows/DS4Forms/MainWindow.xaml b/DS4Windows/DS4Forms/MainWindow.xaml
index 2f7aea7..5b836b1 100644
--- a/DS4Windows/DS4Forms/MainWindow.xaml
+++ b/DS4Windows/DS4Forms/MainWindow.xaml
@@ -729,6 +729,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+