Skip to content

software/compiler — y1cc, a C cross-compiler for the YACC1

y1cc.py (Python 3, no dependencies) compiles a small C subset to YACC1 assembly for software/assembler (RC/asm with yacc1.def). Written 2026-09-22; the first C compiler the machine has had. Since 2026-09-24 it has a twin in C, c/y1cc.c (below), written in the subset itself and producing the same assembly byte for byte: the first step towards a compiler that runs on the machine. The same day that twin was split into nine programs, c/cc1_lex.c .. c/cc9_final.c ("The multi-pass compiler", below), each of which fits the Y1/OS program area with its tables and its stack, and which together still produce the same assembly. y1cc.py stays the reference and the bootstrap. The front end (lexer, parser, the C subset) is the one of the P8X compiler p8x/compiler/p8cc.py, so P8X C programs port with their source unchanged as far as the subset goes; the back end is new, written for what the YACC1 actually has.

python3 software/compiler/y1cc.py prog.c -o prog.asm            # for the machine (monitor: G3000 calls main)
python3 software/compiler/y1cc.py prog.c -o prog.asm --vector   # for the monitor as burned in 2021 (G = BRVR)
python3 software/compiler/y1cc.py prog.c -o prog.asm --org 0x5000 --os   # a Y1/OS program (os/Makefile): console via the OS
python3 software/compiler/y1cc.py prog.c -o prog.asm --boot     # for the emulator, stand-alone
python3 software/compiler/y1cc.py prog.c -o prog.asm --xisa     # + LDZ/STZ/ADDIW/SHL16 (2026-09-24 microcode), below
python3 software/compiler/y1cc.py prog.c -o prog.asm --org 0x5000 --os --stack 0xCFFF   # main on its own stack (below)
cd <dir with rcasm.rc + yacc1.def> && ../software/assembler/asm prog -d=yacc1 > prog.lst   # -> prog.img (Intel hex)
software/emulator/emulator -x -f prog.img                       # runs it, exits at HALT (--boot images)
python3 tests/compiler/run.py                                    # the test suite (make cc-test)
make -C software/compiler/c && software/compiler/c/y1cc prog.c -o prog.asm --boot   # the C twin: same options, same output
software/compiler/c/y1ccp prog.c -o prog.asm --boot             # the multi-pass compiler (cc1..cc9): the same again
python3 tests/compiler/twin.py [--chain]                         # y1cc.py vs the C twin (vs the passes) over the corpus
python3 tests/compiler/passes.py                                 # each pass against the Y1/OS program area

The C subset

types int (16-bit unsigned, as in p8cc), char (8-bit unsigned), pointers, arrays T a[N], struct/union (by pointer or member: no by-value struct params, returns or assignment); unsigned, const, static, void accepted
top level struct/union definitions, function definitions and prototypes, globals with constant initializers (numbers, strings, {lists}, &var / array addresses, [] length inferred)
statements {} decl (with initializer, several per line) if/else while for(e;e;e) switch/case/default break continue return expr ;
expressions = += -= *= /= %= &= \|= ^= <<= >>= ++ -- (pre/post) ?: \|\| && \| ^ & == != < > <= >= << >> + - * / % unary - ! ~ & * a[i] s.m p->m f(args) sizeof
preprocessor #define NAME value (integer or char), #include "file" (textual, each file once, searched beside the source then in lib/); a directive is its line, where a /* */ comment is one space and may go on over newlines (the directive then ends at the newline after it) and a // comment ends it, as in C (2026-09-25: until then a comment going on from a directive's line had its next lines lexed as code; tests/compiler/dircomment.c, dircomline.c)
builtins putchar(c) getchar() puts(s) (console), peek(a) poke(a,v) peekw(a) pokew(a,v) (memory), inp(port) outp(port,v) (I/O ports, constant 0..15), halt(), bios(addr, r7, acc) (JSR a monitor routine with R7 and ACC set; returns ACC), call(addr) (JSRUR a computed address, returns its R3), argstr() (the command tail at $0F40), sys(n, a, b, c) and funcaddr(f) (the Y1/OS syscall interface, below)
library lib/y1lib.c: putstr putnum puthex puthex2 strlen strcmp strcpy memset — #include "y1lib.c"; unused functions cost nothing (dead-function elimination)
recursion direct and mutual (2026-09-24): a call inside a recursive cycle saves and restores the callee's static frame on the stack (below). Not main; not the address of a local passed into the cycle
not there signed arithmetic, long/float, function pointers, goto, bit fields, do ... while, #if/#ifdef (ignored, like every directive but #define/#include)

Console I/O: on the emulator putchar is OUTA P2 and getchar is INP P2 (returns 0 at end of input); on the machine they call the monitor's BIOS vectors charout ($FFC4) and uartin ($FFE8). The runtime chooses at run time with BRDEV, which never branches on the emulator and always branches on the hardware, so one image serves both. puts appends \n (10) only.

--os (2026-09-23) compiles a Y1/OS program: os/Makefile builds the OS itself and every /BIN command with it. putchar (and puts, which loops over it) then stores the byte in SYSARG0 ($0F06, a big-endian word, high byte 0) and JSRURs the Y1/OS syscall CONOUT (the word at SYSTAB + 38 = $0F3A); getchar JSRURs CONIN (SYSTAB + 34) and returns the low byte of SYSRES, or 0 when CONIN says 65535 (end of input, Ctrl-D, the end of a < file): the same end-of-input byte as INP P2 on the emulator. Both keep R3 and R4 (rt_puts walks its string in R3); the OS handler is compiled code and clobbers R5-R7, ACC and TMP, which nothing keeps across a call. This is what lets the shell redirect a program's output (>, >>, |) and input (<) with no change to its source (os/README.md). Without --os the console runtime is the one above, byte for byte (checked 2026-09-23: every tests/compiler program and the bench sources compile to identical assembly). An --os program must run under Y1/OS: from the bare monitor its first putchar would jump through an empty SYSTAB slot.

How the generated code works (the YACC1-specific part)

The YACC1 has an 8-bit accumulator ACC and an 8-bit TMP, eight 16-bit registers R0-R7 (R0 = PC, R1 = SP), byte loads/stores through any register (LDAVR/STAVR), absolute 16-bit register loads/stores (LDR/STR, 3 bytes), and big-endian words in memory. There is no 16-bit ALU and no indexed addressing, which drove every choice below.

  • R3 is the expression accumulator: every expression leaves its 16-bit value in R3 (chars zero-extended). R4 is the second operand, R5-R7 are runtime scratch (R7 also carries the string for the monitor's stringout). R2 is never touched: on the hardware LDA/STA/LDT/STT/LDR/STR use R2 as the hidden operand-address register (docs/isa/MICROCODE-REVIEW-NOTES.md L-9); the emulator uses a ninth register for that, so a program that relied on R2 would only fail on the real machine.
  • Static frames. A global is a labelled word or byte; a function's parameters and locals are labelled slots of their own (main_i: DS 2). Loading or storing a scalar is one 3-byte LDR R3,label / STR R3,label; a frame-relative access would have cost a 16-bit add per variable (about 12 bytes) because the ISA has no (Rn+d) addressing. A char scalar occupies a 2-byte slot whose high byte is kept zero, so it loads with one LDR too; char arrays and struct members are true bytes. A function's slots are consecutive DS lines: its frame, one block of bytes.
  • Recursion (2026-09-24) keeps the static frames and saves them. The call graph's reachability tells which functions can reach themselves (a recursive cycle, a strongly connected component: fact, or is_even/is_odd). A call from a function to a callee that can reach back to it is a call inside a cycle; only those change: the caller pushes the callee's whole frame (parameters and locals) on the R1 stack, stores the arguments into the slots as usual, JSRs, and pops the frame back afterwards. So every activation finds its own values in the slots while it runs, all accesses stay one LDR/STR, and a function outside every cycle compiles exactly as before (tests/compiler/diffcheck.py proves it on the whole corpus). Why the callee's frame: whatever the callee (or anything it calls) changes in any frame of the cycle is put back by the call that changed it, so after any call inside the cycle every frame of the cycle is as it was. A function outside the cycle needs nothing: it cannot be active further up the stack (it would then reach the cycle and be part of it).
  • Cost per call inside a cycle: frames of up to 8 bytes are copied inline, LDR R4,f+k / PUSHR R4 before and POPR R4 / STR R4,f+k after (10 bytes of code and about 123 microcode steps per frame word, an odd byte through LDA/PUSH, POP/STA); a bigger frame goes through the runtime pair rt_fsave/rt_frest (MVIW R5,frame / MVIW R6,bytes / JSR, 9 bytes each side, about 107 steps per byte each way). Example (2026-09-24, --boot): fib(15) recursive is 1,973 calls, 48,388 instructions and about 980,000 microcode steps (~500 steps a call); the iterative loop is 541 instructions and about 9,000 steps; the recursive function is 14 bytes larger (two call sites × 10 bytes of save/restore, less code elsewhere). Stack: 2 bytes of return address + the frame per level. The stack is the monitor's $0C00-$0EFF (768 bytes, the handle buffers of Y1/OS end at $0BFF), so keep deep recursion to small frames; nothing checks for overflow.
  • Self-calls and arguments. In f(a, b) called from f the arguments are stored into the caller's own parameter slots; an argument whose slot a later argument still reads (hanoi(n - 1, from, via, to) stores to's slot before reading to) waits on the stack like a parked argument. The return value comes back in R3, which the restore does not touch.
  • Rules. main cannot be recursive (it clears the BSS on entry; compile error). The address of a local of a recursive function (&x, a local array, a member of a local struct) cannot be passed as an argument to a call inside its cycle: the callee's activation of the same function would reuse the slot (compile error "the address of local 'x' is passed to f()"). Passing it to a function outside the cycle is fine (fill(buf), strlen(buf)). Not detected: such an address stored in a variable and used after a call into the cycle, or returned. Local arrays and structs in recursive functions are allowed; they are saved and restored whole on every call inside the cycle (the cost above, per byte). A syscall handler must still not sys() itself (the call graph cannot see through SYSTAB).
  • Calls: the caller evaluates each argument into R3 and stores it straight into the callee's parameter slot, then JSR (inside a recursive cycle wrapped in the frame save/restore above). When a later argument's evaluation could itself run the callee (f(x, g()) where g calls f) the earlier ones are parked on the stack meanwhile. The result comes back in R3; return is RET.
  • Arithmetic is byte-wise through ACC/TMP. + & | ^ are inline (MVRLA R4 / MVAT / MVRLA R3 / ADDT / MVARL R3 / ... ADDTC ..., the do_add16 idiom the monitor proved on the hardware; 8-10 bytes), constants fold into ADDI/ADDIC immediates, +1/-1 are INCR/DECR. - is a runtime call (two's-complement add: the emulator and the hardware disagree about SUB's borrow, so it is never used). * / % << >> are runtime loops (rt_mul, rt_divmod, rt_shl, rt_shr); shifts by small constants, *2 *4 *8 *256, /2^k, %2^k are inline.
  • Comparisons in conditions branch straight on the comparator instructions (BRLT/BRGT/BREQ/BRNEQ compare ACC with TMP): high bytes first, low bytes only if they are equal. Two char operands need one compare; x == 0 ORs the two bytes. Unsigned, like p8cc's int. A relation used as a value becomes 0/1 through the same path.
  • The carry flag is used only inside an ADDT/ADDTC (or ADDI/ADDIC) pair with nothing but register moves between them, and inside a CSHL/CSHR pair after an explicit clear (LDAI 0 / CSHL). Plain shifts and subtracts never feed a following carry op, because the hardware loads the carry flip-flop on every shift and on SUB and the emulator does not (review item L-7).
  • Image layout: ORG → main → the other live functions → the runtime helpers actually used → initialised data and strings (DB as numbers: the assembler upper-cases every source line, so DB "text" would be shouted) → uninitialised variables (DS, kept last) → with --boot a stub at $F000 (BR $F003 / MVIW R1,$0EFF / JSR f_main / HALT; the first branch presents an A15-high address, which releases the memory card's FORCE-ROM boot remap exactly as the monitor's first instruction does — without it every fetch stays inside $F000-$FFFF, seen on the microcode emulator). The monitor's G AAAA (rebuilt 2026-09-22) is JSRUR R7, a call: G3000 enters main and main's RET returns to the command loop. --vector is the layout for the monitor as burned in 2021, whose G was BRVR R7: on the hardware that is an indirect jump through the word at AAAA (the microcode reads [R7],[R7+1] into the branch register, docs/isa/steps.txt) that pushes no return address, so that image starts with a 2-byte vector to a stub JSR f_main / BR $F000 (monitor restart). Both were run on the emulator against the respective monitor image (the 2021 one from the chip capture). The default --org is $3000 because $1000-$1FFF is BASIC's token buffer, which the monitor's boot (and the restart after main) clears, and the monitor's T tests use $2000 as scratch; a program at $1000 lost its first byte before it ran (found on the emulator 2026-09-22).
  • Zero-initialised data (2026-09-22, found by the microcode emulator's $FF-filled RAM): main starts by clearing every DS slot between bss_start and bss_end (20 bytes of code), and a partially initialised array's tail is real zero bytes in the image, not DS. The interpreter's zeroed memory had hidden both.
  • switch (2026-09-22): the case labels must be direct statements of the switch block. Dispatch is whichever is smaller: a compare chain (LDTI k / BREQ per case when every case fits a byte, 5 bytes each; a two-level compare, 13 bytes, otherwise) or a jump table through BRUR ($AD, PC ← Rn): subtract the lowest case, range-check, index a table of DW addresses, load the word into R3, BRUR R3 (about 49 bytes plus 2 per slot; holes go to default). --no-brur forbids the table, for the machine until its sequencer EEPROM holds the microcode with BRUR; the same test program passes both ways (switch.c / switchnb.c).
  • Syscalls (2026-09-23): sys(n, a, b, c) is how a program reaches Y1/OS (os/README.md). The arguments (any of a, b, c may be left out) are evaluated into the parameter words SYSARG0..2 at $0F06/$0F08/$0F0A (STR R3,addr); an argument evaluated later that calls anything (it could run a sys() of its own) makes the earlier ones wait on the stack, exactly as a user call's arguments do. Then the entry word SYSTAB + 2n ($0F14 + 2n, n = 0..21; for a constant 22..31 the word at SYSTAB2 + 2n, $4FC0 + 2n, the OS's 32-entry table since 2026-09-25) is loaded into R7 (LDR R7,addr for a constant n; a computed n is shifted, added and dereferenced) and JSRUR R7 calls the handler; the result word SYSRES ($0F0C) comes back in R3 as an int. funcaddr(f) is the address of function f as an int (MVIW R3,f_label): the OS installs its handlers with pokew(SYSTAB + 2 * n, funcaddr(h_open)). A function named in funcaddr() is an entry point: it and whatever it calls are kept in the image even when nothing calls them directly (the dead-function pass starts from main and every such function). The call-graph check cannot see through the table: an OS handler must not sys() itself. tests/compiler/syscall.c installs its own handlers and calls them, constant and computed numbers, 1..4 arguments, nested sys() in arguments. Constant folding got a fix the same day: the operator table was an eager dictionary that evaluated a // b for every fold, so any constant expression with a zero right operand (SYSTAB + 2 * SYS_OPEN) crashed the compiler.
  • --stack ADDR (2026-09-25): main moves the program onto a stack of its own. Its first lines (after the --xisa page load, before the BSS clear) are MOVRR R1,R5 / MVIW R1,ADDR / PUSHR R5: the caller's SP is the first word on the new stack (ADDR-1..ADDR); every return in main, and its end whether or not the last statement returned (4 dead bytes then, the price of an unconditional rule the passes can follow), is POPR R5 / MOVRR R5,R1 / RET, so the RET pops the caller's return address from the caller's own stack and the shell or monitor finds its stack as it left it. R3 (the result) is untouched. Everything else runs on the new stack: return addresses, pushed operands, parked arguments, recursion's frame saves, and the Y1/OS syscall handlers (they run on the caller's stack). Why: the monitor's stack is 768 bytes ($0C00-$0EFF, the handle buffers below it), shared with the shell and the syscalls, and the compiler's parser alone needs 1.2K on the corpus; a Y1/OS program owns $5000-$CFFF, so --stack 0xCFFF puts its stack at the top of its own area, above its tables. The option was chosen over the OS giving every program the area's top (a /BIN command whose data reached $CFFF would collide with it). Nothing checks for overflow at run time; the instruction-level emulator's -S (program watch) reports the lowest SP a --stack program reached, which tests/native/run.py compares with each pass's last byte of data. Without the option the output is byte-identical (diffcheck.py); tests/compiler/stack.c (// y1cc: --stack 0xC7FF: the saved SP, 40 levels of recursion on the new stack, an early return) runs on both emulators, twin.py/--chain cover it, and twinfuzz.py adds --stack to every fifth random program.
  • Never emitted: BR16Z BR16NZ BRNC (no microcode), BRVR (not needed yet), LDZ STZ ADDIW SHL16 without --xisa, negative numbers (the assembler silently drops the sign), labels over 29 characters (crash the assembler), or two labels differing only in case (the assembler folds case; the compiler mangles and uniquifies).

--xisa: the page, ADDIW and SHL16 (2026-09-24)

Four instruction families were added to the microcode on 2026-09-24 (docs/programming/ISA-REFERENCE.md section 4a) to shrink exactly what this compiler emits most: LDZ Rn,d / STZ Rn,d (2 bytes: the word at R6.hi:d), ADDIW Rn,w (3 bytes: Rn += w) and SHL16 Rn (1 byte: Rn <<= 1). --xisa uses them. It is opt-in until the machine's sequencer EEPROM holds that microcode and tests/bench has passed on it (BACKLOG.md); without it the output is byte-identical to before (tests/compiler/diffcheck.py: 126 identical + 4 errors).

  • The page. Every uninitialised 2-byte global and every 2-byte parameter or local is a candidate; so is the whole frame of a function in a recursive cycle (1..256 bytes: frame_save and rt_fsave need it contiguous). A candidate's weight is how many times its variables are named in the live functions' bodies (every identifier of the tree, resolved as vinfo would), its density the weight per word; the densest go first (ties: the earlier in BSS order) while they fit in 256 bytes. They are laid out first in the BSS: zpad: DS (256-(zpad).0)&255 (before bss_start, so main's BSS clear does not clear the padding; it needs the 2026-09-24 assembler, which resolves the label in pass 1), then zpage:, then their DS lines in BSS order, then the rest. A load or store of one of them is LDZ R3,(main_i).0 instead of LDR R3,main_i (the low byte of an address in the aligned page is its offset), and a recursive frame in the page is saved with LDZ R4,(f_a+2).0 / STZ. The peephole's store/reload rules apply to STZ/LDZ pairs as to STR/LDR.
  • R6 is the page register and is reserved: main and every funcaddr() function (an entry from outside: the OS's syscall handlers) start with MVIW R6,zpage, and R6 is set again after everything that leaves compiled code, because the ROM and Y1/OS use R6 freely: after bios()'s JSR, call()'s and sys()'s JSRUR, inside rt_putc/rt_getc after the ROM or OS call, and at the end of rt_fsave/rt_frest (whose callers load R6 with the byte count). rt_divmod keeps its remainder in R5 instead of R6. A program with nothing in the page (no 2-byte variable named anywhere) gets none of this: no MVIW R6, no zpad.
  • ADDIW / SHL16: add_const's 8-byte MVRLA/ADDI/MVARL/MVRHA/ADDIC/MVARH (and the 4-byte high-byte-only form) becomes ADDIW R3,k (a label expression too: ADDIW R3,g_table), the same for R4 in load_address_r4; shl1's 8 bytes become SHL16 R3, in compiled code and in rt_mul and rt_shl. Both leave ACC and the carry as the old sequences did (ACC = the high byte, carry = out of bit 15), but nothing the compiler emits reads the carry after them (rt_divmod's double shift still does it the long way until the machine has confirmed SHL16's carry).
  • Sizes (2026-09-24): the nine compiler passes, compiled as Y1/OS programs (tests/compiler/passes.py [--xisa]):
pass code code --xisa image --xisa total --xisa free --xisa (default)
cc1 lex 12,434 10,148 11,591 29,637 3,131 (1,030)
cc2 parse 16,893 14,325 15,021 28,039 4,729 (2,244)
cc3 decl 8,961 6,585 7,562 19,909 12,859 (10,601)
cc4 calls 5,809 4,529 4,959 17,335 15,433 (14,314)
cc5 layout 8,908 7,069 7,609 24,767 8,001 (6,233)
cc6 stmt 11,921 9,302 9,963 29,795 2,973 (375)
cc7 sema 16,974 13,106 13,546 28,376 4,392 (546)
cc8 emit 25,197 21,979 23,406 27,593 5,175 (2,103)
cc9 final 16,714 14,841 16,771 30,745 2,023 (275)
all nine 123,811 101,884 (-17.7 %)

In the nine with --xisa 9,115 of the 13,063 word loads and stores are LDZ/STZ, with 1,278 ADDIW and 954 SHL16. The /BIN commands (make -C os sizes, XISA=1): 87,235 bytes of images → 75,998 (-12.9 %); the smallest grow by up to 2 bytes (hello: the MVIW R6,zpage costs more than its two variables save) and the data stays the same. The C OS y1os.c: code 13,747 → 11,905. Test programs (code + data, --boot): -9 % to -18 %. - Speed (microcode emulator, clocks): ADDIW is 46 steps against 72, SHL16 35 against 90, LDZ/STZ 30/31 like LDR/STR (they save a byte, not time). Whole programs: bench/sort -5.0 %, arrays -5.5 %, fib/sieve/ structs/rcalc -1 to -1.5 %, arith/rfact -0.5 to -0.7 %, strings/control +0.5 to +0.7 % (the R6 reload after every character printed, and main's larger BSS clear). - Tests: tests/compiler/run.py --xisa and tests/ucemu/run.py --xisa (every test, both emulators, 0 bus fights); twin.py --xisa (also --16, --chain, --chain16) and twinfuzz.py --xisa: y1cc.py, y1cc.c and the passes agree; XISA=1 python3 tests/os/run.py (Y1/OS with every /BIN program built with --xisa, 20/20; make -C os XISA=1 builds that disk); tests/compiler/xisa.c (// y1cc: --xisa) is a test of its own and a tests/bench program for the machine. - In the passes: cc6 counts the identifiers while it copies each expression tree, plans the page after the last function and writes W.zp (a flag byte and a bit per variable); cc8 writes ADDIW R4 and the M_ZP reloads; cc9 prints LDZ/STZ for page variables, the page's DS lines first and the runtime variants (lib/y1ccrt.txt: @NAME+x/-x sections, % lines only when there is a page); cc4 keeps a funcaddr() root's live value at 1 even when another function reaches it.

Tests

tests/compiler/*.c with the expected output beside each (.out, .in for stdin, .err for an expected compile error, // y1cc: flags on a line for per-test compiler flags); tests/compiler/run.py compiles, assembles, runs each on emulator -x and diffs. --oracle regenerates the .out files with the HOST C compiler through host_shim.h (int = unsigned short, unsigned char), so the expectations are independent of this compiler; tests marked no-oracle (peek/poke, struct layout, byte order) carry hand-written expectations. 16 programs, 16/16 on 2026-09-23 (~3 s; syscall.c joined that day); 21 since 2026-09-24 with the recursion tests: rfact.c (fact, fib, Ackermann with a recursive call in an argument, depth 50 with int and char locals), rmutual.c (even/odd, a three-function cycle, Hofstadter F/M), rlocals.c (local arrays and structs in a recursive function through rt_fsave, an odd-sized frame, Hanoi, a pointer to a local handed to a non-recursive helper), rcalc.c (a recursive-descent expression evaluator over a string), and the compile errors recurse.c (address of a local into the cycle) and rmain.c (recursive main); all pass on both emulators (tests/ucemu/run.py); 22 with adjstr.c (an error test, from the twin work below); 23 with xisa.c (2026-09-24, // y1cc: --xisa, section "--xisa"). make check runs them.

tests/compiler/diffcheck.py [--base REV] is the differential proof for a compiler change: it compiles the whole corpus (tests/compiler/corpus.py: the compiler tests with --boot, plain, --os and --no-brur, three --vector builds, the bench sources, os/y1os.c, every /BIN command, tests/os/*.c, and since the twin c/target.c — 121 compiles on 2026-09-24; 130 with the nine passes of the multi-pass compiler as Y1/OS programs, c/target/*.c, the same day) with an old y1cc.py from git and the working one and diffs the assembly (the header's timestamp masked). Against c847a97 (the last compiler without recursion): 100 identical, 17 that only the new one compiles (the recursion tests and y1cc.c), 3 expected errors, 1 that crashed the old one (adjstr.c), 0 different. tests/compiler/twin.py is the same kind of proof between y1cc.py and its C twin (below). The same images also run under the monitor on the emulator (emulator -m -f prog.img, then G3000): the program's output appears after GO ADDRESS: and the monitor's banner follows when main returns (hello and fib tried 2026-09-22).

Sizes on 2026-09-22 (code + data + runtime, bytes): hello 111, io 722, sieve 862, chars 893, calls 948, globals 966, fib 1045, structs 1398, arrays 1446, control 2356, arith 2339.

y1cc.c — the C twin (2026-09-24)

c/y1cc.c is y1cc.py rewritten in C, function by function (the same names where C allows), so that a change to one carries over to the other. It is written in the intersection of C89 and the y1cc subset, so it builds three ways:

make -C software/compiler/c              # ./y1cc on the Mac: cc -std=c89 -Wall -Wextra -pedantic, no warnings
                                         #   (plus ./y1cc16, the 16-bit check build, below)
software/compiler/c/y1cc prog.c -o prog.asm [--org N] [--boot] [--vector] [--no-brur] [--os] [-l]
make -C software/compiler/c target       # y1cc.py compiles y1cc.c as a Y1/OS program; its size (below)
python3 tests/compiler/twin.py [--16]    # the twin test (make check, make cc-test)
file what
c/y1cc.c the compiler, 3,122 lines: lexer, parser, AST, code generator, peephole, runtime text, driver (y1cc_main)
c/io.h the host interface: everything outside y1cc.c goes through these twelve functions (five more for the passes, below)
c/host_io.c io.h on the Mac (stdio, getcwd, main(argc, argv)), plain host C
c/target_io.c io.h on Y1/OS over os/lib_fs.c (the file syscalls), in the subset; run by the native compiler's passes since 2026-09-25, buffered (READN, WRITE) the same day, with c/target_rd.c or c/target_inc.c (the reads); the three output sections, which only y1cc.c uses, are in c/target_sec.c (so the passes do not carry its buffers)
c/host.c, c/target.c the two translation units: limits + (target I/O) + #include "y1cc.c"
c/limits_host.h, c/limits_y1.h the table sizes (#define numbers only: y1cc's preprocessor has no #if)
c/host16.c the check build y1cc16: #define int unsigned short and -funsigned-char

The I/O interface (c/io.h): io_argc(), io_arg(i, buf, max) (the command line); io_open(path), io_getc(h) (0..255, 256 at the end), io_close(h) (source files); io_find(name, from, out, max) (an #include: beside the including file, then the library directory, with a canonical path so each file is included once); io_create(path), io_put(section, c), io_finish() (the output in three sections — code, data, uninitialised data — concatenated at the end; the host writes the file only when the compile succeeded, as y1cc.py does); io_out(c) (the -l summary), io_fail(msg) (message, exit status 1), io_date(buf) (the header's timestamp). All plain ints and char buffers, no negative numbers. On the host the library directory is $Y1CC_LIB, else ../lib beside the executable (= software/compiler/lib, where y1cc.py looks). On Y1/OS (target_io.c) the command line is the argstr() tail, #include falls back to /LIB, code goes straight into the output file while data and BSS wait in RAM (Y1/OS has one write handle), there is no clock (the date is 0000-00-00 00:00) and io_fail HALTs (no exit syscall yet).

Rules it keeps (so it means the same compiled by cc and by y1cc): int is 16-bit unsigned on the YACC1 and 32-bit signed on the Mac, so every value stays in 0..65535 — no negative numbers or -1 sentinels ("none" is 0, the end of a file is 256), wrapping arithmetic is masked (& 65535, products through mul16), no loop runs to 65535; char is unsigned there and signed here, so bytes read back from char arrays are masked with & 255; no casts, no long, no function pointers (y1cc.py's lambdas became mode numbers), no goto, no do ... while, no struct at all (parallel arrays), no adjacent string literals, no #if; every local declared at the top of its function (y1cc does not see declarations inside a switch body); every function defined or prototyped before use (the prototype block at the top). Recursion is y1cc's (since 2026-09-24): the parser and the code generator are recursive, and no address of a local ever goes into a recursive call — every buffer that crosses a call is a global, and functions that return two or more values leave them in globals (fv, tb/tp, sl_*, sm_*...), as their callers read them at once. y1cc16 (host16.c: int = unsigned short, unsigned char, the same trick tests/compiler/host_shim.h plays for the test oracle) runs the whole corpus through the YACC1's integer types on the Mac: arithmetic is still promoted to the host's int, so it does not model every 16-bit wrap, but it found a real bug on its first run — for (i = k; i <= 65535; i++) (emitting up to three DECRs) never ends with a 16-bit int.

How it differs inside (the output does not): y1cc.py lexes the whole file and keeps every line of code until the end; y1cc.c streams both. The lexer reads through a 4-byte lookahead per open file (an #include pushes a file) and the parser looks at most 2 tokens ahead and 1 back; the peephole pass keeps only the lines that a later line can still change — a run of BR lines, which a following label can delete (the four rules delete or replace only the NEW line otherwise, so the rewrite system is confluent and the streaming result equals y1cc.py's repeat-until-no-change passes). The whole program's AST is kept (as in y1cc.py: the call graph, dead functions and recursion need every body before any code is generated) and the nodes made while generating a function are freed after it. Generated labels (Lend12) are a number plus a prefix remembered per function; the labels derived from names (g_x, f_main, main_i) are the only ones checked for case-folded uniqueness (they always contain _, the generated ones never do). Where the two can still disagree, on invalid or odd input only: with several errors in a program y1cc.c may report a different one first (it lexes as it parses; and with bad funcaddr()s in several functions it reports them in function order, where y1cc.py takes the order of the first definitions, and in one function the first in the text where y1cc.py puts a malformed one first - the multi-pass compiler, below, follows y1cc.py in all of these); source must be ASCII (Python decodes UTF-8, y1cc.c sees bytes); a #define value over 65535 is masked at once; *x of a non-pointer does not go to pointer depth -1; a function defined twice (below).

Limits (limits_host.h, each overflow is a clean "y1cc: too many ... (NAME)" error): 4,000 names (32,000 bytes), 2,000 distinct string literals (32,000 bytes, 1,024 per literal), 40,000 AST nodes, 3,000 variables, 512 functions, 64 structs with 512 members, 4,000 derived labels (40,000 bytes), 2,000 generated labels per function, 8 levels of #include and 64 files, 64 nested loops, 512 cases per switch. Enough for the corpus and for y1cc.c itself. limits_y1.h is a small illustrative set for the Y1/OS build (about 41K of tables): a native compiler will size its tables per pass.

The twin test tests/compiler/twin.py compiles the whole corpus (tests/compiler/corpus.py, 121 compiles on 2026-09-24: the compiler tests in four option sets, three --vector builds, the bench sources, os/y1os.c, every /BIN command, tests/os programs, and c/target.c — y1cc.c compiling itself) with both compilers and requires identical assembly (the header's timestamp masked), identical -l summaries and identical error messages for the expected-error tests. 2026-09-24: 117 programs identical, 4 identical errors, 0 different, with y1cc and with y1cc16; 126 and 4 once the nine passes joined the corpus. It runs in make check and make cc-test; --chain and --chain16 run the same comparison against the multi-pass compiler (below). tests/compiler/twinfuzz.py [N] [--seed S] adds random programs (every operator, type and statement shape of the subset mixed, recursion, switch tables, struct members, pointer arithmetic, initialisers, --boot/--os/--no-brur/--vector) and a fixed list of 60 invalid programs whose error messages must match: 2026-09-24, seeds 1-3, 1,400 random programs identical (or the same error) and 60 of 60 error messages, 0 different. Writing the twin found two y1cc.py crashes, now fixed in y1cc.py: a string literal right after an expression (puts("a" "b"), a Python TypeError; now the ordinary "expected ')'" error, tests/compiler/adjstr.err) and 0x without digits (a ValueError; now "bad hex constant").

y1cc.c compiled by y1cc.py (make target, the proof that it is in the subset): no errors, 45,173 lines of assembly, code 75,445 + data 6,900 = an 82,345-byte image (plus about 41K of tables with limits_y1.h), 2.5 times the 32K program area ($5000-$CFFF) for the image alone and over the 64K address space; so the assembler cannot take it (every label past $FFFF is an error; its label table held 1,000 labels until 2026-09-24 and crashed on this one's 3,817 - it holds 8,191 now and says so when full), and the bytes are counted from the assembly with yacc1.def's instruction lengths — a count checked against the assembler's "Object Code" on all 116 corpus programs that assemble (exact on every one). Where the code goes: the code generator 56,040 bytes (gen_call 4,484, gen_bin 2,821, gen_program 2,793, gen_expr 2,257, walk 2,043, gen_stmt 1,988, const_data 1,729, type_of 1,513, gen_cond 1,404), the parser 8,749, the lexer 6,549, the runtime text (emit_runtime) 2,257, the Y1/OS I/O with lib_fs.c 1,603; the data is mostly message and instruction strings. Recursion costs about 4,100 bytes of it (124 call sites through rt_fsave/rt_frest, 190 inline word saves). That is about 24 bytes of code per line of C.

The road to native (BACKLOG "C compiler"; where it stands in "The multi-pass compiler", below): 1. Self-compile on the host — done: y1cc.c compiling target.c gives the same assembly as y1cc.py (the twin corpus), so the C compiler already compiles itself, on the Mac. 2. Split into passes that each fit the 32K area with their tables — done 2026-09-24: nine passes, below. 3. A bigger stack — measured, placed, not built: below, "The stack". 4. Y1/OS support: an exit syscall (io_fail and io_done HALT today), files over 64K, a way to run nine programs in a row, /LIB/y1ccrt.txt on the disk: below, "What is left for native". 5. The on-target assembler (BACKLOG wave 3): the compiler emits assembly text; the machine needs an assembler (the host assembler's label table now holds 8,191, enough for any single pass) before a program compiled on it can run. Done 2026-09-25: /BIN/ASM (os/commands/asm.c, docs/programming/ASSEMBLER.md section 10) is byte-identical to the host assembler on the whole corpus, and its symbol table holds cc8's 1,326 labels; on the emulator it has assembled pass cc4 (--xisa, 58,585 bytes, the one pass under 64K) under Y1/OS (tests/asm/target.py). 2026-09-26: /BIN/ASM is the same assembler hand-written in YACC1 assembly (os/commands-asm/asm.asm, about five times faster, a 20,292-byte label table); asm.c, its specification, is /BIN/ASMC. 6. Native self-host — done 2026-09-25: under Y1/OS on the emulator the native compiler and assembler rebuild all nine passes, /BIN/ASM and /BIN/CC byte-identical to the host builds, and the rebuilt tools do it again identically (below, "Self-host").

The multi-pass compiler (2026-09-24)

y1cc.c compiled by y1cc.py is an 82K image; the Y1/OS program area is 32K ($5000-$CFFF) for a program's image, its uninitialised data (the tables, and every function's static frame) and its stack. So the compiler is split into nine programs run one after the other, each passing the next its work in files. Each is written in the y1cc subset (the rules of y1cc.c, above: C89 on the Mac with -Wall -Wextra -pedantic clean, a 16-bit check build, compiled by y1cc.py), each is y1cc.c's code for its part of the work - the same functions under the same names wherever the part is the same - and the nine chained produce y1cc.py's assembly byte for byte.

make -C software/compiler/c                                     # cc1..cc9, cc1_16..cc9_16, the drivers y1ccp, y1ccp16
software/compiler/c/y1ccp prog.c -o prog.asm [--org N] [--boot] [--vector] [--no-brur] [--os] [-l]   # as y1cc.py
Y1CCP_KEEP=dir software/compiler/c/y1ccp prog.c ...             # the intermediate files kept in dir (dir/w.*)
python3 tests/compiler/twin.py --chain [--16 | --chain16]        # the corpus: y1cc.py against the chain
python3 tests/compiler/twinfuzz.py 500 --seed 1 --chain          # random programs and the error list, likewise
python3 tests/compiler/passes.py [-v]                            # the sizes, the stack, the capacity (below)

y1ccp (c/y1ccp.c, host C) takes y1cc.py's command line, makes a temporary directory W, runs cc1 W <the command line>, then cc2 W ... cc9 W, stops at the first pass that fails (that pass printed the message) and removes the files. On Y1/OS (2026-09-25) /BIN/CC does the same with the syscall EXEC: "Native", below.

The passes

pass y1cc.c's part reads writes
cc1_lex.c y1cc_main's options, the preprocessor (#define, #include), the lexer the source, its includes W.opt options, W.tok tokens, W.nam names, W.lit string literals
cc2_parse.c the parser, constant folding W.tok W.ast one record per top-level declaration, W.typ types
cc3_decl.c gen_program to the check for main(): structs, the function table, the globals, their data W.ast (3 readings), W.typ, W.nam, W.lit W.dat the global data (a stream), W.s1 symbols
cc4_calls.c build_reach (main must not be recursive), the funcaddr() roots, the live functions W.ast (2), W.s1, W.nam W.cg live functions + the reach matrix
cc5_layout.c the labels (ulabel) of the globals, functions, parameters and locals, layout_func, the frames W.ast (2), W.s1, W.cg, W.typ, W.nam W.sym symbol tables, W.lab the labels' owners
cc6_stmt.c compile_func, gen_stmt, gen_switch's case labels: every expression becomes a hole W.ast (2: main first), W.sym W.st a stream: labels, branches, holes
cc7_sema.c the analysis gen_* asks of every node: fold, type_of, type_lval, vinfo, struct_member, sizeof, the call graph walks, escapes W.st, W.sym, W.cg, W.lit W.se the stream, every hole's tree annotated
cc8_emit.c gen_expr, gen_assign, gen_bin, gen_cond, gen_call...: each hole's code W.se, W.sym W.em the stream, instruction records
cc9_final.c the text: labels numbered, the macros (add_const, branch_rel, frame_save, switch_table...) expanded, the peephole, strings at first use, the runtime (from lib/y1ccrt.txt), the three sections W.opt, W.dat, W.em, W.lab, W.nam, W.lit, W.sym the .asm file, the -l line

Shared source: pdefs.h (every number: token and node kinds, record codes, mnemonics, macros...), pcommon.c (strings, errors, the file primitives: bytes, words, strings, whole table columns), pnames.c (the names' text), past.c (reading W.ast), plabel.c (a label's text from its owner); the I/O is io.h as for y1cc.c, grown by io_wopen/io_wput/io_wclose (one file written at a time, as Y1/OS allows), io_done and io_lib, and on 2026-09-25 io_wputs (a string) and io_skip (bytes read past), for the buffered Y1/OS layer. The builds: hostp.c / host16p.c (a pass on the Mac and its 16-bit check build, -DPASS="cc1_lex.c"), plim_host.h (the Mac's table sizes), ylim/NAME.h and target/NAME.c (each pass's Y1/OS table sizes and its Y1/OS translation unit, as target.c is y1cc.c's), stackprobe.c (passes.py's stack probe), lib/y1ccrt.txt (the runtime helpers' text). Each file's header comment gives its formats in full; the ideas that make the split exact:

  • The whole-program work never loads a function body. cc2 writes into each function's record the calls it makes (in the order y1cc.c's walk() meets them) and its declarations (in collect_decls() order), so the call graph (cc4) and the layout (cc5) read short lists; only cc6 loads a body, one function at a time.
  • Holes. The statement pass allocates its labels and writes the branches around each expression, and in the expression's place a hole holding the expression's tree (renumbered 1..n: the biggest in the corpus has 31 nodes). cc7 adds to every node what the code generator will ask about it, cc8 replaces the hole by the code. A generated label is a symbol (cc6's from 1, cc8's from 32768) announced by an R_ALLOC record at the moment y1cc.c's lbl() would have run; cc9 numbers them in stream order, so Lend12 gets the same 12.
  • Errors come out in y1cc.py's order. y1cc.py lexes the whole source first (so cc1 stops at a lexer error, as it does), then parses, then generates each function's statements and expressions in one walk: a statement error (a break outside a loop) comes after any error in an expression before it. So cc6 writes a statement error into its stream and stops (cc8 raises it once the expressions before it are done), and cc7 never stops at an error: it records it with the attribute ("poisoned": code and names) and cc8 raises it when its code generator asks for that attribute, which is when y1cc.py would have failed. The fuzzer's error list (60 programs), every random program that fails, and a list of programs with two errors in different passes give the same message as y1cc.py - better than y1cc.c, which lexes as it parses and can report a parse error before a lexer error further on.
  • No label text is kept. A label is y1cc.c's ulabel(want): the want sanitised, cut to 29 characters, or to 24 and _n after n labels equal to it ignoring case. cc5 remembers each label's owner and n and makes the text again when it compares (plabel.c); cc9 does the same when it prints. The globals' labels moved from the declarations to cc5, in the same order.
  • Fixed sequences are macros. cc8 writes M_ADDK k, M_BREL rel label ..., M_FSAVE var bytes, M_SWITCH ... and cc9 expands them with y1cc.c's code (and allocates their labels then, in the same order).
  • Addresses keep y1cc.c's text: a label and its +k terms as written (g_s+2+4, not g_s+6), a constant index times the element size as a 32-bit number (g_a+131070 for a[-1] of an int array, as y1cc.py prints it).
  • Column files. The symbol tables go to and from the files a column at a time (warr/rarr), and each pass reads only the columns it needs: a statement per field costs y1cc's code model far more.

Found on the way: y1cc.c's switch_table counts v from lo to hi, which never ends in 16 bits when hi is 65535; cc9 counts the table's entries instead. Known differences from y1cc.py, both on programs y1cc.py cannot compile into working assembly, none in the corpus or produced by the fuzzer: an object over 65,535 bytes (int a[40000]; the address space is 64K) - y1cc.py prints its size whole (DS 80000, sizeof 80000), the passes, written for 16-bit ints, mod 65536 (14464), as y1cc16 does (y1cc.c on the Mac prints it whole); and a function defined twice - y1cc.py lays out both definitions and compiles the last one twice under the second label (f_f_1: twice: the assembler rejects it), y1cc.c and the passes compile it once as f_f. (Better: y1cc.py reports the redefinition as an error, like a global declared twice - BACKLOG.)

Sizes against the program area

tests/compiler/passes.py compiles each pass with y1cc.py as a Y1/OS program (c/target/NAME.c: its Y1/OS table sizes c/ylim/NAME.h, the Y1/OS I/O layer c/target_io.c, the pass), assembles it with the host assembler (0 errors; the label count), counts code and data (checked against the assembler's Object Code) and the uninitialised data (tables, static frames), and measures the stack (below). 2026-09-24, in bytes:

pass code data image tables stack total free of 32,768 labels
cc1 lex 12,434 1,443 13,877 17,771 90 31,738 1,030 880
cc2 parse 16,893 696 17,589 11,733 1,202 30,524 2,244 967
cc3 decl 8,961 977 9,938 12,143 86 22,167 10,601 528
cc4 calls 5,809 430 6,239 12,131 84 18,454 14,314 382
cc5 layout 8,908 540 9,448 17,003 84 26,535 6,233 578
cc6 stmt 11,921 661 12,582 19,219 592 32,393 375 663
cc7 sema 16,974 440 17,414 14,613 195 32,222 546 910
cc8 emit 25,197 1,427 26,624 3,617 424 30,665 2,103 1,326
cc9 final 16,714 1,930 18,644 13,749 100 32,493 275 1,178

(2026-09-25, built as the native compiler is, with --stack 0xCFFF, and with target_io.c's upper-case /LIB names: free cc1 717, cc2 2,231, cc3 10,588, cc4 14,301, cc5 6,220, cc6 201, cc7 525, cc8 2,082, cc9 211; the stack prologue and epilogue cost cc6 135 bytes of code, the other passes 11.)

(Updated after --xisa, same day: cc6 grew by the page planning (2.9K of code, 3.3K of tables), cc9 kept its room because five of its macros became cc8's instructions; the table before it had cc6 at 26,206 and cc9 at 244 free. The same passes compiled with --xisa are in the section "--xisa" above: 101,884 bytes of code instead of 123,811.)

(2026-09-25, the passes as the native compiler is built - --xisa, --stack 0xCFFF - after the buffered I/O and the speed work, passes.py: code, image, tables, free: cc1 12,134, 13,733, 18,631, 314; cc2 14,892, 15,630, 12,271, 3,663; cc3 7,115, 8,134, 12,771, 11,775; cc4 5,090, 5,562, 14,034, 13,086; cc5 7,481, 8,063, 18,110, 6,509; cc6 9,886, 10,611, 20,347, 1,214; cc7 13,614, 14,106, 17,152, 1,313; cc8 22,470, 23,949, 4,026, 4,365; cc9 15,927, 17,899, 14,302, 463. Before them cc1 had 1,417 free, cc9 1,539, cc6 1,991, cc7 2,257. The buffers take 128-256 bytes a pass; VARS_MAX went from 380 to 400 in cc5-cc7 and cc9, because the passes' own sources (they compile themselves) now have 384 variables in cc8's. With --xisa the tables start on a page boundary, so a pass's room moves in steps of 256 as its image crosses one: cc9's image is 21 bytes below the next, cc1's 91.)

Every pass fits. The nine together are 123,811 bytes of code against y1cc.c's 75,445: each carries the shared utilities and file code, and the work split across passes costs its intermediate records. What made them fit: the runtime helpers' text moved into lib/y1ccrt.txt (about 3K out of cc9); the call graph and the layout read the call and declaration lists instead of bodies (they had to hold whole functions); the labels made again from their owners (cc5 and cc9 each held all the label text, 5-8K for the bigger programs); the symbol and tree files a column at a time (1.5K of code each in cc7 and cc8); cc1 passing on only the names that become tokens (the pass sources intern about 700 names, about 360 of them #defines that later passes never see); cc7's name lookups hashed instead of an array per name.

The table sizes (c/ylim/; c/plim_host.h has the Mac's, far larger) are set so that the whole corpus except y1cc.c, and the nine passes' own sources, compile: 760 names in cc1 (5,400 bytes of text, #defines included), 400 identifiers after it (2,600 bytes), 170 functions, 380 variables (128 globals), 160 string literals (1,800 bytes), 920 AST nodes in one function (the biggest function of the passes, in cc8, has 856), 40 nodes in one expression (the corpus has 31), 200 generated labels in one function, 64 cases in a switch. passes.py builds the passes on the Mac with exactly these sizes (only the path pool is larger: Mac paths are absolute) and compiles the corpus through them: 125 identical to y1cc.py, 4 identical errors, and y1cc.c the one program that does not fit (833 names, 130K of source). So the chain can compile itself: each of the nine passes, compiled by the nine, is identical to what y1cc.py makes of it.

The stack

y1cc gives every local a fixed address; the stack holds only return addresses, pushed operands and parked arguments, and the frames saved around a call inside a recursive cycle. passes.py measures how deep that goes: it reads, in each pass's y1cc assembly, the bytes each function has on the stack at each of its calls (return address included; a runtime helper, the Y1/OS I/O layer and a syscall are counted there, a syscall with an allowance of 64 bytes for the OS, a ROM routine 16), builds the pass on the Mac with -finstrument-functions (c/stackprobe.c), and replays the YACC1 stack on every call while the corpus and the passes' sources compile. The deepest points are the "stack" column. They grow with nesting: about 144 bytes per level of parentheses in cc2 (its recursive descent: ((((1)))) 12 levels deep takes cc2 to 2,004 bytes), 62 per level of operators in cc8 (x + (x + (...))), 28 in cc7, 16 in cc6; the other passes do not recurse on the input.

Where it goes (built 2026-09-25): at the top of each pass's own program area, growing down from $CFFF towards its tables, which end at $5000 + image + tables; the "free" column is the room beyond the deepest point measured (cc2: about 15 more levels of parentheses than the corpus uses, cc8 about 45 levels of operators). Not the monitor's $0C00-$0EFF: that 768 bytes is shared with the shell that runs the program and with every syscall, and cc2 alone needs 1,202 on the corpus. y1cc's --stack ADDR ("How the generated code works" above) was chosen (2026-09-25) over Y1/OS giving every program the area's top: os/Makefile passes builds each pass with --stack 0xCFFF (build/cc/NAME.bin), and passes.py measures that build. On the emulator, tests/native/run.py runs the passes under Y1/OS with the program watch (emulator -S) and checks that each one's deepest point stays above its last byte of data (below, "Native").

What was left for native (all done 2026-09-25)

  • Files over 64K (done: Y1/OS positions and lengths are 24 bits, files up to 16M). 111 of the corpus's 125 compiles kept every intermediate file and the output under 64K; y1os.c (W.se 174K, its assembly 152K), md.c, awk.c, grep.c, vi.c and the passes compiling themselves (their assembly 68-259K) did not.
  • Open files (done: c/target_inc.c, the lexer's reads). Y1/OS has four handles, one of them the file being written, so cc1 can keep only three sources open, and the /BIN commands nest five (cat.c, lib_stdin.c, lib_globx.c, lib_fs.c, lib_abi.c): a virtual handle per file, the innermost three real; the outermost is closed and later opened again and SEEKed (the new syscall) to where it was.
  • Exit, and nine programs in a row (done: the syscalls EXIT and EXEC, SYSTAB2 for them; /BIN/CC), /LIB/Y1CCRT.TXT on the disk (done), room on the disk for the intermediate files (cc8's source compiling itself: 740K; the emulators' card grows, a real card is far bigger), and the stack (done: --stack, above).
  • The passes run under Y1/OS, one by one and chained (below). Still open: the code size work (every byte y1cc saves shrinks the passes too) - since 2026-09-25 the passes are built with --xisa, because with the chaining and the lexer's include stack cc1, cc6 and cc9 no longer fit without it; they need the 2026-09-24 microcode on the machine.

Native: the compiler on the emulated YACC1 (2026-09-25)

cc /SRC/FIB.C -o FIB.ASM --org 0x5000 --os      # under Y1/OS: y1cc's command line, the nine passes chained
asm FIB.ASM                                     # /BIN/ASM: the program file FIB (asmc: the C build, the same)
run FIB
  • On the disk (make -C os): /BIN/CC (os/commands/cc.c), the passes /LIB/CC/CC1..CC9 (make -C os passes: c/target/NAME.c compiled with --org 0x5000 --os --stack 0xCFFF --xisa), /LIB/Y1CCRT.TXT (the runtime text cc9 reads) and /LIB/Y1LIB.C. #include "name" looks beside the including file, then in /LIB with the name upper-cased (target_io.c io_lib), as the Makefiles put files on a disk.
  • The chain: cc EXECs /LIB/CC/CC1 with CCW + its own command line; every pass ends (target_io.c main, or io_done after a deferred error) by EXECing the next one, named in its target/NAME.c (io_next_pass), with the work prefix CCW as its whole command line; cc9 returns to the shell. A pass that finds an error prints it on the screen (lib_err.c eputs, so not into a > file) and EXITs with status 1, which ends the chain. The work files CCW.opt..CCW.em stay in the current directory (the next compile replaces them; pack gets the space back).
  • The I/O layer: c/target_io.c (every pass: the command line, /LIB, the output file, EXIT/EXEC) with c/target_rd.c (plain reads: passes 2-9) or c/target_inc.c (the lexer: a stack of virtual handles, the three innermost real, the others closed and SEEKed back to on the way out; the /BIN sources nest five #includes). Buffered since 2026-09-25 (a syscall per byte read or written was a third of a compile): io_getc takes the next byte from a buffer of IO_RB bytes that the syscall READN fills (Y1/OS's 25th, os/README.md: up to n bytes, never past a sector); io_wput collects IO_WB bytes for one WRITE, io_wputs a whole line (cc9). IO_RB and IO_WB are in each pass's ylim/NAME.h (64 or 128; cc1 and cc9, the fullest, 64). One read buffer is enough: a pass reads one file at a time (target_rd.c: the buffer belongs to the handle read first; another one, only ever read on the way to an error message, goes byte by byte), and the lexer switches files only at an #include and at an included file's end (target_inc.c: the file it leaves gives back the bytes it has not had and is SEEKed there when it is read again). io_skip (io.h, for the passes that skip function bodies in W.ast) moves through the buffer a block at a time. The files' bytes are the same as before.
  • First run, 2026-09-25: target_io.c, compiled since 2026-09-24 and never run, worked at once: the passes run one by one with the shell's run compiled hello, fib, sieve, calls, globals, chars, structs, switch, rfact and stack byte-identically to y1cc.py (the header's date is 0000-00-00 00:00: Y1/OS has no clock); then chained with cc, and cat.c (five nested #includes) likewise. hello takes 5.7M instructions, the others 23-39M, cat.c 92M; cc1 (the lexer, a syscall per source byte) and cc9 (the text, a syscall per output byte) take most of it.
  • The stack: emulator -S reports each pass's lowest SP; the deepest on the ten was cc2 at $CC8F (880 bytes, rfact's expressions), everything else under 250 bytes, and every pass kept at least 1,476 bytes between its stack and its last byte of data (cc1; 272 before the passes were built with --xisa). Since the buffered I/O and the speed work (2026-09-25) the least is 373 bytes (cc1), then cc9 518 (passes.py: 314 and 463 free, below).
  • The test (tests/native/run.py, in make check and make native-test): 27 compiles on the instruction-level emulator, 4 of them (hello, fib, echo, cat) also on the microcode emulator (--all-uc: all 27, 27 of 27 on 2026-09-25 in 1.53G instructions, 25.4G steps, 49.2G clocks: 13.7 hours at 1 MHz, 18 minutes of emulation): every tests/compiler program that compiles (with its own flags: --stack, --no-brur, --xisa), fib again with --xisa, the /BIN commands hello, echo, cat and wc, and the compiler's own pass 4 (c/target/calls.c: the native compiler compiling itself). Each is compiled by cc in its source directory (the sources are on the disk under /R as in the repository), assembled by /BIN/ASM and, when it can run under the OS, run with its output redirected to a file. The host then checks, 27 of 27 on 2026-09-25: the assembly is byte-identical to y1cc.py's (the date masked), the program file byte-identical to the host toolchain's (y1cc.py + the host assembler
  • img2bin; the four commands also to the Makefile's /BIN builds), and the output is the test's .out (or, for a command, what /BIN's own build prints in the same session). The deepest stack of any pass kept 1,474 bytes of room above its data (cc1); 373 since 2026-09-25's buffers (above).
  • How long (instructions on the instruction-level emulator; the microcode emulator counted 16.6 steps and 32.1 clocks an instruction on hello, fib, echo and cat before 2026-09-25's speed work, 16.7 and 32.4 after, so at 1 MHz an instruction is about 32 us). Before and after the buffered I/O and the passes' hot spots (2026-09-25, the same 27 programs in the same session, tests/native/run.py --int):
program compile before after assemble before after all at 1 MHz before after compile alone
hello.c (9 lines) 5.8M 3.5M 0.9M 0.8M 1.5x 3 min 32 s 2 min 20 s 1.6x
fib.c 28.1M 10.9M 5.8M 5.5M 2.1x 18 min 09 s 8 min 51 s 2.6x
echo.c (/BIN/ECHO) 11.7M 5.1M 0.6M 0.6M 2.2x 6 min 36 s 3 min 05 s 2.3x
cat.c (5 nested #includes) 92.4M 31.3M 13.6M 12.9M 2.4x 56 min 40 s 23 min 50 s 3.0x
wc.c 107.7M 36.4M 16.8M 16.0M 2.4x 1 h 06 min 28 min 18 s 3.0x
xisa.c (the biggest test) 132.9M 50.0M 28.4M 27.3M 2.1x 1 h 26 min 41 min 44 s 2.7x
pass 4 of the compiler (target/calls.c as of 2026-09-25 morning, 60K of assembly) 216.8M 72.6M 35.2M 33.5M 2.4x 2 h 14 min 57 min 3.0x
all 27 1,269M 468M 225M 217M 2.2x 13 h 19 min 6 h 09 min 2.7x

(Pass 4's row compiles the same source both times; the test compiles the tree's own calls.c, which the I/O work itself grew: 78.8M + 35.7M now.) The microcode emulator's clocks agree: hello, fib, echo and cat with their runs took 5.26G clocks before, 2.45G after (2.1x).

Where the time went, and what was done (tests/native/profile.py: the emulator's PC histogram, emulator -P, turned into instructions per function of every pass, the assembler and the OS): - The OS's byte path: GETC/PUTC were about 100 instructions a byte with the syscall, the handle checks and the 24-bit position, a fifth of all the time and most of cc3-cc8's; now READN and WRITE copy a sector's bytes at 7 instructions each (os/README.md) and the passes' buffers cost about 30 (io_getc's fast path). - cc9 made every code line's text three times, once per section, and dropped it twice: it now reads past an instruction record in the data and BSS readings (r_skip). Its peephole parsed each line three times (now once: the pending line's words are kept); bnum did two divisions a digit (now subtraction by powers of ten, pnum); bcat/bchr/s_len indexed (now pointer walks, and the instruction builders append at the line's end, Ls); lcand built a label with a bchr per character. cc9: 33.5M -> 9.2M on cat.c. - cc1 kept the lookahead in an array indexed by the include depth (a third of the lexer's time; now three variables, saved and restored at an #include), tried all 45 operators for every punctuation mark (now a chain per first byte, op_chains), and hashed every name with a multiply and a division per character (now 5h + c and a mask: the tables are 128/256 entries). cc1: 22.4M -> 5.1M on cat.c. - cc2: tk/tv/is_op/is_kw call need_tok only when the token is not read yet (a third of the parser's time); cc4: the reach matrix's closure walks row pointers (it multiplied by the row length per bit and per byte); cc5: lhash as cc1's; rarr/warr and the skips through pointers and io_skip. - /BIN/ASM: getln tests a character against ;, : and the quotes only when it is at most ; (5% of the assembler). The assembler was then the biggest single step (30%): getln (a third of it: each source byte is copied, upper-cased and parsed, about 50 instructions), hash, asmcmd, same; the assembly text itself (cc9 writes 60-260K and the assembler reads it twice) is the design's cost. 2026-09-26: /BIN/ASM in YACC1 assembly (os/commands-asm/asm.asm, the same behaviour byte for byte; the C build is /BIN/ASMC; docs/programming/ASSEMBLER.md section 10): the same 27 programs,

| | assemble | compile and assemble | at 1 MHz | the assembler's share |
|---|---|---|---|---|
| `/BIN/ASM` = asm.c (2026-09-25) | 216.5M | 687.4M | 6 h 11 min | 31% |
| `/BIN/ASM` = asm.asm (2026-09-26) | 46.5M | 517.4M | 4 h 39 min | 9% |

(cat.c: 12.9M -> 2.8M to assemble; pass 4: 35.7M -> 7.5M.) The compiler's passes are now 90% of a native build.
  • Left: each pass's load by the ROM and main's BSS clear (about 2M instructions a compile: half of hello's time), cc9's text building (mn_arg, Ls, bcat: about 20% of a compile), cc2's parser, cc7's and cc8's table and tree records (wi/ri a byte at a time through io_wput/io_getc).

Self-host: the toolchain rebuilds itself on the emulated YACC1 (2026-09-25)

Yes, the compiler compiles itself natively. tests/native/selfhost.py (make selfhost, and in make check: about 30 seconds on the Mac) runs, under Y1/OS on the instruction-level emulator, with the toolchain's own sources on the disk under /R as in the repository (the pass sources, c/target/, c/ylim/, os/lib_*.c, os/asm_optab.c, os/commands/asm.c and cc.c, and since 2026-09-26 os/commands-asm/asm.asm with asmtab.inc; /LIB/Y1LIB.C and /LIB/Y1CCRT.TXT are on the OS disk):

  1. Stage 1, the native build: the host-built /BIN/CC + /LIB/CC/CC1..CC9 compile each of the nine passes (c/target/NAME.c, --org 0x5000 --os --stack 0xCFFF --xisa as os/Makefile builds them), the assembler os/commands/asm.c and the driver os/commands/cc.c (--org 0x5000 --os, the /BIN flags), each in its source directory; the host-built /BIN/ASM assembles each into a program file. The host fetches the eleven assemblies and program files off the image and compares them with os/build (y1cc.py + the host assembler + img2bin).
  2. Stage 2, the fixed point: a fresh OS disk with the natively built CC1..CC9, CC and ASM installed in place of the host builds, and the same eleven compiles and assemblies again.

Result, 2026-09-25, the first run: all eleven identical to the host builds in both stages (assembly byte for byte with the header's date masked, program files byte for byte), so the natively built toolchain reproduces itself exactly. Nothing had to change: every table held (cc1's names, cc8's 1,383 labels in /BIN/ASM's 17,088-byte pool, asm.c's 117K of assembly), every pass kept its stack above its data (the least room: cc1 373 bytes, cc9 522, cc7 1,333, cc6 1,259), and the 24-bit files took the biggest (cc8's source: work files 514K, CCW.se 248K of them, and 235K of assembly).

program assembly program file compile assemble both at 1 MHz
cc1 lex 148K 13,733 172.0M 82.8M 254.9M 2 h 17 min
cc2 parse 161K 15,630 175.4M 94.1M 269.5M 2 h 25 min
cc3 decl 90K 8,134 114.3M 51.6M 165.8M 1 h 29 min
cc4 calls 65K 5,562 84.5M 37.9M 122.4M 1 h 06 min
cc5 layout 90K 8,063 117.6M 53.5M 171.1M 1 h 32 min
cc6 stmt 115K 10,611 134.6M 65.8M 200.4M 1 h 48 min
cc7 sema 157K 14,106 172.1M 90.4M 262.5M 2 h 21 min
cc8 emit 230K 23,949 261.0M 129.7M 390.8M 3 h 31 min
cc9 final 172K 17,899 224.9M 97.0M 321.9M 2 h 53 min
/BIN/ASM (asm.c) 117K 13,186 131.7M 58.5M 190.2M 1 h 42 min
/BIN/CC (cc.c) 5K 498 13.1M 2.5M 15.6M 8 min
the toolchain 1.35M 131,371 1,601M 764M 2,365M 21 h 17 min

(Instructions on the instruction-level emulator, emulator -S; the same in stage 2, whose tools are the same bytes. At 1 MHz with 32.4 clocks an instruction, tests/native/run.py's ratio. The microcode emulator ran stage 1 as well (selfhost.py --stage 1 --uc, 30 minutes on the Mac): all eleven identical, 2,378M instructions with the boot and the shell, 40.9G steps (17.2 an instruction) and 79.3G clocks (33.4 an instruction): 22 hours at 1 MHz, measured.) So on the machine one full rebuild of the toolchain by itself would take most of a day; the fixed-point check doubles it. Where the time goes is as for the other native compiles ("How long", above): cc9's text and the lexer; /BIN/ASM was a third (getln, hash).

2026-09-26: /BIN/ASM in YACC1 assembly. The self-host now rebuilds twelve programs: the eleven above (asm.c's build installed as /BIN/ASMC) and the hand-written assembler os/commands-asm/asm.asm, which the native /BIN/ASM assembles in its own directory (its INCLUDE asmtab.inc); stage 2 installs the native builds of both assemblers, and all twelve come out identical again: the fixed point holds with the assembly-language assembler. One stage:

program assemble with asm.c with asm.asm faster both, at 1 MHz: before after
cc1 lex 82.8M 16.6M 5.0x 2 h 17 min 1 h 41 min
cc2 parse 94.1M 18.8M 5.0x 2 h 25 min 1 h 44 min
cc3 decl 51.6M 10.1M 5.1x 1 h 29 min 1 h 07 min
cc4 calls 37.9M 7.2M 5.3x 1 h 06 min 50 min
cc5 layout 53.5M 10.2M 5.2x 1 h 32 min 1 h 09 min
cc6 stmt 65.8M 12.8M 5.1x 1 h 48 min 1 h 19 min
cc7 sema 90.4M 17.7M 5.1x 2 h 21 min 1 h 42 min
cc8 emit 129.7M 26.4M 4.9x 3 h 31 min 2 h 35 min
cc9 final 97.0M 19.9M 4.9x 2 h 53 min 2 h 12 min
asm.c (/BIN/ASMC) 58.5M 13.1M 4.5x 1 h 42 min 1 h 18 min
cc.c (/BIN/CC) 2.5M 0.6M 4.1x 8 min 7 min
the eleven 763.8M 153.4M 5.0x 21 h 17 min 15 h 47 min
asm.asm (/BIN/ASM, assembled only) 42.8M 9.3M 4.6x 23 min 5 min
the toolchain now 15 h 52 min (1,764M instructions)

(Compiling is unchanged, 1,601M; the assembler's share of a stage fell from 32% to 9%. At 1 MHz with 32.4 clocks an instruction; asm.asm itself runs at 29.4 clocks an instruction on the microcode emulator, so its hours are a little less than shown.)

  • The disk: the P8XFS volume grows as the compiles write (the emulators' card model extends the image; the OS has no size of its own, only the 16-bit free pointer: 32M at most). Each stage writes about 9,400 sectors (4.7M), most of it work files the next compile replaces (CCW.*: 514K for cc8's source); at the end 5,624 sectors are live (2.8M: the OS disk's 0.5M, the sources' 0.4M, the outputs and the last work files) and 5,747 reclaimable by pack. So a 1M card is too small; any real CF card (16M and up) holds a whole stage without packing.
  • What remains for the real machine: the CF interface for Y1/OS, the 2026-09-24 microcode (the passes use --xisa), and a day of run time. y1cc.c, the single-program twin, still does not fit the passes' tables (833 names); it is not part of the native toolchain.

y1cc.c stays as the single-program C twin: it is what the passes were cut from, twin.py keeps it identical to y1cc.py, and it is the quicker program to read. A change to y1cc.py now has two C counterparts to follow it; once the passes run on the machine, y1cc.c can go.

Not done yet (BACKLOG "C compiler")

Running a compiled program on the real machine needs a way to load RAM (the monitor's E-command loader on the backlog, or the bus tester with the CPU off); signed types; peephole work (the code is straightforward, roughly 2-3x what hand assembly would be); the P8X-side libraries. (switch done 2026-09-22, recursion 2026-09-24.)

Size against the P8X compiler

bench/sizecmp.sh compiles the same four programs (written in the subset both compilers accept) with p8cc + p8xasm and with y1cc + asm and compares the binaries, uninitialised data included on both sides (2026-09-22):

program P8X bytes YACC1 bytes ratio
fib 1075 813 0.76
sieve 760 667 0.88
sort 1114 896 0.80
strings 1030 782 0.76

The YACC1 binaries are 14-26% smaller for the same source (2026-09-22 evening, after main gained its 20-byte BSS clear). The same four programs rewritten with everything y1cc accepts (bench/full/: the library's putnum, ++, +=, ?:, continue, pointer loops, char loop counters) come out only a little smaller — sieve 633, fib 769, strings 713, sort 865 bytes (1-6%) — because i++ and i = i + 1 are the same code; what saved bytes was char counters (one-byte compares) and pointer walks. The size is in the code model, not the syntax. The reasons are in the instruction sets rather than in the compilers: y1cc keeps every scalar at a fixed address, so a load or store is one 3-byte LDR/STR and a 16-bit constant is one 3-byte MVIW, while p8cc's frame-relative LDW/STW (P3+d) and LDW __ax,#n (4-5 bytes) plus its memory-word arithmetic helpers cost more per operation; the YACC1's register INCR/DECR and the comparator branches are 1-3 bytes where the P8X needs a memory word op. Speed is another matter: a YACC1 step is two clocks and an instruction 8-30 steps, so the emulator's instruction counts above translate to roughly 10x the clock cycles of the same work on the P8X. Integer literals over 65535 are a compile error (int is 16-bit).