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, references, conditionals, labels, routines, and the standard library (files, random, HTTP, capabilities).
- Examples — walkthroughs of the example programs shipped with the interpreter.
- Lumen in apps — Lumen embedded as a scripting layer inside other software, including a mobile runtime for Android.
- Platforms & Ports — how the same VM runs on Raspberry Pi Pico, AVR, and in the browser via WebAssembly.
- 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.
Try it online
Lumen is also available online as Lumen Playground — no installation needed.
Beyond the desktop
The lumen CLI you’ll build in Getting Started is the full toolchain, but the VM it’s built around also runs on Raspberry Pi Pico, AVR microcontrollers, and — via the same WebAssembly build that powers the Playground — in the browser, where compiled programs can be exported as QR codes and scanned straight into Lumen on Android. See Platforms & Ports for the full picture.
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)
Online
Prefer not to build anything? Lumen Playground runs Lumen entirely in your browser, powered by WebAssembly — no install required. You can open and save both .lmn scripts and precompiled .bin bytecode files straight from your computer, all client-side. The Playground can also export a compiled program as a scannable QR sequence — see WebAssembly.
Other targets
The instructions above build the full desktop toolchain (compiler + VM). The VM alone also runs on Raspberry Pi Pico and AVR microcontrollers, and on Android via LumenRuntimeAndroid — none of these build from this same CMake flow, so see their respective pages under Platforms & Ports for target-specific build steps.
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
Float literals
A numeric literal is a float if it contains a decimal point or an exponent (e/E); otherwise it’s an integer. This is a purely syntactic check (isFloatLiteral() in src/helpers.cpp) — it looks at how the literal is written, not its value:
a = 3 # integer
b = 3.0 # float
c = 3e2 # float (300)
d = -3.5 # float
Integer and float literals are pooled together into the same deduplicated constant table — see Bytecode Format.
Arithmetic between an integer and a float promotes the result to float; / (division) always produces a float result regardless of operand types. See Operators for the full rules.
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 — always produces a float result, even for two integers |
% | Modulo |
^ | Exponentiation |
If either operand of +, -, *, %, or ^ is a float, the result is a float; if both operands are integers, the result stays an integer. / is the one exception — it always yields a float, so 7 / 2 is 3.5, not 3:
a = 7
b = 2
c = a / b
println c # 3.500000
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. Comparisons read both operands as double, so an integer and a float compare correctly against each other (5 == 5.0 is true):
| 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.
Converting to and from numbers
Strings don’t automatically convert to numbers (or vice versa) in arithmetic or comparisons — use one of the four conversion built-ins, all of which take the value first and the destination variable second:
n = 0
str2int '42' &n # n = 42 (int)
f = 0.0
str2float '3.14' &f # f = 3.14 (float)
s1 = ''
int2str 42 &s1 # s1 = '42'
s2 = ''
float2str 3.5 &s2 # s2 = '3.500000'
str2int/str2float fall back to 0/0.0 on unparseable input rather than raising an error. See Built-in Functions for the exact semantics.
String Manipulation
| Function | Description |
|---|---|
strlen s, &out | Length of a string |
substr s, start, len, &out | Extract a substring |
strfind s, needle, &out | Index of the first occurrence of needle in s, or -1 if not found |
strcase s, upper, &out | Convert case: upper = 1 for uppercase, 0 for lowercase |
trim s, &out | Strip leading and trailing whitespace |
References & Dereferencing
Lumen variables can be referenced and dereferenced directly, independent of the &var syntax used to pass output parameters to built-ins.
value = 20
ref = &value
deref = *ref
| Operator | Meaning |
|---|---|
&value | Take a reference to a variable’s storage slot |
*ref | Dereference — read the value a reference points to |
A reference can be stored in a variable like any other value and passed around, then dereferenced later with * to read the current contents of the variable it points to.
Currently, dereferenced assignments (e.g.
*ref = 10) are not supported.
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 / elif / 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. elseandelifare optional.- The condition uses one of the comparison operators:
==,!=,>,<,>=,<=.
Chaining Conditions
Use elif (else-if) to chain multiple conditions without nesting:
if mode == 1
println 'Mode one'
elif mode == 2
println 'Mode two'
else
println 'Unknown mode'
endif
You can chain as many elif blocks as needed:
if number % 15 == 0
println 'Divisible by 15'
elif number % 3 == 0
println 'Divisible by 3'
elif number % 5 == 0
println 'Divisible by 5'
else
println 'Not divisible by 3 or 5'
endif
Inline Expressions in Conditions
Conditions can include arithmetic and other expressions directly:
number = 15
if number % 15 == 0
println 'Divisible by 15'
elif number % 3 == 0
println 'Divisible by 3'
endif
See also Loops, FizzBuzz, and Temperature Converter for examples of conditionals in action.
Loops
Lumen provides two primary loop constructs: while loops for condition-based iteration, and repeat loops for fixed-count iteration.
While Loop
A while loop repeats a block as long as a condition is true:
i = 0
while i < 10
println i
i = i + 1
endwhile
- The condition is checked at the start of each iteration.
- The loop exits when the condition becomes false.
- If the condition is false initially, the loop body never executes.
Repeat Loop
A repeat loop repeats a block a fixed number of times:
repeat 5
println 'Hello'
endrepeat
Repeat with Iterator
Use the optional iterator syntax to automatically manage a loop variable:
repeat 5, i
println i
endrepeat
This prints 0 1 2 3 4 (the iterator starts at 0 and increments each iteration).
The iterator is equivalent to:
i = 0
repeat 5
println i
i = i + 1
endrepeat
Loop Control
Break
Use break to exit a loop early:
i = 0
while i < 100
if i == 5
break
endif
println i
i = i + 1
endwhile
Continue
Use continue to skip to the next iteration:
repeat 10, i
if i % 2 == 0
continue
endif
println i
endrepeat
This prints only the odd numbers from 0 to 9.
Combining Loops and Conditionals
Loops and conditionals work seamlessly together. Here’s FizzBuzz using a repeat loop with inline condition expressions:
repeat 15, i
if i % 15 == 0
println 'FizzBuzz'
elif i % 3 == 0
println 'Fizz'
elif i % 5 == 0
println 'Buzz'
else
println i
endif
endrepeat
See Conditionals for more details on if, elif, and else.
Legacy: Labels and Jumps
Before loops were added to Lumen, iteration was handled with labels and jumps. This approach is still supported but while and repeat loops are preferred:
i = 0
label loop
println i
i = i + 1
if i < 10
jump loop
endif
See Labels & Jumps for more details.
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.
Capabilities
Some standard-library functionality is gated behind capabilities — optional features a given VM build may or may not implement (for example, a minimal embedded build might omit FS or HTTP).
| Function | Description |
|---|---|
assertCapability name | Check whether capability name is implemented by this VM build; raises a runtime error if it isn’t |
assertCapability 'HTTP'
Capability names:
-
'FS'(file I/O) -
'random'(random number generation) -
'HTTP'(HTTP requests).
Calling assertCapability is optional — the gated functions behave the same whether or not you assert first. It’s a guard you add when a script depends on optional functionality and you want a clean, early error on VM builds that don’t include it, rather than failing deeper in the program.
Standard Library
Beyond the core built-ins (Input & Output, string conversions), Lumen ships a small standard library covering strings, files, randomness, and HTTP.
Capabilities
File I/O, random, and HTTP are optional features that a given VM build may or may not implement. See Capabilities for assertCapability and how to check for them.
File I/O
Requires the FS capability.
| Function | Description |
|---|---|
openFile path, &handle | Open a file, storing a handle in handle |
writeFile data, handle | Write a string to an open file |
readFile &out, handle | Read the full contents of an open file into out |
closeFile handle | Close an open file handle |
Random
Requires the random capability.
| Function | Description |
|---|---|
randomSeed seed | Seed the random number generator |
random &out | Generate a random float in [0.0, 1.0) into out |
randomRange min, max, &out | Generate a random integer in [min, max] (inclusive) into out |
HTTP
Requires the HTTP capability.
| Function | Description |
|---|---|
httpRequest method, url, headers, body, &status, &response | Perform an HTTP request |
method—'GET','POST','PUT', or'DELETE'(case-insensitive)url— full URL including scheme, e.g.'http://example.com/path'headers— newline-separatedKey: Valuepairs, or''for nonebody— request body string, ignored forGETstatus— receives the HTTP status code, or-1on a connection-level failureresponse— receives the response body, or an error message on failure
HTTPS URLs are not currently supported — only plain http:// requests.
String Manipulation
See Strings for strlen, substr, strfind, strcase, and trim.
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. Every example page also carries a QR next to its source. It can be scanned directly into Lumen on Android without needing to compile anything on-device.
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.
Scan into Android
See Lumen on Android for how the scan-and-run pipeline works.
FizzBuzz
Generate it:
lumen --examples fizzbuzz
print 'N='
inputInt &x
repeat x, n
n = n + 1
if n % 15 == 0
println 'FizzBuzz'
elif n % 5 == 0
println 'Buzz'
elif n % 3 == 0
println 'Fizz'
else
println n
endif
endrepeat
The classic FizzBuzz, now written with modern Lumen loop and condition syntax:
repeat x, ncreates a loop that runsxtimes with an iteratornthat starts at 0 (see Loops).- Since the iterator starts at 0, we increment it to get 1-indexed counting:
n = n + 1. %(modulo) checks divisibility, usingelifchains instead of nested blocks (see Conditionals).
Note the divisibility-by-15 check runs first — this is the “check the most specific case first” trick FizzBuzz solutions need in any language, since 15 is also divisible by 3 and 5.
Scan into Android
See Lumen on Android for how the scan-and-run pipeline works.
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
elif mode == 2
call ask
inputInt &temp
call f2c
call show
else
println 'Incorrect mode'
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. elifchain to 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.
Scan into Android
See Lumen on Android for how the scan-and-run pipeline works.
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.
Scan into Android
See Lumen on Android for how the scan-and-run pipeline works.
Note
This particular program never halts on its own (Ctrl+C on desktop) — on Android, use the runtime’s cooperative cancellation to stop it instead.
Lumen in Spreadsheets
Spreadsheets embeds the Lumen VM as a scripting backend, similar in spirit to VBA in Excel. Scripts are written and run from the ScriptingPanel, and can read and write cells directly through two host functions.
Host functions
Spreadsheets currently exposes two functions on top of the base language:
| Statement | Description |
|---|---|
getCell <row> <col> &<var> | Reads the value of a cell into a variable |
setCell <row> <col> <value> | Writes a value into a cell |
Both take a 1-based row and column. getCell follows the same &var convention as inputInt/inputStr — the target variable is passed by reference and written into directly. setCell takes the value to write as its third argument, which can be a literal, a variable, or an expression.
value = 0
getCell 1 1 &value
setCell 1 2 'done'
Cell values read with getCell come back as strings — use str2int to convert before doing arithmetic or numeric comparisons on them.
Running a script
Scripts live in the ScriptingPanel and are run with the Run button. Output from print/println goes to the console pane below the editor; the script runs to completion (or until an infinite loop is manually stopped) before control returns to the sheet.
Example: grading a column
This script reads a passing threshold, then walks down column 1 grading each row into column 2:
i = 1
x = 0
inputInt &x
println 'Grading column 1'
label repeat
value = 0
getCell i 1 &value
str2int value &value
if value >= 60
setCell i 2 'PASS'
else
setCell i 2 'FAIL'
endif
if i < x
i = i + 1
jump repeat
endif
println 'Grading completed'
This follows the same label/jump loop pattern described in Labels & Jumps, with getCell/setCell standing in for a body that would otherwise just print.
Example: FizzBuzz into a column
Host functions compose with ordinary Lumen control flow — this script fills column 3 with FizzBuzz output for rows 1 through 15, using the nested if/else pattern since Lumen has no elseif:
i = 1
label loop
i15 = i % 15
if i15 == 0
setCell i 3 'FizzBuzz'
else
i3 = i % 3
if i3 == 0
setCell i 3 'Fizz'
else
i5 = i % 5
if i5 == 0
setCell i 3 'Buzz'
else
setCell i 3 i
endif
endif
endif
if i < 15
i = i + 1
jump loop
endif
Multiple scripts
The ScriptingPanel supports more than one script per file, managed from the Script menu: New, Rename, and Remove, alongside a Scripts submenu for switching between them. Each script is stored independently and saved with the spreadsheet file.
Lumen on Android
LumenRuntimeAndroid (com.spikest3r.lumenruntime) is a self-contained Lumen toolchain for Android. It bundles an in-app editor, the compiler, the VM, a disassembler, and a QR scanner for importing programs compiled elsewhere, all behind a Kotlin UI with a JNI bridge into the same C++ core described in Compiler Pipeline and The Virtual Machine.
This places Android in a different category from the Pico and AVR ports. Those are VM-only, compile-on-desktop targets with no on-device compilation path. Android has no such constraint: a .lmn program can be written, compiled, disassembled, and run entirely on the device, mirroring the desktop lumen CLI’s workflow. The QR pipeline is an additional means of getting a program onto the device, not the only one.
What’s on-device
- Editor. Write and edit
.lmnsource directly in the app. - Compiler. The same compiler pipeline as desktop, turning
.lmnsource into bytecode (.bin) locally — see Compiler Pipeline. - VM. The same
execute()core used on desktop, Pico, and AVR, reached through a JNI bridge. - Disassembler. A live disassembly view alongside the running program, matching the desktop
--disassembleoutput described in Disassembler. - QR scanner. An import path for bytecode compiled elsewhere — covered below.
Because compilation happens on-device, the Android workflow parallels the desktop workflow described in CLI Reference: write source, compile, run, and optionally disassemble, through a touch UI rather than command-line flags.
The QR import pipeline
The QR path is intended for programs not written on the device itself. Android has no convenient equivalent to dropping a .bin file onto a filesystem, so LumenRuntimeAndroid can instead reconstruct a compiled program by scanning a sequence of QR codes.
The pipeline has two sides:
- Encoding (WASM). A compiled
.binfile is split into indexed chunks, each small enough to fit into a single QR code. Every chunk carries its index, the total chunk count, and a CRC32 checksum of its payload. - Decoding (Android). The app scans chunks with the device camera in any order; chunks need not be scanned sequentially. Each incoming chunk is validated against its CRC32 before being accepted, and ingestion is idempotent, so re-scanning an already-captured chunk is a no-op rather than a source of corruption. Once every chunk index from
0tototal-1has been seen and validated, the runtime reassembles the original.binbytes and hands them to the same on-device VM used for locally-compiled programs.
This design allows a grid of QR codes for a program to be scanned in whatever order is convenient, rather than requiring a strict sequence.
Running a program
Once bytecode is ready — whether compiled on-device or reconstructed via QR — the JNI bridge hands it to the native execute() loop. Two Android-specific pieces sit around that core:
- Blocking input. Lumen’s
inputInt/inputStr(see Input & Output) expect to block on stdin on desktop. Android has no stdin, so the runtime surfaces anAlertDialoginstead, with the native thread blocked on astd::mutex/std::condition_variablepair until the user submits a value through the dialog. - Console output.
print/printlncalls are relayed from native code to the Kotlin UI thread via a fire-and-forget JNI callback, rendered in a monospace console below the source editor. Output is capped at 1000 lines; once the limit is reached, execution halts rather than continuing to produce output the console can’t display.
Cancellation
Long-running or accidentally-infinite programs — an unbounded label/jump loop, for instance (see Labels & Jumps) — are cancellable from the UI. The VM checks a std::atomic<bool> cancellation flag cooperatively between instructions, so stopping a program from the Android UI does not require killing the native thread outright; execution unwinds cleanly the next time execute() checks the flag.
Two ways to get a program running
Written on-device:
- Open the editor in LumenRuntimeAndroid and write the
.lmnsource directly. - Compile it in-app.
- Run it, with the disassembler view available alongside.
Imported from elsewhere:
- Write and compile a
.lmnprogram on the WASM Playground. - Export the compiled
.binas a QR sequence rather than, or alongside, a regular file download. - Open LumenRuntimeAndroid and scan the codes — order does not matter, and the app reports how many chunks remain outstanding.
- Once reconstruction finishes, run the program directly on-device.
Each example in this book’s Examples chapter includes a QR placeholder alongside its source, generated with this same chunking scheme, so it can be scanned directly into the Android runtime once real codes are filled in — or retyped in the on-device editor.
Lumina — Visual Game Creation
Lumina is a visual game creation environment that uses Lumen as its core scripting language. It allows developers to build 3D games and interactive projects using either visual block-based programming or by writing LumenLang code directly, providing flexibility in how game logic is expressed.
Dual creation approaches
Lumina supports two complementary ways to build game logic:
- Visual blocks: A drag-and-drop block editor for movement, logic, physics, variables, and interactions — no coding required.
- Lumen code: Write game logic directly in LumenLang, accessing the same engine functions as the block editor.
Developers can switch between the two approaches within the same project, using blocks for high-level flow and Lumen code for complex algorithms or fine-grained control.
The Lumina editor
Lumina’s interface includes:
- Viewport: A 3D grid-based level editor with voxel-like placement and full positioning/rotation control
- Toolbar: Quick access to brushes for placing static tiles (walls, platforms, decorations)
- Object menu: Add interactive objects with physics support
- Block editor or code editor: Switch between visual programming and LumenLang source (toggle with F12)
- Properties panel: Adjust object properties and behaviors
- File menu: Save, load, and test projects in real-time (press F5 to play)
Visual block programming
The block editor provides a full range of blocks for:
- Movement: Position and rotation control
- Physics: Gravity, collisions, and grounding checks
- Control flow: If/else conditionals, loops (repeat, while, forever)
- Variables: Local and global variable creation and manipulation
- Logic: Comparisons, boolean operations
- Interaction: Key press detection, collision detection, dialogue
- Camera: Camera positioning relative to the player
- Procedures: Define and call reusable routines
Blocks are type-aware, so slot connections validate that compatible data types fit together.
Scripting with Lumen
For complex game logic, developers write LumenLang directly. The language compiles to bytecode executed by Lumina’s built-in virtual machine, providing access to engine functions for movement, physics, input, and more. All standard Lumen features apply: variables, functions, loops, conditionals, and the standard library.
Examples & templates
Lumina ships with ready-to-play example projects demonstrating common patterns:
- parkour.lumina — A platformer showcasing movement and jumping mechanics
- collector.lumina — A collection-based gameplay example
- template_movement.lumina — Starter template for basic movement controls
- template_camera_movement.lumina — Starter template for camera-relative controls
Load any example from the editor to explore how it works, then modify it as a starting point for your own projects.
Technology & platforms
Lumina is built with:
- Graphics: Vulkan (via VulkanEngine) for high-performance 3D rendering
- Window management: GLFW3
- UI: ImGui for editor controls
- Language: C++20
- Build system: CMake
Lumina runs on Linux and macOS and requires the Vulkan SDK for building.
Related projects
- LumenLang — The Lumen programming language that powers Lumina’s scripting
- VulkanEngine — The rendering engine underlying Lumina’s graphics
Lumen on Yate IVR
YateIVRcore embeds Lumen inside an IVR (Interactive Voice Response) core for the Yate telephony engine. Call-handling logic — menu structure, DTMF handling, call flow — is written entirely as .lmn scripts, run by the same VM described in The Virtual Machine, reached from live SIP calls rather than a desktop CLI or a JNI bridge.
This is a different embedding shape than Android, Pico, or WASM. Those hosts run one program per invocation, start to finish. The IVR core instead keeps a single Lumen VM instance alive for the duration of one phone call, and repeatedly re-enters it — once when the call starts, once per DTMF digit pressed, once on hangup — while the same globals and state persist across every re-entry.
Routing calls to scripts
Each Yate extension maps to a .lmn script by filename: dialing extension 800 runs 800.lmn. The core owns the single TCP connection to Yate’s extmodule interface, parses incoming protocol messages (call.route, chan.dtmf, chan.hangup), and dispatches each one to the matching script — compiling it if this is the first call to reach that extension.
A script only has to define three routines:
onCall— runs when the call is answeredonDtmf— runs once per DTMF digit pressed during the callonHangup— runs when the call ends
routine onCall
playWav 'C:/VoIP_Assets/welcome_menu.wav'
endroutine
routine onDtmf
getDtmf &str
str2int str, &digit
if digit == 1
speakToWav 'Hello world', 'C:/VoIP_Assets/temp.wav'
playWav 'C:/VoIP_Assets/temp.wav'
else
if digit == 2
playWav 'C:/VoIP_Assets/music.wav'
endif
endif
endroutine
routine onHangup
# empty
endroutine
Nothing about this script differs syntactically from any other Lumen program — the same conditionals, routines, and variables covered elsewhere in this book apply unchanged. What’s different is entirely on the host side: how and when the VM gets re-entered.
One VM instance per call
call.route creates a fresh VM instance for the call and runs onCall. Every subsequent chan.dtmf message for that same call re-enters the same instance to run onDtmf, and chan.hangup re-enters it once more for onHangup before the instance is torn down. Globals declared at the top of the script — a PIN buffer, a menu state flag, anything — persist across all of these re-entries, because they all execute against the same ExecutionData.
This makes call isolation a property of the architecture rather than something a script has to manage. Two people dialing the same extension at the same time get two independent VM instances, each with its own copy of the script’s globals; there is no shared state between them to accidentally clobber, and no call_id-keyed bookkeeping for a script author to get wrong. The isolation exists whether or not the script author ever thinks about concurrency at all.
Calling into the VM from the host
The host doesn’t run “the program” the way a desktop invocation would. It calls a specific routine inside an already-running VM and expects control back once that routine finishes — the call-route handler shouldn’t block waiting for the entire call to play out, and the DTMF handler shouldn’t restart the program from the top.
This works through the same RET mechanism every Lumen routine already uses, without any VM changes: before jumping into a routine, the host pushes a sentinel value onto the routine call stack. The routine runs normally, including any nested calls to other routines, and each RET pops and compares against the top of that stack. When a RET pops the sentinel back off, that’s the signal that control has unwound all the way back to the host, not to another Lumen call site — so the host resumes exactly at that point. Nested routine calls made from inside onDtmf (a shared playConfirm routine, say) are unaffected, since they push and pop their own ordinary return addresses beneath the sentinel.
Native functions
Everything a script uses to affect the outside world — playing audio, synthesizing speech, reading the last DTMF digit, transferring or ending the call — is a native function, dispatched the same way as any built-in or standard library native elsewhere in Lumen.
| Function | Args | Purpose |
|---|---|---|
getDtmf | &out | Writes the most recent DTMF digit into out |
speakToWav | text, output_path | Synthesizes speech to a wav file (blocking) |
playWav | path | Plays a wav file on the call |
masqueradeTo | target | Masquerades the call onto an arbitrary Yate route |
hangUp | — | Terminates the call |
These sit alongside Lumen’s ordinary stdlib natives (str2int, strlen, httpRequest, and so on) — a script mixes both freely, as in the example above.
Extending the native set without touching the VM
Some functionality is specific to the host machine rather than general enough to belong in the VM core — sending a wake-on-LAN packet, starting a Windows service. Rather than adding these to the VM itself, the IVR core loads them from extension DLLs at startup, so the VM and compiler stay unaware of anything host-specific.
An extension DLL exports a single RegisterNatives function, which fills in two things: a mapping from opcode to the actual native implementation, using the same function signature every built-in native uses; and a list of descriptors, each naming a function as it should appear in .lmn source, together with the opcode it resolves to and how many arguments it expects. The first gives the VM something to dispatch to at runtime; the second gives the compiler what it needs to resolve a call by name at compile time — the same two pieces of information a built-in native carries, just supplied by a DLL loaded at startup instead of compiled into the core.
A DLL missing this export, or one that fails to load, is skipped without affecting the rest of the core. Opcodes are chosen by whoever writes the extension; there’s no collision detection yet against the core’s own native table or between multiple extensions, so this is worth tracking by hand if more than one extension is in use.
The result is that a .lmn script calling a host-specific native like a wake-on-LAN trigger reads no differently from one calling playWav — the distinction between “built into the VM” and “loaded from a DLL at startup” is invisible from the script’s point of view.
Platforms & Ports
The core compiler and VM described in Architecture target desktop Linux, but the same bytecode format is designed to run identically everywhere. Lumen currently spans four targets:
| Target | What runs | Notes |
|---|---|---|
| Desktop (Linux/Arch) | Full toolchain — compiler, VM, debugger, disassembler | The lumen CLI described throughout this book |
| Raspberry Pi Pico | VM only, bare-metal C | No compiler on-device — bytecode is cross-compiled on desktop and flashed/loaded onto the Pico |
| AVR | VM only, bare-metal C | Same split as Pico: desktop compiles, AVR executes |
| WebAssembly | Full toolchain, in-browser | Powers the Lumen Playground; can also export compiled programs as QR codes |
| Android | Full toolchain — editor, compiler, VM, disassembler, JNI-wrapped | Also has a QR scanner for importing programs compiled elsewhere; not the only way to get a program onto the device |
Why Pico and AVR ship the VM without the compiler
Pico and AVR ship the VM (execute() and its surrounding loop, see The Virtual Machine) but not the compiler. This is a deliberate split, specific to those two microcontroller targets:
- The compiler depends on the C++ standard library’s string handling and file I/O in ways that either don’t exist or aren’t worth porting to a microcontroller.
- Compiled bytecode (a
.binfile, see Bytecode Format) is a small, flat, self-describing blob — trivial to embed as a C byte array or flash over a wire. - Keeping compilation on desktop means the Pico and AVR VMs only have to get
execute()right, not an entire tokenizer/parser/codegen pipeline.
So the workflow on Pico and AVR is the same shape: compile on desktop, transfer the .bin, run the VM on-device, as a flashed C byte array in both cases.
Android and WebAssembly don’t follow this split — both ship the full compiler alongside the VM, so a program can be written and compiled without ever leaving the device or browser. Android’s QR scanner is a convenience for importing bytecode compiled elsewhere, not a substitute for an on-device compiler it lacks.
Native function tables per target
Built-in Functions covers this in more detail, but the short version: desktop dispatches EXEC through funcMap, a table of C++ lambdas. Embedded targets (Pico, AVR) use funcTable, a plain array of NativeFn function pointers, with indices 0xD0–0xFF reserved for host-specific natives like GPIO calls. This split lets each platform expose exactly the native functions that make sense for it — printing and input on desktop, pin control on a microcontroller — without changing the VM’s core dispatch loop.
Raspberry Pi Pico
Lumen’s VM runs bare-metal on the Raspberry Pi Pico, built against pico-sdk with devkitPro/CMake toolchains. There’s no compiler on-device — you write and compile .lmn programs on desktop, then get the resulting .bin bytecode onto the Pico.
What’s ported
The Pico build reuses the same execute() loop as desktop (see The Virtual Machine), with two structural differences:
funcTableinstead offuncMap. Desktop’s native function table is astd::map-backed set of lambdas; the Pico build uses a plain array ofNativeFnfunction pointers, since embedded C++ toolchains make heavier STL containers more expensive than they’re worth here.- A GPIO-specific
EXECrange. Function indices0xD0–0xFFare reserved on Pico for application-specific native functions — GPIO writes, I2C calls, and similar host behavior — mapped onto the tail end offuncTableat a fixed offset from the base opcode. Desktop programs don’t use this range at all; it only means something once bytecode is destined for a Pico.
Getting bytecode onto the device
- Write and compile your
.lmnprogram on desktop as usual (lumen program.lmn --compile), producing a.binfile — see CLI Reference. - Cross-compile the Pico firmware image, embedding the
.binbytecode as a C byte array baked into the binary (rather than reading from a filesystem, which the Pico doesn’t have in the general case). - Flash the resulting
.uf2image onto the Pico over USB in bootloader mode, the same way you’d flash any other pico-sdk project.
Peripheral access
Programs that need to touch hardware — an I2C display, a GPIO pin, a UART line — do so through native functions registered in the 0xD0–0xFF range, exactly like any other built-in from the language’s point of view: a compiled EXEC call with a function index. From inside a .lmn program, a GPIO write looks like any other built-in call; the fact that it’s toggling a physical pin rather than printing to a terminal is entirely a property of what’s registered in funcTable on that build, not something the language syntax needs to know about.
AVR
Alongside the Pico port, Lumen’s VM also runs bare-metal on AVR microcontrollers (the classic Arduino-family chips). Like Pico, this is a VM-only port — compilation stays on desktop, and only the compiled .bin bytecode makes the trip to the chip. Unlike Pico, the AVR build is compiled and flashed through the Arduino IDE, not a cross-compiling CMake toolchain.
Why AVR alongside Pico
Pico (a Cortex-M0+, 32-bit, with a real amount of RAM by microcontroller standards) and AVR (8-bit, far tighter on RAM and flash) sit at very different points on the embedded spectrum. Targeting both is a useful stress test for the VM’s portability assumptions: if the bytecode interpreter’s core loop and Variant value representation can run correctly on an 8-bit AVR with a few kilobytes of RAM, that’s a strong signal the VM itself doesn’t secretly depend on 32-bit-friendly assumptions anywhere in its hot path.
What’s shared with Pico
The AVR build follows the same structural split described in Raspberry Pi Pico:
- The same
execute()fetch-decode-execute loop, unchanged in logic from desktop. funcTable(a plain array ofNativeFnpointers) instead of desktop’sfuncMap, for the same reasons — no heavier STL containers on constrained hardware.- The same
0xD0–0xFFreservedEXECrange for host-specific native functions, so a bytecode file that calls into that range means something different (and needs a differentfuncTable) depending on which board it’s destined for.
What’s different
AVR’s tighter memory budget means more care is needed around bytecode size and the constant/string pools (see Bytecode Format) than on Pico or desktop — a .lmn program with a large number of distinct string or float literals costs proportionally more on an AVR target than the same program would on Pico or desktop, simply because there’s less RAM to hold the deduplicated pool in. In practice this mostly affects program design rather than the VM itself: keeping AVR-bound programs lean on literals goes a long way.
The build process is also different in kind, not just in tooling — see below.
Getting bytecode onto the device
The AVR VM lives in the avr-vm project as an Arduino sketch, not a standalone CMake build. Getting a program running on-device is a manual, multi-step process:
- Compile the
.lmnprogram on desktop as usual (lumen program.lmn --compile), producing a.binfile. - Run
bin2h(found alongside the sketch inavr-vm) against that.binfile. It converts the compiled bytecode into a C header — a byte array suitable for#include. - Paste the generated header’s contents into
program.hinside the Arduino sketch. - Open the sketch in the Arduino IDE, having first imported all the sources and headers from
avr-vm’ssrcandincdirectories into the sketch. - Build and upload the sketch to the board from the IDE, the same way as any other Arduino project.
There’s no dynamic loading step — the bytecode is baked into program.h at build time, so a new program means regenerating the header with bin2h and re-uploading the sketch, not flashing a separate data blob alongside the firmware.
WebAssembly
Unlike the Pico and AVR ports, the WebAssembly build carries the full toolchain — compiler and VM both — compiled to WASM and running entirely client-side in the browser. This is what powers Lumen Playground: there’s no server-side execution, so programs you write in the Playground never leave your machine unless you choose to export them.
What runs in-browser
- The same compiler pipeline described in Compiler Pipeline, turning
.lmnsource into bytecode. - The same VM described in The Virtual Machine, executing that bytecode.
- File open/save for both
.lmnsource and precompiled.binbytecode, backed by the browser’s native file picker rather than a real filesystem.
Because it’s the full toolchain rather than a VM-only port, the WASM build doesn’t need the funcTable/0xD0–0xFF split that Pico and AVR use — it dispatches built-ins the same way desktop does.
Exporting programs as QR codes
The Playground can take a compiled .bin program and export it as a sequence of QR codes instead of (or alongside) a downloadable file. This exists specifically to bridge to targets that don’t have an easy file-transfer path of their own — most notably Lumen on Android, which imports programs by scanning a QR sequence rather than pulling a file off a filesystem.
The underlying chunking scheme is target-agnostic: a .bin file is split into indexed chunks small enough to fit in a single QR code, each carrying enough information (chunk index, total count, a CRC32 checksum) to be reconstructed in any scan order and validated for corruption. The same scheme has a desktop-side implementation too — see Lumen on Android for how the pieces fit together end to end.
Why this matters for the embedded/mobile split
Between the four non-desktop targets, WASM is really the odd one out in a useful way: it’s the only port that both compiles and is reachable from a phone camera. That combination is what makes it the natural bridge for getting a Lumen program from “written on a laptop” to “running on Android” without ever touching a cable, a file share, or a server.
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<uint8_t> of bytecode plus a string pool (every string literal, deduplicated), a const pool (every integer and float literal, deduplicated — see Bytecode Format), 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<uint8_t>. Each instruction is one or more uint8_ts: 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 (v1) / 0xFE 0xFB (v2) / 0xFE 0xFC (v3) / 0xFE 0xFD (v4, current) — validated on load (see below), load fails if it doesn’t match a known signature |
| Bytecode length | int32 | Number of ints in the bytecode stream |
| Bytecode | uint8_t[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 |
| Const pool size | int32 | Number of pooled constants |
| Const pool entries | double[length] (v3) / int32[length] (v2 and earlier) | Shared integer + float constant array — see below |
| Variable count | int32 | Number of variable slots to allocate at VM startup |
This is the entire file — no header versioning beyond the 2-byte signature, no section table. It’s deliberately minimal.
Signature versions and backward compatibility
BinaryProgram::load() (src/programfile.cpp) checks the signature’s second byte to decide how to read the const pool:
0xFE 0xFA(v1) — refused outright;load()prints a message that v1 binaries aren’t compatible with the current runtime and fails.0xFE 0xFB(v2) — const pool entries are read asint32, then widened todoubleon load, so old integer-only bytecode still runs correctly on the current VM.0xFE 0xFC(v3) — const pool entries are read directly asdouble.0xFE 0xFD(v4, “32-bit addressing”) — const pool is read identically to v3 (directly asdouble). The version bump reflects a bytecode change, not a container change: the compiler now emits the 32-bit-offset jump/call opcodes (JUMP32,CALL32,JEQ32…JNE32) instead of their 8-bit predecessors, so program size is no longer capped at 255 bytes for jump targets — see Opcode Reference. This is the format the current compiler writes.
Everything else in the file (bytecode, string pool, variable count) is unchanged across versions — only the const pool’s on-disk element type (v2 vs. v3/v4) and the bytecode’s addressing width (pre-v4 vs. v4) differ.
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.
The const pool
Integer and float literals share a single deduplicated pool (resolveConst() in src/helpers.cpp), keyed on (TypeTag, value) so an int 2 and a float 2.0 get distinct entries even though they’d compare equal as raw numbers. A PUSH instruction carries the pool index plus a type tag (0x02 for int, 0x05 for float — see Opcode Reference) that tells the VM how to reinterpret the stored double when it lands on the stack.
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;
Arithmetic and comparison opcodes read both TAG_INT and TAG_FLOAT operands, promoting to double whenever either side is a float (see Opcode Reference for the exact per-opcode behavior, including DIV’s always-float result).
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.
Execution state
Two structs hold VM state (include/types.h). VMProgramData holds the compiled program itself (read-only during execution):
| Field | Type | Purpose |
|---|---|---|
bytecode | std::vector<uint8_t> | The instruction stream |
stringPool | std::vector<std::string> | Pooled string literals |
constPool | std::vector<double> | Pooled integer + float constants |
variableCount | int | Number of variable slots to allocate |
VMExecutionData holds the mutable state that changes as the program runs:
| Field | Type | Purpose |
|---|---|---|
variables | std::vector<Variant> | Flat variable storage, one slot per variable |
stack | std::vector<Variant> | Operand stack — values being computed with |
pcStack | std::vector<CallFrame> | Return-address stack for routine calls, each frame holding a return PC and the caller’s routineBase |
PC | int | Program counter — index into the bytecode stream |
routineBase | int | Base offset of the currently executing routine (0 for the main program), added to jump targets |
halt | bool | Set by HLT to stop the main loop |
The main loop
int run(VMProgramData* progData) {
VMExecutionData execData;
execData.variables.resize(progData->variableCount);
while (true) {
int result = execute(progData, &execData);
if (execData.halt || result == -1) break;
execData.PC = result;
}
return 0;
}
execute() decodes a single instruction, performs its effect, and returns the next PC value (or -1 on error). Most instructions simply return PC + offset (fall through to the next instruction); jumps, conditional jumps, and calls instead return an arbitrary target address — usually routineBase + <encoded offset> — 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 (JEQ32/JGR32/…) 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 JUMP32 (0x06), an unconditional PC reassignment |
routine / call / endroutine | call compiles to CALL32 (0x07), which pushes a return frame 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 |
See Opcode Reference for the full opcode table, including the legacy 8-bit CALL/JUMP/JEQ-style opcodes that the VM still executes but the compiler no longer emits.
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.
On embedded targets (Pico), the equivalent table is funcTable — a plain array of NativeFn function pointers rather than lambdas. EXEC operands in the 0xD0–0xFF range are reserved on these targets for application-specific native functions (e.g. GPIO calls), mapped onto the tail end of funcTable at a fixed offset from the base opcode; this range is Pico-specific and not part of the desktop VM’s dispatch.
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 02 00 | PUSH 0
0x00000003: 02 00 | POP i
0x00000005: 07 24 00 00 00 | CALL32 0x00000024
0x0000000a: 03 03 00 | PUSH i
...
===== Routine show =====
0x00000024: 03 03 00 | PUSH i
0x00000027: 04 01 | EXEC println
0x00000029: fe | RET
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.CALL32(or legacyCALL) shows 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 (uint8_t) | Push PC + 2 onto the return stack, jump to target offset. Legacy 8-bit form — the current compiler never emits this; see CALL32 below |
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 — see Built-in Functions |
0x05 | JUMP | 2 | target offset (uint8_t) | Unconditional jump. Legacy 8-bit form — the current compiler never emits this; see JUMP32 below |
0x06 | JUMP32 | 5 | target offset (uint32_t, little-endian) | Unconditional jump, 32-bit target. What jump actually compiles to |
0x07 | CALL32 | 5 | target offset (uint32_t, little-endian) | Push PC + 5 onto the return stack, jump to target offset. What call actually compiles to (used for routine/call) |
0xFE | RET | 1 | — | Pop the return stack and jump there (used for endroutine) |
0xFF | HLT | 1 | — | Halt execution |
8-bit vs. 32-bit addressing
Lumen’s bytecode has two parallel families of jump/call/comparison opcodes: an original 8-bit-offset family (CALL/JUMP/JEQ…/JNE, single-byte target) and a newer 32-bit-offset family (CALL32/JUMP32/JEQ32…/JNE32, uint32_t little-endian target). The current compiler only ever emits the 32-bit family — this is what lets a program’s bytecode exceed 255 bytes and still jump anywhere in it. The 8-bit opcodes are still recognized by the VM (execute() in src/vm.cpp) purely for backward compatibility with older, hand-written, or pre-32-bit-addressing bytecode; you won’t see them in output from the current compiler. BinaryProgram’s container signature reflects this — see Bytecode Format for the 0xFE 0xFD (v4, “32-bit addressing”) signature.
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 — third operand is an index into the shared constant pool |
0x03 | Variable — third operand is a variable slot to read from |
0x04 | uint8_t literal — third operand is value itself |
0x05 | Float — third operand is an index into the shared constant pool, reinterpreted as double |
Integer and float constants share the same underlying pool (constPool, a std::vector<double>, deduplicated by (type, value) — see Bytecode Format); tags 0x02 and 0x05 are both indices into it, distinguished only by which tag the PUSH instruction carries.
Arithmetic
All arithmetic opcodes are size 1 (no operands) — they pop two Variant values off the stack, combine them, and push a Variant result. Operand order: the value pushed second (b) is popped first, so a OP b is computed correctly for non-commutative operators.
Arithmetic is type-aware: if either operand is TAG_FLOAT, both operands are read as double (getNumeric()) and the result is TAG_FLOAT. If both operands are TAG_INT, the result is TAG_INT — with one exception below.
| Opcode | Mnemonic | Operation | Result type |
|---|---|---|---|
0xA0 | ADD | a + b | Float if either operand is float, else int |
0xA1 | SUB | a - b | Float if either operand is float, else int |
0xA2 | MUL | a * b | Float if either operand is float, else int |
0xA3 | DIV | a / b | Always float, regardless of operand types |
0xA4 | POW | a ^ b (std::pow) | Float if either operand is float, else int (result truncated to int64) |
0xA5 | MOD | a % b (std::fmod if either is float, else integer %) | Float if either operand is float, else int |
DIV is the one exception to the “int stays int” rule: it always produces a TAG_FLOAT result even when both operands are integers, so 7 / 2 yields 3.5, not 3.
Comparison / conditional jump
Both pop two values, read them as double via getNumeric() (so int and float operands compare correctly against each other), and either fall through (true) or jump to the operand (false). This is exactly how if blocks compile.
As with CALL/JUMP above, there are two parallel families here — an 8-bit legacy one and a 32-bit one the compiler actually generates:
| Opcode | Mnemonic | Size | Operand | Comparison |
|---|---|---|---|---|
0xB0 | JEQ | 2 | offset (uint8_t) | == — legacy 8-bit form, not emitted by the current compiler |
0xB1 | JGR | 2 | offset (uint8_t) | > — legacy |
0xB2 | JLS | 2 | offset (uint8_t) | < — legacy |
0xB3 | JGE | 2 | offset (uint8_t) | >= — legacy |
0xB4 | JLE | 2 | offset (uint8_t) | <= — legacy |
0xB5 | JNE | 2 | offset (uint8_t) | != — legacy |
0xC0 | JEQ32 | 5 | offset (uint32_t, little-endian) | == — what if x == y actually compiles to |
0xC1 | JGR32 | 5 | offset (uint32_t, little-endian) | > |
0xC2 | JLS32 | 5 | offset (uint32_t, little-endian) | < |
0xC3 | JGE32 | 5 | offset (uint32_t, little-endian) | >= |
0xC4 | JLE32 | 5 | offset (uint32_t, little-endian) | <= |
0xC5 | JNE32 | 5 | offset (uint32_t, little-endian) | != |
In both families, the operand is the offset to jump to if the comparison is false (i.e. it’s the “skip the true-branch body” target, not a “jump if true” target).
Variable operations
| Opcode | Mnemonic | Size | Operands | Behavior |
|---|---|---|---|---|
0xA6 | INC | 1 | — | Pop the stack top, increment it, push the result. Equivalent to x + 1 |
0xA7 | DEC | 1 | — | Pop the stack top, decrement it, push the result. Equivalent to x - 1 |
0xA8 | INCV | 2 | variable slot | Increment the variable at the given slot in-place |
0xA9 | DECV | 2 | variable slot | Decrement the variable at the given slot in-place |
0xAB | CPY | 2 | variable slot | Copy the variable at the given slot onto the stack — equivalent to reading a variable but used internally for optimizing certain patterns |
Pointer operations
| Opcode | Mnemonic | Size | Behavior |
|---|---|---|---|
0xDE | DEREF | 1 | Pop a reference (pointer) from the stack and dereference it — replace it with the value it points to |
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
- Arithmetic and comparison opcodes are shared between
TAG_INTandTAG_FLOAToperands — there’s no separate float-only opcode set. Type promotion happens per-operation, as described above (see Bytecode Format for theVarianttype). - The 8-bit (
CALL,JUMP,JEQ…JNE) and 32-bit (CALL32,JUMP32,JEQ32…JNE32) opcode families are functionally identical apart from operand width — the compiler always chooses 32-bit so program size isn’t limited to 255 bytes.--disassembleoutput from the current toolchain will only ever show the...32mnemonics; the 8-bit forms exist in the VM for backward compatibility with older bytecode. - 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 |
str2int | 0x05 | str2int <value> &out | Pop the referenced variable slot (destination), then the value (TAG_STRING expected; anything else is treated as "0"), parse as int32, store into the destination as TAG_INT. On parse failure or out-of-range, stores 0 instead |
int2str | 0x06 | int2str <value> &out | Pop the referenced variable slot (destination), then the value (only TAG_INT is read; other types fall back to 0), format as a string, store into the destination as TAG_STRING |
str2float | 0x07 | str2float <value> &out | Pop the referenced variable slot (destination), then the value (TAG_STRING expected; anything else is treated as "0"), parse as double, store into the destination as TAG_FLOAT. On parse failure or out-of-range, stores 0.0 instead |
float2str | 0x08 | float2str <value> &out | Pop the referenced variable slot (destination), then the value — accepts both TAG_FLOAT and TAG_INT (integers are widened to double), format as a string via std::to_string, store into the destination as TAG_STRING |
strlen | — | strlen s, &out | Length of s, stored into out as TAG_INT |
substr | — | substr s, start, len, &out | Extract a substring of s starting at start with length len, stored into out as TAG_STRING |
strfind | — | strfind s, needle, &out | Index of the first occurrence of needle in s, or -1 if not found, stored into out |
strcase | — | strcase s, upper, &out | Convert case of s: upper = 1 for uppercase, 0 for lowercase, stored into out |
trim | — | trim s, &out | Strip leading and trailing whitespace from s, stored into out |
assertCapability | — | assertCapability name | Check whether capability name ('FS', 'random', 'HTTP') is implemented by this VM build; raises a runtime error if not. Optional — gated functions work whether or not it’s called first |
openFile | — | openFile path, &handle | Open a file at path, storing a handle in handle. Gated by the FS capability |
writeFile | — | writeFile data, handle | Write string data to the file at handle. Gated by the FS capability |
readFile | — | readFile &out, handle | Read the full contents of the file at handle into out. Gated by the FS capability |
closeFile | — | closeFile handle | Close the file at handle. Gated by the FS capability |
randomSeed | — | randomSeed seed | Seed the random number generator. Gated by the random capability |
random | — | random &out | Generate a random float in [0.0, 1.0) into out. Gated by the random capability |
randomRange | — | randomRange min, max, &out | Generate a random integer in [min, max] into out. Gated by the random capability |
httpRequest | — | httpRequest method, url, headers, body, &status, &response | Perform an HTTP request (GET/POST/PUT/DELETE); status receives the HTTP status code (-1 on connection failure), response receives the body. HTTPS is not supported. Gated by the HTTP capability |
The eight core functions above still occupy indices 0x01–0x08. Gated standard-library functions check capability support on the same EXEC dispatch mechanism; see Capabilities.
Note the argument order: for the two-argument conversion functions, tokens are pushed onto the stack in the order they’re written, so the last-written token is popped first. Since the destination &out is written last, it’s popped first as the destination slot, then the value is popped second — hence str2int <value> &out, not str2int &out <value>.
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–0x08are taken by the eight built-ins above.
That’s it; no changes to execute() in src/vm.cpp are needed, since EXEC already dispatches generically through the table.
On embedded targets (Pico), indices 0xD0–0xFF are reserved for custom, application-specific native functions — GPIO calls and similar host-specific behavior mapped onto the tail end of a separate funcTable array. That reserved range and offset mapping is specific to the Pico build; the desktop VM’s funcMap in src/vmfuncmap.cpp dispatches directly on whatever index EXEC carries, with no offset math, so new desktop built-ins can use any unused index without needing to stay clear of 0xD0–0xFF.
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 |