Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90d13de81e | ||
|
|
1abf5c91b9 | ||
|
|
fdf48fcaa1 | ||
|
|
05a08b9804 | ||
|
|
99c245520c | ||
|
|
4ab6a399e6 | ||
|
|
8216f1d92d | ||
|
|
1677d1c0c2 | ||
|
|
9a52d725d5 | ||
|
|
fbbfce9b0e | ||
|
|
3f738a9989 |
@@ -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 0–3 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 0–3 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 17–21 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 0–3 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 60…120 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 vend→glass
|
||||
/// 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 host↔client 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 17–21 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 = capture→on-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 capture→ENQUEUE —
|
||||
/// not capture→glass 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) {
|
||||
// Capture→enqueue (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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user