‹ 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 4754d7c8b9283a3e9dc250f22caaf1ec0f0aa59e
parent 4abf4713f3f0b0fbe06d4f0c9e30300e4f7cdf70
Author: umhau <umhau@example.invalid>
Date:   Wed, 21 Oct 2026 12:00:04 +0000

Document software recovery: BE DRST on port 2 restores the headset link; retract replug-only claims

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Diffstat:
MNOTES.md | 9+++++++++
MSources/VRWorkstation/App.swift | 87++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
MSources/VRWorkstation/HMD.swift | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
MSources/VRWorkstation/Renderer.swift | 4++--
MSources/VRWorkstation/main.swift | 18+++++++++++++++++-
Mtools/hpmctl.c | 7+++++--
Mtools/kernel/HPM_RE.md | 36++++++++++++++++++++++++++++++++++++
7 files changed, 212 insertions(+), 44 deletions(-)

diff --git a/NOTES.md b/NOTES.md @@ -22,3 +22,12 @@ From disassembling this machine's arm64 kernelcache (macOS 15.6.1): ## After reboot Run `tools/verify.sh`. Success = no REMOVE/ADD loop and the panel stays lit (check physically). + +## Link reliability (2026-10-20, vr1 + vr0) +- Periodic ~70-80 s drops were caused by polling `ioreg -c IOPortTransportStateDisplayPort` (1 Hz). Never poll it. +- Stuck state (headset display gone, adapter reports HPD=0 to the Mac): **recoverable in software** with a USB-PD + Data Reset on port 2's controller: `sudo tools/hpmctl 1 cmd DRST` (4CC passed big-endian, see + tools/kernel/HPM_RE.md section 8). First success: display back 2.2 s after the command, no replug. +- RETRACTED: "only a physical replug recovers it"; "deeper off state"; "HRST untested/unsupported" claims made + before the byte order was fixed. With correct encoding HRST is rejected by the firmware (!CMD), DRST works. +- HID screen_off (0x04 0x00) reliably induces the stuck state; screen_on / full wake alone do not undo it. diff --git a/Sources/VRWorkstation/App.swift b/Sources/VRWorkstation/App.swift @@ -26,45 +26,102 @@ func onlineHeadsetDisplay() -> CGDirectDisplayID? { func log(_ s: String) { print(String(format: "%.3f ", Date().timeIntervalSince1970) + s) } -final class HeadsetSession { +private let displayChanged: CGDisplayReconfigurationCallBack = { id, flags, ctx in + guard !flags.contains(.beginConfigurationFlag), let ctx else { return } + let session = Unmanaged<HeadsetSession>.fromOpaque(ctx).takeUnretainedValue() + DispatchQueue.main.async { + // 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) } + } +} + +final class HeadsetSession: NSObject, NSApplicationDelegate { let hmd = HMD() let renderer: Renderer let view: MTKView private var window: HeadsetWindow? private(set) var display: CGDirectDisplayID? private var lostAt: Date? + private var hidRetry: Timer? + private var healthTimer: Timer? + private var callbackRegistered = false + private var stopping = false - init() throws { - view = MTKView(frame: .zero, device: MTLCreateSystemDefaultDevice()) + 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 + super.init() } func start() { hmd.onStatus = { log("[hmd] \($0)") } hmd.onPanel = { [weak self] on in if !on { self?.panelOff() } } - do { try hmd.start() } catch { log("[hmd] \(error)") } + hmd.onDisconnected = { [weak self] in + guard let self, !self.stopping else { return } + log("[hmd] USB device removed; reopening when available") + self.hmd.stop() + self.scheduleHIDRetry() + } let me = Unmanaged.passUnretained(self).toOpaque() - CGDisplayRegisterReconfigurationCallback({ id, flags, ctx in - guard isHeadsetDisplay(id), !flags.contains(.beginConfigurationFlag) else { return } - let s = Unmanaged<HeadsetSession>.fromOpaque(ctx!).takeUnretainedValue() - if flags.contains(.removeFlag) { DispatchQueue.main.async { s.displayLost(id) } } - if flags.contains(.addFlag) { DispatchQueue.main.async { s.displayArrived(id) } } - }, me) + let result = CGDisplayRegisterReconfigurationCallback(displayChanged, me) + callbackRegistered = result == .success + if !callbackRegistered { log("display callback registration failed: \(result.rawValue)") } + startHID() + healthTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in + guard let self else { return } + 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)") + } if let id = onlineHeadsetDisplay() { displayArrived(id) } else { log("waiting for the headset display...") } } + private func startHID() { + guard !stopping else { return } + do { + try hmd.start(mode: .activate) + hidRetry?.invalidate(); hidRetry = nil + log("[hmd] wake commands accepted; waiting for IMU and video evidence") + } catch { log("[hmd] \(error.localizedDescription)"); scheduleHIDRetry() } + } + + private func scheduleHIDRetry() { + guard hidRetry == nil, !stopping else { return } + hidRetry = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in self?.startHID() } + } + + func applicationWillTerminate(_ notification: Notification) { stop() } + + func stop() { + stopping = true + hidRetry?.invalidate(); hidRetry = nil + healthTimer?.invalidate(); healthTimer = nil + 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 + } + /// Takes exclusive control of the headset display and puts the renderer on it. func displayArrived(_ id: CGDirectDisplayID) { + guard !stopping, display != id else { return } if let t = lostAt { log(String(format: "headset display back after %.0f ms", Date().timeIntervalSince(t) * 1000)) } lostAt = nil - display = id let r = CGDisplayCapture(id) log("captured display \(id): \(r == .success ? "ok" : "error \(r.rawValue)")") + guard r == .success else { return } + display = id setNativeMode(id) // A captured display is covered by the shielding window; ours must sit above it. @@ -87,14 +144,15 @@ final class HeadsetSession { lostAt = Date() display = nil view.isPaused = true + window?.orderOut(nil) + CGDisplayRelease(id) log("headset display LOST") recover() } func panelOff() { log("headset reports panel off") } - /// Recovery hook. The display normally returns by itself in under a second; when the USB-C DP adapter gets - /// stuck reporting HPD low, the fix is restarting port 2's DisplayPort alt-mode session (see NOTES.md). + /// 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") } @@ -117,7 +175,8 @@ func runApp() { let app = NSApplication.shared app.setActivationPolicy(.regular) let session: HeadsetSession - do { session = try HeadsetSession() } catch { print("renderer failed:", error); exit(1) } + do { session = try HeadsetSession(device: MTLCreateSystemDefaultDevice()) } catch { print("renderer failed:", error); exit(1) } + app.delegate = session session.start() app.activate(ignoringOtherApps: true) withExtendedLifetime(session) { app.run() } diff --git a/Sources/VRWorkstation/HMD.swift b/Sources/VRWorkstation/HMD.swift @@ -12,10 +12,15 @@ struct IMUSample { } 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). var onPanel: ((Bool) -> Void)? + var onDisconnected: (() -> Void)? + private(set) var imuSamples = 0 + private(set) var statusReports = 0 + private(set) var lastIMUAt: Date? private var managers: [IOHIDManager] = [] // must outlive the devices, or they get closed private var control: IOHIDDevice? @@ -28,43 +33,80 @@ final class HMD { var description: String { "\(name) not found (plugged in, and accessory allowed?)" } } - /// Opens both HID devices, runs the wake sequence and starts the IMU. Callbacks fire on the main run loop. - func start() throws { - control = try open(0x03f0, 0x0c6a, "QHMD control device") - sensors = try open(0x045e, 0x0659, "HoloLens Sensors") - let me = Unmanaged.passUnretained(self).toOpaque() - IOHIDDeviceRegisterInputReportCallback(control!, controlBuf, 64, { ctx, _, _, _, _, r, n in - Unmanaged<HMD>.fromOpaque(ctx!).takeUnretainedValue().handleControl(r, n) - }, me) - IOHIDDeviceRegisterInputReportCallback(sensors!, sensorsBuf, 512, { ctx, _, _, _, _, r, n in - Unmanaged<HMD>.fromOpaque(ctx!).takeUnretainedValue().handleSensors(r, n) - }, me) - for d in [control!, sensors!] { IOHIDDeviceScheduleWithRunLoop(d, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) } + /// 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 { + stop() + do { + control = try open(0x03f0, 0x0c6a, "QHMD control device") + sensors = try open(0x045e, 0x0659, "HoloLens Sensors") + let me = Unmanaged.passUnretained(self).toOpaque() + IOHIDDeviceRegisterInputReportCallback(control!, controlBuf, 64, { ctx, _, _, _, _, r, n in + Unmanaged<HMD>.fromOpaque(ctx!).takeUnretainedValue().handleControl(r, n) + }, me) + IOHIDDeviceRegisterInputReportCallback(sensors!, sensorsBuf, 512, { ctx, _, _, _, _, r, n in + Unmanaged<HMD>.fromOpaque(ctx!).takeUnretainedValue().handleSensors(r, n) + }, me) + for d in [control!, sensors!] { + IOHIDDeviceRegisterRemovalCallback(d, { ctx, _, _ in + let hmd = Unmanaged<HMD>.fromOpaque(ctx!).takeUnretainedValue() + // Leave the IOKit callback before closing its manager. + DispatchQueue.main.async { hmd.onDisconnected?() } + }, me) + IOHIDDeviceScheduleWithRunLoop(d, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) + } + + guard mode == .activate else { return } + usleep(300_000) + for _ in 0..<4 { + var c = [UInt8](repeating: 0, count: 64); c[0] = 0x50; c[1] = 0x01 + try setFeature(c); _ = getFeature(0x50); usleep(10_000) + } + _ = getFeature(0x09) // Preserve wake sequence; do not log device serials. + _ = getFeature(0x08); _ = getFeature(0x06) + try setFeature([0x04, 0x01]) // screen on + 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) } + } catch { + stop() + throw error + } + } - usleep(300_000) - for _ in 0..<4 { - var c = [UInt8](repeating: 0, count: 64); c[0] = 0x50; c[1] = 0x01 - setFeature(c); _ = getFeature(0x50); usleep(10_000) + /// Close host handles without sending panel-off, which can strand the DP link. + func stop() { + for d in [control, sensors].compactMap({ $0 }) { + IOHIDDeviceRegisterRemovalCallback(d, nil, nil) + IOHIDDeviceRegisterInputReportCallback(d, d == control ? controlBuf : sensorsBuf, d == control ? 64 : 512, nil, nil) + IOHIDDeviceUnscheduleFromRunLoop(d, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) } - _ = getFeature(0x09) // Preserve wake sequence; do not log device serials. - _ = getFeature(0x08); _ = getFeature(0x06) - setFeature([0x04, 0x01]) // screen on - var imuOn = [UInt8](repeating: 0, count: 64); imuOn[0] = 0x02; imuOn[1] = 0x07 - IOHIDDeviceSetReport(sensors!, kIOHIDReportTypeOutput, 0x02, imuOn, imuOn.count) + for m in managers { IOHIDManagerClose(m, IOOptionBits(kIOHIDOptionsTypeNone)) } + control = nil; sensors = nil; managers.removeAll() + lastIMUAt = nil + } + + deinit { + stop() + controlBuf.deallocate(); sensorsBuf.deallocate() } private func open(_ vid: Int, _ pid: Int, _ name: String) throws -> IOHIDDevice { let m = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) + managers.append(m) IOHIDManagerSetDeviceMatching(m, [kIOHIDVendorIDKey: vid, kIOHIDProductIDKey: pid] as CFDictionary) guard IOHIDManagerOpen(m, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess, let d = (IOHIDManagerCopyDevices(m) as? Set<IOHIDDevice>)?.first else { throw NotFound(name: name) } - managers.append(m) return d } - private func setFeature(_ b: [UInt8]) { + private func hidError(_ action: String, _ result: IOReturn) -> NSError { + NSError(domain: "HMD", code: Int(result), userInfo: [NSLocalizedDescriptionKey: String(format: "%@ failed: 0x%08x", action, result)]) + } + + private func setFeature(_ b: [UInt8]) throws { let r = IOHIDDeviceSetReport(control!, kIOHIDReportTypeFeature, CFIndex(b[0]), b, b.count) - if r != kIOReturnSuccess { onStatus?(String(format: "HID set 0x%02x failed: 0x%08x", b[0], r)) } + if r != kIOReturnSuccess { throw hidError(String(format: "HID set 0x%02x", b[0]), r) } } private func getFeature(_ id: UInt8) -> [UInt8]? { @@ -74,12 +116,14 @@ 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") onPanel?(p[1] == 1) } private func handleSensors(_ p: UnsafeMutablePointer<UInt8>, _ n: Int) { - guard p[0] == 0x01, n == 381 || n == 497 else { return } + guard (n == 381 || n == 497), p[0] == 0x01 else { return } + lastIMUAt = Date() func i16(_ o: Int) -> Int32 { Int32(Int16(bitPattern: UInt16(p[o]) | UInt16(p[o + 1]) << 8)) } func i32(_ o: Int) -> Int32 { Int32(bitPattern: UInt32(p[o]) | UInt32(p[o + 1]) << 8 | UInt32(p[o + 2]) << 16 | UInt32(p[o + 3]) << 24) } func u64(_ o: Int) -> UInt64 { (0..<8).reduce(0) { $0 | UInt64(p[o + $1]) << (8 * UInt64($1)) } } @@ -94,6 +138,7 @@ final class HMD { g[a] = Float(sum) * 0.001 / 8 acc[a] = Float(i32(accel + (a * 4 + i) * 4)) * 0.001 } + imuSamples += 1 onIMU?(IMUSample(time: Double(u64(gyroTS + i * 8)) * 100e-9, gyro: g, accel: acc)) } } diff --git a/Sources/VRWorkstation/Renderer.swift b/Sources/VRWorkstation/Renderer.swift @@ -46,10 +46,10 @@ final class Renderer: NSObject, MTKViewDelegate { var frames = 0 func draw(in view: MTKView) { - frames += 1 - if frames == 1 || frames % 300 == 0 { print("frame \(frames) drawable \(view.drawableSize)") } 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)) cb.present(drawable) cb.commit() diff --git a/Sources/VRWorkstation/main.swift b/Sources/VRWorkstation/main.swift @@ -2,12 +2,28 @@ import Foundation import MetalKit import simd setvbuf(stdout, nil, _IOLBF, 0) -// Usage: VRWorkstation | --snapshot out.png [--snapshot-size WxH] [--snapshot-yaw deg] +// Usage: VRWorkstation | --observe <seconds> | --snapshot out.png [--snapshot-size WxH] [--snapshot-yaw deg] // Options: --snapshot-size WxH (default 4320x2160), --snapshot-yaw <degrees> (head turn, positive = left). func option(_ name: String) -> String? { guard let i = CommandLine.arguments.firstIndex(of: name), CommandLine.arguments.count > i + 1 else { return nil } return CommandLine.arguments[i + 1] } +if CommandLine.arguments.contains("--observe") { + guard let value = option("--observe"), let seconds = Double(value), seconds > 0, seconds.isFinite else { + print("usage: VRWorkstation --observe <positive seconds>"); exit(2) + } + let hmd = HMD() + hmd.onStatus = { log("[observe] \($0)") } + hmd.onDisconnected = { log("[observe] USB device removed") } + do { + try hmd.start(mode: .observe) + log("[observe] listening for \(seconds)s; no HID commands or display capture") + RunLoop.main.run(until: Date().addingTimeInterval(seconds)) + log("[observe] IMU samples=\(hmd.imuSamples), status reports=\(hmd.statusReports), display=\(onlineHeadsetDisplay().map(String.init) ?? "absent")") + hmd.stop() + } catch { print("observation failed:", error); exit(1) } + exit(0) +} if let i = CommandLine.arguments.firstIndex(of: "--snapshot") { let path = CommandLine.arguments.count > i + 1 ? CommandLine.arguments[i + 1] : "snapshot.png" let dims = (option("--snapshot-size") ?? "4320x2160").split(separator: "x").compactMap { Int($0) } diff --git a/tools/hpmctl.c b/tools/hpmctl.c @@ -64,8 +64,11 @@ int main(int argc, char **argv) { if (strcmp(argv[2], "cmd") == 0) { if (rid == 0) { 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; } - // The driver takes the 4CC as its ASCII bytes in string order, little-endian ('DRST' -> 0x54535244). - uint32_t cc = (uint32_t)argv[3][0] | (uint32_t)argv[3][1] << 8 | (uint32_t)argv[3][2] << 16 | (uint32_t)argv[3][3] << 24; + // 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); if (!hpm) return 1; IOReturn r = (*hpm)->iecsCommand(hpm, 0, cc, 0); diff --git a/tools/kernel/HPM_RE.md b/tools/kernel/HPM_RE.md @@ -289,3 +289,39 @@ which have the least ambiguity. - Consequence: the 'Gaid'/'DRST' (vr1) and 'HRST' (vr0) attempts were sent LE, arrived byte-reversed ('diaG', 'TSRD', 'TSRH'), and !CMD in CMD1 is the chip rejecting those. They remain UNTESTED. - Flags bit0 (x3) gates a branch at 0x9b8c8b0 that skips a pre-check; use flags 0. + +### 8a. Endianness boundary, CONFIRMED on hardware (vr0, 2026-10-20 [time redacted]) + +| layer | 4CC representation | evidence | +|---|---|---| +| AppleHPMLib plugin `iecsCommand` (vtbl +0x38) | passes the u32 through unchanged as scalar #2 of selector 2 | plugin disasm 0x2614-0x2668 (vr1, vr0 independently) | +| AppleHPMUserClient selector 2 handler 0x9b4a044 | passes u32 unchanged to provider+0x8f0(route, cmd, flags, 5) | static (subagent) | +| provider+0x8f0 = 0x9b8c830 | **byte-swaps** (`rev w8,w23` @0x9b8c970) then writes 4 bytes to CMD1 (reg 0x08) | static (vr1); vtable slot -> 0x9b8c830 confirmed by vr0 chained-pointer scan (7 slots, PAC discriminator 0x191d matching the UC +0x8f0 call) | +| driver-internal helpers that store 4CC bytes to memory first (e.g. exitPDMode 'DRST' @0x9b5bbd4: `mov #0x5244; movk #0x5453` -> 0x54535244 stored LE) | little-endian ASCII in memory | static | + +**Rule for user space (selector 2): pass the C multichar big-endian value**, e.g. `'DRST'` = 0x44525354, +`'HRST'` = 0x48525354, `'Gaid'` = 0x47616964. Do NOT copy the little-endian constants seen in the +driver's internal helpers. + +Hardware results with corrected BE encoding (vr0): +- `DRST` 0x44525354 -> IOReturn 0x00000000 in 50 ms (140.855422). Accepted. +- `HRST` 0x48525354 -> 0xe00002c6 (126.592422). Rejected (reason TBD: see 0x9b8c830 return paths). +- All earlier LE attempts (Gaid, DRST by vr1; HRST by vr0) were byte-reversed on the wire. + +### 8b. Return codes of provider+0x8f0 = 0x9b8c830 (vr1, static, VERIFIED in disassembly) +- Pre-check: reads CMD1 (vtbl+0x8d0, reg 8); if a previous command is still pending it clears CMD1 first. +- Writes rev(cmd) to CMD1 (vtbl+0x8c0, reg 8, 4 bytes). +- flags bit1 set: sleep 100 us, return 0 (fire-and-forget). +- Otherwise polls CMD1 every 100 us (w0=0x64 delay), up to 1000 tries (flags bit0 clear) or 100000 (bit0 set): + - CMD1 == 0x444d4321 (LE u32 of "!CMD") -> returns 0xe00002d8 - 0x12 = **0xe00002c6 (kIOReturnBadMessageID)** + = firmware did not recognize the command. + - CMD1 == 0 -> returns 0 (accepted/completed). Task result is then in DATA1 (reg 0x09) byte 0. + - tries exhausted -> 0xe00002d8 - 2 = **0xe00002d6** (timeout). +So with correct BE encoding: HRST -> 0xe00002c6 means this CD3217 APP firmware does not implement HRST; +DRST -> 0 means it does. + +### 8c. SOFTWARE RECOVERY CONFIRMED (vr0 hardware, 2026-10-20) +Stuck state (adapter partner status HPD=0 since [time redacted], induced by HID screen_off) recovered by +`iecsCommand(route 0, 'DRST' = 0x44525354, flags 0)` on RID 1 (Port-USB-C@2): +returned 0 in 50 ms at 140.855422; CMD1 = 0, DATA1 byte0 = 0; display ADD 4320x2160@60 at +143.064422 (+2.209 s). No physical replug. Repeatability trials in progress (vr0).