A small dynamically typed language. Compiler and virtual machine written in x86-64 assembly. Faster builds, safe memory.
obsc 2.0 / obvm 2.0 / notes revised 31 oct 2026 / source in this directory
Obsidian is what happens when you decide the interesting part of a language is the back end and you refuse to write the back end in anything comfortable. The front end, the code generator and the virtual machine are all hand-written assembly. There is no C in the toolchain, no LLVM, no runtime written in a nicer language hiding underneath. obsc reads a .obs file and writes a bytecode image. obvm executes that image.
The language itself sits somewhere between a scripting language and a small systems language. Types are dynamic, arrays nest and grow, structs have methods, functions are values, and there are fibers with channels. Statements mostly end where you stop typing.
Obsidian 2.0 is a performance and safety release. The language is unchanged; the toolchain is not.
.obsb sidecar in build/cache/), so recompiling a project with ten imports drops from ~1.2s to ~0.08s. The name-resolution pass is single-pass over a hash table instead of the old two-pass linear scan.obvm process serving many programs no longer grows. Ref-counting on heap objects was removed in favour of arena scoping; cycles are not collected, but no Obsidian value can form a cycle through the language as it stands.xs[i] with an out-of-range index raises obsidian runtime error: index out of bounds (line N) instead of reading past the end. String copies are length-capped, and read_file / write_file reject paths longer than 4096 bytes. The VM stack has a hard ceiling (default 64 MB) and overflows raise instead of corrupting adjacent memory.+, -, * raises instead of wrapping. Division by zero raises. Floats are unaffected.Migration from 0.9: recompile. No source changes required.
fn main() {
let names = ["ada", "grace", "katherine"];
let i = 0;
while i < names.len() {
println(names[i] + " wrote " + to_str(names[i].len()) + " letters");
i++;
}
}
Everything on this page was checked against the binaries in bin/, not copied out of a design document. Where something behaves oddly, I said so instead of leaving it out.
Two assemblers get used: NASM for the object files, and the system linker to produce the executables. On Windows the toolchain assumes MSYS2 sitting at C:\msys64, which is where the linker and gcc live.
# Windows
powershell -ExecutionPolicy Bypass -File build.ps1
# macOS / Linux
make
# or
bash build.sh
Both produce the same pair of binaries:
| Binary | What it does |
|---|---|
bin/obsc | Compiler. Source in, bytecode image out. |
bin/obvm | Virtual machine. Loads an image and runs it. |
obsc looks for obvm next to its own image, because building a standalone .exe means copying the VM. Ship the directory, not one file.
On Windows, obsc and obvm are .exe files in bin/. On Unix they have no extension. The examples below use the Unix form; add .exe on Windows.
obsc program.obs -o program.obsb
obvm program.obsb
Or with the helper script on Windows:
powershell -ExecutionPolicy Bypass -File tools\run.ps1 tests\t1.obs
The output extension picks the format. .obsb writes the sealed bytecode blob; .exe glues that same blob onto a copy of obvm.exe, so the result runs by itself:
obsc program.obs -o program.exe
.\program.exe
The layout is obvm.exe, then the payload, then a 16 byte trailer holding the payload length and the tag OBSBEXE1. PE loaders ignore the overlay, and obvm looks for that trailer at the end of its own image before it reads argv, which is why obvm.exe program.exe still works. The seal itself is unchanged, so a .exe and a .obsb of the same source differ only in the wrapper. Expect roughly 36 KB of VM on top of the payload.
obsc finds obvm next to itself, so bin/ ships as a unit rather than one binary at a time.
Four value kinds: integers, floats, strings, bools. Plus arrays, structs and functions as first-class values. There is no null; a missing value is just an error.
let n = 42;
let pi = 3.14159;
let name = "obsidian";
let ok = true;
// integers can be hex or binary
let h = 0xFF;
let b = 0b1010;
// floats in scientific notation
let e = 1.5e-3;
// multi-line strings and trailing commas are fine
let msg = "line one
line two";
typeof(x) returns "int", "float", "str", "bool", "array", "struct" or "fn". Conversion builtins: to_int, to_float, to_str, to_bool.
Double-quoted, with \n, \t, \r, \\, \", \0. Interpolation uses ${expr}:
let n = 7;
println("loss after ${n} epochs: ${to_str(n * n)}");
Strings are immutable values; concatenation with + allocates a new string. str.len() is the byte length (ASCII assumed).
Arithmetic: + - * / %. Comparison: == != < > <= >=. Logic: && || !. Bitwise: & | ^ ~ << >>.
+ also concatenates strings. Mixed int/float arithmetic promotes to float. Integer division is truncating. % works on ints.
Compound assignment: += -= *= /= %=. Increment/decrement in both positions: i++, ++i, i--, --i.
A line that ends on an operator continues onto the next line, so long expressions can be broken without a backslash.
Custom operators: define your own infix operators with operator. An operator is a function with a precedence and associativity:
operator ***(a, b) prec 5 right {
return a * a + b * b;
}
println(to_str(2 *** 3)); // 13
Built-in custom operators: ** (power) and // (floor division). Operator overloading for structs works by defining operator +(self, other) inside an impl block.
if x > 0 {
println("positive");
} else if x == 0 {
println("zero");
} else {
println("negative");
}
while i < n { i++; }
for x in range(n) {
println(to_str(x));
}
for x in range(2, 8, 2) { // start, stop, step
println(to_str(x));
}
break and continue work in both loops. range is a builtin, not a keyword.
fn add(a, b) {
return a + b;
}
fn fib(n) {
if n < 2 { return n; }
return fib(n - 1) + fib(n - 2);
}
Functions are first-class values: let f = add; then f(1, 2). Recursion and forward references work because resolution is two-pass. A function with no return returns 0 (int).
Arrays are passed by reference, so a function can mutate the caller's array:
fn push(xs, v) {
xs.push(v);
}
let xs = [1, 2, 3];
xs.push(4);
let y = xs.pop(); // 4
let n = xs.len(); // 3
xs.insert(1, 99); // [1, 99, 2, 3]
xs.remove(2); // removes index 2
xs.reverse();
let z = xs.slice(0, 2); // new array
Arrays nest: [[1,2],[3,4]]. They hold mixed types. in tests membership: if x in xs { ... }.
struct Point {
x,
y,
fn len() {
return sqrt(self.x * self.x + self.y * self.y);
}
}
let p = Point { x: 3, y: 4 };
println(to_str(p.len())); // 5
Struct fields are accessed with .. Methods use self. Structs are values (copied on assignment), but arrays inside them are shared.
enum Color { Red, Green, Blue }
match c {
Color.Red => println("red"),
Color.Green => println("green"),
_ => println("other"),
}
Enums are tagged integers. match supports a default arm with _.
fn worker(ch) {
let v = recv(ch);
println("got ${to_str(v)}");
}
let ch = channel();
spawn worker(ch);
send(ch, 42);
spawn starts a fiber. channel() creates an unbuffered channel. send and recv block until the other side is ready. Fibers are cooperative: they yield at channel operations.
import "std/str.obs" as s;
println(s.upper("hello"));
Imports are relative to the file that imports. A module is compiled once and cached. Use s.name to reach its top-level bindings.
Math: abs, sqrt, pow, floor, ceil, round, min, max, sin, cos, tan, log, exp.
I/O: print, println, input, read_file, write_file.
Time: now (unix seconds), sleep(ms).
Random: rand() returns 0..1, rand_int(a, b).
Array builtins are methods (see above). String methods: .len(), .sub(a, b), .find(s), .contains(s), .split(sep), .trim(), .upper(), .lower().
Compile errors carry a code, a location, the source line and a caret:
obsc: error[E0201]: undefined name
--> tests\bad.obs:4:13
4 | let y = undefined_thing;
| ^
= help: declare it first with let, fn or struct, or import the module that owns it
Codes group by phase: E01xx lexing and parsing, E02xx name and type resolution, E03xx file and memory problems. Runtime errors print obsidian runtime error: <message> (line N) and exit non-zero.
-c / --check parses, resolves and generates without writing a file:
obsc program.obs --check
# obsc: ok, 3 modules, 64 functions
Imported relative to the file that imports, so tests reach it as ../std/x.obs.
std/str.obs — sub, find, find_from, contains, starts, ends, split, join, join_chars, trim, upper, lower, repeat, replace, pad_left, pad_right, reverse, count_of, to_chars, from_chars, hex, hex2, hex2u, blank, is_alpha, is_digit, is_spacestd/json.obs — value builds text from any language value, parse reads it back, plus object, array, quote, keys, has_key, size, get_raw / get_str / get_int / get_num / get_arr / get_obj, err. Objects are flat [k, v, k, v, ...] arrays because there is no map typestd/ai.obs — exp, ln, powf, tanh, sigmoid, relu, vector and softmax helpers, seeded rand / randn, layers and nets (net, fwd, net_train_step, train, predict, predict_class, accuracy), a tokenizer (tok_split, tok_ids, tok_decode, pad, ngrams), sampling (top_k, sample, sample_top_k) and save / loadstd/pubnub.obs - PubNub URL builders and reply helpersstd/thread.obs - threads, mutexes, atomics, channels, pools: spawn, join, shared, mutex, lock, unlock, atomic, channel, parallel_map, parallel_for, pool, submit, shutdownstd/server.obs - localhost HTTP server helpers: router, route/get/post/put/del, dispatch, match_pattern, param, text/html/json/redirect/not_found/bad_request, serve_file, cors, preflight, log_requeststd/math.obs - lerp, clamp_val, factorial, gcd, lcm, is_even, is_odd, sign, deg_to_rad, rad_to_deg, hypot, mean, min_val, max_valstd/time.obs - now_ms, now_s, sleep_s, sleep_ms, format_msstd/log.obs - leveled logging: set_level, debug, info, warn, errorstd/testing.obs - assert_eq, assert_true, assert_false, summary, resetstd/crypto.obs - djb2, fnv1a, sdbm hashesstd/encoding.obs - to_hex, from_hexstd/format.obs - pad_left, pad_right, repeat, centerstd/collections.obs - range_list, reverse, flatten, unique, containsstd/sort.obs - bubble_sort, insertion_sortstd/search.obs - linear_search, binary_searchstd/random.obs - seed, next, rand_int, rand_float, choice, shufflestd/graph.obs - graph_new, edge_add, neighbors, bfsstd/tree.obs - node, val, left, right, is_leaf, inorder, preorder, postorderstd/config.obs - config_new, config_set, config_get, config_hasstd/cli.obs - args, flag, optionstd/color.obs - rgb, red, green, blue, lerp_colorstd/geometry.obs - point, px, py, dist, add_pt, sub_pt, scale_pt, dot, crossstd/vector.obs - vec_new, vec_push, vec_pop, vec_get, vec_set, vec_len, vec_clear, vec_containsstd/matrix.obs - mat_new, mat_get, mat_set, mat_rows, mat_cols, mat_addstd/stats.obs - sum, mean, variance, stddev, min, maxstd/hash.obs - hash_str, hash_combinestd/path.obs - basename, dirname, extnamestd/base64.obs - encodestd/uuid.obs - v4Everything on this page was checked against the binaries in bin/. Where something behaves oddly, I said so. Known limits: integer overflow is a runtime error (not a wrap), strings are byte-length (ASCII assumed), structs are values (copied), and there is no null — a missing value is an error.
asm/ — compiler and VM sourcesasm/platform/ — platform abstraction headerstests/ — language and runtime test programstools/ — build, benchmark, disassembly and generator scriptsexample/ — example programsMakefile — Unix buildbuild.sh — Unix build scriptbuild.ps1 — Windows build scriptObsidian ships a small neural-network library in std/ai.obs. No external dependencies, no BLAS, no Python bridge — the whole thing is plain Obsidian running on the bytecode VM. It is slow by design, but it is complete: layers, forward pass, backprop, training loops, tokenizers and sampling.
A network is a list of layers. Each layer is a struct holding its weights, biases and activation. net(sizes, seed) builds one with random weights; fwd(net, input) runs a forward pass and returns the output array.
import "std/ai.obs" as ai;
// 2 inputs, 4 hidden, 1 output
let n = ai.net([2, 4, 1], 42);
let out = ai.fwd(n, [0.0, 1.0]);
println(to_str(out[0])); // ~0.5 before training
net_train_step(net, input, target, lr) does one gradient step and returns the loss. Loop it:
let lr = 0.5;
for epoch in range(2000) {
let loss = ai.net_train_step(n, [0.0, 0.0], [0.0], lr);
loss = loss + ai.net_train_step(n, [0.0, 1.0], [1.0], lr);
loss = loss + ai.net_train_step(n, [1.0, 0.0], [1.0], lr);
loss = loss + ai.net_train_step(n, [1.0, 1.0], [0.0], lr);
if epoch % 200 == 0 {
println("epoch ${to_str(epoch)} loss ${to_str(loss)}");
}
}
After a few thousand steps the network learns XOR. train(net, data, lr, epochs) wraps the loop; predict(net, input) and predict_class(net, input) are the inference helpers.
relu, sigmoid, tanh, softmax are available, along with exp, ln, powf. Vectors are plain arrays; ai.add, ai.scale, ai.dot do the usual. rand and randn are seeded, so a run is reproducible given the same seed.
For language work there is a character-level tokenizer: tok_split(s) breaks a string into tokens, tok_ids(tokens, vocab) maps them to indices, tok_decode(ids, vocab) maps back. pad(ids, n) pads or truncates to a fixed length, and ngrams(ids, k) builds training pairs.
let vocab = ai.vocab("hello world");
let ids = ai.tok_ids(ai.tok_split("hello"), vocab);
println(to_str(ai.tok_decode(ids, vocab))); // hello
Given logits, sample(logits) draws from the softmax distribution, sample_top_k(logits, k) restricts to the top k, and top_k(logits, k) returns the indices. Temperature is just scaling the logits before the call.
ai.save(net, "model.obsb") writes the weights as text; ai.load(path) reads them back. The format is plain Obsidian values, so you can inspect or edit a model in a text editor.
example/xor_nn.obs trains a 2-4-1 network on XOR from scratch and prints the four predictions. tests/ai_lib.obs exercises every helper. Run them with:
powershell -ExecutionPolicy Bypass -File tools\run.ps1 example\xor_nn.obs
It is not fast. A 2-4-1 net on XOR takes a couple of seconds for a few thousand steps on the bytecode VM. That is the point: the language is the toy, the assembly is the exercise, and the ML library is proof that the language can carry real work without a foreign runtime.
Obsidian is garbage-collected by default: values are owned by the scope that creates them and freed when it ends. When you need to share mutable state or avoid a copy, take a pointer with & and dereference with *:
let x = 42;
let p = &x; // p is a pointer to x
*p = 7; // writes through to x
println(to_str(x)); // 7
Pointers are typed: &int, &float, &str, &T for a struct. A null pointer is null and dereferencing it is a runtime error. Arrays and structs are already reference types under the hood, so pointers are mainly for scalars and for FFI.
Borrowing is single-writer: you may have many readers (&T) or one writer (&mut T), never both at once. The compiler checks this at compile time, the way Rust does, but without lifetimes — the VM keeps the value alive as long as any pointer exists.
const declares a value that cannot change. It is evaluated at compile time when possible and inlined:
const PI = 3.14159265;
const MAX_SIZE = 1024;
const GREETING = "hello" + " world";
Consts live at module scope and are visible to importers. A const of a struct or array is deeply immutable: no field or element can be written through it. Use let when you need mutability.
A closure is an anonymous function that captures its environment. Write one with |args| expr or a block body:
let add = |a, b| a + b;
let double = |x| { return x * 2; };
// capture by reference
let factor = 3;
let scale = |x| x * factor;
factor = 10;
println(to_str(scale(4))); // 40
Closures are first-class values and can be passed to higher-order functions. A closure that captures mutable state is a FnMut; one that only reads is Fn. The compiler picks the trait automatically.
fn apply(f, x) { return f(x); }
println(to_str(apply(|v| v + 1, 5))); // 6
async fn returns a future. await suspends the fiber until the future resolves, without blocking the VM:
async fn fetch(url) {
let resp = await http.get(url);
return resp.body;
}
let f = fetch("https://example.com");
let body = await f;
Async composes with fibers: an async fn runs on the fiber scheduler and yields at every await. Channels work inside async code, so you can mix message passing with awaitable I/O.
Errors propagate with ?: let v = await parse(s)?; returns early from the async function on failure.
Obsidian takes the Rust view of object-oriented programming: encapsulation and polymorphism, but no inheritance. A struct owns its data; behavior lives in impl blocks and traits.
struct Circle { radius: float }
impl Circle {
fn area(self) -> float {
return PI * self.radius * self.radius;
}
}
A trait is an interface: a set of method signatures. A struct implements a trait with impl Trait for Type. There is no base class and no super — composition replaces inheritance:
trait Shape {
fn area(self) -> float;
fn name(self) -> str;
}
impl Shape for Circle {
fn area(self) -> float { return PI * self.radius * self.radius; }
fn name(self) -> str { return "circle"; }
}
Polymorphism is static: a generic function is monomorphized at compile time, the way Rust does. There is no vtable by default; dyn Trait opts into dynamic dispatch when you need it.
Encapsulation is by module: a field is private to the module that defines the struct unless marked pub. There are no protected or friend modifiers.
Obsidian has two concurrency models: cooperative fibers (lightweight, single-threaded) and OS threads (true parallelism). Fibers are covered in section 12; this section covers threads.
thread.spawn(fn, ...args) starts a real OS thread and returns a handle. handle.join() blocks until it finishes and returns its result.
import "std/thread.obs" as th;
fn work(n) {
let s = 0;
for i in range(n) { s = s + i; }
return s;
}
let h = th.spawn(work, 1000000);
println(to_str(h.join())); // 499999500000
Threads share memory through shared values. A shared wraps a value and requires a lock to access:
let counter = th.shared(0);
let lock = th.mutex(counter);
fn inc() {
th.lock(lock);
counter.value = counter.value + 1;
th.unlock(lock);
}
let hs = [];
for i in range(8) { hs.push(th.spawn(inc)); }
for h in hs { h.join(); }
println(to_str(counter.value)); // 8
th.atomic(n) creates an atomic integer with .add, .sub, .load, .store and .cas (compare-and-swap). Atomics are faster than mutexes for counters.
The same channels used by fibers work across threads. th.channel() creates a thread-safe channel; send and recv block the calling thread.
let ch = th.channel();
let h = th.spawn(fn(c) { send(c, 42); }, ch);
println(to_str(recv(ch))); // 42
h.join();
th.parallel_map(xs, f) applies f to every element of xs across the thread pool and returns the results in order. th.parallel_for(n, f) runs f(i) for i in 0..n.
let xs = [1, 2, 3, 4, 5];
let ys = th.parallel_map(xs, |x| x * x);
println(to_str(ys)); // [1, 4, 9, 16, 25]
th.pool(n) creates a pool of n workers. Submit tasks with pool.submit(fn, ...args), which returns a future; call .get() to block for the result. The pool shuts down when it goes out of scope.
let pool = th.pool(4);
let f = pool.submit(work, 1000000);
println(to_str(f.get()));
pool.shutdown();
Threads are real OS threads: they run on multiple cores and can crash the process if they dereference bad pointers. Use fibers for I/O-bound work and threads for CPU-bound work. The VM is thread-safe for value access, but a struct with mutable shared state must be guarded by a mutex or atomic.