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.
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_SessionIddoes not rescue this: Windows clears it every time the device starts.Five variants refuted (stamping a live pad,
Disable-PnpDevicere-enumeration, reconnect, stampingthe 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:Shipped since v1.4.181.0 (2023-10-31), contributed by DuoStream's author in commit
3934d9a.HidHide/src/Logic.c:817is the whole decision: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:
…\2&130C1E12&0&0000…\2&130C1E12&0&0000!1…\2&130C1E12&0&0000!2(no such session)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
.exepaths — so it does not care where agame 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 canreach) 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 neverconsulted 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
x360publishesthree nodes. Hiding the HID one — which is the obvious move — leaves the pad fully visible in XInput:
!1The XUSB node is
USB\VID_xxxx&PID_xxxx\NN. Both ends need a rule. Leave the keyboard/mouse/consumernodes 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:33and its rule landed at13:27:39.9— 6.9 s, because every read of HidHide'sconfig 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,explorerandGameInputSvcof every sessionopen 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:028Ewhile the actual controller was a GameSir — so the watcher had not recognised it fordays, and nothing said so. Two traps in deriving it:
ROOT\SYSTEM\0001; nothing in the tree says "ViGEm". Looking for the word read both seats' pads asphysical and confined them to a session neither seat was in, reporting success. The real test is
bus (
USB\,BTHENUM\,PCI\) versus PnP root.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 servicerunning 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:
EnableHidHideCloakingEnablePadRulePreWriteEnableSeatPadIdentityEnableXInputRemapThe XInput proxy and the SDL environment filter are now deprecated and off; with confinement on, the
already-deployed DLLs are removed at startup.
GamepadTypestops being part of the mechanism andseats go back to
x360(native XInput).On the identity switch: the last section of the XUSB path is a serial that
ViGEmClient.cpp:558picks 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↔\02alternation that makesinstance 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:
Nonary/Vibepollo1.18.4-stable.2. They add three configkeys —
gamepad_serial_no,gamepad_vid,gamepad_pid— all additive and inert when unset.ViGEmClientsubmodule, becausePVIGEM_TARGETis opaque inthe 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.
mingw-w64-ucrt-x86_64-nlohmann-json, which the docs omit and without whichtools/display_settings_helper_main.cppwill not compile. A full build from clean is 2 min 44 shere, which is what makes the rebase tolerable — but the toolchain has to exist first.
and MultiSeat points at it by path. That also makes rollback one config value.
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
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.
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 sawnone.
ConsolePlayerAppshas to be empty. Note also it protects the process that opens thedevice, which is often not the game — Forza runs from a versioned
WindowsAppspath, and Steamgames often read pads through
steam.exe. That made two of my first three readings say theopposite of the truth.
HidHideCLI.exehas five traps, all of which made an earlier implementation asilent 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 goesdirectly after the switch (
--dev-hide "PATH", there is no--idform); back-to-back invocationsreturn empty, which reads exactly like "nothing is configured"; and the driver's control device
takes one caller at a time, returning
0x0005 Access deniedand doing nothing silently. All fiveare written up with fixes in the full report.
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.