Forks of third-party streaming hosts that the USBridge agent can run, one directory each, with the USBridge changes on top of an upstream snapshot.
| Directory | Upstream | Snapshot (upstream commit date) |
|---|---|---|
punktfunk/ |
https://git.unom.io/unom/punktfunk | a1757d1b7 (2026-11-03, 0.46.0+19) |
sunshine/ |
https://github.com/LizardByte/Sunshine | 2efa60fb8 (2026-11-03) |
vibepollo/ |
https://github.com/Nonary/Vibepollo | 5e6ed8594 (2026-11-02) |
Each directory keeps its upstream license. Each upstream update is a pair of
commits: ": upstream snapshot at " holds the untouched upstream
tree, and the next commit puts the USBridge changes back on top. So
git diff <snapshot commit> HEAD -- <directory> shows exactly what differs
from upstream.
- Fetch upstream into the repository:
git fetch <upstream url> <branch>:refs/up/<fork>. - Take our changes as a patch:
git diff --binary --full-index <snapshot commit> HEAD -- <fork>. If the snapshot does not equal the upstream tree exactly, diff from the upstream commit itself (git diff <upstream>:<path>). That happened forsunshine/, whose first snapshot lackedsrc_assets/macos/build/. - Replace the directory with the new upstream tree and commit it as the new
snapshot:
git rm -r --cached <fork>, thengit read-tree --prefix=<fork>/ -u refs/up/<fork>^{tree}.read-treekeeps the submodule gitlinks. Check that the root.gitmodulesstill lists the same submodules. - Apply the patch with
git apply -3 --indexand resolve the conflicts. If upstream moved files (Punktfunk 0.44 moved crates tocrates/host/), rewrite the paths in the patch first. - Read what upstream changed around our hooks, beyond the conflicts. Look at
new functions our Windows input has to implement, new ViGEm code, new
capture backends, and new checks in front of the
usbridge::calls. Then build every fork (release-all.ymlwith an empty tag) before merging.
Both hosts get the same change. Under the USBridge agent
(USBRIDGE_USB_BROKER_CONTROL names the broker's control port) the host does
not build the client's USB devices itself; it forwards them to the broker's
hid_stream (rust-shine, bin/usb-broker/src/hid_stream.rs), which builds
them on USB/IP ports:
-
a raw HID device the USBridge client sends over the control stream (
LiSendRawHidEvent, a tablet), offered to the client with theLI_FF_USBRIDGE_RAW_HIDfeature flag; -
on Windows, gamepads, as Xbox 360 controllers on usbip-win2 (Sunshine has no other way to build them there, see below; elsewhere
USBRIDGE_PAD_BRIDGE=1turns this on). -
the keyboard: every key event and every UTF-8 text packet, typed by the broker the way RustShine types them (
bin/usb-broker/src/keyboard.rs). On Linux that is the KDE layout switching instead of the IBus Ctrl+Shift+U hex most apps print as digits; on Windows a copy of the broker types from SYSTEM in the console session, so the logon screen and UAC prompts take it. Keys and text share one connection, so they stay in order.USBRIDGE_KEYBOARD_BRIDGE=0turns it off; while the broker can't be reached the host's own injector types.
Plain USB/IP passthrough of a device needs nothing from the host: the broker
does it alone. Without the variable both hosts behave as upstream. The agent
tells a build that has the change from a stock one by asking it
(punktfunk-host usbridge-bridge, sunshine --usbridge-bridge).
punktfunk-host hands a USBridge client's USB devices to the USBridge USB
broker instead of its own injectors: a raw HID device sent over the stream (a
tablet) and, on Windows, gamepads. See
punktfunk/crates/host/punktfunk-host/src/gamestream/usbridge.rs. Without
USBRIDGE_USB_BROKER_CONTROL in its environment the host behaves as upstream.
On Windows, upstream re-ACLs its config dir for the %ProgramData% dir a
SYSTEM service shares with the user: full control for SYSTEM and
Administrators only, read-only for Users. Run outside %ProgramData%, for
example with PUNKTFUNK_CONFIG_DIR in a user's own %APPDATA%, that DACL
locked the host out of its own dir. Its first write (native-key.pem) failed
with "Access is denied" and it exited. The fork hardens only dirs under
%ProgramData% (punktfunk/crates/host/pf-paths/src/lib.rs,
is_under_program_data).
The USBridge agent is a LocalSystem service in Session 0, which has no
displays. It starts punktfunk-host the way it starts Sunshine, Vibepollo
and RustShine: as SYSTEM in the active console session
(sessionlaunch.LaunchInActiveSession), with the admin token,
PUNKTFUNK_CONFIG_DIR and USBRIDGE_USB_BROKER_CONTROL in its environment.
Run as a plain child of the service, the host was still in Session 0. It
could not attach or capture the monitor, and clients got audio but "No video
received from host".
By default every session streams MttVDD (CaptureBackend::DxgiDup). When the
user pins a physical monitor, the agent sets PUNKTFUNK_CAPTURE_MONITOR, and
since 0.44 upstream then mirrors that monitor with Windows Graphics Capture
(CaptureBackend::Wgc) instead.
A monitor duplicated right after it is attached never gives a frame. The
host attached MttVDD, made it primary and opened the duplication about 100 ms
later. Every AcquireNextFrame then returned DXGI_ERROR_ACCESS_LOST, and the
host reopened the duplication at once, again and again. On a Windows 10 Intel
UHD box that was 300+ reopens in 8 s with no frame, and the client showed "No
video received from host". The same monitor attached a second before the
session gave frames at once. Waiting longer for the first frame did not help,
and neither did attaching from another process. The fork does two things:
mttvdd.rsleaves a freshly attached monitor 1.5 s (ATTACH_SETTLE) before capture opens;dxgi_dup.rsbacks off between reopens (25 ms per loss in a row, up to 500 ms) and waits up to 8 s for the first frame (FIRST_FRAME_BUDGET).
With this, the box streams at once, with no reopen. On that box the only audio output is the monitor's HDMI. While the displays switch, that endpoint can vanish for a moment: one session in a few starts without sound ("no loopback-capturable render endpoint").
Before every DuplicateOutput, the capture thread also binds to the desktop
receiving input (attach_input_desktop, OpenInputDesktop +
SetThreadDesktop), as rust-shine's capture-dxgi does. That keeps capture
working through the lock screen and UAC prompts, which a SYSTEM host can
reach.
On Windows, upstream streams only from its own IddCx driver, pf-vdisplay. That
driver creates the virtual monitor and also encodes inside WUDFHost, so the host
must run as a SYSTEM service, and the driver needs an elevated install plus its
own certificate. The fork uses the virtual monitor every USBridge streamer
shares instead: MttVDD (VirtualDrivers' Virtual Display Driver, which RustShine
uses too). That is one driver for all streamers, and the host needs no driver
of its own.
The pieces:
pf-vdisplay/src/vdisplay/windows/mttvdd.rshandles the monitor, ported from rust-shine'svirtual-display:- attaches MttVDD in the mode closest to the client's (
SetDisplayConfig); - makes it the primary display;
- on session end, restores the old primary and detaches it.
- attaches MttVDD in the mode closest to the client's (
pf-capture/src/windows/dxgi_dup.rscaptures it with DXGI Desktop Duplication into BGRA D3D11 textures:- the pointer is blended in (
CursorBlendPass); wait_arrivallets the encode loop follow the desktop's presents.
- the pointer is blended in (
pf-encode/src/select/windows.rsopens the host's own NVENC / AMF / QSV / Media Foundation encoder for those frames; upstream refused here.punktfunk-hosthasCaptureBackend::DxgiDup, picked whenever MttVDD is installed.USBRIDGE_VDISPLAY=pfgoes back to pf-vdisplay.
The monitor has to be primary for 120 Hz: on a hybrid laptop, DWM composes at the
primary display's refresh, so a 120 Hz virtual monitor next to a 60 Hz primary
panel gets 60 frames a second. USBRIDGE_VDD_PRIMARY=0 keeps the physical
primary anyway.
Measured on a Radeon 780M + RTX 3090 laptop at 2560x1600 HEVC, host side, 60 s per run, with a D3D11 vsync animation on the virtual monitor:
| fork on MttVDD | upstream on pf-vdisplay | |
|---|---|---|
| 120 fps: frames sent / unique per s | 118.7 / 117.2 | 119.0 / 119.0 |
| 60 fps: frames sent / unique per s | 60.3 / 60.0 | 60.4 / 60.4 |
| capture, slowest per second (median) | 0.27 ms | 0.6 ms |
Build (Linux):
cd punktfunk
cargo build --release --locked -p punktfunk-host -p punktfunk-encode-worker \
--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode
The fork that used to live in itsme228/Sunshine (web_bind_address,
usbridgeDisplayCursor, no Vulkan encoder), plus the USB broker bridge in
sunshine/src/usbridge.cpp.
Upstream Sunshine on Windows needs third-party input drivers: libvirtualhid's Virtual HID Driver, with its broker service and a paid license, or the retired ViGEmBus. The fork drops both. On Windows the only driver involved is usbip-win2, which is signed and already installed by the USBridge agent:
| Input | Upstream | Fork |
|---|---|---|
| Keyboard, mouse | libvirtualhid driver (licensed), SendInput fallback |
SendInput |
| Touch, pen | libvirtualhid | synthetic pointer devices (Windows 10 1809+) |
| Gamepads | libvirtualhid driver (licensed) or ViGEmBus | USBridge USB broker: Xbox 360 pads on usbip-win2 |
| Tablets, other HID devices from a USBridge client | -- | USBridge USB broker on usbip-win2 |
Where the code is:
sunshine/src/platform/windows/input.cppholds the Windows input.sunshine/src/usbridge.cppholds the broker bridge.
What was removed and what stays:
third-party/ViGEmClientis no longer built, and its submodule is gone.- Of libvirtualhid, only the platform-neutral core is compiled on Windows,
with its "no backend" and "no license" stubs
(
sunshine/cmake/compile_definitions/common.cmake). This keeps the shared config and web UI code building unchanged. Nothing opens the driver, its broker service or the license server.
Without the USBridge agent (no USBRIDGE_USB_BROKER_CONTROL), Sunshine on
Windows has no gamepads. Keyboard, mouse, touch and pen work as before.
Nothing ViGEmBus-related is left either. That covers the gamepad_driver = vigembus choice, the driver status in /api/virtual-input/status, and the
fallback in virtualhid_input. On Windows, the gamepad backend picker,
license prompts and driver cards are also gone from the tray and the web UI.
gamepad_driver remains for macOS only.
Sunshine has no virtual display of its own. The USBridge agent attaches the MttVDD
monitor every USBridge streamer shares before Sunshine starts, and sets output_name to
its friendly name, VDD by MTT. Upstream takes only libdisplaydevice's device_id there,
which is derived from the EDID and the instance path, so the agent cannot compute it. The
fork's map_output_name (sunshine/src/display_device.cpp) therefore also accepts a
monitor's friendly name or GDI name (\\.\DISPLAY5), as Vibepollo does upstream.
These keep upstream libvirtualhid for keyboard, mouse and gamepads (uinput or uhid on Linux; no driver and no license on either). Tablets and other HID devices from a USBridge client still go to the USB broker:
- on Linux, through vhci-hcd;
- on macOS, through the USBridge USB/IP dongle.
Its submodules are registered in this repository's .gitmodules:
git submodule update --init --recursive
cmake -B sunshine/build -S sunshine -DCMAKE_BUILD_TYPE=Release -DBUILD_DOCS=OFF \
-DSUNSHINE_ENABLE_VULKAN=OFF -DSUNSHINE_ENABLE_TRAY=OFF
cmake --build sunshine/build
Nonary's Vibepollo (Apollo/Sunshine fork, Windows only here) with the same USB broker
bridge, web_bind_address and client cursor setting as the Sunshine fork
(vibepollo/src/usbridge.{h,cpp}).
Under the USBridge agent (USBRIDGE_USB_BROKER_CONTROL set, usbridge::under_agent()):
- gamepads and raw HID devices go to the USB broker (usbip-win2);
- the agent owns the displays: it attaches the shared MttVDD monitor and runs Vibepollo
with
virtual_display_mode = disabled, and Vibepollo never starts its display helper (launched as the user, it could not even read its exe in the SYSTEM profile); - a client the agent pairs gets every permission, as on Sunshine. Upstream gives only the first client full access, and the rest get "view + list" until someone grants more in the web UI.
The ViGEm client, its build entries and the third-party/ViGEmClient submodule are gone.
Gamepads come from the USB broker under the agent; without it, only Vibepollo's own drivers
remain (its VHF virtual gamepad driver, the DualSense over usbip-win2). The web UI's ViGEm
endpoints report it as never required, and its installer endpoint refuses.
Built in GitHub Actions only, never on a dev box. The agent takes its streamers from
releases of release-all.yml (Actions -> "Release All Forks"). That workflow builds all
three forks and publishes them under one tag, with a signed manifest.json of every
asset's SHA-256, because the agent refuses unsigned downloads. One tag for all three
matters: GitHub's /releases/latest follows whichever release was published last,
so a one-fork release would hide the others from the agent.
| Fork | Assets |
|---|---|
Sunshine (sunshine-build.yml) |
Linux, Windows, macOS (Developer-ID-signed, notarized) |
Punktfunk (punktfunk-build.yml) |
Linux x86_64 host and encode worker with NVENC; Windows x64 punktfunk-host.exe, no installer |
Vibepollo (vibepollo-build.yml) |
Windows x64 portable zip |
Each fork's platform is picked separately (all by default; none skips the fork). An
empty tag builds and publishes nothing, which is how a change is tested:
gh workflow run release-all.yml -R USBridge-Technologies/Streamers-Forks --ref main \
-f tag= # build only
gh workflow run release-all.yml -R USBridge-Technologies/Streamers-Forks --ref main \
-f tag=v2026.1015.usbridge # release
The older per-fork workflows, sunshine-release.yml and punktfunk-release.yml, still
work, but a release from one of them moves /releases/latest as described above. Only
use them for build-only tests:
scripts/release.sh punktfunk v0.42.0.usbridge.2 # all platforms
scripts/release.sh punktfunk v0.42.0.usbridge.2 windows
scripts/release.sh sunshine v2026.1003.1.usbridge linux
scripts/release.sh punktfunk "" windows # build only