A RISC-V emulator that runs entirely inside Roblox. Written in Luau. No external servers, no HTTP, nothing leaves the client.
Implements the full RV32IMAC instruction set (base integer, multiply/divide, atomics, compressed) plus RV32F single-precision and RV32D double-precision floating point. Comes with a built-in assembler and a fullscreen IDE, so you can write and run assembly right in-game.
Grab the .rbxl from Releases and open it in Roblox Studio.
Fullscreen code editor that opens when you join. Write RISC-V assembly, hit Run, output goes to the terminal panel at the bottom. Tabs let you switch between programs. After execution, you can view register values and memory contents in separate tabs.
Parser.lua handles turning your text into something the CPU can run. It does two passes over the source.
First pass goes through every line and figures out where things live in memory. It builds a table of all your labels and their addresses, handles .text and .data sections, processes directives like .ascii, .word, .space, .byte. Pseudo-instructions get expanded here too so the address math stays correct. Something like li with a big value turns into a lui + addi pair, which takes up two instruction slots instead of one.
Second pass takes the expanded instructions and actually encodes them. Label references get resolved into real offsets or addresses depending on what kind of instruction it is. Branches use PC-relative offsets, lui/auipc sequences use absolute addresses.
| Category | Instructions | Format |
|---|---|---|
| Arithmetic | add, sub |
R-type: op rd, rs1, rs2 |
| Arithmetic Immediate | addi, slti, sltiu |
I-type: op rd, rs1, imm |
| Logical | and, or, xor |
R-type: op rd, rs1, rs2 |
| Logical Immediate | andi, ori, xori |
I-type: op rd, rs1, imm |
| Shifts | sll, srl, sra |
R-type: op rd, rs1, rs2 |
| Shift Immediate | slli, srli, srai |
I-type: op rd, rs1, imm |
| Compare | slt, sltu |
R-type: op rd, rs1, rs2 |
| Load | lb, lh, lw, lbu, lhu |
I-type: op rd, offset(rs1) |
| Store | sb, sh, sw |
S-type: op rs2, offset(rs1) |
| Branch | beq, bne, blt, bge, bltu, bgeu |
B-type: op rs1, rs2, label |
| Upper Immediate | lui, auipc |
U-type: op rd, imm |
| Jump | jal, jalr |
J/I-type: op rd, label/offset |
| System | ecall, ebreak, fence |
| Instruction | What it does |
|---|---|
mul |
rd = (rs1 * rs2)[31:0], lower 32 bits |
mulh |
rd = (rs1 * rs2)[63:32], signed x signed upper bits |
mulhsu |
rd = (rs1 * rs2)[63:32], signed x unsigned |
mulhu |
rd = (rs1 * rs2)[63:32], unsigned x unsigned |
div |
signed division, truncates toward zero |
divu |
unsigned division |
rem |
signed remainder, sign matches dividend |
remu |
unsigned remainder |
Single-core, so aq/rl ordering bits are no-ops and lr.w/sc.w use a simple reservation address.
| Instruction | What it does |
|---|---|
lr.w rd, (rs1) |
Load-reserved: rd = mem[rs1], sets the reservation |
sc.w rd, rs2, (rs1) |
Store-conditional: rd = 0 on success, 1 if the reservation was lost |
amoswap.w / amoadd.w / amoxor.w / amoor.w / amoand.w |
Atomic read-modify-write, rd = old value |
amomin.w / amomax.w / amominu.w / amomaxu.w |
Atomic signed/unsigned min/max |
The assembler emits 32-bit instructions; compressed encodings are decoded when running ELF binaries (Decode.expand16 expands each 16-bit instruction to its 32-bit equivalent). All integer C forms plus the single- and double-precision compressed load/store forms (c.flw/c.fsw/c.flwsp/c.fswsp, c.fld/c.fsd/c.fldsp/c.fsdsp) are supported.
These get expanded by the assembler into real instructions.
| Pseudo | Expands to |
|---|---|
li rd, imm |
addi if small, lui + addi if large |
la rd, label |
auipc + addi |
mv rd, rs |
addi rd, rs, 0 |
j label |
jal x0, label |
jr rs |
jalr x0, rs, 0 |
call label |
auipc ra + jalr ra |
ret |
jalr x0, ra, 0 |
nop |
addi x0, x0, 0 |
not rd, rs |
xori rd, rs, -1 |
neg rd, rs |
sub rd, x0, rs |
beqz rs, label |
beq rs, x0, label |
bnez rs, label |
bne rs, x0, label |
bgt, ble, bgtu, bleu |
swapped-operand branches |
tail label |
auipc t1 + jalr x0, t1 |
| Directive | What it does |
|---|---|
.text |
Switch to code section |
.data / .section .data |
Switch to data section |
.globl label |
Mark a label as global (parsed but no effect in this emulator) |
.ascii "str" |
Store raw string bytes |
.asciz "str" / .string "str" |
Store string with null terminator |
.byte val, ... |
Store individual bytes |
.half val, ... |
Store 16-bit values |
.word val, ... |
Store 32-bit values |
.space n |
Reserve n zero bytes |
.align n |
Align to 2^n byte boundary |
.equ name, val |
Define a constant |
All 32 registers, both by number and ABI name.
| Register | ABI Name | Usage |
|---|---|---|
| x0 | zero | always 0 |
| x1 | ra | return address |
| x2 | sp | stack pointer |
| x3 | gp | global pointer |
| x4 | tp | thread pointer |
| x5-x7 | t0-t2 | temporaries |
| x8 | s0/fp | saved register / frame pointer |
| x9 | s1 | saved register |
| x10-x11 | a0-a1 | function args / return values |
| x12-x17 | a2-a7 | function args |
| x18-x27 | s2-s11 | saved registers |
| x28-x31 | t3-t6 | temporaries |
CPU.lua is the execution engine. Pretty standard fetch-decode-execute loop.
It has 32 integer registers (x0 is hardwired to zero), a program counter, and byte-addressable memory stored as a Lua table keyed by address.
Each cycle: read the instruction at PC, figure out what it does, do it, write back the result, bump PC forward. Branches and jumps just set PC directly.
One thing worth knowing: Luau uses 64-bit floats internally, not integers. So the CPU has to be careful about keeping everything in 32-bit range. It uses bit32 for unsigned operations and manual sign extension for signed ones.
The multiply instructions are the trickiest part. mulh, mulhsu, mulhu need the upper 32 bits of a 64-bit product. Can't just multiply two numbers and grab the top half because float precision will eat some bits. Instead it splits operands into 16-bit halves and does partial products by hand to keep everything exact.
Division follows the RISC-V spec (C99 semantics). Truncation toward zero. Division by zero returns -1 or max unsigned. Overflow case (-2^31 / -1) returns -2^31.
Execution caps out at 50,000,000 instructions so infinite loops don't lock up the client. The run loop executes in short slices and yields between them, so long programs (like the Pi tab) don't freeze the rest of the game while they run.
Programs talk to the host using ecall. Handled by the shared dispatcher in RiscV/Syscalls.lua (with the file syscalls registered by FS.install()).
| Syscall | Number | Registers | What it does |
|---|---|---|---|
| read | 63 | a0=fd, a1=buf, a2=len | Reads from the terminal input. fd=0 for stdin |
| write | 64 | a0=fd, a1=buf, a2=len | Writes bytes from memory to the terminal. fd=1 stdout, fd=2 stderr |
| exit | 93 | a0=exit_code | Stops execution |
Every Run gets a fresh in-memory filesystem, seeded from RiscV/Initrd.lua. Errors return -errno in a0.
| Syscall | Number | Registers | What it does |
|---|---|---|---|
| openat | 56 | a0=dirfd, a1=path, a2=flags, a3=mode | Opens a file, returns fd. dirfd=AT_FDCWD(-100) for cwd-relative |
| open | 1024 | a0=path, a1=flags, a2=mode | Legacy open, same as openat(AT_FDCWD, ...) |
| close | 57 | a0=fd | Closes a fd |
| read | 63 | a0=fd, a1=buf, a2=len | Reads raw bytes from a file fd (fd >= 3) |
| write | 64 | a0=fd, a1=buf, a2=len | Writes raw bytes to a file fd (fd >= 3) |
| readv / writev | 65 / 66 | a0=fd, a1=iov, a2=cnt | Scatter/gather I/O (iovec entries are {base, len}) |
| pread64 | 67 | a0=fd, a1=buf, a2=len, a3=offset | Reads at an offset; fd position untouched |
| pwrite64 | 68 | a0=fd, a1=buf, a2=len, a3=offset | Writes at an offset (O_APPEND ignored); fd position untouched |
| lseek | 62 | a0=fd, a1=off_hi, a2=off_lo, a3=result, a4=whence | rv32 _llseek ABI: 64-bit offset, result written through a3. SEEK_SET=0, SEEK_CUR=1, SEEK_END=2; pipes/tty -> ESPIPE |
| fstat | 80 | a0=fd, a1=statbuf | Writes an 80-byte struct stat. fd 0/1/2 report a char device |
| fstatat | 79 | a0=dirfd, a1=path, a2=statbuf, a3=flags | stat by path; AT_EMPTY_PATH stats dirfd itself |
| stat / lstat | 1038 / 1039 | a0=path, a1=statbuf | Legacy path stat (same as fstatat with AT_FDCWD) |
| statx | 291 | a0=dirfd, a1=path, a2=flags, a3=mask, a4=buf | 256-byte struct statx (musl 1.2.6+ routes fstatat here) |
| getdents64 | 61 | a0=fd, a1=buf, a2=len | Lists a directory (starts with . and ..), returns bytes written |
| mkdirat | 34 | a0=dirfd, a1=path, a2=mode | Creates a directory |
| unlinkat | 35 | a0=dirfd, a1=path, a2=flags | Removes a file; AT_REMOVEDIR=0x200 for rmdir |
| symlinkat | 36 | a0=target, a1=newdirfd, a2=linkpath | Creates a symlink |
| renameat | 38 | a0=olddirfd, a1=oldpath, a2=newdirfd, a3=newpath | Renames / moves |
| renameat2 | 276 | a0..a3, a4=flags | renameat + RENAME_NOREPLACE (0x1) |
| ftruncate | 46 | a0=fd, a1=len | Truncates a file |
| fchmod | 52 | a0=fd, a1=mode | Changes permission bits (type bits preserved) |
| fchmodat | 53 | a0=dirfd, a1=path, a2=mode, a3=flags | Path chmod; AT_SYMLINK_NOFOLLOW changes the link itself |
| readlinkat | 78 | a0=dirfd, a1=path, a2=buf, a3=size | Copies a symlink target (no NUL), returns its length |
| umask | 166 | a0=mask | Returns the previous mask; stored but not applied to creations |
| faccessat | 48 | a0=dirfd, a1=path, a2=mode, a3=flags | Existence check (permissions are not enforced) |
| access | 1033 | a0=path, a1=mode | Legacy faccessat |
| utimensat | 88 / 412 | a0=dirfd, a1=path, a2=times, a3=flags | Accepted and ignored (no clock); path is validated |
| fcntl | 25 | a0=fd, a1=cmd, a2=arg | F_GETFD/F_SETFD, F_GETFL/F_SETFL, F_DUPFD(_CLOEXEC); locks are no-ops |
| chdir | 49 | a0=path | Changes the cwd |
| fchdir | 50 | a0=fd | Changes the cwd to an open directory fd |
| getcwd | 17 | a0=buf, a1=size | Writes the cwd, returns length incl NUL |
| ioctl | 29 | a0=fd, a1=req, a2=arg | Terminal only: TCGETS (zeroed termios), TIOCGWINSZ (24x80); else ENOTTY |
| getpid | 172 | Returns 1 | |
| brk | 214 | a0=addr | Bump allocator over guest heap 0x20000000-0x40000000 |
Process stubs (enough for real static binaries to get through startup): exit_group 94, set_tid_address 96, set_robust_list 99, clock_gettime 113, rt_sigaction 134, rt_sigprocmask 135, gettimeofday 169, getuid/geteuid/getgid/getegid 174-177, munmap 215, mprotect 226, prlimit64 261. mmap 222 hands out guest memory (MAP_FIXED honored, otherwise a bump region above the heap). uname 160 returns the emulator's real identity (the client passes the Roblox player name as nodename), sysinfo 179 reports the actual runtime memory in use against the guest heap window (0x20000000-0x40000000) with a session-wide uptime, and sched_getaffinity 123 reports the single hart. Anything unimplemented warns on the host and returns -ENOSYS.
RiscV/Process.lua is a small user-mode kernel that sits on top of the single CPU. It layers a cooperative process model over it, which is enough for real shells and applets to run:
| Syscall | What it gives you |
|---|---|
clone 220 |
fork-like. Threads are rejected, vfork is treated as a plain fork |
execve 221 |
runs static ELF images, /proc/self/exe re-exec included |
wait4 260 / waitid 95 |
with a correct rv32 siginfo for SIGCHLD |
pipe2 59 |
blocking pipes with refcounted ends, plus read/write/readv/writev |
nanosleep 101 / clock_nanosleep 115 / clock_nanosleep_time64 407 |
timer wakes driven by the host clock |
ppoll 73 / ppoll_time64 414 |
poll on terminals and pipes |
sendfile64 71 |
file to terminal/file fast path |
getpid / getppid |
real values from the process table |
Fork copies the address space and shares the filesystem tree, with a per-process fd table. When a process exits it closes its fds, so pipe readers see EOF the way they should. getgroups 158 returns no supplementary groups, and clock_gettime64 403 just mirrors clock_gettime.
Signals are real, not stubs: rt_sigaction 134 (kernel rv32 struct sigaction, SA_RESTART honored), rt_sigprocmask 135, rt_sigpending 136, rt_sigreturn 139 (a full rv32 rt_sigframe/ucontext/sigcontext, fp state included), rt_sigsuspend 133, kill 129 / tkill 130 / tgkill 131, process groups (setpgid/getpgid/setsid) and TIOCSPGRP/TIOCGPGRP.
Handlers run with a real signal frame. Default actions terminate and the wait status reports the signal. SIGCHLD is raised on child exit so ash can reap children and trap works, and Ctrl+C sends SIGINT to the foreground group. SIGPIPE is raised on writes to a closed pipe.
Note
Job control is the one thing missing. SIGTSTP/SIGSTOP are ignored, so fg, bg and Ctrl+Z do nothing.
Open flags the VFS understands:
O_RDONLY / O_WRONLY / O_RDWR 0x0 / 0x1 / 0x2
O_CREAT 0x40
O_EXCL 0x80
O_TRUNC 0x200
O_APPEND 0x400
O_DIRECTORY 0x10000 (files -> ENOTDIR)
Permissions are reported as 0644/0755 but never enforced. lseek on the terminal returns ESPIPE.
struct stat uses the rv32 ilp32 Linux layout, 80 bytes:
offset field
0 st_dev (8)
8 st_ino (8)
16 st_mode
20 st_nlink
24 st_uid
28 st_gid
32 st_rdev (8)
40 st_size
44 st_blksize
48 st_blocks (8)
56 atim (8)
64 mtim (8)
72 ctim (8)
Timestamps stay zero. The guest world has no clock of its own.
linux_dirent64 records are the usual 8-byte padded format: d_ino (8), d_off (8), d_reclen (2, padded to 8), d_type (1, DT_REG=8, DT_DIR=4) and a NUL-terminated d_name. Records come out sorted by name so the output stays deterministic.
Limits: 63-char names, 256-char paths, 2MB per file, 16MB total. Past that you get ENOSPC. Symlinks work (readlinkat, AT_SYMLINK_NOFOLLOW, DT_LNK), which is what lets the mounted rootfs expose its /bin/* links.
Note
This is the same ABI as Linux RISC-V. A simple program that runs on QEMU with just these syscalls will run here too.
Statically linked ET_EXEC rv32 images run directly. ELF.lua parses the program headers and maps PT_LOAD segments into guest memory. Decode.lua is a full RV32IMAC + F + Zicsr machine-code decoder, and compressed instructions get expanded to their 32-bit equivalents before execution. The CPU runs raw images in a second mode (program.raw = true) that fetches and decodes from memory with a per-PC instruction cache.
Dynamic executables and PIE are rejected with a clear error. There is no dynamic linker here.
Because the emulator covers A and C, real rv32 Linux userland binaries run. The embedded BusyBox (static musl, BusyBox 1.36.1 defconfig, full applet set) boots ash and runs uname, tar, gzip, find, grep, sed, sort, tail, df, free, id, vi and friends through the process kernel. The IDE sidebar still exposes a few applets (sh, ls, cat, ...) as tabs. They all share one blob, with argv0 set per applet, because applets are dispatched by argv[0].
The Sh tab boots the ash shell. Press Run and type commands into the terminal input at the bottom:
ls
cat /etc/motd
echo hi > /tmp/xThe prompt is printed as output, and exit or the EOF button ends the session. RiscV/Process.lua runs the whole thing: clone/fork, wait4/waitid, pipes, execve and signals, so redirections (>, >>, <), pipelines (|), subshells, & background jobs, trap, kill and custom /bin programs work like on Linux. Ctrl+C sends SIGINT to the foreground process group. Job control (fg/bg/Ctrl+Z) is still unsupported.
There is also a Terminal button in the top bar. It hides the IDE and opens the shell in a windowed terminal with macOS-style traffic lights, a colored user@host:~# prompt (the hostname is the actual Roblox player, the same identity uname reports), ANSI/VT100 parsing (colors, carriage returns, erase sequences, OSC titles), scrollback, command history (Up/Down), Ctrl+L, and input echoed inline at the prompt with a blinking cursor. Client/Terminal.lua is the emulator buffer, and FS reports the real window size through TIOCGWINSZ. Type commands until exit. The red dot closes the session, yellow minimizes back to the IDE, green toggles maximize.
Every run mounts a real RISC-V Linux rootfs into the in-memory filesystem: the buildroot initramfs shipped with the rv32emu-prebuilt Linux image (Image + rootfs.cpio). That is 1621 entries, 296 symlinks, 222 hardlink groups and a /dev/console device node.
- The base64 image lives in
RiscV/RootFSData.txt, which Rojo syncs as a StringValue: one 7.5MB string, which sidesteps Roblox's per-script source limit (a.txtfile becomes a StringValue automatically).RiscV/RootFS.luareadsRootFSData.Valuein Roblox and falls back to reading the.txtfrom disk in the Lua test harness.RiscV/Cpio.luaparses the SVR4 "newc" archive once (including the hardlink convention where the data lives on the last link), thenRootFS.mount(fs)populates any number of FS instances. - The image's own busybox is a dynamically linked PIE, which the emulator cannot execute. After mounting,
/bin/busyboxis replaced with the static build, so the/bin/*symlinks resolve to a real binary. - In the shell:
ls /shows the full Linux tree,cat /etc/passwdworks,ls -l /bin/lsshowslrwxrwxrwx ... /bin/ls -> busybox, andcat /etc/os-releasereports Buildroot 2025.11. - The client seeds a synthetic
/procthat describes the actual emulator (one hart, no MMU,isa: rv32imafc, Luau interpreter), so tools like fastfetch report the truth instead of guessing.
Note
The kernel itself is not booted. That would need an MMU, privilege modes, traps, a timer and a UART. This is the userland half: a genuine Linux filesystem with real files, permissions and symlinks.
Embedded binaries live in RiscV/Binaries.lua as base64 and show up in the IDE sidebar as read-only .elf tabs. Pressing Run loads and executes them. The Linux initial stack (argc/argv/envp/auxv, 16-byte aligned) is set up by ELF.setupStack.
Building your own test binary:
riscv32-elf-as -march=rv32im -mno-relax -o prog.o prog.s
riscv32-elf-ld -m elf32lriscv --no-relax -o prog.elf prog.o
lua5.4 test/elf_runner.lua prog.elf src/RiscV [stdin file] [guest args...]
lua5.4 test/kernel_runner.lua prog.elf [stdin file] # runs through the process kernel (fork/exec/signals)Important
--no-relax is required for bare programs. Without it, GNU ld rewrites la into gp-relative addressing, which assumes a crt0 set gp (programs linked with a crt0 are fine either way).
The ELF test suite is validated line-by-line against qemu-riscv32, including compressed (-march=rv32imc, rv32imfc) and atomics (-march=rv32ima) variants.
C-Test/build.sh --custom <source.c> <Name> [description] compiles a C file for riscv32-linux-musl and writes src/CustomPrograms/<Name>.lua, which Rojo syncs into ReplicatedStorage.CustomPrograms. Every run mounts each entry at /bin/<Name> (mode 0755), so ls /bin shows it and typing <Name> [args...] at the terminal prompt runs it through the process kernel's execve, exactly like any other binary. Arguments, stdin/stdout, redirections and pipelines all apply.
./C-Test/build.sh --custom C-Test/hello.c hello "hello world in C"Several samples ship in src/CustomPrograms/: hello, snake, vm, finder and ff. Programs are static rv32im ET_EXEC images, so emulator syscall coverage is the limit (no threads, no signals, no network yet).
Note
The C sources in C-Test/ (hello.c, programC.c, snake.c, vm.c, finder.c, fastfetch.c) are AI-generated test fixtures. They exist to exercise the C compilation and ELF execution pipeline (build.sh, static linking, syscall coverage), not to serve as hand-written reference code.
src/
Client/
EmulatorUI.client.lua - the IDE, runs on client
Terminal.lua - VT100/ANSI cell buffer + RichText renderer
CustomPrograms/ - generated ELF modules, mounted at /bin (build.sh --custom)
Main.server.lua - optional server-side runner for headless testing
RiscV/
CPU.lua - execution engine (assembled list + raw ELF modes)
Parser.lua - assembler
Decode.lua - RV32IMAC + F + Zicsr machine-code decoder
ELF.lua - ELF32 loader + Linux stack setup
Base64.lua - decoder for embedded binaries
Binaries.lua - embedded ELF binaries shown as IDE tabs
Spec.lua - instruction encoding tables, register maps
Syscalls.lua - shared syscall dispatcher (host abstraction)
Process.lua - user-mode process kernel (fork/exec/wait/pipes)
FS.lua - in-memory file system + file syscalls
Cpio.lua - SVR4 newc cpio parser (initramfs images)
RootFS.lua - Linux rootfs image + mount helper
RootFSData.txt - the image, synced as a 7.5MB StringValue
BusyBoxData.txt - full BusyBox 1.36.1 (base64), synced as a StringValue
Initrd.lua - seed files for the file system
Programs.lua - built-in test programs
UserPrograms.lua - example programs that show up as tabs in the IDE
- HelloWorld: basic write syscall
- Fib2: iterative fibonacci
- Factorial: recursive, computes 7!
- BubbleSort: sorts an array, prints it
- SierpinskiTriangle: renders the triangle with bitwise ops
- GCD: euclidean algorithm
- Divide: integer division, prints quotient and remainder
- Mul2: multiplication test
- No dynamic linking / PIE (static ET_EXEC ELF only)
- No real CSR support
- No interrupts or exceptions beyond ecall
- No job control (SIGTSTP/SIGSTOP are ignored;
fg/bg/Ctrl+Z do nothing) - No MMU
- No disk persistence (FS is in-memory, fresh each Run)