Appendix C: Full source listings
The complete, verified source of the finished project, the state the twenty-one chapters build up to. These listings are generated directly from the companion code/odin-speaks-odin/ directory, the authoritative, compilable copy; nothing here has been retyped or paraphrased. Build and run it with ./run.sh (from code/odin-speaks-odin/) for musc, or ./run.sh (from code/odin-speaks-odin/embers/) for Embers itself.
File index
| File | Purpose | Chapter |
|---|---|---|
muspel/token.odin | Token kinds, source spans, the keyword table | 2, 5-6, 20 |
muspel/lexer.odin | Hand-written scanner, automatic semicolon insertion | 2 |
muspel/ast.odin | AST node types for every expression and statement | 3-6, 15, 20 |
muspel/parser.odin | Recursive-descent parser, precedence climbing, error recovery, structs/enums/arrays, modules | 3-4, 5-6, 20 |
muspel/resolver.odin | Static scope resolution, arity checking, struct/enum/array/module resolution | 7, 20 |
muspel/treewalk.odin | The Part II tree-walking interpreter (--walk) | 8 |
muspel/opcodes.odin | The bytecode ISA, Chunk, the line table, struct/array/modulo opcodes | 10 |
muspel/arena.odin | The per-VM-instance struct/array bump allocator | 10 |
muspel/gc.odin | Precise copying garbage collector pass | App. G |
muspel/mapval.odin | Fixed-capacity open-addressed hash map | App. G |
muspel/compiler.odin | AST-to-bytecode compiler, jump patching, struct/enum/array/for-in codegen | 11-12, 20 |
muspel/value.odin | The VM's own runtime Value union, struct and growable-array instances | 13 |
muspel/vm.odin | The VM core, dispatch loop, resumability, fault isolation, arena budget, list builtins | 13, 15, 17-18 |
muspel/disasm.odin | Portable disassembler (host-agnostic, no core:os) | 10, 14 |
muspel/host.odin | vm_register_native, arg_number/arg_string, struct/array marshalling | 16 |
muspel/stdlib.odin | wait/wait_until, parse_with_stdlib, compile_source | 15, 19-21 |
muspel/modules.odin | Import-graph resolution, ordering, and shared compilation | 20 |
tools/musc/main.odin | The musc CLI's own command dispatch | 1 onward |
tools/musc/lex_cmd.odin | musc lex | 2 |
tools/musc/parse_cmd.odin | musc parse | 3, 5-6, 20 |
tools/musc/check_cmd.odin | musc check | 7 |
tools/musc/run_cmd.odin | musc run [--walk], module-graph loading | 8, 13, 15, 20 |
tools/musc/bench_cmd.odin | musc bench | 9 |
tools/musc/compile_cmd.odin | musc compile | 10-11 |
tools/musc/debug_cmd.odin | musc debug, the interactive step debugger | 14 |
tools/musc/asm_demo_cmd.odin | musc asm-demo | 10 |
tools/musc/tick_cmd.odin | musc run --tick | 15 |
tools/musc/host_cmd.odin | musc host, the stub embedding host | 16 |
tools/musc/events_cmd.odin | musc events | 17 |
tools/musc/instances_cmd.odin | musc run --instances=N | 17 |
tools/musc/hotreload.odin | Watched_Script, native file-mtime polling | 19 |
tools/musc/module_loader.odin | File I/O for the module graph, cycle detection during loading | 20 |
tools/musc/watch_cmd.odin | musc run --watch | 19 |
tools/musc/embers_check_cmd.odin | Native harness for Embers' own bundled scripts | 21 |
embers/batch/batch.odin | Batched Canvas2D command buffer (from yo_odin, unmodified) | 21 |
embers/batch/canvas.odin | Canvas/DOM bootstrap (from yo_odin, unmodified) | 21 |
embers/src/main.odin | Wiring: bootstrap, per-frame dispatch, keyboard input | 21 |
embers/src/game.odin | Arena, player, entity table | 21 |
embers/src/draw.odin | Vector rendering into the command buffer | 21 |
embers/src/script_api.odin | The script-callable builtin table | 21 |
embers/src/scripts.odin | Playlist sequencing and the browser-side live-reload bridge | 21 |
embers/server/serve.odin | Static-file HTTP dev server (native Odin, from yo_odin) | 21 |
embers/index.html | The host page: canvas + the live-reload polling loop | 21 |
embers/build.sh / run.sh | Build and run scripts | 21 |
muspel/
muspel/token.odin
package muspel
Token_Kind :: enum {
Illegal,
EOF,
// Literals and names.
Number,
String,
Ident,
// Keywords.
Var,
Const,
Func,
If,
Else,
While,
For,
In,
Return,
Yield,
True,
False,
Nil,
Struct,
Enum,
Import,
As,
// Punctuation.
Left_Paren,
Right_Paren,
Left_Brace,
Right_Brace,
Left_Bracket,
Right_Bracket,
Comma,
Semicolon,
Dot,
Colon,
// Operators.
Plus,
Minus,
Star,
Slash,
Percent,
Bang,
Assign,
Equal,
Not_Equal,
Less,
Less_Equal,
Greater,
Greater_Equal,
And_And,
Or_Or,
}
// keyword_kind looks a lexeme up against the fixed keyword table. A plain switch, rather
// than a map, keeps the table a single readable list with no allocation or init order to
// think about.
keyword_kind :: proc(lexeme: string) -> (Token_Kind, bool) {
switch lexeme {
case "var":
return .Var, true
case "const":
return .Const, true
case "func":
return .Func, true
case "if":
return .If, true
case "else":
return .Else, true
case "while":
return .While, true
case "for":
return .For, true
case "in":
return .In, true
case "return":
return .Return, true
case "yield":
return .Yield, true
case "true":
return .True, true
case "false":
return .False, true
case "nil":
return .Nil, true
case "struct":
return .Struct, true
case "enum":
return .Enum, true
case "import":
return .Import, true
case "as":
return .As, true
}
return .Illegal, false
}
// Token carries its own span: line and column are where its lexeme starts, and the
// lexeme's byte length is the span's extent. Every later error message depends on this
// being right, so it's part of the token from day one rather than bolted on later.
Token :: struct {
kind: Token_Kind,
lexeme: string,
line: int,
column: int,
}
muspel/lexer.odin
package muspel
// The lexer works on bytes, not runes: identifiers and keywords are ASCII only. This is a
// deliberate, defended scope cut (no non-ASCII identifiers) in exchange for a scanner with
// no UTF-8 decoding on its hot path. String and comment *contents* pass through untouched,
// so non-ASCII text inside a string literal is fine; it just can't appear in a name.
Lexer :: struct {
src: string,
pos: int,
line: int,
column: int,
start_line: int,
start_column: int,
}
lexer_init :: proc(src: string) -> Lexer {
return Lexer{src = src, pos = 0, line = 1, column = 1}
}
@(private = "file")
is_at_end :: proc(l: ^Lexer) -> bool {
return l.pos >= len(l.src)
}
@(private = "file")
peek :: proc(l: ^Lexer) -> u8 {
if is_at_end(l) do return 0
return l.src[l.pos]
}
@(private = "file")
peek_next :: proc(l: ^Lexer) -> u8 {
if l.pos + 1 >= len(l.src) do return 0
return l.src[l.pos + 1]
}
@(private = "file")
advance :: proc(l: ^Lexer) -> u8 {
c := l.src[l.pos]
l.pos += 1
if c == '\n' {
l.line += 1
l.column = 1
} else {
l.column += 1
}
return c
}
@(private = "file")
match :: proc(l: ^Lexer, want: u8) -> bool {
if is_at_end(l) || l.src[l.pos] != want do return false
advance(l)
return true
}
@(private = "file")
is_digit :: proc(c: u8) -> bool {
return c >= '0' && c <= '9'
}
@(private = "file")
is_alpha :: proc(c: u8) -> bool {
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
@(private = "file")
is_alnum :: proc(c: u8) -> bool {
return is_alpha(c) || is_digit(c)
}
@(private = "file")
skip_whitespace_and_comments :: proc(l: ^Lexer) {
for !is_at_end(l) {
switch peek(l) {
case ' ', '\t', '\r', '\n':
advance(l)
case '/':
if peek_next(l) == '/' {
for !is_at_end(l) && peek(l) != '\n' do advance(l)
} else {
return
}
case:
return
}
}
}
@(private = "file")
make_token :: proc(l: ^Lexer, kind: Token_Kind, lexeme: string) -> Token {
return Token{kind = kind, lexeme = lexeme, line = l.start_line, column = l.start_column}
}
@(private = "file")
scan_number :: proc(l: ^Lexer) -> Token {
start := l.pos
for !is_at_end(l) && is_digit(peek(l)) do advance(l)
if !is_at_end(l) && peek(l) == '.' && is_digit(peek_next(l)) {
advance(l) // consume '.'
for !is_at_end(l) && is_digit(peek(l)) do advance(l)
}
return make_token(l, .Number, l.src[start:l.pos])
}
@(private = "file")
scan_string :: proc(l: ^Lexer) -> Token {
start := l.pos // just past the opening quote
for !is_at_end(l) && peek(l) != '"' {
if peek(l) == '\\' && peek_next(l) != 0 {
advance(l) // the backslash
}
advance(l)
}
if is_at_end(l) {
return make_token(l, .Illegal, "unterminated string")
}
lexeme := l.src[start:l.pos]
advance(l) // the closing quote
return make_token(l, .String, lexeme)
}
@(private = "file")
scan_ident :: proc(l: ^Lexer) -> Token {
start := l.pos
for !is_at_end(l) && is_alnum(peek(l)) do advance(l)
lexeme := l.src[start:l.pos]
if kind, ok := keyword_kind(lexeme); ok {
return make_token(l, kind, lexeme)
}
return make_token(l, .Ident, lexeme)
}
// scan_token returns the next token, advancing the lexer past it. Callers scan until they
// see a .EOF token.
scan_token :: proc(l: ^Lexer) -> Token {
skip_whitespace_and_comments(l)
l.start_line = l.line
l.start_column = l.column
if is_at_end(l) {
return make_token(l, .EOF, "")
}
c := advance(l)
if is_digit(c) {
l.pos -= 1
l.column -= 1
return scan_number(l)
}
if is_alpha(c) {
l.pos -= 1
l.column -= 1
return scan_ident(l)
}
switch c {
case '(':
return make_token(l, .Left_Paren, "(")
case ')':
return make_token(l, .Right_Paren, ")")
case '{':
return make_token(l, .Left_Brace, "{")
case '}':
return make_token(l, .Right_Brace, "}")
case '[':
return make_token(l, .Left_Bracket, "[")
case ']':
return make_token(l, .Right_Bracket, "]")
case ',':
return make_token(l, .Comma, ",")
case ';':
return make_token(l, .Semicolon, ";")
case '.':
return make_token(l, .Dot, ".")
case ':':
return make_token(l, .Colon, ":")
case '+':
return make_token(l, .Plus, "+")
case '-':
return make_token(l, .Minus, "-")
case '*':
return make_token(l, .Star, "*")
case '/':
return make_token(l, .Slash, "/")
case '%':
return make_token(l, .Percent, "%")
case '!':
if match(l, '=') do return make_token(l, .Not_Equal, "!=")
return make_token(l, .Bang, "!")
case '=':
if match(l, '=') do return make_token(l, .Equal, "==")
return make_token(l, .Assign, "=")
case '<':
if match(l, '=') do return make_token(l, .Less_Equal, "<=")
return make_token(l, .Less, "<")
case '>':
if match(l, '=') do return make_token(l, .Greater_Equal, ">=")
return make_token(l, .Greater, ">")
case '&':
if match(l, '&') do return make_token(l, .And_And, "&&")
return make_token(l, .Illegal, "unexpected '&'")
case '|':
if match(l, '|') do return make_token(l, .Or_Or, "||")
return make_token(l, .Illegal, "unexpected '|'")
case '"':
return scan_string(l)
}
return make_token(l, .Illegal, l.src[l.pos - 1:l.pos])
}
// ends_statement lists the token kinds that can be the last token of a statement: exactly the
// kinds automatic-semicolon-insertion (see lex_all) is willing to insert after. This mirrors
// Go's own ASI rule almost exactly, including the inclusion of `return`: a bare `return` on its
// own line ends the statement right there, which is why a return value has to start on the same
// line as `return` itself (section 2.6 walks through why).
@(private = "file")
ends_statement :: proc(kind: Token_Kind) -> bool {
#partial switch kind {
case .Ident, .Number, .String, .True, .False, .Nil, .Right_Paren, .Right_Brace, .Right_Bracket, .Return, .Yield:
return true
}
return false
}
// lex_all scans src end to end, returning every token including the trailing .EOF, and
// automatically inserting a `.Semicolon` token wherever a newline (or end of file) follows a
// token `ends_statement` says can end one. This is Muspel's automatic semicolon insertion:
// everything downstream of the lexer, the parser's `consume(.Semicolon, ...)` calls included,
// never needs to know whether a given `.Semicolon` token was typed or inserted.
lex_all :: proc(src: string, allocator := context.allocator) -> []Token {
l := lexer_init(src)
tokens := make([dynamic]Token, allocator)
end_line := l.line
for {
tok := scan_token(&l)
if len(tokens) > 0 {
prev := tokens[len(tokens) - 1]
crossed_newline := tok.line > end_line
if (crossed_newline || tok.kind == .EOF) && ends_statement(prev.kind) {
append(
&tokens,
Token {
kind = .Semicolon,
lexeme = "",
line = end_line,
column = prev.column + len(prev.lexeme),
},
)
}
}
append(&tokens, tok)
end_line = l.line
if tok.kind == .EOF do break
}
return tokens[:]
}
muspel/ast.odin
package muspel
// Every AST node is its own distinct struct, not one generic "Node" with an `any` payload:
// the parser and every later pass (resolver, tree-walker, compiler) gets exhaustive-switch
// checking from the compiler for free, and each node carries exactly the fields its own
// construct needs. Expr and Stmt are tagged unions of pointers to those structs.
Expr :: union {
^Expr_Literal,
^Expr_Ident,
^Expr_Group,
^Expr_Unary,
^Expr_Binary,
^Expr_Logical,
^Expr_Call,
^Expr_Assign,
^Expr_Struct_Literal,
^Expr_Field_Access,
^Expr_Field_Assign,
^Expr_Array_Literal,
^Expr_Index,
^Expr_Index_Assign,
^Expr_Func,
}
Stmt :: union {
^Stmt_Var,
^Stmt_Func,
^Stmt_Expr,
^Stmt_If,
^Stmt_While,
^Stmt_For_In,
^Stmt_Return,
^Stmt_Yield,
^Stmt_Block,
^Stmt_Struct_Decl,
^Stmt_Enum_Decl,
^Stmt_Import,
^Stmt_Multi,
}
// Literal_Value holds a parsed literal: a number, a string, a bool, or nil.
Literal_Value :: union {
f64,
string,
bool,
}
Expr_Literal :: struct {
line: int,
value: Literal_Value,
}
// Expr_Ident is a variable reference. depth/slot start unresolved (-1) and are filled in by
// the Chapter 7 resolver: depth counts scopes outward from the reference to its declaration
// (0 = innermost), and slot is that scope's local-array index. depth == -1 after resolution
// means "global".
Expr_Ident :: struct {
line: int,
name: string,
depth: int,
slot: int,
}
Expr_Group :: struct {
line: int,
inner: Expr,
}
Expr_Unary :: struct {
line: int,
op: Token_Kind,
right: Expr,
}
Expr_Binary :: struct {
line: int,
op: Token_Kind,
left: Expr,
right: Expr,
}
// Expr_Logical is && / ||, kept distinct from Expr_Binary because it short-circuits: the
// right operand isn't evaluated (or compiled) unconditionally the way arithmetic is.
Expr_Logical :: struct {
line: int,
op: Token_Kind,
left: Expr,
right: Expr,
}
Expr_Call :: struct {
line: int,
callee: Expr,
args: []Expr,
}
// Expr_Assign is `name = value`. Only plain identifiers are valid assignment targets for
// now; depth/slot are filled in by the resolver exactly as in Expr_Ident.
Expr_Assign :: struct {
line: int,
name: string,
depth: int,
slot: int,
value: Expr,
}
// Stmt_Var declares a new local (or, at the top level, global) binding. depth/slot are
// filled in by the resolver exactly as in Expr_Ident. `is_const` marks a binding introduced with
// `const` rather than `var`: it changes nothing at runtime (a constant is stored and read exactly
// like a variable), it only tells the resolver to reject any later assignment to the name. The
// parser also requires a `const` to have an initialiser, since a binding that can never be assigned
// is useless without one.
Stmt_Var :: struct {
line: int,
name: string,
init: Expr, // nil union value if the declaration had no initialiser
depth: int,
slot: int,
is_const: bool,
}
// Stmt_Func is a `func name(params) { body }` declaration. `depth` mirrors Stmt_Var's: -1 for a
// top-level function (bound as a global, hoisted), 0 for a nested one (an engine-era extension,
// see Expr_Func) which is bound as an ordinary local in its enclosing scope. Filled by the
// resolver.
Stmt_Func :: struct {
line: int,
name: string,
params: []string,
body: ^Stmt_Block,
depth: int,
}
// Expr_Func is an anonymous function expression, `func(params) { body }`, used as a value (bound
// to a var, passed as an argument, stored in a struct field). It is the engine era's closure
// syntax (PLAN.md M6): together with nested `Stmt_Func`, it reverses the book's original
// "functions are top-level and capture nothing" restriction. The bytecode compiler snapshots the
// free variables it reads from enclosing functions by value at the point the expression evaluates;
// because Muspel structs and arrays are references, capturing an entity by value still shares its
// mutable state, which is the whole point (an `on_death` handler that closes over `self`).
Expr_Func :: struct {
line: int,
params: []string,
body: ^Stmt_Block,
}
Stmt_Expr :: struct {
line: int,
expr: Expr,
}
Stmt_If :: struct {
line: int,
cond: Expr,
then_branch: Stmt,
else_branch: Stmt, // nil union value if there was no `else`
}
Stmt_While :: struct {
line: int,
cond: Expr,
body: Stmt,
}
// Stmt_For_In is `for item in list { ... }`: item is a new binding, scoped exactly like a
// Stmt_Var, holding one element of `list` per iteration. depth/slot are filled in by the
// resolver exactly as in Stmt_Var; there is no separate index variable in the grammar.
Stmt_For_In :: struct {
line: int,
item_name: string,
list: Expr,
body: Stmt,
depth: int,
slot: int,
}
Stmt_Return :: struct {
line: int,
value: Expr, // nil union value for a bare `return`
}
// Stmt_Yield is Chapter 15's one new statement, and its bare presence in the grammar is the
// whole point: it carries no operands, no expression, nothing but the line it happened on.
// Everything `wait`/`wait_until` need beyond this is built as ordinary Muspel functions, not
// more grammar.
Stmt_Yield :: struct {
line: int,
}
Stmt_Block :: struct {
line: int,
stmts: []Stmt,
}
// Stmt_Multi is several statements occupying one enclosing statement slot *without* introducing a
// scope, which is exactly what distinguishes it from Stmt_Block. The parser emits it for a
// comma-separated `var a = 1, b = 2`: one `var` keyword desugars to one Stmt_Var per binding, run in
// sequence in the current scope, so `b` lands in the same scope `a` does. It is purely a parser
// convenience; no source construct other than a multi-binding `var` produces one, and every pass
// handles it by simply processing each child statement in turn.
Stmt_Multi :: struct {
line: int,
stmts: []Stmt,
}
// Stmt_Struct_Decl declares a plain-data record type: a name and an ordered list of field
// names, no declared field types (Muspel is dynamically typed at the value level) and no
// methods (Chapter 7's Design decision: data and behaviour stay separate, mirroring Odin's own
// struct philosophy).
Stmt_Struct_Decl :: struct {
line: int,
name: string,
fields: []string,
}
// Stmt_Enum_Decl declares a named, closed set of tagged values. Members carry no associated
// payload (Chapter 7's Why callout): a struct composes "a tag plus data" when that's needed,
// so an enum itself stays a plain integer tag under the hood.
Stmt_Enum_Decl :: struct {
line: int,
name: string,
members: []string,
}
// Stmt_Import is `import "path" as alias` (Chapter 20): a path, relative to a project-defined
// script root, and a mandatory local alias. Qualified imports only, no bare/unqualified names
// (Chapter 20's own Design decision): every reference to something this file imports is written
// `alias.Name`, never a bare `Name` that could silently collide with another import or a local
// declaration.
Stmt_Import :: struct {
line: int,
path: string,
alias: string,
}
// Expr_Struct_Literal is `Vec2{ x: 1, y: 2 }`: keyed-field construction, chosen (Chapter 7,
// section 3) over positional construction so fields can be reordered or omitted at a call site
// without every call site needing to change. field_indices[i] is the declaring struct's
// resolved index for keys[i], filled in by the resolver; -1 until then.
// all_fields is the declaring struct's full, ordered field list (not just the keys this
// particular literal provided), filled in by the resolver alongside field_indices: the compiler
// needs it to size a MAKE_STRUCT instance correctly and to give it a field-name table, since an
// omitted field still needs a slot (defaulting to nil).
Expr_Struct_Literal :: struct {
line: int,
type_name: string,
keys: []string,
values: []Expr,
field_indices: []int,
all_fields: []string,
}
// Expr_Field_Access is `.field`, a postfix operator alongside function calls in the precedence
// climb (Chapter 7, section 4), so `enemy.pos.x` chains exactly like `f(x)(y)`. The same node
// shape covers an enum member reference (`Phase.Attacking`): the parser can't tell the two
// apart (both are `ident '.' ident`), so the resolver disambiguates and, for an enum member,
// sets is_enum_member/enum_tag instead of field_index.
Expr_Field_Access :: struct {
line: int,
object: Expr,
field: string,
field_index: int,
is_enum_member: bool,
enum_tag: int,
// is_qualified_ref/qualified_name mark `alias.name` referring to a function imported from
// another module (Chapter 20): resolved once, against the importing module's own resolver
// (which copied the imported module's functions in under `alias.`), rather than treated as a
// runtime field access on a struct value named `alias`.
is_qualified_ref: bool,
qualified_name: string,
}
// Expr_Field_Assign is `obj.field = value`, Expr_Assign's sibling for a field target.
Expr_Field_Assign :: struct {
line: int,
object: Expr,
field: string,
field_index: int,
value: Expr,
}
// Expr_Array_Literal is `[1, 2, 3]`: a fixed-capacity array, sized once at construction
// (Chapter 8's Design decision: no `append`, no silent reallocation).
Expr_Array_Literal :: struct {
line: int,
elements: []Expr,
}
// Expr_Index is `list[i]`, a postfix operator alongside `.field` and calls. The index is a
// compiled sub-expression, not a compile-time constant, unlike a struct field's resolved index.
Expr_Index :: struct {
line: int,
object: Expr,
index: Expr,
}
// Expr_Index_Assign is `list[i] = value`, Expr_Assign's sibling for an index target.
Expr_Index_Assign :: struct {
line: int,
object: Expr,
index: Expr,
value: Expr,
}
muspel/parser.odin
package muspel
import "base:runtime"
import "core:strconv"
import "core:strings"
Parse_Error :: struct {
line: int,
column: int,
message: string,
}
Parser :: struct {
tokens: []Token,
pos: int,
errors: [dynamic]Parse_Error,
// no_struct_literal suppresses treating `Ident {` as a struct literal while parsing a
// for-in loop's `list` expression, which is immediately followed by the loop's own
// mandatory `{`: the same ambiguity Go's grammar has with `if`/`for` headers, resolved the
// same way Go resolves it (see parse_for_in_stmt).
no_struct_literal: bool,
}
parser_init :: proc(tokens: []Token, allocator := context.allocator) -> Parser {
return Parser{tokens = tokens, pos = 0, errors = make([dynamic]Parse_Error, allocator)}
}
@(private = "file")
peek :: proc(p: ^Parser) -> Token {
return p.tokens[p.pos]
}
@(private = "file")
previous :: proc(p: ^Parser) -> Token {
return p.tokens[p.pos - 1]
}
@(private = "file")
is_at_end :: proc(p: ^Parser) -> bool {
return peek(p).kind == .EOF
}
@(private = "file")
check :: proc(p: ^Parser, kind: Token_Kind) -> bool {
return !is_at_end(p) && peek(p).kind == kind
}
// check_next peeks one token past the current one, without consuming anything. It is the single
// point of lookahead the grammar needs to tell a `func name(...)` declaration apart from a bare
// `func(...)` anonymous-function expression at the start of a block statement.
@(private = "file")
check_next :: proc(p: ^Parser, kind: Token_Kind) -> bool {
if is_at_end(p) do return false
if p.pos + 1 >= len(p.tokens) do return false
return p.tokens[p.pos + 1].kind == kind
}
@(private = "file")
advance :: proc(p: ^Parser) -> Token {
if !is_at_end(p) do p.pos += 1
return previous(p)
}
@(private = "file")
match :: proc(p: ^Parser, kind: Token_Kind) -> bool {
if !check(p, kind) do return false
advance(p)
return true
}
@(private = "file")
parse_error :: proc(p: ^Parser, tok: Token, message: string) {
append(&p.errors, Parse_Error{line = tok.line, column = tok.column, message = message})
}
// consume expects the next token to be `kind`; if it isn't, it records a Parse_Error and
// returns the unexpected token anyway, so the caller can keep building a (partial) AST node
// rather than aborting the whole parse.
@(private = "file")
consume :: proc(p: ^Parser, kind: Token_Kind, message: string) -> Token {
if check(p, kind) do return advance(p)
parse_error(p, peek(p), message)
return peek(p)
}
// synchronize implements panic-mode recovery: after a statement-level parse error, skip
// tokens until we're likely at the start of the next statement, rather than stopping at the
// first error. This is what lets `musc parse` report every syntax error in a file in one
// pass instead of just the first.
@(private = "file")
synchronize :: proc(p: ^Parser) {
for !is_at_end(p) {
if previous(p).kind == .Semicolon do return
#partial switch peek(p).kind {
case .Var, .Func, .If, .While, .For, .Struct, .Enum, .Import, .Return, .Left_Brace:
return
}
advance(p)
}
}
// parse_program parses a whole file to a slice of top-level statements. Even a file full of
// syntax errors returns (a partial AST, one Parse_Error per problem found), never a single
// aborted parse.
parse_program :: proc(p: ^Parser, allocator := context.allocator) -> []Stmt {
stmts := make([dynamic]Stmt, allocator)
for !is_at_end(p) {
if match(p, .Semicolon) do continue
before := len(p.errors)
s := parse_declaration(p, allocator)
if len(p.errors) > before {
synchronize(p)
}
if s != nil do append(&stmts, s)
}
return stmts[:]
}
@(private = "file")
parse_declaration :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
if match(p, .Var) do return parse_var_decl(p, allocator)
if match(p, .Const) do return parse_var_decl(p, allocator, true)
if match(p, .Func) do return parse_func_decl(p, allocator)
if match(p, .Struct) do return parse_struct_decl(p, allocator)
if match(p, .Enum) do return parse_enum_decl(p, allocator)
if match(p, .Import) do return parse_import_decl(p, allocator)
return parse_statement(p, allocator)
}
// parse_import_decl parses `import "path" as alias`.
@(private = "file")
parse_import_decl :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
line := previous(p).line
path_tok := consume(p, .String, "expected a module path string after 'import'")
consume(p, .As, "expected 'as' after import path")
alias_tok := consume(p, .Ident, "expected an alias name after 'as'")
consume(p, .Semicolon, "expected ';' after import")
node := new(Stmt_Import, allocator)
node^ = Stmt_Import{line = line, path = path_tok.lexeme, alias = alias_tok.lexeme}
return node
}
// parse_block_declaration is parse_declaration's sibling for inside a block. The book's original
// grammar rejected a nested `func` here (functions were top-level only); the engine era (PLAN.md
// M6) allows a nested named `func name(...)` declaration, bound as an ordinary local in the
// enclosing scope and able to close over enclosing variables. A bare `func(` (no name) is instead
// an anonymous-function *expression* and falls through to the statement/expression path below, so
// this only claims the keyword when a name actually follows it.
@(private = "file")
parse_block_declaration :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
if match(p, .Var) do return parse_var_decl(p, allocator)
if match(p, .Const) do return parse_var_decl(p, allocator, true)
if check(p, .Func) && check_next(p, .Ident) {
advance(p) // consume `func`
return parse_func_decl(p, allocator)
}
if check(p, .Struct) {
parse_error(p, peek(p), "structs can only be declared at the top level")
advance(p)
return parse_struct_decl(p, allocator)
}
if check(p, .Enum) {
parse_error(p, peek(p), "enums can only be declared at the top level")
advance(p)
return parse_enum_decl(p, allocator)
}
if check(p, .Import) {
parse_error(p, peek(p), "imports can only be declared at the top level")
advance(p)
return parse_import_decl(p, allocator)
}
return parse_statement(p, allocator)
}
// parse_struct_decl parses `struct Name { field, field, ... }`: field names only, comma
// separated, no declared types (see the AST doc comment).
@(private = "file")
parse_struct_decl :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
line := previous(p).line
name_tok := consume(p, .Ident, "expected a struct name after 'struct'")
consume(p, .Left_Brace, "expected '{' after struct name")
fields := make([dynamic]string, allocator)
if !check(p, .Right_Brace) {
for {
field_tok := consume(p, .Ident, "expected a field name")
append(&fields, field_tok.lexeme)
if !match(p, .Comma) do break
}
}
consume(p, .Right_Brace, "expected '}' after struct fields")
node := new(Stmt_Struct_Decl, allocator)
node^ = Stmt_Struct_Decl{line = line, name = name_tok.lexeme, fields = fields[:]}
return node
}
// parse_enum_decl parses `enum Name { Member, Member, ... }`: a closed set of names, no
// associated payload (see the AST doc comment).
@(private = "file")
parse_enum_decl :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
line := previous(p).line
name_tok := consume(p, .Ident, "expected an enum name after 'enum'")
consume(p, .Left_Brace, "expected '{' after enum name")
members := make([dynamic]string, allocator)
if !check(p, .Right_Brace) {
for {
member_tok := consume(p, .Ident, "expected a member name")
append(&members, member_tok.lexeme)
if !match(p, .Comma) do break
}
}
consume(p, .Right_Brace, "expected '}' after enum members")
node := new(Stmt_Enum_Decl, allocator)
node^ = Stmt_Enum_Decl{line = line, name = name_tok.lexeme, members = members[:]}
return node
}
// parse_var_decl parses both `var` and `const` declarations; `is_const` selects which, having been
// decided by the caller from the keyword it matched. The two are identical to parse except that a
// `const` binding must have an initialiser (a name that can never be assigned is useless empty), and
// the Stmt_Var it produces carries `is_const` for the resolver to enforce (nothing else cares).
@(private = "file")
parse_var_decl :: proc(p: ^Parser, allocator: runtime.Allocator, is_const := false) -> Stmt {
line := previous(p).line
// One `var`/`const` keyword may introduce several comma-separated bindings: `var a = 1, b = 2, c`.
// Each is parsed into its own Stmt_Var (so the resolver and compiler treat them exactly as if
// written on separate lines), and the single terminator is consumed once, after the last. A lone
// binding returns the Stmt_Var directly; two or more are wrapped in a Stmt_Multi that runs them in
// sequence in the current scope. The commas here are never ambiguous with an expression's own
// commas (call arguments, array and struct-literal elements): those are all consumed inside
// parse_expression before it returns, so any comma still standing belongs to the binding list.
decls := make([dynamic]Stmt, allocator)
for {
name_tok := consume(p, .Ident, "expected a variable name after 'var'")
init: Expr
if match(p, .Assign) {
init = parse_expression(p, allocator)
} else if is_const {
parse_error(p, name_tok, "a constant must be initialised, e.g. `const name = value`")
}
node := new(Stmt_Var, allocator)
node^ = Stmt_Var{line = name_tok.line, name = name_tok.lexeme, init = init, depth = -1, slot = -1, is_const = is_const}
append(&decls, node)
if !match(p, .Comma) do break
}
consume_statement_end(p, "expected ';' after variable declaration")
if len(decls) == 1 do return decls[0]
multi := new(Stmt_Multi, allocator)
multi^ = Stmt_Multi{line = line, stmts = decls[:]}
return multi
}
// parse_params_and_body parses the `(params) { body }` tail shared by a named `func` declaration
// and an anonymous `func(...)` expression, called once the `func` keyword (and, for a declaration,
// the name) has been consumed.
@(private = "file")
parse_params_and_body :: proc(p: ^Parser, allocator: runtime.Allocator) -> (params: []string, body: ^Stmt_Block) {
consume(p, .Left_Paren, "expected '(' after function parameters start")
ps := make([dynamic]string, allocator)
if !check(p, .Right_Paren) {
for {
param_tok := consume(p, .Ident, "expected a parameter name")
append(&ps, param_tok.lexeme)
if !match(p, .Comma) do break
}
}
consume(p, .Right_Paren, "expected ')' after parameters")
body = parse_block(p, allocator)
return ps[:], body
}
@(private = "file")
parse_func_decl :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
line := previous(p).line
name_tok := consume(p, .Ident, "expected a function name after 'func'")
params, body := parse_params_and_body(p, allocator)
node := new(Stmt_Func, allocator)
node^ = Stmt_Func{line = line, name = name_tok.lexeme, params = params, body = body, depth = -1}
return node
}
@(private = "file")
parse_statement :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
if match(p, .If) do return parse_if_stmt(p, allocator)
if match(p, .While) do return parse_while_stmt(p, allocator)
if match(p, .For) do return parse_for_stmt(p, allocator)
if match(p, .Return) do return parse_return_stmt(p, allocator)
if match(p, .Yield) do return parse_yield_stmt(p, allocator)
if check(p, .Left_Brace) {
block := parse_block(p, allocator)
return block
}
return parse_expr_stmt(p, allocator)
}
// Design decision: if/while bodies must be blocks (`{ ... }`), not bare statements. A
// language that allows `if (x) do_thing();` needs a rule for the classic dangling-else
// ambiguity and gains very little: requiring braces removes the ambiguity outright and
// keeps every control-flow body a Stmt_Block, which the Chapter 11 compiler's jump patching
// leans on directly.
@(private = "file")
parse_if_stmt :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
line := previous(p).line
consume(p, .Left_Paren, "expected '(' after 'if'")
cond := parse_expression(p, allocator)
consume(p, .Right_Paren, "expected ')' after if condition")
then_branch := parse_block(p, allocator)
else_branch: Stmt
if match(p, .Else) {
if check(p, .If) {
advance(p)
else_branch = parse_if_stmt(p, allocator)
} else {
else_branch = parse_block(p, allocator)
}
}
node := new(Stmt_If, allocator)
node^ = Stmt_If{line = line, cond = cond, then_branch = then_branch, else_branch = else_branch}
return node
}
@(private = "file")
parse_while_stmt :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
line := previous(p).line
consume(p, .Left_Paren, "expected '(' after 'while'")
cond := parse_expression(p, allocator)
consume(p, .Right_Paren, "expected ')' after while condition")
body := parse_block(p, allocator)
node := new(Stmt_While, allocator)
node^ = Stmt_While{line = line, cond = cond, body = body}
return node
}
// parse_for_stmt dispatches Muspel's one `for` keyword to either of its two forms, distinguished
// by nothing but the very next token: `for (` is the counting form (section 4.5), everything else
// is `for item in list` (Chapter 8).
@(private = "file")
parse_for_stmt :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
if check(p, .Left_Paren) do return parse_counting_for_stmt(p, allocator)
return parse_for_in_stmt(p, allocator)
}
// parse_counting_for_stmt parses `for (init; cond; step) { body }`, deliberately introduced as
// pure sugar over machinery the grammar already has: it desugars right here, at parse time, into
// `{ init; while (cond) { body; step; } }`, using nothing but Stmt_Block, Stmt_While, Stmt_Var,
// and Stmt_Expr, no new AST node category at all (section 4.5's own Why callout: there's no real
// design fork here, just a convenience form worth having). Any of the three clauses may be empty;
// an empty `cond` means "loop forever," exactly like C's own `for (;;)`.
@(private = "file")
parse_counting_for_stmt :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
line := previous(p).line
consume(p, .Left_Paren, "expected '(' after 'for'")
init: Stmt
if !check(p, .Semicolon) {
if match(p, .Var) {
init = parse_var_decl(p, allocator)
} else {
init = parse_expr_stmt(p, allocator)
}
} else {
consume(p, .Semicolon, "expected ';' after for-loop initialiser")
}
cond: Expr
if !check(p, .Semicolon) {
cond = parse_expression(p, allocator)
}
consume(p, .Semicolon, "expected ';' after for-loop condition")
step: Expr
if !check(p, .Right_Paren) {
step = parse_expression(p, allocator)
}
consume(p, .Right_Paren, "expected ')' after for-loop clauses")
body := parse_block(p, allocator)
inner_stmts := make([dynamic]Stmt, allocator)
append(&inner_stmts, body)
if step != nil {
step_stmt := new(Stmt_Expr, allocator)
step_stmt^ = Stmt_Expr{line = line, expr = step}
append(&inner_stmts, step_stmt)
}
inner_block := new(Stmt_Block, allocator)
inner_block^ = Stmt_Block{line = line, stmts = inner_stmts[:]}
cond_expr := cond
if cond_expr == nil {
true_lit := new(Expr_Literal, allocator)
true_lit^ = Expr_Literal{line = line, value = true}
cond_expr = true_lit
}
while_stmt := new(Stmt_While, allocator)
while_stmt^ = Stmt_While{line = line, cond = cond_expr, body = inner_block}
outer_stmts := make([dynamic]Stmt, allocator)
if init != nil do append(&outer_stmts, init)
append(&outer_stmts, while_stmt)
outer_block := new(Stmt_Block, allocator)
outer_block^ = Stmt_Block{line = line, stmts = outer_stmts[:]}
return outer_block
}
// parse_for_in_stmt parses `for item in list { ... }`. The list expression is parsed with
// no_struct_literal set, so `for e in Foo { ... }` reads `Foo` as a plain identifier and `{`
// as the loop's own body, never as a `Foo{...}` struct literal swallowing the body: the same
// ambiguity Go's grammar has with an unparenthesised `if`/`for` header, resolved the same way.
@(private = "file")
parse_for_in_stmt :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
line := previous(p).line
item_tok := consume(p, .Ident, "expected a loop variable name after 'for'")
consume(p, .In, "expected 'in' after loop variable name")
saved := p.no_struct_literal
p.no_struct_literal = true
list := parse_expression(p, allocator)
p.no_struct_literal = saved
body := parse_block(p, allocator)
node := new(Stmt_For_In, allocator)
node^ = Stmt_For_In{line = line, item_name = item_tok.lexeme, list = list, body = body, depth = -1, slot = -1}
return node
}
@(private = "file")
parse_return_stmt :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
line := previous(p).line
value: Expr
// A value is optional. It is absent when the next token already ends the statement: a ';'
// (or the ASI-inserted one at a newline), or the block's own '}' when the return sits on the
// same line as it (`{ return }` / `if (c) { return v }`).
if !check(p, .Semicolon) && !check(p, .Right_Brace) {
value = parse_expression(p, allocator)
}
consume_statement_end(p, "expected ';' after return value")
node := new(Stmt_Return, allocator)
node^ = Stmt_Return{line = line, value = value}
return node
}
@(private = "file")
parse_yield_stmt :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
line := previous(p).line
consume_statement_end(p, "expected ';' after 'yield'")
node := new(Stmt_Yield, allocator)
node^ = Stmt_Yield{line = line}
return node
}
@(private = "file")
parse_block :: proc(p: ^Parser, allocator: runtime.Allocator) -> ^Stmt_Block {
line := peek(p).line
consume(p, .Left_Brace, "expected '{'")
stmts := make([dynamic]Stmt, allocator)
for !check(p, .Right_Brace) && !is_at_end(p) {
if match(p, .Semicolon) do continue
before := len(p.errors)
s := parse_block_declaration(p, allocator)
if len(p.errors) > before {
synchronize(p)
}
if s != nil do append(&stmts, s)
}
consume(p, .Right_Brace, "expected '}' after block")
node := new(Stmt_Block, allocator)
node^ = Stmt_Block{line = line, stmts = stmts[:]}
return node
}
// consume_statement_end accepts the semicolon that terminates a statement, but allows it to be
// omitted immediately before a closing '}'. This mirrors Go's rule that a semicolon may be omitted
// before a closing ')' or '}', and is what lets a whole block body sit on one line, e.g.
// `if (c) { x = 1 }` or `func f() { return v }`: automatic semicolon insertion (Chapter 2) still
// supplies a semicolon at any newline, so this only matters when the final statement and the
// block's '}' share a line. The '}' itself is left for parse_block to consume; here it just stands
// in for the missing separator. Every statement terminator goes through here (expression, var,
// return, yield), so the one-line-block convenience applies uniformly; struct and array literals,
// whose '}' is not a block's, are unaffected.
@(private = "file")
consume_statement_end :: proc(p: ^Parser, message: string) {
if match(p, .Semicolon) do return
if check(p, .Right_Brace) do return
consume(p, .Semicolon, message)
}
@(private = "file")
parse_expr_stmt :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
line := peek(p).line
expr := parse_expression(p, allocator)
consume_statement_end(p, "expected ';' after expression")
node := new(Stmt_Expr, allocator)
node^ = Stmt_Expr{line = line, expr = expr}
return node
}
// --- Expressions: assignment, then precedence climbing over everything else. ---
parse_expression :: proc(p: ^Parser, allocator: runtime.Allocator) -> Expr {
return parse_assignment(p, allocator)
}
// Assignment sits outside the precedence-climbing loop below, at the lowest precedence and
// right-associative, exactly as in most C-family grammars: parse a full expression, and if
// it's followed by '=' and the expression we just parsed was a plain identifier, reinterpret
// it as an assignment target instead of a value.
@(private = "file")
parse_assignment :: proc(p: ^Parser, allocator: runtime.Allocator) -> Expr {
left := parse_precedence(p, allocator, 1)
if match(p, .Assign) {
eq := previous(p)
value := parse_assignment(p, allocator)
#partial switch target in left {
case ^Expr_Ident:
node := new(Expr_Assign, allocator)
node^ = Expr_Assign{line = eq.line, name = target.name, depth = -1, slot = -1, value = value}
return node
case ^Expr_Field_Access:
node := new(Expr_Field_Assign, allocator)
node^ = Expr_Field_Assign {
line = eq.line,
object = target.object,
field = target.field,
field_index = -1,
value = value,
}
return node
case ^Expr_Index:
node := new(Expr_Index_Assign, allocator)
node^ = Expr_Index_Assign{line = eq.line, object = target.object, index = target.index, value = value}
return node
case:
parse_error(p, eq, "invalid assignment target")
}
}
return left
}
// infix_binding_power is Muspel's entire precedence table: higher binds tighter. This one
// table, read by the single loop in parse_precedence below, is what a precedence-climbing
// parser buys over a separate grammar rule (and a separate parser function) per level: adding
// a new binary operator is one line here, not a new function.
@(private = "file")
infix_binding_power :: proc(kind: Token_Kind) -> (bp: int, ok: bool) {
#partial switch kind {
case .Or_Or:
return 1, true
case .And_And:
return 2, true
case .Equal, .Not_Equal:
return 3, true
case .Less, .Less_Equal, .Greater, .Greater_Equal:
return 4, true
case .Plus, .Minus:
return 5, true
case .Star, .Slash, .Percent:
return 6, true
}
return 0, false
}
@(private = "file")
parse_precedence :: proc(p: ^Parser, allocator: runtime.Allocator, min_bp: int) -> Expr {
left := parse_unary(p, allocator)
for {
op := peek(p)
bp, ok := infix_binding_power(op.kind)
if !ok || bp < min_bp do break
advance(p)
// Left-associative: the recursive call requires strictly higher binding power than
// the operator just consumed, so equal-precedence operators to the right start a new
// (shallower) climb instead of nesting under this one.
right := parse_precedence(p, allocator, bp + 1)
if op.kind == .And_And || op.kind == .Or_Or {
node := new(Expr_Logical, allocator)
node^ = Expr_Logical{line = op.line, op = op.kind, left = left, right = right}
left = node
} else {
node := new(Expr_Binary, allocator)
node^ = Expr_Binary{line = op.line, op = op.kind, left = left, right = right}
left = node
}
}
return left
}
@(private = "file")
parse_unary :: proc(p: ^Parser, allocator: runtime.Allocator) -> Expr {
#partial switch peek(p).kind {
case .Bang, .Minus:
op := advance(p)
right := parse_unary(p, allocator)
node := new(Expr_Unary, allocator)
node^ = Expr_Unary{line = op.line, op = op.kind, right = right}
return node
}
return parse_call(p, allocator)
}
// Function calls, `.field` access, and `[index]` access are all parsed as one postfix loop
// after the primary expression, so `f(1)(2)`, `enemy.pos.x`, and `list[i][j]` (and any mix of
// the three) fall out of the same loop rather than needing their own grammar rules (Chapter 7
// section 4, Chapter 8 section 2).
@(private = "file")
parse_call :: proc(p: ^Parser, allocator: runtime.Allocator) -> Expr {
expr := parse_primary(p, allocator)
for {
if match(p, .Left_Paren) {
line := previous(p).line
args := make([dynamic]Expr, allocator)
if !check(p, .Right_Paren) {
for {
append(&args, parse_expression(p, allocator))
if !match(p, .Comma) do break
}
}
consume(p, .Right_Paren, "expected ')' after arguments")
node := new(Expr_Call, allocator)
node^ = Expr_Call{line = line, callee = expr, args = args[:]}
expr = node
} else if match(p, .Dot) {
line := previous(p).line
field_tok := consume(p, .Ident, "expected a field name after '.'")
// `alias.Type{...}` is a qualified struct literal (Chapter 20): the same
// bare-identifier-then-brace rule parse_primary's own struct literal uses, extended
// one dot further, and only when `expr` so far is a single plain identifier (the
// import alias), not an arbitrary chain.
if ident, is_ident := expr.(^Expr_Ident); is_ident && !p.no_struct_literal && check(p, .Left_Brace) {
qualified := strings.concatenate({ident.name, ".", field_tok.lexeme}, allocator)
expr = parse_struct_literal(p, allocator, Token{kind = .Ident, lexeme = qualified, line = line})
continue
}
node := new(Expr_Field_Access, allocator)
node^ = Expr_Field_Access{line = line, object = expr, field = field_tok.lexeme, field_index = -1, enum_tag = -1}
expr = node
} else if match(p, .Left_Bracket) {
line := previous(p).line
saved := p.no_struct_literal
p.no_struct_literal = false
index := parse_expression(p, allocator)
p.no_struct_literal = saved
consume(p, .Right_Bracket, "expected ']' after index")
node := new(Expr_Index, allocator)
node^ = Expr_Index{line = line, object = expr, index = index}
expr = node
} else {
break
}
}
return expr
}
// parse_struct_literal parses `TypeName{ key: value, key: value, ... }`, called once
// parse_primary has already peeked the type name identifier and the following '{'.
@(private = "file")
parse_struct_literal :: proc(p: ^Parser, allocator: runtime.Allocator, type_tok: Token) -> Expr {
consume(p, .Left_Brace, "expected '{' after struct type name")
saved := p.no_struct_literal
p.no_struct_literal = false
keys := make([dynamic]string, allocator)
values := make([dynamic]Expr, allocator)
if !check(p, .Right_Brace) {
for {
key_tok := consume(p, .Ident, "expected a field name")
consume(p, .Colon, "expected ':' after field name")
append(&keys, key_tok.lexeme)
append(&values, parse_expression(p, allocator))
if !match(p, .Comma) do break
}
}
p.no_struct_literal = saved
consume(p, .Right_Brace, "expected '}' after struct literal fields")
field_indices := make([]int, len(keys), allocator)
for i in 0 ..< len(field_indices) do field_indices[i] = -1
node := new(Expr_Struct_Literal, allocator)
node^ = Expr_Struct_Literal {
line = type_tok.line,
type_name = type_tok.lexeme,
keys = keys[:],
values = values[:],
field_indices = field_indices,
}
return node
}
@(private = "file")
parse_primary :: proc(p: ^Parser, allocator: runtime.Allocator) -> Expr {
tok := peek(p)
#partial switch tok.kind {
case .Number:
advance(p)
// The lexer only ever hands `scan_number` a string of digits (optionally with one
// interior '.'), so this can never fail; the `ok` flag is discarded deliberately, not
// overlooked.
value, _ := strconv.parse_f64(tok.lexeme)
node := new(Expr_Literal, allocator)
node^ = Expr_Literal{line = tok.line, value = value}
return node
case .String:
advance(p)
node := new(Expr_Literal, allocator)
node^ = Expr_Literal{line = tok.line, value = tok.lexeme}
return node
case .True:
advance(p)
node := new(Expr_Literal, allocator)
node^ = Expr_Literal{line = tok.line, value = true}
return node
case .False:
advance(p)
node := new(Expr_Literal, allocator)
node^ = Expr_Literal{line = tok.line, value = false}
return node
case .Nil:
advance(p)
node := new(Expr_Literal, allocator)
node^ = Expr_Literal{line = tok.line, value = nil}
return node
case .Ident:
advance(p)
// A `{` right after a bare identifier is a struct literal, `TypeName{ key: value, ... }`,
// unless no_struct_literal is set (see parse_for_in_stmt): the same "bare identifier
// followed by a brace" ambiguity Go's grammar resolves the same way in an unparenthesised
// `if`/`for` header.
if !p.no_struct_literal && check(p, .Left_Brace) {
return parse_struct_literal(p, allocator, tok)
}
node := new(Expr_Ident, allocator)
node^ = Expr_Ident{line = tok.line, name = tok.lexeme, depth = -1, slot = -1}
return node
case .Left_Paren:
advance(p)
saved := p.no_struct_literal
p.no_struct_literal = false
inner := parse_expression(p, allocator)
p.no_struct_literal = saved
consume(p, .Right_Paren, "expected ')' after expression")
node := new(Expr_Group, allocator)
node^ = Expr_Group{line = tok.line, inner = inner}
return node
case .Left_Bracket:
advance(p)
saved := p.no_struct_literal
p.no_struct_literal = false
elements := make([dynamic]Expr, allocator)
if !check(p, .Right_Bracket) {
for {
append(&elements, parse_expression(p, allocator))
if !match(p, .Comma) do break
}
}
p.no_struct_literal = saved
consume(p, .Right_Bracket, "expected ']' after array elements")
node := new(Expr_Array_Literal, allocator)
node^ = Expr_Array_Literal{line = tok.line, elements = elements[:]}
return node
case .Func:
// An anonymous function expression, `func(params) { body }`, the engine-era closure value
// (PLAN.md M6). A named `func` at statement start is claimed earlier by
// parse_block_declaration; reaching here means no name follows the keyword.
advance(p)
params, body := parse_params_and_body(p, allocator)
node := new(Expr_Func, allocator)
node^ = Expr_Func{line = tok.line, params = params, body = body}
return node
}
parse_error(p, tok, "expected an expression")
advance(p)
node := new(Expr_Literal, allocator)
node^ = Expr_Literal{line = tok.line, value = nil}
return node
}
muspel/resolver.odin
package muspel
import "base:runtime"
import "core:fmt"
import "core:strings"
Resolve_Error :: struct {
line: int,
message: string,
}
// Local tracks one name declared in one scope: its slot (the array index the bytecode
// compiler will eventually use for GET_LOCAL/SET_LOCAL) and whether its initialiser has
// finished resolving yet. `defined == false` is what catches `var x = x;` as a
// use-before-declaration error: `x` exists in the scope already (so shadowing rules can see
// it) but isn't usable yet.
Local :: struct {
slot: int,
defined: bool,
is_const: bool, // declared with `const`: any assignment to it is a resolve error
}
// Func_Info records a known function's arity, so a call site can be checked against it
// without re-parsing the declaration.
Func_Info :: struct {
arity: int,
}
// Struct_Info records a known struct type's declared field order, so a struct literal's keys
// can be resolved to fixed indices (Chapter 9, section 4) and its arity checked.
Struct_Info :: struct {
fields: []string,
}
// Enum_Info records a known enum type's declared member order, so `Enum.Member` resolves to a
// fixed integer tag (Chapter 9, section 5) instead of a runtime string comparison.
Enum_Info :: struct {
members: []string,
}
// Design decision: static scope resolution as its own pass, before anything runs, rather
// than looking variables up dynamically at call time (which is what a naively simple
// tree-walker would do). The cost is a real extra pass, paid here; the payoff is twofold:
// undefined-variable and arity errors are caught up front instead of wherever in a running
// script the bug happens to execute, and the (depth, slot) pairs this pass computes are
// exactly what the Chapter 11-12 bytecode compiler needs for GET_LOCAL/SET_LOCAL, so this
// chapter's "extra" work is paid for twice.
Resolver :: struct {
scopes: [dynamic]map[string]Local,
globals: map[string]bool,
functions: map[string]Func_Info,
structs: map[string]Struct_Info,
enums: map[string]Enum_Info,
builtins: map[string]bool,
errors: [dynamic]Resolve_Error,
}
resolver_init :: proc(allocator := context.allocator) -> Resolver {
r := Resolver{
scopes = make([dynamic]map[string]Local, allocator),
globals = make(map[string]bool, allocator),
functions = make(map[string]Func_Info, allocator),
structs = make(map[string]Struct_Info, allocator),
enums = make(map[string]Enum_Info, allocator),
builtins = make(map[string]bool, allocator),
errors = make([dynamic]Resolve_Error, allocator),
}
// Host builtins are predeclared as known globals so example and host scripts can call
// them without first defining them in Muspel. Chapter 16 formalises this registration;
// for now it's a fixed, tiny table. Arity isn't checked against a builtin: native
// functions are the embedding boundary's business, not the resolver's.
r.builtins["print"] = true
r.builtins["host_seconds"] = true
r.builtins["on"] = true
r.builtins["random"] = true
r.builtins["len"] = true
r.builtins["list"] = true
r.builtins["append"] = true
r.builtins["pop"] = true
r.builtins["remove"] = true
r.builtins["map"] = true
r.builtins["has"] = true
r.builtins["keys"] = true
return r
}
@(private = "file")
resolve_error :: proc(r: ^Resolver, line: int, message: string) {
append(&r.errors, Resolve_Error{line = line, message = message})
}
@(private = "file")
begin_scope :: proc(r: ^Resolver, allocator: runtime.Allocator) {
append(&r.scopes, make(map[string]Local, allocator))
}
@(private = "file")
end_scope :: proc(r: ^Resolver) {
scope := pop(&r.scopes)
delete(scope)
}
// declare introduces a name into the current (innermost) scope, undefined until define is
// called for it. A duplicate declaration in the *same* scope is an error; shadowing a name
// from an outer scope is fine and ordinary.
@(private = "file")
// declare records a new binding. `is_const` (default false, so every existing caller is unchanged)
// marks it as introduced by `const`; it is stored alongside the binding so a later assignment can be
// rejected. The global table's value is now exactly that flag: r.globals[name] == true means "a
// top-level constant", false means "a top-level var/func". Membership, not the flag, still answers
// "does this global exist", so every existence test uses the comma-ok form.
declare :: proc(r: ^Resolver, line: int, name: string, is_const := false) -> int {
if len(r.scopes) == 0 {
// Top level: no local slot, tracked as a known global instead.
if _, exists := r.globals[name]; exists {
resolve_error(r, line, fmt_undeclared(name, "is already declared"))
}
r.globals[name] = is_const
return -1
}
scope := &r.scopes[len(r.scopes) - 1]
if _, exists := scope[name]; exists {
resolve_error(r, line, fmt_undeclared(name, "is already declared in this scope"))
return -1
}
slot := len(scope)
scope[name] = Local{slot = slot, defined = false, is_const = is_const}
return slot
}
// name_is_const answers whether `name` resolves to a constant binding, walking scopes innermost-out
// and then the global table, the same order resolve_name walks so it agrees on which binding is
// meant. A name that resolves to a function, a builtin, or nothing is not a constant (assigning to a
// function name is caught elsewhere / is simply a global write); only `const` bindings return true.
@(private = "file")
name_is_const :: proc(r: ^Resolver, name: string) -> bool {
for i := len(r.scopes) - 1; i >= 0; i -= 1 {
if local, ok := r.scopes[i][name]; ok do return local.is_const
}
if is_const, ok := r.globals[name]; ok do return is_const
return false
}
@(private = "file")
define :: proc(r: ^Resolver, name: string) {
if len(r.scopes) == 0 do return
scope := &r.scopes[len(r.scopes) - 1]
local := scope[name]
local.defined = true
scope[name] = local
}
// resolve_name walks the scope stack from innermost outward, exactly like a running program
// would look a variable up through nested blocks. depth counts how many scopes out the
// binding was found (0 = innermost); depth == -1 means "not found locally", i.e. a global,
// a known function, or a builtin.
@(private = "file")
resolve_name :: proc(r: ^Resolver, line: int, name: string) -> (depth: int, slot: int) {
for i := len(r.scopes) - 1; i >= 0; i -= 1 {
if local, ok := r.scopes[i][name]; ok {
if !local.defined {
resolve_error(r, line, fmt_undeclared(name, "can't be used in its own initialiser"))
}
return len(r.scopes) - 1 - i, local.slot
}
}
if _, ok := r.functions[name]; ok do return -1, -1
if _, ok := r.builtins[name]; ok do return -1, -1
if _, ok := r.globals[name]; ok do return -1, -1
resolve_error(r, line, fmt_undeclared(name, "is undefined"))
return -1, -1
}
// Error messages are built with fmt.tprintf, into the temp allocator: they're read (or
// copied out, as musc's callers do) well before the temp arena is ever reset, so there's no
// lifetime hazard in practice.
@(private = "file")
fmt_undeclared :: proc(name: string, tail: string) -> string {
return fmt.tprintf("'%s' %s", name, tail)
}
// resolve_program is the resolver's entry point. Top-level functions are declared in a first
// pass, over all of them, before any body is resolved, so a function can call another
// function declared later in the same file, exactly like reading a program's table of
// contents before reading its chapters.
resolve_program :: proc(r: ^Resolver, stmts: []Stmt, allocator := context.allocator) {
for s in stmts {
switch node in s {
case ^Stmt_Func:
if _, exists := r.functions[node.name]; exists {
resolve_error(r, node.line, fmt_undeclared(node.name, "is already declared"))
continue
}
r.functions[node.name] = Func_Info{arity = len(node.params)}
case ^Stmt_Struct_Decl:
if _, exists := r.structs[node.name]; exists {
resolve_error(r, node.line, fmt_undeclared(node.name, "is already declared"))
continue
}
r.structs[node.name] = Struct_Info{fields = node.fields}
case ^Stmt_Enum_Decl:
if _, exists := r.enums[node.name]; exists {
resolve_error(r, node.line, fmt_undeclared(node.name, "is already declared"))
continue
}
r.enums[node.name] = Enum_Info{members = node.members}
case ^Stmt_Var, ^Stmt_Expr, ^Stmt_If, ^Stmt_While, ^Stmt_For_In, ^Stmt_Return, ^Stmt_Yield, ^Stmt_Block, ^Stmt_Import, ^Stmt_Multi:
// nothing to hoist (a Stmt_Multi only ever wraps `var` bindings, which are not hoisted)
}
}
for s in stmts {
resolve_stmt(r, s, allocator)
}
}
resolve_stmt :: proc(r: ^Resolver, s: Stmt, allocator: runtime.Allocator) {
switch node in s {
case ^Stmt_Var:
if node.init != nil do resolve_expr(r, node.init, allocator)
node.slot = declare(r, node.line, node.name, node.is_const)
define(r, node.name)
node.depth = 0 if len(r.scopes) > 0 else -1
case ^Stmt_Multi:
// A desugared multi-binding `var`: resolve each binding in the current scope, in order, so
// a later binding can refer to an earlier one on the same line (`var a = 1, b = a + 1`).
for child in node.stmts {
resolve_stmt(r, child, allocator)
}
case ^Stmt_Func:
// A top-level function is already registered as a global by the hoisting pass; a nested
// one (engine era, PLAN.md M6) is bound as a local in its enclosing scope. Its body is
// resolved in its own scope either way; the enclosing scopes stay on the stack, so a
// reference to an enclosing local resolves rather than erroring, which is what the compiler
// turns into an upvalue capture. The name is declared *after* the body, like a `var`, so a
// nested function cannot refer to itself (by-value capture cannot close over a value that
// does not exist yet); recursion still belongs to top-level functions.
nested := len(r.scopes) > 0
begin_scope(r, allocator)
for param in node.params {
declare(r, node.line, param)
define(r, param)
}
for stmt in node.body.stmts {
resolve_stmt(r, stmt, allocator)
}
end_scope(r)
if nested {
declare(r, node.line, node.name)
define(r, node.name)
node.depth = 0
} else {
node.depth = -1
}
case ^Stmt_Expr:
resolve_expr(r, node.expr, allocator)
case ^Stmt_If:
resolve_expr(r, node.cond, allocator)
resolve_stmt(r, node.then_branch, allocator)
if node.else_branch != nil do resolve_stmt(r, node.else_branch, allocator)
case ^Stmt_While:
resolve_expr(r, node.cond, allocator)
resolve_stmt(r, node.body, allocator)
case ^Stmt_For_In:
resolve_expr(r, node.list, allocator)
begin_scope(r, allocator)
node.slot = declare(r, node.line, node.item_name)
define(r, node.item_name)
node.depth = 0
resolve_stmt(r, node.body, allocator)
end_scope(r)
case ^Stmt_Struct_Decl, ^Stmt_Enum_Decl, ^Stmt_Import:
// Struct/enum declarations are already registered by resolve_program's hoisting pass.
// An import itself resolves nothing (Chapter 20): the caller orchestrating a whole module
// graph (modules.odin's resolve_module_graph) is what actually copies the imported module's
// own struct/enum/function tables into this resolver, under `alias.`, before this file's own
// statements are resolved; the Stmt_Import node here is just a marker the parser produced.
case ^Stmt_Return:
if node.value != nil do resolve_expr(r, node.value, allocator)
case ^Stmt_Yield:
// nothing to resolve: no operand, no scope of its own
case ^Stmt_Block:
begin_scope(r, allocator)
for stmt in node.stmts {
resolve_stmt(r, stmt, allocator)
}
end_scope(r)
}
}
resolve_expr :: proc(r: ^Resolver, e: Expr, allocator: runtime.Allocator) {
switch node in e {
case ^Expr_Literal:
// nothing to resolve
case ^Expr_Ident:
node.depth, node.slot = resolve_name(r, node.line, node.name)
case ^Expr_Group:
resolve_expr(r, node.inner, allocator)
case ^Expr_Unary:
resolve_expr(r, node.right, allocator)
case ^Expr_Binary:
resolve_expr(r, node.left, allocator)
resolve_expr(r, node.right, allocator)
case ^Expr_Logical:
resolve_expr(r, node.left, allocator)
resolve_expr(r, node.right, allocator)
case ^Expr_Call:
resolve_expr(r, node.callee, allocator)
for arg in node.args {
resolve_expr(r, arg, allocator)
}
if ident, ok := node.callee.(^Expr_Ident); ok {
if info, known := r.functions[ident.name]; known {
if len(node.args) != info.arity {
resolve_error(r, node.line, fmt_arity(ident.name, info.arity, len(node.args)))
}
}
}
case ^Expr_Assign:
resolve_expr(r, node.value, allocator)
if name_is_const(r, node.name) {
resolve_error(r, node.line, fmt.tprintf("cannot assign to constant '%s'", node.name))
}
node.depth, node.slot = resolve_name(r, node.line, node.name)
case ^Expr_Struct_Literal:
if info, ok := r.structs[node.type_name]; ok {
node.all_fields = info.fields
for key, i in node.keys {
idx := -1
for f, fi in info.fields {
if f == key {
idx = fi
break
}
}
if idx == -1 {
resolve_error(r, node.line, fmt.tprintf("struct '%s' has no field '%s'", node.type_name, key))
}
node.field_indices[i] = idx
}
} else {
resolve_error(r, node.line, fmt.tprintf("'%s' is not a known struct type", node.type_name))
}
for v in node.values {
resolve_expr(r, v, allocator)
}
case ^Expr_Field_Access:
// `Enum.Member`, `alias.function`, and `struct_value.field` all share one grammar
// production (Chapter 7, extended by Chapter 20's imports): the resolver tells them apart
// here by checking whether the object side is a dotted chain of plain names (an enum or
// import path never has a runtime value in between) matching a *known* enum or qualified
// function. When it is either, `object` names a type or a namespaced function, not a
// value, so it is deliberately not resolved as a variable reference below.
if base, ok := qualified_prefix(node.object); ok {
if einfo, is_enum := r.enums[base]; is_enum {
tag := -1
for m, mi in einfo.members {
if m == node.field {
tag = mi
break
}
}
if tag == -1 {
resolve_error(r, node.line, fmt.tprintf("enum '%s' has no member '%s'", base, node.field))
}
node.is_enum_member = true
node.enum_tag = tag
return
}
full := strings.concatenate({base, ".", node.field}, context.temp_allocator)
if _, is_func := r.functions[full]; is_func {
node.is_qualified_ref = true
node.qualified_name = strings.clone(full, allocator)
return
}
}
resolve_expr(r, node.object, allocator)
case ^Expr_Field_Assign:
resolve_expr(r, node.object, allocator)
resolve_expr(r, node.value, allocator)
case ^Expr_Array_Literal:
for el in node.elements {
resolve_expr(r, el, allocator)
}
case ^Expr_Index:
resolve_expr(r, node.object, allocator)
resolve_expr(r, node.index, allocator)
case ^Expr_Index_Assign:
resolve_expr(r, node.object, allocator)
resolve_expr(r, node.index, allocator)
resolve_expr(r, node.value, allocator)
case ^Expr_Func:
// An anonymous function expression resolves exactly like a function body: one shared scope
// for its params and body, with the enclosing scopes left in place so free variables
// resolve to their enclosing declarations (which the compiler captures by value).
begin_scope(r, allocator)
for param in node.params {
declare(r, node.line, param)
define(r, param)
}
for stmt in node.body.stmts {
resolve_stmt(r, stmt, allocator)
}
end_scope(r)
}
}
// qualified_prefix reads `e` back as a dotted name (`"Phase"`, `"common"`, `"common.Phase"`), the
// same shape a script writes an enum type or an import alias's own export as, so the caller can
// check it against r.enums/r.functions before assuming `e` is an ordinary runtime value. It only
// ever recurses through *already-plain* identifier/field chains, stopping (returning false) the
// moment it hits anything already resolved as an enum member or qualified reference itself, so it
// can never misread a genuine value expression (`a_struct.a_field.another_field`, once resolved)
// as a type or module path.
@(private = "file")
qualified_prefix :: proc(e: Expr) -> (string, bool) {
#partial switch node in e {
case ^Expr_Ident:
return node.name, true
case ^Expr_Field_Access:
if node.is_enum_member || node.is_qualified_ref do return "", false
if base, ok := qualified_prefix(node.object); ok {
return fmt.tprintf("%s.%s", base, node.field), true
}
}
return "", false
}
@(private = "file")
fmt_arity :: proc(name: string, want: int, got: int) -> string {
return fmt.tprintf("'%s' expects %d argument(s), got %d", name, want, got)
}
muspel/treewalk.odin
package muspel
import "base:runtime"
import "core:fmt"
import "core:math"
import "core:math/rand"
import "core:strings"
import "core:time"
// Design decision: this whole file is a deliberate stepping stone, not the destination. It
// exists so the language's *semantics* (does `while` actually loop right, does shadowing
// actually shadow right, does a closure actually capture the right environment) can be
// validated by directly walking the AST the parser and resolver already built, before any of
// that gets re-derived through a bytecode compiler and VM in Part III/IV. Chapter 9 measures
// exactly what this simplicity costs; Chapter 13 replaces this file's job, not its answers.
// Walk_Value is the tree-walker's own runtime representation. It started this book simply named
// `Value`, back when it was the only runtime value type Muspel had; Chapter 13 renames it to
// `Walk_Value` (and `Function`/`Native_Fn` to `Walk_Function`/`Walk_Native_Fn` alongside it) the
// moment the bytecode VM needs the plain name `Value` for its own, real runtime representation,
// so the two are never confused on the page or in an import. A nil union value is Muspel's
// `nil`, matching how Expr_Literal already represents it.
Walk_Value :: union {
f64,
string,
bool,
^Walk_Function,
Walk_Native_Fn,
^Walk_Struct_Instance,
^Walk_Array_Instance,
}
// Walk_Struct_Instance is the tree-walker's own runtime record: an ordered field-name array
// (shared with every instance of the same struct type, never copied per instance) plus this
// instance's own values, one per declared field, in that same order. This is the tree-walker's
// version of what Chapter 12's bytecode GET_FIELD/SET_FIELD will later do at VM speed: for now
// the values simply live on Odin's own heap (Chapter 7, section 6), not the arena the bytecode
// VM's struct/array values will need once VM instances exist to own one.
Walk_Struct_Instance :: struct {
type_name: string,
field_names: []string,
values: []Walk_Value,
}
// Walk_Array_Instance is a fixed-capacity run of values, sized once at construction (Chapter 8):
// there is no append, matching the language's own array semantics exactly.
Walk_Array_Instance :: struct {
values: []Walk_Value,
}
// Walk_Native_Fn is a host-registered builtin. The full embedding boundary (registration,
// marshalling, error handling) is Chapter 16's job; for now there are only the language's own
// always-present ones, `print` and `random`, neither needing a `yield` the way `host_seconds`
// (and everything built on it, `wait`/`wait_until`) does, which is exactly why those two, alone,
// are the ones the tree-walker also implements.
Walk_Native_Fn :: proc(interp: ^Interpreter, args: []Walk_Value) -> Walk_Value
// Walk_Function is a Muspel function value: its declaration plus the environment it closed over at
// creation time. Capturing `closure` here, once, is the entire closure mechanism: a tree-walk
// closure is nearly free because the environment it needs to remember already exists as a
// live Odin value the garbage-free-but-leaking allocator keeps around for us. Chapter 12's
// bytecode closures have to reconstruct this by hand, which is exactly why that chapter flags
// the contrast this one sets up.
Walk_Function :: struct {
name: string,
params: []string,
body: ^Stmt_Block,
closure: ^Environment,
}
// Environment is one scope frame: a flat array of local slots (indexed exactly as the
// Chapter 7 resolver numbered them) plus a link to the enclosing frame. Resolved (depth, slot)
// pairs are what let a lookup skip straight to the right frame and index instead of searching
// by name, the same information the bytecode compiler will reuse for GET_LOCAL/SET_LOCAL.
Environment :: struct {
values: [dynamic]Walk_Value,
parent: ^Environment,
}
env_new :: proc(parent: ^Environment, allocator := context.allocator) -> ^Environment {
env := new(Environment, allocator)
env.values = make([dynamic]Walk_Value, allocator)
env.parent = parent
return env
}
// env_define appends a new local slot, in declaration order: since the resolver assigned
// slots in that same left-to-right order, the array index a value lands at here always
// matches the slot the resolver already stamped onto the AST node.
env_define :: proc(env: ^Environment, value: Walk_Value) {
append(&env.values, value)
}
env_get_at :: proc(env: ^Environment, depth: int, slot: int) -> Walk_Value {
e := env
for i in 0 ..< depth {
e = e.parent
}
return e.values[slot]
}
env_set_at :: proc(env: ^Environment, depth: int, slot: int, value: Walk_Value) {
e := env
for i in 0 ..< depth {
e = e.parent
}
e.values[slot] = value
}
// Interpreter holds the tree-walker's global state: every top-level `var`, every top-level
// `func`, and every native builtin live in one flat map, keyed by name. This mirrors the
// resolver's own treatment of depth == -1 as "not a local, look it up some other way": at run
// time that "other way" is always this map.
Interpreter :: struct {
globals: map[string]Walk_Value,
rng: rand.Default_Random_State,
// structs is the resolver's own struct table (Struct_Info, resolver.odin), shared by
// reference after resolution: the tree-walker needs a struct type's declared field order
// to build a literal's value array and to construct field_names for a new instance.
structs: map[string]Struct_Info,
}
interp_init :: proc(allocator := context.allocator) -> Interpreter {
interp := Interpreter{
globals = make(map[string]Walk_Value, allocator),
}
interp.rng = rand.create_u64(u64(time.to_unix_nanoseconds(time.now())))
interp.globals["print"] = Walk_Native_Fn(native_print)
interp.globals["random"] = Walk_Native_Fn(native_random_walk)
interp.globals["len"] = Walk_Native_Fn(native_len)
return interp
}
@(private = "file")
native_len :: proc(interp: ^Interpreter, args: []Walk_Value) -> Walk_Value {
arr, ok := args[0].(^Walk_Array_Instance)
if !ok do panic("runtime error: 'len' expects an array")
return f64(len(arr.values))
}
@(private = "file")
native_print :: proc(interp: ^Interpreter, args: []Walk_Value) -> Walk_Value {
sb := strings.builder_make(context.temp_allocator)
for arg, i in args {
if i > 0 do strings.write_string(&sb, " ")
strings.write_string(&sb, walk_value_to_string(arg))
}
fmt.println(strings.to_string(sb))
return nil
}
// native_random_walk mirrors vm.odin's own native_random exactly: a float in [0, 1) from this
// interpreter's own generator, so a script using nothing but `random()` (no `yield`) produces
// the identical sequence shape under `--walk` as under the real VM, section 9.5's "the
// tree-walker as the VM's own oracle" holding for this builtin same as any other.
@(private = "file")
native_random_walk :: proc(interp: ^Interpreter, args: []Walk_Value) -> Walk_Value {
gen := runtime.default_random_generator(&interp.rng)
return Walk_Value(rand.float64(gen))
}
// walk_value_to_string renders a Walk_Value the way Muspel scripts see their own output: numbers
// without a trailing ".0" for whole values, strings unquoted (this is display, not `musc
// parse`'s debug dump), booleans and nil as their keywords, functions by name.
walk_value_to_string :: proc(v: Walk_Value) -> string {
switch val in v {
case f64:
return fmt.tprintf("%v", val)
case string:
return val
case bool:
return val ? "true" : "false"
case ^Walk_Function:
return fmt.tprintf("<func %s>", val.name)
case Walk_Native_Fn:
return "<native func>"
case ^Walk_Struct_Instance:
sb := strings.builder_make(context.temp_allocator)
strings.write_string(&sb, val.type_name)
strings.write_string(&sb, "{")
for name, i in val.field_names {
if i > 0 do strings.write_string(&sb, ", ")
strings.write_string(&sb, name)
strings.write_string(&sb, ": ")
strings.write_string(&sb, walk_value_to_string(val.values[i]))
}
strings.write_string(&sb, "}")
return strings.to_string(sb)
case ^Walk_Array_Instance:
sb := strings.builder_make(context.temp_allocator)
strings.write_string(&sb, "[")
for v, i in val.values {
if i > 0 do strings.write_string(&sb, ", ")
strings.write_string(&sb, walk_value_to_string(v))
}
strings.write_string(&sb, "]")
return strings.to_string(sb)
case:
return "nil"
}
}
walk_is_truthy :: proc(v: Walk_Value) -> bool {
#partial switch val in v {
case bool:
return val
case:
return v != nil
}
}
// Exec_Signal reports why exec_stmt/exec_block stopped early: `.Return` carries a value up
// through however many enclosing blocks and loops sit between the `return` statement and the
// function call it's returning from. There's no `.Break`/`.Continue` yet; Muspel's grammar
// doesn't have those statements (see Chapter 4's grammar), so there's nothing to signal.
Exec_Signal :: enum {
Normal,
Return,
}
// exec_program runs a whole file's top-level statements. Top-level functions are hoisted into
// `globals` before anything else runs, mirroring the resolver's own two-pass treatment in
// resolve_program: a top-level function may call another declared later in the same file.
exec_program :: proc(interp: ^Interpreter, stmts: []Stmt) {
for s in stmts {
if fn, ok := s.(^Stmt_Func); ok {
interp.globals[fn.name] = make_function(fn, nil)
}
}
for s in stmts {
exec_stmt(interp, nil, s)
}
}
@(private = "file")
make_function :: proc(decl: ^Stmt_Func, closure: ^Environment) -> ^Walk_Function {
fn := new(Walk_Function)
fn^ = Walk_Function{name = decl.name, params = decl.params, body = decl.body, closure = closure}
return fn
}
// exec_stmt executes one statement in `env` (nil at the top level, where every name resolves
// to `interp.globals` instead; see Interpreter's doc comment). It returns `.Return` the
// instant a `return` is hit anywhere inside, so every caller that walks a list of statements
// must check the signal and stop, rather than assuming it always reaches the end.
exec_stmt :: proc(interp: ^Interpreter, env: ^Environment, s: Stmt) -> (Exec_Signal, Walk_Value) {
switch node in s {
case ^Stmt_Var:
value: Walk_Value
if node.init != nil do value = eval_expr(interp, env, node.init)
if node.depth == -1 {
interp.globals[node.name] = value
} else {
env_define(env, value)
}
case ^Stmt_Multi:
// A desugared multi-binding `var`: execute each binding in order in the current env. None can
// contain a `return`, but the signal is threaded through anyway to keep the contract uniform.
for child in node.stmts {
sig, val := exec_stmt(interp, env, child)
if sig == .Return do return sig, val
}
case ^Stmt_Func:
// A top-level function (depth == -1) is a global, re-assigned harmlessly here after the
// hoisting pass in exec_program. A nested one (engine era, PLAN.md M6) is bound as a local
// closing over the current environment, so its body can read the enclosing scope. The
// tree-walker captures the environment by reference; the bytecode VM snapshots by value.
// The two agree for the reference values (structs, arrays) closures are actually used to
// carry; they can differ only for a captured primitive later mutated in the outer scope,
// which the book's own examples never do.
if node.depth == -1 {
interp.globals[node.name] = make_function(node, nil)
} else {
env_define(env, make_function(node, env))
}
case ^Stmt_Expr:
eval_expr(interp, env, node.expr)
case ^Stmt_If:
if walk_is_truthy(eval_expr(interp, env, node.cond)) {
return exec_stmt(interp, env, node.then_branch)
} else if node.else_branch != nil {
return exec_stmt(interp, env, node.else_branch)
}
case ^Stmt_While:
for walk_is_truthy(eval_expr(interp, env, node.cond)) {
signal, value := exec_stmt(interp, env, node.body)
if signal == .Return do return signal, value
}
case ^Stmt_For_In:
list_val := eval_expr(interp, env, node.list)
arr, ok := list_val.(^Walk_Array_Instance)
if !ok do panic("runtime error: 'for ... in' requires an array")
for item in arr.values {
item_env := env_new(env)
env_define(item_env, item)
signal, value := exec_stmt(interp, item_env, node.body)
if signal == .Return do return signal, value
}
case ^Stmt_Import:
// The tree-walker only ever runs a single already-parsed file (--walk has no module
// loader, Chapter 20's own multi-file orchestration is VM-path only, tools/musc's
// module_loader.odin); an import statement reaching here has nothing to do.
case ^Stmt_Struct_Decl, ^Stmt_Enum_Decl:
// Declarations only: the resolver already recorded the struct's field order / enum's
// member order (interp.structs, and Expr_Field_Access.enum_tag respectively); nothing
// executes here.
case ^Stmt_Return:
value: Walk_Value
if node.value != nil do value = eval_expr(interp, env, node.value)
return .Return, value
// Design decision: `yield` is a hard error under --walk, not a silent no-op. Suspending
// mid-evaluation has no answer here: exec_stmt is an ordinary recursive Odin call, so the
// state a real yield would need to preserve (every pending eval_expr, every enclosing
// exec_stmt) is smeared across Odin's own native call stack, which Odin gives no way to
// detach, store, and resume later. That absence is section 15.2's whole argument for why
// Chapter 13 built the VM's dispatch loop the way it did; this is where the tree-walker
// pays for not having.
case ^Stmt_Yield:
panic("runtime error: 'yield' is not supported by the tree-walking interpreter (--walk); see Chapter 15")
case ^Stmt_Block:
child := env_new(env)
return exec_block_stmts(interp, child, node.stmts)
}
return .Normal, nil
}
// exec_block_stmts runs a statement list in an *already-current* environment, without pushing
// a new scope of its own. Stmt_Block's own case above is the only place that pushes a new
// scope for an ordinary `{ ... }`; call_function reuses this proc directly on the fresh
// parameter environment it builds, because the Chapter 7 resolver gives a function's params
// and its top-level body statements exactly one shared scope, not two nested ones.
@(private = "file")
exec_block_stmts :: proc(interp: ^Interpreter, env: ^Environment, stmts: []Stmt) -> (Exec_Signal, Walk_Value) {
for s in stmts {
signal, value := exec_stmt(interp, env, s)
if signal == .Return do return signal, value
}
return .Normal, nil
}
eval_expr :: proc(interp: ^Interpreter, env: ^Environment, e: Expr) -> Walk_Value {
switch node in e {
case ^Expr_Literal:
switch lit in node.value {
case f64:
return lit
case string:
return lit
case bool:
return lit
case:
return nil
}
case ^Expr_Ident:
if node.depth == -1 do return interp.globals[node.name]
return env_get_at(env, node.depth, node.slot)
case ^Expr_Group:
return eval_expr(interp, env, node.inner)
case ^Expr_Unary:
right := eval_expr(interp, env, node.right)
#partial switch node.op {
case .Minus:
return -right.(f64)
case .Bang:
return !walk_is_truthy(right)
case:
return nil
}
case ^Expr_Binary:
return eval_binary(node.op, eval_expr(interp, env, node.left), eval_expr(interp, env, node.right))
case ^Expr_Logical:
left := eval_expr(interp, env, node.left)
#partial switch node.op {
case .And_And:
if !walk_is_truthy(left) do return left
case .Or_Or:
if walk_is_truthy(left) do return left
}
return eval_expr(interp, env, node.right)
case ^Expr_Call:
callee := eval_expr(interp, env, node.callee)
args := make([]Walk_Value, len(node.args), context.temp_allocator)
for arg, i in node.args {
args[i] = eval_expr(interp, env, arg)
}
return call_value(interp, callee, args)
case ^Expr_Assign:
value := eval_expr(interp, env, node.value)
if node.depth == -1 {
interp.globals[node.name] = value
} else {
env_set_at(env, node.depth, node.slot, value)
}
return value
case ^Expr_Struct_Literal:
info := interp.structs[node.type_name]
values := make([]Walk_Value, len(info.fields))
for i in 0 ..< len(node.keys) {
idx := node.field_indices[i]
v := eval_expr(interp, env, node.values[i])
if idx >= 0 do values[idx] = v
}
inst := new(Walk_Struct_Instance)
inst^ = Walk_Struct_Instance{type_name = node.type_name, field_names = info.fields, values = values}
return inst
case ^Expr_Field_Access:
// Enum member reference: resolved to a plain integer tag at resolve time (Chapter 9),
// so there is no struct instance and no field lookup to do here at all.
if node.is_enum_member do return f64(node.enum_tag)
obj := eval_expr(interp, env, node.object)
inst, ok := obj.(^Walk_Struct_Instance)
if !ok do panic(fmt.tprintf("runtime error: '.%s' used on a non-struct value", node.field))
for name, i in inst.field_names {
if name == node.field do return inst.values[i]
}
panic(fmt.tprintf("runtime error: struct '%s' has no field '%s'", inst.type_name, node.field))
case ^Expr_Field_Assign:
obj := eval_expr(interp, env, node.object)
inst, ok := obj.(^Walk_Struct_Instance)
if !ok do panic(fmt.tprintf("runtime error: '.%s' used on a non-struct value", node.field))
value := eval_expr(interp, env, node.value)
for name, i in inst.field_names {
if name == node.field {
inst.values[i] = value
return value
}
}
panic(fmt.tprintf("runtime error: struct '%s' has no field '%s'", inst.type_name, node.field))
case ^Expr_Array_Literal:
values := make([]Walk_Value, len(node.elements))
for el, i in node.elements {
values[i] = eval_expr(interp, env, el)
}
inst := new(Walk_Array_Instance)
inst^ = Walk_Array_Instance{values = values}
return inst
case ^Expr_Index:
obj := eval_expr(interp, env, node.object)
arr, ok := obj.(^Walk_Array_Instance)
if !ok do panic("runtime error: '[]' used on a non-array value")
i := int(eval_expr(interp, env, node.index).(f64))
if i < 0 || i >= len(arr.values) {
panic(fmt.tprintf("runtime error: index %d out of bounds for array of length %d", i, len(arr.values)))
}
return arr.values[i]
case ^Expr_Index_Assign:
obj := eval_expr(interp, env, node.object)
arr, ok := obj.(^Walk_Array_Instance)
if !ok do panic("runtime error: '[]' used on a non-array value")
i := int(eval_expr(interp, env, node.index).(f64))
value := eval_expr(interp, env, node.value)
if i < 0 || i >= len(arr.values) {
panic(fmt.tprintf("runtime error: index %d out of bounds for array of length %d", i, len(arr.values)))
}
arr.values[i] = value
return value
case ^Expr_Func:
// An anonymous function expression closes over the current environment (engine era,
// PLAN.md M6), exactly as a nested named function does; it just has no name to bind.
fn := new(Walk_Function)
fn^ = Walk_Function{name = "<anon>", params = node.params, body = node.body, closure = env}
return fn
}
return nil
}
@(private = "file")
eval_binary :: proc(op: Token_Kind, left, right: Walk_Value) -> Walk_Value {
#partial switch op {
case .Plus:
// `+` is overloaded: number addition or string concatenation, chosen by the left
// operand's runtime type. Mixed-type `+` is a Chapter 18-era concern (a graceful
// runtime error instead of an Odin panic); the tree-walker doesn't pretend to that
// robustness yet.
if l, ok := left.(string); ok do return strings.concatenate({l, right.(string)})
return left.(f64) + right.(f64)
case .Minus:
return left.(f64) - right.(f64)
case .Star:
return left.(f64) * right.(f64)
case .Slash:
return left.(f64) / right.(f64)
case .Percent:
return math.mod(left.(f64), right.(f64))
case .Equal:
return walk_values_equal(left, right)
case .Not_Equal:
return !walk_values_equal(left, right)
case .Less:
return left.(f64) < right.(f64)
case .Less_Equal:
return left.(f64) <= right.(f64)
case .Greater:
return left.(f64) > right.(f64)
case .Greater_Equal:
return left.(f64) >= right.(f64)
}
return nil
}
@(private = "file")
walk_values_equal :: proc(a, b: Walk_Value) -> bool {
#partial switch av in a {
case f64:
bv, ok := b.(f64)
return ok && av == bv
case string:
bv, ok := b.(string)
return ok && av == bv
case bool:
bv, ok := b.(bool)
return ok && av == bv
case ^Walk_Function:
bv, ok := b.(^Walk_Function)
return ok && av == bv
case Walk_Native_Fn:
bv, ok := b.(Walk_Native_Fn)
return ok && av == bv
// Struct equality is field-wise and array equality is element-wise, not pointer identity:
// two independently constructed `Vec2{x: 1, y: 2}` values are equal, the way a script author
// expects value types to compare, not the way Odin's own `^T` pointers do.
case ^Walk_Struct_Instance:
bv, ok := b.(^Walk_Struct_Instance)
if !ok || av.type_name != bv.type_name || len(av.values) != len(bv.values) do return false
for v, i in av.values {
if !walk_values_equal(v, bv.values[i]) do return false
}
return true
case ^Walk_Array_Instance:
bv, ok := b.(^Walk_Array_Instance)
if !ok || len(av.values) != len(bv.values) do return false
for v, i in av.values {
if !walk_values_equal(v, bv.values[i]) do return false
}
return true
case:
return b == nil
}
}
// call_value dispatches a call to whatever the callee expression evaluated to: a native
// builtin (arity already checked by the resolver only for *known, named* functions, so a
// native call's argument count is the native's own business), or a Muspel function.
@(private = "file")
call_value :: proc(interp: ^Interpreter, callee: Walk_Value, args: []Walk_Value) -> Walk_Value {
#partial switch fn in callee {
case Walk_Native_Fn:
return fn(interp, args)
case ^Walk_Function:
return call_function(interp, fn, args)
case:
return nil
}
}
// call_function is where a tree-walk closure earns its keep: the new call environment's
// parent is `fn.closure`, the environment live at the moment the function was *declared*, not
// whatever environment happens to be calling it now. Params and the body's own statements
// share that one new environment, exactly as resolve_stmt's ^Stmt_Func case resolved them.
call_function :: proc(interp: ^Interpreter, fn: ^Walk_Function, args: []Walk_Value) -> Walk_Value {
call_env := env_new(fn.closure)
for arg in args {
env_define(call_env, arg)
}
signal, value := exec_block_stmts(interp, call_env, fn.body.stmts)
if signal == .Return do return value
return nil
}
muspel/opcodes.odin
package muspel
// Op_Code is Muspel's bytecode instruction set: one byte per opcode. Every instruction is
// opcode-byte followed by zero or more fixed-width operand bytes; op_operand_bytes is this
// table's single source of truth for how many, used by the disassembler (this chapter) and the
// VM's dispatch loop (Chapter 13) alike, so the two can never quietly disagree about an
// instruction's width.
//
// | Mnemonic | Operand | Stack effect | Semantics |
// |-------------------|--------------|----------------------|------------------------------------|
// | Constant | u8 idx | `-> value` | push `chunk.constants[idx]` |
// | Nil | - | `-> nil` | push the nil value |
// | True | - | `-> true` | push the boolean `true` |
// | False | - | `-> false` | push the boolean `false` |
// | Pop | - | `value ->` | discard the top of the stack |
// | Dup | - | `value -> value value` | duplicate the top of the stack |
// | Add | - | `a b -> (a+b)` | numeric add, or string concat |
// | Sub | - | `a b -> (a-b)` | numeric subtract |
// | Mul | - | `a b -> (a*b)` | numeric multiply |
// | Div | - | `a b -> (a/b)` | numeric divide |
// | Mod | - | `a b -> (a%b)` | numeric modulo (fmod, sign of a) |
// | Negate | - | `a -> (-a)` | numeric negation |
// | Not | - | `a -> (!truthy(a))` | logical not |
// | Equal | - | `a b -> (a==b)` | value equality |
// | Not_Equal | - | `a b -> (a!=b)` | value inequality |
// | Less | - | `a b -> (a<b)` | numeric less-than |
// | Less_Equal | - | `a b -> (a<=b)` | numeric less-or-equal |
// | Greater | - | `a b -> (a>b)` | numeric greater-than |
// | Greater_Equal | - | `a b -> (a>=b)` | numeric greater-or-equal |
// | Get_Local | u8 slot | `-> value` | push the current frame's local slot|
// | Set_Local | u8 slot | `value -> value` | overwrite a local slot, leave value|
// | Get_Global | u8 name idx | `-> value` | push the named global's value |
// | Set_Global | u8 name idx | `value -> value` | overwrite a global, leave value |
// | Define_Global | u8 name idx | `value ->` | bind the top of stack as a global |
// | Jump | i16 offset | - | `ip += offset`, unconditional |
// | Jump_If_False | i16 offset | `value -> value` | if `!truthy(value)`, `ip += offset` |
// | Call | u8 argc | `f a1..aN -> result` | call `f` with `argc` arguments |
// | Return | - | `value -> ` | pop `value`, return it to the caller|
// | Yield | - | - | suspend the VM; resumes just past here |
// | Make_Struct | u8 layout idx| `v1..vN -> instance` | build a struct from a Struct_Layout constant |
// | Get_Field | u8 name idx | `obj -> value` | read a field by name from a struct |
// | Set_Field | u8 name idx | `obj value -> value` | write a field by name on a struct |
// | Make_Array | u8 count | `v1..vN -> array` | build a fixed-capacity array |
// | Get_Index | - | `arr idx -> value` | bounds-checked array read |
// | Set_Index | - | `arr idx value -> value` | bounds-checked array write |
// | Make_Closure | u8 proto, u8 n | `c1..cN -> closure` | wrap a proto with N snapshotted captures |
// | Get_Upvalue | u8 idx | `-> value` | push the current closure's capture idx |
// | Set_Upvalue | u8 idx | `value -> value` | overwrite a capture, leave value |
//
// Make_Closure / Get_Upvalue / Set_Upvalue are the engine-era closure opcodes (PLAN.md M6),
// added after the book's own instruction set. Make_Closure has two u8 operands (a Func_Proto
// constant index and a capture count) and pops that many already-pushed capture values, in push
// order, into the new closure; a function with no captures never emits it (it is a plain Constant
// push of its proto). Captures are by value: Set_Upvalue mutates the closure's own snapshot, not
// the enclosing local it was copied from.
//
// `Jump_If_False` deliberately *peeks* rather than pops: leaving the tested value on the stack is
// what lets `&&`/`||` short-circuit to that value directly, and is what makes an `if`/`while`'s
// own explicit `Pop` (emitted by the compiler on both branches, Chapter 11) the only place the
// condition is actually discarded.
Op_Code :: enum u8 {
Constant,
Nil,
True,
False,
Pop,
Dup,
Add,
Sub,
Mul,
Div,
Mod,
Negate,
Not,
Equal,
Not_Equal,
Less,
Less_Equal,
Greater,
Greater_Equal,
Get_Local,
Set_Local,
Get_Global,
Set_Global,
Define_Global,
Jump,
Jump_If_False,
Call,
Return,
Yield,
Make_Struct,
Get_Field,
Set_Field,
Make_Array,
Get_Index,
Set_Index,
Make_Closure,
Get_Upvalue,
Set_Upvalue,
}
// op_operand_bytes is how many operand bytes follow an opcode byte in the code stream: 0 for a
// bare instruction, 1 for a u8 index/slot/count, 2 for a jump's i16 offset.
op_operand_bytes :: proc(op: Op_Code) -> int {
#partial switch op {
case .Constant, .Get_Local, .Set_Local, .Get_Global, .Set_Global, .Define_Global, .Call,
.Make_Struct, .Get_Field, .Set_Field, .Make_Array, .Get_Upvalue, .Set_Upvalue:
return 1
case .Jump, .Jump_If_False, .Make_Closure:
return 2
case:
return 0
}
}
op_name :: proc(op: Op_Code) -> string {
switch op {
case .Constant: return "CONSTANT"
case .Nil: return "NIL"
case .True: return "TRUE"
case .False: return "FALSE"
case .Pop: return "POP"
case .Dup: return "DUP"
case .Add: return "ADD"
case .Sub: return "SUB"
case .Mul: return "MUL"
case .Div: return "DIV"
case .Mod: return "MOD"
case .Negate: return "NEGATE"
case .Not: return "NOT"
case .Equal: return "EQUAL"
case .Not_Equal: return "NOT_EQUAL"
case .Less: return "LESS"
case .Less_Equal: return "LESS_EQUAL"
case .Greater: return "GREATER"
case .Greater_Equal: return "GREATER_EQUAL"
case .Get_Local: return "GET_LOCAL"
case .Set_Local: return "SET_LOCAL"
case .Get_Global: return "GET_GLOBAL"
case .Set_Global: return "SET_GLOBAL"
case .Define_Global: return "DEFINE_GLOBAL"
case .Jump: return "JUMP"
case .Jump_If_False: return "JUMP_IF_FALSE"
case .Call: return "CALL"
case .Return: return "RETURN"
case .Yield: return "YIELD"
case .Make_Struct: return "MAKE_STRUCT"
case .Get_Field: return "GET_FIELD"
case .Set_Field: return "SET_FIELD"
case .Make_Array: return "MAKE_ARRAY"
case .Get_Index: return "GET_INDEX"
case .Set_Index: return "SET_INDEX"
case .Make_Closure: return "MAKE_CLOSURE"
case .Get_Upvalue: return "GET_UPVALUE"
case .Set_Upvalue: return "SET_UPVALUE"
}
return "UNKNOWN"
}
// Const_Value is one constant-pool entry: a literal a chunk refers to by index rather than
// embedding inline, so the same literal used twice (or a string of any length) costs one index
// byte at each use site, not a copy of the value there. Deliberately *not* named `Value`: the
// tree-walker's own `Value` (Chapter 8) is a separate, distinct type, and Chapter 13 is where the
// two representations finally get reconciled into the VM's real runtime value. Booleans and nil
// never need a pool slot at all: `Nil`/`True`/`False` push their literal directly (see the table
// above), so this union only needs to hold what a fixed-width `Constant` instruction can't.
Const_Value :: union {
f64,
string,
^Func_Proto,
^Struct_Layout,
}
// Struct_Layout is Make_Struct's own constant-pool payload: the declaring struct's full field
// order (shared by every instance of this type, never copied per instance) and, for this one
// literal, which pushed value lands at which field index (field_indices[i] is where the i-th
// value MAKE_STRUCT pops corresponds to in the instance's own values array; an omitted field
// simply has no entry pointing at it and is left nil, exactly matching Chapter 10's tree-walker).
Struct_Layout :: struct {
type_name: string,
field_names: []string,
field_indices: []int,
}
// Line_Run is one run-length-encoded entry in a chunk's line table: "every byte from
// `start_offset` onward is on `line`, until the next run says otherwise." Many consecutive
// instructions share a source line (a whole expression's worth of bytecode, an entire loop
// body), so this costs one entry per *change* of line, not one per byte, the way an early,
// one-int-per-byte version of this table did (see chunk_line_at's own doc comment for how it's
// read back).
Line_Run :: struct {
start_offset: int,
line: int,
}
// Chunk is one compiled function's bytecode: a flat byte stream, its constant pool, and a
// run-length-encoded line table.
Chunk :: struct {
code: [dynamic]u8,
constants: [dynamic]Const_Value,
lines: [dynamic]Line_Run,
}
chunk_init :: proc(allocator := context.allocator) -> Chunk {
return Chunk{
code = make([dynamic]u8, allocator),
constants = make([dynamic]Const_Value, allocator),
lines = make([dynamic]Line_Run, allocator),
}
}
// chunk_write appends one byte, starting a new Line_Run only when the line actually changes
// from the previous byte written: two bytes of the same instruction (an opcode and its own
// operand) always share a line already, since every call site passes the same `node.line` for
// both, so in practice a run almost always spans a whole instruction, often several.
chunk_write :: proc(c: ^Chunk, byte: u8, line: int) {
if len(c.lines) == 0 || c.lines[len(c.lines) - 1].line != line {
append(&c.lines, Line_Run{start_offset = len(c.code), line = line})
}
append(&c.code, byte)
}
// chunk_line_at looks up which source line a given byte offset belongs to: the line of the last
// run whose `start_offset` is at or before `offset`. Runs are in increasing `start_offset` order
// (chunk_write only ever appends), so this is a linear scan backward from the end, correct for
// any offset actually inside the chunk without needing the run table sorted or searched any more
// cleverly than that; a chunk's own run count is small enough that this is not a real cost.
chunk_line_at :: proc(c: ^Chunk, offset: int) -> int {
for i := len(c.lines) - 1; i >= 0; i -= 1 {
if c.lines[i].start_offset <= offset {
return c.lines[i].line
}
}
return 0
}
chunk_write_op :: proc(c: ^Chunk, op: Op_Code, line: int) {
chunk_write(c, u8(op), line)
}
// chunk_add_const appends a value to the constant pool and returns its index. A `u8` index caps
// a single chunk at 256 constants, a real limit worth naming rather than hiding: a wider or
// variable-width encoding would cost every other instruction's simplicity to serve scripts far
// larger than anything this book's examples, or Embers' own scripts, ever need.
chunk_add_const :: proc(c: ^Chunk, value: Const_Value) -> u8 {
append(&c.constants, value)
return u8(len(c.constants) - 1)
}
muspel/arena.odin
package muspel
import "core:mem"
// Value_Arena_Size is the fixed backing buffer every VM instance's struct/array arena gets:
// Chapter 12's Design decision made concrete as one tuning constant, alongside the VM's other
// budgets (Max_Call_Depth, Instruction_Tick_Budget, vm.odin). A script that allocates past this
// is Chapter 21's problem (an arena-exhausted script becomes a recoverable Muspel-level error,
// not a crash); Chapter 12 only names that failure mode, it doesn't yet guard against it.
// Value_Arena_Size is the default per-VM arena. The engine (PLAN.md M5) lets a game raise it via
// `_config().arena_kb` for a world with a lot of persistent state; 1 MiB is a comfortable default,
// four times the book's original 256 KiB, so most games never need to touch it. The arena is still
// bump-only and never reclaimed mid-run: churn (create/destroy over a long run) is handled by
// pooling, exactly as the games do; a larger, configurable ceiling is the deliberate M5 trade
// (PLAN.md 3.4 / ยง5), not a garbage collector.
Value_Arena_Size :: 128 * 1024 // 128 KiB
// Value_Arena is every struct and array a single VM instance ever creates, in one bump-allocated
// buffer: a `MAKE_STRUCT`/`MAKE_ARRAY` allocation is "bump the offset and return a slice," and
// tearing the whole instance down (Chapter 19) is `value_arena_reset`, one write, reclaiming
// everything at once with no per-object trace, no reference count, no cycle check. This is
// exactly the trade Odin's own `context.temp_allocator` makes at a smaller scale (bump-allocate
// freely, reset the whole arena at a well-defined boundary), applied here to a scripting VM's
// entire struct/array value lifetime instead of one frame; `core:mem`'s own `Arena` (a plain
// buffer-backed bump allocator, no OS virtual memory) is that same mechanism, reused rather than
// hand-rolled, and works identically on every target this book's code runs on, native or
// `js_wasm32` alike.
Value_Arena :: struct {
buf: []byte,
arena: mem.Arena,
}
value_arena_init :: proc(a: ^Value_Arena, size := Value_Arena_Size, allocator := context.allocator) {
a.buf = make([]byte, size, allocator)
mem.arena_init(&a.arena, a.buf)
}
value_arena_allocator :: proc(a: ^Value_Arena) -> mem.Allocator {
return mem.arena_allocator(&a.arena)
}
// value_arena_reset is Chapter 19's own closing move: every struct and array this VM instance
// ever allocated is reclaimed in this one call, because nothing in Muspel ever holds a raw
// pointer into another VM instance's arena (Chapter 18's marshalling rule) and nothing here has a
// destructor that could need to run first.
value_arena_reset :: proc(a: ^Value_Arena) {
mem.arena_free_all(&a.arena)
}
// value_arena_bytes_used reports the arena's current high-water offset: Chapter 21's arena
// budget check reads this before a MAKE_STRUCT/MAKE_ARRAY allocation to decide whether the
// script has run this VM instance's arena dry.
value_arena_bytes_used :: proc(a: ^Value_Arena) -> int {
return a.arena.offset
}
muspel/gc.odin
package muspel
import "core:mem"
// vm_collect_garbage performs a precise copying garbage collection pass over the VM's value_arena.
// It traces all roots (vm.stack, vm.globals, vm.callbacks, vm.frames upvalues), copies live objects
// (Struct_Instance, Array_Instance, Map_Instance, Closure) into a fresh Value_Arena, and replaces
// the VM's arena, reclaiming all unreachable objects in one pass.
vm_collect_garbage :: proc(vm: ^VM) -> int {
initial_used := value_arena_bytes_used(&vm.value_arena)
capacity := len(vm.value_arena.buf)
if capacity == 0 do return 0
// 1. Create a fresh backing arena for surviving objects
new_arena: Value_Arena
value_arena_init(&new_arena, capacity)
alloc := value_arena_allocator(&new_arena)
// 2. Track forwarded pointers (old rawptr -> new rawptr) to handle shared references and cycles
forwarded := make(map[rawptr]rawptr, 32, context.temp_allocator)
defer delete(forwarded)
// Helper procedure to copy values recursively into new_arena
copy_value: proc(v: Value, alloc: mem.Allocator, forwarded: ^map[rawptr]rawptr, copy_val_ptr: rawptr) -> Value
copy_value = proc(v: Value, alloc: mem.Allocator, forwarded: ^map[rawptr]rawptr, copy_val_ptr: rawptr) -> Value {
copy_proc := cast(proc(v: Value, alloc: mem.Allocator, forwarded: ^map[rawptr]rawptr, copy_val_ptr: rawptr) -> Value)copy_val_ptr
#partial switch val in v {
case ^Struct_Instance:
if val == nil do return nil
old_ptr := rawptr(val)
if new_ptr, found := forwarded[old_ptr]; found {
return Value(cast(^Struct_Instance)new_ptr)
}
new_inst := new(Struct_Instance, alloc)
forwarded[old_ptr] = rawptr(new_inst)
new_inst.type_name = val.type_name
new_inst.field_names = val.field_names
new_inst.values = make([]Value, len(val.values), alloc)
for field_val, i in val.values {
new_inst.values[i] = copy_proc(field_val, alloc, forwarded, copy_val_ptr)
}
return Value(new_inst)
case ^Array_Instance:
if val == nil do return nil
old_ptr := rawptr(val)
if new_ptr, found := forwarded[old_ptr]; found {
return Value(cast(^Array_Instance)new_ptr)
}
new_inst := new(Array_Instance, alloc)
forwarded[old_ptr] = rawptr(new_inst)
new_inst.count = val.count
new_inst.values = make([]Value, len(val.values), alloc)
for elem_val, i in 0 ..< val.count {
new_inst.values[i] = copy_proc(val.values[i], alloc, forwarded, copy_val_ptr)
}
return Value(new_inst)
case ^Map_Instance:
if val == nil do return nil
old_ptr := rawptr(val)
if new_ptr, found := forwarded[old_ptr]; found {
return Value(cast(^Map_Instance)new_ptr)
}
new_inst := new(Map_Instance, alloc)
forwarded[old_ptr] = rawptr(new_inst)
new_inst.count = val.count
cap := len(val.keys)
new_inst.keys = make([]Value, cap, alloc)
new_inst.values = make([]Value, cap, alloc)
new_inst.state = make([]u8, cap, alloc)
copy(new_inst.state, val.state)
for st, i in val.state {
if st == 1 {
new_inst.keys[i] = copy_proc(val.keys[i], alloc, forwarded, copy_val_ptr)
new_inst.values[i] = copy_proc(val.values[i], alloc, forwarded, copy_val_ptr)
}
}
return Value(new_inst)
case ^Closure:
if val == nil do return nil
old_ptr := rawptr(val)
if new_ptr, found := forwarded[old_ptr]; found {
return Value(cast(^Closure)new_ptr)
}
new_inst := new(Closure, alloc)
forwarded[old_ptr] = rawptr(new_inst)
new_inst.proto = val.proto
new_inst.captures = make([]Value, len(val.captures), alloc)
for cap_val, i in val.captures {
new_inst.captures[i] = copy_proc(cap_val, alloc, forwarded, copy_val_ptr)
}
return Value(new_inst)
case:
return v
}
}
copy_proc_ptr := rawptr(copy_value)
// 3. Traversal of Roots
// Root 1: Value Stack
for i in 0 ..< len(vm.stack) {
vm.stack[i] = copy_value(vm.stack[i], alloc, &forwarded, copy_proc_ptr)
}
// Root 2: Globals
for name, val in vm.globals {
vm.globals[name] = copy_value(val, alloc, &forwarded, copy_proc_ptr)
}
// Root 3: Callbacks
for name, val in vm.callbacks {
vm.callbacks[name] = copy_value(val, alloc, &forwarded, copy_proc_ptr)
}
// Root 4: Active Frame Closures (Upvalues)
for i in 0 ..< len(vm.frames) {
if vm.frames[i].closure != nil {
cl_val := copy_value(Value(vm.frames[i].closure), alloc, &forwarded, copy_proc_ptr)
if cl, ok := cl_val.(^Closure); ok {
vm.frames[i].closure = cl
}
}
}
// 4. Swap old arena with new_arena
value_arena_reset(&vm.value_arena)
vm.value_arena = new_arena
reclaimed := initial_used - value_arena_bytes_used(&vm.value_arena)
return reclaimed
}
muspel/mapval.odin
package muspel
// mapval.odin: the `map` dictionary type's runtime (PLAN.md M5). A Map_Instance (value.odin) is a
// fixed-capacity, open-addressed hash table allocated once by `map(cap)`; `m[key]` reads and writes
// it through the ordinary Get_Index/Set_Index opcodes (vm.odin), and `has`/`keys`/`len`/`remove`
// are builtins. Keys are strings or numbers; add and remove reuse slots (tombstones), so a map that
// churns keys every frame never grows the arena, the same discipline `list(cap)` follows.
//
// This is a small linear-probe table, deliberately: a game's maps hold tens to low hundreds of
// entries, where a clear probe loop beats a fancier scheme, and it needs no growth path (the
// fixed-capacity, reuse-in-place design is the whole point). It sits alongside the other collection
// code rather than reaching for Odin's own `map`, which would allocate on Odin's general heap
// instead of this VM instance's arena and so would not be freed by value_arena_reset on teardown.
// map_hash returns a hash for a key and whether the key is hashable at all: strings (FNV-1a over
// their bytes) and numbers (their raw bits) are; anything else is not, and the caller reports it.
@(private = "file")
map_hash :: proc(key: Value) -> (h: u64, ok: bool) {
#partial switch k in key {
case string:
hash: u64 = 1469598103934665603 // FNV-1a offset basis
for c in transmute([]u8)k {
hash = (hash ~ u64(c)) * 1099511628211
}
return hash, true
case f64:
return transmute(u64)k, true
case:
return 0, false
}
}
// map_find locates an existing key: it probes from the key's hash slot, stops at the first empty
// slot (which terminates the chain: the key is absent), skips tombstones, and returns the index of
// the occupied slot whose key matches. found == false means the key is not in the map.
@(private = "file")
map_find :: proc(m: ^Map_Instance, key: Value) -> (idx: int, found: bool) {
capn := len(m.state)
if capn == 0 do return -1, false
h, ok := map_hash(key)
if !ok do return -1, false
start := int(h % u64(capn))
for i in 0 ..< capn {
j := (start + i) % capn
switch m.state[j] {
case 0:
return -1, false
case 1:
if values_equal(m.keys[j], key) do return j, true
case 2:
// deleted tombstone: keep probing
}
}
return -1, false
}
// map_insert_slot finds the slot a key should occupy for a write: an existing slot with this key
// (an update), or the first free slot the probe reaches (reusing the first tombstone seen, so
// removed space is recovered). ok == false means the map is full or the key is not hashable.
@(private = "file")
map_insert_slot :: proc(m: ^Map_Instance, key: Value) -> (idx: int, ok: bool) {
capn := len(m.state)
if capn == 0 do return -1, false
h, hok := map_hash(key)
if !hok do return -1, false
start := int(h % u64(capn))
first_deleted := -1
for i in 0 ..< capn {
j := (start + i) % capn
switch m.state[j] {
case 0:
if first_deleted >= 0 do return first_deleted, true
return j, true
case 1:
if values_equal(m.keys[j], key) do return j, true
case 2:
if first_deleted < 0 do first_deleted = j
}
}
if first_deleted >= 0 do return first_deleted, true
return -1, false
}
// map_get is Get_Index for a map: the stored value, or nil if the key is absent.
map_get :: proc(m: ^Map_Instance, key: Value) -> Value {
if j, found := map_find(m, key); found do return m.values[j]
return nil
}
// map_set is Set_Index for a map. It reports a runtime error (and does nothing) if the key is not a
// string or number, or if the map is full; otherwise it inserts or updates in place.
map_set :: proc(vm: ^VM, m: ^Map_Instance, key: Value, value: Value) {
if _, hok := map_hash(key); !hok {
runtime_error(vm, "a map key must be a string or number")
return
}
j, ok := map_insert_slot(m, key)
if !ok {
runtime_error(vm, "map is full (capacity %d)", len(m.state))
return
}
if m.state[j] != 1 do m.count += 1 // a new entry, not an update of an existing one
m.keys[j] = key
m.values[j] = value
m.state[j] = 1
}
// map_delete is remove(map, key): mark the slot deleted (a tombstone the probe still walks past)
// and drop the live count. No error if the key was absent.
map_delete :: proc(m: ^Map_Instance, key: Value) {
if j, found := map_find(m, key); found {
m.state[j] = 2
m.keys[j] = nil
m.values[j] = nil
m.count -= 1
}
}
arg_map :: proc(args: []Value, i: int) -> (^Map_Instance, bool) {
if i >= len(args) do return nil, false
v, ok := args[i].(^Map_Instance)
return v, ok
}
// native_map builds an empty map with room for `cap` entries, the dictionary counterpart to
// list(cap): sized once, reused in place, never grown.
native_map :: proc(vm: ^VM, args: []Value) -> Value {
capf, ok := arg_number(args, 0)
if !ok {
runtime_error(vm, "map expects (capacity)")
return nil
}
capn := int(capf)
if capn < 1 do capn = 1
a := value_arena_allocator(&vm.value_arena)
keys := make([]Value, capn, a)
values := make([]Value, capn, a)
state := make([]u8, capn, a)
inst := new(Map_Instance, a)
if len(keys) != capn || len(values) != capn || len(state) != capn || inst == nil {
runtime_error(vm, "arena exhausted: cannot allocate a map of capacity %d", capn)
return nil
}
inst^ = Map_Instance{keys = keys, values = values, state = state, count = 0}
return inst
}
// native_has reports whether a key is present, the test m[key] alone cannot make (a stored nil and
// an absent key both read back as nil).
native_has :: proc(vm: ^VM, args: []Value) -> Value {
m, ok := arg_map(args, 0)
if !ok || len(args) < 2 {
runtime_error(vm, "has expects (map, key)")
return nil
}
_, found := map_find(m, args[1])
return found
}
// native_keys returns the map's live keys as a fresh array, for `for k in keys(m) { ... }`
// iteration. It allocates that array from the arena, so call it outside a per-frame hot loop (the
// same "keys() is not free" caveat every dictionary's key enumeration carries); ordinary lookups
// (m[key]) allocate nothing.
native_keys :: proc(vm: ^VM, args: []Value) -> Value {
m, ok := arg_map(args, 0)
if !ok {
runtime_error(vm, "keys expects (map)")
return nil
}
a := value_arena_allocator(&vm.value_arena)
out := make([]Value, m.count, a)
arr := new(Array_Instance, a)
if len(out) != m.count || arr == nil {
runtime_error(vm, "arena exhausted: cannot allocate a keys array of %d", m.count)
return nil
}
n := 0
for st, i in m.state {
if st == 1 {
out[n] = m.keys[i]
n += 1
}
}
arr^ = Array_Instance{values = out, count = m.count}
return arr
}
muspel/compiler.odin
package muspel
// Func_Proto is a compiled function: its own independent Chunk (constants and all), plus the
// name and arity a call site needs to check against. A top-level `func` compiles to exactly one
// of these, stored as a constant and bound to its name via Define_Global, precisely mirroring
// how a top-level `var` is compiled: functions are values here just as they were in Chapter 8.
Func_Proto :: struct {
name: string,
arity: int,
chunk: Chunk,
}
// Compiler_Local tracks one local's name and the block-nesting depth it was declared at. This
// is deliberately a different structure from Chapter 7's own `Local`, not an oversight:
//
// Design decision: the compiler tracks its own locals, rather than reusing the resolver's
// (depth, slot) pairs directly.
// Alternatives considered: have the Chapter 7 resolver stamp a second, bytecode-flavoured slot
// number onto every Expr_Ident/Stmt_Var/Expr_Assign node, alongside the (depth, slot) pair the
// tree-walker already uses, so both backends read from the same pass.
// Trade-off: the resolver's existing `slot` restarts at zero in every nested block, exactly
// right for Chapter 8's tree-walker, where each block gets its own freshly allocated
// `Environment` and `depth` says how many of those to hop through. A bytecode VM has no such
// per-block allocation: every local a function ever declares, across all its nested blocks,
// lives in *one* flat region of the VM's value stack (Chapter 13), so two variables declared in
// sibling blocks can safely share a slot number (they're never live at the same time), while two
// variables nested one inside the other never can, a distinction the resolver's per-block
// counter was never built to make. Stamping a second number onto the resolver's output would
// couple Chapter 7's pass to a bytecode design it doesn't otherwise need to know exists.
// Choice: the compiler re-derives locals itself, with its own small scope-stack, doing the same
// left-to-right declare-before-use walk the resolver already validated is correct. It reuses
// exactly one thing from Chapter 7: the global/local distinction (top level versus everywhere
// else), which is genuinely backend-agnostic; everything past that is recomputed for the flat
// layout a bytecode VM actually needs.
Compiler_Local :: struct {
name: string,
depth: int,
}
// Compiler_Upvalue records one variable a nested function captures from an enclosing one (PLAN.md
// M6). `is_local` distinguishes the two ways a capture reaches its source: `true` means "a local
// slot in my *immediate* enclosing function" (emit Get_Local at closure-creation time), `false`
// means "an upvalue of my enclosing function" (emit Get_Upvalue, so a capture two or more levels
// out is threaded down one function at a time). `index` is that slot or upvalue index. This is the
// standard clox upvalue-resolution shape; the difference is purely at run time, where Muspel
// snapshots the captured value rather than sharing a live cell.
Compiler_Upvalue :: struct {
index: u8,
is_local: bool,
}
// Compiler holds one function's worth of in-progress compilation: the Chunk being written into,
// `locals` used as a stack (push on declare, truncate on scope exit) so sibling blocks reuse slot
// numbers and nested ones never collide, `enclosing` (the compiler of the function this one is
// nested inside, nil at the top level) for upvalue resolution, and `upvalues` the ordered list of
// captures this function needs, filled as its body compiles and read back by the enclosing
// function to emit the matching Make_Closure.
Compiler :: struct {
chunk: ^Chunk,
locals: [dynamic]Compiler_Local,
scope_depth: int,
enclosing: ^Compiler,
upvalues: [dynamic]Compiler_Upvalue,
}
compiler_init :: proc(chunk: ^Chunk, allocator := context.allocator) -> Compiler {
return Compiler{
chunk = chunk,
locals = make([dynamic]Compiler_Local, allocator),
scope_depth = 0,
enclosing = nil,
upvalues = make([dynamic]Compiler_Upvalue, allocator),
}
}
// resolve_upvalue finds `name` in an enclosing function and records the capture chain needed to
// reach it, returning this function's own upvalue index for it. It searches the immediate
// enclosing function's locals first (a direct capture), then recurses so a variable captured from
// two or more levels out is threaded down through each intervening function as an upvalue-of-an-
// upvalue. Returns found == false when `name` is not an enclosing local at all (a global or
// builtin), leaving the caller to fall back to Get_Global.
@(private = "file")
resolve_upvalue :: proc(c: ^Compiler, name: string) -> (idx: u8, found: bool) {
if c.enclosing == nil do return 0, false
if slot, ok := resolve_local(c.enclosing, name); ok {
return add_upvalue(c, slot, true), true
}
if up, ok := resolve_upvalue(c.enclosing, name); ok {
return add_upvalue(c, up, false), true
}
return 0, false
}
// add_upvalue appends a capture to this function's upvalue list, de-duplicating so two references
// to the same captured variable share one slot, and returns its index.
@(private = "file")
add_upvalue :: proc(c: ^Compiler, index: u8, is_local: bool) -> u8 {
for uv, i in c.upvalues {
if uv.index == index && uv.is_local == is_local {
return u8(i)
}
}
append(&c.upvalues, Compiler_Upvalue{index = index, is_local = is_local})
return u8(len(c.upvalues) - 1)
}
@(private = "file")
begin_scope :: proc(c: ^Compiler) {
c.scope_depth += 1
}
// end_scope pops every local this scope declared, both from the compiler's own bookkeeping and,
// with one `Pop` per local, from the VM's value stack at run time: the flat-array design's whole
// point is that this is the *only* cleanup a closing block ever needs.
@(private = "file")
end_scope :: proc(c: ^Compiler, line: int) {
c.scope_depth -= 1
for len(c.locals) > 0 && c.locals[len(c.locals) - 1].depth > c.scope_depth {
pop(&c.locals)
chunk_write_op(c.chunk, .Pop, line)
}
}
@(private = "file")
declare_local :: proc(c: ^Compiler, name: string) {
append(&c.locals, Compiler_Local{name = name, depth = c.scope_depth})
}
// resolve_local searches innermost-first, so a shadowing local (appended later, hence found
// first) always wins over whatever it's shadowing, exactly like the resolver's own scope stack.
@(private = "file")
resolve_local :: proc(c: ^Compiler, name: string) -> (slot: u8, found: bool) {
for i := len(c.locals) - 1; i >= 0; i -= 1 {
if c.locals[i].name == name {
return u8(i), true
}
}
return 0, false
}
@(private = "file")
emit_jump :: proc(c: ^Compiler, op: Op_Code, line: int) -> int {
chunk_write_op(c.chunk, op, line)
chunk_write(c.chunk, 0xff, line)
chunk_write(c.chunk, 0xff, line)
return len(c.chunk.code) - 2
}
// patch_jump backfills a forward jump's placeholder operand once the target (whatever code
// comes right after the jump was emitted) is finally known. The offset is measured from the
// instruction *after* the jump's own two operand bytes, so `ip += offset`, applied post-fetch,
// lands exactly here.
@(private = "file")
patch_jump :: proc(c: ^Compiler, jump_pos: int) {
offset := len(c.chunk.code) - (jump_pos + 2)
c.chunk.code[jump_pos] = u8((offset >> 8) & 0xff)
c.chunk.code[jump_pos + 1] = u8(offset & 0xff)
}
// emit_loop is a backward jump: `Jump`'s operand is a signed i16, so the same single opcode
// that patch_jump patches forward also runs backward, encoding a negative offset, no separate
// "loop" opcode needed.
@(private = "file")
emit_loop :: proc(c: ^Compiler, loop_start: int, line: int) {
chunk_write_op(c.chunk, .Jump, line)
offset := loop_start - (len(c.chunk.code) + 2)
chunk_write(c.chunk, u8((offset >> 8) & 0xff), line)
chunk_write(c.chunk, u8(offset & 0xff), line)
}
// compile_program compiles a whole file's top-level statements into `chunk`, treating the file
// itself as an implicit top-level function body: every top-level `var` and `func` compiles as a
// global (Chapter 7 already marked both `depth == -1`; a fresh `Compiler` starts at
// `scope_depth == 0`, so the two conventions agree without either pass consulting the other's
// numbers). The trailing `Nil; Return` mirrors `compile_function`'s own implicit return exactly:
// `Return` (Chapter 13) always pops a value, so there must always be one to pop, even here,
// where nothing in the source ever wrote a `return` at all.
//
// Top-level functions are compiled and bound to their names in a first pass over every
// statement, before any of the statements *around* them run, exactly mirroring Chapter 7's
// resolver and Chapter 8's tree-walker, both of which hoist every top-level function ahead of
// everything else for the same reason: one top-level function calling another declared later in
// the same file, immediately, not just eventually, must find its `Define_Global` has already
// executed. Compiling in strict file order instead would make that a runtime "undefined global"
// error the moment the call happens to run before the callee's own declaration does, a real bug,
// not a hypothetical one: `print(a())` calling a forward-referenced `b()` from inside `a()`,
// with `b` declared later in the file, is exactly the case this pass exists to get right.
compile_program :: proc(chunk: ^Chunk, stmts: []Stmt, allocator := context.allocator) {
c := compiler_init(chunk, allocator)
for s in stmts {
if _, ok := s.(^Stmt_Func); ok {
compile_stmt(&c, s)
}
}
for s in stmts {
if _, ok := s.(^Stmt_Func); ok do continue
compile_stmt(&c, s)
}
chunk_write_op(c.chunk, .Nil, 0)
chunk_write_op(c.chunk, .Return, 0)
}
compile_stmt :: proc(c: ^Compiler, s: Stmt) {
switch node in s {
case ^Stmt_Var:
if node.init != nil {
compile_expr(c, node.init)
} else {
chunk_write_op(c.chunk, .Nil, node.line)
}
if c.scope_depth == 0 {
idx := chunk_add_const(c.chunk, Const_Value(node.name))
chunk_write_op(c.chunk, .Define_Global, node.line)
chunk_write(c.chunk, idx, node.line)
} else {
declare_local(c, node.name)
}
case ^Stmt_Multi:
// A desugared multi-binding `var`: emit each binding in turn, in the current scope. Each
// child is a Stmt_Var, so this is exactly the code the separate-lines form would produce.
for child in node.stmts {
compile_stmt(c, child)
}
case ^Stmt_Func:
if c.scope_depth == 0 {
// Top-level: a global, non-capturing, bound by name (unchanged from the book).
proto, _ := compile_fn_body(node.params, node.body, node.name, node.line, nil)
idx := chunk_add_const(c.chunk, Const_Value(proto))
chunk_write_op(c.chunk, .Constant, node.line)
chunk_write(c.chunk, idx, node.line)
name_idx := chunk_add_const(c.chunk, Const_Value(node.name))
chunk_write_op(c.chunk, .Define_Global, node.line)
chunk_write(c.chunk, name_idx, node.line)
} else {
// Nested: compile as a closure value, then bind it as a local, exactly as if the source
// had written `var name = func(...) { ... }` (engine era, PLAN.md M6).
emit_closure(c, node.params, node.body, node.name, node.line)
declare_local(c, node.name)
}
case ^Stmt_Expr:
compile_expr(c, node.expr)
chunk_write_op(c.chunk, .Pop, node.line)
case ^Stmt_If:
compile_expr(c, node.cond)
then_jump := emit_jump(c, .Jump_If_False, node.line)
chunk_write_op(c.chunk, .Pop, node.line) // discard cond: truthy path, entering `then`
compile_stmt(c, node.then_branch)
else_jump := emit_jump(c, .Jump, node.line)
patch_jump(c, then_jump)
chunk_write_op(c.chunk, .Pop, node.line) // discard cond: falsy path, entering `else`
if node.else_branch != nil {
compile_stmt(c, node.else_branch)
}
patch_jump(c, else_jump)
case ^Stmt_While:
loop_start := len(c.chunk.code)
compile_expr(c, node.cond)
exit_jump := emit_jump(c, .Jump_If_False, node.line)
chunk_write_op(c.chunk, .Pop, node.line) // discard cond: truthy path, entering the body
compile_stmt(c, node.body)
emit_loop(c, loop_start, node.line)
patch_jump(c, exit_jump)
chunk_write_op(c.chunk, .Pop, node.line) // discard cond: falsy path, loop exits
case ^Stmt_Return:
if node.value != nil {
compile_expr(c, node.value)
} else {
chunk_write_op(c.chunk, .Nil, node.line)
}
chunk_write_op(c.chunk, .Return, node.line)
case ^Stmt_Yield:
chunk_write_op(c.chunk, .Yield, node.line)
case ^Stmt_Block:
begin_scope(c)
for stmt in node.stmts {
compile_stmt(c, stmt)
}
end_scope(c, node.line)
case ^Stmt_Struct_Decl, ^Stmt_Enum_Decl, ^Stmt_Import:
// Declarations only: nothing to emit. An import's qualified re-binding
// (Get_Global name; Define_Global "alias.name" for every function the imported module
// exports) is emitted once, by modules.odin's compile_module, when that *other* module is
// compiled, not here: this node is purely a marker the resolver already consumed. A struct's field order/an enum's member order live
// entirely at compile time, baked into every Struct_Layout constant or CONST-tag reference
// that uses them (compile_expr's Expr_Struct_Literal/Expr_Field_Access cases); there is no
// runtime "the type itself" value the way there is for a function.
// for item in list { ... } desugars into a hidden index counter plus a GET_INDEX/bounds-check/
// jump sequence, reusing the same jump-patching machinery `while` already uses, rather than a
// dedicated iteration opcode (Chapter 13, section 7).
case ^Stmt_For_In:
begin_scope(c)
compile_expr(c, node.list)
declare_local(c, "@list")
list_slot, _ := resolve_local(c, "@list")
zero_idx := chunk_add_const(c.chunk, Const_Value(f64(0)))
chunk_write_op(c.chunk, .Constant, node.line)
chunk_write(c.chunk, zero_idx, node.line)
declare_local(c, "@idx")
idx_slot, _ := resolve_local(c, "@idx")
chunk_write_op(c.chunk, .Nil, node.line)
declare_local(c, node.item_name)
item_slot, _ := resolve_local(c, node.item_name)
loop_start := len(c.chunk.code)
// idx < len(list)
chunk_write_op(c.chunk, .Get_Local, node.line)
chunk_write(c.chunk, idx_slot, node.line)
len_idx := chunk_add_const(c.chunk, Const_Value("len"))
chunk_write_op(c.chunk, .Get_Global, node.line)
chunk_write(c.chunk, len_idx, node.line)
chunk_write_op(c.chunk, .Get_Local, node.line)
chunk_write(c.chunk, list_slot, node.line)
chunk_write_op(c.chunk, .Call, node.line)
chunk_write(c.chunk, 1, node.line)
chunk_write_op(c.chunk, .Less, node.line)
exit_jump := emit_jump(c, .Jump_If_False, node.line)
chunk_write_op(c.chunk, .Pop, node.line) // discard cond: truthy path, entering the body
// item = list[idx]
chunk_write_op(c.chunk, .Get_Local, node.line)
chunk_write(c.chunk, list_slot, node.line)
chunk_write_op(c.chunk, .Get_Local, node.line)
chunk_write(c.chunk, idx_slot, node.line)
chunk_write_op(c.chunk, .Get_Index, node.line)
chunk_write_op(c.chunk, .Set_Local, node.line)
chunk_write(c.chunk, item_slot, node.line)
chunk_write_op(c.chunk, .Pop, node.line) // discard Set_Local's own leftover value
compile_stmt(c, node.body)
// idx = idx + 1
chunk_write_op(c.chunk, .Get_Local, node.line)
chunk_write(c.chunk, idx_slot, node.line)
one_idx := chunk_add_const(c.chunk, Const_Value(f64(1)))
chunk_write_op(c.chunk, .Constant, node.line)
chunk_write(c.chunk, one_idx, node.line)
chunk_write_op(c.chunk, .Add, node.line)
chunk_write_op(c.chunk, .Set_Local, node.line)
chunk_write(c.chunk, idx_slot, node.line)
chunk_write_op(c.chunk, .Pop, node.line)
emit_loop(c, loop_start, node.line)
patch_jump(c, exit_jump)
chunk_write_op(c.chunk, .Pop, node.line) // discard cond: falsy path, loop exits
end_scope(c, node.line)
}
}
compile_expr :: proc(c: ^Compiler, e: Expr) {
switch node in e {
case ^Expr_Literal:
switch lit in node.value {
case f64:
idx := chunk_add_const(c.chunk, Const_Value(lit))
chunk_write_op(c.chunk, .Constant, node.line)
chunk_write(c.chunk, idx, node.line)
case string:
idx := chunk_add_const(c.chunk, Const_Value(lit))
chunk_write_op(c.chunk, .Constant, node.line)
chunk_write(c.chunk, idx, node.line)
case bool:
chunk_write_op(c.chunk, lit ? .True : .False, node.line)
case:
chunk_write_op(c.chunk, .Nil, node.line)
}
case ^Expr_Ident:
if slot, found := resolve_local(c, node.name); found {
chunk_write_op(c.chunk, .Get_Local, node.line)
chunk_write(c.chunk, slot, node.line)
} else if up, found := resolve_upvalue(c, node.name); found {
chunk_write_op(c.chunk, .Get_Upvalue, node.line)
chunk_write(c.chunk, up, node.line)
} else {
idx := chunk_add_const(c.chunk, Const_Value(node.name))
chunk_write_op(c.chunk, .Get_Global, node.line)
chunk_write(c.chunk, idx, node.line)
}
case ^Expr_Group:
compile_expr(c, node.inner)
case ^Expr_Unary:
compile_expr(c, node.right)
#partial switch node.op {
case .Minus:
chunk_write_op(c.chunk, .Negate, node.line)
case .Bang:
chunk_write_op(c.chunk, .Not, node.line)
}
case ^Expr_Binary:
compile_expr(c, node.left)
compile_expr(c, node.right)
#partial switch node.op {
case .Plus: chunk_write_op(c.chunk, .Add, node.line)
case .Minus: chunk_write_op(c.chunk, .Sub, node.line)
case .Star: chunk_write_op(c.chunk, .Mul, node.line)
case .Slash: chunk_write_op(c.chunk, .Div, node.line)
case .Percent: chunk_write_op(c.chunk, .Mod, node.line)
case .Equal: chunk_write_op(c.chunk, .Equal, node.line)
case .Not_Equal: chunk_write_op(c.chunk, .Not_Equal, node.line)
case .Less: chunk_write_op(c.chunk, .Less, node.line)
case .Less_Equal: chunk_write_op(c.chunk, .Less_Equal, node.line)
case .Greater: chunk_write_op(c.chunk, .Greater, node.line)
case .Greater_Equal: chunk_write_op(c.chunk, .Greater_Equal, node.line)
}
// Expr_Logical short-circuits using only Jump and Jump_If_False, the same two opcodes
// if/while already use, rather than adding a third jump opcode of its own. `&&` falls
// through to evaluate the right side only when the left was truthy (discarding the truthy
// left value first, since it's not the answer); `||` does the mirror image, deciding via
// one extra unconditional Jump which side's value becomes the result.
case ^Expr_Logical:
compile_expr(c, node.left)
#partial switch node.op {
case .And_And:
end_jump := emit_jump(c, .Jump_If_False, node.line)
chunk_write_op(c.chunk, .Pop, node.line)
compile_expr(c, node.right)
patch_jump(c, end_jump)
case .Or_Or:
else_jump := emit_jump(c, .Jump_If_False, node.line)
end_jump := emit_jump(c, .Jump, node.line)
patch_jump(c, else_jump)
chunk_write_op(c.chunk, .Pop, node.line)
compile_expr(c, node.right)
patch_jump(c, end_jump)
}
case ^Expr_Call:
compile_expr(c, node.callee)
for arg in node.args {
compile_expr(c, arg)
}
chunk_write_op(c.chunk, .Call, node.line)
chunk_write(c.chunk, u8(len(node.args)), node.line)
case ^Expr_Assign:
compile_expr(c, node.value)
if slot, found := resolve_local(c, node.name); found {
chunk_write_op(c.chunk, .Set_Local, node.line)
chunk_write(c.chunk, slot, node.line)
} else if up, found := resolve_upvalue(c, node.name); found {
// Assigning to a captured variable writes the closure's own snapshot (by-value
// semantics), not the enclosing local it was copied from.
chunk_write_op(c.chunk, .Set_Upvalue, node.line)
chunk_write(c.chunk, up, node.line)
} else {
idx := chunk_add_const(c.chunk, Const_Value(node.name))
chunk_write_op(c.chunk, .Set_Global, node.line)
chunk_write(c.chunk, idx, node.line)
}
// A struct literal compiles each provided value, in the order the literal wrote them, then
// emits Make_Struct with a Struct_Layout constant recording where each one lands (Chapter 13,
// section 3): an omitted field has no entry in field_indices and is simply left nil.
case ^Expr_Struct_Literal:
for v in node.values {
compile_expr(c, v)
}
layout := new(Struct_Layout)
layout^ = Struct_Layout {
type_name = node.type_name,
field_names = node.all_fields,
field_indices = node.field_indices,
}
idx := chunk_add_const(c.chunk, Const_Value(layout))
chunk_write_op(c.chunk, .Make_Struct, node.line)
chunk_write(c.chunk, idx, node.line)
// An enum member reference needs no struct/field machinery at all: it's a plain CONST with
// its resolved integer tag (Chapter 13, section 3), the payoff of Chapter 7's decision to
// keep enum members payload-free. A genuine field access compiles straight to Get_Field with
// the field's name as a constant operand.
case ^Expr_Field_Access:
if node.is_enum_member {
idx := chunk_add_const(c.chunk, Const_Value(f64(node.enum_tag)))
chunk_write_op(c.chunk, .Constant, node.line)
chunk_write(c.chunk, idx, node.line)
} else if node.is_qualified_ref {
// alias.function: an ordinary global lookup under its qualified name, exactly the
// binding modules.odin's compile_module re-exported the imported module's own
// `function` under (Chapter 20, section 6).
idx := chunk_add_const(c.chunk, Const_Value(node.qualified_name))
chunk_write_op(c.chunk, .Get_Global, node.line)
chunk_write(c.chunk, idx, node.line)
} else {
compile_expr(c, node.object)
idx := chunk_add_const(c.chunk, Const_Value(node.field))
chunk_write_op(c.chunk, .Get_Field, node.line)
chunk_write(c.chunk, idx, node.line)
}
case ^Expr_Field_Assign:
compile_expr(c, node.object)
compile_expr(c, node.value)
idx := chunk_add_const(c.chunk, Const_Value(node.field))
chunk_write_op(c.chunk, .Set_Field, node.line)
chunk_write(c.chunk, idx, node.line)
case ^Expr_Array_Literal:
for el in node.elements {
compile_expr(c, el)
}
chunk_write_op(c.chunk, .Make_Array, node.line)
chunk_write(c.chunk, u8(len(node.elements)), node.line)
case ^Expr_Index:
compile_expr(c, node.object)
compile_expr(c, node.index)
chunk_write_op(c.chunk, .Get_Index, node.line)
case ^Expr_Index_Assign:
compile_expr(c, node.object)
compile_expr(c, node.index)
compile_expr(c, node.value)
chunk_write_op(c.chunk, .Set_Index, node.line)
case ^Expr_Func:
// An anonymous function expression evaluates to a closure value (engine era, PLAN.md M6).
emit_closure(c, node.params, node.body, "<anon>", node.line)
}
}
// emit_closure compiles a function body (`compile_fn_body`, in a sub-compiler enclosed by `c`) and
// then emits, in `c`'s own instruction stream, the code that produces the resulting function
// *value* at run time. A function that captured nothing is a plain Constant push of its proto, no
// closure object at all, exactly like a top-level function used as a value; only when the body
// actually read enclosing variables does it emit each capture (Get_Local for a direct enclosing
// local, Get_Upvalue for one threaded from further out) followed by Make_Closure, which snapshots
// those pushed values into a new closure.
@(private = "file")
emit_closure :: proc(c: ^Compiler, params: []string, body: ^Stmt_Block, name: string, line: int) {
proto, upvals := compile_fn_body(params, body, name, line, c)
proto_idx := chunk_add_const(c.chunk, Const_Value(proto))
if len(upvals) == 0 {
chunk_write_op(c.chunk, .Constant, line)
chunk_write(c.chunk, proto_idx, line)
return
}
for uv in upvals {
chunk_write_op(c.chunk, uv.is_local ? .Get_Local : .Get_Upvalue, line)
chunk_write(c.chunk, uv.index, line)
}
chunk_write_op(c.chunk, .Make_Closure, line)
chunk_write(c.chunk, proto_idx, line)
chunk_write(c.chunk, u8(len(upvals)), line)
}
// compile_fn_body compiles one function's params and body into its own fresh Chunk, returning the
// finished proto and the list of upvalues its body turned out to capture (empty for a top-level or
// otherwise non-capturing function). Its parameters and its body's own top-level statements share
// exactly one scope, `scope_depth == 1` from the start, with no matching `end_scope` call at the
// end: unlike an ordinary block, a function's own top scope is never explicitly unwound with `Pop`
// instructions, because Chapter 13's `Return` discards the whole call frame at once, mirroring
// precisely how Chapter 7's resolver gave a function's params and body one shared scope rather than
// two nested ones. `enclosing` links this sub-compiler to the function it is nested inside (nil at
// the top level) so a reference to an enclosing variable resolves to an upvalue (PLAN.md M6).
compile_fn_body :: proc(params: []string, body: ^Stmt_Block, name: string, line: int, enclosing: ^Compiler) -> (^Func_Proto, []Compiler_Upvalue) {
chunk := chunk_init()
c := compiler_init(&chunk)
c.scope_depth = 1
c.enclosing = enclosing
for param in params {
declare_local(&c, param)
}
for stmt in body.stmts {
compile_stmt(&c, stmt)
}
// A function whose every path already ended in an explicit `return` never reaches this;
// one that falls off the end (or has no `return` at all) gets an implicit `return nil`,
// exactly like Chapter 8's tree-walker.
chunk_write_op(&chunk, .Nil, line)
chunk_write_op(&chunk, .Return, line)
proto := new(Func_Proto)
proto^ = Func_Proto{name = name, arity = len(params), chunk = chunk}
return proto, c.upvalues[:]
}
muspel/value.odin
package muspel
import "core:fmt"
import "core:strings"
// Value is the bytecode VM's own runtime representation: what actually sits on `VM.stack`
// (Chapter 13) and in a running script's local slots and globals. It's the plain name `Value`
// because it's the real one now; Chapter 8's tree-walker had first claim on that name, and its
// own `Value` (and `Function`/`Native_Fn` alongside it) were renamed to `Walk_Value`
// (`Walk_Function`/`Walk_Native_Fn`) the moment this type was written, so the two are never
// confused on the page or in an import. A nil union value is Muspel's `nil`, matching
// Expr_Literal, Const_Value, and Walk_Value all the way back to Chapter 3.
//
// `^Func_Proto` (Chapter 12's compiled function) is Value's own function representation too:
// there's no separate "runtime function object" wrapping it, because Muspel's functions capture
// nothing (section 12.3's honestly-scoped closures), so a compiled prototype already *is* a
// complete, callable value with nothing more to attach to it. `Builtin` joins it here for
// exactly the same reason `Native_Fn` joined Chapter 8's `Value`: `print` needs a way to be a
// value a script can call, ahead of Chapter 16's full embedding boundary.
Value :: union {
f64,
string,
bool,
^Func_Proto,
Builtin,
^Struct_Instance,
^Array_Instance,
^Closure,
^Map_Instance,
}
// Map_Instance is a script dictionary (PLAN.md M5): string- or number-keyed values in one
// fixed-capacity, open-addressed table, allocated once by `map(cap)` exactly the way `list(cap)`
// reserves an array. Add and remove reuse slots in place, so churn never grows the arena, the same
// no-per-frame-allocation discipline every collection in this engine follows. `state[i]` is 0
// empty, 1 occupied, or 2 deleted, a tombstone that keeps an open-addressing probe chain intact
// while still being reusable by a later insert; `count` is the live entry count `len()` reports.
Map_Instance :: struct {
keys: []Value,
values: []Value,
state: []u8,
count: int,
}
// Closure is a compiled function proto paired with the values it captured from enclosing
// functions (PLAN.md M6, the engine era's reversal of "functions capture nothing"). A top-level
// or non-capturing function is still just a bare `^Func_Proto`; a `^Closure` is only built (by the
// Make_Closure opcode, arena-allocated) when a nested function or `func(...)` expression actually
// reads a variable from an enclosing scope. `captures` is snapshotted by value at creation: for a
// reference value (a struct or array) that still shares the underlying object, which is exactly
// what makes capturing `self` useful; for a number/string/bool it is a private copy the closure
// may mutate (Set_Upvalue) without touching the enclosing local.
Closure :: struct {
proto: ^Func_Proto,
captures: []Value,
}
// Struct_Instance is the VM's own runtime record, arena-allocated (never on Odin's general
// heap) from the owning VM instance's value_arena (Chapter 12's arena Design decision):
// field_names is shared, read-only, across every instance of the same struct type (it lives in
// the compiled chunk's constant pool, via Struct_Layout, not the arena), and values is this
// instance's own arena-allocated slots, one per declared field.
Struct_Instance :: struct {
type_name: string,
field_names: []string,
values: []Value,
}
// Array_Instance is an arena-allocated run of values with a fixed backing capacity (`values`,
// sized once, never reallocated) and a live length (`count`, always <= len(values)). An array
// literal or `list_new` fills the whole backing, so `count == len(values)` and it behaves exactly
// like the fixed array Chapter 6 introduced. `list(cap)` instead reserves capacity with `count`
// 0, and the VM-only `append`/`pop`/`remove` builtins move `count` up and down within that
// backing, never touching the arena again: a growable list that still costs nothing per push, the
// same fixed-pool-with-a-count discipline the monorepo's own games use in Odin. Everything that
// reads an array (`len`, indexing, `for`-`in`, equality, printing) reads `count`, never
// `len(values)`, so the reserved-but-unused tail is invisible to a script.
Array_Instance :: struct {
values: []Value,
count: int,
}
// Builtin is a host-registered native function. Chapter 16 turns this into a real, curated
// registration API; for now `print` is the one builtin `vm_init` predeclares, exactly mirroring
// the resolver's own `r.builtins["print"]` and the tree-walker's `Walk_Native_Fn`.
Builtin :: proc(vm: ^VM, args: []Value) -> Value
// value_is_truthy: every value is truthy except `false` and `nil` itself, the same rule Chapter
// 6's `walk_is_truthy` already established.
value_is_truthy :: proc(v: Value) -> bool {
#partial switch val in v {
case bool:
return val
case:
return v != nil
}
}
// values_equal is used by both `Equal`/`Not_Equal` (Chapter 10's opcodes): two values of
// different underlying types are never equal, `nil` is only ever equal to `nil`.
values_equal :: proc(a, b: Value) -> bool {
#partial switch av in a {
case f64:
bv, ok := b.(f64)
return ok && av == bv
case string:
bv, ok := b.(string)
return ok && av == bv
case bool:
bv, ok := b.(bool)
return ok && av == bv
case ^Func_Proto:
bv, ok := b.(^Func_Proto)
return ok && av == bv
case ^Closure:
// Closures compare by identity, like functions: two closures are equal only if they are
// the same closure value, never by comparing captured contents.
bv, ok := b.(^Closure)
return ok && av == bv
case ^Map_Instance:
// Maps compare by identity: two map handles are equal only when they are the same map.
bv, ok := b.(^Map_Instance)
return ok && av == bv
// Struct/array equality is field-wise/element-wise, not pointer identity (mirrors
// walk_values_equal): two independently built `Vec2{x:1,y:2}` values are equal, the value-type
// comparison a script author expects, not the reference comparison Odin's own `^T` gives.
case ^Struct_Instance:
bv, ok := b.(^Struct_Instance)
if !ok || av.type_name != bv.type_name || len(av.values) != len(bv.values) do return false
for v, i in av.values {
if !values_equal(v, bv.values[i]) do return false
}
return true
case ^Array_Instance:
bv, ok := b.(^Array_Instance)
if !ok || av.count != bv.count do return false
for i in 0 ..< av.count {
if !values_equal(av.values[i], bv.values[i]) do return false
}
return true
case:
return b == nil
}
}
// value_to_string renders a Value the way a running script's own `print` sees it: numbers
// without a trailing `.0` for whole values, strings unquoted, functions by name. Deliberately
// the same shape as Chapter 8's `walk_value_to_string`, so a script prints identically whether
// it's run with `--walk` or through the real VM.
value_to_string :: proc(v: Value) -> string {
switch val in v {
case f64:
return fmt.tprintf("%v", val)
case string:
return val
case bool:
return val ? "true" : "false"
case ^Func_Proto:
return fmt.tprintf("<fn %s>", val.name)
case ^Closure:
return fmt.tprintf("<fn %s>", val.proto.name)
case Builtin:
return "<native fn>"
case ^Struct_Instance:
sb := strings.builder_make(context.temp_allocator)
strings.write_string(&sb, val.type_name)
strings.write_string(&sb, "{")
for name, i in val.field_names {
if i > 0 do strings.write_string(&sb, ", ")
strings.write_string(&sb, name)
strings.write_string(&sb, ": ")
strings.write_string(&sb, value_to_string(val.values[i]))
}
strings.write_string(&sb, "}")
return strings.to_string(sb)
case ^Array_Instance:
sb := strings.builder_make(context.temp_allocator)
strings.write_string(&sb, "[")
for i in 0 ..< val.count {
if i > 0 do strings.write_string(&sb, ", ")
strings.write_string(&sb, value_to_string(val.values[i]))
}
strings.write_string(&sb, "]")
return strings.to_string(sb)
case ^Map_Instance:
sb := strings.builder_make(context.temp_allocator)
strings.write_string(&sb, "{")
first := true
for st, i in val.state {
if st != 1 do continue
if !first do strings.write_string(&sb, ", ")
first = false
strings.write_string(&sb, value_to_string(val.keys[i]))
strings.write_string(&sb, ": ")
strings.write_string(&sb, value_to_string(val.values[i]))
}
strings.write_string(&sb, "}")
return strings.to_string(sb)
case:
return "nil"
}
}
muspel/vm.odin
package muspel
import "base:runtime"
import "core:fmt"
import "core:math"
import "core:math/rand"
import "core:strings"
import "core:time"
// Frame is one active call's slice of the VM's shared value stack: `base` is the stack index
// its own local slot `0` lives at (Chapter 11's `Get_Local`/`Set_Local` operands are always
// relative to this, never an absolute stack index), and `ip` is this call's own instruction
// pointer into `chunk.code`, saved and restored automatically just by pushing and popping
// `Frame` values, no explicit save/restore code needed anywhere. `name` exists purely for
// Chapter 14's stack traces and step debugger: `"<script>"` for the outermost frame, a
// function's own name for every call beneath it.
Frame :: struct {
chunk: ^Chunk,
ip: int,
base: int,
name: string,
// closure is the calling closure when this frame is a closure call, giving Get_Upvalue/
// Set_Upvalue access to its captured values; nil for a plain function or the top-level script,
// which have no upvalues to read (PLAN.md M6).
closure: ^Closure,
}
// Max_Call_Depth, Instruction_Tick_Budget, and Instruction_Hard_Cap are Chapter 18's own tuning
// constants, a single-source-of-truth table exactly the size a scope this small needs (see
// section 18.2/18.3 for what each one guards against). `Max_Call_Depth` bounds recursion; the
// other two bound how much work one `vm_call_run` invocation is allowed to do before either
// pausing (the soft budget, every legitimate long-running tick eventually hits) or giving up
// entirely (the hard cap, only a script that never yields and never finishes should ever reach).
Max_Call_Depth :: 256
Instruction_Tick_Budget :: 100_000
Instruction_Hard_Cap :: 500_000_000
// VM_Status is the VM's own suspend/resume state, section 15.5's whole host/script contract in
// one field: `vm_resume` is a no-op once it's `.Done` or `.Error`, and reads it back out to
// report which of the three ways a resume call can end just happened. `.Running` is the zero
// value deliberately: a freshly-initialised VM that hasn't been resumed yet reads the same as
// one mid-tick, since neither is `.Done` or `.Error`, the only two states `vm_resume` actually
// checks for before doing any work.
VM_Status :: enum {
Running,
Suspended,
Done,
Error,
}
// Resume_Result is `vm_resume`'s own return value: the three-way answer a host needs after
// calling it once per tick. `.Error` was named here back in Chapter 15, before `runtime_error`
// did anything but end the whole process; Chapter 18 is what makes it a real, reachable outcome
// (see `runtime_error`'s own doc comment), not just a shape reserved for later.
Resume_Result :: enum {
Suspended,
Done,
Error,
}
// VM is the whole runtime: one shared value stack across every active call (Chapter 12's
// `Func_Proto` calls into another `Func_Proto` just pushes a second `Frame` over the same
// stack, no separate stack-per-call anywhere), a call-frame stack, and the same flat
// `map[string]Value` globals table Chapter 11's `Get_Global`/`Set_Global`/`Define_Global` already
// assumed existed. `status` is Chapter 15's own addition; `clock_start`/`fake_clock`/
// `fake_seconds` back `host_seconds`, the one native builtin `wait`/`wait_until` need (section
// 13.7): real wall-clock time by default, or a manually-advanced fake one once `vm_set_fake_clock`
// has been called, which is what lets `musc run --tick` drive a script through simulated frames
// without actually waiting on the real clock. `user_data` is Chapter 16's own addition: an
// opaque pointer the host sets after `vm_init` and every one of its own registered builtins can
// read back (host.odin's own doc comments walk through the pattern), the standard way an
// embedding API gives native code a way back to whatever host state it needs without baking that
// state into the VM itself. `callbacks` is Chapter 17's own addition: the script-side `on`
// builtin's registration table, event name to the Muspel function value that should run when it
// fires (see native_on and vm_fire_event below).
VM :: struct {
stack: [dynamic]Value,
frames: [dynamic]Frame,
globals: map[string]Value,
callbacks: map[string]Value,
status: VM_Status,
clock_start: time.Time,
fake_clock: bool,
fake_seconds: f64,
rng: rand.Default_Random_State,
user_data: rawptr,
instr_count: int,
// error_buf/error_len retain the most recent runtime error's message so a host can surface it
// beyond the stderr trace runtime_error always writes: the engine's on-canvas error overlay
// (PLAN.md M7) reads it through vm_error_message. A fixed buffer, filled with fmt.bprintf, so
// recording an error never itself allocates on a VM that is already failing.
error_buf: [256]u8,
error_len: int,
error_line: int, // source line of the innermost frame when the error fired (0 if none)
// value_arena is this VM instance's own struct/array arena (Chapter 12), the only place
// Make_Struct/Make_Array ever allocate from.
value_arena: Value_Arena,
gc_enabled: bool,
gc_threshold_bytes: int,
}
native_gc :: proc(vm: ^VM, args: []Value) -> Value {
reclaimed := vm_collect_garbage(vm)
return Value(f64(reclaimed))
}
vm_init :: proc(arena_size := Value_Arena_Size, allocator := context.allocator) -> VM {
vm := VM{
stack = make([dynamic]Value, allocator),
frames = make([dynamic]Frame, allocator),
globals = make(map[string]Value, allocator),
callbacks = make(map[string]Value, allocator),
clock_start = time.now(),
gc_threshold_bytes = arena_size * 3 / 4,
}
// Seeded once here, from real elapsed nanoseconds, exactly the "reseeded at one well-defined
// point, never wall-clock-read inside the hot path" convention the root CLAUDE.md names for
// every game in this monorepo: native_random below only ever reads vm.rng, never the clock,
// so two entities spawned in the same tick still get independent-looking draws.
vm.rng = rand.create_u64(u64(time.to_unix_nanoseconds(vm.clock_start)))
vm.globals["print"] = Builtin(native_print)
vm.globals["host_seconds"] = Builtin(native_host_seconds)
vm.globals["on"] = Builtin(native_on)
vm.globals["random"] = Builtin(native_random)
vm.globals["len"] = Builtin(native_len)
vm.globals["list"] = Builtin(native_list)
vm.globals["append"] = Builtin(native_append)
vm.globals["pop"] = Builtin(native_pop)
vm.globals["remove"] = Builtin(native_remove)
vm.globals["map"] = Builtin(native_map)
vm.globals["has"] = Builtin(native_has)
vm.globals["keys"] = Builtin(native_keys)
vm.globals["gc"] = Builtin(native_gc)
value_arena_init(&vm.value_arena, arena_size, allocator)
return vm
}
@(private = "file")
native_len :: proc(vm: ^VM, args: []Value) -> Value {
if len(args) > 0 {
#partial switch v in args[0] {
case ^Array_Instance:
return Value(f64(v.count))
case ^Map_Instance:
return Value(f64(v.count))
}
}
runtime_error(vm, "'len' expects an array or map")
return nil
}
// native_list, native_append, native_pop, and native_remove are the growable-list builtins: a
// `list(cap)` reserves capacity in the value arena once, then append/pop/remove move the array's
// `count` within that backing without ever allocating again. They are registered on the VM only
// (not the tree-walker), the same VM-only status `wait`/`yield` already have: the tree-walker's
// own Walk_Array_Instance is deliberately fixed-length, so growth is a bytecode-VM capability.
// Bounds and capacity failures are graceful runtime_errors, never Odin panics, exactly like
// indexing.
native_list :: proc(vm: ^VM, args: []Value) -> Value {
n, ok := arg_number(args, 0)
if !ok || n < 0 {
runtime_error(vm, "'list' expects a capacity >= 0")
return nil
}
capn := int(n)
values := make([]Value, capn, value_arena_allocator(&vm.value_arena))
inst := new(Array_Instance, value_arena_allocator(&vm.value_arena))
if (capn > 0 && len(values) != capn) || inst == nil {
runtime_error(vm, "arena exhausted: cannot allocate a list of capacity %d", capn)
return nil
}
inst^ = Array_Instance{values = values, count = 0}
return inst
}
native_append :: proc(vm: ^VM, args: []Value) -> Value {
arr, ok := arg_array(args, 0)
if !ok || len(args) < 2 {
runtime_error(vm, "'append' expects (list, value)")
return nil
}
if arr.count >= len(arr.values) {
runtime_error(vm, "'append': list is already at its capacity of %d", len(arr.values))
return nil
}
arr.values[arr.count] = args[1]
arr.count += 1
return Value(f64(arr.count))
}
native_pop :: proc(vm: ^VM, args: []Value) -> Value {
arr, ok := arg_array(args, 0)
if !ok {
runtime_error(vm, "'pop' expects (list)")
return nil
}
if arr.count == 0 {
runtime_error(vm, "'pop' from an empty list")
return nil
}
arr.count -= 1
v := arr.values[arr.count]
arr.values[arr.count] = nil // drop the reference so a popped value can't linger
return v
}
native_remove :: proc(vm: ^VM, args: []Value) -> Value {
// remove(map, key) deletes a map entry (reusing its slot); remove(list, index) swap-removes an
// array element. One name, dispatched on the collection type.
if m, is_map := arg_map(args, 0); is_map {
if len(args) < 2 {
runtime_error(vm, "'remove' expects (map, key)")
return nil
}
map_delete(m, args[1])
return nil
}
arr, ok := arg_array(args, 0)
idx, iok := arg_number(args, 1)
if !ok || !iok {
runtime_error(vm, "'remove' expects (list, index) or (map, key)")
return nil
}
i := int(idx)
if i < 0 || i >= arr.count {
runtime_error(vm, "'remove' index %d out of bounds for list of length %d", i, arr.count)
return nil
}
// Swap-remove: move the last element into the gap and shrink. O(1), but does not preserve
// order, exactly the pooled-entity removal the monorepo's own games use (a dead bullet is
// overwritten by the last live one).
arr.count -= 1
arr.values[i] = arr.values[arr.count]
arr.values[arr.count] = nil
return nil
}
// vm_set_random_seed reseeds `random()`'s own generator deterministically, the `random()`
// counterpart to vm_set_fake_clock above: a host (or a future `musc run --tick`-style
// deterministic replay) that wants a repeatable script run reaches for this instead of leaving
// vm_init's own nanosecond seed in place.
vm_set_random_seed :: proc(vm: ^VM, seed: u64) {
vm.rng = rand.create_u64(seed)
}
// native_random is Muspel's one source of randomness: a float in [0, 1), drawn from this VM's
// own generator, never from context.random_generator directly, so two VM instances (Chapter
// 15's own multi-instance story) never share, or perturb, each other's sequence.
@(private = "file")
native_random :: proc(vm: ^VM, args: []Value) -> Value {
gen := runtime.default_random_generator(&vm.rng)
return Value(rand.float64(gen))
}
// native_on is `on(event_name, fn)`: a script's own way of saying "call me back when this event
// fires." It does nothing but record `fn` under `name` in `vm.callbacks`; the actual calling is
// entirely the host's job, section 17.1's `vm_fire_event`, since only the host knows when an
// event has actually happened.
@(private = "file")
native_on :: proc(vm: ^VM, args: []Value) -> Value {
name, name_ok := arg_string(args, 0)
if !name_ok || len(args) < 2 {
runtime_error(vm, "on expects (event_name, function)")
return nil
}
vm.callbacks[name] = args[1]
return nil
}
// native_host_seconds is the one load-bearing native builtin section 15.7's whole standard
// library is built on: real elapsed seconds since this VM was created, or the fake,
// manually-advanced clock `vm_set_fake_clock` installs, whichever the host has asked for.
@(private = "file")
native_host_seconds :: proc(vm: ^VM, args: []Value) -> Value {
if vm.fake_clock {
return Value(vm.fake_seconds)
}
return Value(time.duration_seconds(time.since(vm.clock_start)))
}
// vm_set_fake_clock switches `host_seconds` from the real wall clock to a manually-advanced one:
// once called, every later `host_seconds()` call returns exactly `seconds`, until the next call
// changes it again. This exists purely so a host (or `musc run --tick`, section 15.10) can drive
// `wait`/`wait_until` through deterministic, simulated frames instead of the real clock's own
// unpredictable pacing.
vm_set_fake_clock :: proc(vm: ^VM, seconds: f64) {
vm.fake_clock = true
vm.fake_seconds = seconds
}
@(private = "file")
native_print :: proc(vm: ^VM, args: []Value) -> Value {
sb := strings.builder_make(context.temp_allocator)
for arg, i in args {
if i > 0 do strings.write_string(&sb, " ")
strings.write_string(&sb, value_to_string(arg))
}
fmt.println(strings.to_string(sb))
return nil
}
@(private = "file")
read_u8 :: proc(f: ^Frame) -> u8 {
b := f.chunk.code[f.ip]
f.ip += 1
return b
}
// read_i16 decodes a jump's two-byte operand back into the signed offset Chapter 11's
// `emit_jump`/`emit_loop` encoded, big-endian, the same bit pattern the disassembler already
// reads (Chapter 10's `disassemble_instruction`).
@(private = "file")
read_i16 :: proc(f: ^Frame) -> i16 {
hi := f.chunk.code[f.ip]
lo := f.chunk.code[f.ip + 1]
f.ip += 2
return i16(u16(hi) << 8 | u16(lo))
}
// runtime_error reports a graceful runtime failure with a full call-stack trace, innermost frame
// first, each line resolved through `chunk_line_at` back to the Muspel source line it came from.
// `f.ip - 1` (rather than `f.ip` itself) is what makes this correct regardless of which
// instruction is currently failing: by the time any case in `vm_step` calls this, `read_u8`/
// `read_i16` have already advanced `ip` past every byte of the current instruction, so `ip - 1`
// always lands somewhere inside that instruction's own byte range, and every byte of one
// instruction is always on the same source line (Chapter 11's compiler emits them that way), so
// which byte within it doesn't matter.
//
// Through Chapter 17 this ended the whole process (`os.exit(1)`); from Chapter 18 onward it
// instead sets `vm.status = .Error` and returns normally, section 18.5's whole change in one
// field write. Every existing call site (division by zero, an undefined global, calling a
// non-callable value, a host builtin's own argument-marshalling failure) already falls through
// harmlessly after calling this: none of them touches anything that would itself panic once
// `vm.status` no longer means "keep going," and `vm_call_run`'s own loop (section 17.2, extended
// in 16.4) stops the instant it sees `.Error`, before any further instruction ever executes.
// Deliberately not file-private from Chapter 16 onward: a host-registered builtin (host.odin's
// own docs walk through the pattern) reports its own boundary failures through this exact same
// mechanism, so a script gets one consistent kind of error, complete with a call-stack trace,
// regardless of whether an opcode or a native builtin is what actually detected the problem.
runtime_error :: proc(vm: ^VM, format: string, args: ..any) {
fmt.eprintf("runtime error: ")
fmt.eprintfln(format, ..args)
// Retain the message (not the whole trace) so a host can show it: the engine draws it on the
// canvas so a script author sees the failure without opening devtools (PLAN.md 3.8).
msg := fmt.bprintf(vm.error_buf[:], format, ..args)
vm.error_len = len(msg)
vm.error_line = 0
for i := len(vm.frames) - 1; i >= 0; i -= 1 {
f := vm.frames[i]
line := chunk_line_at(f.chunk, f.ip - 1)
if i == len(vm.frames) - 1 do vm.error_line = line // innermost frame: where it actually blew up
fmt.eprintfln(" at %s (line %d)", f.name, line)
}
vm.status = .Error
}
// vm_error_message returns the most recent runtime error's message (empty if none), for a host
// that surfaces failures itself rather than only through the stderr trace runtime_error writes.
vm_error_message :: proc(vm: ^VM) -> string {
return string(vm.error_buf[:vm.error_len])
}
// vm_error_line is the source line of the innermost frame at the most recent runtime error (0 if
// none), so a host can show that line alongside the message.
vm_error_line :: proc(vm: ^VM) -> int {
return vm.error_line
}
// vm_seed reseeds this VM instance's random state deterministically, so a host can expose a
// script-level `seed(n)` for reproducible runs (a replay, a fixed screensaver): the same
// well-defined-reseed-point discipline vm_init's own one-time seeding follows, now under script
// control (PLAN.md 3.8).
vm_seed :: proc(vm: ^VM, seed: u64) {
vm.rng = rand.create_u64(seed)
}
// as_number and as_number_pair are arithmetic's own version of section 16.4's `arg_number`: a
// graceful `runtime_error`, never an Odin type-assertion panic, the moment an opcode's own
// operand turns out not to be a number. Chapter 14 section 14.3's own Design decision named this
// exact gap (`1 + true` still panicked ungracefully even after that chapter's own stack traces
// landed) and said plainly it was this chapter's job; every arithmetic and comparison opcode
// below reaches through one of these instead of an unchecked `.(f64)`. Returning `ok = false`
// after `runtime_error` has already fired is deliberate, not redundant: the caller uses it to
// skip pushing anything onto an already-doomed stack, since `vm.status` is `.Error` by the time
// either of these returns and the VM is about to stop regardless of what, if anything, is left
// sitting on top of it.
@(private = "file")
as_number :: proc(vm: ^VM, v: Value) -> (f64, bool) {
n, ok := v.(f64)
if !ok {
runtime_error(vm, "expected a number, got %s", value_to_string(v))
}
return n, ok
}
@(private = "file")
as_number_pair :: proc(vm: ^VM, a, b: Value) -> (an, bn: f64, ok: bool) {
a_n, a_ok := as_number(vm, a)
if !a_ok do return 0, 0, false
b_n, b_ok := as_number(vm, b)
if !b_ok do return 0, 0, false
return a_n, b_n, true
}
// vm_step executes exactly one instruction and returns whether the VM is still running
// afterward: `false` only once the outermost frame's own `Return` has unwound everything.
// Section 13.3 already explained why `Call` pushing onto `vm.frames` here, rather than a
// recursive Odin call into `vm_run`, is what makes this decomposable into single steps at all;
// Chapter 14's step debugger (`vm_debug_run`) is `vm_run`'s own loop, just paused between calls
// to this proc instead of run back to back.
vm_step :: proc(vm: ^VM) -> bool {
frame := &vm.frames[len(vm.frames) - 1]
op := Op_Code(read_u8(frame))
switch op {
case .Constant:
idx := read_u8(frame)
append(&vm.stack, const_to_value(frame.chunk.constants[idx]))
case .Nil:
append(&vm.stack, Value(nil))
case .True:
append(&vm.stack, Value(true))
case .False:
append(&vm.stack, Value(false))
case .Pop:
pop(&vm.stack)
case .Dup:
append(&vm.stack, vm.stack[len(vm.stack) - 1])
case .Add:
b := pop(&vm.stack); a := pop(&vm.stack)
if as, ok := a.(string); ok {
bs, bs_ok := b.(string)
if bs_ok {
append(&vm.stack, Value(strings.concatenate({as, bs})))
} else {
runtime_error(vm, "expected a string, got %s", value_to_string(b))
}
} else if an, bn, ok := as_number_pair(vm, a, b); ok {
append(&vm.stack, Value(an + bn))
}
case .Sub:
b := pop(&vm.stack); a := pop(&vm.stack)
if an, bn, ok := as_number_pair(vm, a, b); ok {
append(&vm.stack, Value(an - bn))
}
case .Mul:
b := pop(&vm.stack); a := pop(&vm.stack)
if an, bn, ok := as_number_pair(vm, a, b); ok {
append(&vm.stack, Value(an * bn))
}
case .Div:
b := pop(&vm.stack); a := pop(&vm.stack)
if an, bn, ok := as_number_pair(vm, a, b); ok {
if bn == 0 {
runtime_error(vm, "division by zero")
} else {
append(&vm.stack, Value(an / bn))
}
}
// Mod is the floating-point remainder (math.mod, i.e. C's fmod: the result takes the sign of
// the dividend), the same semantics the engine's earlier `mod()` builtin had, now a real
// operator. A zero divisor is a graceful runtime_error, exactly like Div, never a NaN silently
// propagated through a script's arithmetic.
case .Mod:
b := pop(&vm.stack); a := pop(&vm.stack)
if an, bn, ok := as_number_pair(vm, a, b); ok {
if bn == 0 {
runtime_error(vm, "modulo by zero")
} else {
append(&vm.stack, Value(math.mod(an, bn)))
}
}
case .Negate:
a := pop(&vm.stack)
if an, ok := as_number(vm, a); ok {
append(&vm.stack, Value(-an))
}
case .Not:
a := pop(&vm.stack)
append(&vm.stack, Value(!value_is_truthy(a)))
case .Equal:
b := pop(&vm.stack); a := pop(&vm.stack)
append(&vm.stack, Value(values_equal(a, b)))
case .Not_Equal:
b := pop(&vm.stack); a := pop(&vm.stack)
append(&vm.stack, Value(!values_equal(a, b)))
case .Less:
b := pop(&vm.stack); a := pop(&vm.stack)
if an, bn, ok := as_number_pair(vm, a, b); ok {
append(&vm.stack, Value(an < bn))
}
case .Less_Equal:
b := pop(&vm.stack); a := pop(&vm.stack)
if an, bn, ok := as_number_pair(vm, a, b); ok {
append(&vm.stack, Value(an <= bn))
}
case .Greater:
b := pop(&vm.stack); a := pop(&vm.stack)
if an, bn, ok := as_number_pair(vm, a, b); ok {
append(&vm.stack, Value(an > bn))
}
case .Greater_Equal:
b := pop(&vm.stack); a := pop(&vm.stack)
if an, bn, ok := as_number_pair(vm, a, b); ok {
append(&vm.stack, Value(an >= bn))
}
case .Get_Local:
slot := read_u8(frame)
append(&vm.stack, vm.stack[frame.base + int(slot)])
case .Set_Local:
slot := read_u8(frame)
vm.stack[frame.base + int(slot)] = vm.stack[len(vm.stack) - 1]
case .Get_Global:
idx := read_u8(frame)
name := frame.chunk.constants[idx].(string)
v, ok := vm.globals[name]
if !ok {
runtime_error(vm, "undefined global '%s'", name)
}
append(&vm.stack, v)
case .Set_Global:
idx := read_u8(frame)
name := frame.chunk.constants[idx].(string)
vm.globals[name] = vm.stack[len(vm.stack) - 1]
case .Define_Global:
idx := read_u8(frame)
name := frame.chunk.constants[idx].(string)
vm.globals[name] = pop(&vm.stack)
case .Jump:
offset := read_i16(frame)
frame.ip += int(offset)
case .Jump_If_False:
offset := read_i16(frame)
if !value_is_truthy(vm.stack[len(vm.stack) - 1]) {
frame.ip += int(offset)
}
case .Call:
argc := int(read_u8(frame))
callee := vm.stack[len(vm.stack) - 1 - argc]
#partial switch fn in callee {
case Builtin:
args := vm.stack[len(vm.stack) - argc:]
result := fn(vm, args)
resize(&vm.stack, len(vm.stack) - argc - 1)
append(&vm.stack, result)
case ^Func_Proto:
if len(vm.frames) >= Max_Call_Depth {
runtime_error(vm, "stack overflow: call depth exceeded %d", Max_Call_Depth)
} else {
new_base := len(vm.stack) - argc
append(&vm.frames, Frame{chunk = &fn.chunk, ip = 0, base = new_base, name = fn.name})
}
case ^Closure:
// A closure call is a Func_Proto call that also carries the closure so its body's
// Get_Upvalue/Set_Upvalue can reach the captured values (PLAN.md M6).
if len(vm.frames) >= Max_Call_Depth {
runtime_error(vm, "stack overflow: call depth exceeded %d", Max_Call_Depth)
} else {
new_base := len(vm.stack) - argc
append(&vm.frames, Frame{chunk = &fn.proto.chunk, ip = 0, base = new_base, name = fn.proto.name, closure = fn})
}
case:
runtime_error(vm, "'%s' is not callable", value_to_string(callee))
}
case .Return:
result := pop(&vm.stack)
old := pop(&vm.frames)
if len(vm.frames) == 0 do return false
resize(&vm.stack, old.base - 1)
append(&vm.stack, result)
// Yield's entire implementation is one field write. `read_u8` already advanced `frame.ip`
// past this instruction's own single opcode byte before this case ever runs (decode-then-
// execute, same as every other instruction), so resuming needs no bookkeeping at all: the
// next call to vm_step simply starts again, and the next byte it sees is whatever comes
// after Yield in the bytecode. Section 15.2 is the reason this is enough: vm.stack and
// vm.frames are already the VM's entire paused state, ordinary Odin data sitting on this
// struct, not smeared across Odin's own call stack the way a recursive tree-walk's would be.
case .Yield:
vm.status = .Suspended
// The N pushed values always come off the stack first, arena-exhausted or not: an arena
// budget failure (Chapter 21) is a recoverable script-level error, not a reason to leave the
// stack in a shape no other opcode expects.
case .Make_Struct:
idx := read_u8(frame)
layout := frame.chunk.constants[idx].(^Struct_Layout)
n := len(layout.field_indices)
popped := make([]Value, n, context.temp_allocator)
for i := n - 1; i >= 0; i -= 1 {
popped[i] = pop(&vm.stack)
}
if vm.gc_enabled && value_arena_bytes_used(&vm.value_arena) >= vm.gc_threshold_bytes {
vm_collect_garbage(vm)
}
values := make([]Value, len(layout.field_names), value_arena_allocator(&vm.value_arena))
inst := new(Struct_Instance, value_arena_allocator(&vm.value_arena))
if ((len(layout.field_names) > 0 && len(values) != len(layout.field_names)) || inst == nil) && vm.gc_enabled {
vm_collect_garbage(vm)
values = make([]Value, len(layout.field_names), value_arena_allocator(&vm.value_arena))
inst = new(Struct_Instance, value_arena_allocator(&vm.value_arena))
}
if (len(layout.field_names) > 0 && len(values) != len(layout.field_names)) || inst == nil {
runtime_error(vm, "arena exhausted: cannot allocate struct '%s'", layout.type_name)
append(&vm.stack, Value(nil))
} else {
for i in 0 ..< n {
fi := layout.field_indices[i]
if fi >= 0 do values[fi] = popped[i]
}
inst^ = Struct_Instance{type_name = layout.type_name, field_names = layout.field_names, values = values}
append(&vm.stack, Value(inst))
}
case .Get_Field:
idx := read_u8(frame)
name := frame.chunk.constants[idx].(string)
obj := pop(&vm.stack)
if inst, ok := obj.(^Struct_Instance); ok {
found := false
for n, i in inst.field_names {
if n == name {
append(&vm.stack, inst.values[i])
found = true
break
}
}
if !found {
runtime_error(vm, "struct '%s' has no field '%s'", inst.type_name, name)
append(&vm.stack, Value(nil))
}
} else {
runtime_error(vm, "'.%s' used on a non-struct value", name)
append(&vm.stack, Value(nil))
}
case .Set_Field:
idx := read_u8(frame)
name := frame.chunk.constants[idx].(string)
value := pop(&vm.stack)
obj := pop(&vm.stack)
if inst, ok := obj.(^Struct_Instance); ok {
found := false
for n, i in inst.field_names {
if n == name {
inst.values[i] = value
found = true
break
}
}
if !found {
runtime_error(vm, "struct '%s' has no field '%s'", inst.type_name, name)
}
} else {
runtime_error(vm, "'.%s' used on a non-struct value", name)
}
append(&vm.stack, value)
case .Make_Array:
n := int(read_u8(frame))
popped := make([]Value, n, context.temp_allocator)
for i := n - 1; i >= 0; i -= 1 {
popped[i] = pop(&vm.stack)
}
if vm.gc_enabled && value_arena_bytes_used(&vm.value_arena) >= vm.gc_threshold_bytes {
vm_collect_garbage(vm)
}
values := make([]Value, n, value_arena_allocator(&vm.value_arena))
inst := new(Array_Instance, value_arena_allocator(&vm.value_arena))
if ((n > 0 && len(values) != n) || inst == nil) && vm.gc_enabled {
vm_collect_garbage(vm)
values = make([]Value, n, value_arena_allocator(&vm.value_arena))
inst = new(Array_Instance, value_arena_allocator(&vm.value_arena))
}
if (n > 0 && len(values) != n) || inst == nil {
runtime_error(vm, "arena exhausted: cannot allocate an array of %d elements", n)
append(&vm.stack, Value(nil))
} else {
copy(values, popped)
// A literal fills its whole backing, so count == capacity: identical to Chapter 6's
// fixed array. Spare capacity only ever comes from `list(cap)`.
inst^ = Array_Instance{values = values, count = n}
append(&vm.stack, Value(inst))
}
case .Get_Index:
index_v := pop(&vm.stack)
obj := pop(&vm.stack)
// `m[key]` on a map (string/number key) reads the stored value, or nil if absent; `a[i]`
// on an array is the bounds-checked numeric index it always was.
if m, is_map := obj.(^Map_Instance); is_map {
append(&vm.stack, map_get(m, index_v))
} else if arr, ok := obj.(^Array_Instance); !ok {
runtime_error(vm, "'[]' used on a non-array, non-map value")
append(&vm.stack, Value(nil))
} else if idx_f, idx_ok := index_v.(f64); !idx_ok {
runtime_error(vm, "array index must be a number")
append(&vm.stack, Value(nil))
} else if i := int(idx_f); i < 0 || i >= arr.count {
runtime_error(vm, "index %d out of bounds for array of length %d", i, arr.count)
append(&vm.stack, Value(nil))
} else {
append(&vm.stack, arr.values[i])
}
case .Set_Index:
value := pop(&vm.stack)
index_v := pop(&vm.stack)
obj := pop(&vm.stack)
if m, is_map := obj.(^Map_Instance); is_map {
map_set(vm, m, index_v, value)
} else if arr, ok := obj.(^Array_Instance); !ok {
runtime_error(vm, "'[]' used on a non-array, non-map value")
} else if idx_f, idx_ok := index_v.(f64); !idx_ok {
runtime_error(vm, "array index must be a number")
} else if i := int(idx_f); i < 0 || i >= arr.count {
runtime_error(vm, "index %d out of bounds for array of length %d", i, arr.count)
} else {
arr.values[i] = value
}
append(&vm.stack, value)
// Make_Closure snapshots the N capture values already pushed on top of the stack (in push
// order, so the first pushed becomes captures[0]) into a fresh closure over the proto in the
// constant pool, then leaves the closure where those captures were (PLAN.md M6).
case .Make_Closure:
proto_idx := read_u8(frame)
ncap := int(read_u8(frame))
proto := frame.chunk.constants[proto_idx].(^Func_Proto)
base := len(vm.stack) - ncap
if vm.gc_enabled && value_arena_bytes_used(&vm.value_arena) >= vm.gc_threshold_bytes {
vm_collect_garbage(vm)
}
caps := make([]Value, ncap, value_arena_allocator(&vm.value_arena))
cl := new(Closure, value_arena_allocator(&vm.value_arena))
if ((ncap > 0 && len(caps) != ncap) || cl == nil) && vm.gc_enabled {
vm_collect_garbage(vm)
caps = make([]Value, ncap, value_arena_allocator(&vm.value_arena))
cl = new(Closure, value_arena_allocator(&vm.value_arena))
}
if (ncap > 0 && len(caps) != ncap) || cl == nil {
runtime_error(vm, "arena exhausted: cannot allocate a closure of %d capture(s)", ncap)
} else {
for i in 0 ..< ncap {
caps[i] = vm.stack[base + i]
}
cl^ = Closure{proto = proto, captures = caps}
resize(&vm.stack, base)
append(&vm.stack, Value(cl))
}
case .Get_Upvalue:
idx := read_u8(frame)
if frame.closure == nil {
runtime_error(vm, "internal: GET_UPVALUE outside a closure")
} else {
append(&vm.stack, frame.closure.captures[idx])
}
case .Set_Upvalue:
idx := read_u8(frame)
if frame.closure == nil {
runtime_error(vm, "internal: SET_UPVALUE outside a closure")
} else {
frame.closure.captures[idx] = vm.stack[len(vm.stack) - 1]
}
}
return true
}
// vm_call_run drives the dispatch loop until the frame stack unwinds back down to `floor` (a
// call that started with exactly `floor` frames already on the stack has now completely
// returned) or the VM suspends. `vm_resume` is the `floor = 0` case, "run until absolutely
// everything is done"; Chapter 17's `vm_fire_event`/`vm_resume_call` reuse this exact loop with
// `floor` set to wherever a fired callback's own frame started, which is what lets firing a
// callback never accidentally also resume whatever frame, suspended mid-`wait` or otherwise,
// happened to already be sitting beneath it on `vm.frames`: `vm_step`'s own `Return` case only
// ever pops one frame at a time, so checking `len(vm.frames) > floor` after every step, rather
// than trusting `vm_step`'s own "is everything totally empty" bool, is what stops this loop
// exactly at the call it was asked to drive, and not one frame further.
@(private = "file")
vm_call_run :: proc(vm: ^VM, floor: int) -> Resume_Result {
saved := vm.status
vm.status = .Running
ticked := 0
for vm.status == .Running && len(vm.frames) > floor {
vm.instr_count += 1
if vm.instr_count > Instruction_Hard_Cap {
runtime_error(vm, "instruction budget exceeded: over %d instructions total, likely an infinite loop", Instruction_Hard_Cap)
break
}
ticked += 1
if ticked > Instruction_Tick_Budget {
vm.status = .Suspended
break
}
vm_step(vm)
}
if vm.status == .Error {
return .Error
}
if len(vm.frames) <= floor {
vm.status = saved
return .Done
}
return .Suspended
}
// vm_resume is the entire host/script contract from section 15.5 onward: run the VM until
// either the outermost frame's own Return unwinds everything (.Done), a Yield suspends the VM
// (.Suspended), or a runtime fault occurs (.Error, Chapter 18's own machinery, see
// runtime_error's doc comment). Already-finished VMs are a no-op: calling vm_resume again on a
// `.Done` or `.Error` VM just reports the same status back, rather than doing anything.
vm_resume :: proc(vm: ^VM) -> Resume_Result {
if vm.status == .Done do return .Done
if vm.status == .Error do return .Error
result := vm_call_run(vm, 0)
if result == .Done do vm.status = .Done
return result
}
// Callback_Call is the ticket vm_fire_event hands back for a callback that suspended instead of
// finishing: which frame-stack depth its own frame started at, exactly what vm_resume_call needs
// to keep driving that specific call (and only that call) on a later tick, via vm_call_run.
Callback_Call :: struct {
floor: int,
}
// vm_fire_event is section 17.1's whole mechanism: look `name` up in `vm.callbacks` (populated
// by a script's own `on(event_name, fn)` calls), and if a handler is registered, call it with
// `args`, right now, reusing vm_call_run exactly the way vm_resume does. `fired` is false, and
// nothing runs, if no handler was ever registered for `name`; a host is expected to check it
// before treating `result`/`call` as meaningful. If the callback itself calls `wait` (or
// anything else that yields), `result` comes back `.Suspended` and `call` is the ticket
// vm_resume_call needs to continue it later, exactly like the main script's own `wait` would
// suspend `vm_resume` itself.
//
// A second call fired while an earlier one is still suspended stacks correctly, on the same
// `vm.frames`, exactly like any ordinary nested call: the new call's own frame(s) sit above the
// suspended one's, untouched underneath. What this does *not* support is draining them out of
// order: `vm_resume_call` doesn't check which call is actually on top, only where the frame
// stack needs to unwind back down to, so calling it with an *earlier* call's own ticket while a
// *later* one is still sitting above it just keeps stepping whatever's currently on top (the
// later call), not the one the ticket names, until that later call itself finishes or suspends
// again. Nothing corrupts either way; a host just has to drain tickets last-fired-first, the
// same discipline ordinary nested function calls already require and get for free.
vm_fire_event :: proc(vm: ^VM, name: string, args: ..Value) -> (call: Callback_Call, result: Resume_Result, fired: bool) {
cb, ok := vm.callbacks[name]
if !ok do return {}, .Done, false
return vm_call_value(vm, cb, ..args)
}
// vm_call_value is vm_fire_event's own machinery, pulled out one level: call an *already-
// resolved* function Value directly, rather than looking one up in `vm.callbacks` by name first.
// `vm_fire_event` is the `name -> vm.callbacks[name]` case; a host that keeps its own separate
// table of function values (Chapter 21's Embers registers one function value per entity for
// `on_death`, never a single named, VM-wide event) calls this directly instead. `ok` is false,
// and nothing runs, if `fn` isn't actually a callable `^Func_Proto` or `^Closure`. A closure is
// accepted too so a host can hand back a captured handler (engine era, PLAN.md M6): an `on_death`
// written as `func() { self.dead = true }` is a closure over `self`, not a bare proto.
vm_call_value :: proc(vm: ^VM, fn: Value, args: ..Value) -> (call: Callback_Call, result: Resume_Result, ok: bool) {
proto: ^Func_Proto
closure: ^Closure
#partial switch f in fn {
case ^Func_Proto:
proto = f
case ^Closure:
proto = f.proto
closure = f
case:
return {}, .Done, false
}
floor := len(vm.frames)
// Push `fn` itself before the arguments, exactly mirroring the stack shape ordinary bytecode
// leaves behind before a Call instruction runs (callee, then argc args): Return always
// resizes back to `frame.base - 1`, the callee's own slot, and writes the result there. Skip
// this and that resize eats one slot too many, silently overwriting whatever Value happened
// to already be sitting just below where this call's own arguments started, exactly the bug
// a two-callback stress test caught during Chapter 17's own testing.
append(&vm.stack, fn)
for a in args {
append(&vm.stack, a)
}
base := len(vm.stack) - len(args)
append(&vm.frames, Frame{chunk = &proto.chunk, ip = 0, base = base, name = proto.name, closure = closure})
result = vm_call_run(vm, floor)
return Callback_Call{floor = floor}, result, true
}
// vm_resume_call continues a callback vm_fire_event reported as `.Suspended`, driving the exact
// same call (never anything beneath it on `vm.frames`) until it too finishes or suspends again.
vm_resume_call :: proc(vm: ^VM, call: Callback_Call) -> Resume_Result {
return vm_call_run(vm, call.floor)
}
// vm_run is the dispatch loop proper, now built on vm_resume rather than vm_step directly: it
// keeps calling vm_resume, immediately resuming again every time the result is `.Suspended`,
// until the program actually finishes. This is what lets `musc run` (no host, no real ticking)
// still run a script that calls `wait`: each Yield inside `wait`'s own loop hands control back
// for exactly one vm_step, vm_run immediately resumes it again, and the whole thing behaves as a
// busy-wait against the real clock rather than blocking, correct even though nothing here is
// pacing it to any real frame rate. `Call` appending to `vm.frames` and `Return` popping from it
// inside `vm_step` itself, never a nested call into `vm_run`, is what makes any of this possible
// at all: the entire state of every call in progress is sitting in `vm.stack` and `vm.frames`,
// ordinary Odin data on the `VM` struct, never smeared across Odin's own native call stack the
// way a recursive tree-walk evaluator's would be.
vm_run :: proc(vm: ^VM) {
for vm_resume(vm) == .Suspended {}
}
// const_to_value lifts a compile-time constant-pool entry (Chapter 10's `Const_Value`, which has
// no `bool`/`nil` case at all, see section 10.3) into a runtime `Value`: every `Const_Value` case
// has a matching `Value` case, since a constant is always some already-known-at-compile-time
// value, never a computed one.
@(private = "file")
const_to_value :: proc(cv: Const_Value) -> Value {
switch v in cv {
case f64:
return v
case string:
return v
case ^Func_Proto:
return v
case ^Struct_Layout:
// Make_Struct reads a Struct_Layout constant directly (frame.chunk.constants[idx]);
// unlike every other Const_Value case, it never reaches the VM as a pushed .Constant
// value, so there's no Value case to lift it into.
return nil
}
return nil
}
// vm_push_script pushes `chunk` as the VM's outermost frame, named `"<script>"` for stack
// traces and the step debugger alike, without running anything: the one piece both
// `vm_run_chunk` and `musc debug` need before they diverge on *how* to run it.
vm_push_script :: proc(vm: ^VM, chunk: ^Chunk) {
append(&vm.frames, Frame{chunk = chunk, ip = 0, base = 0, name = "<script>"})
}
// vm_run_chunk is the whole host/script contract for now (Chapter 15 replaces the "run to
// completion" assumption with resumability): push `chunk` as the outermost frame and run until
// its own top-level `Return` unwinds all the way out.
vm_run_chunk :: proc(vm: ^VM, chunk: ^Chunk) {
vm_push_script(vm, chunk)
vm_run(vm)
}
muspel/disasm.odin
package muspel
import "core:fmt"
// This file is deliberately host-agnostic: disassembling a chunk to text is useful to any host,
// a native CLI tool or a browser-hosted one alike, so nothing here touches `core:os`. Chapter
// 12's interactive step debugger (reading one line at a time from stdin) is a genuinely
// CLI-only concern and lives in `tools/musc/debug_cmd.odin` instead, built entirely on top of
// this file's own `disassemble_instruction` and `vm_step` (Chapter 13); see that file's own doc
// comment for why the split matters (`muspel/` needs to compile for `-target:js_wasm32` too,
// Chapter 21's own requirement, and `core:os` doesn't exist there at all).
// disassemble_chunk prints every instruction in a chunk, one line each.
disassemble_chunk :: proc(c: ^Chunk, name: string) {
fmt.printfln("== %s ==", name)
offset := 0
for offset < len(c.code) {
offset = disassemble_instruction(c, offset)
}
}
// format_line renders an instruction's line-number column: the actual line the first time a run
// of instructions on it appears, and a bare `|` on every instruction after that, the same
// convention real disassemblers (clox's included) use so a long run of same-line instructions
// (a whole compiled expression, an entire loop body) doesn't repeat the same number down the
// left edge of the listing.
@(private = "file")
format_line :: proc(c: ^Chunk, offset: int) -> string {
line := chunk_line_at(c, offset)
if offset > 0 && chunk_line_at(c, offset - 1) == line {
return " |"
}
return fmt.tprintf("%4d", line)
}
// disassemble_instruction prints one instruction at `offset` and returns the offset of the next
// one, `1 + op_operand_bytes(op)` further along: the same arithmetic the VM's own dispatch loop
// (Chapter 13) uses to advance its instruction pointer, so a disassembler bug and a dispatch bug
// would have to be the same bug. Every operand is resolved, not just decoded: a `Constant`
// prints the value it refers to, and a jump prints the absolute byte offset it lands on, not
// just the raw signed delta the bytecode actually stores (see Chapter 11's `patch_jump`).
disassemble_instruction :: proc(c: ^Chunk, offset: int) -> int {
op := Op_Code(c.code[offset])
line := format_line(c, offset)
width := 1 + op_operand_bytes(op)
switch op_operand_bytes(op) {
case 0:
fmt.printfln("%04d %s %s", offset, line, op_name(op))
case 1:
operand := c.code[offset + 1]
#partial switch op {
case .Constant, .Get_Global, .Set_Global, .Define_Global, .Get_Field, .Set_Field:
value := c.constants[operand]
fmt.printfln("%04d %s %-14s %4d '%s'", offset, line, op_name(op), operand, const_value_to_string(value))
case .Make_Struct:
layout := c.constants[operand].(^Struct_Layout)
fmt.printfln("%04d %s %-14s %4d '%s' fields=%v", offset, line, op_name(op), operand, layout.type_name, layout.field_names)
case:
fmt.printfln("%04d %s %-14s %4d", offset, line, op_name(op), operand)
}
case 2:
// Make_Closure's two bytes are a proto constant index and a capture count, not a jump's
// signed i16 offset, so it prints its own way (the function it wraps plus how many values
// it captures); every other 2-byte instruction is a jump.
if op == .Make_Closure {
proto_idx := c.code[offset + 1]
ncap := c.code[offset + 2]
fmt.printfln("%04d %s %-14s %4d '%s' captures=%d", offset, line, op_name(op), proto_idx, const_value_to_string(c.constants[proto_idx]), ncap)
} else {
hi := c.code[offset + 1]
lo := c.code[offset + 2]
raw_offset := i16(u16(hi) << 8 | u16(lo))
target := offset + width + int(raw_offset)
fmt.printfln("%04d %s %-14s %6d -> %04d", offset, line, op_name(op), raw_offset, target)
}
}
return offset + width
}
// const_value_to_string renders a constant-pool entry for the disassembler: numbers and strings
// print directly, and a compiled function prints as its name, not a dump of its own chunk (that
// would make one line of output recurse into hundreds).
const_value_to_string :: proc(v: Const_Value) -> string {
switch val in v {
case f64:
return fmt.tprintf("%v", val)
case string:
return val
case ^Func_Proto:
return fmt.tprintf("<fn %s>", val.name)
case ^Struct_Layout:
return fmt.tprintf("<struct %s>", val.type_name)
}
return ""
}
muspel/host.odin
package muspel
// vm_register_native is Chapter 16's whole registration mechanism: bind one host-callable
// builtin under `name`, in both the VM's own globals (so a running script can actually call it)
// and the resolver's known-builtin set (so a script that calls it resolves cleanly ahead of
// time, exactly like `print` and `host_seconds` already do; see resolver.odin's own
// `r.builtins`). `print` and `host_seconds` are the *language's* own always-present builtins,
// registered by `vm_init`/`resolver_init` directly since every script gets them regardless of
// host; `vm_register_native` is what any *host* (`musc`'s own stub host,
// tools/musc/host_cmd.odin, and later Embers, Chapter 21) reaches for to add its own, without
// needing to know `vm.globals` and `r.builtins` are two separate tables at all.
vm_register_native :: proc(vm: ^VM, r: ^Resolver, name: string, fn: Builtin) {
vm.globals[name] = fn
r.builtins[name] = true
}
// arg_number and arg_string are the marshalling layer's other half: safely pulling a typed
// argument back out of a native builtin's own `args []Value`, without ever panicking. A script
// calling a builtin with too few arguments, or an argument of the wrong type, is exactly the
// kind of mistake section 16.4 says must never become an Odin-level crash; every builtin in this
// book (and every builtin Embers registers in Chapter 21) reaches into `args` through one of
// these rather than an unchecked `args[i].(f64)`, so a bad call becomes a graceful, traced
// `runtime_error` (see vm.odin) instead of a bare Odin type-assertion panic.
arg_number :: proc(args: []Value, i: int) -> (f64, bool) {
if i >= len(args) do return 0, false
v, ok := args[i].(f64)
return v, ok
}
arg_string :: proc(args: []Value, i: int) -> (string, bool) {
if i >= len(args) do return "", false
v, ok := args[i].(string)
return v, ok
}
// arg_struct_field is a host builtin's other half of struct marshalling: reading one named field
// back out of a Muspel struct argument, the same by-name scan Get_Field itself does at the
// bytecode level, so a host never needs its own copy of a struct's field layout to unpack an
// argument like `spawn(pos)` where `pos` is a `Vec2{x:.., y:..}` value.
arg_struct_field :: proc(args: []Value, i: int, field: string) -> (Value, bool) {
inst, ok := arg_struct(args, i)
if !ok do return nil, false
for name, fi in inst.field_names {
if name == field do return inst.values[fi], true
}
return nil, false
}
arg_struct :: proc(args: []Value, i: int) -> (^Struct_Instance, bool) {
if i >= len(args) do return nil, false
v, ok := args[i].(^Struct_Instance)
return v, ok
}
arg_array :: proc(args: []Value, i: int) -> (^Array_Instance, bool) {
if i >= len(args) do return nil, false
v, ok := args[i].(^Array_Instance)
return v, ok
}
// make_struct_value is marshalling's other direction: a host handing a struct value *back* to
// Muspel (a builtin's return value, or an argument to a fired callback) has to allocate it from
// the *calling* VM instance's own arena, never the host's general heap or another instance's
// arena (Chapter 18, section 3's handles-not-pointers rule extends the same way to values: a
// struct instance never outlives the arena that owns it). field_names is expected to be the same
// backing slice every instance of this type shares (typically read back off an existing instance,
// or a small fixed table the host keeps for its own known types), not copied per call.
make_struct_value :: proc(vm: ^VM, type_name: string, field_names: []string, values: []Value) -> Value {
arena_values := make([]Value, len(values), value_arena_allocator(&vm.value_arena))
inst := new(Struct_Instance, value_arena_allocator(&vm.value_arena))
if len(arena_values) != len(values) || inst == nil {
runtime_error(vm, "arena exhausted: cannot allocate struct '%s'", type_name)
return nil
}
copy(arena_values, values)
inst^ = Struct_Instance{type_name = type_name, field_names = field_names, values = arena_values}
return inst
}
// make_array_value is make_struct_value's array counterpart.
make_array_value :: proc(vm: ^VM, values: []Value) -> Value {
arena_values := make([]Value, len(values), value_arena_allocator(&vm.value_arena))
inst := new(Array_Instance, value_arena_allocator(&vm.value_arena))
if len(arena_values) != len(values) || inst == nil {
runtime_error(vm, "arena exhausted: cannot allocate an array of %d elements", len(values))
return nil
}
copy(arena_values, values)
// A host-built array is handed over fully populated, so count == capacity, the same shape an
// array literal has.
inst^ = Array_Instance{values = arena_values, count = len(values)}
return inst
}
muspel/stdlib.odin
package muspel
import "core:fmt"
// Stdlib_Source is Muspel's own standard library, written in Muspel itself: `wait`/`wait_until`,
// built on nothing but the Yield opcode (Chapter 15) and one native builtin, `host_seconds`; and
// `random_range`, built the identical way on the `random` native (Appendix G's own randomness
// follow-on). None of the three needed a single line of Odin beyond the one native each is built
// on: `yield` is a statement that compiles straight to Yield, exactly like `return` compiles to
// Return, and the ordinary Chapter 11-12 compiler needed no special-casing at all to make that
// true.
Stdlib_Source :: `
func wait(seconds) {
var deadline = host_seconds() + seconds
while (host_seconds() < deadline) {
yield
}
}
func wait_until(condition) {
while (!condition()) {
yield
}
}
func random_range(min, max) {
return min + random() * (max - min)
}
`
// parse_with_stdlib parses `src` together with Stdlib_Source, returning one combined statement
// list: the standard library's own functions first, then `src`'s top-level statements, in the
// order compile_program's own function-hoisting pass already expects. Every caller that wants
// `wait`/`wait_until` available to a script (Chapter 15 onward, the VM run path only, since the
// tree-walker has no way to honour a Yield inside either of them) parses through here instead of
// calling lex_all/parser_init/parse_program directly. Stdlib_Source is fixed and always parses
// cleanly, so only src's own parse errors are ever returned; each statement's `.line` comes from
// its own, separately-lexed token stream, so a syntax error in src still points at src's own
// line, never shifted by however long the standard library happens to be.
parse_with_stdlib :: proc(src: string, allocator := context.allocator) -> (stmts: []Stmt, errors: []Parse_Error) {
std_tokens := lex_all(Stdlib_Source, allocator)
std_p := parser_init(std_tokens, allocator)
std_stmts := parse_program(&std_p, allocator)
tokens := lex_all(src, allocator)
p := parser_init(tokens, allocator)
user_stmts := parse_program(&p, allocator)
if len(p.errors) > 0 {
return nil, p.errors[:]
}
combined := make([dynamic]Stmt, allocator)
append(&combined, ..std_stmts)
append(&combined, ..user_stmts)
return combined[:], nil
}
// compile_source is the whole lex/parse/resolve/compile pipeline as one call, source text in,
// `chunk` filled in on success, `false` and `chunk` left untouched on the first syntax or
// resolution error. Deliberately host-agnostic: it takes a `string`, never touches a file or a
// clock, so any host can drive it from wherever its own source text came from, a `core:os` file
// read on native (`tools/musc`'s own hot-reload, Chapter 19) or a `fetch()` response handed
// across the WASM boundary in a browser (Embers, Chapter 21) alike. `muspel/` itself never reads
// a file: that's always the host's own job, exactly the boundary the "muspel/ never imports
// embers/" rule (ยง2) exists to keep clean.
//
// `host_builtins` is what lets a host's own registered builtins (Chapter 16's
// `vm_register_native`, `spawn`/`move_to`/... for Embers) resolve cleanly: the resolver this
// proc builds internally has no way to know about them otherwise, since it's created and thrown
// away entirely inside this one call, invisible to the caller. Passing their names here is the
// resolve-time half of registering a host builtin; vm_register_native (or, for a host like
// Embers that builds its own VM instances directly, a plain `vm.globals[name] = fn` loop) is
// still the run-time half, binding the actual callable value onto whichever VM instance runs the
// compiled chunk.
compile_source :: proc(chunk: ^Chunk, src: string, host_builtins: []string = nil, allocator := context.allocator) -> bool {
discard: [1]u8
ok, _, _, _ := compile_source_reporting(chunk, src, host_builtins, discard[:], allocator)
return ok
}
// compile_source_reporting is compile_source that also writes the *first* parse or resolve error's
// message (with its line, and column for a parse error) into `err_buf`, returning its length. A
// host that surfaces compile failures to the user, rather than only returning a bool, uses this:
// the engine's on-canvas error overlay (PLAN.md 3.8) shows the message so a script author sees why
// a saved edit would not load without opening devtools. Only the first error is reported, the same
// "stop at the first problem the reader has to fix" shape a compiler's own top line takes. `line`
// (1-based) and `col` (1-based, or -1 when unknown) locate the error so a host can show the
// offending source line with a caret: a parse error carries a real column; a resolve error (the
// resolver tracks lines, not columns) returns col == -1.
compile_source_reporting :: proc(chunk: ^Chunk, src: string, host_builtins: []string, err_buf: []u8, allocator := context.allocator) -> (ok: bool, err_len: int, line: int, col: int) {
stmts, errors := parse_with_stdlib(src, allocator)
if len(errors) > 0 {
e := errors[0]
m := fmt.bprintf(err_buf, "%s", e.message)
return false, len(m), e.line, e.column
}
r := resolver_init(allocator)
for name in host_builtins {
r.builtins[name] = true
}
resolve_program(&r, stmts, allocator)
if len(r.errors) > 0 {
e := r.errors[0]
m := fmt.bprintf(err_buf, "%s", e.message)
return false, len(m), e.line, -1
}
chunk^ = chunk_init(allocator)
compile_program(chunk, stmts, allocator)
return true, 0, 0, 0
}
muspel/modules.odin
package muspel
import "core:strings"
// This file is deliberately host-agnostic, exactly like disasm.odin: gathering source text for
// an import (reading another `.mus` file off disk) needs `core:os`, which doesn't exist on
// `-target:js_wasm32` (Chapter 21's own constraint), so that job belongs to the host
// (`tools/musc/module_loader.odin`). Everything here only ever consumes an already-parsed graph.
// Import_Ref is one `import "path" as alias` statement, pulled out of a module's own top-level
// statements by scan_imports, so the graph-walking code below never has to switch over Stmt
// itself.
Import_Ref :: struct {
path: string,
alias: string,
line: int,
}
// Loaded_Module is one file's own already-lexed-and-parsed statements, plus the imports it
// declares. `path` is whatever the host used as this file's own map key (module_loader.odin uses
// a path relative to the entry script's directory, mirroring how Odin itself resolves imports
// relative to a collection root).
Loaded_Module :: struct {
path: string,
stmts: []Stmt,
imports: []Import_Ref,
}
scan_imports :: proc(stmts: []Stmt, allocator := context.allocator) -> []Import_Ref {
refs := make([dynamic]Import_Ref, allocator)
for s in stmts {
if imp, ok := s.(^Stmt_Import); ok {
append(&refs, Import_Ref{path = imp.path, alias = imp.alias, line = imp.line})
}
}
return refs[:]
}
@(private = "file")
visit_module :: proc(
modules: ^map[string]Loaded_Module,
path: string,
visited: ^map[string]bool,
visiting: ^map[string]bool,
result: ^[dynamic]string,
) -> (cycle_path: string, has_cycle: bool) {
if visited[path] do return "", false
if visiting[path] do return path, true
visiting[path] = true
if mod, ok := modules[path]; ok {
for imp in mod.imports {
cp, cyc := visit_module(modules, imp.path, visited, visiting, result)
if cyc do return cp, true
}
}
visiting[path] = false
visited[path] = true
append(result, path)
return "", false
}
// build_module_order walks the import graph reachable from `entry_path`, returning every module
// in dependency order (an import always appears before the file that imports it). A naive walk
// with no visited set would happily infinite-loop on `a` importing `b` importing `a`; `visiting`
// is exactly that set, and the moment it's asked to revisit a module still in progress, that's a
// reported cycle, a clean compile error, instead of a stack overflow (Chapter 20, section 4's own
// Gotcha).
build_module_order :: proc(
modules: map[string]Loaded_Module,
entry_path: string,
allocator := context.allocator,
) -> (order: []string, cycle_path: string, has_cycle: bool) {
modules_var := modules
visited := make(map[string]bool, allocator)
visiting := make(map[string]bool, allocator)
result := make([dynamic]string, allocator)
cp, cyc := visit_module(&modules_var, entry_path, &visited, &visiting, &result)
if cyc do return nil, cp, true
return result[:], "", false
}
// resolve_module_graph resolves every module in `order` (already dependency-first), each in its
// own Resolver, and copies the struct/enum/function tables an importED module exposes into every
// module that imports it, namespaced under that importer's own chosen alias: `common.Vec2`,
// `common.Phase`, `common.lerp`, never a bare name pulled straight into the importer's own
// namespace (Chapter 20, section 3's qualified-imports-only Design decision). Returns one
// Resolver per module path so a caller can report every module's own errors, not just the entry
// script's.
// `host_builtins` names the host's own registered natives (draw_rect, spawn, ...), exactly as
// compile_source_reporting takes them for the single-file path: without this, resolving a module
// that calls a host builtin would report it as an undefined function, since each module gets a
// fresh resolver that only knows the language's own builtins. Native `musc run` passes nil (its
// scripts call only language builtins); a host like the engine passes engine_builtin_names().
resolve_module_graph :: proc(
modules: map[string]Loaded_Module,
order: []string,
host_builtins: []string = nil,
allocator := context.allocator,
) -> map[string]Resolver {
resolvers := make(map[string]Resolver, allocator)
for path in order {
mod := modules[path]
r := resolver_init(allocator)
for name in host_builtins {
r.builtins[name] = true
}
for imp in mod.imports {
imp_r, ok := resolvers[imp.path]
if !ok do continue // the import itself failed to load/resolve; already reported elsewhere
for name, info in imp_r.structs {
r.structs[strings.concatenate({imp.alias, ".", name}, allocator)] = info
}
for name, info in imp_r.enums {
r.enums[strings.concatenate({imp.alias, ".", name}, allocator)] = info
}
for name, info in imp_r.functions {
r.functions[strings.concatenate({imp.alias, ".", name}, allocator)] = info
}
}
resolve_program(&r, mod.stmts, allocator)
resolvers[path] = r
}
return resolvers
}
// compile_module compiles one file's top-level statements into `chunk`, exactly like
// compile_program, but when `alias` is non-empty (every module except the entry script) it also
// re-binds each of the module's own top-level functions under its qualified name: after the
// ordinary `Define_Global "lerp"` an imported `common.mus` produces, this appends
// `Get_Global "lerp"; Define_Global "common.lerp"`, so an importing script can call
// `common.lerp(...)` as an ordinary global lookup (compiler.odin's own Expr_Field_Access,
// is_qualified_ref branch), with the underlying function compiled and stored exactly once
// (Chapter 20, section 6: shared compiled code, never recompiled per importer).
compile_module :: proc(chunk: ^Chunk, stmts: []Stmt, alias: string, allocator := context.allocator) {
c := compiler_init(chunk, allocator)
for s in stmts {
if _, ok := s.(^Stmt_Func); ok do compile_stmt(&c, s)
}
for s in stmts {
if _, ok := s.(^Stmt_Func); ok do continue
compile_stmt(&c, s)
}
if alias != "" {
for s in stmts {
if fn, ok := s.(^Stmt_Func); ok {
name_idx := chunk_add_const(c.chunk, Const_Value(fn.name))
chunk_write_op(c.chunk, .Get_Global, fn.line)
chunk_write(c.chunk, name_idx, fn.line)
qualified := strings.concatenate({alias, ".", fn.name}, allocator)
qual_idx := chunk_add_const(c.chunk, Const_Value(qualified))
chunk_write_op(c.chunk, .Define_Global, fn.line)
chunk_write(c.chunk, qual_idx, fn.line)
}
}
}
chunk_write_op(c.chunk, .Nil, 0)
chunk_write_op(c.chunk, .Return, 0)
}
// canonical_aliases picks one alias per module path to compile its qualified re-exports under
// (compile_module, "alias.function"): a module compiles exactly once and is shared by every
// importer (Chapter 20, section 6), but two different importers are free to write `import "x.mus"
// as a` and `import "x.mus" as b` for the very same file. Each importer's own *resolver* already
// gets exactly the alias it asked for (resolve_module_graph copies qualified entries per
// importer, not per module), so this only matters for which name the *compiled bytecode* binds
// its globals under; picking the first alias found, scanning modules in `order` (a stable,
// deterministic order), is a small, named scope cut for this book rather than re-exporting under
// every alias a module happens to be known by.
@(private = "file")
canonical_aliases :: proc(
modules: map[string]Loaded_Module,
order: []string,
allocator := context.allocator,
) -> map[string]string {
aliases := make(map[string]string, allocator)
for path in order {
mod := modules[path]
for imp in mod.imports {
if _, exists := aliases[imp.path]; !exists {
aliases[imp.path] = imp.alias
}
}
}
return aliases
}
// run_module_graph is the whole module-aware host/script contract in one call: compile every
// dependency in `order` (all but the last entry) under its own canonical import alias and run
// each to completion on `vm` (populating vm.globals with both its bare and qualified bindings)
// before compiling and running the final entry module bare, unqualified, exactly as
// compile_program always has. A program with no imports at all is the `order == {entry_path}`
// case, which costs nothing beyond what compile_program already did: this is a strict superset of
// the pre-Chapter-20 single-file path, not a parallel one.
run_module_graph :: proc(
vm: ^VM,
modules: map[string]Loaded_Module,
order: []string,
allocator := context.allocator,
) {
aliases := canonical_aliases(modules, order, allocator)
for path, i in order {
mod := modules[path]
is_entry := i == len(order) - 1
chunk := new(Chunk, allocator)
chunk^ = chunk_init(allocator)
if is_entry {
compile_program(chunk, mod.stmts, allocator)
} else {
compile_module(chunk, mod.stmts, aliases[path], allocator)
}
vm.status = .Running
vm_push_script(vm, chunk)
vm_run(vm)
if vm.status == .Error do return
}
}
// run_dependency_modules is run_module_graph's split-boundary sibling for a host that drives the
// entry module through its own lifecycle rather than running it once and forgetting it (the
// engine's _config/_ready/_process/_draw contract, versus `musc run`'s one-shot execution). It
// compiles and runs every module in `order` EXCEPT `entry_key` (which, `order` being dependency-
// first, is the last element), each under its own canonical alias, populating `vm.globals` with
// their bare and qualified bindings, then hands control back: the caller compiles the entry
// module with compile_program (so its bytecode is a plain top-level chunk it can keep, push a
// sentinel frame beneath, and re-enter for each hook) and drives it itself. Returns false if a
// dependency's own top level raised a runtime error, so the host can surface it before the entry
// ever runs.
run_dependency_modules :: proc(
vm: ^VM,
modules: map[string]Loaded_Module,
order: []string,
entry_key: string,
allocator := context.allocator,
) -> bool {
aliases := canonical_aliases(modules, order, allocator)
for path in order {
if path == entry_key do continue
mod := modules[path]
chunk := new(Chunk, allocator)
chunk^ = chunk_init(allocator)
compile_module(chunk, mod.stmts, aliases[path], allocator)
vm.status = .Running
vm_push_script(vm, chunk)
vm_run(vm)
if vm.status == .Error do return false
}
return true
}
tools/musc/
tools/musc/main.odin
package musc
import "core:fmt"
import "core:os"
import "core:strconv"
import "core:strings"
main :: proc() {
if len(os.args) < 2 {
print_usage()
os.exit(1)
}
cmd := os.args[1]
if cmd == "embers-check" {
cmd_embers_check()
return
}
// `asm-demo` takes no file at all: it hand-assembles its own chunk directly in Odin, the
// point being to prove the bytecode format itself is sound before anything (a compiler)
// ever generates a chunk automatically. Every other command needs a path.
if cmd == "asm-demo" {
cmd_asm_demo()
return
}
if len(os.args) < 3 {
print_usage()
os.exit(1)
}
// `run` accepts an optional `--walk`, `--tick`, `--instances=N`, or `--watch` flag ahead of
// the path: plain `run` uses the real bytecode VM (Chapter 13), `--walk` runs the same file
// through Chapter 8's tree-walker instead, for direct comparison, `--tick` (Chapter 15)
// drives it one simulated host frame at a time against a fake clock instead of running it
// straight through, `--instances=N` (Chapter 17) drives N independent VM instances of it the
// same way, all sharing one compiled chunk, and `--watch` (Chapter 19) runs it repeatedly,
// polling the file's own mtime between suspensions and hot-swapping in a clean recompile.
// Every other command takes just the path.
path_index := 2
walk := false
tick := false
watch := false
instances := 0
if cmd == "run" && len(os.args) > 2 && os.args[2] == "--walk" {
path_index = 3
walk = true
}
if cmd == "run" && len(os.args) > 2 && os.args[2] == "--tick" {
path_index = 3
tick = true
}
if cmd == "run" && len(os.args) > 2 && os.args[2] == "--watch" {
path_index = 3
watch = true
}
if cmd == "run" && len(os.args) > 2 && strings.has_prefix(os.args[2], "--instances=") {
path_index = 3
instances, _ = strconv.parse_int(strings.trim_prefix(os.args[2], "--instances="))
}
if len(os.args) <= path_index {
print_usage()
os.exit(1)
}
path := os.args[path_index]
// --watch re-reads (and re-watches) the file itself on every poll, so it never needs the
// single up-front read every other command does.
if cmd == "run" && watch {
cmd_run_watch(path)
return
}
src, err := os.read_entire_file_from_path(path, context.allocator)
if err != nil {
fmt.eprintfln("musc: could not read %s: %v", path, err)
os.exit(1)
}
defer delete(src)
switch cmd {
case "lex":
cmd_lex(string(src))
case "parse":
cmd_parse(string(src))
case "check":
cmd_check(string(src))
case "run":
if tick {
cmd_run_tick(string(src))
} else if instances > 0 {
cmd_run_instances(string(src), instances)
} else {
cmd_run(path, string(src), walk)
}
case "bench":
cmd_bench(string(src))
case "compile":
cmd_compile(string(src))
case "debug":
cmd_debug(string(src))
case "host":
cmd_host(string(src))
case "events":
cmd_events(string(src))
case:
print_usage()
os.exit(1)
}
}
print_usage :: proc() {
fmt.eprintln("usage: musc <lex|parse|check|run [--walk|--tick|--instances=N|--watch]|bench|compile|debug|host|events> <file.mus>")
fmt.eprintln(" musc asm-demo")
}
tools/musc/lex_cmd.odin
package musc
import "core:fmt"
import "core:strings"
import muspel "../../muspel"
cmd_lex :: proc(src: string) {
tokens := muspel.lex_all(src)
for tok in tokens {
pos := fmt.tprintf("%d:%d", tok.line, tok.column)
kind := tok.kind == .Illegal ? "ILLEGAL" : fmt.tprintf("%v", tok.kind)
fmt.printfln("%s %s %q", pad_right(pos, 8), pad_right(kind, 12), tok.lexeme)
if tok.kind == .EOF do break
}
}
// pad_right is the alignment helper `fmt`'s width specifiers won't do for us: Odin's
// formatter pads with '0', even under the left-align flag, which is fine for numbers and
// wrong for the columnar token dump this command wants.
@(private = "file")
pad_right :: proc(s: string, width: int) -> string {
if len(s) >= width do return s
return fmt.tprintf("%s%s", s, strings.repeat(" ", width - len(s)))
}
tools/musc/parse_cmd.odin
package musc
import "core:fmt"
import muspel "../../muspel"
cmd_parse :: proc(src: string) {
tokens := muspel.lex_all(src)
p := muspel.parser_init(tokens)
stmts := muspel.parse_program(&p)
for s in stmts {
print_stmt(s, 0)
}
if len(p.errors) > 0 {
fmt.println("---")
for e in p.errors {
fmt.printfln("%d:%d: error: %s", e.line, e.column, e.message)
}
}
}
@(private = "file")
indent :: proc(depth: int) {
for _ in 0 ..< depth do fmt.print(" ")
}
@(private = "file")
print_stmt :: proc(s: muspel.Stmt, depth: int) {
indent(depth)
switch node in s {
case ^muspel.Stmt_Var:
fmt.printfln("Var %s", node.name)
if node.init != nil do print_expr(node.init, depth + 1)
case ^muspel.Stmt_Func:
fmt.printfln("Func %s(%v)", node.name, node.params)
for stmt in node.body.stmts do print_stmt(stmt, depth + 1)
case ^muspel.Stmt_Expr:
fmt.println("ExprStmt")
print_expr(node.expr, depth + 1)
case ^muspel.Stmt_If:
fmt.println("If")
print_expr(node.cond, depth + 1)
print_stmt(node.then_branch, depth + 1)
if node.else_branch != nil do print_stmt(node.else_branch, depth + 1)
case ^muspel.Stmt_While:
fmt.println("While")
print_expr(node.cond, depth + 1)
print_stmt(node.body, depth + 1)
case ^muspel.Stmt_For_In:
fmt.printfln("ForIn %s [depth=%d slot=%d]", node.item_name, node.depth, node.slot)
print_expr(node.list, depth + 1)
print_stmt(node.body, depth + 1)
case ^muspel.Stmt_Struct_Decl:
fmt.printfln("StructDecl %s %v", node.name, node.fields)
case ^muspel.Stmt_Enum_Decl:
fmt.printfln("EnumDecl %s %v", node.name, node.members)
case ^muspel.Stmt_Import:
fmt.printfln("Import %q as %s", node.path, node.alias)
case ^muspel.Stmt_Return:
fmt.println("Return")
if node.value != nil do print_expr(node.value, depth + 1)
case ^muspel.Stmt_Yield:
fmt.println("Yield")
case ^muspel.Stmt_Block:
fmt.println("Block")
for stmt in node.stmts do print_stmt(stmt, depth + 1)
case ^muspel.Stmt_Multi:
fmt.println("Multi")
for stmt in node.stmts do print_stmt(stmt, depth + 1)
}
}
@(private = "file")
print_expr :: proc(e: muspel.Expr, depth: int) {
indent(depth)
switch node in e {
case ^muspel.Expr_Literal:
if s, ok := node.value.(string); ok {
fmt.printfln("Literal %q", s)
} else {
fmt.printfln("Literal %v", node.value)
}
case ^muspel.Expr_Ident:
fmt.printfln("Ident %s [depth=%d slot=%d]", node.name, node.depth, node.slot)
case ^muspel.Expr_Group:
fmt.println("Group")
print_expr(node.inner, depth + 1)
case ^muspel.Expr_Unary:
fmt.printfln("Unary %v", node.op)
print_expr(node.right, depth + 1)
case ^muspel.Expr_Binary:
fmt.printfln("Binary %v", node.op)
print_expr(node.left, depth + 1)
print_expr(node.right, depth + 1)
case ^muspel.Expr_Logical:
fmt.printfln("Logical %v", node.op)
print_expr(node.left, depth + 1)
print_expr(node.right, depth + 1)
case ^muspel.Expr_Call:
fmt.println("Call")
print_expr(node.callee, depth + 1)
for arg in node.args do print_expr(arg, depth + 1)
case ^muspel.Expr_Assign:
fmt.printfln("Assign %s [depth=%d slot=%d]", node.name, node.depth, node.slot)
print_expr(node.value, depth + 1)
case ^muspel.Expr_Struct_Literal:
fmt.printfln("StructLiteral %s %v %v", node.type_name, node.keys, node.field_indices)
for v in node.values do print_expr(v, depth + 1)
case ^muspel.Expr_Field_Access:
if node.is_enum_member {
fmt.printfln("EnumMember .%s [tag=%d]", node.field, node.enum_tag)
} else {
fmt.printfln("FieldAccess .%s [index=%d]", node.field, node.field_index)
print_expr(node.object, depth + 1)
}
case ^muspel.Expr_Field_Assign:
fmt.printfln("FieldAssign .%s [index=%d]", node.field, node.field_index)
print_expr(node.object, depth + 1)
print_expr(node.value, depth + 1)
case ^muspel.Expr_Array_Literal:
fmt.println("ArrayLiteral")
for el in node.elements do print_expr(el, depth + 1)
case ^muspel.Expr_Index:
fmt.println("Index")
print_expr(node.object, depth + 1)
print_expr(node.index, depth + 1)
case ^muspel.Expr_Index_Assign:
fmt.println("IndexAssign")
print_expr(node.object, depth + 1)
print_expr(node.index, depth + 1)
print_expr(node.value, depth + 1)
}
}
tools/musc/check_cmd.odin
package musc
import "core:fmt"
import muspel "../../muspel"
cmd_check :: proc(src: string) {
tokens := muspel.lex_all(src)
p := muspel.parser_init(tokens)
stmts := muspel.parse_program(&p)
if len(p.errors) > 0 {
for e in p.errors {
fmt.printfln("%d:%d: syntax error: %s", e.line, e.column, e.message)
}
return
}
r := muspel.resolver_init()
muspel.resolve_program(&r, stmts)
if len(r.errors) == 0 {
fmt.println("resolved cleanly")
return
}
for e in r.errors {
fmt.printfln("%d: error: %s", e.line, e.message)
}
}
tools/musc/run_cmd.odin
package musc
import "core:fmt"
import "core:os"
import muspel "../../muspel"
// cmd_run lexes, parses, and resolves a program, then executes it: by default through the real
// bytecode VM (compile, then `vm_run_chunk`), or through Chapter 8's tree-walker when `--walk` is
// given, to compare the two directly. A program with syntax or resolution errors never reaches
// either one.
//
// Only the VM path parses `wait`/`wait_until` in alongside the script (`parse_with_stdlib`,
// Chapter 15): both are built on `yield`, and the tree-walker has no way to honour a Yield
// wherever it lands mid-evaluation (see treewalk.odin's own Stmt_Yield case). `--walk` keeps
// parsing the file on its own, exactly as every earlier chapter's checkpoint already does, and
// has no module loader of its own (Chapter 20's import graph is VM-path only): a script with an
// `import` runs fine under the VM but --walk simply skips the statement, so an imported struct or
// function called by name resolves as an ordinary undefined-name error under --walk.
cmd_run :: proc(path: string, src: string, walk: bool) {
if walk {
tokens := muspel.lex_all(src)
p := muspel.parser_init(tokens)
stmts := muspel.parse_program(&p)
if len(p.errors) > 0 {
for e in p.errors {
fmt.printfln("%d:%d: syntax error: %s", e.line, e.column, e.message)
}
return
}
r := muspel.resolver_init()
muspel.resolve_program(&r, stmts)
if len(r.errors) > 0 {
for e in r.errors {
fmt.printfln("%d: error: %s", e.line, e.message)
}
return
}
interp := muspel.interp_init()
interp.structs = r.structs
muspel.exec_program(&interp, stmts)
return
}
// Chapter 20: `path` and every `import`-ed `.mus` file it (transitively) reaches are gathered
// into one module graph before anything is resolved or compiled. A script with no imports at
// all is the one-module case, `order == {clean(path)}`, which costs nothing beyond what the
// pre-Chapter-20 single-file pipeline already did.
modules, entry_key, load_ok := load_module_graph(path, src)
if !load_ok {
os.exit(1)
}
order, cycle_path, has_cycle := muspel.build_module_order(modules, entry_key)
if has_cycle {
fmt.printfln("error: import cycle detected at %s", cycle_path)
os.exit(1)
}
resolvers := muspel.resolve_module_graph(modules, order)
had_errors := false
for p in order {
r := resolvers[p]
if len(r.errors) > 0 {
had_errors = true
for e in r.errors {
fmt.printfln("%s:%d: error: %s", p, e.line, e.message)
}
}
}
if had_errors do return
vm := muspel.vm_init()
muspel.run_module_graph(&vm, modules, order)
// A runtime error (Chapter 18) no longer ends the process on its own: vm_run_chunk returns
// normally either way, leaving vm.status as the only record of which. `musc run` is a
// one-shot CLI command, not a long-lived host juggling many VM instances, so it still reports
// failure the ordinary command-line way, a non-zero exit code, even though the VM API itself
// (any host embedding Muspel for real, Chapter 21's Embers included) never makes that choice
// for the caller.
if vm.status == .Error {
os.exit(1)
}
}
tools/musc/bench_cmd.odin
package musc
import "core:fmt"
import "core:time"
import muspel "../../muspel"
// cmd_bench times both ways of running a program: Chapter 8's tree-walker and, since Chapter 13,
// the real bytecode VM, back to back against the same resolved AST, so the two numbers are
// directly comparable. Lexing, parsing and resolving aren't timed: this is measuring what
// running the program costs, not the one-time cost of getting there. Compiling to bytecode
// *is* timed as part of the VM's own number, since a real host pays that cost too, once, before
// a script's first tick (Chapter 17 revisits this once compiled chunks are shared and reused
// across many script instances).
cmd_bench :: proc(src: string) {
tokens := muspel.lex_all(src)
p := muspel.parser_init(tokens)
stmts := muspel.parse_program(&p)
if len(p.errors) > 0 {
for e in p.errors {
fmt.printfln("%d:%d: syntax error: %s", e.line, e.column, e.message)
}
return
}
r := muspel.resolver_init()
muspel.resolve_program(&r, stmts)
if len(r.errors) > 0 {
for e in r.errors {
fmt.printfln("%d: error: %s", e.line, e.message)
}
return
}
interp := muspel.interp_init()
interp.structs = r.structs
walk_start := time.now()
muspel.exec_program(&interp, stmts)
walk_elapsed := time.since(walk_start)
fmt.printfln("tree-walk: %v", walk_elapsed)
vm_start := time.now()
chunk := muspel.chunk_init()
muspel.compile_program(&chunk, stmts)
vm := muspel.vm_init()
muspel.vm_run_chunk(&vm, &chunk)
vm_elapsed := time.since(vm_start)
fmt.printfln("bytecode vm: %v", vm_elapsed)
}
tools/musc/compile_cmd.odin
package musc
import "core:fmt"
import muspel "../../muspel"
// cmd_compile lexes, parses, and resolves exactly like cmd_check, then compiles the resolved AST
// to bytecode and disassembles the result: the checkpoint for Chapters 11 and 12, the same way
// cmd_check was the checkpoint for Chapter 7. There's still no VM (Chapter 13): this command
// only proves the compiler produces the bytecode it should, read back as mnemonics.
cmd_compile :: proc(src: string) {
tokens := muspel.lex_all(src)
p := muspel.parser_init(tokens)
stmts := muspel.parse_program(&p)
if len(p.errors) > 0 {
for e in p.errors {
fmt.printfln("%d:%d: syntax error: %s", e.line, e.column, e.message)
}
return
}
r := muspel.resolver_init()
muspel.resolve_program(&r, stmts)
if len(r.errors) > 0 {
for e in r.errors {
fmt.printfln("%d: error: %s", e.line, e.message)
}
return
}
chunk := muspel.chunk_init()
muspel.compile_program(&chunk, stmts)
// Top-level functions were compiled as constants into the top-level chunk's own pool; print
// each one's chunk too, so a function's body is just as inspectable as the top level.
for const in chunk.constants {
if proto, ok := const.(^muspel.Func_Proto); ok {
fn_chunk := proto.chunk
muspel.disassemble_chunk(&fn_chunk, proto.name)
}
}
muspel.disassemble_chunk(&chunk, "<script>")
}
tools/musc/debug_cmd.odin
package musc
import "core:bufio"
import "core:fmt"
import "core:os"
import muspel "../../muspel"
// cmd_debug lexes, parses, resolves, and compiles exactly like cmd_run's default (VM) path, then
// hands the resulting chunk to Chapter 14's step debugger instead of running it straight through.
cmd_debug :: proc(src: string) {
tokens := muspel.lex_all(src)
p := muspel.parser_init(tokens)
stmts := muspel.parse_program(&p)
if len(p.errors) > 0 {
for e in p.errors {
fmt.printfln("%d:%d: syntax error: %s", e.line, e.column, e.message)
}
return
}
r := muspel.resolver_init()
muspel.resolve_program(&r, stmts)
if len(r.errors) > 0 {
for e in r.errors {
fmt.printfln("%d: error: %s", e.line, e.message)
}
return
}
chunk := muspel.chunk_init()
muspel.compile_program(&chunk, stmts)
vm := muspel.vm_init()
muspel.vm_push_script(&vm, &chunk)
vm_debug_run(&vm)
}
// vm_debug_run is a minimal single-step debugger, built entirely on tools already in hand:
// muspel/disasm.odin's own `disassemble_instruction`, and `vm_step` (Chapter 13), which was
// already written to execute exactly one instruction and report whether the program is still
// running. Before every step, it prints which frame is executing, the *next* instruction about
// to run (not yet executed), and the current value stack, then waits for one line of input:
// Enter to take just that one step, `c` to run the rest without stopping again, `q` to give up
// early. Reading stdin (`core:bufio`, `core:os`) is why this lives here rather than inside
// `muspel/disasm.odin` itself: neither package exists on `-target:js_wasm32` at all, and
// `muspel/` needs to stay compilable there for Embers (Chapter 21) regardless of whether Embers
// itself ever calls this proc.
vm_debug_run :: proc(vm: ^muspel.VM) {
scanner: bufio.Scanner
bufio.scanner_init(&scanner, os.to_reader(os.stdin))
defer bufio.scanner_destroy(&scanner)
fmt.println("Muspel step debugger: Enter to step, 'c' to run to completion, 'q' to quit.")
running := true
auto_run := false
for running {
if !auto_run {
frame := vm.frames[len(vm.frames) - 1]
fmt.printf("[%s] ", frame.name)
muspel.disassemble_instruction(frame.chunk, frame.ip)
debug_print_stack(vm)
fmt.print("> ")
if !bufio.scanner_scan(&scanner) do return
switch bufio.scanner_text(&scanner) {
case "q":
return
case "c":
auto_run = true
}
}
running = muspel.vm_step(vm) && vm.status != .Error
}
}
@(private = "file")
debug_print_stack :: proc(vm: ^muspel.VM) {
fmt.printf(" stack:")
for v in vm.stack {
fmt.printf(" [%s]", muspel.value_to_string(v))
}
fmt.println()
}
tools/musc/asm_demo_cmd.odin
package musc
import muspel "../../muspel"
// cmd_asm_demo hand-assembles `(1 + 2) * 3` directly, byte by byte, with no compiler involved:
// Chapter 10's whole point is proving the *format* (Chunk, the constant pool, fixed-width
// operands) is sound on its own terms, before Chapter 11 ever writes anything that generates a
// Chunk automatically.
cmd_asm_demo :: proc() {
chunk := muspel.chunk_init()
c1 := muspel.chunk_add_const(&chunk, muspel.Const_Value(1.0))
c2 := muspel.chunk_add_const(&chunk, muspel.Const_Value(2.0))
c3 := muspel.chunk_add_const(&chunk, muspel.Const_Value(3.0))
line := 1
muspel.chunk_write_op(&chunk, .Constant, line)
muspel.chunk_write(&chunk, c1, line)
muspel.chunk_write_op(&chunk, .Constant, line)
muspel.chunk_write(&chunk, c2, line)
muspel.chunk_write_op(&chunk, .Add, line)
muspel.chunk_write_op(&chunk, .Constant, line)
muspel.chunk_write(&chunk, c3, line)
muspel.chunk_write_op(&chunk, .Mul, line)
muspel.chunk_write_op(&chunk, .Return, line)
muspel.disassemble_chunk(&chunk, "(1 + 2) * 3")
}
tools/musc/tick_cmd.odin
package musc
import "core:fmt"
import muspel "../../muspel"
// tick_dt is the fake clock's per-frame step: small enough that a two-or-three-tick wait shows
// its suspend/resume behaviour clearly, without a real script needing dozens of frames to finish.
@(private = "file")
tick_dt :: 0.05
// cmd_run_tick is Chapter 15's own checkpoint: drive a program through vm_resume one simulated
// host frame at a time, against a fake, manually-advanced clock (vm_set_fake_clock) instead of
// the real one, printing exactly what section 15.8's worked trace describes, so the whole
// suspend/resume mechanism is demonstrated and checkable before Embers (Chapter 21) exists to
// host it for real.
cmd_run_tick :: proc(src: string) {
stmts, errors := muspel.parse_with_stdlib(src)
if len(errors) > 0 {
for e in errors {
fmt.printfln("%d:%d: syntax error: %s", e.line, e.column, e.message)
}
return
}
r := muspel.resolver_init()
muspel.resolve_program(&r, stmts)
if len(r.errors) > 0 {
for e in r.errors {
fmt.printfln("%d: error: %s", e.line, e.message)
}
return
}
chunk := muspel.chunk_init()
muspel.compile_program(&chunk, stmts)
vm := muspel.vm_init()
muspel.vm_push_script(&vm, &chunk)
clock := 0.0
frame := 0
for {
muspel.vm_set_fake_clock(&vm, clock)
result := muspel.vm_resume(&vm)
fmt.printfln("frame %d: clock=%.2f status=%v", frame, clock, result)
if result != .Suspended do return
frame += 1
clock += tick_dt
}
}
tools/musc/host_cmd.odin
package musc
import "core:fmt"
import "core:os"
import muspel "../../muspel"
// Stub_Entity and Stub_Host are deliberately the smallest possible thing worth calling a "host":
// an id and a position, enough for spawn/move_to/log to have something real to act on. Chapter
// 18's Embers replaces this with real game entities and a real arena; this file exists purely to
// prove the embedding boundary (muspel/host.odin) works from outside muspel/ itself, which is
// exactly the shape any future host, Embers included, will follow: muspel/ never imports this
// file, or knows it exists.
Stub_Entity :: struct {
id: int,
x, y: f64,
}
Stub_Host :: struct {
entities: [dynamic]Stub_Entity,
next_id: int,
}
// Vec2_Fields is the one field-name table every Vec2 value this host hands to or reads from a
// script shares, exactly the way a compiled struct's own field_names is shared across every
// instance of that type (opcodes.odin's Struct_Layout): a host constructing a struct value to
// pass back into Muspel (Chapter 18, section 3) needs the same kind of table a script's own
// `struct Vec2 { x, y }` declaration would have produced.
@(private = "file")
Vec2_Fields := []string{"x", "y"}
// native_log, native_spawn, and native_move_to are the first real builtin set a host in this
// book registers. `spawn`/`move_to` thread a `Vec2` struct value through the boundary instead of
// loose `x, y` numbers (Chapter 18, section 3 and Chapter 21, section 2), reading it back with
// `arg_struct_field` on the way in and building one with `make_struct_value` on neither side ever
// touching the other's own memory: the script gets a value allocated from *its own* VM instance's
// arena, the host gets plain Odin floats it copies into `Stub_Entity`. Reaching `vm.user_data`
// back to this file's own Stub_Host is the entire embedding boundary in miniature: the script
// only ever sees a struct value and a number (its handle), never a pointer into host memory.
@(private = "file")
native_log :: proc(vm: ^muspel.VM, args: []muspel.Value) -> muspel.Value {
for arg, i in args {
if i > 0 do fmt.print(" ")
fmt.print(muspel.value_to_string(arg))
}
fmt.println()
return nil
}
@(private = "file")
native_spawn :: proc(vm: ^muspel.VM, args: []muspel.Value) -> muspel.Value {
host := (^Stub_Host)(vm.user_data)
x, x_ok := muspel.arg_struct_field(args, 0, "x")
y, y_ok := muspel.arg_struct_field(args, 0, "y")
xn, xn_ok := x.(f64)
yn, yn_ok := y.(f64)
if !x_ok || !y_ok || !xn_ok || !yn_ok {
muspel.runtime_error(vm, "spawn expects (Vec2)")
return nil
}
id := host.next_id
host.next_id += 1
append(&host.entities, Stub_Entity{id = id, x = xn, y = yn})
fmt.printfln("spawn -> entity %d at (%.0f, %.0f)", id, xn, yn)
return f64(id)
}
@(private = "file")
native_move_to :: proc(vm: ^muspel.VM, args: []muspel.Value) -> muspel.Value {
host := (^Stub_Host)(vm.user_data)
handle, h_ok := muspel.arg_number(args, 0)
x, x_ok := muspel.arg_struct_field(args, 1, "x")
y, y_ok := muspel.arg_struct_field(args, 1, "y")
xn, xn_ok := x.(f64)
yn, yn_ok := y.(f64)
if !h_ok || !x_ok || !y_ok || !xn_ok || !yn_ok {
muspel.runtime_error(vm, "move_to expects (entity, Vec2)")
return nil
}
id := int(handle)
for &e in host.entities {
if e.id == id {
e.x, e.y = xn, yn
fmt.printfln("move_to -> entity %d now at (%.0f, %.0f)", id, xn, yn)
return nil
}
}
muspel.runtime_error(vm, "move_to: no entity %d", id)
return nil
}
// native_entity_pos hands a Vec2 struct value *back* into Muspel, make_struct_value's own half
// of the round trip: `spawn`/`move_to` above only ever read a script-built Vec2 apart, this is
// the one direction that builds one, allocated from the calling VM instance's own arena.
@(private = "file")
native_entity_pos :: proc(vm: ^muspel.VM, args: []muspel.Value) -> muspel.Value {
host := (^Stub_Host)(vm.user_data)
handle, h_ok := muspel.arg_number(args, 0)
if !h_ok {
muspel.runtime_error(vm, "entity_pos expects (entity)")
return nil
}
id := int(handle)
for e in host.entities {
if e.id == id {
return muspel.make_struct_value(vm, "Vec2", Vec2_Fields, {e.x, e.y})
}
}
muspel.runtime_error(vm, "entity_pos: no entity %d", id)
return nil
}
// cmd_host lexes, parses, resolves, and runs a program exactly like cmd_run's default VM path,
// except a Stub_Host's own builtins are registered (muspel.vm_register_native) before resolution
// ever runs, so `log`, `spawn`, and `move_to` are already known globals by the time the resolver
// sees a call to any of them, precisely mirroring how `print` and `host_seconds` are always
// already known.
cmd_host :: proc(src: string) {
tokens := muspel.lex_all(src)
p := muspel.parser_init(tokens)
stmts := muspel.parse_program(&p)
if len(p.errors) > 0 {
for e in p.errors {
fmt.printfln("%d:%d: syntax error: %s", e.line, e.column, e.message)
}
return
}
r := muspel.resolver_init()
vm := muspel.vm_init()
host := Stub_Host{}
muspel.vm_register_native(&vm, &r, "log", native_log)
muspel.vm_register_native(&vm, &r, "spawn", native_spawn)
muspel.vm_register_native(&vm, &r, "move_to", native_move_to)
muspel.vm_register_native(&vm, &r, "entity_pos", native_entity_pos)
vm.user_data = &host
muspel.resolve_program(&r, stmts)
if len(r.errors) > 0 {
for e in r.errors {
fmt.printfln("%d: error: %s", e.line, e.message)
}
return
}
chunk := muspel.chunk_init()
muspel.compile_program(&chunk, stmts)
muspel.vm_run_chunk(&vm, &chunk)
if vm.status == .Error {
os.exit(1)
}
}
tools/musc/events_cmd.odin
package musc
import "core:fmt"
import muspel "../../muspel"
// cmd_events is Chapter 17's own checkpoint for callback registration and firing. It resumes the
// main script exactly once (`examples/events_demo.mus`'s own `main_loop` suspends immediately,
// mid-`wait`), then fires and fully drains a `damage` event, a handler that itself calls `wait`,
// while the main script is still sitting suspended. The point being proven: vm_fire_event and
// vm_resume_call (section 17.1) never touch a frame beneath the one they were asked to drive, so
// the main script's own frame count and status come out exactly as they went in, and it can be
// resumed afterward as if the callback had never run at all.
cmd_events :: proc(src: string) {
stmts, errors := muspel.parse_with_stdlib(src)
if len(errors) > 0 {
for e in errors {
fmt.printfln("%d:%d: syntax error: %s", e.line, e.column, e.message)
}
return
}
r := muspel.resolver_init()
muspel.resolve_program(&r, stmts)
if len(r.errors) > 0 {
for e in r.errors {
fmt.printfln("%d: error: %s", e.line, e.message)
}
return
}
chunk := muspel.chunk_init()
muspel.compile_program(&chunk, stmts)
vm := muspel.vm_init()
muspel.vm_push_script(&vm, &chunk)
result := muspel.vm_resume(&vm)
fmt.printfln("main script: %v, %d frame(s) on the stack", result, len(vm.frames))
pre_frames, pre_status := len(vm.frames), vm.status
fmt.println("--- firing 'damage' while the main script is still suspended ---")
call, cb_result, fired := muspel.vm_fire_event(&vm, "damage", muspel.Value(f64(5)))
if !fired {
fmt.println(" no 'damage' handler registered")
return
}
clock := 0.0
for cb_result == .Suspended {
clock += 0.05
muspel.vm_set_fake_clock(&vm, clock)
cb_result = muspel.vm_resume_call(&vm, call)
}
fmt.printfln("'damage' handler finished: %v", cb_result)
fmt.printfln(
"main script untouched: %d frame(s), %v (unchanged: %v)",
len(vm.frames),
vm.status,
len(vm.frames) == pre_frames && vm.status == pre_status,
)
}
tools/musc/instances_cmd.odin
package musc
import "core:fmt"
import "core:time"
import muspel "../../muspel"
// Max_Instances is the stress-test harness's own fixed capacity: a plain Odin array, not a
// dynamic one, exactly the no-per-frame-allocation discipline the rest of the `dinn` monorepo's
// own house style already uses everywhere else. 256 is comfortably past anything Embers (Chapter
// 18) will ever field at once, and past section 17.2's own "fifty enemies" checkpoint by a wide
// margin.
Max_Instances :: 256
@(private = "file")
instance_tick_dt :: 0.05
// cmd_run_instances is section 17.2/15.3's own checkpoint: compile `src` exactly once into
// `chunk`, then drive `count` independent VM instances of it through simulated ticks, each
// suspending and resuming on its own schedule, until every one of them is `.Done`. Every
// instance's own `Frame.chunk` points at the *same* `chunk` value (vm_push_script takes a
// pointer, never a copy), so the bytecode itself is shared, read-only, exactly once; only each
// instance's own stack, frames, and globals are its own. Timing the whole run is what section
// 15.3's own claim (this is the lever that makes "fifty scripted enemies" cheap) gets to rest on
// a real number instead of an assertion.
cmd_run_instances :: proc(src: string, count: int) {
if count < 1 || count > Max_Instances {
fmt.eprintfln("musc: --instances=%d is outside the supported range (1..%d)", count, Max_Instances)
return
}
stmts, errors := muspel.parse_with_stdlib(src)
if len(errors) > 0 {
for e in errors {
fmt.printfln("%d:%d: syntax error: %s", e.line, e.column, e.message)
}
return
}
r := muspel.resolver_init()
muspel.resolve_program(&r, stmts)
if len(r.errors) > 0 {
for e in r.errors {
fmt.printfln("%d: error: %s", e.line, e.message)
}
return
}
chunk := muspel.chunk_init()
muspel.compile_program(&chunk, stmts)
vms: [Max_Instances]muspel.VM
for i in 0 ..< count {
vms[i] = muspel.vm_init()
muspel.vm_push_script(&vms[i], &chunk)
}
start := time.now()
clock := 0.0
frames := 0
errored := 0
for {
still_running := 0
for i in 0 ..< count {
// A `.Done` instance never needs resuming again; a `.Error` one (Chapter 18) never
// will either, vm_resume's own no-op guard would just report `.Error` back forever.
// Both have to be excluded from `still_running`, not just `.Done`, or one instance's
// own runtime error would keep this loop spinning long after it had anything left to
// contribute, exactly the "one bad script hangs everything" outcome fault isolation
// exists to rule out.
if vms[i].status == .Done || vms[i].status == .Error do continue
muspel.vm_set_fake_clock(&vms[i], clock)
result := muspel.vm_resume(&vms[i])
if result == .Error {
errored += 1
continue
}
if result != .Done {
still_running += 1
}
}
frames += 1
clock += instance_tick_dt
if still_running == 0 do break
}
elapsed := time.since(start)
fmt.printfln(
"%d instance(s), 1 shared chunk, %d simulated frame(s), %d error(s), finished in %v",
count,
frames,
errored,
elapsed,
)
}
tools/musc/hotreload.odin
package musc
import "core:os"
import "core:time"
import muspel "../../muspel"
// Watched_Script is one `.mus` file, its most recently seen modification time, and its
// currently-active compiled chunk. `chunk` is deliberately a pointer: swapping it never mutates
// the `Chunk` value an existing `Frame` might already be pointing at (`vm_push_script` stores a
// pointer, not a copy), so an already-running `VM` instance keeps executing the exact bytecode it
// started with even after `chunk` itself has been swapped out from under it, section 19.3's whole
// design decision made real by nothing more than "replace the pointer, never edit the pointee."
//
// This lives in `tools/musc/`, not `muspel/`: watching a file's own mtime is a `core:os` call
// (`os.modification_time_by_path`), and `core:os` doesn't exist on `-target:js_wasm32` at all.
// `muspel/`'s own `compile_source` (stdlib.odin) is the host-agnostic half of this mechanism,
// source text in, a compiled `Chunk` out; this file is `musc`'s own native-only wrapper around
// it, polling a real file on a real filesystem. Embers (Chapter 21) reuses `compile_source`
// directly for its own browser-side reload, driven by a `fetch()` response instead of a file
// read, never this struct.
Watched_Script :: struct {
path: string,
mtime: time.Time,
chunk: ^muspel.Chunk,
}
// watched_script_load compiles `path` for the first time, the starting point every later
// `watched_script_poll` call diffs against.
//
// Gotcha: `src` is never freed. Every string constant a compiled Chunk holds (identifiers,
// string literals) is a slice straight into whatever source buffer produced it, all the way back
// to the lexer's own tokens, never a copy; nothing in this book's compiler clones a string
// literal to give it its own lifetime. Every other `musc` command gets away with reading a file
// once and letting `main`'s own `defer delete(src)` free it only after the whole command has
// finished, so the buffer outlives every use. A reload target doesn't have that luxury:
// `ws.chunk` needs to stay valid for as long as *any* VM instance might still be running it,
// which this file has no way to know. The first version of this file freed `src` right after
// compiling, exactly the way it looked like it should; the result was a use-after-free that
// showed up as a compiled string constant reading back as garbage bytes the moment a VM later
// tried to look one up, caught only by actually running the reload demo rather than trusting
// that "compiles cleanly" meant "runs correctly." Leaking the buffer instead, one per successful
// compile, forever, is the same trade-off the tree-walker's own `Environment` allocation already
// made (treewalk.odin's own doc comment: "the garbage-free-but-leaking allocator keeps around
// for us"), applied here for the identical reason.
watched_script_load :: proc(path: string, allocator := context.allocator) -> (ws: Watched_Script, ok: bool) {
src, read_err := os.read_entire_file_from_path(path, allocator)
if read_err != nil do return {}, false
chunk := new(muspel.Chunk, allocator)
if !muspel.compile_source(chunk, string(src), allocator = allocator) {
return {}, false
}
mtime, _ := os.modification_time_by_path(path)
return Watched_Script{path = path, mtime = mtime, chunk = chunk}, true
}
// watched_script_poll checks `ws.path`'s modification time and, if it's changed since the last
// poll (or load), tries recompiling it. `reloaded` is true the instant a *change on disk* was
// seen at all, regardless of whether it compiled; `ok` is only meaningful when `reloaded` is
// true, and says whether the new version actually replaced `ws.chunk`.
//
// Design decision: a broken edit never discards the last-good chunk. `ws.chunk` is only ever
// reassigned on a fully successful lex/parse/resolve/compile; a syntax error or a resolution
// error partway through typing a change leaves `ws.chunk` exactly as it was, still serving
// whatever last compiled cleanly, exactly the behaviour section 19.2 promises: a designer mid-
// edit never sees their in-progress typo take an entity's script down.
watched_script_poll :: proc(ws: ^Watched_Script, allocator := context.allocator) -> (reloaded: bool, ok: bool) {
mtime, stat_err := os.modification_time_by_path(ws.path)
if stat_err != nil do return false, false
if time.diff(ws.mtime, mtime) == 0 do return false, false
ws.mtime = mtime
// src is deliberately never freed here either, for the same reason watched_script_load
// never frees it: see that proc's own Gotcha.
src, read_err := os.read_entire_file_from_path(ws.path, allocator)
if read_err != nil do return true, false
new_chunk := new(muspel.Chunk, allocator)
if !muspel.compile_source(new_chunk, string(src), allocator = allocator) {
return true, false
}
ws.chunk = new_chunk
return true, true
}
tools/musc/module_loader.odin
package musc
import "base:runtime"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import muspel "../../muspel"
// This file is the one piece of Chapter 20's module system that has to live outside `muspel/`:
// reading another `.mus` file off disk needs `core:os`, which doesn't exist on `-target:js_wasm32`
// (the same reason `disasm.odin`'s own doc comment gives for keeping the interactive step
// debugger out of `muspel/` too). `muspel/modules.odin` does the graph-walking, resolving, and
// compiling; this file's only job is turning `import "path" as alias` into another parsed
// `Loaded_Module` in the map that work needs.
// load_module_graph reads and parses `entry_path` and, recursively, every file it (transitively)
// imports, resolving each import path relative to the *importing* file's own directory: no
// separate "collection root" concept, just "relative to the file that wrote the import," simple
// enough for a book-scale project and consistent regardless of which directory `musc` is run
// from. Returns the graph keyed by a cleaned path, so the same file reached by two different
// relative spellings still resolves to one module (and one compiled chunk, Chapter 20 section 6).
// The entry file alone is parsed together with the standard library (`parse_with_stdlib`,
// Chapter 15), exactly as the single-file VM path always has; an imported module has no need of
// `wait`/`wait_until` predeclared into its own statement list.
load_module_graph :: proc(
entry_path: string,
entry_src: string,
allocator := context.allocator,
) -> (modules: map[string]muspel.Loaded_Module, entry_key: string, ok: bool) {
modules = make(map[string]muspel.Loaded_Module, allocator)
clean, _ := filepath.clean(entry_path, allocator)
entry_key = clean
stmts, errors := muspel.parse_with_stdlib(entry_src, allocator)
if len(errors) > 0 {
for e in errors {
fmt.printfln("%d:%d: syntax error: %s", e.line, e.column, e.message)
}
return modules, entry_key, false
}
loading := make(map[string]bool, allocator)
loading[clean] = true
ok = load_module_imports(clean, stmts, &modules, &loading, allocator)
return modules, entry_key, ok
}
// loading tracks every path currently *in progress* (its own imports are still being read),
// as opposed to `modules`, which only gains an entry once a file's imports have all been
// attempted. Checking `loading` before recursing, not just `modules`, is what turns `a.mus`
// importing `b.mus` importing `a.mus` into a reported cycle instead of unbounded recursion: by
// the time the inner `load_one_module(a.mus)` call would fire, `modules[a.mus]` doesn't exist yet
// (the outer call hasn't returned to write it), so that map alone can't catch the cycle in time.
@(private = "file")
load_one_module :: proc(
clean_path: string,
src: string,
modules: ^map[string]muspel.Loaded_Module,
loading: ^map[string]bool,
allocator: runtime.Allocator,
) -> bool {
if _, exists := modules[clean_path]; exists do return true
if loading[clean_path] {
fmt.printfln("error: import cycle detected at %s", clean_path)
return false
}
loading[clean_path] = true
tokens := muspel.lex_all(src, allocator)
p := muspel.parser_init(tokens, allocator)
stmts := muspel.parse_program(&p, allocator)
if len(p.errors) > 0 {
for e in p.errors {
fmt.printfln("%s:%d:%d: syntax error: %s", clean_path, e.line, e.column, e.message)
}
return false
}
return load_module_imports(clean_path, stmts, modules, loading, allocator)
}
// load_module_imports finishes loading a module whose own statements are already parsed
// (`stmts`): scan its `import` declarations, recursively load each one relative to this file's
// own directory, and record the module itself in `modules` once every import beneath it has been
// attempted (an import that fails to read, parse, or forms a cycle is reported and skipped,
// `all_ok` set false, rather than aborting the whole graph, so one broken import doesn't hide
// errors in unrelated sibling imports).
@(private = "file")
load_module_imports :: proc(
clean_path: string,
stmts: []muspel.Stmt,
modules: ^map[string]muspel.Loaded_Module,
loading: ^map[string]bool,
allocator: runtime.Allocator,
) -> bool {
raw_imports := muspel.scan_imports(stmts, allocator)
resolved := make([dynamic]muspel.Import_Ref, allocator)
dir := filepath.dir(clean_path)
all_ok := true
for imp in raw_imports {
joined, join_err := filepath.join({dir, imp.path}, allocator)
if join_err != nil {
fmt.printfln("%s:%d: error: could not resolve import path %q", clean_path, imp.line, imp.path)
all_ok = false
continue
}
imp_clean, clean_err := filepath.clean(joined, allocator)
if clean_err != nil {
imp_clean = joined
}
append(&resolved, muspel.Import_Ref{path = imp_clean, alias = imp.alias, line = imp.line})
imp_src, read_err := os.read_entire_file_from_path(imp_clean, allocator)
if read_err != nil {
fmt.printfln("%s:%d: error: could not read imported module %q: %v", clean_path, imp.line, imp.path, read_err)
all_ok = false
continue
}
if !load_one_module(imp_clean, string(imp_src), modules, loading, allocator) {
all_ok = false
}
}
loading[clean_path] = false
modules[clean_path] = muspel.Loaded_Module{path = clean_path, stmts = stmts, imports = resolved[:]}
return all_ok
}
tools/musc/watch_cmd.odin
package musc
import "core:fmt"
import "core:time"
import muspel "../../muspel"
// Watch_Poll_Interval is how often cmd_run_watch checks the file's own mtime while a run is
// suspended: frequent enough that an edit shows up quickly in a live terminal session, coarse
// enough not to busy-poll the filesystem. Watch_Run_Count bounds the demo to a fixed, reportable
// number of runs rather than looping forever, so this checkpoint (and this book's own
// verification of it) is reproducible without needing to send the process a signal to stop it.
@(private = "file")
Watch_Poll_Interval :: 200 * time.Millisecond
@(private = "file")
Watch_Run_Count :: 3
// cmd_run_watch is Chapter 19's own checkpoint: run `path` through to completion, up to
// Watch_Run_Count times in a row, polling `path`'s own mtime (Watched_Script,
// tools/musc/hotreload.odin) every time the *current* run is suspended. A run already in progress is bound
// to whatever `^Chunk` it was pushed with at its own start; a later successful reload only ever
// changes which chunk the *next* run picks up when it calls vm_push_script, section 19.3's
// design decision made visible one `--- run N ---` header at a time. Edit and save `path` in
// another terminal while this is running to see it live; without an edit, every run just uses
// whatever last compiled cleanly.
cmd_run_watch :: proc(path: string) {
ws, ok := watched_script_load(path)
if !ok {
fmt.eprintfln("musc: %s failed to load or compile", path)
return
}
fmt.printfln("watching %s (%d run(s), polling every %v)", path, Watch_Run_Count, Watch_Poll_Interval)
for run_index in 1 ..= Watch_Run_Count {
fmt.printfln("--- run %d ---", run_index)
vm := muspel.vm_init()
muspel.vm_push_script(&vm, ws.chunk)
for {
result := muspel.vm_resume(&vm)
if result != .Suspended do break
time.sleep(Watch_Poll_Interval)
reloaded, compiled_ok := watched_script_poll(&ws)
if !reloaded do continue
if compiled_ok {
fmt.printfln("[reload] %s recompiled; this run keeps its own bytecode, the next run will use the new version", path)
} else {
fmt.printfln("[reload] %s has a compile error; still serving the last-good version", path)
}
}
if vm.status == .Error {
fmt.printfln("run %d ended with a runtime error", run_index)
}
}
}
tools/musc/embers_check_cmd.odin
package musc
import "core:fmt"
import muspel "../../muspel"
cmd_embers_check :: proc() {
sources := [3]string{
#load("../../embers/scripts/wave_01.mus", string),
#load("../../embers/scripts/wave_02.mus", string),
#load("../../embers/scripts/boss_ashling.mus", string),
}
names := [3]string{"wave_01", "wave_02", "boss_ashling"}
builtins := []string{"spawn", "move_to", "velocity", "on_death", "emit"}
for src, i in sources {
chunk: muspel.Chunk
ok := muspel.compile_source(&chunk, src, builtins)
fmt.printfln("%s: ok=%v", names[i], ok)
}
}
embers/
embers/batch/batch.odin
// batch.odin: the batched Canvas2D draw-command buffer.
//
// WHY: driving the browser's Canvas 2D API one method at a time across the WASM<->JS boundary
// (fillRect, arc, fill, ...) means one bridge crossing per call, which is expensive at 60fps.
// This package records an entire frame's worth of draw operations into ONE packed byte buffer,
// then flushes it with a SINGLE foreign call. The JS side (web/batch.js `batch_cmd_flush`) walks
// the buffer and replays each op onto the context. Encoding logic stays in Odin; JS is a
// mechanical dispatcher.
//
// WIRE FORMAT (little-endian):
// command := opcode:u8 followed by that opcode's typed args
// f32 arg := 4 bytes IEEE-754 LE
// str arg := len:u16 LE followed by `len` UTF-8 bytes
// u8 arg := 1 byte
//
// The Buffer is a fixed-capacity, preallocated arena reused every frame (reset() rewinds the
// cursor); no per-frame heap churn, no allocator dependency.
//
// This is a deliberately trimmed cousin of the `Batchiness` library in the `dinn` monorepo: it
// carries only the ops Destroids uses. See Appendix C of the book for how to add gradients,
// sprites/baking, filters and measureText the same way.
package batch
foreign import batch_env "batch_env"
@(default_calling_convention = "contextless")
foreign batch_env {
// batch_cmd_flush replays the packed command buffer onto the 2D context `ctx`.
batch_cmd_flush :: proc(ctx: Handle, buf: []byte) ---
}
// ------------------------------------------------------------------------------------------------
// Opcodes: keep in sync with the switch in web/batch.js `batch_cmd_flush`.
// ------------------------------------------------------------------------------------------------
OP_SET_FILL :: 0x01 // str
OP_SET_STROKE :: 0x02 // str
OP_SET_LINE_WIDTH :: 0x03 // f32
OP_SET_FONT :: 0x04 // str
OP_SET_TEXT_ALIGN :: 0x05 // str
OP_SET_TEXT_BASELINE :: 0x06 // str
OP_SET_GLOBAL_ALPHA :: 0x07 // f32
OP_SET_LINE_CAP :: 0x08 // str
OP_FILL_RECT :: 0x10 // f32 x4
OP_STROKE_RECT :: 0x11 // f32 x4
OP_CLEAR_RECT :: 0x12 // f32 x4
OP_BEGIN_PATH :: 0x20
OP_MOVE_TO :: 0x21 // f32 x2
OP_LINE_TO :: 0x22 // f32 x2
OP_CLOSE_PATH :: 0x23
OP_ARC :: 0x24 // x,y,r,a0,a1 (f32) + ccw (u8)
OP_FILL :: 0x28
OP_STROKE :: 0x29
OP_FILL_TEXT :: 0x30 // str, x, y (f32)
OP_STROKE_TEXT :: 0x31 // str, x, y (f32)
OP_SAVE :: 0x40
OP_RESTORE :: 0x41
OP_TRANSLATE :: 0x42 // f32 x2
OP_SCALE :: 0x43 // f32 x2
OP_ROTATE :: 0x44 // f32
OP_SET_SHADOW :: 0x70 // color(str), blur(f32)
OP_CLEAR_SHADOW :: 0x71
// ------------------------------------------------------------------------------------------------
// Buffer is a fixed-capacity command arena, reused across frames.
// ------------------------------------------------------------------------------------------------
CAPACITY :: 1 << 20 // 1 MiB, comfortably fits a full frame of a large scene.
Buffer :: struct {
data: [CAPACITY]u8,
len: int,
overflowed: bool,
}
// reset rewinds the buffer to empty. Call once at the start of every frame.
reset :: proc "contextless" (b: ^Buffer) {
b.len = 0
b.overflowed = false
}
// flush replays the recorded commands onto ctx, then leaves the buffer as-is (call reset() next
// frame). No-op if nothing was recorded.
flush :: proc "contextless" (ctx: Handle, b: ^Buffer) {
if b.len == 0 {
return
}
batch_cmd_flush(ctx, b.data[:b.len])
}
// ------------------------------------------------------------------------------------------------
// Low-level encoders (private).
// ------------------------------------------------------------------------------------------------
@(private)
put_u8 :: proc "contextless" (b: ^Buffer, v: u8) {
if b.len + 1 > CAPACITY {
b.overflowed = true
return
}
b.data[b.len] = v
b.len += 1
}
@(private)
put_f32 :: proc "contextless" (b: ^Buffer, v: f32) {
if b.len + 4 > CAPACITY {
b.overflowed = true
return
}
bits := transmute(u32)v
b.data[b.len + 0] = u8(bits)
b.data[b.len + 1] = u8(bits >> 8)
b.data[b.len + 2] = u8(bits >> 16)
b.data[b.len + 3] = u8(bits >> 24)
b.len += 4
}
@(private)
put_u16 :: proc "contextless" (b: ^Buffer, v: u16) {
if b.len + 2 > CAPACITY {
b.overflowed = true
return
}
b.data[b.len + 0] = u8(v)
b.data[b.len + 1] = u8(v >> 8)
b.len += 2
}
@(private)
put_str :: proc "contextless" (b: ^Buffer, s: string) {
n := len(s)
if n > 0xFFFF {
n = 0xFFFF
}
if b.len + 2 + n > CAPACITY {
b.overflowed = true
return
}
b.data[b.len + 0] = u8(n)
b.data[b.len + 1] = u8(n >> 8)
b.len += 2
bytes := transmute([]u8)s
for i in 0 ..< n {
b.data[b.len + i] = bytes[i]
}
b.len += n
}
// ------------------------------------------------------------------------------------------------
// State setters.
// ------------------------------------------------------------------------------------------------
set_fill :: proc "contextless" (b: ^Buffer, color: string) {
put_u8(b, OP_SET_FILL);put_str(b, color)
}
set_stroke :: proc "contextless" (b: ^Buffer, color: string) {
put_u8(b, OP_SET_STROKE);put_str(b, color)
}
set_line_width :: proc "contextless" (b: ^Buffer, w: f32) {
put_u8(b, OP_SET_LINE_WIDTH);put_f32(b, w)
}
set_font :: proc "contextless" (b: ^Buffer, font: string) {
put_u8(b, OP_SET_FONT);put_str(b, font)
}
set_text_align :: proc "contextless" (b: ^Buffer, align: string) {
put_u8(b, OP_SET_TEXT_ALIGN);put_str(b, align)
}
set_text_baseline :: proc "contextless" (b: ^Buffer, baseline: string) {
put_u8(b, OP_SET_TEXT_BASELINE);put_str(b, baseline)
}
set_global_alpha :: proc "contextless" (b: ^Buffer, a: f32) {
put_u8(b, OP_SET_GLOBAL_ALPHA);put_f32(b, a)
}
set_line_cap :: proc "contextless" (b: ^Buffer, cap: string) {
put_u8(b, OP_SET_LINE_CAP);put_str(b, cap)
}
// ------------------------------------------------------------------------------------------------
// Rectangles.
// ------------------------------------------------------------------------------------------------
fill_rect :: proc "contextless" (b: ^Buffer, x, y, w, h: f32) {
put_u8(b, OP_FILL_RECT);put_f32(b, x);put_f32(b, y);put_f32(b, w);put_f32(b, h)
}
stroke_rect :: proc "contextless" (b: ^Buffer, x, y, w, h: f32) {
put_u8(b, OP_STROKE_RECT);put_f32(b, x);put_f32(b, y);put_f32(b, w);put_f32(b, h)
}
clear_rect :: proc "contextless" (b: ^Buffer, x, y, w, h: f32) {
put_u8(b, OP_CLEAR_RECT);put_f32(b, x);put_f32(b, y);put_f32(b, w);put_f32(b, h)
}
// ------------------------------------------------------------------------------------------------
// Paths.
// ------------------------------------------------------------------------------------------------
begin_path :: proc "contextless" (b: ^Buffer) {put_u8(b, OP_BEGIN_PATH)}
close_path :: proc "contextless" (b: ^Buffer) {put_u8(b, OP_CLOSE_PATH)}
fill :: proc "contextless" (b: ^Buffer) {put_u8(b, OP_FILL)}
stroke :: proc "contextless" (b: ^Buffer) {put_u8(b, OP_STROKE)}
move_to :: proc "contextless" (b: ^Buffer, x, y: f32) {
put_u8(b, OP_MOVE_TO);put_f32(b, x);put_f32(b, y)
}
line_to :: proc "contextless" (b: ^Buffer, x, y: f32) {
put_u8(b, OP_LINE_TO);put_f32(b, x);put_f32(b, y)
}
arc :: proc "contextless" (b: ^Buffer, x, y, r, a0, a1: f32, ccw: bool = false) {
put_u8(b, OP_ARC)
put_f32(b, x);put_f32(b, y);put_f32(b, r);put_f32(b, a0);put_f32(b, a1)
put_u8(b, ccw ? 1 : 0)
}
// ------------------------------------------------------------------------------------------------
// Text.
// ------------------------------------------------------------------------------------------------
fill_text :: proc "contextless" (b: ^Buffer, text: string, x, y: f32) {
put_u8(b, OP_FILL_TEXT);put_str(b, text);put_f32(b, x);put_f32(b, y)
}
stroke_text :: proc "contextless" (b: ^Buffer, text: string, x, y: f32) {
put_u8(b, OP_STROKE_TEXT);put_str(b, text);put_f32(b, x);put_f32(b, y)
}
// ------------------------------------------------------------------------------------------------
// Transforms.
// ------------------------------------------------------------------------------------------------
save :: proc "contextless" (b: ^Buffer) {put_u8(b, OP_SAVE)}
restore :: proc "contextless" (b: ^Buffer) {put_u8(b, OP_RESTORE)}
translate :: proc "contextless" (b: ^Buffer, x, y: f32) {
put_u8(b, OP_TRANSLATE);put_f32(b, x);put_f32(b, y)
}
scale :: proc "contextless" (b: ^Buffer, x, y: f32) {
put_u8(b, OP_SCALE);put_f32(b, x);put_f32(b, y)
}
rotate :: proc "contextless" (b: ^Buffer, a: f32) {
put_u8(b, OP_ROTATE);put_f32(b, a)
}
// ------------------------------------------------------------------------------------------------
// Shadows. save/restore captures and restores the shadow state as part of the context's saved
// state, so scoping a glow to one shape is just save() ... set_shadow() ... draw ... restore().
// ------------------------------------------------------------------------------------------------
set_shadow :: proc "contextless" (b: ^Buffer, color: string, blur: f32) {
put_u8(b, OP_SET_SHADOW);put_str(b, color);put_f32(b, blur)
}
clear_shadow :: proc "contextless" (b: ^Buffer) {put_u8(b, OP_CLEAR_SHADOW)}
embers/batch/canvas.odin
// canvas.odin: the tiny bootstrap that gets a canvas + 2D context to draw onto, drives a
// requestAnimationFrame loop, and reads DOM events. Everything else in the package is pure
// drawing (see batch.odin).
//
// NAMING: functions declared inside `foreign batch_env { ... }` are provided BY JavaScript and
// called FROM Odin (JS -> capability). Functions marked `@(export)` go the other way: the JS
// loader (web/batch.js) calls them. The host app must export two of them itself, `batch_main`
// and `batch_invoke_callback`: see the book's Chapter 8/7.
package batch
// ------------------------------------------------------------------------------------------------
// Handle references a live JS object (a canvas element, its 2D context, or a DOM event) stored in
// the JS-side handle table and addressed by integer id. WASM cannot hold a live JS reference, so
// everything crosses the boundary as an opaque u32. 0 is null / invalid.
Handle :: distinct u32
INVALID :: Handle(0)
// ------------------------------------------------------------------------------------------------
// Scratch buffer for receiving strings back from JS (e.g. event property values). A single global
// is safe on the wasm target: everything runs on one thread.
//
// GOTCHA: the string returned by get() points straight at this buffer. It is valid only until the
// NEXT call that touches scratch. Copy it into a local before making another such call.
@(private)
scratch: [4096]u8
// ------------------------------------------------------------------------------------------------
foreign import batch_env "batch_env"
@(default_calling_convention = "contextless")
foreign batch_env {
batch_get_element_by_id :: proc(id: string) -> Handle ---
batch_get_global :: proc(name: string) -> Handle ---
batch_canvas_create :: proc(parent: Handle, width, height: u32) -> Handle ---
batch_canvas_get_context :: proc(canvas: Handle) -> Handle ---
batch_start_animation_loop :: proc(cb_id: u32) ---
batch_add_event_listener :: proc(elem: Handle, event: string, cb_id: u32) ---
batch_log :: proc(msg: string) ---
batch_now :: proc() -> f64 ---
// Generic JS-value property/method bridge: lets event callbacks read fields off the raw
// browser Event object without a bespoke foreign proc per field.
batch_get_property_str :: proc(h: Handle, key: string, buf: []byte) -> int ---
batch_call_method0 :: proc(h: Handle, name: string) ---
}
// ------------------------------------------------------------------------------------------------
is_valid :: proc "contextless" (h: Handle) -> bool {
return h != INVALID
}
get_element_by_id :: proc "contextless" (id: string) -> Handle {
return batch_get_element_by_id(id)
}
// canvas_create creates a <canvas> of the given size, appends it to parent, and returns its handle.
canvas_create :: proc "contextless" (parent: Handle, width, height: int) -> Handle {
return batch_canvas_create(parent, u32(width), u32(height))
}
// canvas_get_context retrieves the "2d" rendering context: the handle you pass to flush().
canvas_get_context :: proc "contextless" (canvas: Handle) -> Handle {
return batch_canvas_get_context(canvas)
}
// start_animation_loop drives a requestAnimationFrame loop, invoking the host app's
// batch_invoke_callback dispatcher with cb_id on every frame.
start_animation_loop :: proc "contextless" (cb_id: u32) {
batch_start_animation_loop(cb_id)
}
// add_event_listener registers a DOM event listener on elem. cb_id identifies the callback to the
// host app's batch_invoke_callback dispatcher.
add_event_listener :: proc "contextless" (elem: Handle, event: string, cb_id: u32) {
batch_add_event_listener(elem, event, cb_id)
}
log :: proc "contextless" (msg: string) {
batch_log(msg)
}
// now returns milliseconds since page load (performance.now()).
now :: proc "contextless" () -> f64 {
return batch_now()
}
// get_window returns the browser's global `window` object: the usual target for keydown
// listeners, since keys aren't delivered to an unfocused canvas.
get_window :: proc "contextless" () -> Handle {
return batch_get_global("window")
}
// get retrieves a property's string form (e.g. a KeyboardEvent's "code").
//
// WARNING: the returned string points at a shared global scratch buffer, copy it if you need to
// retain the value across another call to get.
get :: proc "contextless" (h: Handle, key: string) -> string {
n := batch_get_property_str(h, key, scratch[:])
if n > len(scratch) {
n = len(scratch)
}
return string(scratch[:n])
}
// call_method calls a zero-argument, no-return-value method on the JS value (e.g.
// "preventDefault", "stopPropagation").
call_method :: proc "contextless" (h: Handle, name: string) {
batch_call_method0(h, name)
}
// ------------------------------------------------------------------------------------------------
// last_event holds a handle to the raw JS Event object for the callback currently being
// dispatched. web/batch.js calls batch_set_last_event() immediately before invoking a listener's
// callback, so within batch_invoke_callback you can read event fields via
// batch.get(batch.last_event_handle(), "code") etc.
@(private)
last_event: Handle = INVALID
last_event_handle :: proc "contextless" () -> Handle {
return last_event
}
@(export)
batch_set_last_event :: proc "contextless" (h: Handle) {
last_event = h
}
embers/src/main.odin
// main.odin: Embers' own wiring. Canvas bootstrap, the per-frame animation callback, keyboard
// input, and sequencing which `.mus` script is currently driving the arena, all thin, exactly
// the "thin main.odin, fat game/ package" convention the root CLAUDE.md names for every game in
// this monorepo, applied here to a book's own capstone instead of a full game/ package.
package main
import "base:runtime"
import batch "../batch"
import muspel "../../muspel"
foreign import app_env "app_env"
@(default_calling_convention = "contextless")
foreign app_env {
set_fps :: proc(fps: f32) ---
}
CANVAS_W :: ARENA_W
CANVAS_H :: ARENA_H
CB_TICK :: 0
KEY_LEFT :: 0
KEY_RIGHT :: 1
KEY_UP :: 2
KEY_DOWN :: 3
KEY_COUNT :: 4
KIND_GRUNT :: 0
KIND_SWARM :: 1
KIND_BOSS :: 2
KIND_STAR :: 3
KIND_PLANET :: 4
// Script_Runner is the one currently-active `.mus` script driving the arena: a single VM
// instance, resumed once per frame. Embers runs exactly one script at a time (a wave, then the
// next wave, then the boss), section 21.4's own honestly-scoped simplification of Chapter 17's
// full many-instances-at-once model: nothing here needs fifty concurrently scripted entities,
// one scripted *director* orchestrating many entities through spawn/move_to is enough to prove
// the same embedding end to end.
Script_Runner :: struct {
vm: muspel.VM,
active: bool,
}
runner: Script_Runner
ctx: batch.Handle
cmd: batch.Buffer
keys: [KEY_COUNT]bool
last_ms: f64
fps_frames: int
fps_accum: f32
@(export)
batch_main :: proc "c" () {
context = runtime.default_context()
canvas := batch.canvas_create(batch.get_element_by_id("app"), CANVAS_W, CANVAS_H)
ctx = batch.canvas_get_context(canvas)
init_game()
init_script_api()
init_scripts()
start_script(Playlist[0])
window := batch.get_window()
batch.add_event_listener(window, "keydown", CB_KEYDOWN)
batch.add_event_listener(window, "keyup", CB_KEYUP)
last_ms = batch.now()
batch.start_animation_loop(CB_TICK)
}
CB_KEYDOWN :: 1
CB_KEYUP :: 2
@(export)
batch_invoke_callback :: proc "c" (id: u32) {
context = runtime.default_context()
switch id {
case CB_TICK:
now := batch.now()
dt := f32((now - last_ms) / 1000.0)
last_ms = now
if dt > 0.05 do dt = 0.05
if dt < 0 do dt = 0
tick_script()
update_game(dt)
draw_game()
if dt > 0 {
fps_accum += 1.0 / dt
fps_frames += 1
if fps_frames >= 24 {
set_fps(fps_accum / f32(fps_frames))
fps_accum = 0
fps_frames = 0
}
}
case CB_KEYDOWN, CB_KEYUP:
code_str := batch.get(batch.last_event_handle(), "code")
code, ok := key_slot(code_str)
if ok {
keys[code] = id == CB_KEYDOWN
return
}
// Digit1-4 are a one-shot jump, not a held movement key, so they're handled here
// directly rather than through the keys[] array: pressing one restarts the runner
// straight into that playlist slot, the same start_script call tick_script's own
// advance-on-.Done path already makes, just triggered by a keypress instead of a
// finished script. keyup is ignored entirely; there's nothing to release.
if id == CB_KEYDOWN {
if slot, jump_ok := playlist_slot(code_str); jump_ok {
playlist_index = slot
start_script(Playlist[slot])
}
}
}
}
@(private = "file")
key_slot :: proc(code: string) -> (int, bool) {
switch code {
case "ArrowLeft", "KeyA":
return KEY_LEFT, true
case "ArrowRight", "KeyD":
return KEY_RIGHT, true
case "ArrowUp", "KeyW":
return KEY_UP, true
case "ArrowDown", "KeyS":
return KEY_DOWN, true
}
return 0, false
}
// playlist_slot maps Digit1-4 to a Playlist index, a dev-and-demo convenience so a reader (or
// this book's own screenshots) doesn't have to sit through the ~20s wave/wave/boss cycle just to
// reach whichever entry they actually want to look at; ยง21.4's fixed, single-runner-instance
// design means "jump" is always exactly a start_script call, never anything trickier.
@(private = "file")
playlist_slot :: proc(code: string) -> (int, bool) {
switch code {
case "Digit1": return 0, true
case "Digit2": return 1, true
case "Digit3": return 2, true
case "Digit4": return 3, true
case "Digit5": return 4, true
}
return 0, false
}
update_player_movement :: proc(dt: f32) {
speed :: f32(220)
if keys[KEY_LEFT] do game.player_x -= speed * dt
if keys[KEY_RIGHT] do game.player_x += speed * dt
if keys[KEY_UP] do game.player_y -= speed * dt
if keys[KEY_DOWN] do game.player_y += speed * dt
if game.player_x < 12 do game.player_x = 12
if game.player_x > ARENA_W - 12 do game.player_x = ARENA_W - 12
if game.player_y < 12 do game.player_y = 12
if game.player_y > ARENA_H - 12 do game.player_y = ARENA_H - 12
}
embers/src/game.odin
// game.odin: the small, non-scripted core Embers needs: an arena, a player, and the entity
// table every scripted wave and boss pattern spawns into and drives. Nothing here is a Muspel
// concept; script_api.odin is the entire boundary between this file and the scripts under
// scripts/. This chapter is about that boundary, not about game design, so this file stays as
// small as it can: one arena, one player, entities that move exactly where a script tells them
// to, and nothing resembling combat.
package main
import "core:math"
ARENA_W :: 480
ARENA_H :: 480
MAX_ENTITIES :: 64
// Entity is deliberately plain data: a script never gets a pointer to one of these, only ever an
// integer handle (its own `id`), section 16.3's handle rule carried into Embers' own script API.
Entity :: struct {
id: int,
alive: bool,
kind: int,
x, y: f32,
vx, vy: f32, // continuous drift, set by the `velocity` builtin
target_x, target_y: f32,
speed: f32, // px/sec toward target_x/target_y while moving == true
moving: bool,
color: string,
radius: f32,
spark_frames_left: int, // >0 draws a brief flash ring, set by the `emit` builtin
}
Game :: struct {
entities: [MAX_ENTITIES]Entity,
next_id: int,
player_x, player_y: f32,
orbit_time: f32, // elapsed seconds, draw.odin's own moon-orbit clock (KIND_PLANET only)
}
game: Game
init_game :: proc() {
game.player_x = ARENA_W / 2
game.player_y = ARENA_H - 40
}
// update_game advances every live entity by dt seconds: continuous drift (`velocity`) always
// applies, and a move_to target is approached at its own recorded speed, arriving exactly rather
// than overshooting past it. An entity that drifts outside the arena, with generous margin, is
// despawned; despawn_entity (script_api.odin) is what actually fires its on_death handler, if it
// registered one.
update_game :: proc(dt: f32) {
margin :: f32(40)
game.orbit_time += dt
for &e in game.entities {
if !e.alive do continue
e.x += e.vx * dt
e.y += e.vy * dt
if e.moving {
dx := e.target_x - e.x
dy := e.target_y - e.y
dist := math.sqrt(dx * dx + dy * dy)
step := e.speed * dt
if dist <= step || dist == 0 {
e.x, e.y = e.target_x, e.target_y
e.moving = false
} else {
e.x += dx / dist * step
e.y += dy / dist * step
}
}
if e.spark_frames_left > 0 {
e.spark_frames_left -= 1
}
if e.x < -margin || e.x > ARENA_W + margin || e.y < -margin || e.y > ARENA_H + margin {
despawn_entity(e.id)
}
}
update_player_movement(dt)
}
find_entity :: proc(id: int) -> (^Entity, bool) {
for &e in game.entities {
if e.alive && e.id == id do return &e, true
}
return nil, false
}
embers/src/draw.odin
// draw.odin: everything Embers puts on screen, recorded into one batched command buffer and
// flushed with a single WASM<->JS crossing per frame, exactly the discipline `batch` (copied
// from Yo Odin, credited, not re-derived) was built for.
package main
import "core:math"
import batch "../batch"
draw_game :: proc() {
batch.reset(&cmd)
batch.set_fill(&cmd, "#05070d")
batch.fill_rect(&cmd, 0, 0, ARENA_W, ARENA_H)
draw_entities()
draw_player()
batch.flush(ctx, &cmd)
}
@(private = "file")
draw_entities :: proc() {
for e in game.entities {
if !e.alive do continue
if e.kind == KIND_PLANET {
draw_planet(e)
continue
}
batch.set_fill(&cmd, e.color)
batch.begin_path(&cmd)
batch.arc(&cmd, e.x, e.y, e.radius, 0, 6.2832)
batch.fill(&cmd)
if e.spark_frames_left > 0 {
batch.set_stroke(&cmd, "#ffffff")
batch.set_line_width(&cmd, 2)
batch.begin_path(&cmd)
batch.arc(&cmd, e.x, e.y, e.radius + 6, 0, 6.2832)
batch.stroke(&cmd)
}
}
}
// draw_planet is entirely a host-side rendering detail keyed off kind == KIND_PLANET, exactly
// the pattern Appendix F names for Destroids' own polygon shapes: the script (planet.mus) only
// ever chose where this entity enters, where it's headed, and how fast; the ring and its moons
// are drawn here from nothing but the entity's own position/radius and game.orbit_time, no
// script involvement at all.
//
// The ring is one tilted ellipse (canvas has no native ellipse stroke, so `scale` inside a
// save/restore is the standard trick: squash a circular `arc` vertically, tilt the whole frame
// with `rotate`, draw, then restore before anything else assumes an unrotated, unscaled canvas).
// Stroking it once *before* the sphere and once more, thinner, *after*, is what sells "the ring
// passes behind the planet": the sphere's own fill overwrites the ring's back half, and the
// second, front-only arc redraws just the near half on top.
@(private = "file")
draw_planet :: proc(e: Entity) {
ring_rx := e.radius * 1.9
ring_ry := e.radius * 0.6
tilt := f32(-0.35)
batch.set_stroke(&cmd, "#c9a86a")
batch.set_line_width(&cmd, 3)
batch.save(&cmd)
batch.translate(&cmd, e.x, e.y)
batch.rotate(&cmd, tilt)
batch.scale(&cmd, 1, ring_ry / ring_rx)
batch.begin_path(&cmd)
batch.arc(&cmd, 0, 0, ring_rx, 0, 6.2832)
batch.stroke(&cmd)
batch.restore(&cmd)
batch.set_fill(&cmd, e.color)
batch.begin_path(&cmd)
batch.arc(&cmd, e.x, e.y, e.radius, 0, 6.2832)
batch.fill(&cmd)
batch.set_stroke(&cmd, "#f0dcb0")
batch.set_line_width(&cmd, 2)
batch.save(&cmd)
batch.translate(&cmd, e.x, e.y)
batch.rotate(&cmd, tilt)
batch.scale(&cmd, 1, ring_ry / ring_rx)
batch.begin_path(&cmd)
batch.arc(&cmd, 0, 0, ring_rx, 0.15, 3.14 - 0.15)
batch.stroke(&cmd)
batch.restore(&cmd)
draw_moon(e, 1.0, 0.7, e.radius * 2.6)
draw_moon(e, -1.4, 0.5, e.radius * 3.6)
}
// draw_moon places one small moon on a circular orbit around `e`: phase (radians) staggers
// multiple moons apart, speed (radians/sec) is how fast each one travels, and game.orbit_time
// (game.odin's own per-frame accumulator) is the one shared clock every moon reads, so they
// stay in smooth, continuous motion regardless of how long the planet itself has been alive.
@(private = "file")
draw_moon :: proc(e: Entity, phase, speed, orbit_radius: f32) {
angle := phase + game.orbit_time * speed
mx := e.x + math.cos(angle) * orbit_radius
my := e.y + math.sin(angle) * orbit_radius * 0.5
batch.set_fill(&cmd, "#dfe6f2")
batch.begin_path(&cmd)
batch.arc(&cmd, mx, my, 3, 0, 6.2832)
batch.fill(&cmd)
}
@(private = "file")
draw_player :: proc() {
batch.set_fill(&cmd, "#8fe3a0")
batch.begin_path(&cmd)
batch.move_to(&cmd, game.player_x, game.player_y - 12)
batch.line_to(&cmd, game.player_x - 10, game.player_y + 10)
batch.line_to(&cmd, game.player_x + 10, game.player_y + 10)
batch.close_path(&cmd)
batch.fill(&cmd)
}
embers/src/script_api.odin
// script_api.odin: the entire boundary between Embers and Muspel. Every builtin a `.mus` script
// can call is registered here, in one place, the same single-source-of-truth-table convention
// the `dinn` monorepo's own `TowerDefs`/`CarDefs` use for game data (root CLAUDE.md, credited):
// adding a new script-callable action means adding one native proc and one registration line
// here, nowhere else.
package main
import muspel "../../muspel"
// Death_Handler is a per-entity callback, deliberately its own small table rather than reusing
// muspel's own `vm.callbacks`: that table is keyed by a single, VM-wide event *name* (Chapter
// 15's `on("damage", fn)`), exactly wrong for "call me back when *this specific* entity dies."
// vm_call_value (muspel/vm.odin) is what makes storing a bare function Value here, instead of a
// name to look up, both possible and cheap: it's vm_fire_event's own machinery with the
// `vm.callbacks[name]` lookup step lifted out, added this chapter for exactly this reason.
Death_Handler :: struct {
entity_id: int,
fn: muspel.Value,
}
MAX_DEATH_HANDLERS :: MAX_ENTITIES
death_handlers: [MAX_DEATH_HANDLERS]Death_Handler
death_handler_count: int
// Script_Builtin is one entry in Embers' own script API: a name and the native proc behind it.
// Script_Builtins is the single source of truth both halves of registration read from: script
// API surface. compile_source (`muspel/stdlib.odin`) needs just the names, at resolve time, so a
// script calling `spawn` resolves cleanly; a fresh VM instance needs the names *and* the actual
// callable values, at run time, so calling `spawn` does something. Adding a new script-callable
// action means adding one native proc and one row here, nowhere else.
Script_Builtin :: struct {
name: string,
fn: muspel.Builtin,
}
Script_Builtins := [?]Script_Builtin {
{"spawn", native_spawn},
{"move_to", native_move_to},
{"velocity", native_velocity},
{"on_death", native_on_death},
{"emit", native_emit},
}
// Script_Builtin_Names is Script_Builtins' own names, the exact shape compile_source's
// `host_builtins` parameter expects: a package-level array, since a slice of a local one can't
// outlive the proc that would otherwise build it.
//
// Gotcha: this used to be filled in by an `@(init)` proc, the ordinary Odin way to run start-up
// code before `main` without an explicit call anywhere. It compiled cleanly, `odin check`
// reported nothing wrong, and it worked perfectly under `musc embers-check`, a native harness
// this chapter used early on to test compile_source against the bundled scripts before the WASM
// build existed at all. It failed the instant Embers actually loaded in a browser: `init_scripts`
// (scripts.odin) called `compile_or_panic`, which called `compile_source` with an empty
// `host_builtins` slice (every name still `""`), which meant `spawn`/`move_to`/... resolved as
// undefined globals, which meant every bundled script failed to compile, which tripped
// `compile_or_panic`'s own `assert`. The root cause: `@(init)` procs are invoked by
// `runtime._startup_runtime`, which only runs as part of the WASM `_start` entry point
// (`base:runtime/entry_wasm.odin`) that every game in this monorepo deliberately compiles
// *without*, via `-no-entry-point` (JS drives the module through `batch_main` and
// `batch_invoke_callback` instead, never through a WASM-native `_start`). No `_start` means no
// `_startup_runtime`, means no `@(init)` procs ever run at all: silent on native (where the
// ordinary entry point still exists and calls them), an outright runtime failure the moment the
// exact same code runs under `-target:js_wasm32 -no-entry-point`. The fix is `init_script_api`,
// below, called explicitly from `batch_main` before anything that needs
// `Script_Builtin_Names` populated, the same "the host calls it, nothing calls itself" discipline
// `batch_main`/`batch_invoke_callback` already use for every other piece of Embers' own startup.
@(private = "file")
Script_Builtin_Names: [len(Script_Builtins)]string
init_script_api :: proc() {
for b, i in Script_Builtins {
Script_Builtin_Names[i] = b.name
}
}
script_builtin_names :: proc() -> []string {
return Script_Builtin_Names[:]
}
// register_script_api binds every Embers builtin directly onto a fresh VM's own globals: the
// run-time half of registration, called once per playlist entry (scripts.odin's own
// start_script), mirroring `vm_register_native`'s "one call per builtin" shape from Chapter 16
// without needing a `Resolver` at all, since compile_source already resolved against
// `host_builtins` before this VM instance, or its chunk, existed.
register_script_api :: proc(vm: ^muspel.VM) {
for b in Script_Builtins {
vm.globals[b.name] = b.fn
}
}
// native_spawn creates a new arena entity and returns its id, section 16.3's handle convention:
// the script only ever holds this number back, never a pointer to the Entity itself.
@(private = "file")
native_spawn :: proc(vm: ^muspel.VM, args: []muspel.Value) -> muspel.Value {
kind, kind_ok := muspel.arg_number(args, 0)
x, x_ok := muspel.arg_number(args, 1)
y, y_ok := muspel.arg_number(args, 2)
if !kind_ok || !x_ok || !y_ok {
muspel.runtime_error(vm, "spawn expects (kind, x, y)")
return nil
}
for &e in game.entities {
if e.alive do continue
id := game.next_id
game.next_id += 1
e = Entity {
id = id,
alive = true,
kind = int(kind),
x = f32(x),
y = f32(y),
color = entity_color(int(kind)),
radius = entity_radius(int(kind)),
}
return f64(id)
}
muspel.runtime_error(vm, "spawn: arena is full")
return nil
}
// native_move_to starts a live entity moving toward (x, y) at speed px/sec; update_game
// (game.odin) is what actually advances it, one frame at a time. Any earlier `velocity` drift on
// the same entity is left running alongside it: a script that wants pure move_to motion should
// call `velocity(e, 0, 0)` first, an explicit choice rather than one builtin silently overriding
// the other.
@(private = "file")
native_move_to :: proc(vm: ^muspel.VM, args: []muspel.Value) -> muspel.Value {
handle, h_ok := muspel.arg_number(args, 0)
x, x_ok := muspel.arg_number(args, 1)
y, y_ok := muspel.arg_number(args, 2)
speed, s_ok := muspel.arg_number(args, 3)
if !h_ok || !x_ok || !y_ok || !s_ok {
muspel.runtime_error(vm, "move_to expects (entity, x, y, speed)")
return nil
}
e, found := find_entity(int(handle))
if !found {
muspel.runtime_error(vm, "move_to: no entity %d", int(handle))
return nil
}
e.target_x, e.target_y = f32(x), f32(y)
e.speed = f32(speed)
e.moving = true
return nil
}
// native_velocity sets (or clears, with zero) an entity's continuous per-frame drift: a boss's
// slow sideways sway, or a swarm enemy's constant downward fall, without a script having to
// re-issue `move_to` every frame to fake the same thing.
@(private = "file")
native_velocity :: proc(vm: ^muspel.VM, args: []muspel.Value) -> muspel.Value {
handle, h_ok := muspel.arg_number(args, 0)
dx, dx_ok := muspel.arg_number(args, 1)
dy, dy_ok := muspel.arg_number(args, 2)
if !h_ok || !dx_ok || !dy_ok {
muspel.runtime_error(vm, "velocity expects (entity, dx, dy)")
return nil
}
e, found := find_entity(int(handle))
if !found {
muspel.runtime_error(vm, "velocity: no entity %d", int(handle))
return nil
}
e.vx, e.vy = f32(dx), f32(dy)
return nil
}
// native_on_death registers `fn` to be called, with no arguments, the moment `entity` despawns
// (game.odin's own update_game, on leaving the arena). Muspel has no closures capturing mutable
// state (section 12.4's own honest scope cut), so a handler that needs to know *which* entity
// died reads it from wherever the script itself already tracked it, exactly the same shape any
// event-driven callback in this book has had since Chapter 17.
@(private = "file")
native_on_death :: proc(vm: ^muspel.VM, args: []muspel.Value) -> muspel.Value {
handle, h_ok := muspel.arg_number(args, 0)
if !h_ok || len(args) < 2 {
muspel.runtime_error(vm, "on_death expects (entity, function)")
return nil
}
if death_handler_count >= MAX_DEATH_HANDLERS {
muspel.runtime_error(vm, "on_death: too many pending handlers")
return nil
}
death_handlers[death_handler_count] = Death_Handler{entity_id = int(handle), fn = args[1]}
death_handler_count += 1
return nil
}
// native_emit is Embers' own smallest possible "something happened here" signal: a brief flash
// ring drawn at the entity's own position for a handful of frames (draw.odin), standing in for
// whatever a real weapon/particle system would eventually do. `kind` is accepted but unused
// today, room for a future reader to grow this into more than one visual without changing the
// script-facing signature.
@(private = "file")
native_emit :: proc(vm: ^muspel.VM, args: []muspel.Value) -> muspel.Value {
handle, h_ok := muspel.arg_number(args, 0)
if !h_ok {
muspel.runtime_error(vm, "emit expects (entity, kind)")
return nil
}
e, found := find_entity(int(handle))
if !found {
muspel.runtime_error(vm, "emit: no entity %d", int(handle))
return nil
}
e.spark_frames_left = 12
return nil
}
// despawn_entity is the one place an entity actually leaves the arena: called from update_game
// once it's drifted past the bounds. Firing its own on_death handler, if it registered one, and
// removing that handler afterward (a dead entity's own callback firing twice, on some later
// entity that happens to reuse the same id, would be a real bug) both happen here.
despawn_entity :: proc(id: int) {
e, found := find_entity(id)
if !found do return
e.alive = false
for i := 0; i < death_handler_count; i += 1 {
if death_handlers[i].entity_id != id do continue
fn := death_handlers[i].fn
death_handler_count -= 1
death_handlers[i] = death_handlers[death_handler_count]
if runner.active {
call, result, ok := muspel.vm_call_value(&runner.vm, fn)
// A handler that itself calls `wait` is realistic (a death animation cue, say); this
// chapter's own runner drains it to completion immediately rather than tracking it
// as a separately-resumable ticket, the same honestly-scoped limitation section
// 15.1's own Design decision already accepted for concurrently-fired callbacks.
for ok && result == .Suspended {
result = muspel.vm_resume_call(&runner.vm, call)
}
}
return
}
}
@(private = "file")
entity_color :: proc(kind: int) -> string {
switch kind {
case KIND_BOSS:
return "#e0577a"
case KIND_SWARM:
return "#f2c14e"
case KIND_STAR:
return "#aab8d6"
case KIND_PLANET:
return "#e3c79a"
case:
return "#5ab4f2"
}
}
@(private = "file")
entity_radius :: proc(kind: int) -> f32 {
switch kind {
case KIND_BOSS:
return 22
case KIND_SWARM:
return 6
case KIND_STAR:
return 2
case KIND_PLANET:
return 24
case:
return 9
}
}
embers/src/scripts.odin
// scripts.odin: which `.mus` file is currently driving the arena, and Embers' own half of live
// reload. Chapter 19's own Watched_Script (tools/musc/hotreload.odin) polls a real file's mtime
// through core:os; none of that exists on `-target:js_wasm32` (muspel/compile_source, added this
// chapter, is the portable half that survived the split). A browser host needs its own way to
// notice an edit, and "the host owns its own I/O, muspel/ just compiles what it's handed" is
// exactly the boundary Chapter 16 already drew: this file is a browser-shaped Watched_Script,
// not a second copy of the native one.
package main
import "base:runtime"
import "core:strings"
import batch "../batch"
import muspel "../../muspel"
Playlist_Entry :: struct {
name: string,
chunk: ^muspel.Chunk,
}
// Playlist is the fixed running order: wave, wave, boss, starfield, planet, then back to the
// start. Each entry's `chunk` is compiled once at startup from the copy baked into the binary at
// compile time (#load, below), then possibly swapped later by embers_reload_script, exactly the
// way Watched_Script.chunk is swapped natively: a pointer reassignment, never an edit to the
// Chunk an already-running VM instance might still be pointing at.
Playlist: [5]Playlist_Entry
@(private = "file")
Wave_01_Source := #load("../scripts/wave_01.mus", string)
@(private = "file")
Wave_02_Source := #load("../scripts/wave_02.mus", string)
@(private = "file")
Boss_Ashling_Source := #load("../scripts/boss_ashling.mus", string)
@(private = "file")
Starfield_Source := #load("../scripts/starfield.mus", string)
@(private = "file")
Planet_Source := #load("../scripts/planet.mus", string)
playlist_index: int
init_scripts :: proc() {
Playlist[0] = Playlist_Entry{name = "wave_01.mus", chunk = compile_or_panic(Wave_01_Source)}
Playlist[1] = Playlist_Entry{name = "wave_02.mus", chunk = compile_or_panic(Wave_02_Source)}
Playlist[2] = Playlist_Entry{name = "boss_ashling.mus", chunk = compile_or_panic(Boss_Ashling_Source)}
Playlist[3] = Playlist_Entry{name = "starfield.mus", chunk = compile_or_panic(Starfield_Source)}
Playlist[4] = Playlist_Entry{name = "planet.mus", chunk = compile_or_panic(Planet_Source)}
}
@(private = "file")
compile_or_panic :: proc(src: string) -> ^muspel.Chunk {
chunk := new(muspel.Chunk)
ok := muspel.compile_source(chunk, src, script_builtin_names())
assert(ok, "Embers' own bundled scripts must compile; this is a build-time invariant, not a runtime one")
return chunk
}
start_script :: proc(entry: Playlist_Entry) {
runner.vm = muspel.vm_init()
register_script_api(&runner.vm)
muspel.vm_push_script(&runner.vm, entry.chunk)
runner.active = true
death_handler_count = 0
}
// tick_script drives the current playlist entry exactly one simulated frame; once it's `.Done`
// (or `.Error`, Chapter 18's own graceful failure, logged and moved past rather than freezing
// the arena), the next entry starts, wrapping back to the beginning after the boss.
tick_script :: proc() {
if !runner.active do return
result := muspel.vm_resume(&runner.vm)
if result == .Suspended do return
if result == .Error {
batch.log("script error, advancing to next entry")
}
playlist_index = (playlist_index + 1) % len(Playlist)
start_script(Playlist[playlist_index])
}
// embers_reload_script is called by index.html once per fetched script, byte length only: the
// text itself was already copied into script_recv_buf (embers_script_buffer_ptr gives JS that
// buffer's own address to write into). Returns 1 on a successful recompile that actually swapped
// the chunk, 0 otherwise (too large, or a compile error), so the JS side can log which.
//
// Gotcha, the same one Chapter 19 already hit once: `src` must be cloned, never used as a slice
// straight into script_recv_buf. That buffer is reused, overwritten, on every future poll; a
// compiled chunk's own string constants slicing into it directly would go stale (or worse,
// silently start reading a *different* script's bytes) the moment the next fetch lands. Cloning
// once here and never freeing the clone is the same deliberate leak Chapter 19's own
// watched_script_load already accepted, for the same reason.
@(export)
embers_script_buffer_ptr :: proc "contextless" () -> [^]byte {
return &script_recv_buf[0]
}
@(private = "file")
script_recv_buf: [16384]byte
@(export)
embers_reload_script :: proc "c" (slot: u32, length: u32) -> u32 {
context = runtime.default_context()
if int(slot) >= len(Playlist) do return 0
if int(length) > len(script_recv_buf) do return 0
src := strings.clone(string(script_recv_buf[:length]))
new_chunk := new(muspel.Chunk)
if !muspel.compile_source(new_chunk, src, script_builtin_names()) do return 0
Playlist[slot].chunk = new_chunk
return 1
}
embers/server/serve.odin
// serve.odin: a tiny static-file HTTP/1.1 development server, in pure Odin.
//
// This is the server the book uses to load the game over HTTP. It is DELIBERATELY minimal: a
// development tool, not a production server; single-threaded, one request per connection, no
// keep-alive, no chunking, no compression. That keeps it short enough to read in one sitting.
//
// Why we need it at all: the browser blocks fetch()/instantiateStreaming() of a .wasm file over a
// file:// URL with a CORS error, so the page MUST be served over HTTP. And crucially, the .wasm
// must go out with `Content-Type: application/wasm`, or streaming instantiation refuses it.
//
// Build (native, NOT wasm): odin build serve -out:serve
// Run: ./serve # serves ./dist on http://localhost:9000
package main
import "core:fmt"
import "core:net"
import "core:os"
import "core:strings"
ROOT :: "dist" // directory served, relative to where you run the binary
PORT :: 9000
// SCRIPTS_ROOT is where `/scripts/*` requests are read from instead of ROOT. `./serve` runs with
// its working directory at embers/, the same level as both dist/ and scripts/, so "." resolves
// "/scripts/wave_01.mus" to "./scripts/wave_01.mus", the project's own source file, not
// dist/scripts. dist/scripts is a real, self-contained copy build.sh makes for deployment (so
// uploading dist/ alone works on a real server), but that copy is exactly what section 21.5's own
// live reload can't use locally: it goes stale the moment scripts/wave_01.mus is edited, until
// the next build.sh run recopies it. Reading straight from SCRIPTS_ROOT means an edit to the real
// file is visible on the very next poll, no rebuild step, no copy, no symlink.
SCRIPTS_ROOT :: "."
main :: proc() {
endpoint := net.Endpoint {
address = net.IP4_Address{0, 0, 0, 0}, // all interfaces
port = PORT,
}
listen, lerr := net.listen_tcp(endpoint)
if lerr != nil {
fmt.eprintfln("could not listen on port %d: %v", PORT, lerr)
os.exit(1)
}
defer net.close(listen)
fmt.printfln("Serving ./%s at http://localhost:%d (Ctrl-C to stop)", ROOT, PORT)
for {
client, _, aerr := net.accept_tcp(listen)
if aerr != nil {
fmt.eprintfln("accept failed: %v", aerr)
continue
}
handle_connection(client)
net.close(client)
}
}
// handle_connection reads one request, serves one file, and returns. No keep-alive.
handle_connection :: proc(client: net.TCP_Socket) {
buf: [4096]u8
n, rerr := net.recv_tcp(client, buf[:])
if rerr != nil || n == 0 {
return
}
path := request_path(string(buf[:n]))
if path == "" {
send_status(client, 400, "Bad Request")
return
}
// Map "/" to the index, then to a file under ROOT. Reject any path that tries to escape ROOT
// with "..", so a request can never read files outside the served directory.
if path == "/" {
path = "/index.html"
}
if strings.contains(path, "..") {
send_status(client, 403, "Forbidden")
return
}
root := ROOT
if strings.has_prefix(path, "/scripts/") {
root = SCRIPTS_ROOT
}
full := strings.concatenate({root, path})
defer delete(full)
data, derr := os.read_entire_file_from_path(full, context.allocator)
if derr != nil {
send_status(client, 404, "Not Found")
return
}
defer delete(data)
send_file(client, content_type(path), data)
}
// request_path extracts the path from the first line of an HTTP request: "GET /foo.js HTTP/1.1".
request_path :: proc(req: string) -> string {
// First line ends at the first CR or LF.
line := req
if i := strings.index_any(req, "\r\n"); i >= 0 {
line = req[:i]
}
// Split "METHOD SP PATH SP VERSION" and take the middle field.
parts := strings.fields(line)
defer delete(parts)
if len(parts) < 2 {
return ""
}
path := parts[1]
// Strip a query string before it ever reaches a file lookup. This server predates section
// 18.5's own live-reload polling loop, which cache-busts every fetch with `?t=<timestamp>`
// so the browser never serves a stale .mus file from its own HTTP cache; without stripping
// it here, every single reload poll 404s, since "wave_01.mus?t=1785332624744" is never a
// real file on disk. Caught only by actually loading Embers in a browser and reading the
// console, not by anything odin check or musc's own commands could have found: this server
// package has no tests of its own and nothing else in this book ever sends it a request with
// a query string.
if i := strings.index_byte(path, '?'); i >= 0 {
path = path[:i]
}
return path
}
// content_type picks a MIME type from the file extension. The application/wasm entry is the one
// that matters for the game itself; .mus matters for Chapter 19/18's own live reload, section
// 18.5's own fetch()-polling loop wants readable script text back, not a downloaded attachment.
content_type :: proc(path: string) -> string {
switch {
case strings.has_suffix(path, ".html"):
return "text/html; charset=utf-8"
case strings.has_suffix(path, ".js"):
return "text/javascript; charset=utf-8"
case strings.has_suffix(path, ".css"):
return "text/css; charset=utf-8"
case strings.has_suffix(path, ".wasm"):
return "application/wasm"
case strings.has_suffix(path, ".json"):
return "application/json; charset=utf-8"
case strings.has_suffix(path, ".mus"):
return "text/plain; charset=utf-8"
case:
return "application/octet-stream"
}
}
// send_file writes a 200 response with the given content type and body.
send_file :: proc(client: net.TCP_Socket, ctype: string, body: []u8) {
header := fmt.tprintf(
"HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",
ctype,
len(body),
)
net.send_tcp(client, transmute([]u8)header)
net.send_tcp(client, body)
}
// send_status writes a tiny plain-text error response.
send_status :: proc(client: net.TCP_Socket, code: int, text: string) {
body := fmt.tprintf("%d %s\n", code, text)
resp := fmt.tprintf(
"HTTP/1.1 %d %s\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
code,
text,
len(body),
body,
)
net.send_tcp(client, transmute([]u8)resp)
}
embers/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Embers: a game entirely scripted by Muspel</title>
<meta name="theme-color" content="#05070d">
<style>
:root { color-scheme: dark; }
html, body {
margin: 0;
height: 100%;
background: #05070d;
color: #cdd7ea;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
#game-hero {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 100vh;
overflow: hidden;
}
#app canvas {
display: block;
aspect-ratio: 1 / 1;
width: min(90vw, 84vh);
height: auto;
border: 1px solid #1b2436;
box-shadow: 0 0 40px rgba(60, 120, 220, 0.15);
}
footer {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
letter-spacing: 0.04em;
color: #7f8ba6;
max-width: 84vh;
}
footer .row { display: flex; gap: 20px; align-items: center; justify-content: center; }
footer .fps { color: #7fe0a0; min-width: 92px; }
footer .keys { color: #5c6884; }
footer #reload-log { color: #4b5670; font-size: 12px; min-height: 1.4em; }
</style>
</head>
<body>
<div id="game-hero">
<!-- The batch library appends its <canvas> inside this element (see batch_main). -->
<div id="app"></div>
<footer>
<div class="row">
<span class="fps" id="fps">FPS: -</span>
<span class="keys">Arrow keys / WASD to move · 1-5 to jump to a playlist entry</span>
</div>
<div class="row" id="reload-log">watching scripts/ ...</div>
</footer>
</div>
<!-- The batch runtime + bridge, copied from Yo Odin (credited, not re-derived). Defines
globalThis.Batch. -->
<script src="./batch.js"></script>
<script>
"use strict";
const fpsEl = document.getElementById("fps");
const logEl = document.getElementById("reload-log");
// Section 21.5's own live-reload bridge: `core:os` doesn't exist on -target:js_wasm32 at
// all, so unlike Chapter 19's musc --watch (a real file poll via core:os), a browser host has
// to notice an edit itself. This is the entire mechanism: fetch every script on an interval,
// write its bytes into the WASM module's own linear memory at the address
// embers_script_buffer_ptr() reports, and call embers_reload_script(slot, length). Odin does
// the rest (muspel.compile_source), exactly the same compile-and-swap Watched_Script already
// does natively.
const SCRIPTS = ["scripts/wave_01.mus", "scripts/wave_02.mus", "scripts/boss_ashling.mus", "scripts/starfield.mus", "scripts/planet.mus"];
const POLL_MS = 1500;
const encoder = new TextEncoder();
function pollScripts(exports) {
const bufPtr = exports.embers_script_buffer_ptr();
const memory = exports.memory;
setInterval(async () => {
for (let slot = 0; slot < SCRIPTS.length; slot++) {
try {
const res = await fetch(SCRIPTS[slot] + "?t=" + Date.now());
if (!res.ok) continue;
const text = await res.text();
const bytes = encoder.encode(text);
if (bytes.length > 16384) {
logEl.textContent = SCRIPTS[slot] + ": too large to reload (>16KB)";
continue;
}
new Uint8Array(memory.buffer, bufPtr, bytes.length).set(bytes);
const ok = exports.embers_reload_script(slot, bytes.length);
if (ok) {
logEl.textContent = SCRIPTS[slot] + ": reloaded " + new Date().toLocaleTimeString();
} else {
logEl.textContent = SCRIPTS[slot] + ": compile error, still serving the last-good version";
}
} catch (err) {
// A transient fetch failure just skips this poll; the last-good chunk keeps running.
}
}
}, POLL_MS);
}
// Unlike Yo Odin's own Destroids (a bespoke destroids_key export, called directly from a
// JS-side keydown/keyup listener), Embers uses batch's own generic add_event_listener
// mechanism instead, registered from Odin itself (main.odin's own batch_main): no JS-side
// key handling is needed here at all, `key_slot` (main.odin) does the mapping from a raw
// KeyboardEvent's own "code" string.
Batch.instantiate("embers.wasm", {
app_env: {
set_fps: (fps) => { fpsEl.textContent = "FPS: " + Math.round(fps); },
},
}).then((exports) => {
pollScripts(exports);
}).catch((err) => {
console.error("Failed to load Embers:", err);
});
</script>
</body>
</html>
embers/build.sh
#!/usr/bin/env bash
# build.sh: compile Embers to WebAssembly and assemble dist/.
#
# Output:
# dist/embers.wasm the game
# dist/batch.js the renderer runtime + bridge (copied from Yo Odin, unmodified)
# dist/index.html the page
# dist/scripts/ a real copy of scripts/, so dist/ is self-contained and deployable
# on its own. The dev server (server/serve.odin) serves /scripts/*
# straight from the project's own scripts/ directory instead of from
# dist/, so section 21.5's live reload never depends on this copy at all.
set -euo pipefail
cd "$(dirname "$0")"
mkdir -p dist
echo "==> Compiling Odin -> WebAssembly ..."
odin build src -out:dist/embers.wasm -target:js_wasm32 -no-entry-point -o:size
echo "==> Copying assets ..."
cp web/batch.js dist/batch.js
cp index.html dist/index.html
rm -rf dist/scripts
cp -r scripts dist/scripts
echo "==> Done. dist/ contains:"
ls -lh dist/ dist/scripts/
WASM_BYTES=$(wc -c < dist/embers.wasm)
if [ "$WASM_BYTES" -gt $((5 * 1024 * 1024)) ]; then
echo "WARNING: embers.wasm is $((WASM_BYTES / 1024))KB, over the 5MB soft budget"
else
echo "==> embers.wasm: $((WASM_BYTES / 1024))KB"
fi
embers/run.sh
#!/usr/bin/env bash
# run.sh: build Embers, build the dev server, then serve dist/ at http://localhost:9000.
#
# Edit any file under scripts/ and save while a game is loaded in the browser: section 21.5's
# own live-reload bridge re-fetches it every 1.5s and recompiles it in place, no rebuild, no
# refresh needed.
set -euo pipefail
cd "$(dirname "$0")"
bash build.sh
echo "==> Building the dev server (native) ..."
odin build server -out:serve -o:size
echo ""
echo "=== Starting server ==="
./serve # serves ./dist on http://localhost:9000 (Ctrl-C to stop)