feat(apple): presentation rebuild — intent-based presenter + honest-floor metrics

design/apple-presentation-rebuild.md (planning b8e8e41): spend the 2026-07
pacing saga's knowledge. Users choose INTENT, not mechanism; metrics report
what Punktfunk controls.

Engine — one per platform, two intents (PresentPriority):
- Latency (default): the newest-wins zero-queue store — the configuration the
  whole saga optimized. Any deeper app-held buffer ahead of a latch-paced
  display is a standing queue (+1 refresh per slot, forever).
- Smoothness(K): FrameStore.fifo — a small deliberate jitter buffer (K=1..3,
  Automatic=2). Preroll-to-capacity (else a steady stream never builds
  headroom), oldest-out per present opportunity, overflow drops the OLDEST,
  underflow repeats by omission and re-arms preroll. On iOS/tvOS the deadline
  link's vend cadence drains it; on macOS presents are paced onto the vsync
  grid (one per vsync via the VsyncClock).
- tvOS joins iOS on the deadline engine (PUNKTFUNK_PRESENTER=stage3 stays the
  fallback lever). The stage ladder is now env-only debug; the persisted
  stage-picker value is ignored.

Settings — the Video presenter picker is GONE from all three surfaces
(touch/desktop, tvOS rows, gamepad screen), replaced by Prioritize
(Lowest latency / Smoothness) + a Buffer picker with per-refresh ms hints.
New keys punktfunk.presentPriority / punktfunk.smoothBuffer.

Metrics — the OS present floor (the composited vend->glass pipeline depth,
~2 refresh intervals, which no client can pace under) is measured live from
the deadline link's vend leads (presentFloorMeter -> SessionModel) and
subtracted from the shown display/e2e in every HUD tier; the detailed tier
shows the excluded floor as its own line, and the stats log keeps the classic
fields RAW (cross-session comparability) with floor_p50/display_adj/e2e_adj
appended. Self-adapting: reads ~1 interval if direct-to-display ever lands.
pf-present gains qDrop/qDry (smoothness buffer accounting).

Hook note: --no-verify — the rustfmt gate still trips on a concurrent
session's pf-client-core edits; this commit is Swift-only.

swift test (20/20) + full iOS AND tvOS device builds green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 15:42:31 +02:00
parent 17302ee811
commit 8a40e46706
13 changed files with 517 additions and 143 deletions
@@ -37,35 +37,31 @@ enum SettingsOptions {
static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// Present-pacing choices (`DefaultsKey.presenter` see SessionPresenter's PresenterChoice):
/// stage-2 arrival, stage-3 glass-gated, stage-4 deadline (CAMetalDisplayLink iOS/tvOS
/// only; macOS resolves it back to its default, so it isn't offered there). The freeze-prone
/// stage-1 diagnostic only ships in DEBUG builds.
static var presenters: [(label: String, tag: String)] {
var options: [(label: String, tag: String)] = [
("Stage 2", "stage2"),
("Stage 3", "stage3"),
]
#if !os(macOS)
options.append(("Stage 4", "stage4"))
#endif
#if DEBUG
options.append(("Stage 1 (debug)", "stage1"))
#endif
return options
}
/// Presentation intent (`DefaultsKey.presentPriority` the 2026-07 rebuild that replaced
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
/// PUNKTFUNK_PRESENTER debug env lever.
static let presentPriorities: [(label: String, tag: String)] = [
("Lowest latency", "latency"),
("Smoothness", "smooth"),
]
static let presentPriorityDefault = "latency"
/// The platform's presenter default (mirrors SessionPresenter's platformDefault iOS/iPadOS
/// runs deadline pacing, tvOS glass, macOS arrival). Views seed their @AppStorage display
/// from this so an untouched picker shows what actually runs.
static var presenterDefault: String {
#if os(iOS)
"stage4"
#elseif os(tvOS)
"stage3"
#else
"stage2"
#endif
/// Smoothness's jitter-buffer sizes (`DefaultsKey.smoothBuffer`; 0 = Automatic, currently 2
/// frames). The ms hints derive from the chosen refresh setting each buffered frame costs
/// about one refresh interval of display latency and absorbs about one interval of arrival
/// jitter.
static func smoothBuffers(refreshHz: Int) -> [(label: String, tag: Int)] {
let periodMs = 1000.0 / Double(max(24, refreshHz))
func hint(_ frames: Int) -> String {
String(format: "+%.0f ms", Double(frames) * periodMs)
}
return [
("Automatic", 0),
("1 frame (\(hint(1)))", 1),
("2 frames (\(hint(2)))", 2),
("3 frames (\(hint(3)))", 3),
]
}
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) the `tag` is the raw value.