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.
The deliverable compiler is an 8502 program that runs on a Commodore 128 in native mode. Host-side tools may:
ca65 and ld65;c1541;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.
src/cfg/examples/build/| 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.
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.
The design uses the two 64K RAM banks and a verified common-RAM window.
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.
Story E01-S02 must prove:
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.
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:
$0E (bank 0) or $4E (bank 1), 8K bottom common
RAM, P0 relocated to bank 0 page $1A, IRQs masked.$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.$2000-$6AFF (D118), compiler tables
$6B00-$BFFF (21,760 bytes).$02-$FF via P0; KERNAL calls run
on the system zero page.mmu_nmi_guard through $0318; RESTORE is ignored
during compilation.$1BF0, shared by platform tests, the
compiler, and generated programs.The generated-program memory map remains open (E04-S01/E04-S02).
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.
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.
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.
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).
Final byte layouts are story outputs, but records require these logical fields:
Use compact fixed-width records where practical. Every table has an explicit capacity and produces a resource diagnostic rather than corrupting adjacent memory.
$1C01; BASIC 7 stub 10 SYS 7181; entry $1C0D.NAME.MAP records ENTRY, every SECTION name start size, and optional
CLASS/METHOD records in hex.$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).boolean, byte, and char occupy one byte.integer, word, concrete references, and addresses occupy two bytes.IF_RESULT ($C6-$C9); the
callee writes it and the caller copies it out before another
interface-returning call (E06-S01, D142)The final register-clobber table is part of story E02-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.
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.
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:
if_slot reads the two-byte target from the value's method table;IF_ERR_NULL, IF_ERR_TABLE, IF_ERR_ENTRY, or IF_ERR_STACK before
anything is executed;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.
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.
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.
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.
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.
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.
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.
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.
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.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.
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.
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.
The compiler uses documented KERNAL entry points through diskio.s wrappers.
Each wrapper:
The command channel must be opened before source/output files when status is needed and closed last, matching the proven editor workflow.
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:
../vim/work.d81 to build/newlang-dev.d81;N128C.PRG, examples, and fixtures;x64sc and autostart VIM for editing;x128 and autostart N128C.PRG for compilation;Scripts must refuse obvious concurrent use where detectable. Tests create a fresh image per run.
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.
Testing is layered:
$1BF0 (src/common/mailbox.inc, docs/memory-map.md).Each emulator test has a cycle/time limit and captures enough output to explain failure without relying only on a screenshot.
The story order makes each high-risk mechanism prove itself before downstream features depend on it.
Powered by TurnKey Linux.