Skip to content

Latest commit

 

History

24 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Roblox RV32IM Emulator

A RISC-V emulator that runs entirely inside Roblox. Written in Luau. No external servers, no HTTP, nothing leaves the client.

Language ISA Platform License Release

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.


What this is

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.

The Assembler

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.

RV32I Instructions

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

RV32M Extension (Multiply/Divide)

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

RV32A Extension (Atomics)

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

RV32C Extension (Compressed)

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.

Pseudo-instructions

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

Data Directives

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

Registers

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

The CPU

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.

Syscalls

Programs talk to the host using ecall. Handled by the shared dispatcher in RiscV/Syscalls.lua (with the file syscalls registered by FS.install()).

Terminal

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

File system

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.

Process kernel

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.

ELF binaries

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/x

The 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.

Linux rootfs

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 .txt file becomes a StringValue automatically). RiscV/RootFS.lua reads RootFSData.Value in Roblox and falls back to reading the .txt from disk in the Lua test harness. RiscV/Cpio.lua parses the SVR4 "newc" archive once (including the hardlink convention where the data lives on the last link), then RootFS.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/busybox is 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/passwd works, ls -l /bin/ls shows lrwxrwxrwx ... /bin/ls -> busybox, and cat /etc/os-release reports Buildroot 2025.11.
  • The client seeds a synthetic /proc that 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.

Custom programs

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.

Project structure

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

Example programs included

  • 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

What's not here

  • 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)

About

No description, website, or topics provided.

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages