§4.2h (C2): the host already pins the discrete GPU via IOCTL_SET_RENDER_ADAPTER on the IDD-push path; now that the pf-vdisplay driver implements it (cdccec6), correct the stale 'driver returns STATUS_NOT_IMPLEMENTED / STEP-4 stub' comments. Hybrid iGPU+dGPU boxes now actually pin the NVENC GPU.
§6.1 (C5): switch the host gamepad SHM consumers (inject/{dualsense,gamepad}_windows.rs) to derive size/offsets/magic/name from pf_vdisplay_proto::gamepad::{PadShm,XusbShm} via offset_of!/size_of!/helpers, instead of hand-literal OFF_*/140 — proto is now the single source of truth (driver-side switch follows with the gamepad-driver unification). The DualShock4 backend reuses the same pub(super) consts unchanged.
Verified: host clippy (nvenc) clean on the RTX box (x86_64-pc-windows-msvc).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Windows virtual gamepads now have zero external dependencies - ViGEmBus is removed.
- DualShock 4: Windows UMDF backend (inject/dualshock4_windows.rs + dualshock4_proto.rs),
reusing the DualSense SwDeviceCreate game-detection identity fix. The one UMDF driver serves
the DS5 or DS4 identity/descriptor/features/strings per a device_type byte the host stamps into
shared memory. Driver also gains IOCTL_HID_GET_STRING and a 41-byte calibration feature.
- Xbox 360: a new UMDF2 XUSB companion driver (packaging/windows/xusb-driver/) that registers
GUID_DEVINTERFACE_XUSB and answers the buffered XInput IOCTLs from a shared section, so classic
XInputGetState/SetState work with no kernel bus driver. inject/gamepad_windows.rs is rewritten
to drive it and the vigem-client dependency is removed. Xbox One folds to the 360 XInput path.
- Installer: vendor + pnputil-install the three UMDF drivers (packaging/windows/gamepad-drivers/
+ install-gamepad-drivers.ps1, wired into pack-host-installer.ps1 + punktfunk-host.iss).
- Multi-pad: the host stamps each pad index into the device Location (pszDeviceLocation); the
driver reads it via WdfDeviceAllocAndQueryProperty to map its own *-shm-<index>, with
UmdfHostProcessSharing=ProcessSharingDisabled giving each pad its own host (per-pad statics).
Validated live on the Windows host: Cyberpunk native DualSense detection, DS4 identity + descriptor,
XInputGetState + rumble round-trip, two pads -> two distinct XInput slots, and a full installer build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dualshock4.rs left `cargo fmt --all --check` red on main (it landed with the
Windows-host DualSense work): a standalone comment placed directly after a line
ending in a trailing comment gets absorbed and re-aligned to the trailing-comment
column. A blank line before the comment block keeps rustfmt happy — and the
comment readable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
create_swdevice now succeeds. The two requirements (each E_INVALIDARG otherwise): the
enumerator name must have no underscore (use "punktfunk"), and the completion callback is
mandatory (the docs mark pCallback [in], not optional -- NULL is rejected). Back on the
typed windows-rs SwDeviceCreate (a raw-FFI diagnosis confirmed it's the OS, not the
binding), parameterized by pad index (instance pf_pad_<index>), waiting on the callback.
Per-session device: created on connect, SwDeviceClose'd on drop -- no leftovers, no phantom.
Live-verified on the RTX box: device materializes, the UMDF driver binds, SDL3 identifies it
as a PS5 ("DualSense Wireless Controller"), input flows; removed on disconnect. The
dualsense-windows-test CLI now cycles input + prints any 0x02 feedback for diagnosis.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DualSenseWindowsManager now SwDeviceCreate's the pf_dualsense devnode per session
(SwDeviceClose on drop), matching the Linux UHID pad's lifecycle. It's best-effort:
SwDeviceCreate currently hits an unresolved E_INVALIDARG when a completion callback is
passed (an underscore in the enumerator name was a second cause, fixed by using
"punktfunk"), so on failure the host keeps the section + data plane and falls back to
an out-of-band devnode (installer/devgen) — see docs/windows-dualsense-scoping.md.
Add a `dualsense-windows-test` host CLI that drives the manager (create devnode + push
a frame + hold), used to validate the path. Live on the RTX box: the manager creates
the section + pushes report 0x01 and a devnode serves it to a HID read (b1=0xC0,
b8=0x28) — the host-side data plane works end to end.
cargo check + clippy -D warnings clean on x86_64-pc-windows-msvc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the Windows UMDF DualSense driver into the host as a real pad backend, so a
client that requests a DualSense gets a genuine one on a Windows host (instead of
folding to Xbox 360).
- Extract the transport-independent DualSense contract (DsState + from_gamepad,
serialize_state, parse_ds_output, DUALSENSE_RDESC, feature blobs, DS_* consts)
out of the Linux-only UHID backend into inject/dualsense_proto.rs, shared by both
platforms; dualsense.rs is now just the /dev/uhid plumbing.
- Add inject/dualsense_windows.rs: DualSenseWindowsManager mirroring the Linux
DualSenseManager (same new/handle/apply_rich/pump/heartbeat surface) over a
DsWinPad that creates the Global\pfds-shm-<idx> section (CreateFileMappingW +
SDDL D:(A;;GA;;;WD) so WUDFHost can open it), writes serialize_state -> input
slot, polls output_seq -> parse_ds_output -> rumble/hidout callbacks.
- Un-gate the seam: PadBackend::DualSenseWindows arm; pick_gamepad gains a
windows flag (DualSense honored on linux||windows; DS4/Xbox One stay Linux-only).
Verified: Linux cargo test gamepad_resolution_precedence + clippy clean; Windows
cargo check + clippy -D warnings clean (on the RTX box). Device lifecycle still
uses an out-of-band devnode (devgen/installer); SwDeviceCreate per session is next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends virtual-controller support beyond Xbox 360 + DualSense. Goal: a
physical Xbox One or PS4 pad on the client gets a near-native matching virtual
pad on the host, auto-resolved from the controller type.
Protocol/core:
- GamepadPref gains XboxOne (wire 3) + DualShock4 (wire 4); to_u8/from_u8/
from_name/as_str + C ABI PUNKTFUNK_GAMEPAD_XBOXONE/_DUALSHOCK4 constants
(compile-time guard ties them to the enum). Single-byte wire form is
unchanged, so it's forward-compatible (older peers degrade to Auto).
Host (Linux):
- New UHID DualShock 4 backend (inject/dualshock4.rs) bound by hid-playstation:
lightbar, touchpad, motion, rumble — DualSense minus adaptive triggers /
player LEDs / mute. Reuses the DualSense pure state + button mapping; only the
report byte layout, the real-DS4 HID descriptor, the GET_REPORT handshake
(0x12 MAC mandatory; 0x02 calibration; 0xa3 firmware) and the touchpad
resolution (1920x942) differ. Touchpad/motion ride the existing 0xCC plane,
lightbar the 0xCD Led plane (deduped); rumble the universal 0xCA plane.
- Xbox One/Series is the uinput Xbox-360 backend parameterized with the One S
USB identity (045e:02ea) for matching glyphs — XInput-identical otherwise.
- PadBackend dispatch + resolver handle both; off Linux the UHID pads and
One/Series fold into Xbox 360. Windows-host DS4 (ViGEm) deferred.
Clients (auto-resolve physical pad -> virtual type, plus manual settings):
- Linux/Windows (SDL3): SDL_GAMEPAD_TYPE_PS4 -> DualShock 4, _XBOXONE ->
Xbox One; PadInfo carries the resolved pref; DS4 touchpad/motion capture +
lightbar already type-agnostic. Linux settings combo + label updated.
- Apple (GameController): GCDualShockGamepad/GCXboxGamepad detection, DS4
touchpad capture, settings picker entries.
- Android (Kotlin): InputDevice VID/PID auto-detect (matching the other
clients) + settings entries.
- probe: --gamepad help/aliases.
Also hardens the Android JNI boundary: wrap the teardown + poll-thread shims in
catch_unwind so a panic degrades to a logged no-op instead of aborting the app.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The injector reattached the input desktop (OpenInputDesktop + SetThreadDesktop,
two syscalls) before EVERY event. Now it stays bound to its desktop and only
reattaches on a SendInput short write (the input desktop switched into UAC/lock)
+ retries once — Sunshine's model. No steady-state per-event overhead; still
follows the desktop across the secure boundary, serving both desktops.
Validated on the RTX 4090 (host as SYSTEM): client-rs --input-test injected for
~6s with no "blocked desktop" errors. Completes all 6 steps of the two-process
secure-desktop build; only a real-UAC user smoke test remains.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the host→client rumble path on Windows, the analogue of the Linux
uinput EV_FF read loop: a game's force-feedback on the virtual Xbox 360
pad is delivered by ViGEm's notification API (`request_notification` →
`spawn_thread`, gated by the crate's `unstable_xtarget_notification`
feature). A per-pad background thread stores the latest motor levels;
`pump_rumble` relays changes to the client on the universal 0xCA plane
(motors scaled 0..255 → 0..65535). Dropping the target aborts the
notification, so the thread exits with the session. Live verification
still needs a physical pad.
Also fix the Windows backends' clippy debt — these modules are cfg-
excluded from Linux CI, so `clippy -D warnings` never saw them, and the
VM's rustc 1.96 clippy is stricter on shared code than the CI image:
- dxgi: manual checked division → checked_div().map_or
- sendinput: `x = x | y` → `x |= y`
- sudovda: `.then(|| ptr)` → `.then_some(ptr)`
- m3 pick_compositor: drop the needless early return (match form)
- m3 resolve_compositor: Windows arm is a tail expr, not `return`
All Windows backends now build + clippy clean (default and --features
nvenc); Linux unaffected (fmt/clippy/check green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Controller disconnected every few seconds" (Forza Horizon, held steady): the
virtual UHID DualSense emitted HID report 0x01 ONLY on state change, but a real
DualSense streams it continuously (~250 Hz). When the player holds the
wheel/throttle steady the client sends no wire events, so the host wrote nothing
and /dev/uhid went silent for seconds — the kernel hid-playstation driver / Proton
/ SDL treat that as an unplugged controller. (The uinput X-Box pad is immune:
evdev holds last-known state with no periodic-report requirement.)
Add DualSenseManager::heartbeat(max_gap): re-emit each live pad's CURRENT report
when it's been silent for max_gap (idempotent — a stale-but-correct frame, never a
phantom input; write_state bumps seq+timestamp). write() resets the per-pad timer,
so an actively-used pad emits no extra reports — the heartbeat only fills genuine
silence. PadBackend::heartbeat() drives it at an 8 ms gap (~125 Hz) for DualSense
(no-op for X-Box), called every input-thread tick (the loop already runs ≤4 ms).
GET_REPORT feature replies + the pad lifecycle were ruled out by the investigation
(pad is created once, never torn down mid-session). Compiles, clippy/fmt clean, 78
host tests pass. Verify on the box: held-idle DualSense stays present in evtest /
no SDL CONTROLLERDEVICEREMOVED; Forza no longer toasts "controller disconnected".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Windows GamepadManager via vigem-client (ViGEmBus) — the uinput-xpad analogue: one virtual Xbox 360 controller per client pad index, created lazily on first State. GameStream/Moonlight already uses the XInput conventions (low-16 button bits, sticks -32768..32767 +Y up, triggers 0..255), so the GamepadFrame->XGamepad mapping is 1:1. Replaces the non-Linux GamepadManager stub (same new/handle/pump_rumble API the m3 PadBackend drives, so no m3 change). Graceful when ViGEmBus is absent (gamepad disabled, session continues). Compiles clean on Windows + Linux; live-test needs the ViGEmBus driver + a physical pad. Rumble back-channel is a TODO (ViGEm notification API).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Windows InputInjector via SendInput (Win32 KeyboardAndMouse), mirroring the wlroots backend: absolute mouse (MOUSEEVENTF_VIRTUALDESK normalized to the virtual desktop), relative mouse, scancode keyboard (MapVirtualKeyExW + extended-key flagging), scroll (no sign flip — Windows wheel matches GameStream), buttons. Client already sends Windows VK codes (no keycode table). Reattaches the thread to the input desktop (OpenInputDesktop/SetThreadDesktop) to survive UAC/lock switches. New Backend::SendInput, the Windows auto-default in default_backend(), open() arm, windows-crate features. Compiles clean on Windows + Linux. Live injection validates with the in-session host run (SendInput is desktop-isolated from an SSH network logon).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two DualSense (UHID) fixes surfaced live on the Bazzite host:
- Battery: serialize_state never set the input report's status byte (struct off 52 →
r[53]), so hid-playstation read battery capacity 0 and SteamOS warned "low battery"
even on a fully-charged pad. Set it to 0x0A (discharging, low nibble 0xA → 100 %) —
a virtual pad has no real cell. (Forwarding the client pad's real charge is a later
feature.) Regression assert added to the layout test.
- Rumble diagnostic: log the silent→active transition when forwarding a buzz on the
0xCA plane, so a live test can tell "host never receives rumble from the game"
(Steam Input / parse) apart from "client doesn't render it". Once per buzz, no spam.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The libei backend forwarded mouse wheel only via scroll_discrete (120-per-detent).
Mutter floors a sub-detent delta — a trackpad, a precise/high-res wheel, or a
fractional smooth-scroll event — to zero whole clicks, so small scrolls never land and
you have to spin the wheel a lot before anything moves. Emit the continuous `scroll`
axis (logical px, ~15 px/detent) alongside the discrete steps, matching the wlroots
backend's 15-px/notch behaviour, so every delta moves proportionally while full
detents still drive line/page scrolling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deep dive into the two GNOME-only host bugs (KWin/gamescope clean):
1. Stale-frame flashes (windows at old positions, typed text reverting):
Mutter renders its virtual monitors DIRECTLY into the PipeWire buffer
pool, and NVIDIA has no implicit dmabuf fencing — our zero-copy
import raced the render and encoded each pool buffer's PREVIOUS
contents. Fix, in order of preference:
- Consumer-side PipeWire explicit sync (SPA_META_SyncTimeline): new
drm_sync module (DRM timeline-syncobj wait/signal via raw ioctls,
unit-tested incl. a live signal->wait round trip); announced
post-format via update_params (the OBS pattern — at connect time
the meta makes producers fail allocation, observed on KWin), with
a blocks=3 Buffers filter so the producer's sync pod wins; acquire
point awaited before any read (GPU import or CPU mmap), release
point signaled on every path.
- Where the producer can't do explicit sync (Mutter on NVIDIA today:
no cogl sync_fd, "error alloc buffers"), a sticky fallback flips
the capture to the synchronous CPU/shm path — Mutter's glReadPixels
download orders against its render, so frames are correct by
construction. First session pays one ~10 s probe+retry; later
sessions go straight there. Validated live on home-worker-3
(GNOME 50 + RTX 4090): clean fallback, 30 MB HEVC streamed.
- Sync is only announced on Mutter sessions (new VirtualOutput.mutter
tag): KWin+NVIDIA fails allocation when merely asked, and doesn't
need it (verified unchanged: zero-copy CUDA import + 1.1 MB/10 s).
PUNKTFUNK_EXPLICIT_SYNC=0 disables the probe outright.
2. Clicks wedged in the focused app after disconnect+reconnect: a client
vanishing mid-press left keys/buttons latched in the compositor —
Mutter keeps the destroyed EIS device's implicit grab and the focused
app stops taking clicks until restarted. EiState now tracks held
keys/buttons/touches (wire codes) and synthesizes releases through
the normal inject path before the EIS connection goes away.
GNOME hosts on NVIDIA temporarily lose zero-copy (correctness over
throughput); the moment Mutter+driver gain working explicit sync, the
sync path engages automatically and zero-copy returns.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On a headless GNOME host the xdg-desktop-portal RemoteDesktop Start() blocks on an
interactive "Allow remote control?" approval nobody can click, so libei input timed out
("EIS setup timed out") and neither mouse nor keyboard worked — even though video worked
(it uses Mutter's direct RemoteDesktop API).
Add EiSource::MutterEis: obtain the EIS fd from
org.gnome.Mutter.RemoteDesktop.Session.ConnectToEIS (CreateSession → Start → ConnectToEIS),
no portal and no approval. Selected for GNOME/Mutter; KWin keeps the RemoteDesktop portal,
gamescope keeps its own EIS socket.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment reflow only — the pinned "stable" channel moved and CI checks
formatting with the current toolchain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From a bug-hunt + unsafe-audit pass (4 reviewers + adversarial verify). It
confirmed ZERO real bugs in the recent batched/paced data-plane work — these are
the surfaced cleanups + one genuine soundness fix:
- SOUNDNESS (reduce unsafe): inject/gamepad.rs::pump_ff did `ptr::read` of an
InputEventRaw (align 8, holds a timeval) out of a 1-aligned [u8; N] buffer — UB
per the reference (x86_64 tolerates it, but it can miscompile under LTO). Use
ptr::read_unaligned + a SAFETY note. Zero behavior change.
- recv parity: recv_batch (recvmmsg) didn't drop an oversized/truncated datagram
the way scalar recv does — poll_frame now skips a message whose len fills the
buffer (> MAX_DATAGRAM_BYTES), matching recv's `n >= RECV_BUF` drop. (AEAD
already rejected these on encrypted sessions; this restores the documented
invariant on the batched path.)
- dedup unsafe FFI: factor the identical mmsghdr-from-iovec construction out of
send_batch + recv_batch into one `mmsghdrs()` helper — the raw-pointer
scaffolding + its lifetime SAFETY note now live in one place.
- docs: TARGET_SOCKBUF no longer calls paced sending future work (it landed,
m3.rs::paced_submit); gamescope.rs input is no longer "(TODO)" (wired +
live-validated); the PUNKTFUNK_PERF `wire_mbps` field is renamed `tx_mbps` and
noted as attempted/sealed bytes (send_dropped shows what didn't reach the wire).
Full suite (35 + loopback round-trip + 6) + clippy + fmt green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The uinput X-Box 360 backend logs "virtual gamepad created" on success, but
the UHID DualSense backend logged only on failure — so a working DualSense
session was silent and indistinguishable in the logs from one where no pad
was ever created. Add the matching success log.
This makes a DualSense-not-working report self-diagnosing: the host now logs
either "virtual DualSense created (UHID hid-playstation)" or the existing
"virtual DualSense creation failed — controller input disabled" (which fires
when /dev/uhid isn't writable — i.e. the 60-punktfunk.rules uhid rule isn't
installed or the user isn't in the 'input' group).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Apple client grows full gamepad support and punktfunk/1 learns to negotiate
the virtual pad type:
- Protocol: Hello carries a GamepadPref byte (offset 21, the same trailing-byte
back-compat pattern as the compositor; echoed resolved in Welcome at 54).
Host precedence: explicit client choice > PUNKTFUNK_GAMEPAD env > Xbox 360,
DualSense (UHID) only where available. ABI: punktfunk_connect_ex2 +
punktfunk_connection_gamepad (connect_ex delegates; ABI_VERSION stays 2 — the
trailing byte IS the compat mechanism). punktfunk-client-rs gets --gamepad.
- Swift client: GamepadManager (app-lifetime discovery + selection — Settings
lists every controller with capabilities/battery/"In use"; exactly ONE pad
forwards as pad 0, auto = most recently connected, or pinned), GamepadCapture
(snapshot-diff button/axis events, DualSense touchpad + ~250 Hz motion on the
rich-input plane, held state released on switch/deactivate/stop),
GamepadFeedback (rumble → CoreHaptics per-handle engines; lightbar →
GCDeviceLight; player LEDs → playerIndex; adaptive-trigger blocks → the
table-driven DualSenseTriggerEffect parser → GCDualSenseAdaptiveTrigger,
exact for the 10-zone positional modes). The pad type auto-resolves from the
physical controller at connect time, user-overridable in Settings.
- Host DualSense fixes surfaced by adversarial review against hid-playstation /
SDL / Nielk1 ground truth: input-report sensor/touch offsets were off by one
(the kernel read garbage motion + phantom touches), the L2/R2 trigger blocks
were swapped (the report is right-trigger-first), feedback now gates on the
report's valid-flags (a plain rumble write no longer blanks lightbar/
triggers), and the touchpad rescale clamps to the advertised ABS_MT extents.
- Tests: Hello/Welcome trailing-byte back-compat, pick_gamepad precedence,
byte-exact input-report layout, valid-flag gating, per-mode trigger-parser
table (incl. packed 3-bit zones), wire conversions, and a scripted loopback
feedback burst (PUNKTFUNK_TEST_FEEDBACK=1) asserted through the xcframework
on the rumble + HID-output planes.
Validated: cargo test/clippy/fmt green on macOS + Linux (61 host tests), swift
build/test green, test-loopback.sh green, tvOS/iOS targets compile. DualSense
motion sign/scale is derived from the calibration blob, not yet live-verified
(constants isolated in GamepadWire).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The host-lifetime libei injector could connect to a gamescope EIS socket whose
listen socket exists but whose server never drives the EI handshake — a stale
socket left by a SIGKILLed prior session, or one created early in a new
gamescope's startup before its libei server is ready. `UnixStream::connect` to a
socket *file* succeeds the moment the path exists, so the worker sailed past the
connect and then hung forever in `handshake_tokio` (or sat connected with no
device ever resumed). Because `LibeiInjector::inject` only enqueues onto a
channel (the !Send worker owns the connection), the send never errors, so
InjectorService never noticed the dead worker and never reopened — every input
event for the whole session was silently swallowed. The 30s setup timeout didn't
help: a typical session ends first, so input just died with no error logged.
Reconnecting made it worse (more stale sockets to land on).
Two self-heal bounds, both paths (gamescope socket + KWin/GNOME portal):
- Bound the EI handshake at 8s — a non-responding EIS server now errors instead
of hanging, so the worker exits and the next inject() reopens.
- Watchdog: if no input device resumes within 5s of connecting, treat the
connection as dead-on-arrival and exit (same reopen path). Healthy servers
add+resume a device within a beat of the handshake.
Verified on-box: clean gamescope + KWin paths connect/resume/emit unchanged; a
stale listener that accepts-but-never-handshakes now errors in 8s; two
back-to-back gamescope sessions both inject (session 2 reopens against the fresh
socket). Independently confirmed end-to-end delivery on KWin — a focused wev got
the injected motions/keys/buttons — i.e. injection itself was never broken, only
its recovery from a bad connection.
Also adds permanent low-volume diagnostics so the next "input dead" report is
instantly triageable: log each EIS device's capabilities on resume, the first of
each InputKind a client sends + whether it emitted, and no-resumed-device drops.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of "input doesn't work" on the unified host: a single fresh session
injects fine (EIS connects, "Gamescope Virtual Input" device added), but the
host-lifetime injector reused a STALE per-session EIS socket across sessions →
"connect EIS socket …: Connection refused". (Headless gamescope is EIS-only — it
ignores uinput — so libei/EIS is the one input path for both gamescope and KWin;
no second path needed.)
- connect_socket_file: re-READ the relay file and RETRY the connect on
refused/missing (the live gamescope's EIS appears shortly), bounded at 15s,
instead of connecting once and bubbling ECONNREFUSED.
- GamescopeProc::drop: clear the relayed EIS socket name on teardown so a dead
session can't hand a stale path to the next reconnect.
Validated: two sessions back-to-back each reconnect (EIS connected + device added).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PUNKTFUNK_GAMEPAD=dualsense now routes a session's gamepad through a real virtual
DualSense (UHID + hid-playstation) end to end:
- host: a `PadBackend` enum (m3.rs) selects `GamepadManager` (uinput xpad, default)
or the new `DualSenseManager` (dualsense.rs) per session. The manager keeps each
pad's full DsState so touchpad + motion (rich-input plane) persist across
button/stick frames, and services the !Send /dev/uhid fd only on the input thread
(which cycles <=4ms, so the GET_REPORT init handshake completes).
- feedback: `service()` now returns `DsFeedback { hidout, rumble }`. Motor rumble
stays on the universal 0xCA plane (so non-DualSense clients still feel it; manager
dedups change); lightbar / player LEDs / adaptive-trigger effects ride the new
0xCD HID-output plane (host->client) as `HidOutput`.
- rich input: touchpad contacts + motion ride the 0xCC plane (client->host) as
`RichInput`, applied via `DualSenseManager::apply_rich` (merged with button state;
touch normalized 0..65535 -> the touchpad resolution).
- connector + C ABI: `NativeClient::next_hidout` / `send_rich_input`, exported as
`punktfunk_connection_next_hidout` (-> PunktfunkHidOutput) and
`punktfunk_connection_send_rich_input` (<- PunktfunkRichInput); header regenerated.
- reference client: `--rich-input-test` drives the DualSense touchpad + motion and
logs the 0xCD feedback that comes back.
Validated live on-box: a synthetic-source m3-host + client-rs created the real
kernel DualSense, drove 0xCC, and decoded 12 live 0xCD events (the kernel's actual
lightbar/trigger init reports) with the data plane unaffected (600/600 frames).
Adversarial review fixes folded in: the input loop no longer skips the rich drain +
feedback pump on a dropped gamepad event, and the touch contact id is clamped to its
slot. Remaining: the Apple client renders triggers/rumble on a real DualSense.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Roadmap #5 (rich DualSense). A UHID device presents a real Sony DualSense to the kernel's
hid-playstation driver (matched by VID 054C/PID 0CE6), which exposes the full controller —
gamepad, motion sensors, touchpad, lightbar/player LEDs, adaptive triggers — unlike the
uinput X-Box-360 pad.
- inject/dualsense.rs: hand-rolled /dev/uhid codec (no bindgen) mirroring the uinput style;
the canonical inputtino 232-byte USB HID report descriptor + the feature-report replies
(calibration 0x05 / pairing 0x09 / firmware 0x20) — answering hid-playstation's GET_REPORTs
during init is REQUIRED or it creates no input devices. DsState::from_gamepad maps a
GameStream/XInput frame → the DualSense input report (buttons/sticks/triggers/dpad, +
touchpad/motion fields); service() answers GET_REPORTs and parses HID OUTPUT (rumble /
lightbar RGB / player LEDs / adaptive triggers) into quic::HidOutput.
- scripts/60-punktfunk.rules: grant /dev/uhid to the 'input' group (like /dev/uinput).
- `punktfunk-host dualsense-test`: standalone validation (no streaming session).
Validated live: `dualsense-test` → hid-playstation binds + loads ff_memless + led_class_
multicolor; the kernel creates "Punktfunk DualSense 0" (event/js gamepad + Motion Sensors +
Touchpad + Headset Jack) at VID 054c/PID 0ce6, plus the lightbar at /sys/class/leds/
input*:rgb:indicator; js shows the Cross button firing + the left-stick sweep. Clippy/fmt
clean, workspace tests green. Wiring into the session (pad-type select, touchpad/motion
routing, HID-output back-channel) is the next commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Roadmap #5 (touch, ahead of the XL UHID DualSense work). Touch fits the existing 18-byte
InputEvent: code = touch id, x/y = client pixels, flags = (w<<16)|h — the same absolute
mapping as MouseMoveAbs.
- core: InputKind::{TouchDown=9, TouchMove=10, TouchUp=11} + from_u8 + roundtrip test.
- host inject/libei.rs: request the RemoteDesktop Touchscreen device type, bind the Touch
capability, and inject ei_touchscreen down/motion/up (one event = one frame, per the
protocol rule), mapping coordinates into the device region like the abs pointer. wlroots
has no virtual-touch protocol wired — no-ops there.
- client-rs --touch-test: drags a synthetic finger (touch id 0) in a circle.
Validated live on headless KWin: the portal GRANTS the Touchscreen device type
(Keyboard|Pointer|Touchscreen), proving the request path — but KWin's EIS server creates no
touchscreen *device*, so touch currently no-ops on this KWin (now logged once, not silent).
The injection code is correct and will land on a backend that exposes ei_touchscreen
(gamescope / a newer compositor / the real touch-client path). Workspace green, clippy/fmt
clean, +1 unit test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full project rename, decided 2026-06-10:
- Crates/binaries: punktfunk-core / punktfunk-host / punktfunk-client-rs.
- C ABI: punktfunk_* symbols, Punktfunk* types, include/punktfunk_core.h,
PUNKTFUNK_FEATURE_QUIC guard (header regenerated; cbindgen renames updated, incl.
PUNKTFUNK_BTN_*/PUNKTFUNK_AXIS_* wire constants).
- Protocol: punktfunk/1 — control-plane magic LMN1 → PKF1, nonce salt lmn1 → pkf1.
WIRE BREAK: clients must be rebuilt from this revision.
- Env knobs: PUNKTFUNK_VIDEO_SOURCE / PUNKTFUNK_COMPOSITOR / PUNKTFUNK_ZEROCOPY / ….
- Host config dir: ~/.config/punktfunk (the box's dir was migrated in place — the
persistent identity is unchanged, pinned fingerprints stay valid).
- Swift package: PunktfunkKit + PunktfunkCore.xcframework + PunktfunkConnection
(Sources/PunktfunkClient app + tests renamed with it); build-xcframework.sh updated.
- scripts/: 60-punktfunk.rules, punktfunk-host.service; OpenAPI doc regenerated.
Also: scripts/headless/run-headless-kde.sh — full headless Plasma bringup. Root cause of
"desktop but no apps/settings" over the stream: plasmashell launched without
XDG_MENU_PREFIX=plasma-, so the launcher resolved a nonexistent applications.menu and
rendered an empty menu. The script sets the complete KDE session env (menu prefix,
KDE_FULL_SESSION, session version) and rebuilds ksycoca before starting plasmashell.
Gate: 97/97 tests, clippy -D warnings (both feature sets), fmt, C-ABI harness PASS,
zero lumen references left outside .git.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>