Task 1 (latency): the transactional present now commits from the RENDER
thread inside an explicit CATransaction + flush() instead of hopping to
main. The present harness (2026-07-21, 240 Hz Studio, full-size window)
measured the main hop batching presents at runloop-iteration rate when
main is busy — an active implicit transaction NESTS the explicit one —
which is the field's presents=55 @ fps=240 / display_p50 18.6 ms.
Off-main commits measured immune to main churn: glass p50 ~10 ms,
cadence a clean 4.17 ms at 240. flush() is load-bearing: without it the
render thread's own implicit transaction (drawableSize/colour writes)
swallows the explicit commit and NOTHING reaches glass — the harness
reproduced the exact freeze (every present dropped). Legacy main-hop
kept as PUNKTFUNK_TXN_PRESENT=main for field A/B. New pf-windowed
Console line (subsystem io.unom.punktfunk, category presenter)
decomposes the issue side per second.
Task 1 lever D: the f407f418 IOSurface contents-swap path is
resurrected format-aware as WindowedPresentMode.surface — bgra8 SDR /
rgba16Float+PQ-tagged HDR pool, swap committed off-main from the
completion handler; EDR anchored by the metal layer underneath.
Env-only prototype (PUNKTFUNK_WINDOWED_PRESENT=surface) until the HDR
composite is eyeballed on glass.
Task 2 (setting): punktfunk.windowedSafePresent (default ON =
transactional mitigation; OFF = the fast async path whose panic the
saga is about — the caption says so in plain words). Rows in the touch
Settings (Presentation) and gamepad settings, macOS-only.
PUNKTFUNK_WINDOWED_PRESENT=async|transaction|surface overrides for dev
A/B. maximumDrawableCount stays 3 — the harness measured 2 starving the
render loop (172/s) and worsening glass p50.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Swift `Int` is 64-bit (Foundation infers %lld); the stats mirror's format
string used %d (32-bit C int) for fps/presents/lost. macOS 26's strict
String(format:) validator rejects the mismatch and drops the whole line
(the error also cascades onto the float args, mis-blaming floor_p50). Use
%lld for the three Int fields. Pre-existing, unrelated to the DCP work;
surfaced while reading presenter logs during the panic fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The windowed-macOS "mismatched swapID's" @UnifiedPipeline.cpp kernel
panic is the CAMetalLayer ASYNC image queue (commandBuffer.present,
mandatory with displaySyncEnabled=false) diverging from WindowServer's
compositor on a high-refresh composited session — it survived glass
pacing (PyroWave, 2026-07-18) and hit windowed HEVC on the same 240 Hz
Mac Studio (2026-07-21), so it's the async queue itself, at any codec
or pacing.
f407f418's mitigation routed windowed PyroWave through a BGRA8 IOSurface
layer.contents pool, which cannot carry HDR — extending it to HEVC would
have tone-mapped windowed HDR down to SDR. Instead, present the drawable
through a Core Animation transaction (CAMetalLayer.presentsWithTransaction)
for every windowed session: commit, waitUntilScheduled, then drawable.present()
inside a CATransaction, so the swap commits with the layer tree and stays
in lockstep with the compositor instead of racing it. The full
rgba16Float / PQ / EDR render path is untouched — windowed HDR is real
HDR again. Fullscreen keeps the async path (direct scanout, lowest latency,
no panic there).
Removes the IOSurface surface pool, its surfaceLayer sibling, and the
macOS windowed PQ->SDR tone-map (tvOS keeps its no-headroom tone-map).
Net -150 lines. swift build + 169 tests green. Needs on-glass soak on
the 240 Hz Mac Studio (kernel race — only real hardware confirms).
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 SteamOS scripts built the host featureless, so PlatformAuto could never
pick the Vulkan Video backend (default-on since 84329205) and every session
silently ran libav VAAPI — unlike the deb/arch/rpm/sysext builds, which all
pass the feature. Three gaps, found live on a Deck host:
- install.sh/update.sh: cargo build with --features punktfunk-host/vulkan-encode
(matches the packaged builds; pure-Rust ash, no new system dep).
- host.env: RADV_PERFTEST=video_encode — Van Gogh RADV still gates
VK_KHR_video_encode_* behind it, so without it the backend can't open and
falls back to VAAPI. Harmless where encode is exposed by default.
- io.unom.Punktfunk.Host.desktop (Exec rewritten to this install's binary):
KWin only grants zkde_screencast_unstable_v1/org_kde_kwin_fake_input to an
exe a .desktop authorizes — without it Desktop-Mode capture fails outright
and a mid-stream Game↔Desktop switch strands the stream on the old backend.
update.sh retrofits the last two idempotently so existing installs get them
without a reinstall.
Validated on a SteamOS 3.8.14 Deck (Van Gogh): Vulkan HEVC opened on both the
KWin and gamescope paths at 5120x1440 — in-place ABR reconfigure (no IDR, no
encoder rebuild) vs the VAAPI path's full teardown per ABR step, and Desktop
capture came up without a re-login.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`<CardContent className="p-0">` never removed the padding it looked like it
removed: tailwind-merge resolves conflicts within a variant, so `p-0` cancels
`p-4` but leaves `sm:p-6` standing. Every call site that used it nests a
CardHeader inside, which brings its own `sm:p-6` — so at >=640px the title sat
24px further in than its neighbours'. Visible on the Plugins page as 'Installed
plugins' not lining up with 'Plugin runner'.
Give CardContent a `flush` prop that omits the padding outright, and use it at
the three call sites (Store, Pairing, Stats) rather than fixing the symptom
three times and leaving the footgun in place.
Co-Authored-By: Claude Fable 5 <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>
Two things looked wrong in the store:
The cards had no space above the icon. `CardContent` zeroes its own top padding
(`pt-0`/`sm:pt-0`) because it normally sits under a `CardHeader` that supplies
it — but these cards have no header, so the top padding simply vanished while
the other three sides kept `p-card`. `p-card` does not undo it: `card` is a
custom `--spacing-*` token, which tailwind-merge does not recognise as a
spacing value and so never dedupes against `pt-0`, leaving the longhand to win.
Both header-less cards in this section now restore it explicitly, at both
breakpoints.
Platform chips read "windows" / "linux". Those are the catalog's platform
IDENTIFIERS (the index validator pins `linux | windows | macos`), which is right
for the wire and wrong on screen; the card now maps them to display names —
Linux, Windows, macOS. Proper nouns, so deliberately not routed through i18n.
Adds Store/StoreCard stories covering the padding, the platform labels, and the
installed / update / incompatible / blocked / external states, so the card can
be eyeballed without a host or a catalog.
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>
Slipped through 421e8112: the on-box clippy runs covered punktfunk-core and
punktfunk-host but not the tools crates; CI's workspace-wide pass caught it.
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 Phase-2 gate: more, smaller wire units must not regress FEC recovery.
Adds a GF16 streamed column (three chunk pushes + finish per AU — sentinel
blocks then real totals). Measured: identical to whole-AU at every loss
rate (50/50 through 1/6, the same 25%-budget cliff).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The probe's reassembler is the shared core one, and the probe is the tool
that measures the streamed-AU overlap win — advertise the cap so a host
with chunked encode streams to it.
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>
The encoder half of sub-frame slice output (latency §7 LN1, planning
design/nvenc-subframe-slice-output.md Phase 1): with PUNKTFUNK_NVENC_SLICES=N +
PUNKTFUNK_NVENC_SUBFRAME=1 on a sync depth-1 session, Encoder::poll_chunk hands
the in-flight AU out as slice-boundary chunks read through doNotWait sub-frame
locks while the tail is still encoding — the readback loop the on-hw probe
validated (~200 µs slice spacing on the 5070 Ti), productionized.
- codec.rs: AuChunk (AU metadata on the first chunk, `last` closes the AU;
chunks concatenate to exactly the poll() bytes) + supports_chunked_poll /
poll_chunk trait surface. Default impl wraps poll() as one self-closing
chunk, so a chunk-driven consumer works against every backend.
- nvenc_cuda: chunked readback cut at slice boundaries only (bitstream size at
n reported slices = end of slice n, Annex-B contiguous); completion is NEVER
numSlices alone — one finishing BLOCKING lock is the authority and the wedge
watchdog, so the final chunk blocks exactly like sync poll (the depth-1 pump
contract; 6dc195f9 bug class). Keyframe on early chunks is the submit-time
IDR prediction (exact under P-only/infinite-GOP), cross-checked at finish.
Debug builds shadow-check emitted chunks against the finished AU prefix.
Mutually exclusive with pipelined retrieve (gated off when async_rt exists,
dropped by the escalation rebuild); composes with stream-ordered submit.
- nvenc_core: slices_env/subframe_requested shared parses so the config author
and the chunked-poll arming can't disagree.
- TrackedEncoder forwards both new methods (the set_wire_chunking trap class).
Host loop untouched — Phase 2 (VIDEO_CAP_STREAMED_AU seal/send) consumes this.
On-hw: nvenc_cuda_chunked_poll_end_to_end + nvenc_cuda_chunked_poll_fallback_whole_au.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every session encoder is boxed in TrackedEncoder (open_video), and the
wrapper never forwarded Encoder::set_pipelined — so the host loop's
contention escalation (ae673158) hit the trait default, which returns
false ("backend can't pipeline, stop asking"), and the pipelined-retrieve
stage could never engage adaptively. Only the explicit
PUNKTFUNK_NVENC_ASYNC=1 open-time path worked. The exact defaulted-method
trap the wrapper's own comment documents for set_wire_chunking.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Console: a Plugins section (Browse / Installed / Sources) on a static nav
entry, with install friction proportional to trust — a plain confirm for a
verified entry, a warning naming the curator for an external one, and a
danger dialog that makes you retype the spec for a raw package. Tier badges
are permanent and follow the plugin onto its own UI page.
Index: unom/punktfunk-plugin-index published and served from Gitea's raw
endpoint (real HTTPS, byte-exact, no vhost to stand up) — merge to main is
publish, which resolves the design's open hosting question.
Four things only running it could find:
- runner discovery matched @punktfunk/plugin-* only, so a third-party
scoped plugin (which D8 requires) would install and never run
- ...and that convention also matches @punktfunk/plugin-kit, a plugin's
own framework: it listed as installed and would have been imported as a
unit. Both now key off the plugins dir's top-level dependencies, with an
emptied dependency list meaning 'nothing installed' rather than falling
back to the naming convention
- the store must not pass new flags to the runner: the scripting package
ships separately and an older one reads an unknown flag's value as a
package name. The host writes the bunfig scope mapping itself
- ureq reports only >= 400 as Err, so a conditional request's 304 arrives
as Ok with an empty body — handled as an error it made every refresh
after the first verify a signature over zero bytes and sit stale
Also: the console's first Tabs use exposed an @unom/ui theme gap that
rendered inactive tabs invisible (caught in a browser pass, not by types).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The index is the verification gate: a catalog entry pins one exact version
plus that version's tarball integrity hash, so 'verified on every release'
is a property of the data rather than a promise about process. Nothing can
express 'track latest' for a catalogued plugin.
- store/index.rs signed index parse + ed25519 verify (ring), validate-and-drop
per entry, semver minHost/advisory ranges
- store/sources.rs built-in unom source (compiled-in URL + two key slots for
rotation) + operator sources in plugin-sources.json
- store/catalog.rs https fetch with size/timeout/redirect caps, signature before
parse, last-good disk cache (stale-but-usable when offline)
- store/jobs.rs single-flight install/uninstall: registry-integrity preflight
against the pin, spawn the runner CLI with live log capture,
post-install version check with rollback, provenance record,
runner restart (discovery is startup-only)
- store/manifest.rs install provenance; absence means CLI-installed
- mgmt/store.rs 12 routes under /api/v1/store, denied to the plugin token
(a plugin that can install plugins is an escalation primitive)
Also generalizes runner discovery and listInstalled from @punktfunk/plugin-*
to ANY scope's plugin-*: catalog entries must be scoped so the scope can map
to that entry's registry, so a third-party plugin necessarily arrives under
its own scope and would otherwise install but never run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lifts 3aacec53's alignment gate into a mode select: an aligned mode keeps
the true zero-copy direct import; an unaligned one (1080p!) now blits the
visible frame into a per-slot ALIGNED BGRA staging image and duplicates
the edge rows/columns into the 64x16 padding — transfer-only regions in
one vkCmdCopyImage (1080p: visible + 8 row regions; width padding adds a
second self-copy pass in GENERAL), no compute shader. The encode reads
the staging image, never the capture buffer, so the EFC can never read
past a producer allocation (the field GPU hang). Still one ~8 MB copy vs
the CSC path's ~17 MB + dispatch + plane copies. Verdict line:
active(padded-copy). The staging import drops the video profile entirely
(TRANSFER_SRC only).
CPU-payload paths made honest on the way (they were the smoke baseline
AND the software-capture fallback):
- rgb mode: the staging upload is padded CPU-side (edge duplication) so
the aligned encode-src is fully defined;
- CSC mode: the sampled image is now SOURCE-sized with a matching copy
extent — the old aligned-size image + tightly-packed buffer sheared
rows and left garbage rows at unaligned modes (black-bar artifacts;
YMIN=16 in every smoke frame), which also masked as a 24 dB PSNR
'regression' against the (correct) padded output.
On-glass (780M, host Mesa 26.0.4): all four smokes pass at 256x256
(direct) AND 250x250 (padded); padded-EFC frames decode perfectly
uniform (YMIN==YMAX) at the exact 709-narrow values (79/148/60 for the
first three fills); CSC-vs-padded PSNR 49.9 dB avg after the baseline
fix. clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Field report (commit 25624443, RDNA2/VCN3 host, 1080p): repeated VCN0 VM
protection faults from punktfunk-host, vcn_enc_0.0 ring timeouts, two ring
resets, then a failed reset escalating to a full MODE1 GPU reset with VRAM
loss. Root cause is B1's RGB-direct source: the session's coded extent is
64x16-aligned (1920x1088) but the captured dmabuf is only the real mode
size (1920x1080) — the VCN EFC reads the 8 padding rows past the end of
the buffer (8 x 7680 B = 61 KB, matching the faulting page span exactly).
The CSC shader absorbs alignment by clamping reads and duplicating edges;
RGB-direct has no such stage. Every prior validation surface happened to
be aligned (256x256 smoke, 1440p live box) — 1080p is the common mode
that is not. The stall watchdog's rebuild-and-refault loop then turned a
per-session fault into the GPU death spiral.
Two layers:
- Open-time gate: RGB-direct engages only when the real mode equals the
aligned coded extent (720p/1440p/4K yes, 1080p no); otherwise the CSC
path runs and the verdict line says why (unaligned-mode). The padded-
copy variant that re-enables 1080p is design-doc B2 work.
- Submit-time check: an RGB-direct frame that does not cover the coded
extent errors out (encoder-rebuild path) instead of importing an
undersized source.
On-glass (780M, host Mesa 26.0.4): all four smokes pass aligned; at
PF_SMOKE_W/H=250 the gate refuses RGB-direct and the rgb smokes soft-skip.
clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CardContent is shadcn's header-adjacent variant (`p-4 pt-0 sm:p-6 sm:pt-0`),
but the four Live-status tiles use it with no CardHeader. tailwind-merge lets
the call site's unprefixed `p-4` cancel the base `pt-0`, yet nothing cancels
`sm:pt-0` — so the tiles lost their top padding at >=640px only, and the
content sat against the top edge. Restores the top inset at the sm breakpoint
and lets the content fill the stretched grid cell so it stays centred when a
sibling tile is taller.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The RGB→NV12 compute CSC shares the SMs with the game; request an
elevated global-priority queue (VK_KHR/EXT_global_priority) so the
driver can schedule it ahead. PUNKTFUNK_VK_QUEUE_PRIORITY =
off|high|realtime (default realtime), downgrading REALTIME→HIGH→none
on NOT_PERMITTED/INITIALIZATION_FAILED — a refused class never fails
the bridge. Mirrors PyroWave's ac0e7332 (same honest caveat: WDDM
didn't move; the Linux driver may — contended-tail lever, correct and
harmless either way).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PUNKTFUNK_NVENC_ASYNC gains a real tri-state: 1 = always (as before, now
documented as ~+1 tick at depth-1), 0 = never (vetoes escalation), unset =
ADAPTIVE — off until the session loop's cadence-overrun detector escalates.
The host loop's adaptive-depth leaky bucket grows a second stage: once the
capturer's depth is maxed (Linux portal is permanently depth-1), it asks
the encoder for pipelined retrieve via the new Encoder::set_pipelined hook
(asked exactly once; default impl declines, Windows untouched).
nvenc_cuda engages at a safe point via a clean session rebuild WITHOUT the
IO-stream binding: with input==output stream bound, later stream work
waits on prior encode completions and would serialize a pipelined session
— stream-ordered submit and two-thread retrieve are mutually exclusive.
The ordered gate now also requires async_rt absence (belt-and-braces for
the runtime switch). Re-open's first frame is the standard session IDR.
On-hardware test: escalate mid-session → retrieve thread live, binding
gone, all AUs deliver, first post-escalation AU is the IDR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
design/vulkan-rgb-direct-encode.md B1: when the probe passes AND
PUNKTFUNK_VULKAN_RGB_DIRECT=1 (default OFF until the B2 on-glass A/B),
the session opens with pictureFormat=B8G8R8A8 and the captured RGB frame
becomes the encode source — the VCN EFC front-end does the 709-narrow CSC
inline during encode. Deleted from the per-frame path: the compute CSC
dispatch, both NV12 plane copies, the semaphore hop and one queue submit
(dmabuf frames are ONE encode-queue submit; ~17 MB/frame of GPU traffic
gone). The DPB stays NV12; RFI/RC/quality-level machinery is untouched.
Shape:
- probe_rgb_direct now returns the chroma-siting bits to create the
session with (midpoint preferred, else cosited-even per axis); the open
verdict line gains "active" / "available(off; ...)" states.
- RgbProfileStack: the rgb-chained video profile rebuilt on the stack per
profiled-image creation after open (profile identity is by value) —
dmabuf imports become profiled VIDEO_ENCODE_SRC images (import cache
unchanged), the CPU staging image likewise (concurrent encode+compute).
- record_submit_rgb: steps 2–4 twin (dmabuf: single submit; CPU: staging
copy on the compute queue, semaphore-ordered); shared step-1/bookkeeping.
- begin_encode_cmd takes a SrcAcquire (CSC general / fresh-import FOREIGN
QFOT / cached visibility-only / staging TRANSFER_DST) so both paths
share the encode recording.
- RGB-direct frames skip the CSC per-slot resources entirely (make_frame
split into csc/common halves); cursor bitmaps warn once (EFC cannot
composite; gamescope — the flagship — embeds the cursor itself).
On-glass (780M RADV PHOENIX, host Mesa 26.0.4): all four smokes pass
(vulkan_smoke{,_av1,_rgb,_rgb_av1}); the EFC-encoded H265 stream decodes
clean and matches the CSC-encoded stream at 49.9 dB average PSNR (min
48.9) — within the design's ±1-code-value tolerance. (Raw-OBU ffmpeg
probing of the tiny AV1 dumps fails identically for BOTH paths —
pre-existing dump quirk, not a stream defect.) check+clippy+full unit
suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
servePluginUi's Bun.serve leaves idleTimeout at Bun's 10s default, so the old
15s keepalive could never arrive — an idle status feed was closed before its
first ping, which is exactly what a live host showed. Default is now 5s.
Also adds the regression suite that should have caught it: the original test
only covered a finite, self-driving stream. The new one exercises the
production shape (PubSub-backed, published after the response opens) plus the
keepalive — with a reader that does not re-enter read(), which is what made the
first version of this test lie.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@unom/ui rings cards ring-2 ring-accent, sized for its own subtle accent; in
this palette --accent is the brand violet, so every card shouted. The console
already softens the same ring in its Card wrapper — plugin UIs now inherit that
instead of each wrapping AnimatedCard themselves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PUNKTFUNK_NVENC_SLICES=N (2..=32, default off) splits H.264/HEVC frames
into N slices (sliceMode 3); PUNKTFUNK_NVENC_SUBFRAME=1 (default off)
arms enableSubFrameWrite + reportSliceOffsets on sync sessions only.
Both experimental groundwork for sub-frame slice output (plan §7 LN1).
nvenc_cuda_subframe_slice_probe (on-hardware, ignored) answers the LN1
go/no-go: spins lock_bitstream(doNotWait) against an in-flight frame and
prints the (t_us, status, numSlices, bytes) timeline — incremental slice
availability and its spacing, or all-at-completion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-glass B0 result from the 780M (RADV PHOENIX, host Mesa 26.0.4): the
probe returned no-709-narrow-midpoint because RADV advertises
xChromaOffsets = COSITED_EVEN only — the VCN EFC does canonical H.26x
left-cosited x-chroma, while the requirement was written to bit-match our
2x2-average (midpoint) shader. The delta is a half-pel chroma-x phase,
imperceptible and unsignalled in our bitstream; EFC's siting is arguably
the more correct one. Accept either siting bit per axis (model + range
stay exact: 709 narrow); B1 will pass the preferred available bit.
With this the 780M probes rgb_direct=available; both GPU smoke tests
(H265 + AV1) pass on that box against host Mesa 26.0.4 — the first
real-RADV validation of the poll fix, the quality-level control, and B0.
(Container mesa alone can't run the H265 smoke: Fedora ships RADV with
H264/H265 encode compiled out — AV1 only. Use the host ICD via
VK_ICD_FILENAMES on Atomic boxes.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@unom/ui reads --secondary as muted TEXT; this palette (like the console's) uses
it as a dark SURFACE, so EmptyState captions, badge text and table headers
rendered dark-on-dark. Tailwind derives both utilities from one token, so the
text lane is re-pointed at --muted-foreground here — once, for every plugin,
instead of in each plugin's stylesheet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bind the session's IO streams to the encode thread's high-priority copy
stream: in sync-retrieve depth-1 use the per-frame input copy and cursor
blend now enqueue with NO cuStreamSynchronize and encode_picture orders
after them on the stream. Same stream both directions, so the encode's
completion is inserted into the stream and later work (the next frame's
copy into a reused ring slot) waits for it.
Soundness gate: the fast path engages only when pending is empty (true
depth-1 usage) — every prior encode was drained by a blocking poll, and
the caller holds the frame payload across the matching poll (contract now
documented on Encoder::submit; both host loops already comply). Pipelined
callers and PUNKTFUNK_NVENC_ASYNC mode keep the blocking copies.
True zero-copy input registration (registering the worker-owned IPC
buffer directly) stays the LN2 v2 follow-up — it needs a contiguous
worker-pool NV12 layout and a registration<->IPC-mapping lifetime tie.
PUNKTFUNK_NVENC_STREAM_ORDERED=0 restores the old blocking behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Latency plan §7 LN0 (Linux/NVIDIA encode follow-on):
- sampled PUNKTFUNK_PERF submit split (copy/blend/map/pic) in nvenc_cuda —
the host loop's submit_us folds all four together; the D2D input copy is
the LN2 zero-copy target and now measurable on its own
- sampled blocking lock_bitstream timing on pf-nvenc-out — in two-thread
mode the host loop's wait_us wraps a non-blocking poll, so the real
encode wait was measured by no timer
- caps probe + log SUPPORT_SUBFRAME_READBACK / SUPPORT_DYNAMIC_SLICE_MODE
(LN1 sub-frame slice-output prerequisites, fleet visibility)
- explicit zeroReorderDelay=1 in the shared low-latency config (P-only +
no lookahead has no reordering anyway; pins the bit against preset or
driver drift; shared with the Windows backend)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First phase of design/vulkan-rgb-direct-encode.md (punktfunk-planning): make
the captured BGRx dmabuf the direct encode source with the VCN EFC front-end
doing the 709-narrow CSC — deleting the per-frame compute CSC, both plane
copies, the semaphore hop and one queue submit.
B0 changes nothing about the encode path. It vendors the extension surface
(vk_valve_rgb.rs — ash 0.38 predates it; same rationale and style as the
vk_av1_encode module) and probes at open whether this host qualifies:
extension present (Mesa >= 26.0 + EFC hardware) → feature bit → conversion
caps cover the compute shader's exact math (709 / narrow / midpoint both
axes) → encode-src format set offers B8G8R8A8 with DRM-modifier tiling.
The verdict is one INFO line (rgb_direct=available | first missing
requirement) — the field telemetry that decides where B1 can default on.
Verified: cargo check + clippy clean + unit tests green (Linux box); the
probe short-circuits to no-ext on non-RADV drivers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Field follow-up to 6dc195f9 (the AMD host whose H265 encode sits at ~5 ms):
the remaining time is the VCN ASIC preset, and we never chose one.
1. Explicit encode quality level. RADV only emits a VCN preset op when the
app issues ENCODE_QUALITY_LEVEL — we never did, so every session ran the
firmware's default preset. Install the resolved level on the first
frame's RESET control (chained ahead of the CBR state, which satisfies
the spec's quality-change-carries-rate-control rule) and bake the same
level into the session-parameters object (the spec requires the match).
Default 0 = the fastest tier the driver exposes (RADV: SPEED, except
H265 on pre-RDNA4 which the driver pins to BALANCE); clamped to the
profile's maxQualityLevels. PUNKTFUNK_VULKAN_QUALITY=0..3 overrides for
quality-biased setups. Logged at open so field logs show the tier.
2. Persistent bitstream mapping. read_slot vkMapMemory/vkUnmapMemory'd the
host-visible bitstream buffer every frame; map each ring slot once at
build instead (coherent memory; vkFreeMemory implicitly unmaps).
3. PUNKTFUNK_PERF CSC split. GPU timestamps bracket the compute batch
(import barriers + cursor prep + CSC dispatch + plane copies) and a
sampled csc_us line logs every ~2 s — separating shader/copy time from
the ASIC encode inside the pump's wait_us, the VAAPI submit-split
analog for this backend. Off (and unrecorded) unless PUNKTFUNK_PERF.
Verified: cargo check + clippy + DPB/RPS unit tests green (Linux box).
The GPU smoke tests still need a RADV host (NVIDIA driver fails session
open on the build box, pre-existing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The session pump's depth-1 loop (capture → submit → poll) treats a poll()
None as "the backend holds the frame internally, re-poll next tick" —
true only of the libav AMF/QSV wrappers, whose codecs genuinely hold ~2
frames. The Vulkan Video backend probed its slot fence with
get_fence_status instead, so the AU of every frame — finished on the
ASIC in ~5 ms — sat unharvested until the tick AFTER the next frame was
submitted: encode_us read as one full frame period (~17 ms at 60 Hz vs
VAAPI's ~5.3 ms on the same VCN, the AMD field report) and every frame
shipped a frame period late — one frame of avoidable glass-to-glass
latency on the path that exists to beat libav VAAPI.
Wait the oldest in-flight slot's fence in poll() instead, matching the
sync NVENC backend's blocking lock_bitstream and the documented
Capturer::pipeline_depth contract ("capture → submit → poll-blocks").
Bounded by ENCODE_FENCE_TIMEOUT_NS like enqueue()'s backpressure wait,
for the same reason: poll runs on the thread the stall recovery runs on,
so a wedged GPU must surface as an error, not park it. None now only
means "nothing submitted". Covers H265 and AV1 (shared poll).
Verified: cargo check + DPB/RPS unit tests green (home-worker-5); the
GPU smoke tests fail at session open on that box's NVIDIA driver
identically on unmodified origin/main — pre-existing, needs the RADV
box for on-glass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>