Docs Virtual iOS device How it works
How the virtual iOS device works#
For contributors who want to change it. To use it, read usage.
On this page (6)
Nothing from Apple is in this repository. Every Apple part comes from the user's own restore images, on private copies. Legal notes lists each protection this changes and why.
The whole picture#
restore images -> prepared disks and kernel -> one-step start
|
engine (patched QEMU, software translation on x86_64) <---+
|-- tomacco-vphone machine: six application processors and a SEP
|-- reims-vgpu: Apple's paravirtual GPU in Rust on Vulkan, with Metal
| shaders going through the metal2vulkan translator
`-- guest tools: control service, setup helpers and diagnostics,
reached over virtio-vsock
The engine's sources are not in this repository. tools/build_ios_paravirt.py fetches pinned upstream revisions
and applies the ordered patch series in integrations/ios-emulator.
| Upstream | What it gives | License of its patches |
|---|---|---|
| jprx/qemu-sptm | QEMU that runs Apple's SPTM and TXM | GPL-2.0 |
| yaelliethy/orchard, steelbrain/reims-vgpu | The vphone machine's graphics device and renderer | LGPL-3.0 |
| steelbrain/metal2vulkan | The Metal shader translator | LGPL-3.0-or-later |
| rust-vmm/vhost-device | The virtio-vsock backend for the guest channel | Apache-2.0 OR BSD-3-Clause |
The machine#
The guest is Apple's vphone600, the virtual device of Private Cloud Compute (PCC), running the iOS 27 userland of a
real iPhone image on top of the PCC kernel. integrations/ios-emulator/vphone-research.c is the board.
Processors. Six application processors (APs) plus a SEP. QEMU's TCG translates ARM to x86_64 in software, because
KVM cannot run an ARM guest on Intel. Each AP has its own host thread (MTTCG). With host_cpu_policy: "performance", AP
threads go to separate physical cores, fastest first. Sixteen APs start but are not supported or measured, and the
source tree's maximum of 32 does not work.
Memory. 8 GiB of guest RAM. The translation cache takes up to 2 GiB of host memory
(translation_cache_mib: 2048, the most this host architecture allows).
Boot. The kernel starts directly, without Apple's boot chain. SPTM (Apple's page-table monitor) enters at the guarded level,
TXM (its trusted execution monitor) runs at EL0 and XNU (the kernel) at EL1. Unknown Apple hypercalls return the real
NOT_SUPPORTED, and unknown system registers still fault, rather than being faked.
Two engine options carry most of the speed. Both are boot options, inert until asked for, and a generated profile asks for both:
target_page: "16k": 16 KiB translated pages.bti: "off": the emulated CPU reports no FEAT_BTI, the ARM branch-target check.
With both on the development host, a Home-screen swipe went from 2-6 to 10-15 changed frames per second, with the processors at 30-40 % instead of 60-73 %.
Pointer authentication is bypassed. The engine does not enforce ARM pointer authentication, and
qemu-arm-pauth-bypass-inline.patch keeps that and makes it faster. Invalid pointers are not caught, so the guest is not a
security boundary.
The SEP is Apple's security processor. Unmodified firmware runs on its own CPU with 64 MiB of private RAM. vphone-sep.c
is its mailbox, vphone-asmf.c its shared measurement registers, and vphone-vstr.c its persistent storage. There are no
synthetic replies, so SEP services work only as far as the firmware's own code works.
Storage. Apple VirtIO devices for the root and auxiliary disks. Writable disks must be asked for by name, and the launcher
locks the real files. A flush is a real flush with error reporting. The root disk is a qcow2 overlay on the previous
run's root disk (tools/ios_disk_overlay.py) whenever a qemu-img is found, or with start_disks: "copy" or without
qemu-img a raw copy. The launcher attaches an overlay with the qcow2 block driver and lets the file grow to the disk's
virtual size.
Input. A USB touchscreen and keyboard (vphone-usb and vphone-hid), with Home and the side button as GPIO or HID. iOS
ignores the virtio keyboard, which is why key has no effect. Touch has one contact.
Time. The guest clock follows wall-clock time. An older instruction-counting clock showed that a faster clock does not make iOS faster; it gives iOS less work per second of its own time, and its watchdogs then kill what draws the interface.
Models. The device tree carries one gestalt variant. device_model copies its tags into the product properties, and the
other models are derived from it with their own panel, artwork subtype and corner radius.
The renderer#
iOS's own Metal driver, from the PCC image, talks to a paravirtual GPU. reims-vgpu implements that device in Rust on Vulkan, and C code adapts it to QEMU.
Metal shaders are compiled to Vulkan through metal2vulkan. The compiler plugin (ios.emulator.build_gpu_plugin) is
cross-built on Linux, from vphone-cli, and loads inside the guest.
What to know before changing it:
- XR formats. iOS's extended-range formats render through a linear RGBA16F attachment and encode their guest texels on store. XR multisampling is open.
- Declined work is logged. The renderer records every operation it declines in its report's
renderer/fail.log, always on, cut back at 64 MiB. Start there for a display defect. - Memory has no budget. The renderer holds what iOS has drawn. Images of deleted resources are given back, surfaces when their
generation ends, and depth and hash-keyed targets only by age above half the card; sole copies stay. A budget that retired images while a draw was being prepared ended the engine, so
the session stops the careful way instead (
tools/ios_host_pressure.py,tools/run_ios_paravirt.py). - The GPU and guest RAM. The renderer can let the GPU read and write guest RAM directly, which spares a render
Store its CPU copy into the guest's pages. On the development host (RX 6900 XT, Mesa 26.2.5) the driver imports a
host pointer only over private anonymous memory: 2 GiB of shared anonymous memory, of a memfd mapped shared or
private and of a
/dev/shmfile are refused withERROR_INVALID_EXTERNAL_HANDLE. Guest RAM is shared memory once the guest channel's vhost-user device maps it too, so there every block logshost_ram_import ... ok=falseand Stores copy on the CPU.--guest-memory-import dma-buf(guest_memory_importon the paravirtual start) is the other route: the launcher lays guest RAM on a memfd sealed against resizing, which QEMU maps throughmemory-backend-fileat the 2 MiB alignmentmemory-backend-ram,share=onused, and setsREIMS_VGPU_GUEST_IMPORT_DMA_BUF=on. The renderer (reims-guest-ram-dma-buf.patch) cuts each window of guest RAM from the memfd with/dev/udmabufand imports it as a dma-buf. A window is the kernel'sudmabuf.size_limit_mb, 64 MiB unless raised, is imported on the first reference into it and pins its pages while imported. The request receipt'sguest_memory_importnames the backing and any fallback;renderer/fail.loghasguest_dma_buf=armedon itsvk_capsline andhost_ram_import id=... route=dma_buf ... ok=trueper window, orhost_ram_import_dma_buf_refusedwith the check that refused. Off the phone on that host, a sealed 2 GiB memfd imported as 32 windows in 316-323 ms, a GPU fill of a used 64 MiB window took 2.5-2.7 ms, and the CPU saw the GPU's writes. On a live phone every render Store and GVA Store then went to the GPU (render_flush_gpu_direct,gva_flush_gpu_direct; 2 847 in one boot, none declined, no window refused) and the windows in use reached 124 and 125 of 128; the present, which used to take the frame a Store had read back, reads and converts its own (present_capture_fresh_nothing). GPU access to guest RAM has the measurements and why the setting stays off by default. - Frames.
ios.emulator.controlframewithtransport:"file"writes an uncompressed PPM into a private, memory-backed directory, and reports whether the picture changed. A viewer can skip the reload then. - A frame's cost on the drain. With no host window carrying the frame, each frame's render Store reads the RGBA16F front
surface back, copies it into the guest's pages and converts it into the BGRA8 frame the present and the frame mailbox
use. The copy runs on up to four threads in row parts with streaming stores, and each part converts its rows right
after copying them;
REIMS_VGPU_STORE_PUBLISH_BANDS=offbrings back the single copy with the conversion beside it. With guest RAM imported as dma-bufs the GPU writes the guest's pages and the present reads back and converts the frame instead. The renderer's census inrenderer/fail.logtimes each part (store_routes:native_float_readback_us,native_float_publish_us,store_capture_convert_ussummed over the parts;frame_mailbox:offer_us). - No GPU access to shared guest RAM on the development host. Without
guest_memory_importdma-buf, every session there logshost_ram_import ... ok=false(ERROR_INVALID_EXTERNAL_HANDLE) for each 2 GiB block of guest RAM, so the renderer's GPU-direct writeback and gather rails are not used and Stores copy on the CPU. That the driver refuses shared memory is measured: see the GPU and guest RAM above.
Device contracts: reims-device-capabilities.md, reims-display-panel.md, reims-texel-copy-regions.md.
The guest tools#
Tomacco's own programs, in C, built on Linux with clang, an ARM64e-capable linker and ldid (ios.emulator.build_guest_tools). No Apple
SDK is used. The sources are integrations/ios-emulator/guest-*.
Getting them in. ios.emulator.prepare_guest_tools installs the payload into unsealed Preboot through a networkless Linux APFS
helper, on an independent copy of the disks. A trust cache names their hashes, with local development signatures, not Apple's. Original
launchd then finds the jobs through its SupplementalPaths contract.
The channel. The host reaches the guest's control service over virtio-vsock, and ios.emulator.guest is the API. Requests have
receipts. A reply that outlives its wait is followed through ios.emulator.guest_result, and an install is never sent twice.
Development identity. development_device: true prepares a virtual, non-production identity, so iOS's own MobileActivation takes its
existing local path. Neither that daemon nor its answer is patched.
App signing. Two things decide before boot whether an executable runs: its code-directory hash must be in the trust cache the guest booted with, and installd admits an ad-hoc signed app only through the development-signing adapter's policy.
- The build copies a bundle into the payload, signs it ad hoc, adds its hashes to the trust cache and compiles its identity into a policy
table inside the adapter (
guest-development-protocol.h). - Inside installd, the adapter enables ad-hoc signing only when the original validator returns a table entry's hash and identifier. An app outside the table keeps the original policy.
- A new adapter needs a kernel that pins its hash, because the kernel maps files from
/private/varas executable only for hashes on its own allow-list.tools/prepare_ios_adapter_pin.pyswaps that one 20-byte slot, and a start does this by itself. tools/ios_application_bundle.pyholds the bundle checks, the same inconfigure, the build and the start.
Quiet services. guest-quiet.c unloads services that only burn time under emulation, and guards locationd.
A start and a stop#
tools/ios_virtual_iphone.py runs them, and tools/run_ios_paravirt.py supervises the engine. native/ios_emulator/virtual_phone.rs is the
operations. They only call public operations.
- Checks. Inspect each app, and refuse a kernel or payload that does not fit the tools, before any disk copy.
preparing. Make a private copy from the last verified run (or an overlay on it), and reinstall the guest tools.booting. Run the engine as an independent job, and wait for the guest service.- Watchdog and time zone, then Auto-Lock.
installing. Install each app whose hash the base does not record.unlocking. Home presses, or the swipe on a phone with no Home button.launch-accepted. Then look at the panel for up to a minute, fordisplay_visible.
Every attempt is recorded before its disk copy exists, so a failed start cannot leave a copy that blocks the next one.
tools/ios_start_timing.py puts each start's and stop's steps together from the start's own marks, the preparation's
step_times and the launcher's timing, for start_timing and stop_timing in the status.
A stop asks the guest to flush and write stop.json, lets the emulator exit, and then checks the flush, exit code 0 and complete disk
hashes. Only that makes a run a base. tools/ios_run_verdict.py puts that evidence and the launcher's outcome into
words, which the state keeps as verdict and ios.virtual.status shows as last_run. Near the session limit, or when the guard trips, the supervisor
asks the control plane for this same stop.
Where to read next#
| To change | Read |
|---|---|
| The USB device and host client | vphone-usb.md |
| Touch and keyboard | vphone-hid.md |
| Engine builds, patches and their licenses | integrations/ios-emulator/README.md |
| PMP firmware and controller research | pmp-research.md, asc-mailbox.md, asc-controller.md |
| The T8140 MCC and PTD models | mcc-t8140.md, ptd-t8140.md |
| Reproducible engine builds | Building |
| The earlier restore-shell engine | Research engines |
Tests that need neither an image nor a GPU cover the patches and guest programs, in tests/python/. Live checks are
tests/ios_*_integration.py.