Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions DS4Windows/DS4Control/ControlService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
27 changes: 25 additions & 2 deletions DS4Windows/DS4Library/DS4Devices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
125 changes: 125 additions & 0 deletions DS4Windows/DS4Library/UsbipAttachedInputPolicy.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>What input discovery should do with a candidate pad.</summary>
internal enum UsbipInputVerdict
{
/// <summary>Not usbip-attached; the normal admission rules apply.</summary>
Accept,

/// <summary>
/// One of this session's own live VIIPER outputs. Rejected quietly —
/// its creation already logged, and it leaves with its lifetime.
/// </summary>
RejectOwnLiveOutput,

/// <summary>
/// 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.
/// </summary>
RejectUnmanagedImport,
}

/// <summary>
/// Admission policy for pads attached through usbip-win2's emulated host
/// controller: they are never input.
///
/// <para><b>Why position decides, not ownership.</b> A pad under that
/// controller is a virtual device being <i>served</i> 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.</para>
///
/// <para><b>What this deliberately gives up:</b> a <i>real</i> 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 <c>CheckIfVirtualDevice</c>'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.</para>
///
/// <para>Same shape as <see cref="MoonlightVirtualDevicePolicy"/>: the
/// verdict is a pure function of the two probes' answers, so the rule is
/// testable without a device tree.</para>
/// </summary>
internal static class UsbipAttachedInputPolicy
{
private static readonly object warnedLock = new object();
private static readonly HashSet<string> warnedPaths =
new HashSet<string>(StringComparer.OrdinalIgnoreCase);

internal static UsbipInputVerdict Decide(bool isOwnLiveOutput,
bool isUsbipAttached)
{
if (isOwnLiveOutput)
{
return UsbipInputVerdict.RejectOwnLiveOutput;
}

return isUsbipAttached
? UsbipInputVerdict.RejectUnmanagedImport
: UsbipInputVerdict.Accept;
}

/// <summary>
/// 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.
/// </summary>
internal static bool ShouldWarnOnce(string devicePath)
{
if (string.IsNullOrEmpty(devicePath))
{
return false;
}

lock (warnedLock)
{
return warnedPaths.Add(devicePath);
}
}

/// <summary>
/// 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.
/// </summary>
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();
}
}
}
}
92 changes: 92 additions & 0 deletions DS4WindowsTests/UsbipAttachedInputPolicyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using DS4Windows;

namespace DS4WindowsTests;

/// <summary>
/// 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.
/// </summary>
[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.");
}

/// <summary>
/// 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.
/// </summary>
[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));
}

/// <summary>
/// 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.
/// </summary>
[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");
}
}
51 changes: 51 additions & 0 deletions docs/dev/PLAN-PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading