Compare commits

..
Author SHA1 Message Date
enricobuehler 2d43275fcb fix(core/abi)!: stop exporting 149 unprefixed macros into every embedder's namespace
ci / rust-arm64 (pull_request) Failing after 15s
ci / web (pull_request) Successful in 1m6s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m57s
ci / rust (pull_request) Successful in 6m25s
BREAKING (C header only): constants such as MAX_PADS, TAG_LEN, ABI_VERSION,
INPUT_MAGIC and the whole BTN_/AXIS_ family are now PUNKTFUNK_-prefixed.

cbindgen emits a bare #define per `pub const`, so those names landed in the
namespace of every C program that includes the header. The rename table already
said this was the rule and already carried the handful someone had noticed —
and its own comment spells out why it matters: a clashing #define silently
takes the last definition rather than failing to compile, so the failure mode
is a wrong value, not a build error. This is the remaining 149.

Associated constants are deliberately left alone. cbindgen already qualifies
those with their type name, which is the very property whose absence makes a
bare MAX_PADS dangerous — they are namespaced, just not by us.

Nothing in this repository consumed the unprefixed spellings except one Swift
test, which sat next to lines already using the prefixed form because its
constant happened never to have been added to the table; it is updated here.
The C harness links and runs against the regenerated header.

Scheduled deliberately: the sweep flagged this for a release boundary, and
0.24.0 has shipped. External C embedders using the old spellings must add the
prefix; there is no silent breakage, since the old names simply stop existing.
2026-08-04 20:52:58 +02:00
enricobuehler 77ddd05b13 fix(core/wire): a truncated trigger datagram stops cancelling the effect it should carry
Three wire and ABI faults.

An out-of-range pad index reached one rumble consumer and not the other. It
skipped the reorder gate — the per-pad seq cursor has no slot for it — and was
handed to the legacy queue, while the policy engine discarded it on its own
bounds check, so the comment promising both consumers are fed was false for
exactly these. An embedder draining the queue could be handed an index it would
use to subscript its own per-pad array. The host never emits one, so it is
malformed or hostile either way; both consumers now agree by dropping it before
either sees it.

The adaptive-trigger effect was the only variable-length wire field bounded on
neither side. Encode appended whatever it was handed and decode took the whole
tail, while its sibling raw-report field had been bounded both ways all along;
there is now one constant both sides clamp to. Worse than the missing bound was
the empty case: a body with no effect bytes decoded as an EMPTY effect, and
downstream an empty block is written as an all-zero trigger report, which is
mode 0x00 — release. A truncated datagram could therefore silently cancel the
trigger effect a game was holding. That shape is now rejected outright; a
genuine release is a full-length zero block and still decodes.

The C ABI history had a hole and a symbol nobody versioned. v11 shipped without
its line, and the rumble policy engine's C surface was added while the version
constant still read 7, with no bump at all — so every core since has exported
those symbols while advertising a number that never promised them. A shipped
binary says what it says, so that cannot be corrected backwards; v15 instead
establishes the floor that guarantees the surface, and the v11 line is written
down. No code changed for the bump and nothing moved on the wire.
2026-08-04 20:52:44 +02:00
15 changed files with 509 additions and 524 deletions
+1 -5
View File
@@ -121,11 +121,7 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
host's virtual pad.
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on
every main push. See the script header for details. The script's `SCENES` array is the listing
set, in listing order; override it (`SCENES="06-gamepad-home 10-edithost" tools/screenshots.sh ios`)
to capture any of the other scenes in `ShotScenes.all`. Mock data — hosts, adverts, profiles — is
seeded in `ShotMock` so a capture is byte-for-byte deterministic and never browses the real LAN
(a stranger's hostname reached the live listing that way once).
every main push. See the script header for details.
- Deeper design notes live in the internal planning repo (punktfunk-planning:
`apple-stage2-presenter.md`).
@@ -176,33 +176,18 @@ struct GamepadHomeView: View {
// MARK: - Chrome
private var titleBar: some View {
// The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone
// it sat directly on top of the centred title ("Select a Host" ran straight into the pad
// name). Laying it out as a row with a hidden mirror on the leading side keeps the title
// optically centred AND clear of the chip at every width; the title shrinks a little
// before it would ever truncate.
HStack(spacing: 12) {
statusChip(hidden: true)
Text("Select a Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
statusChip(hidden: false)
}
.padding(.horizontal, 20)
}
/// Which pad is driving this UI (name + battery) quiet, and only where there's room; a
/// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same
/// chip purely as a width reserve.
@ViewBuilder private func statusChip(hidden: Bool) -> some View {
if !compact, let active = gamepads.active {
ControllerStatusChip(controller: active)
.opacity(hidden ? 0 : 1)
.accessibilityHidden(hidden)
}
Text("Select a Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.overlay(alignment: .trailing) {
// Which pad is driving this UI (name + battery) quiet, and only where there's
// room; a compact-height phone gives the pixels to the carousel instead.
if !compact, let active = gamepads.active {
ControllerStatusChip(controller: active)
.padding(.trailing, 20)
}
}
}
private var cardSpacing: CGFloat {
@@ -24,13 +24,6 @@ import ImageIO
@MainActor
enum ScreenshotMode {
/// This process was launched to capture a screenshot. Cheap enough to consult from the
/// stores' persistence paths (`HostStore` / `ProfileStore`), which must NOT write their
/// mock contents back into a real user's App Group when the harness runs on a dev Mac.
static var isActive: Bool {
!(ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? "").isEmpty
}
/// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch.
static var requestedScene: ShotScene? {
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
@@ -48,11 +41,8 @@ struct ScreenshotHostView: View {
scene.make()
.environment(\.colorScheme, scene.colorScheme)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
// line was unreadable in every 6.9" capture); scenes that genuinely want full bleed
// the streamed frame itself ignore it themselves.
.background(Color.black.ignoresSafeArea())
.background(Color.black)
.ignoresSafeArea()
#if os(macOS)
.background(MacShotWindowConfigurator(scene: scene))
#elseif os(iOS)
@@ -139,64 +129,18 @@ enum MacSelfCapture {
#endif
#if os(iOS)
/// Orientation lock for the requested scene (landscape for the stream hero, portrait for chrome).
/// Requires the app to allow those orientations in Info.plist it does, for both.
/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait
/// for chrome). Requires the app to allow those orientations in Info.plist.
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
let orientation: ShotOrientation
func makeUIViewController(context: Context) -> ShotOrientationController {
ShotOrientationController(mask: mask)
}
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
func updateUIViewController(_ vc: ShotOrientationController, context: Context) {
vc.mask = mask
vc.applyGeometry()
}
private var mask: UIInterfaceOrientationMask {
orientation == .landscape ? .landscapeRight : .portrait
}
}
/// Asks the window scene to rotate, from a place where there IS a window.
///
/// The previous version made the request inside `updateUIViewController`, where `view.window` is
/// still nil: SwiftUI makes exactly one update pass for a representable mounted as a `.background`,
/// before the hierarchy is in a window, so the `guard` fell through and nothing ever asked again.
/// Every scene declared `.landscape` the stream hero and the trust card was therefore captured
/// in PORTRAIT at the portrait App Store size. Overriding `supportedInterfaceOrientations` as well
/// keeps the scene from rotating back if the simulator reports a device orientation change.
final class ShotOrientationController: UIViewController {
var mask: UIInterfaceOrientationMask
init(mask: UIInterfaceOrientationMask) {
self.mask = mask
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("not from a nib") }
override var supportedInterfaceOrientations: UIInterfaceOrientationMask { mask }
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
applyGeometry()
}
func applyGeometry() {
// `view.window` once mounted; the connected-scene lookup covers the first update pass,
// which still runs before this controller is in a window.
let scene = view.window?.windowScene
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
guard let scene else { return }
// Report a refusal instead of silently shipping the wrong orientation that is exactly
// how every landscape scene went out as a portrait PNG for as long as it did.
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in
print("PF_SHOT_ORIENTATION_REFUSED \(error.localizedDescription)")
fflush(stdout)
}
setNeedsUpdateOfSupportedInterfaceOrientations()
func updateUIViewController(_ vc: UIViewController, context: Context) {
guard let scene = vc.view.window?.windowScene else { return }
let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
vc.setNeedsUpdateOfSupportedInterfaceOrientations()
}
}
#endif
@@ -81,126 +81,24 @@ enum ShotScenes {
@MainActor
enum ShotMock {
// Stable ids so the store, the adverts and the profile bindings all point at the same things
// across every scene and every run.
static let battlestationID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000001")!
static let livingRoomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000002")!
static let workshopID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000003")!
static let officeID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000004")!
static let editingID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000005")!
static let bedroomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000006")!
static let hdrProfileID = "a71c4e0d9f22"
static let couchProfileID = "3e88b107c4da"
/// The catalog the host cards read their chips and pinned cards from. Seeded once, on the
/// first store build `ProfileStore` is a singleton, and in shot mode its write-back is
/// suppressed, so this never reaches a real user's catalog.
static func installProfiles() {
guard !profilesInstalled else { return }
profilesInstalled = true
ProfileStore.shared.debugSet([
StreamProfile(name: "4K HDR", id: hdrProfileID, accent: "#8B7BF7"),
StreamProfile(name: "Couch 1080p", id: couchProfileID, accent: "#4FD1A5"),
])
}
private static var profilesInstalled = false
/// A populated saved-host grid: the most-recent host bound to a profile (its chip), a second
/// paired machine, and one asleep box we hold a MAC for (so its card offers Wake-on-LAN). OS
/// chains give every tile its real vendor mark instead of a letter monogram.
///
/// No PINNED host+profile card: it renders a second tile for the SAME host, which is the
/// feature working as designed but reads as a duplicate to anyone meeting the app in a store
/// listing. The binding chip carries the profile story on its own.
/// A populated saved-host grid: a pinned recent host, a couple more, mixed online state.
static func hostStore() -> HostStore {
installProfiles()
let store = HostStore()
store.hosts = [
StoredHost(
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420),
macAddresses: ["a4:b1:c2:d3:e4:f5"], profileID: hdrProfileID,
osChain: "windows/11"),
StoredHost(
id: livingRoomID, name: "Living Room PC", address: "192.168.1.41", port: 9777,
pinnedSHA256: hostFingerprint(1), lastConnected: Date().addingTimeInterval(-86_400),
macAddresses: ["b8:27:eb:11:22:33"], osChain: "linux/fedora/bazzite"),
StoredHost(
id: officeID, name: "Office NUC", address: "192.168.1.33", port: 9777,
pinnedSHA256: hostFingerprint(4), lastConnected: Date().addingTimeInterval(-259_200),
profileID: couchProfileID, osChain: "linux/ubuntu"),
StoredHost(
id: workshopID, name: "Workshop", address: "10.0.0.7", port: 9777,
pinnedSHA256: hostFingerprint(2), macAddresses: ["de:ad:be:ef:00:07"],
osChain: "linux/arch"),
StoredHost(
id: editingID, name: "Editing Rig", address: "192.168.1.62", port: 9777,
pinnedSHA256: hostFingerprint(5), lastConnected: Date().addingTimeInterval(-604_800),
osChain: "linux/nobara"),
StoredHost(
id: bedroomID, name: "Bedroom Mini", address: "192.168.1.77", port: 9777,
pinnedSHA256: hostFingerprint(6), macAddresses: ["00:1a:2b:3c:4d:5e"],
osChain: "windows/11"),
StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)),
StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777,
pinnedSHA256: fingerprint),
StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777),
]
return store
}
/// Discovery, seeded rather than live. Two saved hosts advertise (so their cards read ONLINE
/// through the real `advertises` path, and the reachability probe skips them no network from
/// a capture), "Workshop" stays quiet so the grid shows an asleep machine, and one genuinely
/// new host populates the "On this network" section.
///
/// A live browse made the shot non-deterministic AND leaked whatever was on the capturing
/// machine's LAN into the App Store listing.
static func discovery() -> HostDiscovery {
let discovery = HostDiscovery()
discovery.debugSet([
HostDiscovery.debugAdvert(
id: "battlestation", name: "Battlestation", host: "192.168.1.20",
fingerprintHex: fingerprint.hexLower, macAddresses: ["a4:b1:c2:d3:e4:f5"],
osChain: "windows/11"),
HostDiscovery.debugAdvert(
id: "living-room", name: "Living Room PC", host: "192.168.1.41",
fingerprintHex: hostFingerprint(1).hexLower, macAddresses: ["b8:27:eb:11:22:33"],
osChain: "linux/fedora/bazzite"),
HostDiscovery.debugAdvert(
id: "office-nuc", name: "Office NUC", host: "192.168.1.33",
fingerprintHex: hostFingerprint(4).hexLower, osChain: "linux/ubuntu"),
HostDiscovery.debugAdvert(
id: "studio", name: "Studio PC", host: "192.168.1.58",
fingerprintHex: hostFingerprint(3).hexLower, requiresPairing: true, allowsTofu: false,
osChain: "windows/11"),
])
return discovery
}
static let host = StoredHost(
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint, osChain: "windows/11")
/// What the pairing sheet calls THIS device. Taken from the platform, not from
/// `UIDevice.current.name` on a capture simulator that is the harness's own throwaway name
/// (`pf-shot-iphone-6.9` went out on the store listing that way).
static var clientDeviceName: String {
#if os(tvOS)
"Apple TV"
#elseif os(macOS)
"MacBook Pro"
#else
UIDevice.current.userInterfaceIdiom == .pad ? "iPad Pro" : "iPhone"
#endif
}
static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint)
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
static let fingerprint = hostFingerprint(0)
/// Distinct per host `StoredHost.matches` prefers a fingerprint comparison, so sharing one
/// across the mock grid made a single advert light up every card.
static func hostFingerprint(_ seed: Int) -> Data {
Data((0..<32).map { UInt8((($0 &* 37) &+ 0x1d &+ (seed &* 91)) & 0xff) })
}
static let fingerprint = Data((0..<32).map { UInt8(($0 &* 37 &+ 0x1d) & 0xff) })
}
// MARK: - Home
@@ -208,7 +106,7 @@ enum ShotMock {
private struct ShotHome: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = ShotMock.discovery()
@StateObject private var discovery = HostDiscovery()
var body: some View {
#if os(macOS)
@@ -236,7 +134,7 @@ private struct ShotHome: View {
private struct ShotGamepadHome: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = ShotMock.discovery()
@StateObject private var discovery = HostDiscovery()
@StateObject private var waker = HostWaker()
var body: some View {
@@ -268,7 +166,7 @@ private struct ShotConnect: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = ShotMock.discovery()
@StateObject private var discovery = HostDiscovery()
@StateObject private var waker = HostWaker()
var body: some View {
@@ -345,9 +243,9 @@ private struct ShotSettings: View {
#elseif os(iOS)
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
// rendered directly a wrapping NavigationStack would nest a split view in a stack. Open
// on Display rather than the bare category list: resolution, frame rate, bitrate, HDR and
// codec are what someone reads a streaming app's settings shot to find out.
SettingsView(initialCategory: .display)
// on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
// the General page) instead of the bare category list.
SettingsView(initialCategory: .general)
#else
NavigationStack { SettingsView() }
#endif
@@ -357,44 +255,16 @@ private struct ShotSettings: View {
// MARK: - Pair (PIN ceremony)
private struct ShotPair: View {
/// The PIN as the host's web console shows it, and a device name that doesn't depend on what
/// the capture simulator happens to be called.
private var sheet: some View {
PairSheet(
host: ShotMock.host, shotPIN: "418 306",
shotClientName: ShotMock.clientDeviceName, onPaired: { _ in })
}
var body: some View {
#if os(iOS)
// PRESENT it, don't rebuild it. `PairSheet` is a bottom sheet on iOS it carries its own
// `.presentationDetents([.medium, .large])` and the system's Liquid Glass background, both
// of which only exist inside a real `.sheet`. Composed into a ZStack instead (what this
// scene used to do), the detents were inert, the grouped Form stretched to the full height
// of the screen, and the capture was a thin strip of content over a huge black void.
ShotHome()
.sheet(isPresented: .constant(true)) {
// Pinned to one detent. The sheet ships `[.medium, .large]` so it can grow over
// the keyboard, and the resting height leaves a wide empty band between the form
// and the button row; a capture wants the snug version.
sheet.presentationDetents([.fraction(0.52)])
}
#elseif os(tvOS)
// tvOS pushes the ceremony as a full screen (HomeView's `navigationDestination`).
NavigationStack { sheet }
#else
// macOS: a fixed-width panel (`.frame(width: 400).fixedSize()`) that hugs its content, so
// floating it over the dimmed grid matches how the window-modal sheet reads. `screencapture
// -l<windowID>` grabs one window, and an AppKit sheet is a child window a real `.sheet`
// would fall outside the capture.
ZStack {
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
sheet
PairSheet(host: ShotMock.host, onPaired: { _ in })
.frame(maxWidth: 460)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
.clipShape(RoundedRectangle(cornerRadius: 18))
.shadow(radius: 40, y: 16)
.padding(40)
}
#endif
}
}
@@ -191,12 +191,6 @@ final class HostStore: ObservableObject {
private func persist() {
#if DEBUG
// The screenshot harness fills a store with mock hosts (ShotMock) purely to render a
// scene. On a dev Mac that store is the SAME App-Group suite the real app reads, so
// persisting would replace the tester's saved hosts with "Battlestation" & co.
if ScreenshotMode.isActive { return }
#endif
if let data = try? JSONEncoder().encode(hosts) {
defaults.set(data, forKey: Self.key)
}
@@ -20,14 +20,7 @@ final class ProfileStore: ObservableObject {
static let shared = ProfileStore()
@Published private(set) var catalog: ProfileCatalog {
didSet {
#if DEBUG
// Shot mode seeds this SINGLETON with mock profiles to populate the host cards.
// Saving would write them into the tester's real catalog see HostStore.persist().
if ScreenshotMode.isActive { return }
#endif
catalog.save()
}
didSet { catalog.save() }
}
var profiles: [StreamProfile] { catalog.profiles }
@@ -40,14 +33,6 @@ final class ProfileStore: ObservableObject {
id.flatMap { catalog.profile(id: $0) }
}
#if DEBUG
/// Shot-mode seed: replace the catalog outright so a capture shows a known set of profiles
/// rather than the tester's. Safe because `didSet` suppresses the write-back in shot mode.
func debugSet(_ profiles: [StreamProfile]) {
catalog = ProfileCatalog(profiles: profiles)
}
#endif
/// This host's default profile, dangling ids dropped a deleted profile resolves as "Default
/// settings", never an error (§4.4).
func binding(for host: StoredHost) -> StreamProfile? { catalog.binding(for: host) }
@@ -109,7 +109,7 @@ struct PairSheet: View {
#endif
TextField(
"Client name", text: $clientName,
prompt: Text(Self.clientNamePrompt))
prompt: Text("How the host lists this Mac"))
#if os(tvOS)
.labelsHidden() // prefilled tvOS floats the label off-center
#endif
@@ -184,16 +184,6 @@ struct PairSheet: View {
#endif
}
/// The field prompt names the device you are actually on it said "this Mac" on every
/// platform, which on an iPhone is simply wrong.
private static var clientNamePrompt: String {
#if os(macOS)
"How the host lists this Mac"
#else
"How the host lists this device"
#endif
}
private func runCeremony() {
busy = true
errorText = nil
@@ -239,24 +229,3 @@ struct PairSheet: View {
}
}
}
#if DEBUG
extension PairSheet {
/// Screenshot-harness seed (`ShotScenes`). A capture of the untouched sheet shows an empty PIN
/// field, a DISABLED "Pair & Connect", and because the client name defaults to the device's
/// own whatever the capture simulator happens to be called (`pf-shot-iphone-6.9` reached App
/// Store Connect that way). Seeding both fields captures the ceremony as a user meets it,
/// mid-entry, with a live primary button.
///
/// An extension so `PairSheet` keeps its memberwise initialiser, and THIS file so it can reach
/// the private state.
init(
host: StoredHost, shotPIN: String, shotClientName: String,
onPaired: @escaping (Data) -> Void
) {
self.init(host: host, onPaired: onPaired)
_pin = State(initialValue: shotPIN)
_clientName = State(initialValue: shotClientName)
}
}
#endif
@@ -59,9 +59,6 @@ public final class HostDiscovery: ObservableObject {
/// Start browsing `_punktfunk._udp`. Idempotent a second call while live is a no-op.
public func start() {
#if DEBUG
guard !debugPinned else { return } // a seeded advert set outranks the live LAN
#endif
guard browser == nil else { return }
let browser = NWBrowser(
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
@@ -95,35 +92,6 @@ public final class HostDiscovery: ObservableObject {
for conn in connections.values { conn.cancel() }
}
#if DEBUG
/// A seeded advert set is in force `start()` must not replace it with the live browse.
private var debugPinned = false
/// Screenshot/preview seam, the discovery counterpart to `HostWaker.debugSet`: publish a FIXED
/// set of adverts and keep browsing off. Without it a capture shows whatever happens to be on
/// the machine's LAN the App Store screenshots shipped a stranger's hostname more than once
/// and every mock host reads Offline because nothing advertises it.
public func debugSet(_ adverts: [DiscoveredHost]) {
stop()
debugPinned = true
hosts = adverts
}
/// Builds one advert. `DiscoveredHost`'s memberwise init is internal (a public struct's is), and
/// making it public would expose a wire-shaped model's construction to every consumer just to
/// serve the harness.
public static func debugAdvert(
id: String, name: String, host: String, port: UInt16 = 9777,
fingerprintHex: String? = nil, requiresPairing: Bool = false, allowsTofu: Bool = true,
macAddresses: [String] = [], osChain: String = ""
) -> DiscoveredHost {
DiscoveredHost(
id: id, name: name, host: host, port: port, fingerprintHex: fingerprintHex,
requiresPairing: requiresPairing, allowsTofu: allowsTofu,
macAddresses: macAddresses, osChain: osChain)
}
#endif
private func restart() {
stop()
start()
@@ -79,7 +79,7 @@ final class GamepadWireTests: XCTestCase {
XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y))
XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT))
XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT))
XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS))
XCTAssertEqual(GamepadWire.maxPads, Int(PUNKTFUNK_MAX_PADS))
}
func testPadIndexRidesFlagsOnEveryPerPadEvent() {
+14 -36
View File
@@ -11,15 +11,9 @@
# The captured pixels are exactly App Store Connect's required sizes:
# mac 2880×1800 (a 1× display yields 1440×900 — also accepted)
# iphone-6.9 1320×2868 (portrait) / 2868×1320 (the landscape hero)
# ipad-13 2064×2752 (portrait)
# ipad-13 2064×2752 (portrait) / 2752×2064 (the landscape hero)
# appletv 1920×1080
#
# A `.landscape` scene rotates on iPhone but NOT on iPad: an iPad app that supports multitasking
# is resizable, and iPadOS ignores `requestGeometryUpdate` orientation requests for it — the app
# follows the device, and simctl cannot rotate a simulated device. The iPad set is therefore
# portrait throughout (a valid App Store size, and uniform, which the gallery prefers). To get a
# landscape iPad hero, rotate the Simulator by hand (⌘←) and re-run just that scene.
#
# Requirements:
# • macOS target: just the Swift toolchain (`swift build`) + a one-time Screen Recording grant
# for your terminal (System Settings → Privacy & Security → Screen Recording).
@@ -41,11 +35,7 @@ cd "$APPLE_DIR"
OUT="${OUT:-$APPLE_DIR/screenshots}"
BUNDLE_ID="io.unom.punktfunk"
# The App Store set, in listing order — the first three are what most people ever see, so they are
# the stream itself, the machines it found, and the couch/controller mode. Everything else in
# ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`.
SCENES=(${SCENES:-01-stream 02-hosts 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
SCENES=(01-stream 02-hosts 03-pair 04-trust 05-settings)
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
mkdir -p "$OUT"
@@ -99,20 +89,13 @@ shoot_macos() {
# $1 device-type regex (matches both existing device names and the device-type catalog)
# $2 scheme $3 sdk $4 file prefix $5 runtime platform (iOS|tvOS — for the create fallback)
# $6 name for a device we have to create — MUST satisfy $1 (see below)
shoot_sim() {
require_xcode
local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5" createname="$6"
local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5"
# Reuse an existing device of this type; else create one against the newest available runtime
# for the platform. CI runners commonly ship a runtime but not every device (the iPhone 16 Pro
# Max is absent on ours), so create-on-demand is what makes it reproducible.
#
# The created device is named after the DEVICE, not after this script, for two reasons. It used
# to be "pf-shot-<prefix>", which `$match` never matches — so every run created another
# simulator and none was ever reused (they piled up on the runner). And the name is user-visible:
# `UIDevice.current.name` is what the pairing sheet prefills as this device's name, so
# "pf-shot-iphone-6.9" was rendered into an App Store screenshot.
# Reuse an existing device of this type; else create a throwaway one against the newest
# available runtime for the platform. CI runners commonly ship a runtime but not every device
# (the iPhone 16 Pro Max is absent on ours), so create-on-demand is what makes it reproducible.
local udid
udid="$(xcrun simctl list devices available | grep -E "$match" | grep -oE '[0-9A-F-]{36}' | head -1 || true)"
if [ -z "$udid" ]; then
@@ -122,8 +105,8 @@ shoot_sim() {
rt="$(xcrun simctl list runtimes available | grep -E "^$platform " \
| grep -oE 'com\.apple\.CoreSimulator\.SimRuntime\.[A-Za-z0-9.-]+' | tail -1 || true)"
if [ -n "$devtype" ] && [ -n "$rt" ]; then
udid="$(xcrun simctl create "$createname" "$devtype" "$rt" 2>/dev/null || true)"
[ -n "$udid" ] && log "$prefix — created Simulator \"$createname\" $udid ($devtype)"
udid="$(xcrun simctl create "pf-shot-$prefix" "$devtype" "$rt" 2>/dev/null || true)"
[ -n "$udid" ] && log "$prefix — created Simulator $udid ($devtype)"
fi
fi
[ -n "$udid" ] || die "$prefix: no Simulator matching /$match/, and none could be created
@@ -131,11 +114,6 @@ shoot_sim() {
log "$prefix — Simulator $udid"
xcrun simctl boot "$udid" 2>/dev/null || true
xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || true
# Every scene is a dark-mode scene. The in-app `.environment(\.colorScheme, .dark)` override
# does NOT cross a presentation boundary — a `.sheet` gets its own environment and follows the
# DEVICE appearance — so the pairing sheet came out light grey over the dark app. Set the
# simulator itself to dark and the whole hierarchy, presentations included, agrees.
xcrun simctl ui "$udid" appearance dark >/dev/null 2>&1 || true
log "$prefix — building ($scheme)…"
# PF_SHOT_DERIVED_DATA (optional): a STABLE DerivedData root, so repeat runs reuse the
@@ -172,15 +150,15 @@ pixels() { sips -g pixelWidth -g pixelHeight "$1" 2>/dev/null | awk '/pixel/{pri
for target in "$@"; do
case "$target" in
macos) shoot_macos ;;
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS 'iPhone 16 Pro Max' ;;
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS 'iPad Pro 13-inch (M4)' ;;
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K' ;;
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS ;;
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS ;;
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS ;;
all)
shoot_macos
if xcrun --find simctl >/dev/null 2>&1; then
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS 'iPhone 16 Pro Max'
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS 'iPad Pro 13-inch (M4)'
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K'
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS
else
warn "Skipping iOS/iPadOS/tvOS — full Xcode not found (Command Line Tools only)."
fi
+161
View File
@@ -56,6 +56,167 @@ exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
"FRAME_MS" = "PUNKTFUNK_AUDIO_FRAME_MS"
"SAMPLE_RATE_HZ" = "PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ"
# R21: every remaining exported constant, prefixed. cbindgen emits a bare `#define` per
# `pub const`, so without an entry here names as generic as MAX_PADS, TAG_LEN, ABI_VERSION and
# INPUT_MAGIC land in the namespace of every C embedder that includes this header — and, as the
# note above says, a clashing #define silently takes the last definition rather than failing to
# compile. The table above had been doing this by hand for the handful someone noticed; this is
# the rest of them, so the stated rule finally holds for the whole surface.
#
# NOT covered, deliberately: associated constants (`ColorInfo_CP_BT709`, `ClockResync_ROUNDS`,
# `ResyncGuard_MAX_REJECTED_STREAK`). cbindgen already qualifies those with their type name,
# which is the very property whose absence makes a bare `MAX_PADS` dangerous — they are
# namespaced, just not by us.
"ABI_VERSION" = "PUNKTFUNK_ABI_VERSION"
"APP_EXITED_CLOSE_CODE" = "PUNKTFUNK_APP_EXITED_CLOSE_CODE"
"BTN_MISC1" = "PUNKTFUNK_BTN_MISC1"
"BTN_PADDLE1" = "PUNKTFUNK_BTN_PADDLE1"
"BTN_PADDLE2" = "PUNKTFUNK_BTN_PADDLE2"
"BTN_PADDLE3" = "PUNKTFUNK_BTN_PADDLE3"
"BTN_PADDLE4" = "PUNKTFUNK_BTN_PADDLE4"
"CHROMA_IDC_420" = "PUNKTFUNK_CHROMA_IDC_420"
"CHROMA_IDC_444" = "PUNKTFUNK_CHROMA_IDC_444"
"CIPHER_AES_128_GCM" = "PUNKTFUNK_CIPHER_AES_128_GCM"
"CIPHER_CHACHA20_POLY1305" = "PUNKTFUNK_CIPHER_CHACHA20_POLY1305"
"CLIENT_CAP_AUDIO_RED" = "PUNKTFUNK_CLIENT_CAP_AUDIO_RED"
"CLIENT_CAP_CURSOR" = "PUNKTFUNK_CLIENT_CAP_CURSOR"
"CLIENT_CAP_PHASE_LOCK" = "PUNKTFUNK_CLIENT_CAP_PHASE_LOCK"
"CLIP_CANCELLED_CODE" = "PUNKTFUNK_CLIP_CANCELLED_CODE"
"CLIP_CHUNK" = "PUNKTFUNK_CLIP_CHUNK"
"CLIP_FETCH_CAP" = "PUNKTFUNK_CLIP_FETCH_CAP"
"CLIP_FETCH_DENIED" = "PUNKTFUNK_CLIP_FETCH_DENIED"
"CLIP_FETCH_OK" = "PUNKTFUNK_CLIP_FETCH_OK"
"CLIP_FETCH_STALE" = "PUNKTFUNK_CLIP_FETCH_STALE"
"CLIP_FETCH_UNAVAILABLE" = "PUNKTFUNK_CLIP_FETCH_UNAVAILABLE"
"CLIP_FILE_INDEX_NONE" = "PUNKTFUNK_CLIP_FILE_INDEX_NONE"
"CLIP_FLAG_FILES" = "PUNKTFUNK_CLIP_FLAG_FILES"
"CLIP_MAX_KINDS" = "PUNKTFUNK_CLIP_MAX_KINDS"
"CLIP_MAX_MIME" = "PUNKTFUNK_CLIP_MAX_MIME"
"CLIP_POLICY_FILES" = "PUNKTFUNK_CLIP_POLICY_FILES"
"CLIP_POLICY_TEXT" = "PUNKTFUNK_CLIP_POLICY_TEXT"
"CLIP_REASON_BACKEND_UNAVAILABLE" = "PUNKTFUNK_CLIP_REASON_BACKEND_UNAVAILABLE"
"CLIP_REASON_NO_FILES" = "PUNKTFUNK_CLIP_REASON_NO_FILES"
"CLIP_REASON_OK" = "PUNKTFUNK_CLIP_REASON_OK"
"CLIP_REASON_POLICY_DISABLED" = "PUNKTFUNK_CLIP_REASON_POLICY_DISABLED"
"CLIP_REASON_TAKEN_OVER" = "PUNKTFUNK_CLIP_REASON_TAKEN_OVER"
"CLIP_STREAM_KIND_FETCH" = "PUNKTFUNK_CLIP_STREAM_KIND_FETCH"
"ClockResync_ROUNDS" = "PUNKTFUNK_ClockResync_ROUNDS"
"CODEC_AV1" = "PUNKTFUNK_CODEC_AV1"
"CODEC_H264" = "PUNKTFUNK_CODEC_H264"
"CODEC_HEVC" = "PUNKTFUNK_CODEC_HEVC"
"CODEC_PYROWAVE" = "PUNKTFUNK_CODEC_PYROWAVE"
"ColorInfo_CP_BT2020" = "PUNKTFUNK_ColorInfo_CP_BT2020"
"ColorInfo_CP_BT709" = "PUNKTFUNK_ColorInfo_CP_BT709"
"ColorInfo_MC_BT2020_NCL" = "PUNKTFUNK_ColorInfo_MC_BT2020_NCL"
"ColorInfo_MC_BT709" = "PUNKTFUNK_ColorInfo_MC_BT709"
"ColorInfo_TRC_BT709" = "PUNKTFUNK_ColorInfo_TRC_BT709"
"ColorInfo_TRC_HLG" = "PUNKTFUNK_ColorInfo_TRC_HLG"
"ColorInfo_TRC_PQ" = "PUNKTFUNK_ColorInfo_TRC_PQ"
"CURSOR_RELATIVE_HINT" = "PUNKTFUNK_CURSOR_RELATIVE_HINT"
"CURSOR_SHAPE_MAX_SIDE" = "PUNKTFUNK_CURSOR_SHAPE_MAX_SIDE"
"CURSOR_STATE_MAGIC" = "PUNKTFUNK_CURSOR_STATE_MAGIC"
"CURSOR_VISIBLE" = "PUNKTFUNK_CURSOR_VISIBLE"
"FLAG_EOF" = "PUNKTFUNK_FLAG_EOF"
"FLAG_PIC" = "PUNKTFUNK_FLAG_PIC"
"FLAG_PROBE" = "PUNKTFUNK_FLAG_PROBE"
"FLAG_SOF" = "PUNKTFUNK_FLAG_SOF"
"HDR_META_BODY_LEN" = "PUNKTFUNK_HDR_META_BODY_LEN"
"HDR_META_MAGIC" = "PUNKTFUNK_HDR_META_MAGIC"
"HELLO_LAUNCH_MAX" = "PUNKTFUNK_HELLO_LAUNCH_MAX"
"HELLO_NAME_MAX" = "PUNKTFUNK_HELLO_NAME_MAX"
"HID_RAW_FEATURE" = "PUNKTFUNK_HID_RAW_FEATURE"
"HID_RAW_OUTPUT" = "PUNKTFUNK_HID_RAW_OUTPUT"
"HID_REPORT_MAX" = "PUNKTFUNK_HID_REPORT_MAX"
"HIDOUT_MAGIC" = "PUNKTFUNK_HIDOUT_MAGIC"
"HOST_CAP_AUDIO_RED" = "PUNKTFUNK_HOST_CAP_AUDIO_RED"
"HOST_CAP_CLIPBOARD" = "PUNKTFUNK_HOST_CAP_CLIPBOARD"
"HOST_CAP_CURSOR" = "PUNKTFUNK_HOST_CAP_CURSOR"
"HOST_CAP_GAMEPAD_STATE" = "PUNKTFUNK_HOST_CAP_GAMEPAD_STATE"
"HOST_CAP_PEN" = "PUNKTFUNK_HOST_CAP_PEN"
"HOST_CAP_TEXT_INPUT" = "PUNKTFUNK_HOST_CAP_TEXT_INPUT"
"HOST_TIMING_MAGIC" = "PUNKTFUNK_HOST_TIMING_MAGIC"
"INBOUND_REQ_FLAG" = "PUNKTFUNK_INBOUND_REQ_FLAG"
"INPUT_MAGIC" = "PUNKTFUNK_INPUT_MAGIC"
"INPUT_WIRE_LEN" = "PUNKTFUNK_INPUT_WIRE_LEN"
"LEGACY_STALE_MS" = "PUNKTFUNK_LEGACY_STALE_MS"
"MAX_DATAGRAM_BYTES" = "PUNKTFUNK_MAX_DATAGRAM_BYTES"
"MAX_PADS" = "PUNKTFUNK_MAX_PADS"
"MAX_SCALE" = "PUNKTFUNK_MAX_SCALE"
"MIC_MAGIC" = "PUNKTFUNK_MIC_MAGIC"
"MIN_SCALE" = "PUNKTFUNK_MIN_SCALE"
"MIN_SHARD_PAYLOAD" = "PUNKTFUNK_MIN_SHARD_PAYLOAD"
"MIN_STREAM_BLOCK_SHARDS" = "PUNKTFUNK_MIN_STREAM_BLOCK_SHARDS"
"MSG_BITRATE_CHANGED" = "PUNKTFUNK_MSG_BITRATE_CHANGED"
"MSG_CLIP_CONTROL" = "PUNKTFUNK_MSG_CLIP_CONTROL"
"MSG_CLIP_FETCH" = "PUNKTFUNK_MSG_CLIP_FETCH"
"MSG_CLIP_FETCH_HDR" = "PUNKTFUNK_MSG_CLIP_FETCH_HDR"
"MSG_CLIP_OFFER" = "PUNKTFUNK_MSG_CLIP_OFFER"
"MSG_CLIP_STATE" = "PUNKTFUNK_MSG_CLIP_STATE"
"MSG_CLOCK_ECHO" = "PUNKTFUNK_MSG_CLOCK_ECHO"
"MSG_CLOCK_PROBE" = "PUNKTFUNK_MSG_CLOCK_PROBE"
"MSG_CURSOR_RENDER" = "PUNKTFUNK_MSG_CURSOR_RENDER"
"MSG_CURSOR_SHAPE" = "PUNKTFUNK_MSG_CURSOR_SHAPE"
"MSG_LOSS_REPORT" = "PUNKTFUNK_MSG_LOSS_REPORT"
"MSG_PAIR_CHALLENGE" = "PUNKTFUNK_MSG_PAIR_CHALLENGE"
"MSG_PAIR_PROOF" = "PUNKTFUNK_MSG_PAIR_PROOF"
"MSG_PAIR_REQUEST" = "PUNKTFUNK_MSG_PAIR_REQUEST"
"MSG_PAIR_RESULT" = "PUNKTFUNK_MSG_PAIR_RESULT"
"MSG_PHASE_REPORT" = "PUNKTFUNK_MSG_PHASE_REPORT"
"MSG_PROBE_REQUEST" = "PUNKTFUNK_MSG_PROBE_REQUEST"
"MSG_PROBE_RESULT" = "PUNKTFUNK_MSG_PROBE_RESULT"
"MSG_RECONFIGURE" = "PUNKTFUNK_MSG_RECONFIGURE"
"MSG_RECONFIGURED" = "PUNKTFUNK_MSG_RECONFIGURED"
"MSG_REQUEST_KEYFRAME" = "PUNKTFUNK_MSG_REQUEST_KEYFRAME"
"MSG_RFI_REQUEST" = "PUNKTFUNK_MSG_RFI_REQUEST"
"MSG_SET_BITRATE" = "PUNKTFUNK_MSG_SET_BITRATE"
"MSG_SHARD_PAYLOAD_ACK" = "PUNKTFUNK_MSG_SHARD_PAYLOAD_ACK"
"MSG_SHARD_PAYLOAD_CHANGED" = "PUNKTFUNK_MSG_SHARD_PAYLOAD_CHANGED"
"NO_OUTPUT_KEYFRAME_STREAK" = "PUNKTFUNK_NO_OUTPUT_KEYFRAME_STREAK"
"PAIR_APPROVAL_TIMEOUT_CLOSE_CODE" = "PUNKTFUNK_PAIR_APPROVAL_TIMEOUT_CLOSE_CODE"
"PAIR_BOUND_OTHER_CLOSE_CODE" = "PUNKTFUNK_PAIR_BOUND_OTHER_CLOSE_CODE"
"PAIR_DENIED_CLOSE_CODE" = "PUNKTFUNK_PAIR_DENIED_CLOSE_CODE"
"PAIR_NO_IDENTITY_CLOSE_CODE" = "PUNKTFUNK_PAIR_NO_IDENTITY_CLOSE_CODE"
"PAIR_NOT_ARMED_CLOSE_CODE" = "PUNKTFUNK_PAIR_NOT_ARMED_CLOSE_CODE"
"PAIR_RATE_LIMITED_CLOSE_CODE" = "PUNKTFUNK_PAIR_RATE_LIMITED_CLOSE_CODE"
"PAIR_SUPERSEDED_CLOSE_CODE" = "PUNKTFUNK_PAIR_SUPERSEDED_CLOSE_CODE"
"PEN_ANGLE_UNKNOWN" = "PUNKTFUNK_PEN_ANGLE_UNKNOWN"
"PEN_BARREL1" = "PUNKTFUNK_PEN_BARREL1"
"PEN_BARREL2" = "PUNKTFUNK_PEN_BARREL2"
"PEN_BATCH_MAX" = "PUNKTFUNK_PEN_BATCH_MAX"
"PEN_DISTANCE_UNKNOWN" = "PUNKTFUNK_PEN_DISTANCE_UNKNOWN"
"PEN_IN_RANGE" = "PUNKTFUNK_PEN_IN_RANGE"
"PEN_PREDICTED" = "PUNKTFUNK_PEN_PREDICTED"
"PEN_SAMPLE_WIRE_LEN" = "PUNKTFUNK_PEN_SAMPLE_WIRE_LEN"
"PEN_TILT_UNKNOWN" = "PUNKTFUNK_PEN_TILT_UNKNOWN"
"PEN_TOUCH_TIMEOUT_MS" = "PUNKTFUNK_PEN_TOUCH_TIMEOUT_MS"
"PEN_TOUCHING" = "PUNKTFUNK_PEN_TOUCHING"
"PRESETS" = "PUNKTFUNK_PRESETS"
"QUIT_CLOSE_CODE" = "PUNKTFUNK_QUIT_CLOSE_CODE"
"REANCHOR_MARKS_TO_LIFT" = "PUNKTFUNK_REANCHOR_MARKS_TO_LIFT"
"REJECT_BUSY_CLOSE_CODE" = "PUNKTFUNK_REJECT_BUSY_CLOSE_CODE"
"ResyncGuard_MAX_REJECTED_STREAK" = "PUNKTFUNK_ResyncGuard_MAX_REJECTED_STREAK"
"RFI_MAX_RANGE" = "PUNKTFUNK_RFI_MAX_RANGE"
"RICH_INPUT_MAGIC" = "PUNKTFUNK_RICH_INPUT_MAGIC"
"RUMBLE_V1_LEN" = "PUNKTFUNK_RUMBLE_V1_LEN"
"RUMBLE_V2_LEN" = "PUNKTFUNK_RUMBLE_V2_LEN"
"SETUP_FAILED_CLOSE_CODE" = "PUNKTFUNK_SETUP_FAILED_CLOSE_CODE"
"TAG_LEN" = "PUNKTFUNK_TAG_LEN"
"TRIGGER_EFFECT_MAX" = "PUNKTFUNK_TRIGGER_EFFECT_MAX"
"USER_FLAG_CHUNK_ALIGNED" = "PUNKTFUNK_USER_FLAG_CHUNK_ALIGNED"
"USER_FLAG_RECOVERY_ANCHOR" = "PUNKTFUNK_USER_FLAG_RECOVERY_ANCHOR"
"USER_FLAG_RECOVERY_POINT" = "PUNKTFUNK_USER_FLAG_RECOVERY_POINT"
"USER_FLAG_SLICE_STREAM" = "PUNKTFUNK_USER_FLAG_SLICE_STREAM"
"VIDEO_CAP_10BIT" = "PUNKTFUNK_VIDEO_CAP_10BIT"
"VIDEO_CAP_444" = "PUNKTFUNK_VIDEO_CAP_444"
"VIDEO_CAP_CHACHA20" = "PUNKTFUNK_VIDEO_CAP_CHACHA20"
"VIDEO_CAP_HDR" = "PUNKTFUNK_VIDEO_CAP_HDR"
"VIDEO_CAP_HOST_TIMING" = "PUNKTFUNK_VIDEO_CAP_HOST_TIMING"
"VIDEO_CAP_MULTI_SLICE" = "PUNKTFUNK_VIDEO_CAP_MULTI_SLICE"
"VIDEO_CAP_PROBE_SEQ" = "PUNKTFUNK_VIDEO_CAP_PROBE_SEQ"
"VIDEO_CAP_STREAMED_AU" = "PUNKTFUNK_VIDEO_CAP_STREAMED_AU"
"WIRE_VERSION" = "PUNKTFUNK_WIRE_VERSION"
"WIRE_VERSION_CLOSE_CODE" = "PUNKTFUNK_WIRE_VERSION_CLOSE_CODE"
# QualifiedScreamingSnakeCase already qualifies each variant with the enum name
# (PunktfunkStatus::Ok -> PUNKTFUNK_STATUS_OK); do NOT also set prefix_with_name or it doubles.
[enum]
@@ -60,22 +60,28 @@ pub(super) async fn run(
}
Some(&crate::quic::RUMBLE_MAGIC) => {
if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
// A pad index the client cannot represent is dropped outright, before either
// consumer sees it. It used to be waved through: the seq gate was skipped (its
// per-pad cursor has no slot for it) and it was handed to the legacy queue,
// while the policy engine silently discarded it on its own bounds check — so
// "both consumers are fed" below was false for exactly these, and an embedder
// draining the queue could be handed an index it would use to subscript its
// own per-pad array. The host never emits one; this is malformed or hostile.
let idx = u.pad as usize;
if idx >= crate::input::MAX_PADS {
continue;
}
// Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
let fresh = match u.envelope {
Some(env) => {
let idx = u.pad as usize;
if idx < crate::input::MAX_PADS {
if crate::input::GamepadSnapshot::seq_newer(
env.seq,
rumble_last_seq[idx],
) {
rumble_last_seq[idx] = Some(env.seq);
true
} else {
false // reordered/duplicate — drop, keep the newer state
}
if crate::input::GamepadSnapshot::seq_newer(
env.seq,
rumble_last_seq[idx],
) {
rumble_last_seq[idx] = Some(env.seq);
true
} else {
true // out-of-range pad (host never sends these): no gate
false // reordered/duplicate — drop, keep the newer state
}
}
None => true,
+13 -1
View File
@@ -107,6 +107,10 @@ pub use stats::Stats;
/// v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced)
/// clock offset ongoing latency math must use; the connect-time getter stays frozen by
/// contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged.
/// v11: added `punktfunk_connect_ex9` — `connect_ex8` plus a `client_caps` bitfield
/// (`PUNKTFUNK_CLIENT_CAP_CURSOR`, later `…_PHASE_LOCK`), which is how a client tells the host it
/// renders the pointer itself. Additive; the caps ride the existing Hello, so [`WIRE_VERSION`] is
/// unchanged. (Documented late — the bump shipped without its line here.)
/// v12: added `punktfunk_connection_set_cursor_render` — the mid-stream cursor-render flip
/// (design/remote-desktop-sweep.md §8): the client's mouse-model chord tells the host who
/// renders the pointer. Additive; rides the existing control stream (a new message TYPE, which
@@ -120,7 +124,15 @@ pub use stats::Stats;
/// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive;
/// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a
/// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 14;
/// v15: versions the shared rumble policy engine's C surface —
/// `punktfunk_connection_next_rumble_cmd`, `punktfunk_connection_set_rumble_quirks` and the
/// `PUNKTFUNK_RUMBLE_QUIRK_*` bits. These symbols are NOT new: they landed while this constant
/// still read 7 and no bump was made, so every core since has exported them while advertising a
/// version that never promised them. That cannot be corrected retroactively — a shipped binary
/// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
/// present, below it an embedder must probe for the symbol. Purely a version statement; no code
/// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 15;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+96 -3
View File
@@ -401,6 +401,16 @@ impl RichInput {
}
}
/// Longest [`HidOutput::Trigger`] `effect` the wire carries: the DualSense adaptive-trigger
/// parameter block is a mode byte plus ten parameters, and every consumer copies at most this many
/// into its report.
///
/// The single source for the clamp on BOTH sides. `Trigger` was the only variable-length variant
/// bounded on neither: encode appended whatever it was handed and decode took the entire tail, so
/// an attacker-sized datagram was reproduced verbatim into a `Vec` while its sibling `HidRaw` had
/// been bounded on both ends all along.
pub const TRIGGER_EFFECT_MAX: usize = 11;
const HIDOUT_LED: u8 = 0x01;
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
const HIDOUT_TRIGGER: u8 = 0x03;
@@ -460,7 +470,7 @@ impl HidOutput {
}
HidOutput::Trigger { pad, which, effect } => {
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
out.extend_from_slice(effect);
out.extend_from_slice(&effect[..effect.len().min(TRIGGER_EFFECT_MAX)]);
}
HidOutput::TrackpadHaptic {
pad,
@@ -497,10 +507,17 @@ impl HidOutput {
pad: b[2],
bits: b[3],
}),
HIDOUT_TRIGGER if b.len() >= 4 => Some(HidOutput::Trigger {
// `> 4`, not `>= 4`: a body with no effect bytes at all is malformed, and decoding it
// as an EMPTY effect was actively harmful — downstream an empty block is written as an
// all-zero trigger report, which is mode 0x00, which RELEASES a held effect. A
// truncated datagram could therefore silently cancel the trigger a game was holding.
// A genuine "no effect" is a full-length zero block and still decodes fine.
HIDOUT_TRIGGER if b.len() > 4 => Some(HidOutput::Trigger {
pad: b[2],
which: b[3],
effect: b[4..].to_vec(),
// Bounded like `HidRaw` below: at most the parameter block is kept from the
// (attacker-sized) tail.
effect: b[4..b.len().min(4 + TRIGGER_EFFECT_MAX)].to_vec(),
}),
HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic {
pad: b[2],
@@ -981,6 +998,82 @@ mod tests {
assert!(decode_rumble_datagram(&d[..6]).is_none());
}
/// `Trigger` is the only variable-length variant that used to be bounded on NEITHER side.
/// Pinned here because both halves matter: an over-long effect must be clamped on the way out
/// AND on the way in, and a body with no effect bytes must not decode at all.
#[test]
fn trigger_effect_is_clamped_on_both_encode_and_decode() {
// Encode clamps: a caller handing over an over-long block cannot put it on the wire.
let long = HidOutput::Trigger {
pad: 1,
which: 0,
effect: vec![0xAB; 200],
};
let d = long.encode();
assert_eq!(
d.len(),
4 + TRIGGER_EFFECT_MAX,
"magic + kind + pad + which + at most the parameter block"
);
// Decode clamps independently of encode — a hostile peer does not use our encoder.
let mut hostile = vec![HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 1, 0];
hostile.extend_from_slice(&[0xCD; 500]);
match HidOutput::decode(&hostile) {
Some(HidOutput::Trigger { effect, .. }) => {
assert_eq!(effect.len(), TRIGGER_EFFECT_MAX, "tail is bounded");
}
other => panic!("expected a clamped Trigger, got {other:?}"),
}
// An exact-length effect survives untouched, and round-trips.
let ok = HidOutput::Trigger {
pad: 2,
which: 1,
effect: vec![0x02, 0x90, 0xA0, 0xFF, 0, 0, 0, 0, 0, 0, 0],
};
assert_eq!(HidOutput::decode(&ok.encode()), Some(ok));
}
/// A body with no effect bytes is malformed and must be REJECTED, not read as an empty effect:
/// downstream an empty block becomes an all-zero trigger report, which is mode 0x00 — it
/// releases whatever effect the game was holding. A truncated datagram must not do that.
#[test]
fn a_trigger_with_no_effect_bytes_is_rejected_not_read_as_cancel() {
let empty = [HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 0, 0];
assert_eq!(HidOutput::decode(&empty), None);
// One byte of effect is a legitimate short block (consumers zero-pad it) and still decodes.
let one = [HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 0, 0, 0x02];
assert_eq!(
HidOutput::decode(&one),
Some(HidOutput::Trigger {
pad: 0,
which: 0,
effect: vec![0x02]
})
);
}
/// `HidRaw`'s bound was already correct on both sides — pinned alongside `Trigger` so the pair
/// cannot drift apart again.
#[test]
fn hid_raw_stays_bounded_on_both_sides() {
let long = HidOutput::HidRaw {
pad: 0,
kind: HID_RAW_OUTPUT,
data: vec![0x11; 500],
};
assert_eq!(long.encode().len(), 4 + HID_REPORT_MAX);
let mut hostile = vec![HIDOUT_MAGIC, super::HIDOUT_HID_RAW, 0, HID_RAW_FEATURE];
hostile.extend_from_slice(&[0x22; 900]);
match HidOutput::decode(&hostile) {
Some(HidOutput::HidRaw { data, .. }) => assert_eq!(data.len(), HID_REPORT_MAX),
other => panic!("expected a clamped HidRaw, got {other:?}"),
}
}
#[test]
fn rumble_envelope_roundtrip_and_legacy_tolerance() {
// v2 envelope round-trips seq + ttl.
+163 -139
View File
@@ -45,6 +45,10 @@
// v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced)
// clock offset ongoing latency math must use; the connect-time getter stays frozen by
// contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged.
// v11: added `punktfunk_connect_ex9` — `connect_ex8` plus a `client_caps` bitfield
// (`PUNKTFUNK_CLIENT_CAP_CURSOR`, later `…_PHASE_LOCK`), which is how a client tells the host it
// renders the pointer itself. Additive; the caps ride the existing Hello, so [`WIRE_VERSION`] is
// unchanged. (Documented late — the bump shipped without its line here.)
// v12: added `punktfunk_connection_set_cursor_render` — the mid-stream cursor-render flip
// (design/remote-desktop-sweep.md §8): the client's mouse-model chord tells the host who
// renders the pointer. Additive; rides the existing control stream (a new message TYPE, which
@@ -58,7 +62,15 @@
// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive;
// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a
// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged.
#define ABI_VERSION 14
// v15: versions the shared rumble policy engine's C surface —
// `punktfunk_connection_next_rumble_cmd`, `punktfunk_connection_set_rumble_quirks` and the
// `PUNKTFUNK_RUMBLE_QUIRK_*` bits. These symbols are NOT new: they landed while this constant
// still read 7 and no bump was made, so every core since has exported them while advertising a
// version that never promised them. That cannot be corrected retroactively — a shipped binary
// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
// present, below it an embedder must probe for the symbol. Purely a version statement; no code
// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
#define PUNKTFUNK_ABI_VERSION 15
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -66,7 +78,7 @@
// `punktfunk_wake_on_lan` is client-local, and riding the C-ABI bump onto the wire locked
// every new client out of every deployed host ("ABI mismatch: client 3 host 2", observed
// live). Bump this ONLY when the handshake/planes actually change incompatibly.
#define WIRE_VERSION 2
#define PUNKTFUNK_WIRE_VERSION 2
// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
#define PUNKTFUNK_HIDOUT_LED 1
@@ -323,41 +335,41 @@
// The uniform no-TTL-host staleness bound: a legacy host refreshes state every 500 ms, so two
// missed refreshes = quiet host → silence. Replaces the per-platform zoo (1.6 s / 60 s / 1.5 s /
// 1 s), and matches the ratio the Steam Deck ceiling shipped with.
#define LEGACY_STALE_MS 1000
#define PUNKTFUNK_LEGACY_STALE_MS 1000
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Per-fetch requester-side size cap (bytes). A holder that streams more than this is treated as a
// cap breach and the fetch fails rather than buffering unboundedly (§7). Phase 0 uses one fixed
// value; a future host-policy `PUNKTFUNK_CLIP_MAX_MB` tightens it per session.
#define CLIP_FETCH_CAP (64 << 20)
#define PUNKTFUNK_CLIP_FETCH_CAP (64 << 20)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Inbound-serve `req_id`s carry this high bit so they never collide with the client-assigned
// outbound-fetch `xfer_id`s (which count up from 1). A single [`ClipCommand::Cancel`] `id` can
// then be routed to the right table.
#define INBOUND_REQ_FLAG 2147483648
#define PUNKTFUNK_INBOUND_REQ_FLAG 2147483648
#endif
// Floor for a negotiated `shard_payload` (even, well under every real path). A path whose UDP
// budget lands below this can't carry the QUIC control plane either (QUIC's own minimum is a
// 1200-byte UDP payload), so shrinking video shards further buys nothing — the clamp helpers
// bottom out here instead of producing degenerate confetti-sized shards.
#define MIN_SHARD_PAYLOAD 512
#define PUNKTFUNK_MIN_SHARD_PAYLOAD 512
// 16-byte AEAD authentication tag appended by either session cipher.
#define TAG_LEN 16
#define PUNKTFUNK_TAG_LEN 16
// Wire tag distinguishing an input datagram from a video packet.
#define INPUT_MAGIC 200
#define PUNKTFUNK_INPUT_MAGIC 200
// Fixed serialized size of an [`InputEvent`] on the wire (tag + fields).
#define INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
#define PUNKTFUNK_INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
// The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the
// client's snapshot fold and the host's per-pad accumulators.
#define MAX_PADS 16
#define PUNKTFUNK_MAX_PADS 16
#define PUNKTFUNK_BTN_DPAD_UP 1
@@ -390,16 +402,16 @@
#define PUNKTFUNK_BTN_Y 32768
// Back grip R4 — SDL `RightPaddle1` / GameStream `PADDLE1`.
#define BTN_PADDLE1 65536
#define PUNKTFUNK_BTN_PADDLE1 65536
// Back grip L4 — SDL `LeftPaddle1` / GameStream `PADDLE2`.
#define BTN_PADDLE2 131072
#define PUNKTFUNK_BTN_PADDLE2 131072
// Back grip R5 — SDL `RightPaddle2` / GameStream `PADDLE3`.
#define BTN_PADDLE3 262144
#define PUNKTFUNK_BTN_PADDLE3 262144
// Back grip L5 — SDL `LeftPaddle2` / GameStream `PADDLE4`.
#define BTN_PADDLE4 524288
#define PUNKTFUNK_BTN_PADDLE4 524288
// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2`
// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on
@@ -407,7 +419,7 @@
#define PUNKTFUNK_BTN_TOUCHPAD 1048576
// Misc / capture button — the Deck `…`/quick-access, Share/Capture / GameStream `MISC`.
#define BTN_MISC1 2097152
#define PUNKTFUNK_BTN_MISC1 2097152
// Axis ids for `InputKind::GamepadAxis`.
#define PUNKTFUNK_AXIS_LS_X 0
@@ -426,16 +438,16 @@
// Identifies a punktfunk video packet (vs. an input datagram, see [`crate::input`]).
#define PUNKTFUNK_MAGIC 201
#define FLAG_PIC 1
#define PUNKTFUNK_FLAG_PIC 1
#define FLAG_EOF 2
#define PUNKTFUNK_FLAG_EOF 2
#define FLAG_SOF 4
#define PUNKTFUNK_FLAG_SOF 4
// Bandwidth-probe filler, not decodable video: a [`crate::quic::ProbeRequest`] speed test makes
// the host burst access units carrying this flag so the client measures throughput/loss without
// feeding them to the decoder. Punktfunk/1 only (GameStream never sets it).
#define FLAG_PROBE 8
#define PUNKTFUNK_FLAG_PROBE 8
// Application `user_flags` bit (the u32 [`PacketHeader::user_flags`] word, surfaced to the client
// as [`crate::session::Frame::flags`]) — NOT a transport packet flag. Marks the access unit that
@@ -444,7 +456,7 @@
// post-loss display freeze on this bit as well as on a real keyframe — the only bitstream-invisible
// clean point it can honor without forcing a full IDR. Lives above the low nibble because the host
// reuses `FLAG_PIC`/`FLAG_SOF`/`FLAG_PROBE` bit values inside `user_flags`; `0x10` clears all four.
#define USER_FLAG_RECOVERY_POINT 16
#define PUNKTFUNK_USER_FLAG_RECOVERY_POINT 16
// Application `user_flags` bit — a **definitive single-frame clean re-anchor**. Unlike
// [`USER_FLAG_RECOVERY_POINT`] (an intra-refresh wave boundary, where the first boundary after a loss
@@ -454,7 +466,7 @@
// already has, not an IDR. The picture is loss-free the instant this AU decodes, so the client lifts
// its post-loss freeze on the **first** such mark. Coded `P` (no IDR), so the decoder never sets
// `AV_FRAME_FLAG_KEY` — this host flag is the only signal.
#define USER_FLAG_RECOVERY_ANCHOR 32
#define PUNKTFUNK_USER_FLAG_RECOVERY_ANCHOR 32
// `user_flags` bit: the AU's content is **shard-aligned self-delimiting chunks** — every
// `shard_payload`-sized window of the frame buffer starts a fresh codec packet, padded to the
@@ -462,7 +474,7 @@
// consequences: a receiver that opted into partial delivery can use an aged-out frame's buffer
// AS-IS (missing shards stay zeroed; the codec's block walk skips zero windows), and even a
// COMPLETE frame must be consumed window-by-window (the padding is not part of the stream).
#define USER_FLAG_CHUNK_ALIGNED 64
#define PUNKTFUNK_USER_FLAG_CHUNK_ALIGNED 64
// `user_flags` bit: this AU was packetized as a **slice-streamed** frame (the P2 slice
// pipeline): its sentinel blocks (`block_count == 0`) are SLICE-granularity and carry their
@@ -475,7 +487,7 @@
// [`VIDEO_CAP_STREAMED_AU`](crate::quic::VIDEO_CAP_STREAMED_AU) ∧
// [`VIDEO_CAP_MULTI_SLICE`](crate::quic::VIDEO_CAP_MULTI_SLICE) — the pair whose receivers
// know this contract.
#define USER_FLAG_SLICE_STREAM 128
#define PUNKTFUNK_USER_FLAG_SLICE_STREAM 128
// Widest lost-frame range (frames, wrapping `last - first`) a reference-frame-invalidation
// recovery may be asked to repair; anything wider goes straight to the keyframe path on BOTH
@@ -484,7 +496,7 @@
// reference anywhere, so an RFI request for it is either hopeless or (worse) a phantom range
// from a desynced counter. Shared by the host's RFI dispatch (range → keyframe fallback) and the
// client-side gap detectors (huge gap → resync + keyframe request, no RFI).
#define RFI_MAX_RANGE 256
#define PUNKTFUNK_RFI_MAX_RANGE 256
// Largest UDP datagram the core will send or accept. `Config::validate` bounds
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
@@ -498,22 +510,22 @@
// for never having to resize buffers on a mid-session grow. Senders still derive their
// shard payload from the path MTU (`config::mtu1500_shard_payload*`, the wire-MTU clamps);
// this is the acceptance ceiling, not a transmit size.
#define MAX_DATAGRAM_BYTES 9216
#define PUNKTFUNK_MAX_DATAGRAM_BYTES 9216
// The slice-flush floor: a sentinel block below this many data shards costs disproportionate
// per-block FEC parity (`ceil(k × pct/100)` ≥ 1 whatever `k`), so slice boundaries only flush
// once this much has accumulated (~22 KB at the standard shard payload). Small slices simply
// ride with the next one; the wire is never worse than one flush per slice.
#define MIN_STREAM_BLOCK_SHARDS 16
#define PUNKTFUNK_MIN_STREAM_BLOCK_SHARDS 16
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream.
#define VIDEO_CAP_10BIT 1
#define PUNKTFUNK_VIDEO_CAP_10BIT 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
#define VIDEO_CAP_HDR 2
#define PUNKTFUNK_VIDEO_CAP_HDR 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -525,7 +537,7 @@
// 4:2:0 and [`Welcome::chroma_format`] reflects the real resolved value. Independent of
// 10-bit/HDR (4:4:4 is a chroma decision, bit depth is a depth decision; the two may combine
// where the hardware allows).
#define VIDEO_CAP_444 4
#define PUNKTFUNK_VIDEO_CAP_444 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -535,7 +547,7 @@
// (design/stats-unification.md Phase 2). The host emits 0xCF ONLY when this bit is set (an older
// host ignores it and simply never sends any); a client that doesn't set it keeps the combined
// stage. Purely observability — never changes what the host encodes.
#define VIDEO_CAP_HOST_TIMING 8
#define PUNKTFUNK_VIDEO_CAP_HOST_TIMING 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -550,7 +562,7 @@
// depends on. The host runs mid-session probe bursts ONLY against clients that set this bit — an
// older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window
// reassembler would silently drop as stale.
#define VIDEO_CAP_PROBE_SEQ 16
#define PUNKTFUNK_VIDEO_CAP_PROBE_SEQ 16
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -565,7 +577,7 @@
// — a mismatch drops the frame wholesale. The host streams ONLY to clients advertising this
// bit; every other client gets today's whole-AU path (chunks concatenated before sealing), so
// the fallback is zero-risk.
#define VIDEO_CAP_STREAMED_AU 32
#define PUNKTFUNK_VIDEO_CAP_STREAMED_AU 32
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -579,7 +591,7 @@
// toward every other client the Welcome stays byte-identical AES-128-GCM. Purely a
// performance choice — both AEADs are full-strength, and Hello/Welcome ride the pinned-TLS
// control channel, so there is no downgrade surface.
#define VIDEO_CAP_CHACHA20 64
#define PUNKTFUNK_VIDEO_CAP_CHACHA20 64
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -595,7 +607,7 @@
// bit (`PUNKTFUNK_NVENC_SLICES` stays the explicit operator override in both directions);
// every other client gets single-slice frames — the pre-0.17 wire shape. NOTE: this takes the
// video_caps byte's last free bit — the next video cap needs a second byte (ABI bump).
#define VIDEO_CAP_MULTI_SLICE 128
#define PUNKTFUNK_VIDEO_CAP_MULTI_SLICE 128
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -604,7 +616,7 @@
// sequence number. A capable client then sends gamepad state as snapshots (idempotent on the
// lossy datagram plane, periodically refreshed) instead of the fragile per-transition
// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
#define HOST_CAP_GAMEPAD_STATE 1
#define PUNKTFUNK_HOST_CAP_GAMEPAD_STATE 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -614,7 +626,7 @@
// out. Purely additive: nothing clipboard-related happens until a [`ClipControl`]`{ enabled:
// true }` crosses (see `design/clipboard-and-file-transfer.md` §3.1). Packs into the existing
// trailing `host_caps` byte — no wire-layout change.
#define HOST_CAP_CLIPBOARD 2
#define PUNKTFUNK_HOST_CAP_CLIPBOARD 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -626,7 +638,7 @@
// non-Latin scripts, emoji) through `TextInput` instead of lossy VK synthesis; absent the bit it
// keeps the VK fallback. Packs into the existing trailing `host_caps` byte — no wire-layout
// change; an older host ignores the unknown input tag anyway (input is lossy by design).
#define HOST_CAP_TEXT_INPUT 4
#define PUNKTFUNK_HOST_CAP_TEXT_INPUT 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -638,7 +650,7 @@
// (`SessionPlan.cursor_blend = false`) or the user sees it twice. Active only when the host
// answers with [`HOST_CAP_CURSOR`] (capable-and-agreed, the 444/clipboard precedent); toward
// an older or incapable host nothing changes.
#define CLIENT_CAP_CURSOR 1
#define PUNKTFUNK_CLIENT_CAP_CURSOR 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -647,7 +659,7 @@
// capture/send tick to the client's display latch (design/phase-locked-capture.md). Without
// the bit the host never arms the phase controller; toward an older host the reports are
// simply ignored — no behavior change in either direction.
#define CLIENT_CAP_PHASE_LOCK 2
#define PUNKTFUNK_CLIENT_CAP_PHASE_LOCK 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -660,7 +672,7 @@
// cursor/clipboard precedent). Toward an older host, or a host that declines because the link is
// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
#define CLIENT_CAP_AUDIO_RED 4
#define PUNKTFUNK_CLIENT_CAP_AUDIO_RED 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -671,7 +683,7 @@
// host stops blending and ships [`CursorShape`](super::control::CursorShape) +
// [`CursorState`](super::datagram::CursorState) instead. `0x08` — `0x04` is
// [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard.
#define HOST_CAP_CURSOR 8
#define PUNKTFUNK_HOST_CAP_CURSOR 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -685,7 +697,7 @@
// wire ships ahead of the backend (P0): no host sets this bit until the P1 injector lands —
// which is exactly why the gate exists. `0x10` — `0x08` is [`HOST_CAP_CURSOR`], `0x04` is
// [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard.
#define HOST_CAP_PEN 16
#define PUNKTFUNK_HOST_CAP_PEN 16
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -699,25 +711,25 @@
// loss-gated — a clean LAN shouldn't pay for it), which is why clients decode BOTH tags
// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
#define HOST_CAP_AUDIO_RED 32
#define PUNKTFUNK_HOST_CAP_AUDIO_RED 32
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
// advertise this.
#define CODEC_H264 1
#define PUNKTFUNK_CODEC_H264 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_codecs`] bit: the client can decode H.265 / HEVC — the default every existing
// build produces and decodes (a peer that omits [`Hello::video_codecs`] is treated as HEVC-only).
#define CODEC_HEVC 2
#define PUNKTFUNK_CODEC_HEVC 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_codecs`] bit: the client can decode AV1.
#define CODEC_AV1 4
#define PUNKTFUNK_CODEC_AV1 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -731,18 +743,18 @@
// (`crates/pyrowave-sys/vendor/pyrowave/PUNKTFUNK-VENDOR.txt`): upstream has no bitstream
// version field, so a vendored bump that changes the bitstream bumps the punktfunk protocol
// version instead (plan §4.2).
#define CODEC_PYROWAVE 8
#define PUNKTFUNK_CODEC_PYROWAVE 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// HEVC `chroma_format_idc` for 4:2:0 — what every pre-4:4:4 build produced and the back-compat
// default when a peer omits [`Welcome::chroma_format`].
#define CHROMA_IDC_420 1
#define PUNKTFUNK_CHROMA_IDC_420 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// HEVC `chroma_format_idc` for full-chroma 4:4:4 (Range Extensions).
#define CHROMA_IDC_444 3
#define PUNKTFUNK_CHROMA_IDC_444 3
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -793,195 +805,195 @@
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`Reconfigure`] (first byte after the magic).
#define MSG_RECONFIGURE 1
#define PUNKTFUNK_MSG_RECONFIGURE 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`Reconfigured`].
#define MSG_RECONFIGURED 2
#define PUNKTFUNK_MSG_RECONFIGURED 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`RequestKeyframe`].
#define MSG_REQUEST_KEYFRAME 3
#define PUNKTFUNK_MSG_REQUEST_KEYFRAME 3
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`LossReport`].
#define MSG_LOSS_REPORT 4
#define PUNKTFUNK_MSG_LOSS_REPORT 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`SetBitrate`].
#define MSG_SET_BITRATE 5
#define PUNKTFUNK_MSG_SET_BITRATE 5
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`BitrateChanged`].
#define MSG_BITRATE_CHANGED 6
#define PUNKTFUNK_MSG_BITRATE_CHANGED 6
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`RfiRequest`].
#define MSG_RFI_REQUEST 7
#define PUNKTFUNK_MSG_RFI_REQUEST 7
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ShardPayloadChanged`].
#define MSG_SHARD_PAYLOAD_CHANGED 8
#define PUNKTFUNK_MSG_SHARD_PAYLOAD_CHANGED 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ShardPayloadAck`].
#define MSG_SHARD_PAYLOAD_ACK 9
#define PUNKTFUNK_MSG_SHARD_PAYLOAD_ACK 9
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ProbeRequest`].
#define MSG_PROBE_REQUEST 32
#define PUNKTFUNK_MSG_PROBE_REQUEST 32
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ProbeResult`].
#define MSG_PROBE_RESULT 33
#define PUNKTFUNK_MSG_PROBE_RESULT 33
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ClockProbe`].
#define MSG_CLOCK_PROBE 48
#define PUNKTFUNK_MSG_CLOCK_PROBE 48
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ClockEcho`].
#define MSG_CLOCK_ECHO 49
#define PUNKTFUNK_MSG_CLOCK_ECHO 49
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`PhaseReport`].
#define MSG_PHASE_REPORT 50
#define PUNKTFUNK_MSG_PHASE_REPORT 50
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ClipControl`] (client → host): enable/disable the shared clipboard for this
// session. Idempotent; opt-in is enforced here, not just in UI.
#define MSG_CLIP_CONTROL 64
#define PUNKTFUNK_MSG_CLIP_CONTROL 64
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ClipState`] (host → client): ack + unsolicited policy/backend updates.
#define MSG_CLIP_STATE 65
#define PUNKTFUNK_MSG_CLIP_STATE 65
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ClipOffer`] (symmetric): the lazy announcement — format list only, no bytes.
#define MSG_CLIP_OFFER 66
#define PUNKTFUNK_MSG_CLIP_OFFER 66
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ClipFetch`] (requester → holder, **fetch stream only**): pull one format of the
// current offer.
#define MSG_CLIP_FETCH 67
#define PUNKTFUNK_MSG_CLIP_FETCH 67
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ClipFetchHdr`] (holder → requester, **fetch stream only**): the fetch response
// header that precedes the data chunks.
#define MSG_CLIP_FETCH_HDR 68
#define PUNKTFUNK_MSG_CLIP_FETCH_HDR 68
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipControl::flags`] bit: the client permits file kinds to be offered/fetched this session.
// Absent ⇒ files are filtered out of offers in both directions (text/rich/image only).
#define CLIP_FLAG_FILES 1
#define PUNKTFUNK_CLIP_FLAG_FILES 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipState::policy`] bit: the host permits non-file formats (text/RTF/HTML/image). Always set
// while enabled unless a future direction limit clears it.
#define CLIP_POLICY_TEXT 1
#define PUNKTFUNK_CLIP_POLICY_TEXT 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipState::policy`] bit: the host permits file formats. Cleared by the operator `no-files`
// / `text-only` policy so the client can grey out "Include files".
#define CLIP_POLICY_FILES 2
#define PUNKTFUNK_CLIP_POLICY_FILES 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipState::reason`]: normal ack, nothing exceptional.
#define CLIP_REASON_OK 0
#define PUNKTFUNK_CLIP_REASON_OK 0
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipState::reason`]: this session type has no working clipboard backend (e.g. a gamescope
// session with no data-control global) — the client shows "not supported in this session type".
#define CLIP_REASON_BACKEND_UNAVAILABLE 1
#define PUNKTFUNK_CLIP_REASON_BACKEND_UNAVAILABLE 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipState::reason`]: another client took over the single per-desktop clipboard binding; this
// one was disabled (last `ClipControl{enabled}` wins).
#define CLIP_REASON_TAKEN_OVER 2
#define PUNKTFUNK_CLIP_REASON_TAKEN_OVER 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipState::reason`]: the host operator policy (`PUNKTFUNK_CLIPBOARD=off`) disables clipboard.
#define CLIP_REASON_POLICY_DISABLED 3
#define PUNKTFUNK_CLIP_REASON_POLICY_DISABLED 3
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipState::reason`]: enabled, but the host policy forbids file transfer (`no-files` /
// `text-only`) — surfaced so the client greys "Include files" with a footnote.
#define CLIP_REASON_NO_FILES 4
#define PUNKTFUNK_CLIP_REASON_NO_FILES 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN.
#define CLIP_FETCH_OK 0
#define PUNKTFUNK_CLIP_FETCH_OK 0
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipFetchHdr::status`]: the fetch named a `seq` that is no longer the holder's current offer;
// the requester degrades the paste to "nothing inserted" rather than wrong data. No chunks follow.
#define CLIP_FETCH_STALE 1
#define PUNKTFUNK_CLIP_FETCH_STALE 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipFetchHdr::status`]: the format/index is not available (no backend, or it vanished). No
// chunks follow.
#define CLIP_FETCH_UNAVAILABLE 2
#define PUNKTFUNK_CLIP_FETCH_UNAVAILABLE 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipFetchHdr::status`]: policy/cap denies this fetch (e.g. a file fetch under `no-files`). No
// chunks follow.
#define CLIP_FETCH_DENIED 3
#define PUNKTFUNK_CLIP_FETCH_DENIED 3
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Maximum number of [`ClipKind`] entries in one [`ClipOffer`] (resource cap, §7).
#define CLIP_MAX_KINDS 16
#define PUNKTFUNK_CLIP_MAX_KINDS 16
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Maximum length in bytes of a [`ClipKind::mime`] string (resource cap, §7).
#define CLIP_MAX_MIME 128
#define PUNKTFUNK_CLIP_MAX_MIME 128
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipFetch::file_index`] sentinel meaning "not a file fetch" (a whole non-file format, or the
// file *manifest* itself). Real file fetches use `0..n`.
#define CLIP_FILE_INDEX_NONE UINT32_MAX
#define PUNKTFUNK_CLIP_FILE_INDEX_NONE UINT32_MAX
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`CursorShape`] (host → client): the pointer's bitmap + hotspot changed.
#define MSG_CURSOR_SHAPE 80
#define PUNKTFUNK_MSG_CURSOR_SHAPE 80
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`CursorRenderMode`] (client → host): who renders the pointer right now.
#define MSG_CURSOR_RENDER 81
#define PUNKTFUNK_MSG_CURSOR_RENDER 81
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -990,7 +1002,7 @@
// overshoots before the 17-byte header. 120² (57.6 KiB + header) fits with headroom and covers
// real cursors (typically ≤ 64 px, ≤ 96 px at HiDPI scale); the HOST downscales anything
// larger before forwarding, so the cap is invisible to clients.
#define CURSOR_SHAPE_MAX_SIDE 120
#define PUNKTFUNK_CURSOR_SHAPE_MAX_SIDE 120
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1010,21 +1022,21 @@
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
// [`AUDIO_MAGIC`]). The host feeds it into a virtual PipeWire source so its apps can record it.
#define MIC_MAGIC 203
#define PUNKTFUNK_MIC_MAGIC 203
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Rich client→host input: events too big for the fixed 18-byte [`InputEvent`]
// (crate::input::InputEvent) — the DualSense touchpad and motion sensors. Variable-length,
// kind-tagged (see [`RichInput`]).
#define RICH_INPUT_MAGIC 204
#define PUNKTFUNK_RICH_INPUT_MAGIC 204
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// HID output, host → client: DualSense feedback a game wrote to the host's virtual controller
// (lightbar, player LEDs, adaptive triggers) — the rich analog of [`RUMBLE_MAGIC`]. See
// [`HidOutput`].
#define HIDOUT_MAGIC 205
#define PUNKTFUNK_HIDOUT_MAGIC 205
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1064,7 +1076,7 @@
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Wire length of a v1 (legacy, level) rumble datagram.
#define RUMBLE_V1_LEN 7
#define PUNKTFUNK_RUMBLE_V1_LEN 7
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1072,48 +1084,60 @@
// tail. Decoders are length-tolerant (see [`decode_rumble_envelope`]): an old client reads the
// first 7 bytes as a plain level and ignores the tail, so no wire-version bump is needed — the
// same dual-size idiom the HDR-luminance `AddRequest` tail uses.
#define RUMBLE_V2_LEN 10
#define PUNKTFUNK_RUMBLE_V2_LEN 10
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Longest raw HID report a [`RichInput::HidReport`] / [`HidOutput::HidRaw`] can carry — the
// 64-byte interrupt/feature report size every Valve controller uses (Triton input reports are
// 4654 bytes; feature and output reports are at most 64).
#define HID_REPORT_MAX 64
#define PUNKTFUNK_HID_REPORT_MAX 64
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Longest [`HidOutput::Trigger`] `effect` the wire carries: the DualSense adaptive-trigger
// parameter block is a mode byte plus ten parameters, and every consumer copies at most this many
// into its report.
//
// The single source for the clamp on BOTH sides. `Trigger` was the only variable-length variant
// bounded on neither: encode appended whatever it was handed and decode took the entire tail, so
// an attacker-sized datagram was reproduced verbatim into a `Vec` while its sibling `HidRaw` had
// been bounded on both ends all along.
#define PUNKTFUNK_TRIGGER_EFFECT_MAX 11
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
// it on the physical device's interrupt-OUT endpoint / GATT write.
#define HID_RAW_OUTPUT 0
#define PUNKTFUNK_HID_RAW_OUTPUT 0
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`HidOutput::HidRaw`] `kind`: a FEATURE report — what the host's hidraw client sent with
// `SET_REPORT` (`SDL_hid_send_feature_report`: lizard mode, IMU enable, settings). The client
// replays it as a USB `SET_REPORT(Feature)` control transfer / GATT feature write.
#define HID_RAW_FEATURE 1
#define PUNKTFUNK_HID_RAW_FEATURE 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI;
// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`].
#define HDR_META_MAGIC 206
#define PUNKTFUNK_HDR_META_MAGIC 206
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Wire length of an [`HdrMeta`] body (no tag byte): 6×u16 primaries + 2×u16 white + 2×u32
// luminance + 2×u16 CLL/FALL = 28 bytes. Shared by the [`HDR_META_MAGIC`] datagram (which
// prefixes the tag) and the `Hello::display_hdr` trailing field (which carries the bare body).
#define HDR_META_BODY_LEN (((12 + 4) + 8) + 4)
#define PUNKTFUNK_HDR_META_BODY_LEN (((12 + 4) + 8) + 4)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Per-AU host-timing datagram tag, host → client (see [`HostTiming`]). Next tag after
// [`HDR_META_MAGIC`]. Emitted once per access unit, right after its last packet left the host's
// socket, and only when the client advertised [`VIDEO_CAP_HOST_TIMING`].
#define HOST_TIMING_MAGIC 207
#define PUNKTFUNK_HOST_TIMING_MAGIC 207
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1124,18 +1148,18 @@
// self-healing under loss (latest-wins, no refresh timer). The bitmap itself rides the
// reliable control stream ([`CursorShape`](super::control::CursorShape)); this 14-byte
// datagram only moves/hides the pointer.
#define CURSOR_STATE_MAGIC 208
#define PUNKTFUNK_CURSOR_STATE_MAGIC 208
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`CursorState::flags`] bit: the host cursor is visible.
#define CURSOR_VISIBLE 1
#define PUNKTFUNK_CURSOR_VISIBLE 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`CursorState::flags`] bit: a host app captured/hid the pointer — the client SHOULD run
// relative/captured (M3 auto-flip; advisory, user override always wins).
#define CURSOR_RELATIVE_HINT 2
#define PUNKTFUNK_CURSOR_RELATIVE_HINT 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1144,7 +1168,7 @@
// `ApplicationClosed` reason and tears the session's virtual display down immediately, skipping the
// keep-alive linger; any other close reason (idle timeout, reset, a bare code 0) still lingers so a
// reconnect can resume. Shared so host + every client agree on the code.
#define QUIT_CLOSE_CODE 81
#define PUNKTFUNK_QUIT_CLOSE_CODE 81
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1154,107 +1178,107 @@
// surfacing a failure (`design/gamemode-and-dedicated-sessions.md` §5.3). Sibling of
// [`QUIT_CLOSE_CODE`]; a client that doesn't special-case it still ends the session (every client
// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree.
#define APP_EXITED_CLOSE_CODE 82
#define PUNKTFUNK_APP_EXITED_CLOSE_CODE 82
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
#define HELLO_NAME_MAX 64
#define PUNKTFUNK_HELLO_NAME_MAX 64
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Longest library id carried in a [`Hello::launch`] (bytes of UTF-8). Ids are short
// (`steam:<appid>` / `custom:<12 hex>`); the cap just bounds an attacker-controlled field.
#define HELLO_LAUNCH_MAX 128
#define PUNKTFUNK_HELLO_LAUNCH_MAX 128
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::cipher`] id: AES-128-GCM — the default session AEAD every peer speaks (and the
// only one pre-cipher builds know).
#define CIPHER_AES_128_GCM 0
#define PUNKTFUNK_CIPHER_AES_128_GCM 0
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::cipher`] id: ChaCha20-Poly1305 (RFC 8439) — negotiated via
// [`VIDEO_CAP_CHACHA20`] for clients without hardware AES.
#define CIPHER_CHACHA20_POLY1305 1
#define PUNKTFUNK_CIPHER_CHACHA20_POLY1305 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`PairRequest`].
#define MSG_PAIR_REQUEST 16
#define PUNKTFUNK_MSG_PAIR_REQUEST 16
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`PairChallenge`].
#define MSG_PAIR_CHALLENGE 17
#define PUNKTFUNK_MSG_PAIR_CHALLENGE 17
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`PairProof`].
#define MSG_PAIR_PROOF 18
#define PUNKTFUNK_MSG_PAIR_PROOF 18
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`PairResult`].
#define MSG_PAIR_RESULT 19
#define PUNKTFUNK_MSG_PAIR_RESULT 19
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PenSample::state`] bit: the pen is in the hover range of the surface. Implied by
// [`PEN_TOUCHING`] (decode normalizes, so a client that only sets TOUCHING still produces a
// coherent contact).
#define PEN_IN_RANGE 1
#define PUNKTFUNK_PEN_IN_RANGE 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PenSample::state`] bit: the tip is in contact with the surface.
#define PEN_TOUCHING 2
#define PUNKTFUNK_PEN_TOUCHING 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PenSample::state`] bit: the primary barrel button (or the client's squeeze mapping) is held.
#define PEN_BARREL1 4
#define PUNKTFUNK_PEN_BARREL1 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PenSample::state`] bit: the secondary barrel button (or the client's double-tap mapping)
// is held.
#define PEN_BARREL2 8
#define PUNKTFUNK_PEN_BARREL2 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PenSample::state`] bit, RESERVED: a predicted (not yet observed) sample. Never sent v1;
// receivers MUST ignore samples carrying it until a capability negotiates otherwise
// (design/pen-tablet-input.md §8).
#define PEN_PREDICTED 128
#define PUNKTFUNK_PEN_PREDICTED 128
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PenSample::tilt_deg`] sentinel: the client has no tilt sensor / no reading.
#define PEN_TILT_UNKNOWN 255
#define PUNKTFUNK_PEN_TILT_UNKNOWN 255
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PenSample::azimuth_deg`] / [`PenSample::roll_deg`] sentinel: no reading.
#define PEN_ANGLE_UNKNOWN 65535
#define PUNKTFUNK_PEN_ANGLE_UNKNOWN 65535
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PenSample::distance`] sentinel: no hover-distance reading.
#define PEN_DISTANCE_UNKNOWN 65535
#define PUNKTFUNK_PEN_DISTANCE_UNKNOWN 65535
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Most samples one [`PenBatch`] can carry. Sized for coalesced capture at video-frame cadence
// (240 Hz pen ÷ 30 fps = 8); a client producing more splits into consecutive batches.
#define PEN_BATCH_MAX 8
#define PUNKTFUNK_PEN_BATCH_MAX 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Wire length of one encoded [`PenSample`].
#define PEN_SAMPLE_WIRE_LEN 21
#define PUNKTFUNK_PEN_SAMPLE_WIRE_LEN 21
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1265,13 +1289,13 @@
// pen is naturally silent — senders MUST repeat the last sample at least every ~100 ms while
// the pen is in range or touching (it re-decodes as pure Motion, harmless), keeping a live
// stationary stroke two heartbeats clear of the deadline.
#define PEN_TOUCH_TIMEOUT_MS 200
#define PUNKTFUNK_PEN_TOUCH_TIMEOUT_MS 200
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Stream-kind byte: a clipboard fetch (request/response of one format). Future stream kinds
// (e.g. a bulk file-content push) mux under the same [`STREAM_MAGIC`] with a different byte.
#define CLIP_STREAM_KIND_FETCH 1
#define PUNKTFUNK_CLIP_STREAM_KIND_FETCH 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1281,18 +1305,18 @@
// `0x52`), the connection reject code `0x42`, and the pairing-rejection close block
// `0x60``0x67` — stream reset codes and connection close codes are separate QUIC namespaces,
// but the vocabularies stay disjoint on purpose so a captured code is unambiguous.
#define CLIP_CANCELLED_CODE 112
#define PUNKTFUNK_CLIP_CANCELLED_CODE 112
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Chunk size for streaming fetch data (64 KiB writes — matches the control-frame bound).
#define CLIP_CHUNK (64 * 1024)
#define PUNKTFUNK_CLIP_CHUNK (64 * 1024)
#endif
// Consecutive no-output AUs that force a keyframe request. ~50 ms at 60 Hz — long enough not to fire
// on a one-frame decoder hiccup, short enough that a lost initial IDR (or a mid-GOP join) unfreezes
// almost immediately instead of never.
#define NO_OUTPUT_KEYFRAME_STREAK 3
#define PUNKTFUNK_NO_OUTPUT_KEYFRAME_STREAK 3
// How many host intra-refresh recovery marks ([`USER_FLAG_RECOVERY_POINT`]) must arrive since the
// latest loss before the gate lifts its freeze on an IDR-free stream. TWO, not one: with a continuous
@@ -1304,12 +1328,12 @@
// deliberate "hold longer, never show garbage" trade.
//
// [`USER_FLAG_RECOVERY_POINT`]: crate::packet::USER_FLAG_RECOVERY_POINT
#define REANCHOR_MARKS_TO_LIFT 2
#define PUNKTFUNK_REANCHOR_MARKS_TO_LIFT 2
// QUIC application error code the host closes with on a `mode_conflict = reject` admission
// refusal, carrying the human-readable busy reason (live mode + client label). A distinct code
// lets a client tell "host busy" apart from a transport failure. Shared so clients can render it.
#define REJECT_BUSY_CLOSE_CODE 66
#define PUNKTFUNK_REJECT_BUSY_CLOSE_CODE 66
// QUIC application close codes the host sends on **pairing-gate rejections**, so a client can
// tell the user WHY it was turned away instead of collapsing every close into a generic
@@ -1318,44 +1342,44 @@
// their own 0x60 block, disjoint from [`REJECT_BUSY_CLOSE_CODE`] (0x42) and the deliberate-end
// codes (0x51/0x52). Purely additive: an older client treats them as a bare close (exactly the
// pre-code behavior), an older host never sends them. Decode with [`RejectReason::from_close_code`].
#define PAIR_NOT_ARMED_CLOSE_CODE 96
#define PUNKTFUNK_PAIR_NOT_ARMED_CLOSE_CODE 96
// Pairing window armed, but bound to a DIFFERENT device fingerprint (the attempt does not
// consume the window). See [`PAIR_NOT_ARMED_CLOSE_CODE`] for the block's contract.
#define PAIR_BOUND_OTHER_CLOSE_CODE 97
#define PUNKTFUNK_PAIR_BOUND_OTHER_CLOSE_CODE 97
// PIN attempt inside the host's global pairing cooldown — retry shortly.
#define PAIR_RATE_LIMITED_CLOSE_CODE 98
#define PUNKTFUNK_PAIR_RATE_LIMITED_CLOSE_CODE 98
// Unpaired client presented no certificate: nothing to approve, and the SPAKE2 ceremony needs an
// identity to bind — the PIN flow with a client identity is the way in.
#define PAIR_NO_IDENTITY_CLOSE_CODE 99
#define PUNKTFUNK_PAIR_NO_IDENTITY_CLOSE_CODE 99
// The operator explicitly denied this pairing request in the host console.
#define PAIR_DENIED_CLOSE_CODE 100
#define PUNKTFUNK_PAIR_DENIED_CLOSE_CODE 100
// Nobody decided on the parked pairing request before the host's approval wait elapsed.
#define PAIR_APPROVAL_TIMEOUT_CLOSE_CODE 101
#define PUNKTFUNK_PAIR_APPROVAL_TIMEOUT_CLOSE_CODE 101
// This parked knock was superseded by a newer connection from the same device — only the
// newest is admitted on approval.
#define PAIR_SUPERSEDED_CLOSE_CODE 102
#define PUNKTFUNK_PAIR_SUPERSEDED_CLOSE_CODE 102
// The client's wire (protocol) version does not match the host's — one side needs updating.
#define WIRE_VERSION_CLOSE_CODE 103
#define PUNKTFUNK_WIRE_VERSION_CLOSE_CODE 103
// The host admitted the connection but could not stand the stream session up (compositor /
// capture / encoder setup failed host-side). The close reason bytes carry the specific error
// text for logs/diagnostics; clients render a stable "host-side failure" sentence. Before this
// code, a setup failure reached the client as a bare dropped connection ("control stream
// finished mid-frame") — indistinguishable from transport trouble.
#define SETUP_FAILED_CLOSE_CODE 104
#define PUNKTFUNK_SETUP_FAILED_CLOSE_CODE 104
// Minimum supported multiplier (renders under native, upscaled on present).
#define MIN_SCALE 0.5
#define PUNKTFUNK_MIN_SCALE 0.5
// Maximum supported multiplier (supersamples, clamped to the codec ceiling per axis).
#define MAX_SCALE 4.0
#define PUNKTFUNK_MAX_SCALE 4.0
// Stable C ABI status codes. `Ok` is 0; all errors are negative so callers can
// test `rc < 0`. Do not renumber existing variants — only append.
@@ -1901,7 +1925,7 @@ typedef struct {
// The multipliers a picker offers. `1.0` (Native) is the default; the rest are the round stops
// users reason about. Shared so every client's list stays identical.
#define PRESETS { 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, }
#define PUNKTFUNK_PRESETS { 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, }
#ifdef __cplusplus
extern "C" {