Nitai is an ARM64 to x86_64 binary translation layer for Android, built as an Android native bridge shared object: Android's runtime dlopen()s it and calls into it whenever an app ships only arm64-v8a native code on a non-arm64 device.
Current status: work in progress. libnitai.so builds, loads, and
registers itself with the platform as the native bridge for arm64, and it
now does real translation: it JIT-compiles a target library's arm64
machine code into equivalent x86-64 code the first time a native method is
resolved, rather than leaving every callback a no-op. It's still narrow -
see "What's supported" below - but it's no longer boilerplate.
Project scope and philosophy: development proceeds in two passes, not one. The first pass, for each subsystem, is to get it working end to end against real compiler output and real on-device execution - not a minimal slice of an instruction class, but its whole architectural shape (every addressing mode, every operand width, every ABI-mandated case), since a narrower slice reliably turns into a later stage's blocker rather than actually saving work (see the load/store addressing-mode note under Stage 4 in the roadmap for a concrete example). The second pass, applied as gaps are found rather than spec-read up front, is going back to fix places where Nitai's translation appears to work but actually diverges from what a real arm64 device would do - subtle flag-polarity mismatches, ABI edge cases, memory-ordering shortcuts (MFENCE for DMB/DSB/ISB is deliberately over-strong, for instance) - once a concrete case exposes the difference, rather than trying to anticipate every such gap speculatively before anything real has run. "Fail loudly instead of miscompiling" is what makes this order safe: unimplemented cases are a hard translation error, never a silently wrong answer, so shipping pass one ahead of pass two never hides a bug behind a plausible-looking result.
The translator handles whole functions with real control flow - loops, if/else with divergent RET sites, compiler-generated branch soup - as long as they take no declared Java parameters and return an integer-class value. A function's extent is discovered by following its own control flow (fall-through plus every branch edge), so no symbol sizes are needed. It currently handles:
- Data processing: MOVZ/MOVN/MOVK, ADD/SUB (immediate, shifted register, and extended register incl. the SP-relative forms), AND/ORR/EOR (immediate and shifted register, plus BIC/ORN/EON/MVN), MUL/MADD/MSUB, SDIV/UDIV, LSL/LSR/ASR (immediate and register-amount via LSLV/LSRV/ASRV/RORV), SXTB/SXTH/SXTW, CLZ/CLS/RBIT/REV/REV16/REV32 - each at both the 64-bit (Xd) and 32-bit (Wd) operand width, with correct wraparound and zero-extend-on-write semantics for the 32-bit forms.
- Load/store: STR/LDR (unsigned-offset, unscaled, pre/post-indexed,
and register-offset addressing incl. the extended/scaled
UXTW/SXTW/LSL index forms) at byte/half/word/doubleword widths,
sign-extending LDRSB/LDRSH/LDRSW, and STP/LDP/LDPSW (64-bit and 32-bit
register pairs). Immediate-offset addressing's base register can be
SP or any general register (X10-X30-range bases resolve through the
same spill mechanism as any other cold-register use, with pre/post-index
writeback committed back through it too) - the pattern real
-O0output constantly produces once a function has a frame pointer. Register-offset addressing and STP/LDP still require an SP base for now (documented as a scoped, not accidental, gap intranslate/loadstore.c). - Conditionals: CMP/CMN/TST, CCMP/CCMN, and the CSEL/CSINC/CSINV/CSNEG family (including the CSET/CSETM/MVN aliases), backed by real x86 EFLAGS, at both operand widths.
- Control flow: B, B.cond (every condition, including the AL/NV always-forms), CBZ/CBNZ and TBZ/TBNZ (both register widths, any bit number), forward and backward, with multiple RET sites - assembled in two passes with rel32 backpatching. Flag-consuming instructions after a branch-target boundary are rejected loudly rather than trusting whichever path's flags happen to arrive.
- The quiet stuff real binaries are full of: the entire hint space (NOP, PACIASP/AUTIASP, BTI, YIELD/WFE/...) as no-ops, DMB/DSB/ISB as MFENCE, PRFM as a no-op, and BRK as a real trap (UD2).
- Function calls: BL, BLR, and BR. Every callee gets its own
translated "guest entry" (prologue + body, no JNI-specific overhead),
cached process-lifetime per
(library, vaddr)so a function called from several places - or recursively - is only ever translated once; the cache's slot-indirection design means a direct or mutually recursive BL resolves correctly even when its target is still mid-translation higher up the same call stack. BL bakes a direct call through the cache at translate time; BLR/BR (whose target is a runtime register value) resolve through a small always-linked C helper instead, saving and restoring every caller-saved guest register around it so the register file comes out the other side exactly as if no such call had happened. BR is a real tail call (frame torn down before the jump, so the callee's own RET returns straight to the original caller). The top-level entry point getTrampoline resolves must still take no declared Java parameters (parameter passing is Stage 5); functions it calls internally aren't subject to that restriction, since they receive arguments the same way real AArch64 code does (X0-X7).
Register allocation covers the full integer file, X0-X30 (or their W0-W30
32-bit views): X0-X9 map directly to host registers, X10-X30 are spilled
to a per-call stack block instead (there aren't enough x86-64 GPRs to map
all of them directly). Everything else (parameter passing beyond the
implicit JNIEnv*/jclass, PC-relative data addressing, floating point/SIMD,
32-bit stack-pointer arithmetic) is out of scope for now and fails
translation loudly rather than miscompiling. See the doc comments in
include/nitai/decode/arm64.h and src/translate/ for the exact
boundaries.
src/decode/- the arm64 instruction decoder, split by instruction category, dispatched fromdecode.c.src/emit/- the x86-64 machine code emitter.src/translate/- the actual translator, mapping decoded arm64 instructions to emitted x86-64 code.translate/cache.cholds the process-lifetime(library, vaddr)-> translated-guest-entry cache BL/BLR/BR calls go through, plus the BLR/BR run-time call-target resolver.src/native_bridge.c- theNativeBridgeCallbacksentry point the platform actually calls into.src/elf_loader.c- a narrow but real ELF64 loader: builds an in-memory image of every PT_LOAD segment (with.bsszero-fill and eagerly-applied dynamic relocations) from a plain file or an uncompressed APK zip entry, and resolves symbols/vaddrs against it.
Requires the Android NDK and CMake/Ninja. Point ANDROID_NDK_HOME (or
ANDROID_NDK_ROOT) at your NDK install, then:
./scripts/build.shThis produces build/libnitai.so, cross-compiled for x86_64 (the
container's native ISA - see comment in the script for why).
./scripts/install-waydroid.shBuilds if needed, drops libnitai.so into Waydroid's system overlay, and
registers the required ro.dalvik.vm.isa.arm64 / ro.dalvik.vm.native.bridge
properties. Requires sudo and a Waydroid container restart to take effect.