A simple extensible stack-based bytecode virtual machine and assembler written in Rust.
Multi-byte immediate values, embedded-data length fields and multi-byte memory loads and stores use little-endian byte order.
Higher-level languages targeting the VM may use a different internal representation, but are responsible for converting byte order when necessary.
The VM provides 16 global registers, numbered 0 through 15. Each register stores one raw u64 word and is initialized to zero when the VM starts.
Registers have no predefined purpose or calling convention. CALL and RT do not automatically save, restore, or otherwise modify register values. Languages and programs targeting the VM are free to define their own conventions for register use.
Registers can be accessed using RGET and RSET. When a register index is provided, the assembler emits a direct register instruction. When the index is omitted, the VM pops the register index from the operand stack.
Register indices must be between 0 and 15. An out-of-range index causes execution to fail.
Host calls do not modify registers.
The VM uses one-byte opcodes and a u64 stack word.
Some opcode ranges encode a small signed or unsigned value directly in the low nibble.
The assembler supports the full instruction set listed below.
Stack effects are written from bottom to top, with the rightmost value representing the top of the operand stack.
Stack words are raw 64-bit values. Signed instructions interpret them as signed two's-complement integers, while unsigned instructions interpret them as unsigned integers.
Relative jump and call offsets are measured in bytes from the address immediately following the instruction.
| Byte / Range | Opcode | Stack effect |
|---|---|---|
0x00 |
Noop |
[] → [] |
0x01 |
Add |
[a, b] → [a + b] |
0x02 |
Sub |
[a, b] → [a - b] |
0x03 |
Mul |
[a, b] → [a * b] |
0x04 |
DivUnsigned |
[a, b] → [a / b] |
0x05 |
DivSigned |
[a, b] → [a / b] |
0x06 |
RemUnsigned |
[a, b] → [a % b] |
0x07 |
RemSigned |
[a, b] → [a % b] |
0x08 |
BitAnd |
[a, b] → [a & b] |
0x09 |
BitOr |
[a, b] → [a | b] |
0x0A |
BitXor |
[a, b] → [a ^ b] |
0x0B |
BitNot |
[a] → [~a] |
0x0C |
ShiftLeft |
[a, b] → [a << b] |
0x0D |
LogicShiftRight |
[a, b] → [a >> b] |
0x0E |
ArithShiftRight |
[a, b] → [a >> b] |
0x0F |
Pop |
[a] → [] |
0x10 |
Swap |
[a, b] → [b, a] |
0x11 |
PushUnsigned8 |
[] → [value] |
0x12 |
PushSigned8 |
[] → [value] |
0x13 |
PushUnsigned16 |
[] → [value] |
0x14 |
PushSigned16 |
[] → [value] |
0x15 |
PushUnsigned32 |
[] → [value] |
0x16 |
PushSigned32 |
[] → [value] |
0x17 |
Push64 |
[] → [value] |
0x18 |
CompEqual |
[a, b] → [a == b] |
0x19 |
CompNotEqual |
[a, b] → [a != b] |
0x1A |
CompLessThanUnsigned |
[a, b] → [a < b] |
0x1B |
CompLessThanSigned |
[a, b] → [a < b] |
0x1C |
CompLessThanOrEqualUnsigned |
[a, b] → [a <= b] |
0x1D |
CompLessThanOrEqualSigned |
[a, b] → [a <= b] |
0x1E |
CompGreaterThanUnsigned |
[a, b] → [a > b] |
0x1F |
CompGreaterThanSigned |
[a, b] → [a > b] |
0x20 |
CompGreaterThanOrEqualUnsigned |
[a, b] → [a >= b] |
0x21 |
CompGreaterThanOrEqualSigned |
[a, b] → [a >= b] |
0x22 |
JumpRelSigned8 |
[] → [] |
0x23 |
JumpRelSigned16 |
[] → [] |
0x24 |
JumpRelSigned32 |
[] → [] |
0x25 |
JumpRelSigned64 |
[] → [] |
0x26 |
JumpIfZeroRelSigned8 |
[condition] → [] |
0x27 |
JumpIfZeroRelSigned16 |
[condition] → [] |
0x28 |
JumpIfZeroRelSigned32 |
[condition] → [] |
0x29 |
JumpIfZeroRelSigned64 |
[condition] → [] |
0x2A |
CallRelSigned8 |
[] → [] |
0x2B |
CallRelSigned16 |
[] → [] |
0x2C |
CallRelSigned32 |
[] → [] |
0x2D |
CallRelSigned64 |
[] → [] |
0x2E |
JumpAbsIndirect |
[address] → [] |
0x2F |
JumpIfZeroAbsIndirect |
[address, condition] → [] |
0x30 |
CallAbsIndirect |
[address] → [] |
0x31 |
Return |
[] → [] |
0x32 |
MemLoad8 |
[address] → [value] |
0x33 |
MemLoad16 |
[address] → [value] |
0x34 |
MemLoad32 |
[address] → [value] |
0x35 |
MemLoad64 |
[address] → [value] |
0x36 |
MemStore8 |
[address, value] → [] |
0x37 |
MemStore16 |
[address, value] → [] |
0x38 |
MemStore32 |
[address, value] → [] |
0x39 |
MemStore64 |
[address, value] → [] |
0x3A |
MemStore128 |
[address, w0, w1] → [] |
0x3B |
MemStore256 |
[address, w0, ..., w3] → [] |
0x3C |
MemStore512 |
[address, w0, ..., w7] → [] |
0x3D |
MemStore1024 |
[address, w0, ..., w15] → [] |
0x40–0x4F |
PickUnsigned4 |
[..., value, ...] → [..., value, ..., value] |
0x50 |
PickUnsigned8 |
[..., value, ...] → [..., value, ..., value] |
0x51 |
PickUnsigned16 |
[..., value, ...] → [..., value, ..., value] |
0x52 |
PickUnsigned32 |
[..., value, ...] → [..., value, ..., value] |
0x53 |
PickUnsigned64 |
[..., value, ...] → [..., value, ..., value] |
0x54 |
RegGetIndirect |
[index] → [value] |
0x55 |
RegSetIndirect |
[value, index] → [] |
0x56 |
MemSize |
[] → [size] |
0x57 |
MemGrow |
[amount] → [old_size] |
0x58 |
MemCopy |
[destination, source, length] → [] |
0x59 |
MemFill |
[address, value, length] → [] |
0x5A |
MemBytecodeCopy |
[destination, bytecode_source, length] → [] |
0x60–0x6F |
RegisterGet |
[] → [value] |
0x70–0x7F |
RegisterSet |
[value] → [] |
0x80 |
DataUnsigned8 |
[] → [] |
0x81 |
DataUnsigned16 |
[] → [] |
0x82 |
DataUnsigned32 |
[] → [] |
0x83 |
DataUnsigned64 |
[] → [] |
0x84–0x8F |
DataLength1–DataLength12 |
[] → [] |
0x90–0x9F |
PushUnsigned4 |
[] → [0..15] |
0xA0–0xAF |
PushSigned4 |
[] → [-8..7] |
0xB0–0xBF |
CallRelSigned4 |
[] → [] |
0xC0–0xCF |
JumpIfZeroRelSigned4 |
[condition] → [] |
0xD0–0xDF |
JumpRelSigned4 |
[] → [] |
0xE0–0xFD |
HostCallDirect |
Host-call dependent |
0xFE |
HostCallIndirect |
[id] → [] (excluding host-specific effects) |
0xFF |
Halt |
[] → [] |
The following opcode ranges are currently unassigned:
0x3E–0x3F0x5B–0x5F
The table below provides a plain-English description of what each opcode does during execution:
| Opcode | Description |
|---|---|
Noop |
Does nothing and continues to the next instruction. |
Add |
Pops two values and pushes their wrapping sum. |
Sub |
Pops two values and pushes their wrapping difference. |
Mul |
Pops two values and pushes their wrapping product. |
DivUnsigned |
Divides two values as unsigned integers and pushes the quotient. |
DivSigned |
Divides two values as signed integers and pushes the quotient. |
RemUnsigned |
Divides two values as unsigned integers and pushes the remainder. |
RemSigned |
Divides two values as signed integers and pushes the remainder. |
BitAnd |
Performs a bitwise AND on two values. |
BitOr |
Performs a bitwise OR on two values. |
BitXor |
Performs a bitwise XOR on two values. |
BitNot |
Inverts every bit of a value. |
ShiftLeft |
Shifts a value left by the specified number of bits. |
LogicShiftRight |
Shifts a value right logically, shifting zero bits in from the left. |
ArithShiftRight |
Shifts a signed value right arithmetically, preserving its sign. |
Pop |
Removes the value at the top of the operand stack. |
Swap |
Swaps the top two values on the operand stack. |
PushUnsigned4 |
Pushes a 4-bit unsigned immediate value encoded directly in the opcode. |
PushSigned4 |
Pushes a 4-bit signed immediate value encoded directly in the opcode. |
PushUnsigned8 |
Pushes an 8-bit unsigned immediate value. |
PushSigned8 |
Pushes an 8-bit signed immediate value, sign-extended to a stack word. |
PushUnsigned16 |
Pushes a 16-bit unsigned immediate value. |
PushSigned16 |
Pushes a 16-bit signed immediate value, sign-extended to a stack word. |
PushUnsigned32 |
Pushes a 32-bit unsigned immediate value. |
PushSigned32 |
Pushes a 32-bit signed immediate value, sign-extended to a stack word. |
Push64 |
Pushes a raw 64-bit immediate value. |
CompEqual |
Pushes 1 if two values are equal, otherwise 0. |
CompNotEqual |
Pushes 1 if two values are not equal, otherwise 0. |
CompLessThanUnsigned |
Compares two values as unsigned integers and pushes whether the first is less than the second. |
CompLessThanSigned |
Compares two values as signed integers and pushes whether the first is less than the second. |
CompLessThanOrEqualUnsigned |
Performs an unsigned less-than-or-equal comparison. |
CompLessThanOrEqualSigned |
Performs a signed less-than-or-equal comparison. |
CompGreaterThanUnsigned |
Performs an unsigned greater-than comparison. |
CompGreaterThanSigned |
Performs a signed greater-than comparison. |
CompGreaterThanOrEqualUnsigned |
Performs an unsigned greater-than-or-equal comparison. |
CompGreaterThanOrEqualSigned |
Performs a signed greater-than-or-equal comparison. |
JumpRelSigned4 |
Jumps by a signed 4-bit byte offset encoded directly in the opcode. |
JumpRelSigned8 |
Jumps by a signed 8-bit byte offset. |
JumpRelSigned16 |
Jumps by a signed 16-bit byte offset. |
JumpRelSigned32 |
Jumps by a signed 32-bit byte offset. |
JumpRelSigned64 |
Jumps by a signed 64-bit byte offset. |
JumpIfZeroRelSigned4 |
Pops a condition and performs a 4-bit relative jump if it is zero. |
JumpIfZeroRelSigned8 |
Pops a condition and performs an 8-bit relative jump if it is zero. |
JumpIfZeroRelSigned16 |
Pops a condition and performs a 16-bit relative jump if it is zero. |
JumpIfZeroRelSigned32 |
Pops a condition and performs a 32-bit relative jump if it is zero. |
JumpIfZeroRelSigned64 |
Pops a condition and performs a 64-bit relative jump if it is zero. |
CallRelSigned4 |
Saves the return address and calls a target using a signed 4-bit relative offset. |
CallRelSigned8 |
Saves the return address and calls a target using a signed 8-bit relative offset. |
CallRelSigned16 |
Saves the return address and calls a target using a signed 16-bit relative offset. |
CallRelSigned32 |
Saves the return address and calls a target using a signed 32-bit relative offset. |
CallRelSigned64 |
Saves the return address and calls a target using a signed 64-bit relative offset. |
JumpAbsIndirect |
Pops an absolute byte address from the operand stack and jumps to it. |
JumpIfZeroAbsIndirect |
Pops a condition and an absolute byte address from the operand stack. Jumps to the address if the condition is zero; otherwise continues execution. Both values are consumed. |
CallAbsIndirect |
Pops an absolute byte address, saves the return address, and calls it. |
Return |
Returns to the most recently saved call address. |
MemLoad8 |
Loads 1 byte from linear memory and zero-extends it to a stack word. |
MemLoad16 |
Loads 2 bytes from linear memory and zero-extends them to a stack word. |
MemLoad32 |
Loads 4 bytes from linear memory and zero-extends them to a stack word. |
MemLoad64 |
Loads 8 bytes from linear memory. |
MemStore8 |
Pops a value and a destination address, then stores the low 8 bits of the value to linear memory. |
MemStore16 |
Pops a value and a destination address, then stores the low 16 bits of the value to linear memory. |
MemStore32 |
Pops a value and a destination address, then stores the low 32 bits of the value to linear memory. |
MemStore64 |
Pops a 64-bit value and a destination address, then stores the value as 8 bytes in linear memory. |
MemStore128 |
Pops two 64-bit values and a destination address, then stores the values as 16 consecutive bytes in linear memory. |
MemStore256 |
Pops four 64-bit values and a destination address, then stores the values as 32 consecutive bytes in linear memory. |
MemStore512 |
Pops eight 64-bit values and a destination address, then stores the values as 64 consecutive bytes in linear memory. |
MemStore1024 |
Pops sixteen 64-bit values and a destination address, then stores the values as 128 consecutive bytes in linear memory. |
MemSize |
Pushes the current size of linear memory in bytes. |
MemGrow |
Grows linear memory by the requested number of bytes and pushes its previous size. |
MemCopy |
Pops a length, source address and destination address, then copies the specified number of bytes within linear memory, safely handling overlapping ranges. |
MemFill |
Pops a length, value and destination address, then fills the specified number of bytes in linear memory with the low 8 bits of the value. |
MemBytecodeCopy |
Pops a byte count, source bytecode address and destination linear-memory address, then copies the specified bytes from bytecode into linear memory. |
RegisterGet |
Pushes the value of the register encoded in the low nibble of the opcode onto the operand stack. |
RegisterSet |
Pops a value from the operand stack and stores it in the register encoded in the low nibble of the opcode. |
DataUnsigned8 |
Reads an unsigned 8-bit payload length and skips that many bytes in the bytecode. |
DataUnsigned16 |
Reads an unsigned 16-bit payload length and skips that many bytes in the bytecode. |
DataUnsigned32 |
Reads an unsigned 32-bit payload length and skips that many bytes in the bytecode. |
DataUnsigned64 |
Reads an unsigned 64-bit payload length and skips that many bytes in the bytecode. |
DataLength1–DataLength12 |
Skips 1–12 embedded bytes, with the payload length encoded directly in the opcode. |
PickUnsigned4 |
Copies a value from the operand stack at a depth encoded in the opcode's low nibble. |
PickUnsigned8 |
Copies a value from the operand stack at an unsigned 8-bit depth. |
PickUnsigned16 |
Copies a value from the operand stack at an unsigned 16-bit depth. |
PickUnsigned32 |
Copies a value from the operand stack at an unsigned 32-bit depth. |
PickUnsigned64 |
Copies a value from the operand stack at an unsigned 64-bit depth. |
RegGetIndirect |
Pops a register index and pushes the value stored in that register. |
RegSetIndirect |
Pops a register index followed by a value, and stores the value in that register. |
HostCall |
Pops a host-call identifier and invokes the corresponding host-defined operation. Arguments and results are defined by the individual host call. |
HostCallDirect |
Invokes a host call with an ID between 0 and 29, encoded directly in the opcode byte. The ID is not popped from the operand stack. |
HostCallIndirect |
Pops a host-call ID from the operand stack and invokes it. Supports any u64 host-call ID. |
Halt |
Stops bytecode execution. |
The assembler provides a human-readable layer over the bytecode instruction set.
Mnemonic commands and label names are case-insensitive. Labels retain their original spelling in diagnostic messages, and names differing only by case are treated as duplicates.
Labels are written with a trailing colon:
loop:
PUSH 1
JMP loopComments begin with ; and continue to the end of the line:
PUSH 10 ; push ten
ADD ; add the top two valuesFor instructions with multiple available encodings, the assembler automatically chooses an appropriate compact encoding.
PUSH accepts an integer literal, a named constant or a label name. Integer literals and named constants use the smallest suitable encoding, while label names resolve to absolute byte addresses and are encoded using Push64.
The standard host-call names (e.g. WROUT, WRERR, RDIN and TERM) are available as case-insensitive named constants and cannot be used as label names.
HOST uses a single-byte direct encoding for host-call IDs 0–29. For other IDs, it emits the smallest suitable PUSH encoding followed by HostCallIndirect.
The operands of JMP, JZ, CALL, RGET, RSET and HOST are optional. When an operand is omitted, the corresponding instruction obtains its target, register index or host-call ID from the operand stack.
String literals are double-quoted and single-line. Supported escapes are \n, \r, \t, \0, \\, \" and \xNN (two hexadecimal digits, interpreted as one raw byte). Semicolons within strings do not begin comments. Empty strings are allowed and simply push the length 0.
Some assembly commands map directly to a single opcode, while others select between several opcode encodings or expand to a different low-level instruction.
| Command | Description |
|---|---|
NOOP |
Does nothing and continues to the next instruction. |
PUSH value_or_name_or_label |
Pushes an integer literal, named constant or absolute label address. Uses the smallest suitable encoding for integer values and Push64 for label addresses. |
ADD |
Adds the top two stack values. |
SUB |
Subtracts the top stack value from the value beneath it. |
MUL |
Multiplies the top two stack values. |
DIVU |
Divides the second stack value by the top value as unsigned integers. |
DIVS |
Divides the second stack value by the top value as signed integers. |
REMU |
Pushes the unsigned remainder of dividing the second stack value by the top value. |
REMS |
Pushes the signed remainder of dividing the second stack value by the top value. |
AND |
Performs a bitwise AND on the top two stack values. |
OR |
Performs a bitwise OR on the top two stack values. |
XOR |
Performs a bitwise XOR on the top two stack values. |
NOT |
Inverts every bit of the value at the top of the stack. |
SL |
Shifts the second stack value left by the number of bits given by the top value. |
LSR |
Shifts the second stack value right logically by the number of bits given by the top value. |
ASR |
Shifts the second stack value right arithmetically by the number of bits given by the top value. |
CEQ |
Pushes 1 if the top two stack values are equal, otherwise 0. |
CNE |
Pushes 1 if the top two stack values are not equal, otherwise 0. |
CLTU |
Compares the top two values as unsigned integers and pushes whether the lower stack value is less than the top value. |
CLTS |
Compares the top two values as signed integers and pushes whether the lower stack value is less than the top value. |
CLEU |
Performs an unsigned less-than-or-equal comparison. |
CLES |
Performs a signed less-than-or-equal comparison. |
CGTU |
Performs an unsigned greater-than comparison. |
CGTS |
Performs a signed greater-than comparison. |
CGEU |
Performs an unsigned greater-than-or-equal comparison. |
CGES |
Performs a signed greater-than-or-equal comparison. |
RGET [index] |
Pushes the value of the specified register. If index is omitted, pops the register index from the stack. |
RSET [index] |
Pops a value and stores it in the specified register. If index is omitted, pops the register index from the stack first. |
MEMLD8 |
Loads 1 byte from linear memory at the address on top of the stack. |
MEMLD16 |
Loads 2 bytes from linear memory at the address on top of the stack. |
MEMLD32 |
Loads 4 bytes from linear memory at the address on top of the stack. |
MEMLD64 |
Loads 8 bytes from linear memory at the address on top of the stack. |
MEMST8 |
Pops a value and a destination address, then stores the low 8 bits of the value to linear memory. |
MEMST16 |
Pops a value and a destination address, then stores the low 16 bits of the value to linear memory. |
MEMST32 |
Pops a value and a destination address, then stores the low 32 bits of the value to linear memory. |
MEMST64 |
Pops a 64-bit value and a destination address, then stores the value as 8 bytes in linear memory. |
MEMST128 |
Pops two 64-bit values and a destination address, storing 16 consecutive bytes in linear memory. |
MEMST256 |
Pops four 64-bit values and a destination address, storing 32 consecutive bytes in linear memory. |
MEMST512 |
Pops eight 64-bit values and a destination address, storing 64 consecutive bytes in linear memory. |
MEMST1024 |
Pops sixteen 64-bit values and a destination address, storing 128 consecutive bytes in linear memory. |
MEMSZE |
Pushes the current size of linear memory in bytes. |
MEMGRW |
Grows linear memory by the requested number of bytes and pushes its previous size. |
MEMCP |
Copies a byte range within linear memory, safely handling overlapping ranges. |
MEMFL |
Fills a byte range of linear memory with the low 8 bits of a value. |
PICK depth |
Copies the value at the specified unsigned stack depth, using the smallest suitable encoding. |
DUP |
Alias for PICK 0. Emits the 0x40 opcode. |
POP |
Removes the value at the top of the stack. |
SWP |
Swaps the top two stack values. |
JMP [label] |
Jumps to a label using the smallest suitable relative encoding. If omitted, pops an absolute target address from the stack. |
JZ [label] |
Pops a condition and jumps to label if it is zero, using the smallest suitable relative encoding. If label is omitted, also pops an absolute target address from the stack and jumps to it if the condition is zero. |
CALL [label] |
Calls a label using the smallest suitable relative encoding. If omitted, pops an absolute target address from the stack. |
RT |
Returns to the most recently saved call address. |
DATA name "string" |
Declares a named sequence of bytes embedded in the bytecode using a skippable data opcode. |
MEMDTCP "string" or MEMDTCP name |
Copies inline or previously declared data into linear memory, preserving the destination address and pushing the byte length. |
HOST [name_or_id] |
Invokes a host call using its symbolic name or numeric ID, automatically selecting the shortest encoding. If omitted, pops the host-call ID from the stack. |
INC "path" |
Inserts another assembly source file at the current position. Supports nested includes and relative paths. Emits no opcode. |
HALT |
Stops execution. |
The INC "path" directive inserts another assembly source file at the current
position before assembly. It does not emit a VM opcode.
Paths are resolved relative to the directory containing the file with the
INC directive. Nested includes are supported, and circular includes are
rejected.
For example:
INC "lib/std/registers.sub.asm"Included files share the same case-insensitive label and data-symbol namespace as the main file.
Each source file is included at most once per assembly operation. Subsequent INC directives referencing the same file are ignored, including when the file is reached through nested includes or different relative paths. File identity is determined using canonical filesystem paths.
Host calls provide functionality supplied by the embedding host rather than by the VM itself.
The VM only dispatches host-call identifiers. The arguments, results and behaviour of each host call are defined by the host.
| ID | Assembly-code name | Stack effect | Description |
|---|---|---|---|
0 |
WROUT |
[address, length] → [] |
Writes length bytes from linear memory starting at address to standard output. |
1 |
WRERR |
[address, length] → [] |
Writes length bytes from linear memory starting at address to standard error. |
2 |
RDIN |
[address, max_length] → [bytes_read] |
Reads up to max_length bytes from standard input into linear memory starting at address, returning the number of bytes read. |
255 |
TERM |
[exit_code] → [] |
Terminates bytecode execution with the specified exit code. |
Host calls can be invoked using either their symbolic names or numeric IDs. For example, HOST WROUT and HOST 0 are equivalent. Symbolic names are case-insensitive.
Host-call IDs 0–29 have compact single-byte encodings (0xE0–0xFD). Other IDs are supported through HostCallIndirect (0xFE), which obtains the ID from the operand stack. The operand-free assembly instruction HOST emits this indirect opcode.
Numeric IDs also allow programs to invoke custom host calls provided by the embedding host.
The stack effects shown above exclude the host-call identifier itself. Direct host-call instructions do not consume an identifier from the stack, while HostCallIndirect does.
The following short example, in assembly code, writes the character A followed by a newline character to standard output:
; Allocate two bytes of memory.
PUSH 2
MEMGRW
POP ; Discard the previous memory size.
; Store the ASCII value of 'A' at address 0.
PUSH 0
PUSH 0x41 ; This is the ASCII value of 'A' in hexadecimal notation.
MEMST8
; Store the ACSII value of '\n', a newline, at address 1.
PUSH 1
PUSH 0x0A
MEMST8
; Write both bytes, beginning at address 0, to standard output.
PUSH 0
PUSH 2
HOST WROUTA program can finish execution in the following ways:
| Termination | Behaviour |
|---|---|
HALT |
Stops execution normally, without an explicit exit code. |
| End of bytecode | Stops execution normally when the instruction pointer reaches the end of the program. |
HOST TERM or HOST 255 |
Stops execution immediately and returns an explicit exit code to the host. |
In all three cases, the VM returns the remaining operand stack.
Normal completion produces no explicit exit code. The command-line runner interprets this as a successful exit with status 0.
Invalid command-line arguments cause the runner to exit with a failure status.