Obsidian 2.0

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.

0. What's new in 2.0

Obsidian 2.0 is a performance and safety release. The language is unchanged; the toolchain is not.

Migration from 0.9: recompile. No source changes required.

a complete program
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.

Contents

  1. 1Building it
  2. 2Compiling and running
  3. 3Standalone executables
  4. 4Values and literals
  5. 5Strings
  6. 6Operators
  7. 7Control flow
  8. 8Functions
  9. 9Arrays
  10. 10Structs and methods
  11. 11Enums and match
  12. 12Fibers and channels
  13. 13Modules
  14. 14Builtins
  15. 15Errors
  16. 16Standard library
  17. 17Rough edges
  18. 18Directory layout
  19. 19Machine learning
  20. 20Pointers
  21. 21Const
  22. 22Closures
  23. 23Async
  24. 24OOP (Rust-style)
  25. 25Concurrency and threads

1. Building it

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:

BinaryWhat it does
bin/obscCompiler. Source in, bytecode image out.
bin/obvmVirtual 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.

2. Compiling and running

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

3. Standalone executables

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.

4. Values and literals

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.

5. Strings

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

6. Operators

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.

7. Control flow

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.

8. Functions

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);
}

9. Arrays

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 { ... }.

10. Structs and methods

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.

11. Enums and match

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

12. Fibers and channels

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.

13. Modules

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.

14. Builtins

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

15. Errors

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

16. Standard library

Imported relative to the file that imports, so tests reach it as ../std/x.obs.

17. Rough edges

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

18. Directory layout

19. Machine learning with std/ai.obs

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

Building a net

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

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.

Activations and math

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.

Tokenizing text

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

Sampling

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.

Saving and loading

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.

A full example

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.

20. Pointers

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.

21. Const

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.

22. Closures

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

23. Async

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.

24. OOP (Rust-style)

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.

25. Concurrency and threads

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.

Spawning 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

Shared state and mutexes

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.

Channels across threads

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();

Parallel helpers

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]

Thread pool

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.