Compare commits

..
Author SHA1 Message Date
enricobuehler 90d13de81e fix(apple): the stats overlay lied three ways — a frozen clock offset, silently trimmed impossible samples, and Int -1 fallbacks printing as NaN
ci / web (pull_request) Successful in 1m13s
ci / bun-nix (pull_request) Successful in 1m47s
apple / swift (pull_request) Successful in 2m3s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m57s
ci / rust-arm64 (pull_request) Successful in 3m15s
ci / rust (pull_request) Successful in 4m20s
Field 2026-08-13, Apple TV vs Bazzite VM host, two sessions minutes apart on
the same wire: hostnet_p50 read 17-21 ms, then a physically impossible
4.4 ms (host-side encode alone is ~4.7). Root causes, each its own defect:

- The client consumed the CONNECT-TIME skew offset and froze it: cached in
  a Stage2Pipeline field, in a StreamPump let, and in a ContentView closure
  CAPTURE LIST feeding the hostnet meter and the host/network splitter.
  The core keeps a live estimate (punktfunk_connection_clock_offset_now_ns,
  ABI v10, re-synced every 60 s + on suspected wall-clock steps) and its
  own doc says the connect-time value 'silently corrupts every
  capture-clock comparison' after an NTP step — a VM host steps. Now
  PunktfunkConnection.clockOffsetNs IS the live read (an atomic load
  behind the FFI) and every consumer reads it at use: per record, per AU,
  per enqueue. The Swift audio plane's AvSync observation gets the live
  value through the same property.

- LatencyMeter's impossible-sample guard (≤ 0 after offset correction)
  dropped samples SILENTLY, so a wrong offset didn't invalidate a window —
  it trimmed the impossible half of the shifted distribution and presented
  the surviving tail as a plausible small number ('e2e 0-3 ms p50 /
  23 ms p95' on a session whose true hostnet was ~18 ms; also the
  historical '0 ms network / 0 ms e2e' readings). The refusals are now
  counted and drained separately from Stats — deliberately, because a
  fully-poisoned window drains to nil and a count inside Stats would
  vanish with it. The HUD shows an orange 'clock offset suspect' line and
  the stats line grew skew_trim=N; nonzero means disregard e2e/hostnet.

- Every invalid-field fallback in the 1 Hz stats line was a bare -1: in
  the variadic CVarArg context the ternary does NOT unify to Double, the
  literal goes in as Int, and %f reads Int64(-1)'s all-ones bit pattern —
  which is a quiet NaN. Latent since the line existed; stage-1 (the first
  rung with invalid fields while frames flow) printed nan for every one.
  All fallbacks are now typed -1.0 / Double()-wrapped.
2026-08-13 23:15:30 +02:00
enricobuehler 1abf5c91b9 feat(apple): the tvOS present-floor verdict rested on a property readback, so open the two untested doors
The 2026-08-13 field ladder closed 'the tvOS two-refresh present floor is
immovable' on the strength of a 'link granted latency 1.00 frames' HUD line.
But that line reads back preferredFrameLatency — a plain read-write float
(CAMetalDisplayLink.h carries no doc contract) that echoes whatever we
stored. A readback is not a grant; the measured vend lead (1.95 refresh
periods) was the only truth-teller, and two levers were never actually
pulled. This commit also carries the ladder instrumentation that run used:
the 1 Hz stats mirror to stdout (the only log channel that exists on an
Apple TV), the PresentLinkInfo HUD plumbing, the stage-4 drawable-pool
clamp to 2, and the tvOS fixed-rate range pin.

- PUNKTFUNK_FRAME_LATENCY makes the ask a lever (float 0...4, default 1) so
  an on-device ladder can prove whether the property does ANYTHING on tvOS:
  ask=2 growing the vend lead to ~3 means it works and the floor is ~ask+1;
  a lead pinned at ~2 means it is inert and the compositor regime is fixed.
  ask=0.5 is the in-regime win probe (the property is a float for a reason).
  Ask + readback go to the HUD line and the stats line (link_ask/
  link_readback) so the ladder reads HUD-off over stdout.

- PUNKTFUNK_PRESENTER=stage1 now resolves on Release builds (env only; the
  persisted picker stays DEBUG-gated — an env var is never a leftover, it
  takes a devicectl/Xcode launch to exist). Stage-1 presents on the hardware
  video plane (AVSampleBufferDisplayLayer + DisplayImmediately) instead of
  through the GPU compositor — the only rung that can dodge the two-refresh
  regime — and the field A/B silently ran stage-4 because the gate keyed on
  build config. The pump gains stage-1's only latency instrument:
  capture→enqueue into the e2e meter (offset-corrected, displayed frames
  only), so cross-rung runs can pin any felt difference on the present tail.
2026-08-13 23:15:30 +02:00
enricobuehler fdf48fcaa1 Merge pull request 'The console UI died on any Vulkan loader newer than the version we asked for' (#204) from worktree-deck-skia-browse-fix into main
ci / docs-site (push) Successful in 1m16s
ci / rust-arm64 (push) Successful in 1m24s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m4s
android / android (push) Failing after 3m26s
deb / build-publish-gamescope (push) Failing after 14s
ci / web (push) Successful in 4m4s
deb / build-publish (push) Successful in 3m17s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
deb / build-publish-host (push) Successful in 4m14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 23s
ci / bun-nix (push) Successful in 5m15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m7s
arch / build-publish (push) Successful in 7m42s
deb / build-publish-client-arm64 (push) Successful in 4m23s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 2m57s
docker / builders-arm64cross (push) Successful in 19s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m55s
deb / smoke-install (push) Successful in 3m54s
docker / deploy-docs (push) Failing after 3m59s
flatpak / build-publish (push) Successful in 9m29s
ci / rust (push) Successful in 17m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m13s
Reviewed-on: #204
2026-08-13 20:26:24 +00:00
enricobuehler 05a08b9804 Merge pull request 'The plugin runner was located by FHS path only, so NixOS never found it' (#207) from worktree-nix-plugin-runner-resolve into main
deb / smoke-install (push) Successful in 2m38s
ci / web (push) Successful in 1m14s
ci / docs-site (push) Successful in 1m19s
android / android (push) Failing after 2m13s
arch / build-publish (push) Failing after 2m15s
deb / build-publish-gamescope (push) Successful in 1m8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 38s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
ci / bun-nix (push) Successful in 3m32s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 26s
ci / rust-arm64 (push) Successful in 4m37s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 1m18s
ci / rust (push) Successful in 4m39s
deb / build-publish-client-arm64 (push) Successful in 2m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 58s
deb / build-publish-host (push) Successful in 4m36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 59s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 54s
docker / builders-arm64cross (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m37s
deb / build-publish (push) Failing after 4m19s
nix / flake (push) Failing after 5m46s
windows-host / package (push) Successful in 12m31s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
docker / deploy-docs (push) Failing after 6m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11m32s
2026-08-13 20:10:12 +00:00
enricobuehler 99c245520c Merge pull request 'AV1 decoded on the D3D11VA rung because 31 is not a level, and two host warnings that named the wrong subsystem' (#205) from worktree-av1-level-sentinel into main
android / android (push) Failing after 19s
ci / bun-nix (push) Successful in 24s
ci / docs-site (push) Successful in 1m16s
deb / build-publish-client-arm64 (push) Successful in 1m23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
apple / swift (push) Successful in 2m19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
ci / web (push) Successful in 3m36s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
deb / build-publish-host (push) Successful in 4m45s
ci / rust-arm64 (push) Successful in 5m38s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m7s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 4m37s
arch / build-publish (push) Successful in 8m33s
deb / build-publish (push) Successful in 10m27s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m6s
flatpak / build-publish (push) Successful in 8m57s
apple / distribute (push) Successful in 11m16s
docker / deploy-docs (push) Successful in 38s
docker / builders-arm64cross (push) Successful in 17s
deb / build-publish-gamescope (push) Failing after 1m23s
ci / rust (push) Successful in 20m46s
apple / screenshots (push) Successful in 6m28s
windows-host / package (push) Successful in 13m22s
windows-host / winget-source (push) Skipped
deb / smoke-install (push) Successful in 8m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m23s
windows-host / canary-manifest (push) Successful in 59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m34s
2026-08-13 19:35:37 +00:00
enricobuehler 4ab6a399e6 Merge pull request 'The console screens read the ink they publish, so a pale palette stayed white on Apple TV' (#206) from worktree-apple-tv-light-palette-ink into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 40s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 27s
ci / bun-nix (push) Successful in 1m37s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
apple / swift (push) Successful in 2m2s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
ci / rust-arm64 (push) Successful in 2m4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
ci / web (push) Successful in 3m40s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
docker / deploy-docs (push) Successful in 31s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 2m44s
docker / builders-arm64cross (push) Successful in 9s
ci / docs-site (push) Successful in 4m45s
apple / distribute (push) Successful in 10m52s
ci / rust (push) Successful in 15m14s
apple / screenshots (push) Canceled after 3m41s
2026-08-13 19:18:54 +00:00
enricobuehler 8216f1d92d fix(host): a 2 s keyframe cadence is the client's flush cooldown, not display churn
ci / bun-nix (pull_request) Successful in 45s
apple / swift (pull_request) Successful in 2m12s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 5m52s
ci / web (pull_request) Successful in 6m40s
ci / rust-arm64 (pull_request) Successful in 6m54s
android / android (pull_request) Successful in 7m42s
ci / rust (pull_request) Successful in 12m59s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m55s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m28s
The host's recovery-cadence detector warns that "client keyframe recoveries are
METRONOMIC — a periodic host/display disturbance (display-topology churn,
display-poller software, virtual-display timing) is the likely cause, not
random network loss". In a 2026-08-13 field log it fired at period_s=2.0 and
sent the investigation at three innocent host subsystems.

2.0 s is `punktfunk_core::client::FLUSH_COOLDOWN`. The client's receive-backlog
guard sheds a standing queue with a flush plus a keyframe request and is
rate-limited to one per cooldown, so a client that cannot sustain the stream
asks for a keyframe at EXACTLY that spacing for as long as it stays behind —
the constant's own doc says it "degrades into a periodic skip + a logged
warning", which is the behaviour the detector then read as physical. Perfect
periodicity argues FOR a fixed software cooldown, not against it.

In the field case the host was blameless and the chain ran the other way: the
client refused the negotiated codec on its Vulkan rung, demoted to a slower
decode path, could not hold 4K120 there, and built the standing queue. Three
layers between the symptom the host reported and the cause.

So the detector now routes: a period on the client's cooldown names the client
and says where to look in ITS log (`receive backlog stopped draining`, and a
demoted decode rung); anything else keeps the display-disturbance wording it
had. The comparison reads FLUSH_COOLDOWN itself — now `pub` for exactly this,
documented as such — rather than a copy of the number, so the two cannot drift.
±10 % absorbs scheduling jitter and the request's trip without being wide
enough to swallow the disturbance cadences the other branch exists to report.

Verified: 18/18 native::stream::tests on linux/amd64 (container), including the
new case, which derives its inputs from FLUSH_COOLDOWN so it survives a retune;
clippy --all-targets -D warnings clean; cargo check clean on the Windows CI
runner.
2026-08-13 21:00:48 +02:00
enricobuehler 1677d1c0c2 fix(host/audio): stop warning that "the stream will click" when there is no stream
A 2026-08-13 field host log carried ten "the audio encode thread could not keep
up — captured audio was DROPPED" warnings, the worst reading
dropped_chunks=11251. That reads like catastrophic audio loss. It was not: not
one sample anybody wanted was lost.

PipeWire negotiated a 128-frame quantum, so the plane produces 48000/128 = 375
chunks/s and a 30 s stats window holds exactly 11250 — those windows were a
100 % drop rate, at peak_db=-120.0 (digital silence). Every one of the ten
straddled a session boundary, and across all of them dropped_chunks/375 matches
the seconds with NO live session in that window to within a fraction of a
second (3890/375 = 10.4 s against a 10.5 s gap; 3616/375 = 9.6 s against 9.8 s).

The capturer is host-lifetime: the native and gamestream planes PARK it between
sessions (`AudioCapturer::idle`) rather than dropping it, but the consumer is
the per-session encode thread. The hand-off channel is a bounded
sync_channel(64), so ~170 ms after a session ends it is full and every
try_send fails for as long as the host sits idle — counted as the encode thread
falling behind, and reported with a sentence about a stream that does not
exist. It is the worst kind of false alarm: it names a real failure mode, in a
subsystem with real open audio work, at a volume that demands attention.

So the drop counter now only counts while a session is actually reading, via an
`active` flag shared with the capture thread and toggled by the same
open/drain/idle/Drop transitions that already own the routing claim. A full
channel under a live consumer still means exactly what it used to.

Both backends: the parking call sites are platform-independent, so the WASAPI
half had the identical defect (it had no `idle` at all, and gains one). Only
the Linux half has field evidence.

Verified: punktfunk-host clippy --all-targets -D warnings clean on
linux/amd64 (container) and cargo check clean on the Windows CI runner.
2026-08-13 21:00:26 +02:00
enricobuehler 9a52d725d5 fix(apple): every console screen read the ink it publishes, so a pale palette stayed white on tvOS
ci / rust-arm64 (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 2m49s
ci / docs-site (pull_request) Successful in 2m21s
ci / bun-nix (pull_request) Successful in 19s
apple / swift (pull_request) Successful in 2m31s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 6m49s
A screen that applies `gamepadPaletteInk()` to its own body sits ABOVE its own copy of the
environment: the modifier covers its descendants, never the body's own `ink.…` references. So
each of these screens read whatever was published above it — and on tvOS, where they are
presented as covers rather than nested in the iOS shell, that is nothing at all. They got the
bare dark default while their CHILD views (the hint bar, the host tiles, the glass) resolved the
real palette, which is why a pale field came out with a white title, white row labels and white
values under correctly-pale glass, with the focus wash still brand violet instead of the
palette's accent. The same trap the `gamepadMetrics` comment already documents, one environment
key over.

Resolve the ink from the stored `ui_palette` instead of the environment in the six screens that
publish it, and in `GamepadScreenBackground` — mounted as their `.background { }`, so it was
reading the parent's ink too and bleaching a pale field's scrim toward white.

Three more of the same family, all tvOS-only:
  - the pairing cover drew the system's dark chrome straight over the launcher showing through
    it (a tvOS cover has no background of its own): the PIN prompt was white on the bright
    aurora. It gets the console field and the palette now, in the launcher's branch only — the
    touch route to the same sheet still belongs to the system background.
  - the library cover's navigation title is drawn by the NavigationStack, which wraps LibraryView
    from outside its own ink, so the shelf's name stayed white over content that had already gone
    dark. Fixed on tvOS and on the macOS sheet (gated there — that sheet is both modes').
  - the library's loading / error / empty states mounted no backdrop at all; only the coverflow
    did. They now take the same field, so the spinner no longer sits on the launcher's own
    aurora with the host tiles still visible behind it.

And a contrast bug the same screens made visible: a saved host's badge glyph took `fg`, which is
chosen against the FIELD, while the badge it sits on IS the accent. The two disagree at both ends
of the set — a pale palette put near-black on a deep accent, Graphite (accent luma 0.80) put
white on light grey. It takes `onAccent` now, like the selected settings tab.

Verified on the tvOS 26.5 simulator across Mint, Sunset, Violet and Graphite: launcher, settings,
add-host, pairing and the library's loading state. `swift test` 288 passed / 6 skipped; iOS and
tvOS both build.
2026-08-13 20:58:36 +02:00
enricobuehler fbbfce9b0e fix(console-ui): Skia sized its function table to the loader, not to what we promised
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 3m33s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 5m15s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m58s
ci / rust (pull_request) Successful in 16m40s
android / android (pull_request) Successful in 5m18s
The skia-safe 0.87 -> 0.99 move swapped `BackendContext::new` for
`new_builder(..., None)` and recorded the `None` as "byte-for-byte what the
(now removed) `BackendContext::new` did". That is true of the VALUE and false
of the BEHAVIOUR. `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel,
and the newer Skia acts on that sentinel by falling back to
`vkEnumerateInstanceVersion()` -- the LOADER's ceiling, not ours. The presenter
declares 1.3; a current Mesa answers 1.4 (1.4.321 on SteamOS 3.7, host and
inside the flatpak sandbox alike). Skia then validates a 1.4 function table
against an instance that only ever promised 1.3, `vkGetDeviceProcAddr` returns
null for the entry points in between, validation fails, and `make_vulkan` hands
back `None`. At 0.87 the same sentinel was inert, because that Skia knew nothing
of Vulkan 1.4 -- which is why this surfaced the moment 0.28.0 landed.

`run.rs` makes an overlay that cannot init fatal for `--browse`, so on the Steam
Deck the console home died on update: the Decky panel's button and the
gamepad-UI library shortcut both launch `PF_BROWSE=1`, and neither would open.
In a stream the same failure only warns, so those sessions quietly lost their
stats OSD and capture HUD instead. `pf-presenter`'s `vk` module is
`cfg(any(linux, windows))`, so this was never Deck-specific.

The presenter now publishes the version an overlay may size itself to as
`SharedDevice::api_version`, and `SkiaOverlay::init` passes it instead of `None`.
It is `min(what we declared, what the loader reports)`: taking the loader's
number alone is this bug, and taking ours alone would break the mirror case,
where a 1.1+ loader accepts our 1.3 `apiVersion` as intent even when it cannot
deliver 1.3. Three unit tests pin both directions and the no-answer case. The
three `API_VERSION_1_3` spellings in setup.rs now read the one constant, so the
number the overlay is told can no longer drift from the number we asked for.

Measured on the Deck (RADV VANGOGH, loader 1.4.321) with a standalone repro
against the shipped crate -- the client build is not needed to see it:

  vkEnumerateInstanceVersion() -> 1.4.321 ; VkApplicationInfo -> 1.3.0
  max_api_version = None      => DirectContext NULL
  max_api_version = Some(1.3) => DirectContext OK

Verified: cargo fmt --all --check; and in the pf-lxcheck2 x86_64 container,
cargo build + cargo clippy --all-targets -- -D warnings for pf-console-ui and
pf-presenter, plus cargo test -p pf-presenter (46 passed). Note that
`cargo check -p pf-console-ui` on macOS is vacuous -- every mod in that crate is
cfg(linux|windows), so it compiles nothing there.
2026-08-13 20:50:09 +02:00
enricobuehler 3f738a9989 fix(pf-vkdecode): AV1's "maximum parameters" level is not a level above the ceiling
A 2026-08-13 field report from the same RTX 5060 client as a02014ec: every AV1
session demoted to D3D11VA with "outside device caps: stream level
(seq_level_idx 31) above the device's maxLevel (AV1 Std level 23)" — 4K120,
NVIDIA, the hardware decoding the stream trivially on the D3D11VA rung it fell
through to. a02014ec fixed the H.264/H.265 half of exactly this and left AV1
alone on the premise that "no over-declaration has been seen in the field";
the reporter's own log from that same day already showed otherwise.

seq_level_idx is a 5-bit field. Annex A defines 0…23 (levels 2.0…7.3),
reserves 24…30, and makes 31 the "maximum parameters" level — the spec's own
way of saying the bitstream is NOT constrained to a level. StdVideoAV1Level
stops at 7.3 = 23, so 31 has no Std code point and the index-coded comparison
that holds across 0…23 says nothing here: 31 > 23 is true even of a device
that decodes everything AV1 can name, which is what makes it useless as a
capability test. We write no AV1 level on any host encode path, so whichever
sentinel the vendor's encoder defaults to is what the client must accept.

So the gate warns once and proceeds, like its H.265 sibling. Unlike H.265
there is nothing to clamp: StdVideoAV1SequenceHeader carries no level field,
so the declaration never reaches the driver and cannot be invalid usage. The
stream's real demands stay enforced where they are physical facts — coded
extent and DPB depth, both checked at session build.

Not verified on glass: no RTX 5060 here, and the reporter's box is the only
one that has produced a seq_level_idx 31 stream. The unit test pins the
arithmetic that made the refusal look reasonable.
2026-08-13 20:05:41 +02:00
30 changed files with 837 additions and 116 deletions
@@ -453,6 +453,10 @@ struct ContentView: View {
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
.frame(minWidth: 940, minHeight: 620)
// The stack draws the title, and it sits outside LibraryView's own ink see the tvOS
// cover. Gated, because this sheet is BOTH modes' library on macOS and the touch
// grid's title belongs to the system background.
.gamepadPaletteInk(gamepadUIActive)
}
#else
// iOS: the cover is the TOUCH UI's presentation only. In gamepad mode the library is one
@@ -851,12 +855,29 @@ struct ContentView: View {
.fullScreenCover(item: $pairingTarget) { host in
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
.onExitCommand { pairingTarget = nil }
// A tvOS cover draws NO background of its own, and this one is attached
// outside the launcher's `gamepadPaletteInk` so the pairing screen used
// to render the system's dark chrome directly over the launcher showing
// through it, which under a pale palette is white text on a bright field
// (the PIN prompt was all but invisible). Give it the console's own field
// and the palette's ink, like every other screen the launcher opens. Only
// this branch: `HomeView`'s route to the same sheet is the TOUCH UI, which
// sits on the system background and has no palette.
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background { GamepadFormBackground() }
.gamepadPaletteInk()
}
.fullScreenCover(item: $libraryTarget) { shelf in
NavigationStack {
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
.onExitCommand { libraryTarget = nil }
// On the STACK, not just inside LibraryView: the navigation title is drawn by
// the stack, which wraps that view from outside its own `gamepadPaletteInk`
// so the shelf's name stayed white over a pale field while the content below
// it had already gone dark. Unconditional here because this cover only exists
// in the launcher's branch, where the console UI is by definition drawing.
.gamepadPaletteInk()
}
#endif
} else {
@@ -973,9 +994,15 @@ struct ContentView: View {
model?.disconnect() // the captured-state D combo
},
onFrame: { [meter = model.meter, latency = model.latency,
split = model.latencySplit, queue = model.clientQueue,
offset = conn.clockOffsetNs] au in
split = model.latencySplit, queue = model.clientQueue] au in
meter.note(byteCount: au.data.count)
// Read the offset PER AU (an atomic load), never in the capture list: a
// capture-list `offset =` froze the connect-time estimate for the whole
// session, and on a host whose wall clock steps (VM + NTP) that frozen
// value shifted hostnet/e2e by ~15 ms between sessions while the meter's
// impossible-sample guard hid the damage (field 2026-08-13). See
// `PunktfunkConnection.clockOffsetNs`.
let offset = conn.clockOffsetNs
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
// host/network split drained by the 1 s stats tick). receivedNs is
@@ -12,7 +12,10 @@ import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
struct GamepadAddHostView: View {
@Environment(\.gamepadInk) private var ink
/// Resolved from the stored palette, NOT from `\.gamepadInk` this screen publishes that
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.dismiss) private var dismiss
@@ -430,7 +430,12 @@ private struct HintCellStyle: ButtonStyle {
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
struct GamepadScreenBackground: View {
@Environment(\.gamepadInk) private var ink
/// Resolved from `paletteID` below rather than `\.gamepadInk`: this is mounted as a screen's
/// `.background { }`, which the screen attaches BEFORE its own `gamepadPaletteInk()`, so the
/// environment here is the screen's parent's the dark default under a cover or a sheet. It
/// only feeds a pale palette's scrim, so the symptom was subtle: the field bleached toward
/// white instead of settling onto its own ink. (see `GamepadInk.stored`)
private var ink: GamepadInk { .stored(paletteID) }
/// How far toward the form screens' quiet the field sits: 0 = the launcher's full aurora,
/// 1 = calm, fractional mid-chase. Continuous (not a Bool) so the in-place shell can CHASE
/// it during a push/pop the console does the same with its `bg_mix` and every
@@ -64,7 +64,10 @@ private struct HomeTile: Identifiable {
}
struct GamepadHomeView: View {
@Environment(\.gamepadInk) private var ink
/// Resolved from the stored palette, NOT from `\.gamepadInk` this screen publishes that
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }
/// Published by ContentView at the app ROOT, so this reads its own window's tier this screen
/// applies `gamepadPaletteInk` itself and so sits above its own copy of the environment.
@Environment(\.gamepadMetrics) private var metrics
@@ -657,6 +660,12 @@ private struct GamepadHostTile: View {
private var monogramBadge: some View {
let shape = RoundedRectangle(cornerRadius: Self.badgeCorner, style: .continuous)
// What the glyph is drawn ON: a filled badge IS the accent, so its mark takes `onAccent`
// the colour picked by the accent's own luminance exactly as the settings screen's
// selected tab pill does. It used to take `fg`, which is chosen against the FIELD, and the
// two disagree at both ends of the set: a pale palette put near-black on a deep accent, and
// Graphite (accent luma 0.80) put white on a light grey.
let glyph = tile.filled ? ink.onAccent : ink.accent
return ZStack {
shape.fill(tile.filled
? AnyShapeStyle(LinearGradient(
@@ -664,7 +673,7 @@ private struct GamepadHostTile: View {
startPoint: .top, endPoint: .bottom))
: AnyShapeStyle(ink.accent(0.16)))
if tile.isConnecting {
ProgressView().tint(ink.fg)
ProgressView().tint(glyph)
} else if let icon = tile.icon {
Image(systemName: icon)
.font(.system(size: Self.iconFont, weight: .semibold))
@@ -676,12 +685,12 @@ private struct GamepadHostTile: View {
.resizable()
.scaledToFit()
.frame(width: Self.monogramFont, height: Self.monogramFont)
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
.foregroundStyle(glyph)
.accessibilityLabel(tile.osChain ?? "")
} else {
Text(monogram(tile.title))
.font(.geistFixed(Self.monogramFont, .bold))
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
.foregroundStyle(glyph)
}
}
.frame(width: Self.badgeSide, height: Self.badgeSide)
@@ -67,6 +67,24 @@ struct GamepadInk: Equatable, Sendable {
/// The shipped dark look what a preview or a test composition gets.
static let dark = GamepadInk.of(GamepadPalette.named("violet"))
/// The ink for a stored `ui_palette` id, resolved WITHOUT the environment.
///
/// For the screens that publish their own ink with `gamepadPaletteInk()`. A view's
/// `@Environment` resolves against its PARENT the modifier a screen applies to its own body
/// covers its descendants, never the body's own `ink.` references so such a screen reads
/// whatever was published ABOVE it. Nested inside another gamepad screen (the iOS shell's
/// layers) that happens to be right; presented as a cover or a sheet (tvOS, macOS) there is
/// nothing above it and it gets the bare dark default. That is precisely how a pale palette
/// came out with a WHITE title, white row labels and a violet focus wash on an Apple TV, while
/// the child views in the same screen the hint bar, the host tiles, the glass were
/// correctly dark-on-pale.
///
/// Declare it beside an `@AppStorage(DefaultsKey.uiPalette)`, which is what re-renders the
/// screen when the setting changes (`GamepadInkModifier` reads the same key).
static func stored(_ paletteID: String) -> GamepadInk {
.of(GamepadPalette.named(paletteID))
}
/// The online pip deliberately NOT palette-derived: a status colour must not change
/// meaning with the wallpaper (the console's rule; this is its `ONLINE_GREEN` verbatim).
static let onlineGreen = Color(red: 0.20, green: 0.84, blue: 0.29)
@@ -10,7 +10,10 @@ import SwiftUI
#if os(iOS)
struct GamepadLibraryScreen: View {
@Environment(\.gamepadInk) private var ink
/// Resolved from the stored palette, NOT from `\.gamepadInk` this screen publishes that
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }
@ObservedObject var store: HostStore
let target: LibraryTarget
let onLaunch: (String) -> Void
@@ -19,7 +19,10 @@ import SwiftUI
import GameController
struct LibraryCoverflowView: View {
@Environment(\.gamepadInk) private var ink
/// Resolved from the stored palette, NOT from `\.gamepadInk` this screen publishes that
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }
let games: [GameEntry]
let artLoader: LibraryArtLoader?
var onLaunch: ((String) -> Void)?
@@ -94,6 +94,9 @@ struct LibraryView: View {
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
mode: gamepadUIMode)
}
/// True when the iOS shell already draws one persistent field behind its layers mounting a
/// second would double the mesh (the same rule the coverflow and the settings screen follow).
@Environment(\.gamepadHostedInShell) private var hostedInShell
#endif
var body: some View {
@@ -150,12 +153,13 @@ struct LibraryView: View {
@ViewBuilder private var content: some View {
if loading && games.isEmpty {
ProgressView("Loading library…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
consoleField(
ProgressView("Loading library…")
.frame(maxWidth: .infinity, maxHeight: .infinity))
} else if let errorText, games.isEmpty {
errorState(errorText)
consoleField(errorState(errorText))
} else if games.isEmpty {
emptyState
consoleField(emptyState)
} else {
if gamepadUIActive {
LibraryCoverflowView(
@@ -168,6 +172,24 @@ struct LibraryView: View {
}
}
/// The console field behind the three states that are NOT the coverflow loading, error,
/// empty. The coverflow mounts its own backdrop; these mounted nothing, so wherever this view
/// is a COVER over the launcher (tvOS, macOS) they drew straight onto it: the spinner and its
/// label sat on the launcher's own aurora with the host tiles still showing through. The same
/// field as the coverflow's (not the calmed form one), so nothing shifts under the content when
/// the titles land and the coverflow takes over.
///
/// Only in gamepad mode: the plain grid's states belong on the system background, as before.
@ViewBuilder private func consoleField(_ view: some View) -> some View {
#if os(iOS) || os(macOS) || os(tvOS)
view.background {
if gamepadUIActive, !hostedInShell { GamepadScreenBackground() }
}
#else
view
#endif
}
private var grid: some View {
// Design D4: launcher entries get their own section above the titles, never interleaved.
// Both headers appear only when both groups exist, so a library without launcher entries
@@ -20,6 +20,18 @@ import SwiftUI
/// instrument: any visible overlay forces the metal layer through the compositor, which costs a
/// refresh period on the vsync-latched platforms this is how to measure with it off.
private let statsLog = Logger(subsystem: "io.unom.punktfunk", category: "stats")
/// Mirror the 1 Hz vitals line to STDOUT as well as the unified log.
///
/// Exists for **tvOS, where the unified log is unreachable**: `log stream --device` is gone from
/// modern macOS, `log collect --device-name` needs root and then fails "Device not configured"
/// (an Apple TV has no USB to fall back to), and libimobiledevice pairs against a different
/// database than Xcode. Stdout, however, IS bridged `xcrun devicectl device process launch
/// --console -e '{"PUNKTFUNK_STATS_STDOUT":"1"}' io.unom.punktfunk` streams these lines straight
/// to the Mac. That is the only way to read a session's numbers with the **stats overlay OFF**,
/// which matters because the overlay is itself a composited layer over the Metal one i.e. a
/// plausible cause of the very present-floor inflation the overlay is used to measure.
/// Env-gated: no cost, and no stdout noise, unless someone is deliberately measuring.
private let statsToStdout = ProcessInfo.processInfo.environment["PUNKTFUNK_STATS_STDOUT"] == "1"
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
/// values. NSLock instead of an actor the writer is the (non-async) pump thread.
@@ -137,6 +149,25 @@ final class SessionModel: ObservableObject {
/// and under stage-1.
@Published var osFloorP50Ms = 0.0
@Published var osFloorValid = false
/// The deadline link's `preferredFrameLatency` ASK beside its property READBACK (see
/// `PresentLinkInfo` it exists because tvOS has no reachable log). The readback is NOT
/// a grant: it is a plain float property, so it echoes whatever was stored unless the
/// system clamps the setter. readback ask a visible clamp (the one signal the API can
/// give); readback == ask proves nothing `osFloorP50Ms` (the measured vend lead) is the
/// truth-teller (field 2026-08-13: readback 1.00 beside a 32.5 ms floor).
@Published var linkLatencyAskFrames: Float = 0
@Published var linkLatencyFrames: Float = 0
@Published var linkRangeMinHz: Float = 0
@Published var linkRangeMaxHz: Float = 0
@Published var linkDrawables = 0
@Published var linkInfoValid = false
/// Impossible samples the HOST-ANCHORED meters (host+network, end-to-end) refused this
/// second (`LatencyMeter.drainTrimmed`). Nonzero means the clock offset is lying and every
/// host-anchored p50/p95 this window is a TRUNCATED distribution the HUD marks the window
/// suspect instead of letting a trimmed tail pose as a healthy small number (the field
/// "e2e 03 ms" reading, 2026-08-13). Client-local stages can't go negative, so they carry
/// no such term.
@Published var skewTrimPerS = 0
/// The AUDIO plane's latency, from the playback ring (`SessionAudio.Stats`): how much decoded
/// audio is queued ahead of the speaker, and where that PUTS it relative to the picture
/// (positive = audio behind). `audioValid` is false until playback runs.
@@ -683,6 +714,10 @@ final class SessionModel: ObservableObject {
displayValid = false
clientQueueValid = false
osFloorValid = false
linkInfoValid = false
// Drop the previous session's grant too the shared box outlives the session, and a new
// link may never come up (a non-deadline rung has none at all).
PresentLinkInfo.shared.clear()
audioValid = false
lostFrames = 0
lostPct = 0
@@ -904,6 +939,10 @@ final class SessionModel: ObservableObject {
} else {
self.endToEndValid = false
}
// Drained even when the stats drains came back empty with a badly wrong offset
// an entire window is refused and only this counter still tells the story.
self.skewTrimPerS =
self.latency.drainTrimmed() + self.endToEnd.drainTrimmed()
if let d = self.decodeStage.drain() {
self.decodeP50Ms = d.p50Ms
self.decodeValid = true
@@ -923,6 +962,18 @@ final class SessionModel: ObservableObject {
} else {
self.osFloorValid = false
}
// The display link's latency ask + property readback (deadline rung only) a
// LEVEL, not a window, so it is read rather than drained.
if let l = PresentLinkInfo.shared.snapshot() {
self.linkLatencyAskFrames = l.ask
self.linkLatencyFrames = l.latency
self.linkRangeMinHz = l.rangeMin
self.linkRangeMaxHz = l.rangeMax
self.linkDrawables = l.drawables
self.linkInfoValid = true
} else {
self.linkInfoValid = false
}
if let q = self.clientQueue.drain() {
self.clientQueueP50Ms = q.p50Ms
self.clientQueueValid = true
@@ -951,6 +1002,14 @@ final class SessionModel: ObservableObject {
// Swift Int is 64-bit %lld, NOT %d (which is a 32-bit C int); macOS 26's
// strict String(format:) validator rejects the %d/Int mismatch and drops
// the whole line (a cascade error that also mis-blames the float args).
//
// Every invalid-field fallback below MUST be a typed `-1.0` (or a
// `Double(...)`-wrapped value), never a bare `-1`: in this variadic
// `CVarArg` context the ternary does NOT unify to Double the untyped
// literal goes in as Int, and `%f` then reads Int64(-1)'s all-ones bit
// pattern, which IS a quiet NaN. Field 2026-08-13 (tvOS, stage-1, the
// first session ever to have invalid fields while frames flowed): every
// fallback printed `nan`. Latent since the line was added.
format: "fps=%lld presents=%lld e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
+ "decode_p50=%.1f display_p50=%.1f lost=%lld "
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f "
@@ -958,22 +1017,35 @@ final class SessionModel: ObservableObject {
// In the log as well as on the HUD because the overlay is only up when
// someone thought to turn it on, and the reports that need these
// numbers arrive after the fact.
+ "audio_buffer=%lld audio_av_offset=%lld",
+ "audio_buffer=%lld audio_av_offset=%lld "
// The deadline link's latency ask + property readback (both -1 on
// non-deadline rungs) appended so the PUNKTFUNK_FRAME_LATENCY
// ladder is readable over the stdout channel with the HUD off,
// which is the only honest way to run it on a tvOS device.
+ "link_ask=%.2f link_readback=%.2f "
// Impossible samples the host-anchored meters refused this window:
// nonzero the clock offset is lying and e2e/hostnet above are
// truncated distributions disregard their p50/p95.
+ "skew_trim=%lld",
frames,
displayWindow?.count ?? 0,
self.endToEndValid ? self.endToEndP50Ms : -1,
self.endToEndValid ? self.endToEndP95Ms : -1,
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
self.decodeValid ? self.decodeP50Ms : -1,
self.displayValid ? self.displayP50Ms : -1,
self.endToEndValid ? self.endToEndP50Ms : -1.0,
self.endToEndValid ? self.endToEndP95Ms : -1.0,
self.hostNetworkValid ? self.hostNetworkP50Ms : -1.0,
self.decodeValid ? self.decodeP50Ms : -1.0,
self.displayValid ? self.displayP50Ms : -1.0,
lost,
self.osFloorValid ? self.osFloorP50Ms : -1,
self.displayValid ? self.displayAdjP50Ms : -1,
self.endToEndValid ? self.endToEndAdjP50Ms : -1,
self.clientQueueValid ? self.clientQueueP50Ms : -1,
self.osFloorValid ? self.osFloorP50Ms : -1.0,
self.displayValid ? self.displayAdjP50Ms : -1.0,
self.endToEndValid ? self.endToEndAdjP50Ms : -1.0,
self.clientQueueValid ? self.clientQueueP50Ms : -1.0,
self.audioValid ? self.audioBufferMs : -1,
self.audioValid ? self.audioAvOffsetMs : 0)
self.audioValid ? self.audioAvOffsetMs : 0,
self.linkInfoValid ? Double(self.linkLatencyAskFrames) : -1.0,
self.linkInfoValid ? Double(self.linkLatencyFrames) : -1.0,
self.skewTrimPerS)
statsLog.info("\(line, privacy: .public)")
if statsToStdout { print("pf.stats \(line)") }
}
}
}
@@ -128,6 +128,29 @@ struct StreamHUDView: View {
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary)
}
// The deadline link's frame-latency ASK beside its property READBACK. The
// readback is NOT a grant the property echoes whatever we stored (field
// 2026-08-13: 1.00 beside a 32.5 ms `os present` floor). The line earns its
// place because a readback that DIFFERS from the ask is the one clamp signal
// the API can give, and on tvOS the screen is the only place to read either
// (no log is reachable on an Apple TV; see PresentLinkInfo).
if model.linkInfoValid {
Text("link latency ask \(model.linkLatencyAskFrames, specifier: "%.2f") readback \(model.linkLatencyFrames, specifier: "%.2f") · range \(model.linkRangeMinHz, specifier: "%.0f")-\(model.linkRangeMaxHz, specifier: "%.0f") Hz · drawables \(model.linkDrawables)")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary)
}
// The clock-offset tripwire: host-anchored meters refused samples as
// impossible ( 0 after offset correction) this second. When this shows,
// e2e and host+network above are TRUNCATED distributions a wrong skew
// offset shifted them and the impossible half was trimmed so their
// p50/p95 flatter the stream (the field "e2e 03 ms" reading). Orange on
// purpose: every other stat here stays legible-quiet, but a number that
// has stopped meaning anything must not.
if model.skewTrimPerS > 0 {
Text("clock offset suspect — \(model.skewTrimPerS)/s impossible samples trimmed; e2e & host+network unreliable")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.orange)
}
// Client-queue wait (reassembly receipt decode pull, ABI v9 split): ~0 on
// a healthy stream and hidden as noise; shown from 2 ms a persistent value
// is a client-side standing backlog that pre-split builds displayed as
@@ -45,7 +45,10 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
}
struct GamepadSettingsView: View {
@Environment(\.gamepadInk) private var ink
/// Resolved from `paletteID` below, NOT from `\.gamepadInk` this screen publishes that value
/// itself and so sits above its own copy (see `GamepadInk.stored`). Reading the environment
/// here is what left the title, the tab pills and every row label white-on-pale on tvOS.
private var ink: GamepadInk { .stored(paletteID) }
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.dismiss) private var dismiss
@@ -18,7 +18,10 @@ import SwiftUI
#if os(iOS) || os(macOS)
struct GamepadPairView: View {
@Environment(\.gamepadInk) private var ink
/// Resolved from the stored palette, NOT from `\.gamepadInk` this screen publishes that
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.dismiss) private var dismiss
@@ -382,12 +382,29 @@ public final class PunktfunkConnection {
/// the client draws its own (a visible system cursor over the stream).
public private(set) var resolvedCompositor: Compositor = .auto
/// Host clock minus client clock (nanoseconds), from the connect-time wall-clock skew handshake
/// (`punktfunk_connection_clock_offset_ns`). Add it to a local `CLOCK_REALTIME` instant to
/// express that instant in the host's capture clock the clock each `AccessUnit.ptsNs` is
/// stamped in so a glass-to-glass latency (present/enqueue time minus `ptsNs`) is valid across
/// machines. `0` = no correction (an older host that didn't answer, or synchronized clocks).
public private(set) var clockOffsetNs: Int64 = 0
/// Host clock minus client clock (nanoseconds) LIVE: the connect-time skew handshake's
/// estimate, kept fresh by the core's mid-stream re-syncs (every 60 s plus immediately on a
/// suspected wall-clock step; `punktfunk_connection_clock_offset_now_ns`, ABI v10). Add it to
/// a local `CLOCK_REALTIME` instant to express that instant in the host's capture clock the
/// clock each `AccessUnit.ptsNs` is stamped in so a glass-to-glass latency (present/enqueue
/// time minus `ptsNs`) is valid across machines. `0` = no correction (an older host that
/// didn't answer, synchronized clocks, or a closed connection).
///
/// LIVE means DO NOT CACHE. Until 2026-08-13 this was a connect-time snapshot, and the
/// core's own doc names the failure: "after an NTP step or slow drift the connect-time value
/// silently corrupts every capture-clock comparison." The field evidence was stark two
/// sessions minutes apart against the same wired host read hostnet 1721 ms, then a
/// physically impossible 4.4 ms (the host is a VM; VM wall clocks step), and LatencyMeter's
/// impossible-sample guard silently trimmed the shifted-negative half, so the HUD showed a
/// plausible small number instead of an alarm. Read this property at each use it is an
/// atomic load behind the FFI and never park it in a `let` or a closure capture list.
/// Cross-thread reads follow the `framesDropped()` precedent.
public var clockOffsetNs: Int64 {
guard let handle else { return 0 }
var offset: Int64 = 0
_ = punktfunk_connection_clock_offset_now_ns(handle, &offset)
return offset
}
/// The video encoder bitrate (kbps) the host actually configured the requested
/// `bitrateKbps` clamped to the host's range ([500, 2 000 000] kbps), or its default
@@ -635,9 +652,6 @@ public final class PunktfunkConnection {
var comp: UInt32 = 0
_ = punktfunk_connection_compositor(handle, &comp)
resolvedCompositor = Compositor(rawValue: comp) ?? .auto
var offset: Int64 = 0
_ = punktfunk_connection_clock_offset_ns(handle, &offset)
clockOffsetNs = offset
var br: UInt32 = 0
_ = punktfunk_connection_bitrate(handle, &br)
resolvedBitrateKbps = br
@@ -15,8 +15,9 @@ import Foundation
/// `record(ptsNs:atNs:offsetNs:)` at present.
///
/// For the host-anchored intervals (capture) the sample is `end + offset - pts_ns`, where
/// `pts_ns` is the host's capture wall clock (the AU's pts) and the connect-time **clock-skew
/// offset** (`PunktfunkConnection.clockOffsetNs`, host minus client) makes the difference valid
/// `pts_ns` is the host's capture wall clock (the AU's pts) and the LIVE **clock-skew
/// offset** (`PunktfunkConnection.clockOffsetNs`, host minus client, mid-stream re-synced
/// read it per record, never cached) makes the difference valid
/// across machines. `offsetNs == 0` means an old host that didn't answer the skew handshake (or
/// genuinely synced clocks) the number is then only meaningful same-host, and the HUD tags the
/// end-to-end line `(same-host clock)`.
@@ -24,6 +25,8 @@ public final class LatencyMeter: @unchecked Sendable {
private let lock = NSLock()
private var samplesUs: [Int64] = []
private var skewCorrected = false
/// Samples `record` refused as impossible since the last `drainTrimmed` (see the guard).
private var trimmed = 0
/// The most recent sample and the instant it ended, for `latestSample(asOfNs:maxAgeMs:)`
/// a LEVEL, not a window, so `drain` deliberately leaves both alone.
private var latestNs: Int64 = 0
@@ -49,8 +52,19 @@ public final class LatencyMeter: @unchecked Sendable {
public func record(ptsNs: UInt64, atNs: Int64, offsetNs: Int64) {
let latNs = atNs &+ offsetNs &- Int64(bitPattern: ptsNs)
// Drop absurd values (a clock step, a wildly wrong offset, garbage pts, or a stage whose
// start stamp is missing/after its end) samples are clamped to (0, 10 s).
guard latNs > 0, latNs < 10_000_000_000 else { return }
// start stamp is missing/after its end) samples are clamped to (0, 10 s). COUNTED, not
// silent: a cluster of non-positive samples is the signature of a wrong clock offset
// (client-local stages can't go negative), and a meter that quietly trims the impossible
// half of a shifted distribution presents the surviving tail as a plausible small number
// field 2026-08-13: "e2e 03 ms p50 / 23 ms p95" on a session whose true hostnet was
// ~18 ms. `drainTrimmed` surfaces the count so the window can be MARKED suspect instead
// of looking healthy.
guard latNs > 0, latNs < 10_000_000_000 else {
lock.lock()
trimmed += 1
lock.unlock()
return
}
lock.lock()
samplesUs.append(latNs / 1000)
latestNs = latNs
@@ -99,6 +113,18 @@ public final class LatencyMeter: @unchecked Sendable {
public let skewCorrected: Bool
}
/// Take-and-reset the count of impossible samples `record` refused (see its guard). Drained
/// SEPARATELY from `drain()` on purpose: with a badly wrong offset EVERY sample of a window
/// can be non-positive, `drain()` then returns `nil` and a count folded into `Stats` would
/// vanish with it, hiding the very windows that scream loudest. This survives an empty window.
public func drainTrimmed() -> Int {
lock.lock()
defer { lock.unlock() }
let n = trimmed
trimmed = 0
return n
}
/// Percentiles over the samples accumulated since the last drain, then reset the window. `nil`
/// when no samples arrived in the interval.
public func drain() -> Stats? {
@@ -549,6 +549,11 @@ public final class MetalVideoPresenter {
layer.contentsGravity = .resizeAspect
// Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the display-link /
// MAIN thread) has to block waiting for one to free.
// This is the STAGE-2/3 depth. Stage-4 (deadline pacing, the iOS/tvOS default) never
// calls `nextDrawable()` the link vends every drawable so the third slot only gives
// the compositor room to queue a second present ahead of scanout, i.e. the two-refresh
// present floor. `Stage2Pipeline.startDeadlinePresenter` clamps it to 2 for that pacing;
// keep the two in step if this number ever changes.
layer.maximumDrawableCount = 3
return MetalVideoPresenter(
@@ -260,13 +260,22 @@ final class SessionPresenter {
// value is deliberately ignored). The user-facing choice is the INTENT
// (PresentPriority): latency (newest-wins zero-queue store) vs smoothness (a FIFO jitter
// buffer; on macOS it additionally paces presents onto the vsync grid so the buffer
// drains on display cadence). Stage-1 is reachable only via env in DEBUG; release maps
// it back to the default (the stage-1 pump below stays the automatic Metal-missing
// fallback).
// drains on display cadence). Stage-1 resolves from the persisted picker only in DEBUG;
// in release the ENV alone reaches it (the stage-1 pump below stays the automatic
// Metal-missing fallback either way).
#if DEBUG
let allowStage1 = true
#else
let allowStage1 = false
// The gate exists so a LEFTOVER value can't revive the freeze-prone fallback but the
// persisted picker is no longer read at all (setting: nil below), so the only channel
// left is the env, and an env var is never leftover: it takes a devicectl/Xcode launch
// to exist. It must stay openable on Release because Release is the only build that
// measures presentation honestly, and stage-1 is the one rung that presents on the
// hardware video plane instead of through the GPU compositor the A/B for the tvOS
// two-refresh present floor (field 2026-08-13: PUNKTFUNK_PRESENTER=stage1 on a Release
// build silently ran stage-4, which would have false-negatived that A/B).
let allowStage1 =
ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"] == "stage1"
#endif
let explicit = PresenterChoice.explicit(
setting: nil, // the legacy DefaultsKey.presenter picker value is no longer read
@@ -336,7 +345,7 @@ final class SessionPresenter {
} else {
let pump = StreamPump()
pump.start(
connection: connection, layer: baseLayer,
connection: connection, layer: baseLayer, endToEndMeter: endToEndMeter,
onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
self.pump = pump
}
@@ -271,6 +271,64 @@ final class LatestBox<T>: @unchecked Sendable {
}
}
/// The deadline link's frame-latency ASK and property READBACK, published for the HUD to render.
///
/// A readback is NOT a grant. `preferredFrameLatency` is a plain read-write float
/// (CAMetalDisplayLink.h carries no doc contract), so reading it returns whatever we last
/// stored unless the system actively clamps the setter and the 2026-08-13 field run proved
/// how misleading that is: it read 1.00 while the measured vend lead sat at 1.95 refresh
/// periods. The number that tells the truth about scheduling is the vend lead (the HUD's
/// `os present` floor), never this property. The line still earns its place twice over: a
/// readback that DIFFERS from the ask is the one clamp signal the API can give, and the ask
/// must be visible on screen because **on tvOS no log is reachable** `log stream --device`
/// is gone from modern macOS, `log collect --device-name` needs root and then fails "Device
/// not configured" because an Apple TV has no USB to fall back to, and the libimobiledevice
/// pairing is a different database from Xcode's. Console.app is a GUI.
///
/// A process-global rather than a sixth parameter threaded through SessionModel StreamView
/// controller SessionPresenter Stage2Pipeline delegate: it is write-once-per-session
/// diagnostics, and this file already keeps `presentDebug`/`presentLog` at file scope. Reset by
/// `clear()` at session start so a stale session's answer can never be read as this one's.
public final class PresentLinkInfo: @unchecked Sendable {
public static let shared = PresentLinkInfo()
private let lock = NSLock()
private var ask: Float = 0
private var latency: Float = 0
private var rangeMin: Float = 0
private var rangeMax: Float = 0
private var drawables: Int = 0
private var present = false
private init() {}
func publish(ask: Float, latency: Float, rangeMin: Float, rangeMax: Float, drawables: Int) {
lock.lock()
self.ask = ask
self.latency = latency
self.rangeMin = rangeMin
self.rangeMax = rangeMax
self.drawables = drawables
present = true
lock.unlock()
}
/// Session start a link that never comes up must not leave the previous one's answer up.
public func clear() {
lock.lock()
present = false
lock.unlock()
}
/// `nil` until the link's first update (or on a non-deadline rung, which has no link).
public func snapshot()
-> (ask: Float, latency: Float, rangeMin: Float, rangeMax: Float, drawables: Int)?
{
lock.lock()
defer { lock.unlock() }
return present ? (ask, latency, rangeMin, rangeMax, drawables) : nil
}
}
/// Deadline pacing's staged frame-rate hint. SessionPresenter pushes the stream rate from the
/// MAIN thread (session start + every layout/Reconfigure); the link's own thread drains and
/// applies it, so the CAMetalDisplayLink is only ever touched from the thread that runs it. The
@@ -312,9 +370,24 @@ private final class FrameRateHint: @unchecked Sendable {
return p
}
private static func range(hz: Float, boosted: Bool) -> CAFrameRateRange {
#if os(tvOS)
// A TV is a FIXED-rate display: there is no ProMotion panel to lift and no Pencil to
// sample for, so the `max(hz, 120)` ceiling below asks a 60 Hz Apple TV to accept
// anything up to 120. A range is a promise about how variable our cadence may be, and a
// scheduler handed 60120 on a fixed 60 Hz display has every reason to keep a frame of
// slack in hand which is what a two-refresh `targetPresentationTimestamp` IS. Pin all
// three bounds to the stream rate so the deadline has nothing to hedge against.
// (Field 2026-08-13, Apple TV 4K / tvOS 27: `os present` stuck at ~2 × 16.67 with
// `preferredFrameLatency = 1` asked for and re-asserted every update; shrinking the
// drawable pool to 2 moved it not at all.) `boosted` is deliberately ignored it exists
// for pen proximity, which tvOS does not have.
_ = boosted
return CAFrameRateRange(minimum: hz, maximum: hz, preferred: hz)
#else
let cap = max(hz, 120)
let preferred = boosted ? cap : hz
return CAFrameRateRange(minimum: preferred, maximum: cap, preferred: preferred)
#endif
}
}
@@ -435,18 +508,28 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
private let phase: PhaseReporter?
/// The OS-floor sampler (design/apple-presentation-rebuild.md): every update's vendglass
/// lead is recorded so its p50 becomes the "OS present floor" the HUD subtracts from the
/// shown display/e2e numbers. Self-adapting reads ~2 refresh periods composited today,
/// would read ~1 under direct-to-display, tracks VRR rate changes.
/// shown display/e2e numbers. Self-adapting: ~1 refresh period is the goal, ~2 means the
/// compositor is running a frame ahead of us (what a 3-slot drawable pool bought it before
/// `startDeadlinePresenter` clamped stage-4 to 2). Tracks VRR rate changes.
private let floorMeter: LatencyMeter?
/// One-shot: log the link's EFFECTIVE preferredFrameLatency after the first re-assert
/// reads 1 while vendLeadMs sits at ~2 periods the scheduler ignores the request while
/// the layer is composited (the promotion hunt); reads 2 the system clamped it outright.
/// The pool depth this session vends from (`startDeadlinePresenter` sets it on the layer).
/// Carried only so the one-shot line below reports the two halves of the depth question
/// together a `preferredFrameLatency` of 1 against a 3-slot pool is the configuration that
/// measured a two-refresh floor in the field, and reading either number alone hides that.
private let drawableCount: Int
/// The `preferredFrameLatency` this session asks for 1 by default, PUNKTFUNK_FRAME_LATENCY
/// for the on-device ladder (see `startDeadlinePresenter` for the ladder's design).
private let latencyAsk: Float
/// One-shot: log the link's preferredFrameLatency READBACK after the first re-assert. A
/// readback differing from the ask the system clamps the property (the one clamp signal
/// it can give); a readback EQUAL to the ask proves nothing only vendLeadMs does (see
/// PresentLinkInfo's doc for the field lesson).
private var loggedEffective = false
init(
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?,
phase: PhaseReporter?
phase: PhaseReporter?, drawableCount: Int, latencyAsk: Float
) {
self.stash = stash
self.renderSignal = renderSignal
@@ -454,23 +537,34 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
self.stats = stats
self.floorMeter = floorMeter
self.phase = phase
self.drawableCount = drawableCount
self.latencyAsk = latencyAsk
}
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
if let range = hint.drain(), link.preferredFrameRateRange != range {
link.preferredFrameRateRange = range
}
// Re-assert the minimum-latency request every update (cheap compare): it was set once
// before add(to:), and whether a pre-add set survives scheduling is exactly the kind of
// Re-assert the latency ask every update (cheap compare): it was set once before
// add(to:), and whether a pre-add set survives scheduling is exactly the kind of
// thing the vendLeadMs stat exists to catch belt and braces.
if link.preferredFrameLatency != 1 { link.preferredFrameLatency = 1 }
if link.preferredFrameLatency != latencyAsk { link.preferredFrameLatency = latencyAsk }
// Publish every update, not just the first: the range is re-applied from the staged hint
// above (mode switch / rate change), and `preferredFrameLatency` is re-asserted right
// here so the readback can change mid-session, and a write-once snapshot would keep
// showing the answer to a question we have since asked again. Cheap: five stores under
// an uncontended lock, once per refresh.
let range = link.preferredFrameRateRange
PresentLinkInfo.shared.publish(
ask: latencyAsk, latency: link.preferredFrameLatency, rangeMin: range.minimum,
rangeMax: range.maximum, drawables: drawableCount)
if !loggedEffective {
loggedEffective = true
let range = link.preferredFrameRateRange
let msg = String(
format: "deadline link up: effective preferredFrameLatency=%.2f "
+ "range=%.0f-%.0f preferred=%.0f",
link.preferredFrameLatency, range.minimum, range.maximum, range.preferred ?? 0)
format: "deadline link up: preferredFrameLatency ask=%.2f readback=%.2f "
+ "maxDrawables=%d range=%.0f-%.0f preferred=%.0f",
latencyAsk, link.preferredFrameLatency, drawableCount,
range.minimum, range.maximum, range.preferred ?? 0)
presentLog.info("\(msg, privacy: .public)")
}
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
@@ -729,7 +823,13 @@ public final class Stage2Pipeline {
/// (which withhold concealed frames) and driven by the pump (arm on a gap, poll per iteration).
private let gate = ReanchorGate(framesDropped: 0)
private var token = StopFlag()
private var offsetNs: Int64 = 0
/// LIVE hostclient clock offset, read AT EACH RECORD never cached per session. Until
/// 2026-08-13 this was a `let` snapshot of the connect-time handshake, and on a host whose
/// wall clock steps (a VM under NTP) the frozen value silently shifted every host-anchored
/// stat field evidence: hostnet 1721 ms one session, a physically impossible 4.4 ms the
/// next, same wired host. The core re-syncs the estimate mid-stream (60 s + step detection);
/// each call is an atomic load behind the FFI.
private var clockOffset: () -> Int64 = { 0 }
/// Signalled when the pump thread exits, so `stop()` can join it (bounded) before `decoder.reset()`
/// otherwise a pump iteration already past its `token.isStopped` check can rebuild a decode session
/// right after the reset (a brief orphan session). `pumpJoinable` is armed by `start`, consumed by
@@ -831,7 +931,7 @@ public final class Stage2Pipeline {
onSessionEnd: (@Sendable () -> Void)?,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
) {
offsetNs = connection.clockOffsetNs
clockOffset = { connection.clockOffsetNs } // live (re-synced) see the field doc
recovery.bind(connection) // arm host-keyframe recovery for this session
decodeReport.bind(connection) // arm the Automatic-bitrate decode signal for this session
phaseReporter.bind(connection) // arm phase reports (flushed only by the deadline link)
@@ -1001,7 +1101,7 @@ public final class Stage2Pipeline {
let ring = ring
let endToEndMeter = endToEndMeter
let displayMeter = displayMeter
let offsetNs = offsetNs
let clockOffset = clockOffset
let renderSignal = renderSignal
let renderStopped = renderStopped
// Present policy the user's V-Sync setting (default OFF = immediate, the long-proven
@@ -1075,7 +1175,7 @@ public final class Stage2Pipeline {
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
// End-to-end = captureon-glass, measured directly (skew-corrected via the
// connect-time clock offset) the HUD headline.
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: clockOffset())
// Display stage = decoded on-glass. Both instants are client CLOCK_REALTIME,
// so no skew offset applies.
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
@@ -1134,11 +1234,52 @@ public final class Stage2Pipeline {
let presenter = presenter
let endToEndMeter = endToEndMeter
let displayMeter = displayMeter
let offsetNs = offsetNs
let clockOffset = clockOffset
let hint = frameRateHint
let layer = presenter.layer
let stash = LatestBox<CAMetalDrawable>()
// Shrink the drawable pool to 2 for THIS pacing the measured fix for a present floor
// stuck at two refreshes (field 2026-08-13, Apple TV 4K / tvOS 27: `os present +32.5` at
// 60 Hz = 1.95 × 16.67, i.e. the system running a whole frame ahead of us).
//
// `maximumDrawableCount` is 3 from MetalVideoPresenter.make(), and its rationale there
// "more in-flight drawables before nextDrawable() has to block" is a STAGE-2 concern.
// Stage-4 never calls nextDrawable(): every drawable is vended by the link
// (`update.drawable` stash `render(into:)`), so the third slot buys this path nothing
// and costs it a refresh a pool of 3 is exactly the room the compositor needs to keep
// two presents queued ahead of scanout, which is what `preferredFrameLatency = 1` is
// asking it not to do. Two slots is the shallowest pool that still double-buffers: one
// vended (stashed or being rendered), one being scanned out.
//
// Set HERE, not on the link thread: this runs before either the render thread or the link
// thread exists, so the layer still has a single writer (the render thread owns
// drawableSize/format afterwards see MetalVideoPresenter's threading notes).
// PUNKTFUNK_DRAWABLE_COUNT=3 restores the old depth for an on-glass A/B without a
// rebuild; values outside 2...3 are ignored (CAMetalLayer's own accepted range).
let drawableCount =
ProcessInfo.processInfo.environment["PUNKTFUNK_DRAWABLE_COUNT"]
.flatMap(Int.init)
.flatMap { (2...3).contains($0) ? $0 : nil } ?? 2
layer.maximumDrawableCount = drawableCount
// The frame-latency ASK (default 1 wake as late as fits: latch the NEXT refresh).
// PUNKTFUNK_FRAME_LATENCY overrides it for the on-device ladder. The property is a
// FLOAT, so sub-frame asks (0.5) are expressible; whether the scheduler honours them
// or reacts to the property at all is exactly what the ladder measures. Field
// 2026-08-13 (Apple TV 4K, tvOS 27): ask 1 vend lead 1.95 refresh periods, and the
// readback echoed the ask throughout (it is a plain property see PresentLinkInfo).
// The discriminating runs, watching `os present` (the vend lead), are:
// ask=2 lead grows to ~3 the property WORKS and the tvOS floor is ~ask+1;
// lead stays ~2 the property is INERT here stop pulling this lever.
// ask=0.5 any lead below ~1.9 a real in-regime win to then tune.
// Clamped to 0...4: negatives/NaN are meaningless, and beyond 4 asked-for frames of
// latency nothing is being measured.
let latencyAsk =
ProcessInfo.processInfo.environment["PUNKTFUNK_FRAME_LATENCY"]
.flatMap(Float.init)
.flatMap { $0.isFinite ? min(max($0, 0), 4) : nil } ?? 1
let floorMeter = presentFloorMeter
let phaseReporter = phaseReporter
// The link starts LAZILY the render thread triggers this after the FIRST decoded
@@ -1151,9 +1292,10 @@ public final class Stage2Pipeline {
let linkThread = Thread {
let delegate = DeadlineLinkDelegate(
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
floorMeter: floorMeter, phase: phaseReporter)
floorMeter: floorMeter, phase: phaseReporter,
drawableCount: drawableCount, latencyAsk: latencyAsk)
let link = CAMetalDisplayLink(metalLayer: layer)
link.preferredFrameLatency = 1 // wake as late as fits: latch the NEXT refresh
link.preferredFrameLatency = latencyAsk // see the ladder note above
if let range = hint.drain() { link.preferredFrameRateRange = range }
link.delegate = delegate // weak this closure is the strong ref
link.add(to: RunLoop.current, forMode: .default)
@@ -1223,7 +1365,7 @@ public final class Stage2Pipeline {
let onGlass: (Int64?) -> Void = { presentedNs in
let atNs = presentedNs
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: clockOffset())
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
}
@@ -17,9 +17,18 @@ final class StreamPump {
/// Pump thread: pull AUs, wrap, enqueue. Non-IDR AUs before the first format
/// description are dropped. `onFrame`/`onSessionEnd` fire on the pump thread.
///
/// `endToEndMeter` is stage-1's ONLY latency instrument, and it measures captureENQUEUE
/// not captureglass like the Metal rungs: the layer decodes AND presents after our hand-off,
/// and AVSampleBufferDisplayLayer has no presented callback, so the tail past enqueue (its
/// internal decode + the video-plane flip) is unmeasurable from the app. Cross-rung
/// comparisons must read this as e2e MINUS decode+display and settle the remainder on
/// camera. It is still worth wiring: matching pre-tail halves between rungs pins any felt
/// difference on the present tail the video-plane-vs-compositor question itself.
func start(
connection: PunktfunkConnection,
layer: AVSampleBufferDisplayLayer,
endToEndMeter: LatencyMeter? = nil,
onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)?,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
@@ -158,7 +167,14 @@ final class StreamPump {
// flagging it DoNotDisplay the layer still decodes it (keeping the reference
// chain fed) but shows the last GOOD picture until a clean re-anchor lifts the
// gate. Folded from the AU's wire flags (stage-1 has no decode callback).
if !gate.onDecoded(flags: au.flags) {
if gate.onDecoded(flags: au.flags) {
// Captureenqueue (see start's doc). Only frames that will DISPLAY:
// a withheld frame never reaches glass, so its enqueue instant would
// dilute the population the Metal rungs are compared against. The
// offset is read PER ENQUEUE it is live (mid-stream re-synced) and
// caching it rebuilds the stale-offset corruption (see clockOffsetNs).
endToEndMeter?.record(ptsNs: au.ptsNs, offsetNs: connection.clockOffsetNs)
} else {
StreamPump.setDoNotDisplay(sample)
}
layer.enqueue(sample)
+16 -5
View File
@@ -245,11 +245,22 @@ impl Overlay for SkiaOverlay {
shared.queue_family_index as usize,
),
&get_proc,
// `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, so it caps entry-point
// validation at whatever `vkEnumerateInstanceVersion()` reports — byte-for-byte what
// the (now removed) `BackendContext::new` did. The presenter owns the instance and its
// `VkApplicationInfo`, so pinning a version here would just duplicate its choice.
None,
// 🛑 MUST be the presenter's declared version, never `None`.
//
// `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, which makes Skia fall
// back to `vkEnumerateInstanceVersion()` — the LOADER's ceiling, not ours. Those are
// not the same number: the presenter asks for 1.3, while a current Mesa loader answers
// 1.4 (1.4.321 on SteamOS 3.7). Skia then validates a 1.4 function table against an
// instance that only ever promised 1.3, `vkGetDeviceProcAddr` returns null for the
// entry points above 1.3, validation fails, and `make_vulkan` hands back `None` — so
// the console UI refuses to start and `--browse` dies with it.
//
// ⚠ The `0` sentinel was harmless at skia-safe 0.87 (that Skia knew nothing of 1.4, so
// clamping to the loader was a no-op) and the 0.99 migration preserved it as
// "byte-for-byte what `BackendContext::new` did" — true of the VALUE, false of the
// BEHAVIOUR. It shipped in 0.28.0 and took the Deck's launcher out. 0.99's own doc for
// this parameter says it should match `VkApplicationInfo::apiVersion`; this is that.
Some(skvk::Version::from(shared.api_version)),
);
// SAFETY: the instance/physical-device/device handles come from `shared`, which owns them
// and outlives this backend context, and `get_proc` above resolves through those same
+11
View File
@@ -26,6 +26,17 @@ pub struct SharedDevice {
/// with [`pf_client_core::video::QueueLock::guard`], whose RAII form is what every
/// Rust caller wants.
pub queue_lock: std::sync::Arc<pf_client_core::video::QueueLock>,
/// The Vulkan version an overlay renderer may size its function table to — the lower of
/// [`crate::vk::INSTANCE_API_VERSION`] (what `VkApplicationInfo::apiVersion` declared for
/// `instance`) and what the loader provides.
///
/// **Cap yourself here; do not ask the loader yourself.** Entry points above this version
/// were never promised to us — `vkGetDeviceProcAddr` returns null for them — so a renderer
/// that probes `vkEnumerateInstanceVersion` instead (a current Mesa answers 1.4 where we
/// asked for 1.3) validates a function table it can never fill and refuses to start. That
/// is exactly how the Skia console UI died in 0.28.0; see the note in `pf-console-ui`'s
/// `SkiaOverlay::init`.
pub api_version: u32,
}
/// What the overlay may draw this frame — composed by the run loop from session state.
+79
View File
@@ -43,6 +43,24 @@ mod setup;
pub use setup::{list_adapters, probe_decode, AdapterDecode, PresentPref};
/// The Vulkan version every instance this crate creates declares in
/// `VkApplicationInfo::apiVersion`.
///
/// 1.3 because Vulkan Video decode and PyroWave's compute kernels both need a 1.3 device.
/// It is deliberately a CEILING as well as a floor: the loader is routinely newer (Mesa 26
/// answers `vkEnumerateInstanceVersion` with 1.4), but we only ever promised 1.3, so the
/// entry points above it are not ours to call. Anything that must know how far the device
/// side reaches — notably an overlay renderer sizing its own function table — reads this
/// through [`crate::overlay::SharedDevice::api_version`] rather than asking the loader.
pub const INSTANCE_API_VERSION: u32 = vk::API_VERSION_1_3;
/// The clamp behind [`Presenter::overlay_api_version`], split out so the decision is provable
/// without a device: the answer is the lower of what we declared and what the loader reports,
/// and a loader too old to answer at all (`None`) can only be a 1.0 one.
fn overlay_api_version_of(declared: u32, loader: Option<u32>) -> u32 {
declared.min(loader.unwrap_or(vk::API_VERSION_1_0))
}
/// The video-format probe behind [`AdapterDecode::formats`], re-exported so a caller
/// that prints the report does not need its own `pf-vkdecode` dependency (and cannot
/// end up printing a DIFFERENT crate version's idea of the flag names).
@@ -387,8 +405,30 @@ impl Presenter {
queue: self.queue,
queue_family_index: self.qfi,
queue_lock: self.queue_lock.clone(),
api_version: self.overlay_api_version(),
}
}
/// The Vulkan version an overlay renderer may size its function table to: the LOWER of
/// what our instance declared ([`INSTANCE_API_VERSION`]) and what the loader actually
/// provides.
///
/// Both halves are load-bearing, in opposite directions. Taking only the loader's number
/// is the bug that killed the console UI in 0.28.0 — Mesa answers 1.4 where we asked for
/// 1.3, and the entry points in between resolve to null. Taking only ours would break the
/// mirror case: a loader older than 1.3 still accepts our 1.3 instance (1.1+ loaders treat
/// `apiVersion` as intent, not a contract), and claiming 1.3 to a renderer there promises
/// functions the loader has never heard of. The minimum is the only number that is true on
/// both sides.
fn overlay_api_version(&self) -> u32 {
// SAFETY: per the Vulkan contract above - `vkEnumerateInstanceVersion` is a global
// command taking no handles, resolved through the loaded entry that owns it; it writes
// one `u32` local. Absent (a 1.0 loader) it reports `None` rather than failing.
let loader = unsafe { self.entry.try_enumerate_instance_version() }
.ok()
.flatten();
overlay_api_version_of(INSTANCE_API_VERSION, loader)
}
}
impl Drop for Presenter {
@@ -449,3 +489,42 @@ impl Drop for Presenter {
let _ = &self.entry;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The 0.28.0 regression, as an assertion: a loader NEWER than the version we declared
/// must not raise the cap. Skia sized its function table to the loader's 1.4 here, then
/// could not resolve the entry points our 1.3 instance never exposed, and the console UI
/// refused to start (Steam Deck, Mesa loader 1.4.321).
#[test]
fn a_newer_loader_never_raises_the_cap() {
let loader = vk::make_api_version(0, 1, 4, 321);
assert_eq!(
overlay_api_version_of(INSTANCE_API_VERSION, Some(loader)),
INSTANCE_API_VERSION
);
}
/// The mirror case, which is why this is a `min` and not "just use ours": a 1.1+ loader
/// accepts our 1.3 `apiVersion` as intent even when it cannot deliver 1.3, so promising
/// 1.3 to the overlay there would name functions the loader has never heard of.
#[test]
fn an_older_loader_lowers_the_cap() {
let loader = vk::make_api_version(0, 1, 2, 198);
assert_eq!(
overlay_api_version_of(INSTANCE_API_VERSION, Some(loader)),
loader
);
}
/// No `vkEnumerateInstanceVersion` at all is the one thing it can mean: a 1.0 loader.
#[test]
fn a_loader_that_cannot_answer_is_1_0() {
assert_eq!(
overlay_api_version_of(INSTANCE_API_VERSION, None),
vk::API_VERSION_1_0
);
}
}
+5 -3
View File
@@ -155,9 +155,11 @@ impl Presenter {
// 1.3: Vulkan Video decode and PyroWave's compute kernels both need a 1.3
// device, and the instance version caps what the device can report (any current
// loader accepts 1.3 regardless of device support; device-level gating is below).
// `SharedDevice::api_version` republishes this constant to the overlay — keep the
// two the same by construction rather than by two spellings of `API_VERSION_1_3`.
let app_info = vk::ApplicationInfo::default()
.application_name(&app_name)
.api_version(vk::API_VERSION_1_3);
.api_version(super::INSTANCE_API_VERSION);
// HDR10 presentation needs the extended colorspaces at the INSTANCE level.
let mut instance_extensions: Vec<String> = instance_extensions.to_vec();
let inst_available =
@@ -749,7 +751,7 @@ pub fn probe_decode() -> Result<Vec<AdapterDecode>> {
let app_name = CString::new("punktfunk-session").unwrap();
let app_info = vk::ApplicationInfo::default()
.application_name(&app_name)
.api_version(vk::API_VERSION_1_3);
.api_version(super::INSTANCE_API_VERSION);
// SAFETY: per the Vulkan contract above - a create/allocate call on the live device, over
// builder structs that are locals outliving the call; the handle it returns is owned by the
// value being built here.
@@ -902,7 +904,7 @@ pub fn list_adapters() -> Result<Vec<String>> {
let app_name = CString::new("punktfunk-session").unwrap();
let app_info = vk::ApplicationInfo::default()
.application_name(&app_name)
.api_version(vk::API_VERSION_1_3);
.api_version(super::INSTANCE_API_VERSION);
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this type
// and live for the call, and every builder struct is a local that outlives it.
let instance = unsafe {
+6 -2
View File
@@ -185,8 +185,12 @@ pub enum MaxLevelIdc {
H265(hh::StdVideoH265LevelIdc),
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel`. Unlike the other two this code
/// space is the BITSTREAM's own: `StdVideoAV1Level` is index-coded exactly like
/// AV1's `seq_level_idx` (2.0 = 0, 2.1 = 1, … 7.3 = 23), so the decoder's gate
/// compares the sequence header's value against it directly.
/// AV1's `seq_level_idx` (2.0 = 0, 2.1 = 1, … 7.3 = 23).
///
/// ⚠ Only over 0…23. `seq_level_idx` is 5 bits, and 31 is Annex A's "maximum
/// parameters" sentinel — no level constraint — which outranks even a device
/// reporting the enum's top value. The AV1 gate therefore treats a stream above
/// this ceiling as advisory instead of refusing it (`VkAv1Decoder::ensure_state`).
Av1(hh::StdVideoAV1Level),
}
+10 -3
View File
@@ -211,9 +211,16 @@ pub struct RawAv1Caps {
pub max_coded_extent: vk::Extent2D,
pub max_dpb_slots: u32,
pub max_active_reference_pictures: u32,
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel` (index-coded Std level — the
/// SAME numbering as the bitstream's `seq_level_idx`, which is what makes the
/// decoder's level gate a plain comparison).
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel` (index-coded Std level — the same
/// numbering as the bitstream's `seq_level_idx` OVER 0…23, which is the whole
/// range `StdVideoAV1Level` enumerates).
///
/// ⚠ That correspondence does not extend to the rest of the bitstream field.
/// `seq_level_idx` is 5 bits: 24…30 are reserved and 31 is Annex A's "maximum
/// parameters" sentinel — "not constrained to a level" — which has no Std code
/// point and is NOT an ordering above 7.3. The decoder's gate therefore treats
/// a stream above this ceiling as advisory rather than comparing it as a level
/// (`VkAv1Decoder::ensure_state`).
pub max_level: hh::StdVideoAV1Level,
/// `VkVideoCapabilitiesKHR::stdHeaderVersion` — session creation echoes it back.
pub std_header_version: vk::ExtensionProperties,
+81 -15
View File
@@ -100,6 +100,7 @@ use pf_bitstream::av1::NUM_REF_SLOTS;
use pf_bitstream::h264::DisplayCrop;
use tracing::debug;
use tracing::trace;
use tracing::warn;
use crate::caps::DecodeCaps;
use crate::caps::DecodeProfile;
@@ -688,6 +689,10 @@ pub struct VkAv1Decoder {
/// through a temporal unit, which is why the skip is per FRAME while the error
/// is per ACCESS UNIT.
awaiting_key: bool,
/// One-shot latch for the over-declared-level warning, so a stream whose
/// sequence header sits above the device ceiling says so once per decoder
/// rather than once per access unit (`ensure_state` runs per AU).
level_advisory_warned: bool,
}
impl VkAv1Decoder {
@@ -728,6 +733,7 @@ impl VkAv1Decoder {
device_lost: false,
recovery: RecoveryLatch::default(),
awaiting_key: false,
level_advisory_warned: false,
})
}
@@ -745,8 +751,10 @@ impl VkAv1Decoder {
///
/// The negotiated facts are a HINT (the in-band sequence header is
/// authoritative), so this is deliberately not a promise that decode will
/// succeed: the level ceiling and a sequence header that disagrees with the
/// Welcome still surface at the first AU.
/// succeed: a coded extent outside the caps, a DPB deeper than the device
/// allows, and a sequence header that disagrees with the Welcome all still
/// surface at the first AU. The declared LEVEL is not among them — it is
/// advisory, and `ensure_state` only warns on it.
pub fn probe_stream_support(
&self,
chroma_format_idc: u8,
@@ -1478,8 +1486,9 @@ impl VkAv1Decoder {
self.flush();
}
/// Session/caps for THIS plan exist and match its extent + profile, and the
/// stream sits inside the device's level ceiling.
/// Session/caps for THIS plan exist and match its extent + profile. A declared
/// level above the device ceiling warns once and proceeds — see the gate below
/// for why an AV1 `seq_level_idx` is advisory and 31 is not even a level.
fn ensure_state(&mut self, plan: &AuPlan) -> Result<(), VkDecodeError> {
let key = profile_key_for(plan)?;
if self.caps.as_ref().map(|(k, _)| *k) != Some(key) {
@@ -1491,17 +1500,39 @@ impl VkAv1Decoder {
unsafe { query_av1_caps(&self.dev, key) }.map_err(|r| caps_query_error(r, key))?;
self.caps = Some((key, derive_caps_av1(&raw, wanted)?));
}
// The level gate. AV1's `StdVideoAV1Level` is index-coded exactly like the
// bitstream's `seq_level_idx` (2.0 = 0 … 7.3 = 23) and ascends with the
// level, so this is a plain comparison — of AV1 code points against an AV1
// ceiling, the pairing `MaxLevelIdc`'s tag exists to keep honest.
// The declared level vs the device ceiling: a DECLARED level above `maxLevel`
// is NOT a refusal, for the reason `VkH265Decoder::ensure_state` spells out —
// the level is a CLAIM, and the stream's real demands are enforced where they
// are physical facts (coded extent and DPB depth, checked in `rebuild_state`).
//
// AV1 makes the point sharper than H.265 did. `seq_level_idx` is a 5-bit
// field; Annex A defines 0…23 (levels 2.0…7.3) and reserves 24…30, but **31 is
// the "maximum parameters" level — the spec's own way of saying the bitstream
// is not constrained to any level at all**. `StdVideoAV1Level` has no code
// point for it (it stops at 7.3 = 23), so the index-coded comparison that
// holds across 0…23 is meaningless against 31: the sentinel is not a level
// and 31 > 23 is not "too demanding". Real-time encoders emit it as a matter
// of course — a 2026-08-13 field report (RTX 5060 client, 4K120) had EVERY
// AV1 session demote to D3D11VA on "stream level (seq_level_idx 31) above the
// device's maxLevel (AV1 Std level 23)" while the same hardware decoded the
// stream trivially. We never write an AV1 level on any host encode path, so
// whatever the vendor defaults to is what the client must accept.
//
// Unlike H.265 there is nothing to clamp: `StdVideoAV1SequenceHeader` carries
// no level field (see `params_av1`), so the declaration never reaches the
// driver and cannot be invalid usage. Warn once, proceed.
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
let stream_level = u32::from(stream_level_idx(plan));
if stream_level > caps_max_level.code_point() {
return Err(VkDecodeError::Unsupported(format!(
"stream level (seq_level_idx {stream_level}) above the device's \
maxLevel ({caps_max_level})"
)));
if stream_level > caps_max_level.code_point() && !self.level_advisory_warned {
self.level_advisory_warned = true;
warn!(
stream_level,
ceiling = %caps_max_level,
"stream declares an AV1 level above the device ceiling — the declared \
level is advisory (seq_level_idx 31 means \"maximum parameters\", and \
encoders over-declare); proceeding, since the level never reaches the \
driver"
);
}
let coded = coded_extent(plan);
match &self.state {
@@ -2907,10 +2938,45 @@ mod tests {
assert_eq!(key.output_format(), Some(crate::caps::NV12));
assert!(!key.film_grain);
// The level gate reads operating point 0 and stays inside the Std range.
// The level gate reads operating point 0. This vector declares a real level,
// inside the Std range — the sentinel case is pinned separately below.
assert!(stream_level_idx(&plan) <= 23);
}
/// `seq_level_idx` 31 is Annex A's "maximum parameters" — "not constrained to a
/// level" — not a level above 7.3, and `StdVideoAV1Level` has no code point for
/// it. Comparing it as an ordinary level is what demoted every AV1 session on a
/// 2026-08-13 field report (RTX 5060, 4K120): `maxLevel` came back 23 (7.3, the
/// device's own maximum) and 31 > 23 refused a stream the hardware decodes fine.
///
/// This pins the ARITHMETIC that made the refusal look reasonable, so nobody
/// restores the gate by reading `31 > 23` as "too demanding":
#[test]
fn the_av1_max_parameters_sentinel_is_not_a_level_above_the_ceiling() {
// The ceiling as the gate reads it, on a device that decodes everything the
// Std enum can name — 7.3, the top code point there is.
let ceiling = crate::caps::MaxLevelIdc::Av1(hh::StdVideoAV1Level_STD_VIDEO_AV1_LEVEL_7_3);
assert_eq!(ceiling.code_point(), 23, "the Std enum's top code point");
// Every `seq_level_idx` the Std enum names compares sanely against it…
for idx in 0..=ceiling.code_point() {
assert!(idx <= ceiling.code_point());
}
// …and everything above is OUTSIDE that code space, not above the ceiling:
// 24…30 are reserved and 31 is "maximum parameters". A maxed-out device
// cannot satisfy the comparison, which is why it is not a capability test.
for idx in (ceiling.code_point() + 1)..=31 {
assert!(
idx > ceiling.code_point(),
"seq_level_idx {idx} is outside the Std range, not a more demanding level"
);
}
// The field report's exact pairing, kept legible: 31 against a ceiling of 23.
assert!(31 > ceiling.code_point());
assert_eq!(format!("{ceiling}"), "AV1 Std level 23");
}
#[test]
fn only_a_decoded_key_frame_ends_the_wait_for_one() {
let mut planner = Av1Planner::new();
@@ -2951,7 +3017,7 @@ mod tests {
/// `PlanError::AwaitingIdr`, and the reason [`VkAv1Decoder::awaiting_key`]'s
/// docs carry: a clean `Ok(None)` resets the consumer's demotion streak once
/// per frame, so a rung whose every key frame fails (film grain on a device
/// without the grain profile; a level above `maxLevelIdc`; a sequence header
/// without the grain profile; a coded extent outside the caps; a sequence header
/// disagreeing with the negotiation) would never demote and the session would
/// hold a frozen screen with a clean bill of health.
///
@@ -47,7 +47,15 @@ pub(crate) const FLUSH_AFTER: Duration = Duration::from_millis(250);
/// Minimum spacing between jump-to-live events, so a bottleneck that instantly rebuilds the queue (a
/// link/consumer that can't sustain the bitrate at all) degrades into a periodic skip + a logged
/// warning instead of a continuous flush/keyframe storm.
pub(crate) const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
///
/// **Public because the HOST needs it to read its own logs.** Each jump-to-live sends a keyframe
/// request, so a client that cannot sustain the rate asks for one at exactly this spacing,
/// forever — and the host's recovery-cadence detector saw that perfect periodicity and blamed a
/// periodic *display* disturbance (2026-08-13 field log: `period_s=2.0`, three subsystems named,
/// none of them the cause). Perfect periodicity is the signature of a fixed software cooldown,
/// not of a physical disturbance. The host compares against this constant rather than a copy of
/// the number, so the two can never drift apart.
pub const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
/// A clock-triggered jump-to-live that discarded fewer datagrams than this (and no queued AUs)
/// found NO local backlog: the frames read as late, but nothing here was actually behind. Two
+1
View File
@@ -42,6 +42,7 @@ mod recovery;
mod rumble;
mod worker;
pub use self::frame_channel::FLUSH_COOLDOWN;
pub use self::planes::AudioPacket;
pub use self::probe::ProbeOutcome;
pub use self::rumble::{ActuatorQuirks, RumbleCommand};
+47 -6
View File
@@ -62,6 +62,17 @@ pub struct PwAudioCapturer {
/// active). Toggled by open/[`drain`](AudioCapturer::drain) (claim) and
/// [`idle`](AudioCapturer::idle)/Drop (release).
claimed: bool,
/// Whether a session is currently CONSUMING this capturer, shared with the PipeWire
/// thread so the drop counter can tell "the encode thread fell behind" from "nobody is
/// reading". The capturer is host-lifetime and merely PARKED between sessions
/// ([`idle`](AudioCapturer::idle)), so without this the producer keeps filling the bounded
/// hand-off channel, every `try_send` fails once it is full, and the plane reports a 100 %
/// drop rate — warning that "the stream will click" when there is no stream. A 2026-08-13
/// field host log carried ten such warnings, up to `dropped_chunks=11251` (= 30 s × 375
/// chunks/s, i.e. every single chunk), each one straddling a session boundary and each one
/// meaningless. Distinct from `claimed`, which tracks the sink-routing claim and only
/// exists when the stream sink is enabled at all.
active: Arc<AtomicBool>,
}
impl PwAudioCapturer {
@@ -90,10 +101,21 @@ impl PwAudioCapturer {
// mode the sink node must exist before we claim the default to its name.
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
let thread_sink_name = sink_name.clone();
// Opens at session start (see the routing claim below), so the consumer is live from
// the first chunk.
let active = Arc::new(AtomicBool::new(true));
let thread_active = Arc::clone(&active);
thread::Builder::new()
.name("punktfunk-pw-audio".into())
.spawn(move || {
if let Err(e) = pw_thread(tx, quit_rx, channels, thread_sink_name, ready_tx) {
if let Err(e) = pw_thread(
tx,
quit_rx,
channels,
thread_sink_name,
ready_tx,
thread_active,
) {
tracing::error!(error = %format!("{e:#}"), "pipewire audio thread failed");
}
})
@@ -118,12 +140,16 @@ impl PwAudioCapturer {
quit: quit_tx,
sink_name,
claimed,
active,
})
}
}
impl Drop for PwAudioCapturer {
fn drop(&mut self) {
// The receiver dies with us; anything the producer still pushes is unwanted by
// definition, and it must not be reported as the encode thread falling behind.
self.active.store(false, Ordering::Relaxed);
if self.claimed {
self.claimed = false;
stream_sink::release();
@@ -157,9 +183,15 @@ impl AudioCapturer for PwAudioCapturer {
stream_sink::claim(name);
self.claimed = true;
}
// Ordered AFTER the backlog drain, so the producer never counts a drop against a
// channel this call is still emptying.
self.active.store(true, Ordering::Relaxed);
}
fn idle(&mut self) {
// Parked: from here the channel fills and stays full, and those drops are nobody's
// fault. See `PwAudioCapturer::active`.
self.active.store(false, Ordering::Relaxed);
if self.claimed {
self.claimed = false;
stream_sink::release();
@@ -644,6 +676,7 @@ fn pw_thread(
channels: u32,
sink_name: Option<String>,
ready: std::sync::mpsc::SyncSender<Result<()>>,
active: Arc<AtomicBool>,
) -> Result<()> {
use pipewire as pw;
use pw::{properties::properties, spa};
@@ -735,6 +768,9 @@ fn pw_thread(
/// never again — the one number that identifies a clamped quantum, invisible on every
/// subsequent open (including every reopen after a device change).
reported_quantum: bool,
/// Shared with the capturer — see [`PwAudioCapturer::active`]. Read on every
/// failed hand-off to keep parked-capturer backpressure out of the drop count.
active: Arc<AtomicBool>,
}
let ud = CapUd {
tx,
@@ -742,6 +778,7 @@ fn pw_thread(
stats: Default::default(),
last_stats: std::time::Instant::now(),
reported_quantum: false,
active,
};
let _listener = stream
.add_local_listener_with_user_data(ud)
@@ -844,11 +881,15 @@ fn pw_thread(
samples.push(f32::from_le_bytes(b));
}
ud.stats.observe(&samples, ud.channels);
// Non-blocking and lossy, as before — but COUNTED. A full channel means the
// encode thread is not keeping up, and because the encoder simply
// concatenates across the hole every dropped chunk is a click AND a
// permanent shift of everything after it.
if ud.tx.try_send(samples).is_err() {
// Non-blocking and lossy, as before — but COUNTED, and only while a session
// is actually reading. A full channel under a LIVE consumer means the encode
// thread is not keeping up, and because the encoder simply concatenates
// across the hole every dropped chunk is a click AND a permanent shift of
// everything after it. A full channel under a PARKED capturer means nothing
// at all: the capturer is host-lifetime, so between sessions the channel
// fills once and then refuses everything, which counted as a 100 % drop rate
// and warned about a stream that did not exist (`PwAudioCapturer::active`).
if ud.tx.try_send(samples).is_err() && ud.active.load(Ordering::Relaxed) {
ud.stats.dropped_chunks += 1;
}
if ud.last_stats.elapsed() >= crate::audio::capture_policy::STATS_EVERY {
@@ -43,6 +43,15 @@ pub struct WasapiLoopbackCapturer {
channels: u32,
stop: Arc<AtomicBool>,
join: Option<JoinHandle<()>>,
/// Whether a session is currently CONSUMING this capturer, shared with the capture thread
/// so the drop counter can tell "the encode thread fell behind" from "nobody is reading".
/// The native/gamestream planes park a capturer between sessions
/// ([`idle`](AudioCapturer::idle)) instead of dropping it, and the hand-off channel is
/// bounded — so without this the thread fills it once, then counts every subsequent chunk
/// as a drop and warns that "the stream will click" with no stream to click. Proven on the
/// Linux twin by a 2026-08-13 field log (100 % drop rate across session gaps); the parking
/// call sites are platform-independent, so this half had the same defect.
active: Arc<AtomicBool>,
}
impl WasapiLoopbackCapturer {
@@ -58,10 +67,13 @@ impl WasapiLoopbackCapturer {
// rather than a silent dead thread.
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
let stop_t = stop.clone();
// Opens at session start, so the consumer is live from the first chunk.
let active = Arc::new(AtomicBool::new(true));
let active_t = active.clone();
let join = thread::Builder::new()
.name("punktfunk-wasapi-audio".into())
.spawn(move || {
if let Err(e) = capture_thread(tx, stop_t, ready_tx, channels) {
if let Err(e) = capture_thread(tx, stop_t, ready_tx, channels, active_t) {
tracing::error!(error = %format!("{e:#}"), "wasapi loopback thread failed");
}
})
@@ -76,6 +88,7 @@ impl WasapiLoopbackCapturer {
channels,
stop,
join: Some(join),
active,
})
}
Ok(Err(e)) => Err(e),
@@ -92,6 +105,9 @@ impl WasapiLoopbackCapturer {
impl Drop for WasapiLoopbackCapturer {
fn drop(&mut self) {
// The receiver dies with us; anything the thread still pushes is unwanted by
// definition, and must not be reported as the encode thread falling behind.
self.active.store(false, Ordering::Relaxed);
self.stop.store(true, Ordering::SeqCst);
if let Some(j) = self.join.take() {
let _ = j.join();
@@ -114,6 +130,14 @@ impl AudioCapturer for WasapiLoopbackCapturer {
}
fn drain(&mut self) {
while self.chunks.try_recv().is_ok() {}
// Ordered AFTER the backlog drain, so the capture thread never counts a drop against a
// channel this call is still emptying.
self.active.store(true, Ordering::Relaxed);
}
fn idle(&mut self) {
// Parked: from here the channel fills and stays full, and those drops are nobody's
// fault. See [`WasapiLoopbackCapturer::active`].
self.active.store(false, Ordering::Relaxed);
}
}
@@ -167,6 +191,7 @@ fn capture_thread(
stop: Arc<AtomicBool>,
ready: SyncSender<Result<()>>,
channels: u32,
active: Arc<AtomicBool>,
) -> Result<()> {
// COM must be initialized on THIS thread (MTA), before any device call.
if let Err(e) = wasapi::initialize_mta()
@@ -192,7 +217,7 @@ fn capture_thread(
// is said once per topology — the field log drowned in 256+ copies of the same line.
let mut unsat_logged: Option<u64> = None;
while !stop.load(Ordering::Relaxed) {
match capture_once(&tx, &stop, &mut ready, channels, mode) {
match capture_once(&tx, &stop, &mut ready, channels, mode, &active) {
Ok(Next::Stopped) => break,
Ok(Next::Reopen(m)) => {
mode = m;
@@ -357,6 +382,7 @@ fn capture_once(
ready: &mut Option<SyncSender<Result<()>>>,
channels: u32,
mode: TargetMode,
active: &AtomicBool,
) -> Result<Next> {
// Interleaved f32: channels * 4 bytes per frame.
let block_align = channels as usize * 4;
@@ -611,10 +637,14 @@ fn capture_once(
samples.push(f32::from_le_bytes([c[0], c[1], c[2], c[3]]));
}
stats.observe(&samples, channels);
// Non-blocking, lossy — same discipline as PipeWire. Now COUNTED: a full channel
// means the encode thread is not keeping up, and every dropped chunk is a click plus
// a permanent shift of everything after it.
if tx.try_send(samples).is_err() {
// Non-blocking, lossy — same discipline as PipeWire. COUNTED, and only while a
// session is actually reading: a full channel under a LIVE consumer means the encode
// thread is not keeping up, and every dropped chunk is a click plus a permanent
// shift of everything after it. A full channel under a PARKED capturer means nothing
// — the planes park capturers between sessions rather than dropping them, so the
// channel fills once and then refuses everything
// ([`WasapiLoopbackCapturer::active`]).
if tx.try_send(samples).is_err() && active.load(Ordering::Relaxed) {
stats.dropped_chunks += 1;
}
}
+66 -8
View File
@@ -2722,14 +2722,35 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
last_forced_idr = Some(now);
rfi_echo_swallowed = 0; // the IDR resets the episode — echoes of IT coalesce via the cooldown
if let Some(period) = recovery_cadence.note(now) {
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
"client keyframe recoveries are METRONOMIC — a periodic host/display \
disturbance (display-topology churn, display-poller software, \
virtual-display timing) is the likely cause, not random network loss; \
correlate with 'slow display-descriptor poll' / 'display descriptor \
changed' / 'IDD-push capture stall' lines"
);
// A period that lands on the CLIENT's jump-to-live cooldown is not evidence
// of a periodic disturbance here at all — it is the client shedding a
// standing receive queue, which it is rate-limited to do exactly this often
// (`punktfunk_core::client::FLUSH_COOLDOWN`), so the cadence is a property of
// our own backpressure code rather than of anything physical. Naming display
// churn there sent a 2026-08-13 field investigation at three innocent
// subsystems while the real chain was: client refused the codec → demoted to
// a slower decode rung → could not sustain the rate → standing queue.
// Perfect periodicity argues FOR a software cooldown, not against it.
if matches_client_flush_cadence(period) {
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
"client keyframe recoveries match the client's jump-to-live cooldown \
the CLIENT cannot sustain the stream and is shedding a standing \
receive queue (check its log for 'receive backlog stopped draining' \
with queue_depth, and for a decode rung that demoted); a slower \
decode path or a link below the bitrate does this, and it is NOT a \
host display disturbance"
);
} else {
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
"client keyframe recoveries are METRONOMIC — a periodic host/display \
disturbance (display-topology churn, display-poller software, \
virtual-display timing) is the likely cause, not random network \
loss; correlate with 'slow display-descriptor poll' / 'display \
descriptor changed' / 'IDD-push capture stall' lines"
);
}
}
}
}
@@ -3785,6 +3806,23 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
Ok(())
}
/// Whether a measured keyframe-recovery period is the CLIENT's jump-to-live cooldown rather
/// than anything happening on this host.
///
/// Every jump-to-live sends a keyframe request and is rate-limited to one per
/// [`punktfunk_core::client::FLUSH_COOLDOWN`], so a client that simply cannot sustain the
/// stream asks at exactly that spacing for as long as it stays behind. The recovery-cadence
/// detector reads perfect periodicity as evidence of a periodic *disturbance*, which is
/// backwards here: a fixed software cooldown is the most periodic thing in the system.
///
/// ±10 % — wide enough for scheduling jitter and the request's network trip, narrow enough that
/// it cannot swallow the disturbance cadences the other branch exists to report (display-mode
/// churn and descriptor polls run at their own, unrelated periods).
fn matches_client_flush_cadence(period: std::time::Duration) -> bool {
let flush = punktfunk_core::client::FLUSH_COOLDOWN;
period.abs_diff(flush) < flush / 10
}
/// One mode's capture/encode pipeline: (capturer, encoder, first frame, frame interval).
/// Dropping the capturer tears down the PipeWire stream and the virtual output with it.
type Pipeline = (
@@ -4597,6 +4635,26 @@ fn build_pipeline(
mod tests {
use super::*;
/// The 2026-08-13 field log's exact reading — `period_s=2.0` — must be attributed to the
/// client's backlog shedding, not to a host display disturbance. The whole point of routing
/// on the shared constant is that this stays true if the cooldown is ever retuned, so the
/// test derives its cases from `FLUSH_COOLDOWN` instead of hardcoding two seconds.
#[test]
fn a_recovery_cadence_on_the_clients_cooldown_is_not_blamed_on_the_display() {
let flush = punktfunk_core::client::FLUSH_COOLDOWN;
assert!(matches_client_flush_cadence(flush), "the field reading");
// Scheduling jitter and the request's trip across the link stay inside the band.
assert!(matches_client_flush_cadence(flush + flush / 20));
assert!(matches_client_flush_cadence(flush - flush / 20));
// Cadences that are NOT the cooldown still reach the display-disturbance branch — the
// band must not be so wide that it swallows them.
assert!(!matches_client_flush_cadence(flush / 2));
assert!(!matches_client_flush_cadence(flush * 2));
assert!(!matches_client_flush_cadence(flush + flush / 5));
assert!(!matches_client_flush_cadence(std::time::Duration::ZERO));
}
#[test]
fn an_escalated_but_caught_up_encoder_stops_refusing_climbs() {
const DEGRADE: u32 = 10;