Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

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

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

println 'Hello, world!'

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

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

What this book covers

  • Getting Started — building the lumen executable and running your first script.
  • Language Guide — every language feature: variables, operators, strings, 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 .lmn source into bytecode, and how the VM executes it.
  • Debugging & Tooling — the disassembler, debug symbols, and the interactive debugger.
  • Reference — the opcode table and built-in function list, for when you need the exact bytes.

Project goals

Lumen exists to explore, hands-on:

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

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

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 lumen supports.

CLI Reference

lumen <file> [options]

Special first arguments

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

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

File flags

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

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

Default behavior

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

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

Flag combination rules

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

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

Comments

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

# This is a comment

println 'Hello!' # Inline comments work too

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

Variables & Values

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

number = 42
result = number + 10

println result

Value types

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

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

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

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

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

OperatorMeaning
+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):

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

String concatenation

.. concatenates two values into a string:

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

println text

See Strings for more.

Strings

Strings are delimited with single quotes.

message = 'Hello, Lumen!'
println message

Concatenation

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

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

println text

No apostrophes inside strings

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

print 'What`s your name? '

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

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

FunctionDescription
strlen s, &outLength of a string
substr s, start, len, &outExtract a substring
strfind s, needle, &outIndex of the first occurrence of needle in s, or -1 if not found
strcase s, upper, &outConvert case: upper = 1 for uppercase, 0 for lowercase
trim s, &outStrip 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
OperatorMeaning
&valueTake a reference to a variable’s storage slot
*refDereference — 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

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

Input

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

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

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

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

Conditionals

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

if age >= 18
    println 'Adult'
else
    println 'Minor'
endif
  • Every if must be closed with endif.
  • else and elif are 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 routine must be closed with endroutine.
  • Routines cannot be nested. Defining a routine inside another routine is a compile error.
  • Routines take no parameters and return no value. There’s no argument list on routine or call — communication in and out of a routine happens entirely through variables, which are global and shared across the whole program.
  • A routine can be called from anywhere in the file, including before its routine ... endroutine block appears — routine calls are resolved after the full file is compiled.

Pattern: routines as functions over shared state

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

temp = 0
result = 0

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

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

routine show
print 'Result: '
println result
endroutine

inputInt &temp
call c2f
call show

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

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).

FunctionDescription
assertCapability nameCheck 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.

FunctionDescription
openFile path, &handleOpen a file, storing a handle in handle
writeFile data, handleWrite a string to an open file
readFile &out, handleRead the full contents of an open file into out
closeFile handleClose an open file handle

Random

Requires the random capability.

FunctionDescription
randomSeed seedSeed the random number generator
random &outGenerate a random float in [0.0, 1.0) into out
randomRange min, max, &outGenerate a random integer in [min, max] (inclusive) into out

HTTP

Requires the HTTP capability.

FunctionDescription
httpRequest method, url, headers, body, &status, &responsePerform an HTTP request
  • method'GET', 'POST', 'PUT', or 'DELETE' (case-insensitive)
  • url — full URL including scheme, e.g. 'http://example.com/path'
  • headers — newline-separated Key: Value pairs, or '' for none
  • body — request body string, ignored for GET
  • status — receives the HTTP status code, or -1 on a connection-level failure
  • response — 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>
ExampleGeneratesDescription
ageage.lmnAge calculator — reads a birth year, computes age
fizzbuzzfizzbuzz.lmnClassic FizzBuzz up to a user-supplied N
temperaturetemperature.lmnCelsius ↔ Fahrenheit converter using routines
infinite-loopjump.lmnMinimal label/jump demonstration

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

Each example is broken down in its own page in this chapter, in increasing order of what language features it touches. 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, n creates a loop that runs x times with an iterator n that 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, using elif chains 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 variables temp and result — see Routines for why this pattern exists.
  • elif chain to build a three-way menu (mode == 1, mode == 2, anything else) — see Conditionals.
  • inputInt used twice: once for the menu selection, once for the temperature value itself.

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

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:

StatementDescription
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 .lmn source directly in the app.
  • Compiler. The same compiler pipeline as desktop, turning .lmn source 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 --disassemble output 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 .bin file 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 0 to total-1 has been seen and validated, the runtime reassembles the original .bin bytes 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 an AlertDialog instead, with the native thread blocked on a std::mutex/std::condition_variable pair until the user submits a value through the dialog.
  • Console output. print/println calls 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:

  1. Open the editor in LumenRuntimeAndroid and write the .lmn source directly.
  2. Compile it in-app.
  3. Run it, with the disassembler view available alongside.

Imported from elsewhere:

  1. Write and compile a .lmn program on the WASM Playground.
  2. Export the compiled .bin as a QR sequence rather than, or alongside, a regular file download.
  3. Open LumenRuntimeAndroid and scan the codes — order does not matter, and the app reports how many chunks remain outstanding.
  4. 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.

  • 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 answered
  • onDtmf — runs once per DTMF digit pressed during the call
  • onHangup — 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.

FunctionArgsPurpose
getDtmf&outWrites the most recent DTMF digit into out
speakToWavtext, output_pathSynthesizes speech to a wav file (blocking)
playWavpathPlays a wav file on the call
masqueradeTotargetMasquerades the call onto an arbitrary Yate route
hangUpTerminates 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:

TargetWhat runsNotes
Desktop (Linux/Arch)Full toolchain — compiler, VM, debugger, disassemblerThe lumen CLI described throughout this book
Raspberry Pi PicoVM only, bare-metal CNo compiler on-device — bytecode is cross-compiled on desktop and flashed/loaded onto the Pico
AVRVM only, bare-metal CSame split as Pico: desktop compiles, AVR executes
WebAssemblyFull toolchain, in-browserPowers the Lumen Playground; can also export compiled programs as QR codes
AndroidFull toolchain — editor, compiler, VM, disassembler, JNI-wrappedAlso 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 .bin file, 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 0xD00xFF 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:

  • funcTable instead of funcMap. Desktop’s native function table is a std::map-backed set of lambdas; the Pico build uses a plain array of NativeFn function pointers, since embedded C++ toolchains make heavier STL containers more expensive than they’re worth here.
  • A GPIO-specific EXEC range. Function indices 0xD00xFF are reserved on Pico for application-specific native functions — GPIO writes, I2C calls, and similar host behavior — mapped onto the tail end of funcTable at 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

  1. Write and compile your .lmn program on desktop as usual (lumen program.lmn --compile), producing a .bin file — see CLI Reference.
  2. Cross-compile the Pico firmware image, embedding the .bin bytecode 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).
  3. Flash the resulting .uf2 image 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 0xD00xFF 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 of NativeFn pointers) instead of desktop’s funcMap, for the same reasons — no heavier STL containers on constrained hardware.
  • The same 0xD00xFF reserved EXEC range for host-specific native functions, so a bytecode file that calls into that range means something different (and needs a different funcTable) 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:

  1. Compile the .lmn program on desktop as usual (lumen program.lmn --compile), producing a .bin file.
  2. Run bin2h (found alongside the sketch in avr-vm) against that .bin file. It converts the compiled bytecode into a C header — a byte array suitable for #include.
  3. Paste the generated header’s contents into program.h inside the Arduino sketch.
  4. Open the sketch in the Arduino IDE, having first imported all the sources and headers from avr-vm’s src and inc directories into the sketch.
  5. 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 .lmn source into bytecode.
  • The same VM described in The Virtual Machine, executing that bytecode.
  • File open/save for both .lmn source and precompiled .bin bytecode, 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/0xD00xFF 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 / inputStr become calls into the VM’s native function table (see Built-in Functions).
  • if opens a conditional block, emitting a comparison opcode and a placeholder jump target; else and endif patch that target once the block’s extent is known.
  • label records the current bytecode offset under a name; jump emits a jump instruction referencing it.
  • routine / endroutine collect their body into a separate bytecode segment, appended after the main program once compilation finishes; call emits a jump to that segment.

Two-pass resolution

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

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

Output

The result is a flat std::vector<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:

FieldTypeDescription
Signature2 bytes0xFE 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 lengthint32Number of ints in the bytecode stream
Bytecodeuint8_t[length]The instruction stream itself
String pool sizeint32Number of pooled string literals
String pool entriesrepeated {int32 len, char[len]}Each string literal, length-prefixed, not null-terminated
Const pool sizeint32Number of pooled constants
Const pool entriesdouble[length] (v3) / int32[length] (v2 and earlier)Shared integer + float constant array — see below
Variable countint32Number 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 as int32, then widened to double on load, so old integer-only bytecode still runs correctly on the current VM.
  • 0xFE 0xFC (v3) — const pool entries are read directly as double.
  • 0xFE 0xFD (v4, “32-bit addressing”) — const pool is read identically to v3 (directly as double). The version bump reflects a bytecode change, not a container change: the compiler now emits the 32-bit-offset jump/call opcodes (JUMP32, CALL32, JEQ32JNE32) 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):

FieldTypePurpose
bytecodestd::vector<uint8_t>The instruction stream
stringPoolstd::vector<std::string>Pooled string literals
constPoolstd::vector<double>Pooled integer + float constants
variableCountintNumber of variable slots to allocate

VMExecutionData holds the mutable state that changes as the program runs:

FieldTypePurpose
variablesstd::vector<Variant>Flat variable storage, one slot per variable
stackstd::vector<Variant>Operand stack — values being computed with
pcStackstd::vector<CallFrame>Return-address stack for routine calls, each frame holding a return PC and the caller’s routineBase
PCintProgram counter — index into the bytecode stream
routineBaseintBase offset of the currently executing routine (0 for the main program), added to jump targets
haltboolSet 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 constructBytecode behavior
x = exprEvaluate 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 / jumplabel is purely a compile-time bookmark; jump compiles to JUMP32 (0x06), an unconditional PC reassignment
routine / call / endroutinecall 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 0xD00xFF 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 through execute() one instruction at a time and inspects the same stack/variables/PC state between steps.
  • disassemble() (src/disassembler.cpp) — doesn’t execute anything; it statically walks the bytecode stream opcode by opcode and prints a human-readable listing. See Disassembler.

Debug Symbols

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

Generating them

Pass --dbgsym alongside --compile:

lumen program.lmn --compile --dbgsym

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

What’s in the file

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

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

How it’s consumed

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

Interactive Debugger

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

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

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

Starting up

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

Commands

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

A typical session

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

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

Disassembler

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

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

Reading the output

===== Main =====
0x00000000: 03 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:

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

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

With and without debug symbols

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

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

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

Opcode Reference

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

Control & data movement

OpcodeMnemonicSizeOperandsBehavior
0x01CALL2target 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
0x02POP2variable slotPop the stack top into the given variable slot
0x03PUSH3type tag, valuePush a value onto the stack — see below
0x04EXEC2function indexCall a native function — see Built-in Functions
0x05JUMP2target offset (uint8_t)Unconditional jump. Legacy 8-bit form — the current compiler never emits this; see JUMP32 below
0x06JUMP325target offset (uint32_t, little-endian)Unconditional jump, 32-bit target. What jump actually compiles to
0x07CALL325target 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)
0xFERET1Pop the return stack and jump there (used for endroutine)
0xFFHLT1Halt 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:

TagMeaning
0x01String — third operand is an index into the string pool
0x02Integer — third operand is an index into the shared constant pool
0x03Variable — third operand is a variable slot to read from
0x04uint8_t literal — third operand is value itself
0x05Float — 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.

OpcodeMnemonicOperationResult type
0xA0ADDa + bFloat if either operand is float, else int
0xA1SUBa - bFloat if either operand is float, else int
0xA2MULa * bFloat if either operand is float, else int
0xA3DIVa / bAlways float, regardless of operand types
0xA4POWa ^ b (std::pow)Float if either operand is float, else int (result truncated to int64)
0xA5MODa % 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:

OpcodeMnemonicSizeOperandComparison
0xB0JEQ2offset (uint8_t)==legacy 8-bit form, not emitted by the current compiler
0xB1JGR2offset (uint8_t)> — legacy
0xB2JLS2offset (uint8_t)< — legacy
0xB3JGE2offset (uint8_t)>= — legacy
0xB4JLE2offset (uint8_t)<= — legacy
0xB5JNE2offset (uint8_t)!= — legacy
0xC0JEQ325offset (uint32_t, little-endian)== — what if x == y actually compiles to
0xC1JGR325offset (uint32_t, little-endian)>
0xC2JLS325offset (uint32_t, little-endian)<
0xC3JGE325offset (uint32_t, little-endian)>=
0xC4JLE325offset (uint32_t, little-endian)<=
0xC5JNE325offset (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

OpcodeMnemonicSizeOperandsBehavior
0xA6INC1Pop the stack top, increment it, push the result. Equivalent to x + 1
0xA7DEC1Pop the stack top, decrement it, push the result. Equivalent to x - 1
0xA8INCV2variable slotIncrement the variable at the given slot in-place
0xA9DECV2variable slotDecrement the variable at the given slot in-place
0xABCPY2variable slotCopy the variable at the given slot onto the stack — equivalent to reading a variable but used internally for optimizing certain patterns

Pointer operations

OpcodeMnemonicSizeBehavior
0xDEDEREF1Pop a reference (pointer) from the stack and dereference it — replace it with the value it points to

Strings

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

Notes

  • Arithmetic and comparison opcodes are shared between TAG_INT and TAG_FLOAT operands — there’s no separate float-only opcode set. Type promotion happens per-operation, as described above (see Bytecode Format for the Variant type).
  • The 8-bit (CALL, JUMP, JEQJNE) and 32-bit (CALL32, JUMP32, JEQ32JNE32) opcode families are functionally identical apart from operand width — the compiler always chooses 32-bit so program size isn’t limited to 255 bytes. --disassemble output from the current toolchain will only ever show the ...32 mnemonics; the 8-bit forms exist in the VM for backward compatibility with older bytecode.
  • Unrecognized opcodes cause the VM to print Invalid opcode and halt with an error.

Built-in Functions

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

KeywordFunction indexSignatureBehavior
println0x01println <value>Pop the stack top, print it, then print a newline
print0x02print <value>Pop the stack top, print it, no trailing newline
inputInt0x03inputInt &varRead a token from stdin, parse as int64, store into the referenced variable slot as TAG_INT. On parse failure, prints Invalid value! and leaves the variable’s value untouched (whatever it held before, which defaults to 0)
inputStr0x04inputStr &varRead a whitespace-delimited token from stdin, store into the referenced variable slot as TAG_STRING
str2int0x05str2int <value> &outPop 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
int2str0x06int2str <value> &outPop 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
str2float0x07str2float <value> &outPop 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
float2str0x08float2str <value> &outPop 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
strlenstrlen s, &outLength of s, stored into out as TAG_INT
substrsubstr s, start, len, &outExtract a substring of s starting at start with length len, stored into out as TAG_STRING
strfindstrfind s, needle, &outIndex of the first occurrence of needle in s, or -1 if not found, stored into out
strcasestrcase s, upper, &outConvert case of s: upper = 1 for uppercase, 0 for lowercase, stored into out
trimtrim s, &outStrip leading and trailing whitespace from s, stored into out
assertCapabilityassertCapability nameCheck 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
openFileopenFile path, &handleOpen a file at path, storing a handle in handle. Gated by the FS capability
writeFilewriteFile data, handleWrite string data to the file at handle. Gated by the FS capability
readFilereadFile &out, handleRead the full contents of the file at handle into out. Gated by the FS capability
closeFilecloseFile handleClose the file at handle. Gated by the FS capability
randomSeedrandomSeed seedSeed the random number generator. Gated by the random capability
randomrandom &outGenerate a random float in [0.0, 1.0) into out. Gated by the random capability
randomRangerandomRange min, max, &outGenerate a random integer in [min, max] into out. Gated by the random capability
httpRequesthttpRequest method, url, headers, body, &status, &responsePerform 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 0x010x08. 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:

  1. Add an entry to funcMap in src/vmfuncmap.cpp — a lambda taking (stack, variables), with whatever native behavior you want.
  2. Add a matching entry to funcList in src/compiler.cpp so the compiler recognizes the keyword and knows what index to emit.
  3. Pick an unused function index — 0x010x08 are 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 0xD00xFF 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 0xD00xFF.

Contributing

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

Before you send a PR

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

Where things live

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