Mesa now exposes Vulkan Video decode queues by default (and the session
binary opts RADV in for the Deck's sake), which silently moved every desktop
AMD/Intel box onto FFmpeg-Vulkan-on-Mesa under `auto` — user-reported
(CachyOS/KDE) to judder or error-streak into the software demotion while an
explicit VAAPI pick streams perfectly. Auto's hardware order is now
device-aware (`VulkanDecodeDevice::prefer_vulkan_over_vaapi`, fed
vendor id + device name by the presenter): Vulkan-first stays only where it
is the established right answer — NVIDIA (no usable VAAPI) and the Deck's
VanGogh (VAAPI dmabuf import chroma-fringes) — and everything else gets the
battle-tested zero-copy VAAPI first, with Vulkan as its fallback.
A mid-session Vulkan failure streak now also demotes to VAAPI before
software, so a broken Mesa Vulkan path can never strand a box with a
perfectly good VAAPI driver on CPU decode.
The GTK shell's decoder setting gains the missing "Vulkan Video" option
(values now mirror the console UI's auto/vulkan/vaapi/software) and drops
its pre-Vulkan "Automatic (VAAPI → software)" label.
Verified on the RTX 5070 Ti box (loopback session, auto → "Vulkan Video
hardware decode active", 60 fps); policy locked by unit test; clippy -D
warnings + pf-client-core/pf-presenter tests green on Linux.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ABR ceiling was the negotiated start rate, so an 'Automatic' session
was permanently boxed at the 20 Mbps default no matter the link — the
most user-visible cap left after the transport work lifted the client
receive ceiling to ~4.8 Gbps wire.
- Startup link-capacity probe: ~2 s into an Automatic session the pump
fires one speed-test burst (2 Gbps target, 800 ms) over the existing
ProbeRequest machinery; delivered wire throughput x0.7 (FEC + variance
headroom) becomes the controller's climb ceiling via set_ceiling().
Old hosts decline (all-zero reply) or never answer (a 6 s timeout
clears the stuck probe state so LossReports resume) — the ceiling then
stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0
opts out.
- Slow start: until the first congestion signal, every cooled clean
window DOUBLES the rate toward the ceiling (20 Mbps -> 640 Mbps in
~10 s) instead of +6% per ~10 s (which would have taken ~10 minutes).
Any congestion signal ends it for good; classic AIMD takes over.
- Faster, severity-aware AIMD: a SEVERE window (unrecoverable frame,
jump-to-live flush, or >=6% loss) backs off x0.7 immediately instead
of waiting two windows; ordinary congestion (2-6% loss, OWD rise)
keeps the two-window fuse. Additive climbs need 6 clean windows
(~4.5 s, was ~10 s); the change cooldown drops 3 s -> 1.5 s.
- PUNKTFUNK_VBV_FRAMES now also scales the direct-NVENC VBV (Windows +
Linux, previously hardwired to 1 frame) — parity with AMF/VAAPI/QSV.
Each accepted step still costs an encoder rebuild + IDR on the host;
in-place rate reconfigure (NvEncReconfigureEncoder / AMF dynamic
properties / Vulkan per-frame RC) is the planned follow-up that makes
stepping free. Controller tests rewritten to the new policy (severity
classes, slow-start climb, ceiling semantics; 144 green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- REPLAY_WINDOW 32768 -> 131072: the anti-replay bitmap covered the
120 ms loss window only to ~2 Gbps; the client now delivers ~4.8 Gbps
wire, where a late-but-valid Wi-Fi-retried datagram would have been
dropped as 'older than the window' — false loss. 16 KiB/session
covers ~12 Gbps.
- RECV_BATCH 32 -> 128: syscall rate stays ~3.4k/s at 430k pkt/s and
each pump iteration drains the kernel buffer deeper (ring 64->256 KB,
client sessions only). flush_backlog's iteration cap rescaled to keep
its ~190 MB guard equivalent.
- PUNKTFUNK_GSO gate is now value-aware: '=0' used to ENABLE GSO on
Linux (presence check) while disabling Windows USO. GSO stays OPT-IN,
deliberately: A/B'd twice today — it cuts send-thread CPU ~30% but
its 16-packet line-rate trains cost delivered throughput on a
constrained fabric (2.5GbE-hop pair: peak 2453 -> 1908 Mbps and 0.4%
loss at a rate sendmmsg carries clean). Flipping the default belongs
with pace-aware chunk spacing (plan Phase 1.2/1.3). docs-site row
corrected to match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RustCrypto aes 0.8.x and polyval 0.6.x gate their ARMv8 AES / PMULL
paths behind --cfg aes_armv8 / --cfg polyval_armv8 on aarch64 (x86_64
runtime-detects AES-NI with no flag, which is why hosts never showed
it). Without the cfgs every Apple and Android client decrypted the
media plane in SOFTWARE: 240 MiB/s/core measured on an M3 Ultra —
7 µs per 1.4 KB datagram, single-handedly capping receive throughput
at ~1.57 Gbps wire on both host pairs.
Workspace .cargo/config.toml sets both cfgs for
cfg(target_arch = "aarch64"); detection stays runtime (cpufeatures)
with a safe soft fallback. open_in_place: 240 MiB/s -> 2.42 GiB/s
(10.3x). Live sweep .173 -> M3 Ultra over 10GbE: ceiling 1572 ->
4830 Mbps wire, zero loss through a 3.5 Gbps target; the .21 pair now
saturates its physical 2.4 Gbps fabric exactly.
No in-tree build path sets RUSTFLAGS (xcframework + gradle checked),
so the config reaches all client builds; a lane that sets RUSTFLAGS
overrides config rustflags entirely and must carry the cfgs itself
(noted in the file). Shipping Apple/Android binaries stay on software
crypto until rebuilt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rewrite the client Reassembler around one whole-frame buffer per frame:
frame_bytes rides in every header and packetize geometry is
deterministic (every non-final block is exactly max_data_per_block data
shards), so a data shard's final AU offset is computable on arrival —
copy it there once, straight from the decrypt ring. New
ErasureCoder::reconstruct_into decodes ONLY the missing shards directly
into the frame buffer's holes (gf16 native; gf8 legacy shim); received
recovery shards ride pooled shard-sized buffers. The completed buffer
IS Frame::data.
Deletes the per-shard to_vec + per-block concat + final AU concat
(~178k allocs and a double copy of every byte per second at 2 Gbps —
the pump wall the 2026-07-14 sweeps measured at 98.9% of an M3 Ultra
core). Reassembly now costs ~0.4 µs/packet in-stream.
The eager buffer changes the hostile-header exposure, so two new
firewalls: derived-geometry validation (a header lying about its
data_shards/block_count vs its own frame_bytes is dropped before it can
scribble across another shard's range) and an in-flight allocation
budget (IN_FLIGHT_BUF_FACTOR × max_frame_bytes) so a window of tiny
first-shards can't commit gigabytes.
Behavior parity pinned by the existing suite (all green unchanged) plus
new end-to-end roundtrips through the real Packetizer (multi-block +
partial tail, loss within budget, reversed delivery, duplicates, empty
frame, unrecoverable block ages out, budget enforcement). loss-harness
recovery curve identical; pipeline bench: gf8/1MB +42%, gf16 neutral
(host-encode dominated). Known pre-existing quirk kept as-is: reversed
delivery reconstructs early (data+recovery ≥ k) and counts late-not-lost
shards into fec_recovered_shards.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three bugs found running the owed throughput sweeps (all three conspired
to make yesterday's 'transport does 1G+' numbers fabrications):
- the probe never advertised VIDEO_CAP_PROBE_SEQ, so every host DECLINED
its speed tests; the zeroed decline reply divided a settle-window
sliver by 1 ms and printed plausible-looking garbage. Advertise the
cap (the shared-core reassembler windows probe-space frames) and
detect the all-zero decline explicitly.
- an idle virtual desktop publishes no frames on damage-driven capture
(Windows IDD-push), so the pipeline build timed out before the burst
could run. The probe now injects a ±2 px cursor wiggle over the wire
during --speed-test warmup — injected host-side into the right
session, works headless everywhere.
- throughput-sweep.py: tracing emits ANSI color into pipes, which broke
the key=value parser (crash on the first point); strip it, guard
half-parsed lines, and surface host declines as a flag.
Also logs the whole-run receive stage split (PUNKTFUNK_PERF) at stream
end — the probe is the measurement tool for the client-pump wall.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A host (re)started after a desktop login inherits the user manager's
compositor env; a stale WAYLAND_DISPLAY makes headless gamescope 3.16
exit at startup ('Failed to connect to wayland socket') before its
PipeWire node appears. Unset both on the systemd-run transient unit
(UnsetEnvironment=) and the direct spawn (env_remove) — gamescope
exports its own DISPLAY/GAMESCOPE_WAYLAND_DISPLAY to the nested app.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session::poll_frame accumulates per-stage ns (recv_batch syscall, AES-GCM
open, Reassembler::push incl. FEC) into a PumpPerf drained via
take_pump_perf(); the client pump logs the split plus completed-AU
inter-arrival jitter (p50/p95/max + late count) every report window.
Gated on PUNKTFUNK_PERF — one branch per stage when off.
Smoothness previously had no metric at all (jump-to-live counters fire
seconds late), and the receive core had no attribution. First live use
pinned the 1.57 Gbps client wall on software AES-GCM (7 µs/pkt) vs
0.4 µs reassembly — see punktfunk-planning/design/throughput-beyond-1gbps.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Standalone sweep to probe the ~500 Mbps throughput wall (transport vs encoder
CBR undershoot); built and validated, no runtime coupling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
B1b: a Steam running in a plain GNOME/KDE desktop session holds Steam's single
instance, so a dedicated gamescope launch's own Steam exits at birth — the
game-library launch goes to a black screen. Release the desktop instance
(free_desktop_steam) on Steam launches before creating the managed session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
iOS + Android: a new opt-in setting mirrors controller 1's rumble onto the
device's own actuator (Apple RumbleRenderer Actuator.device / CoreHaptics,
Android deviceBodyVibrator), so a motor-less clip-on pad still gives haptic
feedback through the phone/tablet it's clamped to. Default off; wired through
the gamepad settings on both platforms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
iOS + Android: a three-finger vertical swipe up/down summons/dismisses the
device soft keyboard while streaming (trackpad + pointer modes). Mobile scroll
is now exactly two fingers so it never collides with the 3+-finger gesture
(3+ only fell into the old `>= 2` scroll path by accident).
Android: a TYPE_NULL KeyCaptureView plus IME meta-shift wrapping feeds key
events through. iOS: UIKeyInput plus a SoftKeyMap char->VK table with a
GCKeyboard dup gate so a hardware keyboard and the soft keyboard don't
double-emit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ship a Steam Input controller layout (controller_config/punktfunk.vdf) whose
always-on `ts_n` command enables native touchscreen delivery on the Deck, and
have the backend auto-install it (apply_controller_config: copy to
controller_base/templates + upsert the per-account configset entry, chown to the
user, back up first). This is what makes the Deck touchscreen reach the client
as native touch under gamescope without disabling Steam Input (impossible on the
Deck) — no manual controller setup.
Two shortcuts sharing the "Punktfunk" name (so one config key covers both): a
hidden stateful stream entry and a visible stateless entry that launches straight
into the gamepad UI. Both get full artwork (grid/gridwide/hero/logo/icon,
replaced with exported PNGs). Drop the art-generation script.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend the gamepad/console shell (pf-console-ui) to parity with the other clients:
- Settings gain a Touchscreen → Touch mode row (Trackpad / Direct pointer /
Touch passthrough), the one couch-relevant Settings field the screen lacked.
- The pair screen adds the no-PIN delegated-approval path: a "Request access"
action (only when the host advertises a fingerprint to pin) opens a connect the
host PARKS until the operator approves this device, then persists it as paired.
A role-based row model keeps the cursor off stale indices; manual hosts stay
PIN-only, matching the desktop shells.
- Threads request_access through OverlayAction::Launch and ConnectIntent; the
shell shows a "Waiting for approval…" takeover, and the session binary parks on
a 185 s budget (PendingApproval → persist-as-paired via on_connected).
Auto-wake (WoL) was already implemented end-to-end and is left as-is.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Steam validates the Deck unit serial's format before accepting it. Our
"PFDK..." serial was REJECTED ("Invalid or missing unit serial number"), so
Steam substituted a hash identity and mangled the displayed name to
"Steam Deck Controllerggg" on every host tested. An 'F'-leading serial passes,
so switch to "FVPF..." — keeps the PunktFunk marker one slot in, still distinct
from a real Deck's "FVZZ..." for the Linux self-detection in
physical_steam_controller_present(). The name now shows a clean "Steam Deck
Controller" with a serial-derived handle (verified on .173).
Also fix the UMDF driver's 0xAE GET_STRING_ATTRIBUTE handler to echo the
requested attribute id faithfully instead of collapsing board-serial (0x00)
requests to unit-serial (0x01). Steam still logs a benign "Deck Controller PCB
Serial# invalid" for the board serial — it validates that against a
Valve-internal format for ANY value, including an empty one (verified) — but
that line does not mangle the name, change the handle, or block promotion.
Applied to both transports: host inject/proto/steam_proto.rs::deck_serial
(Linux gadget/usbip) and the pf-dualsense UMDF driver (Windows), which mirror
each other's serial format.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The N4 GO verdict, productized. GamepadPref::SteamDeck on a Windows host
now builds a real virtual Deck instead of folding to DualSense: games
get native Deck glyphs + both trackpads + gyro + all four back grips
through Steam Input's own remapping.
- steam_deck_windows.rs: DeckWinPad/DeckWinProto/SteamDeckWindowsManager
over the sealed shm channel, sharing the whole Linux Deck codec
(steam_proto now compiles on Windows too — it was already pure). The
SwDevice identity carries usb_mi: Some(2): the &MI_02 hardware-id
token hidclass mirrors into the HID child and Steam parses as the
wired controller interface — the promotion gate.
- Driver: DEVTYPE_STEAMDECK (3) graduates from the spike — SET_FEATURE
0xEB rumble / 0x8F haptic pulses are republished to the host through
the output slot (report-id-0 prefixed, so parse_steam_output sees the
Linux wire shape), and the 0xAE/GET_STRING serial + 0x83 unit id are
per-pad (read from the section's pad_index; PFDK<unit-id> matches
steam_proto::deck_serial).
- Router: SteamDeck arms in the Windows Pads paths; pick_gamepad flips
SteamDeck-if-windows -> SteamDeck (the DualSense fold retires);
dualsense-windows-test grows --deck.
ON-GLASS VALIDATED on .173 (rebuilt signed driver 9.9.0714.12xx
installed, Steam live): the manager-created pad (index 1) enumerates
with per-pad serial PFDK50460001, Steam logs Interface: 2 ->
'!! Steam controller device opened' -> 'Steam Controller reserving
XInput slot 0' -> PollState 2 (actively polling our cycling input
frames) -> mapping activated; clean teardown on exit. Rumble round-trip
through a real game remains an on-glass debt (nothing sent 0xEB during
the idle hold).
Known gap vs Linux: no physical-Steam-controller conflict degrade on
Windows yet (degrade_steam_on_conflict is Linux-only — /sys scan); a
Windows equivalent needs SetupDi enumeration and is deferred.
Verified: .21 clippy -D warnings + 304/0 tests + fmt --all; .133 clippy
-D warnings + the WDK driver-workspace check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three things that belong together:
1. rustfmt the gamepad-new-types host files ci.yml's `cargo fmt --all
--check` gate flags (the .21/.133 verify recipes ran clippy+tests
but never fmt — the same class of miss as 69f30f30).
2. Enforce it at the source: scripts/git-hooks/{pre-commit,pre-push}
run the exact CI fmt gates (main workspace + the shipped-driver
crates of the UMDF workspace); CONTRIBUTING documents the one-time
`git config core.hooksPath scripts/git-hooks`. pre-push is the
enforcement point (plumbing commits bypass pre-commit).
3. N4 follow-up — the spike verdict FLIPS TO GO: SwDeviceProfile grows
`usb_mi`, synthesizing `&MI_02` into the Deck spike's USB hardware
ids. hidclass mirrors the parent's USB tokens into the HID child's
hardware ids, and hidapi/SDL/Steam parse `MI_` as bInterfaceNumber
(defaulting to 0 when absent — the exact gate the first run hit:
Steam wants the Deck controller on interface 2). Re-run live on
.173: Steam logs `Interface: 2`, then `!! Steam controller device
opened`, `Steam Controller reserving XInput slot 0`, and activates
a mapping — full Steam Input promotion of the software-devnode
Deck, no driver change needed. The PS identities pass
`usb_mi: None` (real single-interface devices carry no MI_ token).
A proper Windows-Deck backend phase is now justified; planned
separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
057E:2069 (Pro Controller 2) and 057E:2068 (Joy-Con 2 pair) are the
same full pad surface as the OG Pro and ride the same virtual
hid-nintendo pad. Mirrors SDL, which folds both to its public
NINTENDO_SWITCH_PRO type (the SDL clients bundle 3.4.10, whose switch2
hidapi driver already covers them end to end incl. gyro + GL/GR
paddles-as-paddle-buttons). :kit Kotlin compile green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RECORD_AUDIO / Wi-Fi-state permissions implied hard microphone + wifi
requirements, filtering mic-less TVs (reported: Philips OLED707) and
ethernet-only boxes as "not compatible" on Play; both are optional at
runtime and now declared required=false (aapt2-verified).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Used for the .173 on-glass verify: the Edge devnode enumerates
(SWD\PUNKTFUNK\PF_EDGE_1, driver pf_dualsenseedge attaches, proto 2),
and Steam's live controller.txt confirms the identity end to end —
'type: 054c 0df2', 'Product: DualSense Edge Wireless Controller',
'Controller using HIDAPI driver, vid=0x054c, pid=0x0df2' — with probe
lightbar/player-LED feedback flowing back on the 0xCD plane.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gamepad-new-types §6 go/no-go rig, ready to run the moment .173 is
back (the box is currently down, so the observation itself is still
owed): does Steam Input on Windows promote a software-devnode HID Deck
(28DE:1205), or does it need a real USB bus identity (the documented
GameInput instance-path gap — the Linux 'Interface: -1' lesson)?
- Driver: scratch device_type=3 serves the Deck identity — the captured
38-byte controller-interface descriptor, 28DE:1205 attributes, Valve
strings, the Deck neutral frame, and the Steam 0x83/0xAE feature
contract (SET_FEATURE latches the command, GET_FEATURE answers it —
attribute blob + unit serial mirroring steam_proto::feature_reply).
Never stamped by a session. INF gains pf_steamdeck.
- Host: deck_spike_hold() + the `deck-windows-spike` subcommand — stamps
devtype 3, spawns the devnode under VID_28DE&PID_1205, streams the
neutral frame, prints what to observe (Steam logs/controller.txt,
controller settings) and logs any output reports Steam writes.
Run recipe (on .173, once the updated signed driver is staged): install
driver, start Steam, `punktfunk-host.exe deck-windows-spike`, watch
controller.txt. GO -> plan a proper N4 phase (the Deck codec is already
shared); NO-GO -> document next to the Linux Interface:-1 note and keep
the SteamDeck->DualSense Windows fold.
Verified: .133 clippy -D warnings + the driver workspace cargo check
(WDK) both green; .21 clippy + 304/0 tests unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plan 0.4 for the N1/N2 backends (SDL landed with them):
- Apple: GamepadType grows dualSenseEdge=7 / switchPro=8 (wire-byte
parity + name parsing). padKind splits the Edge out of the shared
GCDualSenseGamepad subclass by product category, and resolves Switch
Pro / a paired Joy-Con set by category (GameController has no Nintendo
subclass; single Joy-Cons stay on the Xbox 360 fallback — half a pad).
The DualSense-only gates (adaptive-trigger feedback, player LEDs, the
touchpad+motion rich capture) now include the Edge — same surfaces.
Paddle CAPTURE stays gated on G22 (needs a real pad to pin the
paddleButton1..4 correspondence); the declared identity is right
meanwhile. swift build + 124 tests green.
- Android: PREF_DUALSENSEEDGE/PREF_SWITCHPRO wire bytes; the Sony PID
table splits 0x0DF2 (Edge) out of DualSense; Nintendo 057E:2009
declares Switch Pro; ControllersScreen labels the new kinds.
:kit/:app Kotlin compile green (-PskipRustBuild).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reserved GamepadPref::SteamController = 5 slot goes live: the same
hid-steam driver under the wired-SC identity (28DE:1102,
ID_CONTROLLER_STATE), UHID-only in v1 (no captured SC USB interface
layout, so no Steam-Input promotion — the pre-usbip Deck state;
acceptable for discontinued hardware).
Layout pinned against the kernel's ID_CONTROLLER_STATE table: 24-bit
buttons at 8..11 (low bits shared with the Deck; grips at 9.7/10.0 =
the Deck's L5/R5 positions; right-pad click 10.2; joystick click 10.6),
u8 triggers at 11/12, the joystick/left-pad MULTIPLEX at 16..20 (a
left-pad contact shadows the stick, like real hardware's lpad_touched
flag), right pad at 20..24. Mapping: wire left stick -> SC stick; wire
right stick -> right-pad coords + touched bit (the SC's camera surface —
the second-stick loss is inherent); PADDLE1/2 -> the two grips (natively,
masked out of the fold input); PADDLE3/4 + MISC1 -> the remap policy.
The SC parser has NO gamepad_mode gate, so no mode-entry pulse.
SteamDeckPad grew a SteamModel (open_model); ScProto/SteamCtrlManager;
pick_gamepad flips SteamController -> itself on Linux (replacing the
Xbox360 fold); SDL picker splits Valve PIDs (Deck 1205 stays SteamDeck,
SC 1102/1142 now declare SteamController).
Verified: .21 clippy -D warnings + 304/0 tests + on-box UHID smoke
(hid-steam binds 1102, BTN_A + right-pad ABS_RX land on evdev, no mode
pulse); .133 clippy -D warnings green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RECORD_AUDIO implies android.hardware.microphone required=true and the
Wi-Fi state permissions imply android.hardware.wifi required=true unless
declared otherwise, so Google Play filtered the app as "not compatible"
on TVs that declare no microphone (reported on a Philips 65OLED707/12,
Android TV 11, closed-testing track) and would do the same on
ethernet-only boxes. Both capabilities are optional at runtime: the mic
uplink is runtime-requested and the Wi-Fi locks are best-effort hedges.
Verified via aapt2 dump badging: microphone + wifi now report
uses-feature-not-required and no implied hard requirements remain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 0 of gamepad-new-types: the two new kinds exist on the wire (enum,
to_u8/from_u8/from_name/as_str, C-ABI constants + header), and pick_gamepad
folds them to the closest EXISTING backend until their own backends land —
DualSenseEdge -> DualSense (keeps the rich planes; only the paddles go
through the fold policy), SwitchPro -> Xbox360. Wire round-trip pinned
0..=8 + unknown->Auto; fold table extended.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The deferred Phase 3.3 of the gamepad review (gamepad-review-cleanup.md
§3a): the seven virtual-pad managers' copy-pasted lifecycle (slot table,
active_mask unplug sweep, gate-checked create, rumble/hidout dedup,
heartbeat) extracted into shared PadSlots<P> + PadProto/UhidManager<B>;
each backend now supplies only its protocol half via a type alias, with
zero Pads-router edits. Includes the 3.3.0 pre-step fixing the drifted
Linux DS4 backend (rich-plane pad clicks + the Steam left pad were dead
on the DS4 kind).
10 commits, each verified as it landed: Linux .21 clippy -D warnings +
full host suite 290 pass / 0 fail + fmt; Windows CI VM .133 clippy
--all-targets -D warnings EXITCODE 0. On-glass kind-cycling smoke
(one real pad per platform) still owed post-merge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop the vestigial Ds4Feedback.hidout field (parse_ds4_output never
filled it and neither DS4 manager read it — the lightbar rides the led
field, now converted to a HidOutput::Led by the protos) and its
now-unused HidOutput import; refresh the pad_gate module doc (managers
now drive it via pad_slots).
Verified: .21 clippy --all-targets -D warnings + full suite 290 pass /
0 fail + cargo fmt --check clean; .133 clippy --all-targets -D warnings
EXITCODE 0.
Part of G12/3.3 (§3a.4 commit 10) — extraction complete.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Wrap the long dispatch_finger call args, Abs struct literals, and Act::Button/
Scroll/MoveRel pushes per rustfmt (the CI fmt check on pf-presenter). No behavior
change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bumps [workspace.package] version 0.10.0 -> 0.10.1 (14 workspace crates) and
syncs Cargo.lock (versions-only). Apple MARKETING_VERSION / Android versionName
are set from the release tag by CI, so no client manifest changes; the nested
Windows-driver workspace keeps its independent 0.0.1 version.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bring the SDL presenter (Linux/Deck + Windows) to parity with the Android and
Apple clients: a persisted TouchMode selects how a touchscreen drives the host —
* Trackpad (default): relative cursor with pointer ballistics + the shared
gesture vocabulary (tap = left click, two-finger tap = right click,
two-finger drag = scroll, tap-then-drag = held left drag, three-finger tap =
cycle the stats overlay).
* Direct pointer: the cursor jumps to and follows the finger (absolute).
* Touch passthrough: every finger is a real host touchscreen contact.
Previously the presenter had no finger handling, so SDL synthesized mouse events
from touch and — under the stream's relative-mouse lock — walked the host cursor
into the corner (the reported Deck bug). SDL touch->mouse synthesis is now off;
DIRECT touchscreens route through a new incremental gesture engine (a port of
Android TouchInput.kt / Apple TouchMouse.swift), while INDIRECT trackpads keep
driving the mouse. Fingers map through the aspect-fit letterbox onto the content
rect.
TouchMode lives in the shared trust::Settings (default trackpad, so passthrough
is opt-in like the other clients); the GTK and WinUI settings screens both gained
a "Touch input" picker. Gesture engine, letterbox mapping, and settings
back-compat are unit-tested (28 tests green); clippy -D warnings clean; full
Linux client + session build verified on-host.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
The gamepad-UI navigation resolvers disagreed on which way a perfect 45-degree
stick push (|x| == |y|) resolves: the SDL core picked horizontal (`ax >= ay`)
while Apple (`abs(x) > abs(y)`) and Android (`abs(Y) >= abs(X)`) picked vertical.
Align Apple (`>` -> `>=`) and Android (`>=` -> `>`) to the SDL core so an exact
diagonal moves focus the same way on every client (horizontal wins). This is
client-local menu navigation only and never reaches the wire. Completes the last
deferred G25 sub-part.
Verified: Apple `swift build` + full suite (124 pass); Android `:app:compileDebugKotlin`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
`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>
The two-motor split assumes ids[0] = light/right and ids[1] = heavy/left, an
ordering `VibratorManager.getVibratorIds()` does not guarantee. Record the
assumption and its tactile-only failure mode (a heavy-first pad inverts the feel
but nothing silences or crashes) at the call site. No behavior change: a per-pad
fix needs on-glass verification, and a blanket count-based fallback is unsafe
(extra ids may be DualSense trigger actuators that must stay silent).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Against a legacy (no-TTL) host, a held Deck rumble droned forever if the stop
datagram was lost: the 40 ms keep-alive re-kicked the actuator indefinitely and
only the v2 lease `deadline` ever bounded it. Add a per-slot `updated_at` clock
bumped ONLY by real host datagrams (never by the keep-alive re-kick, unlike
`last_at`), and in the legacy branch (`ttl_ms == 0`) issue a single (0, 0) once
it is stale past LEGACY_RUMBLE_CEILING_MS (1000 ms = 2x the host's flat 500 ms
legacy refresh). A genuinely-held legacy rumble refreshes every 500 ms and never
trips; the v2 `deadline` path is untouched and stays authoritative.
Verified: Windows .173 `cargo clippy -p pf-client-core -- -D warnings` (green).
On-glass owed: real Deck with an induced legacy stop-frame drop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>