design/latency-reduction-2026-07.md T0.1/T0.2/T1.2/T1.3:
- T1.2 rate-capped front-loaded pacing: the paced overflow's budget is now
min(0.9x slack, overflow wire time at ~3x the live encoder bitrate)
(PUNKTFUNK_PACE_FACTOR, 0 = legacy deadline-only spread). A 300 KB-1 MB
frame's tail leaves in ~2-5 ms instead of smearing across ~15 ms at 60 fps;
GameStream schedule byte-identical (pins unchanged).
- T1.3 data-first wire order: packetize emits every block's data shards before
any parity (per-block parity pools keep all blocks' parity alive for the
second pass), so lossless completion stops waiting behind the parity tail.
EOF = last emitted packet; receiver already order-agnostic.
- T0.1 staged 0xCF: HostTiming gains an append-extensible per-stage tail
(queue/encode/pace us; seal+channel-wait derived as residual) - no cap bit
needed, old peers read the 13-byte prefix. Joined client-side into
Stats::host_{queue,encode,xfer,pace}_ms, the OSD detailed tier, and the
probe's report.
- T0.2 true on-glass present timing: VK_KHR_present_id/present_wait enabled
when supported; a PresentTimer waiter thread resolves each present id to
real visibility, replacing the submit-time display stamp (which undercounts
by up to a refresh and hides a silent-FIFO standing queue).
Validated on .21: core 185 + host 185 tests, pf-presenter 19, clippy
-D warnings across all five touched crates; loss-harness recovery curve
unchanged; C ABI harness round-trips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
macOS image copies rarely carry public.png — screenshots/Preview put TIFF on
the pasteboard, browsers add WebP/AVIF/GIF (observed live: TIFF+RTFD+WebP+AVIF+
8BPS+GIF, no PNG) — so the literal .png announce never fired and images
silently didn't sync. Announce image/png whenever a convertible image is
present (TIFF/HEIC alongside the native PNG check) and convert at serve time
via NSImage -> NSBitmapImageRep PNG (lazy, per design §3.5 — bytes still cross
only on a host paste).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
macOS image copies rarely carry public.png — screenshots/Preview put TIFF on
the pasteboard, browsers add WebP/AVIF/GIF (observed live: TIFF+RTFD+WebP+AVIF+
8BPS+GIF, no PNG) — so the literal .png announce never fired and images
silently didn't sync. Announce image/png whenever a convertible image is
present (TIFF/HEIC alongside the native PNG check) and convert at serve time
via NSImage -> NSBitmapImageRep PNG (lazy, per design §3.5 — bytes still cross
only on a host paste).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings origin's release prep (widgets App Store profile mapping + workspace
version 0.13.0) into the local line that landed the Linux stream sink, the
Windows client-only audio rework, the July security-audit fixes, cross-client
render-scale, and the widgets scheme. release.yml was byte-identical on both
sides; no other file overlap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirrors origin's 15233a68 (byte-identical release.yml, so it dedupes at the
next reconcile) and adds the shared PunktfunkWidgetsExtension scheme the
archive step builds with.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Client-side supersampling/downscaling: the client asks the host to render
and encode at chosen-resolution × scale (the host does no scaling) and the
presenter rescales the decoded frame to the display. >1 supersamples for
sharpness; <1 lightens the host GPU and the link. Default 1.0 = Native, the
prior behavior.
The geometry lives once in punktfunk_core::render_scale (multiply, preserve
aspect ratio, floor to even, clamp to the codec's per-axis ceiling — 4096
for H.264, 8192 otherwise), the Rust twin of the Apple client's
RenderScale.swift, consumed by the native session client, the presenter's
match-window path, the Windows/Linux settings UIs, Decky, and Android
(settings + host connect + unit test).
Implemented and platform-verified by the Apple-client-features session
(Linux+Android+Apple green there); the punktfunk-core wiring
(pub mod render_scale) is restored here after being lost in a working-tree
reconciliation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The low/medium findings from the July host+Windows security review, as
implemented in the audit session's working tree:
- Webhooks and library-art fetches refuse loopback/link-local/metadata
targets and no longer follow redirects (SSRF pivots from the privileged
host process).
- The paired-client rosters (/clients, /native/clients) move off the
streaming-client auth lane — one paired device could enumerate every other
device's name + fingerprint; only the bearer/loopback console keeps them.
- Device-name sanitizing extends to bidi/format control characters
(spoofable rendering) via the shared native_pairing::is_spoofy_char;
stream-marker quoting uses the same set.
- The SYSTEM service resolves powershell by its full System32 path —
CreateProcess checks the launching EXE's own directory first, so a planted
powershell.exe beside the host binary would have run as SYSTEM.
- The pf-vdisplay driver's opt-in file log moves from world-writable
C:\Users\Public to WUDFHost's own temp dir.
- GameStream pairing sessions are single-use (removed whatever the outcome).
- Uninstall also removes the pf_mouse driver-store entry (rider from the
virtual-HID-mouse work).
- openapi.json regenerated (hardened-config-dir doc wording).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of the field report (Android client, Windows host: no audio until
the user manually cycled Sound-output devices, then audio on BOTH PC and
phone): the WASAPI loopback captured whatever the default render endpoint
was at open time — not the wiring plan's chosen endpoint — the plan's
IPolicyConfig default-set is warn-only and racy, and nothing reacted to
mid-stream default-device changes.
- Explicit-endpoint capture: the capture thread opens the plan's
loopback_render by id, never "the default" (KEEP_DEFAULT preserves the old
default-capturing behavior with the echo guard).
- Client-only playback default: wiring_plan::plan(..., host_audio) prefers a
silent sink (Steam Streaming Microphone render side — loopback-validated,
silent on host) over real hardware, so stream audio plays on the client
only; PUNKTFUNK_HOST_AUDIO=1 restores real-hw-first (audible on the host).
The capture side auto-installs the Steam pair once per process when no
silent sink exists; open() handshake timeout 3s -> 30s to cover it, and a
handshake timeout now stops the detached thread (it used to run for the
process lifetime with the default still parked).
- Self-healing capture thread (wasapi_cap): outer capture_once loop
(Assert|Follow) with a ~1s watchdog on the default render id. A user
switch to a capturable endpoint is followed (their choice wins, audio on
both); a switch to a dud (cable/SSS/mic target) re-asserts the plan;
IPolicyConfig-denied converges to Follow instead of churning. Device
errors reopen with 2s backoff; only the FIRST open failure is fatal.
Zero-packets breadcrumb after 30s distinguishes broken-loopback from
quiet-desktop.
- Park/restore of the default playback device (audio_control):
wire_now(set_playback) parks the default on the loopback sink only for the
capture's lifetime (the mic pump passes false — it runs while the host is
idle); crash marker audio-default.prev + recover_orphaned_default() at
first wire; restore is skipped if the operator changed the default
themselves. A mic-default hygiene pass keeps VB-Cable installs audible and
never records the mic target as the restore target.
- Session-end park_audio_capture(): Windows DROPS the capturer (thread join
restores the default) instead of caching it; Linux keeps the parked
PipeWire thread. Composes with the stream-sink idle() hook at all three
park sites (idle is a no-op on Windows).
Verified: Linux (.21) clippy -D warnings + 176 punktfunk-host tests green
(incl. the new wiring-plan preference tests); Windows (.173) clippy with
nvenc,amf-qsv --all-targets -D warnings green at this exact tree.
On-glass winbox/Android validation still owed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merges 44b71e74 (see its message for the full design: the "Punktfunk Stream
Speaker" Audio/Sink claimed as the session's default output — the crackle
root fix for hardware-sink churn — plus the core-error liveness fix and true
5.1/7.1 capture) across the W1–W8 refactor that landed since the branch
forked:
- punktfunk1.rs was split on main: the audio_thread idle()/drain() hunks are
hand-ported into native/audio.rs (same three sites: reuse-drain comment,
encoder-fail park, end-of-thread park).
- pwinit moved to pf-capture (W6.2): the branch's crate::pwinit calls in
pw_thread and stream_sink.rs become pf_capture::pwinit.
- Kept main's log-tier demotions (stream-state + setup lines at debug)
inside the restructured pw_thread.
Resolution verified on Linux (home-worker-5): clippy -D warnings clean +
173 punktfunk-host tests green at the 691c064a-based equivalent ba5973a2;
re-verified at this base before landing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The eight W6 leaf crates hardcoded 0.12.0 instead of inheriting the
workspace version — switched to version.workspace = true so the next bump
is one line again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The .ipa now embeds PunktfunkWidgetsExtension (io.unom.punktfunk.widgets), a
second distribution artifact, so the manual-signing exportArchive must map its
App ID to its own App Store profile — without it the export fails with "no
profile for io.unom.punktfunk.widgets". Requires the "Punktfunk iOS Widgets
App Store Distribution" profile on the runner.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brings the day's landed work — clipboard (pf-clipboard + macOS client, ABI v8),
the perf/first-frame-latency merge (driver proto v4, Welcome-time display prep,
in-place resize), and the W7/W8 reconciliation — into the local main that had
diverged with the SDK-publish commits.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The W7/W8 client reconciliation committed the CONSUMERS of the render-scale
setting and the toggle-fullscreen notification (ContentView/FullscreenController
et al.) while their definitions were still uncommitted working-tree state from
the Apple-features effort — apple.yml red on main (run 10657). Lands the two
definition files (PunktfunkShared/RenderScale.swift + the DefaultsKeys additions)
so main builds; the rest of that effort's WIP stays in its working tree.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rename-followed perf hunks still said crate::win_display:: (the pre-W6
layout) — point them at pf_win_display::win_display:: and widen the four
helpers they call cross-crate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The W-refactor extracted VdisplayDriver into manager/driver.rs (the merge
resolution assumed it deleted) — carry the v4 changes there: open() returns the
driver's protocol version, update_modes() default-errs to the re-arrival
fallback. wait_target_departed goes pub for the manager's cross-crate call.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the first-frame-latency branch (P0.1 transition tracing, P1.1/P1.2
Welcome-time display prep, P2 in-place resize; pf-driver-proto v3 -> v4 with
IOCTL_UPDATE_MODES) onto current main. The branch predates the W6.2/W7 splits,
so git's rename detection carried most of it into the moved crates
(pf-capture idd_push, pf-vdisplay manager/pf_vdisplay, pf-win-display,
pf-driver-proto, the driver workspace) and the punktfunk1.rs remainder was
re-homed by hand:
- native/handshake.rs: welcome/start trace marks + the Welcome-time display
prep spawn (the prep thread BECOMES the stream thread; hand-off via a
SyncSender<SessionContext>). negotiate() gains bringup/quit/stop and returns
the PrepHandle.
- native.rs: bringup/resize_ms creation + the stop/quit flags hoisted BEFORE
the handshake (the close watcher splits: flags pre-handshake, lifecycle
events post-handshake where `hello` exists); punch_done stamp; the data
plane adopts the prep thread's result or builds inline.
- native/stream.rs: SessionContext/SendStats carry the trace; send_loop
finishes it on the first video packet; the resize path gains the in-place
fast path (try_inplace_resize) with the full rebuild as fallback, restructured
so both share the post-rebuild bookkeeping; prepare_display/PreparedDisplay/
PrepHandle; build_pipeline(+retry) thread the stage marks.
- session_status/mgmt: ttff_ms + last_resize_ms per session (union with the
lifecycle-events fields main added to the same spots).
- pf-capture: Capturer gains capture_target_id() + resize_output() defaults.
- pf-vdisplay manager: perf's faster activation poll (60x50ms) + the settle
floor before the PnP sweep, on main's knobs/no-trait shape.
Also: packaging/windows/build-gamepad-drivers.ps1 is ASCII again (an em-dash
from the pf-mouse work tripped windows-host.yml's locale-safety gate on main).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- package.json: drop private; point main/types/exports/bin at a tsc-built dist/;
add publishConfig (unom/npm registry), files, repo metadata, and the
MIT OR Apache-2.0 license; effect becomes a peerDependency (shared instance).
- tsconfig.build.json: emit dist/ JS + .d.ts (bun shebang preserved on the bin).
- .npmrc: map the @punktfunk scope to the registry (no token committed).
- sdk-publish.yml: publish on sdk-v* tags or manual dispatch, reusing the
REGISTRY_TOKEN secret; typecheck/test/build/tag-matches-version gate.
- README: Install section for consumers.
Verified: build green, frozen lockfile stable, bun publish --dry-run packs
@punktfunk/host@0.1.0 (dist + README only) to the unom registry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add sdk/examples/virtualhere-dualsense.ts — bind a real USB DualSense (shared
from the couch via VirtualHere USB-over-IP) to the host for the length of each
connection and release it after, for full gyro/touchpad/adaptive-trigger/USB-
rumble passthrough instead of the emulated pad. Brackets on client.connected/
disconnected and releases the pad on SIGTERM for a clean runner stop.
Document it in the Events & hooks page with a zero-code hooks.json variant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The W6.2 pf-vdisplay extraction (27a5d8da) committed six spots that the pinned
rustfmt (rust-toolchain.toml = 1.96.0) reformats — a short closure collapse and
chain-call wrapping. No semantic change; makes cargo fmt --all --check green so
the combined push does not land CI red on pre-existing format drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
origin/main landed the shared clipboard (design/clipboard-and-file-transfer.md) while
this branch split quic/msgs.rs -> quic/{caps,control,...} and client.rs ->
client/{mod,control,worker,pump,planes,...} (W7) and deleted the two monoliths. The
feature had modified both deleted files, so its delta is re-applied onto the split
instead of resurrecting the monoliths:
- HOST_CAP_CLIPBOARD -> quic/caps.rs
- MSG_CLIP_* / CLIP_* consts, the six Clip*
structs, and their encode/decode impls -> quic/control.rs (beside the clock codecs)
- CtrlRequest::{ClipControl,ClipOffer} +
Negotiated.host_caps -> client/control.rs
- WorkerArgs.{clip_event_tx,clip_cmd_rx} -> client/worker.rs
- CLIP_EVENT_QUEUE -> client/planes.rs
- NativeClient clip fields, the 7 clip_* /
host_caps / next_clip methods, connect()
channel wiring -> client/mod.rs
- the control-task encode/decode arms and
the clipboard-task spawn -> client/pump.rs
Cargo.lock reconciled (adds pf-clipboard), punktfunk-host/Cargo.toml unions the W6
pf-* subsystem deps with pf-clipboard, and include/punktfunk_core.h is the cbindgen
union (clipboard + rumble C-ABI). punktfunk-core builds --all-features and its 174
lib tests pass, including quic::tests::clip_loopback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Break the 1747-line clients/android/native/src/decode.rs into a decode/ directory
module (mod.rs + 5 concern submodules):
- decode/setup.rs : codec creation + low-latency config + thread/frame-rate
tuning + HDR static-info encode
- decode/display.rs : DisplayTracker + render-callback registration + HDR dataspace
- decode/latency.rs : realtime clock + decoded-pts / user-flags stat recording
- decode/sync_loop.rs : the synchronous poll decode loop (+ feed/drain) — moved WHOLE
- decode/async_loop.rs : the event-driven async decode loop (+ helpers) — moved WHOLE
decode/mod.rs keeps the consts, DecodeOptions, and the `run` entry point + the
`codec_mime`/`codec_label` re-export, so every crate::decode::X path stays byte-stable.
The module has no decoder struct (free functions + small types), so both decode loops
move byte-for-byte and their separately-inlined received-stat recording is NOT unified.
16 helper fns/types became pub(super) for sibling access; zero field bumps. lib.rs
unchanged (`#[cfg(target_os="android")] mod decode;` resolves to decode/mod.rs).
Verified: cargo-ndk check (aarch64-linux-android, clean) + the gradle cargoNdkDebug
build (arm64-v8a / armeabi-v7a / x86_64). On-device runtime re-verification still owed
per the plan (the two decode loops are a hot path).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the macOS `FullscreenController` (NSViewRepresentable that drives native
fullscreen) into its own FullscreenController.swift, and `ApprovalRequest` (the
pending-trust-decision value type) into ApprovalRequest.swift, out of the
1041-line ContentView.swift. Both were file-`private`; dropped to internal so
ContentView (same module) still references them across files. StreamView*.swift
and the connection body are untouched. Pure move; no behavior change.
Verified: `swift build` (macOS) — Build complete.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Break the 2513-line pf-presenter/src/vk.rs into a vk/ directory module (mod.rs +
6 concern submodules), keeping ALL type definitions in vk/mod.rs so every submodule
(a descendant of vk) sees the Presenter/OverlayPipe/etc. private fields with no
field bumps:
- vk/setup.rs : Presenter::new + device/format/present-mode selection
- vk/present.rs : the per-frame present path (present + CSC record + AVVkFrame
sync) — HOT PATH, moved whole
- vk/reconfig.rs : swapchain recreate/resize + HDR reconfiguration
- vk/resources.rs : video-image/staging (re)build + Retired-frame destruction
- vk/overlay_pipe.rs: the presenter-side overlay composite pipeline
- vk/gpu.rs : memory allocation, image barriers, geometry helpers (+ tests)
vk/mod.rs keeps FrameInput/Presenter/OverlayPipe/VideoImage/Staging/Retired/HwCtx*
+ the public accessors + Drop. Methods/free-fns a sibling submodule calls became
pub(super) (~18); zero field bumps, zero re-exports (Presenter/FrameInput never
leave mod.rs). lib.rs unchanged (`pub mod vk;` resolves to vk/mod.rs). The moved
overlay shader include_bytes! gained one `../` for the deeper dir. Pure move; no
behavior change; the hot present path keeps only static pub(super) calls (inlinable).
Verified both platforms: Linux (home-worker-5) clippy -p pf-presenter
(--all-targets -D warnings) + test; Windows (winbox, ASCII CARGO_HOME) clippy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Break the 1212-line pf-console-ui/src/shell.rs into a facade + shell/ subdir
(shell.rs stays the parent; `mod render;` resolves to shell/render.rs):
- shell/render.rs : the per-frame screen compose/transition path (Shell::render
+ the LayerEnv paint helper)
- shell/overlays.rs : the modal overlays (Shell::draw_overlays + draw_takeover)
- shell/tests.rs : the inline #[cfg(test)] module, extracted verbatim
The Shell struct + its public API + draw_aurora() stay in shell.rs (both children
reach draw_aurora + the private fields as descendants — no bumps). Sole visibility
change: draw_overlays -> pub(in crate::shell) (its caller Shell::render is now a
sibling). Zero re-exports needed (Shell/ConsoleOptions never leave the root). Pure
move; no behavior change.
Verified both platforms: Linux (home-worker-5) clippy -p pf-console-ui
(--all-targets -D warnings) + test; Windows (winbox, ASCII CARGO_HOME) clippy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
punktfunk-core client/rumble.rs: a per-connection policy engine consumes seq-gated wire
updates and emits EFFECTIVE actuator commands — re-emits on renewals (duration APIs stay
re-armed), self-silences at the v2 lease, a UNIFORM 1 s legacy-host staleness replacing the
per-platform zoo (Apple 1.6 s / Android 60 s / SDL 1.5 s / Deck 1 s), quirk-declared
actuator keepalives (Deck 40 ms + LSB dedupe-defeat jitter), and one stop per buzzing pad
on connection close. Per-pad mailbox semantics: a stalled embedder wakes to ONE current
command, and a stop can structurally never be the update an overflowing queue drops.
New API/ABI: NativeClient::{next_rumble_command,set_rumble_quirks} +
punktfunk_connection_next_rumble_cmd/_set_rumble_quirks (next_rumble/next_rumble2 stay for
un-migrated embedders; both consumers are fed). Migrations DELETE the platform forks:
pf-client-core loses RumbleState + the Deck keepalive loop + LEGACY_RUMBLE_CEILING_MS and
physically silences a slot at close; Android loses the 60 s legacy one-shot (backstop
repack, cancel-on-zero); Apple loses envelopeDeadline + sessionStaleSeconds + both tick
watchdogs (CoreHaptics realization untouched; mac xcframework rebuilt locally).
design/rumble-root-fix.md par. D. Engine 10/10 unit tests; core tests 176 Linux / 175
Windows + clippy -D warnings; swift build + RumbleTuningTests; Kotlin + android-native
compile green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
B: PadFeedback.game_drove -> rumble_drove, keyed on vibration-asserting reports — an
LED/adaptive-trigger stream can no longer feed the abandoned-rumble force-off while a
coalesced stop never re-asserts (the confirmed unbounded stuck-ON path). C: Linux parity —
every UHID backend now arms the shared watchdog (Steam Input drives these pads over hidraw
with Windows abandonment semantics) and the uinput mixer force-stops abandoned
infinite-replay FF effects (FfState, unit-tested). Shared PUNKTFUNK_RUMBLE_IDLE_MS hatch
(0 = off; non-zero floored above SDL's ~2 s rumble resend).
A: PadShm v2.1 — a 1024 B tail extension carrying an 8-slot lossless output-report ring,
feature-negotiated via zeroed reserved fields (out_ring_ver; deliberately NO
GAMEPAD_PROTO_VERSION bump — mixed generations degrade to the legacy latest-report slot
instead of failing closed). The pf-dualsense driver dual-writes both planes
(publish_output); the host's shared OutputDrain drains oldest->newest with a torn-read
recheck and an overflow->resync path (PadFeedback.resync force-stops + re-arms dedups).
pf-umdf-util grows a min_data_size map fallback. Ds*Feedback.fresh removed (dead).
design/rumble-root-fix.md par. A-C. Verified: pf-inject tests+clippy Linux+Windows (53/53
on winbox incl. the stop-coalesce repro); drivers ws check+clippy on the CI runner.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Break the 1974-line pf-client-core/src/video.rs into flat sibling modules
(matching the crate's video_d3d11.rs / video_pyrowave.rs convention), leaving
video.rs as the contract + Decoder dispatch facade:
- video_color.rs : ColorDesc + csc_rows (the Y'CbCr->RGB matrix)
- video_software.rs : the libavcodec/swscale SoftwareDecoder
- video_vaapi.rs : the Linux-only VAAPI/DRM-PRIME backend (mod is cfg(linux))
- video_vulkan.rs : the FFmpeg Vulkan Video backend
Every crate::video::X / video::X path stays byte-stable (ColorDesc + csc_rows
re-exported from video.rs; frame POD, VulkanDecodeDevice, QueueLock, Decoder,
decodable_codecs*, ffmpeg_codec_id, fourcc/drm_fourcc_for all stay in video.rs).
Code-driven placements: averr, AVERROR_EAGAIN, frame_is_keyframe stay in video.rs
(shared by all three decoders); DrmFrameGuard's field + drm_fourcc_for +
Software/Vaapi/VulkanDecoder ctors/decode became pub(crate) (sibling access);
the test module split three ways (software tests need private decoder internals).
Pure move; no behavior change.
Verified on Linux (home-worker-5): cargo clippy -p pf-client-core (default
[pyrowave] + --no-default-features, --all-targets -D warnings) + cargo test.
Windows verify BLOCKED environmentally: pf-client-core -> sdl3 build-from-source
-> CMake/CL.exe fails on winbox's non-ASCII home path (fails the baseline too,
independent of this split); the split's Windows surface (facade cfg(windows) bits
+ video_d3d11) is verbatim-preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The NSPasteboard bridge completing Phase 1 (design/clipboard-and-file-transfer.md
§5) — with the host backends on this branch, copy/paste now crosses the wire in
both directions on macOS. Lazy in both directions:
- PunktfunkConnection grows the clipboard plane: its own clipboardLock (close()
joins it like the other pullers), hostCaps/hostSupportsClipboard from the
Welcome, the typed ClipEvent vocabulary, and the six ABI wrappers
(clipControl/clipOffer/clipFetch/clipServe/clipCancel/nextClipboard — borrowed
event payloads copied out before the next poll).
- ClipboardSync (PunktfunkKit, macOS-only): one drain thread bridging
NSPasteboard.general ↔ the QUIC clipboard plane. Local copies announce format
lists via a 500 ms changeCount poll (+ immediate on app activation); bytes
leave only on a host FetchRequest, answered from the live pasteboard and
seq-guarded against staleness. Host copies install one NSPasteboardItem whose
data provider fires only when a Mac app actually pastes, then blocks its
provider thread (never main) on a 10 s-bounded fetch. Concealed/Transient
pasteboards (password managers) are never announced; our own writes are
changeCount-suppressed (§3.4). Text/RTF/HTML/PNG; files ride Phase 2.
- UI: per-host "Share clipboard with this host" toggle (StoredHost.clipboardSync,
optional for saved-JSON forward-compat — wire-format tests extended), a
mid-session Share/Stop Sharing Clipboard item in the Stream menu (⌃⌥⇧C,
greyed without HOST_CAP_CLIPBOARD), SessionModel owning the lifecycle
(start on streaming after the trust gate, drain joined off-main on teardown).
swift build + swift test green (macOS). Requires the ABI v8 xcframework
(scripts/build-xcframework.sh).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turn the 2674-line client.rs into a client/ directory module (mod.rs facade +
8 submodules) behind glob/`use self::` re-exports, so crate::client::X paths
(NativeClient, ProbeOutcome, AudioPacket, display_hdr_env_override) stay
byte-stable. Leaf lifts: frame_channel.rs (the FIFO hand-off + jump-to-live
consts + DecodeLatAcc), recovery.rs (RfiRecovery loss-range detector),
probe.rs (ProbeState/ProbeOutcome), planes.rs (side-plane queues + AudioPacket),
control.rs (CtrlRequest/Negotiated), worker.rs (WorkerArgs + reject_from_close),
pairing.rs (NativeClient::pair). The per-frame pump moves WHOLE as a plain
`pub(super) async fn run_pump` (was worker_main) — the only edit is the
signature line: no trait object, no Box, no per-frame allocation or indirection.
NativeClient + its public impl + Drop + the cfg-gated thread-pin/hot-tid helpers
stay in the facade. Visibility bumps are pub(crate) (struct + each field for
WorkerArgs/Negotiated/ProbeState; FrameChannel + each method); reject_from_close
is pub(crate) (sibling access). No behavior change.
Verified: Linux clippy (quic + no-default, -D warnings) + full cargo test;
Windows clippy (both) + test --lib; macOS clippy (apple thread-pin variant) +
165 lib tests. On-glass jump-to-live + ABR smoke still owed (pump is a pure
relocation, so this is a formality) per the plan's pump gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Break the 1302-line quic/msgs.rs into four flat sibling modules behind the
quic facade's glob re-exports, so every crate::quic::X path stays byte-stable:
handshake.rs (Hello/Welcome/Start + codecs), caps.rs (video-cap bits, codec &
chroma negotiation, ColorInfo), control.rs (typed CTL_MAGIC messages + frame),
pairing.rs (SPAKE2 ceremony messages). msgs.rs is deleted; quic/mod.rs gains the
four `mod`/`pub use` lines and the `pub use crate::reject::*` hoist (moved up from
msgs.rs). Pure move; no wire-format or behavior change. Private helpers
(truncate_to, put_bytes, get_bytes) stay with their sole callers; no visibility
changes.
Verified both platforms from clean HEAD snapshots: Linux clippy (quic +
no-default, -D warnings) + full cargo test (157 lib + integration); Windows
clippy (both) + test --lib (156).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wayland.rs (pipe2/poll on the paste pipes) and mutter.rs (fcntl un-nonblocking
on the transfer fd) reference libc:: inline — caught by the Linux leg.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The host half of the shared clipboard (design/clipboard-and-file-transfer.md §4),
ported from feat/shared-clipboard (6bd8c18b) into the post-W6 crate shape: the
backends land as a pf-clipboard subsystem crate (the pf-inject/pf-capture
pattern) instead of growing punktfunk-host back out, and the ~340-line
punktfunk1.rs integration is re-implemented against the native.rs/control.rs
split that replaced it.
pf-clipboard:
- host::wayland — ext-data-control-v1 (KWin / wlroots / Sway / Hyprland).
- host::mutter — GNOME via Mutter's *direct* org.gnome.Mutter.RemoteDesktop
clipboard (no data-control at any GNOME version; the xdg portal needs an
interactive grant a headless host can't answer).
- host::windows + host::winfmt — Win32 clipboard on a hidden message-loop
window: WM_CLIPBOARDUPDATE listener + OLE delayed rendering (WM_RENDERFORMAT)
for text / CF_HTML / RTF / PNG.
- host::session — the backend-agnostic coordinator bridging HostClipboard to
the QUIC clipboard plane (offers, fetch accept-loop, remote offers, pastes).
- A portable facade (policy / enabled / cap_advertised / ClipCoordCmd / start /
spawn_decline_loop) so the orchestrator compiles cfg-free on every platform;
ClipCoordCmd moves into the crate (it was host-owned before).
punktfunk-host glue:
- handshake.rs advertises HOST_CAP_CLIPBOARD via pf_clipboard::cap_advertised.
- serve_session starts the coordinator (gated on a real compositor — the
synthetic source stays out of the session clipboard) and spawns the
CLIP_FETCH_UNAVAILABLE decline loop when the policy is on but no backend bound.
- control.rs gains the ClipControl/ClipOffer arms + the host-offer forward
branch, and the e2e session test (cap advertise → ClipState ack with
BACKEND_UNAVAILABLE → fetch decline) rides in native.rs's tests.
Still opt-in default OFF (PUNKTFUNK_CLIPBOARD). Remaining: the macOS client
(design §5) — then this becomes user-visible.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turn transport/udp.rs into a udp/ directory module: the cross-platform core
(UdpTransport, is_transient_io, spawn_data_punch, the Transport trait impl) stays
in mod.rs; the platform batched-I/O backends move to udp/{linux,windows,apple}.rs.
The trait impl is kept whole -- its per-OS send_batch/send_gso/recv_batch methods
become cfg-gated one-line delegators to pub(super) free fns that take &UdpTransport
(byte-identical bodies, self -> t). transport/mod.rs is unchanged (re-exports still
resolve; udp/mod.rs re-exports windows::send_uso_all). No behavior change.
Module gates: linux = any(linux, android) (Android uses sendmmsg/recvmmsg via its
bionic binding); windows = windows (USO); apple = all(unix, not(any(linux,android)))
(recvmsg_x on Darwin, recv-loop on BSD). GSO stays linux-only.
Verified on all four target families from clean HEAD snapshots: Linux clippy
(quic + no-default, -D warnings) + full test; Windows clippy (both) + test --lib
(156); macOS check (apple recvmsg_x path); aarch64-linux-android check (android_mmsg).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The portable shared-clipboard plane in punktfunk-core, all behind the `quic`
feature (design/clipboard-and-file-transfer.md §3):
- Control messages 0x40–0x44 (ClipControl / ClipOffer / ClipFetch...) and the
HOST_CAP_CLIPBOARD capability bit, negotiated in the Welcome caps.
- Per-transfer QUIC bi-streams ("PKFs" magic) for lazy fetch of offered content,
with ClipFetchHdr status/size framing (quic::clipstream).
- The §3.5 portable wire-MIME vocabulary (text/plain;utf-8, text/html, text/rtf,
image/png) shared by both ends.
- Client-side clipboard task (client.rs) + C ABI surface bumped to v8 (abi.rs,
regenerated include/punktfunk_core.h).
- Loopback transport tests (quic::tests).
No OS clipboard integration yet — that is the host backends (Phase 1/3) and the
macOS client (Phase 1).
Ported from feat/shared-clipboard (af3a7d8c, pre-W6 base) onto current main;
three deliberate deviations from the original commit:
- ABI v6 → v8: main took v6 (reanchor gate) and v7 (typed connect rejection)
in the meantime; the clipboard C surface re-lands as v8.
- CLIP_CANCELLED_CODE 0x60 → 0x70: main's pairing-rejection close codes claimed
the 0x60–0x67 block; the vocabularies stay disjoint on purpose.
- Negotiated.host_caps coexists with main's 6-tuple host_caps plumbing: main
needs the worker-local copy for gamepad snapshots, the clipboard path needs it
across ready_tx to build the NativeClient handle (punktfunk_connection_host_caps).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turn the 1446-line packet.rs into a packet/ directory module (mod.rs facade
+ header/packetize/reassemble/tests) behind glob re-exports, so every
crate::packet::X path stays byte-stable. Pure move: the header consts +
PacketHeader -> header.rs; Packetizer -> packetize.rs; the Reassembler cluster
(kept WHOLE -- disjoint-borrow hot path) + loss-window consts -> reassemble.rs;
the inline #[cfg(test)] block -> tests.rs. Sole visibility change:
LOSS_WINDOW_NS -> pub(super) (a test imports it). No behavior change.
Verified on both platforms from a clean HEAD snapshot: Linux clippy
(--features quic and --no-default-features, --all-targets -D warnings) + full
cargo test; Windows clippy (both feature sets) + cargo test --lib (156 pass).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop Orval for the first-party @effect/openapi-generator (OpenAPI 3.1 ->
Effect Schema + a typed HttpClient client) and bump effect 3.19 ->
4.0.0-beta.98. Port the hand-written surfaces to the v4 API (Result over
Either, Context.Service, Codec, Literals/Union arrays, Stream/Schedule/
Effect renames). Transport (CA-pinning fetch) and the reconnecting SSE
source are kept intact.
Make the SDK approachable for non-Effect users:
- Add pf.api.* on the Promise facade: the generated client surfaced as
typed, Promise-native methods (await pf.api.listPairedClients()), so REST
calls are autocompleted and checked instead of stringly-typed
pf.request(method, path, body) + `as` casts. Zero-drift veneer over
make(httpClient), backed by the same pinning fetch. pf.request stays as
the untyped escape hatch.
- Re-tier examples into a 1-4 complexity ladder, rewritten onto pf.api.*
(the typed payloads caught a wrong `launch` shape in provider-sync);
the Effect example is labelled advanced. Add examples/ to tsconfig so
they are typechecked (stops rot).
typecheck + 19 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vdisplay.rs + vdisplay/* (the per-compositor Linux backends — KWin zkde-screencast,
wlroots swaymsg, Mutter RemoteDesktop, Hyprland — and the Windows IddCx/pf-vdisplay
driver backend, behind one VirtualDisplay trait; the mode-conflict admission
registry, the display policy/identity/custom-preset state, and the session-env /
gamescope routing) move into crates/pf-vdisplay (plan §W6). The DDC/CI panel-power
control (used only here) and the KWin zkde protocol XML move with it. This
completes the host-crate decomposition: capture, encode, inject, and vdisplay are
now four subsystem crates over the shared leaves, and punktfunk-host is the
orchestrator (serve/supervisor + native + gamestream + mgmt).
Coupling breaks (all down-only, cargo-tree acyclic):
- capture::dxgi identity -> pf_frame::dxgi; win_display/monitor_devnode/
console_session_mismatch -> pf-win-display leaf; can_open_another_session ->
pf-encode (the NVENC session-budget admission gate — acyclic peer edge).
- The registry's DisplayCreated/DisplayReleased emits into the host SSE event bus
invert to a leaf hook: pf-vdisplay emits a neutral DisplayEvent to a
host-registered DISPLAY_EVENT_SINK, so it never reaches the orchestrator's
events module.
- The IddCx driver module is renamed pf_vdisplay -> driver (its old name collided
with the crate name through the host's `mod vdisplay` shim glob).
The host keeps `mod vdisplay { pub use pf_vdisplay::* }` so every crate::vdisplay::*
path (serve/mgmt/native/the capture FrameChannelSender seam) is unchanged; the
heavy deps (wayland/ashpd/tokio + the zkde protocol) moved with the crate.
Co-authored: a fail-closed IOCTL-reply-length security fix (reject short/zeroed
pf-vdisplay driver replies before trusting protocol_version/target_id/wudf_pid/luid,
security-review 2026-07-17) rides this commit in the moved driver module.
Verified: Linux clippy -D warnings (pf-vdisplay + host nvenc,vulkan-encode,pyrowave
--all-targets) + pf-vdisplay 63/63 + host 167/167 tests; Windows clippy -D warnings
(pf-vdisplay --all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
inject.rs + inject/* (the per-OS injectors — wlroots virtual-input, KWin
fake_input, libei/reis, gamescope-EI on Linux; SendInput on Windows — plus the
virtual-gamepad HID stack: DualSense/DualShock4/Switch Pro/Steam Controller/Deck
over uhid/usbip and the Windows UMDF drivers, the proto codecs, the injector
service, and the uhid manager) move into crates/pf-inject behind the
InputInjector trait (plan §W6). It consumes punktfunk_core::input (the neutral
GamepadEvent/InputEvent vocabulary, moved to core in W5) + the pf-driver-proto
wire contract, and reaches pf-capture only for the Windows gamepad-channel
WUDFHost check + the resident-mouse compose-kick hook.
The one inject->vdisplay coupling (the libei gamescope-EI backend needs the EIS
relay socket path) is broken via a leaf: gamescope_ei_socket_file moves to
pf-paths as the shared contract — the gamescope producer (host vdisplay) keeps
its session-env-lock wrapper around it, the libei consumer (pf-inject) reads it
directly post-retarget. The host keeps a `mod inject { pub use pf_inject::* }`
shim so every crate::inject::* path (the native/gamestream input planes + devtest)
is unchanged; the heavy input deps (wayland/reis/xkbcommon/usbip + the KWin
fake-input protocol XML) moved with the crate.
Verified: Linux clippy -D warnings (pf-inject + host nvenc,vulkan-encode,pyrowave
--all-targets) + pf-inject 69/69 + host 230/230 tests; Windows clippy -D warnings
(pf-inject --all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The final piece of the lid-closed field report (fixes 1-3: 3d9b3290;
prerequisites: 85dd2bb0 pf-mouse, 845a9760 leaf primitives, 94ca4041
pf-capture hook): the IDD first-frame gate can only pass if DWM composes
at least one frame, and a lid-closed/locked/idle machine is exactly the
state Windows has decided not to compose in. The SendInput compose kick
is conditional on this process's context — wrong session → wrong input
queue; secure desktop → blocked; display powered off → no wake. A report
from the resident pf-mouse HID device is REAL input to win32k:
session-independent, secure-desktop-proof, wakes a powered-off display,
counts as user presence. Parsec-class mechanism, and semantically honest
— a remote user starting a stream IS a user arriving at this machine.
- hid_kick(rect, bounds): newest-wins kick slot + condvar, serviced by
the keeper thread that owns the ONE process-wide VirtualMouse (a
second open() would squat the bootstrap mailbox). Not-ready (opt-out,
driver missing, not yet attached) returns false → the capture crate
falls back to SendInput.
- perform_kick: park the pointer at the target display's center, dwell
35 ms (Stage-W3: DWM samples cursor position at the next vsync tick;
the gaps also keep reports from coalescing in the driver's 8 ms
timer), wiggle ~2 px, restore the saved position. Desktop→HID
coordinates normalize against pf_win_display::desktop_bounds() (CCD
union — correct from any session, unlike per-session GDI metrics).
- ensure_resident registers the hook (pf_capture::HID_COMPOSE_KICK) —
the one-way-edge inversion: pf-capture never reaches into inject.
- keeper loop: condvar wait (250 ms tick) so a kick executes
immediately, not at the next tick; publishes MOUSE_READY from
driver_proto.
Paired with pf-frame's DisplayWakeRequest (held by the capturer from
before the first-frame gate): the power request keeps the display from
going dark mid-session, the HID kick wakes it when it already is.
Verified on winbox: combined-tree cargo check + clippy for
punktfunk-host, pf-capture, pf-frame, pf-win-display all EXIT 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
capture/linux (PipeWire portal) + capture/windows (IDD direct-push: dxgi
mechanics, idd_push + submodules, synthetic_nv12) + pwinit move into
crates/pf-capture behind the Capturer trait + synthetic sources (plan §W6).
The crate speaks pf-frame (CapturedFrame/PixelFormat + the DXGI identity),
pf-zerocopy (CUDA import), and the pf-win-display leaves, and NEVER pf-encode —
the capture->encode edge is one-way. This completes the deliberate capture/encode
crate split (the invasive path the plan had merged into one pf-media): capture
and encode are now separate subsystem crates sharing only pf-frame.
Four seams keep the capturer off the orchestrator:
- VirtualOutput is EXPLODED into primitives (remote_fd/node_id/preferred_mode/
keepalive) by the host facade, so pf-capture never depends on the vdisplay type;
- FrameChannelSender: the sealed-channel delivery is a Send+Sync closure the host
facade builds from the pf-vdisplay control device + send_frame_channel IOCTL and
hands in; ChannelBroker holds the closure instead of the control HANDLE (the
whole-desktop handle-duplication security boundary is byte-for-byte unchanged);
- console_session_mismatch + desktop_bounds live in pf-win-display (leaf peers);
- pwinit moves here (audio caller -> pf_capture::pwinit).
The host keeps capture.rs as a thin BRIDGE: it re-exports the vocabulary + capturer
types (every crate::capture::* path is unchanged) and keeps open_portal_monitor /
capture_virtual_output, which resolve the ZeroCopyPolicy + FrameChannelSender and
call into pf-capture. verify_is_wudfhost + install_gpu_pref_hook are re-exported
(the gamepad-channel bootstrap + the main.rs subcommand consume them).
Co-developed: the resident-HID-mouse compose-kick hook (HID_COMPOSE_KICK + the
HID-first cursor kick + _display_wake) rides this commit into pf-capture; the host
mouse_windows registration side lands separately on top.
Verified: Linux clippy -D warnings (pf-capture + host nvenc,vulkan-encode,pyrowave
--all-targets) + host tests 299/299; Windows clippy -D warnings (pf-capture
--all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three leaf-crate additions the IDD-push capturer (pf-capture, plan §W6 C6)
builds on — committed ahead so the capture-crate extraction and the HID
compose kick can land on top:
- pf-frame session_tuning::DisplayWakeRequest — RAII PowerCreateRequest/
PowerSetRequest(PowerRequestDisplayRequired + SystemRequired), the
service-grade 'someone is watching this screen' assertion (visible in
powercfg /requests), held for a capture session so the console cannot
drop into display-off mid-stream. Object-lifetime, unlike the
thread-bound ES_* flags in on_hot_thread. Prevention only: no power
request turns an already-off display back on — that wake is input's
job (the virtual-mouse compose kick).
- pf-win-display win_display::desktop_bounds() — the virtual-desktop
bounds as the union of every ACTIVE CCD path's source rect. From the
CCD database (global), NOT GetSystemMetrics (a per-session view), so
a non-console-session host still aims HID absolute coordinates at the
console's real layout.
- pf-win-display console_session_mismatch() — the session guard from
3d9b3290, copied into the leaf so pf-capture reads it as a peer
instead of reaching into the orchestrator (relocation authored by the
W6 extraction session).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Headless Windows hosts (no dongle) stream an INVISIBLE cursor: with no
pointing device present win32k reports SM_MOUSEPRESENT=0 and DWM never
composites a pointer into the pf-vdisplay frame, even though SendInput
moves it. Keep ONE virtual HID mouse devnode alive for the host's
lifetime — the Sunshine/Parsec-class fix, zero client changes.
- pf-mouse: UMDF2 HID minidriver, one fixed identity (PF:MO 5046:4D4F,
obviously virtual, nothing fingerprints it), one 8-byte input report
(5 buttons + absolute 15-bit X/Y + wheel + AC-pan). Transport is the
sealed pad channel verbatim (Global\pfmouse-boot-0 mailbox + unnamed
MouseShm DATA section) so pf-umdf-util's audited layer serves it
unchanged; report delivery is event-driven (idle = no HID traffic).
- host: inject::mouse_windows — VirtualMouse (SwDeviceCreate'd devnode +
channel), ensure_resident() keeper thread started by every
InjectorService (process-wide, PUNKTFUNK_NO_VIRTUAL_MOUSE opts out),
vmouse-spike on-glass validation (cursor sweep via HID reports).
- proto: mouse module (magic, boot-name, identity, report layout,
unit-tested input_report packing).
- SwDeviceProfile grows container_tag so the mouse's ContainerId family
(PFMO) never groups with a pad's (PFDS) in the Devices UI.
- packaging: pf-mouse rides the gamepad-driver build + install pipeline
(build-gamepad-drivers.ps1, windows-drivers.yml, driver install
--gamepad picks up every staged .inf).
On-glass validated on winbox: devnode + HID child bind, SM_MOUSEPRESENT=1
with no physical mouse, cursor sweeps via HID reports (vmouse-spike).
This work was implemented in a parallel session; committed here as the
build prerequisite for the HID compose kick that follows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report (Windows laptop, lid closed, Tailscale): v0.12.0's activation
fix works — the pf-vdisplay target activates in ~200ms — but the session
still dies at the first-frame gate: 'driver_status=1 but no frame published
within 4s'. Triage showed three independent blind spots; this names all of
them at their source instead of guessing downstream:
- pf-win-display: decode ChangeDisplaySettingsExW failures (-1 FAILED — a
display write rejected, the wrong/remote-session signature — vs -2 BADMODE,
which the old 'mode not advertised?' text conflated), and WARN on every
non-zero SetDisplayConfig rc in the CCD isolate even when verification
passes vacuously (the lid-closed case: nothing else active, so the INFO
swallowed rc=0x5 ERROR_ACCESS_DENIED while the load-bearing COMMIT_MODES →
ASSIGN_SWAPCHAIN re-commit silently never applied). Access-denied rcs get
the remedy appended (console session / installed service).
- host: console-session guard (interactive::console_session_mismatch) — a
host outside the active console session (a hand-launched host after an RDP
round-trip) fails every display write, reads the wrong session's GDI view,
and its SendInput compose kicks go nowhere. Named ERROR at vdisplay
acquire + appended to the first-frame timeout, instead of the misleading
generic failure. (The idd_push diagnosis half of this landed in 9a36ea21;
this commit adds the proto helpers + session guard it references, healing
the windows-cfg build.)
- proto + driver: while OPENED, driver_status_detail now carries a live
packed word (bit31 live-marker | offered 15-bit | mismatch-dropped 16-bit)
maintained by the publisher, so the host's first-frame timeout can tell
apart: never-attached (no swap-chain worker ran), attached-but-DWM-composed-
zero-frames (undamaged/powered-off desktop, kicks blocked on the secure
desktop), and composed-but-every-frame-mismatched (ring sized from a stale/
foreign-session GDI mode). Zero layout change, old drivers read as 'no
detail'; unit-tested pack/unpack in pf-driver-proto.
Verified on winbox: cargo check + clippy -p punktfunk-host -p pf-win-display
-p pf-driver-proto EXIT 0, drivers ws cargo check -p pf-vdisplay EXIT 0
(Version_Number=10.0.26100.0), cargo fmt --all --check clean; pf-driver-proto
tests 13/13 pass locally.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
encode.rs + encode/* (NVENC, VAAPI, native AMF, AMF/QSV ffmpeg, direct-SDK
NVENC/CUDA, raw Vulkan-Video, PyroWave, openh264) move into crates/pf-encode
behind one Encoder trait + open_video selector (plan §W6). The crate speaks the
shared frame vocabulary (pf-frame: CapturedFrame/PixelFormat + the DXGI identity
D3d11Frame/make_device) and pf-zerocopy (CUDA context/buffers), and NEVER
pf-capture — the capture→encode edge is one-way (ZeroCopyPolicy, prior commit).
Dep moves: the heavy encoder deps (ffmpeg-next, the NVENC SDK, openh264,
pyrowave-sys) move from the host to pf-encode; the host's
nvenc/amf-qsv/vulkan-encode/pyrowave features now FORWARD to pf-encode/*. The
host keeps a mod-encode shim (pub use pf_encode) so every crate::encode::* path
(negotiator + GameStream/native/mgmt planes) is unchanged.
resolve_render_adapter_luid moves from the host's windows/win_adapter.rs into
pf-gpu (both pf-encode and pf-capture need it as a peer of GPU selection); its 5
call sites (encode amf/nvenc, capture idd_push/synthetic_nv12, vdisplay manager)
rewire to pf_gpu::resolve_render_adapter_luid and win_adapter.rs is deleted.
pf-frame's make_device gains a # Safety section (public-unsafe-fn lint, latent
since the pf-frame carve — a full-workspace -D warnings clippy catches it).
Verified: Linux clippy -D warnings (pf-encode + host nvenc,vulkan-encode,pyrowave
--all-targets) + 13/13 pf-encode + 299/299 host tests; Windows clippy -D warnings
(pf-encode nvenc,amf-qsv --all-targets + host nvenc,amf-qsv --all-targets)
Finished exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The PipeWire capture thread re-derived three encode-backend facts by calling
encode::{linux_zero_copy_is_vaapi, resolved_backend_is_gpu,
pyrowave_capture_modifiers} — a capture→encode back-reference that would force
pf-capture to depend on pf-encode (and vice versa, since encode already speaks
the frame vocabulary). Resolve them ONCE in the host capture facade (which may
reach crate::encode) as a ZeroCopyPolicy { backend_is_vaapi, backend_is_gpu,
pyrowave_modifiers } and thread it into PortalCapturer::open /
from_virtual_output → spawn_pipewire → pipewire_thread.
capture/linux/mod.rs now makes ZERO crate::encode calls — the edge is one-way
(plan §2.4 / §W6), so pf-capture can be extracted depending only on pf-frame
(not pf-encode). pyrowave_modifiers is computed by the facade whenever the
encoder pref is pyrowave (which implies the VAAPI backend); the thread still
consumes them only inside its existing vaapi_passthrough guard, so behavior is
unchanged.
Verified: Linux clippy -D warnings (host nvenc,vulkan-encode,pyrowave
--all-targets); Windows clippy nvenc,amf-qsv --all-targets Finished exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
windows/{win_display,monitor_devnode,display_events}.rs move into
crates/pf-win-display: the CCD/GDI path-activation + mode-set + HDR
advanced-colour + source-rect helpers, the PnP monitor-devnode enable/disable
lever, and the WM_DISPLAYCHANGE / device-arrival watch. The coming pf-capture
crate's IDD-push capturer consumes all three; the host's pf-vdisplay backend
consumes win_display + monitor_devnode. A leaf lets both depend on them as a
PEER instead of the capturer reaching back into the orchestrator (plan §W6).
win_display's one external tie (crate::vdisplay::Mode) becomes the underlying
punktfunk_core::Mode; the cluster is otherwise self-contained (pf-paths for the
state file, serde_json for it, windows). pub(crate) items bump to pub at the
boundary; win_display carries a module-level allow(missing_safety_doc) to keep
the pre-carve behavior (the FFI helpers were pub(crate) unsafe fn with prose
safety docs — the lint only fires once they're pub, and this is an internal
publish=false leaf). The host imports the three modules at its crate root, so
every crate::{win_display,monitor_devnode,display_events}::* path is unchanged.
Verified: Linux clippy -D warnings (leaf empty + host
nvenc,vulkan-encode,pyrowave --all-targets); Windows clippy -D warnings
(pf-win-display --all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The captured-frame types both capture (producer) and encode (consumer) speak —
PixelFormat, OutputFormat, CursorOverlay, CapturedFrame, FramePayload,
DmabufFrame, drm_fourcc — move into crates/pf-frame, alongside the small pure
helpers that ride the same seam: hdr (HDR static metadata / in-band SEI),
metronome (the metronomic-stall detector), thread_qos (per-thread scheduling
QoS), session_tuning (Windows process tuning), and the Windows DXGI capture
IDENTITY (WinCaptureTarget, D3d11Frame, pack_luid, make_device + the GPU
scheduling-priority hardening it applies) (plan §W6).
This is the crate that breaks the capture<->encode cycle: FramePayload's GPU
variants own their backends from BELOW (Cuda -> pf_zerocopy::DeviceBuffer,
D3d11 -> dxgi::D3d11Frame), so encode can speak the vocabulary without a path to
capture, and vice versa. The Windows DXGI identity moving here lets capture,
encode, and pf-vdisplay share ONE WinCaptureTarget/device factory instead of the
old capture<->encode<->vdisplay reach-in.
The host keeps thin facades: capture.rs re-exports the vocabulary
(crate::capture::{PixelFormat,…} unchanged); capture/windows/dxgi.rs keeps the
win32u GPU-preference hook + HDR/video-engine converters + self-test and
re-exports the identity; native.rs re-exports boost_thread_priority from
pf_frame. crate::hdr/metronome/session_tuning callers rewired to pf_frame::*.
metronome's Metronome::new gained a Default impl (new_without_default fires once
the type is public across the crate boundary).
Verified: Linux clippy -D warnings (pf-frame --all-targets + host
nvenc,vulkan-encode,pyrowave --all-targets) + 9/9 pf-frame tests; Windows clippy
nvenc,amf-qsv --all-targets Finished exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The controller's target is only a promise: on calm content the encoder
emits a fraction of it, every window looks clean while proving nothing,
and the climb drifts the target into rates the decoder has never seen.
The first motion spike is then the first real test — it fails, and the
decoder is overloaded for the two-window backoff latency (the reported
settle-calm-then-spike stutter).
Three changes, all client-side (no wire/ABI impact, old hosts unaffected):
- Climb gate: a clean window authorizes a climb only when its ACTUAL
delivered throughput reached 3/4 of the current target — the target was
genuinely tested. Calm windows still bank clean credit; the first
loaded window after a clean run climbs immediately.
- Proven-throughput cap: climbs step at most x1.5 past the session's
high-water mark of delivered-and-digested (decode-latency-flat)
throughput, so slow start becomes a bounded experiment instead of a
blind doubling. High-water never decays: calm periods keep a validated
target, so returning motion gets the full rate instantly; shrinking
capacity (thermals) stays the reactive decode signal's job.
- Severe decode excursion: a >45 ms-over-baseline decode spike backs off
after ONE window instead of two — the overload is already on screen.
The pump feeds the window's wire-byte throughput; the byte baseline is
rebased when the startup capacity probe completes so FLAG_PROBE filler
can't poison the proven mark with the link rate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>