Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

LumenLang is a lightweight, stack-based scripting language built from scratch in C++20. It ships with its own compiler, its own bytecode format, and its own virtual machine — nothing is borrowed or generated by a parser toolkit.

Lumen isn’t trying to be the next production language. It’s a teaching tool disguised as one: a small, readable playground for exploring how real language toolchains work under the hood, from tokenizing source text down to executing raw bytecode instructions on a stack machine.

println 'Hello, world!'

name = ''
print 'What`s your name? '
inputStr &name

greeting = 'Hello, ' .. name .. '!'
println greeting
Hello, world!
What`s your name? Ryan
Hello, Ryan!

What this book covers

  • Getting Started — building the lumen executable and running your first script.
  • Language Guide — every language feature: variables, operators, strings, conditionals, labels, and routines.
  • Examples — walkthroughs of the example programs shipped with the interpreter.
  • Architecture — how the compiler pipeline turns .lmn source into bytecode, and how the VM executes it.
  • Debugging & Tooling — the disassembler, debug symbols, and the interactive debugger.
  • Reference — the opcode table and built-in function list, for when you need the exact bytes.

Project goals

Lumen exists to explore, hands-on:

  • How programming languages work
  • Compiler design
  • Bytecode formats
  • Virtual machines
  • Debugging systems
  • Language tooling

The goal is to keep the language approachable while implementing the same fundamental ideas used by much larger language runtimes.

Source & license

LumenLang is open source under the GPL-3.0 license. The source lives at github.com/spikest3r/LumenLang.

Installation & Building

Arch Linux

If you’re on Arch (or an Arch-based distro) with an AUR helper, this is the fastest path — no manual build required:

yay -S lumen-lang-git

Once installed, it’s available on your PATH as lumen. Verify it:

lumen --version

If you’re not on Arch, or prefer building from source, follow the steps below instead.

Building from source

Lumen doesn’t have packaged binaries for other platforms yet — you build it from source. This is a five-minute process.

Requirements

  • A Linux or Unix-like operating system
  • A C++20-capable compiler
  • CMake

Building

Clone the repository and build with CMake, out-of-source, from the repository root:

git clone https://github.com/spikest3r/LumenLang.git
cd LumenLang

mkdir build
cd build
cmake ..
make -j$(nproc)

Once the build finishes, the executable is available at:

./build/lumen

You can optionally copy or symlink it somewhere on your PATH so you can call lumen from any directory:

sudo ln -s "$(pwd)/lumen" /usr/local/bin/lumen

(run this from inside the build directory, so $(pwd) resolves to .../LumenLang/build)

Verifying the build

Whichever install method you used, check the version and build metadata:

lumen --version

Next: Your First Program.

Your First Lumen Program

The fastest way to get oriented is to let Lumen generate a starter script for you.

Generate a starter program

lumen --introduction

This writes a file called helloworld.lmn in the current directory:

println 'Hello, world!'

name = ''
print 'What`s your name? '
inputStr &name

greeting = 'Hello, ' .. name .. '!'
println greeting

It also prints a short welcome message pointing you at the next steps.

Run it

lumen helloworld.lmn
Hello, world!
What`s your name? Ryan
Hello, Ryan!

When you run a .lmn file with no flags, Lumen compiles and executes it in one step — you don’t need to invoke the compiler and VM separately unless you want to (see CLI Reference).

Explore the built-in examples

Lumen ships with a handful of example programs baked into the binary. List them:

lumen --examples
Available examples:
  age               Age calculator
  infinite-loop     Infinite loop demonstrating labels and jumps
  temperature       Temperature converter
  fizzbuzz          Classical FizzBuzz algorithm

Generate an example:
  lumen --examples <name>

Generate one to disk:

lumen --examples fizzbuzz
✨ Created 'fizzbuzz.lmn'!
👉 Run it with: lumen fizzbuzz.lmn

And run it:

lumen fizzbuzz.lmn

Each of these is walked through in detail in the Examples chapter.

What’s next

  • Read through the Language Guide to learn Lumen’s syntax feature by feature.
  • Or jump straight to the CLI Reference to see every flag lumen supports.

CLI Reference

lumen <file> [options]

Special first arguments

These take the place of a file name and short-circuit everything else:

ArgumentDescription
--introductionWrite a starter helloworld.lmn to the current directory
--examplesList all built-in examples
--examples <name>Write the named example to <name>.lmn
--helpPrint usage and exit
--versionPrint version, git branch, commit, and build date

File flags

Everything else takes <file> as the first argument, followed by any of:

FlagDescription
--verbosePrint extra compiler/VM diagnostics, including the raw compiled bytecode
--compileCompile the source file to <file>.bin
--runExecute compiled bytecode
--disassembleDisassemble a compiled .bin file into readable instructions
--dbgsymEmit a <file>.bin.dbg debug symbols file alongside the bytecode
--debuggerRun under the interactive debugger

Default behavior

If you pass no --compile, --run, or --disassemble flag, Lumen compiles and runs the file in one shot:

lumen script.lmn
# equivalent to:
lumen script.lmn --compile --run

Flag combination rules

Lumen enforces a few sane combinations and will refuse to run with an error otherwise:

  • --disassemble is exclusive. It cannot be combined with --compile or --run — disassembling reads an existing .bin file, it doesn’t produce or execute one.
  • --debugger requires --run. You can’t compile-only into the debugger; the debugger attaches to execution.
  • --dbgsym requires --compile. Debug symbols are only generated as part of compilation.

Comments

Comments start with # and run to the end of the line. They can be their own line or trail after code.

# This is a comment

println 'Hello!' # Inline comments work too

There is no block comment syntax — every comment is single-line.

Variables & Values

Lumen is dynamically typed. A variable comes into existence the first time you assign to it — there’s no separate declaration syntax.

number = 42
result = number + 10

println result

Value types

Under the hood, every value carried on the stack or stored in a variable is tagged with one of three types:

TypeDescription
IntegerWhole numbers, stored as 64-bit integers
FloatFloating-point numbers, stored as double
StringText, delimited with single quotes

You never annotate the type yourself — it’s inferred from the literal or the result of an expression, and it can change across the lifetime of a variable, since Lumen re-tags the value on every assignment.

x = 5        # integer
x = 'five'   # now a string — perfectly legal

Assignment

Assignment is a single =. The right-hand side can be a literal, another variable, or an arithmetic expression:

a = 10
b = a * 2
c = a + b - 1

Negative numbers work as you’d expect:

z = -20

Operators

Arithmetic

OperatorMeaning
+Addition
-Subtraction
*Multiplication
/Division
%Modulo
^Exponentiation

Parentheses can be used to control evaluation order:

z = -20
a = z
b = 30
c = a + b
d = c * a + b
e = d / (a + b)
println e

Comparison

Used in conditionals and conditional jumps:

OperatorMeaning
==Equal
!=Not equal
>Greater than
<Less than
>=Greater than or equal
<=Less than or equal

String concatenation

.. concatenates two values into a string:

name = 'Ryan'
text = 'Hello, ' .. name

println text

See Strings for more.

Strings

Strings are delimited with single quotes.

message = 'Hello, Lumen!'
println message

Concatenation

Use .. to join strings (or a string and another value) together:

name = 'Ryan'
text = 'Hello, ' .. name

println text

No apostrophes inside strings

Because a single quote (') is the string delimiter, you can’t put a literal apostrophe inside a string — the tokenizer would read it as the end of the string. There’s no escape sequence for this yet, so the convention used in the examples is to substitute a backtick where an apostrophe would go:

print 'What`s your name? '

This is printed exactly as written — the backtick is not converted to an apostrophe, it’s just a visual stand-in the source code uses to avoid breaking the string literal. Keep this in mind if you’re generating output that should read naturally; there is currently no way to produce a real ' character inside a string.

Input & Output

Output

StatementDescription
print <value>Print a value without a trailing newline
println <value>Print a value followed by a newline
print 'Loading...'
println 'done'

Input

Input statements write into an existing variable, referenced with a leading &:

StatementDescription
inputInt &varRead a line from stdin, parse it as an integer, store it in var
inputStr &varRead a whitespace-delimited token from stdin, store it in var
age = 0
print 'Enter your age: '
inputInt &age
println age
name = ''
print 'Enter your name: '
inputStr &name
println name

Every example in this book assigns a placeholder value (age = 0, name = '') before reading into a variable with inputInt/inputStr. This isn’t strictly required by the compiler — a variable is registered the first time it’s seen, wherever that is — but it’s good practice: it documents the variable’s intended type up front and avoids relying on whatever default the VM gives an unseen variable.

If inputInt receives text that can’t be parsed as an integer, it prints Invalid value! and leaves the variable at 0.

Conditionals

if / else / endif blocks execute based on a comparison between two values.

if age >= 18
    println 'Adult'
else
    println 'Minor'
endif
  • Every if must be closed with endif.
  • else is optional.
  • The condition uses one of the comparison operators: ==, !=, >, <, >=, <=.

Nesting

if/endif blocks nest freely — there’s no elseif, so a chain of conditions is written as nested if/else blocks:

if mode == 1
    println 'Mode one'
else
    if mode == 2
        println 'Mode two'
    else
        println 'Unknown mode'
    endif
endif

This is exactly the pattern used in the Temperature Converter example. See also FizzBuzz, which nests three levels deep to check divisibility by 15, 3, and 5.

Labels & Jumps

Lumen has no dedicated loop keywords (while, for) — loops are built from label and jump, the same primitives the underlying bytecode exposes directly.

Unconditional jump

label repeat
println 'Hello, world!'
jump repeat

label <name> marks a position in the program. jump <name> transfers control to it unconditionally. The pair above is an infinite loop — see the Labels & Jumps Demo example.

Conditional jump

Combine jump with an if block to build a real loop with an exit condition:

i = 0

label loop

println i

i = i + 1

if i < 10
    jump loop
endif

This counts from 0 to 9. The loop body runs, i increments, and the if decides whether to jump back to label loop or fall through and end the program.

Scope

Labels are program-global — a jump can target any label in the file, not just ones in the same block. This is what makes them powerful (and occasionally easy to misuse): there’s no structural nesting enforced between a label and the jumps that target it, unlike if/endif or routine/endroutine.

Routines

A routine is a named, reusable block of code, invoked with call.

routine hello

println 'Hello from a routine!'

endroutine

call hello

Rules

  • Every routine must be closed with endroutine.
  • Routines cannot be nested. Defining a routine inside another routine is a compile error.
  • Routines take no parameters and return no value. There’s no argument list on routine or call — communication in and out of a routine happens entirely through variables, which are global and shared across the whole program.
  • A routine can be called from anywhere in the file, including before its routine ... endroutine block appears — routine calls are resolved after the full file is compiled.

Pattern: routines as functions over shared state

Because routines have no parameters, the idiomatic way to use them is to read and write well-known variable names, treating those variables as the routine’s implicit “arguments” and “return value”. The Temperature Converter example demonstrates this:

temp = 0
result = 0

routine c2f
result = temp * 9 / 5 + 32
endroutine

routine f2c
result = temp - 32
result = result * 5 / 9
endroutine

routine show
print 'Result: '
println result
endroutine

inputInt &temp
call c2f
call show

c2f and f2c both read temp and write result; show reads result back out. It’s a manual convention, not something the language enforces — nothing stops a routine from touching a variable it “shouldn’t”, so keep routines small and their variable contracts obvious.

Examples Overview

Lumen ships example programs baked directly into the lumen binary (see include/examples.h). You don’t need to find them on disk — generate any of them with:

lumen --examples <name>
ExampleGeneratesDescription
ageage.lmnAge calculator — reads a birth year, computes age
fizzbuzzfizzbuzz.lmnClassic FizzBuzz up to a user-supplied N
temperaturetemperature.lmnCelsius ↔ Fahrenheit converter using routines
infinite-loopjump.lmnMinimal label/jump demonstration

Two additional programs — math.lmn and paint.lmn — live in the repository’s examples/ folder as source but aren’t wired into lumen --examples; you can still read them directly from the repo for more arithmetic and expression examples.

Each example is broken down in its own page in this chapter, in increasing order of what language features it touches.

Age Calculator

Generate it:

lumen --examples age
yearNow = 2026
userYear = 0
println 'Hello, world!'
print 'Enter your birth year: '
inputInt &userYear
age = yearNow - userYear
print 'Your age: '
println age

A minimal but complete program: it declares two variables, reads an integer from the user with inputInt, does one subtraction, and prints the result with a mix of print and println.

Touches: variables, input/output, arithmetic operators.

FizzBuzz

Generate it:

lumen --examples fizzbuzz
N = 0

print 'N='
inputInt &N
println ''

i = 1

label loop

i15 = i % 15

if i15 == 0
    println 'FizzBuzz'
else
    i3 = i % 3

    if i3 == 0
        println 'Fizz'
    else
        i5 = i % 5

        if i5 == 0
            println 'Buzz'
        else
            println i
        endif
    endif
endif

i = i + 1

if i <= N
    jump loop
endif

The classic FizzBuzz, and the best single example of how the language pieces fit together:

  • A label loop / conditional jump loop pair drives iteration from i = 1 up to N (see Labels & Jumps).
  • % (modulo) checks divisibility by 15, then 3, then 5.
  • Three levels of nested if/else/endif stand in for the elseif chain Lumen doesn’t have (see Conditionals).

Note the divisibility-by-15 check runs first — this is the same “check the most specific case first” trick FizzBuzz solutions need in any language, since 15 is also divisible by 3 and 5.

Temperature Converter

Generate it:

lumen --examples temperature
temp = 0
result = 0

routine c2f
result = temp * 9 / 5 + 32
endroutine

routine f2c
result = temp - 32
result = result * 5 / 9
endroutine

routine ask
print 'Temparature: '
endroutine

routine show
print 'Result: '
println result
endroutine

println '1. C to F'
println '2. F to C'
print 'Select mode '
mode = 0
inputInt &mode
if mode == 1
call ask
inputInt &temp
call c2f
call show
else
if mode == 2
call ask
inputInt &temp
call f2c
call show
else
println 'Incorrect mode'
endif
endif

The most feature-complete example in the box. It combines:

  • Four routines (c2f, f2c, ask, show) that communicate purely through the shared variables temp and result — see Routines for why this pattern exists.
  • Nested if/else to build a three-way menu (mode == 1, mode == 2, anything else) — see Conditionals.
  • inputInt used twice: once for the menu selection, once for the temperature value itself.

It’s a good template to copy from when you want a small menu-driven Lumen program with reusable logic.

Labels & Jumps Demo

Generate it:

lumen --examples infinite-loop
label repeat
println 'Hello, world!'
jump repeat

The smallest possible demonstration of Lumen’s looping primitive. label repeat marks a point in the program; jump repeat transfers control back to it — forever, in this case, since there’s no condition around the jump. Run it and stop it with Ctrl+C.

See Labels & Jumps for how to turn this into a bounded loop with an exit condition.

Compiler Pipeline

Lumen follows the classic compiler pipeline: source text goes in, bytecode comes out, a virtual machine executes it.

Source Code (.lmn)
        |
        v
   Lumen Compiler
        |
        v
 Bytecode (.bin)
        |
        v
 Virtual Machine
        |
        v
     Output

Compilation

compile() (src/compiler.cpp) reads the source file line by line. Each line is tokenized by tokenizeFormula() (src/tokenizer.cpp), which splits on whitespace and operators while respecting single-quoted string boundaries, and drops anything after a #.

Each line’s leading token decides what gets emitted:

  • A bare identifier followed by = becomes an assignment: the right-hand side is evaluated and pushed onto the value stack, and an assignment instruction pops it into the target variable’s slot.
  • println / print / inputInt / inputStr become calls into the VM’s native function table (see Built-in Functions).
  • if opens a conditional block, emitting a comparison opcode and a placeholder jump target; else and endif patch that target once the block’s extent is known.
  • label records the current bytecode offset under a name; jump emits a jump instruction referencing it.
  • routine / endroutine collect their body into a separate bytecode segment, appended after the main program once compilation finishes; call emits a jump to that segment.

Two-pass resolution

Labels, routine calls, and jump targets can reference names that haven’t been seen yet (a jump to a label defined later in the file, a call to a routine defined below it). Rather than requiring forward declarations, the compiler emits placeholder addresses for these and records them as “unresolved.” Once the whole file has been read and every label/routine position is known, a resolution pass walks the unresolved list and patches the real offsets into the bytecode.

This is also where routine bodies — compiled into their own separate buffers as they’re encountered — get concatenated onto the end of the main bytecode stream, and their call sites get patched to point at the right offset.

Output

The result is a flat std::vector<int> of bytecode plus a string pool (every string literal, deduplicated) and a variable table (name → slot index). These get serialized to a .bin file by BinaryProgram::save() (src/programfile.cpp). If --dbgsym was passed, a parallel .bin.dbg file is written mapping variable slots, routine names, and offsets back to human-readable names — see Debug Symbols.

For the exact byte-level format, see Bytecode Format. For how it’s executed, see The Virtual Machine.

Bytecode Format

Instruction encoding

Lumen’s bytecode is not byte-packed — it’s a flat std::vector<int>. Each instruction is one or more ints: an opcode, followed by however many operand ints that opcode needs. There’s no length prefix per instruction; the VM knows how many operands to consume from the opcode alone, via getOpCodeOffset().

For the full opcode table, see Opcode Reference.

The .bin container

Compiled programs are written to disk with BinaryProgram::save() (src/programfile.cpp) in a small custom binary format:

FieldTypeDescription
Signature2 bytes0xFE 0xFA — validated on load, load fails if it doesn’t match
Bytecode lengthint32Number of ints in the bytecode stream
Bytecodeint32[length]The instruction stream itself
String pool sizeint32Number of pooled string literals
String pool entriesrepeated {int32 len, char[len]}Each string literal, length-prefixed, not null-terminated
Variable countint32Number of variable slots to allocate at VM startup

This is the entire file — no header versioning, no section table. It’s deliberately minimal.

The string pool

Every string literal in the source is deduplicated into a single pool during compilation (resolveString() in src/helpers.cpp); a PUSH instruction for a string operand carries an index into this pool rather than embedding the text inline in the bytecode stream.

Variables are slots, not names

By the time a program reaches the .bin file, variable names are gone — the compiler maps each name to an integer slot index (resolveVariableIndex()), and the VM allocates a flat std::vector<Variant> of that size at startup. This is why a .bin file alone is unreadable to a human: --disassemble will show you variable index 3, not the original name. To get names back, you need the separate debug symbols file — see Debug Symbols.

Values on the stack: Variant

Every value that moves through the VM — on the stack or in a variable slot — is a tagged union (include/types.h):

typedef enum {
    TAG_INT = 2,
    TAG_FLOAT = 3,
    TAG_STRING = 1
} TypeTag;

typedef struct {
    TypeTag type;
    std::variant<int64_t, double, std::string> data;
} Variant;

In practice, today’s arithmetic and comparison opcodes operate on int64_t exclusively (see Opcode Reference for the exact behavior) — TAG_FLOAT is defined in the type system but not yet exercised by the current instruction set.

The Virtual Machine

The Lumen VM (src/vm.cpp) is a stack machine: instructions operate on an operand stack rather than registers. It’s a straightforward fetch-decode-execute loop with three pieces of state.

Execution state

StateTypePurpose
PCintProgram counter — index into the bytecode stream
stackstd::vector<Variant>Operand stack — values being computed with
variablesstd::vector<Variant>Flat variable storage, one slot per variable, sized from the compiled variable count
pcStackstd::vector<int>Return-address stack, used for routine calls

The main loop

while(true) {
    auto opcode = bytecode[PC];
    int result = execute(bytecode, stringPool, variables, stack, pcStack, PC, halt);
    if(halt) break;
    PC = result;
}

execute() decodes a single instruction, performs its effect, and returns the next PC value. Most instructions simply return PC + offset (fall through to the next instruction); jumps, conditional jumps, calls, and returns instead return an arbitrary target address, which is how control flow works — there’s no separate branch-prediction or block structure at runtime, just PC reassignment.

How each construct compiles down

Language constructBytecode behavior
x = exprEvaluate expr by pushing operands and applying arithmetic opcodes (ADD/SUB/…), then pop the result into variables[x]
println / print / inputInt / inputStrEXEC (0x04) dispatches through a native function table — see Built-in Functions
if <a> <cmp> <b>Push a and b, then a comparison opcode (JEQ/JGR/…) that pops both and jumps past the block if the comparison is false
label / jumplabel is purely a compile-time bookmark; jump compiles to JUMP (0x05), an unconditional PC reassignment
routine / call / endroutinecall compiles to CALL (0x01), which pushes a return address onto pcStack and jumps to the routine’s offset; endroutine compiles to RET (0xFE), which pops pcStack and jumps back
.. (string concat)JOIN (0xAA) pops a count and that many strings off the stack, concatenates them, and pushes the result

Native functions

print, println, inputInt, and inputStr aren’t opcodes of their own — they’re entries in funcMap (src/vmfuncmap.cpp), a lookup table from a small integer index to a C++ lambda. The EXEC opcode (0x04) just looks up the index and calls the corresponding lambda with the current stack and variable storage. This is a deliberately extensible design: adding a new built-in function means adding one entry to funcMap plus a matching entry to the compiler’s funcList, without touching the opcode set at all.

Halting

Execution stops when the VM reaches HLT (0xFF), which the compiler appends to the end of the main bytecode stream after every line of source has been compiled.

Debug and disassembly variants

src/vm.cpp implements the plain execution loop described above. Two related tools reuse the same execute() function but wrap it differently:

  • run_debug() (src/debugvm.cpp) — the interactive debugger, which steps through execute() one instruction at a time and inspects the same stack/variables/PC state between steps.
  • disassemble() (src/disassembler.cpp) — doesn’t execute anything; it statically walks the bytecode stream opcode by opcode and prints a human-readable listing. See Disassembler.

Debug Symbols

Compiled .bin files are anonymous — variable names, routine names, and function names are all erased in favor of integer slots and offsets. Debug symbols restore that human-readable information for the disassembler and debugger to use.

Generating them

Pass --dbgsym alongside --compile:

lumen program.lmn --compile --dbgsym

This produces program.lmn.bin (the bytecode, as always) and program.lmn.bin.dbg (the symbol table).

What’s in the file

The .dbg file is plain text, written directly by the compiler (src/compiler.cpp) in three sections:

variables
<name> <slot index>
...
routines
<name>
<bytecode offset>
<bytecode length>
...
exec
<name> <function index>
...
  • variables maps every variable name the compiler saw to its slot index in the VM’s variable array.
  • routines maps each routine name to where its compiled body starts in the bytecode and how long it is.
  • exec maps built-in function names (println, print, inputInt, inputStr) to their funcMap index — see Built-in Functions.

How it’s consumed

Both --disassemble and --debugger look for a .dbg file next to the binary they’re operating on (<binary>.dbg) and load it automatically if present, substituting names back in wherever the raw bytecode would otherwise show a bare integer. Without it, both tools still work — you just see slot numbers and offsets instead of the original identifiers.

Interactive Debugger

Lumen has a built-in step debugger (src/debugvm.cpp), reached via --debugger (which requires --run):

lumen program.lmn --compile --dbgsym --run --debugger

Compiling with --dbgsym first is optional but strongly recommended — without it, the debugger shows raw slot indices instead of your variable and routine names.

Starting up

Debugger active. 'help' for list of commands
>>

Commands

CommandDescription
runBegin bytecode execution
stopStop execution
breakpoint set <address>Set a breakpoint at a bytecode offset
breakpoint remove <address>Remove a breakpoint
breakpoint listList all breakpoints
breakpoint clearRemove all breakpoints
pcPrint the current program counter
pc <address>Set the program counter
step / sExecute a single instruction
continueResume execution until the next breakpoint or halt
stackPrint the operand stack, top to bottom
variablesPrint all variable slots and their current values
disassembleShow the disassembled bytecode, with the current PC marked
helpShow the command list

A typical session

>> run
Executing...
Breakpoint hit!
>> stack
Stack (top to bottom):
  [2] {int64: 5}  <- top
  [1] {int64: 3}
  [0] {string: "hello"}
>> variables
Variables:
  [i] {int64: 5}
  [result] {int64: 8}
>> step
>> continue
Execution finished

Addresses for breakpoint and pc are bytecode offsets, not source line numbers — pair --debugger with --disassemble (or the debugger’s own disassemble command) to find the offset you want to break at. If you compiled with --dbgsym, routine names shown by disassemble will help you find the offset where a particular routine begins.

Disassembler

--disassemble (src/disassembler.cpp) turns a compiled .bin file back into a readable instruction listing. It’s read-only — it cannot be combined with --compile or --run.

lumen program.lmn --compile --dbgsym
lumen program.lmn.bin --disassemble

Reading the output

===== Main =====
0x00000000: 03 01 00       | PUSH 'hello'
0x00000003: 02 00          | POP i
0x00000005: 03 03 00       | PUSH i
...

===== Routine show =====
0x0000004a: 04 01          | EXEC println
...

Each line shows:

  1. The bytecode offset, in hex — this is the address you’d use with the debugger’s breakpoint or pc commands.
  2. The raw operand bytes for that instruction.
  3. The mnemonic (see Opcode Reference) and its decoded operand.

Routine bodies are appended after the main program stream, and — if debug symbols are loaded — the disassembler prints a ===== Routine <name> ===== header right before the offset where each one begins.

With and without debug symbols

If a matching <binary>.dbg file exists next to the file you’re disassembling, it’s loaded automatically (see Debug Symbols), and:

  • POP/PUSH operands referencing variables show the variable’s original name instead of its slot index.
  • CALL shows the target routine’s name instead of a bare offset.
  • EXEC shows the built-in function’s name (println, inputInt, …) instead of its numeric index.

Without a .dbg file, you still get a complete, correct listing — just with numbers where names would be.

Opcode Reference

Every instruction is a sequence of ints in the bytecode stream: one opcode int, followed by zero or more operand ints. “Size” below is the total instruction length (opcode + operands), i.e. what getOpCodeOffset() returns and how far PC advances by default.

Control & data movement

OpcodeMnemonicSizeOperandsBehavior
0x01CALL2target offsetPush PC + 2 onto the return stack, jump to target offset (used for call)
0x02POP2variable slotPop the stack top into the given variable slot
0x03PUSH3type tag, valuePush a value onto the stack — see below
0x04EXEC2function indexCall a native function (println, print, inputInt, inputStr) — see Built-in Functions
0x05JUMP2target offsetUnconditional jump (used for jump)
0xFERET1Pop the return stack and jump there (used for endroutine)
0xFFHLT1Halt execution

PUSH type tags

The second operand of PUSH selects what the third operand means:

TagMeaning
0x01String — third operand is an index into the string pool
0x02Integer literal — third operand is the value itself
0x03Variable — third operand is a variable slot to read from

Arithmetic

All arithmetic opcodes are size 1 (no operands) — they pop two int64 values off the stack, combine them, and push an int64 result. Operand order: the value pushed second (b) is popped first, so a OP b is computed correctly for non-commutative operators.

OpcodeMnemonicOperation
0xA0ADDa + b
0xA1SUBa - b
0xA2MULa * b
0xA3DIVa / b (integer division)
0xA4POWa ^ b
0xA5MODa % b

Comparison / conditional jump

Size 2 — operand is the offset to jump to if the comparison is false. All pop two values, compare as int64, and either fall through (true) or jump to the operand (false). This is exactly how if blocks compile.

OpcodeMnemonicComparison
0xB0JEQ==
0xB1JGR>
0xB2JLS<
0xB3JGE>=
0xB4JLE<=
0xB5JNE!=

Strings

OpcodeMnemonicSizeBehavior
0xAAJOIN1Pop a count n, then pop n strings off the stack and concatenate them in original order, push the result — this is what .. compiles to

Notes

  • There is no dedicated float arithmetic opcode set yet — TAG_FLOAT exists in the type system (see Bytecode Format) but the current arithmetic opcodes operate on int64_t only.
  • Unrecognized opcodes cause the VM to print Invalid opcode and halt with an error.

Built-in Functions

Built-in functions aren’t opcodes — they’re entries in a native function table (funcMap, src/vmfuncmap.cpp) invoked through the single EXEC (0x04) opcode, which looks up a function index and calls the matching C++ lambda with the current stack and variable storage. The compiler maps each keyword to the same index via funcList (src/compiler.cpp).

KeywordFunction indexSignatureBehavior
println0x01println <value>Pop the stack top, print it, then print a newline
print0x02print <value>Pop the stack top, print it, no trailing newline
inputInt0x03inputInt &varRead a token from stdin, parse as int64, store into the referenced variable slot as TAG_INT. On parse failure, prints Invalid value! and leaves the variable’s value untouched (whatever it held before, which defaults to 0)
inputStr0x04inputStr &varRead a whitespace-delimited token from stdin, store into the referenced variable slot as TAG_STRING

Adding a new built-in function

Since dispatch goes through a single opcode and a lookup table, adding a new built-in doesn’t require touching the opcode set or the VM’s core loop:

  1. Add an entry to funcMap in src/vmfuncmap.cpp — a lambda taking (stack, variables), with whatever native behavior you want.
  2. Add a matching entry to funcList in src/compiler.cpp so the compiler recognizes the keyword and knows what index to emit.
  3. Pick an unused function index — 0x010x04 are taken by the four built-ins above.

That’s it; no changes to execute() in src/vm.cpp are needed, since EXEC already dispatches generically through the table.

Contributing

LumenLang is a solo, exploratory project, but issues, pull requests, and questions are welcome on GitHub.

Before you send a PR

  1. Build it. Follow Installation & Building — make sure cmake .. && make -j$(nproc) succeeds cleanly.
  2. Run the test suite.
    ./test.sh
    
    This exercises the compiler and VM end-to-end. Any change to src/compiler.cpp, src/vm.cpp, src/tokenizer.cpp, or the bytecode format should leave this passing.
  3. Keep the disassembler and debug symbols in sync. If you add or change an opcode, update disassemblyMap in src/disassembler.cpp and getOpCodeOffset() in src/helpers.cpp together — a mismatch between the two silently corrupts disassembly output. See Opcode Reference.

Where things live

AreaFiles
Tokenizersrc/tokenizer.cpp, include/tokenizer.h
Compilersrc/compiler.cpp, src/compiler_math.cpp, include/compiler.h
Virtual machinesrc/vm.cpp, include/vm.h
Native functionssrc/vmfuncmap.cpp
Binary .bin formatsrc/programfile.cpp, include/programfile.h
Disassemblersrc/disassembler.cpp, include/disassembler.h
Interactive debuggersrc/debugvm.cpp
Bundled examplesinclude/examples.h, examples/*.lmn