‹ 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 5733747618335531bc5860334847cdf7f1b37874
parent 7feda948f29af1c3618c82d5020e993fa4a1168e
Author: umhau <umhau@example.invalid>
Date:   Wed, 21 Oct 2026 12:00:23 +0000

Render headset without exclusive capture or desktop focus interference

Diffstat:
MNOTES.md | 4++++
MSETUP.md | 6++++--
MSources/VRWorkstation/App.swift | 63++++++++++++++++++++++++++++++++++++++++++---------------------
MSources/VRWorkstation/LinkRecovery.swift | 2+-
MSources/VRWorkstation/OpticsControls.swift | 4+++-
MTests/LinkRecoveryChecks.swift | 4++--
Aevidence/desktop-coexistence.txt | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mevidence/helper-safety-checks.txt | 2+-
8 files changed, 136 insertions(+), 28 deletions(-)

diff --git a/NOTES.md b/NOTES.md @@ -146,3 +146,7 @@ The optical tuning shader passes float-coordinate comparisons including independ the end-to-end quantized sampling check. Screenshots show pre-compensated output before the physical lenses, so they cannot establish the blur/fringe that reaches a user's eye. Software rendering, optical compensation, and physical lens/eye placement are separate contributors; do not infer a hardware defect from the remaining residuals. + +## Desktop coexistence + +The demo uses a non-activating headset panel without exclusive display capture. Its session-only display layout places the headset to the right of other displays. The application does not change Dock preferences. Native 90 Hz rendering and clean shutdown were checked; see evidence/desktop-coexistence.txt. diff --git a/SETUP.md b/SETUP.md @@ -90,7 +90,7 @@ zsh tools/build.sh # builds the app + the signe - Calibrated rendering passed a 180 s trial: 89.996 presented fps over the settled health interval, no display drops, 0 GPU errors, approximately 5.97 ms GPU/frame, clean shutdown. See `evidence/calibrated-90hz-trial.json`. Software validation is complete; physical optical validation remains separate from software/model checks. -- The app captures the headset display (no desktop on it) and uses its native unscaled 4320x2160 @ 90 Hz mode. +- The app covers the headset with a non-activating borderless panel, without exclusive display capture, at native unscaled 4320x2160 @ 90 Hz. If macOS changes the mode by itself, the app restores 90 Hz (also checked every 10 s, CoreGraphics only). - Rendering runs on a dedicated "Headset Metal rendering" thread: a `CAMetalDisplayLink` on that thread's run loop hands each frame a ready drawable (no `nextDrawable` calls, nothing drawn on the main thread). The main thread only @@ -108,7 +108,7 @@ zsh tools/build.sh # builds the app + the signe - Forced-outage duration (display lost -> back): 14.4 s before the recovery queue, 4.4 s with it (= 2 s grace + ~2.2 s reset/retrain/re-add). Releasing the capture inside the callback did not help (CGDisplayRelease returns 1001 = kCGErrorIllegalArgument there; outages 4.6 s) and was removed. - - Every DRST is followed ~5.7 s later by a brief lane-sync blip (IRQ_HPD/INTERLANE retrain) that the app recaptures. + - Every DRST is followed ~5.7 s later by a brief lane-sync blip (IRQ_HPD/INTERLANE retrain) that the app handles automatically. - **Reliability milestone PASSED** (`evidence/verified-90hz-soak.json`, `evidence/verified-90hz-soak.txt`): no-fault soak at native 4320x2160 @ 90 Hz, exit 0, 649.965 s settled interval, 58,496 presented frames = 89.9987 fps, skipped +3, 0 GPU errors, no render-worker join timeout, zero DP resets. One 0.637 s self-heal at @@ -156,3 +156,5 @@ sharpness grid. Red correction and separate blue horizontal/vertical correction factory compensation. Changes save automatically to `~/Library/Application Support/VRWorkstation/optics-adjustments.json`. The factory reset button restores 100% for all three controls. Color controls are disabled for the green-only pattern. The floor now uses a filtered procedural grid to avoid subpixel line aliasing at distance. + +The headset uses a non-activating panel and is placed to the right of other displays for this session. The Mac controls remain a normal window with a Quit button. No Dock preferences are changed. diff --git a/Sources/VRWorkstation/App.swift b/Sources/VRWorkstation/App.swift @@ -1,4 +1,4 @@ -// The app owns the headset: it wakes it over HID, captures its display (no desktop, no other windows), +// The app wakes the headset over HID and covers its display with a non-activating surface, // 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). @@ -8,11 +8,10 @@ import AppKit import Metal import QuartzCore -final class HeadsetWindow: NSWindow { - override var canBecomeKey: Bool { true } - override func keyDown(with e: NSEvent) { - if e.charactersIgnoringModifiers == "q" || e.keyCode == 53 { NSApp.terminate(nil) } - } +/// A display surface, never the keyboard focus or an exclusive display owner. +final class HeadsetWindow: NSPanel { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } } /// The headset's display: vendor 0x220E (8718). @@ -33,7 +32,7 @@ private func isNativeHeadsetMode(_ mode: CGDisplayMode) -> Bool { 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 } + guard flags.contains(.removeFlag) || flags.contains(.addFlag) || flags.contains(.setModeFlag) || flags.contains(.movedFlag) else { return } let enteredAt = Date() log("[display callback] id=\(id) flags=\(flags.rawValue) main=\(Thread.isMainThread)") let session = Unmanaged<HeadsetSession>.fromOpaque(ctx).takeUnretainedValue() @@ -47,6 +46,7 @@ private let displayChanged: CGDisplayReconfigurationCallBack = { id, flags, ctx if flags.contains(.removeFlag) { session.displayLost(id) } if flags.contains(.addFlag), isHeadsetDisplay(id) { session.displayArrived(id) } if flags.contains(.setModeFlag) { session.displayModeChanged(id) } + if flags.contains(.movedFlag) { session.displayMoved(id) } } } @@ -155,25 +155,23 @@ final class HeadsetSession: NSObject, NSApplicationDelegate { hmd.stop() window?.orderOut(nil) opticsControls?.close(); opticsControls = nil - if let display { CGDisplayRelease(display) } display = nil } - /// Takes exclusive control of the headset display and puts the renderer on it. + /// Put the render surface on the headset without exclusive display capture. func displayArrived(_ id: CGDirectDisplayID) { 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 { recover(); return } + log("attaching renderer to headset display \(id)") display = id - recovery.capturedDisplay(id) + placeHeadsetToRight(id) + recovery.trackDisplay(id) recoveryTimer?.invalidate(); recoveryTimer = nil guard setNativeMode(id) else { - CGDisplayRelease(id); display = nil + display = nil log("cannot render at required native mode") recover() return @@ -181,6 +179,28 @@ final class HeadsetSession: NSObject, NSApplicationDelegate { configureOutput(id) } + /// Keep the Mac's left desktop edge available for its left-positioned Dock. + private func placeHeadsetToRight(_ id: CGDirectDisplayID) { + var ids = [CGDirectDisplayID](repeating: 0, count: 16), count: UInt32 = 0 + guard CGGetOnlineDisplayList(16, &ids, &count) == .success else { return } + let other = ids.prefix(Int(count)).filter { $0 != id } + guard let right = other.map({ CGDisplayBounds($0).maxX }).max() else { return } + let y = CGDisplayBounds(CGMainDisplayID()).minY + let current = CGDisplayBounds(id) + guard current.minX != right || current.minY != y else { return } + var configuration: CGDisplayConfigRef? + guard CGBeginDisplayConfiguration(&configuration) == .success, let configuration else { return } + let moved = CGConfigureDisplayOrigin(configuration, id, Int32(right), Int32(y)) + guard moved == .success else { CGCancelDisplayConfiguration(configuration); return } + let result = CGCompleteDisplayConfiguration(configuration, .forSession) + log("headset desktop placement to right: \(result.rawValue)") + } + + func displayMoved(_ id: CGDirectDisplayID) { + guard display == id, !stopping else { return } + configureOutput(id) + } + func displayModeChanged(_ id: CGDirectDisplayID) { guard !stopping, display == id, let current = CGDisplayCopyDisplayMode(id) else { return } let changed = !isNativeHeadsetMode(current) @@ -198,16 +218,18 @@ final class HeadsetSession: NSObject, NSApplicationDelegate { 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. + // Keep this surface above the headset desktop without taking over macOS display capture. let bounds = CGDisplayBounds(id) let mainH = CGDisplayBounds(CGMainDisplayID()).height let frame = NSRect(x: bounds.minX, y: mainH - bounds.maxY, width: bounds.width, height: bounds.height) - let win = window ?? HeadsetWindow(contentRect: frame, styleMask: .borderless, backing: .buffered, defer: false) - win.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()) + 1) - win.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] + let win = window ?? HeadsetWindow(contentRect: frame, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) + win.level = .statusBar + win.hidesOnDeactivate = false + win.ignoresMouseEvents = true + win.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary] win.contentView = view win.setFrame(frame, display: true) - win.makeKeyAndOrderFront(nil) + win.orderFrontRegardless() window = win view.metalLayer.drawableSize = CGSize(width: 4320, height: 2160) outputConfigured = true @@ -222,7 +244,6 @@ final class HeadsetSession: NSObject, NSApplicationDelegate { outputConfigured = false renderLoop.setPaused(true) window?.orderOut(nil) - CGDisplayRelease(id) log("headset display LOST") recover() } @@ -247,7 +268,7 @@ final class HeadsetSession: NSObject, NSApplicationDelegate { 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") + log("headset display online; retrying headset attachment without a DP reset") self.displayArrived(id) } else { self.recovery.requestRecovery() } } diff --git a/Sources/VRWorkstation/LinkRecovery.swift b/Sources/VRWorkstation/LinkRecovery.swift @@ -19,7 +19,7 @@ final class LinkRecovery { self.helper = helper; self.wake = wake } - func capturedDisplay(_ id: UInt32) { + func trackDisplay(_ id: UInt32) { queue.async { self.target = id; self.cancelWaiting() } } diff --git a/Sources/VRWorkstation/OpticsControls.swift b/Sources/VRWorkstation/OpticsControls.swift @@ -68,7 +68,9 @@ final class OpticsControls: NSObject { } stack.addArrangedSubview(NSTextField(labelWithString: "Color correction: 0% = off, 100% = factory")) let reset = NSButton(title: "Reset colors to factory", target: self, action: #selector(resetColors)) - stack.addArrangedSubview(reset) + let buttons = NSStackView(views: [reset, NSButton(title: "Quit", target: NSApp, action: #selector(NSApplication.terminate(_:)))]) + buttons.spacing = 12 + stack.addArrangedSubview(buttons) let settings = renderer.opticalSettings() pattern.selectItem(at: settings.pattern.rawValue) red.floatValue = settings.redGain diff --git a/Tests/LinkRecoveryChecks.swift b/Tests/LinkRecoveryChecks.swift @@ -41,7 +41,7 @@ struct LinkRecoveryChecks { 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. + // A headset ADD must cancel startup recovery even before the session 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) @@ -49,7 +49,7 @@ struct LinkRecoveryChecks { 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.trackDisplay(42) startup.displayRemoved(42) startup.displayAdded(43) Thread.sleep(forTimeInterval: 2.3) diff --git a/evidence/desktop-coexistence.txt b/evidence/desktop-coexistence.txt @@ -0,0 +1,79 @@ +8711.769422 [hmd] serial REDACTED-DEVICE-SERIAL +8711.871422 [hmd] wake commands accepted; waiting for IMU and video evidence +8711.872422 attaching renderer to headset display 2 +8712.151422 [display callback] id=2 flags=4098 main=true +8712.152422 headset desktop placement to right: 0 +8712.724422 [display callback] id=2 flags=8 main=true +8712.726422 mode 4320x2160@90 (ui 4320x2160): ok +8712.750422 rendering on headset (4320.0, 2160.0), drawable (4320.0, 2160.0); dedicated Metal display loop at 90 Hz +8712.755422 render worker main=false +frame 1 drawable 4320x2160 +8712.848422 [display callback] handled after 697.5 ms +8712.849422 rendering on headset (4320.0, 2160.0), drawable (4320.0, 2160.0); dedicated Metal display loop at 90 Hz +8712.849422 [display callback] handled after 124.5 ms +8712.868422 [hmd] display ready=false; raw 05 00 01 01 00 3b 00 00 00 0a 27 +8712.868422 headset reports display not ready +8714.042422 [hmd] display ready=true; raw 05 01 01 01 01 3b 00 00 00 0a 27 +frame 300 drawable 4320x2160 +frame 600 drawable 4320x2160 +8721.873422 health display=2 submitted=815 presented=807 skipped=5 fps=77.09 presentAge=0.00s GPUms=6.53 GPUerrors=0 IMU=9188 lastIMU=0.0s status=2 +frame 900 drawable 4320x2160 +frame 1200 drawable 4320x2160 +frame 1500 drawable 4320x2160 +8731.871422 health display=2 submitted=1715 presented=1706 skipped=6 fps=89.91 presentAge=0.00s GPUms=6.57 GPUerrors=0 IMU=19188 lastIMU=0.0s status=2 +frame 1800 drawable 4320x2160 +frame 2100 drawable 4320x2160 +frame 2400 drawable 4320x2160 +8741.872422 health display=2 submitted=2615 presented=2603 skipped=9 fps=89.69 presentAge=0.00s GPUms=6.56 GPUerrors=0 IMU=29192 lastIMU=0.0s status=2 +frame 2700 drawable 4320x2160 +frame 3000 drawable 4320x2160 +frame 3300 drawable 4320x2160 +8751.872422 health display=2 submitted=3515 presented=3503 skipped=9 fps=90.00 presentAge=0.00s GPUms=6.57 GPUerrors=0 IMU=39196 lastIMU=0.0s status=2 +frame 3600 drawable 4320x2160 +frame 3900 drawable 4320x2160 +frame 4200 drawable 4320x2160 +8761.872422 health display=2 submitted=4415 presented=4403 skipped=9 fps=90.00 presentAge=0.00s GPUms=6.58 GPUerrors=0 IMU=49196 lastIMU=0.0s status=2 +frame 4500 drawable 4320x2160 +frame 4800 drawable 4320x2160 +frame 5100 drawable 4320x2160 +8771.871422 health display=2 submitted=5314 presented=5295 skipped=16 fps=89.20 presentAge=0.00s GPUms=6.58 GPUerrors=0 IMU=59200 lastIMU=0.0s status=2 +frame 5400 drawable 4320x2160 +frame 5700 drawable 4320x2160 +frame 6000 drawable 4320x2160 +8781.871422 health display=2 submitted=6214 presented=6195 skipped=16 fps=90.00 presentAge=0.00s GPUms=6.58 GPUerrors=0 IMU=69204 lastIMU=0.0s status=2 +frame 6300 drawable 4320x2160 +frame 6600 drawable 4320x2160 +frame 6900 drawable 4320x2160 +8791.871422 health display=2 submitted=7114 presented=7095 skipped=16 fps=90.00 presentAge=0.00s GPUms=6.58 GPUerrors=0 IMU=79204 lastIMU=0.0s status=2 +frame 7200 drawable 4320x2160 +frame 7500 drawable 4320x2160 +frame 7800 drawable 4320x2160 +8801.871422 health display=2 submitted=8014 presented=7995 skipped=16 fps=90.00 presentAge=0.00s GPUms=6.57 GPUerrors=0 IMU=89208 lastIMU=0.0s status=2 +frame 8100 drawable 4320x2160 +frame 8400 drawable 4320x2160 +frame 8700 drawable 4320x2160 +8811.871422 health display=2 submitted=8914 presented=8890 skipped=21 fps=89.50 presentAge=0.00s GPUms=6.56 GPUerrors=0 IMU=99208 lastIMU=0.0s status=2 +frame 9000 drawable 4320x2160 +frame 9300 drawable 4320x2160 +frame 9600 drawable 4320x2160 +8821.871422 health display=2 submitted=9814 presented=9790 skipped=21 fps=90.00 presentAge=0.01s GPUms=6.56 GPUerrors=0 IMU=109212 lastIMU=0.0s status=2 +frame 9900 drawable 4320x2160 +frame 10200 drawable 4320x2160 +frame 10500 drawable 4320x2160 +8831.871422 health display=2 submitted=10714 presented=10689 skipped=22 fps=89.90 presentAge=0.01s GPUms=6.56 GPUerrors=0 IMU=119216 lastIMU=0.0s status=2 +frame 10800 drawable 4320x2160 +frame 11100 drawable 4320x2160 +frame 11400 drawable 4320x2160 +8841.869422 health display=2 submitted=11614 presented=11588 skipped=23 fps=89.91 presentAge=0.00s GPUms=6.56 GPUerrors=0 IMU=129216 lastIMU=0.0s status=2 +frame 11700 drawable 4320x2160 +frame 12000 drawable 4320x2160 +frame 12300 drawable 4320x2160 +8851.870422 health display=2 submitted=12514 presented=12487 skipped=24 fps=89.89 presentAge=0.01s GPUms=6.56 GPUerrors=0 IMU=139220 lastIMU=0.0s status=2 +frame 12600 drawable 4320x2160 +frame 12900 drawable 4320x2160 +frame 13200 drawable 4320x2160 +8861.870422 health display=2 submitted=13414 presented=13387 skipped=24 fps=90.00 presentAge=0.01s GPUms=6.56 GPUerrors=0 IMU=149220 lastIMU=0.0s status=2 +frame 13500 drawable 4320x2160 +frame 13800 drawable 4320x2160 +frame 14100 drawable 4320x2160 +8871.870422 health display=2 submitted=14314 presented=14286 skipped=24 fps=89.90 presentAge=0.01s GPUms=6.56 GPUerrors=0 IMU=159224 lastIMU=0.0s status=2 diff --git a/evidence/helper-safety-checks.txt b/evidence/helper-safety-checks.txt @@ -7,4 +7,4 @@ recovery helper failed (exit 9) recovery helper missing: run tools/build.sh (expected /tmp/REDACTED-TEST-DIRECTORY) recovery: display absent after grace period; resetting DP adapter recovery helper accepted DRST; waiting for display ADD -PASS: three resets/minute; stuck helper stopped in 3.0318520069122314s; missing helper rejected +PASS: three resets/minute; stuck helper stopped in 3.0193029642105103s; missing helper rejected