A work-in-progress Switch emulator focused on native recompilation.

The big picture

The Nintendo Switch runs ARM64 (AArch64) code. A normal PC runs x86_64. These are completely different instruction sets, so you cannot just run Switch code directly on a PC, it has to be translated somehow.

Most emulators do this translation at runtime with an interpreter or a JIT compiler. Nx86 takes a different approach: it translates as much ARM64 code as possible into native x86_64 machine code before you hit play. The result gets saved to disk so the next launch is faster. If the emulator hits code paths it has not seen before during gameplay, it compiles those on the fly and adds them to the cache for next time.

This is what the project calls Continuous Dynamic Compilation: the cache gets smarter the more you play.


The compiler pipeline

When Nx86 compiles a piece of ARM64 code, it goes through a chain of stages. Each stage has one job and passes its output to the next one.

1. Decode

Raw ARM64 bytes get parsed into structured instruction objects. The decoder knows about arithmetic (ADD, SUB, AND, OR, XOR), moves (MOVZ), loads and stores (LDR, STR), branches (B, B.cond), comparisons (CMP, CMN), supervisor calls (SVC), atomics, barriers, scalar FP, and basic NEON operations. Each decoded instruction carries its address, raw bytes, what kind of instruction it is, and a human-readable disassembly string.

2. Lift to NxIR

The decoded ARM64 instructions get converted into Nx86's own intermediate representation called NxIR. This is the central layer that everything else plugs into, both the ahead-of-time compiler and the emergency JIT produce NxIR before generating any native code.

The lifter also figures out where basic blocks begin and end by looking at branch targets and what comes after terminators. Guest register state carries across blocks through explicit GetReg/SetReg operations, which keeps things simple and avoids needing phi nodes.

3. Optimize

The NxIR goes through optimization passes. Right now there is one: dead-flag elimination. ARM64 sets condition flags (NZCV) after most arithmetic operations, but a lot of the time nobody actually reads those flags before they get overwritten. The optimizer removes the redundant flag computations. The last SetFlags in a block is always kept conservatively since something outside the block might need it.

4. Verify

After lifting and after every optimization pass, a verifier runs over the NxIR. It checks that SSA properties hold (no duplicate definitions, no use-before-define), types are consistent, block terminators are legal, branch targets actually exist, and side effects line up with their results. If something is wrong, it fails early with a clear error instead of producing garbage native code.

5. Lower to x86_64

Verified NxIR gets turned into actual x86_64 machine code. There are two modes: single-block functions (no internal branches, used for the initial path) and multi-block functions (each block lowers independently, branches set the next guest PC and exit back to the dispatcher).

A linear-scan register allocator maps IR values onto a pool of six physical registers (RDX, RSI, R8, R9, R10, R11). When the pool runs out, values spill to the stack. The allocation is deterministic, so the same input always produces the same output.

The code then goes through a hand-written x86_64 assembler that emits raw bytes with correct REX prefixes, ModR/M encoding, SIB bytes, and displacement handling. It supports labels with forward-reference fixups, so you can jump to a label that has not been emitted yet.

6. Package and store

The finished native code gets packed into an .nxo file, a compact little-endian binary with a header (magic bytes, version, entry address, stack size), the code itself, and an FNV-1a content hash at the end for integrity checking. These files live in a managed cache directory.


NxIR in more detail

NxIR is worth calling out because it is the thing the whole compiler revolves around. It is:

Lazy flags

One interesting design choice is how condition flags work. On ARM64, almost every arithmetic instruction updates the NZCV flags. Computing those flags after every single operation is wasteful when most of them never get read. NxIR uses a SetFlags operation that just records the operands without actually computing anything. The flags only get materialized when something downstream (like a conditional branch) actually needs them. The dead-flag optimization pass can then remove any SetFlags that get overwritten before anyone reads them.


Memory and execution

Virtual memory manager

Nx86 includes a software page table that manages a 64 GiB virtual address space with 4 KiB pages. On Linux, this is backed by a real mmap reservation with no physical memory committed upfront (MAP_NORESERVE). Pages can be mapped with read, write, and execute permissions, and the system handles cross-page accesses and permission faults correctly.

W^X enforcement

When native code needs to run, the JIT allocates memory pages, copies the code in while they are writable, then switches them to read+execute before any function pointer is created. This is the standard W^X (write XOR execute) policy: memory is never both writable and executable at the same time. It is a security measure borrowed from how modern operating systems handle code loading.

Guest CPU state

The emulator models the full AArch64 guest state: 31 general-purpose 64-bit registers, the stack pointer, the program counter, NZCV flags (stored as individual booleans), 32 128-bit floating-point/SIMD registers, and the floating-point control and status registers. The native code reads and writes this state through a pointer passed as the first argument to every generated function.


Homebrew and services

Nx86 now has a simple homebrew path, but it is deliberately not a real Switch loader yet. It reads an Nx86-owned .nxhb.toml descriptor, maps declared code, data, and stack ranges into guest memory, starts at the descriptor entrypoint, and classifies minimal SVC exits.

The HLE layer exposes deterministic skeleton services for the current homebrew path. The service boundary covers clean exit, filesystem success stubs, synthetic thread and memory responses, and a homebrew-visible input request. This proves the runtime/service split without claiming Horizon ABI coverage.


Input, IPC, and audio

The input runtime stores stable button bits, keyboard mappings, gamepad mappings, and injected test snapshots. Homebrew can receive a packed controller state through the current input service path.

Guest IPC v0 is a clean-room service command format for Nx86 tests and prototypes. It includes service names, sessions, domains, object handles, result codes, handle transfers, buffer descriptors, and binary command/response buffers. The current audio path uses that IPC layer to queue interleaved stereo f32 PCM into an audio runtime skeleton.


Graphics and shader skeletons

The Vulkan backend skeleton loads Vulkan through ash, detects whether a usable device is available, creates a graphics device where possible, and can render an offscreen frame. The GUI can surface the rendered-frame path.

The shader translation skeleton models shader stages, source hashes, metadata, deterministic placeholder translation output, and .nxshader cache objects. It is not a real shader compiler yet: it does not generate SPIR-V or translate Maxwell/NVN binaries.

Shader AOT compiles a title's whole shader set during initial compile. Each shader gets translated (currently a placeholder), cached as a .nxshader object, and the pass reports readiness in basis points. Shared profile hints steer compilation order so hot shaders compile first. Shader readiness is one axis of the Native Coverage min-gate alongside CPU readiness.

The pipeline cache infrastructure wraps Vulkan's VkPipelineCache with save/load persistence. PipelineCacheBlob persists opaque pipeline cache bytes to disk with atomic writes. PipelineKey is a deterministic FNV-1a hash of shader combination plus render state. A PipelineMissLog records cache misses at runtime. Pipeline readiness is the third axis of the Native Coverage min-gate.

Graphics profile feedback closes the loop on the GPU axis: ShaderUsage and PipelineUsage events record which shaders and pipelines actually ran at runtime and for how many frames. These are aggregated (deduplicated by hash/key, keeping the highest observed frame count) and promoted into shader and pipeline hints that steer the next compile/rebuild cycle — the same closed loop the CPU side already had through profile-guided hot/cold layout.


Compatibility and speculative optimization

A title behavior patch is typed compatibility metadata that can change executable runtime behavior, not just documentation. Patches carry a category, a requirement (required vs optional), and a kind, and are resolved into a reportable decision set. A required patch stays enabled even if a caller asks to disable it, and the resolver reports that the disable request was ignored rather than silently dropping it.

GSO (Guarded Speculative Optimization) v0 is the first speculative compilation path. A versioned GsoProfile is generated from runtime branch-target observations, but only for monomorphic sources — a branch that has shown exactly one distinct target. Polymorphic sites are deliberately left alone until the profile format can rank or guard them properly. Manual local overrides can force, disable, or add targets, and the resolved report keeps all of that visible instead of collapsing it into an opaque final list. The native x86_64-v4 backend can lower the v0 guard shape for a terminal SUBS comparison: on success, execution falls through to the guarded path; on failure, it deopts to a resume PC reconstructed from the deopt metadata table.

An official title profile bundles one title's behavior patches and GSO profile together, matched by title ID and a non-cryptographic FNV-1a content hash used purely as an identification aid — not an integrity guarantee. The registry distinguishes an unknown match (no hash on one side) from a real disagreement (both sides have a hash and they differ), so a likely-wrong-content case is never silently treated the same as a merely-unverified one.

Before any local title profile can be shared, it has to pass a sanitizer. The sanitizer strips personal path substrings from free-text fields, a soft redact where the export still succeeds, but hard-rejects anything that looks like embedded blob content: a data:...;base64, prefix or a long unbroken base64/hex run, since the schema has no raw-bytes field to check structurally. Local GSO overrides never appear in the shareable form at all — they are structurally absent from the exported type, not merely stripped.


The cache system

Compiled native blocks get stored as .nxo files in a managed cache directory. The cache manager can scan the directory, validate files by checking their headers (cheap) or their full content hashes (thorough), insert new blocks, load existing ones, and clear everything out.

Writes go to temporary files first and get moved into place atomically, so a crash mid-write will not corrupt an existing cached block.

On subsequent launches, previously compiled blocks are loaded directly from disk instead of being recompiled. This is what makes the compile-before-play approach practical: you pay the cost once, not every session.


Block chaining

When the dispatcher finishes one native block, it normally looks up the next guest PC in the chain cache and jumps to the matching native block. That lookup is fast, but it is still a hash-table hit on every block exit. Block chaining eliminates that overhead for hot paths.

After two blocks execute back-to-back enough times, the runtime patches the exit of the first block to jump directly to the second. The patch site is a writable landing pad that gets flipped from a “go to dispatcher” stub to a “go to next block” jump. The whole thing respects W^X: the landing pad is writable during patching, then switched back to read+execute.

If a cached block gets invalidated (for example, by self-modifying code in a future phase), all chains that point to it get torn down and the dispatcher path re-takes over until new chains form.


Fastmem and slowmem

Guest memory accesses go through one of two paths.

Fastmem is the fast path. The 64 GiB guest arena is mapped at a fixed base address, and the native code can compute a guest address and load or store directly through that base with no function call overhead. A bounds check against the arena size keeps things safe. If the address is in range and the page permissions allow it, the access completes in a handful of instructions.

Slowmem is the fallback. When fastmem cannot handle an access (the page is not mapped, permissions are wrong, or the access crosses a page boundary), the generated code falls back to a runtime helper that does the full software page-table walk, reports faults, and logs the event. Every slowmem hit gets counted and written into the runtime profile, so the profile-guided rebuild can see which code paths are expensive and try to optimize them away next time.


Memory mirroring

Some Switch titles map the same physical memory at multiple virtual addresses. Nx86 handles this through mirroring: multiple guest addresses resolve to the same underlying arena page. In release mode, mirrored accesses go through the fastmem path transparently. In debug mode, mirroring is disabled so that aliasing bugs surface as clean faults instead of silent corruption.


Guard and deopt metadata

NxIR supports a Guard terminator that checks a boolean condition at runtime. If the condition holds, execution continues to the normal successor block. If it fails, the runtime jumps to a deoptimization handler that can reconstruct the full guest CPU state from a metadata table.

Each function carries a deopt-point table that maps guard IDs to the register state that was live at that point. This is the foundation for future speculative optimization, the compiler can make optimistic assumptions, guard them, and fall back safely if the assumption turns out to be wrong.


Runtime profiling

Every session produces a versioned JSONL profile file. Events include JIT block compilations, block-chaining decisions, slowmem hits, branch targets, and helper calls. The format is append-only and crash-safe: if the emulator crashes mid-session, the tail of the file can still be recovered.

The profile-guided rebuild reads these files to decide which JIT-compiled blocks should be promoted to ahead-of-time objects. Blocks that ran frequently enough get recompiled with full optimization and stored in the cache for the next launch.


Continuous Dynamic Compilation

The compile-then-play loop works like this:

  1. Before launch, the compiler scans the game's code and translates as much as it can find into native x86_64 blocks.
  2. The dispatcher runs native blocks in sequence, looking up each one by guest PC.
  3. If the dispatcher reaches a guest PC that has no cached native block, the emergency JIT kicks in. It compiles that single block on the spot, saves it to the cache, installs it in the dispatcher, and keeps going.
  4. Next time you launch the same game, that block is already in the cache. The JIT path gets shorter every session.

The emergency JIT uses the exact same compiler pipeline as the ahead-of-time path. It just prioritizes speed over optimization depth. Blocks it discovers get persisted for reuse, so the cache gradually fills in the gaps.

The main metric for tracking progress is called Native Coverage. It combines three axes — CPU readiness, shader readiness, and pipeline readiness — using a min-gate, so the weakest axis caps the combined number. Each axis reports in basis points. The bands are: Terrible (0–60%), Poor (60–90%), Great (90–98%), Excellent (98–100%), and Perfect (100%).


Testing

Nx86 uses three-way differential testing, which is a fancy way of saying the same program gets run three different ways and the results are compared against each other. The current local workspace passes 399 unit tests.

All three must produce identical final register states and observable memory contents. If any of them disagree, something is wrong in the pipeline and the test fails.

Test programs are defined as TOML files with hex-encoded ARM64 bytes, expected register values, expected memory ranges, and optional framebuffer specifications. These synthetic tests let the compiler be validated without needing real Switch software.


Current limits

The decoder handles enough ARM64 for synthetic tests and the simple Nx86 homebrew path, but not enough for commercial title boot. Real NRO/NSO parsing, Horizon ABI coverage, broad service behavior, real shader translation, and title compatibility work are still later phases.

The register allocator operates on single blocks with no cross-block awareness. Conditional branch lowering in native multi-block functions is not yet implemented. The GUI works on Linux with both Wayland and X11, but native code execution only runs on x86_64 Linux, other hosts get a clean "unsupported" message rather than a crash.

The graphics stack has shader AOT, pipeline cache infrastructure, graphics profile feedback, and a three-axis Native Coverage min-gate. Vulkan can be detected and used for a test frame where a device exists. Real SPIR-V generation, Maxwell/NVN shader translation, and runtime pipeline creation are not implemented yet.

Title behavior patches, GSO, and official title profiles currently ship one synthetic built-in title for proving the loop end to end. There is no download/upload approval flow, no signed profile format, and no compatibility database yet — those are later phases.

The project is developed on Apple Silicon (aarch64-apple-darwin) but targets Linux x86_64-v4. The pure-logic parts (decoder, lifter, IR, verifier, tests) run on any platform. The native execution path is Linux-only for now.


The codebase

Nx86 is a Rust workspace monorepo with 33 crates. Each crate covers a distinct part of the system. The project enforces strict code quality: unsafe usage, unwrap(), todo!(), and dbg!() are all treated as warnings, and CI runs clippy with -D warnings so they break the build. Any new unsafe block requires an explicit safety comment explaining why it is sound.