Lumen
Lumen is a small, embeddable scripting language. Source files (.lmn) are compiled to compact bytecode (.bin) that a tiny stack-based virtual machine executes. The same VM runs on the desktop, in the browser (WebAssembly), on microcontrollers and inside host applications.
println('Hello, world!')
print('What`s your name? ')
name = inputStr()
greeting = 'Hello, ' .. name .. '!'
println(greeting)
What the language looks like
- Line-oriented. One statement per line, no semicolons, block statements closed with
endif,endwhile,endrepeat,endroutine,endfunction. - Dynamically typed. Values are integers, floats or strings, and a variable can change type on every assignment. No declarations.
- Function-call syntax. Built-ins, routines and functions are called with parentheses:
println('x'),n = str2int('42'). - Global state. All variables are global; routines and functions share them.
- Arrays. Named, growable, untyped arrays:
scores[0] = 10. - Modular.
import 'file.lmn'pulls another script into the program. - Garbage-collected memory. Variable and array storage lives in a managed heap with a mark-and-sweep collector.
How this book is organised
- Getting Started covers building the interpreter, writing a first program and the command line.
- Language Guide documents every statement and operator.
- Examples are complete programs you can generate with
lumen --examples <name>. - Architecture, Debugging & Tooling and Reference describe the compiler, the bytecode, the VM, the debugger and every opcode and built-in function.
- Lumen in apps and Platforms & Ports describe projects that embed or port the VM. They follow their own release schedule and may lag behind the language described in the Language Guide.
This book describes Lumen v4 (lumen --version).
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 Program
Let the interpreter write a starter script for you:
lumen --introduction
This creates helloworld.lmn in the current directory:
println('Hello, world!')
print('What`s your name? ')
name = inputStr()
greeting = 'Hello, ' .. name .. '!'
println(greeting)
Run it:
lumen helloworld.lmn
Hello, world!
What`s your name? Ryan
Hello, Ryan!
What happened
lumen helloworld.lmncompiled the script tohelloworld.lmn.binand immediately ran it. See CLI Reference to do the two steps separately.println('...')andprint('...')are built-in functions.printlnappends a newline,printdoes not.inputStr()reads one whitespace-delimited word from standard input and returns it, so it is used on the right-hand side of an assignment...joins strings. Numbers are converted automatically.
The backtick in What`s is only a habit: \' also works inside a single-quoted string. See Strings.
Next steps
- Generate the bundled programs with
lumen --examples <name>(see Examples). - Learn the language topic by topic.
- Step through the program with
lumen helloworld.lmn --debugger, see Interactive Debugger.
CLI Reference
lumen (file) [options]
The file comes first, options after it. With no options the file is compiled and then run.
| Option | Meaning |
|---|---|
--compile | Compile the source file to <file>.bin. |
--run | Run a compiled program. Used alone, file must be a .bin; combined with --compile, the freshly written <file>.bin is run. |
--disassemble | Print the disassembly of a .bin. Cannot be combined with --compile or --run. |
--debugger | Run under the interactive debugger. Cannot be combined with --compile alone. |
--no-debug | Do not embed debug symbols in the compiled .bin (smaller file, no names in the disassembler or debugger). |
--verbose | Print prescan tokens, the emitted bytecode and execution progress. |
Standalone commands (no file needed):
| Command | Meaning |
|---|---|
--help | Print usage. |
--version | Print the language version, git branch, commit and build date. |
--introduction | Write a starter script helloworld.lmn to the current directory. |
--examples | List the bundled examples. |
--examples <name> | Write the named example to disk (age.lmn, infinite.lmn, temperature.lmn, fizzbuzz.lmn). |
Typical workflows
lumen script.lmn # compile + run
lumen script.lmn --compile # writes script.lmn.bin
lumen script.lmn.bin --run # run the compiled program
lumen script.lmn.bin --disassemble # inspect the bytecode
lumen script.lmn --debugger # compile, then debug
lumen script.lmn --compile --no-debug # release build without symbols
Errors
Compile errors name the file and line and stop the build:
In file script.lmn
- Error on line 3
>>> argument count mismatch for 'println': expected 1, got 0
Compilation failed!
Runtime errors print Runtime error (or Function error when a built-in fails) followed by a message, and the program stops. The process exit code is still 0.
Comments
A # starts a comment that runs to the end of the line. It can follow code, and a # inside a string literal is just text.
# a full-line comment
x = 5 # a trailing comment
println('a # not a comment')
There are no block comments. Indentation and blank lines are ignored, so indent bodies however you like.
Variables & Values
Assignment
count = 10
price = 2.5
name = 'Lumen'
There are no declarations: the first assignment creates the variable. Names start with a letter or _ and continue with letters, digits or _. Keywords cannot be used as names. Names starting with @ are reserved for the compiler.
Types
| Type | Literal | Notes |
|---|---|---|
| Integer | 42, -7 | 64-bit signed. |
| Float | 3.14 | Double precision. |
| String | 'text' or "text" | See Strings. |
A variable’s type is whatever it was last assigned; it can change freely:
x = 5
x = 'five'
Reading a variable that was never assigned gives the integer 0.
Float literals
A number containing a . is a float. 3.0 is a float; 3 is an integer. Arithmetic between an integer and a float produces a float. See Operators.
Everything is global
All variables live in one global scope. Routine and function parameters and any variable assigned inside a routine are ordinary globals too, see Routines & Functions.
Limits
Variables, arrays, string literals and numeric constants are each addressed by a single byte in the bytecode, so one program can use at most 256 of each. Exceeding this is not detected by the compiler. Each repeat nesting level also uses one hidden counter variable, named @cnt_<n>_<depth>.
Printing values
println prints integers as they are and floats with up to six significant digits (3.5, 0.333333, 3 for 3.0). Joining a float into a string with .. uses six decimals (2.500000).
Operators
Arithmetic
| Operator | Meaning | Notes |
|---|---|---|
+ - * | add, subtract, multiply | Integer result when both operands are integers, float otherwise. |
/ | divide | Always a float division: 7 / 2 is 3.5. |
% | remainder | Integer remainder for integers, fmod for floats. Integer % 0 is a runtime error. |
^ | power | 2 ^ 10 is 1024; 2 ^ 0.5 is a float. Right associative. |
-x | negation | Binds tighter than ^: -2 ^ 2 is 4. |
( ) | grouping |
Precedence, high to low: unary -, ^, * / %, + -, ...
println(2 ^ 3 ^ 2) # 512
println(10 - 2 - 3) # 5
println(7 % 3) # 1
Arithmetic on a string literal is rejected at compile time (type error: arithmetic operator '+' cannot be applied to a string literal). Arithmetic on a string variable is caught at run time: Runtime error, then type error: arithmetic on a string value, and the program stops.
String join
.. joins two values into a string; numbers are converted. It has the lowest precedence, so 'n=' .. 1 + 2 gives n=3.
Comparison
==, !=, <, >, <=, >= are used in if, elif and while. Numbers compare numerically (5 == 5.0 is true) and two strings compare lexicographically. A condition holds exactly one comparison; there is no and, or or not, so nest if blocks instead.
Reference operators
&x and *r are covered in References & Dereferencing.
Strings
Strings are written with single or double quotes:
a = 'hello'
b = "world"
println(a .. ' ' .. b)
Escape sequences
| Sequence | Result |
|---|---|
\n | newline |
\t | tab |
\r | carriage return |
\\ | backslash |
\' | single quote |
\" | double quote |
Any other backslash sequence is kept as written. Because \t and \n are escapes, write Windows paths with forward slashes or doubled backslashes ('C:\\temp').
println('it\'s')
println("say \"hi\"\nbye")
Joining
.. joins values. Integers are printed as-is and floats with six decimals:
println('total: ' .. 2.5) # total: 2.500000
String manipulation
The standard library has strlen, substr, strfind, strcase, trim and the conversions str2int, int2str, str2float, float2str. See Standard Library.
Comparing
==, < and friends compare two strings character by character. Comparing a string to a number treats the string as 0.
References & Dereferencing
A reference is the address (slot number) of a variable.
| Syntax | Meaning |
|---|---|
&x | the reference to variable x |
*r | the current value of the variable that r refers to |
x = 10
r = &x
println(*r) # 10
x = 20
println(*r) # 20
println(*r + 1) # 21
A reference is stored as a plain integer, so println(r) prints a slot number. *r can be used anywhere an expression is allowed (assignments, arguments, conditions):
if *r == 20
println('twenty')
endif
Rules
- Assigning through a reference (
*r = 5) is not supported and is a compile error. - Dereferencing a reference to a variable that was never assigned stops the program.
- References are how built-ins return extra values.
httpRequestwrites the response body into the variable you pass with&, see Standard Library.
Input & Output
Output
print('no newline')
println('with newline')
println(42)
println('x = ' .. x)
print and println take exactly one argument, any expression. Floats print with up to six significant digits.
Input
Input functions return the value read:
age = inputInt()
name = inputStr()
inputInt()reads a whitespace-delimited token and converts it to an integer. If it is not a valid integer it printsInvalid value!and returns0.inputStr()reads one whitespace-delimited word. It cannot read a line containing spaces.
Both read from standard input, so the prompt has to be printed first:
print('Enter your birth year: ')
year = inputInt()
Conditionals
if score >= 90
println('A')
elif score >= 80
println('B')
else
println('C or below')
endif
elif and else are optional and elif can repeat. Every if ends with endif.
Conditions
A condition is one comparison between two expressions: ==, !=, <, >, <=, >=. Both sides can be full expressions, including function calls, array reads and dereferences:
if strlen(name) > 3
println('long name')
endif
if scores[0] == *r
println('match')
endif
Numbers compare numerically and two strings compare lexicographically. A bare value (if x) and boolean operators (and, or, not) do not exist.
Nesting
Blocks nest to any depth. Use nested ifs in place of and:
if age >= 18
if hasTicket == 1
println('welcome')
endif
endif
Loops
while
i = 0
while i < 3
println(i)
i = i + 1
endwhile
The condition is checked before every iteration and follows the rules in Conditionals.
repeat
repeat 5
println('hi')
endrepeat
repeat 3, i
println(i) # 0, 1, 2
endrepeat
repeat <count> runs its body count times. The count must be a single number or variable (an expression such as n * 2 is a compile error); a variable is re-read on every iteration. With repeat <count>, <name> the variable name receives the iteration number, starting at 0. After the loop it equals the count.
Loops can nest. Each repeat level keeps its own hidden counter.
break and continue
break leaves the innermost loop, continue jumps to its next iteration. Both are compile errors outside a loop.
i = 0
while i < 10
i = i + 1
if i == 3
continue
endif
if i == 6
break
endif
println(i) # 1 2 4 5
endwhile
Labels & Jumps
label <name> marks a position and jump <name> transfers control to it unconditionally. The pair below is an infinite loop, see the Labels & Jumps Demo.
label loop
println('Hello, world!')
jump loop
Jumps can go forward or backward:
i = 0
label top
i = i + 1
if i < 3
jump top
endif
println(i) # 3
Scope
Labels in the main program are visible to the whole main program (including code brought in by import). Labels inside a routine or function are local to it. Jumping to a label in another scope is a compile error (Label 'x' is not defined in global scope).
Stopping the program
halt stops execution immediately:
println('done')
halt
println('never printed')
The program also ends when the last statement has run.
Arrays
An array is a named, growable list of values. There is no declaration: writing to name[index] creates the array.
scores[0] = 10
scores[1] = 20
scores[2] = scores[0] + scores[1]
println(scores[2]) # 30
Indices and growth
- The index is any integer expression:
scores[i + 1],scores[scores[0] - 9]. - Indices start at 0.
- Writing past the end grows the array; cells in between are filled with the integer
0. - Elements are untyped: an array can hold integers, floats and strings together.
data[0] = 1.5
data[1] = 'two'
data[3] = 4 # data[2] becomes 0
Reading
Reading a missing array, an index past the end, or a negative index is a runtime error: Runtime error, then ARRREAD: index out of range (or array does not exist), and the program stops. Writing to a negative index fails the same way (ARRWRITE: negative index).
Notes
- Arrays and variables have separate namespaces:
a = 7anda[0] = 1can coexist. - Arrays are global and cannot be passed to or returned from routines, or assigned as a whole. Loop over the index instead.
- There is no built-in length. Keep the count in a variable.
- Array storage is managed by the garbage collector.
- A program can use at most 256 distinct array names.
Debugging
The debugger’s variables command lists every array with its name, length and contents, and the disassembler shows array names on ARRWRITE / ARRREAD.
Routines & Functions
Both are reusable blocks of code with parameters. A routine performs actions; a function also hands back a value with return.
routine greet(name, times)
repeat times
println('Hi ' .. name)
endrepeat
endroutine
function add(a, b)
return a + b
endfunction
greet('Ann', 2)
total = add(2, 3)
println(add(add(1, 2), 4)) # 7
Defining
routine name(p1, p2)…endroutinefunction name(p1, p2)…return <expression>…endfunction- The parentheses may be empty or omitted for no parameters.
- Definitions can appear anywhere at the top level of a file, before or after the code that calls them. They cannot be nested.
Calling
- A routine is a statement:
greet('Ann', 2). - A function is used inside an expression:
x = add(1, 2),println(add(1, 2)),'sum: ' .. add(1, 1). - The argument count must match the definition (
argument count mismatch for 'greet': expected 2, got 1). - Using a routine as a value is a compile error (
'r' does not return a value).
Every path through a function should end in a return <value>. return is only valid inside a function; a routine simply runs until endroutine.
Parameters and globals
On entry the arguments are stored into global variables named after the parameters. There is no local scope: a parameter overwrites any global variable with the same name, and every variable assigned in a routine is visible everywhere.
Recursion works for values that were pushed before the recursive call, so this factorial is correct:
function fact(n)
if n <= 1
return 1
endif
return n * fact(n - 1)
endfunction
println(fact(5)) # 120
but any variable read after a recursive call sees the value the callee left behind.
Labels
Labels defined inside a routine are local to it, see Labels & Jumps.
Errors
unknown function or routine: name is reported for calls to something that is neither defined nor built in. Defining the same name twice (also across imported files) is an error.
Imports
import 'lib.lmn'
import compiles another script into the program at that point. The imported file’s top-level statements run when execution reaches the import; its routines and functions become available to the whole program (before or after the import line); its variables, arrays and labels live in the same global scope.
# lib.lmn
function twice(x)
return x * 2
endfunction
println('lib loaded')
import 'lib.lmn'
println(twice(4))
lib loaded
8
Rules
- The path is resolved relative to the directory you run
lumenfrom, not relative to the importing file. - The path is a string literal without parentheses.
- Imports can nest up to five levels deep (
Reached maximum import depth of 5). - A file cannot import itself, a missing file is a compile error (
Cannot open imported file '...'), and defining the same routine or function name in two files is a compile error. - Everything is compiled into one
.bin; nothing is looked up at run time. - Error messages name the file the error is in.
Capabilities
A capability is a named feature the host provides. A script can require one:
assertCapability('HTTP')
If the host does not provide it, the program stops with Function error and assertCapability failed: capability HTTP is not present. The desktop interpreter provides FS, random and HTTP. Embedded hosts and ports provide their own set, see Platforms & Ports.
Put assertions at the top of a script so it fails fast on a host that cannot run it.
Standard Library
Built-in functions are called like any function. Those that produce a value are used inside expressions; the others are statements. The complete table with argument lists is in the Built-in Functions reference.
Console
print(value), println(value), inputInt(), inputStr(), see Input & Output.
Conversion
n = str2int('42') # 42
f = str2float('3.5') # 3.5
s = int2str(42) # '42'
t = float2str(3.5) # '3.500000'
str2intstops the program withFunction errorif the text is not an integer (str2int('abc')).str2floatreturns0for text that is not a number.int2strneeds an integer; a float argument is aFunction error.float2straccepts an integer or a float.
Strings
strlen('hello') # 5
substr('hello world', 6, 5) # 'world' (text, start, length)
strfind('hello world', 'world') # 6, or -1 when absent
strcase('Hello', 1) # 'HELLO' (non-zero: upper, 0: lower)
trim(' hi ') # 'hi'
substr returns an empty string when start is past the end.
Random numbers
randomSeed(42)
r = random() # float in [0, 1)
dice = randomRange(1, 6) # integer from 1 to 6, inclusive
Files
assertCapability('FS')
h = openFile('log.txt')
writeFile('line one\n', h)
writeFile('line two\n', h)
println(readFile(h))
closeFile(h)
openFile(path)opens the file for reading and writing, creating it if it does not exist. Existing contents are kept. It returns a handle.writeFile(text, handle)appendstextto the end of the file and flushes it.readFile(handle)returns the whole file, from the start.closeFile(handle)closes the handle. Always close files you open.
HTTP
assertCapability('HTTP')
body = ''
status = httpRequest('GET', 'http://example.com/', '', '', &body)
println(status)
println(body)
httpRequest(method, url, headers, body, &response):
methodisGET,POST,PUTorDELETE.headersis a string ofName: valuelines separated by\n(empty for none). AContent-Typeheader sets the type of the request body.bodyis the request body (empty for GET).&responseis a reference to the variable that receives the response body.- It returns the HTTP status code, or
-1if the request could not be made, in which case the response variable holds an error message.
Capabilities
assertCapability(name), see Capabilities.
Errors
If a built-in cannot do its job (wrong argument type, unknown handle…), the program prints Function error followed by a message and stops.
Examples
Every bundled example can be written to disk:
lumen --examples <name>
| Name | File written | Topic |
|---|---|---|
age | age.lmn | variables, input, arithmetic |
fizzbuzz | fizzbuzz.lmn | repeat, if/elif/else, % |
temperature | temperature.lmn | functions, routines, branching |
infinite-loop | infinite.lmn | labels and jumps |
Each example is broken down in its own page, in increasing order of the language features it touches. The example pages also carry a QR next to the source that can be scanned into Lumen on Android.
Age Calculator
lumen --examples age
yearNow = 2026
userYear = 0
println('Hello, world!')
print('Enter your birth year: ')
userYear = inputInt()
age = yearNow - userYear
print('Your age: ')
println(age)
A minimal but complete program: two variables, an integer read with inputInt(), one subtraction, and 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
lumen --examples fizzbuzz
print('N=')
x = inputInt()
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
repeat x, n runs the body x times and stores the iteration number (0, 1, 2, …) in n; the body then rewrites n as n + 1 so the printed numbers start at 1. The if / elif / else chain tests divisibility with %.
Touches: loops, conditionals, operators.
Scan into Android
Temperature Converter
lumen --examples temperature
function c2f(temp)
return temp * 9 / 5 + 32
endfunction
function f2c(temp)
result = temp - 32
return result * 5 / 9
endfunction
routine ask()
print('Temparature: ')
endroutine
routine show(result)
print('Result: ')
println(result)
endroutine
println('1. C to F')
println('2. F to C')
print('Select mode ')
mode = inputInt()
if mode == 1
ask()
temp = inputInt()
result = c2f(temp)
show(result)
elif mode == 2
ask()
temp = inputInt()
result = f2c(temp)
show(result)
else
println('Incorrect mode')
endif
Two functions compute a value and return it; two routines print. Definitions come first, but they could equally follow the main code. Parameters are global variables, so temp in the main program and the temp parameter are the same variable.
Touches: routines & functions, conditionals.
Scan into Android
Labels & Jumps Demo
lumen --examples infinite-loop
This writes infinite.lmn:
label loop
println('Hello, world!')
jump loop
label loop marks a position; jump loop goes back to it, forever. Stop it with Ctrl+C. See Labels & Jumps for conditional loops built from the same two statements.
Scan into Android
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
source (.lmn)
│
▼
read all lines ──► prescan routines/functions (names, arg counts, indices)
│
▼
per line: protect string literals ──► tokenize ──► statement dispatch
│ │
│ expressions: shunting-yard ──► RPN ──► bytecode
▼
finalize: HLT, resolve jumps, append routine bodies, patch calls, write debug data
│
▼
Program (bytecode + string pool + constant pool + debug data) ──► .bin
The code lives in src/compiler/ (with src/tokenizer.cpp and src/helpers.cpp):
| File | Role |
|---|---|
compiler.cpp | Entry points (compileFromFile, compileFromText, compileFromStream); reads lines, runs the prescan, drives the line loop and finalization. |
compiler_statement.cpp | compileLine: one state machine for every statement (assignment, if, loops, routine, function, return, import, calls, label/jump, halt). |
exprparse.cpp | Expression compiler: tokenizer, shunting-yard, RPN evaluation to bytecode (operators, calls, array reads, &/*). |
compiler_helpers.cpp | Routine prescan, error printing, pushToStack. |
compiler_tables.cpp | Operator, keyword and built-in function tables (funcList). |
compiler_finalize.cpp | Jump/call resolution, routine layout, debug-symbol emission. |
Stages
- Prescan. Every
routine/functionheader is found first, so a call can appear before the definition. Each gets an index, an argument count and a “returns a value” flag. Names are unique across imported files. - String protection. String literals are replaced by placeholders so
#,.., quotes and operators inside them never reach the tokenizer; escapes are decoded when the literal is turned into a pool entry. - Tokenizing.
&xand*rbecome single tokens when they sit in an operand position; the same characters are binary operators (*) elsewhere. - Statements.
compileLinekeeps per-file state (CompileState): open blocks, labels, unresolved jumps and calls, the routine being compiled. Loops use hidden counter variables named@cnt_<context>_<depth>. - Expressions. Conditions and right-hand sides are compiled to postfix code that leaves one value on the stack. Function calls and array reads are compiled in place.
- Imports.
importrecursively compiles the other file with the same state and data, so labels, routines, pools and variable slots are shared and the code lands in the main stream. - Finalization. Appends
HLT, patches jumps to labels, lays out routine bodies after the main code, patchesCALL32targets and, unless--no-debug, builds the debug symbols.
Diagnostics
Errors are printed as In file <file> / Error on line N / >>> message. A failed import adds Script compilation has failed for the importing file.
Bytecode Format
The .bin container
All integers are little-endian.
| Field | Size | Notes |
|---|---|---|
| Signature | 2 bytes | FE FF for the current format. |
| Bytecode size | int32 | |
| Bytecode | n bytes | |
| String pool size | int32 | number of strings |
| String entries | repeated | int32 length + bytes |
| Constant pool size | int32 | number of constants |
| Constants | repeated | 8-byte doubles |
| Variable count | int32 | |
| Debug data size | int32 | 0 with --no-debug |
| Debug data | n bytes | text, see Debug Symbols |
Signatures the loader understands: FE FB (v2), FE FC (v2.1, double constants), FE FD (v2.2, 32-bit addressing), FE FE (v3) and FE FF (v4, current). FE FA (v1) is rejected. Older files load without debug data, because the trailing debug section is optional.
Values on the stack (Variant)
The VM stack holds tagged values: integer (int64), float (double) or string. An internal array tag exists for the memory manager but arrays are not first-class values.
Instruction encoding
Instructions are a one-byte opcode followed by byte operands:
- Variable, string, constant and array operands are one byte (an index into its pool), which is why a program can use at most 256 of each.
- Jump and call targets are four bytes (
uint32). Legacy 8-bit jump forms remain in the opcode table. - Jump targets inside a routine are relative to the start of that routine; the VM adds the routine’s base address.
PUSH type tags
PUSH is three bytes: opcode, a tag, and an operand.
| Tag | Operand |
|---|---|
0x01 | index into the string pool |
0x02 | index into the integer constant pool |
0x03 | variable slot to read |
0x04 | an immediate integer (used for references &x and the operand count of JOIN) |
0x05 | index into the float constant pool |
The const pool
Numbers that appear in source are stored once, in the constant pool, and pushed by index. Floats are stored as doubles.
Program layout
main program ... HLT
routine 0 body ... RET
routine 1 body ... RET
Calls to routines are CALL32 <absolute address>. Imported code is part of the main program.
Limits
256 variables, 256 arrays, 256 string literals and 256 numeric constants per program (one-byte operands, not checked by the compiler).
The Virtual Machine
The VM is a stack machine. run() repeats: fetch the opcode at PC, execute it, move to the next instruction, until HLT, an error or the end of the program.
State
| Piece | Purpose |
|---|---|
bytecode, string pool, constant pool | The loaded program (read-only). |
| operand stack | Variant values; expressions and call arguments live here. |
| call stack | Return address and routine base for each active CALL32. |
PC | Address of the next instruction. |
| routine base | Address the current routine starts at; added to relative jump targets. |
| memory | Variable and array storage, see Memory & Garbage Collection. |
halt flag | Set by HLT and by errors. |
Execution model
- Expressions are postfix: operands are pushed, an operator pops two and pushes one.
- Assignment compiles to an expression followed by
POP <var>. - Native calls:
EXEC <index>pops the function’s arguments (last argument on top), runs the native function and pushes the result when it returns one. - Routine calls:
CALL32 <addr>pushes a frame and jumps; the body starts withPOPinstructions that store the arguments into the parameter variables;RETreturns. A function’sreturnpushes its value beforeRET. - Comparisons and branches are fused compare-and-jump instructions (
JEQ32…), which pop two values. - Loops are built from those jumps;
repeatuses hidden counters andCPYto publish the iterator.
Errors
- A native function that throws is caught by
EXEC: the VM printsFunction errorand the message, then halts. - Any other exception during an instruction (array index errors, arithmetic on a string, modulo by zero) is caught by
execute: the VM printsRuntime errorand the message, then halts. - Some malformed programs (for example popping an empty stack) are not checked; the compiler never generates them.
Garbage collection
run() checks memory pressure every 1000 instructions and collects when needed. The debugger steps instructions without running the collector.
Memory & Garbage Collection
Variables, array elements and string values live in a managed heap instead of a fixed array of values.
Layers
| Layer | File | Role |
|---|---|---|
MemAllocator | src/vm/memoryallocator.cpp | Paged allocator (4 KiB pages; bigger blocks get their own page). Hands out handles, not raw pointers, so blocks can move. |
AddrTranslator | src/vm/translator.cpp | Maps a variable slot number to a slot (handle, size, type tag) and an array number to an array block. Reads and writes go through it. |
| VM | src/vm/vm.cpp | Uses the translator for PUSH/POP/CPY/INCV/ARRREAD/ARRWRITE. |
Slots are created on the first write to a variable. Reading a variable that was never written yields integer 0. Arrays are created by the first ARRWRITE and grow on demand; an array block starts with a header holding its length followed by its elements, and string elements own their own heap blocks.
Collector
A mark-and-sweep collector:
- Mark every handle reachable from variable slots and from array blocks (including string elements).
- Sweep (
collect) frees unmarked blocks and returns empty pages. - Defragment compacts live objects and rewrites the handle table.
Values on the operand stack are copied Variants and are not roots.
Trigger. Every 1000 instructions run() compares allocated bytes with a threshold. It starts at 1 MiB; after each collection it becomes twice the live size, never below 1 MiB.
Observing memory
The debugger has memory for allocator statistics and variables for variables and arrays.
Debug Symbols
Debug symbols map the numbers in bytecode back to names. They are embedded in the .bin (no separate file) and are on by default; --no-debug leaves them out.
Format
A text block at the end of the .bin, in four sections:
variables
<name> <slot index>
routines
<name>
<start offset>
<length>
exec
<name> <function index>
arrays
<name> <array index>
- variables: variable slot for each name (including hidden
@cnt_...loop counters). - routines: name, start address and length of each routine and function.
- exec: every built-in function and its
EXECindex, e.g.println 1. - arrays: array number for each array name.
Older binaries without the arrays section still load.
What uses them
- The disassembler prints
PUSH x,EXEC println,ARRWRITE scoresand===== Routine add =====headers. - The debugger prints variable and array names in
variables.
Without symbols the same output shows numbers: [0] 1, EXEC fn[1], ARRWRITE arr[0].
Interactive Debugger
lumen program.lmn --debugger
The program is compiled and then loaded under a command prompt. Execution does not start until you type run. Program input (inputInt(), inputStr()) is read from the same terminal as debugger commands. Compile without --no-debug to see variable and array names.
Commands
| Command | Meaning |
|---|---|
run | Start executing until a breakpoint or the end. |
stop | Stop execution. |
breakpoint set <addr> | Add a breakpoint at a hexadecimal address (as shown by the disassembler). |
breakpoint remove <addr> | Remove one breakpoint. |
breakpoint list | List breakpoints. |
breakpoint clear | Remove all breakpoints (asks to confirm). |
pc / pc <addr> | Show or set the program counter. |
step | Execute one instruction. |
continue | Resume after a breakpoint. |
stack | Show the operand stack, top first. |
variables | Show all variables, then all arrays with their length and contents. |
disassemble | Show the full disassembly. |
profiling | Toggle instruction-level profiling. |
memory | Show memory-allocator statistics. |
help | List the commands. |
quit / exit | Leave the debugger (asks to confirm if the program is still running). |
stack, variables and step need a running program: they answer Execution has not started before run or after the program finished. Stop at a breakpoint first.
Example
For this program:
scores[0] = 10
scores[1] = 20
names[0] = 'ann'
i = 1
x = scores[i] + 1
println(x)
>> breakpoint set 28
Breakpoint set at 28
>> run
Executing...
Breakpoint hit!
>> variables
Variables:
[i] 1
[x] 21
Arrays:
[scores] length 2: [10, 20]
[names] length 1: ['ann']
>> continue
21
Execution finished
Arrays are listed by name with every element (strings are quoted). Without debug symbols the names are replaced by numbers.
Notes
- Breakpoint addresses are instruction addresses; a breakpoint on an address in the middle of an instruction is never hit.
- The debugger executes instructions one at a time and does not run the garbage collector.
Disassembler
lumen program.lmn.bin --disassemble
Prints the address, raw bytes and mnemonic of every instruction. With debug symbols operands are shown by name.
===== Main =====
0x00000000: 03 02 00 | PUSH 2
0x00000003: 03 02 01 | PUSH 3
0x00000006: 07 0e 00 00 00 | CALL32 0x0000000e
0x0000000b: 04 01 | EXEC println
0x0000000d: ff | HLT
===== Routine add =====
0x0000000e: 02 00 | POP b
0x00000010: 02 01 | POP a
0x00000012: 03 03 01 | PUSH a
0x00000015: 03 03 00 | PUSH b
0x00000018: a0 | ADD
0x00000019: fe | RET
0x0000001a: fe | RET
- Routine bodies are appended after the main program. Each starts with a
===== Routine <name> =====header when symbols are present. CALL32and the jumps show their target address.PUSHshows the value, string, constant or variable it pushes.EXECshows the built-in’s name;ARRWRITE/ARRREADshow the array’s name.- The same output is available inside the debugger with
disassemble.
Opcodes are listed in the Opcode Reference.
Opcode Reference
Sizes are in bytes, opcode included. S is the operand stack, top on the right.
Stack and variables
| Opcode | Mnemonic | Size | Effect |
|---|---|---|---|
0x02 | POP v | 2 | Pop a value into variable slot v. |
0x03 | PUSH tag x | 3 | Push a value, see PUSH type tags. |
0xA6 | INC | 1 | Add 1 to the top of the stack. |
0xA7 | DEC | 1 | Subtract 1 from the top of the stack. |
0xA8 | INCV v | 2 | Increment variable slot v. |
0xA9 | DECV v | 2 | Decrement variable slot v. |
0xAB | CPY v | 2 | Copy the top of the stack into variable slot v without popping it (used for the repeat iterator). |
0xDE | DEREF | 1 | Pop a slot number and push that variable’s value (*r). Stops the VM if the slot does not exist. |
PUSH type tags
| Tag | Operand |
|---|---|
0x01 | string pool index |
0x02 | integer constant pool index |
0x03 | variable slot to read |
0x04 | immediate integer (references, JOIN count) |
0x05 | float constant pool index |
Arithmetic
All pop two values and push one. Integer operands give an integer; a float operand gives a float. A string operand is a runtime error.
| Opcode | Mnemonic | Effect |
|---|---|---|
0xA0 | ADD | a + b |
0xA1 | SUB | a - b |
0xA2 | MUL | a * b |
0xA3 | DIV | a / b (always floating point) |
0xA4 | POW | a ^ b |
0xA5 | MOD | a % b (integer % 0 is a runtime error) |
0xAA | JOIN | Pop a count n, then join the next n values into one string (..). |
Arrays
| Opcode | Mnemonic | Size | Effect |
|---|---|---|---|
0xDA | ARRWRITE a | 2 | Stack [value, index]: store value in array a, creating or growing it. |
0xDB | ARRREAD a | 2 | Stack [index]: push element index of array a. |
Errors: negative write index, missing array, index out of range.
Control flow
8-bit vs 32-bit addressing
The current compiler emits only the 32-bit forms (opcode + 4-byte address, 5 bytes). The 8-bit forms (2 bytes) are kept so older programs still run.
| 8-bit | 32-bit | Mnemonic | Effect |
|---|---|---|---|
0x05 | 0x06 | JUMP | Unconditional jump. |
0x01 | 0x07 | CALL | Push a frame and jump to a routine. |
0xB0 | 0xC0 | JEQ | Pop b, a; jump if a == b. |
0xB1 | 0xC1 | JGR | jump if a > b |
0xB2 | 0xC2 | JLS | jump if a < b |
0xB3 | 0xC3 | JGE | jump if a >= b |
0xB4 | 0xC4 | JLE | jump if a <= b |
0xB5 | 0xC5 | JNE | jump if a != b |
Two strings compare lexicographically, other values numerically. Jump targets inside a routine are relative to the routine’s start.
| Opcode | Mnemonic | Size | Effect |
|---|---|---|---|
0x04 | EXEC f | 2 | Call built-in function f, see Built-in Functions. |
0xFE | RET | 1 | Return from a routine. |
0xFF | HLT | 1 | Stop the program. |
Built-in Functions
Built-ins are compiled to EXEC <index>: the arguments are pushed left to right, the VM pops them, runs the native function and pushes the result if there is one. The compiler checks the argument count (argument count mismatch for 'name': expected N, got M).
| Index | Function | Args | Returns | Description |
|---|---|---|---|---|
0x01 | println(value) | 1 | – | Print a value and a newline. |
0x02 | print(value) | 1 | – | Print a value. |
0x03 | inputInt() | 0 | integer | Read a whitespace-delimited integer; 0 and a message if invalid. |
0x04 | inputStr() | 0 | string | Read one whitespace-delimited word. |
0x05 | str2int(text) | 1 | integer | Parse an integer; Function error if invalid. |
0x06 | int2str(n) | 1 | string | Integer to text; Function error for a float. |
0x07 | str2float(text) | 1 | float | Parse a float; 0 if invalid. |
0x08 | float2str(x) | 1 | string | Number to text with six decimals. |
0xA0 | assertCapability(name) | 1 | – | Stop with an error if the host lacks the capability. |
0xA1 | openFile(path) | 1 | integer | Open (create if missing, keep contents) for read/write; returns a handle. |
0xA2 | writeFile(text, handle) | 2 | – | Append text to the file and flush. |
0xA3 | readFile(handle) | 1 | string | Whole file contents from the start. |
0xA4 | closeFile(handle) | 1 | – | Close and release the handle. |
0xA5 | randomSeed(seed) | 1 | – | Seed the generator. |
0xA6 | random() | 0 | float | Float in [0, 1). |
0xA7 | randomRange(min, max) | 2 | integer | Integer in [min, max]. |
0xA8 | httpRequest(method, url, headers, body, &response) | 5 | integer | HTTP request; returns status or -1. |
0xA9 | strlen(text) | 1 | integer | Length. |
0xAA | substr(text, start, length) | 3 | string | Substring. |
0xAB | strfind(text, needle) | 2 | integer | Index of first match or -1. |
0xAC | strcase(text, upper) | 2 | string | Upper case if upper is non-zero, else lower. |
0xAD | trim(text) | 1 | string | Strip leading and trailing whitespace. |
Failures inside a native function are caught by the VM: it prints Function error, then the message, and halts.
Adding a built-in
- Implement it in
src/vm/vmfuncmap.cppunder a new index. Pop the arguments in reverse order (last argument on top) and push the result if there is one. - Register it in
funcListinsrc/compiler/compiler_tables.cppwith{index, argCount, returnable}.
The disassembler and debug symbols pick the name up from funcList automatically.
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/,src/vm/,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 (statements, expressions, tables, finalize), include/compiler.h, include/compiler_internal.h |
| Virtual machine | src/vm/vm.cpp, src/vm/translator.cpp, src/vm/memoryallocator.cpp, include/vm.h |
| Native functions | src/vm/vmfuncmap.cpp (implementation), src/compiler/compiler_tables.cpp (funcList) |
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 |