Chapter 7
Reading the Disassembly
Bloom's bytecode is an assembly language
The bytecode VM executes a flat array of bytes. That byte array is a kind of machine code, and like any machine code it has a human-readable form: an assembly listing. The tool that turns the raw bytes back into that listing is a disassembler.
Reading the disassembly is one of the best ways to understand what the compiler actually does with your code — how an if becomes a conditional jump, how a loop jumps backward, how a constant gets stored in a pool.
bloom disasm yourfile.blm from the command line to print the full listing for any program.
A Worked Example
Here is a small recursive Fibonacci function and the program that calls it:
fn fib(n) {
if (n < 2) { return n }
return fib(n - 1) + fib(n - 2)
}
print(fib(10))
Running bloom disasm fib.blm produces:
== main ==
0000 LOAD_GLOBAL 0 ; = "fib"
0003 CONST 1 ; = 10
0006 CALL 1
0008 CALL_NATIVE 60 1 ; = print()
0012 POP
0013 HALT
== function fib(s0) == (locals: 1)
0000 LOAD_LOCAL s0
0002 CONST 0 ; = 2
0005 LT
0006 JUMP_IF_FALSE 7 ; -> 0016
0009 POP
0010 LOAD_LOCAL s0
0012 RETURN
0013 JUMP 1 ; -> 0017
0016 POP
0017 LOAD_GLOBAL 1 ; = "fib"
0020 LOAD_LOCAL s0
0022 CONST 2 ; = 1
0025 SUB
0026 CALL 1
0028 LOAD_GLOBAL 1 ; = "fib"
0031 LOAD_LOCAL s0
0033 CONST 0 ; = 2
0036 SUB
0037 CALL 1
0039 ADD
0040 RETURN
0041 RETURN_NIL
Anatomy of a Line
Every instruction line follows the same shape:
0006 JUMP_IF_FALSE 7 ; -> 0016
^^^^ ^^^^^^^^^^^^^ ^ ^^^^^^^
offset opcode operand annotation
- Offset — the byte position of this instruction within its chunk. The VM's instruction pointer counts in these units. Each chunk (main, and every function) starts back at
0000. - Opcode — the operation, named from the
OpCodeenum. - Operands — the bytes that follow the opcode. A slot is shown as
s0,s1…; a constant or jump shows its raw numeric value. - Annotation — everything after
;. The disassembler resolves the operand for you: a constant index becomes= 10, a global index becomes= "fib", a native id becomes= print(), and a jump distance becomes its absolute target-> 0016.
Walking Through It
The constant pool
Numbers and strings aren't stored inline in the bytecode. They live in a per-chunk constant pool, and instructions refer to them by index. CONST 1 means "push the constant at index 1" — the disassembler looks it up and tells you that's 10.
Locals vs. globals
Inside fib, the parameter n is local slot 0, so reading it is LOAD_LOCAL s0 — a direct array index, the fast path. At the top level, fib itself is a global, so calling it starts with LOAD_GLOBAL 0, whose name ("fib") is resolved from the constant pool.
How an if becomes a jump
There is no if instruction. The condition n < 2 compiles to LOAD_LOCAL s0, CONST 2, LT, leaving a boolean on the stack. Then JUMP_IF_FALSE at offset 0006 skips the then branch when that boolean is false. Its target, -> 0016, is exactly where the else path begins — the disassembler computes the absolute landing site from the relative offset stored in the bytecode, so you don't have to do the arithmetic.
Calls
User functions use CALL n, where n is the argument count; the callee was pushed first, then its arguments. Built-ins like print use CALL_NATIVE id n, dispatched directly through the native table — which is why print shows up as CALL_NATIVE 60 1 rather than a regular call.
Returns
Notice the trailing RETURN_NIL at offset 0041. The compiler always appends an implicit "return nothing" so a function that falls off the end still returns cleanly — even when, as here, every real path already hit a RETURN.
Loops Jump Backward
A for loop shows the other direction of control flow. The LOOP instruction jumps backward to re-test the loop, while ITER_NEXT jumps forward to the exit when the iterator is exhausted:
0019 RANGE
0020 ITER_INIT
...
0026 ITER_NEXT 17 ; -> 0046 (forward, to the exit)
...
0043 LOOP 22 ; -> 0024 (backward, to the test)
0046 POP
Because the disassembler resolves both into absolute offsets, you can read the loop's shape at a glance: the body sits between the ITER_NEXT test and the LOOP that closes it.
Superinstructions
For speed the compiler sometimes fuses common pairs into a single superinstruction. If you see opcodes like ADD_LOCAL_CONST or LOAD_LOCAL_CONST_LT_JUMP in a listing, that's one instruction doing what would otherwise be two or three. The disassembler decodes each one's operands the same way the VM does, so the listing always stays in sync.
In the Source Code
The disassembler lives in src/tools/disassembler.ts:
disassemble(source)— runs the lexer, parser, and bytecode compiler, then renders the listingOP_TABLE— maps eachOpCodeto its name and operand layout, derived directly from how the VM advances its instruction pointer