Stream reliability
- Default to the stage-2 presenter (VTDecompressionSession + CAMetalLayer): it detects
and recovers a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard
on a lost HEVC reference frame with no app-side recovery (confirmed Apple limitation).
Stage 1 is now a DEBUG-only presenter toggle, plus the automatic no-Metal fallback.
- Stage-2 pixel-perfect: render the drawable at the decoded size (shader stays 1:1 =
identity) and let the layer's contentsGravity scale via the system compositor — the
same path stage-1's videoGravity used — instead of scaling in-shader.
- Loss recovery in both pumps is now a persistent awaitingIDR want, retried until an IDR
actually lands, so a keyframe request swallowed by the throttle can't strand a frozen
frame; 100 ms keyframe throttle to match the Android path.
- Fix "Publishing changes from within view updates": defer the HostStore writes out of
the .onChange(of: model.phase) callback.
- Move AVAudioSession setActive/setCategory off the main thread (async on a shared serial
queue) to stop the UI-stall warning.
Controllers
- Rumble: capped-exponential backoff when the gamecontrollerd.haptics XPC breaks (-4811)
so a transient server interruption self-heals instead of cascading; playsHapticsOnly so
a controller engine doesn't join the always-active streaming audio session.
- Host cards: iPad pointer "magnet" hover effect; iPhone press scale + light haptic.
UI / design
- Ship Geist (SIL OFL 1.1) as the app font (bundled OTFs + registration), with the
license surfaced in Acknowledgements.
- Restructure iOS/iPadOS Settings into a category NavigationSplitView; resolution wheel
with custom-resolution entry; 10-bit HDR toggle in Display.
- Industrial host-card redesign (left-aligned, bold, brand monogram tiles).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GameController's CHHapticEngine never reaches the DualSense's motors on macOS — its
adaptive triggers and lightbar work, but rumble stays silent (a documented platform
gap). Drive the motors directly via the DualSense HID output report instead, the way
SDL and the Linux hid-playstation driver do — the same report that already rumbles
the pad on a Linux host. Confirmed live on macOS.
- DualSenseHID (macOS): opens the Sony DualSense via IOHIDManager and writes the USB
(0x02, 48 bytes) and Bluetooth (0x31, 78 bytes + CRC32) output reports through
IOHIDDeviceSetReport. Allowed under the App Sandbox by the existing device.usb +
device.bluetooth entitlements; coexists with GameController (non-seized open).
Flags mirror the kernel driver (COMPATIBLE_VIBRATION | HAPTICS_SELECT +
COMPATIBLE_VIBRATION2); valid_flag1 = 0 so a rumble report leaves the
GameController-managed lightbar / triggers / player LEDs untouched.
- RumbleRenderer routes a DualSense to the HID backend and keeps CoreHaptics for
every other pad, fixing both live sessions and the test panel (shared renderer).
- CoreHaptics path reworked too: bake the target intensity + an explicit sharpness
into the continuous event (the dynamic-parameter scaling is silent on controller
engines) and tear down outside the inout access to fix a latent exclusivity hazard.
Adds a DEBUG-only Settings -> Controllers -> "Test Controller" panel (ControllerTestView
+ ControllerTester) that shows live input and fires rumble / adaptive triggers /
lightbar / player LEDs straight at the pad, with a readout of the active rumble backend
("DualSense HID - USB/Bluetooth"). Used to validate the fix.
Tests: DualSenseHIDTests pins the USB/BT report layout and the BT CRC32 (canonical
0xCBF43926 check vector). Debug + release build clean; gamepad suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>