Syntax Notes #
Every decision the example programs implicitly make, so we can revisit them deliberately instead of by accident. Nothing here is final.
Decisions embodied in the examples #
Lexical #
- Comments:
#line comments. Chosen over--because--collides with unary minus (x--1would silently comment out the rest of the line; Haskell needs special lexing rules to avoid this).@stays reserved for attributes (e.g.@interrupt). No block comments yet. - Statements: newline-terminated, no semicolons. Blocks use
{ }. - Line continuation (Option C): a newline does not end a statement
when (a) inside
(...)/[...], (b) the line ends with a token that can't end an expression — binary operators,,,=,=>— or (c) the next line starts with a token that can't begin a statement —.,?,|,else. The prefix/infix dual-role tokens (-,*,&,() are deliberately in neither set, avoiding the JS-style ASI hazard. (Gleam goes further — newlines mean nothing, parsing is greedy — but that only works because it has no paren grouping and almost no prefix operators. Our four dual-role tokens rule it out. Lesson kept: every new prefix/infix dual-role token complicates continuation.) - Hex literals:
0x0400. (Considered$0400for 6809 charm;$is still free if we want it as an alias.) - Identifiers:
snake_casevalues/functions,PascalCasetypes and variant constructors, lowercase region parameters,SCREAMING_CASEconsts.
Types #
- Primitives:
Uint8,Int8,Uint16,Int16,Bool,Unit,Never. Nothing wider.Unitis Rust's()(blocks/functions with no value; spellable, no literal);Neveris Rust's!(uninhabited, diverging expressions, coerces to any type).Stringdeferred (see open questions). - No implicit conversions:
x as Uint16required to widen. On a 6809, 8-bit vs 16-bit arithmetic is a real codegen difference, so it stays visible. - Integer literal typing: context determines the type where possible
(annotation, function argument, other operand, element type). No
context:
Uint8fallback (Int8when negative). A literal that doesn't fit its resolved type is a compile error requiring an annotation — including the fallback case (let x = 300errors rather than silently promoting toUint16, so shrinking a literal never changes a type). - Fixed arrays:
[Uint8; 512], size part of the type. No slices yet — a fat pointer is 4 bytes on this machine;for/enumeratemay cover enough use cases to skip them entirely at first. - Records:
type Point = { x: Uint8, y: Uint8 }. - Sum types:
type Input = Idle | Move(Direction) | Fire, ML-style, with optional leading|. Variants may carry positional payloads or named fields (Shooting { cooldown: Uint8 }). - Generics:
Option<T>,Result<T, E>. Type params PascalCase. - Recursive types: only behind an arena pointer, so every type has a compile-time footprint.
Bindings and functions #
letimmutable,varmutable.constfor compile-time constants.fn name(x: Type) -> Type { ... }; last expression of a block is its value (ML-style),returnfor early exit.if/matchare expressions.- UFCS from day one:
x.foo(y)is sugar forfoo(x, y). There are no methods as a distinct concept —arr.enumerate(),frame.new(e), andcmds.push(x)are all free-function calls. Resolution order for.name: field first, function in scope second.
Control flow #
for x in array— by value, bounds proven once per loop. This is the load-bearing construct for the bounds-elision design principle.for var x in array— by-reference mutable iteration (02_blitclear).for (ix, x) in array.enumerate()— index-carrying; the index is known in-bounds by construction and feeds downstream bounds proofs.for ix in 0 .. n— half-open ranges.while cond { },loop { }(types asNever).matchmust be exhaustive;_catch-all; tuple scrutinees allowed; integer patterns allowed for 8-bit types.if letfor one-armed matches.?propagatesResulterrors, as in Rust. (Prefixtry, Zig-style, was considered — reads better in statement position but needs parens mid-expression. UFCS chaining is?'s main advantage, and we have UFCS, so?stays.)
Arithmetic #
- Overflow is checked, always:
+,-,*trap on overflow in every build — no debug/release divergence. The 6809 sets carry/overflow flags for free, so a check is one conditional branch (~3 cycles), and the same range analysis that elides bounds checks elides overflow checks (loop counters with proven ranges compile clean). - Wrapping is explicit:
+%,-%,*%(Zig-style) for intentional modular arithmetic.
Failure model (the "panic" story) #
- A failed check traps: no unwinding — disable interrupts, flash the
border and write a fault code + address to the text screen (visible even
with a corrupted display), halt. User-overridable later via
@panic_handler. Costs a few bytes and aJMP. (As implemented:__trappaints the fault code across the top 16 screen cells and hangs — no interrupt disable or address dump yet. Trap codes: 1 = bounds, 2 = overflow, 3 = arena exhaustion.) - The complete list of trap sources, each with a total (non-trapping)
alternative:
arr[ix]— avoid viafor/enumerate/ranges, orget(arr, ix) -> Option<T>- arithmetic overflow — avoid via proven ranges or
+% - arena exhaustion in
new— avoid viatry_new -> Option<&r T>; static arena size bounds are a possible future for eliminating this statically - explicit
panic("...")
- Indexing with
[]is therefore the explicit "trapping is acceptable here" form, not the idiom. A future lint/annotation could enforce "this function cannot trap".
Memory model surface syntax #
- MMIO:
var pia1_a: Uint8 at 0xFF20— pins a global to an absolute address; no storage allocated. Language feature, not library. - Arenas:
arena frame { ... }opens a lexical region; bulk free at the closing brace. - Allocation:
frame.new(expr)returns&frame T. The arena name is part of the pointer type. - Region polymorphism: lowercase region params share the
<>list with type params:fn cons<r, T>(a: Arena<r>, ...) -> &r List<r, T>. Casing distinguishes regions from types — no Rust-style'rtick. - Subtyping: outer arenas outlive inner ones;
&outer Tis usable where&inner Tis expected, never the reverse. Globals can hold no arena pointers. - Deref:
*ptr, explicit (05_regionssum). Whether field access auto-derefs (ptr.headvs(*ptr).head) is open. (As implemented: arenas,new,&a T, deref,*p = vstores, the subtyping/escape rules, and region polymorphism (fn f<r>(a: Arena<r>, ...) -> &r T,type Node<r> = ..., recursive types behind arena pointers) all work; type parameters (T) still wait on generics. Region bindings are inferred at call sites (arena arguments bind exactly, pointer arguments covariantly); distinct region parameters are unrelated, and a parameter's region outlives any arena the callee opens locally. Allocation enforces the escape rule too: memory inamay not hold pointers into arenasaoutlives. Field access does not auto-deref: write(*p).x. Arena memory is mutable through any pointer, even one held by aletbinding. The backing store is a bump heap from the end of BSS up to a per-target limit ($7000 for now, below Color BASIC's stack on a 32K machine); eacharenablock snapshots the bump pointer on entry and restores it on exit, and a function containing arenas also restores in its epilogue so earlyreturns can't leak — unless it takesArena<r>parameters, in which case it allocates on its caller's behalf and must not restore (an earlyreturnfrom a localarenablock then leaks into the caller's region until that region's block exits). Known simplification: everything shares one bump pointer, so allocating into an outer arena while an inner block is open hands back memory the inner block's exit will reclaim; the checker doesn't reject this yet.newtraps with code 3 on exhaustion;try_newwaits onOption<T>.)
Target & program entry #
- First target: disk
.BIN(LOADM/EXECunder Disk Extended Color BASIC), for the emulator dev loop (XRoar/MAME) and because a clean return to the BASIC prompt is itself a smoke test. ROM cartridge target later (different startup shim + copy globals from ROM to RAM). - Memory layout (globals, stack, arena backing store) lives in
per-target config, not language syntax.
atcovers the only case where source should care. - Startup shim before
main: set stack pointer, set DP register (direct-page globals are a future optimization target), callmain. (As implemented: the shim only runs global initializers andJSRsmain, keeping BASIC's stack and DP — sufficient for the disk target, where BASIC's environment is live and we return into it.) mainsignature: bothfn main()(may return — disk target:RTSback to BASIC) andfn main() -> Neverallowed; the target checks compatibility.
Decisions made during implementation #
Things the vertical-slice implementation forced that the paper design never specified. Semantics first, then ABI/codegen facts (which are implementation choices, not language contract — but record them so changes are deliberate).
Semantics #
- Shift amounts are always
Uint8, whatever the shifted operand's type — a 16-bit count is meaningless for 8/16-bit values. Shifts don't trap (they're bit ops, not arithmetic). - Ranges (
a .. b) are only legal asforiterables. They are not first-class values; anywhere else is a type error. - A const may only reference consts declared above it (evaluated in declaration order; forward references error). Other items are fully order-independent.
==/!=work onBool; ordering comparisons (<etc.) don't.- Evaluation order is left-to-right for binary operands and call
arguments (pushed left-to-right). Assignments evaluate the value
first, then the place (matters for
a[f()] = g()). - Integer consts must be constant-evaluable (literals, earlier
consts, and arithmetic on them — see
const_evalincheck.rs); the same evaluator serves array lengths andataddresses.
ABI and codegen (crates/dodo/src/codegen.rs) #
- Output: DECB disk
.BIN, ORG$3000, exec address = ORG. BSS (initialized non-atglobals) sits immediately after the code; initializers run in the startup shim, so the file contains only code. - Frames:
Uis the frame pointer (PSHS U; TFR S,U; LEAS -n,S). Locals at negative offsets fromU, args at positive (first arg highest); caller pushes args and cleans up. Frame limit 120 bytes. - Returns:
Bfor 1-byte values,Dfor 2-byte, nothing forUnit/Never. Records and sums larger than 2 bytes can't be returned yet (needs an out-pointer convention). - Sum layout: a 1-byte tag followed by the largest variant's
payload fields, concatenated with no padding. Sums that fit in
2 bytes ride the register conventions (tag in
A, payload byte inB, i.e.D); larger sums are memory-resident like records: built/copied via the aggregate paths (__memcpyfor place-to-place copies), matched by testing the tag byte in a hidden frame slot (the scrutinee is copied in, preserving by-value semantics), with payload bindings aliasing the slot at their field offsets. - Runtime routines:
__trap(see failure model),__mul16(checked unsigned 16×16;X * D -> D, traps on overflow),__memcpy, and — only when the module uses arenas —__alloc(size inD, address out inX; bumps the 2-byte BSS cell__arena_nextand traps with code 3 past the arena top). Arena pointers are plain 16-bit addresses, so they ride the 2-byte value conventions (Dreturns). - Bounds checks are elided for literal indexes provably in range and
for
Uint8indexes into arrays longer than 255;for x in arraycompiles to a pointer walk with no checks at all. - Known cycle tax: naive stack-based expression codegen makes tight
loops ~10–20× slower than hand-written 6809 (measured empirically via
07_sound.dodo, whose timing constants are tuned to current output). See TODO.md §2 (peephole optimizer).
Temporary implementation gaps (not language decisions) #
These produce clean "not supported yet" errors; the language includes them, the compiler doesn't yet:
- division and remainder (
/,%) — no hardware divide; needs a runtime routine - signed multiplication
for var(by-reference mutable iteration)- array parameters, returns, and whole-array copies (arrays are initializer-only aggregates for now)
- record returns, and returns of sums larger than 2 bytes
- region parameters on sum types (
type List<r> = ...) Bool/array consts in expression position (integer consts only; sums ≤2 bytes work as consts, bigger ones don't)
Open questions #
- String literal spelling. The shape is decided: the VDG's character
set is not ASCII (its 64-char set has inverse/normal video in bit 6,
semigraphics in bit 7; uppercase happens to align with ASCII, digits
and punctuation don't, lowercase doesn't exist). So we want two
compile-time literal encodings: ASCII by default (ROM routines, disk,
printer) and a VDG-encoded variant (direct screen pokes), e.g.
v"HELLO". Exact spelling and theStringtype itself still open. - Auto-deref on field access through
&r T(see above). - Growable
List<T>in arenas (04_frame_arena): day-one feature or later? It's the first thing that needs realloc-ish behavior (bump allocators can only grow the newest allocation in place). - Tuples. 05_regions allocates
(xs, total); do we want full tuple types or only inmatchscrutinees? (A distinct tuple sigil à la Gleam's#(...)would remove(from the dual-role token list — but#is taken by comments, so it'd need another spelling.) @interrupthandlers and other entry points beyondmain— deferred until the disk target works.
What the vertical-slice subset should be #
(Status: ✅ done — implemented end-to-end and verified on XRoar;
01_hello and 07_sound compile and run. Kept for the record.)
Small enough to implement quickly, large enough that 01 and 07 compile:
fn,let,var,const,atglobalsUint8/Uint16/Bool, fixed arrays,asif,while,loop,for _ in array,for _ in a .. b- arithmetic (checked, trapping) +
+%/-%/*%, bitwise ops, comparisons, indexing with runtime checks - the trap handler (border flash + fault code + halt)
Defer: sum types, match, generics, arenas, records, ?, enumerate,
UFCS. Reserve all their keywords now.