You write readable source, but the VM runs bytes. The compiler translates between the two. This chapter takes one expression and one function call apart. By the end, every push, stack value, and return address will have a clear reason to be there.
Starts with: Functions and expressions.
Ends with: Guest instructions plus a calling convention.
Question: What must the VM do to preserve the meaning of source code?
What this chapter adds to the pilot
In the previous chapter, diver calculated dx*dx + dy*dy. You can read that at a glance. The VM cannot: it must work in small steps. It squares dx, squares dy, and then adds the two results. The compiler writes those steps and reserves memory for local values.
Keep three things apart. Source says what you mean, and instructions tell the VM what to do next. The operand stack holds the VM's temporary numbers. Think of a recipe, its numbered steps, and the ingredients on the counter. The next diagram uses small numbers to show every change.
One expression, three views
(7 + 5) * 3: the human-friendly intent.The order is the meaning. Multiply before add and you get 22 instead of 36.
For (7 + 5) * 3, the VM must add before it multiplies. The compiler decides that order. If the compiler emitted 7, 5, 3, MUL, ADD instead, the VM would calculate 7 + (5 * 3) = 22, not 36. Instruction order is how the parentheses survive.
The compilation pipeline
The compiler first splits the source into names, numbers, and symbols, and then works out which pieces form functions, expressions, and branches. Next, it adds built-in helpers, rejects code that breaks its rules, and writes VM instructions.
At first, a branch only says “go to this label”. Once the code is placed, the compiler fills in the label's byte address. The same input produces the same bytes.
The output is not a native Windows or Linux program. It is a file of bytes for the game's VM. Players make those bytes with expc-cc, and the production runner reads the compiled upload.
The source language is intentionally small
Functions use fn, and the compiler also accepts def. Values are 32-bit integers. Variables belong to their function, and parameters are passed by value. A function that reaches its end without return returns zero. The language has arithmetic, comparisons, bitwise operations, and assignment, plus the control words if, while, for, break, continue, and return.
Boolean expressions use integers: zero means false, and any nonzero word means true. The logical operators stop early once the result is known. They also produce normalized Boolean results. The grammar accepts Python-flavored aliases such as and, or, and not, but they do not relax its C-like parentheses and semicolons.
The compiler also inserts helper functions before your code. A helper such as sense() can use low-level operations that player code cannot call directly. The compiler marks player code as Origin::User and the inserted helpers as trusted source. This is a compiler rule. The VM itself still implements the underlying instructions.
A documented claim that the implementation contradicts
The player-facing Bot API says recursion is allowed. This compiler revision disagrees: it builds a function-call graph and rejects recursive cycles with E_RECURSION, even before it writes the startup loop.
For this revision, the compiler's behavior is the real rule. It rejects both direct recursion and cycles between functions. The guide flags the mismatch because the Bot API text would teach the wrong rule.
reject_recursion(&funcs)?;main is called repeatedly, not once per tick
The startup code sets up the frame pointer, calls main, discards its return value, and jumps back to call main again. This jump-back loop is the trampoline.
Separately, the outer VM pauses when this tick's instruction budget runs out. One mechanism repeats a function; the other limits VM work per tick. The two are not synchronized.
So a short main can run several times in one game tick, while a long one can pause halfway and continue next tick. Returning from main does not start a new game frame. The startup loop and the VM budget decide the timing.
e.push_u32(FRAME_BASE);
e.push_u32(FP_CELL);
e.op(op::STORE);
e.push_label(main_label);
e.op(op::CALL_ABS);
e.op(op::POP);
e.push_u32(0);
e.op(op::JMP_ABS);This has practical effects for an ordinary pilot. Repeated movement requests in one tick still share the host's movement budget. Repeated reads use up instructions without advancing the world. A local variable also belongs to a single function call. VM memory persists, but that alone does not make a local variable keep its value between calls to main.
Frames sit on top of a simpler VM call
The outer VM already has a stack for temporary numbers. It can also remember where a call returns. The compiler adds a frame on top: a row of named slots for one call's parameters and locals. The saved return address comes first. Each later slot uses four bytes, so slot index starts at FP + 4 + index × 4. A whole frame has 256 bytes.
fn frame_addr(&mut self, idx: u32) {
self.e.push_u32(FP_CELL);
self.e.op(op::LOAD);
self.e.push_u32(4 + idx * 4);
self.e.op(op::ADD);
}The compiler checks that the return address and all slots fit in 256 bytes. Because one word holds the return address, a frame can have at most 63 parameter and local slots. The check protects the next frame. Without it, a legal function could write a local value into that frame's return address.
A function prologue saves the return address and initializes the local slots. Around each call, the generated code moves the frame pointer and restores it afterward. That means source-language calls are built inside guest memory. There is no separate native call stack for each source function.
A concrete call frame, one slot at a time
Consider an ordinary helper, fn distance2(dx, dy) { let sum = dx*dx + dy*dy; return sum; }. Its frame has two parameter slots, one local slot, and the saved return address. If the frame pointer is F, the return address sits at F. Then dx is at F+4, dy at F+8, and sum at F+12. These addresses are a compiler convention inside guest memory.
With inputs 3 and 4, the code squares dx to get 9 and dy to get 16. It adds them and returns 25.
The caller does not care which stack words held 9 and 16. It only needs the calling convention to preserve the return location and deliver 25.
Now the limit of 63 makes sense. A 256-byte frame has 64 four-byte words. One of them holds the return address, which leaves 63 for parameters and locals. The limit comes from this memory layout, not from a parser preference.
Two functions can use the same local name without sharing a slot, because each active frame pointer selects its own slots. Recursion, however, is still rejected by this revision's call-graph check.
How expressions become instructions
For arithmetic, the compiler writes the left expression, then the right one, then the matching VM operation. Signed division becomes DIV_S, signed remainder becomes REM_S, and right shift becomes SHR_S. A greater-than test can reuse a less-than operation with the operands reversed.
Conditionals use labels and a short branch sequence that keeps the stack balanced. The parser's precedence layers decide how a compound expression is grouped. The code generator then writes instructions in that order.
Short-circuit logic needs real branches. If the compiler evaluated both sides and then applied a bitwise operator, the right side would always run. That is wrong when the right side has side effects, such as a host call. It is also wrong when the right side relies on a guard on the left, such as a range check.
| Source idea | Machine-level responsibility |
|---|---|
a + b |
Produce two words and consume them into one result |
if (condition) |
Evaluate a truth value and choose a labeled path |
a && b |
Skip evaluation of b when a is false |
| A function call | Put arguments into the compiler's frame slots and remember where to continue |
return value |
Produce a result and restore the caller's continuation |
| Local assignment | Store into the function's selected frame slot |
The standard library is a snapshot interface
sense() asks the host to write a snapshot of the knight's status. Accessors such as my_x(), my_hp(), and my_level() then read fields from that saved copy. Enemy and gem scans have their own snapshots. The world and boss helpers use another memory block.
In other words, “read my position” and “ask the world for my current position” are different operations. An accessor does not refresh the snapshot by itself. The bot must request the snapshot it needs and watch for later actions that can make that snapshot old.
Each scan writes into a fixed area of guest memory. A later scan can overwrite those bytes. Save the values you need, such as an enemy's X and Y, before another host call changes the buffer. An old scan is not a permanent list of enemy objects.
Decode one enemy record without treating it as an object pointer
The public enemy scan uses 16 bytes per record. One word packs X into its low 16 bits and Y into its high 16 bits. Another word holds the enemy's identity. A later one packs HP, type, and flags. The accessors calculate each record's address and extract the bits they need:
fn look_enemies() {
*(0xC050) = ((my_x() - SENSE_RANGE) & 0xFFFF) | (((my_y() - SENSE_RANGE) & 0xFFFF) << 16);
*(0xC054) = ((my_x() + SENSE_RANGE) & 0xFFFF) | (((my_y() + SENSE_RANGE) & 0xFFFF) << 16);
*(0xC040) = 16;
return syscall(2, 0xC040, 0xC100, 0xC050);
}
fn enemy_count() { return look_enemies(); }
fn nearest_enemy() { return enemy_count() > 0 ? 0 : (0 - 1); }
fn enemy_x(i) { return *(0xC100 + i*16) & 0xFFFF; }
fn enemy_y(i) { return (*(0xC100 + i*16) >> 16) & 0xFFFF; }
fn enemy_hp(i) { return *(0xC108 + i*16) & 0xFFFF; }
fn enemy_type(i) { return (*(0xC108 + i*16) >> 16) & 0xFF; }
fn enemy_flags(i){ return (*(0xC108 + i*16) >> 24) & 0xFF; }
fn enemy_id(i) { return *(0xC104 + i*16); }For i = 2, the coordinate word starts 32 bytes after the buffer base. Suppose that word holds X=1120 and Y=1000. Masking with 0xFFFF then gives 1120. Shifting right by 16 bits and masking gives 1000.
The type accessor decodes a different packed field. It uses another offset and an 8-bit mask. A name such as enemy_x hides this byte work from your source. It does not create an enemy object inside the VM.
The helper sets the scan capacity to 16 before it calls the host. Note that enemy_count() calls look_enemies() again rather than returning a saved count. That is why the pilot saves n before it loops, which avoids a new scan on every pass. List indices describe the current scan; enemy_id(i) reads the separate identity field.
Gem records use a different stride and a separate buffer:
fn look_gems() {
*(0xC058) = ((my_x() - SENSE_RANGE) & 0xFFFF) | (((my_y() - SENSE_RANGE) & 0xFFFF) << 16);
*(0xC05C) = ((my_x() + SENSE_RANGE) & 0xFFFF) | (((my_y() + SENSE_RANGE) & 0xFFFF) << 16);
*(0xC044) = 16;
return syscall(3, 0xC044, 0xC200, 0xC058);
}
fn gem_count() { return look_gems(); }
fn nearest_gem() { return gem_count() > 0 ? 0 : (0 - 1); }
fn gem_x(i) { return *(0xC200 + i*12) & 0xFFFF; }
fn gem_y(i) { return (*(0xC200 + i*12) >> 16) & 0xFFFF; }
fn gem_value(i) { return *(0xC204 + i*12); }A gem record uses 12 bytes, not 16. Enemy-record arithmetic would therefore read the wrong bytes after the first gem. Both formats pack coordinates, but their full layouts differ. Reading the helpers shows what a scan costs, how long its data lasts, and how its bytes are laid out.
A minimal ordinary pilot
This source uses only the public interface. It refreshes status and scans for an enemy. If it finds one, it aims at that enemy and moves toward it. The world handles attacks and gem pickups automatically, so no explicit attack or collect call is needed.
fn main() {
sense();
let e = nearest_enemy();
if (e >= 0) {
aim_at(enemy_x(e), enemy_y(e));
step_toward(enemy_x(e), enemy_y(e));
}
}This makes a good first experiment because it isolates the basic loop, but it is not a high-score policy. It ignores walls, crowd size, gem value, incoming attacks, and level-based healing. The pilot chapter works through those decisions with the repository's example bots.
Reading compiled behavior carefully
Keep four layers apart: source code, standard-library helpers, the compiled image, and the VM. A rule in one layer need not exist in another. Likewise, the VM can have an instruction that no particular source operator emits.
The ABYSS apex README, for instance, says the normal compiler can build its upload. That does not make it a simple pilot. The VM and the host still decide what it can do in a match.