RecordVirtual was created with cursor-mode EMBEDDED, which never delivers
cursor metadata — the cursor channel had nothing to forward (client-drawn
cursor invisible) and the always-built encoder blend had nothing to
composite (host-drawn cursor invisible too). Metadata mode feeds both:
non-forwarding sessions get the blended pointer (same pixels Mutter would
have embedded), forwarding sessions strip the overlay and send shape/state.
The capturer already negotiates the meta on every stream (meta_param).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
faad4b57 shipped the field, shader scale, and plumbing but the
scripted edit inserting the sdr_white_level_scale() refresh in the
scratch-rebuild arm matched nothing and silently no-op'd (no assert) —
sdr_white_scale stayed at its 1.0 init, i.e. 80 nits, i.e. exactly the
old dark cursor. Now queried on every scratch rebuild, with an info
log of queried/applied so an HDR session shows what it uses.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The polled position is also the composite-blend position: at 16 ms a
240 fps session reused a stale position for ~4 consecutive frames and
the composited pointer visibly stuttered against the video. 4 ms
out-paces every session rate; a tick is one GetCursorInfo syscall.
- Log the queried DISPLAYCONFIG_SDR_WHITE_LEVEL scale on each blend-
scratch rebuild so an HDR session shows what is actually applied
(the virtual display's own SDR-brightness setting is the suspect
when the cursor still reads dark).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sRGB→linear alone lands cursor-white at 80 nits (scRGB 1.0) while DWM
composes the surrounding SDR desktop at the user's SDR-brightness
setting (~200+ nits by default) — the cursor read visibly dark on HDR.
Scale by DISPLAYCONFIG_SDR_WHITE_LEVEL (new
win_display::sdr_white_level_scale, queried on blend-scratch rebuilds —
the CCD query stays off the per-frame path).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
The FIRST session's Welcome runs before any backend construction
(vdisplay::open happens at display prep, after the Welcome), so the
capability probe's vdm() expect panicked the very first handshake of a
fresh service — the client saw a dead connect and auto-wake kicked in.
init() is idempotent and the driver facade is a unit struct, so the
probe now initialises the manager itself. Found on-glass (.173, first
Mac-client connect), fix deployed there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Mac client now exercises the full cursor channel against capable
hosts: desktop-mode sessions advertise CLIENT_CAP_CURSOR, the host
stops compositing the pointer, and StreamView draws the forwarded
shapes as the real NSCursor — plus the M3 host-driven model flip.
- ABI v11: punktfunk_connect_ex9 (adds client_caps; ex7/ex8 pass 0 —
Android untouched), PunktfunkCursorShape/State + the two
next_cursor_* poll fns (audio-style borrow contract), and
PUNKTFUNK_CLIENT_CAP_CURSOR. Fixed in passing: the first insertion
split next_host_timing's cfg/no_mangle attributes off the fn, which
silently dropped the symbol from the header AND dylib — caught by
the Swift build, reattached with its docs.
- PunktfunkConnection: clientCaps connect param, hostSupportsCursor,
CursorShapeEvent/CursorStateEvent + nextCursorShape/nextCursorState
(one cursor thread drains both planes; cursorLock joins the close()
ladder).
- SessionModel: desktop-mode sessions (DefaultsKey.mouseMode) connect
with the cursor cap on macOS.
- StreamView: shape cache (serial → NSCursor, straight-alpha RGBA via
CGImage), resetCursorRects wears the HOST shape while the desktop
model is engaged (hidden host pointer ⇒ invisible), latest-wins
state pump, and the M3 auto-flip — edge-triggered on relative_hint,
⌃⌥⇧M sets an override latch cleared by the next host edge, leaving
relative warps the pointer to the host position via the inverse
letterbox mapping (CGWarpMouseCursorPosition, CursorCapture's
coordinate convention).
Verified: swift build + full test suites green (xcframework rebuilt at
ABI v11, signed); core 218 tests + clippy -D warnings on Linux (.21).
Co-Authored-By: Claude Fable 5 <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 full Parsec model: launching a game from a desktop session flips the
client into captured relative automatically, and back — no chords.
- Capture overlay now distinguishes 'hidden' from 'no cursor yet':
CursorOverlay grows a visible flag, overlay() returns Some whenever a
bitmap is known (the encode loop strips invisible overlays after
forwarding so no blend path ever draws one; the CPU composite guarded
on visibility already).
- Host forwarder maps it onto the reserved wire bit: visible ⇒ VISIBLE,
hidden-but-known ⇒ RELATIVE_HINT (an app grabbed/hid the pointer),
never any hint before the first bitmap — a cold start can't flip a
desktop session into capture.
- Presenter: edge-triggered auto-flip on hint changes via the new
Capture::set_desktop (chord semantics untouched); a manual ⌃⌥⇧M sets
an override latch that holds until the HOST's intent next changes, so
the hint never fights the user. Leaving relative warps the local
cursor to the host's last pointer position through the new
content_to_window inverse-letterbox mapping (unit-tested against
finger_to_content) — the hand-back is seamless.
Verified on .21: fmt + clippy -D warnings + tests (presenter 20 incl.
the inverse-mapping roundtrip, host 245).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The host cursor stops riding the video and becomes a real OS cursor on
the client (the Parsec/RDP model): pointer feel no longer pays the
capture→encode→network→decode→present round trip.
Wire (M2a):
- Hello grows a client_caps trailing byte (CLIENT_CAP_CURSOR) after the
fixed display_hdr block — presence disambiguated by remaining length,
which caps the post-HDR tail at 27 bytes (documented); Welcome answers
HOST_CAP_CURSOR (capable-and-asked, the 444/clipboard precedent).
- CursorShape (0x50, control stream): serial + dims + hotspot + straight
RGBA, ≤120px/side so the u16 frame always fits (128² would overshoot);
client caches by serial — re-showing a known shape costs 14 bytes, not
a bitmap (RDP pointer-cache for free).
- CursorState (0xD0 datagram): serial + visible/relative_hint flags +
position, sent once per encode-loop tick — latest-wins, self-healing
under loss, no refresh timer. relative_hint is reserved for M3.
- Client core: two new planes (control-task + datagram-task arms) →
next_cursor_shape/next_cursor_state; connect() grows client_caps
(C ABI passes 0 until the v11 cursor poll fns exist).
Host (M2b, Linux portal only):
- handshake::cursor_forward is THE predicate (client asked ∧ Linux ∧
compositor ≠ gamescope) — Welcome bit and session wiring both read it.
- SessionPlan.cursor_blend goes false for a forwarding session; the
encode loop ticks a CursorForwarder every iteration: shape-serial diff
→ control-task bridge (mirrors probe_result), state datagram → conn.
- CursorOverlay/capture CursorState carry the hotspot through
(nearest-neighbor downscale backstop for XL cursors, unit-tested).
Presenter:
- CursorChannel drains both planes per loop iteration; shapes become
SDL color cursors (from_surface + hotspot), applied while the desktop
mouse model is engaged; visibility follows the host; capture/released
hands back the system cursor. Sessions advertise the cap when they
START in desktop mode.
Verified on .21: fmt + clippy -D warnings (7 crates) + tests green
(core 218 incl. new wire roundtrips, host 245 incl. e2e + forwarder
downscale tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New physical-mouse model beside capture: Desktop leaves the pointer
uncaptured and sends absolute positions through the letterbox
(MouseMoveAbs — every host injector already consumes it). Capture
stays the default and the game model.
- pf-client-core: MouseMode (capture|desktop) persisted in Settings,
default capture so existing stores are unchanged.
- pf-presenter: Capture grows a desktop model — latest-wins pending
abs position coalesced per loop iteration (same 1000 Hz discipline
as relative), flushed before clicks/keys/wheel so they land where
the cursor is; Ctrl+Alt+Shift+M flips the model live; local cursor
stays hidden over the window (the host's composited cursor is the
one you see until the M2 cursor channel); Windows keyboard grab
only engages for capture (a desktop stream is something you
Alt-Tab away from).
- gamescope gating: its EIS is relative-only, so desktop mode is
pinned off there (resolved_compositor), with a log note.
- Settings surfaces: GTK row (dynamic caption), WinUI combo,
console-UI row + step test, capture-hint line.
Verified: fmt + clippy -D warnings + tests (33/40/19) on Linux .21;
clippy -D warnings for all five crates incl. punktfunk-client-windows
native on .173.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
punktfunk negotiates HDR correctly (the stream is BT.2020 PQ 10-bit) but never
told the client to switch its display into HDR picture mode, so HDR streams
stayed in SDR on the TV. Sunshine sends an async control-stream message
(IDX_HDR_MODE, type 0x010e, carrying SS_HDR_METADATA) shortly after the stream
starts; Moonlight only flips the TV into HDR on that cue. We sent exactly one
host→client control message (rumble 0x010B) and never this one. (aurora-tv PR
#53 worked around it client-side by inferring HDR from the negotiated format;
this fixes it host-side so stock Moonlight clients switch too.)
Once the control stream is live and the session negotiated HDR, send a one-shot
0x010e message (enable + 26-byte SS_HDR_METADATA from generic HDR10 defaults),
latched per connection and re-armed on Disconnect. The rumble sequence counter
is unified into a single monotonic `host_seq` shared by both message types —
the GCM nonce derives from `seq`, so a separate per-type counter would reuse
(key, nonce) pairs in the host direction.
The BT.2020 PQ VUI already rides the bitstream (the encoder derives it from the
P010 capture format), so this is purely the missing "switch now" signal; no
encoder change needed. Wire layout locked by unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rustls-webpki 0.103 accepts only X.509 v3 certs and returns
`UnsupportedCertVersion` for anything older, aborting the mTLS handshake at
the `CertificateVerify` step before it ever checks the signature. The
moonlight-embedded client family (moonlight-embedded, aurora-tv, …) still mint
self-signed **v2** certs with no keyUsage extension, so pairing fails against
punktfunk while it works against Sunshine — whose OpenSSL verify callback never
inspects the cert version. (aurora-tv PR #53 worked around this client-side by
switching to v3 + keyUsage; this fixes it host-side for stock clients too.)
The cert's X.509 version and extensions carry no security weight here: the
client cert is self-signed and pinned by SHA-256 (`peer_is_paired`), and the PIN
pairing ceremony is the real proof of identity. The property that DOES matter —
that the peer holds the private key for the cert it presented — is still
enforced: when webpki rejects the cert we re-run exactly that `CertificateVerify`
check with a version-agnostic parser (x509-parser SPKI + the same `rsa` verify
`pairing::verify256` uses). This is a full cryptographic check, never a bypass:
a bad signature still fails, and non-RSA / unsupported schemes fall through to
webpki's original error. Matches Sunshine's leniency without loosening the
pinned trust model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The VK key-event vocabulary cannot express text an input method COMMITS
(autocorrect, gesture typing, non-Latin scripts, emoji). Add a first-class
text event and negotiate it:
- punktfunk-core: InputKind::TextInput (= 15) carries one Unicode scalar per
event in `code`; HOST_CAP_TEXT_INPUT (0x04) in Welcome::host_caps.
- Host advertises the cap only where the session's inject backend can type
text: Windows SendInput (KEYEVENTF_UNICODE, surrogate-pair aware) and the
Linux wlroots backend — a dedicated second zwp_virtual_keyboard whose xkb
keymap grows Unicode keysyms on demand (the wtype model), so keymap
re-uploads never disturb the main device's layout/modifier state. The
KWin-fake-input/libei/gamescope backends can only press layout keycodes, so
those sessions don't set the bit and clients keep the VK fallback.
- GameStream plane: Moonlight's UTF-8 text packet (MAGIC_UTF8, previously
recognized-and-dropped) now decodes to the same TextInput events.
- Android: KeyCaptureView picks a real editable InputConnection when the host
has the cap — the IME runs its full machinery, mirrored to the host live via
common-prefix diffs of the composition (backspaces + new suffix), with
setComposingRegion adopting committed text so autocorrect-revert flows diff
instead of retyping; newline→Enter, deleteSurroundingText→Backspace/Delete.
Older hosts keep the TYPE_NULL raw-key path unchanged.
- keymap: media VKs (0xB0-0xB3) → evdev so the Android media keys land on
Linux hosts too.
Verified: punktfunk-core + host gamestream + pf-inject tests green on Linux
(Ubuntu box), clippy clean; Android app+native builds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report: with Exclusive Topology, the physical monitor sometimes stayed
lit — every isolate attempt re-committed and failed SetDisplayConfig with
0x57 ERROR_INVALID_PARAMETER. Clearing DISPLAYCONFIG_PATH_ACTIVE alone leaves
the deactivated path's source/target modeInfoIdx pinned to the queried mode
entries, and per the SetDisplayConfig contract a path being turned off must
have BOTH mode indexes set to DISPLAYCONFIG_PATH_MODE_IDX_INVALID; some
driver/topology combinations (notably when the doomed path shares a source
with the kept virtual path) reject the supplied config outright, so the
retry loop could never converge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remote INPUT resets the compositor's idle timers, but a passive
(video-only) viewer sends none — observed live: a SteamOS Game-Mode
host s2idled mid-day and dropped off the network (in a VM with GPU
passthrough it never woke again). Refcounted across planes: the native
LiveSessionGuard and the GameStream video worker each hold a share;
first hold takes the logind sleep:idle block inhibitor on a dedicated
plain thread (zbus blocking must not run on a tokio worker), last drop
releases. Best-effort: no logind → warn once and stream on. No-op off
Linux (macOS/Windows manage their own assertions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The raw-client-pixels fallback is exact only while the managed session
runs at the client's mode. When they diverge — foreign-gamescope
attach at its own resolution, supersample/under-render, transitions —
raw pixels drift or land out of range. The EIS relay file now carries
the session's current output size as a second "WxH" line (from
current_gamescope_output_size()); the injector scales normalized
client positions into it, keeping raw pixels only as the last resort.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous commit's no-region fallback never fired: gamescope DOES
advertise a region — (0,0,INT32_MAX,INT32_MAX), meaning "coordinates
are raw". Mapping normalized positions into it explodes a center tap
to x≈1e9, clamped by gamescope to the far corner — the cursor pinned
at (1279,799) and every degraded-touch tap landing there. Treat a
region as an output geometry only when plausibly sized (≤16384 px);
otherwise emit raw client pixels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gamescope's "Gamescope Virtual Input" advertises pointer_abs but no
region, so every MouseMoveAbs (and the degraded-touch moves built on
it) was silently dropped (emitted=false) — clicks then landed at a
stale cursor position. With no region, the managed session runs at the
client's mode, so client pixels are output pixels: emit them raw.
Also: log region count/dims at device-resume, and add
PUNKTFUNK_INPUT_TEST_ABS=WxH to `input-test` (corners + center, 1s
apart) so the degraded-touch path is verifiable with xdotool alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gamescope's EIS ("Gamescope Virtual Input") advertises pointer/
pointer_abs/button but never a touchscreen — so in Game Mode every
remote TouchDown/Move/Up was dropped on the floor (Desktop Mode works
via KwinFakeInput, which does touch). Headless KWin's libei has the
same gap. Instead of dropping: degrade to a single-finger absolute
pointer — down = abs-move + left press, move = abs-move, up = release —
synthesized through the normal Mouse* inject paths so region mapping,
held-state tracking, and release_all apply. First finger drives the
pointer; later fingers are ignored (a pinch degrades to a drag). A
touchscreen device appearing later takes over on the next touch —
checked per event, never latched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- scripts/steamdeck/install.sh (§2b) + update.sh: build and install the
scripting runner user-scoped (~/.local wrapper + pinned bun + bundle) —
SteamOS's read-only /usr can't take the .deb layout, so the console's
plugin store reported "the plugin runner isn't installed" on every
SteamOS host. update.sh retrofits it onto existing installs.
- plugins.rs runner discovery: check the ~/.local layout after the /usr
ones; mention the SteamOS path in the not-installed error.
- update.sh: define warn() — it was used but never defined, so under
`set -e` the first warn aborted the update before services restarted.
- pf-client-core gamepad: a Steam Input virtual pad forwarded on a real
Deck (the only-pad case) now declares the DECK kind in its per-pad
arrival instead of the wrapper's Xbox 360 identity — the session-level
auto_pref already resolved SteamDeck, but the arrival overrides it on
current hosts.
- reject tests: cover SetupFailed; move the foreign-code probe off 0x68.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three fixes for the "most connects simply fail" trap on a SteamOS host
with no physical display (VM testbox, panel-less mini PC):
- restore path: skip the restore-to-physical-panel when no DRM connector
reports a connected display — removing the headless drop-in and
restarting gamescope-session.target on such a box just crash-loops
gamescope and strands every later connect on "no usable compositor".
Keep the headless session (and the takeover state) instead.
- connect path: when no live graphical session exists but the MANAGED
gamescope infra is present (SteamOS gamescope-session / Bazzite
session-plus), route to the gamescope takeover — which rebuilds the
session at the client's mode — instead of failing the connect.
- error delivery: a session-setup failure used to just drop the QUIC
connection, reaching the client as "control stream finished mid-frame"
(indistinguishable from transport trouble). Close with a typed
setup-failed code (0x68, RejectedSetupFailed = -29) carrying the error
text, and give the client handshake/pairing error paths a short grace
for the CONNECTION_CLOSE to beat the stream error it caused.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lifts the ~100 Mbps decrypt ceiling on clients without hardware AES — the
armv7 soft-AES targets (webOS TVs), where AES-128-GCM resolves to fixsliced
software AES + software GHASH (~50-100 cpb) while ChaCha20-Poly1305's ARX
construction runs ~10-17 cpb portable, a 4-7x lift that PyroWave-on-TV needs
(design/chacha20-session-cipher.md).
Phase 1 (core crypto, no wire change): SessionKey merges cipher choice and
key material (invalid combinations unrepresentable, zeroize + redacted-Debug
discipline kept); SessionCrypto dispatches both aead-0.5 ciphers per call —
the salt||seq nonce scheme, per-direction salts, seq-as-AAD and replay
window carry over verbatim (same 96-bit nonce / 16-byte tag, const-asserted).
Config.key becomes SessionKey; validate's zero-key rejection follows the
active variant. The C ABI keeps its fixed 16-byte key mapped to AES — no
ABI_VERSION bump.
Phase 2 (negotiation): VIDEO_CAP_CHACHA20 (0x40) — support-plus-request in
one bit, the VIDEO_CAP_444 precedent. Welcome grows cipher@68 +
key_chacha@69..101, emitted only when non-zero so an AES session's Welcome
stays byte-identical to the pre-cipher form; decode is fail-closed (short
key or unknown id -> Err, never a silent AES fallback). No WIRE_VERSION
bump; downgrade resistance inherited from the pinned-TLS control channel.
Phase 3 (host): grant only when the client advertised the bit and the
PUNKTFUNK_CHACHA20 kill-switch (default on, documented) allows; fresh
32-byte per-session key from the same RNG discipline, legacy key field
stays independently random; resolved cipher logged at session start.
Verification: seal/open suites parameterized over both ciphers + a
cross-cipher tamper case; Welcome roundtrip/truncation/fail-closed tests;
ChaCha lossy-loopback soak (loss/replay is cipher-independent); bench
gains _chacha20 series (AES ids unchanged for CI history) — host-side
sealing line-rate-trivial on both x86 (~640 MiB/s) and Apple Silicon
(~535 MiB/s). punktfunk-probe drives the interop matrix via
PUNKTFUNK_CLIENT_CHACHA20=1 and logs the negotiated cipher.
Phase 4 (pf-webos pin bump + unconditional cap bit) follows the next
core release.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The PUNKTFUNK_PIN_CLOCKS clock pin (AMD power_dpm_force_performance_level=high / NVIDIA nvmlDeviceSetGpuLockedClocks) was armed once at host start and released only at host exit, so the box's clocks stayed pinned the whole time the host ran — even with no client connected.
Move the pin behind a box-wide refcount (gpuclocks::session_pin()) shared across both streaming planes (native + GameStream): it arms on the first live client and releases on the last disconnect. on_host_start() now only installs the process-scoped NVIDIA P2-cap application profile. No behavior change when PUNKTFUNK_PIN_CLOCKS is unset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Launching from the Decky plugin against a sleeping host fired the
Wake-on-LAN packet and dialed immediately — but the dial was a single
quinn attempt that gave up after the transport's 8 s idle window, far
shorter than a suspend-to-RAM resume. The host woke to find the client
already gone (its retransmitted Initials show up host-side as
"QUIC accept failed error=timed out"), the stream shortcut exited, and
the launch looked cancelled.
- punktfunk-core: dial in a loop until the embedder's connect budget is
spent — short 3 s attempts keep the Initial cadence dense, so the
connect lands within ~a second of the host's network returning. Only
silence is retried: pin mismatches, typed rejections, and any real
answer surface immediately, and the shutdown flag stops the loop.
- decky: stretch the budget to 75 s when a magic packet actually went
out (PF_CONNECT_TIMEOUT → --connect-timeout via the wrapper), so the
retry window covers the resume; an awake host still connects in <1 s.
- host: a clean close before the control handshake is a reachability
probe (hosts-page online pips), not a failed session — log it at
debug instead of WARN "session ended with error", which buried the
real failures in the reporter's log.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flip both zero-copy levers from opt-in to default:
- The capture negotiation now PREFERS gamescope's producer-side NV12 pod by
default (PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation).
Codec-aware gating rides a new OutputFormat::nv12_native ->
ZeroCopyPolicy::native_nv12_session edge resolved by the host facade from
the session plan (pf_encode::linux_native_nv12_ok): only H265/AV1 sessions
whose backend can open the raw Vulkan Video encoder ever see the NV12 pod
-- an H264/Moonlight session (libav VAAPI, which would misread the
two-plane buffer) keeps today's BGRx negotiation, as do the
GameStream-resolve and portal-mirror paths, PyroWave, and NVENC prefs.
- RGB-direct's unaligned modes default to the true-extent direct import
(PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT=0 restores the padded-copy staging).
Guarded-tested on Van Gogh with the kernel journal watched: clean, and at
5.38 ms p50 the fastest 1080p encode path measured on that hardware. The
EFC only exists on Mesa >= 26, where the codedExtent-driven session_init
padding is guaranteed (verified back to Mesa 24.2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opt-in alternative to RGB-direct's padded-copy staging at unaligned modes:
direct-import the visible-size capture and declare the TRUE-SIZE source
codedExtent. RADV has derived the VCN session_init firmware padding from
srcPictureResource.codedExtent since Mesa 24.2, so the EFC is told the source
lacks the alignment rows and the hardware edge-extends them internally --
which also reframes the 2026-07-20 field GPU reset: that crash passed the
ALIGNED extent as the source codedExtent (firmware padding zero) over a
visible-size buffer. Off by default until a guarded live test proves the EFC
front-end honors the padding like the YUV fetch path does; the padded-copy
staging remains the shipped behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop the padded-copy staging for producer-native NV12 and direct-import the
visible-size buffer at unaligned modes (1080p) too. Safe by the driver's own
contract rather than by allocation luck: native sessions now author the H265
SPS / AV1 sequence header at the RENDER size and pass the matching codedExtent
on every picture resource. RADV rounds the bitstream SPS up itself (with a
conformance window -- radv_video_patch_encode_session_parameters, per the
VK_KHR_video_encode_h265 proposal's "implementations may override" clause) and
programs the VCN session_init with the true extent plus nonzero firmware
padding, so the hardware edge-extends the alignment rows internally and never
fetches past the source's real extent -- the exact mechanism VAAPI/radeonsi has
always used to encode 1080p. The driver-emitted header NALs already carry the
patched SPS, so the wire format is unchanged.
The CSC and RGB-direct paths keep the app-aligned convention (their sources
genuinely cover the aligned extent); RGB padded-copy staging is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Software decode uploads swscale RGBA with no CSC/tonemap pass. On the new
desktop-compositor HDR10 surfaces (GNOME 48 / Plasma 6 + Mesa >= 25.1 offer
ST2084 even on SDR desktops) that sRGB-encoded content was composed as PQ —
the field-reported psychedelic picture (Fedora clients decode HEVC in
software because stock Mesa strips it; reproduced + fix live-validated on
GNOME Wayland: mode-0 soup -> HDR→SDR washed-out-but-correct).
The CPU lane keeps the SDR swapchain and its known untonemapped-PQ gap; a
real PQ→sRGB pass for CPU frames is the follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SDL_APP_ID = io.unom.Punktfunk — compositors match it against the shipped
.desktop for the window icon; without it KWin/mutter show the generic Wayland
icon (field-noticed on the Deck). Linux analog of win32 AppUserModelID.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GNOME 48 / Plasma 6 with Mesa >= 25.1 now offer HDR10_ST2084 surfaces even on
SDR desktops, silently flipping PQ streams into the mode-0 passthrough lane —
previously unreachable on desktop compositors and never validated there. The
hatch pins the shader tonemap for diagnosis and as the field escape.
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>
With PUNKTFUNK_PIPEWIRE_NV12=1 (bring-up gate), the PipeWire negotiation
offers an NV12 LINEAR DMA-BUF pod (BT.709 limited pinned MANDATORY) ahead
of the BGRx one, and gamescope's producer-side RGB->YUV pass replaces the
host CSC entirely: the encoder imports the two-plane buffer as a profiled
VIDEO_ENCODE_SRC image and the VCN encodes it directly. Contributed
measurements: encode p99 2.9 ms (from ~4-4.5 ms via EFC RGB-direct),
60 fps capture, 0 send drops.
Hardening on top of the contributed patch:
- unaligned modes (1080p!) stage through a padded aligned NV12 copy (edge
rows/columns duplicated, transfer-only) instead of direct-importing the
visible-size buffer -- a direct import would make the VCN read past the
producer allocation, the exact OOB class behind the 2026-07-20 field GPU
reset; the encode extents return to the aligned coded extent everywhere
- the UV plane layout honors the producer's plane-1 chunk (offset/stride)
when the SPA buffer carries one (same-BO verified by inode), with the
contiguous-plane contract as fallback
- PyroWave sessions are excluded from the gate (their Vulkan compute CSC
ingests packed RGB), and a native-NV12 session that resolves to libav
VAAPI (H264 codec, PUNKTFUNK_VULKAN_ENCODE=0, feature off, or a failed
Vulkan open) refuses at open instead of streaming garbage chroma
- pad staging images carry TRANSFER_SRC (the width-padding pass self-copies
the staging image -- previously missing on 1366-wide modes)
- metadata-cursor one-shot warn (parity with RGB-direct) and a padded-NV12
PUNKTFUNK_PERF split label; the padded RGB copy gets its timestamps too
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
settle_desktop_portal pushes the live desktop's WAYLAND_DISPLAY into the
systemd --user manager (import-environment) so a re-activated portal inherits
the right session — but the import PERSISTS after that desktop dies. Every
later user unit then inherits the stale socket, including
gamescope-session.target: gamescope sees WAYLAND_DISPLAY, runs NESTED, and
aborts with "Failed to connect to wayland socket: wayland-0" — observed live
on a Deck, where Game Mode could not start at all (autologin crash-looped)
until the var was unset by hand.
Two-layer fix, mirroring the protection launch_session already gives its
transient unit:
- observe_session_instance: when a desktop compositor instance goes away,
unset WAYLAND_DISPLAY/DISPLAY from the manager env (a desktop bounce is
harmless — the next portal settle re-imports).
- write_steamos_dropin: UnsetEnvironment=DISPLAY WAYLAND_DISPLAY on the
takeover drop-in, unit-scoped belt-and-suspenders for host-initiated
restarts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Observed live on a Deck host: the user switched Game Mode → Desktop, KDE came
up in 0.8 s — and the capture-loss rebuild's FIRST detection ran inside that
gap, still said Gaming, and its gamescope re-acquire restarted
gamescope-session.target. On SteamOS that steals the seat: the user was
yanked out of the KDE session they had just chosen, the two session managers
fought (job canceled / dependency failed), no gamescope node appeared within
30 s, and the stream died at the 40 s rebuild budget.
A rebuild probe acting on possibly-stale detection must never stop, relaunch,
or take over box sessions. New pf_vdisplay::rebuild_probe_scope (RAII,
counted): while held, the gamescope managed/takeover create paths only attach
to a live node and fail fast otherwise — no stop_autologin_sessions, no
session-plus relaunch, no gamescope-session.target restart. The capture-loss
loop holds it for the first 4 s after a loss (the detection-ambiguity
window); after that, destructive rebuilds are allowed again, so a genuine
switch INTO Game Mode still gets its headless takeover. Probe failures are
instant, so the loop now paces itself at ~2 Hz instead of spinning.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two session-transition stalls found live on a SteamOS Deck host, one cause:
the pipeline retry loop couldn't tell a wait-it-out failure from a
retry-now-and-it-works one.
- Gamescope first-frame race: a PipeWire stream connected while gamescope
re-initializes its headless takeover negotiates a format, reaches
Streaming, and never receives a buffer — while a fresh connect delivers
within ~0.5 s. Every gamescope bring-up ate the full 10 s first-frame
budget on attempt 1 (17 s bring-ups; KWin: 1.2 s). The retry loop's FIRST
attempt now waits 2.5 s (Capturer::next_frame_within), so the reconnect
that fixes the race happens at ~3 s. Later attempts keep the patient 10 s
— the documented 30-60 s Big Picture cold start still fits the budget.
- Capture-loss rebuild vs session switch: the rebuild loop re-detects the
active session between build_pipeline_with_retry calls, but each call
burned 8 attempts (~13 s) against a compositor that no longer exists (a
Desktop→Gaming switch spent 13 of its 27 s retrying gone-KWin). The
capture-loss path now passes max_attempts=2, turning the outer loop into
~1 s detect-and-retry cycles that follow the box to the new session.
The resize path's direct build_pipeline call keeps the default budget (no
retry wrapper there to absorb an early bail).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The guard checked the package NAME shape, and `@punktfunk/plugin-kit` — the
framework every kit-built plugin depends on — matches `@scope/plugin-*`
exactly. Windows on-glass accepted an uninstall of it; bun no-ops removing a
non-dependency so nothing broke, but the store was offering to pull a library
out from under the plugins using it.
Require membership in the top-level dependency list, the same authority that
already keeps plugin-kit out of the installed listing. Same blind spot, two
call sites — the listing was fixed during implementation, this one wasn't.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gates recorded (nvenc-subframe-slice-output.md Phase 3): loss-harness curve
identical streamed-vs-whole; adversarial security review clean; live .21
3-leg A/B — streamed 0 corrupted frames, p99 8527→5363 µs vs sliced-whole,
first_slice_us ~500 µs of a ~1250 µs encode reaching the send thread; wire
bytes rate-governed under CBR + 1-frame VBV (the ~1-2 % slice-header cost is
absorbed by rate control, not added to the wire). GameStream: plane has zero
code diff (own RTP packetizer; shares only untouched send_pacing); a live
Moonlight re-test joins the standing owed Moonlight item.
- resolve_slices(codec, default): env 1..=32 wins (1 = the NEW explicit
single-slice escape), else the backend default — 4 on Linux direct-NVENC,
1 elsewhere. resolve_subframe(default_on): PUNKTFUNK_NVENC_SUBFRAME
tri-state (0 = never, 1 = force, unset = backend default).
- Linux nvenc_cuda: resolves both ONCE in query_caps — the sub-frame default
is gated on the GPU's SUBFRAME_READBACK cap so an unsupporting GPU never
has enableSubFrameWrite forced into its init params — and feeds the same
resolved values to build_config, build_init_params (open AND in-place
reconfigure) and the chunked-poll latch.
- Windows: env-only as before (async path untouched, byte-identical config
for unset env); LowLatencyConfig.slices + the explicit subframe param
replace the shared env reads.
- Tests: chunked e2e now runs at the DEFAULTS (no knobs); the fallback test
becomes the escape test (SLICES=1 disarms; SUBFRAME=0 disarms while
4-slice encode continues on the plain poll path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bootstrap key generated during implementation was lost, and its
replacement was pasted into the wrong repository's secret store — Gitea
secrets neither cross repos nor read back, so neither is usable. The private
half of this one lives in punktfunk-plugin-index's own INDEX_SIGNING_KEY,
which is the only thing that signs the catalog.
Nothing has shipped pinning any of these, so this is a straight replacement
of slot 0; the second slot stays reserved for a genuine rotation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
B2 default-on (design/vulkan-rgb-direct-encode.md): wherever the probe
passes, Vulkan Video sessions now take the EFC RGB source by default —
direct import on aligned modes, padded-copy staging on unaligned ones
(1080p). PUNKTFUNK_VULKAN_RGB_DIRECT becomes the override: =0 disables,
=1 forces, unset = the default below.
The one session class that must NOT default on: cursor-as-metadata
captures (every non-gamescope compositor), where the CSC shader's blend
IS the visible pointer — the EFC cannot composite, and defaulting there
would silently drop the cursor from the stream. The hint rides the
existing plumbing:
- SessionPlan gains cursor_blend, resolved once where the compositor is
known (gamescope embeds the pointer itself → false; kwin/mutter/
wlroots/hyprland → true), and shows up in the logged plan line.
- open_video/open_video_backend thread it through (native pump: all
three encoder-open sites read plan.cursor_blend; GameStream monitor
capture: true — it negotiates metadata cursor; spike: false).
- VulkanVideoEncoder::open resolves: env override, else ON iff the
session never hands us cursor bitmaps. The warn-once for a cursor on
an RGB session (forced via =1) stays.
Verified on-hw box (Linux): pf-encode + punktfunk-host compile, clippy
clean, unit suite green. The GPU paths themselves are unchanged from the
smoke-validated 96e19986 — this commit only changes which sessions
select them by default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review verdict: no exploitable memory-safety / amplification / splice /
resurrection issue; nonce order and two-lane seal byte-identical for the
legacy path. Actionable findings applied:
- explicit per-block data_shards cross-check next to the recovery_shards
one (defense-in-depth — the geometry invariants make it unreachable
today, but have_data indexing assumes it)
- partial delivery skips an UNPINNED streamed frame (frame_bytes still 0)
instead of truncating its max-sized buffer to an empty "partial"
- regression tests for the two untested load-bearing guards: the
final-first out-of-range-sentinel drop (the exact-sized-buffer guard)
and second-final-with-different-totals rejection
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wire half of sub-frame slice output (latency §7 LN1, planning
design/nvenc-subframe-slice-output.md Phase 2): toward a client that
advertises the new cap bit (0x20), a chunked-poll encoder session ships each
AU's completed FEC blocks while the tail of the frame is still encoding —
the AU's last packet leaves the host as the encode finishes instead of
after it.
Wire semantics (negotiated; zero change for anyone else):
- Non-final blocks ride SENTINEL headers: block_count = 0 (a value no legacy
sender emits) + frame_bytes = 0 + exactly max_data_per_block data shards,
so the receiver's shard-offset formula needs no total.
- The final block's headers carry the real frame_bytes/block_count (+
FLAG_EOF) and RETRO-VALIDATE the whole frame: totals under which a
received sentinel block is out of range or not full-K kill the frame
wholesale (no spliced delivery) and the index can't be resurrected.
- Firewall: sentinels are bounded by the negotiated limits (full-K exactly,
never the last block the limits allow, no total to lie about); the exact
derived-geometry check runs unchanged on every non-sentinel packet and
retroactively at pinning. Sentinel opens commit a max_frame_bytes buffer,
bounded by the existing IN_FLIGHT_BUF_FACTOR budget (amplification test).
- Order-agnostic like legacy: a reversed frame (final block first) opens
legacy-shaped and still accepts its sentinels against the pinned totals.
- Small/empty streamed AUs degenerate to byte-identical legacy headers.
Host: Packetizer::{begin,push,finish}_streamed seal full-K blocks (data +
parity per block) as chunks arrive; Session::seal_streamed_* share the
pooled-wire + two-lane seal machinery via the new seal_run; the send thread
paces each flush under the frame's existing deadline (pace_sealed split out
of paced_submit) and runs the whole-AU accounting at the last chunk; the
encode pump forwards poll_chunk output as ChunkMsg when the client has the
cap AND the encoder chunks (re-queried per AU — an escalation falls back
seamlessly). Probes never run mid-AU. PUNKTFUNK_STREAMED_AU=0 = host escape
hatch. Client core ORs the cap into Hello (the shared reassembler carries
the support). Sampled first_slice_us vs encode_us PERF log measures the
overlap; the 0xCF stage-field extension stays a follow-up.
Core tests: streamed round-trip (clean/loss/reorder/duplicate, both orders),
sentinel firewall bounds, lying-final wholesale kill + no-resurrect,
open-amplification budget, header-shape pins. Gates still owed before
default-on: security review pass, loss-harness curve, GameStream smoke
(plane untouched structurally), bitrate A/B.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>