Root cause, proven on-glass across five driver builds: a declared IddCx
hardware cursor is IRREVOCABLE. There is no un-declare DDI, the
empty-caps re-setup is rejected INVALID_PARAMETER, and after a
successful same-mode re-commit with the driver's re-declare provably
suppressed (sticky per-target flag, zero re-setups logged) DWM still
never composites the software cursor back into the monitor's frames.
Every DWM-based composite flip is therefore unfixable.
The composite (capture) model is now implemented in OUR pipeline:
- the driver keeps its hardware cursor declared for the session's whole
life (the state that works) — frames stay pointer-free always;
- in composite mode try_consume routes the conversion through a blend
scratch: slot copy + one alpha-blended quad of the GDI poller's shape
at its polled position (CursorBlendPass — fullscreen-triangle VS with
viewport placement, straight-alpha PS, sRGB→linear for FP16/HDR
rings), covering all four convert paths (NV12, 4:4:4 copy, P010,
PyroWave) GPU-side under the slot's keyed mutex;
- pointer-only motion produces no driver publish (declared hw cursor),
so try_consume REGENERATES from the last slot whenever the polled
cursor state changes — the cursor moves on a static desktop at tick
rate, without faking freshness for the stall/death watchdogs;
- set_cursor_forward becomes purely host-internal state; the
SET_CURSOR_FORWARD IOCTL machinery is no longer used by the host
(driver keeps it, dormant) and its sender plumbing is removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The empty-caps un-declare is REJECTED by IddCx (STATUS_INVALID_PARAMETER,
on-glass driver 9.9.0722.1407) — there is no un-declare DDI. The
composite flip now works with the only lever the OS gives us: every
mode COMMIT reverts the path to the software cursor, and the driver
skips its per-commit re-declare while cursor_forward_on is off. So:
- driver: disable stores the flag only (no DDI call); enable still
declares immediately against the live worker's event.
- host capturer: after a successful disable flip, force a same-mode
re-commit (win_display::force_mode_reenumeration) — DWM composites
the pointer from the very next commit; the swap-chain flap is the
class the driver's preserved-publisher machinery already rides out.
- state now survives re-arrivals end-to-end: the capturer caches the
APPLIED state (cleared on every channel (re)delivery, which
re-created driver entries need — their flag defaults to declared),
and the stream loop re-applies every tick instead of edge-gating
(steady-state cost: one Option compare).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On-glass (.173, match-window session): every window-size convergence
re-creates the driver-side monitor (re-arrival resize / sibling-session
slot churn), destroying the cursor worker the channel was delivered to —
the flip then died NOT_FOUND (no entry with target_id ∧ worker) and the
declared state was lost entirely.
Driver: set_cursor_forward becomes STATE, not an edge on one monitor
generation — find by target_id ∧ hw_cursor, store cursor_forward_on
even without a live worker (it steers the next delivery/re-setup);
declare/un-declare only when a worker exists. Channel delivery honors
the flag: setup_and_spawn(declare=false) adopts + spawns WITHOUT
declaring (composite mode), so a later enable-flip declares against the
worker's event.
Host: retain the SET_CURSOR_CHANNEL sender; extract
deliver_cursor_channel and RE-deliver the surviving section on every
ring recreate and — with one retry — when a flip IOCTL fails
(the re-arrived entry gets a fresh worker, declared per its flag).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cursor channel excluded the host pointer from the video for the
whole session, but the client only draws it under the DESKTOP mouse
model — flipping ⌃⌥⇧M to capture mid-stream left the session
pointer-blind (user-found). The fix makes render ownership LIVE state:
- wire: CursorRenderMode 0x51 (client→host, control stream) —
client_draws: true = desktop model (host excludes + forwards),
false = capture model / released (host composites, full fidelity —
DWM on Windows incl. real XOR inversion, encoder blend on Linux).
Sessions start client_draws=true (the pre-message behavior).
- host: control task stores the flag; the encode loop edge-detects it —
forwarding + frame.cursor strip while the client draws, quiet
forwarder + overlay-into-blend while composited. SessionPlan now
grants blend CAPABILITY wherever the capture has a pointer (dropping
the !cursor_forward gate) so the composite side needs no encoder
rebuild; per-tick frame.cursor decides what is drawn.
- Windows: Capturer::set_cursor_forward → retained
IOCTL_SET_CURSOR_FORWARD sender (proto v6) → driver (un)declares the
IddCx hardware cursor on the LIVE monitor. Un-declare re-issues the
setup with EMPTY caps (candidate mechanism — the DDI has no
documented un-setup); resetup-on-commit is skipped while off, so a
mode commit's software-cursor default is the fallback path. Failure
against a pre-v6 driver logs and keeps declared-at-ADD behavior.
- SDL client: one edge-detected reconciler at the cursor pump site —
desktop-active = client draws; capture model or released = host
composites. Covers the chord, the M3 auto-flip, and engage/release.
- C ABI v12: punktfunk_connection_set_cursor_render (additive; wire
version unchanged — pre-§8 hosts ignore the unknown message type).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The M2c redesign (design/remote-desktop-sweep.md §8): the IddCx hardware-
cursor query is alpha-only by design — IDDCX_CURSOR_SHAPE_TYPE has no
monochrome value, the OS pre-converts monochrome to masked-color, and
masked-color delivery is dead code on modern builds (proven on-glass at
every ColorXorCursorSupport level). The driver keeps its hardware cursor
declared (XOR FULL) purely so DWM excludes every cursor type from the
IDD frame; the SHAPE now comes from a GDI poller in the capture host —
which runs as SYSTEM inside the interactive session, so no helper
process is needed.
CursorPoller (idd_push/cursor_poll.rs, the RustDesk/WebRTC/OBS pattern):
~60 Hz GetCursorInfo on a dedicated thread; rasterise via
CopyIcon→GetIconInfo→GetDIBits only when the HCURSOR value changes;
monochrome via the WebRTC truth table with invert pixels as opaque black
plus a white outline; AND-mask alpha for alpha-less color cursors;
CURSOR_SUPPRESSED = hidden; per-output visibility via the target's
desktop rect; per-monitor-V2 DPI awareness thread-scoped; input-desktop
reattach on failure + 2 s cadence (GetCursorInfo on a stale desktop
succeeds with stale data). Spawned iff the cursor channel was delivered;
a live poller is the sole overlay source (no serial-namespace mixing) —
the driver shm read remains as fallback if the poller dies.
Not chosen: DXGI Desktop Duplication GetFramePointerShape —
PointerPosition.Visible goes stale under injected-only input on current
Win11 (Sunshine #5293, exactly our topology), it burns one of the
session's four duplication slots, and IDD-output metadata has
conflicting field reports.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the cursor channel to Windows hosts. The pf-vdisplay driver
declares an IddCx hardware cursor for sessions that negotiated
cursor-forward — DWM then EXCLUDES the pointer from the IDD frame and
delivers shape/position out-of-band, into the same CursorOverlay →
forwarder → wire → client pipeline the Linux portal path uses.
- pf-driver-proto v5 (additive, host floor stays 3): AddRequest's spare
tail becomes hw_cursor (same size/offsets); IOCTL_SET_CURSOR_CHANNEL
delivers a host-created CursorShm section (64-byte seqlock header +
256² shape buffer, layout pinned + tested). No event crosses the
boundary — the host polls at encode-tick pace.
- driver: wdk-iddcx grows the two cursor DDI wrappers; a per-monitor
cursor worker (event wait → QueryHardwareCursor → seqlock publish)
starts only when BOTH the ADD asked and the channel arrived, so a
failed delivery leaves DWM compositing as today. Shape bytes ship raw
(BGRA/masked + pitch); the host converts.
- host: the section rides the existing sealed-channel broker (least-
privilege dup, remote reap on failure); IddPushCapturer::cursor()
seqlock-reads → CursorOverlay (BGRA→RGBA, masked-color approximation,
desktop→frame origin shift, per-shape conversion cache). New
Capturer::cursor() trait hook — the encode loop prefers it over the
frame-attached overlay because hardware-cursor moves produce NO new
frame on a static desktop. hw_cursor survives the re-arrival resize
(carried on the manager's Monitor).
- negotiation: cursor_forward grows the Windows arm (client cap ∧
driver proto ≥ 5, probed once via the control device); SessionPlan
carries cursor_forward → OutputFormat.hw_cursor.
- drive-by: wdk-probe's two pre-existing same-type casts (clippy) and
pf-vdisplay's stale spike-test refs (crate::win_display moved to
pf-win-display; tracing-subscriber was never a dep) repaired.
Verified: proto tests on Mac AND MSVC (15/15 incl. the CursorShm
layout pin); clippy -D warnings for proto/frame/capture/vdisplay/host
on Linux (.21) and native Windows (.173); the DRIVER workspace clippy
-D warnings green against the real WDK 10.0.26100 bindgen (DDI names,
enum variants and IDARG layouts all bind). On-box driver deploy +
on-glass validation follow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The native QSV encoder only attached mfxExtVideoSignalInfo on HDR sessions —
an SDR stream carried no colour description at all (NVENC writes 709-limited
unconditionally; qsv.rs now mirrors it).
Diagnostics for the field-reported blue/magenta Intel-host colours:
- hdr-p010-selftest takes an optional WxH + GPU vendor (intel|nvidia|amd) and
prints which adapter it tested — 64x64 on the default GPU proved nothing on
dual-GPU boxes, and the field heights (1080/1400) are not 16-aligned.
- pf-capture: ignored live test running the converter at 1920x1080 pinned to
the Intel adapter.
- pf-encode: qsv_live_p010_1080_colorbars_dump — known P010 bars through the
UNALIGNED-height ingest copy (1080 src -> align16 1088 pool, the seam no
640x480 test exercises) to Main10 HEVC, dumped for off-box decode checks.
- dxgi.rs: drop the stale 'falls back to the R10 path' claim (no such path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two medium findings from the round-1 sweep.
- The `.process` callback dequeued a buffer INSIDE its `catch_unwind`, and every
requeue site was inside too. `newest` is a raw pointer with no Drop, so any
caught panic (update_cursor_meta / consume_frame) unwound past all three
requeues and permanently stranded that buffer. Once the stream's fixed pool
drained, `dequeue_raw_buffer` returned null every call and capture silently
wedged while still reporting negotiated/active — defeating the very
catch_unwind that was meant to keep a panic survivable. The drain loop now runs
OUTSIDE the catch (dequeue/queue are non-panicking C FFI pointer ops) and
`newest` is requeued exactly once after it, on every path: normal,
corrupted-skip, or caught panic.
- `recreate_ring` invalidated `video_conv` and `hdr_p010_conv` but not
`pyro_conv`. That converter is mode-baked — BgraToYuvPlanes selects entirely
different shaders and output formats for SDR (8-bit BT.709 → R8/R8G8) vs HDR
(scRGB→PQ BT.2020 → R16/R16G16) — and `ensure_pyro_conv` only builds when None,
so a display_hdr flip reused the stale SDR converter against a freshly
HDR-formatted pyro ring, corrupting every frame. Reachable at the documented
"Downgrade point D": a PyroWave session with client_10bit=true that opens on a
box where HDR can't enable, then flips once the display comes up.
Linux .21: pf-capture 1/0. Windows .173: `cargo check -p pf-capture` clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the partial fix from the previous commit. The Windows PyroWave backend
caches its imported plane images keyed on the D3D11 texture's COM address, and
holds no reference on that texture — so once the capturer recreates its ring,
those addresses can be handed straight back out by the allocator and a
pointer-keyed cache hit returns an image bound to a texture that no longer
exists. Adding the extent to the key ruled out same-address-different-size
aliasing, but a recycle at identical dimensions still aliased.
The capturer already tracks exactly the value needed: `generation`, bumped on
every ring recreate. Plumbed it onto `PyroFrameShare` and the encoder now
flushes every cached import when it changes, which makes cache identity
independent of allocator behaviour rather than a bet against pointer reuse.
Validated on the RTX box: `pyrowave_win_smoke` (forced with `--ignored`, the
only test that actually exercises this path on real hardware) passes all ten
configurations — 1024²/720p/1080p/1440p across SDR/HDR and 4:2:0/4:4:4 — with
correct decoded chroma means, so the steady-state cache-hit path still works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Windows client with HDR off (video_caps=0) negotiates bit_depth=8, but the
H.26x IDD-push capture composition FOLLOWED the virtual display's live "Use
HDR" state rather than the negotiated depth. On a display whose advanced color
was on — leftover from a prior 10-bit session on a reused monitor, the driver
default, or the host's global toggle — the capturer emitted P010 and NVENC
stamped HEVC Main10 + BT.2020 PQ from the pixel format alone (the in-band HDR
upgrade). That 10-bit PQ stream reached a client that advertised SDR-only; on a
client whose monitor is HDR-capable but has "Use HDR" off, the PQ landed on an
SDR desktop and blew out.
Make client_10bit authoritative for the composition depth on BOTH codecs,
mirroring the PyroWave branch that already did this:
* setup: force advanced color OFF for any !client_10bit session (was
pyrowave-only), settling before the ring is sized;
* display_hdr gates on client_10bit alone, so an SDR-negotiated session
composes SDR even if a physical display forces HDR;
* the descriptor poller re-asserts SDR against a mid-session "Use HDR" flip for
any SDR-negotiated session (an HDR-negotiated H.26x session still follows
flips both ways — its encoder re-inits on the depth change).
Only Punktfunk's virtual display is touched, never the host's real desktop.
Not yet on-glass; the client-side robustness fix (presenter should tone-map PQ
on an SDR-mode display instead of taking an HDR10 swapchain) is a follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3 of design/pyrowave-444-hdr.md. A PyroWave session now negotiates HDR
(10-bit) and 4:4:4 on a Windows host exactly like HEVC/AV1, and the Linux
client presents it through the real HDR10 path.
Host (Windows): BgraToYuvPlanes becomes mode-aware — SDR/BGRA and HDR/scRGB
variants at half- or full-res chroma. The HDR passes reuse HdrP010Converter's
exact colour math (scRGB -> PQ BT.2020 limited studio codes, verified by
hdr_p010_selftest) but write P010-style MSB-packed codes into two separate
shareable R16_UNORM/R16G16_UNORM textures; chroma keeps the pyrowave family's
centre-sited 2x2 box. idd_push pins the composition to the NEGOTIATED depth
(SDR sessions force advanced color off as before; 10-bit sessions enable it
and ride the FP16 ring), and the descriptor poller re-asserts that state
instead of following display flips the fixed-format encoder can't. The
encoder imports 8/16-bit planes per session and stamps the sequence header's
BT.2020/PQ/matrix bits on HDR (stamp_color_bits, extending 574e3e4e's range
stamp); supports_10bit/can_encode_10bit/can_encode_444 gates open (HDR
Windows-only — Linux capture has no HDR source).
Client: the plane ring becomes R16_UNORM for 10-bit sessions (with a
STORAGE_IMAGE format probe), the planar CSC pass joins the HDR10 swapchain
rebuild (set_hdr_mode previously destroyed it without rebuilding — latent),
st.hdr follows frame.color.is_pq(), and the planar push constants carry
depth-10 MSB-packed rows + the PQ tonemap mode, identical to the NV12 arm.
Verified: .173 (RTX 4090) deploy-config clippy + fmt + wire tests + the
extended pyrowave_win_smoke (10-case {SDR,HDR}x{420,444} matrix incl. R16
imports and header stamps); .21 (RTX 5070 Ti) clippy across 4 crates, host
186 tests, client/presenter/encode tests, both Linux GPU smokes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire PyroWave into the Windows host (design/pyrowave-windows-host-zerocopy.md).
Before this a macOS client + Windows host that both selected PyroWave silently ran
HEVC: the host never advertised CODEC_PYROWAVE and open_video_backend bailed.
Approach (zero-copy, no GPU→CPU→GPU): pyrowave owns its own Vulkan device
(create_device_by_compat, by render-GPU vendor/device-id — NOT LUID, invalid in
Session 0). The capturer runs a BGRA→YUV BT.709-limited CSC (matching rgb2yuv.comp)
into TWO SEPARATE shareable plane textures — full-res R8 Y + half-res R8G8 CbCr —
which the encoder imports into pyrowave's device. Separate single/two-component
textures import reliably on NVIDIA at any size; a single planar NV12 import does NOT
(the vendored interop test: "only very specific resource sizes" — confirmed on-glass:
1024² fine, 720p/1080p/1440p garbage). A shared D3D11 fence, signalled after the CSC,
is imported as a Vulkan timeline semaphore so the wavelet read is ordered after it.
- pf-encode: enc/windows/pyrowave.rs (Encoder impl, two-plane import + Linux-style
plane views); host_wire_caps advertises CODEC_PYROWAVE on Windows when the backend
isn't Software; open_video_backend routes a negotiated PyroWave session first;
pyrowave-sys on the Windows target; interop confirmed at open → clean HEVC fallback.
- pf-encode: shared, unit-tested enc/pyrowave_wire.rs (single source of truth for the
client-facing AU framing); Linux encoder uses it too.
- pf-capture: dxgi.rs BgraToYuvPlanes CSC; idd_push.rs pyrowave mode — forces the
virtual display SDR (the VideoProcessor can't ingest the FP16 HDR ring), a
two-plane shareable out-ring, a shared fence passed every frame (so a rebuilt
encoder re-imports it). Threaded via OutputFormat::pyrowave.
- pf-frame: D3d11Frame::pyro carries the CbCr plane + fence; OutputFormat::pyrowave.
Verified on .173 (RTX 4090): full-host build + clippy -D warnings (nvenc,amf-qsv) +
fmt --all --check; pyrowave_wire unit tests; pyrowave_win_smoke GPU test round-trips
distinct Y/Cb/Cr (100/180/60) exactly at 1024²/720p/1080p/1440p; Stage-0 interop
validated in the real Session-0 service context on-glass. Deployed to the box.
Owed: final on-glass picture/latency confirmation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
design/latency-reduction-2026-07.md tier 1, remaining halves:
- T1.1: the native encode loop wakes on the capture's ACTUAL arrival instead of
sampling at a free-running tick — deletes the sample-and-hold (~half a frame
interval on average, a full one worst-case: ~8ms avg @60fps). New
Capturer::supports_arrival_wait/wait_arrival pair (IDD-push waits its
frame-ready event against the shared-header token; the PipeWire portal blocks
its channel with a pending stash); backends without an arrival signal — and
PUNKTFUNK_FRAME_DRIVEN=0 — keep the legacy tick bit-identically. A
0.9x-interval rate floor caps encode at ~1.11x target when the compositor
outruns the session; a +0.5x-interval keepalive keeps static desktops
re-encoding at 1.5x-interval cadence. Pacing deadlines re-anchor to the
actual submit so they can't drift against the arrival clock. GameStream
plane untouched.
- T1.4: the jump-to-live detectors run on WALL-CLOCK now (STANDING_TIME /
FLUSH_AFTER = 250ms) instead of 30-frame counts whose meaning scaled with
fps (500ms @60 but 125ms @240 — and stretching further under T1.1's slower
static-scene repeats). The queue trip also requires depth still >= high, so
a hysteresis-band hover can't fire on elapsed time alone.
Validated: .21 Linux 185 core + 177 host + pf-capture tests, clippy
-D warnings; .133 Windows cargo check of pf-capture + punktfunk-host green.
Co-Authored-By: Claude Fable 5 <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>
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>
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>