Files
punktfunk/clients/apple
enricobuehler efb7f99129 fix(client/apple): motion arrived in the wrong frame — measured against a real pad
G16 step 1, and the second half of what 9e9bb9f4 started. That commit fixed the SIGN
of acceleration (Apple reports the gravity vector, pointing down; a pad reports proper
acceleration, pointing up). This fixes the FRAME, which is a separate defect and was
never going to show up as an inverted axis — it shows up as roll where the game reads
yaw.

The wire is a unit passthrough. `dualsense_proto::write_report` puts gyro[0..3] and
accel[0..3] straight into the virtual pad's report bytes 16.. and 22.., in order, with
no permutation — the same slots a real DualSense fills. So the frame the wire is
DEFINED in is the pad's own report frame, and forwarding GameController's x/y/z
unconverted was speaking a different language with the same vocabulary.

Both frames measured 2026-08-07 from ONE physical DualSense on one desk, read twice —
over raw HID and through GameController — so this is two readings of the same
controller in the same orientations rather than two documents:

  DualSense report frame: (Right, Up, Backward)   axis 0 pitch, 1 yaw, 2 roll
  GameController frame:   (Right, Forward, Up)

Right is already slot 0; Up is GC's z and moves to slot 1; slot 2 wants Backward, which
is GC's y negated. Hence (x, z, -y), applied to gyro AND acceleration because it is a
change of basis and both live in that basis.

Notable: the wire's documented naming was right all along — gyro[0]=pitch, [1]=yaw,
[2]=roll is exactly what the hardware does. And Android needs no remap at all: it
forwards the pad's own axis order un-remapped, which is correct. Its old reading was
purely the scale bug f6de620f fixed. Only Apple was converting nothing.

How the hardware frame was established, since a wrong frame here is invisible. Gravity
at rest put +0.997 g on axis 1. Yaw clockwise-from-above drove axis 1 negative (98% of
the rotation), pitch nose-down drove axis 0 negative (100%), roll right-side-down drove
axis 2 negative (95%) — plain right-hand rule, and (a0 x a1 = a2) confirms the triad is
right-handed. The accelerometer then corroborated the gyro's assignment independently:
under pitch-down axis 2 rose 0.160 -> +0.339 (nose down raises the back, so world-up
gains a Backward component) and under roll-right-down axis 0 went +0.021 -> -0.197,
while yaw left acceleration untouched. Two different physical quantities agreeing on
one triad.

Apple's frame took four attempts, and the failures are worth recording because each was
a different way to be confidently wrong:
  - peak |w| over a window containing BOTH the tip-down and the return stroke can record
    the return, with the opposite sign. Yaw (a continuous one-way spin) was unaffected;
    pitch and roll were exactly the two that disagreed with everything else.
  - reading `gravity + userAcceleration` when `hasGravityAndUserAcceleration` is FALSE
    yields a constant (0,0,1) in every orientation. It looks like data. The tell is that
    it never moves. The client's own else-branch on `m.acceleration` is the correct read
    and is what the instrument now mirrors.
  - `da/dt = -w x a` holds only for gravity, so testing it during vigorous waving — when
    `m.acceleration` carries inseparable linear acceleration — fits nothing.
The frame that survived all of that: static poses, three of them, three repetitions
each. Nose-down moved axis 1 by -0.635 (so axis 1 is Forward), right-side-down moved
axis 0 by -0.686 (so axis 0 is Right), flat put +0.99 on axis 2 (Up). That conclusion
holds whether or not the acceleration negation is right, because negating flips the
measured vector and the physical direction it represents together.

Confidence, stated honestly. The accelerometer half is solid: nine pose measurements,
and mapping the flat pose through gives (+0.005, +0.992, +0.192) against the hardware's
own (+0.021, +0.997, +0.160) — all three components, including the small tilt term that
is what distinguishes this mapping from the five other permutations that also put
gravity on slot 1. That the gyro shares the frame unmodified rests on a weaker
measurement: a gravity-dominated consistency test that preferred (+x,+y,+z) by 1.22x,
which is a margin, not a landslide. It is corroborated by the yaw reading (the one
rotation measured without the return-stroke ambiguity) agreeing with right-hand rule in
that frame, and by the peak-vs-return mechanism explaining the two that did not. A
device-side confirmation is still owed and is listed below.

The tests carry the measurements, not just the conclusion. Resting gravity is asserted
against BOTH readings of that pose; each rotation is asserted to reach the slot the wire
reads it from; and two properties guard the shape rather than the numbers — that the
conversion is an isometry (a basis change may not stretch anything) and that it
preserves handedness. That last one matters most: a permutation with the wrong number of
sign flips is a REFLECTION, which looks plausible axis by axis and inverts every
rotation. Mutation-checked: dropping only the negation fails 6 assertions across 4 of
the 5 cases, the handedness test among them.

Owed, and not claimed done: on-glass re-verification through a real iOS device, together
with the two already owed on that rig (the 9e9bb9f4 sign fix and the Android
calibration read) — one pass covers all three. G14's DualSense neutral acceleration is
now unblocked by this measurement (1 g on slot 1, not the z-up the notes assumed) but is
deliberately left to its own change; and that constant must NOT be propagated to
switch_proto, which is a different device whose frame nobody has measured.

Gate: macOS `swift build` + the full suite (215 tests, 5 skipped, 0 failures) with the
five new cases observed in the run's own output, and the iOS-triple typecheck green.
2026-08-07 18:41:54 +02:00
..

punktfunk — Apple client (macOS · iOS · iPadOS · tvOS)

The native Apple app for streaming a punktfunk host to your Mac, iPhone, iPad, or Apple TV. A SwiftUI app that finds hosts on your network, pairs with a PIN, and streams at your display's own resolution and refresh rate — with VideoToolbox hardware decode and full controller support.

All the networking and protocol work — QUIC control plane, UDP data plane, GF(2¹⁶) FEC, AES-GCM, Opus audio, cert pinning — lives in the shared Rust punktfunk-core (statically linked as PunktfunkCore.xcframework). This package is the Swift shell: decode, present, input, and UI.

Features

  • Hardware decode — VideoToolbox H.264/HEVC (plus AV1 on devices with an AV1 hardware decoder — M3-class Macs, A17 Pro-class iPhones), with a low-latency stage-2 presenter (VTDecompressionSessionCAMetalLayer, presented off a CADisplayLink, ~11 ms p50) as the default and an AVSampleBufferDisplayLayer fallback.
  • HDR & 4:4:4 — PQ passthrough with a correct reference-white anchor, mid-session SDR↔HDR reconfiguration, and hardware-probed 4:4:4 support.
  • Your display's native mode — the host builds a virtual output at exactly your WxH@Hz; mid-stream resize renegotiates without reconnecting.
  • Audio both ways — Opus playback (CoreAudio, no bundled libopus) with a jitter ring, plus mic uplink; speaker/mic selectable in Settings.
  • Full controller support — one selected controller forwarded as pad 0, including DualSense feedback (rumble → CoreHaptics, lightbar, player LEDs, adaptive triggers) and touchpad/motion. The virtual pad type auto-resolves from your physical controller.
  • Mouse & keyboardGCMouse/GCKeyboard capture with click-to-capture and a ⌃⌥⇧Q release (the cross-client Ctrl+Alt+Shift+Q; ⌘⎋ still works as the macOS/iPad toggle), plus iPad pointer lock and touch input.
  • Find hosts automatically — mDNS discovery (NWBrowser over _punktfunk._udp); first connect does a one-time SPAKE2 PIN pairing (or TOFU on trusted LANs), then reconnects on a pinned, Keychain-stored identity.
  • Tune the stream — a fps / Mb·s / latency HUD (skew-corrected across machines), a bitrate control, a per-host network speed test with a recommended bitrate, and a host-compositor picker.

Runs from one shared codebase across macOS, iOS, iPadOS, and tvOS.

Get it

Install from the App Store / TestFlight, or build from source below. Per-device install steps and the pairing walkthrough: docs.punktfunk.unom.io/docs/install-client.

Build / run / test (on a Mac)

Requires Xcode 26.5 / Swift 6.3. First build the Rust core into an xcframework, then build the app:

rustup target add aarch64-apple-darwin x86_64-apple-darwin
bash scripts/build-xcframework.sh            # → clients/apple/PunktfunkCore.xcframework
#   BUILD_IOS=1  also builds the iOS slices  (add the ios rustup targets)
#   BUILD_TVOS=1 also builds tvOS            (tier-3 targets, built from source — see below)

cd clients/apple
open Punktfunk.xcodeproj                      # the real app: ⌘R builds + runs Punktfunk.app
swift run PunktfunkClient                     # or the unbundled dev shell (CLI)
swift build && swift test                     # unit + loopback/remote tests (self-skip w/o a host)

tvOS slices are tier-3 Rust targets, built from source: rustup toolchain install nightly && rustup component add rust-src --toolchain nightly.

Test against a host

# full loopback proof — builds punktfunk-host (synthetic source, runs on macOS) and streams
# byte-verified frames into the Swift client, incl. the PIN pairing ceremony:
bash test-loopback.sh

# against a real Linux host on the LAN (see the repo README "Running on this box"):
PUNKTFUNK_REMOTE_HOST=<box-ip> swift test --filter RemoteFirstLightTests            # headless
PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkClient # on glass

Project layout

  • PunktfunkKit (library) — the reusable pieces:
    • PunktfunkConnection — the wrapper over the C ABI (thread-safe close(), per-plane locks, pinning + TOFU).
    • AnnexB / StreamView / VideoDecoder / MetalVideoPresenter — format handling, the stage-1 (AVSampleBufferDisplayLayer) and stage-2 (VTDecompressionSessionCAMetalLayer) presenters.
    • InputCaptureGCMouse/GCKeyboard → host VK/mouse, with fractional-delta accumulation.
    • GamepadManager / GamepadCapture / GamepadFeedback / DualSenseTriggerEffect — controller discovery + selection, capture (buttons/axes/touchpad/motion), and host-feedback rendering.
    • HostDiscoveryNWBrowser over _punktfunk._udp.
  • PunktfunkClient (the app) — hosts grid with an On this network section, add-host sheet, the two trust flows (TOFU prompt + SPAKE2 PairSheet), the stream view with the HUD, a tabbed Settings pane (General / Display / Audio / Controllers / Advanced), and the network speed test. A Scene-level Stream menu carries the cross-client shortcut set: Release Mouse (⌃⌥⇧Q), Disconnect (⌃⌥⇧D) and the HUD toggle (⌃⌥⇧S) — the same Ctrl+Alt+Shift combos the Windows and Linux clients reserve, also shown on a 6-second banner at stream start. On iOS/iPadOS and macOS a connected controller swaps the whole home for the gamepad UI (Home/Gamepad*, Settings/GamepadSettingsView): a console-style host carousel (A connect · Y library · X settings), a controller-navigable settings screen, an add-host flow with an on-screen controller keyboard (no touch required anywhere), and the coverflow library browser — all driven by the shared GamepadMenuInput poller + GamepadCarousel/GamepadMenuList focus machinery, with dual-channel haptics (device Taptic + controller MenuHaptics), over an animated "aurora" backdrop (GamepadScreenBackground — TimelineView-driven drifting color blobs; deliberately pure SwiftUI, since a .metal library only reliably bundles in one of the two build systems these sources compile under). macOS presents the settings/add-host screens as sheets (no fullScreenCover there); PUNKTFUNK_FORCE_GAMEPAD_UI=1 forces the mode without a physical pad (dev/screenshots).
  • Tests (swift test) — Annex-B units, a real-codec VideoToolbox round trip, DualSense trigger-effect and gamepad-wire conversions, loopback integration against real local hosts, and the remote first-light test.

Notes for contributors

  • Xcode project (Punktfunk.xcodeproj) wraps the same sources as the swift run shell (a synchronized folder — no duplication). The macOS target is App-Sandboxed (needs network.server — the raw-UDP plane and quinn both bind()); iOS/tvOS use the shared entitlements file (keep app-sandbox out of it). Verify with codesign -d --entitlements :- <built .app>.
  • Decode flow: the host opens every stream with an IDR carrying VPS/SPS/PPS in-band, and recovery keyframes re-send them — refresh the format description on every IDR; there is no out-of-band extradata, ever.
  • ABI threading: one video pump thread per connection, one optional audio drain thread, and one optional feedback drain thread (rumble + HID-output). send() is enqueue-only and safe alongside all of them. The wrapper's per-plane locks make close() safe from anywhere.
  • DualSense motion scale (GamepadWire) is derived from hid-playstation's math, not yet live-verified — if gyro/accel feel wrong in a game, correct sign/scale there and evtest the 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).
  • Deeper design notes live in the internal planning repo (punktfunk-planning: apple-stage2-presenter.md).