Commit Graph

81 Commits

Author SHA1 Message Date
enricobuehler 89aa52bc58 refactor(inject): uinput + XUSB managers onto PadSlots (3.3)
The two stateless backends keep their structs and special pumps (uinput
FF-effect mixing via pump_ff/last_mix; the XUSB stale-residual
RUMBLE_IDLE_TIMEOUT force-off) but delegate slot lifecycle — table,
unplug sweep, gate-checked create — to the shared PadSlots. XUSB resets
last_rumble/last_active on the swept indices and on fresh create exactly
as before (the G10/G16-adjacent semantics untouched).

Two accepted deltas, both flagged in the plan (§3a): the uinput
arrival/unplug log lines gain the pad-identity label every other backend
already has ("controller arrival (X-Box 360 pad)"), and XUSB's
f.index.max(0) clamp is replaced by the bounds check every other manager
uses — a negative wire index is now dropped instead of being treated as
pad 0.

Verified: .21 clippy --all-targets -D warnings clean + full suite 290
pass / 0 fail (uinput); .133 clippy --all-targets -D warnings EXITCODE 0
(XUSB).

Part of G12/3.3 (§3a.4 commit 9) — all seven managers now share the
PadSlots lifecycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:57:00 +02:00
enricobuehler 384fc30833 refactor(inject/linux/steam_controller): convert to UhidManager<SteamProto> (3.3)
The most hook-laden conversion: SteamControllerManager becomes a pub
type alias of UhidManager<SteamProto>. The Steam-specific pieces map
cleanly onto the trait — open() delegates to open_transport (usbip →
gadget → UHID fallback, which keeps its own per-transport logging, so no
extra success line, matching the old ensure), merge_frame preserves the
trackpad coords/touch-bits/clicks + motion across button-only frames
(the G2 fix, verbatim), and the gamepad-mode-entry pulse rides the
force_heartbeat hook. DeckTransport goes pub (type Pad in a public-trait
impl). Also un-fuses a doc-comment glitch where the manager's doc had
been merged onto the DeckTransport enum.

Verified on .21: clippy --all-targets -D warnings clean; full suite 290
pass / 0 fail.

Part of G12/3.3 (§3a.4 commit 8) — all five stateful managers now share
one skeleton.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:41:21 +02:00
enricobuehler 365d4bb8f1 refactor(inject/linux/dualshock4): convert to UhidManager<Ds4LinuxProto> (3.3)
DualShock4Manager becomes a pub type alias of UhidManager<Ds4LinuxProto>
(the same shape as the other three DS-family conversions); the bespoke
last_led lightbar dedup folds into the shared HidoutDedup exactly as the
Windows DS4 conversion did. With 3.3.0 already applied, the proto half
is byte-identical to Ds4WinProto except the transport open — the codec,
the mappers, and now the manager all shared.

Verified on .21: clippy --all-targets -D warnings clean; full suite 290
pass / 0 fail.

Part of G12/3.3 (§3a.4 commit 7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:38:45 +02:00
enricobuehler f1efd3091e refactor(inject/windows/dualshock4): convert to UhidManager<Ds4WinProto> (3.3)
DualShock4WindowsManager becomes a pub type alias of
UhidManager<Ds4WinProto>. The bespoke last_led lightbar dedup folds into
the shared HidoutDedup: the proto's service() converts Ds4Feedback.led
into a HidOutput::Led, and HidoutDedup compares it against the
last-forwarded value with the same reset-on-create/unplug semantics the
Option<(u8,u8,u8)> vec had. Everything else mirrors the DualSense
conversion (same DsState mappers as linux/dualshock4.rs). Ds4WinPad goes
pub (type Pad in a public-trait impl, E0446 otherwise).

Verified on the Windows CI VM .133: cargo clippy -p punktfunk-host
--all-targets -- -D warnings EXITCODE 0 at this tip.

Part of G12/3.3 (§3a.4 commit 6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:36:45 +02:00
enricobuehler 446818eea6 refactor(inject/windows/dualsense): convert to UhidManager<DsWinProto> (3.3)
DualSenseWindowsManager becomes a pub type alias of
UhidManager<DsWinProto>; the proto supplies the UMDF sealed-channel open
(+ success log), the DsState mappers (identical to linux/dualsense.rs,
paddle fold included), and the section feedback poll. Lifecycle, dedup,
and heartbeat come from the shared skeleton — behavior-identical, same
log lines (LABEL DualSense/Windows + the driver-install hint).

DsWinPad goes pub (it appears as type Pad in the impl of the public
PadProto trait — E0446 otherwise; the Linux pads were already pub).

Verified on the Windows CI VM .133 (same pinned 1.96.0 MSVC toolchain +
Public-path FFmpeg/LLVM the runner uses): cargo clippy -p punktfunk-host
--all-targets -- -D warnings EXITCODE 0 at the DS4-conversion tip
(.173 was down; .133 carries the identical toolchain).

Part of G12/3.3 (§3a.4 commit 5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:36:45 +02:00
enricobuehler 4d6c2394dc refactor(inject/linux/dualsense): convert to UhidManager<DsLinuxProto> (3.3)
The first backend onto the shared skeleton: DualSenseManager becomes
pub type DualSenseManager = UhidManager<DsLinuxProto>, where DsLinuxProto
supplies only the protocol half (UHID open + success log, DsState
neutral/merge/apply_rich with the paddle fold, best-effort write, the
GET_REPORT-answering service pass). handle/apply_rich/heartbeat/pump and
the unplug sweep now come from uhid_manager — behavior-identical
(same log lines, same dedup + reset semantics), zero Pads-router edits.

Verified on .21: clippy --all-targets -D warnings clean; full suite 290
pass / 0 fail.

Part of G12/3.3 (§3a.4 commit 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:10:42 +02:00
enricobuehler 2bea02b0ea feat(inject): generic PadProto + UhidManager<B> stateful manager (3.3 layer 2)
The shared skeleton of the five stateful UHID/UMDF managers (Linux
DualSense / DualShock 4 / Steam Deck, Windows DualSense / DualShock 4),
written once over PadSlots: event routing with the unplug sweep and
was-the-unplug early return, the merge-preserving frame fold, rich-input
application, the silence heartbeat (with a backend force hook for the
Steam mode-entry pulse), and the feedback pump with rumble dedup +
HidoutDedup. A backend supplies only its per-controller half via
PadProto: open / neutral / merge_frame / apply_rich / write_state /
service — exactly where the real protocol differences live.

Method surface (new/handle/apply_rich/pump/heartbeat) matches what the
punktfunk1.rs Pads router already drives, so each backend will convert
as a pub type alias with zero router edits.

Additive only — no backend converted yet. 8 mock-backend tests make the
manager lifecycle unit-testable for the first time; G2 (rich fields
survive a button-only frame) and G10 (Arrival eager-creates) are now
generic regression tests, plus removal-frame no-recreate, absent-pad
rich drop, create-backoff state tracking, rumble/hidout dedup + re-arm
on recreate, and heartbeat gap/force semantics.

Verified on .21: clippy --all-targets -D warnings clean; suite 293
pass / 0 fail (285 prior + 8 new).

Part of G12/3.3 (gamepad-review-cleanup.md §3a.3, commit 3 of the §3a.4
sequence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:08:25 +02:00
enricobuehler 528a51d75c feat(inject): shared PadSlots<P> slot table + lifecycle (3.3 layer 1)
The Vec<Option<Pad>> slot table, active_mask unplug sweep, and PadGate-
checked create that all seven backend managers copy-paste, extracted into
one unit-tested inject/pad_slots.rs (cfg any(linux,windows), like
pad_gate). sweep() returns the swept indices as a bitmask and ensure()
returns fresh-create, so managers reset their per-index sibling state
(state / last_rumble / dedup / clocks) without closure gymnastics.
Lifecycle log lines are label/device/hint-parameterized to stay
byte-identical per backend; open() keeps the success line (it knows the
transport detail).

Additive only — no manager converted yet; first unit coverage for the
sweep/create lifecycle (5 tests: freshness, sweep-once semantics, gate
integration, recreate, pump iteration).

Verified on .21: clippy --all-targets -D warnings clean; suite 285
pass / 0 fail (280 prior + 5 new).

Part of G12/3.3 (gamepad-review-cleanup.md §3a.3, commit 2 of the §3a.4
sequence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:04:22 +02:00
enricobuehler b597bb74bd fix(inject/linux/ds4): fold the Linux DS4 backend onto the shared proto codec (3.3.0)
The Linux DualShock 4 backend missed the G2-era shared-mapping work and
drifted from dualshock4_proto three ways, leaving two user-visible gaps
on the DS4 kind (Windows, written later against the proto, is correct):

- its serialize_state duplicated the proto's byte-for-byte EXCEPT byte 7:
  raw st.buttons[2] instead of buttons2_with_click(), so a rich-plane pad
  click never reached the report;
- its inline apply_rich never set touch_click and dropped the Steam LEFT
  pad entirely (surface 1 skipped), where the shared
  dualsense_proto::DsState::apply_rich splits the one touchpad left/right;
- handle() didn't preserve touch_click across button-only frames.

Net effect: Deck client -> Linux host on the DS4 kind = pad clicks and
the left pad dead.

Delete the local serialize_state/parse_ds4_output/Ds4Feedback/pack_touch
and touch-dim consts in favor of dualshock4_proto (dropping the proto's
keep-in-sync FIXME), route rich events through the shared
DsState::apply_rich, and preserve touch_click in the frame merge exactly
like the other three DS-family managers. The proto's serialize_offsets
test gains a touch_click case pinning byte 7 bit 1.

Verified on .21: cargo clippy -p punktfunk-host --all-targets -D warnings
clean; full suite 277 pass / 0 fail.

Pre-step 3.3.0 of the G12 skeleton extraction (gamepad-review-cleanup.md
§3a.2) — the behavior fix lands before the mechanical dedup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:01:37 +02:00
enricobuehler 94802795e7 Merge branch 'gamepad-apple-cleanup': cross-client + host gamepad review cleanup (G1–G25)
audit / bun-audit (push) Successful in 13s
ci / docs-site (push) Successful in 49s
ci / web (push) Successful in 53s
decky / build-publish (push) Successful in 21s
audit / cargo-audit (push) Successful in 2m27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
apple / swift (push) Successful in 4m22s
ci / bench (push) Successful in 5m54s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m1s
windows-host / package (push) Successful in 9m55s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m58s
flatpak / build-publish (push) Successful in 6m18s
docker / deploy-docs (push) Successful in 27s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m14s
arch / build-publish (push) Successful in 16m1s
android / android (push) Successful in 16m36s
deb / build-publish (push) Successful in 16m58s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m46s
ci / rust (push) Successful in 22m42s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m19s
release / apple (push) Successful in 20m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m8s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m49s
apple / screenshots (push) Successful in 18m55s
48-finding cross-client + host gamepad audit (2026-07-13). Apple/Android/SDL-core
capture + feedback and the Linux/Windows host injectors: held-guide release, the
permanent broken-latch cliff (PadGate), Steam Deck trackpad clicks, DualSense mute,
Windows DS/DS4 paddle fold, uinput button re-sync, gamestream BTN_* dedup, the dead
Windows shell fork, legacy-Deck rumble ceiling, XUSB arrival, ARM64 fences, the
truncate-everywhere value convention, and more. See
punktfunk-planning/design/gamepad-review-cleanup.md.
2026-07-13 22:29:41 +02:00
enricobuehler 1af11cc64d fix(inject/host/windows): order the pad change-detect fields with Release/Acquire (G21)
The XUSB `packet` publish and the XUSB `rumble_seq` / DualSense `out_seq` reads
used plain unaligned accesses with no fence, so a driver could observe a bumped
change-detect field over a torn body on a weakly-ordered core (ARM64). Publish
`packet` via a Release AtomicU32 store behind a Release fence, and Acquire-load
the seq fields, mirroring the gamepad_raii PadChannel seq-fence precedent. The
DualSense input report embeds its seq mid-report with no driver-gated
change-detect field, so it gets a Release fence after the copy and a documented
residual (a per-frame input generation is deferred). No-op on x86-TSO.

Verified: Windows .173 `cargo clippy -p punktfunk-host --all-targets -- -D warnings` (green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:05:13 +02:00
enricobuehler 2f214532d9 fix(inject/host/windows): eager-create the XUSB pad on Arrival + refresh last_active (G10)
The XUSB manager's `handle` dropped `GamepadEvent::Arrival` via a `let else`, so
the GameStream path never created the pad until the first `State` and missed the
first XInput poll. Match on the event and `ensure` eagerly on Arrival, mirroring
the DualSense backend. Also refresh `last_active` on create and unplug so a
freshly-created pad's residual-rumble idle clock starts fresh rather than
inheriting a stale Instant (which could force off a legitimate rumble at once).

Verified: Windows .173 `cargo clippy -p punktfunk-host --all-targets -- -D warnings` (green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:05:13 +02:00
enricobuehler 31bc863084 fix(inject/host/windows): free the per-section security descriptor (G19)
`sddl_sa` leaked the `LocalAlloc`'d PSECURITY_DESCRIPTOR that
ConvertStringSecurityDescriptorToSecurityDescriptorW returns, once per DATA
section and once per bootstrap mailbox create (amplifiable under pad-flap via
create_named's squat-retry loop). Wrap it in a `SecAttr` RAII owner that
`LocalFree`s on drop; it outlives every CreateFileMappingW (the section copies
the security info at create time), and create_named builds one and reuses it
across retries instead of re-allocating.

Verified: Windows .173 `cargo clippy -p punktfunk-host --all-targets -- -D warnings`
(green) -- confirms the LocalFree/HLOCAL signature at the pinned windows-rs rev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:05:13 +02:00
enricobuehler e5166c6e6e fix(host/steam): load vhci_hcd at boot on sysext hosts so the Deck pad is Steam-Input-promotable
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m9s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
decky / build-publish (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
apple / swift (push) Successful in 4m25s
ci / bench (push) Successful in 6m50s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5m49s
docker / deploy-docs (push) Successful in 26s
windows-host / package (push) Successful in 8m40s
arch / build-publish (push) Successful in 11m22s
deb / build-publish (push) Successful in 12m27s
android / android (push) Successful in 16m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m19s
apple / screenshots (push) Successful in 19m19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m14s
ci / rust (push) Successful in 25m14s
The virtual Steam Deck pad only appears in the host's Game Mode (and is
navigable) when it arrives as a real USB device via the usbip/vhci_hcd
transport — Steam Input won't promote the UHID hid-steam fallback
(Interface: -1). The host runs as an unprivileged --user service, so it
cannot modprobe vhci_hcd itself; the module must be loaded at boot and the
vhci attach/detach sysfs files chgrp'd to the `input` group by the udev
rule.

Packaging ships modules-load.d/punktfunk.conf + 60-punktfunk.rules under
the sysext's /usr/lib, but a systemd-sysext image MERGES after
systemd-modules-load and early udev have already run, so on a plain reboot
of a sysext host (e.g. Bazzite) those files are read too late: vhci_hcd is
never loaded, usbip fails, and the pad silently degrades to non-promoted
UHID — the controller vanishes from Game Mode. (deb/arch/rpm are
unaffected: real /usr is present at early boot.)

Fix: sysext post_merge now mirrors BOTH files into real /etc (read at the
normal early-boot time, shadowing the /usr copies by filename; refreshed
every merge since neither is user config), then reloads udev, modprobes
vhci-hcd, and re-triggers the vhci platform device for the live session.
Also raise the UHID-fallback log INFO->WARN with an actionable hint.

Verified on the .41 sysext host: after the /etc mirror, unloading vhci_hcd
and restarting systemd-modules-load (the real reader of /etc/modules-load.d)
reloads the module; a udev coldplug trigger makes attach/detach root:input
0660; the unprivileged host user can then write attach — the exact working
precondition for the usbip transport, now durable across reboot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:41:51 +02:00
enricobuehler 59fc820226 perf(inject/host): dedup the DualSense HID-output feedback plane (G17)
A game's DualSense output report bundles rumble + lightbar + player-LEDs
+ adaptive-triggers into one report, so a pad that is merely rumbling
re-sends its unchanged lightbar / LED / trigger state on every output
report. The managers already dedup rumble, but forwarded every rich
`HidOutput` event verbatim — flooding the 0xCD feedback plane to the
client during continuous rumble.

Add a shared `HidoutDedup` (dualsense_proto, used by both the Linux UHID
and Windows UMDF managers) that forwards Led/PlayerLeds/Trigger only on a
value change (per side for the two triggers) and always forwards one-shot
TrackpadHaptic pulses — mirroring the rumble dedup two lines above and the
DS4 backend's lightbar dedup. Reset per pad on create/unplug.

Verified on Linux .21 (clippy -D warnings clean, new HidoutDedup unit
test + full suite green); Windows .173 with the rest of Phase 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:14:28 +02:00
enricobuehler d611645ffc refactor(inject/host/windows): hoist the shared SwCreateCtx into gamepad_raii (G14)
The `SwDeviceCreate` completion-callback context (`SwCreateCtx`, the
`sw_create_cb` extern callback, and the `instance_id()` accessor) was
copy-pasted byte-for-byte in the XUSB (`gamepad_windows.rs`) and
DualSense/DS4 (`dualsense_windows.rs`) backends. Hoist the one copy into
`gamepad_raii.rs` as `pub(super)`; both `create_swdevice` bodies now build
the shared type and pass the shared callback. Prunes the now-orphaned
HRESULT/SetEvent/HANDLE imports from the two siblings.

Pure move + dedup, no behavior change. Windows-verified with the rest of
Phase 3 (clippy --all-targets -D warnings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:08:00 +02:00
enricobuehler 17457cf4ba refactor(gamestream/host): source gamepad BTN_* from punktfunk_core + pin the wire bits (G13/G15)
`gamestream/gamepad.rs` hand-declared its own copy of the GameStream
buttonFlags/buttonFlags2 layout, which had drifted from the single source
of truth in `punktfunk_core::input::gamepad`: the click bits were named
`BTN_LS_CLK`/`BTN_RS_CLK` (vs core's `…_CLICK`). The two layouts are
bit-identical — GameStream/Limelight and the punktfunk/1 native wire are
one contract — so define the gamestream names as `pub const` aliases of
the core constants. Values now come solely from core (can't drift);
kept as `pub const` (not a `pub use` re-export) because on Windows the
only consumer — the Linux uinput button map — is cfg'd out, where an
unused re-export lints as an error but an unused pub const does not.

Rename the two injector call-sites (`inject/linux/gamepad.rs`) to the
canonical `BTN_LS_CLICK`/`BTN_RS_CLICK`.

G15 host half: replace the 3-bit gamestream-vs-core spot-check with an
exhaustive golden-value test (`gamepad_wire_bits_are_pinned`) that freezes
every button bit + axis id to its exact wire value, so renumbering a bit
in core — which would silently break every shipped client — fails a test
first. The host counterpart to the client-side C-ABI cross-checks.

Verified on Linux .21: clippy -D warnings clean, pin test + gamepad
suite green. (Windows verified together with the rest of Phase 3.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:08:00 +02:00
enricobuehler 6263108e15 fix(inject/host/windows): fold Steam back grips on the Windows DS/DS4 backends (G7)
The Windows DualSense and DualShock 4 managers passed the raw wire
buttons straight into `DsState::from_gamepad`, so a client's Steam back
grips (BTN_PADDLE1..4) were silently dropped and `PUNKTFUNK_STEAM_REMAP`
was ignored — the Linux DS/DS4 backends already fold them via
`steam_remap::fold_paddles`. Bring the Windows backends to parity: add a
`remap: steam_remap::RemapConfig` field (`::from_env()` in `new()`) to
both managers and fold the paddles before `from_gamepad`, exactly as
`linux/dualsense.rs` / `linux/dualshock4.rs`. Default policy stays Drop
(don't fire buttons the user didn't ask for); set the env to map the
grips onto stick-clicks or shoulders.

`steam_remap` was gated `target_os = "linux"`; widened to
`any(linux, windows)`. It's pure (only punktfunk_core + std::env); its
Linux-only Deck motion rescale is `pub` so it compiles clean on Windows
with no dead-code warning.

Verified: Linux .21 (clippy -D warnings clean, inject tests 32 pass / 0
fail — the gate widening is a no-op there); Windows .173 (clean-recheck
of punktfunk-host, cargo clippy --all-targets -D warnings EXITCODE 0,
steam_remap + both managers compiling on Windows for the first time).
On-glass with a real DualSense/DS4 + PUNKTFUNK_STEAM_REMAP still owed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:36:13 +02:00
enricobuehler 0c427cb3f1 fix(inject/host/linux): re-assert absolute gamepad button state each frame (G8)
The uinput gamepad backend emitted only XOR-changed button edges while
advancing `prev_buttons` unconditionally. Because `emit()` is best-effort
(a full kernel queue silently drops the write), a dropped EV_KEY edge was
never re-synced — the button stayed stuck (pressed-not-released, or vice
versa) until it next toggled. The axes never had this problem: they
re-emit their absolute value every frame.

Re-assert every mapped button's absolute state each frame, exactly like
the axes, and drop the now-unused `prev_buttons` field. Restating an
unchanged key is free downstream: the kernel input core discards an
EV_KEY whose value already matches the device's current state (no
duplicate event reaches consumers, and BTN_* keys don't autorepeat). The
`emit()` "next frame re-syncs state" comment is now honest for buttons
too.

Verified on the Linux host build (.21): cargo clippy -D warnings clean
(no dead-field warning), full punktfunk-host suite 277 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:14:02 +02:00
enricobuehler 5109a4c80a refactor(inject/host): extract the shared PadGate create-retry policy + fix the permanent broken latch (G3/G12)
All seven virtual-pad managers (Linux uinput/uhid: gamepad, dualsense,
dualshock4, steam_controller; Windows XUSB/UMDF: gamepad, dualsense,
dualshock4) carried an identical copy-pasted `broken: bool` latch that
was set on the FIRST pad-creation error and never cleared — so a single
transient failure (a startup race on /dev/uinput, a momentary EBUSY, the
Windows companion driver not yet ready) permanently disabled EVERY
controller for the rest of the session, even after the cause cleared.

Extract that latch into one shared, unit-tested `PadGate`
(inject/pad_gate.rs) with the fix baked in: capped exponential backoff
(1s doubling to 30s) instead of a permanent kill. After a failure,
creation is blocked only until the backoff elapses — so the manager no
longer re-attempts (and re-logs) on every one of the 60–240 input
frames/sec — then a single retry is allowed; a success resets the
backoff. A genuinely broken setup therefore self-heals within one
backoff window of the fix (udev reload / driver install / next client
connect) with no host restart. The gate is manager-wide, matching the
old flag's semantics (these failures are systemic, not per-slot).

This folds G3 (broken latch) into G12 (dedup the manager skeleton): the
latch now lives in one place across all seven backends.

Verified on the Linux host build (.21): cargo clippy -D warnings clean,
full punktfunk-host suite 277 passed / 0 failed, 4 new PadGate tests
green. Windows managers verified separately on the x64 box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:33:49 +02:00
enricobuehler 43e52437c0 fix(gamepad/host): map BTN_MISC1 to the DualSense mute button (G6)
DsState::from_gamepad mapped GUIDE→PS and TOUCHPAD→TOUCHPAD into buttons[2] but
never handled BTN_MISC1, so the mic-mute / capture button clients send was inert
on every PlayStation-family virtual pad (DualSense/DualShock4), and btn2::MUTE
was dead code. Map BTN_MISC1 → btn2::MUTE (rebuilt from the wire bit each frame
like PS/TOUCHPAD, so no persistence gap) and drop the #[allow(dead_code)].

Test extended (from_gamepad_maps_touchpad_click); green on Linux (.21).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:04:59 +02:00
enricobuehler 2642ba6ad0 fix(gamepad/host): keep Steam Deck trackpad clicks across a button frame (G2)
SteamControllerManager::handle rebuilds `SteamState.buttons` from the gamepad
frame every tick via from_gamepad, preserving only the rich-plane TOUCH bits —
so a held trackpad CLICK (set on the rich plane by apply_rich, stored in
`buttons`) was wiped on the very next button/stick frame and only flickered
back on the next rich event. This is the exact trap the DualSense backend
already dodges by keeping click in a separate `touch_click` field.

Mirror that: add persisted `lpad_click`/`rpad_click` bools to SteamState set by
apply_rich (instead of pressing LPAD_CLICK/RPAD_CLICK into `buttons`), OR them
into the report's click bits in serialize_deck_state, and preserve them across
the rebuild in handle() like touch/coords/motion. RPAD_CLICK's other owner —
the DualSense touchpad-click wire button via from_gamepad — stays in `buttons`
and is OR'd at serialize, so the two sources release independently (a released
BTN_TOUCHPAD can't strand a rich click, and vice-versa).

Adds a regression test (rich_click_survives_a_buttons_rebuild). All 17
inject::{steam,dualsense,dualshock4}_proto tests pass on Linux (.21).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:04:59 +02:00
enricobuehler 76be4c3e12 feat(gamepad): multi-controller support on the native plane
Host was already built for 16 pads; the blocker was every client
hard-coding pad 0. This lands the host-side + reference-client contract:

- input.rs: new wire kinds GamepadArrival=14 (declares a pad's type:
  code=GamepadPref byte, flags=pad) and GamepadRemove=13 (flags=seq<<24|pad,
  shares the snapshot seq space via encode/decode_gamepad_remove).
- pf-client-core/gamepad.rs: reworked from a single `open` pad to a
  slots: Vec<Slot> model — every forwarded controller gets a stable
  lowest-free wire index held for its lifetime, per-slot held/axis/touch/
  rumble state, GamepadArrival on open + GamepadRemove on close, and
  feedback routed back per wire index. Automatic forwards all real pads;
  a pin forces single-player.
- punktfunk1.rs: replaced the single-session PadBackend enum with a Pads
  router — per-pad kinds[]/owner[] arrays, lazily-created per-kind managers,
  pure route_decision keeping a live device in its manager across a kind
  change (no ghost/dup). Input thread seq-gates GamepadRemove (clears the
  pad_mask bit, resets rumble) and applies GamepadArrival kinds.
- inject linux/windows backends: add the two new no-op InputKind arms.

Native/session + default-Windows clients (both spawn punktfunk-session)
inherit this. 57 core + 33 client-core + 272 host tests green; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:53:07 +02:00
enricobuehler 19e9828e8d fix(host/windows): force off a stale rumble residual the game abandoned
XInput vibration is level-triggered — it persists until the game sets it to
zero — so a game that latches a rumble and then stops calling XInputSetState (a
residual left at a menu/loading screen, or a plain forgotten stop) drones to the
client forever (measured: a stuck (0,512) resent every 500 ms for 5.5 minutes).
A real controller stops when the app stops driving it; mirror that. Keyed on
game ACTIVITY (any SET_STATE, even an unchanged one), so a rumble the game keeps
asserting is never cut — only an abandoned residual is; kept above SDL's ~2 s
resend so an SDL-driven host game refreshes the activity clock before it fires.

This is the game-facing half of the rumble-stop story; the wire-facing half is
the self-terminating envelope model in the following commit. They compose: this
bounds a game-abandoned rumble at the host, envelopes bound a host-abandoned
rumble at the client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 03:07:58 +02:00
enricobuehler 7b25868a19 fix(input): rock-solid held gamepad state — Android device pinning + seq'd snapshots
apple / swift (push) Successful in 1m12s
release / apple (push) Successful in 8m54s
windows-host / package (push) Successful in 7m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m49s
ci / web (push) Successful in 1m16s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m8s
ci / docs-site (push) Successful in 1m21s
android / android (push) Successful in 12m25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 56s
decky / build-publish (push) Successful in 18s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m23s
apple / screenshots (push) Successful in 5m45s
arch / build-publish (push) Successful in 11m9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 5m26s
flatpak / build-publish (push) Failing after 33s
ci / rust (push) Successful in 17m39s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m38s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8m39s
docker / deploy-docs (push) Successful in 22s
deb / build-publish (push) Successful in 11m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m8s
Two causes behind one field report (a held trigger jittering mid-game,
Android client → Windows host):

Android folded joystick ACTION_MOVEs from EVERY device into one axis
state. A controller's joystick-classified sibling node (DualSense/DS4
motion sensors) or a second/drifting pad reports every pad axis as 0,
so a held trigger flapped value→0→value on each event interleave. The
mapper now qualifies the source DEVICE (its source classes must include
GAMEPAD — a joystick event's own source is always plain JOYSTICK), pins
to one deviceId until that device disconnects, and merges LTRIGGER/BRAKE
(and RTRIGGER/GAS) with max, the same fold as the Controllers probe.

Underneath, gamepad input rode per-transition events over unreliable,
unordered QUIC datagrams — no sequence numbers, sharing the 4 KiB
oldest-first-shed send buffer — so one dropped or reordered event
corrupted held pad state until the NEXT change. Gamepad state now
travels the way rumble already does: idempotent state, refreshed.
InputKind::GamepadState packs the whole pad + a wrapping u8 seq into
the existing 18-byte layout; the host advertises HOST_CAP_GAMEPAD_STATE
(Welcome trailing byte, offset 67) and applies snapshots through a
per-pad stale-seq gate, skipping frame emits for unchanged refreshes;
the client folds embedder events into snapshots inside NativeClient's
input task (send on change + 100 ms refresh of touched pads), so the
SDL clients (Linux/Windows/session), Android, and Apple (C ABI) are all
covered with zero capture-code changes. Either end older ⇒ the legacy
per-transition path runs unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 01:11:38 +02:00
enricobuehler dc834ea478 fix(rumble): accept the v2 vibration valid-flag; bound stale buzz to 1.5 s
Two hardenings against runaway vibration (one Deck session buzzed on a
~2 s cadence): the DualSense output-report parse now also treats
valid_flag2 COMPATIBLE_VIBRATION2 (data[39] BIT2) as rumble-carrying —
a writer hardcoding the ≥2.24-firmware convention previously had its
rumble INCLUDING stops silently ignored, and a missed stop re-sends
stale nonzero state forever via the host's 500 ms refresh. And the
client's SDL rumble duration drops 5 s → 1.5 s: long enough that a
couple of lost 500 ms refreshes don't gap genuine rumble, short enough
that stale state dies on its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:29:56 +02:00
enricobuehler e6d9454251 feat(host): fold Steam Deck to DualSense on Windows; split-pad touch + pad clicks
A Deck client asking for 'steamdeck' on a Windows host resolved to Xbox360,
whose apply_rich is a no-op — gyro and both trackpads silently discarded.
Windows already ships a full DualSense backend (UMDF driver, touchpad +
motion, wire units 1:1), so pick_gamepad now folds SteamDeck -> DualSense
there.

The three near-identical DualSense-family appliers (Linux UHID, Windows
DualSense, Windows DS4) are hoisted into one shared
dualsense_proto::DsState::apply_rich, with two mapping upgrades for Steam
dual-pad clients everywhere:
 * the Deck's two pads SPLIT the single DualSense touchpad — left pad ->
   contact 0 on the left half, right pad -> contact 1 on the right half —
   mirroring the physical thumb layout and the split-pad zones games and
   Steam Input already use (the left pad was previously dropped outright)
 * TouchpadEx pad clicks now press the touchpad-click button (persisted
   in DsState::touch_click, OR-ed in by both serializers; previously
   dropped by every DualSense-family backend, Linux included)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:50:53 +02:00
enricobuehler 9ca672d434 fix(host): trackpad Y rides the wire in screen convention, flips at the Deck boundary
Both Deck trackpads were inverted on the virtual pad (first live Deck-to-Deck
session): the client sends SDL's screen-convention coordinates (+y down)
onto the wire, but the Deck's raw report fields are stick convention (+y up)
— and Steam Input parses our report as real Deck hardware — while the host
applier passed the value straight through.

Pin the wire meaning down (quic.rs: TouchpadEx/Touchpad are screen
convention, +x right +y down — what every client capture API produces) and
translate to device-raw in the host applier, the layer that knows the
quirk: steam_proto's apply_rich now negates y (saturating: -32768 has no
i16 negation) for both TouchpadEx surfaces and the DualSense-standin
Touchpad arm, which carried the same latent inversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:50:30 +02:00
enricobuehler 959ec30003 fix(client-linux,host): Deck video defaults to software decode + input-interception diagnostics
Video (Deck): the VAAPI zero-copy path renders corrupt/gray/washed-out on the
Deck — root-caused to Mesa >= 25.1 exporting radeonsi VCN decode surfaces TILED
(the Flatpak runtime's Mesa 26 drives both the decoder and GTK's GL, and GTK's
tiled-NV12 dmabuf import mishandles it; desktop Tier-1 validations ran distro
Mesa with linear export). `auto` now resolves to software on a Deck (clean,
correct-colour, easily handles 1280x800 HEVC); PUNKTFUNK_DECODER=vaapi still
forces the hw path, with the descriptor modifier dump + GSK_RENDERER as the
bisect levers. Also reserve extra_hw_frames=4 on the VAAPI decoder: the
presenter pins mapped surfaces past receive_frame, and the fixed pool recycling
a surface the renderer still samples is intermittent block corruption anywhere.

Input (Deck): with Steam Input ON for Punktfunk, SDL sees only Steam's virtual
X360 pad — the right trackpad arrives as a plain right stick and the left
trackpad/paddles/gyro not at all, silently. The client now checks once the
post-attach enumeration settles and raises a toast + warn naming the fix
(disable Steam Input for the shortcut). The host logs a one-shot warning when
InputPlumber is running (Bazzite default) since it can grab the virtual Deck
pad and re-emit it under a different identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 09:56:06 +00:00
enricobuehler 796d381926 fix(windows): opt-in pad-driver file logs + size-capped service log rotation
Two disk-write fixes:

- pf-xusb/pf-dualsense no longer write C:\Users\Public\pf*-driver.log
  unconditionally — the file log is now opt-in (debug builds, or the
  PFXUSB_DEBUG_LOG / PFDS_DEBUG_LOG system env var), mirroring the audit-§4.4
  fix pf-vdisplay already got: a release driver never writes the world-writable
  Public file (info-leak/DoS surface), and the per-report OUTPUT/SET_STATE hex
  dumps stop being a sustained per-rumble disk-write path during gameplay.
  OutputDebugStringA stays unconditional; the host's driver-silence WARN and
  the gamepad-driver-health failure-mode table now say the log is opt-in.

- service.log/host.log get one-generation rotation: at each (re)open a file
  over 10 MB is renamed to .old, so a crash-restart loop or a RUST_LOG=debug
  left in host.env can't grow the append-forever logs without bound. Rotation
  runs only before an open (never under a live appender — host.log's handle
  lacks FILE_SHARE_DELETE, so a racing rename harmlessly fails).

Windows CI compile/clippy pending (drivers workspace + host are not
Linux-cross-checkable); rides along with the next pad-driver redeploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:03:32 +00:00
enricobuehler 8b47be668f feat(host/windows): seal the host↔driver channels (frame + gamepad, proto v2)
Frame ring (pf-vdisplay) and both gamepad SHM channels move off named Global\
objects (openable by any sibling LocalService) to UNNAMED sections/events whose
handles the host DuplicateHandles into the driver's verified WUDFHost with least
access — frame delivery over the SYSTEM+admins-only IOCTL_SET_FRAME_CHANNEL,
pads over a 32-byte named bootstrap mailbox (pid + handle value only, DoS-bounded;
HID minidrivers have no control device). Driver-validated pad_index kills
cross-pad redirects; v1↔v2 mixes fail closed with diagnosis logs on both sides.
Sibling-LocalService denial proven empirically (design/idd-push-security.md,
design/gamepad-channel-sealing.md).

Driver-side raw ops now live behind pf-umdf-util (checked shm accessors, the
forbid(unsafe_code) ChannelClient state machine, WDF request tokens) — the pad
drivers' logic is 100% safe Rust; whole drivers workspace clippy-gated in CI.

driver install --gamepad now sweeps SWD\punktfunk phantom devnodes: a re-created
SwDevice REVIVES the old devnode with its previously-bound driver (never
re-ranks), so an upgrade otherwise leaves the old driver serving — or, across
the v1→v2 fence, a dead pad (found live on the RTX box).

On-glass validated on the RTX 4090 box: frame path 7007 frames p50 2.06 ms
cross-machine; DualSense + XUSB "sealed pad channel mapped"/proto=2 attach via
both the test harness and a real streaming session; phantom-sweep repro.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:08:56 +00:00
enricobuehler 6bbe7ae137 feat(host/windows,drivers): gamepad driver attach/heartbeat health surfaced in logs
The gamepad drivers have no IOCTL plane (hidclass gates the stack), so
until now the host had ZERO visibility into whether a driver ever
bound: a pad could be "created" with no driver installed and nothing
was logged. Two health fields are carved from reserved shm space
(layout-compatible; pf-driver-proto pins the offsets): driver_proto —
stamped by pf-xusb at device add + per serviced XInput IOCTL (movement
= the game-visible path) and by pf-dualsense/DS4 from its ~125Hz timer
— and driver_heartbeat. Host-side, every pad owns a DriverAttach
watcher fed from the existing service() poll: INFO on attach (WARN on
proto mismatch), and after 3s of silence ONE diagnosis WARN combining
a cached pnputil /enum-drivers store check, the devnode's CM problem
code (CM_Locate_DevNodeW/CM_Get_DevNode_Status on the instance id now
captured from the create callback, with plain-language hints: 28 = not
installed, 52 = signature/Memory Integrity, …) and the driver's debug
log path. Also fixes a real bug both SwDeviceCreate wrappers shared:
the 10s WaitForSingleObject result was ignored and the callback
HRESULT zero-initialised, so a PnP timeout read as SUCCESS (now E_FAIL
init + explicit timeout error). Failure-mode table:
design/gamepad-driver-health.md.

Linux workspace green; Windows host + drivers CI-compile only, on-box
recipe at the bottom of the design doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:33:56 +00:00
enricobuehler 76e2a1f5a7 fix(host/linux,clients/android): honor the host/device keyboard layout in keymaps
wlroots injector: the virtual keyboard keymap now defers to the standard
XKB_DEFAULT_RULES/MODEL/LAYOUT/VARIANT/OPTIONS env vars (libxkbcommon
built-ins as fallback) instead of hardcoding evdev/pc105/us, matching the
libei path where the session compositor's own keymap applies. Android:
Keymap gains the same positional-key coverage for non-US layouts (+ tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:25:07 +02:00
enricobuehler 03ad2406b6 fix(host/windows): layout-correct keyboard injection - semantic vs positional VKs
First-party punktfunk clients send US-positional VKs (the physical key's
US-layout VK), GameStream/Moonlight clients send layout-semantic VKs
(Sunshine's model). The SendInput injector previously resolved everything
through the SYSTEM service's layout - on a German host that is the y/z swap
and u-umlaut-on-o-umlaut scramble. GameStream ingest now tags its key events
KEY_FLAG_SEMANTIC_VK (stripped from punktfunk/1 wire events so a network
client can't flip the convention); the injector maps semantic VKs under the
foreground app's layout and positional VKs through a fixed scancode table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:24:04 +02:00
enricobuehler f3986b8f82 feat(host/steam): shippable usbip/vhci_hcd virtual Deck + client leave-shortcuts
Steam Deck pass-through (design/steam-deck-passthrough-plan.md), code-complete +
all CI checks green on Linux + adversarially reviewed; on-glass validation pending:

- usbip/`vhci_hcd` virtual Deck transport (inject/linux/steam_usbip.rs) for
  non-SteamOS hosts (Bazzite/generic) — presents a real interface-2 USB Deck so
  Steam Input promotes it. In-process vhci attach (loopback OP_REQ_IMPORT handshake
  → sysfs attach) with a bounded `usbip`-CLI fallback; detach on drop.
- Backed by a vendored, libusb-free trim of the `usbip` crate
  (crates/punktfunk-host/vendor/usbip-sim, MIT + NOTICE; host/cdc/hid + rusb/nusb
  removed; interrupt-IN paced by bInterval).
- Selection ladder raw_gadget (SteamOS fast-path) → usbip (universal) → UHID,
  with PUNKTFUNK_STEAM_USBIP / PUNKTFUNK_USBIP_ATTACH knobs.
- Shared Deck descriptors + the 0x83/0xAE feature contract + a Steam-accepted
  serial consolidated into steam_proto.rs; the raw_gadget backend reuses them.
- Linux client leave-shortcuts: Ctrl+Alt+Shift+D + holding the escape chord
  (L1+R1+Start+Select) >=1.5s end the session (short press still exits
  fullscreen); the chord state resets across sessions.

Also bundles in-progress work already staged in the tree:
- host(kwin): xdg-output logical-geometry mapping so the KWin fake_input backend
  places absolute coordinates correctly under display scaling.
- docs: design/README index entries + design/controller-only-mode.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler a41eac933f feat(host/steam): default the gadget Deck on for SteamOS (glass-confirmed)
The virtual Steam Deck is validated glass-to-glass on a Deck: it appears as a
distinct second Steam controller, a held A drives Steam's overlay ("Resume
Game"), and a button press registers in a real game (confirmed in-game).

gadget_preferred() now defaults ON for SteamOS hosts (/etc/os-release ID=steamos
or ID_LIKE), OFF elsewhere where the universal UHID path stays the default;
PUNKTFUNK_STEAM_GADGET=1/0 forces it. A Deck-as-host with a physical Deck never
reaches this path — resolve_gamepad's conflict gate degrades SteamDeck → DualSense
first, so the two-Deck case never happens in production (it was only a test-rig
confound on the dev Deck).

The feature is complete: a virtual Steam Deck that Steam Input recognizes +
promotes, churn-free, with input flowing to games. Workspace clippy/fmt/test
green. Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 8b46b821f2 feat(host/steam): harden the gadget feature contract — fixes the evdev churn
The virtual Deck's gamepad evdev was churning (destroyed + recreated) because
Steam kept re-probing: GetControllerInfo reads HID feature reports, and the gadget
served zeros for them. Captured the real contract off a physical Deck
(packaging/linux/steam-deck-gadget/get_deck_attrs.c, hidraw HIDIOCGFEATURE — usbmon
truncates to 32B) and implemented it in steam_gadget.rs::feature_reply:

- 0x83 GET_ATTRIBUTES_VALUES: [83, 2d, 9×(attr-id, u32-LE)] — product id 0x1205, a
  per-instance unit serial (0x0a/0x04, so a gadget never collides with a real Deck
  or another gadget), and the capability attrs (0x09=0x2e, 0x0b=0x0fa0, rest 0).
- 0xAE GET_STRING_ATTRIBUTE: [ae, len, attr, ascii] — serial (attr 1) / board
  serial (attr 0).
- other commands (0x87 settings): echo the last write.

Validated on the Deck: 1 connect / 0 disconnect / 1 gamepad evdev (was constant
churn), Steam activates the gadget cleanly (no GetControllerInfo failed, no zombie)
and emits its X-Box 360 pad. usbmon on the gadget's bus confirms our state reports
(pressed button at byte 8) are delivered on the interrupt-IN and consumed by
hid-steam — so with M1/M2's byte-8→BTN_SOUTH decode the input chain is proven
end-to-end. Remaining: a foreground-game confirmation of Steam Input's XInput
mapping, then default the gadget on for SteamOS.

Workspace clippy/fmt/test green. Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 3cc8f7dc65 feat(host/steam): raw_gadget Deck host backend (Steam-Input path, opt-in)
Port the proven raw_gadget virtual Deck to a Rust host gamepad backend, the
SteamOS-only transport that gets Steam Input to actually promote the Deck.

- inject/linux/steam_gadget.rs (new): SteamDeckGadget — a userspace raw_gadget
  emulator of the real 3-interface USB Deck (mouse=0/keyboard=1/controller=2,
  28DE:1205) on a dummy_hcd loopback UDC, descriptors captured from a physical
  Deck, answering every control transfer incl. the HID feature reports. Driven by
  the same steam_proto::serialize_deck_state as the UHID pad; rumble feedback via
  parse_steam_output. The raw_gadget UAPI is funneled through 4 documented ioctl
  wrappers (the crate denies undocumented unsafe).
- inject/linux/steam_controller.rs: the manager pad is now a DeckTransport enum
  (Uhid | Gadget); ensure() prefers the gadget when PUNKTFUNK_STEAM_GADGET=1
  (best-effort modprobe dummy_hcd+raw_gadget), gracefully falling back to the
  universal UHID SteamDeckPad. write/pump/heartbeat dispatch through the enum.

Validated on a real Deck via a static musl harness that #[path]-includes the
module: enumerates, hid-steam binds + reads our serial + creates the Steam Deck +
Motion Sensors evdevs — identical to the C PoC. Caught a real portability bug:
raw_gadget's no-arg ioctls (RUN/CONFIGURE/EP0_STALL) reject a non-zero `value`
with EINVAL, and on musl an omitted ioctl vararg is a garbage register — so they
must pass an explicit 0.

Opt-in (default off) while the Steam GetControllerInfo feature contract is
hardened (to stop the gamepad-evdev churn). Workspace clippy/fmt/test green. Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 71e0cace77 feat(host/steam): M5 — fallback remap, motion rescale, degrade ladder
Keep the rich Steam inputs from silently dropping when the resolved backend
isn't the virtual hid-steam device, and fix a cross-device motion-scale bug.

- inject/proto/steam_remap.rs (new, pure + unit-tested):
  * motion_wire_to_deck — the wire carries DualSense-convention units (20 LSB/
    deg.s gyro, 10000 LSB/g accel — what every client capture emits), but the
    Deck's hid-steam report wants 16 LSB/deg.s + 16384 LSB/g. The Deck backend
    now rescales (gyro x16/20, accel x16384/10000): a real Deck<->Deck gyro/
    accel correctness fix (the DualSense/DS4 backends consume the wire 1:1).
  * fold_paddles + RemapConfig (PUNKTFUNK_STEAM_REMAP=paddles=drop|stickclicks|
    shoulders, default drop) — the DualSense + DS4 managers fold a client's back
    grips onto standard buttons rather than dropping them (those pads have no
    back-button HID slot; the uinput Xbox pad already exposes them as Elite
    paddles BTN_TRIGGER_HAPPY5-8).

- resolve_gamepad: a runtime degrade ladder — a UHID backend (DualSense / DS4 /
  Steam Deck) on a host where /dev/uhid isn't writable now falls back to the
  uinput Xbox 360 pad instead of a dead controller (the device-create would
  just fail). Separate from pick_gamepad's compile-time platform check, so the
  existing pick_gamepad tests are untouched.

- Delete the throwaway M0/M1 spike (src/bin/steam_uhid_spike.rs) — M2's
  #[ignore]d backend test subsumes its validation, and removing it frees
  steam_proto to reference steam_remap cleanly.

On-box backend test still green; workspace clippy/fmt/test green (incl. the new
steam_remap tests). Deferred as optional RemapConfig growth: gyro->mouse /
trackpad->stick synthesis on an Xbox target (no slot — documented drop today).
Not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 7cc2ed85cb feat(proto/steam): M3 — rich Steam wire (back buttons + 2nd trackpad)
Carry the rich Steam Controller / Steam Deck inputs end-to-end on the wire —
strictly additive + forward-compatible (unknown kinds/bits drop on old peers).

Core (punktfunk-core):
- input.rs: BTN_PADDLE1..4 + BTN_MISC1 in Moonlight's buttonFlags2<<16 namespace
  (so the GameStream paddle path and native grips share one host injector map;
  Steam L4/L5/R4/R5 reuse the four Xbox-Elite paddle slots).
- quic.rs: RichInput::TouchpadEx (kind 0x03 — surface 0/1/2, touch+click, signed
  coords, pressure; the second trackpad the single Touchpad can't express) and
  HidOutput::TrackpadHaptic (kind 0x04 — the SC voice-coil pulse). Round-tripped.
- abi.rs: PUNKTFUNK_GAMEPAD_STEAMDECK=6 / _STEAMCONTROLLER=5, the paddle bits,
  RICH_TOUCHPAD_EX / HIDOUT_TRACKPAD_HAPTIC constants. from_hid packs
  TrackpadHaptic into the existing which + effect[0..6] — the legacy structs do
  NOT grow (guarded by new size_of==20/19 asserts); GamepadPref lockstep +
  paddle-bit lockstep asserts extended. include/punktfunk_core.h regenerated.

Host (punktfunk-host):
- steam_proto::from_gamepad maps the wire paddles -> the four Deck grips + QAM;
  apply_rich routes TouchpadEx left/right -> the matching pad.
- every DualSense/DS4 manager (Linux + Windows) gained a TouchpadEx arm
  (surface 0/2 -> its one touchpad; surface 1 ignored) so the variant compiles
  everywhere and a Steam client streaming to a DS host keeps its right pad.
- the xpad BUTTON_MAP finally consumes the GameStream paddle bits
  (BTN_TRIGGER_HAPPY5-8) — Sunshine/Moonlight paddle clients were silently
  no-op'd before (design §5.6).
- Android feedback: drop TrackpadHaptic (no coils; rumble rides 0xCA).

Validated on-box: the ignored backend test now drives the full wire path —
from_gamepad (BTN_A + the L4 grip) + apply_rich (a left-pad TouchpadEx) reach the
evdev as BTN_A + ABS_HAT0X=-8000. Wire round-trips + paddle/TouchpadEx mapping
unit-tested. Workspace clippy/fmt/test green. Not pushed.

Deferred to M4: the C-ABI PunktfunkRichInputEx + send_rich_input2 (only the
Apple/embedder *send* path needs it; the host decodes TouchpadEx today).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler baa20ef377 feat(host/steam): M2 — virtual Steam Deck as a wired PadBackend (Linux)
Make the virtual hid-steam device a selectable per-session host gamepad,
end-to-end on Linux: PUNKTFUNK_GAMEPAD=steamdeck now builds a
SteamControllerManager that creates a /dev/uhid 28DE:1205 Deck, enters
gamepad_mode, and feeds the byte-exact Deck report (M1).

- inject/linux/steam_controller.rs: SteamControllerManager / SteamDeckPad,
  mirroring dualsense.rs (open/create2, GET/SET_REPORT pump, heartbeat, RAII
  destroy). Two Steam-specific quirks beyond the DualSense path:
    * gamepad_mode entry — best-effort `lizard_mode=0` via sysfs, plus a b9.6
      creation pulse (MODE_ENTER) so steam_do_deck_input_event stops
      early-returning, plus an anti-toggle guard (MENU_HOLD_CAP) so a long
      in-game Start-hold can't flip gamepad_mode back off.
    * UHID_SET_REPORT answered err=0 (DualSense omits it; the kernel stalls
      ~5s/cmd otherwise); the 0xEB rumble report parsed onto the 0xCA plane.
- core config.rs: GamepadPref::SteamDeck (wire byte 6) + SteamController
  (byte 5, reserved — folds to Xbox360 until its backend lands); from_u8 /
  from_name / as_str. Forward-compatible (unknown byte -> Auto); the C-ABI
  PUNKTFUNK_GAMEPAD_* constants stay M3, so no generated-header drift.
- punktfunk1.rs: PadBackend::SteamDeck variant + select / handle / apply_rich
  / pump / heartbeat arms; pick_gamepad Linux arm.

On-box: an #[ignore]d backend test (backend_binds_and_input_flows) drives the
real SteamDeckPad — it binds hid-steam (gamepad + IMU evdevs), enters gamepad
mode, BTN_A reaches the evdev, and the device tears down on drop. Workspace
clippy/fmt/test green. Not pushed. Next: M3 (protocol/ABI wire) + M4 (client
capture).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 15b9287865 feat(host/steam): M1 — byte-exact Deck input serializer, on-box validated
Flesh out inject/proto/steam_proto.rs into the full Steam Deck HID contract,
transcribed verbatim from the kernel steam_do_deck_input_event /
steam_do_deck_sensors_event and validated field-for-field against kernel 7.0:

- SteamState: the u64 button map (bytes 8..16), sticks/triggers/trackpads/IMU
  stored as raw little-endian report values; serialize_deck_state is a pure,
  byte-exact memcpy into the 64-byte unnumbered frame.
- from_gamepad (XInput frame -> Deck buttons/sticks/triggers) + apply_rich
  (RichInput touchpad -> right pad, motion -> IMU).
- parse_steam_output: the 0xEB ID_TRIGGER_RUMBLE_CMD feedback -> (low, high)
  for the universal rumble plane.
- serial_reply fixed: prepend the report-id-0 byte the kernel strips
  (steam_recv_report does memcpy(data, buf+1, ...)); M0's reply lacked it, so
  the kernel fell back to the "XXXXXXXXXX" serial.
- SteamModel (Deck now; classic Controller later), command/feature IDs.

The spike is repurposed as the M1 validator: it pulses the b9.6 mode-switch to
enter gamepad_mode (steam_do_deck_input_event early-returns under the default
lizard_mode otherwise), then holds a known test pattern. Reading both evdevs via
EVIOCGABS/EVIOCGKEY, every field matched: ABS_X/Y/RX/RY (incl. the kernel
Y-negation), both triggers, the touched right-pad HAT1X/Y, the IMU accel/gyro
(with ABS_Z/RZ negations), and the 6 expected buttons incl. the L4/R5 grips.

5 unit tests + workspace clippy/fmt/test green. Next: M2 (SteamControllerManager
UHID backend + PadBackend wiring). Not pushed — pipeline not yet shippable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 567b0d70e9 feat(host/steam): M0 — virtual hid-steam UHID device binds + parses (Linux)
Greenfield virtual Steam Deck controller, the Steam analogue of the shipped
virtual DualSense. Proves the kernel hid-steam driver binds a /dev/uhid
28DE:1205 device, registers it as a real Steam Deck, and parses our input
reports — the go/no-go gate for the full Steam Controller/Deck pipeline.

- inject/proto/steam_proto.rs: keeper module — the vendor HID descriptor (one
  feature report, the sole thing steam_is_valve_interface() checks), the
  command/feature IDs, serialize_deck_state, and the serial GET_REPORT reply.
  Unit-tested.
- src/bin/steam_uhid_spike.rs: throwaway M0 spike (Linux-only) — opens
  /dev/uhid, creates the device, services the handshake including
  UHID_SET_REPORT (which the DualSense backend omits and which hid-steam
  stalls ~5s/cmd without), and heartbeats a neutral report.
- design/steam-controller-deck-support.md: full design + M0–M7 plan; the two
  walls (Steam Input capture ownership; virtual-Steam recognition) and the
  fidelity ceiling. Status: M0 GREEN.

On-box (headless Ubuntu 26.04, kernel 7.0, no Steam): journalctl -k shows
hid-steam binding the device (rebind off hid-generic), "Steam Controller
connected", and the kernel creating BOTH a "Steam Deck" gamepad evdev and a
"Steam Deck Motion Sensors" IMU evdev (INPUT_PROP_ACCELEROMETER). A
layout-agnostic mash-probe drove 23 distinct BTN_* codes through
hid-steam -> evdev, proving the input-report parse path. M1 line-checks the
exact per-bit report layout against the lab kernel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 42d7f7ab2a fix(host/security): scope Windows shared-section SDDL to SYSTEM+LocalService (close #5)
The gamepad host<->UMDF-driver shared sections (Global\pfds-shm-*, pfxusb-shm-*)
and the IDD-push frame ring/event (Global\pfvd-*) were created with
`D:(A;;GA;;;WD)` — GENERIC_ALL to **Everyone** — on the assumption the driver's
WUDFHost ran under a restricted token needing broad access. So any local
unprivileged user could OpenFileMapping the section to inject controller input,
tamper the trusted HID channel, or read captured screen frames
(security-review 2026-06-28 #5).

On-box validation (RTX box, 2026-06-29) disproved the restricted-token premise:
the WUDFHost token is NT AUTHORITY\LocalService (S-1-5-19), SYSTEM integrity,
with ZERO restricted SIDs. So the section only needs SYSTEM (the host creates +
writes it) and LocalService (the driver opens it). Scope both SDDL sites to
`D:(A;;GA;;;SY)(A;;GA;;;LS)`; rename the now-misnamed `permissive_sa` ->
`shared_object_sa`; correct the stale "restricted-token / Everyone" docs.

Validated live: a full DualSense + 1280x720x60 session — 6943 frames received,
HID output round-tripped, device status OK (pf_dualsense + pf_vdisplay WUDFHosts
both LocalService open the scoped sections fine), while OpenFileMapping from a
non-SYSTEM admin session now returns ACCESS_DENIED (was a granted handle under
WD). Host-only change (the SDDL is set when the host CREATES the section);
drivers unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:17:37 +00:00
enricobuehler 46b82d4eee fix(host/security): close audit findings S1,#1,#4,#10,#12,#7,#6,S2-S6 (Linux/cross-platform)
Remediations from design/security-review-2026-06-28.md verified on Linux
(cargo check/clippy/test green; Windows-gated paths verify in CI):

- S1 [HIGH]: bump quinn-proto 0.11.14 -> 0.11.15 (RUSTSEC-2026-0185,
  pre-auth out-of-order STREAM reassembly memory exhaustion on the
  always-on default QUIC listener).
- #1 [HIGH]: remove the unauthenticated nvhttp `GET /pin` endpoint; the
  GameStream PIN is delivered ONLY via the bearer-gated mgmt API, so a
  network client can no longer submit its own displayed PIN and self-pair.
- #4 [HIGH->MED]: gate the unauthenticated RTSP/UDP media plane on a paired
  `/launch` and bind it to the launching client's source IP (threaded
  through the HTTPS handler), so an unpaired peer can neither start capture
  on an idle host nor ride a paired client's active launch.
- #12: bound concurrent parked pairing waiters (MAX_PARKED_WAITERS) so a
  pre-auth peer can't pin unbounded 300s handshakes. +regression test.
- #10: throttle the per-packet ENet control GCM-decrypt-failed warn
  (exponential backoff) so a junk flood can't spam the log.
- #7 [MED->LOW]: serialize all process-global env mutation on the
  session-setup path under a new vdisplay::ENV_LOCK (apply_session_env /
  apply_input_env / the launch-cmd set_var / the gamescope env read), so
  concurrent native sessions can't race set_var/getenv (data-race UB ->
  host-wide DoS). Full per-session SessionContext threading remains a
  follow-up for cross-session value confusion.
- #6 [MED]: move the gamescope EIS socket relay from world-writable /tmp to
  $XDG_RUNTIME_DIR (per-user 0700) and reject a symlinked relay file, so a
  local user can't intercept (keylog) or deny the remote session's input.
- S2: a malformed client Opus mic frame now drops that frame instead of
  tearing down the shared host-lifetime virtual mic (cross-session DoS).
- S3: track held buttons/keys in capped HashSets (was unbounded Vec with
  O(n) scans) so a paired client can't grow per-session input state.
- S5: reject fps==0/absurd at the open_video chokepoint (covers Hello,
  ANNOUNCE, Reconfigure) so the encoder time_base/pts math can't div-by-0.
- S6: bound the shared mic mpsc (drop-newest when full).
- S4: cap Epic launcher-cache reads (catcache.bin/.item) so a planted giant
  can't OOM the host during library enumeration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:06:24 +00:00
enricobuehler d0e071034a feat(host/input): headless KDE input via org_kde_kwin_fake_input
Desktop-mode (KWin) streaming had no input: the path was libei via the
RemoteDesktop portal, which (a) isn't reachable from the host service env
and (b) requires a human to approve "Allow remote control?" — a
non-starter on a headless box. KWin's own headless RDP server (krdpserver)
solves this with org_kde_kwin_fake_input, authorized by the exact same
.desktop X-KDE-Wayland-Interfaces grant we already ship
(org_kde_kwin_fake_input is listed alongside zkde_screencast_unstable_v1).

Add a fake_input injector: vendor the protocol XML, bind the global as an
ordinary Wayland client, authenticate (auto-accepted for an
interface-authorized client — no dialog), and translate pointer (rel/abs),
button, scroll, keyboard (raw evdev keycodes resolved by KWin's own keymap)
and touch. Select it for KWin (compositor=="kwin" or XDG_CURRENT_DESKTOP
KDE); GNOME stays on libei (it has neither fake_input nor the wlr
protocols). PUNKTFUNK_INPUT_BACKEND=kwin forces it.

cargo check + clippy + fmt green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:26:04 +00:00
enricobuehler 0925f1aaa1 fix: complete the docs/→design/ and openapi→api/ rename references
The file moves (docs/ → design/, docs/api/openapi.json → api/openapi.json) landed
in 8897286, but the matching reference updates did not — so mgmt.rs's drift-test
`include_str!("../../../docs/api/openapi.json")` pointed at a path that no longer
exists and the host failed to build. This restores it and updates every reference:

  - mgmt.rs include_str! → ../../../api/openapi.json (fixes the build)
  - web/orval.config.ts codegen target, web/Dockerfile, .dockerignore
  - deb/rpm/Arch packaging install paths
  - CLAUDE.md, the .gitea CI workflows, code doc-comments, design-doc cross-links

docs-site route URLs (/docs/...) untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:53:02 +00:00
enricobuehler 33328ee417 docs(host): prove the last 3 files + crate-root deny (unsafe-proof program 4/N, final)
Completes the unsafe-proof program now that the parallel WIP has landed:

- idd_push.rs (25 sites), nvenc.rs (7), punktfunk1.rs (21): a SAFETY proof on
  every unsafe block — D3D11/DXGI COM (same-device textures, immediate-context
  single-thread, keyed-mutex-held convert), the NVENC SDK table (versioned POD,
  register/map/lock-bitstream pairing), cross-process shm reads (atomic
  magic/generation handshake), and the C-ABI harness (each call cross-checked
  against its abi.rs `# Safety` doc). No SUSPECT (UB) blocks.
- capture.rs / encode.rs: the parent-module deny is restored (their WIP children
  are now proven), and main.rs gains a crate-root
  #![deny(clippy::undocumented_unsafe_blocks)] — the permanent catch-all gate so
  no future unsafe block anywhere in the crate can land without a proof.
- Fixed 4 blocks the agents missed: unsafe blocks nested inside `assert_eq!(...)`
  macro args (the comment-above-statement didn't associate) — hoisted to a `let`.
- rustfmt-canonicalized the Windows files (the agents' SAFETY comments + some
  pre-existing 1.9.0 drift) so `cargo fmt --all --check` is clean.

Verified: cargo clippy -p punktfunk-host --all-targets -- -D warnings AND
cargo fmt -p punktfunk-host --check both green with the crate-root deny active.
Windows cfg(windows) re-verified on the box next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:57:00 +00:00
enricobuehler 79c8641c3f docs(host): prove unsafe blocks in the Windows + cross-platform files + gate them (unsafe-proof program 3/N)
Continues the unsafe-proof program across the Windows/cross-platform host files
(~75 blocks, 21 files), each with a SAFETY proof of the real invariant and a
per-file #![deny(clippy::undocumented_unsafe_blocks)] gate:

  capture/windows: dxgi.rs, wgc_relay.rs, wgc.rs, desktop_watch.rs, composed_flip.rs
                   (windows-rs COM: interface validity, same-D3D11-device textures,
                    immediate-context single-thread, borrowed args outlive the call)
  windows: service.rs (SCM/token/CreateProcessAsUserW/event handles — OwnedHandle
           liveness, no double-close/signal race), win_display, wgc_helper, interactive
  vdisplay/windows: manager.rs, pf_vdisplay.rs (SwDeviceCreate/IddCx/ioctl handle
                    liveness via the OnceLock VDM singleton + OwnedHandle)
  encode/windows: ffmpeg_win.rs (full AVBufferRef refcount audit — balanced, NO leaks,
                  unlike the vaapi sibling), sw.rs
  cross-platform: gamestream/audio.rs (libopus), gamestream/stream.rs (sendmmsg),
                  inject/windows/sendinput.rs, audio/windows/wasapi_mic.rs,
                  session_tuning.rs, vdisplay.rs

Two findings (handled separately):
- wgc_relay.rs `unsafe impl Sync for HelperRelay` is UNSOUND (its mpsc Receiver is
  !Sync) though not live-exploited — marked SUSPECT inline; fix pending box check
  (it touches the in-flight punktfunk1.rs).
- capture.rs / encode.rs (PARENT modules of the WIP idd_push.rs / nvenc.rs) do NOT
  get the file deny yet — it would propagate the lint into the undocumented WIP
  children. The deny lands there once those are documented (after the WIP commits).

Linux-visible parts verified green (cargo clippy -p punktfunk-host --all-targets
-- -D warnings). The cfg(windows) deny gates are box-verified next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:23:25 +00:00
enricobuehler b54695739d docs(host): prove every unsafe block in the Linux FFI files + gate them (unsafe-proof program 2/N)
Continues the structural unsafe-proof program (every unsafe carries a documented
proof of soundness; the file gains #![deny(clippy::undocumented_unsafe_blocks)]
so it stays proven). This batch covers all 10 remaining pure-Linux files
(104 blocks), each proof stating the REAL invariant — not boilerplate:

  zerocopy/cuda.rs (26)   leaked process-lifetime libcuda fn-ptr table; opaque
                          CUcontext never dereferenced; free-exactly-once via the
                          Arc<Mutex<PoolInner>> ownership graph; dmabuf fd take/close split
  zerocopy/egl.rs (18)    eglGetProcAddress'd procs with the GL context current;
                          EGLImage liveness; the two-call modifier-query bounds
  zerocopy/vulkan.rs (4)  copy-bounds arithmetic (src_size>=span); Send = thread
                          confinement to the punktfunk-pipewire thread
  dmabuf_fence.rs (4)     poll/ioctl/close fd liveness + ownership
  capture/linux/mod.rs (16)  spa_data repr(transparent) cast; null-checked spa
                          derefs; single-loop-thread buffer ownership until requeue
  inject/linux/gamepad.rs (10)  uinput ioctl request-number ↔ struct-size match
                          (static-asserted); InputEventRaw no-padding for the byte cast
  encode/linux/vaapi.rs (15) + encode/linux/mod.rs (9)  ffmpeg object ownership/
                          free ladders; VAAPI/DRM graph; Send = single-thread transfer
  inject/linux/wlr.rs (2), vdisplay/linux/kwin.rs (1)

No memory-unsafety SUSPECT blocks were found — the unsafe is sound. The vaapi
agent did flag two real AVBufferRef *leaks* (not UB) in DmabufInner::open; marked
inline with NOTE(leak) and addressed in a follow-up.

Verified: cargo clippy -p punktfunk-host --all-targets -- -D warnings is clean
(each file's deny gate hard-errors on any undocumented block).

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