SwiftUI app that hosts the engine (QEMU + ranchu board + goldfish services +
libva_gfx) in-process, shows guest frames with Metal, and bridges Android
hardware to iOS through host/include/va_host.h.
brew install xcodegen # project.yml -> VirtualAndroid.xcodeproj
cd app && xcodegen generate && cd ..
xcodebuild -project app/VirtualAndroid.xcodeproj -scheme VirtualAndroid \
-sdk iphonesimulator -destination 'platform=iOS Simulator,name=<device>' \
-derivedDataPath app/build/DerivedData build
xcrun simctl install <udid> app/build/DerivedData/Build/Products/Debug-iphonesimulator/VirtualAndroid.app
xcrun simctl launch <udid> dev.virtualandroid.app -VAEngine stub
app/scripts/run_pure_tests.sh # bootconfig == tools/make_initrd.py, geometry, argv, keymapDevice: set DEVELOPMENT_TEAM in project.yml. The entitlements
com.apple.developer.kernel.increased-memory-limit and
...extended-virtual-addressing need a paid team; Debug adds
get-task-allow (debugger attach = JIT via CS_DEBUGGED, e.g. Xcode/StikDebug).
-sdk iphoneos CODE_SIGNING_ALLOWED=NO checks that it compiles without a team.
JIT on a device (docs/JIT.md): build the engines with
engine/scripts/ios/make_xcframeworks.sh --device-backend tcg+tcti; the app
then embeds the JIT engine and the TCTI + AOT engine and picks JIT when a
debugger (StikDebug, universal.js) is attached at launch. Start screen: "JIT
when opened from StikDebug" (VAJIT, default on) and "Ask StikDebug for JIT
when opened from the home screen" (VAJITAskStikDebug, default on). Developer
switches: -VAJIT NO (TCTI for this launch), -VAJIT26 auto|universal|legacy|none
(engine region protocol; none under Xcode's debugger, which stops on the
brk; on a TXM device it means no JIT). The engine's stderr goes to
vms/default/qemu.out on the device (-VAStderrConsole YES keeps the console).
Engine/Engine.swift defines VMEngine (lifecycle) + EngineInput
(the va_engine_* calls). Two implementations:
StubEngine: 60 Hz animated test pattern rendered into a ring of BGRA IOSurfaces, simulated 5 s boot (BOOTING -> BOOT_COMPLETED), touch/key echo, auto-rotation from the accelerometer it enables, and "simulate guest request" toggles (Diagnostics sheet) that drive everyVaHostCallbacksentry through the same C ABI the real engine uses.QemuEngine: dlopensFrameworks/qemu-aarch64-softmmu.framework, resolvesqemu_init(argc, argv)/int qemu_main_loop()/qemu_cleanup(int)(QEMU 10 signatures) and theva_*symbols, callsva_host_set_callbacks()andva_host_set_present_callback()beforeqemu_init, runs QEMU on an 8 MB-stack pthread with UTM's exit() interception (Support/VAEngineRuntime.m), and controls it over QMP on a socketpair (-chardev socket,fd=N). ranchu dlopens libva_gfx (-M ranchu,gfx-lib=<bundle>/Frameworks/va_gfx.framework/va_gfx; its default lookup is the same path) and calls the present callback on a renderer thread with zero-copy BGRA IOSurfaces (row 0 = top), which the Metal view retains until the next frame.
Selection (Automatic): QEMU when the framework is embedded and an image is
installed, else the stub. scripts/embed_engine_frameworks.sh (build phase)
embeds every xcframework from engine/build-ios/xcframeworks and
gfx/build-ios-xcframeworks (slice for the SDK) and writes
VAEngineBackend.txt (tcg = JIT, tcti) from qemu-backends.txt; the app
uses -accel tcg,thread=multi,tb-size=1024 (simulator), ...,tb-size=512,split-wx=on
(device + JIT) or, for TCTI, a code buffer sized from os_proc_available_memory() at
launch: everything above the 2 GB guest and a 1.5 GB reserve, 512-2048 MB in 256 MB
steps (AccelMode.tctiTBSizeMB; logged as "TCG code buffer N MB"). TCTI streams are
big: a boot plus ten minutes of UI fills about 1 GB, and a full buffer is a tb_flush
(all vCPUs stop, all code including AOT hits is translated again). The buffer is plain
anonymous memory, so only the used part counts. -VATBSizeMB N forces a size; the size
is not part of the snapshot identity. A JIT build is refused when the
process cannot JIT. VA_STUB_ONLY=1 skips embedding. QEMU can run once per
process (globals); a second boot asks to relaunch.
An image pack is a folder with:
kernel-ranchu ramdisk.img system.img vendor.img encryptionkey.(qcow2|img) userdata.qcow2 cache.qcow2 [manifest.json]. iOS has no mke2fs, so the empty
ext4 disks are made on the Mac:
app/scripts/make_userdata_qcow2.sh --pack # -> app/build/ImagePack (APFS clones, ~10 MB real)Side-load into the simulator (clones, no extra space on the same volume):
DATA=$(xcrun simctl get_app_container <udid> dev.virtualandroid.app data)
mkdir -p "$DATA/Documents/ImagePack" && cp -c app/build/ImagePack/* "$DATA/Documents/ImagePack/"On a device copy the folder as ImagePack into the app's Documents (Finder
file sharing / Files app), or use "Import image pack folder…". On first launch
the pack is installed automatically (copyfile COPYFILE_CLONE) into
Library/Application Support/VirtualAndroid/images/android-33-arm64-v8a/; the
writable disks + initrd + logs live in .../vms/default/ (excluded from backup).
"Reset Android data" deletes vms/default (fresh userdata from the template).
The initrd is the ramdisk + bootconfig (VM/BootConfig.swift, byte-identical
port of tools/make_initrd.py), with androidboot.opengles.version from
va_gfx_get_gles_version() (cached from the previous run; 196608 = GLES 3.0
for ANGLE Metal otherwise), plus /adb_debug.prop (ro.hw_timeout_multiplier=5;
Bluetooth: ro.bluetooth.ble_start/stop_timeout_delay=20000, the peer-only OPP/PBAP/MAP/PAN
profiles off and bluetooth.framework.support_persisted_state=false, so the adapter's fixed
4 s profile start-up fits under TCTI and Android does not turn Bluetooth on during boot;
see tools/make_initrd.py; the
opt-in prebaked profile, buildInitrd(..., prebaked: true) = make_initrd.py --prebaked,
is for a userdata compiled ahead of time on the Mac and currently adds nothing: it used to
turn the JIT off and compile installs with speed, which left apps installed later interpreted
or past installd's 570 s dex2oat limit, see docs/AOT.md "Third-party apps"). QEMU argv (VM/QemuArguments.swift) mirrors
tools/run-macos.sh --machine ranchu: -M ranchu,gfx-lib=... (override
-VAMachine virt), cortex-a76, 4 vCPU (-VACPUCount N), 2 GB, disks vendor/encrypt/userdata/
cache/system in that order (writable ones qcow2), virtio net
(no slirp adb forward on ranchu), rng, virtio-serial hvc0/hvc1 (logcat) into
files, -device va-vsock-pci,guest-cid=77,adb-port=<port>,pipe-port=5000
(adbd and the clipboard service talk vsock), QMP, and the radios/sound of
tools/run-macos.sh: va-wifi-pci (the access point "AndroidWifi" that the
guest's mac80211_hwsim wlan0 joins, on its own slirp network: this is the
guest's internet), va-bluetooth (HCI controller on the virtio-serial port
"bluetooth") and virtio-sound-pci on -audiodev va (the speaker/mic
callbacks below). Each is added only if the embedded QEMU has it
(QemuEngine.supportsWifi/supportsBluetooth/supportsAudio, probed in the
binary; without Wi-Fi the bootconfig gets qemu.virtiowifi=0). Serial 0 is
an engine-owned ringbuf (-chardev ringbuf,id=vaconsole,logfile=serial.log):
the engine types on that console to sync the guest clock after a restore and,
once per boot, to turn Android's captive-portal probe off (the probes go to
Google servers; where those are blocked Wi-Fi would show "no internet") and, with
va-bluetooth, to turn Bluetooth on once the booted guest is quiet (unless the user
turned it off: Android still records bluetooth_on), to add a 1 GB zram swap
(-M ranchu,zram-size=MB, 0 = none; the image's fstab has none, and a 2 GB guest
running a big app thrashed until lowmemorykiller killed the app in the foreground), and
to start a job that recompiles, with the verify filter and without installd's 570 s
limit, apps whose install-time dexopt was killed (dexopt-rescue=off disables it; it
logs va-engine: dexopt: ... to logcat; docs/AOT.md "Third-party apps"). adb: port 5570 on the
simulator (it shares the Mac's loopback: adb connect 127.0.0.1:5570),
off on devices unless -VAAdbPort N. Kernel command line: run-macos.sh's plus
kpti=0 nokaslr mitigations=off init_on_alloc=0 (QemuArgumentBuilder.kernelPerfArguments:
KASLR forces KPTI on, and with KPTI every kernel entry/exit writes TTBR1 with another
ASID, which QEMU's softmmu answers with a full TLB flush; -VAKernelPerf NO boots
without them), and mac80211_hwsim.radios=0 (the guest creates its one radio itself).
Bootconfig: opengles.version=196608
(ANGLE Metal GLES 3.0), camera_protocol_ver=0, qemu.virtiowifi=1, logcat=*:I (hvc1 gets Info and
up: ActivityManager, Choreographer "Skipped N frames", OpenGLRenderer "Davey!"), and for dev builds
androidboot.qemu.adb.pubkey from adbkey.pub (Documents/adbkey.pub, else the
image pack's copy, which make_userdata_qcow2.sh --pack takes from
~/.android/adbkey.pub; VA_NO_ADBKEY=1 for packs you give away).
Files: VM/Snapshot.swift (pure: manifest, validity, launch plan, lifecycle
state machine, QMP sequences, hashing; unit-tested), VM/SnapshotManager.swift
(files), Display/FrameImage.swift (last-frame.png), engine suspend() /
restore in Engine/QemuEngine.swift and Engine/StubEngine.swift.
- Snapshot mode argv:
-object memory-backend-file,id=ram0,size=2048M,mem-path=<vm>/snapshot/ram.img,share=on,-M ranchu,...,snapshot-dir=<vm>/snapshot,memory-backend=ram0; restore adds-S -incoming defer. Enabled when "Fast resume" is on and the embedded QEMU has the ranchusnapshot-dirproperty (probed in the binary;-VASnapshotSupport YES|NOoverrides). - Background (
beginBackgroundTask): QMPstop->migrate-set-parameters mode=cpr-reboot->migrate-set-capabilities x-ignore-shared->migrate(file channelstate.tmp) -> pollquery-migrateuntil completed (postmigrate) -> rename tostate.bin->last-frame.png->manifest.json{version, engine, gfx, image, argv_hash, clean: true, saved_at}. QEMU stays alive. Host events (touch, keys, sensors, GNSS, battery, clipboard) are not forwarded while the guest is stopped, so the saved RAM/state cannot change. - Foreground with QEMU alive: manifest
clean=false, thencont(instant). - Cold launch with a valid manifest (clean, same engine/gfx/image/argv hash,
state.bin and 2 GB ram.img and disks present): show
last-frame.pngunder a "Restoring…" overlay, start QEMU with-S -incoming defer, same parameter/capability,migrate-incoming(exit-on-error=false), poll, thenclean=falseandcont. Anything invalid -> discard snapshot (incl. ram.img) and cold boot. A failed load cannot fall back in-process (QEMU runs once per process): the snapshot is discarded and the app asks to be relaunched. - Snapshots are taken only when leaving the app, never "right after the
first boot, then keep running": RAM (ram.img) and disks change in place as
soon as the guest runs, so such a snapshot would be inconsistent by the time
it is used. The manifest is clean only between a suspend and the next
cont; a kill while running means a cold boot (like pulling the power). - Hashes: engine = SHA-256 of the QEMU binary, gfx = va_gfx + GLESv2, image = name/size/mtime of kernel/ramdisk/system/vendor + pack manifest + bootconfig, argv = machine-defining argv with container/bundle paths normalized and the QMP fd/-S/-incoming removed (binary hashes are cached by size+mtime).
- Settings: "Fast resume" toggle, snapshot info, "Discard snapshot / cold boot next time"; VM menu: "Cold boot (discard snapshot)".
app/scripts/m4_e2e.sh [--stub] [--rounds N]: launch -> interactive, background -> suspend,simctl terminate, relaunch -> times (log linesrestore: last frame shown / state loaded / guest running / first live frame +N mssince process start; QEMU: adb focus on Launcher3).
Measured 2026-11-04 (simulator): stub: suspend 0.1 s, relaunch -> live 0.6 s.
Real QEMU with -M virt (no graphics; -VAMachine virt -VASnapshotSupport YES):
suspend migrate 50-73 ms (state.bin 441 KB, ram.img 1.3 GB allocated),
relaunch -> state loaded in 158 ms, guest running +750 ms after process start,
kernel log continues where it stopped; background -> foreground cont after
postmigrate works. ranchu + gfx pending (board/gfx snapshot halves).
Display/MetalDisplayView.swift: CAMetalLayer, frames drawn on a CADisplayLink, letterboxed at the frame's aspect (1080x2340). IOSurface textures are wrapped per draw and never cached (libva_gfx recycles only surfaces nobody holds).- Rotation: default "panel fixed to device": the guest panel stays fixed to the
hardware like a real phone; the frame is counter-rotated for the interface
orientation and
va_engine_set_display_rotation()gets the Android surface rotation (device turned CCW: interface landscapeRight = 90, landscapeLeft = 270).VaGfxFrame.rotation_degis added as an extra clockwise rotation. "Always upright" mode draws the frame unrotated. - Touch: up to 10 fingers, stable slots 0..9, coordinates 0..1 in guest panel space (inverse of the drawing rotation); lifted slots then all down slots are sent per event. Touches starting in the letterbox bars are ignored.
- Keyboard: UIKey HID usage -> Linux keycode (kernel hid_keyboard table).
- On-screen controls (trailing-edge handle): back 158, home 172, recents 580 (KEY_APPSELECT), power 116, volume 115/114 (down on press, up on release), and a VM menu (pause/resume, paste, hold power, rotation, snapshot, shutdown).
| Guest | iOS | Notes |
|---|---|---|
| accelerometer | CMAccelerometer | iOS g with gravity negative (upright y = -1 g) -> Android m/s² specific force (upright y = +9.81): android = -ios × 9.80665. Same axes (x right, y up, z out of screen). |
| gravity / linear accel | CMDeviceMotion.gravity / userAcceleration | same negation and scale |
| gyroscope | CMDeviceMotion.rotationRate (raw gyro fallback) | rad/s, same right-hand axes, unchanged |
| magnetic field | CMDeviceMotion.magneticField (calibrated), raw magnetometer fallback | µT, unchanged |
| rotation vector | attitude quaternion, frame X=magnetic north Z=up | q_android = q_z(+90°) · q_ios (NWU -> ENU), convention checked against gravity; w ≥ 0 |
| orientation (legacy) | from the rotation matrix | like SensorManager.getOrientation, degrees |
| pressure | CMAltimeter | kPa × 10 = hPa |
| simulator | — | accel/gravity synthesized from the display rotation |
| GNSS | CLLocationManager | when-in-use authorization requested on location_set_enabled(true) |
| battery | UIDevice battery + thermalState | voltage nominal, temperature approximated; unknown (simulator) -> engine default |
| clipboard | UIPasteboard | guest->iOS always; iOS->guest on "Paste" (or auto on activate if enabled) to avoid the paste prompt |
| camera | AVCaptureSession 420f -> VTPixelTransferSession -> NV21 | back 90°, front 270°; synthetic camera when none (simulator). The guest camera HAL writes frames into gralloc CPU buffers that live in goldfish_address_space memory; the renderer reads them by DMA (va_gfx_set_dma_host_addr), so preview and photos need an engine and libva_gfx from 2026-11-04 or later |
| speaker / mic | virtio-sound -> engine va audio backend -> AVAudioEngine + lock-free PCM16 rings (Support/VARingBuffer.c) |
48 kHz; the engine feeds the speaker at most 20 ms ahead of real time (the guest's ALSA buffer is ~44 ms), so the session asks for a 5 ms IO buffer. Mic permission on mic_start; session .playback, .playAndRecord only while a real mic captures. Simulator: real input needs the Mac's microphone permission for Simulator, otherwise CoreAudio aborts the app ("Initialize: RPC timeout. Apparently deadlocked"), so the simulator uses a synthetic 300 Hz mic unless -VASimulatorMic YES |
| vibrator | CoreHaptics continuous event |
Sensor timestamps are host CLOCK_UPTIME_RAW ns (CoreMotion's time base).
| run | BOOT_COMPLETED (from VM start) | launcher focused (from app launch) |
|---|---|---|
| first launch (image install + fresh userdata) | 170.6 s | ~187 s |
| later launches (warm userdata) | 70.2 s / 79.4 s | ≤ 97 s (first check) |
End-to-end UI test (Tests/UI/RealBootUITests.swift, real UIKit taps on the
Metal view and the overlay; TEST EXECUTE SUCCEEDED): tap Gallery -> adb focus
com.android.gallery3d/.app.GalleryActivity; overlay HOME -> Launcher3;
tap Phone -> com.android.dialer/.main.impl.MainActivity; overlay BACK ->
Launcher3; menu "Force stop" -> QMP quit, qemu_cleanup, app stays alive.
xcodebuild ... build-for-testing
xcodebuild -project app/VirtualAndroid.xcodeproj -scheme VirtualAndroid \
-destination 'platform=iOS Simulator,id=<udid>' -derivedDataPath app/build/DerivedData \
test-without-building -only-testing:VirtualAndroidUITests # needs the image pack installedScreenshots: docs/screenshot-real-boot.png, docs/screenshot-real-home.png,
docs/screenshot-real-gallery-after-tap.png.
-VAEngine stub|qemu|auto, -VAAutoBoot YES|NO, -VAMachine virt,
-VAAutoStopAfter <s> (QMP quit), -VANavExpanded YES,
-VAForceOrientation landscapeLeft|landscapeRight|portrait,
-VASelfTest YES (injects two touches + VOL+/BACK after boot),
-VAStubDiagnostics gnss,camera,mic,tone,sensors,vibrate,copy (stub: simulate
guest requests after boot and log every bridge's status 6 s later),
-VASimulatorMic YES, -VASnapshotSupport YES|NO, -VAFastResume NO, -VAAdbPort N, -VATrace event,..., -VADumpFrames YES
(libva_gfx PNG dumps in vms/default/frames), -VAStatusLog N (every N s, log each bridge's
status line, e.g. speaker underruns, to the os_log; read with log show --info), -VAScript "wait:5;tap:0.31,0.74;key:172"
(after boot: guest-coordinate taps through the view mapping, keys).
-VAEnv "K=V;K2=V2" adds engine environment variables (e.g. an AOT op capture on the
device: QEMU_AOT_CAPTURE=cap.txt;QEMU_AOT_HOT=hot.txt, paths relative to vms/default).
A/B performance switches: -VAAOT NO (TCTI without the AOT cache),
-VAKernelPerf NO (kernel command line without kpti=0 nokaslr mitigations=off init_on_alloc=0),
-VACPUCount N (-smp, 1-8, default 4), -VAvCPUQoS <spec> (sets VA_VCPU_QOS for
QEMU's TCG vCPU threads: interactive|initiated|default|utility|background for all
vCPUs, or per index such as 0-1:interactive,2-3:utility; default initiated, below
the UIKit main thread; QEMU logs the class of each vCPU to stderr), -VATBSizeMB N
(TCG code buffer, 64-4096 MB, instead of the TCTI default from available memory). The kernel and
vCPU-count switches change the argv hash, so a snapshot taken with other values is
discarded (cold boot). Pre-grant simulator permissions with
xcrun simctl privacy <udid> grant location|camera|microphone|motion dev.virtualandroid.app
and set a position with xcrun simctl location <udid> set <lat>,<lon>.
Guest logs: vms/default/{serial,logcat,hvc0}.log in the app container.