Files
punktfunk/design/gamescope-multiuser.md
T
enricobuehler d01a8fd17a
windows-host / package (push) Failing after 4m16s
ci / rust (push) Failing after 4m56s
ci / web (push) Failing after 22s
ci / docs-site (push) Successful in 1m7s
android / android (push) Successful in 9m19s
ci / bench (push) Successful in 4m47s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 3s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / deploy-docs (push) Has been skipped
deb / build-publish (push) Failing after 6m29s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 7m4s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m17s
apple / swift (push) Successful in 1m13s
apple / screenshots (push) Successful in 5m27s
feat(host): HDR Vulkan layer so Vulkan games get HDR on the virtual display
NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an
IddCx indirect/virtual display, so Vulkan games (Doom: The Dark Ages, id Tech, Indiana
Jones, …) report "device does not support HDR" — even though Windows HDR, DWM compose,
and the client PQ stream all work, and the ICD happily *accepts + presents* a forced HDR
swapchain there. The whole gap is enumeration; the community (Apollo/Sunshine/VDD) wrote
this off as kernel-side / unfixable.

Add VK_LAYER_PUNKTFUNK_hdr_inject (packaging/windows/pf-vkhdr-layer/): a standalone
cdylib Vulkan implicit layer that appends {A2B10G10R10, HDR10_ST2084} + {RGBA16F, scRGB}
to vkGetPhysicalDeviceSurfaceFormats[2]KHR (no need to hook vkCreateSwapchainKHR — the
ICD doesn't validate the color space there). Self-gated on the surface monitor's actual
advanced-color state (DisplayConfig GET_ADVANCED_COLOR_INFO), so it is a complete no-op
on SDR sessions and real monitors (dedup). Always-on (registry-discovered) so it works
regardless of how a game is launched — env-scoping silently fails for already-running
Steam. Escape hatches: DISABLE_PF_VKHDR, PF_VKHDR_EXCLUDE, and a built-in kernel-anti-
cheat denylist.

The installer builds/signs/stages it and registers it under
HKLM64\SOFTWARE\Khronos\Vulkan\ImplicitLayers (opt-out "Install the HDR Vulkan layer"
task); windows-host CI fmt+clippy-gates it (msvc-only FFI).

Live-validated on the RTX box: Doom: The Dark Ages enables HDR over the pf-vdisplay
virtual display.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:33:20 +00:00

4.9 KiB

title, description
title description
gamescope Multi-User Isolation (deferred) Research + design for concurrent INDEPENDENT gamescope desktops (multi-user), and why it's deferred. The shared-desktop multi-view case already landed.

Status: deferred (2026-06-12). Concurrent sessions landed for the shared-desktop multi-view case — multiple devices viewing/controlling the same KWin/Mutter/wlroots desktop (Status). This page captures the research for the other model — independent desktops (each client its own gamescope instance: the multi-user / cloud-gaming-on-one-box case) — and why it's parked. Pick this up from here if the use case becomes a priority.

What landed vs what this is

Model Backends Input Audio Status
Shared-desktop multi-view kwin / mutter / wlroots shared (all drive one desktop) shared (all hear one desktop) landed — correct semantics: stream your desktop to laptop + TV at once
Independent desktops (multi-user) gamescope per-session (each drives its own game) per-session deferred — this page

For independent desktops, shared input/audio is wrong — each user must drive and hear only their own session. gamescope is the natural fit: each create() spawns a fresh nested compositor (own rendering, own EIS input socket). The blocker is that the host's input/audio/mic are host-lifetime shared services, and the gamescope EIS socket is relayed through a single global file.

Current architecture (the research)

Each gamescope process is per-session (vdisplay/gamescope.rs::create() spawns one; the VirtualOutput.keepalive owns it). But:

  • EIS input socket — single global file. gamescope exports LIBEI_SOCKET for its children; a shell wrapper relays it to the fixed path /tmp/punktfunk-gamescope-ei (EI_SOCKET_FILE). Two concurrent instances overwrite each other's socket name in that one file.
  • Injector — one host-lifetime !Send service. punktfunk1.rs::InjectorService opens one inject::open(backend) for the whole run and forwards events over an mpsc channel. It was made shared deliberately (the portal CreateSession churn wedged KWin's EIS — "EIS setup timed out"). For gamescope it reads the one global socket file, so all sessions' input lands in whichever instance wrote last.
  • Audio — global default-sink monitor. audio::open_audio_capture() sets STREAM_CAPTURE_SINK and autoconnects to the host's default sink monitor (PW_ID_ANY) — the whole system's output, not a per-gamescope node. gamescope exposes no per-instance audio node.
  • Mic — one global Audio/Source. MicService feeds one PipeWire source named punktfunk-mic; all clients' mic uplinks mix into it.
  • Per-session already (no work): the gamescope process, the PipeWire video node, and the uinput gamepads.

What it would take

  1. Per-instance EIS socket — give each gamescope a unique relay file (/tmp/punktfunk-gamescope-{id}-ei) and carry the path on VirtualOutput (new field) so the session can find its own socket.
  2. Per-session injector — for gamescope sessions, create a per-session injector bound to that socket (its own thread, since InputInjector is !Send), instead of the shared InjectorService. Keep the shared service for the portal backends (kwin/mutter) where shared input is correct. Ordering nuance: the input thread is wired before the gamescope socket exists, so the per-session injector must open lazily (on first event, by which time gamescope is up) or be created after build_pipeline.
  3. Per-session audio (the bigger piece). gamescope has no per-instance audio node, but audio is isolatable: create a per-session PipeWire null-sink, route that gamescope's apps to it (PULSE_SINK / a target node on the spawn env), and capture that sink's monitor per session. This is the largest addition — null-sink create/teardown + routing + per-session capture.
  4. Per-session mic — a virtual Audio/Source per session (punktfunk-mic-{id}), routed into that gamescope, instead of the one global source.

Why deferred

  • It's a large multi-file refactor — the whole input path (per-instance sockets + per-session injector + the lazy-open ordering), plus per-session null-sink audio routing, plus per-session mic — for a niche use case (multiple independent users gaming on one box).
  • The common concurrency case — stream one desktop to several of your own devices — is the shared-desktop multi-view model, which already landed and is the correct semantics for it.
  • No correctness gap in what shipped: concurrent sessions work today; this is purely the additional independent-desktops model.

Revisit when there's a real multi-user requirement. The plumbing list above is the whole job.