___| |_ ___ __ ___ __\_ \\_ \
/ __| __/ _ \/ _` \ \ / / / /\/ / /\/
\__ \ || __/ (_| |\ V /\/ /_/\/ /_
|___/\__\___|\__,_| \_/\____/\____/
A strongly-typed scripting language, implemented as a bytecode VM in C++,
built to be embedded in a ray tracing renderer. Spiritual successor to
steavscript — same .sts extension, same Khmer-transliterated keywords,
everything under the hood redesigned.
Where steavscript was a Lox-faithful tree-walking interpreter, steav2 is
compiled to bytecode and run on a stack-based VM, clox-style, with real
static typing on top. The keyword vocabulary is the throughline between the
two — most of steavscript's keyword table carries over unchanged.
steavscript proved the front end (scanner/parser/tree-walk evaluator) end to
end, plus a working stdlib (vector, random, math) ported from and
verified against keo's own math code. But a tree-walker re-traverses the AST
and does name-based environment lookups on every evaluation — fine for a
toy, not what you want running millions of times per frame inside a
renderer. steav2 keeps the parts of that design that worked (the stdlib
shape, the keyword vocabulary, the yok import syntax) and replaces the
execution model with a compiled bytecode VM, plus adds real static typing.
- Fast enough to live in the hot path. Materials, shaders, and camera logic run per-ray, per-sample. A bytecode VM with indexed local variable access (no hashmap lookups) is the baseline; value types that never touch the heap or the GC are the other half of that story.
- Strongly typed, locally inferred. Function signatures are always explicit; local variables infer from their initializer. Type errors are caught before the script runs, not mid-render.
- Same steav-flavored syntax, new semantics underneath. Struct/class split for value vs. reference types, real operator overloading — all expressed with the same keyword vocabulary and brace-delimited blocks steavscript already used.
- A real scripting surface for the renderer. Materials (scatter
functions), whole scenes (object placement, transforms), shaders/post
processing, and camera behavior are all meant to be
.stsscripts, not hardcoded in the engine.
| steav2 | meaning |
|---|---|
bongSlanhOun |
required opening statement (see below) |
akthe |
local binding (var/let) |
ber |
if |
minjengte |
else |
somhab |
for |
nvpeldae |
while |
rupamun |
func |
yok |
import |
jea |
as (in yok vector jea vec;) |
morvenh |
return |
sampoan |
struct (new — value types) |
tnak |
class (v1 — reference types) |
jongyeytha |
print |
ng |
and |
reu |
or |
ok |
true |
ort |
false |
sone |
nil |
nis |
this (v1 — refers to the instance inside a tnak method) |
super |
super (reserved) |
Every script has to start with bongSlanhOun <expression>;, same as
steavscript: if the first token isn't bongSlanhOun, nothing runs
(na `bongSlanhOun`?!!!, exit 65). It's ceremonial, the expression is
never checked or evaluated, and more bongSlanhOuns later in the file are
ignored. Only your script needs it, not the built-in modules.
Punctuation is unchanged from Swift/C-family convention where steavscript
didn't already have an opinion — { } for blocks, -> for return types,
: for type annotations. These are symbols, not keywords, so they aren't
translated.
Base types get Khmer names same as the keywords:
| steav2 | meaning |
|---|---|
Lek |
Number |
LekKut |
Int |
LekThom |
Double |
Ahsor |
String |
Boolean |
Boolean (untranslated) |
OrtMean |
Nil |
LekThomKlang |
Long |
With yok vector; you also get Vec2, Vec3, Vec4 (see below) — these
keep their English names since they're stdlib-defined sampoans, not
language primitives with reserved keywords.
Two kinds of user-defined types, split by how they're meant to be used:
Small, copied on assignment/pass, never heap-allocated, never touched by the garbage collector. Declared with fields and methods together:
sampoan Vec3 {
akthe x: LekThom;
akthe y: LekThom;
akthe z: LekThom;
rupamun length() -> LekThom {
morvenh sqrt(x * x + y * y + z * z);
}
}
rupamun +(a: Vec3, b: Vec3) -> Vec3 {
morvenh Vec3(a.x + b.x, a.y + b.y, a.z + b.z);
}
Operators are real, overloadable functions — Vec3 + Vec3, and later
Color + Color or Ray * LekThom, all use the same mechanism, no
special-casing in the VM.
Overloadable operators: + - * / == != < <= > >= (binary), plus unary
- for negation. steav2 doesn't assign inherent meaning to any of these —
</<=/>/>= in particular have no built-in semantics for a struct (no
assumed lexicographic or length-based ordering); the author defines
whatever rupamun <(a: Vec3, b: Vec3) -> Boolean { ... } should mean, same
as any other operator.
Overload resolution is exact-signature-match only, with no automatic
commutativity: a mixed-type operator like Vec3 * LekThom and
LekThom * Vec3 are two distinct overloads that must each be declared
explicitly — the type checker never tries swapped operand order as a
fallback. More typing per mixed-type pair, but overload resolution stays a
straightforward exact match with no implicit rules to reason about.
Assignment and passing copy: a struct is N consecutive slots on the value
stack (nested structs flattened inline, so a HitRecord { p: Vec3, t: LekThom }
is 4 slots), and akthe b = a; copies those slots. akthe b = a; b.x = 5;
never affects a. There's no copy-on-write or refcount: for the small,
fixed-size structs a renderer lives on (Vec3 is 3 slots) a plain copy is
cheaper than sharing, and it keeps structs truly off the heap. Field reads
and writes compile to one slot access at a statically known offset.
A sampoan method gets a copy of its receiver, so it can read nis (or its
fields by bare name) but not assign into it. Mutate a struct from outside:
v.x = 5;.
Heap-allocated, shared by reference, garbage collected (mark-and-sweep,
clox-style). For anything that's naturally shared or graph-like:
Material, Mesh, Light, Scene, Camera — and, as of v1, arrays
(see below), since a growable, shared collection needs the same heap/GC
machinery a user-defined reference type does. Building tnak properly now,
rather than special-casing a one-off reference type just for arrays, means
that machinery only gets built once.
tnak Sphere {
akthe center: Vec3;
akthe radius: LekThom;
rupamun init(center: Vec3, radius: LekThom) {
nis.center = center;
nis.radius = radius;
}
}
tnak instances are constructed via an explicit init method — rupamun init(...) is a reserved method name the compiler recognizes specially;
calling Sphere(Vec3(0, 0, 0), 1.0) allocates the instance on the heap and
runs init with those arguments. nis (steavscript's reserved word for
this, unused until now) refers to the instance being constructed inside
init, same as any other method.
This is a deliberate split from sampoan: structs get free, implicit
memberwise construction (no method needed, just list the field values in
order); classes require an explicit init since instance creation is
heap allocation plus arbitrary setup logic (validation, defaults,
whatever), not just a field copy.
[T] for an array of any type T — structs, other arrays, and eventually
tnak instances ([Vec3], [Sphere], [[Lek]]). Arrays are a built-in
reference type: heap-allocated and GC-managed like any tnak, not a value
type, since collections like a Scene's triangle list need to be shared
rather than copied.
akthe verts: [Vec3] = [Vec3(0, 0, 0), Vec3(1, 0, 0), Vec3(0, 1, 0)];
akthe first = verts[0];
No generic type parameter syntax (Array<T>) for v1 — [T] is
special-cased grammar, not sugar over a general generics mechanism, since
generics aren't otherwise part of the v1 language.
Arrays have two built-in methods, xs.len() -> LekKut and xs.push(x).
Indexes are LekKut and bounds-checked at runtime. An empty [] needs a
type from context (akthe xs: [Vec3] = [];). Elements are stored inline
too: an [Vec3] is one flat run of 3-slot elements, so verts[1].x = 2
writes one slot.
Arrays compose with tnak fields the same as any other type — this is the
actual point of building them as a reference type, so something like a
Scene can hold a shared, growable list of objects:
tnak Scene {
akthe objects: [Sphere];
rupamun init(objects: [Sphere]) {
nis.objects = objects;
}
}
T? for anything that may or may not produce a value — the canonical case
being ray/scene intersection:
rupamun hit(ray: Ray, tMin: LekThom, tMax: LekThom) -> HitRecord? {
// ...
}
No combined "check + bind" (if-let) form yet. Instead, comparing a variable
against sone narrows it: inside ber (x != sone) (and the body of
nvpeldae (x != sone), and the minjengte of ber (x == sone)) x reads
as plain T:
akthe hitResult = scene.hit(ray, 0.001, INFINITY);
ber (hitResult != sone) {
jongyeytha hitResult.t; // HitRecord, not HitRecord?
}
Assigning to x ends the narrowing for the rest of that branch, so
cur = cur.next; in a nvpeldae (cur != sone) walk just works. It's an
error inside a loop nested in the check, where the next time around would
read it as still checked. Using a T? field or method without the check is
a type error. A T goes into a T? implicitly.
An if-let-style form is a natural later addition on top of this — nothing about the plan below forecloses it.
Function signatures (parameter and return types) are always explicit; a
missing -> type means OrtMean. akthe bindings infer their type from
the initializer:
akthe a = 5; // inferred Lek
akthe b: LekThom? = sone; // explicit, since there's no initializer to infer from
Lek is another name for LekThom. Number types never mix implicitly
(LekKut + LekThom is a type error). A literal takes its type from context
(akthe i: LekKut = 5;, i + 1, a LekKut parameter) and is a Lek
otherwise. Convert explicitly by calling the type: LekThom(i),
LekKut(x) (truncates toward zero), LekThomKlang(i).
Same yok import syntax as steavscript:
yok vector; // Vec3, dot, cross, ... in scope unqualified
yok vector jea vec; // vec.Vec3, vec.dot, ... instead
Imports aren't transitive, and an unqualified import that clashes with a
name the importing module declares is an error (import it with jea
instead). Operator overloads are always in scope, however a module was
imported.
yok also takes a quoted path instead of a module name, to split a
program across .sts files:
yok "helpers.sts"; // in scope unqualified
yok "lib/helpers.sts" jea h; // h.whatever(...) instead
The path resolves relative to the importing file's own directory, not
the main script's — so lib/helpers.sts importing "deep.sts" itself
means lib/deep.sts, regardless of where the main script lives. The same
file always resolves to the same module no matter which importer's
relative spelling reached it first, so diamond imports and import cycles
(including a file importing itself, transitively or directly) are caught
at compile time rather than silently re-parsing or looping. An imported
file doesn't start with bongSlanhOun — only the main script does.
Ships Vec2, Vec3, and Vec4, ported from keo's math/vec.h, written
in C++ and compiled into the binary (src/stdlib/vector.cpp). They're
still ordinary sampoans to the language (v.x, printing, stored inline
on the value stack, never on the heap), but every operation on them is a
C++ function: the module declares each one as a body-less
rupamun dot(a: Vec3, b: Vec3) -> LekThom; and the compiler binds it to
the C++ with the same signature. A call to one is a single OP_CALL
that runs the C++ directly, without pushing a bytecode frame. Same
formulas as keo, LekThom (double) instead of float.
- operators:
+ - * /componentwise,v + s,v - s,v * s,v / s,s * v, unary-,==,!= - methods:
length(),length_sq(),normalized(),min(o),max(o),abs(), plusVec3.near_zero()andVec4.xyz() - free functions:
dot,cross,lerp,rotate,reflect,refract,schlick_approx,zero(),one(),up(),right(),forward(),splat(s)(keo'sexplicit Vec3(float s)),extend(v: Vec3, w) -> Vec4, and keo'srandom_unit_vector(),random_in_unit_disk(),random_on_hemisphere(n) Vec2i/Vec3i/Vec4i:LekKut-component counterparts (same field names, no operators/methods of their own), cast to/from withto_lekkut(v) -> Vec3i/to_lekthom(v) -> Vec3(and the usual2/4suffixes) —to_lekkut*truncates toward zero and clamps at the 32 bit boundary, same as the scalarLekKut(x)cast
steav2 has no overloading by name, so the Vec3 version of a free function
gets keo's plain name and the Vec2 / Vec4 versions get a suffix (dot2,
dot4, cross2, lerp4, zero2, one4, ...). keo's min / max /
abs are methods so they don't clash with math's scalar ones. keo's
mutating normalize(), +=, and v[i] aren't there yet (no compound
assignment or struct indexing in v1): write v = v.normalized();.
Not part of vector: Color is not a generic linear-algebra type, so
it isn't shipped here — it lives in the ray tracing implementation itself,
where it can grow renderer-specific behavior (gamma correction, clamping,
toRGB8()) that a generic vector module shouldn't have to carry.
Native (C++) modules. math: sqrt abs floor ceil min max pow sin cos tan clamp rad2deg deg2rad, all over LekThom, plus keo's constants (PI,
TAU, INFINITY, NEAR_ZERO, ...). random: keo's PCG32, same seed and
same stream (uniform(), interval(min, max), seed(s),
seed_stream(s, seq)).
sampoan, tnak, and rupamun declarations all live in the same
top-level namespace — a sampoan Sphere and a rupamun Sphere(...) can't
coexist, same as any ordinary redeclaration error. This is what makes a
call site like Sphere(...) unambiguous: the type checker resolves the
name exactly once, at compile time, to one of three kinds of thing, and
the compiler emits different bytecode accordingly — there's no runtime
"what kind of thing is this" dispatch, since steav2 is statically typed
and the compiler already knows by the time it generates code.
| name resolves to | call site means | emitted opcode (roughly) |
|---|---|---|
sampoan |
struct construction, inline on the value stack, no heap alloc | nothing: the arguments pushed in field order are the struct |
tnak |
heap allocation + run init |
OP_CONSTRUCT_INSTANCE <type> |
rupamun |
ordinary function call | OP_CALL |
Argument matching is the same underlying mechanism (arity + per-argument
type-check against an expected list) in all three cases, just sourced
differently: struct construction matches the struct's field declaration
order positionally (Vec3(1, 2, 3) → x=1, y=2, z=3, no named-argument
reordering in v1), class construction matches init's parameter list, and
a plain function call matches its own declared parameters.
Recursive descent, same shape as steavscript's (and jlox's) — precedence climbing for expressions, one function per grammar rule. Most of it is unchanged; what's new mostly slots in as extra rules or extra postfix suffixes rather than requiring a different parsing strategy.
declaration → structDecl | classDecl | funDecl | varDecl | statement
structDecl → "sampoan" IDENTIFIER "{" typeBody "}"
classDecl → "tnak" IDENTIFIER "{" typeBody "}"
typeBody → ( fieldDecl | funDecl )*
fieldDecl → "akthe" IDENTIFIER ":" type ";"
funDecl → "rupamun" function
function → ( IDENTIFIER | operator | "init" ) "(" parameters? ")" ( "->" type )?
( block | ";" ) // ";" = C++ body, stdlib modules only
operator → "+" | "-" | "*" | "/" | "==" | "!=" | "<" | "<=" | ">" | ">="
parameters → parameter ( "," parameter )*
parameter → IDENTIFIER ":" type
varDecl → "akthe" IDENTIFIER ( ":" type )? ( "=" expression )? ";"
type → "[" type "]" "?"?
| ( IDENTIFIER "." )? IDENTIFIER "?"?
expression → assignment
assignment → ( call "." IDENTIFIER | call "[" expression "]" | IDENTIFIER )
( "=" | "+=" | "-=" | "*=" | "/=" ) assignment | logic_or
logic_or → logic_and ( "reu" logic_and )*
logic_and → equality ( "ng" equality )*
equality → comparison ( ( "!=" | "==" ) comparison )*
comparison → term ( ( "<" | "<=" | ">" | ">=" ) term )*
term → factor ( ( "-" | "+" ) factor )*
factor → unary ( ( "/" | "*" ) unary )*
unary → ( "-" | "!" ) unary | call
call → primary ( "(" arguments? ")" | "." IDENTIFIER | "[" expression "]" )*
primary → NUMBER | STRING | "ok" | "ort" | "sone"
| "(" expression ")" | IDENTIFIER | "nis" | arrayLiteral
arrayLiteral → "[" ( expression ( "," expression )* )? "]"
statement → exprStmt | printStmt | block | ifStmt | whileStmt
| forStmt | returnStmt | importStmt
printStmt → "jongyeytha" expression ";"
block → "{" declaration* "}"
ifStmt → "ber" "(" expression ")" statement ( "minjengte" statement )?
whileStmt → "nvpeldae" "(" expression ")" statement
forStmt → "somhab" "(" ( varDecl | exprStmt | ";" ) expression? ";"
expression? ")" statement
returnStmt → "morvenh" expression? ";"
importStmt → "yok" ( IDENTIFIER | STRING ) ( "jea" IDENTIFIER )? ";"
exprStmt → expression ";"
rupamun, sampoan and tnak only go at the top level (no nested
functions or closures in v1). Top-level akthes are globals, addressed by
index like locals.
A few structural notes:
structDeclandclassDeclshare a body-parsing rule (typeBody) — same loop, only the wrapping AST node and triggering keyword differ.- Operator overloads reuse the ordinary
functionrule. Parsingrupamun +(a: Vec3, b: Vec3) -> Vec3 { ... }is one small fork right after consumingrupamun(peek: is the next token an identifier, an overloadable operator, orinit?) — every branch converges immediately after into identical parameter/arrow/block parsing. - The parser does not know about operator overloading at all.
a + bbuilds the sameBinaryAST node whethera/bare numbers orVec3s — resolving+to a user-defined overload (or rejecting it) is entirely the type checker's job, at a later pass. callgained a third postfix suffix,"[" expression "]", for array indexing (verts[0]) — same postfix chain as function-call-parens and dot-access, just one more alternative.typeis its own recursive rule, separate from the expression grammar, since a type never appears where an expression could (:always introduces a type). Recursion handles nested arrays ([[Lek]]).- This grammar is also the fix for steavscript's known "parenthesized
expressions overflow the stack" bug —
primary's"(" expression ")"case needs to actually consume the closing paren and return, not recurse back into itself.
source → tokens → AST → type-check pass → compile to bytecode → VM
- Scanner/parser: brace-delimited, recursive descent — conceptually
close to steavscript's, adjusted for the new grammar (
sampoan, operator overload declarations, optionals, type annotations). New punctuation over steavscript:[](arrays),:(type annotations),?(optionals),->(return types). Numeric literals stay a single generic token, same as steavscript'sNUMBER— the scanner doesn't distinguishLekKutvs.LekThomvs.LekThomKlanglexically; a literal like5is untyped until the type checker pins it down from context, the same way integer literals are polymorphic in Rust or Swift until inference resolves them. - Type checker: runs on the AST before compilation. Verifies function signatures, infers local types, resolves operator overloads, checks optional unwrapping — so the compiler can assume well-typed input and the VM can skip runtime type tag-checking on most operations.
- Compiler: AST → flat bytecode instruction array,
clox-style. Locals become stack slots addressed by index, not name lookups. - VM: a stack-based bytecode loop with explicit
CallFrames. Value types live inline on the value stack; reference types (tnakinstances, arrays) areObj*behind a mark-and-sweep GC.
v1 works end to end: scanner, parser, type checker, compiler and VM, with
sampoans, operator overloads, tnaks, arrays, optionals, a mark-and-sweep
GC and the vector / math / random modules. tests/ has a golden-file
test per feature, the README's own examples and a small ray tracer
(tests/raytrace/sphere.sts). See TODO.md for what's left.
- Inheritance (
tnakitself is in v1; subclassing is not) - if-let-style optional binding
- Generics (
[T]arrays are special-cased grammar, not general generics) - User-facing GC tuning / introspection
- Anything beyond
vector/random/mathin the stdlib
See BUILD.md for platform-by-platform instructions (macOS,
Linux, Windows/MSVC, Windows/MinGW, WASM via Emscripten). Quick start:
cmake -S . -B build
cmake --build build
./build/steav path/to/script.sts
./build/steav --dump path/to/script.sts # print the bytecode, don't run it
./build/steav --version| option | default | what |
|---|---|---|
BUILD_STEAV_EXAMPLES |
ON | build the steav CLI |
BUILD_STEAV_LSP |
ON | build steav2-lsp |
BUILD_STEAV_WASM |
OFF | build steav-wasm.js/.wasm (needs Emscripten) |
BUILD_STEAV_TESTS |
OFF | register tests/ with ctest |
STEAV_DEBUG_TRACE |
OFF | print disassembly per executed op |
STEAV_DEBUG_PRINT |
OFF | disassemble chunks after compiling |
STEAV_ASAN |
OFF | build with AddressSanitizer + UBSan |
STEAV_DEBUG_STRESS_GC |
OFF | collect garbage on every allocation |
The build type defaults to Debug; pass -DCMAKE_BUILD_TYPE=Release for an
optimized build. Warnings (-Wall -Wextra) are on for everything.
Tests are .sts files with a matching .expected stdout, or a first line
// expect: <message> for ones that must fail:
cmake -S . -B build -DBUILD_STEAV_TESTS=ON && cmake --build build
ctest --test-dir build # or: tests/run.sh build/steavTo install somewhere, cmake --install build --prefix <dir>.
steav2-lsp is a language server for .sts files:
- live errors from the real scanner, parser and type checker (the same
checks
steavruns, so the editor and the CLI never disagree) - hover docs for keywords (
somhab→for), base types, modules and their members (vec.dot,v.length,PI) - completion for keywords, types, imported names, names declared in the
file, and module members after
vec.(it followsjeaaliases)
It also ships a Vim syntax file for highlighting, an ftplugin so gc
comments lines with //, and a Lua snippet that registers the .sts
filetype and starts the server.
./install.sh # or: make installThis builds a Release build and puts steav and steav2-lsp in
~/.local/bin (make sure that's on your PATH), then copies
editor/nvim/ into ~/.config/lvim (or ~/.config/nvim if there's no
LunarVim). --prefix DIR, --vim-dir DIR and --no-editor change that.
Then add this once to config.lua (LunarVim) or init.lua (Neovim):
require("steav2")steavscript uses .sts too: if your config still has its
sts = "steavscript" / sts-lsp block, remove it, the last one to claim
.sts wins. Open a .sts file and :LspInfo should list steav2-lsp.
After changing the server or the Vim files, run ./install.sh again.
One folder per stage of the architecture above, include/ + src/ each:
frontend/: tokens, scanner, recursive descent parser (one function per grammar rule)ast/: index-based AST (header only)typecheck/: types, the shared symbol namespace, operator overloads, the checkercompiler/: AST + type info → bytecode, locals as stack slotsvm/:Value,Chunk, heap objects (tnakinstances, arrays, strings, functions), the mark-and-sweepHeap, and theVMwithCallFramesdebug/: disassemblerstdlib/: built-in modules foryok(math,random,vector), all C++ insrc/stdlib/include/math/:pcg32.handconstants.h(from keo2)include/logging/: logger header (from keo2)examples/steav.cpp: the CLI,examples/sts/: scripts to play with —basics.sts(variables, control flow, loops, functions) is the place to start,tour.stsgoes on tosampoan/tnak/optionals/vector,mistakes.stsshows what the type checker catches,raytracer.stsis a small ray tracerlsp/:steav2-lsp(plus a tiny dependency free JSON reader / writer)editor/nvim/: syntax, ftplugin, filetype + LSP setup,install.shinstalls themtests/: golden-file tests,tests/run.shruns them