The two touch clients had exactly complementary gaps: iOS forwarded fingers
ONLY as raw wire touches (no way to drive the host cursor from the touch
screen), Android had the two mouse modes but no passthrough. Both now share
one three-way "Touch input" setting: Trackpad (default) / Direct pointer /
Touch passthrough.
iOS/iPadOS: Input/TouchMouse.swift ports the Android gesture engine 1:1
(same px-based acceleration curve; tap=click, two-finger tap=right-click,
two-finger drag=scroll, tap-then-drag=held drag, three-finger tap=stats
HUD via the shared hudEnabled default); direct-pointer mode maps through
the aspect-fit letterbox; the previous always-on behavior lives on as the
passthrough option. The mode latches per gesture (a Settings change never
splits one gesture across models), touchesCancelled releases held state
without synthesizing a click, and session stop flushes a mid-drag button.
Settings picker on iPhone + iPad next to the iPad-only pointer-capture
toggle. Deliberate default change: trackpad, not passthrough.
Android: new nativeSendTouch JNI shim → wire TouchDown/Move/Up (the host
already injects real touch on every backend — libei touchscreen, wlroots,
KWin fake-input, SendInput); streamTouchPassthrough forwards every finger
with stable ids and lifts still-held contacts on teardown; the trackpadMode
Boolean becomes the TouchMode enum (old pref migrated on load, never
rewritten) with a Settings dropdown.
Verified: macOS swift build + full suite (incl. new TouchMouseTests), iOS
Simulator Swift compile, cargo check/fmt/clippy on the native crate, Kotlin
app+kit compile + unit tests. On-glass feel of the iOS ballistics and
Android passthrough against a touch-aware app still pending.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root-caused fixes from on-Deck testing (owner + first external tester):
- System input broke while the app was merely OPEN: SDL's Steam Deck HIDAPI
driver clears the built-in controller's "lizard mode" (trackpad-mouse,
clicky pads) at device ENUMERATION and keeps feeding the firmware watchdog
(SDL_hidapi_steamdeck.c InitDevice/UpdateDevice) — and we enabled that
driver at startup and held every pad open app-lifetime. The Valve HIDAPI
hints are now enabled only while a session is attached, and only the active
pad is opened (Settings enumerates via SDL's ID-based metadata getters, no
open). Close/detach hands the hardware back; the watchdog restores lizard
mode within seconds. This also unblocks click-to-capture on the Deck (the
dead trackpad made "input not passed through" a symptom, not a cause).
- Washed-out colors from a Windows host with an HDR desktop: the host ships
Main10 BT.2020 PQ IN-BAND (correct VUI) while the Welcome still says SDR;
this client rendered everything as BT.709 narrow. Colour signaling is now
read per-frame (video::ColorDesc from the AVFrame CICP fields) and drives
the GdkDmabufTexture color state, the software path's swscale matrix/range
plus a tagged MemoryTexture for PQ, and an "· HDR" HUD chip — GTK tone-maps
correctly on SDR displays, mid-session SDR↔HDR flips included. Regression-
tested against a checked-in Main10 PQ fixture (tests/pq-frame.h265).
- Streams start fullscreen by default (Settings toggle; F11 / the controller
chord lead out, and the pointer at the top edge reveals the header while
input isn't captured — a Deck desktop has no F11). Gaming-Mode launches
(--fullscreen / Deck env) build the stream page with NO header bar at all:
gamescope doesn't reliably ACK xdg_toplevel fullscreen, so anything keyed
on is_fullscreen() could leave the title bar drawn over the stream.
- Game Mode settings were uneditable: GTK popovers are xdg_popups, which
gamescope never maps for nested apps — every ComboRow dropdown flashed and
died. Under gamescope the preferences dialog now uses in-window selection
subpages (PreferencesDialog::push_subpage) via a ChoiceRow that stays a
stock ComboRow on desktops. Covered by an in-process GTK test
(choice_row_modes, #[ignore]d — needs a display).
- Forwarded-controller pin persists across restarts (Settings::forward_pad,
stable vid:pid:name key — SDL instance ids are per-run) and survives
disconnects; automatic selection skips Steam Input's sensor-less virtual
pad (28de:11ff) so gyro doesn't silently die on Bazzite/Deck.
- "Punktfunk" branding in the About dialog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes from live debugging on the Deck:
- check_update() was dead on-device: Decky Loader's embedded (PyInstaller)
Python has no usable default CA paths, so every HTTPS fetch failed with
CERTIFICATE_VERIFY_FAILED. Build the SSL context explicitly: default paths
first, then the known system bundles (SteamOS/Arch, Debian, Fedora/Bazzite,
openSUSE), then certifi if importable. Verification stays on; the check
stays offline-tolerant with its 30-min cache.
- "could not chmod runner" on every use: Decky extracts plugin zips without
exec bits into a root-owned dir the unprivileged backend can't chmod. The
Steam shortcut now launches the runner through /bin/sh with the script as a
%command% argument — no exec bit needed, existing shortcuts migrate on
reuse, the chmod attempt is gone.
UI/structure:
- index.tsx (660 lines) split into page/pair/settings/hooks/boundary modules;
PluginErrorBoundary kept guarding every surface.
- New About section/tab: visible version + channel, explicit check-for-updates
(forces past the cache, always toasts an outcome), setup-guide link, leave-
chord help, and a Force-stop backstop for a wedged stream.
- Host rows open a details modal (address, protocol, pairing policy, paired
state, fingerprint). Settings gain 1280×800 (Deck native), Xbox One and
DualShock 4 pad types, and a host-compositor picker.
- Update flows note the Decky store contact can stall a couple of minutes on
networks that blackhole plugins.deckbrew.xyz (observed live).
- "Punktfunk" in all user-facing strings; plugin id/paths/env unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
log-crate events arrive through the tracing-log bridge under the shim target
"log" — normalize them back to the real module path (NormalizeEvent) so the
console's target column and the noise gate see mdns_sd::… , and suppress the
bridge's log.* bookkeeping fields like the stderr fmt layer does.
Gate known-chatty third-party DEBUG targets (mdns-sd DEBUG-logs every
unparseable multicast packet — one AirPlay device floods thousands of entries
per hour) to INFO-and-up in the ring, so ambient LAN noise can't evict the
tail the ring exists to preserve. stderr under RUST_LOG is unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While a pad drives a stream, GamepadCapture now sets EVERY element's
preferredSystemGestureState to .disabled (restored to .enabled on unbind).
iOS/macOS attach system gestures to several controller buttons — share/create
took a LOCAL screenshot instead of reaching the game, and only the Home
element was opted out before. With the gestures claimed, the already-wired
chains do their job: PS/Home → wire guide → BTN_MODE on the virtual xpad
(the Steam-overlay button) / the PS bit on the virtual DualSense.
Also fold the share/create/capture element (GCInputButtonShare) into the
back/select wire bit — clone pads like the GameSir G8 expose their screenshot
button only as the share element, not buttonOptions (OR onto the same bit, so
double-exposed pads are harmless). The G8's other extra button (M) is a
firmware-local modifier (turbo/hair-trigger/swap) invisible to the OS.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ground-up RumbleRenderer rewrite around one principle: rumble is idempotent
state on a lossy channel, and the actuator's divergence from it must be
bounded, not best-effort. The old renderer rebuilt an infinite-duration
CHHapticAdvancedPatternPlayer per 0xCA datagram via an async stop; one stop
lost inside CoreHaptics left an unstoppable player buzzing forever (the
"entered the menu and rumble never stopped" bug).
- Finite 4 s segments, never infinite events — a leaked player self-silences;
steady levels re-arm seamlessly ON the engine timeline (no stop/start race)
- GamepadFeedback drains the rumble plane DRY per cycle, newest-wins (was one
datagram per 8 ms through a 16-deep drop-newest queue = lag + shed stops)
- Host 500 ms state refreshes dedupe to a liveness stamp; zero applies
immediately; nonzero ramps throttle to one rebake/25 ms per motor
- Throwing player stop escalates to engine.stop() (kills leaked players);
1.6 s staleness watchdog (Policy.session) force-silences on a dead channel;
the test panel holds levels via Policy.manual
- Plain makePlayer, NEVER makeAdvancedPlayer: gamecontrollerd's controller
haptics server advertises `adv players: 0`, and iOS 27 beta 2 hard-drops
advanced loads with an XPC decode fault (-4811/4097, rumble silently dead).
Live-verified on an iOS 27 beta 2 iPhone: DualSense rumble works
- Split-handle engines fall back to one combined .default engine on repeated
failure; renderer publishes health transitions and the test panel shows
them (a refused system service no longer reads as silent app breakage)
- Per-motor sharpness on split handles (0.3 heavy / 0.7 light); macOS
DualSense raw-HID path gains a ~1 s keepalive re-write while nonzero
- RumbleTuningTests pin the scheduling math, tuning relations, and a
queue/ticker teardown smoke test
Stuck-rumble streaming repro revalidation on glass still pending.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A moved release tag re-fires the publish workflows, and the Gitea
registries reject duplicate uploads with 409 (deb pool, rpm group, and
the generic packages' versioned URLs; the channel aliases already
pre-deleted). Delete any prior copy of the exact version before
uploading (404 on first publish tolerated), so a republished tag
overwrites instead of wedging — v0.5.0's retag left stale no-port-change
artifacts published and every re-run red.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port 3000 collides with half the dev-server ecosystem; 47992 sits next
to the mgmt API (47990) in the punktfunk port family. Updates the run
scripts, systemd/scheduled-task units, Dockerfile, Windows firewall
rule + installer, packaging, and every doc that referenced :3000.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The snapshot comparison now normalizes info.version on both sides and
compares structurally — a version bump alone can never invalidate the
checked-in spec again (the 0.5.0 release tripped on exactly this; the
API surface is what drift-control protects). Snapshot regenerated so
the docs-site copy shows the current version.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rebase onto main picked up the pre-fix loop{match} variant of the
async retrieve drain — the exact shape the Windows clippy gate rejects
(run 6722 failed on it; the while-let form passed run 6724 on the CI
branch). Restore the gated form.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Remote debugging without shell access: a tracing layer tees every
event at DEBUG-and-up — independent of the RUST_LOG filter gating
stderr/host.log, so console-side debugging never needs a restart —
into a bounded in-memory ring (log_capture.rs, 4096 newest entries,
OnceLock singleton like config()), installed at both init sites
(stderr path in main, the Windows service file path). The mgmt API
serves it cursor-paged at GET /api/v1/logs?after=&limit= — bearer-only
and deliberately NOT on the mTLS cert allowlist (log lines can name
client identities and host paths). The web console grows a Logs page
(follow/pause · min-level filter · text search · eviction-gap badge);
polling self-paces: a non-empty page advances the after-cursor (new
query key → immediate refetch, drains backlogs), an empty page idles
at the 2s interval. OpenAPI regenerated; ring pagination/eviction,
layer wiring, and the authed route are unit-tested; Storybook story
included.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The client end of the "host doesn't see my gamepad" triage chain: a
new ControllersScreen lists every InputDevice Android classifies as a
gamepad/joystick (name, VID:PID, source classes, the punktfunk pad
type it resolves to, rumble test) plus an "Other input devices"
section — a pad behind a BT→USB adapter (the Pico 2W tester case)
often enumerates with the adapter's identity or not as a gamepad at
all, and this makes that visible on the device instead of over a bug
report. A live input test (button chips + axis bars + raw last-keycode
line) consumes pad events via new MainActivity probe hooks ahead of
the focus-nav remap; hold B 1.2s to exit since the pad can't reach the
toggle while captured. Gamepad grows pads()/isPad() (firstPad
generalized).
Kotlin compiles green (kit + app); on-device validation pending.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gpu-contention plan's §5.B lever: today submit and the blocking
lock_bitstream share one thread, so under a GPU-saturating game the
pipeline serializes on the WDDM scheduling wait (1000/17ms ≈ 59fps —
the depth-1 collapse; the old 'deeper pipeline just stacks latency'
result was a same-thread implementation, not a disproof). Async mode
opens the session enableEncodeAsync=1, registers an auto-reset
completion event per pool bitstream, and moves the wait+lock+copy+
unlock onto an internal retrieve thread feeding poll() through a
channel — the exact split the NVENC guide mandates. Register/map/unmap
stay on the encode thread; teardown drops the job channel, joins the
thread, THEN destroys the session. In-flight depth is bounded by
PUNKTFUNK_NVENC_ASYNC_DEPTH (default 4, hard cap POOL-1) — both for
output-buffer reuse and because NVENC encodes the capture ring's
textures in place. Idle latency cost ≈ 0 (same-tick pickup); under
contention completed frames queue instead of stalling capture.
CI-compile validated only — on-glass A/B under game load on the RTX
box still pending (box offline).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PUNKTFUNK_VIDEO_DROP now also covers the native data plane (N% of
sealed wire packets discarded before send in paced_submit — the same
FEC-test knob the GameStream path has; no netem/root needed), and the
probe grows the real clients' recovery trigger: the data loop publishes
the session's unrecoverable-frame count and the control task sends
RequestKeyframe when it rises (100ms poll = natural coalescing).
Together these make the IDR-vs-intra-refresh recovery A/B runnable
against any host.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nvclocks.rs -> gpuclocks.rs. PUNKTFUNK_PIN_CLOCKS=1 now also pins every
amdgpu card's power_dpm_force_performance_level to high for the host
lifetime (prior level restored on exit) — the measured AMD encode-
latency lever: VCN per-frame time doubles when a 60fps paced trickle
lets clocks sag (8 -> 4.4ms/frame at 1440p on the 780M with clocks
hot). Root-gated by sysfs ownership; non-root degrades to a logged
recipe (validated live on the AMD box). Opt-in stays deliberate:
box-wide power-management override, wrong on battery/Deck.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chasing the 8ms submit at 1440p on the 780M: the sampled PUNKTFUNK_PERF
split (push/pull/send) shows desc+buffersrc at ~5us, hwmap-import+VPP
CSC at ~0.2-0.5ms, and avcodec_send_frame owning the rest — so neither
a VA-surface import cache nor CSC overlap would help. Two facts landed:
(1) async_depth>=2 in libavcodec's vaapi_encode is a structural
+1-frame latency (frame N's packet only materializes when N+1 queues;
measured 18ms vs 8.3ms p50 at depth 1) — depth 1 stays the default,
PUNKTFUNK_VAAPI_ASYNC_DEPTH exists for pixel rates beyond the ASIC's
serial budget, and poll() now does a bounded in-flight wait so a deeper
depth still ships the AU as soon as the ASIC finishes. (2) The residual
send_frame block tracks GPU CLOCKS, not the ASIC: ~8ms/frame at a 60fps
duty cycle vs ~4.4ms at 120fps pacing vs 3.5ms back-to-back (270fps CLI
benchmark, even at -async_depth 1) — the clock-sag fix lands in
gpuclocks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A/B'd on the Bazzite box (RTX 5070 Ti, KWin 6.6, driver 595, 1080p60
over the LAN): pixel-correct decode (full desktop, no tint/banding),
latency-neutral idle (p50 1.47ms RGB vs 1.52ms NV12, both 2400/2400
frames), CPU-neutral — and it deletes NVENC's internal RGB->YUV CSC
from the SM/3D engine a game saturates (video 40%+SM 15% -> video
26%+SM 2% measured on Windows). Matches the Windows host default.
PUNKTFUNK_NV12=0 restores the RGB feed; LINEAR/gamescope captures are
unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two halves of the easy-scene p99 lever (host-latency plan Tier 1B):
CudaNoStablePerfLimit application profile (no root; NVIDIA's supported
opt-out of the CUDA/NVENC P2 memory-clock clamp, raw key 0x166c5e=0 per
open-gpu-kernel-modules#333, shipped for obs/Discord in R595) installed
into ~/.nv/nvidia-application-profiles-rc.d/ keyed on procname, opt-out
PUNKTFUNK_NV_PROFILE=0; and PUNKTFUNK_PIN_CLOCKS=1 arming an NVML
SetGpuLockedClocks(TDP, UNLIMITED) core-clock floor (base floor, boost
headroom — never a max pin) held for the host lifetime, reset-on-start
self-healing a crashed run's stale pin, NO_PERMISSION degrading to a
logged sudoers/oneshot recipe. libnvidia-ml is dlopen'd like libcuda —
no link-time dependency, clean no-op off NVIDIA.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Intra-refresh (opt-in PUNKTFUNK_INTRA_REFRESH=1 until on-glass
validated): NVENC runs a moving intra band + recovery-point SEI
(gop_size becomes the wave period, ffmpeg forces the real GOP infinite;
default fps/2, PUNKTFUNK_IR_PERIOD_FRAMES overrides; ENOSYS latches a
fallback to IDR-only). Clients request a keyframe on every
FEC-unrecoverable frame, so under intra-refresh the session glue serves
the first request instantly and suppresses the rest for a 2s window —
the wave heals loss without the 20-40x IDR spike cascade. VAAPI/software
keep IDR recovery.
Instrumentation: the wire pts now anchors at the PipeWire delivery stamp
(client-measured latency covers delivery + queue age, not just
submit->glass; repeats/synthetic stamps fall back to now), encode_us
keeps its submit->AU meaning via a separate inflight stamp, and a new
'queue' stage (delivery->submit age of fresh frames) rides
PUNKTFUNK_PERF and the web-console stats samples.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The GameStream video thread ran unboosted on Linux and the send thread
only got the Windows MMCSS call; both now use boost_thread_priority
(Linux nice -10/-5, Windows HIGHEST/ABOVE_NORMAL + session tuning).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PUNKTFUNK_ZEROCOPY unset now defaults ON when the encode backend is
VAAPI — a stock AMD/Intel install gets the LINEAR-dmabuf -> GPU-CSC path
instead of three full-frame CPU touches (measured on the 780M at 1440p:
0.8s vs 7.9s CPU per 600 frames, pixel-identical). NVENC stays opt-in.
A dmabuf offer the compositor never accepts latches a one-shot downgrade
so the pipeline rebuild renegotiates on the CPU offer; explicit =1 keeps
erroring loudly. The EGL->CUDA importer is no longer built on VAAPI
backends (an NVIDIA box forced to PUNKTFUNK_ENCODER=vaapi now correctly
takes the passthrough instead of producing CUDA frames the encoder
rejects), and a VAAPI session landing on the CPU path warns with the
reason.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gen12+/Arc iHD exposes ONLY EncSliceLP, so the default open fails with
'no usable encoding entrypoint'. Try full-feature first (AMD unchanged,
validated on the 780M), retry low_power=1, cache the mode per codec;
PUNKTFUNK_VAAPI_LOW_POWER pins it. Probes inherit the ladder. Docs note
the Intel HuC firmware requirement.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bump windows-reactor + windows to a4f7b2cb (from b4129fcc) for the new
PointerEntered/PointerExited events; migration is mechanical renames only
(SymbolGlyph->Symbol, placeholder->placeholder_text, on_changed->
on_text_changed/on_toggled, on_menu_item_clicked->on_item_clicked,
on_ready->on_mounted). New runtime model: reactor lost its build.rs, so the
client build.rs stages the WinAppSDK bootstrap via
windows-reactor-setup::as_framework_dependent() and main calls
windows_reactor::bootstrap() (missing either = 0x80040154 at launch);
staged filenames unchanged, so pack-msix and the MSIX manifest are untouched.
- Host tiles: WinUI pointer-over fill (ControlFillSecondary) via the new
pointer enter/exit events, hover id in root state (backend-wired handlers
bypass the reconciler flush, like the flyout clicks).
- Settings: stock NavigationView sidebar (Windows-Settings pattern) with
Display/Video/Input/Audio/About panes, built-in back arrow, wide content
column, and a per-section content slide-up tween. The section card is
KEYED by section: an in-place diff across sections re-sets a reused
ComboBox's items (clearing WinUI's selection) but skips selected_index
when the values compare equal, rendering a blank selection - the key
forces a remount. Card titles/descriptions dropped; per-control guidance
moved to hover tooltips (ToolTipService).
- New "Show the stats overlay (HUD)" setting (show_hud, default on),
honored mid-stream via the 400 ms HUD re-render.
- Add-host modal: entrance fade + slide-up tween (scrim fades with it).
- Self-initiated disconnect (Ctrl+Alt+Shift+D -> Ended(None)) returns to
the host list silently instead of raising the error banner.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Settings gains a GPU selector (shown only on multi-GPU boxes): the picked
DXGI adapter drives decode + present, persisted as Settings.adapter and
applied at the next stream - gpu.rs now caches the shared device keyed by
the resolved preference (env PUNKTFUNK_ADAPTER > Settings > the window's
monitor's adapter) so a change needs no app restart.
- Ctrl+Alt+Shift+D disconnects the session (consumed locally, captured or
released): the hook releases capture and trips the session stop flag,
plumbed through the stream-page handoff; the pump winds down and the UI
navigates back to the host list.
- Stream HUD extended: codec chip (HEVC/H.264/AV1), display-side line from
the render thread (presents/s + capture-to-decoded vs capture-to-on-glass
p50), session line (host name, duration, network-lost frames, skipped
backlog frames), and both shortcut hints incl. the new disconnect.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Decode+present rewrite (first real pixels on glass for this client):
- Decode: FFmpeg D3D11VA on NVIDIA/AMD/Intel. get_format now only returns
AV_PIX_FMT_D3D11 and lets libavcodec build the decode pool from
hw_device_ctx (hand-built frames contexts failed three different ways:
NVIDIA rejects DECODER|SHADER_RESOURCE arrays, BindFlags=0 fails texture
creation, Intel rejects non-128-aligned HEVC surfaces at the first
SubmitDecoderBuffers). A DXVA profile probe before the hwdevice commits
hardware-vs-software up front instead of burning the opening IDR;
extra_hw_frames covers the frames the client holds.
- Present: the decoded slice is copied with ONE display-size-boxed
CopySubresourceRegion (a planar slice is a single subresource in D3D11;
the old two-copy D3D12-style code silently no-opped - the black screen)
into a sampleable NV12/P010 texture, per-plane SRVs + YUV->RGB shaders.
- New dedicated render thread (render.rs): presenting is decoupled from the
XAML thread; frame-latency-waitable swapchain + SetMaximumFrameLatency(1),
newest-wins drain after the wait, crossbeam frame channel with pts for a
capture->presented p50 log.
- HiDPI: pixel-sized buffers + SetMatrixTransform(96/dpi) - was blurry at
125/150 % scaling.
- Software fallback now feeds the same shaders (swscale -> NV12/P010 planes
-> two dynamic plane textures); ps_rgba/X2BGR10 path deleted, hw/sw colour
math identical.
- Adapter selection for hybrid boxes: PUNKTFUNK_ADAPTER > the window's
monitor's adapter > default; PUNKTFUNK_D3D_DEBUG=1 debug layer.
- Session pump: request_keyframe at start and on hw->sw demotion (infinite
GOP would otherwise sit on a black screen).
Validated live on the Arc Pro + RTX 3500 Ada laptop against the local
Windows host: 60 fps D3D11VA on both vendors, software path, GUI on glass.
Also: embedded app icon (build.rs winresource + WM_SETICON, MSIX
Square44x44 targetsize assets, pack-msix stages them) and the hosts-page
tile rework (tap-to-connect tiles with sibling overflow menu - fixes
forget-also-connects - in-tile rename editor, add-host modal via root state).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
- new crate::gpu (compiled on all platforms so the OpenAPI doc stays
platform-independent): DXGI / sysfs GPU inventory with reboot-stable ids
(PCI vendor:device + occurrence — LUIDs are per-boot), persisted auto/manual
preference (<config>/gpu-settings.json, atomic temp+rename with in-memory
rollback), one selection with precedence console preference >
PUNKTFUNK_RENDER_ADAPTER > max VRAM and graceful fallback when the preferred
GPU is absent, plus a live "in use" record (RAII session guard wrapped around
every encoder open_video returns)
- fix: windows_gpu_vendor derived the encoder backend from DXGI adapter 0
instead of the selected render adapter — on a hybrid box (e.g. Intel iGPU at
index 0 + NVIDIA dGPU) the backend could disagree with the GPU the capture
ring / IddCx render pin sit on. The NVENC 4:4:4 probe now also runs on the
selected adapter (was: OS default), the codec/4:4:4 probe caches are keyed
per selected GPU (were process-lifetime OnceLocks), and an explicit
PUNKTFUNK_ENCODER conflicting with the selected GPU's vendor warns up front
- mgmt API: GET /api/v1/gpus (inventory + mode + preferred + next-session
selection with reason + in-use GPU/backend/session-count) and
PUT /api/v1/gpus/preference (validates mode/gpu_id before writing);
openapi.json regenerated; the vdisplay render pin now also engages for a
console preference (not just the env pin)
- web console: GPU card on the Host page — list with vendor + VRAM,
Automatic / Prefer controls, Preferred / Next session / "In use · backend"
badges, missing-preferred-GPU warning and env-pin note; en + de messages
- Linux: a matched manual preference picks the VAAPI render node and the
NVENC-vs-VAAPI auto choice; auto mode is exactly the previous behavior
Validated live on the hybrid laptop (RTX 3500 Ada + Intel Arc Pro, which
enumerates twice — the occurrence ids disambiguate): enumerate, prefer,
bad-id 400, restart persistence, auto-restore keeping the stored pick.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
quick-xml 0.39.4 enters only via wayland-scanner, a build-time proc-macro
that parses trusted crate-shipped protocol XML at compile time — never a
shipped binary, never runtime/attacker-controlled input, so neither DoS is
reachable. wayland-scanner 0.31.10 (latest) pins quick-xml ^0.39; the fixes
land only in >=0.41, so there is nothing to bump to.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Modern branded wizard: WizardStyle=modern dynamic windows11 (Inno >= 6.6,
plain-modern fallback for older compilers; CI provisioning upgrades a
pre-6.6 Inno). Brand-mark wizard side panels + header tiles (100-200% DPI)
and a multi-size punktfunk.ico (SetupIconFile + Apps & Features), generated
AND committed by branding/gen-branding.ps1 from the canonical brand geometry.
Gotcha encoded in the script: ISCC rejects all-PNG icons, so entries <= 64px
are classic DIBs (PNG only at 128/256), and the ICO is load-verified.
- VB-CABLE actually ships now: windows-host.yml never set VBCABLE_DIR, so every
published installer silently omitted the virtual mic (broken mic passthrough
in the field). CI provisions the pinned, SHA-256-verified official Pack45
(provision-windows-punktfunk-extras.ps1) and the pack now FAILS on a
supplied-but-invalid dir instead of shipping mic-less again. Attribution per
VB-Audio's bundling grant surfaced in the visible wizard task text (vendor,
vb-cable.com, donationware) on top of the licenses notice.
- GameStream (Moonlight) compat is a wizard task (checked by default) ->
service install --gamestream=on|off writes PUNKTFUNK_HOST_CMD=
serve[ --gamestream] into host.env. Only the two canonical values are ever
rewritten - a hand-customized command line survives upgrades. Silent
installs: /MERGETASKS="!gamestream".
- Driver uninstall (field report: our virtual-device drivers survived
uninstall): new `driver uninstall [--gamepad]` removes the pf-vdisplay
device node(s) + the pf-vdisplay/pf-dualsense/pf-xusb driver-store packages,
wired into [UninstallRun] after service uninstall. Locale-safe by
construction: devices matched on unlocalized VALUES (never pnputil's
localized labels), packages found by INF content scan - validated against a
German-locale box ("Instanz-ID:" parse; 7/7 punktfunk INFs matched, no
foreign hits). VB-CABLE is deliberately left installed (shared third-party
component with its own uninstaller).
Installer compile, cargo check/clippy/fmt, and the ASCII locale gate are green;
the wizard look + uninstall flow still need one on-glass pass on a disposable
box (this box runs the live host).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Structure: split the 1400-line app.rs into per-screen app/ modules (mod=root/
router, hosts, connect, pair, speed, settings, licenses, stream, style) with
shared card/header/busy-page builders and setting_combo/toggle helpers; the
re-render rule (thread-driven state lives in root use_async_state, flows down
as props) is now documented at the module root.
Parity features the other clients already had:
- "Native display" resolves the real monitor mode at connect
(MonitorFromWindow -> EnumDisplaySettingsW; was a hardcoded 1080p60)
- per-host network speed test: saved-host card button + a results screen
(probe burst -> goodput/loss -> ~70% recommended bitrate applied in one
tap; stale runs invalidated by generation) and `--headless --speed-test`;
the bitrate setting becomes a free-form NumberBox so the recommendation
round-trips
- forget host (ContentDialog confirm -> KnownHosts::remove_by_fp)
- settings: forwarded-controller picker (pads/pinned/set_pinned now wired),
gamepad type, host compositor, capture-system-shortcuts; the previously
dead Settings.compositor / inhibit_shortcuts are honored (shortcuts off =
Alt+Tab/Alt+Esc/Ctrl+Esc/Win act locally)
- click-to-recapture after a Ctrl+Alt+Shift+Q release; the HUD hint tracks
the live capture state
Perf: the input hook caches lock geometry (clip rect + contain-fit scale) at
engage instead of GetClientRect per WM_MOUSEMOVE; the audio jitter ring trims
via drain() and reuses the render scratch buffer.
Validated on the bare-metal box: --discover, synthetic-host loopback E2E
(TOFU -> clock skew -> HEVC negotiate -> D3D11VA init -> session end),
speed-test E2E, and the WinUI shell rendering in the console session via
PsExec (SSH/session-0 cannot create windows, pre-existing 0x80070005).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sources reorganized (client: Home/Session/Settings/Stores/Support/Trust; kit:
Audio/Connection/Gamepad/Input/Support/Video/Views) with the big files split
along the same seams.
The gamepad mode is couch-complete, and now on macOS too (the living-room
Mac case), not just iOS/iPadOS:
- GamepadSettingsView: a console-style, fully controller-navigable settings
screen (X from the launcher) — up/down moves focus, left/right steps values
(clamped, boundary thud), A cycles/toggles, B closes; the focused row shows a
one-line description. Backed by GamepadMenuList, the vertical sibling of
GamepadCarousel, and SettingsOptions — the option lists hoisted out of
SettingsView statics and shared by the touch, tvOS and gamepad settings.
- GamepadAddHostView + GamepadKeyboard: register a host end to end with a pad
— field rows open an on-screen controller keyboard (dpad grid, A types,
X backspaces, B done); the launcher carousel ends in an Add Host tile, so
the dead-end "add one with touch first" empty state is gone.
- Launcher polish: contextual hint bar with the pad's real button glyphs,
controller name + battery chip, one shared console chrome.
- GamepadScreenBackground: an animated aurora (TimelineView-driven drifting
blobs in the brand's violet family, breathing radii, slow hue shift,
legibility scrim; freezes under Reduce Motion). Pure SwiftUI on purpose — a
.metal library only bundles reliably in one of the two build systems (SPM vs
the xcodeproj's synced folders) these sources compile under.
- macOS port: settings/add-host/library present as sized sheets (a macOS sheet
takes its content's IDEAL size, and the GeometryReader-driven screens
collapsed to nothing), NSScreen-based mode lists, scroll indicators .never
(the "always show scroll bars" setting overrides .hidden), tray scrims so
scrolled rows dim under the pinned title/hints, extra title clearance, and a
PUNKTFUNK_FORCE_GAMEPAD_UI=1 dev hook — launcher/settings/add-host/keyboard/
library render-verified live on a real Mac + LAN hosts.
- GamepadMenuInput: X button support, and (re)start now snapshots held buttons
so a controller handoff press never fires twice (the B that closed the
keyboard no longer also cancels the screen underneath).
- Cleanups: one "Connection failed" alert in ContentView instead of one per
home screen; HostDiscovery.advertises/unsaved shared by both home screens.
- host: can_encode_444 stub for the non-Linux/Windows host build (the macOS
synthetic-source loopback used by the Swift tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- library.rs + ui_library.rs: the host's unified game library over the
management API (the Apple LibraryClient/LibraryView ported) — mTLS with the
paired identity, host verified by its pinned cert fingerprint (ureq + rustls,
unified with the workspace rustls 0.23); posters load async with monogram
placeholders, and picking a title starts a session that asks the host to
launch it (the library id rides the Hello).
- app.rs (~800 lines lighter) splits into cli.rs (argv/headless
pairing/--connect/screenshot scenes), launch.rs (mode resolve + session
worker + event stream into the UI) and ui_trust.rs (TOFU / SPAKE2 PIN /
delegated-approval dialogs); ui_hosts/ui_stream reworked around the split.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- native: the 756-line session.rs becomes session/{mod,connect,input,planes}.rs
around a SessionHandle (connect lifecycle + trust, input plane shims, plane
start/stop + stats drain).
- Decode-stats sampling is HUD-gated (nativeSetVideoStatsEnabled): with the
overlay hidden the decode thread skips the per-AU clock read + lock; enabling
resets the measurement window.
- audio: the AAudio open path is a per-sharing-mode try_open closure — the
realtime callback state (ring, prime, free-list) is rebuilt per attempt, so a
failed exclusive-mode try can't leak state into the shared-mode retry.
- Kotlin: ConnectScreen/StreamScreen slimmed by extracting ConnectDialogs,
StatsOverlay and TouchInput.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rounds out codec negotiation across the last three clients — each advertises what it can decode,
builds its decoder from the resolved Welcome.codec, and exposes a "Video codec" preference picker.
**Windows** (Rust, mirrors Linux): `decodable_codecs()` + `ffmpeg_codec_id()`; the D3D11VA and
software FFmpeg decoders (and the mid-session D3D11VA→software demotion) open the negotiated codec
instead of hardcoding HEVC; settings gain a `codec` field + reactor ComboBox; `--codec` CLI flag.
**Apple** (Swift/C-ABI): AnnexB is now codec-aware — a `VideoCodec` enum drives H.264 vs HEVC NAL
parsing / parameter-set extraction (`CMVideoFormatDescriptionCreateFromH264ParameterSets` for H.264,
no VPS) and AVCC repacking; `PunktfunkConnection` advertises H264|HEVC via `punktfunk_connect_ex7`,
reads `resolvedCodec` (`punktfunk_connection_codec`), and threads `videoCodec` into the stage-1/2
pipelines + `VideoDecoder`; SettingsView "Video codec" Picker (auto/HEVC/H.264). AV1 is left out
(hosts don't emit it on the native path, and it's not an AnnexB codec). Test call sites updated.
**Android** (Kotlin + Rust JNI): the JNI `nativeConnect` gains `preferredCodec`; the native decode
loop picks the AMediaCodec MIME (`video/hevc`|`video/avc`) from `connector.codec` and advertises
H264|HEVC; Settings `codec` field + Compose dropdown.
Core/host/probe/Linux clippy + tests green (unchanged from 2a). Windows/Apple/Android compile on
their platform CI (this Linux box can't build them — Windows toolchain / Xcode / the Android NDK's
opus-cmake toolchain). All follow the Linux client's validated pattern.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a client-selectable **preferred codec** and wires the core + ABI + probe + Linux client to
negotiate and decode it. (Windows/Apple/Android follow in 2b.)
**Core:**
- `Hello.preferred_codec` (a single CODEC_* bit, 0 = auto) — a soft hint appended after
`video_codecs`. `resolve_codec(client, host, preferred)` now honors the preference when the host
can also emit it, else falls back to precedence (HEVC > AV1 > H.264). Roundtrip + preference tests.
- `NativeClient::connect` takes `video_codecs` + `preferred_codec`; `NativeClient.codec` exposes the
resolved `Welcome.codec`.
- ABI: `punktfunk_connect_ex7` (adds the two codec params; `ex6` delegates to it advertising
HEVC-only) + `punktfunk_connection_codec` getter + `PUNKTFUNK_CODEC_{H264,HEVC,AV1}` constants
(drift-guarded against the wire values). Header regenerated.
**Host:** passes `hello.preferred_codec` into `resolve_codec`.
**probe:** `--codec h264|hevc|av1|auto` sets the preference (still advertises it can decode all
three); the dump extension already follows the resolved codec.
**Linux client:** advertises the codecs FFmpeg can actually decode (`decodable_codecs()`), threads
the user's `codec` setting as the preference, and builds the decoder — both the software and VAAPI
paths, plus the mid-session VAAPI→software demotion — from the negotiated `Welcome.codec` instead of
hardcoding HEVC. New "Video codec" dropdown in Preferences (Automatic/HEVC/H.264/AV1).
Live-validated on the dev box: probe `--codec hevc` against a software (H.264-only) host resolves to
H.264 (graceful soft-preference fallback), no failure. clippy + core (57) + host (133) tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1 of codec negotiation, and the Linux software H.264 encode path it unblocks.
**Codec negotiation (core `quic`):**
- `Hello.video_codecs` (bitfield: CODEC_H264/HEVC/AV1) — the client advertises what it can
decode; appended as a trailing byte (older client → 0 = HEVC-only, back-compat).
- `Welcome.codec` — the single codec the host resolved and will emit; trailing byte (older
host → HEVC).
- `resolve_codec(client, host_capable)` picks the shared codec (precedence HEVC > AV1 > H.264)
or `None` → the host refuses honestly rather than sending an undecodable stream.
- Roundtrip + back-compat tests; cbindgen exports the CODEC_* constants.
**Software encoder (host):**
- The openh264 `OpenH264Encoder` (was Windows-only) is now built on Linux too — it's
platform-agnostic (consumes CPU RGB `CapturedFrame`s, statically-bundled openh264). `openh264`
moved to the shared linux+windows Cargo target.
- `PUNKTFUNK_ENCODER=software` selects it: `open_video` gains a `software` branch (H.264 only),
and `session_plan::resolve_encoder` / `capture::gpu_encode` resolve `EncoderBackend::Software`
→ `output_format().gpu = false`, so the portal capturer delivers CPU RGB. Explicit-only (auto
never picks it — a box with a dead driver still has /dev/nvidiactl and would mis-resolve NVENC).
**Host codec resolution (`punktfunk1`):**
- The native path no longer hardcodes HEVC: it resolves the codec from the client's advertised
set ∩ the host's capability (`Codec::host_wire_caps`: software→H.264, else HEVC), threads it
through `SessionPlan.codec`, and opens the encoder + validates reconfigures at that codec. A
software host + HEVC-only client is refused with a clear error.
- 4:4:4 is gated on HEVC (it's HEVC-only).
**Probe:** advertises H264|HEVC|AV1 and logs the resolved codec.
Validated on the GPU-less dev box: negotiation is live end-to-end (probe advertises 0x07 → host
resolves H.264 → Welcome reports it → plan = Software/H264), and the openh264 unit test (CPU RGB →
AnnexB IDR) now runs on Linux. Full capture→encode still needs a GPU on this box — every
compositor screencast path (KWin GL, gamescope VK_EXT_physical_device_drm, wlroots EGL) requires
one; software render (llvmpipe/pixman) can't be captured — so this box exercises negotiation +
encoder, not live capture. The software path unblocks GPU-less-*encode* boxes that still have a
display GPU. Phase 2 (clients advertising real codecs + decoding per Welcome.codec) is a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Client-initiated launches (Hello.launch / GameStream applist) were only
wired to gamescope's bare-spawn path via the process-global
PUNKTFUNK_GAMESCOPE_APP env — which leaked across sessions, was never
read by kwin/mutter/wlroots (launch was a silent no-op there), and was
unreachable on gamescope anyway because apply_input_env unconditionally
defaulted to the managed session (which bails on non-Bazzite/SteamOS
boxes and ignores the launch command in all its modes).
- Thread the launch per-session: resolve the library id at handshake,
carry it on SessionContext (Windows: id; else: resolved command), and
hand it to the backend instance via set_launch_command — the global
env write is gone (the env stays as an operator fallback in spawn).
- Gamescope sub-mode ladder (pick_gamescope_mode, pure + unit-tested):
managed only when session-plus/SteamOS infra exists, attach for an
explicit request or a foreign (non-host-descendant) gamescope, else
bare spawn — which nests the launch and is now reachable on plain
distros instead of the guaranteed managed-mode bail.
- launch_session_command: one launch entry point for both planes once
capture is live — desktop compositors plain-spawn into the retargeted
session (the virtual output is primary); managed/attached gamescope
spawns with the live session's DISPLAY/GAMESCOPE_WAYLAND_DISPLAY
discovered from /proc (steam:// URIs also forward over Steam's own
pipe). launch_is_nested gates bare spawn against double-launching.
- GameStream unified onto the same dispatch; also nests library-id
picks into gamescope (previously only apps.json cmd was nested).
Validated live on the dev box up to the missing-GPU wall: handshake
resolution, Spawn sub-mode on plain Ubuntu, gamescope spawned with the
command nested. On-glass validation (kwin spawn on the streamed output,
Bazzite/Deck managed forward) pending GPU reattach.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The punktfunk/1 virtual microphone was created as a plain Audio/Source with
no session priority, which caused two failures — both diagnosed live against a
Bazzite host on PipeWire 1.4.10:
1. It was never WirePlumber's default source, so any app recording the *default*
input (games, Discord, arecord) heard silence. This is the Linux analogue of
the Windows host forcing the default recording endpoint (audio_control.rs).
2. The real killer on PipeWire 1.4.x: a *non-default* Audio/Source recorded via
`--target` never gets a driver assigned — the {source, recorder} group stays
orphaned (pw-top QUANT/RATE 0, driver-node None), so the RT process() callback
never fires and even an explicitly-selected mic is pure silence. PipeWire 1.6
drives any recorded source regardless, which is why the host worked on a 1.6
box but not the 1.4.10 Bazzite host.
Fix: advertise a high priority.session on the source so WirePlumber elects it as
the default source and keeps it driven. Reproduced with a faithful standalone
copy of the node on the same 1.4.10 daemon: no priority.session -> silent,
priority.session set -> audio. Only overrides WirePlumber's auto default; a
user's explicit default.configured.audio.source still wins.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuild configuration.md into a complete PUNKTFUNK_* reference (verified
against config.rs, the host.env templates, and the env read sites): core,
gamescope/session-following, compositor, video quality, gamepads, audio,
Windows host, auth/paths, perf tuning, diagnostics, and client-side knobs.
Rework bazzite.md: it now documents both Steam Gaming Mode (gamescope) and
the KDE Plasma desktop with auto-detect/session-following, attach vs managed,
and the Desktop screencast + kde-desktop-setup.sh input grant — previously it
only described the managed gamescope model.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework the client READMEs to be accurate and inviting to first-time
visitors, and fill in the gaps where crates and tools had none.
- Rewrite clients/{apple,android,decky} READMEs (features-first, trim
dense internal narrative; drop the stale "one session at a time" /
"renegotiation not implemented" section from the Apple README).
- Add READMEs for clients/{linux,windows,probe}, which had none.
- Add crate READMEs for punktfunk-host, punktfunk-core, pf-driver-proto.
- Add brief READMEs for tools/{loss-harness,latency-probe}.
- Fix packaging/README duplicate "Option B" heading (bootc -> Option C).
- Fix docs-site/README stale docs/ -> design/ reference.
- De-stale packaging/windows/drivers/pf-dualsense README (drop "M0 spike"
/ external-checkout framing; reflect in-tree workspace + shipped +
installer-bundled + multi-pad), keeping the driver-authoring lore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The OpenAPI info.version tracks the crate version, so the 0.4.2 bump
(3ba6bf4) left api/openapi.json stale at 0.4.1 and would redden
mgmt::tests::openapi_document_is_complete_and_checked_in. The API surface is
unchanged since the last regen (546b178 already refreshed it for the new
library endpoints), so this is the version string only.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The [workspace.package] version (inherited by every crate via version.workspace)
lagged at 0.4.1 — bump it to 0.4.2, the release being cut, and refresh the 8
workspace entries in Cargo.lock to match (CI builds --locked). This is a patch
release (Windows CI fixes + Apple gamepad UI); the canary base fallbacks stay
at 0.5.0, already one minor ahead of the 0.4.x stable line.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>