claude/a32-full-2026-10-26-macos1 (2026-10-26): full AArch32 lifting, no interpreter in the shipped runtime¶
What changed¶
- No interpreter in the shipped runtime.
a32_run, the instruction cache and both decoders are compiled only withmake -C runtime A32_INTERP=1(NAPPLE_A32_INTERP; the debug / oracle build) and into the host tests. The runtime runs lifted code (a32jit.dylib) and, for the operations the lifter does not inline,a32_op_exec: the executor's semantics routines called with an instruction decoded at build time (the lifter writes theA32Insn+ raw word as read-only data next to the code,_a32jit_fbpasses its address). Nothing is fetched from guest memory or decoded at run time. A pc without lifted code stops the thread (no lifted code at this pc); there is nothing to fall back to.tools/check_a32_no_interp.shproves it for a built runtime (noa32_run/ decoder symbols, noMAP_JIT/PROT_EXECin the A32 sources, dylib + manifest in the data dir). napple buildalways lifts AArch32 titles (NAPPLE_A32_RECOMPILE=0skips; before it was opt-in). A data directory withouta32jit.dylibcannot run.napple rewrite a32 --coverage <modules dir>: lifts without assembling and prints, per module and operation, how many instructions are not inlined, with the first address and encoding of each group (full list in<work>/j.cov).A32LIFT_WHY=<addr>prints the discovery flags of an address.- Inline lifting (verified lifted vs interpreter with
difftest, see below), new in this branch: lda/stl (acquire / release withdmb); flag-setting logical operations with register-controlled shifts (tst r0, r1, lsl r2: run-time shifter carry); NEONvld1/vst1(multiple structures, single lane, all lanes), bitwise operations, integer add/sub/mul/compare/min/max, float add/sub/mul/mla/mls/fma/abd/compare/min/max,vcvtbetween 32-bit integers and floats; VFPvldr/vstr/vldm/vstm/vpush/vpop, core <-> S/D moves,vmov/vabs/vneg, add/sub/mul/nmul/div/sqrt/mla/mls,vmov #imm,vcvt(int, f32 <-> f64),vcmp/vcmpe(flags into FPSCR),vmrs APSR_nzcv. Floating point runs on the host FPU: the host FPCR is switched only inside a run (NEON: standard FPSCR; VFP: the guest's FZ/DN/RMode), closed before any out-of-line operation, branch or region end, so the runtime's C code never sees a modified FPCR.A32LIFT_INLINE=<mask>switches groups off for bisecting. - Code discovery (everything below fixed a real "no lifted code" stop on MK8D): PIC switch tables (
adr; ldr; add pc, also with the table directly after the branch), conditional pc-relative function pointers (ldr rX,[pc,#n]; cmp; addeq rY,pc,rX), jumps into the middle of unrolled division sequences,mov lr, pc; b, and a sweep over every stretch of text no walk visited (literal-pool garbage is lifted and never executed; +4.6 % instructions). - Core fixes found by the new fuzz (
a32_neon.c,a32_vfp.c,a32_decode_arm.c):vld4all-lanes with size 0b11 used 8-byte elements;vqrshl/vqshl64-bit saturation overflowed the 128-bit intermediate;vrecps/vrsqrtswere fused (AArch32 is not);vcvt.f16.f32ignored the standard FPSCR;vmaxnm/vminnmmishandled signalling NaNs and flushed inputs;pliand the0xf4-space words with bit 20 set were decoded as NEON loads (wrote back a base register). - Tests:
runtime/tests/a32/fuzz_all.py(driver aroundfuzz.py: survives Unicorn's SIGILL on some NEON encodings, rerunning the chunk without the word),fuzz.pymnemonics via clang + llvm-objdump (no llvm-mc needed),runtime/tests/a32jit/progtest.{py,c}+progs/(C programs compiled for armv7a ARM and Thumb-2 with the system clang, run lifted and interpreted, compared with the same source compiled for the host),difftestrandomises FZ/DN/RMode,make -C runtime test-a32.
Coverage on Mario Kart 8 Deluxe 1.0 (base build, 7 modules, napple rewrite a32 --coverage)¶
Instructions the lifter emitted: 4,867,102. Inline 4,818,607, out-of-line 48,495, interpreter fallbacks 0 (final run after the last inlining round; the module table below is from the previous round with 62,173 out-of-line, of which 27,161 are undecodable pool/table words) (before: 4,104,089 native / 282,283 via the interpreter). The out-of-line operations are build-time decoded and executed by a32_op_exec:
| module | vfp | neon | svc | multiplies | dp/ldst/ldm/ext/pkh/sat/par | cp15/mrs/msr | bkpt/undef/unimpl | total |
|---|---|---|---|---|---|---|---|---|
| main | 3762 | 8646 | 1722 | 3425 | 5090 | 333 | 16335 | 39313 |
| subsdk0 | 138 | 800 | 4 | 281 | 397 | 18 | 2135 | 3773 |
| subsdk1 | 0 | 0 | 0 | 3 | 15 | 0 | 114 | 132 |
| subsdk2 | 0 | 4 | 0 | 4 | 2 | 0 | 22 | 32 |
| subsdk3 | 14 | 6 | 0 | 8 | 28 | 0 | 317 | 373 |
| subsdk4 | 243 | 172 | 52 | 1787 | 1372 | 8 | 4674 | 8308 |
| sdk | 853 | 1714 | 571 | 1875 | 1555 | 110 | 3564 | 10242 |
| all | 5010 | 11342 | 2349 | 7383 | 8459 | 469 | 27161 | 62173 |
27,161 of them are undef / unimpl / bkpt words: literal pools and tables that the discovery walked as code (they raise a guest fault if ever executed; none is). The VFP/NEON ones left are the rarely used forms (vtbl, vext, vzip, shifts, narrowing, vrint, vsel, vfma variants, vrecpe/vrsqrte, vmrs/vmsr of other registers, structure loads vld2-vld4).
Hot out-of-line operations at run time (races, 60 s): 62 M executed (about 1 M/s) against 402 M in 40 s (10 M/s) with the previous lifter.
Verification (Apple M5, macOS, 2026-10-26)¶
- MK8D base 1.0 boots headless,
NAPPLE_A32_JITSTATS=1: exit 0, 0 exits to the dispatcher (unlifted pc), 0 interpreted instructions, over 40 runs of 25 s (see the log lines below) and scripted 60 s runs reaching the menus and the attract race. fps in a 60 s window withNAPPLE_PERF_LOG=1and the input script: 50.7-52.0 (47.2 once while shaders compiled), 2808 frames in 60 s; before (previous lifted + interpreter mixed mode, same machine class) 44-45 fps and 3707-3799 frames in 90 s. The ceiling is gfx (guest blocked in SyncWait), not the CPU any more. - LAS, SMO and ACNH boot headless 45 s: exit 0, no CRASH (2623 / 2082 / 1202 frames).
- Wild-pc crash: not reproduced in 72 headless runs of 20-25 s (32 + 40) of the lifted image. The two "no lifted code at this pc" stops seen while developing were not that crash but missing entry points (a conditional pc-relative function pointer at 0x94d12c and a PIC switch table at 0x3cff1c preceded by 12
vmovs), both fixed in the discovery; an interpreter-era report of a wild pc remains unexplained by itself, but a stray pc can no longer run anything: it stops the thread with the pc and the call chain.
Differential testing (counts are compared samples; mismatches 0 unless stated)¶
Lifted code vs the interpreter (build/tests/a32/difftest, random registers / flags / memory / D registers / FPSCR modes; "inline" = ran as lifted host code, the rest as out-of-line operations):
| run | compared | inline | mismatches |
|---|---|---|---|
| ARM, 18 integer families, 1,000,000 words | 582,520 | 406,493 | 0 |
| ARM, all families incl. VFP/NEON, 1,000,000 words | 515,578 | 337,344 | 0 |
ARM, real MK8D main words, 1,000,000 samples |
891,219 | 863,577 | 0 |
ARM, real MK8D sdk words, 500,000 samples |
420,929 | 379,953 | 0 |
| Thumb-2, integer, 1,000,000 words | 588,896 | 555,952 | 0 |
| Thumb-2, incl. VFP/NEON, 1,000,000 words | 508,045 | 468,104 | 0 |
| Thumb-2, real MK8D words, 500,000 samples | 460,157 | 457,233 | 0 |
inline_words.py: 155 inlined VFP/NEON/system forms (ARM) + 128 (Thumb), 20,000 states each |
4,797,723 | 4,797,723 | 0 |
progtest.py: 3 C programs x ARM/Thumb x 2 argument sets, lifted vs interpreter vs host |
12 runs | 0 |
Interpreter / semantics core vs Unicorn (QEMU) (runtime/tests/a32/fuzz_all.py --per-family 20000, one random instruction per sample; samples Unicorn rejects, crashes on, or that are architecturally unpredictable are not counted):
| family | compared | mismatches | family | compared | mismatches | |
|---|---|---|---|---|---|---|
| dp_reg | 20,118 | 0 | halfmul | 20,511 | 0 | |
| dp_regsh | 20,344 | 0 | movw | 20,520 | 0 | |
| dp_imm | 20,248 | 0 | uncond | 20,026 | 0 | |
| mul | 20,558 | 0 | cp | 20,041 | 0 | |
| mull | 20,848 | 0 | vfp_dp | 20,005 | 0 | |
| ldst_imm | 20,147 | 0 | vfp_ldst | 20,003 | 0 | |
| ldst_reg | 20,362 | 0 | vfp_dp64 | 20,117 | 0 | |
| extra | 20,195 | 0 | vfp_xfer | 20,007 | 0 | |
| extra2 | 20,065 | 0 | vfp_x64 | 20,014 | 0 | |
| ldm | 20,014 | 0 | vfp_v8 (vsel, vmaxnm, vrint, vcvt |
20,025 | 0 | |
| media | 20,109 | 0 | neon_dp | 20,000 | 0 | |
| misc | 20,192 | 0 | neon_ls | 20,052 | 0 | |
| branch | 20,060 | 0 | sync | 20,028 | 0 | |
| Thumb-2 (random 16- and 32-bit) | 60,487 (4 seeds) | 0 |
ldm needs small chunks (most random ldm words are unpredictable or touch memory outside the fuzz window; 1,965 chunks of 200 for 20,014 compared). The Thumb run before the last filter additions showed 14 mismatches in 60k, all of three kinds that are not core defects: Unicorn's vabd.s32/vaba.s32 through the Thumb encoding, smmls with sp/pc operands (unpredictable), and usat16/ssat16 with the should-be-zero bits 5:4 set (unallocated).
Documented deviations of Unicorn (QEMU 5.0) from the ARM ARM (filtered in fuzz.py, reason next to each filter)¶
vabd.s32 / vaba.s32 take the absolute value of the wrapped difference (this core and the lifted code use the exact difference); vmaxnm.f64 / vminnm.f64 with NaN operands; Advanced SIMD sets cumulative FPSCR exception bits (not compared for NEON); unpredictable encodings (pc as a destination of multiplies, ldrd with Rm = Rt/Rt2, vmov Sn, pc). The lifted code takes its cumulative exception flags from the host FPU; the interpreter derives them in software and differs on flushed results and square roots, so difftest does not compare those six bits (it compares everything else of FPSCR).
Not done / limits¶
- VFP and NEON are partly inline (the table above); the remaining ones cost a spill/reload and a call each. Inline
vtbl/vext/vzip, shifts andvld2-vld4are the next candidates (7e00xxin main is avtbl-heavy loop executing 390k times per 45 s). - Computed jumps into places no recogniser knows still stop the thread; collect them with the
A32_INTERP=1debug runtime andNAPPLE_A32_JITLOG=<file>, feed back asa32jit.seeds. 0 were found in 150 s scripted runs after the sweep. - iOS packaging of
a32jit.dylibis described in docs/architecture/aarch32.md and not done. - C sources were not run through clang-format (not installed on this Mac); they follow the file's style.
make -C gfx testandswift testwere not run (nothing in gfx or studio changed).
Final round (2026-10-27)¶
Merged origin/main 401484e (one Makefile conflict: both CFLAGS kept), clang-formatted the touched C files, inlined the rare NEON/VFP forms (vext, vtbl/vtbx, vdup, scalar moves, modified immediates, vzip/vuzp/vtrn/vswp, rev/cnt/clz, shifts, by-scalar multiplies, pairwise, vsel, vmaxnm/vminnm, vrint*, vcvt{a,n,p,m,r}, vmrs/vmsr fpscr). difftest on the VFP/NEON families (150k words each) 0 mismatches. After the merge: make -C runtime test test-services test-host, cargo test/clippy/fmt and check_docs (support outputs regenerated) pass; MK8D base 60 s with the input script: exit 0, fps 52-60 (60 vsync-limited), 8.8 M out-of-line operations, 0 exits to the dispatcher, 0 interpreted; LAS, SMO, ACNH headless 45 s exit 0 with no CRASH. inline_words.py at 20,000 states per form with the final lifter: 551 forms, 10,318,706 samples, all inline, 0 mismatches. Not re-run in this round: the Unicorn fuzz on the rare-form inlining (the fuzz tests the shared semantics core, which did not change).