vextoly/cryn

Cryn 0.1.0: bootstrap compiler, tests, examples, documentation

Commit 90a2d5c · Vextoly · 2026-10-04T15:35:54+02:00

Changeset
90a2d5c8bf7ac26f12eab6589bd1183ec94ad539

View source at this commit

Cryn 0.1.0: bootstrap compiler, tests, examples, documentation

- 34-command English-worded compiled systems language
- lexer/parser/AST/semantic analysis/IR/optimizer/C backend in C11
- static type system via abstract machine interpretation
- runtime embedded into generated C (alloc, threads, streams, output)
- 35-test suite incl. recursion, memory, files, threads, wordcount,
  sort, allocator; 7 example programs
- README.md, LANGUAGE.md, TUTORIAL.md

Comments

No comments yet.

Log in to comment

Diff

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f265a3c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,22 @@
+# build artifacts
+cryn
+tools/embed
+src/rt_src.h
+*.o
+*.a
+*.so
+
+# test/run artifacts
+tests/cryn_test_*.txt
+*.exe
+
+# generated compiler output
+out.c
+*.c.gen.c
+
+# editors and OS
+.vscode/
+.idea/
+*.swp
+*~
+.DS_Store
diff --git a/LANGUAGE.md b/LANGUAGE.md
new file mode 100644
index 0000000..26e9950
--- /dev/null
+++ b/LANGUAGE.md
@@ -0,0 +1,439 @@
+# The Cryn Programming Language Reference
+
+This document defines Cryn precisely enough for an independent
+implementation. It describes the language implemented by the `cryn`
+compiler (bootstrap version).
+
+Cryn is a compiled, statically typed systems language. Its execution
+model is a small abstract machine consisting of **cells**, named and
+indexed **connections**, a **focus**, a **working value**, a **value
+stack**, and a **comparison state** inside each **execution context**,
+plus a shared **world** of cells.
+
+---
+
+## 1. Lexical rules
+
+### 1.1 Source encoding
+
+Source files are byte streams. Tokens are formed from ASCII words,
+digits, and the delimiters below. Non-ASCII bytes may appear only inside
+string and character literals and comments.
+
+### 1.2 Whitespace
+
+Space, tab, carriage return, and newline separate tokens and have **no
+semantic meaning**. Any two programs with the same token sequence are the
+same program, regardless of layout.
+
+### 1.3 Comments
+
+A comment starts with the exact sequence `[#` and ends with the exact
+sequence `#]`, possibly spanning lines. Comments nest nothing, produce no
+tokens, and are the only comment syntax. An unterminated comment is a
+compile error. `#` or `]` alone are not comment delimiters; outside
+strings they are invalid characters.
+
+### 1.4 Identifiers and keywords
+
+An identifier is `[A-Za-z_][A-Za-z0-9_]*`. Identifiers containing hyphens
+do not exist; hyphens appear only in the two condition keywords
+`less-or-equal` and `greater-or-equal`.
+
+All of the following are reserved words and cannot be identifiers:
+
+* commands: `move back home address make free read write set add subtract
+  multiply divide remainder negate push pop swap compare convert jump call
+  return stop input output open close system spawn wait define end mark`
+* type words: `nothing bool char int8 int16 int32 int64 uint8 uint16
+  uint32 uint64 float32 float64 address array string transform`
+* value words: `true false`
+* argument words: `of when result index at text both less equal greater
+  different less-or-equal greater-or-equal`
+
+### 1.5 Literals
+
+* **Integer:** decimal digits or `0x` hex digits, optionally preceded by
+  `-`. Range: magnitude must fit in `uint64`; the value range of a use is
+  checked contextually (see §7).
+* **Float:** `digits [. digits] [e[+|-]digits]`, optionally negated;
+  IEEE 754 double precision.
+* **Character:** `'a'`, `'\n'`, `'\t'`, `'\r'`, `'\0'`, `'\\'`, `'\''`.
+  A character literal denotes its byte value (0–255).
+* **String:** `"..."` with the same escapes; may contain any other byte.
+  Strings are NUL-terminated byte arrays (§5.4); therefore string
+  literals cannot contain `\0`.
+* **Boolean:** `true`, `false`. **Void:** `nothing`.
+
+### 1.6 Locations
+
+Diagnostics carry 1-based line and column positions.
+
+## 2. Program structure
+
+```text
+program    := { define | worldmake }
+worldmake  := "make" ( basetype name
+                     | "array" int "of" type name
+                     | "string" stringlit name )
+define     := "define" name params [ "result" type ] { command } "end"
+params     := { paramtype name }
+```
+
+A program is a set of transformations and an optional world model (shared
+cells created by top-level `make`). Exactly one transformation named
+`main` with zero parameters must exist; execution starts there.
+
+Parameter lists are `{ type name }` pairs. They end at the first token
+that cannot begin a parameter; `address` only opens a parameter when a
+pointee type follows it. This makes bodies unambiguous without
+punctuation.
+
+## 3. Types
+
+| Type            | Meaning                                   | Size |
+|-----------------|-------------------------------------------|------|
+| `nothing`       | absence of a value                        | –    |
+| `bool`          | `false` / `true`, stored as 0/1           | 1    |
+| `char`          | byte value 0–255 (codepoint in v1)        | 4    |
+| `int8..int64`   | two's-complement signed integers          | 1/2/4/8 |
+| `uint8..uint64` | unsigned integers                         | 1/2/4/8 |
+| `float32/64`    | IEEE 754 binary32/64                      | 4/8  |
+| `address T`     | address of a cell/memory of type `T`; `address` alone means `address nothing` | 8 |
+| `array N of T`  | fixed array of N T (N ≥ 1)                | N·sizeof(T) |
+| `array of T`    | unsized array; only as the pointee of `address`, produced by dynamic `make` | element-sized |
+| `transform`     | reference to a transformation             | 16 (ref) |
+
+Arrays cannot be parameters or results; pass an address. Unsized arrays
+cannot be parameters, results, or cells; they exist only behind
+addresses.
+
+The static type system also has an internal *integer literal* type that
+carries a literal's value; it is not writable in source.
+
+### 3.1 Type equality
+
+Two types are equal iff constructed identically. Array sizes are part of
+the type.
+
+## 4. The machine
+
+### 4.1 World and cells
+
+A **cell** has identity, a type, and an optional value. Cells are:
+
+* **Frame cells**: a transformation's parameters and every named cell
+  made with `make ... name` in its body. They belong to the frame
+  (automatic storage): they are created zeroed each time the `make`
+  executes and live until the frame returns. Taking their address is
+  legal but the address is only valid during the frame's lifetime.
+* **World cells**: created by top-level `make`; zero-initialized at
+  program start; shared by all execution contexts.
+* **Heap blocks**: created by dynamic `make` (§6.3); exist until `free`.
+
+Two cells with equal values remain distinct cells.
+
+### 4.2 Connections
+
+Cells connect to other cells by **key**: a name (frame and world cells;
+each frame connects to all world cells) or an integer index (arrays and
+blocks, indices `0..N-1`). `move` follows connections. Linked structures
+are built by storing address values in cells.
+
+### 4.3 Execution context
+
+Each running transformation invocation is an execution context holding:
+
+* a program counter,
+* the **focus**: a stack of cell references, bottom entry being the
+  frame root,
+* the **working value** (one typed value; initially `nothing`),
+* the **value stack** (unbounded stack of typed values),
+* the **comparison state** (invalid until a `compare`),
+* the current input/output streams (stdin/stdout until `open`),
+* spawn state for `wait`.
+
+Contexts share world cells and heap blocks and nothing else. Focus,
+working value, value stack, and streams are per context.
+
+### 4.4 Focus
+
+The focus identifies the currently selected cell. Its abstract state is a
+stack:
+
+* `move name` **anchors**: the history is reset to `[frame, cell]`.
+* `move <index>`, `move index`, `move at` **push** the previous focus
+  onto the history and focus the target.
+* `back` pops one relative entry (error if only `[frame]` remains — at
+  runtime it restores the frame root focus when the history is empty at
+  the current frame level is impossible; the checker guarantees it).
+* `home` resets to `[frame]`.
+
+`read` and `write` operate on the focused cell; `address` (no argument)
+yields its address.
+
+### 4.5 Working value
+
+One typed value. Set by `set`, `read`, `pop`, arithmetic, `input`,
+`open`, `close`, `system`, `wait`, calls (see §8), conversions. It is
+also the **return value** of a transformation: at `return` (or implicit
+return at `end`) the working value's type must be identical at every
+return point of the transformation; that type is the transformation's
+result type. A call to a transformation whose result is `nothing`
+preserves the caller's working value; any other result replaces it.
+
+Integer literals adapt: a literal working value may be written into,
+compared with, or passed to any integer/char/bool/float context it fits
+(or any float context) without `convert`; otherwise `convert` is required.
+
+### 4.6 Value stack
+
+Holds typed values for preservation across navigation and for argument
+passing. The checker tracks the stack shape at every program point; all
+paths joining at a label must have identical stack depth and slot types,
+the working value type must join (identical, or literal widening), and
+the focus stack must join. The stack must be empty at `return`.
+
+## 5. Values and their semantics
+
+### 5.1 Integers
+
+Two's complement; signed overflow wraps (defined); unsigned overflow
+wraps modulo 2^N.
+
+### 5.2 Division
+
+* Division or remainder by zero: the program aborts with a runtime
+  diagnostic and exit status 1.
+* Signed `INT_MIN / -1` yields `INT_MIN`; `INT_MIN % -1` yields `0`
+  (defined wrap).
+* Truncation is toward zero (C semantics).
+
+### 5.3 Floats
+
+IEEE 754 binary32/binary64 with round-to-nearest-even. Operations on
+`float32` operands compute in single precision. Division by zero yields
+infinities/NaN per IEEE. **NaN comparison:** for `compare`, NaN yields
+`equal` (and not `different`, `less`, or `greater`).
+
+### 5.4 Strings
+
+A string literal used with `make string` creates a byte array of
+length+1 with a terminating NUL. Strings have no other magic; see §7
+`output text` and §6.3 for address-based access.
+
+### 5.5 Addresses
+
+Addresses are 8-byte values. `address nothing` is the type of `null`
+(produce with `set 0; convert address`). `add`/`subtract` on
+`address T` with an integer scales by `sizeof(T)` (for `address array of
+T`, by `sizeof(T)`). `subtract` between two `address T` (same or any
+pointee in v1) yields the element distance as `int64`. Addresses compare
+as unsigned 64-bit integers. Converting between `int64`/`uint64` and any
+address type reinterprets bits.
+
+### 5.6 Conversions (`convert`)
+
+Between any scalar numeric/bool/char/address types, with these rules:
+
+* int → int / char: wrap modulo 2^N.
+* int/uint → float: round to nearest (IEEE).
+* float → int: truncate toward zero; if out of target range, **saturate
+  at the target width** (`float32 1e30 → int32` gives `2147483647`).
+* float32 ↔ float64: round.
+* anything numeric → bool: 0 → `false`, else `true`.
+* char ↔ int: codepoint value.
+* address ↔ int64/uint64 / other address types: bit reinterpretation.
+
+Conversions to or from `nothing`, `transform`, or arrays are errors.
+
+## 6. Commands
+
+34 commands total.
+
+### 6.1 Navigation
+
+| Command | Effect |
+|---|---|
+| `move name` | Focus the frame's cell `name` (params, local cells, world cells). Anchors the focus history to `[frame, cell]`. |
+| `move i` | From an array/block focus, focus element `i` (literal). Compile-time bounds check. Pushes focus. |
+| `move index` | As above, using the working value as index (any integer type; interpreted as unsigned). Runtime bounds check. Working value unchanged. |
+| `move at` | Focus the cell at the address in the working value (`address T` → cell of `T`; `address array of T` → the block). Pushes focus. |
+| `back` | Undo the last relative move. |
+| `home` | Reset focus to `[frame]`. |
+
+### 6.2 Cells and addresses
+
+| Command | Effect |
+|---|---|
+| `make T name` | Create/zero frame cell `name` of type `T`. |
+| `make T` | Create an anonymous heap cell of `T`; working value := its address (`address T`). |
+| `make array N of T name` / `make array N of T` | Frame array / anonymous heap array. |
+| `make string "s" name` | Frame byte array with the bytes and NUL. |
+| `make string "s"` | Working value := address of a static NUL-terminated byte array. |
+| `make n of T` / `make 8 of T` / `make n of T p` | Heap block of n elements (n from an integer cell or literal); working value := `address array of T`; optional `p` additionally binds a frame cell of that type. |
+| `free` | Free the heap block whose address is in the working value. Aborts if the address is not a heap block. |
+| `read` | Working value := value of focused cell. |
+| `write` | Focused cell := working value (literal adaptation applies; else types must match exactly or `convert` first). |
+| `address` | Working value := `address T` of the focused cell. |
+| `address name` | Working value := address of frame/world cell `name`, or (for a transformation name) a `transform` reference to it. |
+
+Frame cells store their values directly; address cells store addresses.
+Writing through an address requires `move at` first.
+
+### 6.3 Working value operations
+
+| Command | Effect |
+|---|---|
+| `set lit` | Working value := literal. |
+| `add`/`subtract`/`multiply`/`divide`/`remainder` [`lit`] | With operand: combine working value with literal (adapted). Without: pop one value and combine. Operand types must match after adaptation (mixed width/sign pairs widen per §7). Address ± integer scales. |
+| `negate` | Two's-complement / float negation. |
+| `convert T` | See §5.6. |
+
+### 6.4 Value stack
+
+| Command | Effect |
+|---|---|
+| `push` | Copy the working value onto the stack. Working value unchanged. |
+| `pop` | Working value := popped value. |
+| `swap` | Exchange working value with the stack top. |
+
+### 6.5 Comparison and control flow
+
+| Command | Effect |
+|---|---|
+| `compare` [`lit`] | Compare working value with popped value (or literal). Sets comparison state to `less`, `equal`, or `greater`. |
+| `jump label` | Unconditional. |
+| `jump label when cond` | cond ∈ `less equal greater different less-or-equal greater-or-equal`. Requires a prior `compare`. |
+| `mark label` | Define label (transformation scoped). |
+| `call name` / `call` | See §8. |
+| `return` | End transformation; result = working value type. |
+| `stop` | End program; exit status = low 8 bits of the working value if numeric, else 0. |
+
+There is no `if`/`while`/`for`; conditional jumps over marks are the
+control flow. A conditional jump **may** jump backward or forward.
+Unreachable commands and labels with no live incoming edge are compile
+errors.
+
+### 6.6 External world
+
+| Command | Effect |
+|---|---|
+| `input` | Read one byte from the current input stream; working value := `int64` 0–255, or `-1` at end of input. |
+| `output` | Write the working value to the current output stream, formatted by its static type: signed ints decimal; unsigned decimal; `uint8` and `char` as one raw byte; `bool` as `true`/`false`; floats shortest-round-trip decimal; `address` as `0x`-hex. |
+| `output text` | Write the NUL-terminated byte string at the working value's address (pointee `uint8` or array of `uint8`). |
+| `open read`/`open write`/`open both` | Open the path (working value address, NUL-terminated) for reading / writing (create/truncate) / both. On success the handle (`int64`) becomes the working value and the context's input and/or output stream is routed through the file; negative means failure and streams are unchanged. |
+| `close` | Close the handle in the working value; restores default streams if they pointed at it. Working value := 0 or -1. |
+| `system` | Run the shell command (working value address, NUL-terminated); working value := exit status as `int64`. |
+
+### 6.7 Concurrency
+
+| Command | Effect |
+|---|---|
+| `spawn name` | Start a new execution context (OS thread) running transformation `name`, which must take zero parameters. The new context starts with working value `nothing`; communication happens through world cells and heap blocks. The spawner's working value is unchanged. Up to 64 outstanding spawns per context. |
+| `wait` | Join all contexts spawned by this one. Working value := `int64 0`. This is the only synchronization guarantee: all memory writes performed by spawned contexts are visible after `wait`. |
+
+Racy access without `wait` yields unspecified values of the cell's type
+(no tearing for aligned ≤ 8-byte cells).
+
+## 7. Arithmetic and comparison typing
+
+* integer literal + any numeric → the numeric type (literal must fit, or
+  `convert` first; float targets always accept).
+* two integer literals → `int64`.
+* same type → that type.
+* integer types of different width/sign → the wider type; mixed
+  signedness becomes signed of the wider width. `uint64` vs signed uses
+  the signed type of width 64; explicit `convert` is recommended.
+* any integer + float → float (float32 only when both are float32).
+* `char` participates as an unsigned 32-bit value.
+* `bool` compares only with `bool` (and literals 0/1).
+* addresses compare with addresses (unsigned) or integer literals.
+
+## 8. Transformations, calls, references
+
+* `define name { type param } result T? ... end`
+* Arguments: push values in parameter order (first parameter deepest),
+  then `call name` — the call pops the parameters. Callee working value
+  starts as `nothing`.
+* Result: the callee's working value at return becomes the caller's
+  working value unless the result type is `nothing` (then the caller's
+  working value is preserved). Result types are inferred from the first
+  syntactic `return`; all return points must agree; recursion requires an
+  explicit `result <type>` declaration.
+* The value stack must be empty at every return.
+* Recursion, mutual recursion, and nested calls are ordinary C-stack
+  recursion — one frame per call.
+* `address name-of-transformation` yields a `transform` reference value.
+  `call` without a name calls the referenced transformation; the
+  reference must denote a zero-parameter transformation (checked
+  statically when known, at runtime otherwise). The result of an indirect
+  call is `nothing` in v1; return data through world cells or memory.
+  Transformations can be passed as arguments (type `transform`) —
+  callbacks.
+
+## 9. Numeric summary
+
+| Situation | Behavior |
+|---|---|
+| signed overflow | wrap (two's complement) |
+| unsigned overflow | wrap modulo 2^N |
+| `/` or `%` by zero | runtime abort, exit 1 |
+| `INT_MIN / -1` | `INT_MIN` (wrap) |
+| float → int out of range | saturate at target width |
+| int → float | round to nearest |
+| index out of bounds | runtime abort, exit 1 |
+| freeing a non-heap address | runtime abort, exit 1 |
+| negative dynamic allocation size | runtime abort, exit 1 |
+
+## 10. Grammar (informal)
+
+```text
+command :=
+    "move" ( name | int | "index" | "at" )
+  | "back" | "home"
+  | "address" [ name ]
+  | "make" ( type [name]
+            | "array" int "of" type [name]
+            | "string" str [name]
+            | (name|int) "of" type [name] )
+  | "free" | "read" | "write"
+  | "set" (int|float|char|true|false|nothing)
+  | ("add"|"subtract"|"multiply"|"divide"|"remainder") [int|float]
+  | "negate"
+  | "push" | "pop" | "swap"
+  | "compare" [int|float|char|true|false]
+  | "convert" type
+  | "jump" name [ "when" cond ]
+  | "call" [name]
+  | "return" | "stop"
+  | "input" | "output" ["text"]
+  | "open" ("read"|"write"|"both") | "close" | "system"
+  | "spawn" name | "wait"
+  | "mark" name
+
+type := basetype | "address" [type] | "array" [int] "of" type
+cond := "less"|"equal"|"greater"|"different"|"less-or-equal"|"greater-or-equal"
+```
+
+## 11. Errors
+
+Compile errors report file, line, column, and message; the first error
+stops compilation of a transformation. Runtime errors print
+`cryn: runtime error: <message>` to stderr and exit with status 1.
+Defined runtime errors: division by zero, remainder by zero, index out
+of bounds, free of a non-heap address, negative or oversized allocation,
+focus history overflow, spawn limit exceeded, indirect call of a
+parameterized transformation, thread creation failure, out of memory.
+
+## 12. Limits
+
+| Limit | Value |
+|---|---|
+| focus history | 256 per frame |
+| value stack depth (static) | 1024 |
+| cells per transformation | 256 |
+| labels per transformation | 256 |
+| outstanding spawns per context | 64 |
+| open files per context | 64 |
+| dynamic allocation | ≤ 2^40 bytes |
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..6999493
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,33 @@
+# The Cryn Programming Language - bootstrap compiler build
+#
+#   make            build the cryn compiler
+#   make test       run the test suite
+#   make clean      remove build artifacts
+
+CC      ?= cc
+CFLAGS  ?= -O2 -std=c11 -Wall -Wextra -Wno-unused-parameter
+BIN     = cryn
+
+SRCS = src/main.c src/util.c src/diag.c src/lex.c src/parse.c src/types.c \
+       src/check.c src/opt.c src/be_c.c
+HDRS = src/util.h src/diag.h src/lex.h src/ast.h src/parse.h src/types.h \
+       src/ir.h src/check.h src/opt.h src/be_c.h src/rt_src.h
+
+all: $(BIN)
+
+tools/embed: tools/embed.c
+	$(CC) $(CFLAGS) -o $@ $<
+
+src/rt_src.h: tools/embed src/rt/cryn_rt.h src/rt/cryn_rt.c
+	./tools/embed src/rt/cryn_rt.h cr_rt_h_src src/rt/cryn_rt.c cr_rt_c_src > $@
+
+$(BIN): $(SRCS) $(HDRS)
+	$(CC) $(CFLAGS) -o $(BIN) $(SRCS)
+
+test: $(BIN)
+	sh tests/run.sh
+
+clean:
+	rm -f $(BIN) tools/embed src/rt_src.h
+
+.PHONY: all test clean
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..138b779
--- /dev/null
+++ b/README.md
@@ -0,0 +1,232 @@
+# The Cryn Programming Language
+
+Cryn is a compiled systems programming language built on a very small and
+unusual core: **cells, connections, focus, a working value, and a value
+stack**. There are no symbolic operators, no braces, no semicolons, and no
+indentation rules. Programs read like small English sentences describing
+operations on a machine:
+
+```text
+define main
+    set 10
+    push
+    set 20
+    add
+    output
+    return
+end
+```
+
+This compiles to a native executable through portable generated C:
+
+```text
+Cryn source -> lexer -> parser -> AST -> semantic analysis -> Cryn IR
+           -> optimizer -> C backend -> host C compiler -> executable
+```
+
+The bootstrap compiler (`cryn`) is written in portable ISO C11 and has no
+dependencies beyond a C compiler and pthreads.
+
+---
+
+## Design philosophy
+
+> C-level power through a radically small set of composable primitives.
+
+* **34 commands total.** No `if`, `while`, `for`, `switch`, `array`,
+  `struct`, `class`, or `pointer` keywords. Control flow is explicit
+  jumps; arrays are indexed connections; structures are blocks of memory;
+  pointers are address values.
+* **Whitespace has no meaning.** Every program can be written on one line.
+  Blocks end with `end`.
+* **One comment syntax:** `[# ... #]`, possibly spanning lines.
+* **English words instead of symbols:** `add`, `compare`, `jump ... when
+  less`, `move`, `write`.
+* **The graph model is the semantics; the compiler lowers it.** Cells and
+  connections are what the language means; the compiler compiles them into
+  ordinary locals, arrays, and pointer arithmetic, so Cryn code runs at
+  native speed with no graph overhead.
+* **Static typing with defined behavior.** Fixed-width integer and float
+  types, defined overflow (wrap), defined division by zero (diagnostic
+  abort), saturating float-to-int conversion, bounds-checked indexing.
+* **No hidden runtime.** The runtime support layer is a few hundred lines:
+  allocation headers, context structs, thread spawn/join, and output
+  formatting. Every dependency is documented in this README.
+
+See `LANGUAGE.md` for the formal language reference and `TUTORIAL.md` for
+a course from first principles.
+
+## Repository layout
+
+```text
+src/
+    main.c       CLI entry point (build / run / check / emit-c / emit-ir)
+    util.[ch]    buffers, lists, file I/O
+    diag.[ch]    diagnostics (file:line:col reporting)
+    lex.[ch]     lexer: keywords, literals, comments, locations
+    ast.h        AST node definitions
+    parse.[ch]   recursive-descent parser (formatting independent)
+    types.[ch]   interned type objects, sizes, classifications
+    check.[ch]   semantic analysis: abstract machine interpretation,
+                 produces fully typed IR (src/check.c is the semantic heart)
+    ir.h         Cryn IR instruction definitions
+    opt.[ch]     IR cleanup: unreachable code and dead label removal
+    be_c.[ch]    C backend: emits a self-contained C11 translation unit
+    rt/
+        cryn_rt.h    minimal runtime interface (embedded into output)
+        cryn_rt.c    runtime implementation
+    rt_src.h     generated: runtime sources as strings (tools/embed)
+tools/
+    embed.c      turns runtime sources into string headers
+tests/
+    *.cryn       test programs
+    expected/    expected outputs
+    input/       stdin fixtures
+    errors/      programs that must fail to compile
+    run.sh       test runner
+examples/        hello, fib, fizzbuzz, sorting, wordcount, linkedlist,
+                 allocator
+```
+
+The frontend (lexer/parser/AST), the semantic layer (types/check), the IR,
+and the backend (be_c) are cleanly separated. A native code backend can be
+added by translating `src/ir.h` instructions directly; nothing in the
+semantic layer is tied to C generation.
+
+## Building
+
+```sh
+make            # builds ./cryn
+make test       # runs the test suite
+make clean
+```
+
+Requirements: any ISO C11 compiler and POSIX `make`. pthreads is required
+for `spawn` / `wait` support.
+
+## Using the compiler
+
+```sh
+cryn hello.cryn              # compile to executable "hello"
+cryn build hello.cryn -o out # compile to executable "out"
+cryn run hello.cryn          # compile and execute
+cryn check hello.cryn        # type check only
+cryn emit-c hello.cryn -o out.c   # write the generated C
+cryn emit-ir hello.cryn      # print the Cryn IR
+cryn help
+cryn version
+```
+
+`cryn` invokes the host C compiler (`$CC`, default `cc`) with
+`-O2 -std=c11 -pthread` to turn generated C into an executable. Generated
+programs are normal executables and need no Cryn runtime at runtime; the
+runtime is compiled into every generated translation unit.
+
+## How the compiler works
+
+1. **Lexing** (`lex.c`): comments are stripped as `[# ... #]`; whitespace
+   (including newlines) is skipped everywhere; line/column tracked for
+   diagnostics.
+2. **Parsing** (`parse.c`): recursive descent over the token stream.
+   Transformation parameters are `{ type name }` pairs terminated by the
+   first non-type token, which keeps parsing unambiguous without any
+   punctuation.
+3. **Semantic analysis** (`check.c`): an abstract interpreter over the
+   Cryn machine. It tracks the static type of the working value, the
+   abstract focus stack, the value stack shape, and comparison state, and
+   merges these states at every label. All merges must agree exactly
+   (integer literals widen to the type they fit). It also infers result
+   types, resolves all names, checks argument counts/types, and emits the
+   fully typed IR in one pass. Recursive transformations with unclear
+   results must declare `result <type>`.
+4. **IR** (`ir.h`): ~50 typed instructions (navigation, value ops, memory
+   access, comparisons, calls, external operations).
+5. **Optimizer** (`opt.c`): removes unreachable code and unreferenced
+   labels. (Deliberately small; the C compiler performs the heavy lifting
+   on the readable C we emit.)
+6. **C backend** (`be_c.c`): each transformation becomes a C function;
+   frame cells become C locals; `move` becomes pointer assignment; value
+   stack and focus history live in a per-thread context struct. The
+   runtime sources are embedded and emitted into every translation unit,
+   making the output a single self-contained `.c` file.
+
+## Runtime dependencies (complete list)
+
+The runtime (`src/rt/cryn_rt.*`) provides only:
+
+| Facility        | Used by                          | Notes                          |
+|-----------------|----------------------------------|--------------------------------|
+| `malloc`/`free` | `make <size> of`, anonymous `make`, `free` | 16-byte header for validation and bounds checks |
+| value stack     | `push`, `pop`, `swap`, `call`    | growable array, per thread     |
+| focus history   | `move` (relative), `back`, `home`| fixed 256 entries per frame    |
+| FILE* streams   | `input`, `output`, `open`, `close`| stdin/stdout by default       |
+| `system()`      | `system`                         | shell escape                   |
+| pthreads        | `spawn`, `wait`                  | join provides the only ordering guarantee |
+| formatting      | `output`                         | shortest round-trip float printing |
+
+There is no garbage collector, no interpreter, and no mandatory runtime
+library beyond the above.
+
+## Testing
+
+```sh
+make test
+```
+
+35 checks: arithmetic (including overflow wrap and division edge cases),
+unsigned arithmetic, floats, all six comparison conditions, branches,
+loops, nested loops, functions, recursion, mutual recursion, the value
+stack, cells and focus navigation, arrays (sieve of Eratosthenes),
+strings, manual memory, pointer arithmetic, linked lists, callbacks
+through transform references, file I/O, threads (`spawn`/`wait`), a word
+counter, bubble sort, a free-list allocator, and ten programs that must
+fail to compile.
+
+## Current limitations
+
+These are honest boundaries of the first implementation, not pretenses:
+
+* **Backend is C.** A native x86-64 backend is planned; the IR is ready
+  for it.
+* **Concurrency is minimal.** `spawn` starts a thread running a
+  zero-parameter transformation; `wait` joins all spawned contexts of the
+  current one. Shared world cells may be accessed concurrently; v1 gives
+  no atomicity guarantees beyond `wait` acting as a full join/sync point.
+  Communication of results happens through shared world cells.
+* **Indirect calls** (through `transform` references) are only allowed to
+  zero-parameter transformations; checked at runtime when the reference is
+  not statically known.
+* **`open` sets the context's current input/output stream** rather than
+  exposing a first-class handle type; `close` uses the last handle value.
+  One stream pair per context at a time.
+* **Bounds checks abort** with a diagnostic. A release mode that removes
+  them is future work.
+* **Fixed limits:** 256 focus history entries, 256 labels, 256 cells per
+  transformation, 64 concurrent spawns per context, 64 open files per
+  context.
+* **No separate compilation** yet: one `.cryn` file per program.
+* **The compiler itself is not yet written in Cryn.** Self-hosting is the
+  long-term goal; the architecture (small passes, plain data structures,
+  no frameworks) was chosen for it.
+
+## Contributing
+
+* Keep the command count at or below 40. Before adding anything, prove it
+  cannot be expressed with the existing primitives.
+* Keep the compiler portable ISO C11; isolate platform specifics in
+  `src/rt/`.
+* Every language change needs: a test in `tests/`, updated `LANGUAGE.md`
+  and `TUTORIAL.md`, and a note in this README if it affects the runtime
+  surface.
+* Run `make test` before committing.
+
+## Future work
+
+1. Native x86-64 backend (direct IR lowering; the C backend remains the
+   portable reference).
+2. Self-hosting: rewrite the compiler in Cryn.
+3. Release mode without bounds checks; compile-time elimination of
+  statically provable checks.
+4. First-class file handles; more `open` modes.
+5. Atomics or another minimal, honest concurrency primitive if a use case
+  cannot be met with `spawn`/`wait`.
diff --git a/TUTORIAL.md b/TUTORIAL.md
new file mode 100644
index 0000000..64fe07f
--- /dev/null
+++ b/TUTORIAL.md
@@ -0,0 +1,769 @@
+# Learning Cryn: A Tutorial
+
+This tutorial teaches Cryn from zero. It explains the machine, not just
+the commands. Every example compiles with the current `cryn` compiler.
+
+---
+
+## 1. Hello, machine
+
+```text
+define main
+    set 10
+    push
+
+    set 20
+    add
+
+    output
+
+    return
+end
+```
+
+Run it:
+
+```sh
+cryn run hello.cryn
+```
+
+Output:
+
+```text
+30
+```
+
+What happened?
+
+* `define main ... end` defines a **transformation** — Cryn's unit of
+  computation. Execution starts at `main`.
+* `set 10` puts 10 into the **working value**: the one value your
+  context is currently holding.
+* `push` copies the working value onto the **value stack** so it
+  survives while we use the working value for something else.
+* `set 20` replaces the working value with 20.
+* `add` pops the stack and adds it to the working value: 20 + 10 = 30.
+* `output` writes the working value to stdout (integers print in
+  decimal).
+* `return` ends the transformation.
+
+Formatting never matters. This is the same program:
+
+```text
+define main set 10 push set 20 add output return end
+```
+
+So is this:
+
+```text
+define
+main
+set
+10
+push
+set
+20
+add
+output
+return
+end
+```
+
+Comments are `[# ... #]` and may span lines. They are the only comment
+syntax:
+
+```text
+set 10 [# working value is ten #]
+```
+
+## 2. The machine you are driving
+
+Every running transformation is an **execution context** containing:
+
+1. A **working value** — one typed value, the thing commands operate on.
+2. A **value stack** — for saving values temporarily.
+3. A **focus** — a stack of currently selected **cells**; the bottom of
+   the stack is the **frame root** (the transformation's own namespace).
+4. A **comparison state** — set by `compare`, tested by conditional
+   jumps.
+
+The **world** is the collection of cells. Some cells belong to your
+frame (locals and parameters). Some belong to everyone (world cells,
+made at the top level). Blocks of raw memory are cells too.
+
+Data flows through three registers of attention:
+
+```text
+world cells  <--move/read/write-->  focus
+working value  <--set/read/push/pop/arith-->  value stack / literals
+```
+
+## 3. Cells: make, move, read, write
+
+A cell is a named place that holds one value:
+
+```text
+define main
+    make int64 x          [# a cell named x, holding an int64, starting at 0 #]
+
+    set 41
+    move x                [# focus the cell x #]
+    write                 [# x := working value (41) #]
+
+    move x
+    read                  [# working value := x #]
+    add 1
+    move x
+    write                 [# x := 42 #]
+
+    move x
+    read
+    output
+    return
+end
+```
+
+This prints `42`.
+
+* `make int64 x` creates cell `x` in the current frame. Executing the
+  `make` again re-zeroes it.
+* `move x` follows the **named connection** `x` from the frame root and
+  focuses the cell. `move` with a name always resolves from the frame
+  root, and it **anchors** the focus: the previous focus is replaced.
+* `read` copies the focused cell into the working value.
+* `write` copies the working value into the focused cell.
+
+Cell types are checked at compile time. Try writing a string address
+into an `int64` cell and the compiler refuses.
+
+### All the integer types
+
+```text
+int8 int16 int32 int64      signed
+uint8 uint16 uint32 uint64  unsigned
+float32 float64             floats
+bool char nothing
+```
+
+`make uint8 c`, `make float32 f`, `make bool b` — all work the same.
+`set 'A'` is a character literal; `set true` a boolean.
+
+## 4. Arithmetic
+
+Arithmetic works on the working value, either with a literal operand or
+by popping the stack:
+
+```text
+set 2
+add 3          [# 5 ]
+multiply 4     [# 20 ]
+subtract 5     [# 15 ]
+
+set 100
+push
+set 7
+divide         [# pops 100, working value 7 -> 0.07? No: integer 7/100 is 0 ]
+```
+
+Careful: `divide` combines *working value* with *popped value*: the
+popped value is the right-hand operand. `100 pushed, working value 7`
+computes `7 / 100`.
+
+The exact rules (LANGUAGE.md §5, §7):
+
+* Integer overflow **wraps** in both directions. `set 9223372036854775807
+  add 1` gives `-9223372036854775808`.
+* Division by zero aborts the program with a diagnostic. `-7 / 2` is
+  `-3` (truncates toward zero).
+* Mixing widths: the wider type wins; mixed signedness becomes signed.
+  Different types otherwise require `convert`:
+  `set 1 convert float32 divide 3`.
+* `negate` flips the sign.
+
+```text
+set 1.5
+add 2.25       [# 3.75 ]
+multiply 2     [# 7.5 ]
+output         [# prints 7.5 ]
+```
+
+Floats print as the shortest decimal that reads back exactly.
+
+## 5. Comparison and branching
+
+There is no `if`. There is `compare` and conditional `jump`:
+
+```text
+set 10
+compare 20
+jump smaller when less
+set 'N'
+jump done
+
+mark smaller
+set 'Y'
+
+mark done
+output
+```
+
+`compare 20` compares the working value (10) with 20 and sets the
+comparison state. `jump smaller when less` jumps when the state is
+`less`. Conditions: `less equal greater different less-or-equal
+greater-or-equal`.
+
+`compare` without an operand pops the stack:
+
+```text
+move a
+read
+push
+move b
+read
+compare          [# b (working) vs a (popped) #]
+```
+
+Labels (`mark`) are transformation-scoped names. Any jump may go forward
+or backward.
+
+### The pattern for if/else
+
+```text
+compare 0
+jump negative when less
+set 1            [# then-branch #]
+jump end
+mark negative
+set 0            [# else-branch #]
+mark end
+```
+
+## 6. Loops
+
+A loop is a backward jump. Both of these print the numbers 0 through 4:
+
+```text
+make int64 i
+make int64 n
+
+set 0
+move i
+write
+set 5
+move n
+write
+set 0
+
+mark loop
+move i
+read
+compare 5
+jump done when greater-or-equal
+move i
+read
+push
+call newline
+pop
+output
+
+move i
+read
+add 1
+move i
+write
+set 0
+
+jump loop
+mark done
+```
+
+### The one rule you must know about loops
+
+The checker tracks the working value type, the focus, and the value
+stack at every label. **Every path that reaches a label must agree.**
+The back edge of a loop and the loop's entry must carry:
+
+* the same working value **type** (integer literals widen to the type
+  they fit),
+* the same focus,
+* the same value stack shape.
+
+The reliable idiom: before entering a loop and before jumping back,
+normalize with `set 0` and `home`:
+
+```text
+set 0
+home
+mark loop
+    ...
+    set 0
+    home
+    jump loop
+```
+
+If you forget, the compiler tells you exactly where the two paths
+disagree — for example *"working value type differs between paths (0 vs
+int64); normalize it with 'set'"*.
+
+(This strictness is deliberate: it keeps every program point typed and
+lets the compiler emit plain C locals.)
+
+## 7. The value stack: push, pop, swap
+
+The stack exists to preserve values while the working value does
+something else:
+
+```text
+set 1
+push            [# stack: 1 ]
+set 2
+add             [# pops 1: working value 3 ]
+```
+
+* `push` copies the working value to the stack (the working value is
+  unchanged).
+* `pop` brings the top back into the working value.
+* `swap` exchanges working value and stack top.
+
+The stack must be empty at `return`. The compiler enforces this; there
+are no leaks.
+
+## 8. Transformations: parameters and results
+
+```text
+define gcd int64 a int64 b result int64
+    mark loop
+    move b
+    read
+    compare 0
+    jump done when equal
+
+    move b
+    read
+    push
+    move b
+    read
+    push
+    move a
+    read
+    remainder
+    push
+    move b
+    write
+    pop
+    pop
+    move a
+    write
+    set nothing
+    home
+    jump loop
+
+    mark done
+    move a
+    read
+    return
+end
+
+define main
+    set 252
+    push
+    set 105
+    push
+    call gcd
+    output
+    return
+end
+```
+
+This prints `21`.
+
+* Parameters are `{ type name }` pairs after the transformation name.
+  They are ordinary frame cells.
+* **Arguments are pushed in parameter order** (first parameter deepest);
+  `call` pops them into the parameter cells.
+* The **result is the working value at return**. `result int64` declares
+  it; without a declaration it is inferred from the first `return`.
+  Recursive transformations must declare `result`.
+* After a call, the caller's working value is the callee's result —
+  unless the result is `nothing`, in which case the caller's working
+  value is preserved.
+* A helper that returns nothing should end with `set nothing` before
+  `return` (a stray value would become its result type):
+
+```text
+make string "\n" nl
+
+define newline result nothing
+    address nl
+    output text
+    set nothing
+    return
+end
+```
+
+Calls may recurse (one real frame per call); mutual recursion just
+works.
+
+### Transform references and callbacks
+
+`address transformation-name` produces a `transform` reference value.
+`call` without a name calls it. References can be passed as arguments:
+
+```text
+define ping result nothing
+    make string "ping
+" msg
+    address msg
+    output text
+    set nothing
+    return
+end
+
+define twice transform f result nothing
+    move f
+    read
+    push
+    call
+    pop
+    call
+    set nothing
+    return
+end
+
+define main
+    address ping
+    push
+    call twice
+    return
+end
+```
+
+(Indirect calls target zero-parameter transformations in this version.)
+
+## 9. Arrays: indexed connections
+
+An array is a cell with indexed connections. Create with
+`make array N of T name`:
+
+```text
+make array 5 of int64 a
+```
+
+Two ways to reach an element:
+
+```text
+[# static index #]
+set 7
+move a
+move 0            [# element 0 #]
+write
+
+[# dynamic index: put the index in the working value FIRST #]
+move i
+read              [# working value = i #]
+move a            [# focus array; working value survives named moves #]
+move index        [# element i; uses the working value as index #]
+read
+```
+
+Out-of-bounds indexing aborts the program with a diagnostic (the checker
+catches constant indices at compile time).
+
+The full array idiom, summed up: **index into the working value, then
+focus, then `move index`**. If you need the index afterwards, push it
+first and pop after.
+
+## 10. Strings
+
+A string is a byte array with a terminating NUL — nothing more:
+
+```text
+make string "Hello, World!" msg
+address msg
+output text
+```
+
+* `make string "..." msg` creates a byte-array cell.
+* `make string "..."` (no name) points the working value at static
+  NUL-terminated bytes.
+* `output text` writes bytes until the NUL.
+
+To process text, loop over bytes:
+
+```text
+make string "interrupt" word
+make int64 n
+set 0
+move n
+write
+set 0
+
+mark count
+move n
+read
+move word
+move index
+read
+compare 0
+jump counted when equal
+move n
+read
+add 1
+move n
+write
+set 0
+jump count
+mark counted
+```
+
+`input` reads one byte (returns -1 at end of input); `output` of a
+`uint8` or `char` writes one raw byte. That is the whole I/O vocabulary;
+word counters and parsers are built from it (see `examples/`).
+
+## 11. Memory: blocks and addresses
+
+Manual memory, no garbage collector:
+
+```text
+make 4 of int64 block      [# heap block of 4 int64s, zeroed #]
+```
+
+After a dynamic `make`, the working value holds the block's address
+(type `address array of int64`). Two idioms:
+
+```text
+[# write element 0 immediately: the address is still in the working value #]
+move at          [# focus the block #]
+set 40
+move 0
+write
+back             [# back to the block #]
+
+[# write element i for a computed i #]
+move i
+read
+push             [# save the index #]
+move block
+read             [# working value := block address #]
+move at          [# focus the block #]
+pop              [# restore the index #]
+move index       [# focus element i #]
+set 41
+write
+```
+
+`free` releases a heap block:
+
+```text
+move block
+read
+free
+```
+
+### Addresses are values
+
+* `address x` — working value := the address of cell `x` (or of a
+  transformation: a `transform` reference).
+* `address` (no name) — the address of the focused cell.
+* `move at` — focus the cell an address points to.
+* `add 1` / `subtract 1` on an address moves by **one element**
+  (scaled by the element size), like pointer arithmetic in C.
+* Converting between `int64`/`uint64` and addresses reinterprets bits —
+  that is how you store links inside homogeneous blocks.
+
+Addresses compare (`compare`) as unsigned numbers; `compare 0` tests
+for the null address.
+
+## 12. Structures and linked lists
+
+There is no `struct` command. A structure is a block of memory; fields
+are elements; links are addresses stored as integers. A linked list node
+`[ value next ]` is a block of two `int64`s:
+
+```text
+make 2 of int64 n1         [# value=10, next -> n2 #]
+make 2 of int64 n2         [# value=20, next = 0 #]
+make 2 of int64 cur
+
+move n2
+read
+move at
+set 20
+move 0
+write
+back
+set 0
+move 1
+write
+back
+
+move n2
+read
+convert int64        [# the link, as a number #]
+push
+move n1
+read
+move at
+set 10
+move 0
+write
+back
+move 1
+pop
+write
+back
+```
+
+Walking means converting the stored link back to an address:
+
+```text
+move cur
+read
+move at
+move 1
+read                      [# the next link #]
+convert address array of int64
+move cur
+write
+```
+
+The complete working program is `examples/linkedlist.cryn`.
+
+## 13. Files and the outside world
+
+Each context has a current input and output stream (stdin/stdout by
+default). `open` reroutes them through a file:
+
+```text
+make string "out.txt" path
+address path
+open write            [# create/truncate; negative result = failure #]
+compare 0
+jump failed when less
+
+set 'H'
+output                [# goes to the file #]
+
+close
+stop
+
+mark failed
+stop
+```
+
+`open read` reroutes input; `input` then reads the file byte by byte
+until it returns `-1`. `open both` opens for reading and writing.
+`system` runs a shell command:
+
+```text
+make string "touch cryn.txt" cmd
+address cmd
+system
+```
+
+There are no per-file read/write commands and no sockets in v1 — one
+context tracks one stream pair at a time (documented limitation). Files,
+networks, and devices are all expected to grow from this small surface.
+
+## 14. Concurrency
+
+```text
+make int64 counter
+
+define worker
+    make int64 k
+    set 0
+    move k
+    write
+    mark w
+    move k
+    read
+    compare 100
+    jump done when greater-or-equal
+    move counter
+    read
+    add 1
+    move counter
+    write
+    set 0
+    move k
+    read
+    add 1
+    move k
+    write
+    set 0
+    jump w
+    mark done
+    return
+end
+
+define main
+    spawn worker
+    spawn worker
+    wait
+    move counter
+    read
+    output
+    return
+end
+```
+
+`spawn name` starts a new context (a real thread) running a
+zero-parameter transformation. Contexts share world cells and heap
+blocks — that is how they communicate. `wait` joins everything this
+context spawned and makes all their writes visible. Without `wait` there
+are no ordering guarantees. This is a minimal, honest model: v1 has no
+locks or atomics.
+
+## 15. A real program
+
+`examples/wordcount.cryn` counts lines, words, and characters from
+stdin using: world cells, a bump helper that increments a cell through
+its address, byte classification with `compare`, and normalization at
+loop labels. Run it:
+
+```sh
+cryn run examples/wordcount.cryn < examples/wordcount.cryn
+```
+
+Other complete programs in `examples/`: `sorting.cryn` (bubble sort over
+a heap block with element helpers), `allocator.cryn` (a free-list
+allocator), `linkedlist.cryn`, `fib.cryn`, `fizzbuzz.cryn`.
+
+## 16. When the compiler argues with you
+
+The checker is strict so that the machine stays simple. The errors you
+will meet and what they mean:
+
+* *"working value type differs between paths"* — normalize with `set`
+  at loop entries and back edges.
+* *"focus depth/type differs between paths"* — add `home` (or `back`)
+  before jumps into shared labels.
+* *"the value stack must be empty at return"* — you pushed something
+  and never popped it.
+* *"cannot write X into a cell of type Y"* — types match exactly or you
+  `convert` first (literals adapt automatically when they fit).
+* *"unreachable command"* — code after an unconditional jump with no
+  label bringing it back to life. Delete it or label it.
+* *"'move index' requires an integer working value"* — get the index
+  into the working value *before* focusing the array (§9).
+
+## 17. The whole language
+
+You have now seen every mechanism. Thirty-four commands:
+
+```text
+move back home address make free read write
+set add subtract multiply divide remainder negate
+push pop swap compare convert
+jump call return stop
+input output open close system
+spawn wait define end mark
+```
+
+Everything else — control flow, arrays, structures, strings, pointers,
+callbacks — is patterns built from these. When you wonder "how do I do X
+in Cryn?", ask instead: *"what does the machine have to do to make X
+happen?"* — and write that.
+
+Welcome to the machine.
diff --git a/examples/allocator.cryn b/examples/allocator.cryn
new file mode 100644
index 0000000..8a999bb
--- /dev/null
+++ b/examples/allocator.cryn
@@ -0,0 +1,182 @@
+[# a small fixed-slot free-list allocator built on a raw block #]
+make string "\n" nl
+make int64 head
+
+define newline result nothing
+  address nl
+  output text
+  set nothing
+  return
+end
+
+define getslot address array of int64 blk int64 idx result int64
+  move idx
+  read
+  push
+  move blk
+  read
+  move at
+  pop
+  move index
+  read
+  return
+end
+
+define setslot address array of int64 blk int64 idx int64 val result nothing
+  move val
+  read
+  push
+  move idx
+  read
+  push
+  move blk
+  read
+  move at
+  pop
+  move index
+  pop
+  write
+  set nothing
+  return
+end
+
+define alloc address array of int64 blk result int64
+  move head
+  read
+  push
+  move blk
+  read
+  push
+  move head
+  read
+  push
+  call getslot
+  move head
+  write
+  pop
+  return
+end
+
+define dealloc address array of int64 blk int64 slot result nothing
+  move blk
+  read
+  push
+  move slot
+  read
+  push
+  move head
+  read
+  push
+  call setslot
+  move slot
+  read
+  move head
+  write
+  set nothing
+  return
+end
+
+define putval int64 v result nothing
+  move v
+  read
+  output
+  call newline
+  set nothing
+  return
+end
+
+define main
+  make 17 of int64 pool
+  make int64 i
+
+  set 1
+  move i
+  write
+  set 0
+  home
+
+  mark build
+  move i
+  read
+  compare 16
+  jump capped when greater-or-equal
+  set 0
+  home
+
+  move pool
+  read
+  push
+  move i
+  read
+  push
+  move i
+  read
+  add 1
+  push
+  call setslot
+
+  move i
+  read
+  add 1
+  move i
+  write
+  set 0
+  home
+  jump build
+
+  mark capped
+  move pool
+  read
+  push
+  set 16
+  push
+  set 0
+  push
+  call setslot
+
+  set 1
+  move head
+  write
+  set 0
+  home
+
+  move pool
+  read
+  push
+  call alloc
+  push
+  call putval
+
+  move pool
+  read
+  push
+  call alloc
+  push
+  call putval
+
+  move pool
+  read
+  push
+  call alloc
+  push
+  call putval
+
+  move pool
+  read
+  push
+  set 2
+  push
+  call dealloc
+
+  move pool
+  read
+  push
+  call alloc
+  push
+  call putval
+
+  move pool
+  read
+  free
+  return
+end
diff --git a/examples/fib.cryn b/examples/fib.cryn
new file mode 100644
index 0000000..1796bb3
--- /dev/null
+++ b/examples/fib.cryn
@@ -0,0 +1,70 @@
+[# iterative fibonacci: prints fib(1) .. fib(12) #]
+make string "\n" nl
+
+define newline result nothing
+  address nl
+  output text
+  set nothing
+  return
+end
+
+define main
+  make int64 a
+  make int64 b
+  make int64 t
+  make int64 n
+
+  set 1
+  move a
+  write
+  set 1
+  move b
+  write
+  set 12
+  move n
+  write
+  set 0
+  home
+
+  mark loop
+  move n
+  read
+  compare 0
+  jump done when less-or-equal
+  set 0
+  home
+
+  move a
+  read
+  output
+  call newline
+
+  move b
+  read
+  push
+  move a
+  read
+  push
+  move b
+  read
+  add
+  push
+  move b
+  write
+  pop
+  pop
+  move a
+  write
+
+  move n
+  read
+  subtract 1
+  move n
+  write
+  set 0
+  home
+  jump loop
+
+  mark done
+  return
+end
diff --git a/examples/fizzbuzz.cryn b/examples/fizzbuzz.cryn
new file mode 100644
index 0000000..b855057
--- /dev/null
+++ b/examples/fizzbuzz.cryn
@@ -0,0 +1,101 @@
+make string "\n" nleol
+
+define nl result nothing
+  address nleol
+  output text
+  set nothing
+  return
+end
+
+[# fizzbuzz 1..30: no strings needed, just markers #]
+define main
+  make int64 i
+  make int64 fizz
+  make int64 buzz
+
+  set 1
+  move i
+  write
+
+  mark loop
+  move i
+  read
+  compare 30
+  jump done when greater
+  set 0
+  home
+
+  move i
+  read
+  remainder 3
+  move fizz
+  write
+
+  move i
+  read
+  remainder 5
+  move buzz
+  write
+  set 0
+  home
+
+  move fizz
+  read
+  home
+  compare 0
+  jump checkBuzz when equal
+  move buzz
+  read
+  home
+  compare 0
+  jump printB when equal
+  move i
+  read
+  output
+  home
+  call nl
+  set 0
+  jump next
+
+  mark checkBuzz
+  move buzz
+  read
+  home
+  compare 0
+  jump printFB when equal
+  set 'F'
+  output
+  home
+  call nl
+  set 0
+  jump next
+
+  mark printB
+  set 'B'
+  output
+  home
+  call nl
+  set 0
+  jump next
+
+  mark printFB
+  set 'F'
+  output
+  set 'B'
+  output
+  home
+  call nl
+  set 0
+
+  mark next
+  move i
+  read
+  add 1
+  move i
+  write
+  set 0
+  jump loop
+
+  mark done
+  return
+end
diff --git a/examples/hello.cryn b/examples/hello.cryn
new file mode 100644
index 0000000..1a99309
--- /dev/null
+++ b/examples/hello.cryn
@@ -0,0 +1,14 @@
+[# the canonical first program from the language definition #]
+define main
+
+set 10
+push
+
+set 20
+add
+
+output
+
+return
+
+end
diff --git a/examples/linkedlist.cryn b/examples/linkedlist.cryn
new file mode 100644
index 0000000..57d319e
--- /dev/null
+++ b/examples/linkedlist.cryn
@@ -0,0 +1,123 @@
+[# a linked list built from raw blocks: node = [value next] #]
+make string "\n" nl
+make int64 total
+
+define newline result nothing
+  address nl
+  output text
+  set nothing
+  return
+end
+
+define main
+  make 2 of int64 n1
+  make 2 of int64 n2
+  make 2 of int64 n3
+  make 2 of int64 cur
+
+  move n3
+  read
+  move at
+  set 30
+  move 0
+  write
+  back
+  set 0
+  move 1
+  write
+  back
+
+  move n3
+  read
+  convert int64
+  push
+  move n2
+  read
+  move at
+  set 20
+  move 0
+  write
+  back
+  move 1
+  pop
+  write
+  back
+
+  move n2
+  read
+  convert int64
+  push
+  move n1
+  read
+  move at
+  set 10
+  move 0
+  write
+  back
+  move 1
+  pop
+  write
+  back
+
+  set 0
+  move total
+  write
+
+  move n1
+  read
+  push
+  move cur
+  write
+  set 0
+  move total
+  write
+  pop
+  home
+
+  mark walk
+  move cur
+  read
+  compare 0
+  jump done when equal
+
+  move cur
+  read
+  move at
+  move 0
+  read
+  output
+  call newline
+  push
+  move total
+  read
+  add
+  move total
+  write
+
+  move cur
+  read
+  move at
+  move 1
+  read
+  convert address array of int64
+  move cur
+  write
+  home
+  jump walk
+
+  mark done
+  move total
+  read
+  output
+
+  move n1
+  read
+  free
+  move n2
+  read
+  free
+  move n3
+  read
+  free
+  return
+end
diff --git a/examples/sorting.cryn b/examples/sorting.cryn
new file mode 100644
index 0000000..12ff43f
--- /dev/null
+++ b/examples/sorting.cryn
@@ -0,0 +1,287 @@
+[# bubble sort over a heap block of int64, using element helpers #]
+make string "\n" nl
+
+define newline result nothing
+  address nl
+  output text
+  set nothing
+  return
+end
+
+define getelem address array of int64 blk int64 idx result int64
+  move idx
+  read
+  push
+  move blk
+  read
+  move at
+  pop
+  move index
+  read
+  return
+end
+
+define setelem address array of int64 blk int64 idx int64 val result nothing
+  move val
+  read
+  push
+  move idx
+  read
+  push
+  move blk
+  read
+  move at
+  pop
+  move index
+  pop
+  write
+  set nothing
+  return
+end
+
+define load address array of int64 blk int64 idx int64 val result nothing
+  move blk
+  read
+  push
+  move idx
+  read
+  push
+  move val
+  read
+  push
+  call setelem
+  set nothing
+  return
+end
+
+define put int64 v result nothing
+  move v
+  read
+  output
+  call newline
+  set nothing
+  return
+end
+
+define main
+  make 8 of int64 a
+  make int64 i
+  make int64 j
+  make int64 tmp
+
+  move a
+  read
+  push
+  set 0
+  push
+  set 42
+  push
+  call load
+
+  move a
+  read
+  push
+  set 1
+  push
+  set 17
+  push
+  call load
+
+  move a
+  read
+  push
+  set 2
+  push
+  set 99
+  push
+  call load
+
+  move a
+  read
+  push
+  set 3
+  push
+  set 3
+  push
+  call load
+
+  move a
+  read
+  push
+  set 4
+  push
+  set 58
+  push
+  call load
+
+  move a
+  read
+  push
+  set 5
+  push
+  set 21
+  push
+  call load
+
+  move a
+  read
+  push
+  set 6
+  push
+  set 7
+  push
+  call load
+
+  move a
+  read
+  push
+  set 7
+  push
+  set 65
+  push
+  call load
+
+  set 7
+  move i
+  write
+  set 0
+  home
+
+  mark outer
+  move i
+  read
+  compare 0
+  jump sorted when less
+  set 0
+  home
+
+  set 0
+  move j
+  write
+  set 0
+  home
+
+  mark inner
+  move i
+  read
+  push
+  move j
+  read
+  compare
+  jump nextPass when greater-or-equal
+  set 0
+  home
+
+  move a
+  read
+  push
+  move j
+  read
+  push
+  call getelem
+  move tmp
+  write
+
+  move a
+  read
+  push
+  move j
+  read
+  add 1
+  push
+  call getelem
+  push
+  move tmp
+  read
+  compare
+  jump noSwap when less-or-equal
+
+  move a
+  read
+  push
+  move j
+  read
+  push
+  move a
+  read
+  push
+  move j
+  read
+  add 1
+  push
+  call getelem
+  push
+  call setelem
+
+  move a
+  read
+  push
+  move j
+  read
+  add 1
+  push
+  move tmp
+  read
+  push
+  call setelem
+
+  mark noSwap
+  move j
+  read
+  add 1
+  move j
+  write
+  set 0
+  home
+  jump inner
+
+  mark nextPass
+  move i
+  read
+  subtract 1
+  move i
+  write
+  set 0
+  home
+  jump outer
+
+  mark sorted
+  set 0
+  move i
+  write
+  set 0
+  home
+
+  mark print
+  move i
+  read
+  compare 8
+  jump done when greater-or-equal
+  set 0
+  home
+
+  move a
+  read
+  push
+  move i
+  read
+  push
+  call getelem
+  push
+  call put
+
+  move i
+  read
+  add 1
+  move i
+  write
+  set 0
+  home
+  jump print
+
+  mark done
+  move a
+  read
+  free
+  return
+end
diff --git a/examples/wordcount.cryn b/examples/wordcount.cryn
new file mode 100644
index 0000000..232aa0e
--- /dev/null
+++ b/examples/wordcount.cryn
@@ -0,0 +1,128 @@
+[# word counter: reads stdin, prints "lines words chars" #]
+make int64 lines
+make int64 words
+make int64 chars
+make int64 inword
+make string "\n" nl
+
+define newline result nothing
+  address nl
+  output text
+  set nothing
+  return
+end
+
+define bump address int64 cell result nothing
+  move cell
+  read
+  move at
+  read
+  add 1
+  write
+  set nothing
+  return
+end
+
+define doline result nothing
+  address lines
+  push
+  call bump
+  set nothing
+  return
+end
+
+define startword result nothing
+  set 1
+  move inword
+  write
+  address words
+  push
+  call bump
+  set nothing
+  return
+end
+
+define put int64 v result nothing
+  move v
+  read
+  output
+  set ' '
+  output
+  set nothing
+  return
+end
+
+define main
+  set 0
+  move lines
+  write
+  set 0
+  move words
+  write
+  set 0
+  move chars
+  write
+  set 0
+  move inword
+  write
+  set 0
+  home
+
+  mark next
+  input
+  compare -1
+  jump done when equal
+  push
+
+  address chars
+  push
+  call bump
+
+  pop
+  compare 10
+  jump newline when equal
+  compare 32
+  jump space when equal
+  compare 9
+  jump space when equal
+  jump word
+
+  mark newline
+  call doline
+
+  mark space
+  set 0
+  move inword
+  write
+  set 0
+  home
+  jump next
+
+  mark word
+  move inword
+  read
+  compare 0
+  jump cont when different
+  call startword
+
+  mark cont
+  set 0
+  home
+  jump next
+
+  mark done
+  move lines
+  read
+  push
+  call put
+  move words
+  read
+  push
+  call put
+  move chars
+  read
+  push
+  call put
+  call newline
+  return
+end
diff --git a/src/ast.h b/src/ast.h
new file mode 100644
index 0000000..274dea1
--- /dev/null
+++ b/src/ast.h
@@ -0,0 +1,116 @@
+#ifndef CR_AST_H
+#define CR_AST_H
+
+#include "diag.h"
+#include "lex.h"
+
+/* ---- literal values ---- */
+typedef enum { IM_NONE, IM_INT, IM_FLOAT, IM_CHAR, IM_BOOL } ImmKind;
+
+typedef struct Imm {
+    ImmKind kind;
+    uint64_t u; /* integer magnitude or char code */
+    int neg;     /* integer sign */
+    int b;       /* boolean */
+    double d;    /* float */
+} Imm;
+
+/* ---- type syntax tree ---- */
+typedef enum {
+    TB_BASE,    /* base type word (TokKind stored in base) */
+    TB_ADDR,    /* address <inner> */
+    TB_ARR      /* array [count] of <inner>; count==0 -> unsized */
+} TypeAstKind;
+
+typedef struct TypeAst {
+    TypeAstKind kind;
+    TokKind base;          /* TB_BASE */
+    struct TypeAst* inner; /* TB_ADDR / TB_ARR */
+    uint64_t count;        /* TB_ARR */
+    Loc loc;
+} TypeAst;
+
+/* ---- commands ---- */
+typedef enum {
+    A_MOVE_NAME,   /* a.name */
+    A_MOVE_LIT,    /* a.idx (integer) */
+    A_MOVE_INDEX,  /* working value is the index */
+    A_MOVE_AT,     /* working value is an address */
+    A_BACK, A_HOME,
+    A_ADDRESS_NAME,/* a.name (cell or transformation) */
+    A_ADDRESS_HERE,
+    A_MAKE_VALUE,  /* t + optional name */
+    A_MAKE_ARRAY,  /* t(elem via count) + optional name */
+    A_MAKE_STRING, /* str + optional name */
+    A_MAKE_DYN,    /* size: name or literal; elem type; optional name */
+    A_FREE, A_READ, A_WRITE,
+    A_SET,         /* imm */
+    A_ARITH,       /* arith op + optional imm */
+    A_NEG,
+    A_PUSH, A_POP, A_SWAP,
+    A_CMP,         /* optional imm */
+    A_CONV,        /* t */
+    A_JMP,         /* label + optional cond */
+    A_CALL_NAME, A_CALL_IND,
+    A_RET, A_STOP,
+    A_INPUT, A_OUTPUT, A_OUTPUT_TEXT,
+    A_OPEN, A_CLOSE, A_SYSTEM,
+    A_SPAWN, A_WAIT,
+    A_MARK
+} AstKind;
+
+typedef enum { AR_ADD, AR_SUB, AR_MUL, AR_DIV, AR_MOD } ArithOp;
+
+typedef struct Node {
+    AstKind kind;
+    Loc loc;
+    char* name;      /* move/address/jump/call/spawn/mark names, dyn size cell */
+    char* name2;     /* make dyn: optional bind name; make string: optional name */
+    char* str;       /* make string content (NOT NUL-terminated) */
+    size_t strlen;
+    Imm imm;         /* set/arith/cmp/make dyn size */
+    int has_imm;
+    ArithOp arith;
+    TypeAst* t;      /* make value / convert */
+    TokKind cond;    /* jump condition token */
+    int has_cond;
+    TokKind mode;    /* open mode: T_READ/T_WRITE/T_BOTH */
+} Node;
+
+/* ---- top level ---- */
+typedef struct ParamAst {
+    char* name;
+    TypeAst* t;
+    Loc loc;
+} ParamAst;
+
+typedef struct FnAst {
+    char* name;
+    Loc loc;
+    ParamAst* params;
+    size_t nparams;
+    TypeAst* result; /* NULL = infer */
+    Node* body;      /* command list */
+    size_t nbody;
+} FnAst;
+
+typedef enum { G_VALUE, G_ARRAY, G_STRING } GlobKind;
+
+typedef struct GlobAst {
+    GlobKind kind;
+    char* name;
+    Loc loc;
+    TypeAst* t;      /* G_VALUE / G_ARRAY */
+    uint64_t count;  /* G_ARRAY */
+    char* str;       /* G_STRING */
+    size_t strlen;
+} GlobAst;
+
+typedef struct ProgramAst {
+    FnAst* fns;
+    size_t nfns;
+    GlobAst* globs;
+    size_t nglobs;
+} ProgramAst;
+
+#endif
diff --git a/src/be_c.c b/src/be_c.c
new file mode 100644
index 0000000..51e4ba9
--- /dev/null
+++ b/src/be_c.c
@@ -0,0 +1,850 @@
+#include "be_c.h"
+#include "rt_src.h"
+#include "util.h"
+
+#include <inttypes.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* ------------------------------------------------------------------ */
+/* helpers                                                            */
+/* ------------------------------------------------------------------ */
+
+static const char* ctype(const Type* t) {
+    switch (t->kind) {
+    case TY_NOTHING: return "uint8_t";
+    case TY_BOOL: return "uint8_t";
+    case TY_CHAR: return "uint32_t";
+    case TY_INT8: return "int8_t";
+    case TY_INT16: return "int16_t";
+    case TY_INT32: return "int32_t";
+    case TY_INT64: return "int64_t";
+    case TY_UINT8: return "uint8_t";
+    case TY_UINT16: return "uint16_t";
+    case TY_UINT32: return "uint32_t";
+    case TY_UINT64: return "uint64_t";
+    case TY_FLOAT32: return "float";
+    case TY_FLOAT64: return "double";
+    case TY_ADDR: return "uint64_t";
+    case TY_FUNC: return "CrFnRef*";
+    case TY_ARR: return ctype(t->inner);
+    case TY_INTLIT: return "int64_t";
+    }
+    return "int64_t";
+}
+
+/* element size used for pointer scaling on addresses */
+static uint64_t addr_elem_size(const Type* addr) {
+    const Type* pt = addr->inner;
+    if (pt->kind == TY_ARR) return type_size(pt->inner);
+    return type_size(pt);
+}
+
+static void emit_c_str(Buf* o, const char* s, size_t len) {
+    buf_putc(o, '"');
+    for (size_t i = 0; i < len; i++) {
+        unsigned char c = (unsigned char)s[i];
+        switch (c) {
+        case '"': buf_puts(o, "\\\""); break;
+        case '\\': buf_puts(o, "\\\\"); break;
+        case '\n': buf_puts(o, "\\n"); break;
+        case '\t': buf_puts(o, "\\t"); break;
+        case '\r': buf_puts(o, "\\r"); break;
+        default:
+            if (c < 32 || c > 126) buf_printf(o, "\\%03o", c);
+            else buf_putc(o, (char)c);
+        }
+    }
+    buf_putc(o, '"');
+}
+
+static void emit_imm_int(Buf* o, ImmV imm) {
+    if (imm.kind == IV_INT)
+        buf_printf(o, "(int64_t)%" PRId64, (int64_t)imm.u);
+    else if (imm.kind == IV_UINT)
+        buf_printf(o, "(uint64_t)%" PRIu64 "ULL", imm.u);
+    else
+        buf_printf(o, "(int64_t)0");
+}
+
+/* ------------------------------------------------------------------ */
+/* fn signature                                                       */
+/* ------------------------------------------------------------------ */
+
+static void emit_fn_sig(Buf* o, ProgramIR* p, int fi) {
+    FnIR* fn = &p->fns[fi];
+    buf_printf(o, "static Val cr_fn_%s(", fn->name);
+    if (fn->nparams == 0) {
+        buf_puts(o, "void");
+    } else {
+        for (int i = 0; i < fn->nparams; i++)
+            buf_printf(o, "%sVal p%d", i ? ", " : "", i);
+    }
+    buf_puts(o, ")");
+}
+
+/* ------------------------------------------------------------------ */
+/* locals                                                             */
+/* ------------------------------------------------------------------ */
+
+static void emit_local_decl(Buf* o, LocalIR* l) {
+    if (l->type->kind == TY_ARR) {
+        buf_printf(o, "    %s l_%s[%llu] = {0}", ctype(l->type->inner), l->name,
+                   (unsigned long long)(l->type->count ? l->type->count : 1));
+    } else {
+        buf_printf(o, "    %s l_%s = 0", ctype(l->type), l->name);
+    }
+    buf_puts(o, ";\n");
+}
+
+/* ------------------------------------------------------------------ */
+/* statement emission                                                 */
+/* ------------------------------------------------------------------ */
+
+static void emit_store(Buf* o, Insn* in) {
+    const Type* t = in->t;
+    const char* ct = ctype(t);
+    switch (in->b) {
+    case SC_EXACT:
+        if (ty_is_float(t)) {
+            buf_printf(o, "    *(%s*)F = (%s)wv.d;\n", ct, ct);
+        } else if (t->kind == TY_FUNC) {
+            buf_printf(o, "    *(CrFnRef**)F = wv.p;\n");
+        } else if (ty_is_signed_int(t)) {
+            buf_printf(o, "    *(%s*)F = (%s)wv.i;\n", ct, ct);
+        } else {
+            buf_printf(o, "    *(%s*)F = (%s)wv.u;\n", ct, ct);
+        }
+        break;
+    case SC_INTLIT:
+        if (ty_is_float(t)) {
+            buf_printf(o, "    *(%s*)F = (%s)(int64_t)wv.u;\n", ct, ct);
+        } else {
+            buf_printf(o, "    *(%s*)F = (%s)(int64_t)wv.u;\n", ct, ct);
+        }
+        break;
+    case SC_INTLIT_BOOL:
+        buf_printf(o, "    *(uint8_t*)F = (uint8_t)(int64_t)wv.u;\n");
+        break;
+    case SC_INTLIT_FLOAT:
+        buf_printf(o, "    *(%s*)F = (%s)(int64_t)wv.u;\n", ct, ct);
+        break;
+    case SC_F32:
+        buf_printf(o, "    *(float*)F = (float)wv.d;\n");
+        break;
+    default:
+        buf_printf(o, "    /* store coercion %d */\n", in->b);
+        break;
+    }
+}
+
+/* ------------------------------------------------------------------ */
+/* function emission                                                  */
+/* ------------------------------------------------------------------ */
+
+static void emit_fn(Buf* o, ProgramIR* p, int fi) {
+    FnIR* fn = &p->fns[fi];
+    emit_fn_sig(o, p, fi);
+    buf_puts(o, " {\n");
+    buf_puts(o, "    uint64_t cr_fh0 = cr_ctx.fh_sp;\n");
+    buf_puts(o, "    int cr_cmp = 0;\n");
+    buf_puts(o, "    Val wv; wv.u = 0;\n");
+    buf_puts(o, "    void* F = 0;\n");
+
+    for (int i = 0; i < fn->nlocals; i++) {
+        LocalIR* l = &fn->locals[i];
+        if (l->is_param) continue;
+        emit_local_decl(o, l);
+    }
+    /* param cells */
+    for (int i = 0; i < fn->nparams; i++) {
+        LocalIR* l = &fn->locals[i];
+        Type* t = l->type;
+        if (t->kind == TY_ARR) continue; /* rejected by checker */
+        if (ty_is_float(t)) {
+            if (t->kind == TY_FLOAT32)
+                buf_printf(o, "    float l_%s = (float)p%d.d;\n", l->name, i);
+            else
+                buf_printf(o, "    double l_%s = p%d.d;\n", l->name, i);
+        } else if (t->kind == TY_FUNC) {
+            buf_printf(o, "    CrFnRef* l_%s = p%d.p;\n", l->name, i);
+        } else if (ty_is_signed_int(t)) {
+            buf_printf(o, "    %s l_%s = (%s)p%d.i;\n", ctype(t), l->name, ctype(t), i);
+        } else {
+            buf_printf(o, "    %s l_%s = (%s)p%d.u;\n", ctype(t), l->name, ctype(t), i);
+        }
+    }
+
+    Type* cur = ty_nothing(); /* static type of wv for the backend's own use */
+
+    for (int i = 0; i < fn->nins; i++) {
+        Insn* in = &fn->ins[i];
+        switch (in->op) {
+        case I_LBL:
+            buf_printf(o, "L%d_%d: ;\n", fi, in->a);
+            break;
+        case I_JMP:
+            buf_printf(o, "    goto L%d_%d;\n", fi, in->a);
+            break;
+        case I_JMPCOND: {
+            const char* cond;
+            switch (in->b) {
+            case CMP_LESS: cond = "cr_cmp < 0"; break;
+            case CMP_EQUAL: cond = "cr_cmp == 0"; break;
+            case CMP_GREATER: cond = "cr_cmp > 0"; break;
+            case CMP_DIFFERENT: cond = "cr_cmp != 0"; break;
+            case CMP_LESS_OR_EQUAL: cond = "cr_cmp <= 0"; break;
+            default: cond = "cr_cmp >= 0"; break;
+            }
+            buf_printf(o, "    if (%s) goto L%d_%d;\n", cond, fi, in->a);
+            break;
+        }
+        case I_MOVE_LOCAL:
+            buf_printf(o, "    cr_ctx.fh_sp = cr_fh0; F = &l_%s;\n", fn->locals[in->a].name);
+            break;
+        case I_MOVE_GLOBAL:
+            buf_printf(o, "    cr_ctx.fh_sp = cr_fh0; F = &g_%s;\n", p->globs[in->a].name);
+            break;
+        case I_MOVE_LIT:
+        case I_MOVE_INDEX: {
+            const char* ct = ctype(in->t);
+            uint64_t esz = type_size(in->t);
+            if (in->op == I_MOVE_LIT) {
+                if (in->b > 0)
+                    buf_printf(o, "    crn_check_range((uint64_t)%" PRIu64 ", %d);\n",
+                               in->imm.u, in->b);
+                else if (in->b < 0)
+                    buf_printf(o, "    crn_check_block(F, (uint64_t)%" PRIu64 ", %" PRIu64 ");\n",
+                               in->imm.u, esz);
+                buf_printf(o, "    crn_focus_push(F); F = (%s*)F + %" PRIu64 ";\n", ct, in->imm.u);
+            } else {
+                if (in->b > 0)
+                    buf_printf(o, "    crn_check_range(wv.u, %d);\n", in->b);
+                else if (in->b < 0)
+                    buf_printf(o, "    crn_check_block(F, wv.u, %" PRIu64 ");\n", esz);
+                buf_printf(o, "    crn_focus_push(F); F = (%s*)F + wv.u;\n", ct);
+            }
+            break;
+        }
+        case I_MOVE_AT:
+            buf_puts(o, "    crn_focus_push(F); F = wv.p;\n");
+            break;
+        case I_BACK:
+            buf_puts(o, "    F = (cr_ctx.fh_sp > cr_fh0) ? crn_focus_pop() : 0;\n");
+            break;
+        case I_HOME:
+            buf_puts(o, "    cr_ctx.fh_sp = cr_fh0; F = 0;\n");
+            break;
+        case I_ADDR_HERE:
+            buf_puts(o, "    wv.u = (uint64_t)(uintptr_t)F;\n");
+            cur = in->t;
+            break;
+        case I_ADDR_LOCAL:
+            buf_printf(o, "    wv.u = (uint64_t)(uintptr_t)&l_%s;\n", fn->locals[in->a].name);
+            cur = in->t;
+            break;
+        case I_ADDR_GLOBAL:
+            buf_printf(o, "    wv.u = (uint64_t)(uintptr_t)&g_%s;\n", p->globs[in->a].name);
+            cur = in->t;
+            break;
+        case I_ADDR_FN:
+            buf_printf(o, "    wv.p = (void*)&cr_ref_%s;\n", p->fns[in->a].name);
+            cur = ty_func();
+            break;
+        case I_MAKE_LOCAL: {
+            LocalIR* l = &fn->locals[in->a];
+            if (l->type->kind == TY_ARR)
+                buf_printf(o, "    memset(l_%s, 0, sizeof l_%s);\n", l->name, l->name);
+            else if (l->type->kind == TY_FUNC)
+                buf_printf(o, "    l_%s = 0;\n", l->name);
+            else
+                buf_printf(o, "    l_%s = 0;\n", l->name);
+            break;
+        }
+        case I_MAKE_ANON:
+            buf_printf(o, "    wv.u = (uint64_t)(uintptr_t)crn_alloc(%" PRIu64 ");\n",
+                       type_size(in->t));
+            cur = ty_addr_to(in->t);
+            break;
+        case I_MAKE_DYN: {
+            uint64_t esz = type_size(in->t);
+            if (in->b == 1)
+                buf_printf(o, "    { uint64_t n = (uint64_t)%" PRIu64 ";"
+                              " if (n > 1099511627776ULL) crn_die(\"allocation too large\");"
+                              " wv.u = (uint64_t)(uintptr_t)crn_alloc(n * %" PRIu64 "); }\n",
+                           in->imm.u, esz);
+            else if (in->b == 0)
+                buf_printf(o, "    { uint64_t n = (uint64_t)(int64_t)l_%s;"
+                              " if (n > 1099511627776ULL) crn_die(\"allocation too large\");"
+                              " wv.u = (uint64_t)(uintptr_t)crn_alloc(n * %" PRIu64 "); }\n",
+                           fn->locals[in->a].name, esz);
+            else
+                buf_printf(o, "    { uint64_t n = (uint64_t)(int64_t)g_%s;"
+                              " if (n > 1099511627776ULL) crn_die(\"allocation too large\");"
+                              " wv.u = (uint64_t)(uintptr_t)crn_alloc(n * %" PRIu64 "); }\n",
+                           p->globs[in->a].name, esz);
+            cur = ty_addr_to(ty_array(in->t, 0));
+            break;
+        }
+        case I_MAKE_STRLOCAL: {
+            LocalIR* l = &fn->locals[in->a];
+            buf_printf(o, "    memcpy(l_%s, ", l->name);
+            emit_c_str(o, l->str_init ? l->str_init : "", l->str_initlen);
+            buf_printf(o, ", %llu);\n", (unsigned long long)(l->str_initlen ? l->str_initlen : 1));
+            break;
+        }
+        case I_MAKE_STRANON:
+            buf_printf(o, "    wv.u = (uint64_t)(uintptr_t)cr_str_%d;\n", in->a);
+            cur = in->t;
+            break;
+        case I_BIND_LOCAL:
+            buf_printf(o, "    l_%s = wv.u;\n", fn->locals[in->a].name);
+            break;
+        case I_FREE:
+            buf_puts(o, "    crn_free(wv.p);\n");
+            break;
+        case I_LOAD: {
+            const char* ct = ctype(in->t);
+            if (ty_is_float(in->t))
+                buf_printf(o, "    wv.d = (double)*(%s*)F;\n", ct);
+            else if (in->t->kind == TY_FUNC)
+                buf_puts(o, "    wv.p = *(CrFnRef**)F;\n");
+            else if (ty_is_signed_int(in->t))
+                buf_printf(o, "    wv.i = (int64_t)*(%s*)F;\n", ct);
+            else
+                buf_printf(o, "    wv.u = (uint64_t)*(%s*)F;\n", ct);
+            cur = in->t;
+            break;
+        }
+        case I_STORE:
+            emit_store(o, in);
+            break;
+        case I_SET:
+            if (ty_is_float(in->t) && in->imm.kind == IV_FLOAT)
+                buf_printf(o, "    wv.d = (double)%.17g;\n", in->imm.d);
+            else if (in->imm.kind == IV_UINT)
+                buf_printf(o, "    wv.u = (uint64_t)%" PRIu64 "ULL;\n", in->imm.u);
+            else
+                buf_printf(o, "    wv.u = (uint64_t)%" PRIu64 "ULL;\n", in->imm.u);
+            cur = in->t;
+            break;
+        case I_ADD: case I_SUB: case I_MUL: case I_DIV: case I_MOD: {
+            /* operand first */
+            char opstr = in->op == I_ADD ? '+' : in->op == I_SUB ? '-' :
+                         in->op == I_MUL ? '*' : ' ';
+            const Type* t = in->t;
+            if (ty_is_float(t)) {
+                if (in->b == 1) {
+                    if (in->imm.kind == IV_FLOAT)
+                        buf_printf(o, "    { double b = (double)%.17g; ", in->imm.d);
+                    else if (in->imm.kind == IV_INT)
+                        buf_printf(o, "    { double b = (double)(int64_t)%" PRIu64 "ULL; ", in->imm.u);
+                    else
+                        buf_printf(o, "    { double b = (double)%" PRIu64 "ULL; ", in->imm.u);
+                } else {
+                    buf_puts(o, "    { double b = crn_pop().d; ");
+                }
+                if (in->op == I_DIV)
+                    buf_puts(o, "wv.d = wv.d / b; }\n");
+                else if (in->op == I_MOD)
+                    buf_puts(o, "wv.d = wv.d - b * (wv.d / b); }\n");
+                else if (t->kind == TY_FLOAT32)
+                    buf_printf(o, "wv.d = (double)((float)wv.d %c (float)b); }\n", opstr);
+                else
+                    buf_printf(o, "wv.d = wv.d %c b; }\n", opstr);
+            } else if (in->op == I_DIV || in->op == I_MOD) {
+                const char* dv = in->op == I_DIV ? "/" : "%";
+                if (ty_is_signed_int(t)) {
+                    const char* ct = ctype(t);
+                    if (in->b == 1) {
+                        buf_printf(o, "    { %s b = (%s)", ct, ct);
+                        emit_imm_int(o, in->imm);
+                        buf_printf(o, "; %s a = (%s)wv.i; "
+                                      "if (b == 0) crn_die(\"division by zero\"); "
+                                      "wv.i = (int64_t)(b == -1 ? (%s)(0 - (uint64_t)a) : (a %s b)); }\n",
+                                   ct, ct, ct, dv);
+                    } else {
+                        buf_printf(o, "    { %s b = (%s)crn_pop().i; %s a = (%s)wv.i; "
+                                      "if (b == 0) crn_die(\"division by zero\"); "
+                                      "wv.i = (int64_t)(b == -1 ? (%s)(0 - (uint64_t)a) : (a %s b)); }\n",
+                                   ct, ct, ct, ct, ct, dv);
+                    }
+                } else {
+                    const char* ct = ctype(t);
+                    if (in->b == 1) {
+                        buf_printf(o, "    { %s b = (%s)", ct, ct);
+                        emit_imm_int(o, in->imm);
+                        buf_printf(o, "; if (b == 0) crn_die(\"division by zero\"); "
+                                      "wv.u = (uint64_t)((%s)wv.u %s b); }\n", ct, dv);
+                    } else {
+                        buf_printf(o, "    { %s b = (%s)crn_pop().u; "
+                                      "if (b == 0) crn_die(\"division by zero\"); "
+                                      "wv.u = (uint64_t)((%s)wv.u %s b); }\n", ct, ct, ct, dv);
+                    }
+                }
+            } else if (t->kind == TY_ADDR) {
+                uint64_t esz = addr_elem_size(t);
+                if (in->b == 1) {
+                    buf_puts(o, "    { int64_t n = ");
+                    emit_imm_int(o, in->imm);
+                    buf_printf(o, "; wv.u = (uint64_t)((int64_t)wv.u %c n * (int64_t)%" PRIu64 "); }\n",
+                               opstr, esz);
+                } else {
+                    buf_printf(o, "    { int64_t n = (int64_t)crn_pop().u; "
+                                  "wv.u = (uint64_t)((int64_t)wv.u %c n * (int64_t)%" PRIu64 "); }\n",
+                               opstr, esz);
+                }
+            } else if (in->a == 1 && in->op == I_SUB) {
+                /* pointer difference */
+                uint64_t esz = addr_elem_size(cur);
+                buf_printf(o, "    wv.i = (int64_t)(((%s*)wv.p) - ((%s*)crn_pop().p));\n",
+                           ctype(cur->inner->kind == TY_ARR ? cur->inner->inner : cur->inner),
+                           ctype(cur->inner->kind == TY_ARR ? cur->inner->inner : cur->inner));
+                (void)esz;
+            } else if (ty_is_signed_int(t) || t->kind == TY_INTLIT) {
+                const char* ct = ctype(t);
+                if (in->b == 1) {
+                    buf_printf(o, "    { int64_t b = ");
+                    emit_imm_int(o, in->imm);
+                    buf_printf(o, "; wv.i = (int64_t)(%s)((uint64_t)(int64_t)wv.i %c (uint64_t)b); }\n",
+                               ct, opstr);
+                } else {
+                    buf_printf(o, "    wv.i = (int64_t)(%s)((uint64_t)(int64_t)wv.i %c "
+                                  "(uint64_t)(int64_t)crn_pop().i);\n", ct, opstr);
+                }
+            } else {
+                const char* ct = ctype(t);
+                if (in->b == 1) {
+                    buf_printf(o, "    { uint64_t b = ");
+                    if (in->imm.kind == IV_UINT)
+                        buf_printf(o, "(uint64_t)%" PRIu64 "ULL", in->imm.u);
+                    else
+                        emit_imm_int(o, in->imm);
+                    buf_printf(o, "; wv.u = (uint64_t)(%s)(wv.u %c b); }\n", ct, opstr);
+                } else {
+                    buf_printf(o, "    wv.u = (uint64_t)(%s)(wv.u %c crn_pop().u);\n", ct, opstr);
+                }
+            }
+            if (!(in->op == I_SUB && in->a == 1)) cur = in->t;
+            else cur = ty_int(64);
+            break;
+        }
+        case I_NEG:
+            if (ty_is_float(in->t)) {
+                if (in->t->kind == TY_FLOAT32)
+                    buf_puts(o, "    wv.d = (double)(-(float)wv.d);\n");
+                else
+                    buf_puts(o, "    wv.d = -wv.d;\n");
+            } else {
+                buf_printf(o, "    wv.i = (int64_t)(%s)(0 - (uint64_t)(int64_t)wv.i);\n",
+                           ctype(in->t));
+            }
+            break;
+        case I_PUSH:
+            buf_puts(o, "    crn_push(wv);\n");
+            break;
+        case I_POP:
+            buf_puts(o, "    wv = crn_pop();\n");
+            cur = in->t;
+            break;
+        case I_SWAP:
+            buf_puts(o, "    { Val t = crn_pop(); crn_push(wv); wv = t; }\n");
+            break;
+        case I_CMP: {
+            const Type* t = in->t;
+            if (in->b == 1) {
+                if (ty_is_float(t)) {
+                    if (in->imm.kind == IV_FLOAT)
+                        buf_printf(o, "    { double b = (double)%.17g; ", in->imm.d);
+                    else if (in->imm.kind == IV_INT)
+                        buf_printf(o, "    { double b = (double)(int64_t)%" PRIu64 "ULL; ", in->imm.u);
+                    else
+                        buf_printf(o, "    { double b = (double)%" PRIu64 "ULL; ", in->imm.u);
+                    buf_puts(o, "double a = wv.d; cr_cmp = a < b ? -1 : a > b ? 1 : 0; }\n");
+                } else if (ty_is_signed_int(t) || t->kind == TY_INTLIT) {
+                    buf_puts(o, "    { int64_t b = ");
+                    emit_imm_int(o, in->imm);
+                    buf_puts(o, "; int64_t a = wv.i; cr_cmp = a < b ? -1 : a > b ? 1 : 0; }\n");
+                } else {
+                    buf_puts(o, "    { uint64_t b = ");
+                    if (in->imm.kind == IV_UINT)
+                        buf_printf(o, "(uint64_t)%" PRIu64 "ULL", in->imm.u);
+                    else
+                        emit_imm_int(o, in->imm);
+                    buf_puts(o, "; uint64_t a = wv.u; cr_cmp = a < b ? -1 : a > b ? 1 : 0; }\n");
+                }
+            } else {
+                if (ty_is_float(t)) {
+                    buf_puts(o, "    { double b = crn_pop().d; double a = wv.d; "
+                                "cr_cmp = a < b ? -1 : a > b ? 1 : 0; }\n");
+                } else if (ty_is_signed_int(t) || t->kind == TY_INTLIT) {
+                    buf_puts(o, "    { int64_t b = crn_pop().i; int64_t a = wv.i; "
+                                "cr_cmp = a < b ? -1 : a > b ? 1 : 0; }\n");
+                } else {
+                    buf_puts(o, "    { uint64_t b = crn_pop().u; uint64_t a = wv.u; "
+                                "cr_cmp = a < b ? -1 : a > b ? 1 : 0; }\n");
+                }
+            }
+            break;
+        }
+        case I_CONV: {
+            const Type* from = cur;
+            Type* to = in->t;
+            const char* ct = ctype(to);
+            if (ty_is_float(from)) {
+                if (to->kind == TY_FLOAT64) {
+                    /* no change needed */
+                } else if (to->kind == TY_FLOAT32) {
+                    buf_puts(o, "    wv.d = (double)(float)wv.d;\n");
+                } else if (to->kind == TY_BOOL) {
+                    buf_puts(o, "    wv.u = (uint64_t)(wv.d != 0.0);\n");
+                } else if (ty_is_signed_int(to)) {
+                    /* saturate at the target width */
+                    if (to->kind == TY_INT8)
+                        buf_puts(o, "    wv.i = (int64_t)crn_d2i8(wv.d);\n");
+                    else if (to->kind == TY_INT16)
+                        buf_puts(o, "    wv.i = (int64_t)crn_d2i16(wv.d);\n");
+                    else if (to->kind == TY_INT32)
+                        buf_puts(o, "    wv.i = (int64_t)crn_d2i32(wv.d);\n");
+                    else
+                        buf_puts(o, "    wv.i = crn_d2i64(wv.d);\n");
+                } else {
+                    if (to->kind == TY_UINT8)
+                        buf_puts(o, "    wv.u = (uint64_t)crn_d2u8(wv.d);\n");
+                    else if (to->kind == TY_UINT16)
+                        buf_puts(o, "    wv.u = (uint64_t)crn_d2u16(wv.d);\n");
+                    else if (to->kind == TY_UINT32)
+                        buf_puts(o, "    wv.u = (uint64_t)crn_d2u32(wv.d);\n");
+                    else
+                        buf_puts(o, "    wv.u = crn_d2u64(wv.d);\n");
+                }
+            } else if (to->kind == TY_FLOAT64) {
+                if (from->kind == TY_INTLIT || ty_is_signed_int(from))
+                    buf_puts(o, "    wv.d = (double)(int64_t)wv.u;\n");
+                else
+                    buf_puts(o, "    wv.d = (double)wv.u;\n");
+            } else if (to->kind == TY_FLOAT32) {
+                if (from->kind == TY_INTLIT || ty_is_signed_int(from))
+                    buf_puts(o, "    wv.d = (double)(float)(int64_t)wv.u;\n");
+                else
+                    buf_puts(o, "    wv.d = (double)(float)wv.u;\n");
+            } else if (to->kind == TY_BOOL) {
+                if (from->kind == TY_INTLIT || ty_is_signed_int(from))
+                    buf_puts(o, "    wv.u = (uint64_t)((int64_t)wv.u != 0);\n");
+                else
+                    buf_puts(o, "    wv.u = (uint64_t)(wv.u != 0);\n");
+            } else if (to->kind == TY_FUNC || from->kind == TY_FUNC) {
+                /* impossible */
+            } else if (ty_is_signed_int(to)) {
+                buf_printf(o, "    wv.i = (int64_t)(%s)wv.u;\n", ct);
+            } else {
+                buf_printf(o, "    wv.u = (uint64_t)(%s)wv.u;\n", ct);
+            }
+            cur = to;
+            break;
+        }
+        case I_CALL: {
+            FnIR* callee = &p->fns[in->a];
+            int np = callee->nparams;
+            if (np == 0) {
+                if (callee->result->kind != TY_NOTHING)
+                    buf_printf(o, "    wv = cr_fn_%s();\n", callee->name);
+                else
+                    buf_printf(o, "    cr_fn_%s();\n", callee->name);
+            } else {
+                buf_printf(o, "    { uint64_t s = cr_ctx.sp; cr_ctx.sp = s - %d;\n", np);
+                if (callee->result->kind != TY_NOTHING)
+                    buf_puts(o, "    wv = ");
+                else
+                    buf_puts(o, "    ");
+                buf_printf(o, "cr_fn_%s(", callee->name);
+                for (int k = 0; k < np; k++) {
+                    Type* want = callee->locals[k].type;
+                    unsigned kind = ((unsigned)in->b >> (2 * k)) & 3u;
+                    if (k) buf_puts(o, ", ");
+                    if (kind == 0) {
+                        buf_printf(o, "cr_ctx.stk[s - %d]", np - k);
+                    } else if (kind == 1) {
+                        if (want->kind == TY_FLOAT32)
+                            buf_printf(o, "(Val){.d = (double)(float)(int64_t)cr_ctx.stk[s - %d].u}",
+                                       np - k);
+                        else if (want->kind == TY_FLOAT64)
+                            buf_printf(o, "(Val){.d = (double)(int64_t)cr_ctx.stk[s - %d].u}",
+                                       np - k);
+                        else if (ty_is_signed_int(want))
+                            buf_printf(o, "(Val){.u = (uint64_t)(%s)cr_ctx.stk[s - %d].u}",
+                                       ctype(want), np - k);
+                        else
+                            buf_printf(o, "(Val){.u = (uint64_t)(%s)cr_ctx.stk[s - %d].u}",
+                                       ctype(want), np - k);
+                    } else {
+                        buf_printf(o, "(Val){.d = (double)(float)cr_ctx.stk[s - %d].d}", np - k);
+                    }
+                }
+                buf_puts(o, "); }\n");
+            }
+            if (callee->result->kind != TY_NOTHING) cur = callee->result;
+            break;
+        }
+        case I_CALLIND:
+            buf_puts(o, "    { CrFnRef* r = (CrFnRef*)wv.p; "
+                        "if (r->np != 0) crn_die(\"indirect call of a transformation "
+                        "that takes parameters\"); r->fn(); wv.u = 0; }\n");
+            cur = ty_nothing();
+            break;
+        case I_RET:
+            buf_puts(o, "    cr_ctx.fh_sp = cr_fh0; return wv;\n");
+            break;
+        case I_STOP:
+            if (ty_is_float(cur))
+                buf_puts(o, "    crn_stop(crn_d2i64(wv.d));\n");
+            else if (cur->kind == TY_NOTHING || cur->kind == TY_FUNC)
+                buf_puts(o, "    crn_stop(0);\n");
+            else
+                buf_puts(o, "    crn_stop((int64_t)wv.u);\n");
+            break;
+        case I_INPUT:
+            buf_puts(o, "    wv.i = crn_input();\n");
+            cur = ty_int(64);
+            break;
+        case I_OUTPUT:
+            if (ty_is_float(cur)) {
+                if (cur->kind == TY_FLOAT32)
+                    buf_puts(o, "    crn_out_f32((float)wv.d);\n");
+                else
+                    buf_puts(o, "    crn_out_f64(wv.d);\n");
+            } else if (cur->kind == TY_CHAR || cur->kind == TY_UINT8) {
+                buf_puts(o, "    crn_out_char((unsigned)wv.u);\n");
+            } else if (cur->kind == TY_BOOL) {
+                buf_puts(o, "    crn_out_bool((int)wv.u);\n");
+            } else if (cur->kind == TY_ADDR) {
+                buf_puts(o, "    crn_out_addr(wv.u);\n");
+            } else if (cur->kind == TY_INTLIT || ty_is_signed_int(cur)) {
+                buf_puts(o, "    crn_out_i64((int64_t)wv.u);\n");
+            } else {
+                buf_puts(o, "    crn_out_u64(wv.u);\n");
+            }
+            break;
+        case I_OUTTEXT:
+            buf_puts(o, "    crn_out_text((uint8_t*)(uintptr_t)wv.u);\n");
+            break;
+        case I_OPEN:
+            buf_printf(o, "    wv.i = crn_open((uint8_t*)(uintptr_t)wv.u, %d);\n", in->b);
+            cur = ty_int(64);
+            break;
+        case I_CLOSE:
+            buf_puts(o, "    wv.i = crn_close((int64_t)wv.u);\n");
+            cur = ty_int(64);
+            break;
+        case I_SYSTEM:
+            buf_puts(o, "    wv.i = crn_system((uint8_t*)(uintptr_t)wv.u);\n");
+            cur = ty_int(64);
+            break;
+        case I_SPAWN:
+            buf_printf(o, "    crn_spawn(&cr_ref_%s);\n", p->fns[in->a].name);
+            break;
+        case I_WAIT:
+            buf_puts(o, "    crn_wait(); wv.i = 0;\n");
+            cur = ty_int(64);
+            break;
+        }
+    }
+    /* implicit return handled by checker (I_RET always present or unreachable) */
+    buf_puts(o, "    cr_ctx.fh_sp = cr_fh0; return wv;\n");
+    buf_puts(o, "}\n\n");
+}
+
+/* ------------------------------------------------------------------ */
+/* program emission                                                   */
+/* ------------------------------------------------------------------ */
+
+void be_c_emit(ProgramIR* p, Buf* out) {
+    buf_puts(out, "/* generated by the Cryn compiler - do not edit */\n");
+    buf_puts(out, "/* Cryn source compiled to portable C11 */\n\n");
+    buf_puts(out, cr_rt_h_src);
+    buf_putc(out, '\n');
+    buf_puts(out, "#include <stdlib.h>\n#include <string.h>\n\n");
+    buf_puts(out, cr_rt_c_src);
+    buf_putc(out, '\n');
+
+    /* string table */
+    for (int i = 0; i < p->nstrs; i++) {
+        StrIR* s = &p->strs[i];
+        buf_printf(out, "static const uint8_t cr_str_%d[] = ", i);
+        emit_c_str(out, s->bytes, s->len + 1);
+        buf_puts(out, ";\n");
+    }
+    buf_putc(out, '\n');
+
+    /* world cells */
+    for (int i = 0; i < p->nglobs; i++) {
+        GlobIR* g = &p->globs[i];
+        if (g->type->kind == TY_ARR) {
+            buf_printf(out, "static %s g_%s[%llu] = {0};\n", ctype(g->type->inner), g->name,
+                       (unsigned long long)g->type->count);
+        } else {
+            buf_printf(out, "static %s g_%s = 0;\n", ctype(g->type), g->name);
+        }
+    }
+    /* string globals need init */
+    for (int i = 0; i < p->nglobs; i++) {
+        GlobIR* g = &p->globs[i];
+        if (g->str_init) {
+            buf_printf(out, "static const uint8_t g_%s_init[] = ", g->name);
+            emit_c_str(out, g->str_init, g->str_initlen);
+            buf_puts(out, ";\n");
+        }
+    }
+    buf_putc(out, '\n');
+
+    /* forward declarations */
+    for (int i = 0; i < p->nfns; i++) {
+        emit_fn_sig(out, p, i);
+        buf_puts(out, ";\n");
+    }
+    buf_putc(out, '\n');
+
+    /* transformation references */
+    for (int i = 0; i < p->nfns; i++) {
+        FnIR* fn = &p->fns[i];
+        buf_printf(out, "static CrFnRef cr_ref_%s = {(Val(*)(void))cr_fn_%s, %d};\n",
+                   fn->name, fn->name, fn->nparams);
+    }
+    buf_putc(out, '\n');
+
+    /* string global initializers run before main */
+    buf_puts(out, "static void crn_init_world(void) {\n");
+    for (int i = 0; i < p->nglobs; i++) {
+        GlobIR* g = &p->globs[i];
+        if (g->str_init)
+            buf_printf(out, "    memcpy(g_%s, g_%s_init, %llu);\n", g->name, g->name,
+                       (unsigned long long)g->str_initlen);
+    }
+    buf_puts(out, "}\n\n");
+
+    /* functions */
+    for (int i = 0; i < p->nfns; i++)
+        emit_fn(out, p, i);
+
+    /* entry point */
+    buf_puts(out, "int main(void) {\n    crn_init();\n    crn_init_world();\n");
+    buf_puts(out, "    cr_fn_main();\n    fflush(NULL);\n    return 0;\n}\n");
+}
+
+/* ------------------------------------------------------------------ */
+/* IR dump                                                            */
+/* ------------------------------------------------------------------ */
+
+static const char* irop_name(IROp op) {
+    switch (op) {
+    case I_LBL: return "label";
+    case I_JMP: return "jump";
+    case I_JMPCOND: return "jump-if";
+    case I_MOVE_LOCAL: return "move-local";
+    case I_MOVE_GLOBAL: return "move-global";
+    case I_MOVE_LIT: return "move-idx";
+    case I_MOVE_INDEX: return "move-index";
+    case I_MOVE_AT: return "move-at";
+    case I_BACK: return "back";
+    case I_HOME: return "home";
+    case I_ADDR_HERE: return "address-here";
+    case I_ADDR_LOCAL: return "address-local";
+    case I_ADDR_GLOBAL: return "address-global";
+    case I_ADDR_FN: return "address-fn";
+    case I_MAKE_LOCAL: return "make-local";
+    case I_MAKE_ANON: return "make-anon";
+    case I_MAKE_DYN: return "make-dyn";
+    case I_MAKE_STRLOCAL: return "make-str-local";
+    case I_MAKE_STRANON: return "make-str-anon";
+    case I_BIND_LOCAL: return "bind-local";
+    case I_FREE: return "free";
+    case I_LOAD: return "load";
+    case I_STORE: return "store";
+    case I_SET: return "set";
+    case I_ADD: return "add";
+    case I_SUB: return "subtract";
+    case I_MUL: return "multiply";
+    case I_DIV: return "divide";
+    case I_MOD: return "remainder";
+    case I_NEG: return "negate";
+    case I_PUSH: return "push";
+    case I_POP: return "pop";
+    case I_SWAP: return "swap";
+    case I_CMP: return "compare";
+    case I_CONV: return "convert";
+    case I_CALL: return "call";
+    case I_CALLIND: return "call-indirect";
+    case I_RET: return "return";
+    case I_STOP: return "stop";
+    case I_INPUT: return "input";
+    case I_OUTPUT: return "output";
+    case I_OUTTEXT: return "output-text";
+    case I_OPEN: return "open";
+    case I_CLOSE: return "close";
+    case I_SYSTEM: return "system";
+    case I_SPAWN: return "spawn";
+    case I_WAIT: return "wait";
+    }
+    return "?";
+}
+
+void ir_dump(ProgramIR* p, Buf* out) {
+    for (int i = 0; i < p->nglobs; i++) {
+        GlobIR* g = &p->globs[i];
+        char b[128];
+        buf_printf(out, "world %s : %s\n", g->name, type_name(g->type, b, sizeof b));
+    }
+    for (int i = 0; i < p->nfns; i++) {
+        FnIR* fn = &p->fns[i];
+        char b[128];
+        buf_printf(out, "\ntransformation %s(", fn->name);
+        for (int j = 0; j < fn->nparams; j++) {
+            if (j) buf_puts(out, " ");
+            buf_printf(out, "%s:%s", fn->locals[j].name,
+                       type_name(fn->locals[j].type, b, sizeof b));
+        }
+        buf_printf(out, ") result %s\n",
+                   type_name(fn->result ? fn->result : ty_nothing(), b, sizeof b));
+        for (int j = 0; j < fn->nins; j++) {
+            Insn* in = &fn->ins[j];
+            buf_printf(out, "  %4d %s", j, irop_name(in->op));
+            switch (in->op) {
+            case I_LBL: case I_JMP:
+                buf_printf(out, " %s", fn->label_names && in->a < fn->nlabels
+                                    ? fn->label_names[in->a] : "?");
+                break;
+            case I_JMPCOND:
+                buf_printf(out, " %s when %d", fn->label_names[in->a], in->b);
+                break;
+            case I_MOVE_LOCAL: case I_ADDR_LOCAL: case I_MAKE_LOCAL:
+            case I_MAKE_STRLOCAL: case I_BIND_LOCAL:
+                if (in->a >= 0 && in->a < fn->nlocals)
+                    buf_printf(out, " %s", fn->locals[in->a].name);
+                break;
+            case I_MOVE_GLOBAL: case I_ADDR_GLOBAL:
+                buf_printf(out, " %s", p->globs[in->a].name);
+                break;
+            case I_ADDR_FN: case I_CALL: case I_SPAWN:
+                buf_printf(out, " %s", p->fns[in->a].name);
+                break;
+            case I_MOVE_LIT:
+                buf_printf(out, " %llu", (unsigned long long)in->imm.u);
+                break;
+            default:
+                if (in->imm.kind == IV_INT)
+                    buf_printf(out, " %lld", (long long)(int64_t)in->imm.u);
+                else if (in->imm.kind == IV_UINT)
+                    buf_printf(out, " %llu", (unsigned long long)in->imm.u);
+                else if (in->imm.kind == IV_FLOAT)
+                    buf_printf(out, " %.17g", in->imm.d);
+                break;
+            }
+            if (in->t) {
+                char b2[128];
+                buf_printf(out, "  [%s]", type_name(in->t, b2, sizeof b2));
+            }
+            buf_putc(out, '\n');
+        }
+    }
+}
diff --git a/src/be_c.h b/src/be_c.h
new file mode 100644
index 0000000..4506f56
--- /dev/null
+++ b/src/be_c.h
@@ -0,0 +1,9 @@
+#ifndef CR_BE_C_H
+#define CR_BE_C_H
+
+#include "ir.h"
+
+/* Emit a complete, self-contained C translation unit for the program. */
+void be_c_emit(ProgramIR* p, Buf* out);
+
+#endif
diff --git a/src/check.c b/src/check.c
new file mode 100644
index 0000000..51f34bd
--- /dev/null
+++ b/src/check.c
@@ -0,0 +1,1607 @@
+#include "check.h"
+#include "util.h"
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/*
+ * The checker is a small abstract interpreter over the abstract machine:
+ *
+ *   - working value: one static type (TY_INTLIT keeps the literal value)
+ *   - focus: a stack of abstract cells; entry 0 is always the frame root
+ *   - value stack: a list of static types
+ *   - comparison state: valid/invalid
+ *
+ * The body is walked in syntactic order. Every `jump` merges the current
+ * abstract state into the target label's state; all merges must agree
+ * exactly. Because a label may be targeted from code that appears before
+ * the label while the fallthrough into it is dead, the walk is repeated
+ * until no label gains a new state, then one final emitting walk runs.
+ *
+ * A transformation's result type is the working value type at its first
+ * syntactic `return` (or at the implicit return at `end`) unless declared
+ * with `result <type>`.
+ */
+
+#define MAX_FOC  256
+#define MAX_STK  1024
+#define MAX_LOCALS 256
+#define MAX_LABELS 256
+
+typedef struct FocusEnt {
+    int is_frame;
+    Type* cell; /* cell type when !is_frame */
+} FocusEnt;
+
+typedef struct CState {
+    Type* wv;
+    FocusEnt foc[MAX_FOC];
+    int nfoc;
+    Type* stk[MAX_STK];
+    int nstk;
+    int cmp;
+    int dead; /* 0 = live; 1 = unreachable (see unk) */
+    int unk;  /* dead only because label state is not yet known */
+} CState;
+
+typedef struct Chk {
+    ProgramIR* prog;
+    FnIR* fn;
+    FnAst* fnast;
+    CState st;
+
+    struct { char* name; CState* state; Loc loc; } labels[MAX_LABELS];
+    int nlabels;
+
+    int emitting;   /* final round: emit IR */
+    int erroring;   /* final round: report unreachable errors */
+    int changed;    /* a label gained/updated state this round */
+    int blocked;    /* a callee result is not yet inferred */
+
+    Type* result_first;
+    int result_seen;
+} Chk;
+
+/* ------------------------------------------------------------------ */
+/* diagnostics                                                        */
+/* ------------------------------------------------------------------ */
+
+static void err(Loc loc, const char* fmt, ...) {
+    va_list ap;
+    char msg[512];
+    va_start(ap, fmt);
+    vsnprintf(msg, sizeof msg, fmt, ap);
+    va_end(ap);
+    crn_error(loc, "%s", msg);
+}
+
+/* ------------------------------------------------------------------ */
+/* types                                                              */
+/* ------------------------------------------------------------------ */
+
+static Type* ast_to_type(TypeAst* t, Loc loc) {
+    if (!t) return ty_nothing();
+    switch (t->kind) {
+    case TB_BASE:
+        switch (t->base) {
+        case T_NOTHING: return ty_nothing();
+        case T_BOOL: return ty_bool();
+        case T_CHAR_T: return ty_char();
+        case T_INT8: return ty_int(8);
+        case T_INT16: return ty_int(16);
+        case T_INT32: return ty_int(32);
+        case T_INT64: return ty_int(64);
+        case T_UINT8: return ty_uint(8);
+        case T_UINT16: return ty_uint(16);
+        case T_UINT32: return ty_uint(32);
+        case T_UINT64: return ty_uint(64);
+        case T_FLOAT32: return ty_float32();
+        case T_FLOAT64: return ty_float64();
+        case T_TRANSFORM: return ty_func();
+        default:
+            err(loc, "not a base type");
+            return ty_nothing();
+        }
+    case TB_ADDR:
+        return ty_addr_to(t->inner ? ast_to_type(t->inner, loc) : ty_nothing());
+    case TB_ARR: {
+        if (!t->inner) { err(loc, "array needs an element type"); return ty_nothing(); }
+        Type* elem = ast_to_type(t->inner, t->inner->loc);
+        if (elem->kind == TY_FUNC) err(loc, "arrays of transform references are not supported");
+        if (elem->kind == TY_ARR && elem->count == 0)
+            err(loc, "array element type cannot be an unsized array");
+        return ty_array(elem, t->count);
+    }
+    }
+    return ty_nothing();
+}
+
+static int lit_value_int(Type* t, int64_t* out) {
+    if (t->kind != TY_INTLIT) return 0;
+    uint64_t m = t->litval;
+    if (t->litneg) {
+        if (m == (1ULL << 63)) *out = INT64_MIN;
+        else *out = -(int64_t)m;
+    } else {
+        if (m > (uint64_t)INT64_MAX) return 0;
+        *out = (int64_t)m;
+    }
+    return 1;
+}
+
+static Type* intlit_from_int64(int64_t v) {
+    if (v < 0) {
+        if (v == INT64_MIN) return ty_intlit(1ULL << 63, 1);
+        return ty_intlit((uint64_t)(-v), 1);
+    }
+    return ty_intlit((uint64_t)v, 0);
+}
+
+/* ------------------------------------------------------------------ */
+/* IR emission                                                        */
+/* ------------------------------------------------------------------ */
+
+static Insn* emit(Chk* c, IROp op, Loc loc) {
+    if (c->fn->nins == c->fn->cap) {
+        c->fn->cap = c->fn->cap ? c->fn->cap * 2 : 64;
+        c->fn->ins = xrealloc(c->fn->ins, (size_t)c->fn->cap * sizeof(Insn));
+    }
+    Insn* in = &c->fn->ins[c->fn->nins++];
+    memset(in, 0, sizeof *in);
+    in->op = op;
+    in->loc = loc;
+    return in;
+}
+
+static void set_imm(Insn* in, Imm* m) {
+    switch (m->kind) {
+    case IM_INT:
+        if (m->neg) {
+            in->imm.kind = IV_INT;
+            in->imm.u = m->u == (1ULL << 63) ? m->u : (uint64_t)(0 - m->u);
+        } else if (m->u <= (uint64_t)INT64_MAX) {
+            in->imm.kind = IV_INT;
+            in->imm.u = m->u;
+        } else {
+            in->imm.kind = IV_UINT;
+            in->imm.u = m->u;
+        }
+        break;
+    case IM_FLOAT:
+        in->imm.kind = IV_FLOAT;
+        in->imm.d = m->d;
+        break;
+    case IM_CHAR:
+        in->imm.kind = IV_UINT;
+        in->imm.u = m->u;
+        break;
+    case IM_BOOL:
+        in->imm.kind = IV_UINT;
+        in->imm.u = (uint64_t)m->b;
+        break;
+    default:
+        break;
+    }
+}
+
+/* ------------------------------------------------------------------ */
+/* symbols                                                            */
+/* ------------------------------------------------------------------ */
+
+static int find_local(Chk* c, const char* name) {
+    for (int i = 0; i < c->fn->nlocals; i++)
+        if (strcmp(c->fn->locals[i].name, name) == 0) return i;
+    return -1;
+}
+
+static int find_label(Chk* c, const char* name) {
+    for (int i = 0; i < c->nlabels; i++)
+        if (strcmp(c->labels[i].name, name) == 0) return i;
+    return -1;
+}
+
+static int find_fn(Chk* c, const char* name) {
+    return prog_find_fn(c->prog, name);
+}
+
+static int find_glob(Chk* c, const char* name) {
+    return prog_find_glob(c->prog, name);
+}
+
+/* ------------------------------------------------------------------ */
+/* abstract state                                                     */
+/* ------------------------------------------------------------------ */
+
+static void st_init(Chk* c) {
+    memset(&c->st, 0, sizeof c->st);
+    c->st.wv = ty_nothing();
+    c->st.foc[0].is_frame = 1;
+    c->st.nfoc = 1;
+    c->st.dead = 0;
+    c->st.unk = 0;
+}
+
+static void st_clone(CState* d, CState* s) { *d = *s; }
+
+/* join two static working-value/stack types for control flow merges.
+   Integer literals widen to the type they fit; two literals join to int64. */
+static Type* join_types(Type* a, Type* b) {
+    if (type_eq(a, b)) return a;
+    if (a->kind == TY_INTLIT && b->kind == TY_INTLIT) return ty_int(64);
+    if (a->kind == TY_INTLIT && (ty_is_numeric(b) || b->kind == TY_CHAR || b->kind == TY_BOOL))
+        return intlit_fits(a->litval, a->litneg, b) ? b : NULL;
+    if (b->kind == TY_INTLIT && (ty_is_numeric(a) || a->kind == TY_CHAR || a->kind == TY_BOOL))
+        return intlit_fits(b->litval, b->litneg, a) ? a : NULL;
+    return NULL;
+}
+
+static int st_merge(CState* into, CState* from, Loc loc, const char* what) {
+    if (into->dead || from->dead) return 1;
+    int changed = 0;
+    Type* jwv = join_types(into->wv, from->wv);
+    if (!jwv) {
+        char b1[128], b2[128];
+        err(loc, "%s: working value type differs between paths (%s vs %s); "
+                 "normalize it with 'set' before entering the loop or branch",
+            what, type_name(into->wv, b1, sizeof b1), type_name(from->wv, b2, sizeof b2));
+        return 0;
+    }
+    if (!type_eq(jwv, into->wv)) { into->wv = jwv; changed = 1; }
+    if (into->nfoc != from->nfoc) {
+        err(loc, "%s: focus depth differs between paths (%d vs %d)", what, into->nfoc, from->nfoc);
+        return 0;
+    }
+    for (int i = 0; i < into->nfoc; i++) {
+        if (into->foc[i].is_frame != from->foc[i].is_frame ||
+            (!into->foc[i].is_frame && !type_eq(into->foc[i].cell, from->foc[i].cell))) {
+            err(loc, "%s: focus differs between paths at depth %d", what, i);
+            return 0;
+        }
+    }
+    if (into->nstk != from->nstk) {
+        err(loc, "%s: value stack depth differs between paths (%d vs %d)", what, into->nstk, from->nstk);
+        return 0;
+    }
+    for (int i = 0; i < into->nstk; i++) {
+        Type* js = join_types(into->stk[i], from->stk[i]);
+        if (!js) {
+            char b1[128], b2[128];
+            err(loc, "%s: value stack slot %d differs between paths (%s vs %s)",
+                what, i, type_name(into->stk[i], b1, sizeof b1), type_name(from->stk[i], b2, sizeof b2));
+            return 0;
+        }
+        if (!type_eq(js, into->stk[i])) { into->stk[i] = js; changed = 1; }
+    }
+    if (from->cmp && !into->cmp) {
+        into->cmp = 1;
+        changed = 1;
+    }
+    return changed ? 2 : 1;
+}
+
+/* merge the current state into a label; creates it if needed.
+   returns: 0 = error, 1 = ok (no change), 2 = ok (label state created/strengthened) */
+static int label_merge(Chk* c, int li, Loc loc) {
+    if (c->st.dead) return 1;
+    if (!c->labels[li].state) {
+        c->labels[li].state = xmalloc(sizeof(CState));
+        st_clone(c->labels[li].state, &c->st);
+        c->changed = 1;
+        return 2;
+    }
+    return st_merge(c->labels[li].state, &c->st, loc, "control flow joins at this label");
+}
+
+/* ------------------------------------------------------------------ */
+/* numeric type pairing                                               */
+/* ------------------------------------------------------------------ */
+
+static Type* numeric_pair_type(Type* a, Type* b) {
+    if (a->kind == TY_INTLIT && b->kind == TY_INTLIT) return ty_int(64);
+    if (a->kind == TY_INTLIT) {
+        if (ty_is_int(b) || ty_is_float(b) || b->kind == TY_CHAR) return b;
+        if (b->kind == TY_BOOL) return ty_bool();
+        return NULL;
+    }
+    if (b->kind == TY_INTLIT) {
+        if (ty_is_int(a) || ty_is_float(a) || a->kind == TY_CHAR) return a;
+        if (a->kind == TY_BOOL) return ty_bool();
+        return NULL;
+    }
+    if (ty_is_float(a) && ty_is_float(b)) return a->kind == TY_FLOAT64 ? a : b;
+    if (ty_is_float(a) || ty_is_float(b)) return ty_float64();
+    if ((ty_is_int(a) || a->kind == TY_CHAR) && (ty_is_int(b) || b->kind == TY_CHAR)) {
+        if (type_eq(a, b)) return a;
+        int aw = a->kind == TY_CHAR ? 32 : ty_int_bits(a);
+        int bw = b->kind == TY_CHAR ? 32 : ty_int_bits(b);
+        int as = a->kind == TY_CHAR ? 0 : ty_is_signed_int(a);
+        int bs = b->kind == TY_CHAR ? 0 : ty_is_signed_int(b);
+        int w = aw > bw ? aw : bw;
+        int sg = as || bs;
+        return sg ? ty_int(w) : ty_uint(w);
+    }
+    if (a->kind == TY_BOOL && b->kind == TY_BOOL) return ty_bool();
+    return NULL;
+}
+
+/* intlit operand must fit the resolved integer type */
+static int lit_fits_rt(Type* lit, Type* rt, Loc loc, const char* what) {
+    if (lit->kind != TY_INTLIT) return 1;
+    if (ty_is_int(rt) || rt->kind == TY_CHAR || rt->kind == TY_BOOL) {
+        if (!intlit_fits(lit->litval, lit->litneg, rt)) {
+            char b[128];
+            err(loc, "%s literal does not fit %s; use 'convert'",
+                what, type_name(rt, b, sizeof b));
+            return 0;
+        }
+    }
+    return 1;
+}
+
+/* ------------------------------------------------------------------ */
+/* focus                                                              */
+/* ------------------------------------------------------------------ */
+
+static FocusEnt* focus_top(Chk* c) { return &c->st.foc[c->st.nfoc - 1]; }
+
+static void focus_push_cell(Chk* c, Type* t, Loc loc) {
+    if (c->st.nfoc >= MAX_FOC) {
+        err(loc, "focus nesting too deep (limit %d)", MAX_FOC);
+        c->st.dead = 1;
+        return;
+    }
+    c->st.foc[c->st.nfoc].is_frame = 0;
+    c->st.foc[c->st.nfoc].cell = t;
+    c->st.nfoc++;
+}
+
+/* ------------------------------------------------------------------ */
+/* the per-command checker (one abstract step + IR emission)          */
+/* ------------------------------------------------------------------ */
+
+static void check_mark(Chk* c, Node* n);
+
+static void check_insn(Chk* c, Node* n) {
+    if (n->kind == A_MARK) { check_mark(c, n); return; }
+    if (c->st.dead) {
+        if (c->st.unk) return; /* not yet known; re-walk later */
+        if (c->erroring) err(n->loc, "unreachable command");
+        return;
+    }
+    FnIR* fn = c->fn;
+    CState* st = &c->st;
+
+    switch (n->kind) {
+    /* ---------------- navigation ---------------- */
+    case A_MOVE_NAME: {
+        /* Named connections resolve against the frame root and anchor the
+           focus: the history is reset to [frame, cell]. Only indexed and
+           address moves are relative (they push onto the history). */
+        int li = find_local(c, n->name);
+        if (li >= 0) {
+            Type* t = fn->locals[li].type;
+            Insn* in = emit(c, I_MOVE_LOCAL, n->loc);
+            in->a = li;
+            in->t = t;
+            st->nfoc = 1;
+            focus_push_cell(c, t, n->loc);
+            return;
+        }
+        int gi = find_glob(c, n->name);
+        if (gi >= 0) {
+            Type* t = c->prog->globs[gi].type;
+            Insn* in = emit(c, I_MOVE_GLOBAL, n->loc);
+            in->a = gi;
+            in->t = t;
+            st->nfoc = 1;
+            focus_push_cell(c, t, n->loc);
+            return;
+        }
+        err(n->loc, "no cell named '%s' in the current frame", n->name);
+        return;
+    }
+    case A_MOVE_LIT: {
+        FocusEnt* f = focus_top(c);
+        if (f->is_frame || f->cell->kind != TY_ARR) {
+            err(n->loc, "focus must be an array or block to move by index");
+            return;
+        }
+        if (n->imm.neg || n->imm.u > 0x7FFFFFFFULL) {
+            err(n->loc, "array index literal out of range");
+            return;
+        }
+        if (f->cell->count && n->imm.u >= f->cell->count) {
+            char b[128];
+            err(n->loc, "index %llu out of bounds for %s",
+                (unsigned long long)n->imm.u, type_name(f->cell, b, sizeof b));
+            return;
+        }
+        Insn* in = emit(c, I_MOVE_LIT, n->loc);
+        in->imm.kind = IV_UINT;
+        in->imm.u = n->imm.u;
+        in->t = f->cell->inner;
+        in->b = (int)(f->cell->count ? f->cell->count : 0);
+        if (f->cell->count == 0) in->b = -1; /* unsized: runtime header check */
+        focus_push_cell(c, f->cell->inner, n->loc);
+        return;
+    }
+    case A_MOVE_INDEX: {
+        FocusEnt* f = focus_top(c);
+        if (f->is_frame || f->cell->kind != TY_ARR) {
+            err(n->loc, "focus must be an array or block to move by index");
+            return;
+        }
+        if (!ty_is_any_int(st->wv)) {
+            char b[128];
+            err(n->loc, "'move index' requires an integer working value (found %s)",
+                type_name(st->wv, b, sizeof b));
+            return;
+        }
+        Insn* in = emit(c, I_MOVE_INDEX, n->loc);
+        in->t = f->cell->inner;
+        in->b = f->cell->count ? (int)f->cell->count : -1;
+        focus_push_cell(c, f->cell->inner, n->loc);
+        return;
+    }
+    case A_MOVE_AT: {
+        if (st->wv->kind != TY_ADDR) {
+            char b[128];
+            err(n->loc, "'move at' requires an address working value (found %s)",
+                type_name(st->wv, b, sizeof b));
+            return;
+        }
+        Insn* in = emit(c, I_MOVE_AT, n->loc);
+        in->t = st->wv->inner;
+        focus_push_cell(c, st->wv->inner, n->loc);
+        return;
+    }
+    case A_BACK: {
+        if (st->nfoc <= 1) {
+            err(n->loc, "focus is already at the frame root");
+            return;
+        }
+        emit(c, I_BACK, n->loc);
+        st->nfoc--;
+        return;
+    }
+    case A_HOME: {
+        emit(c, I_HOME, n->loc);
+        st->nfoc = 1;
+        return;
+    }
+
+    /* ---------------- addresses ---------------- */
+    case A_ADDRESS_HERE: {
+        FocusEnt* f = focus_top(c);
+        if (f->is_frame) {
+            err(n->loc, "cannot take the address of the frame root");
+            return;
+        }
+        Insn* in = emit(c, I_ADDR_HERE, n->loc);
+        in->t = ty_addr_to(f->cell);
+        st->wv = in->t;
+        return;
+    }
+    case A_ADDRESS_NAME: {
+        int li = find_local(c, n->name);
+        if (li >= 0) {
+            Insn* in = emit(c, I_ADDR_LOCAL, n->loc);
+            in->a = li;
+            in->t = ty_addr_to(fn->locals[li].type);
+            st->wv = in->t;
+            return;
+        }
+        int gi = find_glob(c, n->name);
+        if (gi >= 0) {
+            Insn* in = emit(c, I_ADDR_GLOBAL, n->loc);
+            in->a = gi;
+            in->t = ty_addr_to(c->prog->globs[gi].type);
+            st->wv = in->t;
+            return;
+        }
+        int fi = find_fn(c, n->name);
+        if (fi >= 0) {
+            Insn* in = emit(c, I_ADDR_FN, n->loc);
+            in->a = fi;
+            st->wv = ty_func();
+            return;
+        }
+        err(n->loc, "no cell or transformation named '%s'", n->name);
+        return;
+    }
+
+    /* ---------------- cells ---------------- */
+    case A_MAKE_VALUE: {
+        Type* t = ast_to_type(n->t, n->loc);
+        if (t->kind == TY_ARR && t->count == 0) {
+            err(n->loc, "unsized array cells must be created with 'make <size> of <type>'");
+            return;
+        }
+        if (n->name) {
+            int li = find_local(c, n->name);
+            Insn* in = emit(c, I_MAKE_LOCAL, n->loc);
+            in->a = li;
+            in->t = t;
+        } else {
+            Insn* in = emit(c, I_MAKE_ANON, n->loc);
+            in->t = t;
+            st->wv = ty_addr_to(t);
+        }
+        return;
+    }
+    case A_MAKE_ARRAY: {
+        Type* t = ast_to_type(n->t, n->loc);
+        if (t->kind != TY_ARR || t->count == 0) {
+            err(n->loc, "'make array' requires a size and an element type");
+            return;
+        }
+        if (n->name) {
+            int li = find_local(c, n->name);
+            Insn* in = emit(c, I_MAKE_LOCAL, n->loc);
+            in->a = li;
+            in->t = t;
+        } else {
+            Insn* in = emit(c, I_MAKE_ANON, n->loc);
+            in->t = t;
+            st->wv = ty_addr_to(t);
+        }
+        return;
+    }
+    case A_MAKE_STRING: {
+        if (n->name) {
+            int li = find_local(c, n->name);
+            if (li < 0) return;
+            LocalIR* loc = &fn->locals[li];
+            if (!loc->str_init) {
+                loc->str_init = xmalloc(n->strlen + 1);
+                memcpy(loc->str_init, n->str, n->strlen);
+                loc->str_init[n->strlen] = 0;
+                loc->str_initlen = n->strlen + 1;
+            }
+            Insn* in = emit(c, I_MAKE_STRLOCAL, n->loc);
+            in->a = li;
+            in->t = ty_array(ty_uint(8), n->strlen + 1);
+        } else {
+            int si = c->prog->nstrs++;
+            c->prog->strs = xrealloc(c->prog->strs, (size_t)c->prog->nstrs * sizeof(StrIR));
+            c->prog->strs[si].bytes = xmalloc(n->strlen + 1);
+            memcpy(c->prog->strs[si].bytes, n->str, n->strlen);
+            c->prog->strs[si].bytes[n->strlen] = 0;
+            c->prog->strs[si].len = n->strlen;
+            Insn* in = emit(c, I_MAKE_STRANON, n->loc);
+            in->a = si;
+            in->t = ty_addr_to(ty_array(ty_uint(8), n->strlen + 1));
+            st->wv = in->t;
+        }
+        return;
+    }
+    case A_MAKE_DYN: {
+        Type* elem = ast_to_type(n->t, n->loc);
+        if (elem->kind == TY_ARR) {
+            err(n->loc, "block elements must be values, not arrays");
+            return;
+        }
+        if (elem->kind == TY_FUNC) {
+            err(n->loc, "blocks of transform references are not supported");
+            return;
+        }
+        Insn* in = emit(c, I_MAKE_DYN, n->loc);
+        in->t = elem;
+        if (n->name) {
+            int li = find_local(c, n->name);
+            if (li >= 0) {
+                Type* st_t = fn->locals[li].type;
+                if (!ty_is_int(st_t) && st_t->kind != TY_INTLIT) {
+                    err(n->loc, "size cell '%s' must be an integer", n->name);
+                    return;
+                }
+                in->b = 0;
+                in->a = li;
+            } else {
+                int gi = find_glob(c, n->name);
+                if (gi < 0) {
+                    err(n->loc, "no cell named '%s' to use as a size", n->name);
+                    return;
+                }
+                if (!ty_is_int(c->prog->globs[gi].type)) {
+                    err(n->loc, "size cell '%s' must be an integer", n->name);
+                    return;
+                }
+                in->b = 2;
+                in->a = gi;
+            }
+        } else {
+            if (n->imm.neg) {
+                err(n->loc, "allocation size cannot be negative");
+                return;
+            }
+            in->b = 1;
+            set_imm(in, &n->imm);
+        }
+        st->wv = ty_addr_to(ty_array(elem, 0));
+        if (n->name2) {
+            int li = find_local(c, n->name2);
+            if (li >= 0) {
+                Insn* bind = emit(c, I_BIND_LOCAL, n->loc);
+                bind->a = li;
+                bind->t = st->wv;
+            }
+        }
+        return;
+    }
+    case A_FREE: {
+        if (st->wv->kind != TY_ADDR) {
+            char b[128];
+            err(n->loc, "'free' requires an address working value (found %s)",
+                type_name(st->wv, b, sizeof b));
+            return;
+        }
+        emit(c, I_FREE, n->loc);
+        return;
+    }
+    case A_READ: {
+        FocusEnt* f = focus_top(c);
+        if (f->is_frame) {
+            err(n->loc, "the frame root holds no value; move to a cell first");
+            return;
+        }
+        if (f->cell->kind == TY_ARR) {
+            err(n->loc, "cannot read a whole array; move to an element first");
+            return;
+        }
+        Insn* in = emit(c, I_LOAD, n->loc);
+        in->t = f->cell;
+        st->wv = f->cell;
+        return;
+    }
+    case A_WRITE: {
+        FocusEnt* f = focus_top(c);
+        if (f->is_frame) {
+            err(n->loc, "the frame root cannot be written");
+            return;
+        }
+        if (f->cell->kind == TY_ARR) {
+            err(n->loc, "cannot write a whole array; move to an element first");
+            return;
+        }
+        Type* cell = f->cell;
+        Type* wv = st->wv;
+        Insn* in = emit(c, I_STORE, n->loc);
+        in->t = cell;
+        if (type_eq(cell, wv)) {
+            in->b = SC_EXACT;
+        } else if (wv->kind == TY_INTLIT && intlit_fits(wv->litval, wv->litneg, cell)) {
+            if (cell->kind == TY_BOOL) in->b = SC_INTLIT_BOOL;
+            else if (ty_is_float(cell)) in->b = SC_INTLIT_FLOAT;
+            else in->b = SC_INTLIT;
+        } else if (wv->kind == TY_FLOAT64 && cell->kind == TY_FLOAT32) {
+            in->b = SC_F32;
+        } else {
+            char b1[128], b2[128];
+            err(n->loc, "cannot write %s into a cell of type %s; use 'convert'",
+                type_name(wv, b1, sizeof b1), type_name(cell, b2, sizeof b2));
+            return;
+        }
+        return;
+    }
+
+    /* ---------------- working value ---------------- */
+    case A_SET: {
+        Insn* in = emit(c, I_SET, n->loc);
+        switch (n->imm.kind) {
+        case IM_NONE:
+            in->imm.kind = IV_NONE;
+            in->t = ty_nothing();
+            break;
+        case IM_INT:
+            in->t = ty_intlit(n->imm.u, n->imm.neg);
+            set_imm(in, &n->imm);
+            break;
+        case IM_FLOAT:
+            in->imm.kind = IV_FLOAT;
+            in->imm.d = n->imm.d;
+            in->t = ty_float64();
+            break;
+        case IM_CHAR:
+            in->imm.kind = IV_UINT;
+            in->imm.u = n->imm.u;
+            in->t = ty_char();
+            break;
+        case IM_BOOL:
+            in->imm.kind = IV_UINT;
+            in->imm.u = (uint64_t)n->imm.b;
+            in->t = ty_bool();
+            break;
+        }
+        st->wv = in->t;
+        return;
+    }
+    case A_ARITH: {
+        Type* wv = st->wv;
+        IROp op = n->arith == AR_ADD ? I_ADD : n->arith == AR_SUB ? I_SUB :
+                  n->arith == AR_MUL ? I_MUL : n->arith == AR_DIV ? I_DIV : I_MOD;
+
+        if (n->has_imm) {
+            if (n->imm.kind != IM_INT && n->imm.kind != IM_FLOAT) {
+                err(n->loc, "arithmetic operands must be numeric");
+                return;
+            }
+            Type* lit = n->imm.kind == IM_INT ? ty_intlit(n->imm.u, n->imm.neg) : ty_float64();
+
+            if (wv->kind == TY_ADDR) {
+                if (n->arith != AR_ADD && n->arith != AR_SUB) {
+                    err(n->loc, "only add and subtract apply to addresses");
+                    return;
+                }
+                if (n->imm.kind != IM_INT) {
+                    err(n->loc, "address arithmetic requires an integer");
+                    return;
+                }
+                if (!lit_fits_rt(lit, ty_int(64), n->loc, "address offset")) return;
+                Insn* in = emit(c, op, n->loc);
+                in->b = 1;
+                in->t = wv;
+                set_imm(in, &n->imm);
+                return;
+            }
+
+            Type* rt = numeric_pair_type(wv, lit);
+            if (!rt || !ty_is_numeric(rt)) {
+                char b[128];
+                err(n->loc, "arithmetic requires numeric operands (working value is %s)",
+                    type_name(wv, b, sizeof b));
+                return;
+            }
+            if (!lit_fits_rt(lit, rt, n->loc, "arithmetic")) return;
+
+            /* constant folding: intlit op intlit */
+            if (wv->kind == TY_INTLIT && n->imm.kind == IM_INT) {
+                int64_t a, b;
+                if (lit_value_int(wv, &a) && lit_value_int(lit, &b)) {
+                    int64_t r = 0;
+                    switch (n->arith) {
+                    case AR_ADD: r = (int64_t)((uint64_t)a + (uint64_t)b); break;
+                    case AR_SUB: r = (int64_t)((uint64_t)a - (uint64_t)b); break;
+                    case AR_MUL: r = (int64_t)((uint64_t)a * (uint64_t)b); break;
+                    case AR_DIV:
+                        if (b == 0) { err(n->loc, "division by zero in constant expression"); return; }
+                        r = b == -1 ? (int64_t)(0 - (uint64_t)a) : a / b;
+                        break;
+                    case AR_MOD:
+                        if (b == 0) { err(n->loc, "remainder by zero in constant expression"); return; }
+                        r = b == -1 ? 0 : a % b;
+                        break;
+                    }
+                    Insn* s = emit(c, I_SET, n->loc);
+                    s->imm.kind = IV_INT;
+                    s->imm.u = (uint64_t)r;
+                    s->t = intlit_from_int64(r);
+                    st->wv = s->t;
+                    return;
+                }
+            }
+
+            /* coerce an intlit working value into float arithmetic */
+            if (wv->kind == TY_INTLIT && ty_is_float(rt)) {
+                Insn* cv = emit(c, I_CONV, n->loc);
+                cv->t = ty_float64();
+                st->wv = ty_float64();
+                wv = ty_float64();
+                rt = ty_float64();
+                if (n->imm.kind == IM_INT) lit = ty_float64();
+            }
+
+            Insn* in = emit(c, op, n->loc);
+            in->b = 1;
+            in->t = rt;
+            set_imm(in, &n->imm);
+            st->wv = rt;
+            return;
+        }
+
+        /* operand popped from the value stack */
+        if (st->nstk < 1) {
+            err(n->loc, "value stack is empty");
+            return;
+        }
+        Type* popped = st->stk[st->nstk - 1];
+
+        if (wv->kind == TY_ADDR || popped->kind == TY_ADDR) {
+            if (wv->kind == TY_ADDR && ty_is_any_int(popped) &&
+                (n->arith == AR_ADD || n->arith == AR_SUB)) {
+                Insn* in = emit(c, op, n->loc);
+                in->b = 0;
+                in->t = wv;
+                st->nstk--;
+                return;
+            }
+            if (n->arith == AR_SUB && wv->kind == TY_ADDR && popped->kind == TY_ADDR) {
+                Insn* in = emit(c, I_SUB, n->loc);
+                in->b = 0;
+                in->a = 1; /* pointer difference */
+                in->t = ty_int(64);
+                st->nstk--;
+                st->wv = ty_int(64);
+                return;
+            }
+            err(n->loc, "invalid address arithmetic (address +/- integer, or address - address)");
+            return;
+        }
+
+        Type* rt = numeric_pair_type(wv, popped);
+        if (!rt || !ty_is_numeric(rt)) {
+            char b1[128], b2[128];
+            err(n->loc, "arithmetic requires matching numeric types (%s and %s); use 'convert'",
+                type_name(wv, b1, sizeof b1), type_name(popped, b2, sizeof b2));
+            return;
+        }
+        if (!lit_fits_rt(wv, rt, n->loc, "arithmetic")) return;
+        if (!lit_fits_rt(popped, rt, n->loc, "arithmetic")) return;
+        Insn* in = emit(c, op, n->loc);
+        in->b = 0;
+        in->t = rt;
+        st->nstk--;
+        st->wv = rt;
+        return;
+    }
+    case A_NEG: {
+        Type* wv = st->wv;
+        if (wv->kind == TY_INTLIT) {
+            int64_t v;
+            if (lit_value_int(wv, &v)) {
+                st->wv = intlit_from_int64(v == INT64_MIN ? INT64_MIN : -v);
+                Insn* in = emit(c, I_SET, n->loc);
+                in->t = st->wv;
+                in->imm.kind = IV_INT;
+                in->imm.u = (uint64_t)(v == INT64_MIN ? INT64_MIN : -v);
+                return;
+            }
+        }
+        if (!ty_is_signed_int(wv) && !ty_is_float(wv)) {
+            char b[128];
+            err(n->loc, "negate requires a signed integer or a float (found %s)",
+                type_name(wv, b, sizeof b));
+            return;
+        }
+        Insn* in = emit(c, I_NEG, n->loc);
+        in->t = wv;
+        return;
+    }
+
+    /* ---------------- value stack ---------------- */
+    case A_PUSH: {
+        if (st->nstk >= MAX_STK) {
+            err(n->loc, "value stack too deep (limit %d)", MAX_STK);
+            return;
+        }
+        Insn* in = emit(c, I_PUSH, n->loc);
+        in->t = st->wv;
+        st->stk[st->nstk++] = st->wv;
+        return;
+    }
+    case A_POP: {
+        if (st->nstk < 1) {
+            err(n->loc, "value stack is empty");
+            return;
+        }
+        Insn* in = emit(c, I_POP, n->loc);
+        in->t = st->stk[st->nstk - 1];
+        st->nstk--;
+        st->wv = in->t;
+        return;
+    }
+    case A_SWAP: {
+        if (st->nstk < 1) {
+            err(n->loc, "value stack is empty");
+            return;
+        }
+        emit(c, I_SWAP, n->loc);
+        Type* tmp = st->stk[st->nstk - 1];
+        st->stk[st->nstk - 1] = st->wv;
+        st->wv = tmp;
+        return;
+    }
+
+    /* ---------------- comparison ---------------- */
+    case A_CMP: {
+        Type* wv = st->wv;
+        Type* rt;
+        if (n->has_imm) {
+            Type* lit = NULL;
+            if (n->imm.kind == IM_INT) lit = ty_intlit(n->imm.u, n->imm.neg);
+            else if (n->imm.kind == IM_FLOAT) lit = ty_float64();
+            else if (n->imm.kind == IM_CHAR) lit = ty_char();
+            else if (n->imm.kind == IM_BOOL) lit = ty_bool();
+            else { err(n->loc, "invalid comparison operand"); return; }
+
+            if (wv->kind == TY_ADDR) {
+                if (lit->kind == TY_INTLIT && intlit_fits(lit->litval, lit->litneg, ty_uint(64))) {
+                    rt = ty_uint(64);
+                } else {
+                    err(n->loc, "addresses compare with addresses or nonnegative integer literals");
+                    return;
+                }
+            } else if (wv->kind == TY_FUNC || wv->kind == TY_ARR) {
+                err(n->loc, "transform references and arrays cannot be compared");
+                return;
+            } else if (wv->kind == TY_BOOL) {
+                if (lit->kind != TY_BOOL && !(lit->kind == TY_INTLIT && intlit_fits(lit->litval, lit->litneg, ty_bool()))) {
+                    err(n->loc, "booleans compare only with booleans");
+                    return;
+                }
+                rt = ty_bool();
+            } else {
+                rt = numeric_pair_type(wv, lit);
+                if (!rt || (!ty_is_numeric(rt) && rt->kind != TY_CHAR)) {
+                    char b[128];
+                    err(n->loc, "cannot compare %s with that operand",
+                        type_name(wv, b, sizeof b));
+                    return;
+                }
+                if (!lit_fits_rt(lit, rt, n->loc, "comparison")) return;
+            }
+            Insn* in = emit(c, I_CMP, n->loc);
+            in->b = 1;
+            in->t = rt;
+            set_imm(in, &n->imm);
+        } else {
+            if (st->nstk < 1) {
+                err(n->loc, "value stack is empty");
+                return;
+            }
+            Type* popped = st->stk[st->nstk - 1];
+            if (wv->kind == TY_ADDR && popped->kind == TY_ADDR) {
+                rt = ty_uint(64);
+            } else if (wv->kind == TY_ADDR || popped->kind == TY_ADDR) {
+                err(n->loc, "addresses compare only with addresses");
+                return;
+            } else if (wv->kind == TY_FUNC || popped->kind == TY_FUNC) {
+                err(n->loc, "transform references cannot be compared");
+                return;
+            } else if (wv->kind == TY_ARR || popped->kind == TY_ARR) {
+                err(n->loc, "arrays cannot be compared");
+                return;
+            } else if (wv->kind == TY_BOOL || popped->kind == TY_BOOL) {
+                if (!(wv->kind == TY_BOOL && popped->kind == TY_BOOL)) {
+                    err(n->loc, "booleans compare only with booleans");
+                    return;
+                }
+                rt = ty_bool();
+            } else {
+                rt = numeric_pair_type(wv, popped);
+                if (!rt || (!ty_is_numeric(rt) && rt->kind != TY_CHAR)) {
+                    char b1[128], b2[128];
+                    err(n->loc, "cannot compare %s with %s; use 'convert'",
+                        type_name(wv, b1, sizeof b1), type_name(popped, b2, sizeof b2));
+                    return;
+                }
+                if (!lit_fits_rt(wv, rt, n->loc, "comparison")) return;
+                if (!lit_fits_rt(popped, rt, n->loc, "comparison")) return;
+            }
+            Insn* in = emit(c, I_CMP, n->loc);
+            in->b = 0;
+            in->t = rt;
+            st->nstk--;
+        }
+        st->cmp = 1;
+        return;
+    }
+
+    /* ---------------- conversion ---------------- */
+    case A_CONV: {
+        Type* to = ast_to_type(n->t, n->loc);
+        Type* from = st->wv;
+        if (to->kind == TY_ARR) { err(n->loc, "cannot convert to an array type"); return; }
+        if (to->kind == TY_FUNC) { err(n->loc, "cannot convert to a transform reference"); return; }
+        if (from->kind == TY_FUNC) { err(n->loc, "cannot convert a transform reference"); return; }
+        if (from->kind == TY_ARR) { err(n->loc, "cannot convert an array"); return; }
+        if (to->kind == TY_NOTHING || from->kind == TY_NOTHING) {
+            err(n->loc, "cannot convert to or from nothing");
+            return;
+        }
+        int ok;
+        if (from->kind == TY_INTLIT) {
+            /* integer literals convert to any scalar numeric type; the value
+               wraps modulo the target width like any integer conversion */
+            ok = ty_is_int(to) || ty_is_float(to) || to->kind == TY_CHAR ||
+                 to->kind == TY_BOOL || to->kind == TY_ADDR;
+        } else {
+            ok = (ty_is_int(from) || ty_is_float(from) || ty_is_bool(from) ||
+                  ty_is_char(from) || ty_is_addr(from)) &&
+                 (ty_is_int(to) || ty_is_float(to) || ty_is_bool(to) ||
+                  ty_is_char(to) || ty_is_addr(to));
+        }
+        if (!ok) {
+            char b[128];
+            err(n->loc, "cannot convert from %s", type_name(from, b, sizeof b));
+            return;
+        }
+        Insn* in = emit(c, I_CONV, n->loc);
+        in->t = to;
+        st->wv = to;
+        return;
+    }
+
+    /* ---------------- control flow ---------------- */
+    case A_JMP: {
+        int li = find_label(c, n->name);
+        if (li < 0) {
+            err(n->loc, "no label named '%s' in transformation '%s'", n->name, fn->name);
+            return;
+        }
+        if (n->has_cond && !st->cmp) {
+            err(n->loc, "conditional jump before any comparison");
+            return;
+        }
+        if (!label_merge(c, li, n->loc)) return;
+        Insn* in = emit(c, n->has_cond ? I_JMPCOND : I_JMP, n->loc);
+        in->a = li;
+        if (n->has_cond) {
+            switch (n->cond) {
+            case T_LESS: in->b = CMP_LESS; break;
+            case T_EQUAL: in->b = CMP_EQUAL; break;
+            case T_GREATER: in->b = CMP_GREATER; break;
+            case T_DIFFERENT: in->b = CMP_DIFFERENT; break;
+            case T_LESS_OR_EQUAL: in->b = CMP_LESS_OR_EQUAL; break;
+            case T_GREATER_OR_EQUAL: in->b = CMP_GREATER_OR_EQUAL; break;
+            default: in->b = CMP_EQUAL; break;
+            }
+        }
+        if (!n->has_cond) st->dead = 1;
+        return;
+    }
+    case A_CALL_NAME: {
+        int fi = find_fn(c, n->name);
+        if (fi < 0) {
+            err(n->loc, "no transformation named '%s'", n->name);
+            return;
+        }
+        FnIR* callee = &c->prog->fns[fi];
+        int np = callee->nparams;
+        if (st->nstk < np) {
+            err(n->loc, "transformation '%s' expects %d argument%s on the value stack (found %d)",
+                n->name, np, np == 1 ? "" : "s", st->nstk);
+            return;
+        }
+        if (callee->result == NULL) {
+            c->blocked = 1;
+            return;
+        }
+        unsigned mask = 0;
+        for (int i = 0; i < np; i++) {
+            Type* want = callee->locals[i].type;
+            Type* got = st->stk[st->nstk - np + i];
+            unsigned k = 0;
+            if (type_eq(want, got)) k = 0;
+            else if (got->kind == TY_INTLIT && intlit_fits(got->litval, got->litneg, want)) k = 1;
+            else if (got->kind == TY_FLOAT64 && want->kind == TY_FLOAT32) k = 2;
+            else {
+                char b1[128], b2[128];
+                err(n->loc, "argument %d of '%s' needs %s but the stack holds %s",
+                    i + 1, n->name, type_name(want, b1, sizeof b1), type_name(got, b2, sizeof b2));
+                return;
+            }
+            mask |= k << (2 * i);
+        }
+        st->nstk -= np;
+        Insn* in = emit(c, I_CALL, n->loc);
+        in->a = fi;
+        in->b = (int)mask;
+        in->t = callee->result;
+        if (callee->result->kind != TY_NOTHING) st->wv = callee->result;
+        return;
+    }
+    case A_CALL_IND: {
+        if (st->wv->kind != TY_FUNC) {
+            char b[128];
+            err(n->loc, "'call' without a name requires a transform reference working value (found %s)",
+                type_name(st->wv, b, sizeof b));
+            return;
+        }
+        emit(c, I_CALLIND, n->loc);
+        st->wv = ty_nothing();
+        return;
+    }
+    case A_RET: {
+        if (st->nstk != 0) {
+            err(n->loc, "the value stack must be empty at return (holds %d value%s)",
+                st->nstk, st->nstk == 1 ? "" : "s");
+            return;
+        }
+        /* coerce an integer literal working value to the result type */
+        if (st->wv->kind == TY_INTLIT) {
+            Type* want = fn->result;
+            if (want && intlit_fits(st->wv->litval, st->wv->litneg, want) &&
+                (ty_is_int(want) || want->kind == TY_CHAR || want->kind == TY_BOOL ||
+                 ty_is_float(want))) {
+                Insn* cv = emit(c, I_CONV, n->loc);
+                cv->t = want;
+                st->wv = want;
+            } else {
+                Insn* cv = emit(c, I_CONV, n->loc);
+                cv->t = intlit_fits(st->wv->litval, st->wv->litneg, ty_int(64))
+                            ? ty_int(64) : ty_uint(64);
+                st->wv = cv->t;
+            }
+        }
+        if (!c->result_seen) {
+            c->result_first = st->wv;
+            c->result_seen = 1;
+        } else if (!type_eq(c->result_first, st->wv)) {
+            char b1[128], b2[128];
+            err(n->loc, "return type mismatch: an earlier return produces %s but this one produces %s",
+                type_name(c->result_first, b1, sizeof b1), type_name(st->wv, b2, sizeof b2));
+            return;
+        }
+        if (fn->declared_result && fn->result && !type_eq(fn->result, st->wv)) {
+            char b1[128], b2[128];
+            err(n->loc, "return type mismatch: '%s' declares result %s but returns %s",
+                fn->name, type_name(fn->result, b1, sizeof b1), type_name(st->wv, b2, sizeof b2));
+            return;
+        }
+        emit(c, I_RET, n->loc);
+        st->dead = 1;
+        return;
+    }
+    case A_STOP: {
+        Insn* in = emit(c, I_STOP, n->loc);
+        in->t = st->wv;
+        st->dead = 1;
+        return;
+    }
+    case A_MARK:
+        /* handled before the dead-state check in check_insn */
+        return;
+
+    /* ---------------- external world ---------------- */
+    case A_INPUT: {
+        emit(c, I_INPUT, n->loc);
+        st->wv = ty_int(64);
+        return;
+    }
+    case A_OUTPUT: {
+        Type* wv = st->wv;
+        if (!(ty_is_numeric(wv) || wv->kind == TY_BOOL || wv->kind == TY_CHAR ||
+              wv->kind == TY_ADDR)) {
+            char b[128];
+            err(n->loc, "cannot output %s", type_name(wv, b, sizeof b));
+            return;
+        }
+        Insn* in = emit(c, I_OUTPUT, n->loc);
+        in->t = wv;
+        return;
+    }
+    case A_OUTPUT_TEXT: {
+        Type* wv = st->wv;
+        if (!(wv->kind == TY_ADDR &&
+              (wv->inner->kind == TY_UINT8 ||
+               (wv->inner->kind == TY_ARR && wv->inner->inner->kind == TY_UINT8)))) {
+            char b[128];
+            err(n->loc, "'output text' requires an address to bytes or to a byte array (found %s)",
+                type_name(wv, b, sizeof b));
+            return;
+        }
+        emit(c, I_OUTTEXT, n->loc);
+        return;
+    }
+    case A_OPEN: {
+        Type* wv = st->wv;
+        if (!(wv->kind == TY_ADDR &&
+              (wv->inner->kind == TY_UINT8 ||
+               (wv->inner->kind == TY_ARR && wv->inner->inner->kind == TY_UINT8)))) {
+            err(n->loc, "'open' requires an address to a NUL-terminated byte string");
+            return;
+        }
+        Insn* in = emit(c, I_OPEN, n->loc);
+        in->b = n->mode == T_READ ? 0 : n->mode == T_WRITE ? 1 : 2;
+        st->wv = ty_int(64);
+        return;
+    }
+    case A_CLOSE: {
+        if (!ty_is_any_int(st->wv)) {
+            err(n->loc, "'close' requires an integer handle working value");
+            return;
+        }
+        emit(c, I_CLOSE, n->loc);
+        st->wv = ty_int(64);
+        return;
+    }
+    case A_SYSTEM: {
+        Type* wv = st->wv;
+        if (!(wv->kind == TY_ADDR &&
+              (wv->inner->kind == TY_UINT8 ||
+               (wv->inner->kind == TY_ARR && wv->inner->inner->kind == TY_UINT8)))) {
+            err(n->loc, "'system' requires an address to a NUL-terminated byte string");
+            return;
+        }
+        emit(c, I_SYSTEM, n->loc);
+        st->wv = ty_int(64);
+        return;
+    }
+
+    /* ---------------- concurrency ---------------- */
+    case A_SPAWN: {
+        int fi = find_fn(c, n->name);
+        if (fi < 0) {
+            err(n->loc, "no transformation named '%s'", n->name);
+            return;
+        }
+        if (c->prog->fns[fi].nparams != 0) {
+            err(n->loc, "spawned transformations must take no parameters ('%s' takes %d)",
+                n->name, c->prog->fns[fi].nparams);
+            return;
+        }
+        Insn* in = emit(c, I_SPAWN, n->loc);
+        in->a = fi;
+        return;
+    }
+    case A_WAIT: {
+        emit(c, I_WAIT, n->loc);
+        st->wv = ty_int(64);
+        return;
+    }
+    }
+}
+
+/* Marks are processed even in dead state so that labels targeted by
+   forward jumps can revive execution. */
+static void check_mark(Chk* c, Node* n) {
+    int li = find_label(c, n->name);
+    if (li < 0) return;
+    Insn* in = emit(c, I_LBL, n->loc);
+    in->a = li;
+    if (!c->st.dead) {
+        /* merge the fallthrough edge */
+        if (!label_merge(c, li, n->loc)) return;
+    }
+    CState* ls = c->labels[li].state;
+    if (!ls) {
+        /* no live edge reaches this label */
+        if (!c->st.dead) { c->st.dead = 1; c->st.unk = 1; }
+    } else {
+        st_clone(&c->st, ls);
+    }
+}
+
+/* ------------------------------------------------------------------ */
+/* locals & labels                                                    */
+/* ------------------------------------------------------------------ */
+
+static void collect_labels(Chk* c, FnAst* fa) {
+    c->nlabels = 0;
+    for (size_t i = 0; i < fa->nbody; i++) {
+        Node* n = &fa->body[i];
+        if (n->kind != A_MARK) continue;
+        if (find_label(c, n->name) >= 0) {
+            err(n->loc, "duplicate label '%s' in transformation '%s'", n->name, fa->name);
+            continue;
+        }
+        c->labels[c->nlabels].name = n->name;
+        c->labels[c->nlabels].state = NULL;
+        c->labels[c->nlabels].loc = n->loc;
+        c->nlabels++;
+        if (c->nlabels >= MAX_LABELS) {
+            err(n->loc, "too many labels in one transformation (limit %d)", MAX_LABELS);
+            return;
+        }
+    }
+}
+
+static void free_label_states(Chk* c) {
+    for (int i = 0; i < MAX_LABELS; i++) {
+        free(c->labels[i].state);
+        c->labels[i].state = NULL;
+    }
+}
+
+static Type* make_local_type(Chk* c, Node* n) {
+    (void)c;
+    switch (n->kind) {
+    case A_MAKE_STRING: return ty_array(ty_uint(8), n->strlen + 1);
+    case A_MAKE_DYN: return ty_addr_to(ty_array(ast_to_type(n->t, n->loc), 0));
+    default: return ast_to_type(n->t, n->loc);
+    }
+}
+
+static void collect_locals(Chk* c, FnAst* fa) {
+    FnIR* fn = c->fn;
+    fn->nlocals = 0;
+    for (size_t i = 0; i < fa->nparams; i++) {
+        if (find_local(c, fa->params[i].name) >= 0) {
+            err(fa->params[i].loc, "duplicate parameter '%s'", fa->params[i].name);
+            continue;
+        }
+        Type* t = ast_to_type(fa->params[i].t, fa->params[i].loc);
+        if (t->kind == TY_ARR) {
+            err(fa->params[i].loc, "arrays cannot be parameters; pass an address instead");
+            t = ty_nothing();
+        }
+        LocalIR* loc = &fn->locals[fn->nlocals++];
+        memset(loc, 0, sizeof *loc);
+        loc->name = fa->params[i].name;
+        loc->type = t;
+        loc->is_param = 1;
+    }
+    for (size_t i = 0; i < fa->nbody && fn->nlocals < MAX_LOCALS; i++) {
+        Node* n = &fa->body[i];
+        const char* name = NULL;
+        if (n->kind == A_MAKE_VALUE && n->name) name = n->name;
+        else if (n->kind == A_MAKE_ARRAY && n->name) name = n->name;
+        else if (n->kind == A_MAKE_STRING && n->name) name = n->name;
+        else if (n->kind == A_MAKE_DYN && n->name2) name = n->name2;
+        if (!name) continue;
+        if (find_local(c, name) >= 0) {
+            err(n->loc, "duplicate cell '%s' in transformation '%s'", name, fa->name);
+            continue;
+        }
+        Type* ty = make_local_type(c, n);
+        if (ty->kind == TY_ARR && ty->count == 0) {
+            err(n->loc, "cell '%s' would be an unsized array", name);
+            ty = ty_nothing();
+        }
+        if (fn->nlocals >= MAX_LOCALS) {
+            err(n->loc, "too many cells in one transformation (limit %d)", MAX_LOCALS);
+            break;
+        }
+        LocalIR* loc = &fn->locals[fn->nlocals++];
+        memset(loc, 0, sizeof *loc);
+        loc->name = (char*)name;
+        loc->type = ty;
+        loc->is_param = 0;
+    }
+}
+
+/* ------------------------------------------------------------------ */
+/* body checking                                                      */
+/* ------------------------------------------------------------------ */
+
+static int fn_declared_conflict(Chk* c);
+
+static int check_fn_body(Chk* c, FnAst* fa) {
+    c->fn->nins = 0;
+    c->blocked = 0;
+    c->result_first = NULL;
+    c->result_seen = 0;
+
+    collect_labels(c, fa);
+    collect_locals(c, fa);
+    free_label_states(c);
+
+    /* settling rounds: walk without emitting until no label gains a state */
+    for (int round = 0; round <= c->nlabels + 1; round++) {
+        c->changed = 0;
+        c->emitting = 0;
+        c->erroring = 0;
+        c->fn->nins = 0;
+        st_init(c);
+        for (size_t i = 0; i < fa->nbody; i++) {
+            check_insn(c, &fa->body[i]);
+            if (c->blocked) return 0;
+            if (crn_error_count()) return 0;
+        }
+        if (!c->changed) break;
+    }
+
+    /* final emitting walk */
+    c->emitting = 1;
+    c->erroring = 1;
+    c->fn->nins = 0;
+    st_init(c);
+    for (size_t i = 0; i < fa->nbody; i++) {
+        check_insn(c, &fa->body[i]);
+        if (c->blocked) return 0;
+        if (crn_error_count()) return 0;
+    }
+
+    /* unreachable label detection (labels with no live edge) */
+    for (size_t i = 0; i < fa->nbody; i++) {
+        Node* n = &fa->body[i];
+        if (n->kind != A_MARK) continue;
+        int li = find_label(c, n->name);
+        if (li >= 0 && !c->labels[li].state)
+            err(n->loc, "label '%s' can never be reached", n->name);
+    }
+    if (crn_error_count()) return 0;
+
+    /* implicit return at the end of the body */
+    if (!c->st.dead) {
+        if (c->st.nstk != 0) {
+            err(fa->loc, "the value stack must be empty when transformation '%s' ends (holds %d)",
+                fa->name, c->st.nstk);
+            return 0;
+        }
+        if (!c->result_seen) {
+            c->result_first = c->st.wv;
+            c->result_seen = 1;
+        } else if (!type_eq(c->result_first, c->st.wv)) {
+            char b1[128], b2[128];
+            err(fa->loc, "return type mismatch in '%s': %s vs %s", fa->name,
+                type_name(c->result_first, b1, sizeof b1), type_name(c->st.wv, b2, sizeof b2));
+            return 0;
+        }
+        emit(c, I_RET, fa->loc);
+    }
+
+    if (fn_declared_conflict(c)) return 0;
+
+    c->fn->nlabels = c->nlabels;
+    free(c->fn->label_names);
+    c->fn->label_names = xmalloc(sizeof(char*) * (size_t)(c->nlabels ? c->nlabels : 1));
+    for (int i = 0; i < c->nlabels; i++) c->fn->label_names[i] = c->labels[i].name;
+
+    return 1;
+}
+
+static int fn_declared_conflict(Chk* c) {
+    FnIR* fn = c->fn;
+    if (!fn->declared_result || !fn->result) return 0;
+    if (!c->result_seen) {
+        /* no returns at all: declared result must be nothing-compatible */
+        if (fn->result->kind != TY_NOTHING) {
+            char b[128];
+            err(fn->loc, "'%s' declares result %s but never returns a value",
+                fn->name, type_name(fn->result, b, sizeof b));
+            return 1;
+        }
+        return 0;
+    }
+    if (!type_eq(fn->result, c->result_first)) {
+        char b1[128], b2[128];
+        err(fn->loc, "'%s' declares result %s but returns %s", fn->name,
+            type_name(fn->result, b1, sizeof b1), type_name(c->result_first, b2, sizeof b2));
+        return 1;
+    }
+    return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* program level                                                      */
+/* ------------------------------------------------------------------ */
+
+ProgramIR* check_program(ProgramAst* ast) {
+    ProgramIR* p = xmalloc(sizeof(ProgramIR));
+    memset(p, 0, sizeof *p);
+
+    /* world cells */
+    for (size_t i = 0; i < ast->nglobs; i++) {
+        GlobAst* g = &ast->globs[i];
+        int dup = 0;
+        for (size_t j = 0; j < (size_t)p->nglobs; j++)
+            if (strcmp(p->globs[j].name, g->name) == 0) {
+                crn_error(g->loc, "duplicate world cell '%s'", g->name);
+                dup = 1;
+            }
+        for (size_t j = 0; j < ast->nfns; j++)
+            if (strcmp(ast->fns[j].name, g->name) == 0) {
+                crn_error(g->loc, "world cell '%s' collides with a transformation name", g->name);
+                dup = 1;
+            }
+        if (dup) continue;
+        p->globs = xrealloc(p->globs, ((size_t)p->nglobs + 1) * sizeof(GlobIR));
+        GlobIR* gi = &p->globs[p->nglobs++];
+        memset(gi, 0, sizeof *gi);
+        gi->name = g->name;
+        if (g->kind == G_STRING) {
+            gi->type = ty_array(ty_uint(8), g->strlen + 1);
+            gi->str_init = xmalloc(g->strlen + 1);
+            memcpy(gi->str_init, g->str, g->strlen);
+            gi->str_init[g->strlen] = 0;
+            gi->str_initlen = g->strlen + 1;
+        } else {
+            gi->type = ast_to_type(g->t, g->loc);
+            if (gi->type->kind == TY_ARR && gi->type->count == 0)
+                crn_error(g->loc, "world arrays need a size");
+        }
+    }
+
+    /* transformation shells */
+    for (size_t i = 0; i < ast->nfns; i++) {
+        FnAst* fa = &ast->fns[i];
+        int dup = 0;
+        for (size_t j = 0; j < (size_t)p->nfns; j++)
+            if (strcmp(p->fns[j].name, fa->name) == 0) {
+                crn_error(fa->loc, "duplicate transformation '%s'", fa->name);
+                dup = 1;
+            }
+        for (size_t j = 0; j < (size_t)p->nglobs; j++)
+            if (strcmp(p->globs[j].name, fa->name) == 0) {
+                crn_error(fa->loc, "transformation '%s' collides with a world cell name", fa->name);
+                dup = 1;
+            }
+        if (dup) continue;
+        p->fns = xrealloc(p->fns, ((size_t)p->nfns + 1) * sizeof(FnIR));
+        FnIR* fi = &p->fns[p->nfns++];
+        memset(fi, 0, sizeof *fi);
+        fi->name = fa->name;
+        fi->loc = fa->loc;
+        fi->locals = xmalloc(sizeof(LocalIR) * (MAX_LOCALS + 1));
+        memset(fi->locals, 0, sizeof(LocalIR) * (MAX_LOCALS + 1));
+        fi->nparams = (int)fa->nparams;
+        /* fill parameter cells now so callers can check arguments before
+           the callee body has been checked */
+        for (size_t pi = 0; pi < fa->nparams && (int)pi < MAX_LOCALS; pi++) {
+            Type* pt = ast_to_type(fa->params[pi].t, fa->params[pi].loc);
+            if (pt->kind == TY_ARR) pt = ty_nothing();
+            fi->locals[pi].name = fa->params[pi].name;
+            fi->locals[pi].type = pt;
+            fi->locals[pi].is_param = 1;
+        }
+        fi->nlocals = (int)fa->nparams;
+        fi->result = NULL;
+        fi->declared_result = fa->result != NULL;
+        if (fa->result) {
+            Type* t = ast_to_type(fa->result, fa->loc);
+            if (t->kind == TY_ARR) {
+                crn_error(fa->loc, "arrays cannot be results; return an address instead");
+                t = ty_nothing();
+            }
+            fi->result = t;
+        }
+    }
+    if (crn_error_count()) return NULL;
+
+    if (prog_find_fn(p, "main") < 0) {
+        crn_error((Loc){1, 1}, "no 'main' transformation defined");
+        return NULL;
+    }
+    {
+        int mi = prog_find_fn(p, "main");
+        if (p->fns[mi].nparams != 0) {
+            crn_error(p->fns[mi].loc, "'main' must take no parameters");
+            return NULL;
+        }
+    }
+
+    /* body checking with iterative result inference */
+    Chk chk;
+    memset(&chk, 0, sizeof chk);
+    chk.prog = p;
+
+    for (int pass = 0; pass < p->nfns + 2; pass++) {
+        int remaining = 0;
+        for (int i = 0; i < p->nfns; i++) {
+            FnIR* fi = &p->fns[i];
+            if (fi->checked_done) continue;
+            FnAst* fa = NULL;
+            for (size_t j = 0; j < ast->nfns; j++)
+                if (strcmp(ast->fns[j].name, fi->name) == 0) { fa = &ast->fns[j]; break; }
+            if (!fa) continue;
+            chk.fn = fi;
+            chk.fnast = fa;
+            if (check_fn_body(&chk, fa)) {
+                if (!fi->result)
+                    fi->result = chk.result_seen ? chk.result_first : ty_nothing();
+                fi->checked_done = 1;
+            } else {
+                if (crn_error_count()) return NULL;
+                remaining++;
+            }
+        }
+        if (remaining == 0) break;
+        if (pass == p->nfns + 1) {
+            for (int i = 0; i < p->nfns; i++) {
+                if (!p->fns[i].checked_done)
+                    crn_error(p->fns[i].loc,
+                              "cannot infer the result of recursive transformation '%s'; "
+                              "declare it with 'result <type>'",
+                              p->fns[i].name);
+            }
+            return NULL;
+        }
+    }
+    return p;
+}
+
+int prog_find_fn(ProgramIR* p, const char* name) {
+    for (int i = 0; i < p->nfns; i++)
+        if (strcmp(p->fns[i].name, name) == 0) return i;
+    return -1;
+}
+
+int prog_find_glob(ProgramIR* p, const char* name) {
+    for (int i = 0; i < p->nglobs; i++)
+        if (strcmp(p->globs[i].name, name) == 0) return i;
+    return -1;
+}
diff --git a/src/check.h b/src/check.h
new file mode 100644
index 0000000..6019ce2
--- /dev/null
+++ b/src/check.h
@@ -0,0 +1,11 @@
+#ifndef CR_CHECK_H
+#define CR_CHECK_H
+
+#include "ast.h"
+#include "ir.h"
+
+/* Semantic analysis: AST -> fully checked IR.
+   Reports errors via crn_error. Returns NULL if any error occurred. */
+ProgramIR* check_program(ProgramAst* ast);
+
+#endif
diff --git a/src/diag.c b/src/diag.c
new file mode 100644
index 0000000..7d37186
--- /dev/null
+++ b/src/diag.c
@@ -0,0 +1,23 @@
+#include "diag.h"
+
+#include <stdarg.h>
+#include <stdio.h>
+
+static const char* g_file = "input";
+static int g_errors = 0;
+
+void crn_diag_set_file(const char* file) { g_file = file; }
+
+void crn_error(Loc loc, const char* fmt, ...) {
+    va_list ap;
+    fprintf(stderr, "%s:%d:%d: error: ", g_file, loc.line, loc.col);
+    va_start(ap, fmt);
+    vfprintf(stderr, fmt, ap);
+    va_end(ap);
+    fputc('\n', stderr);
+    g_errors++;
+}
+
+int crn_error_count(void) { return g_errors; }
+
+void crn_reset_errors(void) { g_errors = 0; }
diff --git a/src/diag.h b/src/diag.h
new file mode 100644
index 0000000..df0d33d
--- /dev/null
+++ b/src/diag.h
@@ -0,0 +1,18 @@
+#ifndef CR_DIAG_H
+#define CR_DIAG_H
+
+typedef struct Loc {
+    int line; /* 1-based */
+    int col;  /* 1-based */
+} Loc;
+
+/* Set the file name shown in diagnostics. */
+void crn_diag_set_file(const char* file);
+/* Record a compile error. Does not abort. */
+void crn_error(Loc loc, const char* fmt, ...);
+/* Number of errors recorded so far. */
+int crn_error_count(void);
+/* Reset error count (used by tests). */
+void crn_reset_errors(void);
+
+#endif
diff --git a/src/ir.h b/src/ir.h
new file mode 100644
index 0000000..6893e81
--- /dev/null
+++ b/src/ir.h
@@ -0,0 +1,143 @@
+#ifndef CR_IR_H
+#define CR_IR_H
+
+#include "diag.h"
+#include "types.h"
+#include "util.h"
+
+/*
+ * Cryn IR: a typed, flat instruction stream per transformation.
+ * Produced by semantic analysis (check.c) from the AST.
+ * Each instruction has already been fully resolved and type-checked.
+ */
+
+typedef enum {
+    I_LBL,          /* a = label index */
+    I_JMP,          /* a = label index */
+    I_JMPCOND,      /* a = label index, b = CondKind */
+    I_MOVE_LOCAL,   /* a = local index; focus becomes local cell (t) */
+    I_MOVE_GLOBAL,  /* a = global index */
+    I_MOVE_LIT,     /* imm = index (nonneg), t = element type, b = static bound (0 = unsized) */
+    I_MOVE_INDEX,   /* t = element type, b = static bound (0 = unsized) */
+    I_MOVE_AT,      /* t = pointee type of address in working value */
+    I_BACK,
+    I_HOME,
+    I_ADDR_HERE,    /* t = resulting address type */
+    I_ADDR_LOCAL,   /* a = local index, t = address type */
+    I_ADDR_GLOBAL,  /* a = global index, t = address type */
+    I_ADDR_FN,      /* a = fn index */
+    I_MAKE_LOCAL,   /* a = local index (re-zero the cell) */
+    I_MAKE_ANON,    /* t = cell type; working value = address of new heap cell */
+    I_MAKE_DYN,     /* b = 0: a = local index holding size; b = 1: imm = size;
+                       b = 2: a = global index holding size;
+                       t = element type; working value = address of block */
+    I_MAKE_STRLOCAL,/* a = local index (array with initial bytes) */
+    I_MAKE_STRANON, /* a = string index; working value = address of static block */
+    I_BIND_LOCAL,   /* a = local index; store working value into local cell */
+    I_FREE,
+    I_LOAD,         /* t = cell type; working value = value at focus */
+    I_STORE,        /* t = cell type, b = store coercion (see SC_*) */
+    I_SET,          /* imm, t = working value type */
+    I_ADD, I_SUB, I_MUL, I_DIV, I_MOD, /* t = operand type; b = 0 pop, 1 imm; imm (floats/intlit) */
+    I_NEG,          /* t = type */
+    I_PUSH,         /* t = type of value pushed */
+    I_POP,          /* t = type of value popped into working value */
+    I_SWAP,
+    I_CMP,          /* t = comparison type; b = 0 pop, 1 imm */
+    I_CONV,         /* t = target type (from = current static type) */
+    I_CALL,         /* a = fn index, b = arg coercion mask */
+    I_CALLIND,      /* indirect call through working value */
+    I_RET,
+    I_STOP,         /* t = working value type (numeric -> exit code) */
+    I_INPUT,
+    I_OUTPUT,       /* t = working value type */
+    I_OUTTEXT,
+    I_OPEN,         /* b = mode 0 read, 1 write, 2 both */
+    I_CLOSE,
+    I_SYSTEM,
+    I_SPAWN,        /* a = fn index */
+    I_WAIT
+} IROp;
+
+typedef enum {
+    CMP_LESS = 0, CMP_EQUAL, CMP_GREATER, CMP_DIFFERENT,
+    CMP_LESS_OR_EQUAL, CMP_GREATER_OR_EQUAL
+} CondKind;
+
+/* store coercions */
+enum {
+    SC_EXACT = 0,  /* working value type == cell type */
+    SC_INTLIT,     /* integer literal -> int/uint/char cell (fits, checked) */
+    SC_INTLIT_BOOL,/* integer literal 0/1 -> bool */
+    SC_INTLIT_FLOAT,/* integer literal -> float cell */
+    SC_F32         /* float64 -> float32 cell */
+};
+
+/* immediate literal */
+typedef struct ImmV {
+    enum { IV_NONE, IV_INT, IV_UINT, IV_FLOAT } kind;
+    uint64_t u;
+    double d;
+} ImmV;
+
+typedef struct Insn {
+    IROp op;
+    int a, b;
+    ImmV imm;
+    Type* t;
+    Loc loc;
+} Insn;
+
+typedef struct LocalIR {
+    char* name;
+    Type* type;
+    int is_param;
+    char* str_init; /* for make string locals: initial bytes (len+1 incl NUL) */
+    size_t str_initlen;
+} LocalIR;
+
+typedef struct FnIR {
+    char* name;
+    Loc loc;
+    LocalIR* locals;
+    int nlocals;
+    int nparams;
+    Type* result;      /* NULL until resolved; nothing = void */
+    Insn* ins;
+    int nins, cap;
+    /* label names for IR dumps */
+    char** label_names;
+    int nlabels;
+    int declared_result; /* 1 when `result` was written explicitly */
+    int checked_done;    /* body fully checked and emitted */
+} FnIR;
+
+typedef struct GlobIR {
+    char* name;
+    Type* type;
+    /* string globals: initial bytes */
+    char* str_init;
+    size_t str_initlen;
+} GlobIR;
+
+typedef struct StrIR {
+    char* bytes; /* includes terminating NUL */
+    size_t len;  /* length excluding NUL */
+} StrIR;
+
+typedef struct ProgramIR {
+    FnIR* fns;
+    int nfns;
+    GlobIR* globs;
+    int nglobs;
+    StrIR* strs;
+    int nstrs;
+} ProgramIR;
+
+int prog_find_fn(ProgramIR* p, const char* name);
+int prog_find_glob(ProgramIR* p, const char* name);
+
+/* IR dump for `cryn emit-ir` */
+void ir_dump(ProgramIR* p, Buf* out);
+
+#endif
diff --git a/src/lex.c b/src/lex.c
new file mode 100644
index 0000000..eca7544
--- /dev/null
+++ b/src/lex.c
@@ -0,0 +1,362 @@
+#include "lex.h"
+#include "util.h"
+
+#include <ctype.h>
+#include <stdlib.h>
+#include <string.h>
+
+typedef struct Lexer {
+    const char* src;
+    size_t pos, len;
+    int line, col;
+    List toks; /* Token* */
+} Lexer;
+
+static Token* tok_new(Lexer* lx, TokKind kind) {
+    Token* t = xmalloc(sizeof(Token));
+    t->kind = kind;
+    t->loc.line = lx->line;
+    t->loc.col = lx->col;
+    t->text = NULL;
+    t->ival = 0;
+    t->neg = 0;
+    t->fval = 0;
+    t->str = NULL;
+    t->strlen = 0;
+    list_add(&lx->toks, t);
+    return t;
+}
+
+static void adv(Lexer* lx, size_t n) {
+    for (size_t i = 0; i < n && lx->pos < lx->len; i++) {
+        char c = lx->src[lx->pos++];
+        if (c == '\n') {
+            lx->line++;
+            lx->col = 1;
+        } else {
+            lx->col++;
+        }
+    }
+}
+
+static int at_end(Lexer* lx) { return lx->pos >= lx->len; }
+static char cur(Lexer* lx) { return at_end(lx) ? 0 : lx->src[lx->pos]; }
+static char peek(Lexer* lx, size_t n) {
+    return lx->pos + n < lx->len ? lx->src[lx->pos + n] : 0;
+}
+
+static int is_word_start(char c) { return isalpha((unsigned char)c) || c == '_'; }
+static int is_word_char(char c) { return isalnum((unsigned char)c) || c == '_'; }
+
+/* keyword table */
+typedef struct { const char* word; TokKind kind; } Kw;
+static const Kw k_keywords[] = {
+    {"move", T_MOVE}, {"back", T_BACK}, {"home", T_HOME}, {"address", T_ADDRESS},
+    {"make", T_MAKE}, {"free", T_FREE}, {"read", T_READ}, {"write", T_WRITE},
+    {"set", T_SET}, {"add", T_ADD}, {"subtract", T_SUBTRACT}, {"multiply", T_MULTIPLY},
+    {"divide", T_DIVIDE}, {"remainder", T_REMAINDER}, {"negate", T_NEGATE},
+    {"push", T_PUSH}, {"pop", T_POP}, {"swap", T_SWAP}, {"compare", T_COMPARE},
+    {"convert", T_CONVERT}, {"jump", T_JUMP}, {"call", T_CALL}, {"return", T_RETURN},
+    {"stop", T_STOP}, {"input", T_INPUT}, {"output", T_OUTPUT}, {"open", T_OPEN},
+    {"close", T_CLOSE}, {"system", T_SYSTEM}, {"spawn", T_SPAWN}, {"wait", T_WAIT},
+    {"define", T_DEFINE}, {"end", T_END}, {"mark", T_MARK},
+    {"nothing", T_NOTHING}, {"bool", T_BOOL}, {"char", T_CHAR_T},
+    {"int8", T_INT8}, {"int16", T_INT16}, {"int32", T_INT32}, {"int64", T_INT64},
+    {"uint8", T_UINT8}, {"uint16", T_UINT16}, {"uint32", T_UINT32}, {"uint64", T_UINT64},
+    {"float32", T_FLOAT32}, {"float64", T_FLOAT64},
+    {"array", T_ARRAY}, {"string", T_STRING_T},
+    {"transform", T_TRANSFORM},
+    {"true", T_TRUE}, {"false", T_FALSE},
+    {"of", T_OF}, {"when", T_WHEN}, {"result", T_RESULT},
+    {"index", T_INDEX}, {"at", T_AT}, {"text", T_TEXT}, {"both", T_BOTH},
+    {"less", T_LESS}, {"equal", T_EQUAL}, {"greater", T_GREATER}, {"different", T_DIFFERENT},
+    {"less-or-equal", T_LESS_OR_EQUAL}, {"greater-or-equal", T_GREATER_OR_EQUAL},
+    {NULL, T_EOF}
+};
+
+static TokKind lookup_word(const char* w, size_t n) {
+    for (const Kw* k = k_keywords; k->word; k++) {
+        if (strlen(k->word) == n && memcmp(k->word, w, n) == 0) return k->kind;
+    }
+    return T_EOF;
+}
+
+static void lex_comment(Lexer* lx) {
+    /* caller verified '[#' at pos; consume until '#]' or EOF */
+    adv(lx, 2);
+    for (;;) {
+        if (at_end(lx)) {
+            crn_error((Loc){lx->line, lx->col}, "unterminated comment (missing '#]')");
+            return;
+        }
+        if (cur(lx) == '#' && peek(lx, 1) == ']') {
+            adv(lx, 2);
+            return;
+        }
+        adv(lx, 1);
+    }
+}
+
+static int lex_escape(Lexer* lx, unsigned* out) {
+    /* cur(lx) == '\\' */
+    adv(lx, 1);
+    if (at_end(lx)) return -1;
+    char c = cur(lx);
+    unsigned v;
+    switch (c) {
+    case 'n': v = '\n'; break;
+    case 't': v = '\t'; break;
+    case 'r': v = '\r'; break;
+    case '0': v = 0; break;
+    case '\\': v = '\\'; break;
+    case '\'': v = '\''; break;
+    case '"': v = '"'; break;
+    default:
+        crn_error((Loc){lx->line, lx->col}, "unknown escape sequence '\\%c'", c);
+        v = (unsigned char)c;
+        break;
+    }
+    adv(lx, 1);
+    *out = v;
+    return 0;
+}
+
+static void lex_word(Lexer* lx) {
+    size_t start = lx->pos;
+    while (!at_end(lx) && is_word_char(cur(lx))) adv(lx, 1);
+    size_t n = lx->pos - start;
+
+    /* hyphenated keywords: less-or-equal / greater-or-equal */
+    if ((n == 4 && memcmp(lx->src + start, "less", 4) == 0) ||
+        (n == 7 && memcmp(lx->src + start, "greater", 7) == 0)) {
+        const char* tail = "-or-equal";
+        size_t tl = strlen(tail);
+        if (lx->pos + tl <= lx->len && memcmp(lx->src + lx->pos, tail, tl) == 0) {
+            adv(lx, tl);
+            tok_new(lx, n == 4 ? T_LESS_OR_EQUAL : T_GREATER_OR_EQUAL);
+            return;
+        }
+    }
+
+    TokKind k = lookup_word(lx->src + start, n);
+    if (k != T_EOF) {
+        tok_new(lx, k);
+        return;
+    }
+    Token* t = tok_new(lx, T_IDENT);
+    t->text = xmalloc(n + 1);
+    memcpy(t->text, lx->src + start, n);
+    t->text[n] = 0;
+}
+
+static void lex_number(Lexer* lx, int neg) {
+    Loc loc = {lx->line, lx->col};
+
+    /* hex */
+    if (cur(lx) == '0' && (peek(lx, 1) == 'x' || peek(lx, 1) == 'X')) {
+        adv(lx, 2);
+        uint64_t v = 0;
+        int any = 0;
+        for (;;) {
+            char c = cur(lx);
+            int d;
+            if (c >= '0' && c <= '9') d = c - '0';
+            else if (c >= 'a' && c <= 'f') d = c - 'a' + 10;
+            else if (c >= 'A' && c <= 'F') d = c - 'A' + 10;
+            else break;
+            if (v > (0xFFFFFFFFFFFFFFFFULL - (uint64_t)d) / 16) {
+                crn_error(loc, "integer literal out of range");
+                v = 0;
+            }
+            v = v * 16 + (uint64_t)d;
+            any = 1;
+            adv(lx, 1);
+        }
+        if (!any) crn_error(loc, "malformed hex literal");
+        if (is_word_start(cur(lx)))
+            crn_error(loc, "junk after integer literal");
+        Token* t = tok_new(lx, T_INT);
+        t->ival = v;
+        t->neg = neg;
+        return;
+    }
+
+    Buf digits = {0};
+    while (isdigit((unsigned char)cur(lx))) { buf_putc(&digits, cur(lx)); adv(lx, 1); }
+    if (digits.len == 0) { crn_error(loc, "malformed number"); buf_free(&digits); return; }
+
+    int is_float = 0;
+    Buf full = {0};
+    buf_put(&full, digits.data, digits.len);
+    if (cur(lx) == '.' && isdigit((unsigned char)peek(lx, 1))) {
+        is_float = 1;
+        buf_putc(&full, '.');
+        adv(lx, 1);
+        while (isdigit((unsigned char)cur(lx))) { buf_putc(&full, cur(lx)); adv(lx, 1); }
+    }
+    if (cur(lx) == 'e' || cur(lx) == 'E') {
+        size_t save = lx->pos;
+        Buf exp = {0};
+        buf_putc(&exp, 'e');
+        adv(lx, 1);
+        if (cur(lx) == '+' || cur(lx) == '-') { buf_putc(&exp, cur(lx)); adv(lx, 1); }
+        if (!isdigit((unsigned char)cur(lx))) {
+            lx->pos = save;
+            /* note: column tracking for rewind is imperfect; acceptable for error path */
+        } else {
+            while (isdigit((unsigned char)cur(lx))) { buf_putc(&exp, cur(lx)); adv(lx, 1); }
+            buf_put(&full, exp.data, exp.len);
+            is_float = 1;
+        }
+        buf_free(&exp);
+    }
+    buf_putc(&full, 0);
+
+    if (is_word_start(cur(lx)))
+        crn_error(loc, "junk after numeric literal");
+
+    if (is_float) {
+        Token* t = tok_new(lx, T_FLOAT);
+        t->fval = strtod(full.data, NULL);
+        if (neg) t->fval = -t->fval;
+    } else {
+        uint64_t v = strtoull(full.data, NULL, 10);
+        if (v == 0xFFFFFFFFFFFFFFFFULL && strchr(full.data, '9')) {
+            /* strtoull clamps; crude overflow detect: compare length > 20 */
+        }
+        if (full.len - 1 > 20 || (full.len - 1 == 20 && strcmp(full.data, "18446744073709551615") > 0)) {
+            crn_error(loc, "integer literal out of range");
+            v = 0;
+        }
+        Token* t = tok_new(lx, T_INT);
+        t->ival = v;
+        t->neg = neg;
+    }
+    buf_free(&digits);
+    buf_free(&full);
+}
+
+static void lex_string(Lexer* lx) {
+    Loc loc = {lx->line, lx->col};
+    adv(lx, 1); /* opening quote */
+    Buf b = {0};
+    for (;;) {
+        if (at_end(lx) || cur(lx) == '\n') {
+            crn_error(loc, "unterminated string literal");
+            break;
+        }
+        if (cur(lx) == '"') { adv(lx, 1); break; }
+        if (cur(lx) == '\\') {
+            unsigned v;
+            if (lex_escape(lx, &v) == 0) buf_putc(&b, (char)v);
+            else { crn_error(loc, "unterminated string literal"); break; }
+        } else {
+            buf_putc(&b, cur(lx));
+            adv(lx, 1);
+        }
+    }
+    Token* t = tok_new(lx, T_STRING);
+    t->str = b.data ? b.data : xstrdup("");
+    t->strlen = b.len;
+}
+
+static void lex_char(Lexer* lx) {
+    Loc loc = {lx->line, lx->col};
+    adv(lx, 1); /* opening quote */
+    unsigned v = 0;
+    if (at_end(lx)) {
+        crn_error(loc, "unterminated character literal");
+    } else if (cur(lx) == '\'') {
+        crn_error(loc, "empty character literal");
+        adv(lx, 1);
+    } else if (cur(lx) == '\\') {
+        if (lex_escape(lx, &v) != 0) crn_error(loc, "unterminated character literal");
+    } else {
+        v = (unsigned char)cur(lx);
+        adv(lx, 1);
+    }
+    if (cur(lx) == '\'') {
+        adv(lx, 1);
+    } else if (!at_end(lx) && cur(lx) != '\n') {
+        crn_error(loc, "character literal must contain exactly one character");
+        while (!at_end(lx) && cur(lx) != '\'' && cur(lx) != '\n') adv(lx, 1);
+        if (cur(lx) == '\'') adv(lx, 1);
+    } else {
+        crn_error(loc, "unterminated character literal");
+    }
+    Token* t = tok_new(lx, T_CHAR);
+    t->ival = v;
+}
+
+Token* lex_source(const char* src, size_t* out_count) {
+    Lexer lx;
+    lx.src = src;
+    lx.pos = 0;
+    lx.len = strlen(src);
+    lx.line = 1;
+    lx.col = 1;
+    lx.toks.items = NULL;
+    lx.toks.len = lx.toks.cap = 0;
+
+    for (;;) {
+        /* skip whitespace */
+        while (!at_end(&lx) && (cur(&lx) == ' ' || cur(&lx) == '\t' ||
+                                cur(&lx) == '\n' || cur(&lx) == '\r'))
+            adv(&lx, 1);
+        if (at_end(&lx)) break;
+
+        if (cur(&lx) == '[' && peek(&lx, 1) == '#') {
+            lex_comment(&lx);
+            continue;
+        }
+
+        char c = cur(&lx);
+        if (is_word_start(c)) { lex_word(&lx); continue; }
+        if (isdigit((unsigned char)c)) { lex_number(&lx, 0); continue; }
+        if (c == '-' && isdigit((unsigned char)peek(&lx, 1))) {
+            adv(&lx, 1);
+            lex_number(&lx, 1);
+            continue;
+        }
+        if (c == '"') { lex_string(&lx); continue; }
+        if (c == '\'') { lex_char(&lx); continue; }
+
+        crn_error((Loc){lx.line, lx.col}, "unexpected character '%c' (0x%02X)",
+                  isprint((unsigned char)c) ? c : '?', (unsigned char)c);
+        adv(&lx, 1);
+    }
+
+    tok_new(&lx, T_EOF);
+    Token* arr = xmalloc(lx.toks.len * sizeof(Token));
+    for (size_t i = 0; i < lx.toks.len; i++) arr[i] = *(Token*)lx.toks.items[i];
+    if (out_count) *out_count = lx.toks.len;
+    return arr;
+}
+
+const char* tok_kind_name(TokKind k) {
+    switch (k) {
+    case T_EOF: return "end of input";
+    case T_IDENT: return "identifier";
+    case T_INT: return "integer literal";
+    case T_FLOAT: return "float literal";
+    case T_CHAR: return "character literal";
+    case T_STRING: return "string literal";
+    default: break;
+    }
+    for (const Kw* kw = k_keywords; kw->word; kw++)
+        if (kw->kind == k) return kw->word;
+    return "token";
+}
+
+int tok_is_type_start(TokKind k) {
+    switch (k) {
+    case T_NOTHING: case T_BOOL: case T_CHAR_T:
+    case T_INT8: case T_INT16: case T_INT32: case T_INT64:
+    case T_UINT8: case T_UINT16: case T_UINT32: case T_UINT64:
+    case T_FLOAT32: case T_FLOAT64:
+    case T_ADDRESS: case T_ADDRESS_T: case T_ARRAY: case T_TRANSFORM:
+        return 1;
+    default:
+        return 0;
+    }
+}
diff --git a/src/lex.h b/src/lex.h
new file mode 100644
index 0000000..09ab725
--- /dev/null
+++ b/src/lex.h
@@ -0,0 +1,62 @@
+#ifndef CR_LEX_H
+#define CR_LEX_H
+
+#include "diag.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+typedef enum {
+    T_EOF = 0,
+    T_IDENT,
+
+    /* literals */
+    T_INT,    /* u64 magnitude + neg flag */
+    T_FLOAT,  /* double */
+    T_CHAR,   /* unsigned byte/codepoint value in ival */
+    T_STRING, /* text/len in a token-local buffer */
+
+    /* commands */
+    T_MOVE, T_BACK, T_HOME, T_ADDRESS, T_MAKE, T_FREE, T_READ, T_WRITE,
+    T_SET, T_ADD, T_SUBTRACT, T_MULTIPLY, T_DIVIDE, T_REMAINDER, T_NEGATE,
+    T_PUSH, T_POP, T_SWAP, T_COMPARE, T_CONVERT,
+    T_JUMP, T_CALL, T_RETURN, T_STOP,
+    T_INPUT, T_OUTPUT, T_OPEN, T_CLOSE, T_SYSTEM,
+    T_SPAWN, T_WAIT,
+    T_DEFINE, T_END, T_MARK,
+
+    /* type words */
+    T_NOTHING, T_BOOL, T_CHAR_T, T_INT8, T_INT16, T_INT32, T_INT64,
+    T_UINT8, T_UINT16, T_UINT32, T_UINT64,
+    T_FLOAT32, T_FLOAT64, T_ADDRESS_T, T_ARRAY, T_STRING_T, T_TRANSFORM,
+
+    /* value words */
+    T_TRUE, T_FALSE,
+
+    /* structural / argument words */
+    T_OF, T_WHEN, T_RESULT, T_INDEX, T_AT, T_TEXT, T_BOTH,
+    T_LESS, T_EQUAL, T_GREATER, T_DIFFERENT, T_LESS_OR_EQUAL, T_GREATER_OR_EQUAL
+} TokKind;
+
+typedef struct Token {
+    TokKind kind;
+    Loc loc;
+    char* text;   /* identifier text (owned) */
+    uint64_t ival; /* integer magnitude or char code */
+    int neg;       /* integer literal was negative */
+    double fval;   /* float value */
+    char* str;     /* string literal bytes (owned, not NUL-terminated) */
+    size_t strlen; /* string literal length */
+} Token;
+
+/* Lex the whole source into a NUL(kind=T_EOF)-terminated token array.
+   Returns a malloc'd array. On lexical errors, reports via crn_error
+   and returns the tokens lexed so far (always terminated). */
+Token* lex_source(const char* src, size_t* out_count);
+
+const char* tok_kind_name(TokKind k);
+
+/* Is this token a type-starting word? */
+int tok_is_type_start(TokKind k);
+
+#endif
diff --git a/src/main.c b/src/main.c
new file mode 100644
index 0000000..e9560f2
--- /dev/null
+++ b/src/main.c
@@ -0,0 +1,186 @@
+/* cryn - The Cryn Programming Language compiler (bootstrap) */
+#include "be_c.h"
+#include "check.h"
+#include "diag.h"
+#include "lex.h"
+#include "opt.h"
+#include "parse.h"
+#include "util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#define CRYN_VERSION "0.1.0"
+
+static void usage(void) {
+    printf(
+        "cryn - The Cryn Programming Language compiler (v%s)\n"
+        "\n"
+        "usage:\n"
+        "  cryn <file.cryn>               compile to an executable\n"
+        "  cryn build <file.cryn> [-o out]\n"
+        "  cryn run <file.cryn>           compile and execute\n"
+        "  cryn check <file.cryn>         type check only\n"
+        "  cryn emit-c <file.cryn> [-o out.c]\n"
+        "                                 write generated C\n"
+        "  cryn emit-ir <file.cryn>       print the Cryn IR\n"
+        "  cryn help\n"
+        "  cryn version\n"
+        "\n"
+        "environment:\n"
+        "  CC   host C compiler used for code generation (default: cc)\n",
+        CRYN_VERSION);
+}
+
+static char* find_host_cc(void) {
+    const char* cc = getenv("CC");
+    if (cc && cc[0]) return xstrdup(cc);
+    return xstrdup("cc");
+}
+
+static int run_host_cc(const char* cc, const char* cfile, const char* out) {
+    char* cmd;
+    size_t n = snprintf(NULL, 0, "%s -O2 -std=c11 -pthread -o %s %s", cc, out, cfile);
+    cmd = xmalloc(n + 1);
+    snprintf(cmd, n + 1, "%s -O2 -std=c11 -pthread -o %s %s", cc, out, cfile);
+    int rc = system(cmd);
+    free(cmd);
+    if (rc == -1) return -1;
+    if (WIFEXITED(rc)) return WEXITSTATUS(rc);
+    return -1;
+}
+
+static const char* tmp_dir(void) {
+    const char* t = getenv("TMPDIR");
+    if (t && t[0]) return t;
+    return "/tmp";
+}
+
+typedef enum { CMD_COMPILE, CMD_RUN, CMD_CHECK, CMD_EMITC, CMD_EMITIR } CmdKind;
+
+static int compile_file(const char* path, CmdKind kind, const char* out) {
+    char* src = NULL;
+    size_t len = 0;
+    if (read_file(path, &src, &len) != 0) {
+        fprintf(stderr, "cryn: cannot read '%s'\n", path);
+        return 1;
+    }
+    crn_diag_set_file(path);
+
+    Token* toks = lex_source(src, NULL);
+    ProgramAst* ast = parse_program(toks);
+    if (crn_error_count()) return 1;
+
+    ProgramIR* ir = check_program(ast);
+    if (!ir) return 1;
+
+    opt_program(ir);
+
+    switch (kind) {
+    case CMD_CHECK:
+        return 0;
+    case CMD_EMITIR: {
+        Buf b = {0};
+        ir_dump(ir, &b);
+        fwrite(b.data, 1, b.len, stdout);
+        buf_free(&b);
+        return 0;
+    }
+    case CMD_EMITC: {
+        Buf b = {0};
+        be_c_emit(ir, &b);
+        const char* dst = out ? out : "out.c";
+        if (write_file(dst, b.data, b.len) != 0) {
+            fprintf(stderr, "cryn: cannot write '%s'\n", dst);
+            return 1;
+        }
+        return 0;
+    }
+    case CMD_COMPILE:
+    case CMD_RUN: {
+        char cfile[4096], exefile[4096];
+        if (kind == CMD_RUN) {
+            long id = (long)getpid() * 1000 + (long)(time(NULL) % 1000);
+            snprintf(cfile, sizeof cfile, "%s/cryn%ld.c", tmp_dir(), id);
+            snprintf(exefile, sizeof exefile, "%s/cryn%ld", tmp_dir(), id);
+        } else {
+            char base[512];
+            snprintf(base, sizeof base - 8, "%s", out ? out : path);
+            if (!out) path_strip_ext(base);
+            snprintf(cfile, sizeof cfile, "%s.c", base);
+            snprintf(exefile, sizeof exefile, "%s", base);
+        }
+        Buf b = {0};
+        be_c_emit(ir, &b);
+        if (write_file(cfile, b.data, b.len) != 0) {
+            fprintf(stderr, "cryn: cannot write '%s'\n", cfile);
+            return 1;
+        }
+        char* cc = find_host_cc();
+        int rc = run_host_cc(cc, cfile, exefile);
+        free(cc);
+        if (rc != 0) {
+            fprintf(stderr, "cryn: host C compiler failed (exit %d)\n", rc);
+            if (kind == CMD_RUN) remove(cfile);
+            return 1;
+        }
+        if (kind == CMD_COMPILE) {
+            remove(cfile);
+            return 0;
+        }
+        fflush(NULL);
+        int prc = system(exefile);
+        remove(cfile);
+        remove(exefile);
+        if (prc == -1) return 1;
+        if (WIFEXITED(prc)) return WEXITSTATUS(prc);
+        return 1;
+    }
+    }
+    return 1;
+}
+
+int main(int argc, char** argv) {
+    if (argc < 2) {
+        usage();
+        return 1;
+    }
+    const char* cmd = argv[1];
+
+    if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0 || strcmp(cmd, "-h") == 0) {
+        usage();
+        return 0;
+    }
+    if (strcmp(cmd, "version") == 0 || strcmp(cmd, "--version") == 0) {
+        printf("cryn %s\n", CRYN_VERSION);
+        return 0;
+    }
+
+    CmdKind kind = CMD_COMPILE;
+    const char* out = NULL;
+    const char* file = NULL;
+    int has_sub = 1;
+    if (strcmp(cmd, "build") == 0) kind = CMD_COMPILE;
+    else if (strcmp(cmd, "run") == 0) kind = CMD_RUN;
+    else if (strcmp(cmd, "check") == 0) kind = CMD_CHECK;
+    else if (strcmp(cmd, "emit-c") == 0) kind = CMD_EMITC;
+    else if (strcmp(cmd, "emit-ir") == 0) kind = CMD_EMITIR;
+    else { file = cmd; has_sub = 0; }
+
+    if (has_sub) {
+        if (argc < 3) {
+            fprintf(stderr, "cryn: '%s' needs a source file\n", cmd);
+            return 1;
+        }
+        file = argv[2];
+    }
+    int start = has_sub ? 3 : 2;
+    for (int i = start; i < argc; i++) {
+        if (strcmp(argv[i], "-o") == 0 && i + 1 < argc) out = argv[++i];
+    }
+    return compile_file(file, kind, out);
+}
diff --git a/src/opt.c b/src/opt.c
new file mode 100644
index 0000000..7ef8ff9
--- /dev/null
+++ b/src/opt.c
@@ -0,0 +1,44 @@
+#include "opt.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+/* Remove code that is unreachable (after unconditional jumps/returns/stop)
+   up to the next referenced label, and drop labels nobody targets. */
+static void opt_fn(FnIR* fn) {
+    if (fn->nins == 0) return;
+
+    /* which labels are referenced? */
+    char* used = xmalloc((size_t)(fn->nlabels ? fn->nlabels : 1));
+    memset(used, 0, (size_t)(fn->nlabels ? fn->nlabels : 1));
+    for (int i = 0; i < fn->nins; i++) {
+        Insn* in = &fn->ins[i];
+        if (in->op == I_JMP || in->op == I_JMPCOND) used[in->a] = 1;
+    }
+
+    Insn* out = xmalloc((size_t)fn->nins * sizeof(Insn));
+    int nout = 0;
+    int dead = 0;
+    for (int i = 0; i < fn->nins; i++) {
+        Insn* in = &fn->ins[i];
+        if (in->op == I_LBL) {
+            dead = 0;
+            if (!used[in->a]) continue; /* drop unreferenced label */
+            out[nout++] = *in;
+            continue;
+        }
+        if (dead) continue;
+        out[nout++] = *in;
+        if (in->op == I_JMP || in->op == I_RET || in->op == I_STOP) dead = 1;
+    }
+    free(used);
+    if (nout < fn->nins) {
+        memcpy(fn->ins, out, (size_t)nout * sizeof(Insn));
+        fn->nins = nout;
+    }
+    free(out);
+}
+
+void opt_program(ProgramIR* p) {
+    for (int i = 0; i < p->nfns; i++) opt_fn(&p->fns[i]);
+}
diff --git a/src/opt.h b/src/opt.h
new file mode 100644
index 0000000..ed2c160
--- /dev/null
+++ b/src/opt.h
@@ -0,0 +1,9 @@
+#ifndef CR_OPT_H
+#define CR_OPT_H
+
+#include "ir.h"
+
+/* IR-to-IR optimizations: unreachable code removal, dead label removal. */
+void opt_program(ProgramIR* p);
+
+#endif
diff --git a/src/parse.c b/src/parse.c
new file mode 100644
index 0000000..1f6d628
--- /dev/null
+++ b/src/parse.c
@@ -0,0 +1,417 @@
+#include "parse.h"
+#include "util.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+typedef struct Parser {
+    Token* t;
+    size_t i;
+    FnAst* cur_fn; /* for error context */
+} Parser;
+
+static Token* peek(Parser* p) { return &p->t[p->i]; }
+static Token* next(Parser* p) { return &p->t[p->i++]; }
+
+static int at(Parser* p, TokKind k) { return peek(p)->kind == k; }
+static int accept(Parser* p, TokKind k) {
+    if (at(p, k)) { p->i++; return 1; }
+    return 0;
+}
+
+static Token* expect(Parser* p, TokKind k, const char* what) {
+    Token* t = peek(p);
+    if (t->kind != k) {
+        crn_error(t->loc, "expected %s%s%s but found '%s'",
+                  what, what[0] ? " " : "", what[0] ? "" : "", tok_kind_name(t->kind));
+        return t;
+    }
+    return next(p);
+}
+
+static Node* node_new(Parser* p, AstKind kind) {
+    Node* n = xmalloc(sizeof(Node));
+    memset(n, 0, sizeof *n);
+    n->kind = kind;
+    n->loc = peek(p)->loc;
+    (void)p;
+    return n;
+}
+
+static TypeAst* parse_type(Parser* p);
+
+static TypeAst* type_new(Parser* p, TypeAstKind k) {
+    TypeAst* t = xmalloc(sizeof(TypeAst));
+    memset(t, 0, sizeof *t);
+    t->kind = k;
+    t->loc = peek(p)->loc;
+    (void)p;
+    return t;
+}
+
+static TypeAst* parse_type(Parser* p) {
+    Token* t = peek(p);
+    if (t->kind == T_ADDRESS_T || t->kind == T_ADDRESS) {
+        next(p);
+        TypeAst* a = type_new(p, TB_ADDR);
+        if (tok_is_type_start(peek(p)->kind))
+            a->inner = parse_type(p);
+        else
+            a->inner = NULL; /* address of nothing */
+        return a;
+    }
+    if (t->kind == T_ARRAY) {
+        next(p);
+        TypeAst* a = type_new(p, TB_ARR);
+        if (at(p, T_INT)) {
+            Token* n = next(p);
+            if (n->neg) crn_error(n->loc, "array size cannot be negative");
+            a->count = n->ival;
+            if (a->count == 0) crn_error(n->loc, "array size must be at least 1");
+        } else {
+            a->count = 0; /* unsized array (only valid as address pointee) */
+        }
+        expect(p, T_OF, "'of'");
+        a->inner = parse_type(p);
+        if (a->inner && a->inner->kind == TB_ARR && a->inner->count == 0)
+            crn_error(t->loc, "array element type cannot be an unsized array");
+        return a;
+    }
+    if (t->kind == T_STRING_T) {
+        crn_error(t->loc, "'string' is not a type; it is used with 'make'");
+        next(p);
+        return type_new(p, TB_BASE);
+    }
+    if (tok_is_type_start(t->kind)) {
+        next(p);
+        TypeAst* a = type_new(p, TB_BASE);
+        a->base = t->kind;
+        return a;
+    }
+    crn_error(t->loc, "expected a type but found '%s'", tok_kind_name(t->kind));
+    next(p);
+    return NULL;
+}
+
+static char* parse_name(Parser* p, const char* what) {
+    Token* t = peek(p);
+    if (t->kind != T_IDENT) {
+        crn_error(t->loc, "expected %s but found '%s'", what, tok_kind_name(t->kind));
+        if (t->kind != T_EOF) next(p);
+        return xstrdup("_bad");
+    }
+    next(p);
+    return xstrdup(t->text);
+}
+
+static Imm imm_from_token(Token* t) {
+    Imm m;
+    memset(&m, 0, sizeof m);
+    switch (t->kind) {
+    case T_INT: m.kind = IM_INT; m.u = t->ival; m.neg = t->neg; break;
+    case T_FLOAT: m.kind = IM_FLOAT; m.d = t->fval; break;
+    case T_CHAR: m.kind = IM_CHAR; m.u = t->ival; break;
+    case T_TRUE: m.kind = IM_BOOL; m.b = 1; break;
+    case T_FALSE: m.kind = IM_BOOL; m.b = 0; break;
+    default: m.kind = IM_NONE; break;
+    }
+    return m;
+}
+
+static Node* parse_command(Parser* p) {
+    Token* t = peek(p);
+    Node* n;
+    switch (t->kind) {
+    case T_MOVE: {
+        next(p);
+        Token* a = peek(p);
+        if (a->kind == T_IDENT) { next(p); n = node_new(p, A_MOVE_NAME); n->name = xstrdup(a->text); }
+        else if (a->kind == T_INT) { next(p); n = node_new(p, A_MOVE_LIT); n->imm = imm_from_token(a); n->has_imm = 1; }
+        else if (a->kind == T_INDEX) { next(p); n = node_new(p, A_MOVE_INDEX); }
+        else if (a->kind == T_AT) { next(p); n = node_new(p, A_MOVE_AT); }
+        else { crn_error(a->loc, "expected connection name, index, 'index', or 'at' after 'move'"); n = node_new(p, A_HOME); }
+        return n;
+    }
+    case T_BACK: next(p); return node_new(p, A_BACK);
+    case T_HOME: next(p); return node_new(p, A_HOME);
+    case T_ADDRESS: {
+        next(p);
+        if (at(p, T_IDENT)) { n = node_new(p, A_ADDRESS_NAME); n->name = parse_name(p, "a cell or transformation name"); }
+        else n = node_new(p, A_ADDRESS_HERE);
+        return n;
+    }
+    case T_MAKE: {
+        next(p);
+        Token* k = peek(p);
+        if (k->kind == T_STRING_T) {
+            next(p);
+            Token* s = expect(p, T_STRING, "a string literal");
+            n = node_new(p, A_MAKE_STRING);
+            if (s->kind == T_STRING) {
+                n->str = xmalloc(s->strlen ? s->strlen : 1);
+                memcpy(n->str, s->str ? s->str : "", s->strlen);
+                n->strlen = s->strlen;
+            }
+            if (at(p, T_IDENT)) n->name = parse_name(p, "a cell name");
+            return n;
+        }
+        if (k->kind == T_ARRAY) {
+            TypeAst* a = parse_type(p);
+            n = node_new(p, A_MAKE_ARRAY);
+            n->t = a;
+            if (at(p, T_IDENT)) n->name = parse_name(p, "a cell name");
+            return n;
+        }
+        if (tok_is_type_start(k->kind)) {
+            TypeAst* a = parse_type(p);
+            n = node_new(p, A_MAKE_VALUE);
+            n->t = a;
+            if (at(p, T_IDENT)) n->name = parse_name(p, "a cell name");
+            return n;
+        }
+        if (k->kind == T_IDENT || k->kind == T_INT) {
+            /* dynamic block: make <size> of <type> [name] */
+            n = node_new(p, A_MAKE_DYN);
+            if (k->kind == T_IDENT) {
+                next(p);
+                n->name = xstrdup(k->text); /* size cell name */
+                n->has_imm = 0;
+            } else {
+                next(p);
+                n->imm = imm_from_token(k);
+                n->has_imm = 1;
+                n->name = NULL;
+            }
+            expect(p, T_OF, "'of'");
+            n->t = parse_type(p);
+            if (at(p, T_IDENT)) n->name2 = parse_name(p, "a cell name");
+            return n;
+        }
+        crn_error(k->loc, "expected a type, 'array', 'string', a size name, or a size literal after 'make'");
+        next(p);
+        return node_new(p, A_HOME);
+    }
+    case T_FREE: next(p); return node_new(p, A_FREE);
+    case T_READ: next(p); return node_new(p, A_READ);
+    case T_WRITE: next(p); return node_new(p, A_WRITE);
+    case T_SET: {
+        next(p);
+        Token* v = peek(p);
+        if (v->kind != T_INT && v->kind != T_FLOAT && v->kind != T_CHAR &&
+            v->kind != T_TRUE && v->kind != T_FALSE && v->kind != T_NOTHING) {
+            crn_error(v->loc, "expected a literal value after 'set' but found '%s'", tok_kind_name(v->kind));
+            if (v->kind != T_EOF) next(p);
+            return node_new(p, A_SET);
+        }
+        next(p);
+        n = node_new(p, A_SET);
+        n->imm = imm_from_token(v);
+        if (v->kind == T_NOTHING) n->imm.kind = IM_NONE;
+        n->has_imm = 1;
+        return n;
+    }
+    case T_ADD: case T_SUBTRACT: case T_MULTIPLY: case T_DIVIDE: case T_REMAINDER: {
+        TokKind op = next(p)->kind;
+        n = node_new(p, A_ARITH);
+        n->arith = op == T_ADD ? AR_ADD : op == T_SUBTRACT ? AR_SUB :
+                   op == T_MULTIPLY ? AR_MUL : op == T_DIVIDE ? AR_DIV : AR_MOD;
+        Token* v = peek(p);
+        if (v->kind == T_INT || v->kind == T_FLOAT) {
+            next(p);
+            n->imm = imm_from_token(v);
+            n->has_imm = 1;
+        }
+        return n;
+    }
+    case T_NEGATE: next(p); return node_new(p, A_NEG);
+    case T_PUSH: next(p); return node_new(p, A_PUSH);
+    case T_POP: next(p); return node_new(p, A_POP);
+    case T_SWAP: next(p); return node_new(p, A_SWAP);
+    case T_COMPARE: {
+        next(p);
+        n = node_new(p, A_CMP);
+        Token* v = peek(p);
+        if (v->kind == T_INT || v->kind == T_FLOAT || v->kind == T_CHAR ||
+            v->kind == T_TRUE || v->kind == T_FALSE) {
+            next(p);
+            n->imm = imm_from_token(v);
+            n->has_imm = 1;
+        }
+        return n;
+    }
+    case T_CONVERT: {
+        next(p);
+        n = node_new(p, A_CONV);
+        n->t = parse_type(p);
+        return n;
+    }
+    case T_JUMP: {
+        next(p);
+        n = node_new(p, A_JMP);
+        n->name = parse_name(p, "a label name");
+        if (accept(p, T_WHEN)) {
+            Token* c = peek(p);
+            if (c->kind == T_LESS || c->kind == T_EQUAL || c->kind == T_GREATER ||
+                c->kind == T_DIFFERENT || c->kind == T_LESS_OR_EQUAL ||
+                c->kind == T_GREATER_OR_EQUAL) {
+                next(p);
+                n->cond = c->kind;
+                n->has_cond = 1;
+            } else {
+                crn_error(c->loc, "expected a condition (less, equal, greater, different, less-or-equal, greater-or-equal) after 'when'");
+            }
+        }
+        return n;
+    }
+    case T_CALL: {
+        next(p);
+        if (at(p, T_IDENT)) { n = node_new(p, A_CALL_NAME); n->name = parse_name(p, "a transformation name"); }
+        else n = node_new(p, A_CALL_IND);
+        return n;
+    }
+    case T_RETURN: next(p); return node_new(p, A_RET);
+    case T_STOP: next(p); return node_new(p, A_STOP);
+    case T_INPUT: next(p); return node_new(p, A_INPUT);
+    case T_OUTPUT: {
+        next(p);
+        if (at(p, T_TEXT)) { next(p); return node_new(p, A_OUTPUT_TEXT); }
+        return node_new(p, A_OUTPUT);
+    }
+    case T_OPEN: {
+        next(p);
+        n = node_new(p, A_OPEN);
+        Token* m = peek(p);
+        if (m->kind == T_READ || m->kind == T_WRITE || m->kind == T_BOTH) {
+            next(p);
+            n->mode = m->kind;
+        } else {
+            crn_error(m->loc, "expected 'read', 'write', or 'both' after 'open'");
+            n->mode = T_READ;
+        }
+        return n;
+    }
+    case T_CLOSE: next(p); return node_new(p, A_CLOSE);
+    case T_SYSTEM: next(p); return node_new(p, A_SYSTEM);
+    case T_SPAWN: {
+        next(p);
+        n = node_new(p, A_SPAWN);
+        n->name = parse_name(p, "a transformation name");
+        return n;
+    }
+    case T_WAIT: next(p); return node_new(p, A_WAIT);
+    case T_MARK: {
+        next(p);
+        n = node_new(p, A_MARK);
+        n->name = parse_name(p, "a label name");
+        return n;
+    }
+    default:
+        crn_error(t->loc, "expected a command but found '%s'", tok_kind_name(t->kind));
+        next(p);
+        return NULL;
+    }
+}
+
+static void parse_define(Parser* p, ProgramAst* prog) {
+    Token* d = expect(p, T_DEFINE, "'define'");
+    FnAst fn;
+    memset(&fn, 0, sizeof fn);
+    fn.loc = d->loc;
+    fn.name = parse_name(p, "a transformation name");
+
+    /* parameters: { type name } until a non-type token.
+       'address' only opens a parameter when a pointee type follows,
+       so 'address mycell' as the first command is not swallowed. */
+    List params = {0};
+    for (;;) {
+        Token* t = peek(p);
+        int is_type = tok_is_type_start(t->kind) && t->kind != T_ADDRESS;
+        if (t->kind == T_ADDRESS && tok_is_type_start(p->t[p->i + 1].kind))
+            is_type = 1;
+        if (!is_type) break;
+        ParamAst pa;
+        pa.loc = peek(p)->loc;
+        pa.t = parse_type(p);
+        pa.name = parse_name(p, "a parameter name");
+        ParamAst* pp = xmalloc(sizeof(ParamAst));
+        *pp = pa;
+        list_add(&params, pp);
+    }
+
+    if (accept(p, T_RESULT)) fn.result = parse_type(p);
+
+    List body = {0};
+    while (!at(p, T_END) && !at(p, T_EOF)) {
+        Node* n = parse_command(p);
+        if (n) list_add(&body, n);
+    }
+    expect(p, T_END, "'end'");
+
+    /* copy param and body pointer lists into contiguous arrays */
+    fn.params = xmalloc((params.len ? params.len : 1) * sizeof(ParamAst));
+    for (size_t i = 0; i < params.len; i++) fn.params[i] = *(ParamAst*)params.items[i];
+    fn.body = xmalloc((body.len ? body.len : 1) * sizeof(Node));
+    for (size_t i = 0; i < body.len; i++) fn.body[i] = *(Node*)body.items[i];
+    fn.nbody = body.len;
+    fn.nparams = params.len;
+
+    /* append */
+    prog->nfns++;
+    prog->fns = xrealloc(prog->fns, prog->nfns * sizeof(FnAst));
+    prog->fns[prog->nfns - 1] = fn;
+}
+
+static void parse_global_make(Parser* p, ProgramAst* prog) {
+    Token* m = expect(p, T_MAKE, "'make'");
+    GlobAst g;
+    memset(&g, 0, sizeof g);
+    g.loc = m->loc;
+    Token* k = peek(p);
+    if (k->kind == T_STRING_T) {
+        next(p);
+        Token* s = expect(p, T_STRING, "a string literal");
+        if (s->kind == T_STRING) { g.str = xmalloc(s->strlen + 1); memcpy(g.str, s->str, s->strlen); g.str[s->strlen] = 0; g.strlen = s->strlen; }
+        g.kind = G_STRING;
+        g.name = parse_name(p, "a world cell name");
+    } else if (k->kind == T_ARRAY) {
+        g.kind = G_ARRAY;
+        g.t = parse_type(p);
+        if (g.t->kind == TB_ARR) g.count = g.t->count;
+        g.name = parse_name(p, "a world cell name");
+    } else if (tok_is_type_start(k->kind)) {
+        g.kind = G_VALUE;
+        g.t = parse_type(p);
+        g.name = parse_name(p, "a world cell name");
+    } else {
+        crn_error(k->loc, "a world cell made with 'make' at the top level must be a value, array, or string");
+        next(p);
+        return;
+    }
+    prog->nglobs++;
+    prog->globs = xrealloc(prog->globs, prog->nglobs * sizeof(GlobAst));
+    prog->globs[prog->nglobs - 1] = g;
+}
+
+ProgramAst* parse_program(Token* toks) {
+    Parser p;
+    p.t = toks;
+    p.i = 0;
+    p.cur_fn = NULL;
+
+    ProgramAst* prog = xmalloc(sizeof(ProgramAst));
+    prog->fns = NULL;
+    prog->nfns = 0;
+    prog->globs = NULL;
+    prog->nglobs = 0;
+
+    while (!at(&p, T_EOF)) {
+        if (at(&p, T_DEFINE)) parse_define(&p, prog);
+        else if (at(&p, T_MAKE)) parse_global_make(&p, prog);
+        else {
+            Token* t = peek(&p);
+            crn_error(t->loc, "expected 'define' or 'make' at top level but found '%s'", tok_kind_name(t->kind));
+            next(&p);
+        }
+    }
+    return prog;
+}
diff --git a/src/parse.h b/src/parse.h
new file mode 100644
index 0000000..696b99e
--- /dev/null
+++ b/src/parse.h
@@ -0,0 +1,10 @@
+#ifndef CR_PARSE_H
+#define CR_PARSE_H
+
+#include "ast.h"
+
+/* Parse a token array into a program. Reports errors via crn_error.
+   Always returns a ProgramAst (possibly empty on hard error). */
+ProgramAst* parse_program(Token* toks);
+
+#endif
diff --git a/src/rt/cryn_rt.c b/src/rt/cryn_rt.c
new file mode 100644
index 0000000..ff9f743
--- /dev/null
+++ b/src/rt/cryn_rt.c
@@ -0,0 +1,264 @@
+#include "rt/cryn_rt.h"
+#include "util.h"
+
+#include <errno.h>
+#include <math.h>
+#include <stdlib.h>
+#include <string.h>
+
+_Thread_local CrCtx cr_ctx = {0};
+
+void crn_init(void) {
+    memset(&cr_ctx, 0, sizeof cr_ctx);
+    cr_ctx.in = stdin;
+    cr_ctx.out = stdout;
+}
+
+void crn_die(const char* msg) {
+    fprintf(stderr, "cryn: runtime error: %s\n", msg);
+    exit(1);
+}
+
+void crn_die_range(uint64_t i, uint64_t n) {
+    fprintf(stderr, "cryn: runtime error: index %llu out of bounds (size %llu)\n",
+            (unsigned long long)i, (unsigned long long)n);
+    exit(1);
+}
+
+/* ---- value stack ---- */
+
+void crn_push(Val v) {
+    if (cr_ctx.sp == cr_ctx.cap) {
+        cr_ctx.cap = cr_ctx.cap ? cr_ctx.cap * 2 : 64;
+        cr_ctx.stk = realloc(cr_ctx.stk, cr_ctx.cap * sizeof(Val));
+        if (!cr_ctx.stk) crn_die("out of memory");
+    }
+    cr_ctx.stk[cr_ctx.sp++] = v;
+}
+
+Val crn_pop(void) { return cr_ctx.stk[--cr_ctx.sp]; }
+
+/* ---- focus ---- */
+
+void crn_focus_push(void* f) {
+    if (cr_ctx.fh_sp >= CR_FH_MAX) crn_die("focus history overflow");
+    cr_ctx.fh[cr_ctx.fh_sp++] = f;
+}
+
+void* crn_focus_pop(void) {
+    if (cr_ctx.fh_sp == 0) crn_die("focus history underflow");
+    return cr_ctx.fh[--cr_ctx.fh_sp];
+}
+
+/* ---- heap ---- */
+
+typedef struct CrHdr {
+    uint64_t magic;
+    uint64_t size; /* data bytes */
+} CrHdr;
+
+#define CR_MAGIC 0x4352594E424C4BULL /* "CRYNBLK" */
+
+void* crn_alloc(uint64_t nbytes) {
+    CrHdr* h = malloc(sizeof(CrHdr) + (size_t)nbytes + 16);
+    if (!h) crn_die("out of memory");
+    h->magic = CR_MAGIC;
+    h->size = nbytes;
+    unsigned char* p = (unsigned char*)h;
+    p += sizeof(CrHdr);
+    /* keep data 16-aligned: sizeof(CrHdr)==16 on 64-bit */
+    memset(p, 0, (size_t)nbytes);
+    return p;
+}
+
+void crn_free(void* p) {
+    if (!p) crn_die("free of the null address");
+    CrHdr* h = (CrHdr*)((unsigned char*)p - sizeof(CrHdr));
+    if (h->magic != CR_MAGIC) {
+        crn_die("free of an address that is not a heap block "
+                "(only blocks made with 'make <size> of <type>' or anonymous 'make' can be freed)");
+    }
+    h->magic = 0;
+    free(h);
+}
+
+uint64_t crn_block_n(void* data, uint64_t elemsz) {
+    if (elemsz == 0) elemsz = 1;
+    CrHdr* h = (CrHdr*)((unsigned char*)data - sizeof(CrHdr));
+    if (h->magic != CR_MAGIC) {
+        crn_die("index into memory that is not a heap block "
+                "(only blocks made with 'make <size> of <type>' support bounds checks)");
+    }
+    return h->size / elemsz;
+}
+
+void crn_check_block(void* data, uint64_t i, uint64_t elemsz) {
+    uint64_t n = crn_block_n(data, elemsz);
+    if (i >= n) crn_die_range(i, n);
+}
+
+void crn_check_range(uint64_t i, uint64_t n) {
+    if (i >= n) crn_die_range(i, n);
+}
+
+/* ---- conversions ---- */
+
+int64_t crn_d2i64(double d) {
+    if (d >= 9223372036854775808.0) return INT64_MAX;
+    if (d <= -9223372036854775808.0) return INT64_MIN;
+    return (int64_t)d;
+}
+
+uint64_t crn_d2u64(double d) {
+    if (d >= 18446744073709551616.0) return UINT64_MAX;
+    if (d <= 0.0) return 0;
+    return (uint64_t)d;
+}
+
+int64_t crn_d2i8(double d) {
+    if (d >= 127.0) return 127;
+    if (d <= -128.0) return -128;
+    return (int64_t)(int8_t)d;
+}
+
+int64_t crn_d2i16(double d) {
+    if (d >= 32767.0) return 32767;
+    if (d <= -32768.0) return -32768;
+    return (int64_t)(int16_t)d;
+}
+
+int64_t crn_d2i32(double d) {
+    if (d >= 2147483648.0) return 2147483647;
+    if (d <= -2147483649.0) return -2147483648;
+    return (int64_t)(int32_t)d;
+}
+
+uint64_t crn_d2u8(double d) {
+    if (d >= 255.0) return 255;
+    if (d <= 0.0) return 0;
+    return (uint64_t)(uint8_t)d;
+}
+
+uint64_t crn_d2u16(double d) {
+    if (d >= 65535.0) return 65535;
+    if (d <= 0.0) return 0;
+    return (uint64_t)(uint16_t)d;
+}
+
+uint64_t crn_d2u32(double d) {
+    if (d >= 4294967296.0) return 4294967295ULL;
+    if (d <= 0.0) return 0;
+    return (uint64_t)(uint32_t)d;
+}
+
+/* ---- output ---- */
+
+static void crn_out_str(const char* s) { fputs(s, cr_ctx.out); }
+
+void crn_out_i64(int64_t v) { fprintf(cr_ctx.out, "%lld", (long long)v); }
+void crn_out_u64(uint64_t v) { fprintf(cr_ctx.out, "%llu", (unsigned long long)v); }
+
+static void crn_out_f(double v, int maxprec) {
+    char buf[64];
+    for (int prec = 1; prec <= maxprec; prec++) {
+        snprintf(buf, sizeof buf, "%.*g", prec, v);
+        if (strtod(buf, NULL) == v) break;
+    }
+    crn_out_str(buf);
+}
+
+void crn_out_f64(double v) { crn_out_f(v, 17); }
+void crn_out_f32(float v) {
+    char buf[64];
+    for (int prec = 1; prec <= 9; prec++) {
+        snprintf(buf, sizeof buf, "%.*g", prec, v);
+        if ((float)strtod(buf, NULL) == v) break;
+    }
+    crn_out_str(buf);
+}
+void crn_out_bool(int b) { crn_out_str(b ? "true" : "false"); }
+void crn_out_char(unsigned c) { fputc((int)(c & 0xFF), cr_ctx.out); }
+void crn_out_addr(uint64_t a) { fprintf(cr_ctx.out, "0x%llx", (unsigned long long)a); }
+
+void crn_out_text(uint8_t* p) {
+    FILE* f = cr_ctx.out;
+    while (*p) fputc(*p++, f);
+}
+
+/* ---- input / files / system ---- */
+
+int64_t crn_input(void) {
+    int c = fgetc(cr_ctx.in);
+    if (c == EOF) return -1;
+    return c;
+}
+
+int64_t crn_open(uint8_t* path, int mode) {
+    const char* m;
+    int flags_ok = 1;
+    (void)flags_ok;
+    switch (mode) {
+    case 0: m = "rb"; break;
+    case 1: m = "wb"; break;
+    default: m = "w+b"; break;
+    }
+    FILE* f = fopen((const char*)path, m);
+    if (!f) return -1;
+    /* success: route this context's streams through the file */
+    if (mode == 0) cr_ctx.in = f;
+    else if (mode == 1) cr_ctx.out = f;
+    else { cr_ctx.in = f; cr_ctx.out = f; }
+    cr_ctx.files[cr_ctx.nfiles < CR_FILES_MAX ? cr_ctx.nfiles++ : 0] = f;
+    return (int64_t)(uintptr_t)f;
+}
+
+int64_t crn_close(int64_t h) {
+    FILE* f = (FILE*)(uintptr_t)h;
+    if (h <= 0) return -1;
+    if (cr_ctx.in == f) cr_ctx.in = stdin;
+    if (cr_ctx.out == f) cr_ctx.out = stdout;
+    for (int i = 0; i < cr_ctx.nfiles; i++) {
+        if (cr_ctx.files[i] == f) {
+            cr_ctx.files[i] = cr_ctx.files[--cr_ctx.nfiles];
+            break;
+        }
+    }
+    return fclose(f) == 0 ? 0 : -1;
+}
+
+int64_t crn_system(uint8_t* cmd) { return (int64_t)system((const char*)cmd); }
+
+void crn_stop(int64_t code) { exit((int)(code & 0xFF)); }
+
+/* ---- concurrency ---- */
+
+typedef struct CrSpawnArg {
+    CrFnRef* fn;
+} CrSpawnArg;
+
+static void* crn_thread_main(void* argp) {
+    CrSpawnArg* a = argp;
+    CrFnRef* fn = a->fn;
+    free(a);
+    crn_init();
+    fn->fn();
+    return NULL;
+}
+
+void crn_spawn(CrFnRef* f) {
+    if (f->np != 0)
+        crn_die("spawn of a transformation that takes parameters");
+    if (cr_ctx.nspawned >= CR_SPAWN_MAX)
+        crn_die("too many outstanding spawned contexts (limit 64)");
+    CrSpawnArg* a = malloc(sizeof(CrSpawnArg));
+    if (!a) crn_die("out of memory");
+    a->fn = f;
+    if (pthread_create(&cr_ctx.spawned[cr_ctx.nspawned++], NULL, crn_thread_main, a) != 0)
+        crn_die("thread creation failed");
+}
+
+void crn_wait(void) {
+    for (int i = 0; i < cr_ctx.nspawned; i++)
+        pthread_join(cr_ctx.spawned[i], NULL);
+    cr_ctx.nspawned = 0;
+}
diff --git a/src/rt/cryn_rt.h b/src/rt/cryn_rt.h
new file mode 100644
index 0000000..907cd96
--- /dev/null
+++ b/src/rt/cryn_rt.h
@@ -0,0 +1,97 @@
+#ifndef CRYN_RT_H
+#define CRYN_RT_H
+
+/*
+ * Minimal runtime support for compiled Cryn programs.
+ * Everything here exists only because it cannot be expressed as inline C
+ * in generated code (context management, heap headers, threads, output
+ * formatting). The language's semantics live in the compiler.
+ */
+
+#include <stdint.h>
+#include <stdio.h>
+#include <pthread.h>
+
+#if defined(__GNUC__) || defined(__clang__)
+#define CRN_NORETURN __attribute__((noreturn))
+#else
+#define CRN_NORETURN
+#endif
+
+typedef union Val {
+    uint64_t u;   /* unsigned, address (as integer), bool, char */
+    int64_t i;    /* signed integers, integer literals */
+    double d;     /* float32 (widened) and float64 */
+    void* p;      /* focus target / data pointer */
+} Val;
+
+/* A transformation reference: function pointer + parameter count.
+   Indirect calls and spawns are rejected at runtime unless np == 0. */
+typedef struct CrFnRef {
+    Val (*fn)(void);
+    int np;
+} CrFnRef;
+
+#define CR_FH_MAX 256
+#define CR_SPAWN_MAX 64
+#define CR_FILES_MAX 64
+
+typedef struct CrCtx {
+    Val* stk;        /* value stack */
+    uint64_t sp, cap;
+    void* fh[CR_FH_MAX]; /* focus history */
+    uint64_t fh_sp;
+    FILE* in;        /* current input stream (default stdin) */
+    FILE* out;       /* current output stream (default stdout) */
+    FILE* files[CR_FILES_MAX];
+    int nfiles;
+    pthread_t spawned[CR_SPAWN_MAX];
+    int nspawned;
+} CrCtx;
+
+extern _Thread_local CrCtx cr_ctx;
+
+void crn_init(void);
+void crn_die(const char* msg) CRN_NORETURN;
+void crn_die_range(uint64_t i, uint64_t n) CRN_NORETURN;
+
+void crn_push(Val v);
+Val crn_pop(void);
+
+void crn_focus_push(void* f);
+void* crn_focus_pop(void);
+
+void* crn_alloc(uint64_t nbytes);
+void crn_free(void* p);
+uint64_t crn_block_n(void* data, uint64_t elemsz);
+void crn_check_block(void* data, uint64_t i, uint64_t elemsz);
+void crn_check_range(uint64_t i, uint64_t n);
+
+int64_t crn_d2i64(double d);
+uint64_t crn_d2u64(double d);
+int64_t crn_d2i8(double d);
+int64_t crn_d2i16(double d);
+int64_t crn_d2i32(double d);
+uint64_t crn_d2u8(double d);
+uint64_t crn_d2u16(double d);
+uint64_t crn_d2u32(double d);
+
+void crn_out_i64(int64_t v);
+void crn_out_u64(uint64_t v);
+void crn_out_f64(double v);
+void crn_out_f32(float v);
+void crn_out_bool(int b);
+void crn_out_char(unsigned c);
+void crn_out_addr(uint64_t a);
+void crn_out_text(uint8_t* p);
+
+int64_t crn_input(void);
+int64_t crn_open(uint8_t* path, int mode);
+int64_t crn_close(int64_t h);
+int64_t crn_system(uint8_t* cmd);
+void crn_stop(int64_t code) CRN_NORETURN;
+
+void crn_spawn(CrFnRef* f);
+void crn_wait(void);
+
+#endif
diff --git a/src/types.c b/src/types.c
new file mode 100644
index 0000000..27a47db
--- /dev/null
+++ b/src/types.c
@@ -0,0 +1,194 @@
+#include "types.h"
+#include "util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#define MAX_TYPES 65536
+static Type g_types[MAX_TYPES];
+static int g_ntypes = 0;
+
+static Type* intern(Type t) {
+    for (int i = 0; i < g_ntypes; i++) {
+        Type* a = &g_types[i];
+        if (a->kind == t.kind && a->inner == t.inner && a->count == t.count &&
+            a->litval == t.litval && a->litneg == t.litneg)
+            return a;
+    }
+    if (g_ntypes >= MAX_TYPES) {
+        fprintf(stderr, "cryn: too many distinct types\n");
+        exit(1);
+    }
+    g_types[g_ntypes] = t;
+    return &g_types[g_ntypes++];
+}
+
+static Type base(TypeKind k) {
+    Type t;
+    memset(&t, 0, sizeof t);
+    t.kind = k;
+    return t;
+}
+
+Type* ty_nothing(void) { return intern(base(TY_NOTHING)); }
+Type* ty_bool(void) { return intern(base(TY_BOOL)); }
+Type* ty_char(void) { return intern(base(TY_CHAR)); }
+Type* ty_int(int bits) {
+    switch (bits) {
+    case 8: return intern(base(TY_INT8));
+    case 16: return intern(base(TY_INT16));
+    case 32: return intern(base(TY_INT32));
+    default: return intern(base(TY_INT64));
+    }
+}
+Type* ty_uint(int bits) {
+    switch (bits) {
+    case 8: return intern(base(TY_UINT8));
+    case 16: return intern(base(TY_UINT16));
+    case 32: return intern(base(TY_UINT32));
+    default: return intern(base(TY_UINT64));
+    }
+}
+Type* ty_float32(void) { return intern(base(TY_FLOAT32)); }
+Type* ty_float64(void) { return intern(base(TY_FLOAT64)); }
+Type* ty_addr_to(Type* pointee) {
+    Type t;
+    memset(&t, 0, sizeof t);
+    t.kind = TY_ADDR;
+    t.inner = pointee;
+    return intern(t);
+}
+Type* ty_array(Type* elem, uint64_t count) {
+    Type t;
+    memset(&t, 0, sizeof t);
+    t.kind = TY_ARR;
+    t.inner = elem;
+    t.count = count;
+    return intern(t);
+}
+Type* ty_func(void) { return intern(base(TY_FUNC)); }
+Type* ty_intlit(uint64_t mag, int neg) {
+    Type t;
+    memset(&t, 0, sizeof t);
+    t.kind = TY_INTLIT;
+    t.litval = mag;
+    t.litneg = neg;
+    return intern(t);
+}
+
+int type_eq(const Type* a, const Type* b) { return a == b; }
+
+uint64_t type_size(const Type* t) {
+    switch (t->kind) {
+    case TY_NOTHING: return 1;
+    case TY_BOOL: return 1;
+    case TY_CHAR: return 4;
+    case TY_INT8: case TY_UINT8: return 1;
+    case TY_INT16: case TY_UINT16: return 2;
+    case TY_INT32: case TY_UINT32: case TY_FLOAT32: return 4;
+    case TY_INT64: case TY_UINT64: case TY_FLOAT64: case TY_ADDR: case TY_FUNC: return 8;
+    case TY_ARR: return t->count ? t->count * type_size(t->inner) : type_size(t->inner);
+    case TY_INTLIT: return 8;
+    }
+    return 8;
+}
+
+int ty_is_int(const Type* t) {
+    switch (t->kind) {
+    case TY_INT8: case TY_INT16: case TY_INT32: case TY_INT64:
+    case TY_UINT8: case TY_UINT16: case TY_UINT32: case TY_UINT64:
+        return 1;
+    default: return 0;
+    }
+}
+int ty_is_uint(const Type* t) {
+    switch (t->kind) {
+    case TY_UINT8: case TY_UINT16: case TY_UINT32: case TY_UINT64: return 1;
+    default: return 0;
+    }
+}
+int ty_is_signed_int(const Type* t) {
+    switch (t->kind) {
+    case TY_INT8: case TY_INT16: case TY_INT32: case TY_INT64: return 1;
+    default: return 0;
+    }
+}
+int ty_is_any_int(const Type* t) { return ty_is_int(t) || t->kind == TY_INTLIT; }
+int ty_is_float(const Type* t) { return t->kind == TY_FLOAT32 || t->kind == TY_FLOAT64; }
+int ty_is_numeric(const Type* t) { return ty_is_any_int(t) || ty_is_float(t); }
+int ty_is_addr(const Type* t) { return t->kind == TY_ADDR; }
+int ty_is_array(const Type* t) { return t->kind == TY_ARR; }
+int ty_is_intlit(const Type* t) { return t->kind == TY_INTLIT; }
+int ty_is_bool(const Type* t) { return t->kind == TY_BOOL; }
+int ty_is_char(const Type* t) { return t->kind == TY_CHAR; }
+int ty_is_func(const Type* t) { return t->kind == TY_FUNC; }
+int ty_is_nothing(const Type* t) { return t->kind == TY_NOTHING; }
+
+int ty_int_bits(const Type* t) {
+    switch (t->kind) {
+    case TY_INT8: case TY_UINT8: return 8;
+    case TY_INT16: case TY_UINT16: return 16;
+    case TY_INT32: case TY_UINT32: return 32;
+    case TY_INT64: case TY_UINT64: return 64;
+    default: return 0;
+    }
+}
+
+static uint64_t umax_for(int bits) {
+    uint64_t v = 0xFFFFFFFFFFFFFFFFULL;
+    return v >> (64 - bits);
+}
+
+int intlit_fits(uint64_t mag, int neg, const Type* t) {
+    if (t->kind == TY_BOOL) return mag <= 1;
+    if (t->kind == TY_CHAR) return !neg && mag <= 255;
+    if (ty_is_signed_int(t)) {
+        int bits = ty_int_bits(t);
+        uint64_t max = (1ULL << (bits - 1)) - 1;
+        if (neg) return mag <= max + 1;
+        return mag <= max;
+    }
+    if (ty_is_uint(t)) return !neg && mag <= umax_for(ty_int_bits(t));
+    if (t->kind == TY_ADDR) return 1; /* reinterpret */
+    if (t->kind == TY_FLOAT32 || t->kind == TY_FLOAT64) return 1;
+    return 0;
+}
+
+const char* type_name(const Type* t, char* buf, size_t bufsz) {
+    char tmp[128];
+    switch (t->kind) {
+    case TY_NOTHING: return "nothing";
+    case TY_BOOL: return "bool";
+    case TY_CHAR: return "char";
+    case TY_INT8: return "int8";
+    case TY_INT16: return "int16";
+    case TY_INT32: return "int32";
+    case TY_INT64: return "int64";
+    case TY_UINT8: return "uint8";
+    case TY_UINT16: return "uint16";
+    case TY_UINT32: return "uint32";
+    case TY_UINT64: return "uint64";
+    case TY_FLOAT32: return "float32";
+    case TY_FLOAT64: return "float64";
+    case TY_FUNC: return "transform";
+    case TY_ADDR:
+        if (t->inner->kind == TY_NOTHING) return "address";
+        snprintf(buf, bufsz, "address %s", type_name(t->inner, tmp, sizeof tmp));
+        return buf;
+    case TY_ARR:
+        if (t->count)
+            snprintf(buf, bufsz, "array %llu of %s",
+                     (unsigned long long)t->count,
+                     type_name(t->inner, tmp, sizeof tmp));
+        else
+            snprintf(buf, bufsz, "array of %s",
+                     type_name(t->inner, tmp, sizeof tmp));
+        return buf;
+    case TY_INTLIT:
+        snprintf(buf, bufsz, "%s%llu", t->litneg ? "-" : "",
+                 (unsigned long long)t->litval);
+        return buf;
+    }
+    return "?";
+}
diff --git a/src/types.h b/src/types.h
new file mode 100644
index 0000000..3b5a8bd
--- /dev/null
+++ b/src/types.h
@@ -0,0 +1,70 @@
+#ifndef CR_TYPES_H
+#define CR_TYPES_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+typedef enum {
+    TY_NOTHING,
+    TY_BOOL,
+    TY_CHAR,      /* 32-bit codepoint */
+    TY_INT8, TY_INT16, TY_INT32, TY_INT64,
+    TY_UINT8, TY_UINT16, TY_UINT32, TY_UINT64,
+    TY_FLOAT32, TY_FLOAT64,
+    TY_ADDR,      /* pointer; inner = pointee (TY_NOTHING for bare address) */
+    TY_ARR,       /* array; inner = element; count = 0 -> unsized block */
+    TY_FUNC,      /* transform reference */
+    TY_INTLIT     /* integer literal with known value and sign */
+} TypeKind;
+
+typedef struct Type {
+    TypeKind kind;
+    struct Type* inner; /* TY_ADDR pointee, TY_ARR element */
+    uint64_t count;     /* TY_ARR size; 0 = unsized */
+    uint64_t litval;    /* TY_INTLIT magnitude */
+    int litneg;         /* TY_INTLIT sign */
+} Type;
+
+/* Interned singletons and constructors (never free the results). */
+Type* ty_nothing(void);
+Type* ty_bool(void);
+Type* ty_char(void);
+Type* ty_int(int bits);   /* 8/16/32/64 -> signed */
+Type* ty_uint(int bits);
+Type* ty_float32(void);
+Type* ty_float64(void);
+Type* ty_addr_to(Type* pointee);
+Type* ty_array(Type* elem, uint64_t count); /* count 0 = unsized */
+Type* ty_func(void);
+Type* ty_intlit(uint64_t mag, int neg);
+
+int type_eq(const Type* a, const Type* b); /* pointer equality works, but keep explicit */
+
+/* Size in bytes of a value of this type (element size for arrays). */
+uint64_t type_size(const Type* t);
+
+/* Classification */
+int ty_is_int(const Type* t);      /* signed or unsigned integer kinds (not intlit) */
+int ty_is_uint(const Type* t);
+int ty_is_any_int(const Type* t);  /* int/uint/intlit */
+int ty_is_float(const Type* t);
+int ty_is_numeric(const Type* t);  /* int/uint/intlit/float */
+int ty_is_signed_int(const Type* t);
+int ty_is_addr(const Type* t);
+int ty_is_array(const Type* t);
+int ty_is_intlit(const Type* t);
+int ty_is_bool(const Type* t);
+int ty_is_char(const Type* t);
+int ty_is_func(const Type* t);
+int ty_is_nothing(const Type* t);
+
+/* Does int literal (mag,neg) fit the integer-ish type t? (t = int/uint/char/bool) */
+int intlit_fits(uint64_t mag, int neg, const Type* t);
+
+/* Bit width for integer kinds; 0 otherwise. */
+int ty_int_bits(const Type* t);
+
+/* Human-readable name into caller buffer. */
+const char* type_name(const Type* t, char* buf, size_t bufsz);
+
+#endif
diff --git a/src/util.c b/src/util.c
new file mode 100644
index 0000000..a292200
--- /dev/null
+++ b/src/util.c
@@ -0,0 +1,121 @@
+#include "util.h"
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+void* xmalloc(size_t n) {
+    void* p = malloc(n ? n : 1);
+    if (!p) {
+        fprintf(stderr, "cryn: out of memory\n");
+        exit(1);
+    }
+    return p;
+}
+
+void* xrealloc(void* p, size_t n) {
+    void* q = realloc(p, n ? n : 1);
+    if (!q) {
+        fprintf(stderr, "cryn: out of memory\n");
+        exit(1);
+    }
+    return q;
+}
+
+char* xstrdup(const char* s) {
+    size_t n = strlen(s) + 1;
+    char* p = xmalloc(n);
+    memcpy(p, s, n);
+    return p;
+}
+
+void buf_put(Buf* b, const void* p, size_t n) {
+    if (b->len + n > b->cap) {
+        size_t nc = b->cap ? b->cap * 2 : 128;
+        while (nc < b->len + n) nc *= 2;
+        b->data = xrealloc(b->data, nc);
+        b->cap = nc;
+    }
+    memcpy(b->data + b->len, p, n);
+    b->len += n;
+}
+
+void buf_putc(Buf* b, char c) { buf_put(b, &c, 1); }
+
+void buf_puts(Buf* b, const char* s) { buf_put(b, s, strlen(s)); }
+
+void buf_printf(Buf* b, const char* fmt, ...) {
+    char tmp[1024];
+    va_list ap;
+    va_start(ap, fmt);
+    int n = vsnprintf(tmp, sizeof tmp, fmt, ap);
+    va_end(ap);
+    if (n < 0) return;
+    if ((size_t)n < sizeof tmp) {
+        buf_put(b, tmp, (size_t)n);
+    } else {
+        char* big = xmalloc((size_t)n + 1);
+        va_start(ap, fmt);
+        vsnprintf(big, (size_t)n + 1, fmt, ap);
+        va_end(ap);
+        buf_put(b, big, (size_t)n);
+        free(big);
+    }
+}
+
+void buf_free(Buf* b) {
+    free(b->data);
+    b->data = NULL;
+    b->len = b->cap = 0;
+}
+
+void list_add(List* l, void* p) {
+    if (l->len == l->cap) {
+        l->cap = l->cap ? l->cap * 2 : 16;
+        l->items = xrealloc(l->items, l->cap * sizeof(void*));
+    }
+    l->items[l->len++] = p;
+}
+
+void* list_get(List* l, size_t i) { return l->items[i]; }
+
+void list_free(List* l) {
+    free(l->items);
+    l->items = NULL;
+    l->len = l->cap = 0;
+}
+
+int read_file(const char* path, char** out, size_t* outlen) {
+    FILE* f = fopen(path, "rb");
+    if (!f) return -1;
+    if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return -1; }
+    long sz = ftell(f);
+    if (sz < 0) { fclose(f); return -1; }
+    rewind(f);
+    char* buf = xmalloc((size_t)sz + 1);
+    if (sz > 0 && fread(buf, 1, (size_t)sz, f) != (size_t)sz) {
+        free(buf);
+        fclose(f);
+        return -1;
+    }
+    fclose(f);
+    buf[sz] = 0;
+    *out = buf;
+    if (outlen) *outlen = (size_t)sz;
+    return 0;
+}
+
+int write_file(const char* path, const char* data, size_t len) {
+    FILE* f = fopen(path, "wb");
+    if (!f) return -1;
+    if (len > 0 && fwrite(data, 1, len, f) != len) { fclose(f); return -1; }
+    fclose(f);
+    return 0;
+}
+
+void path_strip_ext(char* path) {
+    char* slash = strrchr(path, '/');
+    char* dot = strrchr(path, '.');
+    if (dot && (!slash || dot > slash)) *dot = 0;
+}
diff --git a/src/util.h b/src/util.h
new file mode 100644
index 0000000..660f2df
--- /dev/null
+++ b/src/util.h
@@ -0,0 +1,42 @@
+#ifndef CR_UTIL_H
+#define CR_UTIL_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+void* xmalloc(size_t n);
+void* xrealloc(void* p, size_t n);
+char* xstrdup(const char* s);
+
+/* Growable byte buffer */
+typedef struct Buf {
+    char* data;
+    size_t len, cap;
+} Buf;
+
+void buf_put(Buf* b, const void* p, size_t n);
+void buf_putc(Buf* b, char c);
+void buf_puts(Buf* b, const char* s);
+void buf_printf(Buf* b, const char* fmt, ...);
+void buf_free(Buf* b);
+
+/* Growable pointer array (void*) */
+typedef struct List {
+    void** items;
+    size_t len, cap;
+} List;
+
+void list_add(List* l, void* p);
+void* list_get(List* l, size_t i);
+void list_free(List* l);
+
+/* Read an entire file. Returns 0 on success. NUL-terminates. */
+int read_file(const char* path, char** out, size_t* outlen);
+
+/* Write a buffer to a file. Returns 0 on success. */
+int write_file(const char* path, const char* data, size_t len);
+
+/* Remove the extension of a path (in place) */
+void path_strip_ext(char* path);
+
+#endif
diff --git a/tests/01_hello.cryn b/tests/01_hello.cryn
new file mode 100644
index 0000000..1a99309
--- /dev/null
+++ b/tests/01_hello.cryn
@@ -0,0 +1,14 @@
+[# the canonical first program from the language definition #]
+define main
+
+set 10
+push
+
+set 20
+add
+
+output
+
+return
+
+end
diff --git a/tests/02_format.cryn b/tests/02_format.cryn
new file mode 100644
index 0000000..25dd03f
--- /dev/null
+++ b/tests/02_format.cryn
@@ -0,0 +1,12 @@
+[# whitespace has no meaning: this program is formatted badly on purpose #]
+define
+main
+set
+10
+push
+set
+20
+add
+output
+return
+end
diff --git a/tests/03_arith.cryn b/tests/03_arith.cryn
new file mode 100644
index 0000000..adceaa6
--- /dev/null
+++ b/tests/03_arith.cryn
@@ -0,0 +1,34 @@
+[# integer arithmetic with precedence done by ordering #]
+make string "\n" nl
+
+define newline result nothing
+  address nl
+  output text
+  set nothing
+  return
+end
+
+define main
+  set 2
+  add 3
+  multiply 4
+  subtract 5
+  output
+  call newline
+
+  set 100
+  divide 7
+  output
+  call newline
+
+  set 100
+  remainder 7
+  output
+  call newline
+
+  set 42
+  negate
+  output
+  call newline
+  return
+end
diff --git a/tests/04_wrap.cryn b/tests/04_wrap.cryn
new file mode 100644
index 0000000..4fb1970
--- /dev/null
+++ b/tests/04_wrap.cryn
@@ -0,0 +1,30 @@
+[# overflow wraps in both directions #]
+make string "\n" nl
+
+define newline result nothing
+  address nl
+  output text
+  set nothing
+  return
+end
+
+define main
+  set 9223372036854775807
+  add 1
+  output
+  call newline
+
+  set 0
+  subtract 1
+  convert uint8
+  convert int64
+  output
+  call newline
+
+  set 250
+  convert uint8
+  add 10
+  convert int64
+  output
+  return
+end
diff --git a/tests/05_float.cryn b/tests/05_float.cryn
new file mode 100644
index 0000000..3486a65
--- /dev/null
+++ b/tests/05_float.cryn
@@ -0,0 +1,28 @@
+[# floating point #]
+make string "\n" nl
+
+define newline result nothing
+  address nl
+  output text
+  set nothing
+  return
+end
+
+define main
+  set 1.5
+  add 2.25
+  multiply 2
+  output
+  call newline
+
+  set 1
+  convert float32
+  divide 3
+  output
+  call newline
+
+  set 0.1
+  add 0.2
+  output
+  return
+end
diff --git a/tests/06_compare.cryn b/tests/06_compare.cryn
new file mode 100644
index 0000000..bfd62bb
--- /dev/null
+++ b/tests/06_compare.cryn
@@ -0,0 +1,64 @@
+[# every comparison condition, one character per outcome #]
+define main
+  set 10
+  compare 20
+  jump n1 when less
+  set 'N'
+  jump p1
+  mark n1
+  set 'Y'
+  mark p1
+  output
+
+  set 10
+  compare 10
+  jump n2 when equal
+  set 'N'
+  jump p2
+  mark n2
+  set 'Y'
+  mark p2
+  output
+
+  set 30
+  compare 20
+  jump n3 when greater
+  set 'N'
+  jump p3
+  mark n3
+  set 'Y'
+  mark p3
+  output
+
+  set 5
+  compare 5
+  jump y4 when different
+  set 'N'
+  jump p4
+  mark y4
+  set 'Y'
+  mark p4
+  output
+
+  set 5
+  compare 5
+  jump n5 when less-or-equal
+  set 'N'
+  jump p5
+  mark n5
+  set 'Y'
+  mark p5
+  output
+
+  set 5
+  compare 4
+  jump n6 when greater-or-equal
+  set 'N'
+  jump p6
+  mark n6
+  set 'Y'
+  mark p6
+  output
+
+  return
+end
diff --git a/tests/07_branch.cryn b/tests/07_branch.cryn
new file mode 100644
index 0000000..a3a9f87
--- /dev/null
+++ b/tests/07_branch.cryn
@@ -0,0 +1,40 @@
+[# conditional selection: max of two stored cells #]
+make string "\n" nl
+
+define newline result nothing
+  address nl
+  output text
+  set nothing
+  return
+end
+
+define main
+  make int64 a
+  make int64 b
+
+  set 17
+  move a
+  write
+  set 42
+  move b
+  write
+
+  move a
+  read
+  push
+  move b
+  read
+  compare
+  jump takeB when greater
+
+  move a
+  read
+  output
+  return
+
+  mark takeB
+  move b
+  read
+  output
+  return
+end
diff --git a/tests/08_loop.cryn b/tests/08_loop.cryn
new file mode 100644
index 0000000..1b4892b
--- /dev/null
+++ b/tests/08_loop.cryn
@@ -0,0 +1,44 @@
+[# loop: sum 1..100 #]
+define main
+  make int64 i
+  make int64 sum
+
+  set 0
+  move i
+  write
+  set 0
+  move sum
+  write
+  set 0
+
+  mark loop
+  move i
+  read
+  compare 101
+  jump done when greater-or-equal
+
+  move i
+  read
+  push
+  move sum
+  read
+  add
+  move sum
+  write
+  set 0
+
+  move i
+  read
+  add 1
+  move i
+  write
+  set 0
+
+  jump loop
+  mark done
+
+  move sum
+  read
+  output
+  return
+end
diff --git a/tests/09_nested.cryn b/tests/09_nested.cryn
new file mode 100644
index 0000000..7e860ac
--- /dev/null
+++ b/tests/09_nested.cryn
@@ -0,0 +1,63 @@
+[# nested loops: count iterations of a 3x4 grid #]
+define main
+  make int64 i
+  make int64 j
+  make int64 count
+
+  set 0
+  move i
+  write
+  set 0
+  move j
+  write
+  set 0
+  move count
+  write
+  set 0
+
+  mark outer
+  move i
+  read
+  compare 3
+  jump endOuter when greater-or-equal
+  set 0
+  move j
+  write
+  set 0
+
+  mark inner
+  move j
+  read
+  compare 4
+  jump endInner when greater-or-equal
+
+  move count
+  read
+  add 1
+  move count
+  write
+  set 0
+
+  move j
+  read
+  add 1
+  move j
+  write
+  set 0
+  jump inner
+  mark endInner
+
+  move i
+  read
+  add 1
+  move i
+  write
+  set 0
+  jump outer
+  mark endOuter
+
+  move count
+  read
+  output
+  return
+end
diff --git a/tests/10_func.cryn b/tests/10_func.cryn
new file mode 100644
index 0000000..9719230
--- /dev/null
+++ b/tests/10_func.cryn
@@ -0,0 +1,41 @@
+[# functions with parameters: gcd #]
+define gcd int64 a int64 b result int64
+  mark loop
+  move b
+  read
+  compare 0
+  jump done when equal
+  move b
+  read
+  push
+  move b
+  read
+  push
+  move a
+  read
+  remainder
+  push
+  move b
+  write
+  pop
+  pop
+  move a
+  write
+  set nothing
+  home
+  jump loop
+  mark done
+  move a
+  read
+  return
+end
+
+define main
+  set 252
+  push
+  set 105
+  push
+  call gcd
+  output
+  return
+end
diff --git a/tests/11_recursion.cryn b/tests/11_recursion.cryn
new file mode 100644
index 0000000..db58f65
--- /dev/null
+++ b/tests/11_recursion.cryn
@@ -0,0 +1,56 @@
+[# recursion: factorial(10) and fib(20) #]
+define fact int64 n result int64
+  move n
+  read
+  compare 1
+  jump base when less-or-equal
+  move n
+  read
+  push
+  move n
+  read
+  subtract 1
+  push
+  call fact
+  multiply
+  return
+  mark base
+  set 1
+  return
+end
+
+define fib int64 n result int64
+  move n
+  read
+  compare 2
+  jump base when less
+  move n
+  read
+  subtract 1
+  push
+  call fib
+  push
+  move n
+  read
+  subtract 2
+  push
+  call fib
+  add
+  return
+  mark base
+  set 1
+  return
+end
+
+define main
+  set 10
+  push
+  call fact
+  output
+
+  set 20
+  push
+  call fib
+  output
+  return
+end
diff --git a/tests/12_mutual.cryn b/tests/12_mutual.cryn
new file mode 100644
index 0000000..74fffa2
--- /dev/null
+++ b/tests/12_mutual.cryn
@@ -0,0 +1,48 @@
+[# mutual recursion #]
+define is_even int64 n result bool
+  move n
+  read
+  compare 0
+  jump yes when equal
+  move n
+  read
+  subtract 1
+  push
+  call is_odd
+  return
+  mark yes
+  set true
+  return
+end
+
+define is_odd int64 n result bool
+  move n
+  read
+  compare 0
+  jump yes when equal
+  move n
+  read
+  subtract 1
+  push
+  call is_even
+  return
+  mark yes
+  set false
+  return
+end
+
+define main
+  set 10
+  push
+  call is_even
+  output
+  set 7
+  push
+  call is_odd
+  output
+  set 7
+  push
+  call is_even
+  output
+  return
+end
diff --git a/tests/13_stack.cryn b/tests/13_stack.cryn
new file mode 100644
index 0000000..d1bc35d
--- /dev/null
+++ b/tests/13_stack.cryn
@@ -0,0 +1,21 @@
+[# value stack: push, pop, swap #]
+define main
+  set 2
+  push
+  set 3
+  push
+
+  pop
+  output
+  pop
+  output
+
+  set 9
+  push
+  set 4
+  swap
+  output
+  pop
+  output
+  return
+end
diff --git a/tests/14_cells.cryn b/tests/14_cells.cryn
new file mode 100644
index 0000000..ca1af89
--- /dev/null
+++ b/tests/14_cells.cryn
@@ -0,0 +1,41 @@
+[# cells, connections, focus navigation: back and home #]
+define main
+  make int64 x
+  make int64 y
+
+  set 11
+  move x
+  write
+  set 22
+  move y
+  write
+
+  [# focus is on y; move to x needs no back because named moves anchor #]
+  move x
+  read
+  output
+
+  [# indexed navigation uses the focus stack: back undoes it #]
+  make array 2 of int64 pair
+  set 7
+  move pair
+  move 0
+  write
+  back
+  set 8
+  move 1
+  write
+  back
+
+  move pair
+  move 1
+  read
+  output
+  back
+  home
+
+  move y
+  read
+  output
+  return
+end
diff --git a/tests/15_arrays.cryn b/tests/15_arrays.cryn
new file mode 100644
index 0000000..0c30599
--- /dev/null
+++ b/tests/15_arrays.cryn
@@ -0,0 +1,97 @@
+[# arrays as indexed connections: sieve of Eratosthenes under 50 #]
+define main
+  make array 50 of int64 sieve
+  make int64 i
+  make int64 j
+  make int64 count
+
+  set 2
+  move i
+  write
+  set 0
+  move count
+  write
+  set 0
+  home
+
+  mark outer
+  move i
+  read
+  compare 50
+  jump endOuter when greater-or-equal
+  set 0
+  home
+
+  move i
+  read
+  move sieve
+  move index
+  read
+  home
+  compare 0
+  jump skip when different
+  set 0
+  home
+
+  move count
+  read
+  add 1
+  move count
+  write
+  set 0
+  home
+
+  move i
+  read
+  multiply 2
+  move j
+  write
+  set 0
+  home
+
+  mark inner
+  move j
+  read
+  home
+  compare 50
+  jump outerInc when greater-or-equal
+  set 0
+  home
+
+  move j
+  read
+  move sieve
+  move index
+  set 1
+  write
+  home
+
+  move j
+  read
+  push
+  move i
+  read
+  add
+  move j
+  write
+  set 0
+  home
+  jump inner
+
+  mark skip
+  mark outerInc
+  move i
+  read
+  add 1
+  move i
+  write
+  set 0
+  home
+  jump outer
+
+  mark endOuter
+  move count
+  read
+  output
+  return
+end
diff --git a/tests/16_strings.cryn b/tests/16_strings.cryn
new file mode 100644
index 0000000..10d6669
--- /dev/null
+++ b/tests/16_strings.cryn
@@ -0,0 +1,73 @@
+[# strings are byte arrays: print text, measure length, reverse #]
+make string "interrupt" word
+make string "\n" nl
+
+define newline result nothing
+  address nl
+  output text
+  set nothing
+  return
+end
+
+define main
+  address word
+  output text
+  call newline
+
+  make int64 n
+  set 0
+  move n
+  write
+
+  mark count
+  move n
+  read
+  move word
+  move index
+  read
+  compare 0
+  jump counted when equal
+  move n
+  read
+  add 1
+  move n
+  write
+  set 0
+  jump count
+  mark counted
+
+  move n
+  read
+  output
+  call newline
+
+  make int64 k
+  move n
+  read
+  subtract 1
+  move k
+  write
+  set 0
+
+  mark reverse
+  move k
+  read
+  compare 0
+  jump done when less
+  move k
+  read
+  move word
+  move index
+  read
+  output
+  move k
+  read
+  subtract 1
+  move k
+  write
+  set 0
+  jump reverse
+  mark done
+  call newline
+  return
+end
diff --git a/tests/17_memory.cryn b/tests/17_memory.cryn
new file mode 100644
index 0000000..b400d52
--- /dev/null
+++ b/tests/17_memory.cryn
@@ -0,0 +1,72 @@
+[# manual memory: make a block, fill it, sum it, free it #]
+define main
+  make 4 of int64 block
+
+  move at
+  set 40
+  move 0
+  write
+  back
+  set 41
+  move 1
+  write
+  back
+  set 42
+  move 2
+  write
+  back
+  set 43
+  move 3
+  write
+  back
+
+  make int64 i
+  make int64 sum
+  set 3
+  move i
+  write
+  set 0
+  home
+
+  mark sum
+  move i
+  read
+  compare 0
+  jump done when less
+  set 0
+  home
+
+  move i
+  read
+  push
+  move block
+  read
+  move at
+  pop
+  move index
+  read
+  push
+  move sum
+  read
+  add
+  move sum
+  write
+  move i
+  read
+  subtract 1
+  move i
+  write
+  set 0
+  home
+  jump sum
+
+  mark done
+  move sum
+  read
+  output
+
+  move block
+  read
+  free
+  return
+end
diff --git a/tests/18_pointers.cryn b/tests/18_pointers.cryn
new file mode 100644
index 0000000..d848a7a
--- /dev/null
+++ b/tests/18_pointers.cryn
@@ -0,0 +1,69 @@
+[# addresses: walking a heap block backwards with the push-index idiom #]
+make string "\n" nl
+
+define newline result nothing
+  address nl
+  output text
+  set nothing
+  return
+end
+
+define main
+  make 3 of int64 block
+
+  move at
+  set 5
+  move 0
+  write
+  back
+  set 6
+  move 1
+  write
+  back
+  set 7
+  move 2
+  write
+  back
+
+  make int64 i
+  set 2
+  move i
+  write
+  set 0
+  home
+
+  mark walk
+  move i
+  read
+  compare 0
+  jump done when less
+  set 0
+  home
+
+  move i
+  read
+  push
+  move block
+  read
+  move at
+  pop
+  move index
+  read
+  output
+  call newline
+
+  move i
+  read
+  subtract 1
+  move i
+  write
+  set 0
+  home
+  jump walk
+
+  mark done
+  move block
+  read
+  free
+  return
+end
diff --git a/tests/19_linkedlist.cryn b/tests/19_linkedlist.cryn
new file mode 100644
index 0000000..57d319e
--- /dev/null
+++ b/tests/19_linkedlist.cryn
@@ -0,0 +1,123 @@
+[# a linked list built from raw blocks: node = [value next] #]
+make string "\n" nl
+make int64 total
+
+define newline result nothing
+  address nl
+  output text
+  set nothing
+  return
+end
+
+define main
+  make 2 of int64 n1
+  make 2 of int64 n2
+  make 2 of int64 n3
+  make 2 of int64 cur
+
+  move n3
+  read
+  move at
+  set 30
+  move 0
+  write
+  back
+  set 0
+  move 1
+  write
+  back
+
+  move n3
+  read
+  convert int64
+  push
+  move n2
+  read
+  move at
+  set 20
+  move 0
+  write
+  back
+  move 1
+  pop
+  write
+  back
+
+  move n2
+  read
+  convert int64
+  push
+  move n1
+  read
+  move at
+  set 10
+  move 0
+  write
+  back
+  move 1
+  pop
+  write
+  back
+
+  set 0
+  move total
+  write
+
+  move n1
+  read
+  push
+  move cur
+  write
+  set 0
+  move total
+  write
+  pop
+  home
+
+  mark walk
+  move cur
+  read
+  compare 0
+  jump done when equal
+
+  move cur
+  read
+  move at
+  move 0
+  read
+  output
+  call newline
+  push
+  move total
+  read
+  add
+  move total
+  write
+
+  move cur
+  read
+  move at
+  move 1
+  read
+  convert address array of int64
+  move cur
+  write
+  home
+  jump walk
+
+  mark done
+  move total
+  read
+  output
+
+  move n1
+  read
+  free
+  move n2
+  read
+  free
+  move n3
+  read
+  free
+  return
+end
diff --git a/tests/20_files.cryn b/tests/20_files.cryn
new file mode 100644
index 0000000..ab18000
--- /dev/null
+++ b/tests/20_files.cryn
@@ -0,0 +1,68 @@
+[# file output and input round trip #]
+define main
+  make string "cryn_test_20.txt" path
+
+  address path
+  open write
+  compare 0
+  jump fail when less
+  push
+
+  make string "cryn was here\n" data
+  make int64 i
+  set 0
+  move i
+  write
+  set 0
+
+  mark wr
+  move i
+  read
+  compare 15
+  jump written when greater-or-equal
+  move i
+  read
+  move data
+  move index
+  read
+  output
+  move i
+  read
+  add 1
+  move i
+  write
+  set 0
+  jump wr
+
+  mark written
+  pop
+  close
+  home
+  compare 0
+  jump fail when less
+
+  address path
+  open read
+  home
+  compare 0
+  jump fail when less
+  push
+
+  set 0
+  mark rd
+  input
+  compare -1
+  jump readDone when equal
+  convert uint8
+  output
+  jump rd
+  mark readDone
+
+  pop
+  close
+  stop
+
+  mark fail
+  set 1
+  stop
+end
diff --git a/tests/21_threads.cryn b/tests/21_threads.cryn
new file mode 100644
index 0000000..5f7757f
--- /dev/null
+++ b/tests/21_threads.cryn
@@ -0,0 +1,39 @@
+[# spawn and wait: two contexts incrementing a shared world cell #]
+make int64 counter
+
+define worker
+  make int64 k
+  set 0
+  move k
+  write
+  mark w
+  move k
+  read
+  compare 100
+  jump done when greater-or-equal
+  move counter
+  read
+  add 1
+  move counter
+  write
+  set 0
+  move k
+  read
+  add 1
+  move k
+  write
+  set 0
+  jump w
+  mark done
+  return
+end
+
+define main
+  spawn worker
+  spawn worker
+  wait
+  move counter
+  read
+  output
+  return
+end
diff --git a/tests/22_callbacks.cryn b/tests/22_callbacks.cryn
new file mode 100644
index 0000000..fc1cc48
--- /dev/null
+++ b/tests/22_callbacks.cryn
@@ -0,0 +1,52 @@
+[# transform references: apply two different strategies through one caller #]
+make int64 log
+
+define ping result nothing
+  move log
+  read
+  add 1
+  move log
+  write
+  set nothing
+  return
+end
+
+define pong result nothing
+  move log
+  read
+  add 10
+  move log
+  write
+  set nothing
+  return
+end
+
+define twice transform f result nothing
+  move f
+  read
+  push
+  call
+  pop
+  call
+  set nothing
+  return
+end
+
+define main
+  set 0
+  move log
+  write
+
+  address ping
+  push
+  call twice
+
+  address pong
+  push
+  call twice
+
+  move log
+  read
+  output
+  return
+end
diff --git a/tests/23_sort.cryn b/tests/23_sort.cryn
new file mode 100644
index 0000000..12ff43f
--- /dev/null
+++ b/tests/23_sort.cryn
@@ -0,0 +1,287 @@
+[# bubble sort over a heap block of int64, using element helpers #]
+make string "\n" nl
+
+define newline result nothing
+  address nl
+  output text
+  set nothing
+  return
+end
+
+define getelem address array of int64 blk int64 idx result int64
+  move idx
+  read
+  push
+  move blk
+  read
+  move at
+  pop
+  move index
+  read
+  return
+end
+
+define setelem address array of int64 blk int64 idx int64 val result nothing
+  move val
+  read
+  push
+  move idx
+  read
+  push
+  move blk
+  read
+  move at
+  pop
+  move index
+  pop
+  write
+  set nothing
+  return
+end
+
+define load address array of int64 blk int64 idx int64 val result nothing
+  move blk
+  read
+  push
+  move idx
+  read
+  push
+  move val
+  read
+  push
+  call setelem
+  set nothing
+  return
+end
+
+define put int64 v result nothing
+  move v
+  read
+  output
+  call newline
+  set nothing
+  return
+end
+
+define main
+  make 8 of int64 a
+  make int64 i
+  make int64 j
+  make int64 tmp
+
+  move a
+  read
+  push
+  set 0
+  push
+  set 42
+  push
+  call load
+
+  move a
+  read
+  push
+  set 1
+  push
+  set 17
+  push
+  call load
+
+  move a
+  read
+  push
+  set 2
+  push
+  set 99
+  push
+  call load
+
+  move a
+  read
+  push
+  set 3
+  push
+  set 3
+  push
+  call load
+
+  move a
+  read
+  push
+  set 4
+  push
+  set 58
+  push
+  call load
+
+  move a
+  read
+  push
+  set 5
+  push
+  set 21
+  push
+  call load
+
+  move a
+  read
+  push
+  set 6
+  push
+  set 7
+  push
+  call load
+
+  move a
+  read
+  push
+  set 7
+  push
+  set 65
+  push
+  call load
+
+  set 7
+  move i
+  write
+  set 0
+  home
+
+  mark outer
+  move i
+  read
+  compare 0
+  jump sorted when less
+  set 0
+  home
+
+  set 0
+  move j
+  write
+  set 0
+  home
+
+  mark inner
+  move i
+  read
+  push
+  move j
+  read
+  compare
+  jump nextPass when greater-or-equal
+  set 0
+  home
+
+  move a
+  read
+  push
+  move j
+  read
+  push
+  call getelem
+  move tmp
+  write
+
+  move a
+  read
+  push
+  move j
+  read
+  add 1
+  push
+  call getelem
+  push
+  move tmp
+  read
+  compare
+  jump noSwap when less-or-equal
+
+  move a
+  read
+  push
+  move j
+  read
+  push
+  move a
+  read
+  push
+  move j
+  read
+  add 1
+  push
+  call getelem
+  push
+  call setelem
+
+  move a
+  read
+  push
+  move j
+  read
+  add 1
+  push
+  move tmp
+  read
+  push
+  call setelem
+
+  mark noSwap
+  move j
+  read
+  add 1
+  move j
+  write
+  set 0
+  home
+  jump inner
+
+  mark nextPass
+  move i
+  read
+  subtract 1
+  move i
+  write
+  set 0
+  home
+  jump outer
+
+  mark sorted
+  set 0
+  move i
+  write
+  set 0
+  home
+
+  mark print
+  move i
+  read
+  compare 8
+  jump done when greater-or-equal
+  set 0
+  home
+
+  move a
+  read
+  push
+  move i
+  read
+  push
+  call getelem
+  push
+  call put
+
+  move i
+  read
+  add 1
+  move i
+  write
+  set 0
+  home
+  jump print
+
+  mark done
+  move a
+  read
+  free
+  return
+end
diff --git a/tests/24_wordcount.cryn b/tests/24_wordcount.cryn
new file mode 100644
index 0000000..232aa0e
--- /dev/null
+++ b/tests/24_wordcount.cryn
@@ -0,0 +1,128 @@
+[# word counter: reads stdin, prints "lines words chars" #]
+make int64 lines
+make int64 words
+make int64 chars
+make int64 inword
+make string "\n" nl
+
+define newline result nothing
+  address nl
+  output text
+  set nothing
+  return
+end
+
+define bump address int64 cell result nothing
+  move cell
+  read
+  move at
+  read
+  add 1
+  write
+  set nothing
+  return
+end
+
+define doline result nothing
+  address lines
+  push
+  call bump
+  set nothing
+  return
+end
+
+define startword result nothing
+  set 1
+  move inword
+  write
+  address words
+  push
+  call bump
+  set nothing
+  return
+end
+
+define put int64 v result nothing
+  move v
+  read
+  output
+  set ' '
+  output
+  set nothing
+  return
+end
+
+define main
+  set 0
+  move lines
+  write
+  set 0
+  move words
+  write
+  set 0
+  move chars
+  write
+  set 0
+  move inword
+  write
+  set 0
+  home
+
+  mark next
+  input
+  compare -1
+  jump done when equal
+  push
+
+  address chars
+  push
+  call bump
+
+  pop
+  compare 10
+  jump newline when equal
+  compare 32
+  jump space when equal
+  compare 9
+  jump space when equal
+  jump word
+
+  mark newline
+  call doline
+
+  mark space
+  set 0
+  move inword
+  write
+  set 0
+  home
+  jump next
+
+  mark word
+  move inword
+  read
+  compare 0
+  jump cont when different
+  call startword
+
+  mark cont
+  set 0
+  home
+  jump next
+
+  mark done
+  move lines
+  read
+  push
+  call put
+  move words
+  read
+  push
+  call put
+  move chars
+  read
+  push
+  call put
+  call newline
+  return
+end
diff --git a/tests/25_allocator.cryn b/tests/25_allocator.cryn
new file mode 100644
index 0000000..8a999bb
--- /dev/null
+++ b/tests/25_allocator.cryn
@@ -0,0 +1,182 @@
+[# a small fixed-slot free-list allocator built on a raw block #]
+make string "\n" nl
+make int64 head
+
+define newline result nothing
+  address nl
+  output text
+  set nothing
+  return
+end
+
+define getslot address array of int64 blk int64 idx result int64
+  move idx
+  read
+  push
+  move blk
+  read
+  move at
+  pop
+  move index
+  read
+  return
+end
+
+define setslot address array of int64 blk int64 idx int64 val result nothing
+  move val
+  read
+  push
+  move idx
+  read
+  push
+  move blk
+  read
+  move at
+  pop
+  move index
+  pop
+  write
+  set nothing
+  return
+end
+
+define alloc address array of int64 blk result int64
+  move head
+  read
+  push
+  move blk
+  read
+  push
+  move head
+  read
+  push
+  call getslot
+  move head
+  write
+  pop
+  return
+end
+
+define dealloc address array of int64 blk int64 slot result nothing
+  move blk
+  read
+  push
+  move slot
+  read
+  push
+  move head
+  read
+  push
+  call setslot
+  move slot
+  read
+  move head
+  write
+  set nothing
+  return
+end
+
+define putval int64 v result nothing
+  move v
+  read
+  output
+  call newline
+  set nothing
+  return
+end
+
+define main
+  make 17 of int64 pool
+  make int64 i
+
+  set 1
+  move i
+  write
+  set 0
+  home
+
+  mark build
+  move i
+  read
+  compare 16
+  jump capped when greater-or-equal
+  set 0
+  home
+
+  move pool
+  read
+  push
+  move i
+  read
+  push
+  move i
+  read
+  add 1
+  push
+  call setslot
+
+  move i
+  read
+  add 1
+  move i
+  write
+  set 0
+  home
+  jump build
+
+  mark capped
+  move pool
+  read
+  push
+  set 16
+  push
+  set 0
+  push
+  call setslot
+
+  set 1
+  move head
+  write
+  set 0
+  home
+
+  move pool
+  read
+  push
+  call alloc
+  push
+  call putval
+
+  move pool
+  read
+  push
+  call alloc
+  push
+  call putval
+
+  move pool
+  read
+  push
+  call alloc
+  push
+  call putval
+
+  move pool
+  read
+  push
+  set 2
+  push
+  call dealloc
+
+  move pool
+  read
+  push
+  call alloc
+  push
+  call putval
+
+  move pool
+  read
+  free
+  return
+end
diff --git a/tests/errors/bad_jump.cryn b/tests/errors/bad_jump.cryn
new file mode 100644
index 0000000..8459221
--- /dev/null
+++ b/tests/errors/bad_jump.cryn
@@ -0,0 +1,4 @@
+define main
+  jump nowhere
+  return
+end
diff --git a/tests/errors/cond_jump_without_compare.cryn b/tests/errors/cond_jump_without_compare.cryn
new file mode 100644
index 0000000..9e110fc
--- /dev/null
+++ b/tests/errors/cond_jump_without_compare.cryn
@@ -0,0 +1,5 @@
+define main
+  jump end when equal
+  mark end
+  return
+end
diff --git a/tests/errors/div_by_zero_const.cryn b/tests/errors/div_by_zero_const.cryn
new file mode 100644
index 0000000..a0e03cf
--- /dev/null
+++ b/tests/errors/div_by_zero_const.cryn
@@ -0,0 +1,5 @@
+define main
+  set 1
+  divide 0
+  return
+end
diff --git a/tests/errors/free_non_address.cryn b/tests/errors/free_non_address.cryn
new file mode 100644
index 0000000..f5853c6
--- /dev/null
+++ b/tests/errors/free_non_address.cryn
@@ -0,0 +1,5 @@
+define main
+  set 5
+  free
+  return
+end
diff --git a/tests/errors/no_main.cryn b/tests/errors/no_main.cryn
new file mode 100644
index 0000000..3ad531e
--- /dev/null
+++ b/tests/errors/no_main.cryn
@@ -0,0 +1,3 @@
+define notmain
+  return
+end
diff --git a/tests/errors/stack_underflow.cryn b/tests/errors/stack_underflow.cryn
new file mode 100644
index 0000000..a8a4a3d
--- /dev/null
+++ b/tests/errors/stack_underflow.cryn
@@ -0,0 +1,4 @@
+define main
+  pop
+  return
+end
diff --git a/tests/errors/type_mismatch.cryn b/tests/errors/type_mismatch.cryn
new file mode 100644
index 0000000..f51def3
--- /dev/null
+++ b/tests/errors/type_mismatch.cryn
@@ -0,0 +1,8 @@
+define main
+  make int64 x
+  make string "t" s
+  address s
+  move x
+  write
+  return
+end
diff --git a/tests/errors/undefined_cell.cryn b/tests/errors/undefined_cell.cryn
new file mode 100644
index 0000000..f41b675
--- /dev/null
+++ b/tests/errors/undefined_cell.cryn
@@ -0,0 +1,5 @@
+define main
+  move nowhere
+  read
+  return
+end
diff --git a/tests/errors/unterminated_comment.cryn b/tests/errors/unterminated_comment.cryn
new file mode 100644
index 0000000..38ed1b3
--- /dev/null
+++ b/tests/errors/unterminated_comment.cryn
@@ -0,0 +1,4 @@
+[# this comment never ends
+define main
+  return
+end
diff --git a/tests/errors/write_nothing.cryn b/tests/errors/write_nothing.cryn
new file mode 100644
index 0000000..898bc85
--- /dev/null
+++ b/tests/errors/write_nothing.cryn
@@ -0,0 +1,6 @@
+define main
+  make int64 x
+  move x
+  write
+  return
+end
diff --git a/tests/expected/01_hello.txt b/tests/expected/01_hello.txt
new file mode 100644
index 0000000..8580e7b
--- /dev/null
+++ b/tests/expected/01_hello.txt
@@ -0,0 +1 @@
+30
\ No newline at end of file
diff --git a/tests/expected/02_format.txt b/tests/expected/02_format.txt
new file mode 100644
index 0000000..8580e7b
--- /dev/null
+++ b/tests/expected/02_format.txt
@@ -0,0 +1 @@
+30
\ No newline at end of file
diff --git a/tests/expected/03_arith.txt b/tests/expected/03_arith.txt
new file mode 100644
index 0000000..53d6967
--- /dev/null
+++ b/tests/expected/03_arith.txt
@@ -0,0 +1,4 @@
+15
+14
+2
+-42
\ No newline at end of file
diff --git a/tests/expected/04_wrap.txt b/tests/expected/04_wrap.txt
new file mode 100644
index 0000000..7460a8c
--- /dev/null
+++ b/tests/expected/04_wrap.txt
@@ -0,0 +1,3 @@
+-9223372036854775808
+255
+4
\ No newline at end of file
diff --git a/tests/expected/05_float.txt b/tests/expected/05_float.txt
new file mode 100644
index 0000000..368bbbb
--- /dev/null
+++ b/tests/expected/05_float.txt
@@ -0,0 +1,3 @@
+7.5
+0.33333334
+0.30000000000000004
\ No newline at end of file
diff --git a/tests/expected/06_compare.txt b/tests/expected/06_compare.txt
new file mode 100644
index 0000000..5d1866e
--- /dev/null
+++ b/tests/expected/06_compare.txt
@@ -0,0 +1 @@
+YYYNYY
\ No newline at end of file
diff --git a/tests/expected/07_branch.txt b/tests/expected/07_branch.txt
new file mode 100644
index 0000000..f70d7bb
--- /dev/null
+++ b/tests/expected/07_branch.txt
@@ -0,0 +1 @@
+42
\ No newline at end of file
diff --git a/tests/expected/08_loop.txt b/tests/expected/08_loop.txt
new file mode 100644
index 0000000..c9515ef
--- /dev/null
+++ b/tests/expected/08_loop.txt
@@ -0,0 +1 @@
+5050
\ No newline at end of file
diff --git a/tests/expected/09_nested.txt b/tests/expected/09_nested.txt
new file mode 100644
index 0000000..3cacc0b
--- /dev/null
+++ b/tests/expected/09_nested.txt
@@ -0,0 +1 @@
+12
\ No newline at end of file
diff --git a/tests/expected/10_func.txt b/tests/expected/10_func.txt
new file mode 100644
index 0000000..b5045cc
--- /dev/null
+++ b/tests/expected/10_func.txt
@@ -0,0 +1 @@
+21
\ No newline at end of file
diff --git a/tests/expected/11_recursion.txt b/tests/expected/11_recursion.txt
new file mode 100644
index 0000000..1f8ad37
--- /dev/null
+++ b/tests/expected/11_recursion.txt
@@ -0,0 +1 @@
+362880010946
\ No newline at end of file
diff --git a/tests/expected/12_mutual.txt b/tests/expected/12_mutual.txt
new file mode 100644
index 0000000..962d20a
--- /dev/null
+++ b/tests/expected/12_mutual.txt
@@ -0,0 +1 @@
+truetruefalse
\ No newline at end of file
diff --git a/tests/expected/13_stack.txt b/tests/expected/13_stack.txt
new file mode 100644
index 0000000..bc2fb86
--- /dev/null
+++ b/tests/expected/13_stack.txt
@@ -0,0 +1 @@
+3294
\ No newline at end of file
diff --git a/tests/expected/14_cells.txt b/tests/expected/14_cells.txt
new file mode 100644
index 0000000..c27e76c
--- /dev/null
+++ b/tests/expected/14_cells.txt
@@ -0,0 +1 @@
+11822
\ No newline at end of file
diff --git a/tests/expected/15_arrays.txt b/tests/expected/15_arrays.txt
new file mode 100644
index 0000000..3f10ffe
--- /dev/null
+++ b/tests/expected/15_arrays.txt
@@ -0,0 +1 @@
+15
\ No newline at end of file
diff --git a/tests/expected/16_strings.txt b/tests/expected/16_strings.txt
new file mode 100644
index 0000000..a58cac7
--- /dev/null
+++ b/tests/expected/16_strings.txt
@@ -0,0 +1,3 @@
+interrupt
+9
+tpurretni
\ No newline at end of file
diff --git a/tests/expected/17_memory.txt b/tests/expected/17_memory.txt
new file mode 100644
index 0000000..09df927
--- /dev/null
+++ b/tests/expected/17_memory.txt
@@ -0,0 +1 @@
+166
\ No newline at end of file
diff --git a/tests/expected/18_pointers.txt b/tests/expected/18_pointers.txt
new file mode 100644
index 0000000..128939d
--- /dev/null
+++ b/tests/expected/18_pointers.txt
@@ -0,0 +1,3 @@
+7
+6
+5
\ No newline at end of file
diff --git a/tests/expected/19_linkedlist.txt b/tests/expected/19_linkedlist.txt
new file mode 100644
index 0000000..1c6b766
--- /dev/null
+++ b/tests/expected/19_linkedlist.txt
@@ -0,0 +1,4 @@
+10
+20
+30
+60
\ No newline at end of file
diff --git a/tests/expected/20_files.txt b/tests/expected/20_files.txt
new file mode 100644
index 0000000..c864352
--- /dev/null
+++ b/tests/expected/20_files.txt
@@ -0,0 +1 @@
+cryn was here
\ No newline at end of file
diff --git a/tests/expected/21_threads.txt b/tests/expected/21_threads.txt
new file mode 100644
index 0000000..ae4ee13
--- /dev/null
+++ b/tests/expected/21_threads.txt
@@ -0,0 +1 @@
+200
\ No newline at end of file
diff --git a/tests/expected/22_callbacks.txt b/tests/expected/22_callbacks.txt
new file mode 100644
index 0000000..8fdd954
--- /dev/null
+++ b/tests/expected/22_callbacks.txt
@@ -0,0 +1 @@
+22
\ No newline at end of file
diff --git a/tests/expected/23_sort.txt b/tests/expected/23_sort.txt
new file mode 100644
index 0000000..847232d
--- /dev/null
+++ b/tests/expected/23_sort.txt
@@ -0,0 +1,8 @@
+3
+7
+17
+21
+42
+58
+65
+99
\ No newline at end of file
diff --git a/tests/expected/24_wordcount.txt b/tests/expected/24_wordcount.txt
new file mode 100644
index 0000000..4e2f097
--- /dev/null
+++ b/tests/expected/24_wordcount.txt
@@ -0,0 +1 @@
+4 9 45 
\ No newline at end of file
diff --git a/tests/expected/25_allocator.txt b/tests/expected/25_allocator.txt
new file mode 100644
index 0000000..fa2fa9f
--- /dev/null
+++ b/tests/expected/25_allocator.txt
@@ -0,0 +1,4 @@
+1
+2
+3
+2
\ No newline at end of file
diff --git a/tests/input/24_wordcount.txt b/tests/input/24_wordcount.txt
new file mode 100644
index 0000000..87b60a0
--- /dev/null
+++ b/tests/input/24_wordcount.txt
@@ -0,0 +1,4 @@
+the quick brown fox
+jumps over
+
+the lazy dog
diff --git a/tests/run.sh b/tests/run.sh
new file mode 100644
index 0000000..d51c3e4
--- /dev/null
+++ b/tests/run.sh
@@ -0,0 +1,40 @@
+#!/bin/sh
+# Cryn test suite: compile and run each tests/*.cryn and compare with
+# tests/expected/<name>.txt. Error tests in tests/errors must fail to compile.
+set -u
+cd "$(dirname "$0")"
+CRYN=../cryn
+pass=0
+fail=0
+
+for src in *.cryn; do
+    name=$(basename "$src" .cryn)
+    exp="expected/$name.txt"
+    [ -f "$exp" ] || continue
+    if [ -f "input/$name.txt" ]; then
+        out=$($CRYN run "$src" < "input/$name.txt" 2>/dev/null)
+    else
+        out=$($CRYN run "$src" 2>/dev/null)
+    fi
+    if [ "$out" = "$(cat "$exp")" ]; then
+        pass=$((pass + 1))
+    else
+        fail=$((fail + 1))
+        echo "FAIL: $name"
+        echo "  expected: $(tr '\n' '|' < "$exp")"
+        echo "  got:      $(printf '%s' "$out" | tr '\n' '|')"
+    fi
+done
+
+for src in errors/*.cryn; do
+    name=$(basename "$src" .cryn)
+    if $CRYN check "$src" >/dev/null 2>&1; then
+        fail=$((fail + 1))
+        echo "FAIL (should not compile): $name"
+    else
+        pass=$((pass + 1))
+    fi
+done
+
+echo "$pass passed, $fail failed"
+[ "$fail" -eq 0 ]
diff --git a/tools/embed.c b/tools/embed.c
new file mode 100644
index 0000000..088e1c1
--- /dev/null
+++ b/tools/embed.c
@@ -0,0 +1,58 @@
+/* tools/embed.c - turn runtime sources into C string headers for the compiler.
+   Lines of the form '#include "..."' are dropped (their content is inlined
+   around the string); system includes with <...> are kept. */
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static void emit_escaped(const char* s, size_t n) {
+    for (size_t i = 0; i < n; i++) {
+        unsigned char c = (unsigned char)s[i];
+        switch (c) {
+        case '\\': printf("\\\\"); break;
+        case '"': printf("\\\""); break;
+        case '\t': printf("\\t"); break;
+        case '\r': break;
+        default:
+            if (c < 32 || c > 126) printf("\\%03o", c);
+            else putchar(c);
+        }
+    }
+}
+
+static void emit_string(const char* name, const char* path) {
+    FILE* f = fopen(path, "rb");
+    if (!f) {
+        fprintf(stderr, "embed: cannot open %s\n", path);
+        exit(1);
+    }
+    printf("static const char %s[] =\n", name);
+    char line[1024];
+    int first = 1;
+    while (fgets(line, sizeof line, f)) {
+        if (strncmp(line, "#include \"", 10) == 0) continue; /* local include: dropped */
+        size_t n = strlen(line);
+        /* strip trailing newline */
+        while (n > 0 && (line[n - 1] == '\n' || line[n - 1] == '\r')) n--;
+        if (!first) printf("\n");
+        printf("\"");
+        emit_escaped(line, n);
+        printf("\\n\"");
+        first = 0;
+    }
+    printf(";\n\n");
+    fclose(f);
+}
+
+int main(int argc, char** argv) {
+    if (argc < 3 || (argc - 1) % 2 != 0) {
+        fprintf(stderr, "usage: embed <fil

[Diff truncated. Use the raw view or local clone for the full content.]