diff --git a/DS4Windows/DS4Control/ControlService.cs b/DS4Windows/DS4Control/ControlService.cs
index 5281e74..e89ee94 100644
--- a/DS4Windows/DS4Control/ControlService.cs
+++ b/DS4Windows/DS4Control/ControlService.cs
@@ -1747,13 +1747,15 @@ public bool Start(bool showlog = true)
// VID/PID and a localhost URL, another application's live
// virtual pad is indistinguishable from a leftover, and on
// 2026-07-31 the sweep disconnected one mid-game. The pass
- // still runs for its log line — any unmanaged local import
- // is named, with a pointer to the Settings backend-process
- // card, which can attribute leftovers and clear them with
- // consent. The self-ingestion case that motivated the
- // detach is accepted as a residual risk until inputs can
- // recognise this app's own virtual pads; the startup
- // warning above fires for exactly that state.
+ // runs purely for its log line — any unmanaged local
+ // import is named, with a pointer to the Settings
+ // backend-process card, which can attribute leftovers and
+ // clear them with consent. The self-ingestion case that
+ // motivated the old detach is closed at the correct layer
+ // instead: input discovery refuses any pad attached
+ // through the usbip-win2 controller
+ // (UsbipAttachedInputPolicy), which needs no memory of who
+ // created it.
ViiperUsbipPortManager.ObserveLocalImports();
StartupDiag("DS4Devices.findControllers dispatch begin");
diff --git a/DS4Windows/DS4Library/DS4Devices.cs b/DS4Windows/DS4Library/DS4Devices.cs
index 6545565..d368fa5 100644
--- a/DS4Windows/DS4Library/DS4Devices.cs
+++ b/DS4Windows/DS4Library/DS4Devices.cs
@@ -399,9 +399,32 @@ private static bool IsRealDS4(HidDevice hDevice)
{
// Our own virtual output controllers are never valid input, regardless of
// mode. This is what breaks the Moonlight + DS4-output feedback loop.
- if (IsOwnVirtualDevice(hDevice.DevicePath))
+ //
+ // The second question widens that to position: ANY pad attached
+ // through usbip-win2's controller is a virtual output being served
+ // by something — a leftover of a session that died hard, or
+ // another application's live pad. The in-memory registry cannot
+ // recognise either (it died with its session, or never knew them),
+ // which is how a crashed session's own output used to come back as
+ // an input. Ancestry does not depend on who remembers creating the
+ // device.
+ string devicePath = hDevice.DevicePath;
+ bool ownLiveOutput = IsOwnVirtualDevice(devicePath);
+ bool usbipAttached = ownLiveOutput ||
+ Global.CheckIfUsbIpWin2Device(devicePath);
+ switch (UsbipAttachedInputPolicy.Decide(ownLiveOutput, usbipAttached))
{
- return false;
+ case UsbipInputVerdict.RejectOwnLiveOutput:
+ return false;
+ case UsbipInputVerdict.RejectUnmanagedImport:
+ if (UsbipAttachedInputPolicy.ShouldWarnOnce(devicePath))
+ {
+ AppLogger.LogToGui(
+ UsbipAttachedInputPolicy.DescribeRejectedImport(
+ devicePath), true);
+ }
+
+ return false;
}
bool isVirtualDevice = Global.CheckIfVirtualDevice(hDevice.DevicePath);
diff --git a/DS4Windows/DS4Library/UsbipAttachedInputPolicy.cs b/DS4Windows/DS4Library/UsbipAttachedInputPolicy.cs
new file mode 100644
index 0000000..c214c8b
--- /dev/null
+++ b/DS4Windows/DS4Library/UsbipAttachedInputPolicy.cs
@@ -0,0 +1,125 @@
+/*
+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.
+*/
+
+using System;
+using System.Collections.Generic;
+
+namespace DS4Windows
+{
+ /// What input discovery should do with a candidate pad.
+ internal enum UsbipInputVerdict
+ {
+ /// Not usbip-attached; the normal admission rules apply.
+ Accept,
+
+ ///
+ /// One of this session's own live VIIPER outputs. Rejected quietly —
+ /// its creation already logged, and it leaves with its lifetime.
+ ///
+ RejectOwnLiveOutput,
+
+ ///
+ /// Attached through usbip-win2 but not this session's. Rejected with
+ /// one log line, because unlike our own output nothing else will ever
+ /// explain to the user why this pad is being ignored.
+ ///
+ RejectUnmanagedImport,
+ }
+
+ ///
+ /// Admission policy for pads attached through usbip-win2's emulated host
+ /// controller: they are never input.
+ ///
+ /// Why position decides, not ownership. A pad under that
+ /// controller is a virtual device being served to something — this
+ /// session's own output, a leftover of a session that died hard, or
+ /// another application's live controller. Ingesting the first recurses
+ /// (our output becomes our input, which maps to another output); the
+ /// in-memory path registry has always rejected it. The other two are the
+ /// gap this policy closes: the registry dies with its session, so a
+ /// leftover was re-ingested on the next start — the recursion the old
+ /// startup port sweep existed to prevent, and the reason its removal
+ /// (2026-07-31, after it disconnected another application's live pad)
+ /// left this as the accepted residual. Recognising the pads intrinsically
+ /// retires that residual without touching anything: a devnode's ancestry
+ /// does not depend on who remembers creating it.
+ ///
+ /// What this deliberately gives up: a real controller
+ /// forwarded from another machine over usbip-win2 can no longer be used
+ /// as input. On this project's machines usbip-win2 exists solely as
+ /// VIIPER's transport, remote forwarding has VirtualHere as the supported
+ /// route (see CheckIfVirtualDevice's exclusion list), and the log
+ /// line names the refusal — if the combination ever matters, it arrives
+ /// as a feature request with its evidence attached, not as silence.
+ ///
+ /// Same shape as : the
+ /// verdict is a pure function of the two probes' answers, so the rule is
+ /// testable without a device tree.
+ ///
+ internal static class UsbipAttachedInputPolicy
+ {
+ private static readonly object warnedLock = new object();
+ private static readonly HashSet warnedPaths =
+ new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ internal static UsbipInputVerdict Decide(bool isOwnLiveOutput,
+ bool isUsbipAttached)
+ {
+ if (isOwnLiveOutput)
+ {
+ return UsbipInputVerdict.RejectOwnLiveOutput;
+ }
+
+ return isUsbipAttached
+ ? UsbipInputVerdict.RejectUnmanagedImport
+ : UsbipInputVerdict.Accept;
+ }
+
+ ///
+ /// Whether this rejection is the first for the path. Discovery re-runs
+ /// on every hotplug, and a pad that is being deliberately ignored
+ /// would otherwise be re-announced each time anything else arrives.
+ ///
+ internal static bool ShouldWarnOnce(string devicePath)
+ {
+ if (string.IsNullOrEmpty(devicePath))
+ {
+ return false;
+ }
+
+ lock (warnedLock)
+ {
+ return warnedPaths.Add(devicePath);
+ }
+ }
+
+ ///
+ /// The one line the user gets for a pad that exists but is ignored.
+ /// It has to say what was seen, why it is not input, both readings of
+ /// what it might be, and where to act if it is a leftover.
+ ///
+ internal static string DescribeRejectedImport(string devicePath) =>
+ "Ignoring a controller attached through usbip-win2 (" +
+ devicePath + "): pads on that controller are virtual outputs " +
+ "being served by some application - possibly " +
+ ProductInfo.ProductName + "'s own from a session that ended " +
+ "abruptly, possibly another program's live controller - and are " +
+ "never used as input. If it is a leftover, Settings > VIIPER " +
+ "Virtual Controller Support > Backend process can clear it.";
+
+ internal static void ResetForTests()
+ {
+ lock (warnedLock)
+ {
+ warnedPaths.Clear();
+ }
+ }
+ }
+}
diff --git a/DS4WindowsTests/UsbipAttachedInputPolicyTests.cs b/DS4WindowsTests/UsbipAttachedInputPolicyTests.cs
new file mode 100644
index 0000000..bfae132
--- /dev/null
+++ b/DS4WindowsTests/UsbipAttachedInputPolicyTests.cs
@@ -0,0 +1,92 @@
+using DS4Windows;
+
+namespace DS4WindowsTests;
+
+///
+/// Pads attached through usbip-win2's controller are never input. This closes
+/// the residual PR #30 left open: the in-memory own-output registry dies with
+/// its session, so a hard-killed session's leftover pad used to come back as
+/// an input on the next start — the recursion the old startup port sweep
+/// existed to prevent, and could no longer prevent once it stopped detaching
+/// what it could not attribute. Ancestry does not depend on who remembers
+/// creating the device, so the rule covers our leftovers and other
+/// applications' virtual pads alike.
+///
+[TestClass]
+public class UsbipAttachedInputPolicyTests
+{
+ [TestInitialize]
+ public void ResetWarnState()
+ {
+ UsbipAttachedInputPolicy.ResetForTests();
+ }
+
+ [TestMethod]
+ public void APhysicalPadIsAccepted()
+ {
+ Assert.AreEqual(UsbipInputVerdict.Accept,
+ UsbipAttachedInputPolicy.Decide(isOwnLiveOutput: false,
+ isUsbipAttached: false));
+ }
+
+ [TestMethod]
+ public void OurOwnLiveOutputIsRejectedQuietly()
+ {
+ Assert.AreEqual(UsbipInputVerdict.RejectOwnLiveOutput,
+ UsbipAttachedInputPolicy.Decide(isOwnLiveOutput: true,
+ isUsbipAttached: true));
+ Assert.AreEqual(UsbipInputVerdict.RejectOwnLiveOutput,
+ UsbipAttachedInputPolicy.Decide(isOwnLiveOutput: true,
+ isUsbipAttached: false),
+ "Ownership wins over whatever the ancestry probe said; a device " +
+ "the registry claims is ours is ours to reject as ours.");
+ }
+
+ ///
+ /// The case that used to recurse: usbip-attached, but nothing in this
+ /// session remembers creating it — a dead session's leftover or another
+ /// application's live pad, indistinguishable and equally not input.
+ ///
+ [TestMethod]
+ public void AnUnmanagedUsbipPadIsRejected()
+ {
+ Assert.AreEqual(UsbipInputVerdict.RejectUnmanagedImport,
+ UsbipAttachedInputPolicy.Decide(isOwnLiveOutput: false,
+ isUsbipAttached: true));
+ }
+
+ [TestMethod]
+ public void EachIgnoredPadIsAnnouncedExactlyOnce()
+ {
+ const string pad = @"\\?\hid#vid_054c&pid_0ce6#7&2ab44e7&0&0000#{guid}";
+ const string otherPad = @"\\?\hid#vid_054c&pid_0df2#8&11112222&0&0000#{guid}";
+
+ Assert.IsTrue(UsbipAttachedInputPolicy.ShouldWarnOnce(pad));
+ Assert.IsFalse(UsbipAttachedInputPolicy.ShouldWarnOnce(pad),
+ "Discovery re-runs on every hotplug; a deliberately ignored pad " +
+ "must not be re-announced each time anything else arrives.");
+ Assert.IsTrue(UsbipAttachedInputPolicy.ShouldWarnOnce(otherPad));
+ Assert.IsFalse(UsbipAttachedInputPolicy.ShouldWarnOnce(null));
+ Assert.IsFalse(UsbipAttachedInputPolicy.ShouldWarnOnce(string.Empty));
+ }
+
+ ///
+ /// The line is all the user gets for a pad that visibly exists but is
+ /// ignored: it must say what was seen, admit both readings of what it
+ /// might be, and point at the affordance that can clear a leftover.
+ ///
+ [TestMethod]
+ public void TheAnnouncementCarriesTheEvidenceAndTheRemedy()
+ {
+ const string pad = @"\\?\hid#vid_054c&pid_0ce6#7&2ab44e7&0&0000#{guid}";
+ string line = UsbipAttachedInputPolicy.DescribeRejectedImport(pad);
+
+ StringAssert.Contains(line, pad);
+ StringAssert.Contains(line, "never used as input");
+ StringAssert.Contains(line, "session that ended abruptly");
+ StringAssert.Contains(line, "another program's live controller",
+ "Naming only the leftover reading would invite the user to " +
+ "clear a pad that another application is actively serving.");
+ StringAssert.Contains(line, "Backend process");
+ }
+}
diff --git a/docs/dev/PLAN-PROGRESS.md b/docs/dev/PLAN-PROGRESS.md
index b134b1d..0c6701d 100644
--- a/docs/dev/PLAN-PROGRESS.md
+++ b/docs/dev/PLAN-PROGRESS.md
@@ -3731,3 +3731,54 @@ the controller survived both Thrum start and shutdown. The log carried
the report the detach became.
Suite: **862 passed / 0 failed** (CI filter), from 853.
+
+## 2026-07-31 — Pads attached through usbip-win2 are never input
+
+**Session scope:** the residual the port-sweep fix (PR #30) deliberately left open, closed at
+the correct layer. Branch `fix/usbip-attached-pads-are-not-input`.
+
+### The gap
+
+Input discovery's protection against ingesting our own virtual output was an in-memory
+before/after path registry (`ownVirtualSonyPaths`) plus an active-port check — both records of
+*this session's* creations. A session that dies hard takes the records with it, so its leftover
+pad was a perfectly ordinary DualSense to the next start: ingested, mapped, and recursed on.
+That recursion is what the old startup port sweep prevented by detaching — and PR #30
+established that detach-on-heuristic disconnects other applications' live pads, so the sweep
+now observes only, and the ingestion path had nothing.
+
+The same gap covered another case nobody had named: another application's live virtual pad
+(the maintainer's native-mode DS4Windows serving a DualSense) was equally ingestible the moment
+its device type was enabled — reading input from a pad that a different program is serving to
+games.
+
+### The fix
+
+`UsbipAttachedInputPolicy`, consulted from `DS4Devices.IsRealDS4`: any pad whose devnode
+ancestry reaches the usbip-win2 UDE controller (`Global.CheckIfUsbIpWin2Device`, the ancestry
+walk that already existed for the active-port check) is a virtual output being served by
+something, and is never input. Ancestry does not depend on who remembers creating the device,
+which is exactly what the in-memory registry could not offer. Our own live outputs are rejected
+quietly as before; an unmanaged usbip pad is rejected with one log line per path — naming both
+readings (leftover of a dead session / another program's live controller) and pointing at the
+backend-process card that can clear a leftover with consent.
+
+Deliberately given up: a *real* controller forwarded from another machine over usbip-win2 can
+no longer be input. usbip-win2 exists on these machines solely as VIIPER's transport, remote
+forwarding has VirtualHere as the supported route (`CheckIfVirtualDevice`'s exclusion list),
+and the refusal is named in the log — if the combination ever matters it arrives as a feature
+request with evidence, not as silence.
+
+### Verification
+
+Five policy tests (verdict table, warn-once per path across hotplug re-runs, announcement
+content: evidence, both readings, remedy). Suite: **867 passed / 0 failed** (CI filter),
+from 862.
+
+**Live, against the real coexistence scenario.** With the native-mode build serving its virtual
+DualSense on usbip port 1 and DualSense input enabled in an isolated run, one discovery pass
+produced both halves of the proof: the usbip-attached virtual pad was refused with the new log
+line, and the user's *physical* DualSense on Bluetooth — same VID/PID — was accepted as a
+controller in the same breath. Selectivity, not a VID/PID blanket: position in the device tree
+is the discriminator. The native application, its virtual pad, and its usbip import were
+untouched throughout.