Assemble and run real x86-64 machine code in a browser tab, with no server.
You type Intel-syntax assembly; the page turns it into machine code and executes it on an emulated CPU. No source leaves the tab, no build step, no native binary, no WebAssembly Text (WAT) and no AssemblyScript — this is genuine x86-64.
It is a proof of concept for adding an assembly language to
LiveCodes, in the same shape as
browser-cobol and
browser-haskell were for their
languages.
assembly source → Keystone (WASM) → x86-64 machine code → Unicorn (WASM) → stdout
865 bytes here emulated CPU via syscalls
Both libraries are real, well-known projects compiled to WebAssembly:
- Keystone — the multi-architecture assembler framework (LLVM MC under the hood for x86).
- Unicorn — the CPU emulator framework (several generations of QEMU's TCG under the hood).
npm start # → http://localhost:8123/Pick an example (or type your own) and press Run — or Ctrl/Cmd + Enter
in the editor. A static HTTP server is required only because file:// cannot
compile WebAssembly; there is no build step and nothing is fetched at run time.
npm start is a plain file server over the repository directory. The page runs
on the package in packages/assembly-wasm, pointed at the vendored runtimes
under vendor/ — so it works offline and pins the exact versions, instead of
reaching for the CDN the published package defaults to.
- Client-side assembly and execution. Keystone assembles, Unicorn runs; the
program's
syscalls are served by the page (see below). - Real CPU semantics. Registers, the stack,
rip-relative addressing,div, flags, loops and memory all behave as on the real thing. - No network at run time. ~5 MB of vendored WebAssembly, loaded lazily on the first Run.
- Final register dump for every run, plus the exit code, syscall count, code size and wall-clock time.
The guest is a bare program with no kernel, so syscall is trapped by a Unicorn
hook and handled in JavaScript. Only a small Linux-like subset is implemented:
rax |
call | behaviour |
|---|---|---|
0x00 |
read(fd, buf, len) |
reads from the stdin box, returns the byte count |
0x01 |
write(fd, buf, len) |
fd 1 → output pane, fd 2 → output pane (marked), returns len |
0x3c |
exit(status) |
stops the emulator |
0xe7 |
exit_group(status) |
same as exit |
| other | — | returns -ENOSYS (rax = -38) |
Anything else — open, mmap, brk, fork — is not available. The memory map
is fixed and documented below, and a program is a single flat blob: no linker,
no relocations, no libc.
Memory map
| region | address | size | notes |
|---|---|---|---|
| program + data | 0x1000000 |
64 KiB | read/write/execute; entry point is the first byte |
| stack | 0x2000000 |
64 KiB | rsp starts near the top |
If a program runs off the end of its code it lands on an appended exit(0)
epilogue, so forgetting to exit is not an error.
Every row was run end to end — under Node against the same vendored builds, and again through the page in headless Chrome (pick the example, press Run, read the output pane). Each error row was followed by a good program, to confirm that a failed run leaves the runtime usable.
| program | result | syscalls |
|---|---|---|
write + exit |
Hello, world! |
2 |
read then write (stdin Assembly, running in a tab.) |
Assembly, running in a tab. |
3 |
fill a buffer with A..Z, then write |
ABCDEFGHIJKLMNOPQRSTUVWXYZ |
2 |
sum 1..100 with add/dec/jnz, print it with div |
5050 |
2 |
| read unmapped memory | The program read from an address that is not mapped. | — |
| write unmapped memory | The program wrote to an address that is not mapped. | — |
jmp to an unmapped address |
Execution jumped to an address that is not part of the program. | — |
spin: jmp spin, never exits |
The program did not exit (stopped after 10,000,000 instructions)… | — |
NASM-style ; comments, and a ; inside a string |
a;b;c |
2 |
unprefixed integers are decimal: mov rax, 10 / .byte 10 |
48 c7 c0 0a 00 00 00 / 0a |
— |
frobnicate rax (not an instruction) |
Assembly failed: Invalid mnemonic (KS_ERR_ASM_MNEMONICFAIL) | — |
All four examples exit 0. Through the page, the first Run — toolchain load
included — is ~60 ms and later runs are ~5 ms.
Six behaviours of this stack cost real debugging time and shape the code. Each was established by running it, not by reading docs.
1. ; is not a comment — so we make it one. Keystone's Intel mode follows
the GNU assembler, where # and // comment and ; does not, so the NASM-style
; header almost every x86 tutorial opens with fails on its first line. The page
and the package therefore strip ; comments before assembling, with a
string-aware scan so a ; inside .ascii "a;b" stays data.
The obvious alternative does not work. Keystone's own NASM dialect accepts ;,
but rejects db, equ $, times, resb and section — so it cannot define
data — and it still reads an unprefixed integer as hexadecimal; MASM is not
compiled into this build at all. The dialect in use is the only one with working
directives, so the comment style is the part that gives.
2. Integer literals are hexadecimal — so we rewrite them. Keystone's x86
backend parses an unprefixed number as hex: mov rax, 10 loads 16, and
.byte 10 emits 0x10 rather than a newline. Every mode it offers behaves this
way — its NASM dialect included, and .intel_syntax inside the AT&T parser — so
there is no dialect to switch to, and no decimal spelling to fall back on. The
page and the package therefore re-base bare integers to decimal before
assembling: 0x is hex, 0b is binary, and everything else is decimal,
including a leading zero (which Keystone would read as octal). The examples are
now written the way a tutorial would write them.
3. Unicorn's wall-clock timeout cannot be used. uc_emu_start implements its
timeout with a QEMU timer thread, and this single-threaded WASM build has no
pthreads:
qemu: qemu_thread_create: Not supported
Aborted()
Passing a non-zero timeout aborts the run immediately. The only brake is the instruction-count limit, which works because it is checked inline. The interpreter (TCI, no JIT) runs at roughly 2–4M instructions/second, so the page's 10M budget caps a runaway loop at a few seconds.
4. A bad memory access can poison the emulator instance. Jumping to an
unmapped address is a clean, catchable error (UC_ERR_FETCH_UNMAPPED). Under
Node, a data access to an unmapped address — or simply running off the end of
mapped memory — instead trapped inside Unicorn's error path and threw a
JavaScript RuntimeError: memory access out of bounds, leaving that WASM
instance unusable so the next call into it crashed. In the browser, the same
accesses surfaced as ordinary UC_ERR_READ_UNMAPPED / UC_ERR_WRITE_UNMAPPED
errors instead.
The page handles both. A clean error becomes one of the friendly messages below;
if a JavaScript trap escapes instead, the page drops its reference to the module
and the next Run calls MUnicorn() again, which returns a fresh, working
instance. The rebuild path was verified under Node (trap, rebuild, run cleanly),
and the clean-error path in the browser — each followed by a successful run.
5. Running off the end is not an error. The raw emulator would fall into
unmapped memory. Instead the page appends a 12-byte exit(0) epilogue after the
assembled program, so a program that simply ends — the most common beginner
mistake — exits cleanly.
6. close() is fine on success, not after a trap. uc_close() releases the
engine handle; calling it on a healthy engine is safe and repeated runs work.
Calling it after a trapped run trips the same corrupt state, so the page guards
it and rebuilds if it throws.
- x86-64, Intel syntax only. Keystone and Unicorn both support ARM, MIPS, RISC-V and more; only the x86 build is vendored here.
- A syscall shim, not Linux. The handful of calls in the table above, and nothing else. There is no libc, no dynamic linking and no filesystem.
- ~5 MB of WebAssembly, loaded on the first Run rather than at page load (Unicorn's all-architecture bundle is 9 MB; the x86-only build is 915 KB).
- No disassembly or single-stepping yet. Keystone assembles but does not disassemble; a trace view would need Capstone.
- ~2–4M instructions/second. Fine for the kind of programs a playground runs; not a way to run a compiler.
packages/assembly-wasm is the same idea as a
publishable package, @live-codes/assembly-wasm, for a host that wants a
language rather than a page. It exposes createCompiler(options).run(code, input, options) and resolves with { stdout, stderr, output, errors, exitCode, registers, … } — the shape @live-codes/clang-wasm uses, so errors.length is
the failure test and exitCode is null when the program never ran.
It deliberately does not vendor either runtime. Both are already on npm, so a
browser names the URLs to load them from (DEFAULT_ASSETS pins the same
versions this demo ships) and Node imports them from node_modules. See its
README for the API, and
THIRD-PARTY-NOTICES.md for what
that does and does not require of the GPL-2.0 runtimes.
The demo on this page is built on the package. main.js is nothing but the
DOM wiring, and it hands the package the vendor/ URLs — which is the whole
point of the URL option: a page keeps its own pinned copies, and the package can
still be published without carrying them.
index.html the page: examples, editor, stdin, output, register dump
main.js the UI; hands the vendored asset URLs to the package
vendor/ keystone.js + keystone.wasm, unicorn_x86.js (pinned, offline)
packages/assembly-wasm/ the published package — the engine, runtimes from URLs
serve.js static server; `node serve.js [port] [root]`
There is no bundler and no build step. main.js is an ES module that imports the
package's source directly, and the package loads the two vendored runtimes as
classic scripts on the first Run.
| what | command |
|---|---|
| serve the page | npm start → http://localhost:8123/ |
| syntax-check | npm run check |
| package tests (Node) | npm test |
| package browser probe | node serve.js 8124 → http://localhost:8124/packages/assembly-wasm/test/browser.html |
The page exposes its element ids as globals (editor, stdinInput,
runButton, output, statusEl, registers, examples) and reports state on
document.documentElement.dataset (status, stage, runs, exitCode,
syscalls), so a scripted check never needs to match UI text.
MIT (the page and the package). The vendored vendor/ runtime is GPL-2.0
(Keystone and Unicorn). Both execute as separate programs inside the page rather
than being linked with it — the same arrangement LiveCodes already uses for its
GPL-licensed language runtimes (R, Gnuplot) — but redistributing the vendored
binaries means shipping their source offer and notices alongside them.