ROVM - A Virtual Computer & OS Implemented Entirely in Roblox
What is this?
ROVM is a complete virtual computer running inside Roblox. This project started as an experiment to see how far Roblox’s runtime and Luau could be pushed, but it has since evolved into a full-fledged computer. It has its own CPU, a built in C compiler, assembly language, framebuffer, terminal, filesystem, and even virtual memory with copy-on-write. You can write programs for it in C or assembly and they actually run.
This runs entirely within Roblox’s sandbox, with no exploits, no native code, and no external execution.
What can it do?
- Custom 32-bit CPU with 16 registers and 26+ instructions
- Full assembly language with labels, macros, and directives
- Built in C Compiler supporting standard libraries
- Pixel-addressable framebuffer for drawing graphics
- Memory management with virtual memory and page tables
- Persistent filesystem that saves to DataStore
- Included Demos: DOOM, Neofetch, Bad Apple, and more!
- Interactive terminal with shell commands
- Text display with an 8x8 bitmap font
- Kernel/user mode separation
- Round-robin process scheduler
The Framebuffer
One of the most unique parts of this project is the framebuffer. Programs can write directly to a memory-mapped pixel buffer and draw whatever they want. Each pixel is just a memory address; write an RGB value to it and it shows up on screen. It is powered by EditableImage for extremely fast O(1) rendering, allowing for consistently hitting high FPS while using the shell. For more intensive programs, like DOOM, the screen is not the bottleneck.
The framebuffer maps every pixel to a memory address. Writing 0xFF0000 to address 0x100000 sets the first pixel to red. Simple but powerful.
-- Each pixel is memory-mapped starting at PIX_BASE
mem:mapDevice(PIX_BASE, pixelCount,
function(addr) -- reading a pixel
local i = addr - PIX_BASE
local x = i % w
local y = math.floor(i / w)
return color3ToInt(screen.pixels[y * w + x + 1])
end,
function(addr, value) -- writing a pixel
local i = addr - PIX_BASE
local x = i % w
local y = math.floor(i / w)
screen:setPixel(x, y, intToColor3(value))
end
)
There’s also a control device for things like clearing the screen and triggering reboot:
-- Control registers for screen operations
local CTRL_CLEAR = 0 -- write color to clear screen
local CTRL_FLUSH = 1 -- write anything to flush display
local CTRL_REBOOT = 2 -- write 1 to trigger reboot
local CTRL_WIDTH = 3 -- read screen width
local CTRL_HEIGHT = 4 -- read screen height
Hardware-Accelerated GPU
Originally, the system required you to write pixels one by one to the framebuffer. Now, the OS has a built in virtual GPU accessed via syscalls (SC_GPU_DRAW_RECT, SC_GPU_DRAW_LINE, etc.). This hardware acceleration natively interfaces with Roblox’s EditableImage API under the hood, allowing C programs to draw filled rectangles, lines, and complex 3D raycasted scenes.
// Example of drawing a red square in C
void draw_square(int x, int y, int size) {
syscall(3, x, y, size, size, RGB(255, 0, 0), 0); // SC_GPU_DRAW_RECT
}
There’s also a control device for things like clearing the screen, syncing frames, and triggering reboot:
syscall(2, 0, 0, 0, 0, 0, 0); // FLUSH display
syscall(5, 0, 0, 0, 0, 0, 0); // CLEAR screen
The CPU
The CPU runs in a fetch-decode-execute loop like a real processor. Each instruction is 32 bits packed into one word, opcode in the top 8 bits, then register operands in the rest.
OPCODES[0x04] = function(self, d, a, b) -- ADD rd, ra, rb
self.reg[d+1] = bit32.band(self.reg[a+1] + self.reg[b+1], 0xFFFFFFFF)
self.pc += 1
end
OPCODES[0x07] = function(self, d) -- JZ rd, target
local tgt = memRead(self, self.pc + 1)
if self.reg[d+1] == 0 then
self.pc = tgt
else
self.pc += 2
end
end
OPCODES[0x30] = function(self, _, _, b) -- SYSCALL imm8
self.trap = { kind = "syscall", n = b, pc = self.pc }
self.pc += 1
end
There’s kernel mode and user mode. User programs can’t run privileged instructions like HALT or FLUSH - they’ll get killed if they try:
OPCODES[0x01] = function(self) -- HALT
if self.mode == "user" then
trap(self, "fault", {
type = "privileged_instruction",
instruction = "HALT"
})
return
end
trap(self, "halt", "HALT")
end
Assembly Language
The assembler takes human-readable assembly and produces machine code. It handles labels, wide immediates (32-bit values embedded in the instruction stream), and some useful macros.
Here’s the full instruction set:
local OPCODES = {
NOP = 0x00, HALT = 0x01, LOAD = 0x02, STORE = 0x03,
ADD = 0x04, SUB = 0x05, JMP = 0x06, JZ = 0x07,
LOADI = 0x08, MUL = 0x09, DIV = 0x0A, MOD = 0x0B,
AND = 0x0C, OR = 0x0D, XOR = 0x0E, NOT = 0x0F,
SHL = 0x10, SHR = 0x11, MOV = 0x12, CMPEQ = 0x13,
CMPLT = 0x14, CMPGT = 0x15, FLUSH = 0x20, SLEEP = 0x21,
CALL = 0x22, RET = 0x23, PUSH = 0x24, POP = 0x25,
SYSCALL = 0x30,
-- Immediate variants
ADDI = 0x16, SUBI = 0x17, MULI = 0x18,
ANDI = 0x19, ORI = 0x1A, XORI = 0x1B,
}
Built in constants make it easier to work with memory-mapped devices:
local STDLIB_LABELS = {
SYS_TEXT_WRITE = 0x400004, -- write char here to display it
SYS_TEXT_FG = 0x400002, -- foreground color
SYS_TEXT_BG = 0x400003, -- background color
SYS_NL = 10, SYS_CR = 13, SYS_BS = 8, SYS_SP = 32,
}
Byte-Addressable Memory
A major recent upgrade to the VM architecture: memory is now fully standard, byte-addressable RAM powered by Luau buffer objects. This allows standard C functionality, like char* string manipulation, memcpy, pointers, and malloc, to work exactly as they do natively without awkward 32-bit word alignments.
The Terminal
The operating system shell is written fully in C. The system boots up, compiles shell.c, and launches it inside the VM just like a real Linux machine booting standard binaries.
Commands: help, clear, reboot, echo, ls, mkdir, cat, write, rm
Because it’s written in standard C, the main input loop looks familiar:
int main() {
print_banner();
char buf[256];
int pos = 0;
while (1) {
print("\nuser:/");
print(cwd);
print("> ");
while (1) {
char c = syscall(1, 0, 0, 0, 0, 0, 0); // read char
if (c == 13 || c == 10) break; // enter
if (c == 8 && pos > 0) { // backspace
pos--;
syscall(0, 8, 0, 0, 0, 0, 0);
} else if (c >= 32 && pos < 255) {
buf[pos++] = c;
syscall(0, c, 0, 0, 0, 0, 0);
}
}
buf[pos] = 0;
if (pos > 0) {
execute_cmd(buf); // dispatch it to commands
}
pos = 0;
}
return 0;
}
Syscalls are how C programs talk to the kernel (instead of standard libc wrappers):
// Syscall numbers:
// 0 = write char 1 = read char 2 = flush screen
// 3 = gpu rect 4 = gpu line 5 = clear screen
// 32 = open file 33 = read file 34 = write file
// 35 = close file 37 = unlink 38 = mkdir
// 40 = listdir 42 = sbrk (alloc) 73 = sysinfo
All file I/O, parsing, and command dispatch is handled completely inside the virtual machine with no host side UI shortcuts!
Virtual Memory
The MMU translates virtual addresses to physical ones. Each process gets its own page table so they can’t mess with each other’s memory.
function MMU:translate(virtualAddr, accessType)
if self.kernelMode then
return virtualAddr -- kernel uses physical addresses directly
end
local virtualPage = self.currentPageTable:addressToPage(virtualAddr)
local pageOffset = self.currentPageTable:getPageOffset(virtualAddr)
local physicalPage, err = self.currentPageTable:translate(virtualPage)
if not physicalPage then
return nil, "page_fault"
end
-- Copy-on-write check
if accessType == "write" and self.currentPageTable:isCow(virtualPage) then
return nil, "cow_fault"
end
return (physicalPage * self.pageSize) + pageOffset
end
Copy-on-write means forked processes share memory until one of them writes to it. Then we copy the page:
function MMU:handleCowFault(virtualAddr, physicalMemory)
local virtualPage = self.currentPageTable:addressToPage(virtualAddr)
local oldPhysicalPage = self.currentPageTable:translate(virtualPage)
-- Allocate new page
local newPhysicalPage = self.physicalAllocator:allocatePage()
-- Copy the data
for i = 0, self.pageSize - 1 do
physicalMemory.data[newAddr + i] = physicalMemory.data[oldAddr + i]
end
-- Update page table to point to new page
entry.physicalPage = newPhysicalPage
entry.permissions = bit32.band(entry.permissions, bit32.bnot(PERM_COW))
end
Text Rendering
The text device draws characters using an embedded 8x8 bitmap font. Each character is 8 bytes, one per row, with bits representing pixels.
local function drawChar(screen, cellX, cellY, ch, fg, bg)
local glyph = FONT[ch] or FONT[63] -- '?' for unknown chars
local px0 = cellX * 8
local py0 = cellY * 8
for row = 0, 7 do
local bits = glyph[row + 1]
for col = 0, 7 do
local on = bit32.band(bits, bit32.lshift(1, col)) ~= 0
if on then
screen:setPixel(px0 + col, py0 + row, fg)
elseif bg then
screen:setPixel(px0 + col, py0 + row, bg)
end
end
end
end
-- The font table
local FONT = {
[65] = {56,108,198,254,198,198,198,0}, -- A
[66] = {126,198,198,126,198,198,126,0}, -- B
-- ... 95 printable ASCII characters
}
Filesystem
The filesystem is inode-based like Unix. Files and directories get inode numbers, directories store child inodes by name. Everything persists to DataStore so your files survive between sessions.
function Filesystem:createFile(path, data)
local dirPath, filename = self:splitPath(path)
local parentInode = self:resolvePath(dirPath)
local inode = self:allocateInode()
self.inodes[inode] = {
type = Filesystem.TYPE_FILE,
size = #data,
data = data,
parent = parentInode,
permissions = PERM_READ + PERM_WRITE,
}
self.inodes[parentInode].children[filename] = inode
return inode
end
Process Scheduler
There’s a round-robin scheduler that can run multiple processes. Each process has its own CPU state, page table, and file descriptors.
function Scheduler:scheduleNext()
-- Put current process back in queue if still running
if self.currentPid then
local current = self.processes[self.currentPid]
if current and current.state == "ready" then
table.insert(self.processQueue, self.currentPid)
end
end
-- Pop next ready process
local nextPid = table.remove(self.processQueue, 1)
self.currentPid = nextPid
if nextPid then
local proc = self.processes[nextPid]
proc.state = "running"
proc:restoreState()
end
end
The C Compiler
While the system was originally built just for assembly (and OS written fully in assembly), I’ve since created a full custom C compiler for BloxOS. You can write C code using the built in edit command, compile it directly within the virtual shell using cc, and run it instantly.
The compiler supports #include directives, pointers, arrays, loops, and hooks into kernel syscalls via rovm.h. You can even render graphics in C!
What’s Included
When you boot the VM, the BloxOS shell launches automatically. I’ve pre-compiled several impressive demos that push the system to its absolute limits:
-
doom: Fully playable classic 1993 shooter. -
neofetch: A dynamic system tool that queries the kernel for uptime, used memory, active processes, and screen resolution. -
bad_apple: A playback of the famous music video. -
cube: A smoothly rotating 3D wireframe render using<math.h>sine/cosine functions. -
benchmark: A CPU and Memory stress test that measures virtual instructions-per-second.
What’s Next
Still working on a few things:
- Parallel Luau for multithreading.
This project took a LOT of time and coffee, but it was, in my opinion, worth it to push what is possible in Roblox. You don’t need to understand all of this to play with it, but if you like low-level systems, there’s a lot to dig into. If you have questions about how any of this works, feel free to ask.
Thanks for reading!
TL;DR: I built a full virtual computer and operating system inside Roblox. It has a custom CPU, C compiler, assembler, persistent filesystem, and shell. I even managed to port a fully playable DOOM clone to run entirely within the VM.






