This file is the source of truth for private simulator symbols. Everything
here was verified against a live runtime on the environment below. The research
documents in docs/research/ are background and motivation; where they disagree
with this file, this file is right.
Nothing in here is documented by Apple. Treat every symbol as version-coupled
and re-verify after an Xcode upgrade. Verify with the runtime — NSProtocolFromString
plus protocol_copyMethodDescriptionList, or class_copyMethodList — rather
than by reading write-ups, including this one.
| macOS | 15.x (Darwin 25.6.0) |
| Xcode | 26.x, /Applications/Xcode.app/Contents/Developer; 27.0 (27A266a, CoreSimulator 1174.9.2) measured for input on 2026-10-01 |
| Devices | iPhone 17 Pro / iOS 26.5 (3x) · iPad Pro 13-inch M5 / iOS 26.5 (2x) |
| Date | 2026-09 |
| Framework | Path | Notes |
|---|---|---|
| CoreSimulator | /Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator |
System path, not inside Xcode |
| SimulatorKit | $(xcode-select -p)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit |
Xcode 26 and earlier |
| SimulatorKit | $(xcode-select -p)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit |
Xcode 27 — Apple moved it |
Both must be dlopened before any NSClassFromString lookup. Resolve the
developer directory with xcode-select -p, honouring DEVELOPER_DIR.
SimulatorKit has two homes now, and this table said it did not. It read
"Not in SharedFrameworks", which was true when it was written and was
falsified by Xcode 27.0 (build 27A266a): Apple moved the framework to the
top-level Contents/SharedFrameworks and the old
Contents/Developer/Library/PrivateFrameworks directory is not created at all.
Reported and fixed by an outside contributor (PR #1), who found it the
expensive way — simframed threw frameworksUnavailable before it ever
attached, and the MCP surface showed that as an opaque 60-second timeout with
no mention of a framework path. That diagnosis gap is its own item (DEFERRED
185) and this is independent confirmation of it.
loadFrameworks() therefore takes a list of candidate paths per framework
rather than one, tries the old location first so nothing changes on an Xcode
that still has it, and names every candidate it tried when none exist. Verified
on 26.6 after the change: capture healthy, 18 elements, fusion 0.857. The
Xcode 27 half is the contributor's reading, not ours — there is no Xcode 27 on
the machine this table was measured on, so treat the second row as reported
rather than verified here, and confirm it before relying on it.
SimServiceContext (CoreSimulator)
+sharedServiceContextForDeveloperDir:error: -> SimServiceContext
-defaultDeviceSetWithError: -> SimDeviceSet
.availableDevices -> [SimDevice]
.state == 3 -> Booted
.UDID (NSUUID), .name (NSString), .runtime
.io -> SimDeviceIOClient
.ioPorts -> [SimDeviceIOPortInterface] (13 on a booted iPhone)
-descriptor -> port descriptor
conforms to SimDisplayIOSurfaceRenderable
-displaySize (CGSize) -> MUST be non-zero: this is how you pick the live port
-framebufferSurface -> IOSurface, read on demand
-registerCallbackWithUUID:damageRectanglesCallback: -> per-redraw signal
A booted device exposes several ports whose descriptors conform to
SimDisplayIOSurfaceRenderable. Only one is live. On the test iPhone, port
index 1 reported displaySize 0x0 and a nil framebufferSurface forever; port
index 2 was the real display. Select by non-zero displaySize — never by
"first conforming port", and never by index, which is not stable.
Observed port descriptors on a booted iPhone (indices are illustrative, not stable):
| Descriptor | Use |
|---|---|
SimScreenCaptureService |
— |
SimScreen … SimDisplayIOSurfaceRenderable (x2) |
display; one live, one not |
SimScreenAdapter |
— |
SimAcceleratorMetalDevice, SimAcceleratorIOSurface |
— |
SimLegacyHIDDescriptor |
input — the Phase 1 entry point |
SimStreamProcessable (x3) |
— |
SimAudioHostRoutable |
— |
SimDeviceIOMachServiceProvider (x2) |
— |
framebufferSurface returns an IOSurface usable with the public accessors.
Lock read-only, honour bytesPerRow (it is not width * 4 — a real surface
reported 4864 for a 1206-wide display), and treat the pixels as BGRA.
Do not cache the surface. Re-reading framebufferSurface each capture costs
~0.13 ms and means a reallocated surface (rotation, resize) is picked up with no
re-attach.
SimDisplayIOSurfaceRenderable:
| Selector | Encoding |
|---|---|
registerCallbackWithUUID:ioSurfacesChangeCallback: |
v32@0:8@16@?24 |
unregisterIOSurfacesChangeCallbackWithUUID: |
v24@0:8@16 |
framebufferSurface |
@16@0:8 |
maskedFramebufferSurface |
@16@0:8 |
SimDisplayRenderable:
| Selector | Encoding |
|---|---|
registerCallbackWithUUID:damageRectanglesCallback: |
v32@0:8@16@?24 |
unregisterDamageRectanglesCallbackWithUUID: |
v24@0:8@16 |
registerCallbackWithUUID:displayPropertiesChanged: |
v32@0:8@16@?24 |
displaySize |
{CGSize=dd}16@0:8 |
displayPitch, displaySizeInBytes |
Q16@0:8 |
SimDeviceIOPortInterface: connectToDeviceIO:, disconnect,
portIdentifier, ioPortClass, uuid, descriptor.
The callback blocks are declared untyped (@?), so arity cannot be read from
the protocol. registerCallbackWithUUID:damageRectanglesCallback: works with a
one-argument block. Keep a strong reference to the block for as long as it is
registered.
| Claim | Reality |
|---|---|
registerCallbackWithUUID:ioSurfacesChangeCallback: delivers an IOSurface per frame |
It fires when the surface is reallocated, which is rare. Registering it and waiting produced zero callbacks in 5 s of heavy screen activity. The per-redraw signal is damageRectanglesCallback: (~52/s), and pixels come from reading framebufferSurface. |
mainScreenSurfaceForSimulator: is a SimulatorKit symbol |
It does not exist in SimulatorKit. It appears to be a helper inside idb, not framework API. |
The unregister selector is unregisterIOSurfaceChangeCallbackWithUUID: |
It is unregisterIOSurfacesChangeCallbackWithUUID: — plural. |
| (implied) any port conforming to the renderable protocol will do | Several conform; only the one with non-zero displaySize vends a surface. Picking the first match yields a permanently nil framebufferSurface, which looks exactly like the API being broken. |
IndigoHIDMessageForMouseNSEvent takes 9 arguments on iOS 26 |
It takes six on this Xcode: (CGPoint *, CGPoint *, IndigoHIDTarget, NSEventType, NSSize, IndigoHIDEdge). The binary states its own prototype as a string, so this needs no guessing. The digitizer target 0x32 was correct. |
The HID client is reached through the SimLegacyHIDDescriptor IO port |
It is constructed directly from the SimDevice with initWithDevice:error:. The port exists but is not on the path used here. |
Measured 2026-10-01, Xcode 27.0 (27A266a), CoreSimulator 1174.9.2, iPhone 17 Pro / iOS 26.5, on two devices.
From CoreSimulator 1155.4 the legacy path below is unreliable, and it fails
silently. The guest runs a new HID daemon, dtuhidd. The first time anything
connects to it, the guest sets com.apple.coredevice.dtuhidd.active and
backboardd tears down the legacy digitizer, button and keyboard services for
the rest of that boot. Legacy messages are still accepted, and they reach
nothing. Buttons and keys are always dropped. Touch is dropped on some boots and
intermittently within one. On one device, every legacy input failed across two
boots, a fresh daemon and resetHIDSession: 0 of 9 taps, a swipe, and the lock
button. The same device took 10 of 10 taps over dtuhidd.
SimDevice -lookup:error: ("com.apple.coredevice.feature.remote.hid.digitizer") -> mach_port_t
xpc_endpoint_create_mach_port_4sim(port, 0, 0) -> xpc_endpoint (+1, consumes the send right) dlsym
xpc_connection_create_from_endpoint(endpoint) -> xpc_connection (+1, unresumed)
xpc_connection_enable_sim2host_4sim(connection) required: without it the peer is seen, payloads never are dlsym
Messages are plain XPC dictionaries, with the wire format taken from facebook/idb's
SimulatorDTUHIDTransport (MIT):
| key | type | value |
|---|---|---|
messageType |
string | IndigoDigitizerEvent · IndigoKeyboardButtonEvent · IndigoButtonEvent |
isBarrier |
bool | true only on the liveness probe |
featureIdentifier |
string | the looked-up service name |
payload |
dictionary | per type, below |
- Digitizer:
pointOne {x, y}as doubles normalised to 0–1 from the top left, theneventType(uint64: 0 start, 1 position, 2 end),edge0 andtarget0. - Keyboard:
usageCode(uint64, the same HID usage the legacy path sends) andstate(uint64, 1 down, 2 up; 0 is rejected at decode). - Button:
usagePage0x0C plususageCode(home 0x40, lock 0x30, Siri 0xCF, volume 0xE9/0xEA) andstate.
Liveness must be proven, not assumed. Every step above succeeds against a
dtuhidd that cannot run, because launchd vends the port for a demand-launched
job either way, and a send to it reports no error. A barrier keyboard event
with usage 0, sent with xpc_connection_send_message_with_reply, is the only
evidence. Allow 4 s for the reply, then 200 ms for the device to open.
Paste reads the host's clipboard. On this CoreSimulator the guest's paste is
served by dtpasteboardd, not from what simctl pbcopy wrote.
pbcopy "Wallpaper" + Cmd-V put the Mac's own clipboard into the field, behind
an "Allow Paste" prompt. On this transport paste types key events instead.
The legacy section below is still how input works before 1155.4, and it is the
fallback when dtuhidd cannot be reached. doctor reports that fallback as
warn.
Verified end to end: a single tap at (200, 835) points landed on the intended tab bar item and the screen changed, with idb not installed in the path at all.
SimulatorKit.SimDeviceLegacyHIDClient (Swift class, ObjC-visible)
NSClassFromString("SimulatorKit.SimDeviceLegacyHIDClient")
-initWithDevice:error: -> client
-sendWithMessage:freeWhenDone:completionQueue:completion:
-resetHIDSession
IndigoHIDMessageForMouseNSEvent (exported C, dlsym from SimulatorKit)
(CGPoint *location, CGPoint *unused, IndigoHIDTarget, NSEventType, NSSize screen, IndigoHIDEdge)
The class is not registered under a bare SimDeviceLegacyHIDClient; it is a
Swift class, so look it up as SimulatorKit.SimDeviceLegacyHIDClient (or the
mangled _TtC12SimulatorKit24SimDeviceLegacyHIDClient). Its alloc must go
through the runtime, since alloc() is unavailable in Swift.
| Argument | Value that works |
|---|---|
location |
CGPoint in points, in the device's own coordinate space |
second CGPoint * |
nil is accepted for down/up |
IndigoHIDTarget |
0x32 — the digitizer. The research's one correct guess. |
NSEventType |
AppKit values: leftMouseDown (1), leftMouseUp (2), leftMouseDragged (6) |
NSSize |
the device screen size in points, e.g. 402x874 |
IndigoHIDEdge |
0 |
A tap is a leftMouseDown followed by a leftMouseUp at the same point.
IndigoHIDMessageForButton does not use the digitizer target. Sending a
home press to 0x32 is silently swallowed — no error, no effect. Target 0
works.
| Button | IndigoHIDButtonKeyCode |
Verified |
|---|---|---|
| home | 2 (with target 0) | yes — full transition to springboard |
| lock, siri, volume | unknown | no |
The unverified codes deliberately return nil rather than a guess. A wrong code
here is not a no-op: reports exist of the Siri path crashing backboardd, and
silently locking someone's simulator is a poor failure mode. To identify one,
sweep codes with target 0 on a simulator you are willing to disturb and watch
simframe state --since for a screen change.
IndigoHIDMessageForKeyboardArbitrary sends raw USB HID usage codes, not
characters. iOS maps those through whatever keyboard is currently active, so
the same usage produces different text on different layouts.
Measured on the same field, same device, back to back:
| Route | Result for "Fryer 3" |
|---|---|
type() — key events |
إقغثق ۳ |
paste() — pasteboard |
Fryer 3 |
The device had fa (Persian) among its installed keyboards. Note the mangled
text is exactly five letters, a space and a digit: the usage codes were right,
the layout mapped them elsewhere.
The device setting is what decides this, and it is not the software keyboard picker:
AppleKeyboards = ( "en_US@sw=QWERTY;hw=Automatic",
"fa@sw=Persian;hw=Automatic", ... )
hw=Automatic means the hardware layout follows whichever software keyboard
is currently active, which iOS remembers per field. Automation cannot reliably
control that, and switching the phone's keyboard to English does not fix a field
iOS has already associated with another layout.
IndigoHIDMessageForKeyboardNSEvent(NSEvent *) looks like the answer, since an
NSEvent carries characters as well as a keyCode, and it is what
Simulator.app uses. It is not.
Synthesising events with NSEvent.keyEvent(... characters: "X" ... keyCode: 0)
produced ش for every character — letters, digits and space alike — with the
count growing by exactly the number typed. The constructor reads keyCode, not
characters, so keyCode: 0 mapped everything to one key, which the Persian
layout renders as ش. Supplying real virtual key codes only returns you to
layout mapping.
Beware a trap here: an earlier run appeared to type Fryer correctly through
this path. That text was left in the field by a previous paste() call. Clear
the field between attempts, or you will confirm whatever you hoped for.
So: key events are for interaction, the pasteboard is for content. There is
no layout-independent key-event route from outside the device. paste() runs
simctl pbcopy and then Command-V (usage 0x19 with left GUI 0xE3), which
carries characters rather than key positions.
Because the failure is silent — text appears, so nothing looks broken —
inputStatus() reads AppleKeyboards and warns when any non-English, non-emoji
keyboard is installed. simframe doctor surfaces it.
All exported C, all dlsym-able from SimulatorKit:
| Function | Signature |
|---|---|
IndigoHIDMessageForButton |
(IndigoHIDButtonKeyCode, IndigoHIDButtonOp, IndigoHIDTarget) |
IndigoHIDMessageForKeyboardNSEvent |
(NSEvent *) |
IndigoHIDMessageForKeyboardArbitrary |
(uint32_t, IndigoHIDButtonOp) |
IndigoHIDMessageForScrollEvent |
(uint32_t, double, double, double, IndigoHIDTarget) |
IndigoHIDMessageForPressureEvent |
(CGPoint *, float, float, IndigoHIDTarget, NSSize) |
IndigoHIDMessageForDigitalCrownEvent |
(double) |
The binary carries these prototypes verbatim as strings, which is the fastest way to check a signature after an Xcode upgrade:
strings SimulatorKit | grep '^IndigoHIDMessage'
Verified end to end: the frontmost app's tree read from the host, in device
points, with nothing injected into the guest, no NSView, and idb not on the
path at all.
AXPTranslator (/System/Library/PrivateFrameworks/
AccessibilityPlatformTranslation.framework)
+sharedInstance -> AXPTranslator (the macOS one, on a Mac)
.bridgeTokenDelegate = <your delegate> ← held WEAKLY; retain it yourself
-frontmostApplicationWithDisplayId:bridgeDelegateToken: -> AXPTranslationObject (.pid)
`.pid` is the GUEST pid, and it is the same number `simctl launch` prints
— measured 10695/10695 and 10762/10762 on iOS 26.5. That equality is what
lets `launch` say whether the app it started actually came to the front
(item 169), by comparing two integers instead of matching a display name
against a bundle id. This object answers nothing that names a bundle.
AXPMacPlatformElement
+platformElementWithTranslationObject: -> an element answering NSAccessibility
-accessibilityMultipleAttributes: -> several attributes in ONE guest hop
-accessibilityAttributeValue: -> AXChildren, AXRole, AXValue, AXIdentifier…
-accessibilityLabel, -accessibilityFrame
SimDevice
-accessibilityPlatformTranslationToken ← the token. Do not invent one.
-sendAccessibilityRequestAsync:completionQueue:completionHandler:
completionHandler is ^(id) — ONE argument
The delegate implements three selectors. The first is the one that matters; it returns a block taking one argument and returning the response:
- (id (^)(id))accessibilityTranslationDelegateBridgeCallbackWithToken:(NSString *)token;
- (CGRect)accessibilityTranslationConvertPlatformFrameToSystem:(CGRect)r withToken:(NSString *)t; // return r
- (id)accessibilityTranslationRootParentWithToken:(NSString *)token; // return nilInside the block, forward the request to the device and bridge async to sync:
wait on a semaphore, and return AXPTranslatorResponse.emptyResponse — never
nil — if the guest does not answer. The completion queue must never be main,
and every translator call belongs off the main queue.
Returning the rect unchanged from the frame conversion is deliberate: frames then stay in the device's own top-left point space, which is the space input speaks, so an element's centre is a tap point with no conversion.
An earlier attempt had the bridge and transport working — a real
AXPTranslationObject whose pid matched the app — and still read nothing,
because of these:
| The token is the device's | SimDevice.accessibilityPlatformTranslationToken publishes it. A token you invent routes to nothing. |
| The translation object is not the element | Passing it to requestWithTranslation: and processTranslatorRequest: returns a response whose resultData is nil. AXPMacPlatformElement.platformElementWithTranslationObject: wraps it in something that answers ordinary accessibilityAttributeValue: calls, and the whole tree walks from there. |
So the AXPTranslatorRequest constants below are not needed for reading a tree
host-side. They are kept because they are the in-guest vocabulary idb uses, and
because processTranslatorRequest: is still how a custom attribute would be
asked for.
SimulatorKit.SimAccessibilityManager implements those same three delegate
selectors and is what Simulator.app uses — but it wants an NSView through
addWithDisplayView:, which is exactly what a daemon does not have. Reading its
selector list is useful; instantiating it is not.
accessibilityMultipleAttributes: takes an NSArray of attribute names and
returns a dictionary keyed by them. Every accessibilityAttributeValue: is a
synchronous hop into the guest, so the cost of a walk is round trips rather than
work: eight attributes per node is eight hops, and on a machine where a hop is
slow the round trips are the read. A hosted CI runner spent 28 s inside one
read and returned nothing.
Measured on the same fourteen nodes: 112 calls / 25 ms one at a time, 14 calls / 10 ms batched, identical values. The win on this machine is 2.5×; the win where it matters is eight times fewer round trips.
Batching does not cover everything. accessibilityLabel is a direct accessor
rather than an attribute name, and AXChildren is fetched on its own, so a node
costs three hops — one batch, one label, one children — not one.
Same screen, same element count, alternating reads:
| Path | Median |
|---|---|
idb ui describe-all |
203–224 ms |
| host-side, in-process | 42–55 ms |
A read on a freshly-switched app is slower — 700–900 ms once, while the guest populates — then settles back. An app still launching genuinely has no tree yet and returns the application node alone; that is worth reporting rather than retrying until it looks populated.
Four things can cut a walk short, and every one of them produces something that
looks exactly like a small screen: the depth cap, the node cap, the time budget,
and a guest request that misses its deadline and is answered with
AXPTranslatorResponse.emptyResponse — which makes that subtree look genuinely
childless. Return emptyResponse rather than nil there, because nil crashes the
translator.
None of that is visible in the nodes, so the shortfall has to travel with them.
accessibilityTree() returns whether the tree is all of one, the daemon reports
it as axTruncated, and a partial tree is deliberately not claimed as an
ax source — the layer above treats accessibility elements as the real hit
targets and then writes them into screen memory, and half a screen remembered as
a whole one is worse than a screen read again from pixels.
Two more, learned by getting them wrong:
- Constructing the bridge proves nothing. The classes and selectors existing is exactly the state a bridge is in when it reads nil. Any "is accessibility available" answer has to come from an actual attribute read.
- The translator is a process singleton and the delegate captures one device's token, so a bridge belongs to the device it was built for. Rebinding to another device must discard it.
Everything here is version-coupled and re-verified per Xcode. SIMFRAME_AX_DRIVER=idb
exists as the escape hatch for the day this stops working, and simframe doctor
treats a driver that was asked for as chosen rather than degraded.
Unused by the path above, kept for the in-guest request form.
| Request type | Value |
|---|---|
Attribute |
2 |
MultipleAttribute |
5 |
| Attribute | Value | Attribute | Value | |
|---|---|---|---|---|
| ClassName | 7 | Label | 33 | |
| Children | 8 | Role | 45 | |
| Frame | 21 | Value | 53 | |
| Identifier | 25 | Traits | 77 | |
| IsEnabled | 27 |
A multiple-attribute request carries its list as
parameters[@"attributes"] — a dictionary with that key, holding an
NSArray<NSNumber *>. idb's header warns that passing a bare array "throws
inside the guest and takes the reader down with it". idb also leaves
clientType unset deliberately: setting it makes the app-side children handler
answer from a stale automationElements override.
idb reads in-guest: SimulatorFrameworkBridge is loaded inside the
simulator, dlopens the framework from the booted runtime root, and closes the
loop locally through processTranslatorRequest:. testa reads host-side
through sendAccessibilityRequestAsync:, which is the topology above. Read idb
for the constants and semantics; read testa for the shape.
Every one of these cost real time here, and all of them look like "the private API is broken" rather than like a mistake:
- Handing a call
DispatchQueue.mainand then blocking main. A deadlock that presents as the API silently returning nothing. The AX callback queue must never be main, and translator calls belong off the main queue entirely. - Wrong block arity crashes the process. A two-argument
(response, error)completion handler forsendAccessibilityRequestAsync:exits with SIGTRAP and no output. It takes one argument. - A weakly-held delegate that nobody retains is deallocated immediately and the translator answers nil, exactly as if it were never installed.
objc_copyClassListenumeration crashes the probe outright. Dump named classes instead.- Naming a Swift loop variable
typeshadowstype(of:)and produces a compile error that reads as unrelated. - Reading a text field to check typing without clearing it first confirms whatever you hoped for, using text a previous attempt left behind.
Everything below is a hypothesis carried over from the research and must be checked against this machine before any code depends on it.
SimAccessibilityManagerin SimulatorKit as a route to the tree. Its delegate selectors are verified by inspection; instantiating it is not, and it wants anNSView, so nothing here depends on it.
To verify: dump the symbol from the binary on this machine, read the current
source of a working implementation (not its documentation), and confirm the
effect end to end — for input, send one tap at a known coordinate and check
simframe state --since reports the expected change. Record the result here.