‹ 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

commit 5806ab0a34d10a0dcb47825b7a0b89be087a4803
parent 95213a8defde4ff69e4111a7ffc7c6d6fd00e7bd
Author: umhau <umhau@example.invalid>
Date:   Wed, 21 Oct 2026 12:00:16 +0000

Recover headset links independently of UI and render native 90 Hz

Diffstat:
MPackage.swift | 2+-
MSources/VRWorkstation/App.swift | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
MSources/VRWorkstation/HMD.swift | 27+++++++++++++++++++++++----
ASources/VRWorkstation/LinkRecovery.swift | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/VRWorkstation/MainRunLoop.swift | 8++++++++
ASources/VRWorkstation/MetalOutput.swift | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MSources/VRWorkstation/Renderer.swift | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
MSources/VRWorkstation/main.swift | 6++----
ATests/LinkRecoveryChecks.swift | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/build.sh | 10++++++++++
Mtools/hpmctl.c | 47++++++++++++++++++++++++++++++++++++++++-------
Atools/recovery_trials.py | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 726 insertions(+), 68 deletions(-)

diff --git a/Package.swift b/Package.swift @@ -9,7 +9,7 @@ let package = Package( name: "VRWorkstation", linkerSettings: [ .linkedFramework("IOKit"), .linkedFramework("Metal"), - .linkedFramework("MetalKit"), .linkedFramework("AppKit"), + .linkedFramework("QuartzCore"), .linkedFramework("AppKit"), ] ), ] diff --git a/Sources/VRWorkstation/App.swift b/Sources/VRWorkstation/App.swift @@ -1,11 +1,12 @@ // The app owns the headset: it wakes it over HID, captures its display (no desktop, no other windows), -// renders the stereo scene fullscreen at 4320x2160 @ 60 Hz, and re-takes control the moment the display returns. +// renders the stereo scene fullscreen at 4320x2160 @ 90 Hz, and re-takes control the moment the display returns. // // Health is watched PASSIVELY only (macOS display callbacks + the headset's own 0x05 status reports). -// Never poll USB-C/DisplayPort port state (ioreg IOPortTransportStateDisplayPort): that polling made the -// headset drop its link every ~70 s. +// Avoid intrusive USB-C/DisplayPort polling. Drops have also occurred without polling; +// use callbacks to observe the link and distinguish recovery from sustained stability. import AppKit -import MetalKit +import Metal +import QuartzCore final class HeadsetWindow: NSWindow { override var canBecomeKey: Bool { true } @@ -26,20 +27,34 @@ func onlineHeadsetDisplay() -> CGDirectDisplayID? { func log(_ s: String) { print(String(format: "%.3f ", Date().timeIntervalSince1970) + s) } +private func isNativeHeadsetMode(_ mode: CGDisplayMode) -> Bool { + mode.pixelWidth == 4320 && mode.pixelHeight == 2160 && mode.width == 4320 && mode.height == 2160 && mode.refreshRate == 90 +} + private let displayChanged: CGDisplayReconfigurationCallBack = { id, flags, ctx in guard !flags.contains(.beginConfigurationFlag), let ctx else { return } + guard flags.contains(.removeFlag) || flags.contains(.addFlag) || flags.contains(.setModeFlag) else { return } + let enteredAt = Date() + log("[display callback] id=\(id) flags=\(flags.rawValue) main=\(Thread.isMainThread)") let session = Unmanaged<HeadsetSession>.fromOpaque(ctx).takeUnretainedValue() - DispatchQueue.main.async { + if flags.contains(.removeFlag) { + session.recoveryDisplayRemoved(id) + } + if flags.contains(.addFlag), isHeadsetDisplay(id) { session.recoveryDisplayAdded(id) } + onMainRunLoop { + log(String(format: "[display callback] handled after %.1f ms", Date().timeIntervalSince(enteredAt) * 1000)) // Vendor metadata may already be gone when REMOVE is delivered. if flags.contains(.removeFlag) { session.displayLost(id) } if flags.contains(.addFlag), isHeadsetDisplay(id) { session.displayArrived(id) } + if flags.contains(.setModeFlag) { session.displayModeChanged(id) } } } final class HeadsetSession: NSObject, NSApplicationDelegate { - let hmd = HMD() + let hmd: HMD let renderer: Renderer - let view: MTKView + let view: MetalSurface + private let renderLoop: MetalRenderLoop private var window: HeadsetWindow? private(set) var display: CGDirectDisplayID? private var lostAt: Date? @@ -47,16 +62,22 @@ final class HeadsetSession: NSObject, NSApplicationDelegate { private var healthTimer: Timer? private var callbackRegistered = false private var stopping = false + private var recoveryTimer: Timer? + private let recovery: LinkRecovery + private var lastHealthFrames = 0 + private var lastHealthTime: Double = CACurrentMediaTime() + private var terminationTimer: Timer? + private var outputConfigured = false - init(device: MTLDevice?) throws { - view = MTKView(frame: .zero, device: device) - view.colorPixelFormat = .bgra8Unorm - view.depthStencilPixelFormat = .depth32Float - view.preferredFramesPerSecond = 60 - renderer = try Renderer(view: view) - view.delegate = renderer - view.isPaused = true + init(device: MTLDevice?, recoveryHelper: URL) throws { + let headset = HMD() + hmd = headset + recovery = LinkRecovery(helper: recoveryHelper, wake: { try headset.screenOn() }) + renderer = try Renderer(device: device) + view = MetalSurface(device: renderer.device) + renderLoop = MetalRenderLoop(layer: view.metalLayer, renderer: renderer) super.init() + renderLoop.start() } func start() { @@ -76,10 +97,22 @@ final class HeadsetSession: NSObject, NSApplicationDelegate { startHID() healthTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in guard let self else { return } + if let id = self.display { self.displayModeChanged(id) } let age = self.hmd.lastIMUAt.map { String(format: "%.1fs", Date().timeIntervalSince($0)) } ?? "never" - log("health display=\(self.display.map(String.init) ?? "absent") frames=\(self.renderer.frames) IMU=\(self.hmd.imuSamples) lastIMU=\(age) status=\(self.hmd.statusReports)") + let now = CACurrentMediaTime() + let metrics = self.renderer.frameMetrics() + let fps = Double(metrics.presented - self.lastHealthFrames) / (now - self.lastHealthTime) + self.lastHealthTime = now; self.lastHealthFrames = metrics.presented + log(String(format: "health display=%@ submitted=%d presented=%d skipped=%d fps=%.2f presentAge=%.2fs GPUms=%.2f GPUerrors=%d IMU=%d lastIMU=%@ status=%d", + self.display.map(String.init) ?? "absent", metrics.submitted, + metrics.presented, metrics.skipped, fps, now - metrics.lastPresentedTime, + metrics.gpuSeconds * 1000 / Double(max(1, metrics.completed)), metrics.errors, + self.hmd.imuSamples, age, self.hmd.statusReports)) + } + if let id = onlineHeadsetDisplay() { displayArrived(id) } else { + log("waiting for the headset display...") + recover() } - if let id = onlineHeadsetDisplay() { displayArrived(id) } else { log("waiting for the headset display...") } } private func startHID() { @@ -88,6 +121,7 @@ final class HeadsetSession: NSObject, NSApplicationDelegate { try hmd.start(mode: .activate) hidRetry?.invalidate(); hidRetry = nil log("[hmd] wake commands accepted; waiting for IMU and video evidence") + if display == nil { recover() } } catch { log("[hmd] \(error.localizedDescription)"); scheduleHIDRetry() } } @@ -98,16 +132,24 @@ final class HeadsetSession: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { stop() } + func stopAfter(_ seconds: Double) { + terminationTimer = Timer.scheduledTimer(withTimeInterval: seconds, repeats: false) { _ in NSApp.terminate(nil) } + } + func stop() { + guard !stopping else { return } stopping = true + terminationTimer?.invalidate(); terminationTimer = nil hidRetry?.invalidate(); hidRetry = nil healthTimer?.invalidate(); healthTimer = nil + recoveryTimer?.invalidate(); recoveryTimer = nil + recovery.stop() + renderLoop.stop() if callbackRegistered { CGDisplayRemoveReconfigurationCallback(displayChanged, Unmanaged.passUnretained(self).toOpaque()) callbackRegistered = false } hmd.stop() - view.isPaused = true window?.orderOut(nil) if let display { CGDisplayRelease(display) } display = nil @@ -115,15 +157,43 @@ final class HeadsetSession: NSObject, NSApplicationDelegate { /// Takes exclusive control of the headset display and puts the renderer on it. func displayArrived(_ id: CGDirectDisplayID) { - guard !stopping, display != id else { return } + guard !stopping else { return } + if display == id { displayModeChanged(id); return } + if let previous = display { displayLost(previous) } if let t = lostAt { log(String(format: "headset display back after %.0f ms", Date().timeIntervalSince(t) * 1000)) } lostAt = nil let r = CGDisplayCapture(id) log("captured display \(id): \(r == .success ? "ok" : "error \(r.rawValue)")") - guard r == .success else { return } + guard r == .success else { recover(); return } display = id - setNativeMode(id) + recovery.capturedDisplay(id) + recoveryTimer?.invalidate(); recoveryTimer = nil + guard setNativeMode(id) else { + CGDisplayRelease(id); display = nil + log("cannot render at required native mode") + recover() + return + } + configureOutput(id) + } + func displayModeChanged(_ id: CGDirectDisplayID) { + guard !stopping, display == id, let current = CGDisplayCopyDisplayMode(id) else { return } + let changed = !isNativeHeadsetMode(current) + if changed { + log("headset mode changed to \(current.pixelWidth)x\(current.pixelHeight)@\(current.refreshRate); restoring native 90 Hz") + renderLoop.setPaused(true) + outputConfigured = false + guard setNativeMode(id) else { + log("rendering paused: cannot restore native 90 Hz") + return + } + } + if changed || !outputConfigured { configureOutput(id) } + } + + private func configureOutput(_ id: CGDirectDisplayID) { + guard !stopping, display == id else { return } // A captured display is covered by the shielding window; ours must sit above it. let bounds = CGDisplayBounds(id) let mainH = CGDisplayBounds(CGMainDisplayID()).height @@ -135,39 +205,62 @@ final class HeadsetSession: NSObject, NSApplicationDelegate { win.setFrame(frame, display: true) win.makeKeyAndOrderFront(nil) window = win - view.isPaused = false - log("rendering on headset \(frame.size), drawable \(view.drawableSize)") + view.metalLayer.drawableSize = CGSize(width: 4320, height: 2160) + outputConfigured = true + renderLoop.setPaused(false) + log("rendering on headset \(frame.size), drawable \(view.metalLayer.drawableSize); dedicated Metal display loop at 90 Hz") } func displayLost(_ id: CGDirectDisplayID) { guard id == display else { return } - lostAt = Date() + if lostAt == nil { lostAt = Date() } display = nil - view.isPaused = true + outputConfigured = false + renderLoop.setPaused(true) window?.orderOut(nil) CGDisplayRelease(id) log("headset display LOST") recover() } - func panelOff() { log("headset reports panel off") } + func panelOff() { log("headset reports display not ready") } + + // Callback entry must notify recovery before SkyLight can block the main thread. + func recoveryDisplayRemoved(_ id: CGDirectDisplayID) { + recovery.displayRemoved(id) + if Thread.isMainThread, display == id { + lostAt = Date() + renderLoop.setPaused(true) + } + } + func recoveryDisplayAdded(_ id: CGDirectDisplayID) { recovery.displayAdded(id) } - /// DP recovery is not yet solved. Avoid replaying panel-off or controller resets on a live link. func recover() { - log("recovery: waiting for the display to return") + guard !stopping, display == nil, recoveryTimer == nil else { return } + if onlineHeadsetDisplay() != nil { + recoveryTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in + guard let self else { return } + self.recoveryTimer = nil + guard !self.stopping, self.display == nil else { return } + if let id = onlineHeadsetDisplay() { + log("headset display online; retrying capture without a DP reset") + self.displayArrived(id) + } else { self.recovery.requestRecovery() } + } + } else { recovery.requestRecovery() } } - /// 4320x2160 pixels at 60 Hz, unscaled. - private func setNativeMode(_ id: CGDirectDisplayID) { + /// 4320x2160 pixels at 90 Hz, unscaled. + private func setNativeMode(_ id: CGDirectDisplayID) -> Bool { let opts = [kCGDisplayShowDuplicateLowResolutionModes: true] as CFDictionary let modes = (CGDisplayCopyAllDisplayModes(id, opts) as? [CGDisplayMode]) ?? [] - guard let m = modes.first(where: { $0.pixelWidth == 4320 && $0.pixelHeight == 2160 && $0.refreshRate == 60 && $0.width == 4320 }) - ?? modes.first(where: { $0.pixelWidth == 4320 && $0.pixelHeight == 2160 && $0.refreshRate == 60 }) else { - log("no 4320x2160@60 mode"); return + guard let m = modes.first(where: isNativeHeadsetMode) else { + log("no unscaled 4320x2160@90 mode"); return false } - if CGDisplayCopyDisplayMode(id)?.ioDisplayModeID == m.ioDisplayModeID { return } + if CGDisplayCopyDisplayMode(id)?.ioDisplayModeID == m.ioDisplayModeID { return true } let r = CGDisplaySetDisplayMode(id, m, nil) - log("mode 4320x2160@60 (ui \(m.width)x\(m.height)): \(r == .success ? "ok" : "error \(r.rawValue)")") + log("mode 4320x2160@90 (ui \(m.width)x\(m.height)): \(r == .success ? "ok" : "error \(r.rawValue)")") + return r == .success } } @@ -175,9 +268,24 @@ func runApp() { let app = NSApplication.shared app.setActivationPolicy(.regular) let session: HeadsetSession - do { session = try HeadsetSession(device: MTLCreateSystemDefaultDevice()) } catch { print("renderer failed:", error); exit(1) } + let helper = option("--recovery-helper").map { URL(fileURLWithPath: $0) } + ?? Bundle.main.executableURL!.deletingLastPathComponent().appendingPathComponent("hpmctl") + do { session = try HeadsetSession(device: MTLCreateSystemDefaultDevice(), recoveryHelper: helper) } catch { print("renderer failed:", error); exit(1) } app.delegate = session session.start() + if let value = option("--run-seconds") { + guard let seconds = Double(value), seconds > 0, seconds.isFinite else { + session.stop(); print("--run-seconds must be a positive number"); exit(2) + } + session.stopAfter(seconds) + } app.activate(ignoringOtherApps: true) - withExtendedLifetime(session) { app.run() } + signal(SIGINT, SIG_IGN); signal(SIGTERM, SIG_IGN) + let signals = [SIGINT, SIGTERM].map { value -> DispatchSourceSignal in + let source = DispatchSource.makeSignalSource(signal: value, queue: .main) + source.setEventHandler { app.terminate(nil) } + source.resume() + return source + } + withExtendedLifetime((session, signals)) { app.run() } } diff --git a/Sources/VRWorkstation/HMD.swift b/Sources/VRWorkstation/HMD.swift @@ -15,7 +15,7 @@ final class HMD { enum Mode { case observe, activate } var onIMU: ((IMUSample) -> Void)? var onStatus: ((String) -> Void)? - /// Panel power state from the headset's 0x05 status report (true = lit). + /// Display-ready flag inferred from 0x05. Physical panel illumination needs user confirmation. var onPanel: ((Bool) -> Void)? var onDisconnected: (() -> Void)? private(set) var imuSamples = 0 @@ -23,8 +23,14 @@ final class HMD { private(set) var lastIMUAt: Date? private var managers: [IOHIDManager] = [] // must outlive the devices, or they get closed + private let ioLock = NSRecursiveLock() private var control: IOHIDDevice? private var sensors: IOHIDDevice? + var isConnected: Bool { + ioLock.lock(); defer { ioLock.unlock() } + return control != nil && sensors != nil + } + private var generation = 0 private let controlBuf = UnsafeMutablePointer<UInt8>.allocate(capacity: 64) private let sensorsBuf = UnsafeMutablePointer<UInt8>.allocate(capacity: 512) @@ -36,6 +42,7 @@ final class HMD { /// Observe registers callbacks only. Activate additionally wakes the panel and starts the IMU. /// Callbacks fire on the main run loop. func start(mode: Mode) throws { + ioLock.lock(); defer { ioLock.unlock() } stop() do { control = try open(0x03f0, 0x0c6a, "QHMD control device") @@ -51,7 +58,10 @@ final class HMD { IOHIDDeviceRegisterRemovalCallback(d, { ctx, _, _ in let hmd = Unmanaged<HMD>.fromOpaque(ctx!).takeUnretainedValue() // Leave the IOKit callback before closing its manager. - DispatchQueue.main.async { hmd.onDisconnected?() } + let generation = hmd.generation + onMainRunLoop { + if hmd.generation == generation { hmd.onDisconnected?() } + } }, me) IOHIDDeviceScheduleWithRunLoop(d, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) } @@ -64,7 +74,7 @@ final class HMD { } _ = getFeature(0x09) // Preserve wake sequence; do not log device serials. _ = getFeature(0x08); _ = getFeature(0x06) - try setFeature([0x04, 0x01]) // screen on + try screenOn() var imuOn = [UInt8](repeating: 0, count: 64); imuOn[0] = 0x02; imuOn[1] = 0x07 let result = IOHIDDeviceSetReport(sensors!, kIOHIDReportTypeOutput, 0x02, imuOn, imuOn.count) guard result == kIOReturnSuccess else { throw hidError("IMU start", result) } @@ -74,8 +84,16 @@ final class HMD { } } + func screenOn() throws { + ioLock.lock(); defer { ioLock.unlock() } + guard control != nil else { throw NotFound(name: "QHMD control device") } + try setFeature([0x04, 0x01]) + } + /// Close host handles without sending panel-off, which can strand the DP link. func stop() { + ioLock.lock(); defer { ioLock.unlock() } + generation += 1 for d in [control, sensors].compactMap({ $0 }) { IOHIDDeviceRegisterRemovalCallback(d, nil, nil) IOHIDDeviceRegisterInputReportCallback(d, d == control ? controlBuf : sensorsBuf, d == control ? 64 : 512, nil, nil) @@ -117,7 +135,8 @@ final class HMD { private func handleControl(_ p: UnsafeMutablePointer<UInt8>, _ n: Int) { guard n >= 6 && p[0] == 0x05 else { return } statusReports += 1 - onStatus?("panel \(p[1] == 1 ? "on" : "off"), measured refresh \(p[5] == 0 ? 0 : Int(p[5]) + 1) Hz") + let raw = (0..<n).map { String(format: "%02x", p[$0]) }.joined(separator: " ") + onStatus?("display ready=\(p[1] == 1); raw \(raw)") onPanel?(p[1] == 1) } diff --git a/Sources/VRWorkstation/LinkRecovery.swift b/Sources/VRWorkstation/LinkRecovery.swift @@ -0,0 +1,131 @@ +import Foundation +import Darwin + +/// All deadlines and helper operations run independently of WindowServer's main-thread waits. +final class LinkRecovery { + private let queue = DispatchQueue(label: "Headset link recovery", qos: .userInitiated) + private let helper: URL + private let wake: (() throws -> Void)? + private var process: Process? + private var timeout: DispatchSourceTimer? + private var pending: DispatchWorkItem? + private var attempts: [TimeInterval] = [] + private var target: UInt32? + private var waiting = false + private var stopped = false + private var generation = 0 + + init(helper: URL, wake: (() throws -> Void)? = nil) { + self.helper = helper; self.wake = wake + } + + func capturedDisplay(_ id: UInt32) { + queue.async { self.target = id; self.cancelWaiting() } + } + + func displayRemoved(_ id: UInt32) { + queue.async { if self.target == id { self.beginWaiting() } } + } + + func displayAdded(_ id: UInt32) { + queue.async { self.target = id; self.cancelWaiting() } + } + + func requestRecovery() { queue.async { self.beginWaiting() } } + + private func cancelWaiting() { + waiting = false; generation += 1 + pending?.cancel(); pending = nil + } + + private func beginWaiting() { + guard !stopped, !waiting else { return } + waiting = true + scheduleAttempt(after: 2) + } + + private func scheduleAttempt(after seconds: Double) { + let expected = generation + let work = DispatchWorkItem { [weak self] in + guard let self, !self.stopped, self.waiting, self.generation == expected else { return } + do { try self.wake?() } catch { + log("recovery: USB wake failed: \(error)") + self.scheduleAttempt(after: 10) + return + } + log("recovery: display absent after grace period; resetting DP adapter") + self.startReset { [weak self] _ in + guard let self, self.waiting, !self.stopped, self.generation == expected else { return } + self.scheduleAttempt(after: 10) + } + } + pending = work + queue.asyncAfter(deadline: .now() + seconds, execute: work) + } + + func reset(onCompletion: @escaping (Bool) -> Void) { + queue.async { + self.startReset { accepted in onMainRunLoop { onCompletion(accepted) } } + } + } + + /// Called only on queue. ADD, rather than helper exit status, proves video recovery. + private func startReset(onCompletion: @escaping (Bool) -> Void) { + guard !stopped, process == nil else { onCompletion(false); return } + let now = ProcessInfo.processInfo.systemUptime + attempts.removeAll { now - $0 >= 60 } + guard attempts.count < 3 else { + log("recovery rate limit: at most 3 resets per minute") + onCompletion(false); return + } + guard FileManager.default.isExecutableFile(atPath: helper.path) else { + log("recovery helper missing: run tools/build.sh (expected \(helper.path))") + onCompletion(false); return + } + let command = Process() + command.executableURL = helper + command.arguments = ["1", "recover"] + command.standardOutput = FileHandle.standardOutput + command.standardError = FileHandle.standardError + var timedOut = false + command.terminationHandler = { [weak self] finished in + guard let self else { return } + self.queue.async { + guard self.process === finished else { return } + self.timeout?.cancel(); self.timeout = nil + self.process = nil + let accepted = !timedOut && finished.terminationStatus == 0 + log("recovery helper \(accepted ? "accepted DRST; waiting for display ADD" : "failed (exit \(finished.terminationStatus))")") + onCompletion(accepted) + } + } + do { + try command.run() + process = command + attempts.append(now) + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 3) + timer.setEventHandler { [weak self] in + guard let self, self.process === command, command.isRunning else { return } + timedOut = true + log("recovery helper exceeded 3s; terminating") + kill(command.processIdentifier, SIGKILL) + } + timeout = timer + timer.resume() + } catch { + log("cannot launch recovery helper: \(error)") + onCompletion(false) + } + } + + func stop() { + queue.sync { + stopped = true + cancelWaiting() + timeout?.cancel(); timeout = nil + if let process, process.isRunning { process.terminate() } + process = nil + } + } +} diff --git a/Sources/VRWorkstation/MainRunLoop.swift b/Sources/VRWorkstation/MainRunLoop.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Defer out of driver callbacks and explicitly wake AppKit's run loop. +/// Explicit waking avoids relying on dispatch-queue servicing during display-link rendering. +func onMainRunLoop(_ action: @escaping () -> Void) { + CFRunLoopPerformBlock(CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue, action) + CFRunLoopWakeUp(CFRunLoopGetMain()) +} diff --git a/Sources/VRWorkstation/MetalOutput.swift b/Sources/VRWorkstation/MetalOutput.swift @@ -0,0 +1,107 @@ +import AppKit +import Metal +import QuartzCore + +/// AppKit owns only the surface. Rendering never calls NSView.draw or nextDrawable on main. +final class MetalSurface: NSView { + let metalLayer = CAMetalLayer() + + init(device: MTLDevice) { + super.init(frame: .zero) + metalLayer.device = device + metalLayer.pixelFormat = .bgra8Unorm + metalLayer.framebufferOnly = true + metalLayer.maximumDrawableCount = 3 + metalLayer.drawableSize = CGSize(width: 4320, height: 2160) + metalLayer.contentsScale = 1 // native unscaled headset mode: one point per pixel + layer = metalLayer + wantsLayer = true + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } +} + +/// One serial render worker for the lifetime of the session. The display link supplies +/// a ready drawable and follows its layer's display, including reconnects and mode changes. +final class MetalRenderLoop { + private let layer: CAMetalLayer + private let renderer: Renderer + private let lock = NSLock() + private var thread: Thread? + private var runLoop: CFRunLoop? + private var link: CAMetalDisplayLink? // touched only on the worker's run loop + private var paused = true + private var stopped = false + private let finished = DispatchSemaphore(value: 0) + + init(layer: CAMetalLayer, renderer: Renderer) { + self.layer = layer; self.renderer = renderer + } + + func start() { + let worker = Thread { [weak self] in self?.run() } + worker.name = "Headset Metal rendering" + worker.qualityOfService = .userInteractive + thread = worker + worker.start() + } + + func setPaused(_ value: Bool) { + lock.lock() + paused = value + let loop = runLoop + lock.unlock() + guard let loop else { return } + CFRunLoopPerformBlock(loop, CFRunLoopMode.commonModes.rawValue) { [weak self] in self?.link?.isPaused = value } + CFRunLoopWakeUp(loop) + } + + func stop() { + lock.lock() + stopped = true + let loop = runLoop + lock.unlock() + if let loop { + CFRunLoopStop(loop) + CFRunLoopWakeUp(loop) + } + if thread != nil && finished.wait(timeout: .now() + 0.5) == .timedOut { + log("render worker did not stop within 0.5s") + } + } + + private func run() { + defer { finished.signal() } + autoreleasepool { + let loop = CFRunLoopGetCurrent()! + lock.lock() + if stopped { lock.unlock(); return } + runLoop = loop + let initiallyPaused = paused + lock.unlock() + + let displayLink = CAMetalDisplayLink(metalLayer: layer) + displayLink.delegate = renderer + displayLink.preferredFrameRateRange = CAFrameRateRange(minimum: 90, maximum: 90, preferred: 90) + displayLink.preferredFrameLatency = 1 + displayLink.isPaused = initiallyPaused + link = displayLink + displayLink.add(to: .current, forMode: .common) + let keepAlive = Port() + RunLoop.current.add(keepAlive, forMode: .default) + while true { + lock.lock() + let shouldStop = stopped + let interval = paused ? 0.25 : 0.01 + lock.unlock() + if shouldStop { break } + // Drain drawable/update autoreleases on this dedicated thread each turn. + autoreleasepool { _ = CFRunLoopRunInMode(.defaultMode, interval, true) } + } + displayLink.invalidate() // exactly once, on its owning thread + link = nil + keepAlive.invalidate() + lock.lock(); runLoop = nil; lock.unlock() + } + } +} diff --git a/Sources/VRWorkstation/Renderer.swift b/Sources/VRWorkstation/Renderer.swift @@ -1,13 +1,23 @@ // Side-by-side stereo renderer: one viewport per eye on the 4320x2160 headset panel. import AppKit import Metal -import MetalKit +import QuartzCore import simd struct Vertex { var pos: SIMD3<Float>; var normal: SIMD3<Float>; var color: SIMD3<Float> } struct Uniforms { var mvp: simd_float4x4; var model: simd_float4x4 } -final class Renderer: NSObject, MTKViewDelegate { +struct FrameMetrics { + var presented = 0 + var skipped = 0 + var completed = 0 + var errors = 0 + var lastPresentedTime: Double = 0 + var gpuSeconds: Double = 0 + var submitted = 0 +} + +final class Renderer: NSObject, CAMetalDisplayLinkDelegate { let device: MTLDevice let queue: MTLCommandQueue let pipeline: MTLRenderPipelineState @@ -21,17 +31,17 @@ final class Renderer: NSObject, MTKViewDelegate { var ipd: Float = 0.063 var fovY: Float = 100 * .pi / 180 - init(view: MTKView) throws { - guard let dev = view.device ?? MTLCreateSystemDefaultDevice(), let q = dev.makeCommandQueue() else { + init(device: MTLDevice?) throws { + guard let dev = device, let q = dev.makeCommandQueue() else { throw NSError(domain: "Renderer", code: 1, userInfo: [NSLocalizedDescriptionKey: "No Metal device"]) } - device = dev; queue = q + self.device = dev; queue = q let lib = try dev.makeLibrary(source: shaderSource, options: nil) let pd = MTLRenderPipelineDescriptor() pd.vertexFunction = lib.makeFunction(name: "vmain") pd.fragmentFunction = lib.makeFunction(name: "fmain") - pd.colorAttachments[0].pixelFormat = view.colorPixelFormat - pd.depthAttachmentPixelFormat = view.depthStencilPixelFormat + pd.colorAttachments[0].pixelFormat = .bgra8Unorm + pd.depthAttachmentPixelFormat = .depth32Float pipeline = try dev.makeRenderPipelineState(descriptor: pd) let dd = MTLDepthStencilDescriptor() dd.depthCompareFunction = .less @@ -42,15 +52,62 @@ final class Renderer: NSObject, MTKViewDelegate { sceneBuffer = dev.makeBuffer(bytes: scene.vertices, length: MemoryLayout<Vertex>.stride * scene.vertices.count)! } - func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} + private let metricsLock = NSLock() + private var metrics = FrameMetrics() + private var displayDepth: MTLTexture? // serial render-worker ownership + func frameMetrics() -> FrameMetrics { + metricsLock.lock(); defer { metricsLock.unlock() } + return metrics + } + func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) { + autoreleasepool { + draw(update.drawable, presentationTime: update.targetPresentationTimestamp) + } + } - var frames = 0 - func draw(in view: MTKView) { - guard let rpd = view.currentRenderPassDescriptor, let drawable = view.currentDrawable, - let cb = queue.makeCommandBuffer() else { return } - frames += 1 - if frames == 1 || frames % 300 == 0 { print("frame \(frames) drawable \(view.drawableSize)") } - encode(cb, rpd, size: view.drawableSize, time: Float(CACurrentMediaTime() - start)) + private func draw(_ drawable: CAMetalDrawable, presentationTime: Double) { + let width = drawable.texture.width, height = drawable.texture.height + if displayDepth?.width != width || displayDepth?.height != height { + let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .depth32Float, width: width, height: height, mipmapped: false) + descriptor.usage = .renderTarget + descriptor.storageMode = .private + displayDepth = device.makeTexture(descriptor: descriptor) + } + guard let displayDepth, let cb = queue.makeCommandBuffer() else { return } + let rpd = MTLRenderPassDescriptor() + rpd.colorAttachments[0].texture = drawable.texture + rpd.colorAttachments[0].loadAction = .clear + rpd.colorAttachments[0].storeAction = .store + rpd.depthAttachment.texture = displayDepth + rpd.depthAttachment.loadAction = .clear + rpd.depthAttachment.storeAction = .dontCare + rpd.depthAttachment.clearDepth = 1 + metricsLock.lock() + metrics.submitted += 1 + let frame = metrics.submitted + metricsLock.unlock() + if frame == 1 { log("render worker main=\(Thread.isMainThread)") } + if frame == 1 || frame % 300 == 0 { print("frame \(frame) drawable \(width)x\(height)") } + encode(cb, rpd, size: CGSize(width: width, height: height), time: Float(presentationTime - start)) + drawable.addPresentedHandler { [weak self] presented in + guard let self else { return } + self.metricsLock.lock(); defer { self.metricsLock.unlock() } + // Metal reports zero for a skipped drawable. Callback count alone overstates delivery. + if presented.presentedTime > 0 { + self.metrics.presented += 1 + self.metrics.lastPresentedTime = max(self.metrics.lastPresentedTime, presented.presentedTime) + } else { + self.metrics.skipped += 1 + } + } + cb.addCompletedHandler { [weak self] completed in + guard let self else { return } + self.metricsLock.lock(); defer { self.metricsLock.unlock() } + self.metrics.completed += 1 + self.metrics.gpuSeconds += max(0, completed.gpuEndTime - completed.gpuStartTime) + if completed.status == .error { self.metrics.errors += 1 } + } + // CAMetalDisplayLink owns presentation timing; presentAtTime is invalid for its drawables. cb.present(drawable) cb.commit() } diff --git a/Sources/VRWorkstation/main.swift b/Sources/VRWorkstation/main.swift @@ -1,5 +1,5 @@ import Foundation -import MetalKit +import Metal import simd setvbuf(stdout, nil, _IOLBF, 0) // Usage: VRWorkstation | --observe <seconds> | --snapshot out.png [--snapshot-size WxH] [--snapshot-yaw deg] @@ -29,10 +29,8 @@ if let i = CommandLine.arguments.firstIndex(of: "--snapshot") { let dims = (option("--snapshot-size") ?? "4320x2160").split(separator: "x").compactMap { Int($0) } let (w, h) = dims.count == 2 ? (dims[0], dims[1]) : (4320, 2160) let yaw = Float(option("--snapshot-yaw") ?? "0") ?? 0 - let view = MTKView(frame: .zero, device: MTLCreateSystemDefaultDevice()) - view.colorPixelFormat = .bgra8Unorm; view.depthStencilPixelFormat = .depth32Float do { - let r = try Renderer(view: view) + let r = try Renderer(device: MTLCreateSystemDefaultDevice()) r.headOrientation = { simd_quatf(angle: yaw * .pi / 180, axis: [0, 1, 0]) } try r.snapshot(width: w, height: h, time: 1.0, to: path) print("wrote", path) diff --git a/Tests/LinkRecoveryChecks.swift b/Tests/LinkRecoveryChecks.swift @@ -0,0 +1,63 @@ +// Run without touching hardware: +// swiftc Sources/VRWorkstation/{LinkRecovery,MainRunLoop}.swift Tests/LinkRecoveryChecks.swift -o /tmp/vr-recovery-checks-bin +// /tmp/vr-recovery-checks-bin +import Foundation + +func log(_ text: String) { print(text) } + +@main +struct LinkRecoveryChecks { + static func attempt(_ recovery: LinkRecovery) -> Bool { + var result: Bool? + recovery.reset { result = $0 } + let deadline = Date().addingTimeInterval(6) + while result == nil && Date() < deadline { + RunLoop.main.run(until: Date().addingTimeInterval(0.02)) + } + precondition(result != nil, "recovery did not complete within six seconds") + return result! + } + + static func main() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("vr-helper-check-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + func helper(_ name: String, _ body: String) throws -> URL { + let url = directory.appendingPathComponent(name) + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path) + return url + } + let ok = try helper("ok", "#!/bin/sh\nexit 0\n") + let limited = LinkRecovery(helper: ok) + precondition((0..<4).map { _ in attempt(limited) } == [true, true, true, false], + "helper must run at most three times per minute") + + let stuck = LinkRecovery(helper: try helper("stuck", "#!/bin/sh\nexec /bin/sleep 10\n")) + let start = Date() + precondition(!attempt(stuck), "a timed-out helper must fail") + let seconds = Date().timeIntervalSince(start) + precondition(seconds >= 2.9 && seconds < 5, "helper must be killed after about three seconds") + + let missing = LinkRecovery(helper: directory.appendingPathComponent("missing")) + precondition(!attempt(missing), "a missing helper must fail") + // A headset ADD must cancel startup recovery even before capture binds an ID. + let marker = directory.appendingPathComponent("ran") + let recording = try helper("recording", "#!/bin/sh\n/usr/bin/touch \(marker.path)\n") + let startup = LinkRecovery(helper: recording) + startup.requestRecovery() + startup.displayAdded(42) + Thread.sleep(forTimeInterval: 2.3) // deliberately do not pump main + precondition(!FileManager.default.fileExists(atPath: marker.path), "startup ADD must cancel reset") + startup.capturedDisplay(42) + startup.displayRemoved(42) + startup.displayAdded(43) + Thread.sleep(forTimeInterval: 2.3) + precondition(!FileManager.default.fileExists(atPath: marker.path), "changed display ID must cancel reset") + startup.displayRemoved(43) + Thread.sleep(forTimeInterval: 2.5) + precondition(FileManager.default.fileExists(atPath: marker.path), "recovery deadline must fire while main is blocked") + startup.stop() + print("PASS: three resets/minute; stuck helper stopped in \(seconds)s; missing helper rejected") + } +} diff --git a/tools/build.sh b/tools/build.sh @@ -0,0 +1,10 @@ +#!/bin/zsh +# Build the app and its narrow, entitled recovery helper together. +set -euo pipefail +cd "$(dirname "$0")/.." +swift build +vr_bin_dir=$(swift build --show-bin-path) +clang -Wall -Wextra -O2 -framework IOKit -framework CoreFoundation tools/hpmctl.c -o "$vr_bin_dir/hpmctl" +codesign --force --sign - --entitlements tools/hpmctl.entitlements "$vr_bin_dir/hpmctl" +print "App: $vr_bin_dir/VRWorkstation" +print "Recovery helper: $vr_bin_dir/hpmctl" diff --git a/tools/hpmctl.c b/tools/hpmctl.c @@ -32,7 +32,7 @@ typedef struct HPMInterface { 0xB7, 0xBE, 0xE0, 0x5C, 0xD8, 0x83, 0xC7, 0xB1) /// Finds the AppleHPM bus service whose child AppleHPMDevice has the given RID, and opens the plug-in on it. -static HPMInterface **openHPM(int rid) { +static HPMInterface **openHPM(int rid, int verifyPort) { io_iterator_t it; if (IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("AppleHPMDevice"), &it) != KERN_SUCCESS) return NULL; io_service_t dev, found = 0; @@ -40,7 +40,24 @@ static HPMInterface **openHPM(int rid) { CFNumberRef n = IORegistryEntryCreateCFProperty(dev, CFSTR("RID"), NULL, 0); int v = -1; if (n) { CFNumberGetValue(n, kCFNumberIntType, &v); CFRelease(n); } - if (v == rid) IORegistryEntryGetParentEntry(dev, kIOServicePlane, &found); // AppleHPMARMI2C + if (v == rid) { + int correctPort = !verifyPort; + io_iterator_t children; + if (verifyPort && IORegistryEntryGetChildIterator(dev, kIOServicePlane, &children) == KERN_SUCCESS) { + io_registry_entry_t child; + while ((child = IOIteratorNext(children))) { + io_string_t path; + if (IORegistryEntryGetPath(child, kIOServicePlane, path) == KERN_SUCCESS) { + const char *last = strrchr(path, '/'); + if (last && strcmp(last, "/Port-USB-C@2") == 0) correctPort = 1; + } + IOObjectRelease(child); + } + IOObjectRelease(children); + } + if (correctPort) IORegistryEntryGetParentEntry(dev, kIOServicePlane, &found); + else fprintf(stderr, "RID %d is not verified as Port-USB-C@2; refusing command\n", rid); + } IOObjectRelease(dev); } IOObjectRelease(it); @@ -59,17 +76,33 @@ static HPMInterface **openHPM(int rid) { } int main(int argc, char **argv) { - if (argc < 4) { fprintf(stderr, "usage: hpmctl <rid> read <reg> | hpmctl <rid> cmd <4cc>\n"); return 2; } - int rid = atoi(argv[1]); + if (argc < 3) { fprintf(stderr, "usage: hpmctl <rid> read <reg> | hpmctl <rid> cmd <4cc> | hpmctl 1 recover\n"); return 2; } + char *end; + long ridValue = strtol(argv[1], &end, 10); + if (!*argv[1] || *end || ridValue < 0 || ridValue > 1) { fprintf(stderr, "RID must be 0 or 1\n"); return 2; } + int rid = (int)ridValue; + if (strcmp(argv[2], "recover") == 0) { + if (rid != 1 || argc != 3) { fprintf(stderr, "recovery is restricted to RID 1 (DP adapter)\n"); return 2; } + HPMInterface **hpm = openHPM(rid, 1); + if (!hpm) return 1; + IOReturn r = (*hpm)->iecsCommand(hpm, 0, 0x44525354, 0); // BE numeric 'DRST' + uint8_t data[64] = {0}; + uint64_t got = 0; + if (r == kIOReturnSuccess) r = (*hpm)->iecsRead(hpm, 0, 0x09, data, sizeof data, 0, &got); + printf("rid 1 recover DRST: 0x%08x, task result: 0x%02x, bytes: %llu\n", r, data[0], got); + (*hpm)->Release(hpm); + return r == kIOReturnSuccess && got >= 1 && data[0] == 0 ? 0 : 1; + } + if (argc != 4) { fprintf(stderr, "read/cmd require one argument\n"); return 2; } if (strcmp(argv[2], "cmd") == 0) { - if (rid == 0) { fprintf(stderr, "refusing to send commands to RID 0 (hub + power)\n"); return 2; } + if (rid != 1) { fprintf(stderr, "refusing to send commands to RID 0 (hub + power)\n"); return 2; } if (strlen(argv[3]) != 4) { fprintf(stderr, "4cc must be 4 characters\n"); return 2; } // iecsCommand takes a C-style, big-endian numeric 4CC ('DRST' -> 0x44525354). // AppleHPM byte-swaps it before writing ASCII bytes to CMD1. Passing a little-endian // number sends a reversed command ("TSRD") and the controller responds "!CMD". uint32_t cc = (uint32_t)(unsigned char)argv[3][0] << 24 | (uint32_t)(unsigned char)argv[3][1] << 16 | (uint32_t)(unsigned char)argv[3][2] << 8 | (uint32_t)(unsigned char)argv[3][3]; - HPMInterface **hpm = openHPM(rid); + HPMInterface **hpm = openHPM(rid, 1); if (!hpm) return 1; IOReturn r = (*hpm)->iecsCommand(hpm, 0, cc, 0); printf("rid %d cmd '%s' (0x%08x): 0x%08x\n", rid, argv[3], cc, r); @@ -78,7 +111,7 @@ int main(int argc, char **argv) { } if (strcmp(argv[2], "read") != 0) { fprintf(stderr, "unknown op %s\n", argv[2]); return 2; } uint8_t reg = (uint8_t)strtoul(argv[3], NULL, 0); - HPMInterface **hpm = openHPM(rid); + HPMInterface **hpm = openHPM(rid, 0); if (!hpm) return 1; uint8_t buf[64] = {0}; uint64_t got = 0; diff --git a/tools/recovery_trials.py b/tools/recovery_trials.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Bounded hardware integration test: panel off/on, then DP recovery via DRST. + +Requires sole ownership of the headset and the built displaywatch, hidcmd and +signed hpmctl tools. Observes CoreGraphics callbacks; never polls port registers. +Leaves the headset on after each successful trial. Stops on the first failure. +""" +import argparse +import json +import queue +import re +import subprocess +import threading +import time +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--trials", type=int, default=3) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--app-recovery", action="store_true", help="wait for the running app to recover; do not issue DRST from this test") + parser.add_argument("--leave-off", action="store_true", help="let the app send screen-on as part of recovery") + parser.add_argument("--settle-seconds", type=float, default=10, help="observe this long after ADD, including any short self-healing drops") + args = parser.parse_args() + if not 1 <= args.trials <= 10: + parser.error("--trials must be between 1 and 10") + if not 0 <= args.settle_seconds <= 60: + parser.error("--settle-seconds must be between 0 and 60") + if args.leave_off and not args.app_recovery: + parser.error("--leave-off requires --app-recovery") + tools = Path(__file__).resolve().parent + events = queue.Queue() + report = {"started": time.time(), "app_recovery": args.app_recovery, "leave_off": args.leave_off, + "commands": [], "display_events": [], "trials": []} + watcher = subprocess.Popen([str(tools / "displaywatch")], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, bufsize=1) + + def read_events(): + for line in watcher.stdout: + events.put({"received": time.time(), "line": line.strip()}) + + threading.Thread(target=read_events, daemon=True).start() + + def command(name, *arguments): + record = {"started": time.time(), "argv": [name, *arguments]} + argv = [str(tools / name), *arguments] + if name == "hpmctl": + argv = ["sudo", "-n", *argv] + result = subprocess.run(argv, capture_output=True, text=True, timeout=20) + record.update(ended=time.time(), code=result.returncode, + stdout=result.stdout.strip(), stderr=result.stderr.strip()) + report["commands"].append(record) + if result.returncode or (name == "hidcmd" and result.stdout.split()[-1:] != ["0"]): + raise RuntimeError(f"command failed: {record}") + return record["started"] + + def await_display(online, timeout): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if watcher.poll() is not None: + raise RuntimeError("passive display watcher exited") + try: + event = events.get(timeout=min(0.2, max(0.001, deadline - time.monotonic()))) + except queue.Empty: + continue + report["display_events"].append(event) + if bool(re.search(r"\b\d+:8718\s", event["line"])) == online: + return event + return None + + try: + if not await_display(True, 3): + raise RuntimeError("headset display must be online before the first trial") + for index in range(args.trials): + trial = {"index": index + 1} + report["trials"].append(trial) + command("hidcmd", "off") + removed = await_display(False, 5) + if not removed: + raise RuntimeError("panel-off did not remove the display") + if not args.leave_off: + command("hidcmd", "on") + restored = await_display(True, 2) + if restored: + trial["outcome"] = "returned_after_screen_on_without_DRST" + else: + reset_at = time.time() if args.app_recovery else command("hpmctl", "1", "cmd", "DRST") + event = await_display(True, 15) + if not event: + raise RuntimeError("DRST did not restore the display within 15 seconds") + restored = event + trial.update(outcome="recovered_by_app" if args.app_recovery else "recovered_after_DRST", + seconds=event["received"] - reset_at) + trial["outage_seconds"] = restored["received"] - removed["received"] + settle_until = time.monotonic() + args.settle_seconds + trial["post_recovery_drops"] = [] + while time.monotonic() < settle_until: + removed = await_display(False, settle_until - time.monotonic()) + if removed: + restored = await_display(True, 15) + if not restored: + raise RuntimeError("post-recovery drop did not heal within 15 seconds") + trial["post_recovery_drops"].append(restored["received"] - removed["received"]) + print(json.dumps(trial), flush=True) + report["result"] = "passed" + except Exception as error: + report.update(result="failed", error=str(error)) + print(str(error), flush=True) + finally: + watcher.terminate() + try: + watcher.wait(timeout=3) + except subprocess.TimeoutExpired: + watcher.kill() + watcher.wait() + report["ended"] = time.time() + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2) + "\n") + return 0 if report["result"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main())