Skip to content

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 with make -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 the A32Insn + raw word as read-only data next to the code, _a32jit_fb passes 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.sh proves it for a built runtime (no a32_run / decoder symbols, no MAP_JIT / PROT_EXEC in the A32 sources, dylib + manifest in the data dir).
  • napple build always lifts AArch32 titles (NAPPLE_A32_RECOMPILE=0 skips; before it was opt-in). A data directory without a32jit.dylib cannot 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 with dmb); flag-setting logical operations with register-controlled shifts (tst r0, r1, lsl r2: run-time shifter carry); NEON vld1/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, vcvt between 32-bit integers and floats; VFP vldr/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): vld4 all-lanes with size 0b11 used 8-byte elements; vqrshl/vqshl 64-bit saturation overflowed the 128-bit intermediate; vrecps/vrsqrts were fused (AArch32 is not); vcvt.f16.f32 ignored the standard FPSCR; vmaxnm/vminnm mishandled signalling NaNs and flushed inputs; pli and the 0xf4-space words with bit 20 set were decoded as NEON loads (wrote back a base register).
  • Tests: runtime/tests/a32/fuzz_all.py (driver around fuzz.py: survives Unicorn's SIGILL on some NEON encodings, rerunning the chunk without the word), fuzz.py mnemonics 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), difftest randomises 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 with NAPPLE_PERF_LOG=1 and 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 and vld2-vld4 are the next candidates (7e00xx in main is a vtbl-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=1 debug runtime and NAPPLE_A32_JITLOG=<file>, feed back as a32jit.seeds. 0 were found in 150 s scripted runs after the sweep.
  • iOS packaging of a32jit.dylib is 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 test and swift test were 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).