EXPcaliburINSIDE THE MACHINE

04 / THE MACHINE

How bytes become actions

Now meet the small machine that actually runs your bot. It has 64 KiB of guest memory, a stack, and an instruction pointer. All three survive between game ticks. This chapter follows real instructions one step at a time. Once the byte-level rules are clear, the stranger VM behaviors in later chapters become easier to follow.

Starts with: Bot instructions and the VM state saved from earlier ticks.

Ends with: Updated bot memory and requests the host may accept.

Question: What does one instruction change, and what survives a tick?

Take the compiler's output as our input

The compiler has turned the pilot into bytes, so what runs those bytes? A small stack machine. It keeps most of its working numbers in a last-in, first-out pile. Put down 7, put 5 on top, and an add instruction takes both and leaves 12. The instruction pointer is its bookmark: the address of the next instruction byte. One memory array holds both program bytes and data.

The VM's tiny desk

Memory = notebook64 KiB of guest bytes for code, saved values, and observations.
Stack = working pileThe latest pushed number is the first one used.
ip = bookmarkThe address of the next byte to interpret.

These are bot-owned guest structures. A host call leaves this desk and asks the game to do something.

The interpreter repeats a small loop: read an instruction byte, do what it says, count the work, and move on. Most instructions change only the bot's own VM state, while a host instruction sends a request to the game host. That request can change the world only after the host applies its rules.

State: what survives a tick

The VM keeps four main pieces of state. Its memory, mem, has 65,536 bytes. The instruction pointer ip gives the next instruction, and the stack pointer sp gives the next free stack slot. Finally, halted records a permanent stop. Unused memory starts at zero, and the first instruction is at address zero.

At the start, ip = 0 and sp = 0x8000. Each pushed four-byte word moves sp up by four.

The game does not create a new VM each tick. Memory, ip, and sp all carry forward. When this tick's instruction allowance runs out, the game advances, and the same program continues next tick from where it stopped.

The VM also keeps notes about the bot's work in the current tick. Unlike memory and the bot's place in the program, most of those notes are cleared when the next tick starts. CLASSIC uses them to decide whether certain chains earn rewards.

State Meaning Lifetime
mem Guest bytes: code, values, compiler frames, snapshots Entire run
ip Next instruction address Entire run; resumes after budget exhaustion
sp Next free operand-stack word Entire run
halted Permanent execution stop Sticky once set
mode Engine the host read from the uploaded artifact Immutable for the run
last_inst_count Work accounting for the latest slice Updated per tick
Detector observations Facts about executed behavior Mostly per tick, with explicitly persistent tracking where required

Memory is byte-addressed and little-endian

Addresses are 32-bit numbers, but each byte access uses only their low 16 bits. A four-byte load reads four consecutive bytes one at a time. It can start at any address, even one that is not a multiple of four.

Words are stored in little-endian order. The least significant byte comes first. For example, 0x12345678 sits in memory as 78 56 34 12. Read as one number, that looks backward. To load it, the VM shifts each byte into place and ORs them together.

RUST crates/vm/src/lib.rs · source lines 138–145
    #[inline]
    pub fn load_u32(&self, addr: u32) -> u32 {
        let b0 = self.bytes[Self::idx(addr)] as u32;
        let b1 = self.bytes[Self::idx(addr.wrapping_add(1))] as u32;
        let b2 = self.bytes[Self::idx(addr.wrapping_add(2))] as u32;
        let b3 = self.bytes[Self::idx(addr.wrapping_add(3))] as u32;
        b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)
    }

A 64 KiB machine, one byte at a time

Change an ordinary word address to see little-endian storage and per-byte wrapping. This illustrates the memory model; it does not execute uploaded code.

Past the last byte of guest memory, addresses wrap back to the start. Think of the tunnel in Pac-Man: leave one side of the screen and you reappear on the other. The wrap stays inside guest VM memory. It never reaches host memory.

The memory map is partly convention

The compiler starts its frame area at 0x2000, and the stack starts at 0x8000. The standard library puts observation buffers at fixed high addresses. Think of these addresses as tape labels on one open shelf, not locked drawers. They keep generated programs organized. Still, the VM does not protect one region from another.

Guest memory is one flat array. Program bytes and data share the same space. The VM records the program's length, but that does not stop reads or writes elsewhere.

One guest byte array, four useful landmarksOne guest byte array, four useful landmarks0x00000x20000x80000xC0000xFFFF65,536 bytesone flat arrayImage starts at 0x0000Its length varies. An image boundary is tracked,but it is not a read/write/execute wall.Frames begin at 0x2000This is a compiler convention for source-languagelocals and saved returns.Operand stack starts at 0x8000The stack pointer advances in four-byte words; itsurvives a tick boundary.Status copy uses 0xC000A host service writes a snapshot into guest bytes;the live world remains host-owned.Landmarks describe ordinary compiled code; the VM does not enforce separate protected regions.
The labeled addresses share one guest array. The labels are compiler and standard-library conventions. The VM does not create protected code, frame, stack, and snapshot compartments at those addresses. View diagram at full size

The compiler usually puts code below the frame area. That is a compiler convention, not a VM rule. The VM can fetch instruction bytes from other guest addresses too. An older source comment got this wrong; the current consts.rs corrects it.

The compiler's usual memory layout
Low memoryStartup trampoline and emitted function bodies.
Frame regionSaved returns and function-scoped local slots.
Operand stackTemporary values and call machinery, growing upward.
Observation buffersWorld snapshots written by the host through the standard library.

Instruction encoding and the fetch loop

Most instructions are one byte long, but some carry their own data. PUSH carries four extra bytes, five bytes in total. PUSH_SHORT carries one extra signed byte and turns it into a 32-bit word, two bytes in total. PUSH_ZERO needs no extra data.

So in normal sequential decoding, the VM must know which bytes are opcodes and which are an instruction's data. After a PUSH opcode, it reads four data bytes before it fetches the next opcode. But a jump can also land in the middle of an instruction, and the VM then runs those data bytes as opcodes. CLASSIC chains such as GADGET rely on exactly that.

Each pass of the loop reads an opcode, an instruction byte, at ip. It normally moves ip forward by one byte. Some instructions then read extra bytes; jumps and calls pick a different next address. The Rust interpreter handles every instruction. The bytes never become native CPU instructions.

Here is the exact fetch and default advance. The variable ip_op remembers where the opcode came from. The selected instruction can change self.ip again:

RUST crates/vm/src/lib.rs · source lines 1063–1066
            }
            let ip_op = self.ip;
            let opcode = self.mem.load_u8(ip_op);
            self.ip = ip_op.wrapping_add(1);

Suppose ip = 100 and the next instruction is PUSH_SHORT. Its input byte is 0xFE. As a signed eight-bit value, that byte means −2, so the VM pushes its 32-bit form, 0xFFFFFFFE:

Point ip Stack suffix Why
Before fetch 100 [] No instruction consumed
Default advance 101 [] The opcode byte was read
After PUSH_SHORT arm 102 [0xFFFFFFFE] The immediate byte was sign-extended and pushed
Next loop 102 [0xFFFFFFFE] Fetch begins at the next instruction boundary
One ordinary instruction, byte by byteOne ordinary instruction, byte by byteGUEST MEMORYADDRESS 100PUSH_SHORTopcodeADDRESS 101FEsigned byte: −2ADDRESS 102NEXTnext opcodeINTERPRETER STATE · AFTER EACH STEPip = 100Before the fetch; stackempty.ip = 101Opcode read; stack stillempty.ip = 102Byte read; top: 0xFFFFFFFEThe same interpreter runs again at 102; no native CPU jump or new VM is involved.
Watch the instruction pointer move one byte at a time. The first byte names the instruction. The second is its signed input. The third is where the next fetch begins. The stack stores the sign-extended result, not the raw byte. View diagram at full size

The source arm does those steps in order. It reads the extra byte, extends its sign, updates ip, and pushes the result:

RUST crates/vm/src/lib.rs · source lines 1128–1134
                op::PUSH_SHORT => {
                    let imm = (self.mem.load_u8(ip_op.wrapping_add(1)) as i8) as i32 as u32;
                    self.ip = ip_op.wrapping_add(2);
                    self.mark_interior(ip_op.wrapping_add(1));
                    self.push(imm);
                }
                op::PUSH_ZERO => self.push(0),

If the tick budget runs out before the next fetch, run_tick returns BudgetExhausted. The current ip and stack stay saved, so the next tick resumes there. A HALT is different. It records a permanent stop, not a pause.

Watch an arithmetic program execute

A fixed, benign program: push 7, push 5, add, push 3, multiply. No arbitrary code or host calls are supported.

Every instruction has one small job

Here is the whole instruction set, grouped by job. In a stack picture such as [7, 5], 5 is on top. It comes off first. A “word” is one 32-bit value stored in four bytes. The tables list what each instruction changes; the examples show those changes in motion.

Stack and memory: put words down, pick them up

sp points to the next free stack word, so a push writes at sp and then adds four. A pop subtracts four first and then reads.

RUST crates/vm/src/lib.rs · source lines 535–540
        self.mem.store_u32(self.sp, v);
        if self.sp > self.sp_hi {
            self.sp_hi = self.sp;
        }
        self.sp = self.sp.wrapping_add(4);
        self.note_sp_lo();
RUST crates/vm/src/lib.rs · source lines 560–565
        self.sp = self.sp.wrapping_sub(4);
        self.note_sp_lo();
        let s = ((self.sp & ADDR_MASK) >> 2) as usize;
        self.live_ret[s >> 3] &= !(1u8 << (s & 7));
        self.mem.load_u32(self.sp)

The extra lines in these snippets record notes for CLASSIC's reward checks. The stack move itself is simple. A push is write then advance, and a pop is move back then read.

Instruction What it takes What it does
PUSH Four bytes after the opcode Reads those bytes as one little-endian word, pushes it; ip moves five bytes total
PUSH_SHORT One byte after the opcode Treats that byte as a signed number from −128 to 127, pushes its 32-bit form; ip moves two bytes total
PUSH_ZERO Nothing Pushes 0; one-byte instruction
POP Top word Discards it
LOAD Address on top Reads four guest bytes at that address and pushes the word
STORE Address on top, value just below Writes the value to four guest bytes; consumes both words
LOAD_REL Word offset on top Pops the offset, computes sp + 4 × offset using the new sp, then loads and pushes a word
STORE_REL Word offset on top, value below Pops both, computes sp + 4 × offset using the new sp, then writes the value

For STORE, picture a stack that ends with [25, 256]. The VM removes 256 first, because it is the address. Then it removes 25 and writes it at address 256. LOAD_REL and STORE_REL need more care. They calculate their address from sp after their input words come off, and that detail explains many offsets in compiled functions.

The two relative memory arms below show that order. Notice that each one calculates its address after the pops:

RUST crates/vm/src/lib.rs · source lines 1138–1153
                op::LOAD_REL => {
                    let off = self.pop() as i32;
                    let addr = self.sp.wrapping_add((off as u32).wrapping_mul(4));
                    self.note_arena_load(addr);
                    self.note_read_own_code(addr);
                    let v = self.mem.load_u32(addr);
                    self.push_tagged(v, true);
                }
                op::STORE_REL => {
                    let off = self.pop() as i32;
                    let val = self.pop();
                    let addr = self.sp.wrapping_add((off as u32).wrapping_mul(4));
                    self.note_arena_store(addr);
                    self.note_code_store(addr, val);
                    self.mem.store_u32(addr, val);
                }

Jumps and calls: change the next instruction

A jump sets ip to another address. A call does that and saves a return address on the stack. A return later pops that address and makes it the next ip. Together, they are the VM's version of “go there, then come back here.”

Swipe this table sideways to read the full result column →

Instruction Stack input New ip and stack result
JMP_ABS Target address ip = target; target is consumed
JMP_REL Byte distance ip = address of this jump + distance; distance is consumed
CALL_ABS Target address Consumes target, pushes the address just after this one-byte call, then goes to target
CALL_REL Byte distance Consumes distance, pushes the address just after this one-byte call, then jumps relative to this call's address
RET Saved address Pops it and makes it the next ip
SKIP_IF Condition Pops it; if it is not zero, moves ip one extra byte past the normal one-byte advance

A relative jump measures its distance from its own opcode's address, not from the next byte. SKIP_IF skips one byte, not a complete multi-byte instruction. The compiler relies on that rule. It puts a one-byte JMP_ABS right after each SKIP_IF, so a skip passes over exactly that jump. Skipping over a PUSH instead would land on its first data byte and run it as an opcode.

Imagine CALL_ABS at byte 40, with target 100 on top of the stack. It removes 100, pushes 41 as the return address, and runs byte 100 next. When the code at the target reaches RET, the VM pops 41 and continues after the call. The compiler adds its own frame handling around this basic move.

The short source arms make these byte-level rules concrete:

RUST crates/vm/src/lib.rs · source lines 1168–1172
                op::JMP_REL => {
                    let d = self.pop();
                    self.clear_jit();
                    self.ip = ip_op.wrapping_add(d);
                }
RUST crates/vm/src/lib.rs · source lines 1190–1195
                op::CALL_REL => {
                    let d = self.pop();
                    self.push(ip_op.wrapping_add(1));
                    self.clear_jit();
                    self.ip = ip_op.wrapping_add(d);
                }
RUST crates/vm/src/lib.rs · source lines 1217–1221
                op::SKIP_IF => {
                    if self.pop() != 0 {
                        self.ip = self.ip.wrapping_add(1);
                    }
                }

Arithmetic and comparisons: use the top words

Every two-input operation removes b (the top word) first, then a, and pushes one result. That means [7, 5] followed by SUB gives 2, not −2. Operand order matters.

Instructions Result from a and b
ADD, SUB, MUL a + b, a − b, or a × b; keep the low 32 bits
DIV_U, REM_U Unsigned whole-number division or remainder; 0 when b is 0
DIV_S, REM_S Signed whole-number division or remainder; 0 when b is 0
AND, OR, XOR Apply that bit operation to both words
SHL, SHR_U, SHR_S Shift a left, right with zeroes coming in, or right while keeping its sign; result 0 when b ≥ 32
CMP_EQ, CMP_NE Push 1 if equal or not equal; otherwise 0
CMP_LT_U, CMP_LE_U Push 1 when unsigned a < b or a ≤ b; otherwise 0
CMP_LT_S, CMP_LE_S Same comparisons, but treat both words as signed numbers

Three operations take a single input word, a. NOT gives 1 only when a is zero, while BIT_NOT flips all 32 bits. “Logical not” and “flip every bit” are different jobs. Finally, NEG calculates 0 − a with 32-bit wraparound.

RUST crates/vm/src/lib.rs · source lines 1267–1276
                0x30..=0x45 => {
                    let b = self.pop();
                    let a = self.pop();
                    self.note_alu_faults(opcode, a, b);
                    self.push_tagged(alu_bin(opcode, a, b), true);
                }
                0x50..=0x52 => {
                    let a = self.pop();
                    self.push_tagged(alu_un(opcode, a), true);
                }

Host calls and halting: send a request or stop

The last group either calls the host or stops the VM:

Instruction Stack input Result
SYSCALL Service number, inputs, input count on top Consumes the call frame and leaves one result word; the host may also copy data into guest memory
GATECALL Guest address of a device request ABYSS only: consumes the address, leaves a status word, and charges work for the device call
HALT Nothing Stops this bot for the rest of the run
Unknown opcode Nothing Also stops this bot

In CLASSIC, the GATECALL byte counts as an unknown opcode. Running out of the tick's work allowance, by contrast, only pauses the VM. It does not halt it. A later section traces an ordinary SYSCALL in detail.

The earlier arithmetic example uses only constants and arithmetic. The sections below look more closely at memory, number rules, and a host call.

Stack order is part of the meaning

When an instruction needs two inputs, it removes the top word first. That top word is the right-hand input, so pushing 7, pushing 5, and running SUB gives 7 - 5, as expected. The same order matters for division, comparisons, and stores.

Trace an ordinary store and load

Suppose the bot wants to save 25 at guest address 256. Here is the full sequence:

Step Stack, with top on the right Memory at address 256
Push value, then address [25, 256] Old bytes
STORE takes 256, then 25 [] 19 00 00 00 in hex (25 in little-endian form)
Push 256 [256] Still holds 25
LOAD takes 256, then reads memory [25] Still holds 25

Nothing here touches the knight's HP or EXP. These instructions change only the bot's own guest memory. To affect the world, the bot must send a host request and let the game check and apply it.

The VM's CALL is similarly small: it saves one return address and jumps. The compiler adds its own local-variable storage around that move. When reading compiled code, keep the VM call and the compiler frame apart.

Arithmetic is total and explicit

Every ordinary value is a 32-bit word. If addition, subtraction, or multiplication goes past 32 bits, only the low 32 bits remain. The same bits can mean a negative number or a large positive one. That choice changes how comparisons, division, and right shifts behave. This is why the VM has signed and unsigned versions of them, and the compiler picks the signed version for signed source operations.

Division or remainder by zero gives zero, and so does a shift by 32 or more. Signed division even handles the awkward case of the smallest negative number divided by −1. It keeps the wrapped 32-bit result. Every pair of input words therefore has a defined answer.

Read the implementation in three pieces

Wrapping arithmetic: the VM keeps the low 32 bits. Overflow changes the guest word, but it does not cause native integer overflow in an unchecked host operation.

RUST crates/vm/src/lib.rs · source lines 1445–1447
        op::ADD => a.wrapping_add(b),
        op::SUB => a.wrapping_sub(b),
        op::MUL => a.wrapping_mul(b),

Signed division: the VM handles a zero divisor first. It then interprets both words as signed integers, performs wrapping division, and turns the result back into a word.

RUST crates/vm/src/lib.rs · source lines 1462–1468
        op::DIV_S => {
            if b == 0 {
                0
            } else {
                (a as i32).wrapping_div(b as i32) as u32
            }
        }

Shift width: the VM checks the complete shift count before it shifts. A count of 32 gives zero. It does not act like a count of zero.

RUST crates/vm/src/lib.rs · source lines 1479–1485
        op::SHL => {
            if b >= 32 {
                0
            } else {
                a << b
            }
        }

If you write a bot simulator in another language, copy these rules exactly. JavaScript, for example, treats a shift count of 32 like a count of zero. EXPcalibur gives zero for that shift. Signed integer division also throws away any fractional part.

Worked arithmetic edge cases

Operation Result Why it matters to a bot
Unsigned 0xFFFFFFFF + 1 0 Arithmetic wraps; an accumulator can overflow
Signed 7 / 2 3 Fractional precision disappears unless the numerator is scaled first
Signed -7 / 2 −3 Division truncates toward zero, not mathematical floor
17 / 0 0 Execution remains defined, though the policy's result may be useless
Shift a word by 32 0 Do not assume the shift count is automatically reduced modulo 32
Signed compare 0xFFFFFFFF < 1 True The bits represent −1 under signed interpretation
Unsigned compare 0xFFFFFFFF < 1 False The same bits represent 4,294,967,295 under unsigned interpretation

These rules explain the shape of the pilot's formula. It multiplies the weight by 100,000 before dividing, because dividing first would often give zero. It also adds one to the squared distance, because division by zero gives zero, which would make a poor target score.

Follow one harmless host call across the VM boundary

The public sense() helper asks the game to copy the knight's current status. Follow its request from bot source, through the VM stack, to the host:

BOT crates/compiler/src/lib.rs · source lines 54–56
fn sense() { syscall(1, 0xC000); }
fn game_tick() { return *(0xC000); }
fn my_x()     { return *(0xC004) & 0xFFFF; }

Before SYSCALL, the stack ends with [1, 0xC000, 1]: service number 1, destination address 0xC000, and argument count 1. The VM removes the count first, then the address, then the service number. It also keeps one stack slot open for the reply.

One sense() call: a request goes out, a copy comes back
1 · BOT MEMORYPack the request[1, 0xC000, 1]

Service 1 means status, and 0xC000 is where the bot wants the copy. The final 1 says there is one argument.

2 · OUTER VMDeliver it to the hosthost.syscall(1, [0xC000], mem)

The VM removes those three stack words and leaves a place for the reply. Nothing about the live knight has changed.

3 · GAME WORLDCopy the live status

For a knight at tick 42, position (1000, 1000), and 46 HP, the host writes tick at C000. It writes X/Y at C004 and HP at C008.

4 · BOT MEMORY AGAINRead the copied bytes
C004 E8 03 X = 1000C006 E8 03 Y = 1000C008 2E 00 00 00 HP = 46

The VM puts 0xC000 in its reply slot. Helpers such as my_x() read this guest copy; it stays old until another scan.

Those numbers are an example snapshot, not a recorded match. The host owns the knight; 0xC000 belongs to the bot's 64 KiB of guest memory.

RUST crates/vm/src/lib.rs · source lines 902–908
        let mut args = [0u32; MAX_SYSCALL_ARGS];
        for i in (0..argc).rev() {
            args[i as usize] = self.pop();
        }
        let number = self.pop();
        self.push_tagged(SYSCALL_ERR, true);
        let ret_slot = self.sp.wrapping_sub(4);

The VM hands those inputs and the guest memory array to the host, and service 1 copies current world data to 0xC000. The bot gets a copy of the status. It gets no access to the live knight object.

RUST crates/vm/src/lib.rs · source lines 925–926
        self.scriv_snap[..scan_cap].copy_from_slice(&self.mem.as_slice()[..scan_cap]);
        let ret = host.syscall(number, &args[..argc as usize], &mut self.mem);
RUST crates/engine/src/syscall.rs · source lines 706–714
            1 => {
                if args.is_empty() {
                    return SYSCALL_ERR;
                }
                let addr = args[0];
                let ret = self.sys_status(addr, mem);

Inside service 1, the host reads the knight's fields (self.hero in the code). It writes the tick at the requested address and the knight's x and y four and six bytes later. HP goes eight bytes later, and more fields follow:

RUST crates/engine/src/syscall.rs · source lines 101–113
    fn sys_status(&self, addr: u32, mem: &mut Mem) -> u32 {
        let h = &self.hero;
        mem.store_u32(addr, self.tick);
        mem.store_i16(addr.wrapping_add(4), to_int(h.x) as i16);
        mem.store_i16(addr.wrapping_add(6), to_int(h.y) as i16);
        mem.store_u32(addr.wrapping_add(8), to_int(h.hp) as u32);
        mem.store_u32(addr.wrapping_add(12), to_int(h.max_hp) as u32);
        mem.store_u32(addr.wrapping_add(16), h.level as u32);

Service 1 returns the destination address, which for sense() is 0xC000. The VM puts that value in the reserved stack slot, and helpers such as my_x() then read the copied bytes. Meanwhile, the live knight stays in the world and can change later.

RUST crates/engine/src/syscall.rs · source lines 161–164
        mem.store_u8(addr.wrapping_add(62), 0);
        mem.store_u8(addr.wrapping_add(63), 0);
        addr
RUST crates/vm/src/lib.rs · source lines 955–955
        self.mem.store_u32(ret_slot, ret);

aim_at follows the same calling rule but asks the host to set the aim instead of copying status. An unknown service number or a missing required input returns an error. Each service decides which inputs it accepts, and none of them gives the bot a pointer to live world state.

Budget exhaustion and halting are different

Each tick gives the outer VM 10,000 units of work. When the budget runs out, run_tick returns BudgetExhausted, but the bot's memory, stack, and next instruction stay in place. It can continue next tick. A HALT, by contrast, stops the bot for the rest of the run.

An infinite loop cannot use unlimited work in one tick. It can still make a terrible bot, though, because it can spend every tick's allowance without ever moving or aiming. The work limit keeps the game running, but it does not make the bot useful.

In ABYSS, a device call also costs work: a fixed cost plus the inner machine's steps. That cost can make the outer bot pause sooner. The status word returned to the bot is separate from the work charged.

Observation is not permission

The VM keeps small notes about how the bot runs. They record where it jumps, how it uses the stack, and whether it changes or re-reads its code. CLASSIC uses those notes when it checks reward chains. The notes do not change the instruction itself, which still does the same read, write, calculation, or jump.

So for an ordinary bot that never earns a special chain, CLASSIC's extra checks should not change its game actions. The repository tests this by comparing ordinary runs with the checks enabled and disabled.

Finally, the VM crate starts with #![forbid(unsafe_code)], which bans Rust's unsafe feature there. That helps keep the guest inside its own memory array. It does not prove every other rule correct, though. The project also relies on tests and process limits, which the platform chapter covers.