Skip to content

Per-seat gamepad controller isolation #19

Description

@jmlopezdona

Sharing a week of measurements from a two-seat host, because the answer turned out to be a HidHide
feature that is documented nowhere and that I think MultiSeat should be using.

Full write-up, with every measurement table and the refuted alternatives:
https://claude.ai/code/artifact/a2fd1da7-c18f-4fd0-a7a2-64fb71bcb534

Host: Windows 11 26100.8972, two seats + a console player, HidHide driver 1.4.181.0, ViGEmBus 1.22.0.
Everything below was measured there between 2026-08-14 and 2026-08-20.

Even though I've arrived at a configuration that works for several sessions (two — that's the limit because my GPU has 2 NVENC encoders; with the 5000 series you could do up to 3 simultaneous sessions, regardless of whether the GPU can actually handle those three games in parallel or not), I have the feeling that the setup is very convoluted, and I'd like to know if there's any option (e.g. using an alternative to ViGEmBus) that's simpler, and therefore probably more robust as well.

Without a doubt, once you start digging into the different gamepad driver standards (XInput, DInput, SDL, HID...), the quirks of individual games (e.g. ones that only read XInput slot 0), and the limitations of per-session device isolation in Windows (something already solved on Linux), you begin to understand just how hard it is to set up parallel sessions on a single Windows installation.

The problem

Every Apollo instance asks ViGEmBus for its pad, and the resulting PnP node is machine-level with
no session affinity
. With both seats streaming, a probe run in session 0 — which is neither
seat — saw both pads, XInput slots 0 and 1, live packet counters on both. Keyboard and mouse are
fine, because Apollo SendInputs those inside the session; it is only the pad that escapes.

DEVPKEY_Device_SessionId does not rescue this: Windows clears it every time the device starts.
Five variants refuted (stamping a live pad, Disable-PnpDevice re-enumeration, reconnect, stamping
the phantom devnode, disable → stamp → enable). DuoStream does confine devices this way, but from
their own driver — they dropped ViGEmBus.

What actually works

Append !<sessionId> to the device instance path in HidHide's ordinary, persistent blacklist:

USB\VID_045E&PID_028E\01!2                          seat's pad, XUSB node → visible only in session 2
HID\VID_045E&PID_028E&IG_00\3&130C1E12&0&0000!2     same pad, HID node    → visible only in session 2

Shipped since v1.4.181.0 (2023-10-31), contributed by DuoStream's author in commit 3934d9a.
HidHide/src/Logic.c:817 is the whole decision:

sessionId != 0 && jailSessionId != 0 && sessionId == jailSessionId ? FALSE : TRUE

It is in no README, no CLI help and no release note. Measured in a VM against the released signed
driver, one fresh process per reading:

blacklist entry session 0 session 1
…\2&130C1E12&0&0000 denied denied
…\2&130C1E12&0&0000!1 denied opens
…\2&130C1E12&0&0000!2 (no such session) denied denied
(nothing) opens opens

The third row is what makes it a measurement and not a coincidence.

Why this matters more than the usual answers: it acts on the device, not on the reader. No
DLL in a game folder, no environment variable, no list of .exe paths — so it does not care where a
game is installed or which API it enumerates through. Confirmed in game, both directions: The Last
of Us Part II
in a seat (which reads through Windows.Gaming.Input, the hole no local filter can
reach) stopped responding to the console pad and responded to its own; NBA 2K26 on the console,
the same in reverse.

Two bonuses: on a session match Blacklisted() returns FALSE, so the whitelist is never
consulted
and its global-hole problem disappears for a confined device. And the jail is per entry,
not one global partition — N seats are N rules, so the two-player ceiling lifts.

Three things that will bite whoever implements this

1. A pad is not one device, and XInput reads the node nobody hides. A ViGEm x360 publishes
three nodes. Hiding the HID one — which is the obvious move — leaves the pad fully visible in XInput:

what is hidden HID, s0 XInput, s0 XInput, s1
the HID node only denied connected connected
the XUSB node only opens empty empty
XUSB with !1 opens empty connected

The XUSB node is USB\VID_xxxx&PID_xxxx\NN. Both ends need a rule. Leave the keyboard/mouse/consumer
nodes some pads publish alone.

2. HidHide filters at open time, so a rule written after the pad is late by definition. One pad
appeared at 13:27:33 and its rule landed at 13:27:39.9 — 6.9 s, because every read of HidHide's
config costs ~800 ms. Batching a whole pass into one call gets it to 1.7–3.1 s over eight
creations, and that does not close it: dwm, explorer and GameInputSvc of every session
open each new pad inside that window, and those handles never expire. Symptom that exposed it: a
seat's Xbox app moved with the other seat's controller, because it opens nothing itself — it reads
from its own GameInputSvc, which had both.

A rule for an absent device matches nothing, so it is inert and free to write in advance. Measured
with control: with one pad's rule pre-written by hand, that pad came up open only to its own session,
while the other — rule 2 s late — was open in all three. Same host, same instant, only variable being
when.

And it cuts both ways: releasing a wrong rule does not hand the pad back, because the session
that opened it keeps its handle and the real owner tried once and does not retry. It took two
reconnects.

3. Ownership must be derived, never configured. My config said the console player's pad was
045E:028E while the actual controller was a GameSir — so the watcher had not recognised it for
days, and nothing said so. Two traps in deriving it:

  • "Emulated" cannot be recognised by name. A ViGEm pad's XUSB node reports its parent as
    ROOT\SYSTEM\0001; nothing in the tree says "ViGEm". Looking for the word read both seats' pads as
    physical and confined them to a session neither seat was in, reporting success. The real test is
    bus (USB\, BTHENUM\, PCI\) versus PnP root.
  • A placement made by elimination must not be remembered, and must be said out loud. With one
    free seat, the pad created by the console player's Apollo got confined to that seat with no
    evidence and no warning — the panel showed a healthy verified jail while the player's controller
    had stopped working — and the wrong decision then survived two minutes after the situation that
    produced it was gone.

Useful side effect: session 0 never matches (the driver requires sessionId != 0), so a service
running there can use "can I still open this device?" as a live probe that a rule took effect. Worth
having, given the feature is undocumented and a future release could drop it silently.

What I built

Four switches, all off or empty by default — a host that sets nothing behaves exactly as before:

key what it turns on
EnableHidHideCloaking the mechanism: one entry per relevant node per pad, suffixed with the owner's session
EnablePadRulePreWrite write each seat's rule before its pad exists
EnableSeatPadIdentity a fixed per-seat serial and VID/PID — requires a patched, self-built Apollo/Vibepollo; see the caveat below
EnableXInputRemap per-process XInput slot compaction; refuses to deploy without confinement

The XInput proxy and the SDL environment filter are now deprecated and off; with confinement on, the
already-deployed DLLs are removed at startup. GamepadType stops being part of the mechanism and
seats go back to x360 (native XInput).

On the identity switch: the last section of the XUSB path is a serial that ViGEmClient.cpp:558
picks in user mode by sweeping the free ones from 1 — so where a seat's pad is born is decided
by whoever connects first
, which is the whole explanation for the \01\02 alternation that makes
instance paths look non-deterministic.

Pinning it costs a patched, self-built Apollo, and that is not a small ask. Being explicit,
because it is the one part of this that a host cannot just switch on:

  • Two diffs, 243 added lines, against Nonary/Vibepollo 1.18.4-stable.2. They add three config
    keys — gamepad_serial_no, gamepad_vid, gamepad_pid — all additive and inert when unset.
  • One of them has to live in the ViGEmClient submodule, because PVIGEM_TARGET is opaque in
    the public header and the serial cannot be set from outside the client. So it is a fork of a fork,
    with two levels of rebase every time upstream moves.
  • You have to build it yourself. MSYS2 UCRT64, the documented package list plus
    mingw-w64-ucrt-x86_64-nlohmann-json
    , which the docs omit and without which
    tools/display_settings_helper_main.cpp will not compile. A full build from clean is 2 min 44 s
    here, which is what makes the rebase tolerable — but the toolchain has to exist first.
  • The resulting binary needs its own directory, separate from any console-side Apollo install,
    and MultiSeat points at it by path. That also makes rollback one config value.
  • Nothing of this is upstream. I have offered the keys to Vibepollo separately; until then it is
    carried.

Without it everything above still works, just approximately: the XUSB path is learned from where
a seat's pad has been rather than computed, so a seat that lands on a serial it has never used falls
back to the reactive path for that one creation. That is the honest trade — the patch removes an
approximation, it does not add a capability. Diffs and the full rationale are in the write-up.

Measured result

Two seats streaming, both playing, each session moved only by its own pad. Across seven hours
covering seats with and without identity, the console player's pad, reconnections, a binary swap and
a reboot: zero pads attributed by guesswork.

What it does not cover

  • XInput slot numbers are machine-wide and confinement cannot renumber them. Each session saw
    exactly one pad — isolation working — but each kept the slot XUSB gave it, so one seat had slot 0
    and the other slot 1, and DOOM Eternal moved in one and was blind in the other. 6 of 11 titles
    measured here bind their input to slot 0. Free workaround: the slot frees when its pad disappears,
    so whoever wants the slot-0-bound title just creates their pad first (move the stick, wait, then
    launch). The DLL is only needed when both want such a title at once.
  • A whitelisted application walks straight through it. The list is global and cannot pair an app
    with a device, so one entry sees every confined pad. Mine had twelve entries including
    Hades2.exe, and Hades II in a seat was moved by both players' pads while Forza beside it saw
    none. ConsolePlayerApps has to be empty. Note also it protects the process that opens the
    device, which is often not the game — Forza runs from a versioned WindowsApps path, and Steam
    games often read pads through steam.exe. That made two of my first three readings say the
    opposite of the truth.
  • Talking to HidHideCLI.exe has five traps, all of which made an earlier implementation a
    silent no-op while HidHide sat installed and healthy: it never exits if it inherits stdout/stderr;
    it saves the config on exit even for a pure listing (so reads need --cancel); the value goes
    directly after the switch (--dev-hide "PATH", there is no --id form); back-to-back invocations
    return empty, which reads exactly like "nothing is configured"; and the driver's control device
    takes one caller at a time, returning 0x0005 Access denied and doing nothing silently. All five
    are written up with fixes in the full report.
  • Unmeasured: rumble through a confined pad, raw DirectInput/HID with a borrowed identity, and a
    pad connected by Bluetooth and cable at once.

Offer

All of this is implemented and running: the HidHide CLI client with the five traps handled, the
per-session rule writer, the pre-write registry, attribution, the live probe, the API surface and the
dashboard tab. Happy to open PRs against whatever subset you want, or to answer questions — no
expectation that you take it as-is.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions