‹ projects

reverb-m1

Native wired VR on Apple silicon: Reverb G1 display bring-up and stereo demo
git clone https://umhau.org/code/reverb-m1.git
Log | Files | Refs

App.swift (15753B)


      1 // The app wakes the headset over HID and covers its display with a non-activating surface,
      2 // renders the stereo scene fullscreen at 4320x2160 @ 90 Hz, and re-takes control the moment the display returns.
      3 //
      4 // Health is watched PASSIVELY only (macOS display callbacks + the headset's own 0x05 status reports).
      5 // Avoid intrusive USB-C/DisplayPort polling. Drops have also occurred without polling;
      6 // use callbacks to observe the link and distinguish recovery from sustained stability.
      7 import AppKit
      8 import Metal
      9 import QuartzCore
     10 
     11 /// A display surface, never the keyboard focus or an exclusive display owner.
     12 final class HeadsetWindow: NSPanel {
     13     override var canBecomeKey: Bool { false }
     14     override var canBecomeMain: Bool { false }
     15 }
     16 
     17 /// The headset's display: vendor 0x220E (8718).
     18 func isHeadsetDisplay(_ id: CGDirectDisplayID) -> Bool { CGDisplayVendorNumber(id) == 8718 }
     19 
     20 func onlineHeadsetDisplay() -> CGDirectDisplayID? {
     21     var ids = [CGDirectDisplayID](repeating: 0, count: 16)
     22     var n: UInt32 = 0
     23     CGGetOnlineDisplayList(16, &ids, &n)
     24     return ids.prefix(Int(n)).first(where: isHeadsetDisplay)
     25 }
     26 
     27 func log(_ s: String) { print(String(format: "%.3f ", Date().timeIntervalSince1970) + s) }
     28 
     29 private func isNativeHeadsetMode(_ mode: CGDisplayMode) -> Bool {
     30     mode.pixelWidth == 4320 && mode.pixelHeight == 2160 && mode.width == 4320 && mode.height == 2160 && mode.refreshRate == 90
     31 }
     32 
     33 private let displayChanged: CGDisplayReconfigurationCallBack = { id, flags, ctx in
     34     guard !flags.contains(.beginConfigurationFlag), let ctx else { return }
     35     guard flags.contains(.removeFlag) || flags.contains(.addFlag) || flags.contains(.setModeFlag) || flags.contains(.movedFlag) else { return }
     36     let enteredAt = Date()
     37     log("[display callback] id=\(id) flags=\(flags.rawValue) main=\(Thread.isMainThread)")
     38     let session = Unmanaged<HeadsetSession>.fromOpaque(ctx).takeUnretainedValue()
     39     if flags.contains(.removeFlag) {
     40         session.recoveryDisplayRemoved(id)
     41     }
     42     if flags.contains(.addFlag), isHeadsetDisplay(id) { session.recoveryDisplayAdded(id) }
     43     onMainRunLoop {
     44         log(String(format: "[display callback] handled after %.1f ms", Date().timeIntervalSince(enteredAt) * 1000))
     45         // Vendor metadata may already be gone when REMOVE is delivered.
     46         if flags.contains(.removeFlag) { session.displayLost(id) }
     47         if flags.contains(.addFlag), isHeadsetDisplay(id) { session.displayArrived(id) }
     48         if flags.contains(.setModeFlag) { session.displayModeChanged(id) }
     49         if flags.contains(.movedFlag) { session.displayMoved(id) }
     50     }
     51 }
     52 
     53 final class HeadsetSession: NSObject, NSApplicationDelegate {
     54     let hmd: HMD
     55     let renderer: Renderer
     56     let view: MetalSurface
     57     private let renderLoop: MetalRenderLoop
     58     private var window: HeadsetWindow?
     59     private var opticsControls: OpticsControls?
     60     private(set) var display: CGDirectDisplayID?
     61     private var lostAt: Date?
     62     private var hidRetry: Timer?
     63     private var healthTimer: Timer?
     64     private var callbackRegistered = false
     65     private var stopping = false
     66     private var recoveryTimer: Timer?
     67     private let recovery: LinkRecovery
     68     private var lastHealthFrames = 0
     69     private var lastHealthTime: Double = CACurrentMediaTime()
     70     private var terminationTimer: Timer?
     71     private var outputConfigured = false
     72 
     73     init(device: MTLDevice?, recoveryHelper: URL) throws {
     74         let headset = HMD()
     75         hmd = headset
     76         recovery = LinkRecovery(helper: recoveryHelper, wake: { try headset.screenOn() })
     77         renderer = try Renderer(device: device)
     78         view = MetalSurface(device: renderer.device)
     79         renderLoop = MetalRenderLoop(layer: view.metalLayer, renderer: renderer)
     80         super.init()
     81         renderLoop.start()
     82     }
     83 
     84     func start() {
     85         hmd.onStatus = { log("[hmd] \($0)") }
     86         hmd.onPanel = { [weak self] on in if !on { self?.panelOff() } }
     87         hmd.onDisconnected = { [weak self] in
     88             guard let self, !self.stopping else { return }
     89             log("[hmd] USB device removed; reopening when available")
     90             self.hmd.stop()
     91             self.scheduleHIDRetry()
     92         }
     93 
     94         let me = Unmanaged.passUnretained(self).toOpaque()
     95         let result = CGDisplayRegisterReconfigurationCallback(displayChanged, me)
     96         callbackRegistered = result == .success
     97         if !callbackRegistered { log("display callback registration failed: \(result.rawValue)") }
     98         startHID()
     99         healthTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
    100             guard let self else { return }
    101             if let id = self.display { self.displayModeChanged(id) }
    102             let age = self.hmd.lastIMUAt.map { String(format: "%.1fs", Date().timeIntervalSince($0)) } ?? "never"
    103             let now = CACurrentMediaTime()
    104             let metrics = self.renderer.frameMetrics()
    105             let fps = Double(metrics.presented - self.lastHealthFrames) / (now - self.lastHealthTime)
    106             self.lastHealthTime = now; self.lastHealthFrames = metrics.presented
    107             log(String(format: "health display=%@ submitted=%d presented=%d skipped=%d fps=%.2f presentAge=%.2fs GPUms=%.2f GPUerrors=%d IMU=%d lastIMU=%@ status=%d",
    108                        self.display.map(String.init) ?? "absent", metrics.submitted,
    109                        metrics.presented, metrics.skipped, fps, now - metrics.lastPresentedTime,
    110                        metrics.gpuSeconds * 1000 / Double(max(1, metrics.completed)), metrics.errors,
    111                        self.hmd.imuSamples, age, self.hmd.statusReports))
    112         }
    113         if let id = onlineHeadsetDisplay() { displayArrived(id) } else {
    114             log("waiting for the headset display...")
    115             recover()
    116         }
    117     }
    118 
    119     func showOpticsControls() { opticsControls = OpticsControls(renderer: renderer) }
    120 
    121     private func startHID() {
    122         guard !stopping else { return }
    123         do {
    124             try hmd.start(mode: .activate)
    125             hidRetry?.invalidate(); hidRetry = nil
    126             log("[hmd] wake commands accepted; waiting for IMU and video evidence")
    127             if display == nil { recover() }
    128         } catch { log("[hmd] \(error.localizedDescription)"); scheduleHIDRetry() }
    129     }
    130 
    131     private func scheduleHIDRetry() {
    132         guard hidRetry == nil, !stopping else { return }
    133         hidRetry = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in self?.startHID() }
    134     }
    135 
    136     func applicationWillTerminate(_ notification: Notification) { stop() }
    137 
    138     func stopAfter(_ seconds: Double) {
    139         terminationTimer = Timer.scheduledTimer(withTimeInterval: seconds, repeats: false) { _ in NSApp.terminate(nil) }
    140     }
    141 
    142     func stop() {
    143         guard !stopping else { return }
    144         stopping = true
    145         terminationTimer?.invalidate(); terminationTimer = nil
    146         hidRetry?.invalidate(); hidRetry = nil
    147         healthTimer?.invalidate(); healthTimer = nil
    148         recoveryTimer?.invalidate(); recoveryTimer = nil
    149         recovery.stop()
    150         renderLoop.stop()
    151         if callbackRegistered {
    152             CGDisplayRemoveReconfigurationCallback(displayChanged, Unmanaged.passUnretained(self).toOpaque())
    153             callbackRegistered = false
    154         }
    155         hmd.stop()
    156         window?.orderOut(nil)
    157         opticsControls?.close(); opticsControls = nil
    158         display = nil
    159     }
    160 
    161     /// Put the render surface on the headset without exclusive display capture.
    162     func displayArrived(_ id: CGDirectDisplayID) {
    163         guard !stopping else { return }
    164         if display == id { displayModeChanged(id); return }
    165         if let previous = display { displayLost(previous) }
    166         if let t = lostAt { log(String(format: "headset display back after %.0f ms", Date().timeIntervalSince(t) * 1000)) }
    167         lostAt = nil
    168         log("attaching renderer to headset display \(id)")
    169         display = id
    170         placeHeadsetToRight(id)
    171         recovery.trackDisplay(id)
    172         recoveryTimer?.invalidate(); recoveryTimer = nil
    173         guard setNativeMode(id) else {
    174             display = nil
    175             log("cannot render at required native mode")
    176             recover()
    177             return
    178         }
    179         configureOutput(id)
    180     }
    181 
    182     /// Keep the Mac's left desktop edge available for its left-positioned Dock.
    183     private func placeHeadsetToRight(_ id: CGDirectDisplayID) {
    184         var ids = [CGDirectDisplayID](repeating: 0, count: 16), count: UInt32 = 0
    185         guard CGGetOnlineDisplayList(16, &ids, &count) == .success else { return }
    186         let other = ids.prefix(Int(count)).filter { $0 != id }
    187         guard let right = other.map({ CGDisplayBounds($0).maxX }).max() else { return }
    188         let y = CGDisplayBounds(CGMainDisplayID()).minY
    189         let current = CGDisplayBounds(id)
    190         guard current.minX != right || current.minY != y else { return }
    191         var configuration: CGDisplayConfigRef?
    192         guard CGBeginDisplayConfiguration(&configuration) == .success, let configuration else { return }
    193         let moved = CGConfigureDisplayOrigin(configuration, id, Int32(right), Int32(y))
    194         guard moved == .success else { CGCancelDisplayConfiguration(configuration); return }
    195         let result = CGCompleteDisplayConfiguration(configuration, .forSession)
    196         log("headset desktop placement to right: \(result.rawValue)")
    197     }
    198 
    199     func displayMoved(_ id: CGDirectDisplayID) {
    200         guard display == id, !stopping else { return }
    201         configureOutput(id)
    202     }
    203 
    204     func displayModeChanged(_ id: CGDirectDisplayID) {
    205         guard !stopping, display == id, let current = CGDisplayCopyDisplayMode(id) else { return }
    206         let changed = !isNativeHeadsetMode(current)
    207         if changed {
    208             log("headset mode changed to \(current.pixelWidth)x\(current.pixelHeight)@\(current.refreshRate); restoring native 90 Hz")
    209             renderLoop.setPaused(true)
    210             outputConfigured = false
    211             guard setNativeMode(id) else {
    212                 log("rendering paused: cannot restore native 90 Hz")
    213                 return
    214             }
    215         }
    216         if changed || !outputConfigured { configureOutput(id) }
    217     }
    218 
    219     private func configureOutput(_ id: CGDirectDisplayID) {
    220         guard !stopping, display == id else { return }
    221         // Keep this surface above the headset desktop without taking over macOS display capture.
    222         let bounds = CGDisplayBounds(id)
    223         let mainH = CGDisplayBounds(CGMainDisplayID()).height
    224         let frame = NSRect(x: bounds.minX, y: mainH - bounds.maxY, width: bounds.width, height: bounds.height)
    225         let win = window ?? HeadsetWindow(contentRect: frame, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
    226         win.level = .statusBar
    227         win.hidesOnDeactivate = false
    228         win.ignoresMouseEvents = true
    229         win.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary]
    230         win.contentView = view
    231         win.setFrame(frame, display: true)
    232         win.orderFrontRegardless()
    233         window = win
    234         view.metalLayer.drawableSize = CGSize(width: 4320, height: 2160)
    235         outputConfigured = true
    236         renderLoop.setPaused(false)
    237         log("rendering on headset \(frame.size), drawable \(view.metalLayer.drawableSize); dedicated Metal display loop at 90 Hz")
    238     }
    239 
    240     func displayLost(_ id: CGDirectDisplayID) {
    241         guard id == display else { return }
    242         if lostAt == nil { lostAt = Date() }
    243         display = nil
    244         outputConfigured = false
    245         renderLoop.setPaused(true)
    246         window?.orderOut(nil)
    247         log("headset display LOST")
    248         recover()
    249     }
    250 
    251     func panelOff() { log("headset reports display not ready") }
    252 
    253     // Callback entry must notify recovery before SkyLight can block the main thread.
    254     func recoveryDisplayRemoved(_ id: CGDirectDisplayID) {
    255         recovery.displayRemoved(id)
    256         if Thread.isMainThread, display == id {
    257             lostAt = Date()
    258             renderLoop.setPaused(true)
    259         }
    260     }
    261     func recoveryDisplayAdded(_ id: CGDirectDisplayID) { recovery.displayAdded(id) }
    262 
    263     func recover() {
    264         guard !stopping, display == nil, recoveryTimer == nil else { return }
    265         if onlineHeadsetDisplay() != nil {
    266             recoveryTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
    267                 guard let self else { return }
    268                 self.recoveryTimer = nil
    269                 guard !self.stopping, self.display == nil else { return }
    270                 if let id = onlineHeadsetDisplay() {
    271                     log("headset display online; retrying headset attachment without a DP reset")
    272                     self.displayArrived(id)
    273                 } else { self.recovery.requestRecovery() }
    274             }
    275         } else { recovery.requestRecovery() }
    276     }
    277 
    278     /// 4320x2160 pixels at 90 Hz, unscaled.
    279     private func setNativeMode(_ id: CGDirectDisplayID) -> Bool {
    280         let opts = [kCGDisplayShowDuplicateLowResolutionModes: true] as CFDictionary
    281         let modes = (CGDisplayCopyAllDisplayModes(id, opts) as? [CGDisplayMode]) ?? []
    282         guard let m = modes.first(where: isNativeHeadsetMode) else {
    283             log("no unscaled 4320x2160@90 mode"); return false
    284         }
    285         if CGDisplayCopyDisplayMode(id)?.ioDisplayModeID == m.ioDisplayModeID { return true }
    286         let r = CGDisplaySetDisplayMode(id, m, nil)
    287         log("mode 4320x2160@90 (ui \(m.width)x\(m.height)): \(r == .success ? "ok" : "error \(r.rawValue)")")
    288         return r == .success
    289     }
    290 }
    291 
    292 func runApp() {
    293     do { try prepareFactoryOptics() } catch {
    294         print("cannot load headset optical calibration:", error); exit(1)
    295     }
    296     let app = NSApplication.shared
    297     app.setActivationPolicy(.regular)
    298     let session: HeadsetSession
    299     let helper = option("--recovery-helper").map { URL(fileURLWithPath: $0) }
    300         ?? Bundle.main.executableURL!.deletingLastPathComponent().appendingPathComponent("hpmctl")
    301     do { session = try HeadsetSession(device: MTLCreateSystemDefaultDevice(), recoveryHelper: helper) } catch { print("renderer failed:", error); exit(1) }
    302     app.delegate = session
    303     session.start()
    304     if let value = option("--run-seconds") {
    305         guard let seconds = Double(value), seconds > 0, seconds.isFinite else {
    306             session.stop(); print("--run-seconds must be a positive number"); exit(2)
    307         }
    308         session.stopAfter(seconds)
    309     }
    310     session.showOpticsControls()
    311     app.activate(ignoringOtherApps: true)
    312     signal(SIGINT, SIG_IGN); signal(SIGTERM, SIG_IGN)
    313     let signals = [SIGINT, SIGTERM].map { value -> DispatchSourceSignal in
    314         let source = DispatchSource.makeSignalSource(signal: value, queue: .main)
    315         source.setEventHandler { app.terminate(nil) }
    316         source.resume()
    317         return source
    318     }
    319     withExtendedLifetime((session, signals)) { app.run() }
    320 }
    321 
    322 /// Read once before opening the rendering session; later starts use the validated local cache.
    323 private func prepareFactoryOptics() throws {
    324     if option("--calibration") != nil || FileManager.default.fileExists(atPath: HeadsetOptics.cacheURL.path) {
    325         _ = try HeadsetOptics.loadCached()
    326         return
    327     }
    328     let reader = HMD()
    329     try reader.start(mode: .observe)
    330     defer { reader.stop() }
    331     let block = try WMRConfigBlock(raw: [UInt8](reader.readFactoryConfiguration()))
    332     _ = try HeadsetOptics(json: block.json)
    333     let cache = HeadsetOptics.cacheURL
    334     try FileManager.default.createDirectory(at: cache.deletingLastPathComponent(), withIntermediateDirectories: true)
    335     try block.json.write(to: cache, options: .atomic)
    336     log("cached headset factory optical calibration")
    337 }