R - Real
A - Actual
M - Meow
O - CPU
N - Emulator for teaching
cpu-emulator — Keystone + Unicorn ARM round-trip (in-browser)
A browser-based ARM assembly learning environment (ARM64/AArch64 and ARM32/AArch32, selectable in the toolbar): write assembly, assemble it to machine code, and run/single-step it under emulation — entirely client-side, no backend, no native execution.
If you've written ARM assembly on Linux, the usual loop is as foo.s -o foo.o
then ld foo.o -o foo, and the kernel's ELF loader decides where your code,
data, and stack actually live at runtime. None of that happens here. There is
no object file, no linker, no ELF, and no OS loader. Keystone takes your assembly
text and emits raw machine-code bytes; we copy those bytes to a fixed
address we choose in a flat block of emulated RAM, point the emulated PC at it,
and start executing.
So the differences that trip people up:
Conventional as + ld |
This emulator |
|---|---|
| Assemble → object file → link → ELF executable | Assemble → raw bytes, loaded directly (no link step) |
Sections (.text, .data, .bss) placed by the linker |
One flat address space; you pick the bases (see below) |
| Symbols/relocations resolved at link time | No relocation pass — labels resolve within the one blob |
| OS loader maps segments and sets up the stack | We mem_map regions and set SP ourselves |
.data/.rodata you write get loaded for you |
Only the code blob is loaded; the data region starts zeroed |
| W^X enforced (code pages not writable, etc.) | Every region is PROT_ALL — a teaching sandbox, no W^X traps |
Entry point from ELF header / _start |
Execution begins at the code base, top to bottom |
This is deliberate (see CLAUDE.md): a linker only earns its keep with multiple
sections, symbols, and relocations across translation units — none of which a
single-file teaching snippet has. Skipping it keeps the byte-offset → source-line
mapping trivial and the whole round-trip understandable.
Three non-overlapping 64 KB regions, page-aligned:
| Region | Base | Size | Notes |
|---|---|---|---|
| code | 0x10000 |
64 KB | your assembled bytes are loaded here; PC starts here |
| data | 0x20000 |
64 KB | scratch RAM, starts zeroed; first 256 B snapshotted to the UI |
| stack | 0x30000 |
64 KB | SP initialised to 0x40000 (the top — the stack grows down) |
Practical consequences when writing code for this sandbox:
- You won't get a
.datasection for free. If you need a string or buffer, either embed it in the code region (e.g. after your instructions) or write it into the data region at runtime, then point your syscall at that address. - Addresses are concrete and absolute.
0x10000,0x20000,0x40000are real, predictable numbers you can hard-code — there's no relocation to worry about. - There is no
_start/maindistinction. The first instruction in your text is the first thing that runs.
ARM32 uses the same bases and sizes; only the register widths and syscall ABI differ (see below).
source text ──► Keystone (WASM) ──► machine-code bytes
│
loaded at the code base in
Unicorn (asm.js) flat memory
│
┌───────────────┴───────────────┐
▼ ▼
emu_start(pc,end,0,1) (step) UC_HOOK_INTR on `svc`
emu_start(pc,end,0,cap) (run) ──► ArchProfile syscall layer
│ ──► console output / exit
▼
snapshot: registers + NZCV + pc + current source line + memory
Keystone(src/engine/keystone.ts) — typed wrapper:assemble(text, base)→ bytes, or a clean error message (ks_errno/ks_strerror).EmulatorHarness(src/emulator/harness.ts) — environment-agnostic core: assemble → map memory → load →run/step/reset→snapshot. It receives already-loaded engine handles, so it runs in both the browser worker and Node.- Engine loaders —
load.worker.ts(browser,importScripts) andload.node.ts(Node,vm+require). Same engines, two transports. - Web Worker (
src/worker/emulator.worker.ts) — drives the harness via the message protocol insrc/emulator/protocol.ts.
Everything architecture-specific lives behind one interface
(src/arch/ArchProfile.ts) so adding x86 later is "write a new profile," not
"rewrite the app." Two profiles exist today — Arm64Profile (src/arch/arm64.ts)
and Arm32Profile (src/arch/arm32.ts) — listed in src/arch/registry.ts and
selectable from the toolbar Arch dropdown; the harness/worker/UI talk only to
the interface. A profile bundles:
- Keystone arch/mode + Unicorn arch/mode constants.
- The memory map (regions + initial SP).
- The display registers + their Unicorn register ids, and a flags decoder (NZCV).
- A syscall decoder (
decodeSyscall→{number, args}) and a side-effect-free dispatcher (executeSyscall→ an action the harness performs). - The instruction-length / PC→source strategy.
Handled via UC_HOOK_INTR on svc. Number in x8, args in x0–x5.
write(#64): readcountbytes atbuffrom emulated memory, append to the console (fd ignored — all output goes to the console panel).exit(#93) /exit_group(#94): stop the emulator with the given code.- Unknown syscalls: reported in
diagnostics, never crash.
The ARM32 profile uses the same flat memory map (regions at the same bases) and the
EABI convention: number in r7, args in r0–r6, svc #0. Same set:
write (#4), exit (#1) / exit_group (#248); registers are 32-bit
(wordBytes: 4, hex shown as 8 digits), CPSR provides NZCV at bits 31–28. The
ARM32 round-trip is verified by npm run proof:arm32.
ARM64 is fixed 4-byte: the i-th instruction-bearing source line (blank,
comment-only, label-only and directive lines emit nothing) maps to byte offset
i*4. The strategy lives behind the profile so variable-length archs (x86) can
swap it. Limitation: a line that assembles to more than one instruction desyncs
the map from that point — acceptable for a teaching sandbox.
Infinite loops are expected. run is bounded by two caps:
-
Instruction-count cap (200 000): passed as the fourth argument to
emu_start. The only in-engine guard, because this asm.js Unicorn build can't honoremu_start's µs timeout (it needspthread_sigmask). Wall-clock protection comes from the worker: the main thread can terminate a hung worker. -
Console output cap (16 384 bytes): enforced in the
writesyscall handler. The ARM32 asm.js Unicorn build crashes ("Runtime.functionPointers[index] is not a function") after roughly 7 000HOOK_INTRcallback invocations — well before the 200 000-instruction cap would fire in a tight write loop. Capping console output at 16 KB (the point where ~5 000 write syscalls have been made) stops emulation cleanly before the asm.js runtime hits that limit. Both caps reportstopReason: 'cap'and leave the emulator in a resumable state.
npm install
npm run dev # Vite dev server (the app)
npm run build # tsc typecheck + Vite production build
npm run proof # headless Node proof: drives the REAL harness, no browser
npm run proof:layout # headless Node proof of the panel layout modelnpm run proof assembles a write+exit program, runs it (expects console Hi\n,
exit 0), single-steps it with line mapping, and checks the assemble-error path —
the same EmulatorHarness the browser uses.
npm run dev serves the Phase 2 app: a CodeMirror editor, an
Assemble/Run/Step/Reset toolbar, and live register / NZCV-flags / console /
diagnostics panels, with the current source line highlighted as you step. The
editor seeds with the Hi\n write+exit program, so Run prints output and Step
demonstrates the highlight immediately.
Everything, including the editor, is a panel. There are three zones:
┌──────────────────┬──────────┐ main: exactly one panel, always
│ MAIN │ RIGHT │ right: 0..n panels, stacked vertically
│ │ dock │ bottom: 0..n panels, side by side
├──────────────────┤ │
│ BOTTOM dock │ │ Either dock may be empty, and then collapses
└──────────────────┴──────────┘ away so the others take the whole window.
- Promote to main —
◱in a panel's header, or drag its ⠿ grip onto the main area and release. Promotion swaps: the outgoing main panel drops into the exact slot the promoted one vacated, which is what keeps "there is always exactly one main panel" true without any special cases. The main panel therefore has no collapse, reorder, dock or drag controls — you change it by promoting another. - Move between docks —
⤵/⤴in the header, or drag the grip into the other dock. An empty dock appears as a dashed drop strip while you drag. - Reorder — drag the grip, or use ▲/▼ (◀/▶ in the bottom dock).
- Collapse — click a header. In the bottom dock a collapsed panel becomes a narrow vertical title strip. When every panel in a dock is collapsed the dock stops honouring its fraction and shrinks to its headers — a strip of upright title chips along the bottom, or a narrow column of them on the right — and its splitter hides, since there is nothing left to resize. The fraction is kept, so expanding anything puts the dock back at the size you left it.
- Resize — drag any splitter, between two panels or between a dock and the main zone. Arrow keys resize a focused splitter; double-click evens a panel pair, or restores a dock to its default size.
- Reset —
↺in the toolbar.
Panel sizes are stored as weights within their dock, and dock sizes as a
fraction of the window — never pixels — so a layout saved on a large monitor
restores sensibly on a small one. The whole thing persists in localStorage under
ramon.layout.v2; a corrupt, stale or hand-edited value is repaired rather than
discarded (see mergeSaved in src/ui/layout/model.ts), and blocked storage
(Safari private mode) degrades to a working, non-persistent layout.
npm run proof:layout exercises that model headlessly — the single-main invariant
across every move, draining either dock, and the merge against malformed input.
Toolchain validation: assemble and run ARM64 (AArch64) assembly entirely client-side — no backend, no native execution. Keystone assembles the text to machine code; Unicorn loads those bytes and emulates the CPU.
Program being proven:
mov x0, #42
svc #0Success criterion: the page shows x0 == 42 and confirms the svc interrupt
hook (UC_HOOK_INTR) fired.
| Round-trip | Assembler | Emulator | Page | Headless proof | Status |
|---|---|---|---|---|---|
| WASM-assemble (default) | Keystone true WASM (built from source) | Unicorn asm.js | — | node verify-wasm.js |
✅ works |
| asm.js (alternative) | Keystone asm.js | Unicorn asm.js | — | node verify.js |
✅ works |
Both print the assembled bytes 40 05 80 d2 01 00 00 d4, fire the svc hook
(intno=2), and read X0 = 42.
A fully-WASM round-trip is not achievable: Keystone compiles to real WebAssembly, but Unicorn 2 cannot be built to WASM (details below), so the emulator half stays asm.js. No library was substituted — both halves are still Keystone and Unicorn.
| Engine | File | Source | Size |
|---|---|---|---|
| Keystone | vendor/keystone.min.js |
github.com/AlexAltea/keystone.js (dist/) |
12 MB |
| Unicorn | vendor/unicorn-aarch64.min.js |
github.com/AlexAltea/unicorn.js (dist/, ARM64-only) |
3 MB |
These are self-contained asm.js Emscripten modules (no .wasm; the markers
EMSCRIPTEN_START_ASM / use asm are present, zero WebAssembly references).
Each bundles the engine + JS wrapper + constants and exposes the globals ks /
uc synchronously after the <script> tag. The Unicorn file is the ARM64-only
variant to keep size down. They are not WebAssembly, but they prove the
round-trip runs fully client-side with no backend.
| File | What | Size |
|---|---|---|
vendor-wasm/keystone-core.wasm |
real WebAssembly (wasm) binary module |
832 KB |
vendor-wasm/keystone-core.js |
Emscripten glue (MODULARIZE factory MKeystone) |
63 KB |
Built with Emscripten 5.x from keystone-engine/keystone, AArch64 LLVM
backend only, -Oz, libs-only. ~14× smaller than the asm.js Keystone.
Rebuild with:
bash scripts/build-keystone-wasm.sh # needs .build/emsdk (see below)Attempting an ARM64-only Emscripten build fails immediately:
$ emcmake cmake -DUNICORN_ARCH="aarch64" ...
CMake Error at CMakeLists.txt:282 (message):
Unknown host compiler: .../emscripten/emcc.
This is not a superficial check. Unicorn 2 is QEMU/TCG-based: TCG needs a
native host codegen backend selected by UNICORN_TARGET_ARCH and pulled in
from qemu/tcg/<host> (line ~473). The tree ships only real host backends
(qemu/tcg/{arm,aarch64,i386,mips,ppc,riscv,s390,sparc,...}) — there is no
wasm backend and no TCG interpreter (TCI). Forcing past line 282 only reaches
that deeper, fundamental gap. This is exactly why the only in-browser Unicorn in
existence is AlexAltea's Unicorn 1.x, which is the asm.js build used above.
Per the task instruction ("if you hit a wall on either engine, stop and report rather than substituting a different library"), the emulator remains Unicorn (asm.js); no alternative emulator was swapped in.
Run the app with npm run dev (see Build / run above). The
Keystone-WASM + asm.js-Unicorn round-trip is also provable without a browser via
the headless proofs below.
The same vendored builds, run under Node, so the toolchain is provable in CI.
Run all three with npm test, or individually:
node verify.js # asm.js Keystone + asm.js Unicorn
node verify-keystone-wasm.js # true-WASM Keystone only (assembles correctly)
node verify-wasm.js # true-WASM Keystone + asm.js Unicorn round-tripExpected (each):
... 8 bytes: 40 05 80 d2 01 00 00 d4
... svc hook fired = true (intno=2)
... X0 = 42
PASS
Node note: the asm.js builds are loaded via
vm.runInThisContextwithglobalThis.require = requireso Emscripten's env-detection picks its NODE branch instead of SHELL mode (which references an undefined global
Emscripten is vendored under .build/emsdk (gitignored). To recreate:
mkdir -p .build && cd .build
git clone --depth 1 https://github.com/emscripten-core/emsdk.git
cd emsdk && ./emsdk install latest && ./emsdk activate latest
cd ../.. && git clone --depth 1 https://github.com/keystone-engine/keystone.git .build/keystone
bash scripts/build-keystone-wasm.shnew ks.Keystone(ks.ARCH_ARM64, ks.MODE_LITTLE_ENDIAN).asm(text, addr)→{ mc: Uint8Array, failed: bool, count }.new uc.Unicorn(uc.ARCH_ARM64, uc.MODE_ARM)→mem_map / mem_write / hook_add(uc.HOOK_INTR, cb, data, begin, end) / emu_start(begin, until, timeout, count) / reg_read_i64(uc.ARM64_REG_X0).- The
HOOK_INTRcallback gets(handle, intno, user_data);handleis the Unicorn instance, sohandle.emu_stop()halts onsvc.
In the default WASM Keystone path (verify-wasm.js) the engine is
driven directly via ccall; note ks_asm's uint64_t address is a single i64
param (WASM_BIGINT), so it is passed as a BigInt.