A client setting is global today: pick 4K@120 HDR and the retro box gets it too.
The one escape hatch (per-host `clipboard_sync`) proves the shape works but covers a
single field. This is P0 of design/client-settings-profiles.md — the headless core the
shells, Apple, Android and the deep-link grammar all build against. No UI yet.
A profile is a NAMED BUNDLE OF OVERRIDES, not a snapshot: `SettingsOverlay` is sparse
`Option`s, so a field the user never touched keeps following the global value live, and
fixing a global once fixes it everywhere. `Some(x)` equal to today's global is still
meaningful — it pins x against a later global change — which is why the UI will write
`Some` on touch and `None` on an explicit reset, never by diffing.
The catalog is its own `client-profiles.json`, deliberately not part of the settings
file: that file has five whole-file load-modify-save writers with no merge, so a profile
written by one would be dropped by the next. Which host uses which profile is a field on
the host record instead of a map keyed by host — that is what dissolves the client's
long-standing "what identifies a host record" question for this feature.
Resolution has exactly one implementation, `trust::effective_settings()`:
effective = overlay(profile).apply(global)
profile = one-off pick ?? host binding ?? none
Both session construction sites go through it (`main.rs` AND `console.rs` — touching one
and not the other is a Windows-only build break), so the console, and therefore Decky,
honor host bindings with no work of their own. Nothing here can fail a connect: a
deleted binding resolves as "no profile", and a one-off that can't be honored falls back
to the DEFAULTS rather than to the host's own profile — "connect with Work" must never
quietly stream "Game". `--profile <id|name>` is the one-off door for the shells' coming
"Connect with ▸" and for `punktfunk://…&profile=`; `--profile ""` forces the defaults on
a bound host. The active profile's name closes the stats overlay's first line, so
"which profile am I on?" is answerable without leaving the stream.
Also here, because this effort touches every one of these anyway:
- `KnownHost` gains `profile_id`, `pinned_profiles` and a lazily minted stable `id`. The
id is groundwork (design §4.5) — no lookup is re-keyed, `fp_hex`/`addr:port` stay.
- `upsert` now preserves the state the USER set on a record — the profile binding, its
pins, the clipboard decision, the id — against refreshes that carry none of it.
`clipboard_sync` survived that only because the function happened not to mention it.
- `KnownHost::default()` exists so construction sites read `..Default::default()`; five
hand-written literals were exactly how a new field gets silently dropped on re-pair.
- All three client stores now write temp+rename. A torn settings file loads as `Default`
— i.e. silently resets every setting the user has.
Co-Authored-By: Claude Opus 5 (1M context) <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>
The two-pair investigation (wired Mac clients stuck at a rock-steady
~18-19 ms "network" that survived the load ending and cleared only on
reconnect) exposed two structural gaps, one of measurement and one of
recovery:
- Receipt was stamped at the hand-off PULL (Swift nextAU, pf-client-core,
Android decode loops), not at reassembly completion — so any client-side
standing state between the reassembler and the pull read as NETWORK
latency, undiagnosable from the HUD. ABI v9: `PunktfunkFrame`/`Frame`
grow `received_ns`, stamped by `Session::poll_frame` as the AU crosses
the session boundary. Every embedder now uses the core stamp; the Apple
client keeps the pull instant as `AccessUnit.pulledNs` and shows the
receipt→pull wait as its own "client queue" term (detailed HUD tier from
2 ms + a `queue_p50` stats-log field). Decode stages keep their pull
anchor on all platforms, so no historical stage shifts meaning.
- The jump-to-live detectors deliberately ignore anything under 6 queued
frames / 400 ms behind — so a small, constant, loss-free elevation (a
sub-frame standing backlog, or a stale clock offset after a wall-clock
step/slew) is carried for the rest of the session. New third detector
(`StandingLatency`, unit-tested ladder): window-MIN one-way delay
≥ 10 ms above the session floor with zero loss for ~4.5 s escalates
gently — a free clock re-sync first (an applied re-sync re-bases the
floor), then at most 3 flush+keyframe bleeds sharing the jump-to-live
cooldown, then a loud disarm naming what it means. Loss windows reset
the run: congestion belongs to FEC/ABR, not this detector.
Also: mid-stream re-sync apply/discard logs debug→info — they are the
forensic trail for the stale-offset case and were invisible in the field.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bridge landed always-on whenever the host permitted it; sharing a
clipboard is a trust decision about a specific host, so it needs to be
opted into. Mirrors the Apple client's per-host model
(StoredHost.clipboardSync, "Share clipboard with this host") rather than a
global switch: KnownHost::clipboard_sync, toggled from the host card's
overflow menu, default off.
The session binary resolves the stored flag itself in session_params, so a
direct connect and the console's own launches honor the same decision
without every caller having to remember to pass it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The protocol half has been in punktfunk-core since the clipboard work
landed -- the per-session fetch task, plus clip_control/clip_offer/
clip_fetch/clip_serve/next_clip on NativeClient -- but only the Apple
client ever drove it, through the C ABI. The Windows and Linux clients link
the core directly and simply never wired it, so copy-paste between host and
client worked on a Mac and nowhere else.
This adds the missing half: the OS-clipboard bridge, on its own session
thread beside the audio one.
Local -> remote stays lazy by construction, as the design asks: a
GetClipboardSequenceNumber poll spots a local copy and we announce only the
FORMAT LIST; bytes are read when (and only when) the host actually pastes
and sends a FetchRequest.
Remote -> local is EAGER in this first cut, which is a deliberate deviation
worth naming. macOS gets laziness free from NSPasteboardItemDataProvider;
the Windows equivalent is delayed rendering, which needs a clipboard-owning
window running its own message pump. So we fetch on the offer and place real
bytes under a 4 MB cap -- text always crosses, a large image is skipped
rather than pulled for a paste that may never come. Delayed rendering lifts
the cap later.
Echo suppression is the design's Windows rule verbatim (capture the
sequence number right after our own SetClipboardData); without it every
copy ping-pongs between the machines forever. Content marked
ExcludeClipboardContentFromMonitorProcessing -- what password managers set
-- is never announced and never served.
Text and PNG for now. Apps that publish only CF_DIB need the conversion the
host already has. Linux keeps a stub: the bridge itself is
platform-neutral and will drive a data-control seam unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The decoder was gated to Linux because, when it landed, the Windows client
still had its own in-process WinUI/D3D11 presenter and the PyroWave present
path there was an open question. That client has since been retired: Windows
now spawns the SAME Vulkan session presenter as Linux, and the decoder is
plain Vulkan compute on the presenter's device (no fds, no dmabuf, no D3D11
interop), so the question that gated it answered itself. pyrowave-sys already
builds on Windows too -- the Windows HOST encoder ships on it.
So this is a port by un-gating: every cfg(all(target_os = "linux", feature =
"pyrowave")) becomes any(linux, windows) -- decoder module, backend variant,
Decoder::new_pyrowave, the CODEC_PYROWAVE advertisement, the session pump's
opt-in/build/label arms, and the presenter's planar CSC pass. No new code.
Then offer it in the Windows GUI, which is what prompted this. It stays
preference-only (resolve_codec never auto-picks it) and a host or device that
can't do PyroWave just falls back down the ladder to HEVC.
Co-Authored-By: Claude Fable 5 <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>
Phase 1 of design/pyrowave-444-hdr.md. No behavior change yet: the handshake's
4:4:4 gate now admits PyroWave (probe = can_encode_444(codec), capture gate
inherently satisfied — the wavelet path always ingests an RGB source and does
its own CSC), but can_encode_444 stays false for PyroWave until the per-OS
full-res-chroma CSC variants land (Phase 2 Linux, Phase 3 Windows), so every
session still resolves 4:2:0/8-bit.
- Both host encoders take the negotiated ChromaFormat (bail on 444 for now);
the PUNKTFUNK_ENCODER=pyrowave lab override pins 4:2:0.
- Bitrate: the automatic ~1.6 bpp pin resolves AFTER depth+chroma and scales
x1.625 for 4:4:4 / x1.15 for 10-bit (factors from the Phase-0 fixture
matrix); the mid-stream mode-switch re-resolve threads the session's values.
- Client: PyroWaveDecoder builds its plane ring (full-res chroma when 444) and
creates the upstream decoder from the negotiated chroma, keeps chroma fixed
across mid-stream resizes, drops the even-dims requirement for 444, and
returns the negotiated Welcome ColorInfo as the frame colour contract
instead of hardcoded BT.709 (the wavelet bitstream has no VUI).
Verified on .21 (RTX 5070 Ti): clippy -D warnings (host+client+encode), host
186 tests, client + pf-encode tests, fmt, and the pyrowave_smoke GPU
round-trip through the patched vendored lib (97cf15e3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Local snapshot of intermingled in-flight work, committed to unblock the encode
refactor (a clean ffmpeg_win.rs for the vbv-dedup follow-on). These hunks span
the same files and can't be cleanly split here; the commit bundles three
distinct workstreams that each belong in their own PR:
- logging rework (~43 files: level re-tiering, structured fields, `?e`,
hot-path flood latches)
- conflicting-host detection (detect.rs + detect/{linux,windows}.rs + wiring
in main.rs/mgmt.rs/Cargo.toml/docs/packaging)
- standby-sink DWM-stall attribution (windows/display_events.rs + capture/
vdisplay wiring)
NOT verified as a combination. NOT to be pushed until the refactor is done and
these are re-verified and reorganized into their proper per-workstream PRs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends 56f9c8c4 (the Automatic-bitrate decode signal, core + Android) to the remaining
clients, so every platform caps Automatic at its real decoder limit instead of the network
link ceiling — the fix for a fast LAN feeding a slower hardware decoder.
- core/abi: punktfunk_connection_report_decode_us + _wants_decode_latency expose the
NativeClient methods to the C-ABI embedders (regenerated punktfunk_core.h, additive only).
- apple: PunktfunkConnection wrappers + Stage2Pipeline reports received→decoded from the
VideoToolbox decode-completion callback — every decoded frame, before the newest-wins ring
can drop the backlog. Stage-1 (AVSampleBufferDisplayLayer, no per-frame decode callback)
stays network-only; stage-2 is the metered path.
- windows/linux: the shared punktfunk-session client (pf-client-core) links core directly, so
it calls the NativeClient methods — report received→decoded from the pump, gated on
wants_decode_latency. Exact for the synchronous D3D11VA/software decode; received→submit
(still the decoder-input backpressure signal) for the async Vulkan-Video path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- clients/apple: native Metal wavelet decoder + compute shaders (Phase 5),
decoding PyroWave without embedding MoltenVK.
- pf-client-core: plumb user_flags/completeness through Decoder::decode_frame
so the PyroWave backend parses chunk-aligned + partial AUs; gate the param's
unused-warning to exactly the non-pyrowave builds (fixes -D warnings on the
featureless Linux client build).
- punktfunk-host: on a mid-stream mode switch, re-resolve the "Automatic"
PyroWave bitrate for the new mode's ~1.6 bpp operating point (explicit rates
and H.26x ABR stay put); reject sub-128px PyroWave modes before the encoder
rebuild instead of after the ack.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PyroWave AUs now packetize on the negotiated shard payload, so a lost datagram
costs a few wavelet blocks of localized blur rather than a whole frame — and the
client can render an aged-out lossy frame instead of freezing until the next one.
Host (opt-in, PyroWave only):
- The encoder packetizes at the shard payload behind a 4-byte window prefix
(used-len u16 + kind u16). Whole packets pack into WIN_PACKED windows; a packet
too large for one shard (PyroWave 32x32 blocks are atomic and can exceed a
shard) rides a WIN_FRAG_FIRST/CONT/LAST chain. `set_wire_chunking()` joins the
Encoder trait (forwarded through TrackedEncoder — the silent-no-op trap);
EncodedFrame.chunk_aligned marks the AU.
- virtual_stream tags the AU with USER_FLAG_CHUNK_ALIGNED and re-applies chunking
after every encoder (re)build, the adaptive-bitrate rebuild included.
Core:
- USER_FLAG_CHUNK_ALIGNED (0x40) wire bit. Reassembler opt-in
(set_deliver_partial): a chunk-aligned frame that ages out with holes is handed
over as Frame{complete:false} — received shards at their exact offsets, missing
ranges zero-filled — instead of being dropped. Partials age out on a tight 30ms
fuse (PARTIAL_WINDOW_NS) instead of the 120ms loss window: each frame is
independently decodable, so an ancient partial has no value in a live stream.
Newest-wins. A partial still counts as dropped for loss reporting.
Client (PyroWave decode):
- The session opts in when codec == PyroWave. The decoder walks the AU
window-by-window, skipping zero (missing) windows and reassembling FRAG chains,
then decodes whatever survived. A newest-decoded-index guard drops partials the
pump has already moved past (no time-travel present).
Also fixes a redundant-closure clippy nit in the PyroWave planar-present path.
Validated on an RTX 5070 Ti under 2% netem loss with FEC pinned off: 60fps
sustained entirely via partials, e2e 43ms p50 (146ms before the fuse) vs 23ms
lossless, no keyframe-recovery chatter. Tests green: core 149, host 310 + the
GPU-gated encoder smoke (framed-window walk + FRAG reassembly + upstream
round-trip), client 26; clippy clean on the pyrowave feature combos.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A host's pairing-gate rejections (not armed / bound to another device /
rate-limited / identity required / denied / approval timeout / superseded /
wire-version mismatch) used to drop the connection with a bare code-0 close,
and every client collapsed that — plus plain unreachability — into one
"wrong PIN / not accepted" message. A dead network path, a disarmed host,
and an operator denial were indistinguishable, which is exactly the
misdiagnosis behind the recent Android pairing support thread.
- core: new ungated `reject` module — shared close-code block 0x60–0x67
(+ 0x42 busy promoted from the host), `RejectReason`, and
`PunktfunkError::Rejected`; `pair()`/`connect()` decode the host's
ApplicationClosed code into `Rejected` instead of a generic Io error.
C ABI v7: status block −20…−28 and `punktfunk_connect_ex8` (`status_out`
reports the failure cause; NULL-return alone can't). Wire unchanged —
old peers see exactly the old bare close.
- host: every gate rejection `conn.close()`s with its typed code (and the
human reason as close bytes) before erroring out of the session task.
- pf-client-core: shared `pair_error_message`/`connect_reject_message`
wording consumed by the Windows + Linux + console-UI + CLI surfaces; a
connect failure now renders the host's stated reason.
- android: `nativeTakeLastError()` JNI token + `ConnectErrors.kt` — a
network timeout is no longer reported as "wrong PIN, or the host isn't
armed", and a typed rejection skips the wake-and-wait fallback (the host
is demonstrably awake).
- apple: `HostRejection` + `.rejected`; the pair sheet and session alerts
show the stated reason; connect moves to `ex8`.
Completes the cross-client half of the hunks that rode along in 12148243
(client.rs / trust.rs / punktfunk1.rs) — main did not build without this.
Validated: workspace clippy -D warnings + full test suite green on .21
(EXIT=0, 309 host / 148 core suites); macOS core 147+c_abi green; swift
build green; Android Kotlin + native crate green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ci.yml's -D warnings clippy (default features) flagged the labeled block
whose only break lives behind the pyrowave cfg — restructured as cfg'd
let-bindings, no label. Also boxed Backend::PyroWave (the decoder's
pinned create-info hold + plane ring dwarfed the other variants —
clippy::large_enum_variant under the feature).
Both configs strict-clippy clean on .21; 26 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pump now advertises decodable_codecs_for(presenter device) — the
CODEC_PYROWAVE bit rides only when the device passed the compute-feature
probe — and PUNKTFUNK_PREFER_PYROWAVE=1 is the Phase-2 lab opt-in that
names the codec in preferred_codec (the only route resolve_codec will
take it, plan §3; a Settings toggle is Phase-3 productization). A
negotiated PyroWave session builds Decoder::new_pyrowave on the
presenter's device instead of an FFmpeg decoder. clients/session grows
the `pyrowave` feature forwarding both crate features.
With this the Phase-2 client chain is code-complete:
Hello bit → preference → Welcome::codec → pyrowave decode on the
presenter device → planar CSC → present. On-glass .21 run +
latency-probe/loss-harness numbers vs HEVC remain owed (plan Phase-2
exit criteria).
Validated on .21: session client + all crates compile with and without
the features, clippy clean, 26 + 308 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The presenter's device creation now probes + enables the PyroWave
compute feature set alongside the Vulkan Video probe (shaderInt16,
storageBuffer8BitAccess, subgroup size control — gated on support,
harmless when unused) and exports the facts through VulkanDecodeDevice
(pyrowave_decode capability + feature bools + apiVersion + the queue-
family shape).
pf-client-core (feature `pyrowave`, Linux): video_pyrowave.rs — the
decoder runs pyrowave compute on the PRESENTER's own VkDevice, zero
interop (plan §4.5): pinned content-equivalent create-info
reconstruction satisfies pyrowave 0.4.0's lifetime rule without
refactoring the presenter's creation; queue access rides the existing
device-wide QueueLock (the FFmpeg/Skia contract); decode records into
our command buffer, fence-synchronous (sub-ms), into a 4-deep ring of
3xR8 plane sets (decode REQUIRES storage usage + identity swizzles, so
the encoder's RG8 trick doesn't apply). Backend::PyroWave +
DecodedImage::PyroWave + Decoder::new_pyrowave + decodable_codecs_for
(advertisement gated on the device probe) wired through the decode
dispatch; no demote ladder (nothing else decodes it — fallback is
session renegotiation, plan §4.6).
Still to come for a live session: the presenter's planar-CSC render
path for the new variant, pump/shell opt-in (preferred_codec) wiring,
and the on-glass .21 run.
Validated on .21: pf-client-core + pf-presenter compile with and
without the feature, clippy clean, 26 client-core tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cargo fmt --all --check flagged the reanchor gate wiring (decode.rs / session.rs /
abi.rs / reanchor.rs): wrapped signatures + comparisons, and two multi-line comments
that followed a trailing-comment line were restructured to their own lines so
rustfmt keeps them at normal indentation instead of deep-aligning them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After unrecoverable loss the host keeps sending delta frames that reference a
picture the client never received; hardware decoders conceal these as gray/
garbage with a success status. Linux already withheld them and held the last
good frame until a proven clean re-anchor — this brings that behavior to the
Android and Apple clients.
Extract the Linux pump's freeze state machine into a shared `ReanchorGate` in
punktfunk-core (reanchor.rs, 18 tests) exposed over the C ABI (ABI v6, additive —
no wire change) for the Swift clients. Migrate the Linux/Deck pump
(pf-client-core) onto it as the parity proof (no-op refactor). Then wire:
- Android (decode.rs, both sync + async loops): arm on the frame-index gap, a
pts-keyed flag map carries the wire flags to the output-buffer release, fold
the gate per drained output, gate.poll replaces the dropped-climb block.
- Apple Stage2Pipeline (default): arm on a gap (new noteFrameIndexGap), withhold
at the ring-submit seam (CAMetalLayer holds its last drawable), poll
framesDropped, fold VT decode errors through the no-output streak.
- Apple StreamPump (stage-1): fold at enqueue, withhold via
kCMSampleAttachmentKey_DoNotDisplay so the layer keeps decoding (reference
chain intact) but holds the last displayed frame.
- Apple VideoDecoder: thread the AU's wire flags to the async decode callback via
a retained FrameContext refcon (replaces the receivedNs bit-pattern scalar).
Lifts only on a proven re-anchor (IDR / RFI anchor / 2nd recovery mark) with a
500 ms backstop so a lost re-anchor can never freeze forever. Apple: swift build
clean, 123/123 tests pass (incl. VideoToolboxRoundTripTests). On-glass
loss-injection validation still owed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phases 1–4 of design/encoder-recovery-hardening.md — make the shipped RFI/
freeze-until-reanchor recovery honest and rebuild-safe across every backend.
F1 — frame-index domain desync: the encode loop now owns a session-lifetime
`au_seq`; `Encoder::submit_indexed(au_seq + inflight)` pins NVENC inputTimeStamp
and AMF LTR slots to the WIRE frame index, so `invalidate_ref_frames` compares
client frame numbers in the same domain and survives adaptive-bitrate rebuilds
(an internal counter desynced on the first rebuild → RFI silently dead / an AMF
force-ref onto a never-decoded frame). `FrameMsg.frame_index` →
`Session::seal_frame_at`; GameStream gets the same via `VideoPacketizer::
packetize(.., Some(idx))`.
F2 — Windows NVENC left the client frozen ~1s per loss: NVENC RFI was
transparent (no anchor tag) while the session glue armed the 750ms IDR cooldown,
so the freeze only lifted on the ~1s keyframe re-ask. NVENC now mirrors AMF —
`pending_anchor` tags the first post-invalidate AU (the clean re-anchor
P-frame) `recovery_anchor`, incl. the covering-range dedupe re-arm; the client
lifts at ~RTT.
F3 — speed-test probe filler burned video frame indexes: moved to its own index
space (`Packetizer::alloc_probe_index` + `Session::submit_probe_frame`) with a
second client reassembly window routed on FLAG_PROBE, gated on the new
VIDEO_CAP_PROBE_SEQ Hello bit (mid-session probes declined for older clients).
F4 — RFI range sanity cap: forward gaps wider than `packet::RFI_MAX_RANGE` (256)
resync via keyframe instead of an out-of-range RFI, host- and client-side
(client huge-gap → keyframe in `RfiRecovery::observe` + the pf-client-core pump).
F5 — reset() parity: Windows NVENC (teardown + lazy re-init), Linux VAAPI
(drop-inner), Linux NVENC (reopen from stored OpenArgs) now give the stall
watchdog a heal lever instead of ending the session.
F6 — sw.rs `pending: VecDeque` (was `Option`), killing the silent AU drop at
capturer pipeline depth > 1. F7 — doc sweep on the RFI/anchor comments.
Verified: punktfunk-core lib tests (macOS + Linux), full punktfunk-host suite on
Linux (RTX 5070 Ti), Windows compile. Owed: the on-glass client matrix (F2
freeze A/B, AMF LTR spike across a bitrate rebuild).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `cargo fmt --check` step on the x86_64-pc-windows-msvc job was
failing: the mid-stream loss-recovery and resize-overlay commits landed
with unformatted wraps across pf-presenter, pf-client-core, punktfunk-core,
pf-console-ui, and a few host files.
Applied `cargo fmt`, and hand-relocated two trailing comments in
session.rs (a decoded-frame note and the wrap-counter note) to their own
lines so rustfmt no longer column-aligns the following comment block to
a deep indent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removes the "gray frames with motion" artifact on Vulkan-Video clients and lets
AMD/NVENC hosts re-anchor after loss WITHOUT a 20-40x IDR spike.
Client (pf-client-core): after a reference loss the hardware decoder conceals the
missing-reference deltas (on RADV, a gray plate with new motion painted over) and
returns Ok. The pump now freezes on the last good picture until a clean re-anchor
instead of showing the concealment — lifting on a real IDR, an intra-refresh
recovery mark (2nd wave boundary), or an LTR-RFI recovery anchor (1st). The
frame_index gap is the early, precise loss signal and drives an RFI request.
Host recovery signals (inert unless the backend supports them):
- USER_FLAG_RECOVERY_POINT — intra-refresh wave boundary (NVENC constrained GDR).
- USER_FLAG_RECOVERY_ANCHOR — AMD LTR reference-frame-invalidation recovery frame.
AMD LTR-RFI (encode/windows/amf.rs) — the AMD twin of NVENC RFI. AMF's AVC/HEVC API
has no constrained-intra property (intra-refresh cannot heal; PSNR-proven), so the
only clean-recovery lever is user LTR: mark frames as long-term references, and on
loss force the next frame to re-reference the newest known-good one — a clean
P-frame, not an IDR. Two rotating LTR slots, ~0.5s mark cadence, on by default for
AVC/HEVC (PUNKTFUNK_NO_AMF_LTR disables). invalidate_ref_frames picks the newest LTR
before the loss; a range older than the live slots falls back to a keyframe.
Protocol (punktfunk-core): RfiRequest control message + NativeClient::request_rfi().
Host: RfiRequest dispatch -> invalidate_ref_frames (IDR fallback); an RFI success
anchors the keyframe cooldown so the client's frames_dropped echo of the same loss
is coalesced away rather than emitting a redundant IDR.
Spike: synthetic NV12 GPU source for headless AMF encoder testing.
Validated: core rfi_request_roundtrip; pf-client-core 31 unit tests
(incl. an_rfi_anchor_lifts_immediately); punktfunk-host builds + 271 tests on Linux;
punktfunk-host builds clean on Windows; real AMD iGPU spike (invalidate at frame 90
forced re-reference to LTR frame 60 — 180 frames, keyframes=1, no recovery IDR).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
design/windows-parallel-virtual-displays.md (display-management Stage 7 / §6.6): N
simultaneously-live pf-vdisplay monitors, one sealed ring each, every idd-push-security
invariant preserved per-ring.
- proto v3: SharedHeader._pad → target_id — the ring NAMES its monitor, host-stamped
before the magic; the driver publisher refuses a cross-bound ring via the shared,
unit-tested frame::check_attach (new DRV_STATUS_BIND_FAIL — the gamepad pad_index
validation applied to frames, invariant #10); the host's wait_for_attach surfaces the
refusal loudly and self-checks its own stamp.
- manager: the one-monitor MgrState becomes a slot map keyed by the client's identity
slot (0 = anonymous/GameStream); per-slot reconnect + dead-WUDFHost preempts,
slot-scoped begin_idd_setup (a different identity is an admission question, never a
preempt), ONE device-level watchdog pinger, per-slot /display/state + /display/release.
- group topology: isolate_displays_ccd takes the managed target SET (a sibling slot is
never deactivated); SavedConfig + the DDC/PnP axes move to the group record (first-in
captures, last-out restores); desktop layout via CCD source origins from the pure
layout::arrange (auto-row default, manual pins win), re-applied on create + reconfigure.
- admission: the Windows separate→reject override now sits behind the
PUNKTFUNK_WIN_SEPARATE=1 validation hatch (the wedge it guarded is structurally gone —
a second identity gets its own monitor + ring; default flips in W5 after soak);
max_displays and NVENC session-unit budgets decline an unaffordable display AT
admission; kick_dwm_compose is process-globally throttled and per-display — cursor
jump + 35 ms dwell (a sub-tick jump composes nothing; DWM reads dirties from current
state at the next vsync tick).
On-glass on the RTX box: V1/V2/V4/V5/V6/V9 green — two paired clients on two monitors
streaming ~60 fps each with zero mismatches and zero bind failures, churn-hammer clean
(no 0x80070490), per-ring mode-change recreate leaves the sibling untouched, typed
budget rejection, fault-injected cross-bind refused loudly with the sibling undisturbed.
V7: WUDFHost-kill shared fate is clean; in-process device recovery is a known follow-up
(the retired-never-closed control handles block the adapter cycle — reset-pf-vdisplay.ps1
recovers). DWM composes two IDD monitors concurrently at 60 fps — the plan's
load-bearing unknown, answered yes.
Also carries the client-HDR EDID forwarding that shared this working tree
(Hello::display_hdr → AddRequest luminance tail → the monitor's CTA-861.3 HDR block,
PUNKTFUNK_CLIENT_PEAK_NITS hatch) and the Deck client fixes (40 ms rumble keep-alive
with 1-LSB jitter, HDR self-diagnosing presenter warn, flatpak HDR env).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Networking-audit deferred plan §2. The host↔client offset was measured once
at connect; an NTP step or slow drift silently corrupted the clock-based
jump-to-live signal, the ABR one-way-delay signal, and every latency stat —
4a3b1ae2's disarm backstop stopped the IDR storm but lost the detector for
the session. Now the client re-estimates mid-stream and recovers it.
- quic: ClockResync — the connect-time 8-round probe/echo estimate as a
select!-driven state machine (rounds matched by echoed t1, stale batches
ignored), plus accept_resync (batch min-RTT ≤ max(2 ms, 1.5× connect RTT)
so a congested window can never bias the offset). No wire change: the
host has always answered ClockProbe at any time on the control stream.
- client: the offset lives in an Arc<AtomicI64> seeded at connect; the
control task re-probes every 60 s and immediately after the pump's FIRST
no-op clock flush (the "clock stepped under me" signal, sent on the next
report tick). On apply: store, reset stale_frames/noop_clock_flushes,
re-arm the clock detector if a step had disarmed it. The disarm heuristic
stays as the final backstop. Public NativeClient::clock_offset_ns keeps
the connect-time value (ABI untouched); new clock_offset_now_ns() /
clock_offset_shared() expose the live value.
- consumers migrated to the live offset: pf-client-core session stats, the
pf-presenter e2e stamp, Windows session/render, Android feeder/drain/
DisplayTracker (the tracker holds the shared handle, not the client, so
the leaked render-callback refcount can't pin the session).
- probe: --clock-resync runs a second full handshake mid-connection and
asserts a sane, consistent estimate. Live against the local canary host:
offsets 8646/2139 ns, disagreement 6 µs, 8/8 rounds — OK.
Unit tests cover the round collection, stale-echo rejection, batch restart,
min-RTT selection, and the acceptance guard. cargo ndk check green.
Remaining manual validation: `sudo date -s "+2 sec"` on a live streaming
client → expect one no-op flush, a re-sync, re-armed detector, no IDR pulse.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 0xC9 audio datagrams ride the lossy plane with no FEC, and no client ever
consulted the per-packet sequence: a lost 5 ms Opus packet played out as a hard
gap in the ring — an audible click/pop on every drop, i.e. constantly on the
Wi-Fi links where video loss is already being FEC-absorbed.
Now a shared `AudioGapTracker` (punktfunk-core::audio — pure data, wrap-safe,
unit-tested incl. u32 wraparound / reorder / duplicate cases) tells the decoder
how many packets went missing immediately before each received one, and both
native clients (pf-client-core PipeWire path, Android AAudio path) synthesize
that many frames of libopus packet-loss concealment first: `decode` with empty
input (the opus crate maps it to a NULL data pointer = PLC), sized by the last
real frame's sample count. Interpolated fade instead of a click.
Bounds: a gap is capped at 10 packets (50 ms) — libopus PLC fades to silence
after a few frames anyway, so past the cap the rings' existing underrun/re-prime
path takes over. Reorders and duplicates conceal nothing (the plane has no
reorder buffer; playing a late packet where it lands is the existing behaviour).
In-band Opus FEC (LBRR) is deliberately NOT used: the host sends 5 ms frames
and LBRR needs ≥10 ms frames to carry anything.
The cap is a crate-private const so cbindgen keeps it out of the C ABI header.
Host cargo tests + clippy green; android crate verified via cargo ndk check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The vendor-agnostic DXVA path for GPUs without Vulkan Video (Intel's Windows driver foremost,
which previously landed on CPU decode). Ported from the retired WinUI presenter's decoder with
its Intel-safe discipline intact (decode pool stays libavcodec-derived — a hand-built pool
broke Intel at the first SubmitDecoderBuffers), on a decode device LUID-matched to the
presenter's adapter.
Hand-off is a ring of shareable BGRA8 textures (SHARED_NTHANDLE | SHARED_KEYEDMUTEX) filled by
the fixed-function ID3D11VideoProcessor (NV12/P010 → BGRA8, colour spaces from the per-frame
CICP; PQ is tone-mapped to SDR by the processor — HDR-first boxes take Vulkan Video). BGRA is
deliberate: importing a multiplanar NV12 D3D11 texture device-losts on NVIDIA however it is
consumed (plane-view sampling and DMA copy both validation-clean, both TDR — bisected), while
single-plane RGBA D3D11↔Vulkan interop is the path Chromium/ANGLE exercise on every driver.
The presenter imports a slot's NT handle per frame (VK_KHR_external_memory_win32, gated on the
spec-required external-format probe) and blits it into the video image — no CSC pass; the DXGI
keyed mutex (key 0 both sides, drop-tolerant) is the cross-API lock and visibility barrier.
Verified live vs a real host at 5120x1440@240 HEVC on an RTX 4090: 240 fps, e2e 2.7/3.0 ms
p50/p95 under the Khronos validation layer — parity with Vulkan Video (2.6 ms); auto still
resolves vulkan on NVIDIA. PUNKTFUNK_DECODER=d3d11va forces it; import/present failures demote
to software on the existing streak contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The punktfunk-session Vulkan client (clients/linux-session, now clients/session)
builds and runs on Windows; the WinUI shell spawns it for every stream. Verified
live: 10-bit HEVC via Vulkan Video on both AMD (iGPU) and NVIDIA, 5120x1440 at
130 fps / 8 ms end-to-end on the RTX 4090.
- pf-ffvk: Windows bindgen branch (FFMPEG_DIR + PF_FFVK_VULKAN_INCLUDE, no
pkg-config); provisioning fetches Vulkan-Headers (pinned v1.4.309).
- pf-client-core: builds on Windows — WASAPI audio (audio_wasapi.rs, cfg-swapped
via #[path], same surface as the PipeWire twin), VAAPI/dmabuf gated inline
(chain = vulkan -> software), trust reads the WinUI shell's %APPDATA% stores
(parity tests pin both serialized shapes), Settings gains adapter/hdr_enabled
(serde-defaulted; Linux stores unaffected).
- pf-presenter: builds on Windows — dmabuf module Linux-gated; SDL keyboard grab
while captured (Alt+Tab/Win reach the host); pick_device ranks discrete over
integrated (device 0 was the iGPU on hybrid boxes — the silent footgun) and
honors PUNKTFUNK_VK_ADAPTER (the Settings GPU pick, exported by the session).
- run loop: block in one SDL wait woken by input AND decoded frames (a per-
session forwarder pushes a FrameWake user event) instead of a 1 ms poll —
measured 111%% -> 5%% of a core (NVIDIA), 86%% -> 3.5%% (AMD), stats unchanged.
The pump's decode-fence wait became once-per-window sampling (no per-frame
pipeline stall; the stat now shows true backlog).
- pf-console-ui: builds on Windows (skia-safe msvc prebuilts); font lookup falls
through fontconfig aliases to concrete DirectWrite families (Consolas/Segoe UI)
— browse/coverflow works, verified against a live host.
- WinUI shell: session-always via new src/spawn.rs (GTK spawn.rs port —
CREATE_NO_WINDOW, stdout contract, kill handle); the Stream screen is a status
card (chips + stage lines from the child's stats). The legacy in-process
D3D11VA path stays behind Settings "Streaming engine" / PUNKTFUNK_BUILTIN_
STREAM=1 as the A/B baseline until Phase 8 deletes it. SessionParams.video_caps
makes the HDR toggle real.
- clients/linux-session renamed to clients/session (builds for both OSes).
- CI/MSIX: both workflows build/test both bins with widened path filters; the
MSIX ships punktfunk-session.exe. ARM64 session builds --no-default-features
(rust-skia has no aarch64-pc-windows-msvc prebuilts; flip when it does).
A/B on this box (5120x1440 HEVC vs home-worker-5): NVIDIA Vulkan 130 fps / 8 ms
e2e / 1.6 ms decode — clearly better than the built-in path. The AMD iGPU VCN
saturates at ~52 fps where its own D3D11VA does ~70 — Adrenalin Vulkan decode is
slower on APU silicon; discrete RDNA validation gates Phase 8.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`cargo fmt --all --check` (ci.yml) was red on main: the client re-architecture
commits and origin's windows-shortcut commit landed with rustfmt violations
(e.g. a 104-char .with_context line in hyprland.rs, an unsorted mod block in
vdisplay.rs, the input.rs `{`-placement CI flagged). Reformat the tree so the
fmt gate passes; no functional changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The client now advertises VIDEO_CAP_10BIT|HDR and carries the result all
the way to glass:
- csc_rows is bit-depth exact (10-bit limited code points differ from
8-bit by ~half a code) and folds in the P010/X6 MSB-packing factor;
new 10-bit white/black tests.
- The CSC shader grows a params block: mode 0 passes the transfer
through (SDR as-is, or PQ onto an HDR10 swapchain); mode 1 tonemaps
PQ→SDR in-shader (ST.2084 EOTF, 203-nit reference white exposure,
BT.2020→709, soft maxRGB rolloff, sRGB encode) for desktops without
an HDR surface. PUNKTFUNK_TONEMAP_PEAK tunes the rolloff.
- The presenter probes VK_EXT_swapchain_colorspace + an HDR10/ST.2084
10-bit surface format and flips modes in-band with the stream's PQ
signaling: fence-quiesce, then CSC pass + video image (10-bit
A2B10G10R10 intermediate — PQ in 8 bits bands) + overlay pipe +
swapchain rebuild through the deferred-destroy rules.
- P010 decodes through all three paths: Vulkan Video (X6 multiplanar
pool, R10X6 plane views), VAAPI dmabuf (R16/RG1616 plane imports),
software (swscale as before).
- session pump advertises the caps; the host still gates Main10 behind
its PUNKTFUNK_10BIT policy.
Probed on glass hardware: the KDE/NVIDIA surface exposes
A2B10G10R10+HDR10_ST2084, so true PQ passthrough is available there.
Known v1 gaps: software-decode PQ shows untonemapped (8-bit RGBA
carries the transfer baked); the SDR overlay composites unscaled onto
an HDR10 surface (dim OSD); no vkSetHdrMetadataEXT yet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vkDeviceWaitIdle's external-sync rule claims EVERY queue on the device;
with Vulkan Video the session pump concurrently submits FFmpeg decode
work to the shared device's video queue, so the resize path's wait-idle
(and the video-image/staging rebuilds') raced it — observed as a crash
on window resize mid-stream (software/VAAPI never touched the device
from the pump, which is why this only appeared now).
Mid-session quiescing is now fence-only ( waits the single
in-flight fence, which covers every command buffer WE submitted), and
the replaced swapchain + its per-image semaphores/overlay targets are
parked in a retire list — the presentation engine may still hold the old
swapchain's final semaphore wait, which no fence covers — and destroyed
after the next present on the successor completes a fence cycle. The one
legitimate device-wide idle left is teardown, and the run loop now JOINS
the pump thread first (SessionHandle carries the JoinHandle; quick — the
pump notices stop within its 20 ms receive timeout).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Vulkan path's receive_frame returns at SUBMISSION (~0.1 ms) — the
hardware decodes asynchronously, so the decode stat was truthful but
measured the wrong boundary. The pump now ships the frame to the
presenter FIRST, then waits the frame's timeline fence (vkWaitSemaphores
resolved through the shared device's proc chain) and stamps
received→decode-COMPLETE — true NVDEC time at zero pipeline cost, since
the presenter's own GPU wait is what actually gates sampling. Software/
VAAPI keep their synchronous stamps.
Also: Alt+Enter joins F11 as the fullscreen toggle (some keyboards' Fn
layer sends a media key for plain F11 — observed on glass as
'F11 only works with shift'); the shell's shortcuts panel lists both.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FFmpeg's Vulkan Video decoder now runs on the PRESENTER's own VkDevice —
the decoded VkImage feeds the existing CICP CSC pass directly: zero
copy, no interop, and NVIDIA gets hardware decode for the first time
(its VAAPI is unusable by design). One decode architecture for every
vendor going forward; VAAPI-dmabuf and software remain the fallbacks
(auto: vulkan → vaapi → software; PUNKTFUNK_DECODER=vulkan pins it).
Presenter: instance 1.3; probes VK_KHR_video_queue/decode_queue + codec
extensions, a VIDEO_DECODE queue family (+ its codec caps via
QueueFamilyVideoPropertiesKHR), and the samplerYcbcrConversion/
timelineSemaphore/synchronization2 features — all enabled at device
creation when present, exported as a VulkanDecodeDevice handle bundle.
Decoder: AVVulkanDeviceContext built over those handles via pf-ffvk
(features chain, extension lists, deprecated queue indices + the qf[]
map); get_format supplies OUR frames context with MUTABLE_FORMAT so the
presenter's per-plane views are legal; output is DecodedImage::VkFrame
carrying AVVkFrame/frames-ctx pointers plus the lock fns.
Present: R8+R8G8 plane views over the multiplanar image, the live sync
state read under the AVVulkanFramesContext lock, a timeline-semaphore
wait(sem_value)/signal(sem_value+1) folded into the submit, layout/
queue-family/sem_value written back per FFmpeg's contract, and the frame
guard parked in the retire queue until the fence. CSC pass + video
framebuffer are now unconditional (NVIDIA has no dmabuf-import path).
Verified on the RTX 5070 Ti: device creates with decode_qf=3,
caps=DECODE_H264|H265|AV1|VP9; swapchain unaffected. Live stream
validation next.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Under infinite GOP the pump only re-requested an IDR when the
reassembler's drop count climbed. A lost initial IDR (or a mid-GOP join)
delivers complete-but-undecodable delta frames instead — the reassembler
never drops, so recovery never fired and the stream froze on the last
good frame while libavcodec flooded stderr with missing-reference
errors. Reproduced at 4K@144 (large IDRs, higher loss); lower modes hid
it. Now a 3-frame no-output streak (~50 ms) forces a fresh IDR,
throttled and re-armed across the request→IDR round trip. Verified on
glass: 4K@144 recovers and holds. Also quiets libavcodec's raw stderr
(it bypassed tracing) to fatal-only, PUNKTFUNK_FFMPEG_LOG restores it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Session pump, FFmpeg decode, PipeWire audio, SDL3 gamepads, keymap, trust
store, mDNS discovery, library client and Wake-on-LAN move verbatim from
clients/linux into crates/pf-client-core, shared with the upcoming Vulkan
session binary (punktfunk-planning: linux-client-rearchitecture.md).
The GTK client re-exports them at the crate root so every existing
crate::-path keeps resolving; its manifest drops the moved-only deps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>