iPhone Duo simulator baseline harness
Shared tooling for recording what Apple's own iPhone Duo simulator returns from public and private APIs in each fold posture, so vphone-duo (iPhone19,4, iOS 27.1 24A94425 in a Virtualization.framework VM) can be compared against it.
In the repository:
- the tables:
BASELINE.md,<domain>.md,vm-notes.md; - the harness in
tools/, the probes inprobes/, andsmoke.json; src/: the per-domain drivers, probe generators, the probe app and the VM run scripts, which first lived underraw/.
The per-domain record files (<domain>.json and vm-<domain>.json, 2–63 MB
each) and the transcripts and screenshots under raw/ (about 1 GB) were
deleted on 2026-10-30. The values worth keeping that were not already in these
tables are now in Research/Guest/iphone_duo_simulator_reference.md. Paths to
*.json and raw/ in these notes name output that rerunning the probes
recreates.
- Xcode 27.1 (27A9275), runtime iOS 27.1 (24A94425), device type
com.apple.CoreSimulator.SimDeviceType.iPhone-Duo(iPhone19,4). - Harness simulator:
duo-baseline-harness, UDIDD13582ED-1932-4E12-800F-A44CFE16EA8E(left shut down; clone it withtools/sim.zsh clone). Locale is zh-Hans, so screenshots show Chinese UI. - Never boot, erase or delete the two pre-existing "iPhone Duo" devices
(
B738069B-…,4D48BBE9-…).sim.zshrefuses to boot or shut them down.
All tools are zsh; there is no Python. jq is /opt/homebrew/bin/jq.
Quick start
For your own simulator: tools/sim.zsh clone duo-baseline-harness duo-baseline-<you>
(the source must be shut down), then use that name everywhere.
sim.zsh
pid matches the executable's basename among the children of the sim's own
launchd_sim, so several booted sims never get mixed up.
A panel that is not being driven captures as pure black (max=0); lit means
some pixel of a 32×32 downscale exceeds 24. Display 1 captures 1398×2034
(portrait); display 3 captures 2853×2007 (landscape — the panel's native
rotation is 270).
simctl io screenshot works headless on this host. (An older note in
Research/Guest/iphone_duo_bringup.md says it captures black without
Simulator.app; that is not the case for this runtime — a black capture means the
panel is off.)
probe.zsh
It runs lldb --batch --no-lldbinit -p PID, with symbols.load-on-demand
on, sources prelude.lldb and then your file with stop-on-error off, then
process detach and quit. Afterwards it checks the target: if it was left
stopped (T) it gets SIGCONT and a warning; if it disappeared, a warning.
With out.txt it writes the cleaned transcript there and lldb's untouched
output to out.raw.txt; without, it prints the cleaned transcript. Cleaning
keeps everything after your file's command source echo and drops lldb's
chatter (attach banner, "Executing commands in", the Objective-C++ note,
detach lines). Each (lldb) expr … echo stays as the delimiter for its result.
Exit status 124 means the watchdog fired. An interrupted expression cancels the
rest of the command file, and lldb still detaches (tested with an endless loop
in Preferences: the app kept running).
Avoid side effects in SpringBoard beyond what a probe needs: every probe stops the whole process for ~3 s.
Writing probes
An expression can span lines: expr -l objc -O -- alone on a line, the body,
then an empty line. One-line expr -l objc -O -- … works too.
Rules learned the hard way:
- Prefix locals (
vpScreens,vi): short names such asnamesormcollide with symbols in the shared cache ("Multiple internal symbols found"). - Do not rely on lldb persistent
$variables. Anid $xcannot be messaged later ("no known method"), and after declaring a persistent struct variable other$variablesbecame "undeclared". Do the whole job inside one expression. - Cast an
idto(NSObject *)before-descriptionand similar NSObject protocol methods ([(NSObject *)[obj valueForKey:@"x"] description]);[id description]fails with "no known method". - Non-object returns and struct arguments go through a cast
objc_msgSend:((long (*)(id, SEL, id))objc_msgSend)(obj, @selector(displayContentModeForWindowScene:), scene). - C functions — exported or private, in the shared cache only — via
dlsym, cast to their real signature:((id (*)(NSString *))dlsym(RTLD_DEFAULT, "MGCopyAnswer"))(@"ProductType")→iPhone19,4. Declare structs inline (struct VPSwapSettings { … };) when the function takes or fills one; seeposture.zsh. - Classes that exist only in the shared cache need no headers:
NSClassFromString(@"SBDisplayToolService"), then KVC (valueForKey:also reads ivars such as@"_displayToolService") or castobjc_msgSend. To read a struct ivar:class_getInstanceVariable+ivar_getOffseton(__bridge void *)obj(see the readback inposture.zsh). - Never let a block escape the expression. Blocks written in an expression
live in lldb's JIT memory; one stored by the target (a completion handler, an
observer) can outlive it. Pass
nilcompletions; blocks used only inside the expression (helpers likevpAddin the smoke probe) are fine. - Discovery inside the target:
class_copyMethodList/class_copyIvarList/objc_copyClassNamesForImage/ protocol conformance in an expression;image lookup -r -n <regex>anddisassemble -n <symbol>as plain lldb commands.ipsw class-dump <sim cache> <framework>works for frameworks in the sim cache, butipsw dyld disasscrashed on this cache (objc stub parsing), and SpringBoard.framework is not in the cache — use lldb against the live process for those. - Emit records as JSON from inside the expression: build an
NSArrayofNSDictionary, end with[[NSString alloc] initWithData:[NSJSONSerialization dataWithJSONObject:… options:0 error:nil] encoding:NSUTF8StringEncoding];-Oprints it as one line thatjqtakes as is (probes/smoke-springboard.lldb).
Probing an app
Probe apps the same way as SpringBoard; [[UIApplication sharedApplication] connectedScenes] there returns the app's own UIWindowScene. Daemons work too
(backboardd: tools/probe.zsh … backboardd …); UIKit is not loaded there, so do
not call UIKit in those probes. For a daemon that is not running yet, trigger it
first (launch the client, or xcrun simctl spawn <udid> launchctl kickstart …).
App-side hinge API seen in Preferences (for whoever takes that domain):
UIHingeInteraction / UIHingeInteractionUpdate, _UISceneHingeStateClientComponent -hingeState, _UIHingeState -hingeAngle, and UIApplication -currentHingePoseStateForTesting / -setHingePoseStateForTesting:.
Posture: the mechanism
SpringBoard has a built-in display tool, and the harness calls it in-process.
SpringBoardFoundation in the sim shared cache embeds the help text of Apple's
internal suiatool swap command
([-duration=<seconds>] [-deviceOrientation=<orientation>] [-position=<closed|book|open|0.0-1.0>] [-fade | -continuous] [-wait],
printed in full by SBFDisplayToolSwapSettingsUsageDescription()). The client is
SBSDisplayToolService (SpringBoardServices) and talks over BSServiceConnection
com.apple.springboard.sbdisplay. SpringBoard's server, SBDisplayToolService
(held in -[SpringBoard _displayToolService]), checks the caller for the
entitlement com.apple.springboard.sbdisplay.service, hops to the main queue,
parses the options with SBFDisplayToolSwapSettingsFromDictionary and calls
-[SBDisplayToolServiceCoordinator performDisplaySwapWithSettings:completion:].
No binary in the sim runtime carries that entitlement (and there is no
suiatool binary), so posture.zsh skips the XPC front door and does in
SpringBoard, over lldb, exactly what the server does after its entitlement check:
The coordinator runs a synthetic hinge sweep and pushes it through
SBFDevicePoseProvider (_pushCurrentSettingsThroughPose:), so the hinge state
machine, SBDisplayContentModeCoordinator and SBFDisplayContentModeResolver
resolve the displays the same way they would for real hinge motion. The device
turns on and off panels and moves the foreground app between them. targetMode
1 means an absolute position: closed 0.0, book 0.5556 (100°), open 1.0 (180°).
Each swap takes a 10-second testing lock (lockForTesting) so real hinge input is
ignored; posture.zsh … unlock releases it early. In the sim the state persists
after the lock expires (checked 30 s after the swap: unchanged).
posture.zsh then waits until the coordinator is idle (_currentSettings.state == 0, no instant swap or replay running), reads SpringBoard once (the resolver
description, which includes the pose provider's), screenshots both displays,
and prints one JSON object. Exit 0 means the lit panel matches the posture
(closed → cover, book/open → inner), 3 a mismatch.
Results (smoke run, raw/smoke/)
Screenshots: raw/smoke/20261029-134407-closed-d{1,3}.png,
20261029-134427-book-d{1,3}.png, 20261029-134502-open-d{1,3}.png; readbacks
in the matching -readback.txt; posture JSON in the matching .json and in
smoke.json meta.postures. Preferences was in the foreground: closed shows its
compact layout on the cover, book and open show the split layout on the inner.
-[SBDisplayContentModeCoordinator displayContentModeForWindowScene:] returns
1 = disabled, 2 = primaryContent, 3 = auxiliaryContent (matched against the
resolver's names). The cover's scene is SB-main (display identity Main),
the inner's is com.apple.springboard.bringup (Unknown.main[3]).
primaryWindowScene and activeWindowScene follow the posture. Resolver
policies listed: Single, FoldingDual, Yoink, Tent, FullyClosed,
ForceCoverDisplayEnabledOnly; preferred transition style .continuousLive.
Hysteresis — why book anchors at closed
The hinge regions move with where the hinge last stopped
(raw/posture/*-readback.txt):
- At rest at 0°:
closed 0.00→0.47,partiallyOpen 0.47→0.97— a sweep to 100° swaps to the inner. - Closed at 0° →
angle 60: stays.closedon the cover; regions becomeclosed 0.00→0.63,toOpen 0.33→0.97. - Then
bookwithout an anchor (20261029-134037-book.json): 100° < 113°, so it stays.closedon the cover — the run that failed verification. book→angle 60stays.partiallyOpenon the inner (toClosedneeds more).
So posture.zsh book first does an instant swap to closed (-duration=0,
artifacts under <out>/anchor/) and then sweeps 0° → 100°, which is the
"book from closed swaps cover to inner" case in Apple's help text. A sweep from
open to book also lands on .partiallyOpen/inner (checked). closed and open
are the ends of the range and need no anchor; angle sweeps from wherever the
hinge is unless POSTURE_ANCHOR says otherwise — record the starting posture
with any angle result.
Other mechanisms, not used
-[SBDisplayToolServiceCoordinator performHingeReplayWithSettings:samplesPath:completion:]replays loops of the recordedhinge-samples.csv(SBFHingeReplaySettings,-mode=swap|party -loop= -band=fast|medium|slow …). It is for motion studies, not for landing in a posture; reachable the same way if a later agent needs realistic sweeps.- Hinge-angle HID events into the sim's backboardd (the VM's Hinge dylib path)
were not tried: the display tool already exercises SpringBoard's resolver path
with Apple's own sweep, and the sim's CoreSimulator side offers no hinge HID
service (
simctl io enumeratelists none). - Xcode/CoreSimulator: no fold control found (no Simulator.app on this host;
DeviceKit has no hinge strings;
simctlhas no posture subcommand).
Limits
- CoreMotion is not driven. The sweep lives in SpringBoard's
SBFDevicePoseProvider.CMAngleManager +isAvailableis YES (range 0–180°) and SpringBoard's_cmAngleManagerreportsisAngleActiveYES, andCMMagicPoseManager +isAvailableis YES, but nothing here makes locationd's angle stream or magic pose follow the posture. Treat app-side CMAngleManager / CMMagicPoseManager / CoreDevice hinge values as unverified until a probe shows them changing. Likewise backboardd sees no hinge HID event. - What reaches apps is unverified. Preferences moves between panels, but
whether
UIHingeInteraction/_UISceneHingeStateClientComponentreport the posture in an app is for the app-side domain to establish. - Every probe stops the target for ~3 s; a swap started by a probe begins only after lldb detaches.
- SpringBoard's
[UIScreen mainScreen]is the cover (Main, "LCD") in every posture. The innerUIScreen("LCD-1") reported bounds 669×951 right after boot and 951×669 after the first unfold, and stayed 951×669 in all later postures, closed included. Record the posture history with any inner-screen value. - Only portrait device orientation was exercised (
Device: portrait (1);interfaceOrientation1 in every record).-deviceOrientation=is accepted by the swap.
Record format
One JSON file per domain (<domain>.json, e.g. screens.json, mobilegestalt.json),
raw transcripts under raw/<domain>/:
api: stableClass.memberor function name — the join key with vphone-duo.kind:public(SDK headers) orprivate.process: executable name the call ran in (SpringBoard,backboardd,Preferences, …).posture:closed|book|open|angle<deg>as passed toposture.zsh(add the starting posture incallormetafor angles).orientation:portrait|portraitUpsideDown|landscapeLeft|landscapeRight— the interface orientation of the scene the call ran under (SpringBoard: the primary window scene).call: the exact expression, enough to re-run it.value: JSON — numbers and booleans as such,CGRectas{x, y, width, height},CGSizeas{width, height}, objects as theirdescriptionstring,nullwhen there is none.error:null, or the error / exception / lldb error text (thenvalueisnull).
smoke.json is in this format; tools/smoke.zsh shows how a probe's JSON line is
tagged with process, posture and orientation and merged with meta.