A programming language for the TRS-80 Color Computer
dodo examples NOTES.md
15 kB · 284 lines
Markdown
at main

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--1 would 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 $0400 for 6809 charm; $ is still free if we want it as an alias.)
  • Identifiers: snake_case values/functions, PascalCase types and variant constructors, lowercase region parameters, SCREAMING_CASE consts.

Types #

  • Primitives: Uint8, Int8, Uint16, Int16, Bool, Unit, Never. Nothing wider. Unit is Rust's () (blocks/functions with no value; spellable, no literal); Never is Rust's ! (uninhabited, diverging expressions, coerces to any type). String deferred (see open questions).
  • No implicit conversions: x as Uint16 required 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: Uint8 fallback (Int8 when negative). A literal that doesn't fit its resolved type is a compile error requiring an annotation — including the fallback case (let x = 300 errors rather than silently promoting to Uint16, 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/enumerate may 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 #

  • let immutable, var mutable. const for compile-time constants.
  • fn name(x: Type) -> Type { ... }; last expression of a block is its value (ML-style), return for early exit.
  • if/match are expressions.
  • UFCS from day one: x.foo(y) is sugar for foo(x, y). There are no methods as a distinct concept — arr.enumerate(), frame.new(e), and cmds.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_blit clear).
  • 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 as Never).
  • match must be exhaustive; _ catch-all; tuple scrutinees allowed; integer patterns allowed for 8-bit types. if let for one-armed matches.
  • ? propagates Result errors, as in Rust. (Prefix try, 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 a JMP. (As implemented: __trap paints 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:
    1. arr[ix] — avoid via for/enumerate/ranges, or get(arr, ix) -> Option<T>
    2. arithmetic overflow — avoid via proven ranges or +%
    3. arena exhaustion in new — avoid via try_new -> Option<&r T>; static arena size bounds are a possible future for eliminating this statically
    4. 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 'r tick.
  • Subtyping: outer arenas outlive inner ones; &outer T is usable where &inner T is expected, never the reverse. Globals can hold no arena pointers.
  • Deref: *ptr, explicit (05_regions sum). Whether field access auto-derefs (ptr.head vs (*ptr).head) is open. (As implemented: arenas, new, &a T, deref, *p = v stores, 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 in a may not hold pointers into arenas a outlives. Field access does not auto-deref: write (*p).x. Arena memory is mutable through any pointer, even one held by a let binding. 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); each arena block snapshots the bump pointer on entry and restores it on exit, and a function containing arenas also restores in its epilogue so early returns can't leak — unless it takes Arena<r> parameters, in which case it allocates on its caller's behalf and must not restore (an early return from a local arena block 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. new traps with code 3 on exhaustion; try_new waits on Option<T>.)

Target & program entry #

  • First target: disk .BIN (LOADM/EXEC under 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. at covers 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), call main. (As implemented: the shim only runs global initializers and JSRs main, keeping BASIC's stack and DP — sufficient for the disk target, where BASIC's environment is live and we return into it.)
  • main signature: both fn main() (may return — disk target: RTS back to BASIC) and fn main() -> Never allowed; 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 as for iterables. 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 on Bool; 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_eval in check.rs); the same evaluator serves array lengths and at addresses.

ABI and codegen (crates/dodo/src/codegen.rs) #

  • Output: DECB disk .BIN, ORG $3000, exec address = ORG. BSS (initialized non-at globals) sits immediately after the code; initializers run in the startup shim, so the file contains only code.
  • Frames: U is the frame pointer (PSHS U; TFR S,U; LEAS -n,S). Locals at negative offsets from U, args at positive (first arg highest); caller pushes args and cleans up. Frame limit 120 bytes.
  • Returns: B for 1-byte values, D for 2-byte, nothing for Unit/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 in B, i.e. D); larger sums are memory-resident like records: built/copied via the aggregate paths (__memcpy for 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 in D, address out in X; bumps the 2-byte BSS cell __arena_next and traps with code 3 past the arena top). Arena pointers are plain 16-bit addresses, so they ride the 2-byte value conventions (D returns).
  • Bounds checks are elided for literal indexes provably in range and for Uint8 indexes into arrays longer than 255; for x in array compiles 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 #

  1. 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 the String type itself still open.
  2. Auto-deref on field access through &r T (see above).
  3. 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).
  4. Tuples. 05_regions allocates (xs, total); do we want full tuple types or only in match scrutinees? (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.)
  5. @interrupt handlers and other entry points beyond main — 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, at globals
  • Uint8/Uint16/Bool, fixed arrays, as
  • if, 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.