‹ 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 953367e42d9f600488269c20d5d32ca592ed3cc1
parent c265ec7a54fac4ebb3b87718c6fb9cd535c868e0
Author: umhau <umhau@example.invalid>
Date:   Wed, 21 Oct 2026 12:00:02 +0000

App owns the headset: display capture at 4320x2160@60, passive health watch; hpmctl cmd mode; HPM RE notes

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

Diffstat:
MSources/VRWorkstation/App.swift | 144++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
MSources/VRWorkstation/HMD.swift | 6+++++-
MSources/VRWorkstation/main.swift | 4++--
Mtools/hpmctl.c | 17++++++++++++++++-
Atools/hpmctl.entitlements | 5+++++
Atools/kernel/HPM_RE.md | 268+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 403 insertions(+), 41 deletions(-)

diff --git a/Sources/VRWorkstation/App.swift b/Sources/VRWorkstation/App.swift @@ -1,4 +1,9 @@ -// Fullscreen window on the headset display running the stereo renderer. +// 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. +// +// 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. import AppKit import MetalKit @@ -9,46 +14,111 @@ final class HeadsetWindow: NSWindow { } } -/// The headset shows up as an external display from vendor 0x220E (8718). -func findHeadsetScreen() -> NSScreen? { - NSScreen.screens.first { s in - let id = (s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0 - return CGDisplayVendorNumber(id) == 8718 - } +/// The headset's display: vendor 0x220E (8718). +func isHeadsetDisplay(_ id: CGDirectDisplayID) -> Bool { CGDisplayVendorNumber(id) == 8718 } + +func onlineHeadsetDisplay() -> CGDirectDisplayID? { + var ids = [CGDirectDisplayID](repeating: 0, count: 16) + var n: UInt32 = 0 + CGGetOnlineDisplayList(16, &ids, &n) + return ids.prefix(Int(n)).first(where: isHeadsetDisplay) } -func runApp(windowed: Bool) { - let app = NSApplication.shared - app.setActivationPolicy(.regular) +func log(_ s: String) { print(String(format: "%.3f ", Date().timeIntervalSince1970) + s) } + +final class HeadsetSession { + let hmd = HMD() + let renderer: Renderer + let view: MTKView + private var window: HeadsetWindow? + private(set) var display: CGDirectDisplayID? + private var lostAt: Date? - // Wake the headset only if its display isn't up yet (e.g. tools/hmdwake already running). - var hmd: HMD? - if !windowed && findHeadsetScreen() == nil { - let h = HMD() - h.onStatus = { print("[hmd]", $0) } - do { try h.start(); hmd = h; print("[hmd] woke headset, waiting for its display...") } - catch { print("[hmd] not available:", error) } - let deadline = Date().addingTimeInterval(10) - while findHeadsetScreen() == nil && Date() < deadline { RunLoop.main.run(until: Date().addingTimeInterval(0.25)) } + init() throws { + view = MTKView(frame: .zero, device: MTLCreateSystemDefaultDevice()) + view.colorPixelFormat = .bgra8Unorm + view.depthStencilPixelFormat = .depth32Float + view.preferredFramesPerSecond = 60 + renderer = try Renderer(view: view) + view.delegate = renderer } - let screen = windowed ? nil : findHeadsetScreen() - if screen == nil && !windowed { print("Headset display not found; showing a preview window instead.") } - let frame = screen?.frame ?? NSRect(x: 100, y: 100, width: 1080, height: 540) - let win = HeadsetWindow(contentRect: frame, styleMask: screen == nil ? [.titled, .closable, .resizable] : .borderless, - backing: .buffered, defer: false, screen: screen) - win.title = "VR Workstation preview" - let view = MTKView(frame: NSRect(origin: .zero, size: frame.size), device: MTLCreateSystemDefaultDevice()) - view.colorPixelFormat = .bgra8Unorm - view.depthStencilPixelFormat = .depth32Float - view.preferredFramesPerSecond = 90 - let renderer: Renderer - do { renderer = try Renderer(view: view) } catch { print("Renderer failed:", error); exit(1) } - view.delegate = renderer - win.contentView = view - if screen != nil { win.level = .screenSaver; win.setFrame(frame, display: true) } - win.makeKeyAndOrderFront(nil) + 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)") } + + 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) + if let id = onlineHeadsetDisplay() { displayArrived(id) } else { log("waiting for the headset display...") } + } + + /// Takes exclusive control of the headset display and puts the renderer on it. + func displayArrived(_ id: CGDirectDisplayID) { + 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)")") + setNativeMode(id) + + // A captured display is covered by the shielding window; ours must sit above it. + 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] + win.contentView = view + win.setFrame(frame, display: true) + win.makeKeyAndOrderFront(nil) + window = win + view.isPaused = false + log("rendering on headset \(frame.size), drawable \(view.drawableSize)") + } + + func displayLost(_ id: CGDirectDisplayID) { + guard id == display else { return } + lostAt = Date() + display = nil + view.isPaused = true + 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). + func recover() { + log("recovery: waiting for the display to return") + } + + /// 4320x2160 pixels at 60 Hz, unscaled. + private func setNativeMode(_ id: CGDirectDisplayID) { + 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 + } + if CGDisplayCopyDisplayMode(id)?.ioDisplayModeID == m.ioDisplayModeID { return } + let r = CGDisplaySetDisplayMode(id, m, nil) + log("mode 4320x2160@60 (ui \(m.width)x\(m.height)): \(r == .success ? "ok" : "error \(r.rawValue)")") + } +} + +func runApp() { + let app = NSApplication.shared + app.setActivationPolicy(.regular) + let session: HeadsetSession + do { session = try HeadsetSession() } catch { print("renderer failed:", error); exit(1) } + session.start() app.activate(ignoringOtherApps: true) - print("Rendering on \(screen?.localizedName ?? "preview window") \(frame.size), drawable \(view.drawableSize)") - withExtendedLifetime((hmd, renderer)) { app.run() } + withExtendedLifetime(session) { app.run() } } diff --git a/Sources/VRWorkstation/HMD.swift b/Sources/VRWorkstation/HMD.swift @@ -14,6 +14,8 @@ struct IMUSample { final class HMD { var onIMU: ((IMUSample) -> Void)? var onStatus: ((String) -> Void)? + /// Panel power state from the headset's 0x05 status report (true = lit). + var onPanel: ((Bool) -> Void)? private var managers: [IOHIDManager] = [] // must outlive the devices, or they get closed private var control: IOHIDDevice? @@ -71,7 +73,9 @@ final class HMD { } private func handleControl(_ p: UnsafeMutablePointer<UInt8>, _ n: Int) { - if n >= 6 && p[0] == 0x05 { onStatus?("panel \(p[1] == 1 ? "on" : "off"), measured refresh \(p[5] == 0 ? 0 : Int(p[5]) + 1) Hz") } + guard n >= 6 && p[0] == 0x05 else { return } + 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) { diff --git a/Sources/VRWorkstation/main.swift b/Sources/VRWorkstation/main.swift @@ -2,7 +2,7 @@ import Foundation import MetalKit import simd setvbuf(stdout, nil, _IOLBF, 0) -// Usage: VRWorkstation [--windowed] | --snapshot out.png [--snapshot-size WxH] [--snapshot-yaw deg] +// Usage: VRWorkstation | --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 } @@ -23,4 +23,4 @@ if let i = CommandLine.arguments.firstIndex(of: "--snapshot") { } catch { print("snapshot failed:", error) } exit(0) } -runApp(windowed: CommandLine.arguments.contains("--windowed")) +runApp() diff --git a/tools/hpmctl.c b/tools/hpmctl.c @@ -2,6 +2,8 @@ // Apple's AppleHPMLib IOKit plug-in, the same path /usr/bin/hpmdiagnose uses. // // Usage: hpmctl <rid> read <reg> read one controller register (hex), e.g. `hpmctl 1 read 0x58` +// hpmctl <rid> cmd <4cc> run a 4-char controller command, e.g. `hpmctl 1 cmd DRST` (USB-PD Data Reset) +// RID 0 is refused: it carries the hub, the headset's USB and the Mac's power. // // RID 0 = Port-USB-C@1 (hub, headset USB), RID 1 = Port-USB-C@2 (DP adapter) on this MacBook Air. // Plug-in function table layout (from dyld_info fixups of AppleHPMLib, macOS 15.6.1): @@ -57,8 +59,21 @@ static HPMInterface **openHPM(int rid) { } int main(int argc, char **argv) { - if (argc < 4 || strcmp(argv[2], "read") != 0) { fprintf(stderr, "usage: hpmctl <rid> read <reg>\n"); return 2; } + if (argc < 4) { fprintf(stderr, "usage: hpmctl <rid> read <reg> | hpmctl <rid> cmd <4cc>\n"); return 2; } int rid = atoi(argv[1]); + 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; + HPMInterface **hpm = openHPM(rid); + 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); + (*hpm)->Release(hpm); + return r == kIOReturnSuccess ? 0 : 1; + } + 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); if (!hpm) return 1; diff --git a/tools/hpmctl.entitlements b/tools/hpmctl.entitlements @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"><dict> + <key>com.apple.USBCEntitlement</key><true/> +</dict></plist> diff --git a/tools/kernel/HPM_RE.md b/tools/kernel/HPM_RE.md @@ -0,0 +1,268 @@ +# HPM / USB-C "Ace" (TI CD3217 / TPS6598x) DisplayPort alt-mode restart — RE notes + +Static analysis only. No hardware was touched. Target: decompressed arm64e fileset +kernelcache macOS 15.6.1 (24G90), `tools/kernel/kernelcache.macho`. + +Fixup base (for chained ptrs): `BASE = 0xfffffe0007004000`; decoded pointer = +`BASE + (raw & 0xffffffff)`. Helper scripts used live in the session scratchpad; +all addresses below are runtime VAs. + +Kext text ranges: +- `com.apple.driver.AppleHPM` `__TEXT_EXEC` = `0xfffffe0009b3d8c0` .. `+0x51f00` +- `com.apple.iokit.IOAccessoryManager` `__TEXT_EXEC` = `0xfffffe000aba2ab0` .. `+0xf2d9c` +- `com.apple.iokit.IOPortFamily` `__TEXT_EXEC` = **0x84 bytes (stub)** — all logic lives in IOAccessoryManager. + +--- + +## 0. TL;DR — ranked recipes (gentlest/fastest → most drastic) + +All go through `AppleHPMLib.plugin` on the **AppleHPMDevice** node for Port-USB-C@2 +(RID 1), i.e. the same connection you already use for `iecsRead`. Opening the user +client requires **root AND the `com.apple.USBCEntitlement` entitlement** (see §1); +`hpmdiagnose` has that entitlement, your own tool must be signed with it or injected +some other way. + +`route` = 0 (the controller's own port, as you already use). 4CCs are **little-endian +ASCII u32**. + +| # | Action | Call (plugin vtable → kernel selector) | Confidence it restarts DP | +|---|--------|----------------------------------------|---------------------------| +| 1 | **Re-send DP Status / re-assert HPD** by replaying the controller's own DP-status VDM to the partner | `sendVDM` (vtable +0x40 → **selector 3**), SOP=port-partner, payload = DP VDM (see §4) | Medium — mirrors the driver's own `sendDpStatusUpdates`; cheapest, no teardown | +| 2 | **DP alt-mode Exit then Enter** (true "replug" of just the alt mode) | two `sendVDM` calls: Exit Mode VDM then Enter Mode VDM, SVID 0xFF01 (§4) | Medium-High — this is the literal software unplug/replug of DP | +| 3 | **'DRST' USB-PD Data Reset** (renegotiate PD + re-enter all alt modes) | `iecsCommand` (vtable +0x38 → **selector 2**), cmd=`'DRST'`=0x54535244, flags=0 — or write it raw via `iecsWrite` (§3) | Medium-High — this is exactly what the driver's `exitPDMode` issues (§4) | +| 4 | **Force port re-evaluation / re-run mode logic** (driver re-runs `configureDP`) | `forceMode` (+0x58 → **selector 6**) or `forceUpdateMode` (+0x60 → **selector 7**) | Medium — re-drives mode flags, may re-configure DP | +| 5 | **Raw host-interface VDM**: write DATA1(0x09)+CMD1(0x08)='VDMs' yourself | `iecsWrite` (+0x30 → **selector 1**) ×2 (§3) | Medium — most control, you supply exact VDM bytes | +| 6 | **Full controller reset 'Gaid'/'GAID'** = reboot the Ace = hard replug of the whole port | `iecsCommand` selector 2, cmd=`'Gaid'`=0x64696147 (or `'GAID'`=0x44494147), flags=0 | High that it replugs, but **drastic/slow**: drops USB + power + DP on that port, ~seconds | + +Notes that de-risk this: +- **There is NO 4CC allow/deny list in the kernel AppleHPM user client.** Selector 2 + passes `(route, cmd, flags)` straight through to the provider (§2). I scanned every + `IOReturn` construction in AppleHPM: **`kIOReturnBadMessageID` (0xe00002c6) is never + built anywhere in the kext.** So the `0xe00002c6` you got for `'Gaid'` did **not** + come from a kernel 4CC filter — it came from the plugin's own userspace wrapper or + from calling it on the wrong node (you noted you ran it against the `AppleHPMARMI2C` + parent, whose user client is a different class). Re-try 4CC commands on the + **AppleHPMDevice** node, the same one where `iecsRead` works. +- Start gentle (#1/#2) and keep #6 as the fallback you already wanted. + +--- + +## 1. Access control (who may open the user client) + +`AppleHPMUserClient::initWithTask` @ **0xfffffe0009b49570**: + +``` +0x…495b4 x1="root" ; IOUserClient::clientHasPrivilege(task,"root") +0x…495bc bl clientHasPrivilege +0x…495c8 x1="com.apple.USBCEntitlement" ; copyClientEntitlement(task, …) +0x…495d0 bl copyClientEntitlement +0x…495e8 cmp x0, kOSBooleanTrue ; entitlement must == true +… requires BOTH → else logs and returns 0 (open fails) +``` +Strings: `com.apple.USBCEntitlement` @0x47b6bb, "does NOT have +kIOClientPrivilegeAdministrator" @0x47b6d5, "does NOT have entitlement…" @0x47b733. + +**Requirement: run as root *and* ship `com.apple.USBCEntitlement` (= true).** No +per-command privilege check beyond this. + +--- + +## 2. AppleHPMUserClient external-method dispatch + +Dispatch is **not** an `IOExternalMethodDispatch` array — it is a +`getTargetAndMethodForIndex`-style switch @ **0xfffffe0009b497dc** (vtable idx 272) +that tail-calls one of the class methods by vtable offset. Argument-count validation +is a shared helper at UC vtable **+0x990** (`0xfffffe0009b49b10`, +`checkArguments(args, scalarIn, structIn, scalarOut, structOut)`) that returns +**`kIOReturnBadArgument` 0xe00002c2** on count mismatch (reads +`IOExternalMethodArguments` fields at +0x28 scalarInCnt, +0x38/+0x40 structIn, ++0x50 scalarOutCnt, +0x60/+0x68 structOut). + +Selector map (selector → UC handler → forwards to provider vtable offset): + +| sel | plugin vtbl | name | handler | checkArguments | forwards to provider | +|----:|-------------|------|---------|----------------|----------------------| +| 0 | +0x28 | iecsRead | 0x9b49c24 | struct-out path | reg read | +| 1 | +0x30 | **iecsWrite** | 0x9b49e3c | scalarIn **5** | provider +0x218 (mem) then write | +| 2 | +0x38 | **iecsCommand** | 0x9b4a044 | scalarIn **3** | **provider +0x8f0**(route,cmd,flags,5) | +| 3 | +0x40 | **sendVDM** | 0x9b4a118 | scalarIn **5** | **provider +0x990**(route,SOP,md,len,flags) | +| 4 | +0x48 | receiveVDM | 0x9b4a314 | — | provider +0x998-ish | +| 5 | +0x50 | receiveVDMAttention | 0x9b4a540 | — | — | +| 6 | +0x58 | **forceMode** | 0x9b4a76c | scalarIn **2** | provider +0x938(mode) | +| 7 | +0x60 | **forceUpdateMode** | 0x9b4a834 | scalarIn **4** | provider +0x940 | +| 8 | +0x68 | IECSAtomicCommand | 0x9b4a908 | scalarIn **2** | provider +0x948(route,u8) | +| 9 | +0x70 | EnableOptions | 0x9b4a9d4 | scalarIn **0xb** | up to 4 copyin buffers | +| 10| +0x78 | (reserved) | 0x9b4b0e4 | — | — | +| default | | | | | **kIOReturnUnsupported 0xe00002c7** @0x9b49a88 | + +Provider object = UC+0xe0, set in `start` @0x9b49660: casts the IOService provider to +the required metaclass (GOT `0xfffffe0007f7fcd0` → runtime metaclass `0xc334688`; name +is runtime-initialized, this is the **AppleHPMDevice** RID node), then calls +`provider->vtbl[+0x6b0]()` to get the command target and stores it at +0xe0. Per-call +lock at UC+0xe8 via `IOLockLock`=0x8f1dafc / `IOLockUnlock`=0x8f1da7c. copyin helper = +`0x8f3decc`. + +### Exact ABIs (all `IOConnectCallScalarMethod`, selector as above) + +- **iecsRead (sel 0):** as you use it — `(route, reg u8, …)`, data returned in the + struct-output buffer + `outLen`. +- **iecsWrite (sel 1):** scalars `[route u32, reg u8, len, userBufPtr, flags]`. + Handler (0x9b49e3c): `len` must be **≤ 0x40 (64)**; copies `len` bytes from the user + buffer and writes them to register `reg` on `route`. **This is the raw host-interface + write** — lets you poke CMD1 (0x08) / DATA1 (0x09) directly. +- **iecsCommand (sel 2):** scalars `[route u32, cmd u32(4CC), flags u32]`. Handler + (0x9b4a044) only validates "3 scalars" then calls `provider+0x8f0(route,cmd,flags,5)`. + No 4CC filtering in the UC. The constant `5` is a fixed source/SOP selector the driver + always passes here. +- **sendVDM (sel 3):** scalars `[route u32, SOPtype u32, len, userBufPtr, flags u32]`. + Handler (0x9b4a118) range-checks `len`, copies the VDM byte buffer in, and calls + `provider+0x990(route, SOPtype, IOMemoryDescriptor*, len&0xff, flags)`. `SOPtype` is an + `AppleHPMSOPType` passed straight through (the driver's own code used value **5** when + issuing a Discover-Identity to SOP′, so the enum is *not* the raw USB-PD SOP ordinal — + determine the partner-SOP value empirically; 0 is the natural first guess for SOP). +- **forceMode (sel 6):** scalars `[route, mode]` → `provider+0x938(mode)` + (drives the HPM mode register / `overrideHPMMode`). +- **forceUpdateMode (sel 7):** scalars `[route, u8, u32, u32]` → `provider+0x940` + (firmware/update-mode forcing; less relevant to DP). + +--- + +## 3. 4CC commands and the raw host-interface encoding + +4CCs the kernel itself materializes (movz/movk sites in AppleHPM `__text`): +`'VDMs'`=0x734D4456, `'DRST'`=0x54535244, `'SSPS'`=0x53505353, `'SCfg'`=0x67664353, +`'MCDL'`=0x4C44434D, `'HChk'`=0x6B684348, `'DFUf'`=0x66554644, `'CRYm'`, `'!CMD'`, +`'Gaid'`=0x64696147, `'GAID'`=0x44494147, `'ADFU'` (@0x483a73, used by `sendHPMReset` +"resetting port / out of ADFU"). `AppleHCPM::hpmCmdWrite` @0x9b6cc44 is the software +*emulation* path and enumerates the accepted set (incl. VDMs, Gaid/GAID) — it is NOT +the live I2C path, but it confirms which 4CCs are "known". + +**Two ways to send a 4CC from user space:** + +1. **Structured:** `iecsCommand` (selector 2) — `[route, 4CC, flags]`. Simplest for + data-less commands (`'DRST'`, `'Gaid'`, `'GAID'`, `'SSPS'`). + +2. **Raw TPS6598x host interface** via `iecsWrite` (selector 1), mirroring the TI Ace + register model you already see (reg 0x03 = mode "APP ", reg 0x58 = DP SID status): + - write the **DATA1 register `0x09`** with the command payload (VDM header + VDOs, + or command args), then + - write the **CMD1 register `0x08`** with the 4CC (e.g. `'VDMs'` / `'DRST'`). + The Ace executes and clears CMD1 on completion; poll reg 0x08 / read result from + DATA1 (reg 0x09) or the relevant status register (reg 0x58 for DP). + +--- + +## 4. How the driver itself drives DP / VDMs (templates to copy) + +### 4a. The driver's own VDM send ('VDMs') — payload layout +`AppleHPMInterface` @ **0xfffffe0009b5d7bc** (log: "role is wrong, issuing discovery on +sop′"): +``` +mov w8,'VDMs'(0x734D4456) ; str [sp+0x3c] ; the 4CC → CMD1 +mov w8,#5 ; strb [sp+0x38] ; SOP type byte = 5 +mov w8,0xACA00121 ; str [sp+0x34] ; VDM header dword (Discover Identity here) +… +blraa provider+0xb10 (this, reg=**8**, &fourcc, &dataStruct{header,SOP,…}, count=1) +``` +So the generic executor is **AppleHPMInterface vtbl +0xb10** = +`cmdExecWithData(reg=0x08/CMD1, &4CC, &data, dataLen/VDOcount)`, with the DATA1 payload = +`[u32 VDM header][u8 SOP type][VDOs…]`. For a DisplayPort VDM you replace the header/VDOs +with SVID **0xFF01** (DisplayPort) structured VDMs: +- **DP Status Update** (cmd 0x6) to re-request HPD/status from the partner; +- **Exit Mode** (cmd 0x5) then **Enter Mode** (cmd 0x4) to restart the alt-mode session; +- **DP Configure** (cmd 0x7) to re-apply pin assignment. + +From user space, reproduce this either via `sendVDM` (selector 3, buffer = `[VDM header +dword][VDOs]`, `SOPtype` = partner) or raw via `iecsWrite` DATA1+CMD1='VDMs'. + +### 4b. 'DRST' / Data Reset — `AppleHPMInterface::exitPDMode` @ 0xfffffe0009b5bc44 +Reads two IORegistry properties into 0x40-byte buffers — **"Exit PD Mode TX Identity"** +(@0x47fd66) and **"Exit PD Mode DRST Data"** (@0x47fd7f) — then: +``` +blraa provider+0xb10 (this, reg=8, &'DRST', drstData(0x40 bytes), len=0x40) +… log "exitPDMode … DRST complete - status = 0x%08x" +``` +`'DRST'` performs a **USB-PD Data Reset**: it tears down and re-negotiates the PD +contract and re-enters alt modes = a clean soft replug of the data path. Reachable from +user space as `iecsCommand(route, 'DRST', 0)` (data-less form) — the property-driven +TX-Identity/DRST-Data payloads are optional refinements the driver supplies internally. + +### 4c. DP status / HPD plumbing (for understanding, not a direct trigger) +- `configureDP` @0x9b40160 / `createDPPortObject` / `configureDPPortObject` — build/activate + the DP transport; re-run on port re-evaluation. +- `sendDpStatusUpdates` @ **0xfffffe0009b58490** — reads the cached DP status word at + `this+0x1244`, **HPD = bit 15** (`ubfx …,#0xf,#1`), and on change sets IORegistry + property **"HPDAsserted"** (`this+0x298 setProperty`) and pushes HPD down to the DP + transport (`this+0x770`). This is consistent with your reg 0x58 decode (partner DP + Status VDO, HPD bit). +- `communicateDPHPDChange` @0x9b3fc14, `clearDpIRQ`, `resetDataControl`, `repeaterReset`, + `disablePort`, `forcePortEvaluation` (@0x9b4764c and others) — the internal re-eval / + teardown primitives; `forceMode`/`forceUpdateMode` (selectors 6/7) are the + user-reachable entry points that can re-drive this logic. + +--- + +## 5. Boot-args and settable properties + +PE_parse_boot_argn = `0xfffffe0008fed024`. AppleHPM boot-args (all config/diagnostic — +**none force a DP reset or change HPD timing**): +`hpmI2CRetries`, `hpmI2CRetryInterval`, `hpmI2CNonCompliant`, `hpmDisableAppleUVDMSOP`, +`hpmDisableAppleUVDMSOPp`, `-hpmUnplugForceUpdateMode`, `hpmlog` (log mask). There is no +"HPD Lost period" / "hot plug delay" boot-arg in this kext (searched; absent). +`hpm-exit-dfu` (@0x9b68514) and `boot-command` are IORegistry/NVRAM properties, not +boot-args. No `setProperties` key in AppleHPM was found that triggers a DP re-entry from +user space — use the selectors instead. + +--- + +## 6. IOAccessoryManager / IOPortFamily user clients (item 4) + +`IOPortFamily` is a stub; everything is in **IOAccessoryManager**. Relevant class +`IOPortTransportStateDisplayPort` exposes: +- `setHPDState` @0xabe5adc (log "hpdState: %d"), `HPD_State` property, `triggerHPDIRQ` + @0xabe5f98 (log "HPD IRQ triggered!"), `setDPOverdriveActive` @0xabf17bc / `DPOverdrive`, + `setDisplayPortPinAssignment` @0xac13718 / `DisplayPortPinAssignment`, + `getDPDNConfig` / `configureDPDNConfigOverrideGated`. + +**Important:** these operate on the *OS-side* DP transport node — they set/propagate the +HPD state and pin config that the display pipeline sees. They are driven **from the HPM +side** (AppleHPM → `communicateDPHPDChange` → `setHPDState`). Faking HPD here makes macOS +re-examine its view of the link, but it does **not** make the Ace re-run alt-mode +negotiation with the physical adapter. For the VR "the adapter dropped DP" case, the +AppleHPM paths in §4 (which talk to the controller/partner) are the right lever; +`triggerHPDIRQ` is at best a complementary "make the GPU re-read EDID / re-train" nudge. + +User clients and their gates: +- `IOPortTransportUserClient` — entitlement **`com.apple.iokit.IOPort.user-access`** + (@0x898584; TRM variant `com.apple.iokit.IOPort.TRM.user-access` @0x89855d). Class refs + @0xabc7984/0xabdf764/0xabdf898/0xabe041c; entitlement check @0xabdfba8. +- `IOPortFeatureLDCMUserClient` — `IOExternalMethodDispatch2022` array @ + **0xfffffe00082890f0**, 5 methods, each requiring entitlement + **`com.apple.iokit.IOPortFeature.LDCM`**, each `scalarIn=1`. (LDCM = the SBU/pin + low-data-rate config path; ties to `setLDCMPin` in AppleHPM.) +- `IOPortUserClient`, `IOAccessoryManagerUserClient`, plus AID-bus reset primitives + `resetAccessoryPowerGated` @0xabc1c24, `sendBusReset` @0xac245f0, `goldbondDisablePort` + — these reset the **accessory-ID bus**, not the USB-C/DP data path; not useful here. + +No external method in these clients was found that re-runs USB-C DP alt-mode negotiation; +they manipulate OS-side transport/HPD/pin state only. The AppleHPM user client (§2–§4) +remains the correct path. + +--- + +## 7. Suggested concrete attempts (in order) + +1. On the **AppleHPMDevice** node (where `iecsRead` works), retry + `iecsCommand(route=0, 'DRST', 0)` — data-less Data Reset. Watch reg 0x58 HPD bit. +2. If that is too heavy or no-ops, `sendVDM(route=0, SOP=partner, DP Status Update VDM)`; + then `sendVDM` Exit Mode + Enter Mode (SVID 0xFF01) for a full alt-mode replug. +3. If structured sendVDM mis-encodes, do it raw: `iecsWrite(0x09, <VDM bytes>)` then + `iecsWrite(0x08, 'VDMs')`, mirroring §4a (SOP byte + header + VDOs). +4. `forceMode` / `forceUpdateMode` to force the driver's port re-evaluation. +5. Fallback: `iecsCommand(route=0, 'Gaid', 0)` (or `'GAID'`) — full Ace reset = guaranteed + replug of the whole port, slow; expect USB + charging + DP to drop and re-enumerate. + +Caveat: everything here is static analysis; argument *values* that are enum-like +(`SOPtype` for sendVDM, `mode` for forceMode, exact DP VDM VDOs) could not be verified +without touching hardware. Start with the data-less `'DRST'` and `forceMode` paths, +which have the least ambiguity.