DGCC design notes

DGCC is a compiler for a small subset of C, targeting the Data General Nova/Eclipse 16-bit minicomputer family. It is written to be self-hosting (it only uses the language subset it itself implements) and space-minimal (no heap, no recursion-unfriendly static buffers beyond fixed-size tables), since the target machine has a 32K-word address space shared by everything: code, data, and stack.

A critical disclosure up front

This compiler has never run on real Data General hardware, and no real DG assembler or simulator was available while building it. The sandbox this project was built in has no network access to fetch one. Instead, correctness was checked two ways:

  1. Web research into the real Nova/Eclipse instruction set -- the 015-000009-09_HowToUseNova manual (via archive.org) for general ISA grounding (register semantics, skip conditions, shift/carry behavior), and, for the calling convention specifically, the actual SAVE/RTN dictionary entries from Data General's ECLIPSE Assembly Programming and S/140 Programmer's Reference (Rev. 04), supplied directly rather than fetched (this sandbox has no network access, and the versions found via archive.org/bitsavers were either access-restricted or missing the relevant chapter) -- to ground the calling convention in the real, documented protocol rather than an invented approximation of one.
  2. A custom instruction-level simulator, tools/nova_sim.py, built specifically for this project. It implements the subset of the Nova/Eclipse instruction set that this compiler's own output actually uses (the ALU ops, the MOV-family shift/rotate/seed/no-load variants, skip suffixes, PSHA/POPA, SAVE/RTN, JSR/EJSR/JMP, indirect LDA/STA, ISZ, .SP/AC-relative addressing) and is used by tests/run_tests.py to actually execute every generated program end to end and check its results, rather than just re-reading the assembly and hoping it's right.

This is a meaningfully stronger check than hand-tracing -- it caught several real bugs during development that hand-tracing had missed (see "Bugs this process actually caught" below) -- but it is still not a real Nova or Eclipse, and it does not model real machine cycle timing, real memory sizes, or genuine DG assembler syntax quirks. Treat every claim of correctness in this document as "verified against our own simulator's model of the ISA, cross-checked against the real documentation where that documentation was available to us," not "verified on real hardware."

Pipeline

SOURCE.C --[cpp]--> SOURCE.I --[nparse]--> SOURCE.NIR --[ncodegen]--> SOURCE.ASM --[nopt, optional]--> SOURCE.ASM

Four independent programs, each reading one file and writing another, communicating through two text-based intermediate formats:

  • cpp -- a preprocessor: #define (object- and function-like), #undef, #ifdef/#ifndef/#if defined(...)/#else/#endif, #include "file" and #include <file>, #line, #error, #pragma, the built-in __FILE__/__LINE__/__DATE__/__TIME__ macros, and (with -g) source-location markers for the rest of the pipeline. See "The preprocessor" below for the details. There still isn't a bundled set of ANSI system headers for #include <...> to find -- there are no ANSI headers at all in this world (see "No standard library" below) -- but a project can now supply its own shared headers via -I.
  • nparse -- lexer, parser, and the only stage that understands C syntax and types. Emits NIR, a line-oriented stack-machine intermediate representation (below), so it never needs to hold more than one function at a time in memory.
  • ncodegen -- translates NIR straight into Nova/Eclipse assembler, one NIR line at a time. Deliberately naive: every NIR "push" becomes a value landing in AC0 followed by PSHA 0; every operator POPAs its operands right back off the hardware stack, computes, and pushes the result. Simple to get right, but leaves a lot of redundant stack traffic.
  • nopt -- an optional peephole pass over the generated assembly text that cleans up exactly that redundancy (see "Peephole optimizer" below). Skipping it changes code size and speed, never correctness -- confirmed by running the entire test suite through the simulator both with and without it and diffing the results.

None of the four tools uses a real assembler or linker backend; ncodegen and nopt produce Nova/Eclipse assembler text, which would need a real DG assembler (or, in this project, nova_sim.py) to turn into something executable.

The preprocessor

cpp's command line is cpp [-g] [-I dir]... input output, with -I repeatable and acceptable either as -Idir (concatenated) or -I dir (a separate argument). All of that argument-syntax recognition -- and computing the host's current date/time for __DATE__/__TIME__, below -- lives entirely in the HOSTBUILD-only main() at the bottom of cpp.c, for the same reason argv unpacking always has: it requires an array of pointers, which this project's C subset doesn't support (see "Self-hosting" below). The subset-compiled cpp_main() itself only ever receives plain, already-parsed strings -- the two filenames, a single '\n'-separated list of include directories, a "-g"/"" flag, and the date/time text -- so the actual preprocessing logic remains fully within the subset.

  • Comments: both /* block */ (including ones spanning several lines -- the embedded newlines are preserved as blank-line placeholders, not dropped, so downstream line counting stays correct even without -g) and // line comments are stripped.
  • __FILE__, __LINE__, __DATE__, __TIME__ are recognized as special cases checked before the user macro table, and unconditionally so -- a #define of the same name still succeeds (harmlessly) but is never consulted at a use site, matching how real preprocessors protect these names. __LINE__/__FILE__ reflect the nearest enclosing real file source (skipping over any macro-body text currently being rescanned) and update correctly across #include and #line. __DATE__/__TIME__ have no equivalent to fall back on inside the subset itself -- there's no clock among portio.h's four I/O primitives, and adding one felt like a bigger architectural change than "modify cpp.c" warranted -- so HOSTBUILD's main() supplies the real host date/time (via ANSI time.h, host-only, never part of the subset), and cpp_main() falls back to a fixed placeholder ("Jan 1 1970" / "00:00:00") if none is given.
  • #line NUMBER ["filename"] overrides the line number (and optionally the file name) used for diagnostics, __LINE__/__FILE__, and -g's #L markers, from the following line onward.
  • #error message aborts preprocessing (like a real compiler, not just a warning), reporting the directive's own file and line.
  • #pragma ... is recognized and silently ignored -- this project has no pragmas of its own to act on, and tolerating unknown ones is standard C preprocessor behavior. (An entirely unrecognized directive, such as a typo or the still-unimplemented #elif, is also silently skipped rather than reported as an error -- a pre-existing, documented limitation, not something #pragma support changed.)
  • #include "file" tries the current directory first, then each -I directory in order. #include <file> searches only the -I directories, never the current directory -- matching normal C convention that <...> means "look in the search path", not "look next to the including file". Both fail with a clear error if the file isn't found anywhere in that search.
  • The -I list itself is a fixed-capacity table (MAXINCDIRS), not a genuinely dynamic (heap-allocated) one -- consistent with every other table in this project (see "Self-hosting" below): "dynamic" here means "however many -I flags the invocation happens to pass, up to that fixed capacity."

One correctness bug surfaced and was fixed while adding #line/#error: read_rest_of_line() (used to finish reading almost every directive's line) consumed its own terminating newline internally without ever putting it back, so a #define/#undef/#ifdef/#if line vanished from the output entirely instead of leaving the usual blank-line placeholder behind -- silently breaking physical line-count fidelity for every one of those directives (not just at #include boundaries, where it happened to already work correctly, since do_include() never routed through this function). Fixed by putting the newline back so the main scan loop sees and processes it uniformly, exactly as it always did after #include. See "Bugs this process actually caught" below for the follow-on off-by-one this fix itself exposed in #line's own arithmetic.

Inline assembly

#asm (or its short form, #a) <rest of line> embeds one line of raw target assembler text, passed through unmodified all the way to the .asm output -- for the rare cases the C subset genuinely can't express (a specific instruction sequence, a hand-placed data word, a directive this project's own code generator never needs to emit on its own). It works at both file scope and inside a function body, and threads through every stage of the pipeline the same way -g's #L location markers already did, generalizing that existing mechanism rather than inventing a separate one:

  1. cpp.c: recognizes #asm/#a as a directive (like #define, #include, and so on -- so it respects #ifdef/#if activity like any other directive, and is silently dropped inside an inactive region), reads the rest of the line via the same read_rest_of_line() every other directive's trailing text goes through, and emits it as a #A <text> marker line -- always normalized to that short form regardless of which spelling was used, so nparse.c's lexer only needs to recognize one.
  2. nparse.c: read_location_marker() was generalized into read_hash_marker(), which now handles both #L (unchanged -- pure metadata the lexer silently absorbs, never becoming a token) and #A (which must become a real token, TK_ASMLINE, carrying the raw text in tokstr, since -- unlike #L -- there's a genuine parser-visible construct here). Recognized in both parse_statement() (as a statement inside a function body) and parse_toplevel() (at file scope), each emitting a new ASMLINE <text> NIR entry via a new emit_rawline() helper. Every other emit_* helper's fields are individually whitespace-delimited tokens ncodegen.c reads back with next_field(); ASMLINE's one field is deliberately not split that way (see NIR above), so it must be the last thing on its NIR line.
  3. ncodegen.c: do_asmline() reads the rest of its NIR line verbatim (not via next_field(), for the reason above) and writes it straight to the .asm output, tagged with a trailing ; #ASM comment.
  4. nopt.c: that comment is what line_is_protected() now also checks for, alongside its existing label/directive checks -- so the peephole pass never rewrites or drops a user's own hand-written instructions the way it would ordinary compiler-generated code. Inline assembly's entire point is "emit exactly this text"; a real compiler's inline-asm support is opaque to its optimizer for the same reason. This closes two distinct risks: dead-code elimination dropping a reachable-in-practice line the pass's own heuristics couldn't prove reachable, and the PSHA/POPA-collapsing rewrite folding a user's own PSHA/POPA pair into a MOV they didn't ask for (semantically equivalent, but not "emit exactly this" if that was the whole point of reaching for #asm in the first place). The collapsing rewrite specifically double-checks this rather than relying on it as a side effect: parse_psha_or_popa() would already reject a #ASM-tagged line on its own (the marker's ; is always picked up as an unexpected extra field), but the code checks line_has_asm_marker() explicitly too, so the protection doesn't depend on an incidental detail of how the marker happens to be formatted.

A few deliberate scope limits: the text is read through the same cooked_getch()-based reader #include/#error already use, so (like those) it's comment- and line-splice-aware rather than a byte-for-byte raw copy -- consistent with how every other directive's trailing text is read, not a special case for this one. No macro expansion is applied to it (verbatim means verbatim). And it's exactly one line -- there's no multi-line block form -- matching the scope of what was actually asked for; a block form would need its own closing-directive syntax and isn't hard to add later if it turns out to be needed.

Self-hosting

cpp.c, nparse.c, ncodegen.c, nopt.c, nirdump.c, and their shared util.c/hostio.c are all written using only the C subset DGCC itself compiles: int/char/void, single-level pointers, one-dimensional arrays, if/while/for/do-while/switch/ return/break/continue, the ternary operator, and none of struct/union/enum/unsigned/long/float/the comma operator/ multi-dimensional arrays/arrays-of-pointers. No malloc or free anywhere -- every table is a fixed-size static array. (Block scoping is a partial exception -- see below.)

Each tool defines its real entry point in the subset itself: TOOL_main(char *a1, char *a2, char *a3) (cpp_main takes a few more plain-string parameters, for -I directories and the -g/date/time values -- see "The preprocessor" above). A block guarded by #ifdef HOSTBUILD at the bottom of each file supplies a real main(int argc, char *argv[]) that unpacks argv (and, for cpp, does the actual command-line-flag parsing) and calls it -- that unpacking needs an array of pointers, which is outside the subset, so it is host-only scaffolding, never fed through DGCC itself.

Interim block scoping

C lets the same name be redeclared in separate, non-overlapping blocks (the then and else arms of an if, two different loop bodies, and so on). This project's parser doesn't implement a real per-block scope stack -- every function still has exactly one flat frame, with every local (at any nesting depth) assigned a fixed word offset in it, which is what makes offsets computable in a single pass with nothing more than the symbol table this project already has. A declaration is now allowed to appear at the start of any { } block, not just a function's outer body, but it is simply hoisted straight into that one flat frame and shares its one namespace -- so the same name declared in two different blocks of the same function, even ones that never execute together, is reported as "redeclared local variable" rather than silently allowed. This is an intentional, accepted short-term limitation rather than a real scope stack; when it fires on otherwise legitimate code, the fix is to rename one of the colliding locals (see "Self-compiling dgcc's own sources" below for real examples this surfaced and how each was resolved).

The capture mechanism (LOCALS, for-loop increments, switch, and call arguments)

Four things need "capture this text now, emit it somewhere else later" instead of streaming straight to output:

  1. A function's LOCALS n line has to appear before its body in the NIR stream, but n isn't finally known until the entire body -- including any nested block's hoisted locals -- has been parsed (see above).
  2. A for loop's increment clause is written in the source before the loop body but has to be emitted after it (at the continue target).
  3. A switch's dispatch chain (one JEQC per case, see NIR below) has to appear before its body, but which cases exist and which labels they target isn't known until the whole body has been scanned for case/default labels.
  4. A function call's arguments must be pushed in reverse declaration order (see "Calling a function" above), but each argument still has to be parsed, left to right, in ordinary source order -- a recursive-descent parser has no other way to read the source text.

All four can nest arbitrarily -- a switch inside a for loop inside a function, a call passed as one of another call's own arguments, and so on -- and getting that nesting right took two attempts. The first tried one shared, monotonically growing buffer with a single tail, gated by a capture-depth counter. That is broken: an inner capture's bytes end up sitting, at their original parse-time position, inside whatever range an outer capture will later replay verbatim, so anything captured while nested ends up emitted twice -- once "in place" as an accidental part of the outer capture's own text, and once for real at the inner capture's own, later, explicit replay point. This was not theoretical -- it reliably duplicated a for loop's increment clause the first time a block-scoped local appeared inside that same loop's body, breaking typedef_loops.c's existing test.

The fix partitions the pool into MAXCAPTLEVELS independent slices, one per nesting depth, each its own bump allocator: ir_putc() always writes into the slice for the current depth only, so an outer capture's own slice simply stops growing while an inner one is active, and resumes exactly where it left off once the inner one ends -- instead of the two being interleaved into one stream that can't later tell them apart. A slice is only reset when a new function begins (depth 0 -> 1), which is safe because functions can't nest, so nothing at any depth can still be waiting to be replayed across that boundary; this bounds every slice's real usage to "the most this level ever needs within one function" rather than the sum across the whole file. Each slice is CAPTSLICE_SIZE (32768) characters -- a real, documented limit for a pathological function whose body is essentially one enormous switch, though nothing this project has fed through it has come close.

Self-compiling dgcc's own sources

tests/run_tests.py builds cpp.c, nparse.c, and nirdump.c, and then, as its final step, actually feeds each one's own source through the complete cpp -> nparse -> ncodegen -> nopt pipeline, checking that all four stages exit cleanly. As of this writing, all three do -- including after nparse.c gained the several-hundred-line switch/do-while implementation described above, which is itself now part of what gets self-compiled on every test run. This is real evidence of self-hosting readiness, but it has a specific, honest scope: it proves these files' syntax is inside DGCC's subset and that ncodegen can turn the result into assembler text without error. It does not prove the resulting assembly, linked together with rtlib.asm and whatever hand-written I/O routines a real deployment would need, would actually run correctly as a working compiler on real hardware -- there is no real linker, and no real or simulated Nova/Eclipse capable of running an entire multi-program toolchain's worth of code, in this environment.

nirdump.c is worth calling out specifically here: written from scratch well after the self-hosting discipline below was already established (block scoping, the capture mechanism, the whole rest of this section), it compiled cleanly through the real pipeline on the first attempt -- no subset violations, no name-collision-under-flat- frame-hoisting surprises, nothing. That's a reasonable proxy for how well-understood and how learnable this project's own accumulated constraints have become, compared to cpp.c/nparse.c, which needed several rounds of fixes each (below) because most of those constraints were being discovered while writing them, not already known.

Getting cpp.c/nparse.c there required the language features added for this purpose (the ternary operator, the interim block-scoping approach, and switch/do-while) and, each time, actually running the real files through the real pipeline turned up further gaps -- all now fixed:

  • Two pre-existing subset violations in the sources themselves, unrelated to any requested feature: nparse.c's own lexer used a switch statement back when the subset didn't support one yet (naturally converted back once it did -- see "Using the new statements in dgcc's own sources" below), and cpp.c declared char *src_text[MAXSRC] -- an array of pointers -- for macro-body rescanning (converted to an integer offset into mpool, the same "pool + offset" pattern used everywhere else in that file).
  • Array sizes written as a constant expression rather than a bare literal (cpp.c has char foundname[MAXFNAME + MAXFNAME]) weren't supported; parse_const_int() was extended from a single optionally- negated literal into a real constant-expression evaluator (parens, + - * /), used for both local and global/static array sizes.
  • MAXSTRLIT (the table sizing how many distinct string literals a single compiland may contain) was 128 -- far too small for a compiler's own source, which routinely has several hundred (one for nearly every diagnostic message). Raised to 512, matching the scale of MAXSYM.
  • The capture-nesting duplication bug described above.
  • A follow-on ordering bug in a function-body local's own initializer code (see "Bugs this process actually caught" below).
  • Repeated instances, spread across both files and across two separate rounds of adding new syntax, of the exact same-name-in-separate- branches conflict the interim block-scoping design predicts (n/v in different branches of next_token's lexer, lv repeated across parse_unary's many single-token branches, l2/t2 repeated across cont_mul's three operators and parse_assignment's two assignment forms, lelse/lend/ltop/ltest/lcont repeated across parse_statement's if/while/for handling, and then a fresh batch -- cap_start/cap_end/i/lv/t -- the moment switch's own handler was added to that same giant function). Each was resolved the way the interim design intends: either hoisting a genuinely-shared temporary to the top of its function once, or renaming one of two otherwise-independent locals that happened to collide (most of the switch-related ones, prefixed sw_ for clarity). No case needed an actual scope stack.

Using the new statements in dgcc's own sources

Once switch and do-while existed, the sources were audited for places where using them would reduce generated code size or genuinely simplify the source, rather than converting everything indiscriminately:

  • nparse.c's next_token() punctuation dispatch ((, ), {, }, and so on) and its decode_escape() are both back to real switch statements -- next_token()'s had actually been one, before the subset gained switch support and it had to become an if-chain to stay compilable; restoring it is simultaneously a simplification and a reversion to the original design.
  • cpp.c's skip_hspace() and its //-comment scanner both became do-while loops: each unconditionally reads one character and then loops on a single, simple trailing condition -- exactly the shape do-while exists for.
  • Most of the candidate sites don't qualify, for two recurring reasons. switch requires a constant-integer controlling expression, so the many dispatch chains that key off string equality (ncodegen.c's whole opcode dispatcher, cpp.c's directive dispatcher, nopt.c's keyword checks) are correctly out of reach regardless of size or clarity -- real C's switch simply cannot express them. And most of this codebase's for (;;) loops have several independent early-exit conditions checked at the top of each iteration (typically "hit EOF, or hit a specific terminator character, or both"), which doesn't reduce to do-while's single trailing test without either dropping an exit path or introducing an extra tracking flag that would make the result less clear, not more -- so those were deliberately left as for (;;) with explicit breaks.

Calling convention

Correction applied after initial delivery, then further corrected against the authoritative source: the register roles below were originally assigned the other way around (AC2 as frame pointer, AC3 as free scratch); that was wrong; SAVE places the new frame pointer in AC3. A second, larger correction followed once the actual Eclipse SAVE/RTN/calling-convention dictionary entries (Data General's ECLIPSE Assembly Programming and S/140 Programmer's Reference, Rev. 04) were available: the real protocol is both simpler and more capable than the placeholder this project had been using in the interim (a bare PSHA 3/JSR/POPA 3 standing in for "some SAVE/RTN-like thing"), and this section now describes that real protocol precisely rather than an internally-consistent-but-invented approximation of it.

Register Role
AC0 primary expression accumulator; function return value
AC1 secondary / right-hand-operand temp
AC2 general-purpose scratch; briefly holds the marked stack position during a call's own argument-pushing window (see below), free the rest of the time
AC3 frame pointer (FP) while inside a SAVE'd function body

The real return block, and why nothing needs manual protecting anymore

The real Eclipse frame pointer is a genuinely separate hardware register from AC3 (conventionally written .FP, alongside .SP for the stack pointer) -- JSR/EJSR only ever set AC3 (to the return address), never .FP. It's SAVE, run by the callee immediately on entry, that does everything: it pushes a fixed 5-word return block --

Word Contents Offset from FP
1 AC0 (caller's value) FP-4
2 AC1 (caller's value) FP-3
3 AC2 (caller's value) FP-2
4 the caller's own (pre-call) FP FP-1
5 carry + the return address (from AC3) FP-0

then sets both .FP and AC3 to point at word 5, then reserves SAVE's own immediate operand's worth of words above that for locals (so locals live at FP+1 .. FP+k, not FP+0 .. FP+k-1 -- FP+0 is the return-address word itself). RTN reverses all of it: it locates the return block via FP (not via wherever SP currently happens to be -- this is what lets it discard any leftover temporaries the callee left on its own stack without the callee having to balance them itself), restores AC0-AC2 from their saved slots, and sets FP and AC3 both to the saved old FP before jumping to the saved return address.

The consequence that matters most for code generation: AC0, AC1, AC2, and the caller's FP are all preserved across any call automatically, by the hardware protocol itself, with zero save/restore code needed on either side of the call. This project's earlier, invented approximation didn't have this property (it needed an explicit PSHA 3/POPA 3 around every call site to protect FP from JSR clobbering it) -- that wrapper (ncodegen.c's asm_jsr()) still exists, but its scope shrank to exactly where it's still needed: calls into rtlib.asm's helpers (MULLIB, DIVLIB, BLOAD, and so on), which are plain leaf routines reached via a bare JSR and returning via JMP 0,3 -- no SAVE/RTN, so nothing else protects AC3 for those specific calls. A call to a real, SAVE/RTN-disciplined function (ncodegen.c's do_call()) needs none of that.

Returning a value works by deliberately exploiting the restore step: RET (a return expr;) writes the value into the return block's own AC0 slot (STA 0,-4,3) before RTN runs, so RTN's "restore AC0 from its saved slot" delivers that chosen value to the caller instead of the caller's own pre-call AC0. RETV (a bare return;, and the unconditional one every function body ends with regardless of how it's reached) skips that write, so the caller just gets its own original AC0 back -- harmless, since a void call's result is never consumed.

Calling a function: MARKSP / EJSR / STA .SP

The real convention's call-site sequence is leaner than the wrapper it replaced:

LDA 2,.SP        ; MARKSP: mark the pre-argument stack position in AC2
<push arguments, in REVERSE declaration order -- see below>
EJSR label       ; sets AC3 to the return address, jumps -- touches
                 ; nothing else; SAVE/RTN (above) does all the real work
STA 2,.SP        ; reset SP from AC2, discarding every pushed argument
                 ; word in one instruction, regardless of how many
                 ; there were -- the caller never needs to count them
PSHA 0           ; push the return value for the enclosing expression

.SP (like .FP) is a real, reserved symbolic name for a fixed hardware location, not something this project's assembler output invents.

Arguments are pushed in reverse declaration order, and each parameter has a fixed offset from FP that does not depend on how many total parameters the function has -- the real, standard ARGn symbols this convention uses:

ARG0 = FAC0 - 1 = FP-5   ; a reserved placeholder word every call pushes
                         ; last (right before EJSR), even though this
                         ; project never reads or writes it -- without
                         ; it, every real argument below would be off
                         ; by one
ARG1 = ARG0 - 1 = FP-6   ; the first declared parameter
ARG2 = ARG1 - 1 = FP-7   ; the second declared parameter
ARGk = FP-(k+5)          ; the k-th declared parameter, 1-indexed

Concretely, for f(a, b), the caller pushes b, then a, then the ARG0 placeholder, then calls -- landing a (declared first) at FP-6 and b (declared second) at FP-7, regardless of how many total parameters f has. Since a parameter's offset no longer depends on the total count, nparse.c assigns it directly, in one pass, the moment each parameter is parsed -- no patch-up pass once the count is known, unlike the position-dependent formula this replaced.

Because arguments must be pushed in the reverse of the order they're written in the source, but each argument's own expression still needs to be parsed in ordinary left-to-right order (recursive descent has no other way to read the source), nparse.c parses each argument normally but captures its emitted code (via the same capture mechanism used for LOCALS and switch, see below) instead of streaming it out immediately, then replays all of them afterward in reverse. Since capturing text doesn't execute it, this means actual evaluation order (side effects included) ends up right-to-left, matching the replay order rather than the parse order -- a documented, standards-legal choice (C leaves argument evaluation order unspecified, and right-to-left is what naturally falls out of pushing arguments right-to-left in the first place, a choice other real compilers have also made).

Other details that carry over unchanged:

  • No hardware immediate-load and only ±127-word PC-relative addressing exist, so every constant and every global address is fetched from a small per-function literal pool placed after the function's code (flushed at ENDFUNC, since RTN guarantees control can't fall into it, and periodically mid-function via a JMP-around once a threshold of pool-referencing instructions is reached, so long functions never exceed the addressing range). Pool labels use a reserved __P<n> namespace (a counter that never resets) that can't collide with a user identifier, which nparse always emits with a single leading underscore.
  • Arbitrary runtime addresses can only be dereferenced through a fixed page-zero indirection cell, PTRTMP (declared once in ncodegen's own preamble): the address is stashed there, then LDA r,@PTRTMP / STA r,@PTRTMP does the real access.

    AC2 was a natural-seeming candidate to replace PTRTMP for this (register-indirect addressing, LDA r,@0,2, needs no memory access to fetch the pointer at all, since it's already sitting in a register) -- both the real convention's own documentation and this project's own testing found a genuine reason this doesn't work unconditionally, though. A call site's own MARKSP/STA 2,.SP window relies on AC2 holding the marked pre-argument stack position for its entire duration, but that duration spans evaluating every one of that call's own arguments -- and a C argument can be an arbitrary expression, including one that itself needs an indirect load or store (reading a variable's value, say). Routing that inner access through AC2 clobbers the outer call's own marked value before its cleanup ever runs, silently corrupting the stack. This was caught the direct way: a genuine test failure (a stack underflow a few instructions after a call whose argument was itself a plain variable read) the moment the change was tried, not something reasoned out in advance. PTRTMP therefore stays the indirection mechanism for LOADW/STOREW/increment-decrement for now; a version of this optimization that's provably safe (e.g. only applying it to code that's provably outside any call's own argument- evaluation window) remains a real, open opportunity, deliberately left for a later, more careful pass rather than attempted piecemeal.

  • The default numeral radix in the emitted assembler text is octal (real DG assembler convention); ncodegen renders every numeric literal as a fixed 6-digit octal word via write_octal, so negative values are spelled as their two's-complement bit pattern (e.g. -4 is 177774) rather than relying on the target assembler correctly parsing a leading minus sign in octal mode -- except for the small set of AC-relative displacement fields (STA 0,FAC0,3 and the like -- FAC0, the real DG symbolic name for the return block's saved-AC0 slot, is itself defined once via a plain FAC0=-4 equate in the preamble, alongside PTRTMP, rather than the offset appearing as a bare literal) and .SP/register operands, which are plain signed decimal/symbolic text instead, since those aren't DATAW-style literal values at all.

NIR (the intermediate representation)

One instruction per line, fields space-separated, so both nparse and ncodegen can stream it a line at a time.

  • File/globals: PUBLIC name, EXTERN name (see below), DATAZ label size (zeroed block), DATAW label size v1..vN (word array), DATAB label nchars pv1..pvK (packed-byte data, K = ceil(nchars/2) words, high byte first), FUNC label nparams, LOCALS n (a separate line emitted right after a function's local declarations are parsed, carrying the final local-word total once the whole body -- including any nested block's hoisted locals, see "Self-hosting" below -- has been scanned), ... ENDFUNC.
  • Body opcodes: PUSHC v, PUSHLADDR off, PUSHGADDR label, BPTR0, BPTR1 (byte-pointer encoding fixups, see below), LOADW, LOADB, STOREW, STOREB (stack order [...,address,value], value on top; both push the stored value back, for chained/compound assignment), DUP, POP, ADD SUB MUL DIV MOD AND OR XOR SHL SHR, NEG COM LOGNOT, EQ NE LT LE GT GE, JEQC value label (see below), the eight POSTINCW/POSTDECW/POSTINCB/POSTDECB/PREINCW/PREDECW/PREINCB/PREDECB ops, MARKSP (see "Calling a function" above -- emitted right before a call's argument list is parsed; translates straight to LDA 2,.SP), CALL label (no argument count on this line -- see below, the real calling convention's STA 2,.SP cleanup doesn't need one), `ASMLINE ` (see "Inline assembly" above -- one line of raw, user-written target assembler text, passed through unmodified; unlike every other opcode here, its one field is not whitespace-split and must be the last thing on the line), `LABEL name`, `JMP name`, `JZ name`, `JNZ name`, `RET`, `RETV` (an unconditional `RETV` is emitted at the end of every function body no matter what, even right after an explicit `return` -- a deliberate, tiny dead-code cost in exchange for never needing real flow analysis to decide whether a function might fall off its end).
  • JEQC value label: pops the top of the stack and jumps to label if it equals the compile-time constant value, pushing nothing back either way. This is switch's dispatch primitive -- one JEQC per case -- and exists specifically to be cheaper than the equivalent PUSHC value EQ JNZ label would be: EQ (like every comparison) goes through emit_bool_from_skip's general "materialize a 0/1 word" machinery (two literal-pool loads, two jumps, two labels), where JEQC is a direct compare-and-branch needing roughly half the real instructions per case.
  • EXTERN name: emitted by nparse for a top-level declaration that explicitly uses the C extern keyword -- both extern int f(int); (a function prototype with no body) and extern int x; (a variable, which correspondingly gets no DATAZ and no local storage reserved for it). ncodegen translates this straight to a .EXTN name assembler directive, giving the target assembler/linker an unambiguous external-linkage declaration instead of relying on it to correctly infer that a referenced-but-undefined label must be external. A bare prototype without the extern keyword (just int f(int);) still emits nothing, matching plain C semantics where such a declaration doesn't by itself assert where the definition lives; only an explicit extern triggers this.

NIR tree reconstruction (nirdump.c / tools/nir_dump.py)

NIR has no tree structure on disk -- it's a flat stack-machine instruction stream. nirdump.c (and its Python prototype, tools/nir_dump.py, ported without any behavior change) reconstructs the implicit expression trees an AST-dump-style view needs by simulating the operand stack while reading the file once: every opcode that pops N values and pushes one becomes a node whose children are the N nodes it popped. Control flow (LABEL/JMP/JZ/JNZ/ JEQC) is deliberately not reconstructed back into if/while/ switch/do-while shapes -- that's a real control-flow-structuring problem (matching jump patterns back to source constructs), not just a stack simulation, and an unreliable attempt at it would be worse than not attempting it. Those opcodes print as flat lines in source order; a conditional jump's own tested value still gets a full tree, since that part is just an ordinary pop. ASMLINE (user-written inline assembly, see "Inline assembly" above) is printed the same flat way, as ASM: <text>, rather than being folded into any surrounding expression -- it's opaque, arbitrary text with no defined stack effect this tool could simulate even in principle, and both implementations take care to read its one field byte-for-byte rather than whitespace-splitting and rejoining it, since unlike every other opcode's fields, meaningful content (a user's own deliberate spacing) could otherwise be lost.

The interesting part of the C port is the tree representation itself, since the subset has neither struct nor arrays of pointers. Each node lives at a fixed index into a small set of parallel arrays (an arena, reset once per function, since no tree needs to survive past the statement or function it belongs to):

  • node_label_off[i]: an offset into a pool holding node i's already- assembled display text ("PUSHC 5", "ADD", "CALL _add", ...) -- the same pool-plus-offset pattern this project already uses everywhere else it needs variable-length text (nparse.c's namepool, cpp.c's mpool, and so on).
  • node_child[i * MAXCHILDREN + k]: node i's k-th child's own index. A flat 1-D array standing in for what would naturally be a 2-D node_child[node][slot] array, since multi-dimensional arrays aren't in the subset either -- indexed manually the same way ncodegen.c's captpool addresses its own per-level slices.
  • node_nchildren[i]: how many of node i's up-to-MAXCHILDREN slots are actually populated (MAXCHILDREN is sized for CALL, the only variable-arity opcode, which needs enough for MAXPARAMFN arguments plus the phantom ARG0 placeholder -- see "Calling convention" above).

A CALL's own children need special handling to line up with MARKSP: when a MARKSP is seen, a sentinel (not a real node) is pushed onto the simulated value stack; CALL then pops real nodes back to that sentinel to collect exactly the arguments this call pushed, discards the sentinel, and -- since real DGCC output always pushes a phantom ARG0 = PUSHC 0 placeholder last (see "Calling convention") -- relabels that specific child rather than showing it as an indistinguishable real argument.

One real, subtle bug came up porting this from the already-working Python version: handle_line()'s own opcode-classification logic receives the current opcode as a parameter, but several of its branches also call next_field() (to read that opcode's own operand -- e.g. PUSHLADDR's offset, or JZ's target label) before finishing with it. Since the caller passes the shared field buffer directly rather than a copy, and C passes that as a pointer, the parameter and the buffer next_field() overwrites are the same memory -- so an opcode like PUSHLADDR ended up displayed as "-6 -6" (the offset, twice) instead of "PUSHLADDR -6", because by the time the display text was assembled, the parameter had already been silently overwritten by the very field it was reading. Fixed by copying the opcode into a local buffer at the top of the function, before anything else runs. Caught immediately by a byte-for-byte comparison against the (unaffected, since it works in values rather than shared mutable buffers) Python version's output on the same real input -- exactly the kind of cross-check a same-tool, two-language port makes cheap and worth doing even when a single language's own test suite already passes.

Byte-pointer convention

Nova/Eclipse memory is word-addressed, but C needs individually addressable bytes. This project's scheme:

a byte pointer is word_address * 2 + side_bit, where side_bit = 0 means the high/left byte of the word and 1 means the low/right byte.

  • A scalar char occupies a full word (0-255 by convention, promoted to int the same way). char arrays and string literals are packed two bytes per word, high byte first.
  • int and pointer arrays/scalars are always one word each, so no pointer-arithmetic scaling is ever needed for them.
  • Taking the address of something applies a fixup: BPTR0 (multiply by 2) for a char array/string decaying to a pointer, BPTR1 (multiply by 2, add 1) for a scalar char variable's address. A pointer variable itself (anything with one level of indirection) gets no fixup regardless of what it points to -- its own storage is a plain word, and by the time you dereference it, its value already is a correctly-encoded byte pointer.
  • is_byte_type(basetype, ptrlevel) is simply basetype == CHAR && ptrlevel == 0; that one predicate decides LOADB/STOREB (through the runtime helpers BLOAD/BSTOR) versus plain LOADW/STOREW everywhere, uniformly.
  • A real, historical DG byte-pointer "PICK" routine turned up during research and uses exactly the same word_addr*2 + side-bit idea, independently corroborating that this is authentic period practice -- though the specific high/low polarity (BPTR0=high, BPTR1=low) is this project's own and is applied consistently, not copied from that example.

BLOAD / BSTOR

BLOAD (AC0 = byte pointer in, AC0 = byte value out) and BSTOR (AC0 = byte pointer, AC1 = value in, AC0 = stored value out, matching STOREB's "push the stored value back" convention) live in rtlib.asm and do the actual word fetch/mask/merge. BLOAD never touches AC3 and has no save/restore of it at all; BSTOR does need AC3 as scratch (to hold the value across the address arithmetic) and saves/restores it like every other non-leaf routine here.

Runtime helper library (rtlib.asm)

The hardware has no multiply, divide, modulo, variable-count shift, or XOR/OR instruction, so these are runtime subroutines, called via a plain JSR name and returning via JMP 0,3 (jump to the address JSR left in AC3) rather than through SAVE/RTN -- they're leaves (or, for MODLIB, a near-leaf that calls two other leaves), not user functions, and don't need a stack frame.

Convention: every routine that needs AC3 as scratch saves the incoming return address to a shared cell (HRET) first and restores it right before returning, so from the caller's point of view AC3 survives a call to any of these exactly as if it hadn't been touched. That is what lets MODLIB safely nest calls to DIVLIB and MULLIB using its own separate save cells (MODRET/MSAVEA/MSAVEB) without disturbing DIVLIB's or MULLIB's own (shared) use of HRET -- DIVLIB and MULLIB themselves never call anything else, so sharing one cell between them is safe.

  • MULLIB (AC0=a, AC1=b → AC0=a*b, 16-bit truncated): a standard shift-and-add multiply that is self-terminating without a separate iteration counter -- the "multiplicand so far" is itself a 16-bit register being doubled each step, so after at most 16 doublings it has been shifted out to zero regardless of its starting value, and once it is zero every further term would add zero anyway (truncated multiplication is exact mod 2^16), so stopping there costs nothing.
  • DIVLIB (AC0=a, AC1=b → AC0=a/b, truncating toward zero; division by zero returns 0 rather than trapping) and MODLIB (AC0=a, AC1=b → AC0=a%b): DIVLIB records each operand's sign, works on absolute values with a textbook unsigned restoring binary division (shift the next dividend bit into the remainder, subtract if it fits, restore if it didn't -- 16 iterations, counted down via ISZ), then negates the quotient if the signs differed. This only uses primitives already validated elsewhere in the project (MOVZL/MOVZR/ MOVZL# for shifting and non-destructive sign testing, plain SUB/ADD/NEG/INC, ISZ for the loop count) rather than any more exotic modifier combination, precisely so its correctness doesn't depend on an under-documented corner of the ISA. MODLIB doesn't re-derive remainder bookkeeping at all -- it calls DIVLIB, then MULLIB, and computes a - (a/b)*b, the exact identity that holds for truncating division, which also means the remainder automatically takes the sign of the dividend with no extra logic.

    Known edge case: INT_MIN / -1 (-32768 / -1) mathematically wants 32768, which overflows the signed 16-bit range; this implementation wraps back to -32768, the same two's-complement wraparound this project already accepts for multiplication overflow, rather than trapping. Confirmed via the simulator to be the only mismatch across hundreds of randomized and edge-case division trials.

  • SHLLIB / SHRLIB (AC0=value, AC1=count → AC0=shifted value): both are plain logical (zero-filling) shifts, including SHRLIB on negative values -- a defensible simplification, since C leaves signed right-shift of a negative value implementation-defined anyway.
  • XORLIB (AC0=a, AC1=b → AC0=a^b): there's no hardware XOR or OR, so both are built via De Morgan's laws (a|b is synthesized inline by ncodegen itself as COM+AND+COM; a^b = (a & ~b) | (~a & b) needs four live values at once -- a, b, and their complements -- which, after the AC2/AC3 correction above freed AC2 for general use, now just fit directly in the four accumulators with no need to stash anything on the hardware stack.

Language subset

Supported: int, char, void, pointers (one level only -- no T**), one-dimensional arrays (including as function parameters, which decay to a pointer), typedef (single base type + at most one level of pointer), static/extern storage-class keywords, forward declarations, if/else, while, for, do-while, switch/case/default (constant-integer case labels only, real fall-through, break and default both optional -- see "The capture mechanism" above for how its dispatch chain gets emitted before a body that's parsed after it, and the NIR section above for JEQC, its dispatch primitive), break, continue (inside a switch, continue skips past it to the nearest enclosing loop -- a switch provides a break target but not a continue one, exactly like real C), return, all the usual operators including compound assignment, prefix/postfix ++/--, and the ternary operator ?: (right-associative chaining and correct interaction with surrounding precedence both work; the result's type is simply the true-branch's, with no common-type unification between the two arms -- a documented simplification, harmless in practice since every use in this project's own sources has matching-type branches), casts among int/char/pointer-to-int/pointer-to-char, and sizeof(type) on a parenthesized type name (a compile-time constant, in words: 1 for any scalar or pointer, N for an int[N], ceil(N/2) for a char[N]). Global/static array initializers support both explicit lists (int a[] = {1,2,3}, with the dimension inferred from the initializer count when omitted) and the char s[] = "text" string-literal sugar. Array sizes (local or global) may be a constant expression (+ - * / and parens over literals), not just a bare literal. #asm/#a <rest of line> (a preprocessor directive, not a statement in the grammar proper, though it can appear anywhere a statement can, as well as at file scope) embeds one line of raw target assembler text verbatim -- see "Inline assembly" above.

A declaration may now appear at the start of any { } block, not just a function's outer body -- but see "Interim block scoping" above: this is hoisting into one flat per-function frame, not a real scope stack, so the same name can't be reused across sibling blocks of the same function the way real C scoping would allow. switch's own body is a partial exception to even that: case/default are only recognized at its own top level, not inside a further-nested block within it (no Duff's-device-style buried case labels), though ordinary declarations at that same top level work the same as any other block's.

Not supported, deliberately: struct/union/enum, unsigned/ long/float/double, the comma operator, multi-dimensional arrays, arrays of pointers, double indirection, function pointers, and local array initializers (caught and reported as an error rather than silently miscompiled).

Other documented simplifications

  • Casts are pure type-system relabeling. Casting between, say, char * and int * does not convert between the byte-pointer and plain-word address encodings -- the programmer is responsible for that distinction. int↔char casts are free (both are register-word sized already).
  • static on a local variable is accepted but has no special effect -- it's treated as an ordinary local, not given distinct persistent storage. This is a real, known gap, not an oversight.
  • >> is always a logical (zero-fill) shift, even on negative values, matching SHRLIB above.
  • No narrowing-store truncation is enforced beyond what the byte helpers naturally do. Assigning an out-of-0-255-range value to a char isn't defensively re-masked at the call site (BSTOR does mask correctly for values that came from a genuine byte load, but a value computed some other way and stored through a char pointer is trusted to already be in range).
  • (x) = 5; (assigning through a parenthesized lvalue) doesn't work -- a known, narrow parser gap where the expression parser's safety-net value-resolution runs before the paren-handling gets a chance to preserve the lvalue-ness. Assigning to the bare, unparenthesized x works fine.
  • Error recovery is intentionally weak. A single syntax error can cascade into several follow-on errors reported from the same point, since the parser doesn't skip forward to a synchronization point on a mismatch. Acceptable for a hobbyist/self-hosting compiler; not production-grade diagnostics.
  • JSR label addressing range. Calls are emitted as a direct JSR label with no literal-pool indirection, unlike constants and data addresses. If the real target assembler's JSR is subject to the same short PC-relative reach as LDA/STA, a call to a very distant label in a very large program could be out of range. This wasn't an issue for anything this project could test (nothing here approaches that scale), but it's a real, undemonstrated limitation for large real-world programs.

No standard library

There is no string.h, stdio.h, or ctype.h -- util.c/util.h hand-roll the handful of primitives the compiler itself needs (str_len, str_eq, str_copy, a flat-pool string interner, decimal/octal integer-to-text, character classification, etc). All I/O, for both the compiler and any program it compiles, goes through four external primitives declared in portio.h: open a file, close a file, read a character, write a character. hostio.c is a host-only shim implementing those four on top of ANSI stdio.h so the tools can be built and tested with gcc; it stands in for hand-written Nova I/O routines that would need to be written in real DG assembler to run on actual hardware, and were not (see the disclosure at the top of this document).

Bugs this process actually caught

Worth recording, since it's the concrete evidence for why the simulator was worth building rather than trusting hand-traced assembly:

  1. EQ/NE produced the inverted boolean. The shared emit_bool_from_skip helper's two arguments were swapped relative to its actual (not its documented) behavior. It happened to work for LT/LE/GT/GE/! by coincidence of which polarity of skip condition each one uses, but not for ==/!=. Found by simulating EQ/NE with concrete positive/negative operand pairs and comparing against Python's own ==.
  2. Numeric literals were emitted in decimal, contradicting this project's own stated convention that the target assembler defaults to octal. Fixed by routing every numeric-literal emission through the existing write_octal utility instead of a decimal formatter.
  3. nopt's tokenizer didn't skip commas as a leading delimiter, only as a trailing one -- so any field read immediately after a comma came back empty. This silently broke both the skip-suffix detector (making the dead-code eliminator delete reachable code that followed a skip-guarded, not truly unconditional, JMP) and, via a second, related bug where a probe call overwrote the shared token buffer before it was read, the PSHA/POPA-collapsing peephole. Found by comparing the optimizer's own output against a minimal hand-built test case and a full simulator equivalence check between optimized and unoptimized output of every test program.
  4. cpp -g never emitted a location marker for the initial source file. emit_location_marker() was only ever called from do_include() (entering a #include) and from the "resume a previous source" branch of raw_getch() -- never for the outermost file, which never "resumes" anything. Fixed by calling it once, explicitly, right after the initial source is set up in cpp_main().
  5. The AC2/AC3 correction (moving the frame pointer from AC2 to AC3, matching the real Eclipse SAVE) surfaced a chain of consequences, each caught by re-running the full simulator-backed regression suite after the change and treating any failure as real until proven otherwise:
    • do_incdec_word's decrement path and the original do_incdec_byte were both using AC3 as ordinary scratch (to hold a loaded constant, or to carry a byte address across two calls). Under the new convention that would silently corrupt a function's own frame pointer partway through its body. Fixed by moving that scratch use to AC2 instead, which needs no special protection since JSR never touches it.
    • The first attempt at updating the parameter-offset formula changed it from FP+(i-N-3) to FP+(i-N-2), reasoning that SAVE now pushes only one word instead of two. That's true in isolation, but do_call()'s new PSHA 3 (protecting the caller's FP right before the JSR) adds back exactly the word that was removed, so the original -3 formula is actually still correct -- just for a different reason than before. Caught by simulator trace showing a function reading its own parameters from the wrong stack slot.
    • Even after that, comparison and logical-not tests kept failing. The actual cause was in the test harness, not the compiler: its direct-invocation helpers called a function's entry point directly for convenience, bypassing do_call() entirely, and therefore never pushed the caller-FP padding word that a real call site would have. Fixed by having the harness push that same padding word itself. This one was a reminder that a failing test doesn't automatically mean the code under test is wrong.

nparse.c also gained a real EXTERN/.EXTN opcode in this same round (previously extern was a pure no-op storage-class hint), which incidentally fixed a latent bug: an extern int x; variable declaration was reserving local storage for x via DATAZ in every file that merely declared it, rather than only in whichever file actually defines it.

  1. read_rest_of_line() silently ate the newline that ends almost every directive's line, rather than leaving it for the main scan loop the way #include correctly always did. This one predates the round of work that found it -- it had been silently breaking physical line-count fidelity for #define/#undef/#ifdef/#if since those directives existed -- and only surfaced because #line and #error, added in this round, made the resulting off-by-one errors immediately visible and checkable. Fixed by putting the newline back instead of consuming it.
  2. That fix promptly exposed a second, self-inflicted off-by-one in #line's own line-number arithmetic. The first attempt set src_line = val - 1, reasoning that the main loop's next read of the trailing newline would supply one more increment to reach val. That reasoning no longer held once the newline was merely put back rather than freshly re-read: whichever code path (the digit-parsing loop's own one-character lookahead when nothing follows the line number, or read_rest_of_line()'s own scan when a filename does) first reaches that newline already performs the one genuine, counted read: the newline is just replayed (uncounted) after that. So the correct assignment is src_line = val, with no adjustment -- and #error, which prints the current line for its diagnostic, had to be changed to capture that line number before calling read_rest_of_line() rather than after, for the same reason. Both caught by simply computing what #line 100 / #line 50 "f.c" / #error should report and checking the actual output against it.
  3. A capture nested inside another still-active capture got emitted twice. The first attempt at generalizing the for-loop/function-body capture mechanism to also support switch used one shared, monotonically-growing buffer with a single tail, gated only by a depth counter. That cannot work: an inner capture's bytes land, at their original parse-time position, inside the exact range an outer capture will later replay verbatim, so they come out twice -- once "in place" and once for real at the inner capture's own explicit replay point. Concretely, this reliably duplicated a for loop's increment clause the moment a block-scoped local also appeared inside that loop's body, immediately breaking an existing test. Fixed by giving each nesting level its own independent slice of the pool (see "The capture mechanism" above), so an outer capture's slice simply stops growing while an inner one is active instead of the two being interleaved.
  4. The very next test run after that fix found a second, different bug in the same area: a function-level local declared with an initializer that calls another function (nparse.c's own one-line precedence-wrapper functions are exactly this shape, e.g. static int parse_mul(int *lv) { int t = parse_unary(lv); return cont_mul(t, lv); }) had its initializer's own emitted code land before LOCALS in the NIR stream instead of after it, because that code is produced while parsing the function's top-level declarations, which used to happen before body-capture began. Fixed by starting the capture before parsing those declarations, not after -- an initializer's code is just as much "the body" as anything a later statement emits.
  5. Adding switch's own handler to parse_statement() -- already a single, very large function housing every statement kind -- created a fresh batch of the same same-name-in-sibling-branches conflicts the interim block-scoping design predicts (cap_start/cap_end/ i/lv/t, this time colliding with the for loop's and the continue handler's own locals of the same names). Resolved by prefixing every one of switch's own locals with sw_, the same fix pattern as everywhere else this has come up.
  6. The simulator's PSHA/POPA used a "store, then increment" push timing that doesn't match the real Eclipse convention ("increments the stack pointer... then stores the word"), and had for the whole project up to this point -- harmlessly, since push and pop were always proper duals of each other and nothing had ever needed an absolute address relative to where a pushed value landed. The real calling convention's FP-relative parameter addressing is exactly that, though, and got the arithmetic wrong the moment it was tried: every parameter offset came out one word off. Fixed by correcting PSHA/POPA to the real "increment first" timing -- safe everywhere else in the codebase because every other use is a matched, balanced push/pop pair, which behaves identically either way.
  7. The first attempt at the real argument-passing convention was missing a real, actually-pushed word. The reference material's ARGn symbols (ARG0 = FAC0-1, ARG1 = ARG0-1, ...) looked at first like ARG0 was just documentation for an unused conceptual slot -- but treating it that way left every real argument's offset off by one against the reference's own numbers. It's a real, physically-pushed placeholder word (the "result or zeroth argument" slot, per its own name) that the caller pushes last, immediately before the call, after every real argument -- this project never reads or writes it (the actual return value travels via AC0/FAC0, per SAVE/RTN, not through this slot), but it still has to be pushed, purely to occupy the stack slot the offset arithmetic assumes is there. Caught by working the full return-block-plus- argument arithmetic simultaneously against every one of the reference's own offsets at once (FAC0 through FFLE, ARG0 through ARG2) rather than checking them one at a time, which is what exposed the single relationship that couldn't be made to hold for all of them at once without it.
  8. A prototype of the AC2-instead-of-PTRTMP optimization broke a passing test the moment it was tried, exactly as intended -- testing before declaring victory is the point. A call's own MARKSP/STA 2,.SP window needs AC2 to hold its marked pre-argument stack position for as long as that call's own arguments are being evaluated, and an argument can be an arbitrary expression -- including a plain variable read, which needs its own indirect load. Routing that inner load through AC2 too (instead of PTRTMP) clobbers the outer call's own marked value before its cleanup step ever runs. Reverted rather than patched further; see the "Calling convention" section above for the full account and what a safe version of this would need.