Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

48KB


type: compiler-runtime-architecture source-path: expert design plan, repository Vim notes, C128 reference architecture ingested-at: 2026-11-07 version: 0.1-draft

Nova128 Compiler and Runtime Architecture

Status: approved version 1 baseline. The compiler memory map is frozen by E01-S05 (section 4.4, docs/memory-map.md). E04-S01 defines the generated PRG/MAP container and low-memory startup; E04-S02 adds the frame runtime; E05-S01 supplies an explicit-region heap; E05-S02 freezes class descriptors and MAP class records; E06-S01 freezes the interface value ABI; E06-S02 adds the dispatch trampoline; E06-S03 emits one table per implemented interface; E06-S04 converts a class reference or null into an interface value and stores it; E06-S05 resolves the compile-time slot at a call site and dispatches through the runtime table. Generated-program heap placement and banking remain open.

1. System Boundary

The deliverable compiler is an 8502 program that runs on a Commodore 128 in native mode. Host-side tools may:

  • assemble and link the compiler with ca65 and ld65;
  • copy files into or out of D81 images with c1541;
  • launch and control VICE;
  • compare deterministic test outputs.

Host tools must not perform Nova128 parsing, type checking, or code generation in the release workflow. The C128 compiler performs those functions.

Generated version 1 programs target C128 native 8502 mode. They do not target C64 mode or the Z80.

2. Toolchain and Artifacts

2.1 Authoritative source

  • Compiler/runtime assembly: ASCII ca65 source under src/
  • Linker layouts: ld65 configuration under cfg/
  • Language examples: readable ASCII mirrors under examples/
  • PETSCII/D81 forms: generated under build/

2.2 Compiler artifacts

Artifact Purpose
N128C.PRG On-machine compiler executable
NAME.N8 PETSCII SEQ input source
NAME.PRG Generated application
NAME.ERR Complete stable-code diagnostics
NAME.MAP Symbols, addresses, class layouts, interface slots, and section sizes

Output replacement must be explicit. The compiler must not silently overwrite an existing PRG after a failed compilation. E04-S01 therefore refuses an existing NAME.PRG or NAME.MAP with E202; the user erases the old output before rebuilding. Files created by the current run are scratched on abort or failure.

3. Repository Module Plan

src/
  compiler/
    main.s          command loop and phase orchestration
    mmu.s           all MMU transitions and bank-safe copies
    diskio.s        KERNAL file and command-channel wrappers
    source.s        buffered source stream and positions
    lexer.s         PETSCII normalization and tokenization
    parser.s        recursive-descent declarations/statements
    expr.s          precedence expression parser
    names.s         interning and identifier comparison
    symbols.s       scopes, symbols, and lookups
    types.s         type representation and compatibility
    classes.s       field/method layout and access checks
    interfaces.s    conformance and interface slot assignment
    sizing.s        dry-run code sizing and address assignment
    codegen.s       documented 8502 instruction emission
    flow.s          labels, branches, calls, and frame prologue/epilogue
    output.s        PRG, ERR, and MAP writers
    diagnostics.s   stable error catalog and recovery
  runtime/
    startup.s       generated-program entry and initialization
    frames.s        software argument/local-frame support
    objects.s       object and descriptor accessors
    construct.s     object construction, deletion, and indirect call
    interfaces.s    interface conversion and indirect call trampoline
    heap.s          deterministic allocator and free list
    arithmetic.s    non-inline arithmetic helpers
    print.s         scalar and literal output
    faults.s        null, bounds, divide, stack, and heap failures
  common/
    c128.inc        hardware and KERNAL symbols
    formats.inc     compiler record and output constants
    tokens.inc      token numbers
    errors.inc      stable diagnostic numbers

Files are introduced by stories; this tree is a target, not permission to add empty placeholders.

4. C128 Memory Strategy

The design uses the two 64K RAM banks and a verified common-RAM window.

4.1 Compiler bank roles

  • Bank 0: resident compiler code, phase controller, lexer/parser state, disk/KERNAL wrappers, diagnostics, and small buffers.
  • Bank 1: interned names, symbols, class records, interface records, method signatures, type records, and pass workspaces.
  • Common RAM: MMU transition code, hardware stack, software-stack metadata, disk transfer buffer, and state required while either bank is selected.

No module other than mmu.s directly changes the MMU. Banked data is accessed through handles or explicit copy routines; ordinary pointers must not escape the bank in which they are valid.

4.2 Memory-map freeze gate

Story E01-S02 must prove:

  • chosen MMU configurations;
  • common-memory size and placement;
  • page-zero and page-one behavior;
  • safe execution of the bank-switch trampoline;
  • KERNAL entry and return mappings;
  • interrupt behavior while a non-default bank is active.

Only then may linker addresses and zero-page allocations become normative.

E01-S02 evidence is in docs/mmu-probe.md. In VICE x128 3.10, distinct banks, 8K bottom+top common RAM, bank-switched JSR/RTS, and full MMU restoration pass. Page pointers aimed at bank 1 while bottom common is active resolve to bank 0 at the translated page, so Nova128 keeps P0/P1 in bank 0. Non-default mappings run with IRQs masked; an IRQ path under bank 1 is not yet proven.

E01-S03 evidence is in docs/kernal-wrapper.md. A common-RAM wrapper enters the KERNAL in BASIC's mapping and restores the exact compiler MMU state. From bank 0 and bank 1 compiler mappings, CHROUT, a drive-8 status read, and a device-not-present failure all work, with A/X/Y/C/ST preserved and no channels left open.

4.4 Frozen compiler memory map (E01-S05)

The gate above is closed for the compiler. cfg/compiler.cfg is authoritative and docs/memory-map.md explains it and gives the evidence. In summary:

  • Compiler states: CR $0E (bank 0) or $4E (bank 1), 8K bottom common RAM, P0 relocated to bank 0 page $1A, IRQs masked.
  • Bank 0: COMMON $1300-$18FF (copied at init), DISKBUF $1900, private zero page $1A00, SCRATCH $1B00-$1BEF, MAILBOX $1BF0-$1BFF, MAIN $1C0D-$B7FF (STARTUP first), SWSTACK $B800-$BFFF.
  • Bank 1: source cache $2000-$6AFF (D118), compiler tables $6B00-$BFFF (21,760 bytes).
  • Zero page: the compiler owns logical $02-$FF via P0; KERNAL calls run on the system zero page.
  • NMI: guarded by mmu_nmi_guard through $0318; RESTORE is ignored during compilation.
  • Test mailbox: 16 bytes at $1BF0, shared by platform tests, the compiler, and generated programs.

The generated-program memory map remains open (E04-S01/E04-S02).

4.3 Display and clock

Compiler text uses KERNAL-compatible character output on the display already selected by the user; it does not force 80-column mode. E01-S04 verified the editor's $00D7 mode flag in x128: 40-column uses a deterministic 1 MHz fallback, while 80-column may set $D030 bit 0 for 2 MHz KERNAL output. The speed bit is cleared before returning to BASIC, and any uncertain mode falls back to 1 MHz. Direct VDC screen memory is display-only and is not compiler semantic storage. See docs/platform-display-clock.md for evidence.

2.3 Generated program container (E04-S01)

The generated PRG begins with load address $1C01, the 12-byte BASIC 7 stub 10 SYS 7181, and machine entry $1C0D. Sections are emitted in ascending address order; a gap before a section is filled with zero bytes. The entry point and each section's start/size are recorded in NAME.MAP as CR-terminated text:

NOVA128 MAP 1
ENTRY 1C0D
SECTION STUB 1C01 000C
SECTION START 1C0D 004A

src/runtime/startup.s supplies the fixed stub and startup bytes. The startup initializes the test mailbox, reports producer $03 and PASS, and returns to BASIC. The test fixture keeps its sections below $4000, where BASIC's mapping still exposes RAM; banking, heap, and software-stack placement are E04-S02.

5. Compiler Passes

The compiler reparses a buffered/streamed source rather than retaining a large pointer-rich abstract syntax tree.

E03-S01 implements the declaration-level part of this as parse_file (parser.s): pass 1 parses declarations and method headers, records top-level types, tracks block structure, and reports syntax, ordering, and duplicate errors; pass 2 replays the source and reports type references that name no declared interface or class.

E03-S02 adds members and scopes. Pass 1 records every field and method signature (access, type, parameter types) in bank-1 member and parameter tables, rejects duplicate fields or methods per type (separate namespaces), init/destroy shapes (D122), and var after a method statement or inside a block. Each method signature and the program open a fresh local scope whose locals carry their declared types. Pass 2 scans statement lines token by token: an unqualified name must be a local, parameter, or (in a method) a field of the enclosing class (D123); expr.member follows the left side's type through locals, fields, self, new, call results, and indexing, and reports unknown members and private members used outside their class.

E03-S03 replaced that scan with a real statement and expression parser that runs in both passes. parse_statement handles every spec 7 form (assignment, call, if/else, while, for/to/step, return, print, delete) and requires the line to end after it; a syntax error is reported in pass 1, unwinds to the statement through a saved stack pointer, and skips the rest of the line, so later lines still parse. Expressions use precedence climbing over spec 8's levels with left-associative binary operators and right-nesting unary not and -; postfix member access, calls, and indexing bind tightest. Name, member, and access lookups from E03-S02 run from the parser in pass 2. Each expression level is counted, and more than 16 is E225, which bounds hardware stack use (the deepest fixture leaves SP at $A7). For tests, pass 1 can record every expression in postfix order (ex_trace_on). Operator and E03-S04 adds types.s: every expression carries a type in pass 2, and ty_assign, ty_binary, and ty_unary apply spec 3.4 and 8 (D124-D127). Assignments, call arguments and results, returns, conditions, for, print, delete, indexing, and member chains are checked; a method that declares a result must end in return (an if only when both branches do), reported at end as E150. Numeric literals are typed by context and folded (E084/E086); arrays are never whole values (E088).

E03-S05 computes each class's instance size and field offsets after the declaration pass, once every forward type reference has a kind. Fields keep declaration order after the two-byte descriptor pointer with no padding; ABI sizes are 1 for boolean/byte/char, 2 for integer/word/class reference, 4 for an interface reference, and element size times length for an array. A class whose instance would exceed 65535 bytes reports E096 at its declaration line.

E03-S06 adds interfaces.s. After the declaration pass it numbers every interface method in source order (C_MEM_SLOT), giving later dispatch tables a stable slot order, and checks each class against every interface in its implements list. A satisfying method is public and has the same parameter count, parameter types, and return type; one method may satisfy identical requirements from several interfaces. Failures report at the class declaration line, column 1: E130 for a missing or private method, E131 for a signature mismatch, E132 when a same-named requirement of another implemented interface conflicts with the class method's signature, and E133 when an implements entry names a class or program rather than an interface. An implements entry that names no declared type is left to pass 2's unknown-name diagnostic.

Pass 1 — declarations and layout

  • tokenize the complete source;
  • intern names;
  • collect interfaces, classes, fields, methods, parameters, and program data;
  • validate duplicate declarations and top-level structure;
  • assign field offsets;
  • assign a stable slot order within every interface;
  • build class-to-interface conformance records.

Pass 2 — semantics and sizing

  • parse method/program bodies;
  • resolve names and access control;
  • check types, returns, and interface conversions;
  • perform definite-local-initialization checks that fit the compiler budget;
  • dry-run code generation to determine method sizes and addresses;
  • calculate literal, descriptor, interface-table, and runtime sections.

Pass 3 — emission

  • repeat semantic parsing deterministically;
  • emit documented 8502 instructions;
  • emit literals, class descriptors, and interface method tables;
  • resolve branches and addresses against Pass 2 assignments;
  • append/link required runtime sections;
  • write PRG and MAP output;
  • delete or invalidate partial output on failure.

Repeated passes re-read the source, not the disk. src_load (source.s) reads the file once into the bank-1 cache and enforces the editor-profile limits. Each pass calls src_rewind, then src_next_line delivers one logical line at a time into a common-RAM buffer (D118, E02-S01).

6. Compiler Data Records

Final byte layouts are story outputs, but records require these logical fields:

  • Interned name: hash, length, original spelling location, normalized bytes
  • Symbol: name ID, kind, type ID, scope ID, flags, value/address
  • Type: kind, size, referenced class/interface/element, array length
  • Class: name ID, instance size, field range, method range, interface range
  • Field: owner class, name ID, type ID, access, byte offset
  • Method: owner, name ID, signature, access, code address, code size
  • Interface: name ID, ordered signature range
  • Implementation: class ID, interface ID, emitted table address
  • Diagnostic: code, file position, arguments

Use compact fixed-width records where practical. Every table has an explicit capacity and produces a resource diagnostic rather than corrupting adjacent memory.

7. Generated Program ABI

7.0 Container and entry (E04-S01)

  • Load address $1C01; BASIC 7 stub 10 SYS 7181; entry $1C0D.
  • Sections are contiguous and ascending; gaps are zero-filled.
  • NAME.MAP records ENTRY, every SECTION name start size, and optional CLASS/METHOD records in hex.
  • The E04-S01 startup writes the mailbox with producer $03, then PASS, and returns to BASIC. E04-S02 supplies the frame runtime separately; code generation will switch to CR $0E and initialize frames when it emits user code (E04-S03).

7.1 Scalar representation

  • Multi-byte values are little-endian.
  • boolean, byte, and char occupy one byte.
  • integer, word, concrete references, and addresses occupy two bytes.
  • Interface references occupy four bytes.

7.2 Return convention

  • One-byte result: accumulator A
  • Two-byte result: A is low byte, X is high byte
  • Four-byte interface result: fixed zero-page area IF_RESULT ($C6-$C9); the callee writes it and the caller copies it out before another interface-returning call (E06-S01, D142)
  • No result: A and X are caller-clobbered

The final register-clobber table is part of story E02-S02.

7.3 Frames and calls (E04-S02)

Generated code runs with CR $0E (RAM through $BFFF) and IRQs masked, then restores the entry CR before returning to BASIC. The software frame stack is bank 0 $B800-$BFFF, growing down from $C000; SP and FP use the free zero-page bytes $FB-$FE. The compiler's own stack and private zero page are not shared with the generated program.

Frame layout relative to FP:

FP+0..1  previous FP (pushed by frame_enter)
FP+2..   arguments, arg0 at the lowest address
FP-1..   locals, allocated below FP

A caller pushes arguments right-to-left, executes JSR, and discards the argument bytes after the return. The callee calls frame_enter with the local byte count in A/X, uses locals below FP, then calls frame_leave. One-byte results use A; two-byte results use A (low) and X (high). The hardware stack still carries JSR return addresses and small temporaries, so frame_enter also refuses a frame when the 6502 stack pointer falls below $40.

frame_enter is transactional: it checks both bounds before committing, restores SP and FP on failure, and returns C=1 with FRAME_ERR_STACK. The caller can unwind through frame_leave without a partial frame. self, parameters in declaration order, and direct JSR for concrete method calls are unchanged from the ABI baseline.

7.4 Object layout

Every heap object begins with a two-byte class-descriptor pointer, followed by fields in declaration order with no hidden alignment padding:

+0  class descriptor pointer (2)
+2  first declared field
... remaining fields

E05-S02 freezes the class descriptor as an 8-byte packed record:

+0..1  instance size (includes the 2-byte object header)
+2..3  destructor address; 0 = none
+4     implemented-interface count
+5..6  interface-directory pointer; 0 when the count is 0
+7     reserved flags; must be 0

The interface directory is a count-long array of 4-byte entries: a pointer to the interface descriptor and a pointer to its method table in slot order. There is no parent-class pointer or inheritance slot. src/runtime/objects.s provides read-only obj_class, obj_size, obj_destroy, and obj_iface_dir accessors.

7.5 Interface values and dispatch (E06-S01)

E06-S01 freezes the interface value ABI in src/common/interfaces.inc (D142). An interface value is a four-byte fat reference, little-endian:

+0..1  object pointer
+2..3  interface method-table pointer

All four bytes zero is the canonical null. A non-null object with a zero table pointer is not a valid value: conversion reports IF_ERR_TABLE and writes a zero table half, so no invalid pair is observable.

Each interface defines a slot order from its source declaration (E03-S06). For every implemented interface, a class emits a table of two-byte method addresses in that order with no header, so slot n lives at table+2*n. if_slot reads such an entry and refuses a null value with IF_ERR_NULL or a zero table with IF_ERR_TABLE.

src/runtime/interfaces.s supplies the transfers every later interface path shares. Destinations and sources travel in module zero page (if_obj, if_tab, if_dst, if_src) because A/X carry the object or the value, and if_error reports IF_OK, IF_ERR_NULL, or IF_ERR_TABLE:

  • if_conv writes the object pointer and the compile-time-selected table;
  • if_copy copies all four bytes verbatim (a zero source pointer is IF_ERR_NULL);
  • if_is_null tests the object half;
  • if_slot loads a method entry from a slot;
  • if_ret_store and if_ret_load move a value through IF_RESULT.

Converting a concrete reference to an interface reference therefore copies the object pointer, selects the class's implementation table from compile-time metadata (E06-S03 emits it), and writes the table pointer beside the object pointer, or four zero bytes when the object is null.

Storage follows the type sizes of section 7.1: interface parameters, locals, fields, and array elements occupy four bytes with no alignment padding, and a caller pushes all four bytes right-to-left so the object low byte is at FP+2. A four-byte result cannot travel in A/X, so the callee writes the value to the fixed zero-page area IF_RESULT ($C6-$C9) and the caller copies it out immediately; another interface-returning call overwrites the area. The runtime module asserts at link time that IF_RESULT sits between the print vectors ($C0-$C5) and the for-end values ($D0).

An interface call (if_call, E06-S02) takes A/X = the value pointer and Y = the compile-time slot:

  1. if_slot reads the two-byte target from the value's method table;
  2. a null value, zero table, zero entry, or full hardware stack faults with IF_ERR_NULL, IF_ERR_TABLE, IF_ERR_ENTRY, or IF_ERR_STACK before anything is executed;
  3. the object pointer becomes self in A/X, and the trampoline pushes one synthetic return address and JMP (if_vec)s to the target.

The 8502 has no JSR (address) instruction, so the trampoline builds its return path from a pushed address pair, the same form construct.s uses for constructors. Each nested or recursive call pushes its own pair, so returns unwind without a dispatch stack; if_vec must not straddle a page because of the JMP indirect bug. The hardware-stack check compares TSX with FRAME_HW_MIN so a refused call cannot wrap the 6502 stack silently.

if_call passes the callee's A/X and flags back unchanged, so a callee that sets its own C=1 status is not confused with a refusal: if_error is IF_OK exactly when the call happened. The trampoline leaves FRAME_SP/FRAME_FP, self is the object rather than the fat reference, and the I/D flags and IRQ vectors are untouched.

delete through an interface uses the object pointer and its class descriptor, not the interface table, to find the optional destructor and object size.

src/compiler/iftables.s builds those tables (E06-S03, D144). it_begin takes the image address of the first table, it_set_addr records a method entry for a member row, and it_class walks the class's implements entries and emits one table per interface in implements order, one slot per interface method in slot order. Each requirement resolves through find_method to the class's member row of the same name, so one method fills every interface slot it satisfies. The buffer holds IT_BUFFER_SIZE (512) bytes, at most IT_MAX_TABLES (16) tables, and IT_MAX_ROWS (255) address rows; the parallel arrays it_class_ids/it_iface_ids/it_slots/it_table_addrs/it_offsets describe each table by number. A slot whose method has no address is IT_ERR_UNSET rather than an entry that would dispatch to zero, and a failed class restores the watermark and table count so the caller can retry. The caller supplies addresses because method placement is pass 3.

it_find (E06-S04) turns a class ID and an interface ID into the table that class emitted, or IT_ERR_IFACE when the class does not implement the interface, so a conversion site never guesses a table. if_assign is the one store path: with Y = 0 it copies an interface value verbatim, otherwise it converts if_obj with the compile-time table in if_tab, and a zero object writes the canonical null. if_arg_push pushes the four bytes right-to-left so the object low byte lands at FP+2; if_elem_store and if_elem_load address 4-byte array elements at base + IF_VALUE_SIZE*index and refuse an address that wraps past $FFFF with IF_ERR_RANGE. Assignment rules stay in types.s: a class converts only to an interface it implements and everything else is E080, so an invalid conversion never reaches emission.

An interface call site fixes its slot at compile time: iface_slot reads the slot assign_slots recorded for the interface's method row and refuses a name the interface does not declare (IS_ERR_METHOD), so emission cannot invent an offset. At run time if_call takes the table half of the value and dispatches slot n from it, which is why one call site reaches different classes and one object reaches different interfaces. The caller pushes arguments right-to-left and discards them after the return; a one-byte result comes back in A and a two-byte result in A/X.

The E06-S01 fixture (tests/runtime/interface-test.s) verifies the little-endian layout, canonical null, the invalid-table refusal, four-byte copy, slot order, the result area, and the pushed argument order in x128. The E06-S02 fixture (tests/runtime/dispatch-test.s) adds runtime-selected targets, three-level nesting, 8- and 12-level recursion, SP/frame/argument/IRQ and I/D preservation, the three target faults, and recovery after a full-stack refusal. The E06-S04 fixture (tests/runtime/convert-test.s) adds the class and null conversions, verbatim interface assignment, a four-byte field, three array elements, the wrapped-address refusal, the argument layout, and the result area. The E06-S05 fixture (tests/runtime/ifcall-test.s) adds one call site reaching two classes, one object reaching two interface tables, a nested call with a one-byte result, and a byte/word argument pair returning a word.

7.6 Scalar code generation (E04-S03)

src/compiler/codegen.s emits documented 6502 instructions into a bounded 256-byte buffer. One-byte values are in A; two-byte values are A (low) and X (high). The emitter provides immediate/absolute loads and stores, absolute ADC/SBC/CMP operands, JSR, and forward relative branches with checked fixups. Forward branches are limited to +0..+127 bytes; E04-S04 supplies the control-flow and long-branch expansion.

Multiplication and signed/unsigned division use src/runtime/arithmetic.s. Operands are in ar_a/ar_b, the result is A/X, and division leaves its remainder in ar_rem. Division by zero sets provisional ar_fault to AR_FAULT_DIV0, returns A=X=0 with C=1, and is mapped to the final runtime fault namespace by E04-S05. The scalar fixture executes the emitted shapes, checks short-circuit evaluation, and compares a reviewed golden byte stream and da65 disassembly. Parser integration into statements and frames remains the next backend step.

7.7 Control flow, calls, and returns (E04-S04)

src/compiler/flow.s supplies the statement-level control transfer above the scalar emitter. It keeps 16 labels and 32 fixups per routine. A label records the byte offset where it was defined; fl_jmp emits an absolute JMP and is also the back edge of while and for loops. fl_branch takes a branch opcode and a label and always occupies five bytes:

short  Bxx distance    ; target within +0..+127
       NOP NOP NOP     ; unused JMP slot
long   Bxx'inv +3      ; inverse of the requested condition
       JMP target

The form is chosen when fixups are patched. A forward branch whose target is behind it is FL_ERR_RANGE; loops use JMP. Patching the long form inverts the opcode, so a true condition falls through to the JMP and a false one skips it. Because every conditional branch is five bytes, cg_put's counting mode produces exactly the same label offsets and routine length as emission without writing memory, which is the sizing half of the two-pass contract.

Calls follow section 7.3: fl_call emits JSR and then frame_discard for the argument bytes. fl_call_byte and fl_call_word save the result on the hardware stack across the discard, and frame_discard preserves A/X. A routine returns through fl_ret (no result), fl_ret_byte (A), or fl_ret_word (A/X); the result variants save and restore the result around frame_leave. fl_prologue emits frame_enter for the local byte count and branches to the caller's error label on carry.

The x128 fixture builds a nested routine (prologue, while, if/else, for, call with argument discard, value return), checks sizing against a counting pass, verifies both branch encodings in memory, and executes it. Parser-to-emitter wiring, method address assignment, and compiler pass 3 are not part of this layer.

7.8 Print and runtime faults (E04-S05)

src/runtime/print.s writes PETSCII through CHROUT under the D129 generated-program mapping. It prints a byte, an unsigned word, a signed integer, a boolean (TRUE/FALSE), a length-address text block, and a carriage return. Decimal output has no leading zeros; only pr_cr ends a line. The routines append nothing on their own and do not change CR, RCR, or the P0/P1 pointers, so a runtime message cannot relocate the program. pr_text uses a zero-page pointer, so it depends on the system zero page being mapped as in D129.

src/runtime/faults.s maps the provisional arithmetic fault onto a stable R-code namespace: R001 null, R002 bounds, R003 divide by zero, R004 software stack overflow, and R005 heap exhaustion. flt_report writes the code and fault address into the mailbox, sets MB_ST_FAIL, and prints RUNTIME FAULT R00n. The code and address are written before the status so a monitor never observes a failure with stale fields. The routine returns; unwinding to the runtime entry remains with the frame/exception work.

7.9 First source-to-code proof (E04-S05)

The hello.n8 compile fixture closes one source-to-code path without claiming the general backend. Parser pass 2 records a print that consists of one string literal in the bounded gen_text buffer, sets gen_print_count, and sets gen_bad for any other print shape. The fixture then emits the D128 container by hand: a 12-byte BASIC stub section at $1C01 and a code section at $1C0D. The fixture must initialize out_load and out_entry itself; output.s reads them but does not assign them.

The emitted body saves the entry CR, selects $0E, writes each captured PETSCII byte with LDA #byte / JSR $FFD2, restores CR, and returns to BASIC. It does not call startup.s or the scalar/control-flow emitters. A successful compile produces a 118-byte HELLO.PRG; a second x128 session autostarts the extracted PRG and the host checks for HELLO FROM NOVA128 on the screen.

This is deliberately narrower than compiler pass 3: expressions, calls, multiple statements per program, and address assignment for generated code remain open. Pass-2 code growth also made some parser branches exceed ca65's +/-128 range, so the literal path uses a near branch and JMP trampoline.

7.10 Member emission (E05-S04)

src/compiler/members.s emits the member half of a method body above the scalar emitter and the flow helpers. It takes field offsets from the E03-S05 layout unchanged and assumes the D129 call ABI, where a method receives self in A/X.

A generated method body begins with mb_self_store, which records the incoming self in zero-page MB_SELF ($F9-$FA). self.field then needs one indexed-indirect access: mb_ld_field and mb_st_field emit LDY #offset and LDA (MB_SELF),Y or STA (MB_SELF),Y, adding the +1 byte for a word field. A word store moves the high byte out of X with TXA before storing, because the 6502 has no STX (zp),Y.

mb_point loads a two-byte object reference into MB_SELF, so the same emission serves x.field and a call on a receiver other than self. A concrete call is mb_self_load (A/X = self), JSR target, then frame_discard for the argument bytes: the callee enters its normal frame and reads arguments at FP+2, and a byte or word result survives the discard. That is the direct-JSR path D107 keeps for concrete methods; interface dispatch remains E06.

codegen.s gained the forms this needs: cg_ldy_imm, cg_lda_zp, cg_ldx_zp, cg_sta_zp, cg_stx_zp, and cg_lda_zpy, cg_ldx_zpy, cg_sta_zpy. MB_SELF sits directly below the frame ABI and members.s asserts at link time that it does not overlap FRAME_SP or FRAME_FP.

The fixture generates a field writer and byte and word readers, runs them against two objects of one layout, then generates callers that push arguments and call real methods. It checks field offsets, independent state, the argument discard, and a word result at the call site. Private member access is rejected before code generation: the parser reports ACC_PRIVATE_MEMBER (E120) for a private field or method reached outside its class, so this layer never sees one.

7.11 Statement emission (E04-S06)

src/compiler/emit.s is the emitter driven by the parser itself. Pass 2 parses statements for type checking; when em_on is set, the same parse also emits bytes into the shared codegen.s buffer. parse_file resets the emitter and the buffer at the start of pass 2, and pass 1 assigns the same frame slots without emitting, so the two passes agree by construction. Parser hooks are gated by both em_on and pass 2, so parser fixtures that only type-check leave the emitter idle.

Parameters take FP+2 upward in declaration order and locals FP-1 downward, with one byte for boolean/byte/char and two for integer/word. A slot is not addressed with LDY #offset / (FRAME_FP),Y: indexed-indirect addressing adds Y to the pointer, so a negative offset would address a page above FP. em_point_slot instead emits EM_LP = FRAME_FP + offset (low byte plus carry, high byte plus the offset's sign byte), leaves Y = 0, and the load or store uses (EM_LP),Y. The store keeps its value on the hardware stack across that sequence.

Byte + and - push the left operand, evaluate the right, then combine through EM_TMP. Comparisons emit CMP and record the operator in em_cmp_op; em_cond_false turns those flags into the appropriate branch, including the two-branch forms for > and <=.

Control flow uses flow.s labels and fixups: if/else, while, a positive-step-1 for with a constant byte end, and return. Methods get stable entry labels allocated by member row, so a call written before the method body resolves forward; em_entry records the program body's offset in cg_code so a fixture can run the program after class methods. em_finish checks every flow fixup and converts em_bad to E_RES_EMIT_FAILED (E227) after pass 2. A failed emit leaves only the bounded code buffer to inspect; it writes no output file.

self.field loads and stores through members.s. A concrete call loads MB_SELF, emits JSR to the method label, and preserves a byte result. The current call path accepts only zero-argument self calls; the existing members.s discard path is exercised with a zero-byte argument list.

print emits through em_pr_text, em_pr_byte, and em_pr_cr at $C0-$C5, which the caller fills; each call goes through a JMP (cell) trampoline, so the test records what the program asked to print instead of depending on CHROUT's mapping. Keeping the cells in zero page also keeps their low byte away from $FF, avoiding the 6502 indirect-JMP page bug without run-time alignment. A print that captured a string literal passes gen_text and its length; otherwise it passes the byte in A.

The statements.n8 fixture parses a for, an if/else, and a while, then JSRs the 244 emitted bytes in place. It checks a = 2 (the loop variable one past the end value) and b = 3, with no recorded print output. The calls.n8 fixture parses a method that increments self.n, calls a forward method that increments it again, and explicitly returns; the 53 emitted bytes leave n = 2.

Word values, call arguments, arrays, method result forwarding, the frame prologue, and sizing/pass-3 addresses remain unimplemented. for currently requires a constant byte end and step 1 and leaves the loop variable one past the end value. E04-S07 owns those limits and the 255-byte code buffer.

7.12 Pass-3 relocation and sizing (E04-S07)

sizing.s computes the final CODE base from PRG_ENTRY_ADDR plus the bytes before the section and installs it through parse_file_base. flow.s resolves absolute JMP and JSR operands against that base; relative branches remain offset-based. Normal parse_file clears the base request, so the in-place statement and call fixtures keep using cg_code.

parse_file_sized selects count mode with em_count, captures the count pass with sz_capture, then reruns with emission enabled and diagnostics suppressed. sz_verify compares the code length, entry offset, routine sizes, and all 64 label offsets; any difference raises RES_SIZE_MISMATCH (E228). Routine lengths are recorded by em_end_func in em_routine_sizes. output.s appends METHOD name address records after CLASS records, so descriptor and method entry addresses appear in the deterministic MAP.

counter.n8 is a two-local program: total = 0, then for i = 1 to 5 adds i to total. Its fixture emits COUNTER.PRG and COUNTB.PRG with final STUB, START, and CODE addresses, checks the two files are byte-identical, and runs COUNTER.PRG in a second x128 session. The fixture-owned wrapper selects CR $0E, points FRAME_FP at the software frame area, calls the code, reports total, and restores the entry mapping. The observed result is 15.

The counter fixture also verifies a zero sizing-mismatch counter. The parser's unresolved.n8 fixture produces exactly one SEM_UNKNOWN_NAME (E61) for an unknown method, with no bad-operand follow-up. The class fixture checks METHOD DESTROY and METHOD STUB MAP records, and the output fixture reconfirms the E202 overwrite refusal.

8. Heap and Ownership

  • The generated-program heap initially resides in the same RAM bank as program data; banked heaps are deferred.
  • new requests the class instance size, zeroes the allocation, stores the descriptor pointer, and invokes init.
  • delete invokes destroy at most once for that call and returns the entire block to a coalescing free list.
  • Allocation failure and corrupt/double-free detection use runtime fault codes.
  • Reference copying does not change an ownership count.
  • Aliases can outlive a deletion; that is a program error. Debug poisoning may help diagnose it but is not a correctness mechanism.

8.1 Free-list heap (E05-S01)

src/runtime/heap.s supplies the allocator; src/common/heap.inc fixes the block/API contract. The caller supplies the region, so this does not freeze the still-open generated-program map. In the E05-S01 fixture the region is bank-0 $4000-$4FFF under CR $0E.

Each block has a four-byte header:

+0..1  total block size; bit 15 set while allocated
+2..3  next free block while free; $A55A while allocated
+4..   payload

Blocks are two-byte aligned and the minimum block is six bytes. Free blocks are kept in ascending address order. heap_alloc takes a payload byte count in A/X, walks first-fit, splits a remainder of at least six bytes, zeroes the payload, and returns its pointer in A/X. heap_free takes a payload pointer and inserts/coalesces with adjacent free blocks. A zero pointer is a no-op.

Range, size, used-magic, and double-free checks are always on. The allocator returns HEAP_ERR_* rather than printing; generated new maps allocation failure to R005 when E05-S03 wires construction. The stress fixture frees in different orders and requires the initial free capacity to return.

Heap placement for generated programs and banked heaps remain open. The fixture keeps code below $4000 and uses only its explicit 4K region.

8.2 Class descriptors and objects (E05-S02)

src/common/class.inc fixes the object/descriptor ABI and objects.s reads it without allocating or changing object state. The class fixture covers a class with no fields and no destructor, and a class with an optional destructor and one interface-directory entry.

output.s accepts class records through out_class and appends deterministic MAP lines after the section records. out_class_addr supplies the descriptor address:

CLASS name address size destroy interface-count interface-directory
METHOD name address
TABLE class interface address slots slot0 .. slotN

All numeric fields are four hex digits except the interface count and the slot count, which are two. METHOD records follow all CLASS records in out_method call order; both record kinds use the final linked addresses. Empty classes and no destructor are written as 0000; records keep the caller's order. The class test reads the emitted MAP back and compares it with the descriptor's actual destructor and directory addresses.

out_table (E06-S03) appends one TABLE record per emitted interface table after the METHOD records: class and interface name, the table's assigned address, its slot count, and one address per slot in interface order. Records are capped at OUT_MAX_TABLES (16) with OUT_ERR_TABLES (9). The interface table fixture reads the MAP back and compares all three records with the linker labels of the method stubs.

8.3 Construction and deletion (E05-S03)

src/runtime/construct.s implements new and delete on top of the heap and descriptor layers, so both can be tested before parser-to-codegen exists.

obj_new takes A/X = class descriptor plus obj_init_ptr chosen by the caller (0 = no constructor). It reads the descriptor's instance size and refuses zero with C=1/OBJ_ERR_BAD, allocates exactly that many bytes, and maps HEAP_ERR_OOM to C=1/OBJ_ERR_HEAP with A/X=0. Only after a successful allocation does it write the descriptor pointer at object+0 and call the init entry once with A/X = self; the payload is already zeroed by the heap, so fields start at zero. Constructor arguments are not copied: they stay on the caller's software frame stack, init enters its normal frame and reads them from FP+2 right-to-left, and the caller discards them with frame_discard after obj_new returns. A failed allocation never calls init.

obj_delete takes A/X = object. Null returns C=0 without touching the heap or the destructor. Otherwise it reads the descriptor's optional destructor, calls it once with A/X = self and no arguments, then frees the complete block with heap_free, so an instance returns to the coalescing free list in one piece.

Both calls use obj_call, the synthetic-return trampoline. The entry address travels in zero-page co_vec rather than A/X, because A/X already carry the callee's self pointer; obj_call saves them, pushes a two-byte return address, restores them, and ends with JMP (co_vec). The callee's RTS lands on the trampoline's own RTS, which returns to the original JSR. co_vec must not straddle a page (link-time assertion) because of the 6502 indirect-JMP bug, and every such call costs two extra hardware-stack bytes.

construct.s reports through obj_error and the carry flag rather than printing, so generated code still owns the R005 fault report for new.

The fixture constructs a five-byte class whose init(value as word, tag as byte) stores both fields beside a destructor, then checks the stored header, zeroed fields, the constructor's arguments and self, that delete null calls nothing, that deletion calls the destructor once with the fields intact and restores the full free capacity, that a $FFFF instance fails without calling init, and that a class without a destructor still frees completely.

8.4 Deleting through an interface (E06-S06)

Lifetime is the one operation an interface value performs on its object, so it lives in src/runtime/ifelife.s rather than in the dispatch module: value fixtures that never allocate stay free of the heap and construction code.

if_delete takes A/X = value pointer. A zero pointer, or a value whose object half is zero, is a successful no-op (C=0, IF_OK) and the table half is ignored — delete null through an interface must not call anything. Otherwise the object half is validated with heap_check before any indirect transfer; only a live block reaches obj_delete. A refusal returns C=1 with the new IF_ERR_FREED and leaves destroy_count and the free capacity untouched, so the promise is that a stale value is refused, not that memory is inspected for a destructor that may already be gone.

On success the four value bytes are cleared. That keeps the ordinary single owner path idempotent — a second delete through the same value is a no-op — without adding a reference count. Aliases are ordinary four-byte copies and may outlive the deletion; per §8 the detection mechanism is the heap's always-on validation (range, used bit, even size, $A55A magic), which heap_check exposes to this caller. Poisoning remains optional and is not what makes the stale case correct.

The fixture deletes through a value, checks the destructor ran once and the full capacity returned, treats a null value and a value with a table but no object as no-ops, deletes one object through a second interface table and then shows the first value refused with IF_ERR_FREED, and finally deletes through an alias copy that outlived the deletion. objects.n8 carries the same shape through the parser: two interfaces, a class implementing both, and calls and a delete through an interface variable.

9. KERNAL and Disk I/O

The compiler uses documented KERNAL entry points through diskio.s wrappers. Each wrapper:

  1. records compiler mapping state;
  2. installs the verified KERNAL-compatible mapping;
  3. performs the operation and reads status;
  4. restores compiler mapping state;
  5. reports errors without leaving files or channels open.

The command channel must be opened before source/output files when status is needed and closed last, matching the proven editor workflow.

10. Vim Integration

The existing Vim clone is treated as an external producer/consumer of SEQ files. Version 1 does not link the editor and compiler in memory.

The development image workflow is strictly sequential:

  1. scripts/launch-editor.ps1 copies ../vim/vim-improved.d81 to build/newlang-edit.d81, writes ../vim/vim128.prg into the copy, and launches x128 in 80-column mode with that C128 editor (D151);
  2. the script returns only after the emulator exits and the image is flushed, releasing its session lock;
  3. scripts/launch-compiler.ps1 prepares build/newlang-dev.d81 (unless -SkipPrepare), launches x128 and autostarts build/n128c.prg;
  4. close the compiler session before editing, inspecting or copying the image.

Both launchers share scripts/lib/Nova128Launcher.psm1, which holds an exclusive .newlang-session.lock beside the writable image and reopens the image exclusively before returning, so a second launcher cannot attach to a disk a live session owns and no caller sees a partially written image. An unreadable lock counts as held. docs/editor-workflow.md states the order, recovery for a stale lock and the prohibition on two writable sessions; tests create a fresh image per run and never write ../vim disks.

11. Diagnostics and Faults

Compile-time diagnostics use stable IDs such as E014. Runtime faults use a separate stable namespace such as R003. The compile-time catalog is src/common/diagcat.inc (one line per diagnostic, numbered by category range); errors.inc turns it into E_* constants and diagnostics.s into messages. docs/diagnostics.md is generated from it (E02-S04).

diagnostics.s prints a two-line screen form (header, message clipped to 39 characters) and appends one full line per diagnostic to NAME.ERR. The ERR file is scratched when compilation starts and created only on the first diagnostic. After 20 errors the next is replaced by E221 and the caller must stop. If NAME.ERR cannot be written, E201 is shown once and reporting continues on screen.

Compiler errors include source name, line, column, and compact message. Runtime faults always include a stable fault code and the best available program address that can be resolved through the MAP file. Rich stack traces are deferred.

12. Verification Architecture

Testing is layered:

  1. Host checks for deterministic converters, image preparation, and byte-level expected files.
  2. Compiler fixtures for lexer, parser, symbols, types, class layout, interface conformance, and diagnostics.
  3. Golden PRG bytes and disassembly for small generated routines.
  4. End-to-end x128 runs that write a result signature to the fixed test mailbox at $1BF0 (src/common/mailbox.inc, docs/memory-map.md).
  5. Monitor scripts with MMU, stack, heap, and dispatch break/watch points.
  6. Editor round-trip: Vim saves source, C128 compiler consumes it without host conversion, and Vim reopens unchanged source.
  7. Physical C128 smoke testing when hardware is available.

Each emulator test has a cycle/time limit and captures enough output to explain failure without relying only on a screenshot.

13. Principal Risks

  • Incorrect MMU assumptions corrupt compiler state or KERNAL execution.
  • Interface fat references increase local/array memory use.
  • The indirect-call trampoline can corrupt the hardware stack if designed or tested casually.
  • Repeated parsing can be slow on real disks.
  • Cross-bank raw pointers can create intermittent corruption.
  • Feature growth can exceed the on-machine compiler's memory budget.
  • Two VICE processes can overwrite the same D81 image.

The story order makes each high-risk mechanism prove itself before downstream features depend on it.

Open Questions

  • Generated-program memory map (the compiler map is frozen in section 4.4)
  • Compiler table capacities and their user-visible resource limits
  • Physical-hardware acceptance requirements

Powered by TurnKey Linux.