Files
punktfunk/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift
T
enricobuehler ab2bcc8e68 fix(apple): stage-4 deadline presenter (CAMetalDisplayLink) — iOS default; gate depth back to 1
Field verdict on the depth-2 gate (M4 iPad Pro, 2752x2064@120): display stage
22-28 ms vs depth-1's 14 — a REGRESSION, not the predicted 5-8. Post-mortem:
any bounded-FIFO pacing keeps a STANDING queue on the always-vsync-latch
platforms. One burst fills every admitted slot and, with arrivals and latches
then running at the same rate, occupancy never returns to zero — each gate
slot costs one full refresh, permanently (the ladder fits exactly: arrival ~3
slots -> 30+ ms, depth 2 -> 22-28, depth 1 -> 14). A bounded FIFO caps the
queue; nothing ever drains it. And depth 1 serializes presents on the on-glass
callback's delivery lag, so neither rung can approach the sub-refresh floor.

Stage-4 (PresentPacing.deadline) inverts drawable ownership instead:
- A CAMetalDisplayLink on its own runloop thread vends ONE deadline-timed
  drawable per refresh (preferredFrameLatency 1) into a newest-wins LatestBox;
  an unpresented vend is replaced by the next (back to the pool).
- The render thread pairs it with the newest decoded frame the moment either
  half arrives — the common case presents a frame INSTANTLY into an already-
  vended drawable, latching the upcoming refresh. No image queue can form and
  nothing waits on on-glass callbacks (they only feed the meters now).
- A stashed drawable can lag a mid-session HDR reconfigure by one vend:
  encodePresent skips the mismatched-format vend (frame re-rings) instead of
  tripping Metal validation.
- iOS/iPadOS defaults to stage-4; tvOS keeps stage-3 until its own A/B
  (PUNKTFUNK_PRESENTER=stage4); macOS resolves "stage4" back to its default
  (the sync-off/DCP-panic saga — deadline pacing lands there deliberately or
  not at all). Settings picker gains Stage 4 with the derived default marker.
- Gate depth defaults to 1 everywhere again; PUNKTFUNK_GATE_DEPTH stays only
  to reproduce the standing-queue ladder on-device.
- PUNKTFUNK_PRESENT_DEBUG gains latchMs p50/max (present-issue -> on-glass:
  standing queue reads ~n x period, healthy reads < 1 period) + noDrawable=.

swift test (14/14) + full Punktfunk-iOS device build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:34:26 +02:00

197 lines
8.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The option lists every settings surface renders from — one source of truth shared by the
// touch/desktop SettingsView (Pickers), the tvOS pushed selection rows, and the gamepad settings
// screen (GamepadSettingsView's left/right cycling). Pure data + small pure helpers; anything that
// reads live view state (e.g. the bitrate slider mapping) stays on SettingsView.
#if os(macOS)
import AppKit
#endif
import PunktfunkKit
import SwiftUI
enum SettingsOptions {
/// Compositor choices — the `tag` is the wire value (`PunktfunkConnection.Compositor` raw).
static let compositors: [(label: String, tag: Int)] = [
("Automatic", 0),
("KWin (KDE Plasma)", 1),
("wlroots (Sway / Hyprland)", 2),
("Mutter (GNOME)", 3),
("gamescope", 4),
]
static let audioChannels: [(label: String, tag: Int)] = [
("Stereo", 2),
("5.1 Surround", 6),
("7.1 Surround", 8),
]
/// Virtual-pad types — the `tag` is the wire value (`PunktfunkConnection.GamepadType` raw).
static let padTypes: [(label: String, tag: Int)] = [
("Automatic", 0),
("Xbox 360", 1),
("Xbox One", 3),
("DualSense", 2),
("DualShock 4", 4),
]
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
}
/// 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
}
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
static let statsVerbosities: [(label: String, tag: String)] =
StatsVerbosity.allCases.map { ($0.label, $0.rawValue) }
/// Video-codec preference (`DefaultsKey.codec`) — a soft preference the host falls back from.
/// AV1 appears only on devices with an AV1 hardware decoder (the same
/// `AV1.hardwareDecodeSupported` gate SessionModel advertises by) — elsewhere it would be a
/// dead setting the host could never honor. Ordered by the host's resolve precedence
/// (HEVC > AV1 > H.264).
static let codecs: [(label: String, tag: String)] = {
var options: [(label: String, tag: String)] = [
("Automatic", "auto"),
("HEVC (H.265)", "hevc"),
("H.264 (AVC)", "h264"),
]
if AV1.hardwareDecodeSupported {
options.insert(("AV1", "av1"), at: 2)
}
// PyroWave is the opt-in wired-LAN low-latency codec (100400 Mbps all-intra wavelet,
// 8-bit SDR): selecting it advertises + prefers it for the session. Offered only when
// the Metal decode probe passes (same gate SessionModel advertises by) — elsewhere the
// host could never emit it.
if MetalWaveletDecoder.supported {
options.append(("PyroWave (wired LAN)", "pyrowave"))
}
return options
}()
// MARK: - Bitrate
/// Discrete bitrate steps for the surfaces with no Slider (tvOS pushed pickers, the gamepad
/// settings' left/right cycling), up to the same 3 Gbps ceiling the slider has.
static let bitratePresets: [(label: String, tag: Int)] = [
("Automatic", 0),
("10 Mbps", 10_000),
("20 Mbps", 20_000),
("40 Mbps", 40_000),
("80 Mbps", 80_000),
("150 Mbps", 150_000),
("300 Mbps", 300_000),
("500 Mbps", 500_000),
("1 Gbps", 1_000_000),
("1.5 Gbps", 1_500_000),
("2 Gbps", 2_000_000),
("3 Gbps", 3_000_000),
]
/// The presets plus the currently stored value when it isn't one of them (set via the touch
/// slider or a synced device) — so the current choice stays visible/selectable.
static func bitrateOptions(current: Int) -> [(label: String, tag: Int)] {
var options = bitratePresets
if !options.contains(where: { $0.tag == current }) {
options.insert(
(SpeedTestSheet.mbpsLabel(kbps: current) + " (custom)", current), at: 1)
}
return options
}
// MARK: - Controllers
/// "Use controller" choices: Automatic, every forwardable controller, and — so a stale pin
/// stays visible instead of leaving the selection tag-less — any pinned id that is NOT among
/// the selectable (extended) entries, present-but-unusable included.
@MainActor
static func controllerOptions(_ gamepads: GamepadManager) -> [(label: String, tag: String)] {
let selectable = gamepads.controllers.filter(\.isExtended)
var options: [(label: String, tag: String)] = [("Automatic", "")]
options += selectable.map { ($0.name, $0.id) }
if !gamepads.preferredID.isEmpty,
!selectable.contains(where: { $0.id == gamepads.preferredID }) {
options.append(("Unavailable controller", gamepads.preferredID))
}
return options
}
// MARK: - Stream mode (iOS/macOS pickers + the gamepad settings rows on all three; the
// touch/remote tvOS SettingsView builds its own preset list)
/// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`.
static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
("720p", 1280, 720),
("1080p", 1920, 1080),
("1440p", 2560, 1440),
("4K", 3840, 2160),
("Ultrawide 1080p", 2560, 1080),
("Ultrawide 1440p", 3440, 1440),
("Super ultrawide", 5120, 1440),
]
/// This device's native mode first, then the presets, deduped by dimensions (native wins a
/// tie).
@MainActor
static func resolutionModes() -> [(name: String, w: Int, h: Int)] {
var native: [(name: String, w: Int, h: Int)] = []
#if os(iOS) || os(tvOS)
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels (tvOS: the TV mode)
native = [("This device",
Int(max(bounds.width, bounds.height)),
Int(min(bounds.width, bounds.height)))]
#else
if let screen = NSScreen.main {
let scale = screen.backingScaleFactor
native = [("This display",
Int(screen.frame.width * scale),
Int(screen.frame.height * scale))]
}
#endif
var seen = Set<String>()
return (native + resolutionPresets).filter { seen.insert("\($0.w)x\($0.h)").inserted }
}
/// Refresh rates the device can actually display (no point asking the host to render frames
/// the screen can't show), plus any stored custom value so it stays selectable.
@MainActor
static func refreshRates(including current: Int) -> [Int] {
#if os(iOS) || os(tvOS)
let maxHz = UIScreen.main.maximumFramesPerSecond
#else
let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60
#endif
var rates = [60, 120, 240].filter { $0 <= maxHz }
if rates.isEmpty { rates = [maxHz] }
if !rates.contains(current) { rates.append(current) }
return rates.sorted()
}
}