The device app carries two engines and picks one at launch:
| engine | framework | when |
|---|---|---|
| TCG JIT (QEMU's aarch64 backend, split W^X) | qemu-aarch64-softmmu |
the app was opened from StikDebug (a debugger is attached) and the JIT option is on (default) |
| TCTI + AOT cache | qemu-aarch64-softmmu-tcti + va_aot |
opened from the home screen, option off, or the last JIT start failed |
Both are built from the same engine/qemu-ios tree with the same devices, so one snapshot (M4 fast resume) serves both: suspend under one engine, resume under the other.
A sideloaded app cannot create executable pages. A debugger attach sets
CS_DEBUGGED; before iOS 26 that alone allowed QEMU's split W^X code buffer
(an RX mapping plus an RW mach_vm_remap alias, -accel tcg,split-wx=on).
On devices with TXM (Trusted Execution Monitor: A15/M2 and later on iOS 26,
every device but iPad8,11/8,12 on iOS 27) the process still cannot make its
own pages executable. The attached debugger has to touch every page of the
RX mapping (debugserver M writes, which TXM accepts), and only while it is
attached. So the debugger must still be attached when QEMU allocates its code
buffer (tcg_region_init, during qemu_init).
StikDebug 3.2 runs a JavaScript per app; the default for unknown apps is
universal.js. Its "JIT26" calls are mov x16, #cmd; brk #0xf00d:
0 = detach, 1 = prepare region (x0 = address or 0, x1 = length, returns
x0), 2 = load more script. The older protocol (UTM, StikDebug legacy.js) is
a single brk #0x69 with x0/x1, after which the script detaches. The engine
speaks both:
brk #0x69with the RX alias and the length.- Debugger gone afterwards and x0 unchanged:
legacy.jsprepared and detached. Done. - Still attached and x0 changed:
universal.jsrefused the legacy call. It means to return 0xE0000069, but its 4-byteP0=E0000069packet is padded by debugserver with 0xcc, so x0 comes back as 0xcccccccc690000e0. The engine then sendsx16=1; brk #0xf00d(prepare; x0 must come back as the address), thenx16=0; brk #0xf00d(detach). - Still attached, x0 unchanged: an unknown script handled
brk #0x69and stays attached; nothing more is sent.
Then the engine reads the first byte of every 16 KiB page through the RX
view: StikDebug (and the lldb stand-in) write 0x69 there, the mapping starts
zero-filled. Some but not all pages marked means the session broke off
midway (VPN drop, StikDebug killed); that and any protocol failure make
qemu_init fail with a clear error instead of a crash minutes later when
code is first written to an unprepared page. A region above 64 GB is refused
before any brk. While a call is in flight a SIGTRAP handler skips our own
brk if the debugger detached in between (otherwise the process would die).
Finally the engine writes through the RW view and reads through the RX view
(both ends of the buffer): a debugger write that fell back to VM_PROT_COPY
would have split the two views.
After the detach the region stays executable. Detaching matters: attached, the app stops in the debugger on every signal and dies when StikDebug's connection drops.
StikDebug prepares by writing one byte (0x69) at the start of every 16 KiB
page, 128 pipelined $M<addr>,1:69 packets per round trip
(JSDebugSupport.swift), and encodes addresses with 9 hex digits: the region
must lie below 64 GB. The engine warns if it does not (the buffer is
allocated early in qemu_init; on the iPhone it landed at 0x145a40000). The
writes make every page of the buffer resident.
VA_JIT26 (app: -VAJIT26 auto|universal|legacy|none) forces one protocol;
none skips the brk, for Xcode's debugger, which would stop on it.
- From StikDebug: pick VirtualAndroid, enable JIT. StikDebug 3.2 runs a
script only when one is assigned (TXM devices): long-press VirtualAndroid,
"Assign Script",
universal.js(legacy.jsalso works). Without a script StikDebug just attaches and detaches; the app notices the debugger left before the engine started and uses TCTI. - From the home screen (default "Ask StikDebug for JIT ..." on): before
auto-booting, the app opens
stikjit://enable-jit?bundle-id=dev.virtualandroid.app&pid=<pid>&script-name=universal.js. StikDebug (confirm the "Enable JIT?" dialog) attaches to the running app with universal.js and switches back; the app waits until the debugger is attached (20 s in front at most, "Start without JIT" / Start skip it), then boots with JIT; without a debugger it boots TCTI. Every home-screen launch asks again; turn "Ask StikDebug for JIT ..." off to stop that.
QemuEngine.jitDecision(pure logic inJITDecision, unit-tested): sampled once at launch fromcsops(CS_DEBUGGED),sysctl(P_TRACED) and the TXM table (StikDebug's rule). JIT iff the option is on, the JIT engine is embedded, CS_DEBUGGED is set and, on a TXM device, the debugger is still attached.- No JIT although StikDebug attached for it (option off, last start failed):
va_runtime_release_jit_debugger()sendsbrk #0x69with an empty region (legacy.js detaches) and, ifuniversal.jsanswered, the detach call. - The decision is re-sampled until the engine starts (
refreshJITDecision): after a StikDebug request, and inQemuEngine.start. A debugger that came but cannot be used this launch is released right away. - JIT start marker
jit-start.pending(app support dir): written before a JIT engine starts, removed once a guest frame arrives or the kernel console grows. Found at the next launch, that launch uses TCTI once and says so (and does not ask StikDebug). - A JIT engine that dies in
qemu_init(region not prepared) before QEMU read the snapshot leaves the snapshot alone; the next launch restores it. - Feature probes (snapshots, Wi-Fi, Bluetooth, audio) read an embedded engine binary independent of the JIT decision.
- Snapshot identity: hash of both engine binaries; the argv hash ignores
tb-sizeandsplit-wx. - The engine's stderr goes to
vms/default/qemu.outon the device (VA_STDERR_FILE;-VAStderrConsole YESkeeps the console), so the linetcg: JIT region ... prepared by the debugger (... protocol) in N sis there. - Start screen: "JIT when opened from StikDebug" (takes effect at the next launch) and a line saying what this launch runs and why.
S=engine/scripts/ios
$S/build_qemu.sh -p ios -b tcg -s engine/qemu-ios # device JIT engine (~5 min)
$S/build_qemu.sh -p ios -b tcti -s engine/qemu-ios # device TCTI engine (+ build_aot.sh for its cache)
$S/make_xcframeworks.sh --device-backend tcg+tcti # JIT primary + TCTI secondary (renamed) + va_aottcg+tcti copies the TCTI framework to qemu-aarch64-softmmu-tcti.framework
(install name, executable and bundle id changed; the code and so the AOT
binding are untouched). The app then embeds both (embed_engine_frameworks.sh
embeds every xcframework).
- Install StikDebug: the IPA ships unsigned; sign it with a development
certificate and a profile for its bundle id (a wildcard profile works; it
needs no special entitlement) and install it with
devicectl device install app. - Pairing file: the Mac's lockdown pair record for the phone, read from
usbmuxd (
ReadPairRecord, no root needed) plus aUDIDkey, copied to StikDebug'sDocuments/pairingFile.plist(devicectl device copy to --domain-type appDataContainer --domain-identifier com.stik.stikdebug); StikDebug moves it intoApplication Support/Pairing/. Or import it in the app. - Install LocalDevVPN from the App Store and turn it on (StikDebug reaches the phone's own lockdownd through it).
- StikDebug also downloads and mounts the Developer Disk Image itself (cryptex method on iOS 26.4+, needs internet once); wait for "Tunnel connected" and the DDI mount after (re)opening it.
- Pairing file format: StikDebug 3.2 reads it with
rp_pairing_file_read, which wants RemotePairing keys (public_key,private_key,identifier). A plain lockdown record (what usbmuxdReadPairRecordreturns, and what the file pushed on 2026-11-11 is) is rejected with "missing field public_key". Make a RemotePairing file with StikDebug's own on-device pairing (Settings -> Pairing File -> Pair a Device -> Pair iPhone or iPad, then iOS Settings -> Privacy & Security -> Developer Mode -> "Pair with StikDebug-xxxxxx"), or with iloader / idevice_pair (Remote kind) on the Mac. - Assign
universal.jsto VirtualAndroid (above), or open VirtualAndroid from the home screen and let it ask StikDebug.
Without StikDebug, for development: launch the app stopped
(devicectl device process launch --start-stopped) and attach
tools/jit/jit_host.py from a Mac (xcrun lldb -o "command script import tools/jit/jit_host.py" -o "vajit <device-id> <pid>"). It speaks the same
protocol over USB (one page per lldb round trip: 215-222 s for 512 MB).
Same userdata (not prebaked; CoolApk and Xiaohongshu installed, both verify
compiled), -smp 4, 2 GB guest, 512 MB code buffer. The JIT run used the Mac
lldb stand-in for StikDebug (tools/jit/jit_host.py, the same
universal.js protocol over USB); TCTI + AOT restored the snapshot that the JIT
run saved (cross-engine restore works: adb back 4 s after launch). Every app
start and the scroll run from a quiet guest: all third-party apps
force-stopped, 1-min load average below 3 (am start -W TotalTime, ms;
tools/jit/appstarts.sh and tools/jit/scroll.sh over the guest adb).
| JIT | TCTI + AOT | speedup | |
|---|---|---|---|
| Settings | 1557 | 2500 | 1.6x |
| DeskClock | 1295 | 2214 | 1.7x |
| Calendar | 888 | 1623 | 1.8x |
| Dialer | 1177 | 2227 | 1.9x |
CoolApk (com.coolapk.market) |
16750 | 35415 | 2.1x |
Xiaohongshu (com.xingin.xhs) |
13646 | 26739 | 2.0x |
| Settings scroll: frames / janky | 810 / 1.85 % | 883 / 3.5 % | |
| Settings scroll p50 / p90 / p99 frame time | 18 / 21 / 27 ms | 21 / 25 / 34 ms |
Cold boot under JIT (after the debugger detached): kernel to init second stage
4.9 s, zygote 44.6 s (odsign waited 28 s for odrefresh compiling the 32-bit
boot classpath, a one-off because this userdata had no ART cache), preload
4.5 s, enable_screen 69.9 s, sys.boot_completed 83 s. The same boot under
TCTI + AOT: zygote 154 s, boot_completed 433 s (fresh userdata, docs/AOT.md).
The system UI is mostly in the AOT cache, so scrolling gains little; app code
(native libraries, ART JIT output, dex2oat) is never in the cache and runs
about twice as fast under JIT. With two big apps running the 2 GB guest
swaps (zram 690 MB used, kswapd at 66 % of a vCPU), so measure from a quiet
guest. The process had 5.9 GB available (os_proc_available_memory, logged
at engine start), so a 3 GB guest would fit; not tried yet.
Code buffer preparation: the lldb stand-in writes one page per round trip (4-8 ms per page, 215 s for 512 MB); StikDebug pipelines 128 writes per round trip, so expect a small fraction of that (to be measured with StikDebug).