DeviceHub screen viewing
For host DDI selection, the newer cryptex protocol and the HomePod-specific staging investigation, see Developer disk images on virtual guests (2026-10-23). Its live HomePod result does not extend the validation below to HomePod screen streaming.
Xcode 27 ships DeviceHub.app (Xcode.app/Contents/Applications, bundle id
com.apple.dt.Devices). It lists vphone guests as "iPad / iPhone — Virtual
Machine" and, for a paired guest with its developer disk image (DDI) mounted,
opens a live view of the screen. Before this work the view spun forever on
every guest, 26.x and 27.x alike.
Measured with Xcode 27.0 (27A266a) and CoreDevice 642.16 on macOS 27.0.1,
against iPad16,1 26.6.2 (23G90) and 27.0.1 (24A446) and iPhone17,3 26.6.2
and 27.0.1 guests over cloudOS 26.4, 2026-10-06 and 2026-10-07.
Status: the live view works on iPadOS 26, iOS 26 and iOS 27 (an iPhone Duo
follows its fold, see iPhone Duo: the fold); iPadOS 27 refuses
pairing and is untested. Known issue: under load (several VMs streaming, or the
Mac busy restoring or downloading) the view can stall for seconds or fall
seconds behind and stay there until the stream restarts. A single stream on a
quiet Mac showed no growing delay in two hours, though two short encode stalls
still occurred (see
Known issue). A stream
that splits each frame into bands deadlocked the host GPU until the library
also loaded into avconferenced (see
Banded streams).
What stopped it
Each version stopped at a different place, and a 27.x iPhone stopped at all four:
iOS 27: the DDI does not mount
CoreDevice personalizes the cryptex DDI (TSS succeeds) and hands it to the
guest, then nothing more happens. The device stays "connected (no DDI)" and
DeviceHub logs No providers can handle Display ID: 1 … (bootState: Booted, ddi: false). The guest's cryptexd says why:
In __protex_stage_continue (/usr/libexec/cryptexd, 27.0.1) each staged
asset is opened O_RDWR and given class D with
fcntl(fd, F_SETPROTECTIONCLASS, 4). A failure other than ENOTSUP (45)
aborts staging, and the guest returns EPERM. iOS 26 guests mount the DDI
without this.
Every guest: no media stream features
DeviceHub asks the device for its media stream features before it starts a
stream (MediaStreamGetSupportInfoActionDeclaration, forwarded to the device
feature com.apple.coredevice.feature.getmediasupportinfo) and repeats the
question with backoff (1 s, 2 s, 4 s … 30 s) until the answer is non-empty. A
guest answers every time with
and DeviceHub logs No framebuffer provider found for Display ID: 1 — creators tried: [… AVConferenceDisplayViewFramebufferProviderCreator= canProvideView:false …]. The Mac reports its own features as
Primary video display mirrored output stream, System audio output stream, Display information (Raw Value: 140).
The answer comes from the DDI's dtremotedisplayd
(/System/Developer/usr/libexec/dtremotedisplayd, LaunchDaemon
com.apple.coredevice.dtremotedisplayd, user mobile). Its remote service
com.apple.coredevice.displayservice carries getmediasupportinfo,
getmediastreamserverstatus, startaudiooutput, startvideooutput,
startmediastream and stopmediastream. It returns
MediaStreamSupportedFeatures.current from the DDI's
CoreDeviceUtilities.framework
($s19CoreDeviceUtilities28MediaStreamSupportedFeaturesV7currentACvgZ).
current builds a CurrentDevice and calls
forDeviceInfo(osBuildUpdate:platform:deviceType:isVirtualDevice:mode:isProductionFused:hasInternalOSBuild:hasInternalDDI:).
Its log strings name the policies, and none of them is a capability check:
Remote control restricted. Device is customer-restricted (production fused, customer OS, customer DDI) and running pre-27.0 OS. The predicateCurrentDevice.isCustomerRestricted(isProductionFused:hasInternalOSBuild:hasInternalDDI:)disassembles toisProductionFused && !hasInternalOSBuild && hasInternalDDI == false. Every 26.x guest is refused here.Remote control is only available on iOS 27.0, watchOS 27.0, or tvOS 27.0 or later., shown when the device is not an iPhone, Apple Watch or Apple TV. Every iPad guest is refused here, whatever its version.- A per-build table (
MediaStreamSupportedFeatures.MinimumVersions) keyed by build, platform and device type, which also takesisVirtualDevice.
The guest does not persist the info-level inputs: line, and the hook replaces
the answer on every version, so the branch a 27.x iPhone would take on its own
was not observed. current also returns no features when CurrentDevice.init
cannot build the device identity (version number, build update, device type);
there is no compositor-readiness gate in it.
Feature bits (MediaStreamSupportedFeatures static getters):
devicectl device capture screen-record is a separate feature
(com.apple.coredevice.feature.screenrecording) that a guest does not list,
so it is no way around this.
iOS 27: the capture display is black
With the DDI mounted and features reported, a 27.0.1 stream starts but shows
black, and the guest's FigVirtualDisplayProcessor reports Submits 0 / Encodes 0. backboardd logs, about fifty times a second:
Display 2 is the capture display the stream adds. 10000003 is
MACH_SEND_INVALID_DEST.
The cause is the iOS 27 force-kern patch (row 11 of the guest DSC table in
Research/0_binary_patch_comparison.md). iOS 27 presents the paravirtual
main display through IOMobileFramebuffer's in-process _virt_Swap* path,
which never reaches the 26.4 kernel's paravirtual GPU. The patch therefore
sent every public _IOMobileFramebufferSwap* call to _kern_Swap*, which
calls the kernel userclient. The capture display, however, is a genuine
virtual display: it has no userclient port, so its kernel swaps fail, and its
frames never reach the in-process consumer that _virt_SwapEnd hands surfaces
to. get_wireless_surface_options then has nothing to return
(e00002be is kIOReturnNoResources, e00002d5 kIOReturnBusy).
Every _kern_Swap* implementation loads the connection's IOConnect port from
[x0, #0x14]. That port is non-zero for the paravirtual main display and zero
for the capture display.
iOS 27 iPhone: no framebuffer mask
DeviceHub draws the screen inside a device chrome and clips it with a
framebuffer mask. The DDI's dtdeviceinfod (not dtremotedisplayd)
implements the DisplayInfo and DisplayInfoUpdates actions. It reads
MGCopyAnswer("DisplayExtendedProperties") and otherwise falls back to
ChromeIdentifier and FramebufferIdentifier. On the 27.0.1 guest these
answer null, com.apple.dt.devicekit.chrome.phone11 and null. With a
chrome but no mask, DeviceKit falls back to a rounded rectangle and the
content runs over the bezel corners.
/Library/Developer/DeviceKit/chrome_map.plist maps iPhone17,1 to phone11
with mask 4E5532ED-1470-47D1-BDF4-7AA90C26957A (and iPhone17,3 to
phone9 with A6AC96E7-…). It has no entry for the guest's iPhone99,11.
DeviceTreeGuestDevicePatches deliberately keeps the virtual
framebuffer-identifier while copying the real board's chrome properties, so
the device tree is not the place to fix this. Matching the guest's resolution
to an iPhone 16 (1179x2556) did not change the overflow either.
The fix
libdevicehubfix.dylib
VPhoneGuestComponents/DeviceHubFix/libdevicehubfix.c, installed as
/usr/lib/libdevicehubfix.dylib. Both spawn hooks insert it into four
processes only (vpIsDeviceHubFixTarget in Shared/InjectionEnvironment.h):
cryptexd: interposesfcntl. AnEPERMfromF_SETPROTECTIONCLASSis reported asENOTSUP, which takes the pathcryptexdalready has for a filesystem without protection classes. Every other call and failure is untouched.dtremotedisplayd: interposesMediaStreamSupportedFeatures.current, which answers0x184:0x8c, what a Mac host reports for itself (the host intersects it with its own), without system audio0x8(the VM window plays the guest's audio already, and DeviceHub played it a second time), plus video output by display ID0x100, which an iPhone Duo's inner panel streams through (see iPhone Duo); andCurrentDevice.isCustomerRestricted, which answers false. The struct is resilient (the framework is built for library evolution), so the getter returns it indirectly throughx8; the replacement is three instructions of assembly.dtdeviceinfod: interposesMGCopyAnswer. Only whenFramebufferIdentifieris missing andChromeIdentifieris exactlyphone11, it answers with Xcode's matching mask4E5532ED-…, retained under the Copy ownership rule. Existing answers and other chromes are unchanged. This is display metadata, not a change to the guest's hardware identity. On an iPhone Duo it also stands in for CoreMotion's hinge angle and device state managers and reports the inner display as integrated (see iPhone Duo).avconferenced: replacesVCPSideCarMetal's two band reads and runs one synchronous scaler transform before the first read of each frame (see Banded streams).
Interposing works because cryptexd, dtremotedisplayd, dtdeviceinfod and
CoreDeviceUtilities are standalone images, not shared-cache ones, so dyld
binds their imports through the interposing table. CoreDeviceUtilities lives
on the DDI and not in the SDK, so its two symbols are weak flat-namespace
imports (-Wl,-U) and resolve to nothing outside dtremotedisplayd.
VideoProcessing is in the shared cache, which an interpose does not reach,
so the band reads are replaced through the Objective-C runtime.
The library ships with system-launchdaemons-boot-environment. An existing
guest gets it from the environment update when it next starts on a bundle
that has it.
Conditional IOMFB dispatch
DyldSharedCacheIOMFBForceKernPatcher now rewrites each public swap
trampoline as:
x16 is an intra-procedure-call scratch register, so the arguments reach
either implementation intact. The public, _kern_ and _virt_ siblings are
resolved by name; only operations with all three take part, and SwapBegin,
SwapEnd and SwapSetLayer must be covered. Capstone checks the trampoline
shape and the port load's operands, ARM64Encoder produces all four words
(encodeCompareBranch is new, pinned against keystone), and the 16-byte write
spans are re-attested. On 24A446 it rewrites ten entry points and leaves
SwapSignal, which is not a thin trampoline, on its existing path. The
0x14 port offset was verified on 24A446 only.
Upgrading an existing iOS 27 guest
Nothing to do by hand: bind the machine to the new bundle with its guest
environment (Launchpad's bundle switch, or vm set-bundle <vm> <version> --update-environment), or run cfw install / cfw update-environment.
The patch identifier did not change, and applyDyldPatches normally skips an
enabled patch whose identifier is already in the guest receipt, which would
leave the old 31 four-byte sites in place for good. The undo log tells the two
implementations apart: the old one recorded 4 bytes per entry point, the new
one 16. DyldSharedCacheUndoLog.outdatedImplementations names that shape, and
for such a patch the installer reverts its records and applies the current
implementation in the same run. A second run finds 16-byte records and leaves
the cache alone. If the revert succeeds and the new write fails, the patch is
reported as failed and left out of the receipt rather than listed as applied.
A guest patched before the undo log existed has no records, so it cannot be reverted this way and keeps the old bytes; it needs a restore.
Validated (2026-10-07, iPhone17,3 27.0.1): the guest was put back on the
old implementation with bundle 2.6.0 (31 newly forced), then bound to the new
bundle with --update-environment. The log shows an earlier implementation is applied; reverting it and applying the current one, 31 four-byte reverts and
10 newly forced, 0 already conditional. A further cfw update-environment
did not touch the patch. After boot the VZ window scanned out and DeviceHub
showed the guest live, 30–40 ms behind the VZ window.
Validation
iPad16,1 26.6.2 (23G90), new test VM. DeviceHub receivessupportedFeatures: 140,MediaStreamStartsucceeds for audio and video, and the view shows the live home screen, also after a cold start on the built bundle. SystemHook logsinserted+devicehubfixforcryptexdanddtremotedisplayd.cryptexdnever takes theEPERMpath on 26.x. An unpatched 26.6.2 iPad kept answering 0 throughout.iPhone17,3 27.0.1 (24A446), new test VM. Without the hook the DDI never mounts. With it the hook logsF_SETPROTECTIONCLASS EPERM, reported as ENOTSUPonce and the device becomesconnectedwith DDI services.- With the old patch the stream starts but stays black (above).
- After reverting and reapplying with the conditional dispatch, the VZ window still scans out, the guest encodes 41–60 fps, and DeviceHub shows the home screen and Settings live.
- With the mask, DeviceHub logs
Found FramebufferMaskIdentifier from displayInfowith thephone11UUID, and the home screen and Settings are clipped inside the chrome at all four corners. - A swipe sent to the guest moved the Settings list identically in the VZ window and in DeviceHub.
iPhone17,3 26.6.2 (23G90), new test VM (2026-10-07). Paired on its own,supportedFeatures: 140, live view 50–150 ms behind the VZ window. It reportsphone11chrome without a mask too; thedtdeviceinfodhook fills it and the corners are clipped inside the chrome.iPad16,1 27.0.1 (24A446), new test VM (2026-10-07). The conditional dispatch writes the same ten sites and the VZ window scans out normally, so the patch does not regress iPad. DeviceHub could not be tested: the guest refuses pairing (kAMDUserDeniedPairingError, fromdevicectl manage pairand from Xcode alike) without showing a trust alert. That happens before any of this code is involved and is left for separate work.- Tests.
InjectionEnvironmentTestscovers the four targets and their near-miss suffixes. The seven real-cache force-kern tests (pristine 24A435 cache viaVPHONE_DSC_PRISTINE) cover conditional routing, the preserved null check, symbol discovery, dry run and idempotence, and pass.ARM64CompareBranchTestspins the new encoder.
Touch input sent from inside DeviceHub reaches the guest (checked by hand), but has no automated check.
Banded streams deadlock the host GPU
Fixed. A stream can split each frame into bands that are encoded separately
(tilesPerFrame in the stream configuration). With four bands and a changing
screen, the 27.0.1 iPhone guest froze within 16–35 s: the picture stopped
while touch still worked, then the guest turned green and the VZ window
stopped, until the VM was restarted. The Mac logged Timeout in timestamp wait and restarted its GPU, and the guest never got it back. A still screen
produces no new frames and did not freeze; a single-band stream never froze.
Measured with macOS 27.0.1 (26A434) on an M2 Mac and iPhone17,3 27.0.1
(24A446) over cloudOS 26.4 (23E5207q), 2026-10-07 and 2026-10-08.
Who writes the frame and who reads it
- backboardd's CoreAnimation display thread
(
com.apple.coreanimation.display.primary) writes each capture frame (420f, 1320x2868) with the M2 scaler, throughIOSurfaceAcceleratorTransformSurface. avconferencedgets the frame before that transform has run on the Mac and does not wait for it: IOSurface implicit synchronization orders its reads after the write.- With bands, VideoProcessing's
VCPSideCarMetalreads the frame on the GPU once per band, intemporalTransitionScore:previousFrame:forRegion:(current and previous frame) andcopyFromFrame:toTile:origin:size:withFence:. Each command buffer samples the frame's two planes as two textures. With one band neither method runs and the GPU does not read the frame.
Both run on the Mac. The guest's Metal commands are replayed by one
ParavirtualizedGraphicsGPUTask process per guest process, and scaler
requests (AppleM2ScalerParavirtDriver) by the VM process. Both end in the
Mac's IOSurface implicit synchronization, which the guest cannot see.
Host synchronization
From the Mac's kdebug class 0x8521 (see Measuring):
- The VM process runs scaler requests one at a time, and registers the write 0.25–0.8 ms after taking a request.
- The GPU task registers a command buffer's accesses on its own path, and can register a band read inside that window, before the write.
- A command buffer registers each texture on its own, so the frame's two planes are two registrations, and another registration can land between them.
- Once a writer waits, new readers queue behind it.
At the deadlock, a band read registered plane 0 of the current frame, the scaler's write registered 1 µs later and waited for it, and the same command buffer's plane 1 registration 1 µs after that queued behind the write. The surface was never released again; the scaler queue and the GPU FIFO stopped together. In two traced runs a band read registered before the write in 26 of 779 and 20 of 564 frames. Most such inversions are harmless, because the write waits for the read; only the 1–2 µs between two plane registrations deadlocks, which fits a freeze after tens of seconds.
The fix
Before the first band read of each frame (keyed by IOSurface ID),
avconferenced runs one synchronous transform between two scratch surfaces,
64x64 to 32x32. When avconferenced reads a frame, the VM process has always
taken the request that writes it already (779 of 779 frames, at least 234 µs
earlier), and a synchronous transform returns only after the host released its
write. The scaler runs requests in order, so when the barrier returns the
frame's write is done and no band read can meet it.
Neither method takes a floating-point argument, and the score method returns a
C++ future through x8, so the replacement saves x0–x8, calls the barrier
with the frame and continues into the original. If either method is missing,
nothing is replaced and the library logs VCPSideCarMetal band reads not found.
Ruled out:
IOSurfaceLockbefore the reads returns within microseconds without waiting for the host's write, and still deadlocked after 25 s.- Waiting after the write in backboardd: QuartzCore reaches the scaler through a branch island, not its GOT, so an interpose does not see the call.
- One band per frame avoids the deadlock but gives up the lower latency bands are for.
- Leaving the scaler out of the VM configuration
(
_VZMacScalerAcceleratorDeviceConfiguration) would change every guest process that uses it; not tried.
Validation
Four bands while swiping back and forth on the home screen, unless noted:
The barrier relies on two host behaviors that were observed, not documented:
scaler requests run one at a time and in order, and a frame's request is taken
before avconferenced reads the frame. A surface reused for two consecutive
frames would skip the second barrier; this was not seen, since
temporalTransitionScore holds the previous frame, and all 74651 band-read
command buffers of the 180 s run came after their frame's barrier.
Cost
- Only
avconferenced, and only when frames are split into bands: the two methods are not called otherwise. A single-band stream keeps its frame rate and the host sees no barrier transform. avconferencedwaits in the barrier about 2 ms a frame, but without it most band reads waited on the host for the same write. What the barrier adds is the time from the write's release to the first band read: 760 µs median, 442 µs at least.- Four bands ran at 35–41 fps with the barrier, against about 29 fps without it before the deadlock.
- The host runs one extra 64x64 to 32x32 transform per frame.
Known issue: stalls and growing latency under load
Not fixed. What a user sees and what helps:
- With several VMs streaming, or while the Mac restores or downloads, the DeviceHub view can freeze for 1–3 s, or its delay can climb in steps to a couple of seconds and stay there.
- A new stream clears the accumulated delay; this was verified by restarting
the guest's
avconferenced. Whether reopening the device in DeviceHub alone starts a new stream was not checked. - In testing, one VM streaming on an otherwise quiet Mac showed no growing delay; two encode stalls of up to 1.4 s still occurred in two hours.
- The VM's own window is unaffected, so it remains the reference for timing.
The guest's HEVC encode is paravirtualized to the Mac and shared by every VM (Encoding happens on the Mac), which fits the dependence on load, but no profile of a stall has been caught yet. The sections below are the evidence so far.
Latency grows over a long session
Measured on the 26.6.2 iPad from a guest screen change (an apps.launch RPC)
to the first changed DeviceHub frame; the VM's own window takes about 0.4 s
for the same change:
Recreating the stream (killing the guest's avconferenced) resets it.
Ruled out:
- Mac receiver. For the whole session the video jitter queue stays at
100 ms (briefly up to 500 ms), one frame is pending, and the playback offset
is constant. The image queue displays about 25 ms after enqueue. DeviceHub
and
avconferenceduse a few percent CPU. - Guest encoder and transport. HEVC encoding takes about 5 ms a frame, RTT is 2 ms, the one-way delay estimate 9 ms, with no loss.
- The audio stream. The audio path is unhealthy (erasures, late packets,
an audio jitter queue that reached 11.76 s before it was discarded), but a
video-only stream (features
0x84) grew at the same rate. - Clock drift. The guest's monotonic clock runs within 3 ppm of the host's.
So the frames already carry late timestamps when they reach the Mac. The
likely place is the guest's mirroring into the capture display, ahead of the
encoder (backboardd to FigVirtualDisplayProcessor). Confirming it needs
per-frame timing inside the guest; the logs are too coarse.
Stalls after a cold start
Separately, on the 27.0.1 iPhone, DeviceHub sometimes stuttered for a few
seconds right after a cold start while the VZ window stayed fluid. In the one
confirmed case the Mac's HEVC decoder received nothing for about five seconds
while the receiver still counted packets. DeviceHub then sent a FIR with
reason No video displayed timeout fail safe and recovered. In another cold
start, while the guest was still booting, the guest's encoder and
FigVirtualDisplayProcessor reports stopped for about 17 seconds while
transport health messages continued. On recovery, 22 VTP_Send calls failed
with ENOBUFS and one encode took 125 ms, which looks like a consequence of
the burst rather than its cause. Nine other cold starts were healthy (newest
presentation under 25 ms old), and a run under the same test load as the
failure peaked at 240 ms. This also points at the guest's capture or encode
path.
alarmsSentForDecodeButNotDisplayedCount=16 stays at its threshold even
after presentation recovers, so that warning is no failure detector.
Encoding happens on the Mac
A targeted spindump of the guest's avconferenced shows the queues
com.apple.videotoolbox.paravirtualization.guest and
…paravirtualization.host-to-guest: the guest's VideoToolbox HEVC encoder is
paravirtualized, and the conversion ahead of it goes through
AppleM2ScalerParavirtDriver and IOSurface kernel calls. Encoding a frame is a
round trip to the Mac's VideoToolbox and scaler, which every running VM
shares.
That fits the worst events seen on 2026-10-07: two VMs streaming at once
(26.6.2 iPad and 27.0.1 iPhone) logged Delay Warning: Encoding time=… at the
same second, up to about 1.2 s a frame against about 5 ms normally, and both
DeviceHub views fell 1.9–2.7 s behind, then recovered within about ten
seconds. The VZ windows stayed fluid throughout, so only the encode path
stalled, not the guests.
What triggers it is not established:
- Two such events fell in minutes where the Mac had 40–100 MB free and was compressing and paging in tens of thousands of pages a second. A later VM restore that paged just as hard did not reproduce a stall (encodes peaked at 229 ms, DeviceHub stayed at 10–120 ms), so memory pressure alone is not it.
- Both events also coincided with starting screen-capture measurement processes. A burst of captures later, including one that hung, did not reproduce it either.
The slow growth in a single long session (above) is a different shape: no encode warnings, just a lag that ratchets up. A 26.6.2 iPad stream that had run for about an hour was measured at 2.1 s on 2026-10-07 as well.
A single stream on a quiet Mac does not ratchet
On 2026-10-07 the 26.6.2 iPad streamed alone for about two hours, with the
other test VMs stopped and about 20 GB free, the frame-code probe running and
the guest's avconferenced log captured throughout:
So neither idling, screen lock, UI transitions nor screen capture produce the ratchet or the long encode stalls on their own. Both were only seen while several VMs streamed or the Mac was busy restoring and downloading, which fits the encode path being a shared paravirtual resource. Two stalls did occur in this session (12:13 and 12:15 local, up to 1.4 s a frame) without such load; none of the 29 spindumps taken around Home presses caught one.
During stalls VCScreenCapture logs Frame PresentationTime … going backwards … Dropping frame. MediaToolbox's FigVirtualDisplayProcessor stamps a
submitted frame with the compositor's timestamp and, after max(2 frame intervals, 50 ms) without one, re-encodes the last frame on its own timer;
when the two interleave a real frame can land 1–28 ms behind an idle one and
is dropped. The offsets do not accumulate, so this costs single frames, not
seconds.
iPhone Duo: the fold
Measured 2026-10-29 with Xcode 27.1 against iPhone19,4 27.1 (24A94425).
Xcode 27.1 draws a foldable as a 3D V68 model
(DeviceKit.framework/PlugIns/CoreDevicePopDeviceKitExtension.devicekitplugin,
log subsystem com.apple.dt.coredevicepop, with the host CoreDevice plugin
CoreDevicePopCoreDeviceExtension.coredeviceplugin). It picks the fold and
the lit panel from the device's pose, which is CoreDevice's device motion
state stream (com.apple.coredevice.action.streamdevicemotionstate). The
DDI's dtdeviceinfod (DeviceMotionStateManager) answers it from
CoreMotion's CMDeviceStateManager, reading each CMDeviceStateEvent's
propertyA (device orientation), propertyB (hinge state 1 closed, 2
partially open, 3 open flat) and propertyC. On a guest the manager is
unavailable, DeviceHub logs Pose monitoring failed … Code=1001 every second,
and draws the device closed whatever the hinge angle stream says.
libdevicehubfix answers it in dtdeviceinfod (VPCMAngleManager.m, beside
the hinge angle stand-in): isAvailable follows the hinge provider, and
updates are real CMDeviceStateEvents built from the provider's newest
sample, sent at start and on every change. propertyA is the device
orientation vphoned publishes (VPhoneDeviceOrientation in
com.apple.backboardd, portrait until a rotation or DeviceHub's orientation
picker sets one), propertyB the provider's hinge state.
DeviceHub's model (decompiled, CoreDevicePopDeviceKitExtension):
PhysicalState.updatereads onlypropertyA,propertyBandpropertyC(each aCodableValue.int). Closed pins the angle at 0°, open flat at 180°; partially open keeps the angle from the hinge angle stream (streamhingeangle, samples withisAngleValid, aMeasurementin degrees).- The focused panel (the face the model shows, and whose stream it draws) is, in order: the panel a command is heading for, cover when closed, the active display DisplayInfo reports (accepted only fully open or closed), then the hinge: partially open at 55° or more is the inner panel, 45° or less the cover.
- The model's rotation comes from the pose orientation and the focused panel,
not the hinge state;
DisplayInfo.orientation.currentDeviceOrientationstands in only while the device lies face up or down. The pose buttons' icons turn withpropertyA. - The presets are closed 0°, partially open 130° and open 180°.
The hinge angle stand-in's three angles are floats: dtdeviceinfod's
CMAngle_Bridge declares angleDegrees, mechanicalAngleDegrees and
velocityDegreesPerSeconds as Tf and reads s0. The first stand-in
returned doubles, whose low half is zero for every whole angle, so DeviceHub
read 0° throughout. Open and closed hid it (their hinge states pin the
angle); in book DeviceHub drew the device nearly shut and focused the cover,
whose stream is the composited portrait output, so the inner picture looked a
quarter turned. Reporting landscape right made that picture upright only by
rolling the whole model.
Each panel streams on its own. DeviceHub shows its pose controls only when
DisplayInfo lists the inner display as integrated with its own ID, and then
draws that panel from that display's stream (feature 0x100) and geometry.
FoldDisplay publishes the inner display with the hardware's shape: a portrait
2007×2853 framebuffer at 3×, mounted at 270° (DisplayRotation, as the
simulator's inner CADisplay), into which SpringBoard lays the inner UI out
at interface orientation 90. In dtdeviceinfod the library reports that
display as integrated at point scale 3 (displayType, pointScale on the
CADisplay named VPhone Duo Inner). An earlier landscape inner display
streamed pillarboxed with black bars, and with the mirror of display 1 the
picture came out turned a quarter.
DeviceHub's controls reach the guest as vendor-defined HID events (usage
page 0xFF61, usage 0x5B) from the DDI's dtuhidd virtual service
CoreDevice avpCustom. The payload is an IOKit binary-serialized dictionary
(IOCFUnserializeBinary): provider com.apple.Virtualization.VirtualMachines,
source hinge-slider-control (type range, value degrees, sent every
frame of DeviceHub's own animation) or orientation-picker-control (type
enum, value portrait, portraitUpsideDown, landscapeLeft or
landscapeRight). vphoned watches for them on an iPhone Duo
(vphoned_devicehub.m, an unmatched HID event monitor; matching by usage
missed them) and acts through the display.posture paths
(GuestDeviceHubControl): a run of slider values is folded once it has been
quiet for 200 ms, to where it ended, and a picker value sets the pose
orientation with the hinge where it is, as display.rotation does. The
orientation DeviceHub reads back changes only once SpringBoard's interface
has turned, and a refused turn gets the old pose orientation back (SpringBoard
keeps a refused one, and later folds carry it). Book is left alone: the
override means 90° there and slides the fold toward the cover. Measured with
display.rotation on the open inner panel: 0, 90, 180 and 270 all turn, and
DeviceHub turns its model with each, picture upright. The picker itself was
not pressed.
DeviceHub then waits for its hinge
reading to agree; vphoned moves the virtual hinge to the target as
SpringBoard's sweep starts, since a stale angle through the sweep made
DeviceHub's slider send the old angle back and fold the guest again.
dtdeviceinfod aborted whenever it let a CMAngleManager go, as when
DeviceHub quit or reconnected: -[CMAngleManager dealloc] stops
CoreMotion's own manager, and -[CMAngleManagerInternal stopAngleUpdatesPrivate] asserts CLAngleNotifier::isAvailable()
(CMAngleManager.mm line 214), which is false on a guest without the
foldable product-id. The relaunched dtdeviceinfod left DeviceHub without
the device state, and its fold controls stopped working until DeviceHub was
restarted. The stand-in now skips that internal stop, since under it the
real manager never started. Its minAngleDegrees and maxAngleDegrees also
return floats, as declared. Six crash reports with the same stack were
collected on 2026-10-29 and 2026-10-30. With the fix, dtdeviceinfod kept
running through a DeviceHub quit and relaunch, and DeviceHub followed the
guest to book and closed afterwards.
Restarting backboardd breaks dtuhidd's HID connection and DeviceHub keeps
the dead one: restart DeviceHub after a respring.
Measured on the same guest, portrait: closed and open from DeviceHub's
buttons settle without bouncing and the guest follows; book at 130° (folded
by display.posture) draws the inner face upright with the crease vertical,
as DeviceHub draws a simulator Duo in book. Not yet repeated from DeviceHub's
own book button since the float fix. Once, after DeviceHub's close from book,
the cover came up in landscape (interface 90°); it did not recur in 48
scripted folds over every path, and the guest's device orientation had been
left non-portrait by rotation experiments just before, which the swap keeps.
With the foldable's product-id (preboot-cfw-devicetree_foldable_product_id,
on in standard since Lakr233/vphone-cli#778) CoreMotion has its own angle and
device-state managers, and locationd's device-state relay reads the host
controls (0xFF61/0x5B). Both dtdeviceinfod stand-ins then step aside, and
DeviceHub reads the real managers. dtuhidd's "CoreDevice avpCustom" service
carries the same usage, so the relay takes DeviceHub's controls itself, as on
Apple's VM host, and the guest follows the slider as it moves. The virtual
hinge, which resends its stored values every 5 s for a restarted locationd,
stores each slider value quietly (a quiet configuration: no host-control
event): while it kept the old angle until a debounced fold, a resend landing
inside a run sent the fold back, and sending each value again reached the
relay late, behind DeviceHub's own, and swung the hinge back and forth across
the swap (the panel changed hands eight times on three clicks). An opening run from
closed lights the screen first, as display.posture does; with a sleeping
cover SpringBoard's pose provider commits no moving pose and the inner
display stayed black. The picker sets the hinge's orientation. The provider
check keeps vphoned from following the hinge's own dictionaries, which carry
none. The SpringBoard display-swap path above is for guests without the
relay; there vphoned also writes each slider value as the hinge sample the
stand-ins read, without moving the hinge, because a reading left at the old
pose until the fold pulled DeviceHub's model back there (closed, open,
closed).
Measured on sbdisplay-duo after the product-id patch (2026-10-30): every
DeviceHub click folds the guest once, following the slider. The black on
opening out of closed is SpringBoard's fold effect on one shared panel; see
iphone_duo_bringup.md, "2026-10-30: the fold's fade to black on one
panel". DeviceHub no longer gets the guest's system audio (0x184): the VM
window plays it, and DeviceHub played it again.
Encoding only when the screen draws
An open DeviceHub viewer cost the host about a core while the guest sat on
its Home Screen (iPhone19,4, iOS 27.1 24A94425, macOS 27.0.1 on an M5 Pro):
the VM process at 91%, against 14% with DeviceHub closed. The guest itself
used about a third of a core (avconferenced 17%).
The encoder runs on the host. avconferenced calls VideoProcessing's
VCPCompressionSessionCreate directly (AVConference EncCreateHandle), and
that real-time encoder wraps
paravirtualized:com.apple.videotoolbox.videoencoder.ave.hevc, the host's AVE
hardware. The real-time encoder runs AVE in user-DPB mode
(VCPCompressionAllocateDPB, kVTEncodeFrameOptionKey_DPB_ReferenceFrames_*),
so each encoded frame carries nested reference-frame dictionaries. A host
sample of the VM process showed, per stream, an AVE_UCRecv thread at
about 36% in VTParavirtualizationHostEncoderSessionEmitEncodedFrame → VTParavirtualizationCreateSerializedAtomDataBlockBufferForSampleBuffer → CFPropertyListCreateData and about 22% more in a synchronous wait for the
guest's reply, plus the AppleM2ScalerDevice queue at about 39%. No guest
setting leaves user-DPB mode: it is how the real-time encoder controls its
rate. The Mac decodes with VCPDec, about 1.2 ms a frame.
The cost scales with the frame rate, and the rate did not follow the screen.
The Mac's avconferenced logs each stream's input rate
([VCPDecStatsMonitor] … Input_fps=): the idle Home Screen streamed about
42 fps on each of the Duo's two displays. The capture is FigVirtualDisplay's
writeback clone of each lit display in backboardd
(IOMFBDisplay::clone_update); after the screen draws it keeps writing frames
back at the capture rate for about a second, and a still screen sends nothing
(Settings left alone dropped to 0 fps, with or without the change below). The
live Clock icon on the Home Screen draws about ten times a second
(CARenderServerGetFrameCounterByIndex(0, 0), the main display), so that
second never ends.
libdevicehubfix (VPScreenCaptureIdle.c) replaces
-[VCScreenCaptureFigCaptureController screenCaptureController:didReceiveSampleBuffer:transformFlags:] in
avconferenced: a frame passes when the main display's frame counter moved
since the stream's last frame, and otherwise once every 250 ms. A frame that
carries a change can arrive after the counter moved; the 250 ms frame bounds
that delay and lets the encoder refine a still image. On the Duo the main
display is the panel the inner display is shown on, so one counter covers both
streams.
A first version also passed eight frames after each change; with the Clock icon changing every 100 ms that never ran out (30 fps with two frames).
Other open items
- iPadOS 27 refuses pairing (see Validation), so DeviceHub is untested there.
- Touch input sent from DeviceHub has no automated check.
- The
0x14port offset in the conditional dispatch is fixed, not derived; check it on the next 27.x build. - The band-read barrier was tested on the 27.0.1 iPhone only. It depends on
two private
VCPSideCarMetalmethod names; if they change, the library logs that they were not found and banded streams can deadlock again. - The idle-frame filter depends on the private
VCScreenCaptureFigCaptureControllercallback andCARenderServerGetFrameCounterByIndex; if either is missing, the library logs it and every captured frame is encoded, as before.
Measuring
- Open a guest in DeviceHub without clicking:
open "devices://device/open?id=<CoreDevice identifier>", usingidentifierfromxcrun devicectl list devices --json-output -(not the UDID). It opens or raises a viewer window and starts the stream. - DeviceHub's windows are not in the accessibility tree. Capture them
with
screencapture -x -o -l <CGWindowID>, the ID fromCGWindowListCopyWindowInfofiltered by DeviceHub's pid. - Latency: run a guest app that draws the guest's clock every frame as a machine-readable pattern (a green frame around a 4×8 grid: 24 bits of epoch ms / 10 and an 8-bit checksum), capture the VZ window and the DeviceHub window at the same moment, decode both, and subtract. That gives DeviceHub's delay over the VZ window to about ±30 ms, every few seconds, without touching the guest. Without such an app, change the guest screen through vphoned and capture until the frame differs, with the VM window as the baseline.
- Capturing windows: ScreenCaptureKit (
SCScreenshotManagerwithdesktopIndependentWindow) captures covered windows correctly, but two capturing processes at once hang each other; sample several window pairs from one process.CGWindowListCreateImage(looked up at run time) returned a frame minutes old for DeviceHub's main window, andscreencapture -lis live but its timing varies by hundreds of milliseconds. DeviceHub has one viewer window: opening a second device withdevices://reuses it. - Host logs:
/usr/bin/log show(zsh shadowslog). In DeviceHub,MediaStreamGetSupportInfoandMediaStreamStartreplies. In the Mac'savconferenced,Health: VideoReceiver(videoJitterQueueSize),Health: VCVideoPlayer(presentation times) andVCPDec(decoder input). Player addresses are reused across streams, so start a stream's series at itsdisplayLinkTickCount=0line rather than matching the pointer. - Host IOSurface synchronization:
sudo ktrace dump -b 1024 -T 210s -f C0x85 <file>while streaming, thenktrace trace -R <file> -N --ndjson. In class0x8521,0x85210000registers an access,0x85210004waits,0x85210008is granted and0x8521000creleases;arg1is 1 for the scaler and other non-GPU access in the VM process and 2 for the GPU,arg2the surface ID,arg31 for a read and 2 for a write.0x8521001cis the scaler looking up a surface as it takes a request. A run writes 1–1.5 GB. - Guest logs: vphoned
logs.syslogtakeslevelall,errororfault; info lines are not persisted.avconferencedprintsfvdp_statsReport,VCPEncandHealth: VideoTransmitterevery second. - Guest profiling: follow
Research/Guest/display_refresh_rate.mdand never sample the host VM process at the same time. A targeted capture is enough:spindump avconferenced 40 20 -onlyTarget -wait -noBinary -file <report>. - Restarting the stream:
processes.killsendsSIGTERMby default, whichdtremotedisplaydignores; pass"signal": "KILL". The stream itself lives inavconferenced. - Screenshots are no evidence of scanout. vphoned's
screen.screenshotand the control socket'sscreenshotread the guest, and DeviceHub's saved screenshot showed the home screen while its live panel was black. Look at the VZ window and DeviceHub's live panel directly.