fix(pf-capture): Windows IDD-push defects — HDR pin, cursor, recreate, handles, f16 (Phase 3)

Compile-verified for x86_64-pc-windows-msvc locally (a scratch workspace symlinking the real
sources against a stub punktfunk-core — the `quic` feature's ring/opus C builds are what blocks an
in-tree cross-check). Behaviour is owed the on-glass validation in sweep Phase 7.3.

**3.1 (W1/F3) — the HDR pin asserted a flip it never verified.** `poll_display_hdr` discarded
`set_advanced_color`'s `bool` and then wrote `now.hdr = self.client_10bit` — the DESIRED state in
place of the observed one. On a display that cannot be flipped (the state this file already logs as
"Downgrade point D" at open) that broke in both directions: wanting HDR, the fabricated `true`
differed from `current`, so two poller samples drove `recreate_ring(true, …)` and rebuilt the ring
FP16 while the driver composed 8-bit BGRA — every publish dropped by the driver's format guard,
`recovering_since` expiring, `try_consume` bailing: a permanent 3-second reconnect loop. Wanting
SDR, the fabricated `false` MATCHED `current`, so no recreate ever fired and the ring stayed BGRA
against an FP16 composition — the same dropped-publish outcome, silently. Now it re-reads
`advanced_color_enabled` and follows what the display actually composes, with a one-shot error
naming want/observed/returned. A not-yet-settled read costs one debounce cycle, never a wrong ring,
which is why this does not block the frame path on a settle poll the way `open_on` does. Downgrade
point D's error now carries the same pair, so `Some(false)` (display says no) and `None` (the CCD
read failed) are distinguishable.

**3.2 (W2, W3, W6) — cursor correctness.**
  - W2: the poller's desktop rect was captured once at open and used forever, for BOTH the
    desktop→frame offset and the `in_rect` visibility test — while both mid-session mode-change
    paths (`resize_output`, `poll_display_hdr` → `recreate_ring`) keep the same poller. After an
    in-place resize the pointer was clipped to the old rect and offset by a stale origin. It is now
    a SEED: the poll thread re-queries on its existing 250 ms reattach cadence, keeping the last
    good value on `None` (a transient CCD failure must not park the rect at zero and report every
    position invisible), which keeps the CCD call off the encode thread as `DescriptorPoller`
    demands.
  - W3: `composite_forced` tested `cursor_sender.is_none()`, but §8.6's rationale is "no cursor
    CHANNEL" — and the delivery just above it is explicitly allowed to fail non-fatally, which is
    precisely the state needing the rescue. It was the one state that skipped it: a negotiated
    channel that failed to create or deliver left a cursor-excluded target with NO pointer at all.
    Now `cursor_shared.is_none()`, evaluated after that binding.
  - W6: `cursor()` degraded poller→shm correctly, but the BLEND path — the only consumer that
    matters in the composite model, since the Windows encode loop never attaches `frame.cursor` —
    read the poller directly with no `alive()` check and no fallback, so the documented fallback and
    the spawn-failure warning were both untrue for exactly those sessions (a dead poller meant
    pointer-less frames, not a degraded pointer). One `live_cursor()` now serves all three
    consumers and LATCHES the source, because the two keep independent serial namespaces and
    interleaving them poisons the client's shape cache.

**3.3 (W4, W5, W14) — recreate hardening.**
  - W4: `recreate_ring` committed `display_hdr`/`width`/`height` BEFORE the fallible
    `create_ring_slots` (VRAM pressure at a large new mode — exactly when resizes happen), leaving
    a failed recreate emitting frames stamped with the new geometry against the old ring, the old
    generation and an unchanged header. Slots are built first; nothing after the commit point fails.
  - W5: a recreate never cleared the driver's status words, and `wait_for_attach` — the only
    classifier of TEX_FAIL/BIND_FAIL and the only source of the LUID rebind — runs at open ONLY. A
    stale `OPENED` therefore made a failed re-attach look healthy while the recover-or-drop bail
    reported nothing. Now cleared before the Release generation store (plus `status_logged`), and
    the 3 s bail prints the live `(driver_status, detail, render_luid)` the way `next_frame`'s 20 s
    bail already did. The four-field read is one `driver_diag()` helper instead of four copies of
    the same unsafe block.
  - W14: `IDD_GENERATION` is a full `u32` but the publish token carries 24 bits and `unpack` masks
    what it reads, so past 2²⁴ recreates `tok.generation != self.generation` would be permanently
    true — every frame rejected. Masked at the single mint point, and 0 skipped (it is also the
    cleared-`latest` sentinel).

**3.4 (W8, W9, W12) — handle hygiene.** `shared_object_sa`'s security descriptor is a `LocalAlloc`
nobody freed: leaked twice per open and once per ring recreate. It is now an RAII `SharedObjectSa`
whose `Drop` `LocalFree`s it and whose `as_ptr()` only lends a borrow — which also makes the
"descriptor must outlive the attributes" rule structural instead of a comment. The PyroWave fence's
shared NT handle, created per capturer and never closed, becomes an `OwnedHandle` (the encoder holds
its own duplicate, so closing ours is safe). `cursor_blend`'s `cbuf_scale` is cached only on a
successful `Map` — caching unconditionally wedged the HDR/SDR cursor scale for the session after one
transient failure.

**3.5 (W7) — `f32_to_f16` swallowed the rounding carry.** `sign | half_exp | (half_mant + round)`
ORs a mantissa carry into bit 10, so for every ODD biased exponent (bit 10 already set) the carry
vanished and the result came back ~2× low: `1.9998779 → 1.0`, `0.49996948 → 0.25`. Only values one
ULP below a power of two are affected — precisely what a gradient test pattern is full of — so this
made `hdr-p010-selftest` FAIL a correct shader. Composed additively, with 4 tests (18 asserted bit
patterns, a round-trip property over the self-test's scRGB values, saturation) — all verified
numerically against a standalone reference on this box, including a scan confirming old-vs-new
diverges ONLY on the carry cases. Phase 0.1's `--all-targets` lint is what lets these compile in CI
at all.

pf-capture 20/20 on Linux; workspace clippy --all-targets clean on Linux and windows-msvc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 11:06:26 +02:00
co-authored by Claude Opus 5
parent 9a7b3a46d0
commit bc45287dc6
4 changed files with 414 additions and 123 deletions
@@ -86,10 +86,18 @@ impl CursorPoller {
/// syscalls/s are not.
const REATTACH: Duration = Duration::from_millis(250);
/// Spawn the poller for the virtual display `target_id`. `rect` = the target's desktop rect
/// Spawn the poller for the virtual display `target_id`. `rect` SEEDS the target's desktop rect
/// (`source_desktop_rect` order: x, y, w, h) — cursor positions are desktop-global; the
/// overlay wants frame-relative, and a pointer outside the rect reports `visible: false`
/// (per-output semantics, matching the driver shm path and the Linux portal).
///
/// A SEED, not the value: the poll thread re-queries the rect on its [`Self::REATTACH`] cadence.
/// It used to be captured once here and used forever for BOTH the desktop→frame offset and the
/// `in_rect` test, while both mid-session mode-change paths (`resize_output` and
/// `poll_display_hdr` → `recreate_ring`) keep the same poller — so after an in-place resize the
/// pointer was clipped to the OLD rect and offset by a stale origin. Re-querying on the poll
/// thread is what keeps the CCD call off the capture/encode thread, which is the whole reason
/// this poller exists (see `DescriptorPoller`).
pub(super) fn spawn(target_id: u32, rect: (i32, i32, i32, i32)) -> Self {
let slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>> = Arc::new(Mutex::new(None));
let stop = Arc::new(AtomicBool::new(false));
@@ -139,7 +147,7 @@ impl Drop for CursorPoller {
/// The poll loop. Owns the thread's input-desktop binding and the shape cache.
fn run(
target_id: u32,
rect: (i32, i32, i32, i32),
mut rect: (i32, i32, i32, i32),
slot: &Mutex<Option<pf_frame::CursorOverlay>>,
stop: &AtomicBool,
secure: &AtomicBool,
@@ -167,6 +175,28 @@ fn run(
if last_attach.elapsed() >= CursorPoller::REATTACH {
last_attach = Instant::now();
publish_secure(secure, desktop.reattach());
// …and re-read the target's desktop rect on the same cadence: a mid-session resize (or
// an HDR recreate, or the user moving this display in the desktop arrangement) changes
// BOTH the origin the position is made relative to and the extent `in_rect` tests
// against, and this poller outlives all of them. `None` keeps the last good value — a
// transient CCD failure must not park the pointer at a `(0, 0, 0, 0)` rect, which would
// report every position invisible.
//
// SAFETY: `source_desktop_rect` is an `unsafe fn` running the read-only CCD
// `QueryDisplayConfig` over owned local buffers; the `Copy` target id crosses by value
// and it returns owned `(x, y, w, h)` values, borrowing nothing.
let fresh = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
if let Some(fresh) = fresh {
if fresh != rect {
tracing::info!(
target_id,
from = ?rect,
to = ?fresh,
"cursor poller: target desktop rect changed — re-basing pointer positions"
);
rect = fresh;
}
}
}
let mut ci = CURSORINFO {