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
lumenexecutable 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
.lmnsource 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
lumensupports.
CLI Reference
lumen <file> [options]
Special first arguments
These take the place of a file name and short-circuit everything else:
| Argument | Description |
|---|---|
--introduction | Write a starter helloworld.lmn to the current directory |
--examples | List all built-in examples |
--examples <name> | Write the named example to <name>.lmn |
--help | Print usage and exit |
--version | Print version, git branch, commit, and build date |
File flags
Everything else takes <file> as the first argument, followed by any of:
| Flag | Description |
|---|---|
--verbose | Print extra compiler/VM diagnostics, including the raw compiled bytecode |
--compile | Compile the source file to <file>.bin |
--run | Execute compiled bytecode |
--disassemble | Disassemble a compiled .bin file into readable instructions |
--dbgsym | Emit a <file>.bin.dbg debug symbols file alongside the bytecode |
--debugger | Run 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:
--disassembleis exclusive. It cannot be combined with--compileor--run— disassembling reads an existing.binfile, it doesn’t produce or execute one.--debuggerrequires--run. You can’t compile-only into the debugger; the debugger attaches to execution.--dbgsymrequires--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:
| Type | Description |
|---|---|
| Integer | Whole numbers, stored as 64-bit integers |
| Float | Floating-point numbers, stored as double |
| String | Text, 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
| Operator | Meaning |
|---|---|
+ | 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:
| Operator | Meaning |
|---|---|
== | 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
| Statement | Description |
|---|---|
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 &:
| Statement | Description |
|---|---|
inputInt &var | Read a line from stdin, parse it as an integer, store it in var |
inputStr &var | Read 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
ifmust be closed withendif. elseis 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
routinemust be closed withendroutine. - Routines cannot be nested. Defining a
routineinside anotherroutineis a compile error. - Routines take no parameters and return no value. There’s no argument list on
routineorcall— 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 itsroutine ... endroutineblock 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>
| Example | Generates | Description |
|---|---|---|
age | age.lmn | Age calculator — reads a birth year, computes age |
fizzbuzz | fizzbuzz.lmn | Classic FizzBuzz up to a user-supplied N |
temperature | temperature.lmn | Celsius ↔ Fahrenheit converter using routines |
infinite-loop | jump.lmn | Minimal 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/ conditionaljump looppair drives iteration fromi = 1up toN(see Labels & Jumps). %(modulo) checks divisibility by 15, then 3, then 5.- Three levels of nested
if/else/endifstand in for theelseifchain 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 variablestempandresult— see Routines for why this pattern exists. - Nested
if/elseto build a three-way menu (mode == 1,mode == 2, anything else) — see Conditionals. inputIntused 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/inputStrbecome calls into the VM’s native function table (see Built-in Functions).ifopens a conditional block, emitting a comparison opcode and a placeholder jump target;elseandendifpatch that target once the block’s extent is known.labelrecords the current bytecode offset under a name;jumpemits a jump instruction referencing it.routine/endroutinecollect their body into a separate bytecode segment, appended after the main program once compilation finishes;callemits 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:
| Field | Type | Description |
|---|---|---|
| Signature | 2 bytes | 0xFE 0xFA — validated on load, load fails if it doesn’t match |
| Bytecode length | int32 | Number of ints in the bytecode stream |
| Bytecode | int32[length] | The instruction stream itself |
| String pool size | int32 | Number of pooled string literals |
| String pool entries | repeated {int32 len, char[len]} | Each string literal, length-prefixed, not null-terminated |
| Variable count | int32 | Number 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
| State | Type | Purpose |
|---|---|---|
PC | int | Program counter — index into the bytecode stream |
stack | std::vector<Variant> | Operand stack — values being computed with |
variables | std::vector<Variant> | Flat variable storage, one slot per variable, sized from the compiled variable count |
pcStack | std::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 construct | Bytecode behavior |
|---|---|
x = expr | Evaluate expr by pushing operands and applying arithmetic opcodes (ADD/SUB/…), then pop the result into variables[x] |
println / print / inputInt / inputStr | EXEC (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 / jump | label is purely a compile-time bookmark; jump compiles to JUMP (0x05), an unconditional PC reassignment |
routine / call / endroutine | call 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 throughexecute()one instruction at a time and inspects the samestack/variables/PCstate 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>
...
variablesmaps every variable name the compiler saw to its slot index in the VM’s variable array.routinesmaps each routine name to where its compiled body starts in the bytecode and how long it is.execmaps built-in function names (println,print,inputInt,inputStr) to theirfuncMapindex — 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
| Command | Description |
|---|---|
run | Begin bytecode execution |
stop | Stop execution |
breakpoint set <address> | Set a breakpoint at a bytecode offset |
breakpoint remove <address> | Remove a breakpoint |
breakpoint list | List all breakpoints |
breakpoint clear | Remove all breakpoints |
pc | Print the current program counter |
pc <address> | Set the program counter |
step / s | Execute a single instruction |
continue | Resume execution until the next breakpoint or halt |
stack | Print the operand stack, top to bottom |
variables | Print all variable slots and their current values |
disassemble | Show the disassembled bytecode, with the current PC marked |
help | Show 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:
- The bytecode offset, in hex — this is the address you’d use with the debugger’s
breakpointorpccommands. - The raw operand bytes for that instruction.
- 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/PUSHoperands referencing variables show the variable’s original name instead of its slot index.CALLshows the target routine’s name instead of a bare offset.EXECshows 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
| Opcode | Mnemonic | Size | Operands | Behavior |
|---|---|---|---|---|
0x01 | CALL | 2 | target offset | Push PC + 2 onto the return stack, jump to target offset (used for call) |
0x02 | POP | 2 | variable slot | Pop the stack top into the given variable slot |
0x03 | PUSH | 3 | type tag, value | Push a value onto the stack — see below |
0x04 | EXEC | 2 | function index | Call a native function (println, print, inputInt, inputStr) — see Built-in Functions |
0x05 | JUMP | 2 | target offset | Unconditional jump (used for jump) |
0xFE | RET | 1 | — | Pop the return stack and jump there (used for endroutine) |
0xFF | HLT | 1 | — | Halt execution |
PUSH type tags
The second operand of PUSH selects what the third operand means:
| Tag | Meaning |
|---|---|
0x01 | String — third operand is an index into the string pool |
0x02 | Integer literal — third operand is the value itself |
0x03 | Variable — 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.
| Opcode | Mnemonic | Operation |
|---|---|---|
0xA0 | ADD | a + b |
0xA1 | SUB | a - b |
0xA2 | MUL | a * b |
0xA3 | DIV | a / b (integer division) |
0xA4 | POW | a ^ b |
0xA5 | MOD | a % 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.
| Opcode | Mnemonic | Comparison |
|---|---|---|
0xB0 | JEQ | == |
0xB1 | JGR | > |
0xB2 | JLS | < |
0xB3 | JGE | >= |
0xB4 | JLE | <= |
0xB5 | JNE | != |
Strings
| Opcode | Mnemonic | Size | Behavior |
|---|---|---|---|
0xAA | JOIN | 1 | Pop 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_FLOATexists in the type system (see Bytecode Format) but the current arithmetic opcodes operate onint64_tonly. - Unrecognized opcodes cause the VM to print
Invalid opcodeand 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).
| Keyword | Function index | Signature | Behavior |
|---|---|---|---|
println | 0x01 | println <value> | Pop the stack top, print it, then print a newline |
print | 0x02 | print <value> | Pop the stack top, print it, no trailing newline |
inputInt | 0x03 | inputInt &var | Read 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) |
inputStr | 0x04 | inputStr &var | Read 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:
- Add an entry to
funcMapinsrc/vmfuncmap.cpp— a lambda taking(stack, variables), with whatever native behavior you want. - Add a matching entry to
funcListinsrc/compiler.cppso the compiler recognizes the keyword and knows what index to emit. - Pick an unused function index —
0x01–0x04are 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
- Build it. Follow Installation & Building — make sure
cmake .. && make -j$(nproc)succeeds cleanly. - Run the test suite.
This exercises the compiler and VM end-to-end. Any change to./test.shsrc/compiler.cpp,src/vm.cpp,src/tokenizer.cpp, or the bytecode format should leave this passing. - Keep the disassembler and debug symbols in sync. If you add or change an opcode, update
disassemblyMapinsrc/disassembler.cppandgetOpCodeOffset()insrc/helpers.cpptogether — a mismatch between the two silently corrupts disassembly output. See Opcode Reference.
Where things live
| Area | Files |
|---|---|
| Tokenizer | src/tokenizer.cpp, include/tokenizer.h |
| Compiler | src/compiler.cpp, src/compiler_math.cpp, include/compiler.h |
| Virtual machine | src/vm.cpp, include/vm.h |
| Native functions | src/vmfuncmap.cpp |
Binary .bin format | src/programfile.cpp, include/programfile.h |
| Disassembler | src/disassembler.cpp, include/disassembler.h |
| Interactive debugger | src/debugvm.cpp |
| Bundled examples | include/examples.h, examples/*.lmn |