All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog,
and this project adheres to Semantic Versioning - while
the project is in 0.x, breaking changes may land in MINOR releases; PATCH
releases are reserved for bug fixes only.
-
v1.0 milestone: Complete Single-File Language — every must-have and should-have feature from
docs/roadmap.md's v1.0 checklist is now implemented: function calls, pointer store, address-of, implicit widening, string literal locals, pointer arithmetic (*(ptr + i)), break/continue, struct field access, multiply/divide/modulo, and bitwise/shift operators. The only items left unchecked are explicitly-optional "nice-to-have"s (short-circuit&&/||codegen outside boolean-context use, and the&=/|=/^=/<<=/>>=compound assignment forms) — the roadmap's own goal, "someone can sit down and write a non-trivial 65C02 program without hitting an unimplemented wall," is met; -
breakandcontinue— both now supported insidewhileandforloops. The analyzer tracks aloop_depthcounter (incremented for the duration of a loop body) and rejects either statement outside a loop (ERR_BREAK_OUTSIDE_LOOP/ERR_CONTINUE_OUTSIDE_LOOP). The IR generator maintains aloop_ctx_t { continue_label, break_label }stack; both statements desugar to a plainTAC_JUMPto the appropriate label, so codegen needed no changes. Forforloops,continuejumps to a new label sitting between the body and the incrementer, so the incrementer still runs before the next condition check. Seedocs/break-continue-implementation.mdfor the full design writeup, including a bonus fix along the way: thefor-loop incrementer was being lowered withlower_exprinstead oflower_stmt, soi = i + 1-style incrementers (as opposed to++i) were silently generating no code; -
Local string literal initializers —
u8 *p = "some string";now works inside function bodies, not just at global scope. The string data is placed in the ROM data section via the existingdata_fixup_tbackpatch mechanism (same as global strings); the pointer value (ROM address of the string) is written into the local's ZP slot during function entry viaTAC_COPYwith anOPERAND_CONST_STRsource. Callee-saves push and pop the pointer ZP slot across nested calls to preserve it across function boundaries. The pointer occupies 2 bytes of ZP for the lifetime of the function; -
Variable shadowing is now a semantic error — a variable declaration (or function parameter) that reuses a name still visible from an enclosing, still-live scope now raises
ERR_SHADOWED_DECLARATIONinstead of silently compiling. Root cause: the IR/codegen identify a variable purely by its bare name (OPERAND_VAR.name, matched viastrcmpinzp_map_build), with no per-scope qualifier, so a shadowed inner declaration (e.g. afor (u8 i = 0; ...)nested inside a function that already has an outeru8 i) aliased the same zero-page storage as its outer namesake — the inner loop's own init/exit value silently clobbered the outer variable. This surfaced asbreakfiring on the wrong iteration inarithmetic_demo.c02. Reusing a name across scopes that never overlap on the scope stack (e.g. two siblingforloops each declaring their owni) is unaffected and remains legal;
-
ROM overflow detection — the
EMIT()macro now bounds-checkscode_posagainstROM_SIZEbefore writing, setting anoverflowflag onemitter_tinstead of silently writing past the end of the 32 KB buffer. A newPATCH_BYTE(pos, val)macro applies the same guard to all branch-offset and address backpatches.resolve_func_fixupsandresolve_local_fixupsearly-return when overflow is already set.generate_romcheckse.overflowindependently after the code section, data section, and symbol-table phases, printing a targeted diagnostic and returningNULLon failure. Previously, programs that grew past 32 KB would corrupt the ROM buffer without any error.emit_symbol_tablenow also writes throughEMIT()instead of raw pointer writes, inheriting the overflow guard. -
__heap_startimplicit compiler global — programs may declaredecl u16 __heap_start;to read the address of the first free RAM byte after all user globals are allocated. The driver injects the declaration automatically so no userdeclis required in practice. The value is initialized during the bootstrap sequence. Intended as a base pointer for simple bump allocators. -
__memory_topimplicit compiler global —decl u16 __memory_top;evaluates to$3FFF, the top of the general-purpose RAM region (seedocs/memmap.md). Injected alongside__heap_start. Both constants are defined asRAM_TOPin the codegen memory map. -
Compiler extern two-pass allocation —
emit_compiler_extern_initsis refactored into an explicit two-pass design: pass 1 allocates all RAM slots (settlinge->ram_pos), pass 2 emits initializers. This ensures__heap_startcaptures the correct first-free-RAM address regardless of declaration order. Unknown non-function externs now print a diagnostic and cause codegen to fail instead of being silently skipped.ALLOC_COMPILER_SLOT/EMIT_COMPILER_VALUE— pair of local macros that collapse the slot-allocation and value-emission boilerplate to one line each; both are#undef'd immediately after the function.
-
16-bit multiply / divide / modulo (
__mul16,__div16,__sdiv16) —TAC_MUL,TAC_DIV, andTAC_MODonu16/i16operands now compile to subroutine calls rather than erroring. Three new helpers:__mul16— 16-iteration shift-and-add. Correct for bothu16andi16because the low 16 bits of a two's-complement product are sign-agnostic. Overflow silently wraps to the low 16 bits (same as C).__div16— 16-iteration shift-subtract with CMP-based comparison. Uses aBCS dosubguard before the 16-bit subtract so divisors with bit 15 set (≥$8000) are handled correctly; the naive SEC-before-compare approach clobbers the overflow carry and produces wrong quotients for those values.__sdiv16— sign wrapper around__div16, mirroring__sdiv8: encodes signs inHELPER_SIGN($EC, bit 7 = negate quotient, bit 6 = negate remainder), negates both operands, calls__div16, then restores signs. Follows C truncation-toward-zero convention.needs_sdiv16 = 1impliesneeds_div16 = 1.- New 16-bit helper ZP zone:
$E0–$E7(HELPER16_ARG1/ARG2/RES/REM, 2 bytes each), below the existing 8-bit zone at$E8–$EC. - ZP operand map upper bound tightened from
$EEto$DFto reflect the new reserved zone;zp_map_addnow enforces this with an address guard (previously only a count guard existed). EMIT_ARITH8(ROUTINE, RES_SLOT, NEEDS_FLAG)/EMIT_ARITH16(ROUTINE, RES_SLOT, NEEDS_FLAG)— pair of local macros replacing the three verbose switch arms; bit width, arg-loading, and result-storing all collapse to one line per dispatch branch.- Emulator tests:
mul_u16(300 × 13 = 3900),mul_u16_wrap(256 × 256 = 0, overflow),div_u16(50000 /$C001= 1, remainder = 847 — exercises the high-bit-divisor path).
-
Symbol table embedded in ROM — compiled binaries now carry a
"C02S"symbol table in the NOP fill area between the data section and$FFF6, lettingc02-objdumpshow real function names instead of auto-generated labels. The table is always emitted by default;--strip-debugomits it. Binary size stays exactly 32 KB so EEPROM flashing is unaffected.- Footer layout:
$FFF6–$FFF7= little-endian pointer to the table (or$EAEANOP fill if absent);$FFF8–$FFF9= code/data boundary (unchanged);$FFFA–$FFFF= NMI/Reset/IRQ vectors. - Format: magic
C02S(4 bytes) + u16 entry count (LE) + entries of u16 address (LE) + null-terminated name. All user-defined functions and emitted helpers (__mul8,__div16, etc.) are included. - Old binaries degrade gracefully:
$EAEAat$FFF6passes the range check but fails the magic-byte check, so the disassembler falls back toL0/L1/… auto-labels without error. c02-objdump:parse_symbolsreads the table and merges it into the jump-target label map;scan_endfor the data-section boundary scan stops at the symbol table start rather than$FFF8to avoid misidentifying table bytes as data.driver.h:params_tgainsint strip_debug;main.cadds--strip-debugtolong_options.
- Footer layout:
-
ZP map overflow error propagation — previously,
zp_map_addprinted a diagnostic to stderr and silently continued, potentially generating corrupt code.zp_map_add,zp_map_add_operand, andzp_map_buildnow all returnint(0 = failure);emit_function_from_cfgcheckszp_map_buildand returns 0, propagating togenerate_romwhich returnsNULL— the same path as all other codegen failures, ultimately exiting withCODE_GEN_ERROR_RET_CODE(7). -
Bug fix: signed 8-bit division and modulo (
__sdiv8) —TAC_DIVandTAC_MODoni8operands previously routed through the unsigned__div8helper, soi8 -6 / 2computed250 / 2 = 125instead of-3. A new__sdiv8helper wraps__div8: it saves operand signs into a scratch byte at$EC(HELPER_SIGN), negates both operands to their absolute values, calls__div8, then restores the correct sign on the quotient (bit 7 of SIGN) and remainder (bit 6 of SIGN) per C's truncation-toward-zero convention. Codegen routesTAC_DIV/TAC_MODthrough__sdiv8whenis_signed_type(dst.type);__sdiv8always calls__div8, soneeds_sdiv8 = 1impliesneeds_div8 = 1. New opcode emitters:bpl_rel.$ECadded to the helper ZP zone. -
Emulator tests:
div_i8(−6 / 2 = −3, PORTB = $FD),mod_i8(−7 % 2 = −1, PORTB = $FF). -
Bug fix: binary op operand widening and sign normalisation — binary ops derived both the result type and comparison signedness from the LEFT operand only (
ir.c), ignoring the right operand entirely. Consequences:u8 + u16computed at 8 bits (the u16 high byte was silently dropped), whileu16 + u8was accidentally correct;i8 < u8used a signed compare whileu8 < i8used an unsigned compare — a trichotomy violation where botha < bandb < acould be simultaneously true. Fixed in the IR generator with four new helpers:ir_type_width/ir_is_signed— predicates for 8-vs-16-bit and signedness without anir_gen_t *context.binop_common_type(left, right)— returns the wider type; for equal widths, unsigned wins (C's usual arithmetic conversions), eliminating operand-order dependence in mixed-sign comparisons.emit_widen_if_needed(gen, cfg, op, target)— emitsTAC_CASTwhen the operand type differs from the target;OPERAND_CONST_INTvalues are re-typed in place (no instruction emitted).- Binop lowering now normalises both operands to the common type before arithmetic and comparison ops. Shifts are guarded separately (result type = left, shift count is never widened). Pointer arithmetic skips widening.
-
Emulator tests:
binop_widen(u8(1) + u16(500) = 501, high byte = $01),cmp_mixed_sign(i8(−1) < u8(100)with unsigned-wins → $FF reinterpreted as 255, 255 < 100 = false, branch not taken, PORTB = $01).
-
Function call codegen (
TAC_CALL) — full caller/callee ABI using a fixed 2-byte ZP slot per parameter in the$EF–$FEABI zone. Caller copies each argument into its slot ($EF/$F0for arg 0,$F1/$F2for arg 1, etc.) viaemit_load_byte, which zero-extends u8 args into the hi byte automatically. Callee prologue (emit_function_prologue) copies the ABI zone into the function's own ZP slots at function entry; no-arg functions (includingmain) skip the prologue entirely. Return values are already handled by the existingTAC_RETURN→$02/$03path;TAC_CALLcopies$02/$03into the destination temp afterJSR. Void calls skip the copy (TYPE_VOIDguard). Max 8 parameters enforced at codegen with a hard error. Note: signed narrower→wider widening at call sites (e.g. i8 arg into i16 param) is zero-extended, not sign-extended; a future IR pass should insert explicitTAC_CASTnodes for implicit widening in call arguments. -
Callee-saves ZP preservation — every function (except
main) pushes all of its ZP slots onto the hardware stack at entry (viaPHA) and pops them in reverse order before everyRTS(viaPLA). This ensures the caller's locals and temporaries survive across calls regardless of ZP slot overlap. As a direct consequence, bounded recursion is now supported — stack depth is limited to ≈256 / (function's ZP byte count) levels. -
Emulator tests:
func_call_u8(add(10,32)=42),func_call_void(write_port(99)→PORTB=99),func_call_u16(sum16(200,300)=500, lo byte=244),func_clobber(x=5 survives callee overwriting its ZP slot; result=11),func_recursive(factorial(5)=120, exercises 5-level LIFO stack discipline). -
Struct field access codegen (
TAC_FIELD_LOAD/TAC_FIELD_STORE) — field reads and writes for both by-value and pointer-to-struct operands. By-value structs useemit_load_byte/emit_store_bytewithfield->offset + bso global structs automatically get absolute addressing. Pointer-to-struct usesLDY #(offset+b); LDA/STA ($ptr),Ywith RAM→ZP sync when the pointer is a global.full_type_size(e, type)— struct-aware sizing that consultsir_module_t.structsforTYPE_STRUCT, replacing the silentdefault: return 1fallback. Used in ZP map allocation (zp_map_add),allocate_globals,TAC_COPY, and the new field ops.zp_map_buildnow takesemitter_t *eso struct sizes are available during map construction.lookup_struct_field(e, struct_name, field_name)— field offset/type lookup from the IR module, used by bothTAC_FIELD_LOADandTAC_FIELD_STORE.ir_gen_t *genadded toemitter_tso the module's struct table is reachable from all codegen paths.
-
Bug fix:
++field/--fieldnot writing back —NODE_INC/NODE_DECon a field-access target (++str.val) was loading the field into a temp, incrementing the temp, then silently discarding it. The field was never updated so the loop advanced zero steps each iteration. Fixed in the IR generator: field-access targets now emitTAC_FIELD_LOAD → TAC_INC/DEC → TAC_FIELD_STORErather than loading a throwaway temp. -
Pointer arithmetic (
ptr + int,ptr - int) — the analyzer'sNODE_BINOPtype-check now recognises pointer+integer as valid, returning the pointer type unchanged. The IR already lowered this toTAC_ADD; codegen andTAC_LOADalready handled the resulting pointer correctly, so no codegen changes were needed. -
Zero-extension fix in
emit_load_byteandGLOBAL_AWARE_ALU_HELPER— both helpers were readingZP + byteeven whenbyte >= operand_size, pulling in whatever occupies the adjacent ZP slot as a phantom high byte. Classic symptom:u8 iused as an index into au8*pointer computesptr + i + (adjacent_slot × 256)— always a wrong address, always the same garbage byte. Both paths now emitLDA #0/IMM_FN(e, 0)for out-of-range byte indices.OPERAND_CONST_INTwas already correct via shift+mask. Fixeslcd_hello_world_simplified.c02printing one garbage character in a loop instead of "Hello C02!". -
Emulator tests:
field_local,field_global,field_ptr,lcd_simplified(verifies all 10 PORTB writes match "Hello C02!" in order).
TAC_ADDR_OF(&x) — address-of operator codegen. Globals resolve to their RAM address (g->ram_addr); locals/temporaries resolve to their ZP slot address. The 16-bit address is stored into the destination via twoLDA imm; STA zpgpairs (lo byte then hi byte).TAC_STOREpointer destination (*p = val) — variable-destination pointer stores now emit byte-wiseLDA src; STA ($ptr),Yindirect indexed writes. The pointer's ZP slot is kept in sync from its RAM address before indirect access when the pointer is a global.- Bitwise ops (
TAC_BAND,TAC_BOR,TAC_BXOR,TAC_BNOT) — width-aware byte loops.AND/ORA/EORuse global-aware helpers (emit_and_byte,emit_ora_byte,emit_eor_byte) dispatching imm/zpg/abs variants.TAC_BNOTbyte-loopEOR #$FFs each byte. New opcode emitters:and_imm,and_zpg,and_abs,eor_zpg,eor_abs. - Shift ops (
TAC_SHL,TAC_SHR) — both constant-count and variable-count variants. Constant shifts unrollASL/ROL(left) orLSR/ROR(right) pairs per byte. Variable shifts use an X-register counter loop with hardcoded relative branch offsets derived from fixed loop body sizes. Signed right shift (i8/i16) uses theCMP #$80; RORpattern — CMP sets carry = sign bit, ROR shifts it in as the new MSB. New opcode emitters:asl_zpg,rol_zpg,lsr_zpg,ror_zpg,tax,dex,bcs_rel,bcc_rel. TAC_CAST(type cast codegen) — same-width copies, narrowing (low bytes only), and widening with zero-extension (u8→u16) or sign-extension viaCMP #$80; LDA #$FF; BCS +2; LDA #0(i8→i16).TAC_COPYimplicit widening fix —TAC_COPYpreviously used the destination size for all byte indices, reading garbage from adjacent ZP slots when widening (e.g. u8→u16). Now computessrc_size,dst_size, andcopy_size = min(src_size, dst_size), then zero/sign-extends the remaining bytes whendst_size > src_size.TAC_MUL,TAC_DIV,TAC_MODsoftware helpers — 8-bit multiply/divide/modulo via subroutines__mul8(shift-and-add) and__div8(binary long-division). Arguments pass through fixed ZP slots$E8/$E9; quotient/product at$EA, remainder at$EB. Helpers use lazy emission:needs_mul8/needs_div8flags are set during CFG walk; helpers are emitted after all functions and registered as function labels so the existing JSR fixup system resolves their addresses.TAC_DIVandTAC_MODshare__div8, reading$EAvs.$EBfor the result.- ZP arithmetic helper zone —
$E8–$EBcarved from the scratch register range and reserved for__mul8/__div8argument slots. Scratch registers now span$04–$E7.$EC–$EEreserved for future helpers. README and ZP layout table updated. - LOC table reformatted —
test.py --clocoutput is now a single tree-style ASCII table with├─/└─prefixes, aligned columns, and a grand total footer. Adding a new toolchain component = oneSection(...)entry. - Emulator tests:
addr_of_local,addr_of_global,ptr_store_local,ptr_store_global,bitwise_and,bitwise_or,bitwise_xor,bitwise_not,shl_const,shr_const,shr_signed,shl_var,implicit_widen,mul_u8(7×6=42),div_u8(100÷7=14),mod_u8(100%7=2).
- Codegen diagnostic for unhandled TAC ops — the
default: breakin the TAC instruction switch has been replaced with afprintf(stderr)+ error return that names the unhandled op number. Programs using unimplemented features now fail loudly at compile time instead of silently producing wrong binaries.emit_function_from_cfgnow returnsint(0 on failure) so the error propagates togenerate_rom. - Bootstrap emu tests decoupled from analyzer_basic.c02 — the eight
bootstrap-verification tests (rom_size, reset_vector, etc.) now use
emu_store_const.c02instead ofanalyzer_basic.c02, which uses TAC ops not yet implemented in the code generator. - Fix global/ZP bug class —
TAC_INC/TAC_DECon global variables now emitINC abs($EE) /DEC abs($CE) targeting the global's RAM address instead of the stale ZP scratch slot. 16-bit global INC/DEC usesBNE +3(3-byte abs instruction) instead of+2.COMPARE_OPright-hand operands now route through a global-awareemit_cmp_bytehelper that dispatchesCMP abs($CD) for globals. New opcode emitters:inc_abs,dec_abs,cmp_abs. - Emulator tests:
inc_global(global u8 increment),cmp_global(compare local against global with branch). - u8 arithmetic (
TAC_ADD,TAC_SUB) —CLC; LDA src1; ADC src2; STA dstfor addition,SEC; LDA src1; SBC src2; STA dstfor subtraction. Global-aware RHS helpers (emit_adc_byte,emit_sbc_byte) dispatch ADC/SBC abs ($6D/$ED) for globals. New opcode emitters:clc($18),sec($38),adc_imm($69),adc_zpg($65),adc_abs($6D),sbc_imm($E9),sbc_zpg($E5),sbc_abs($ED). CLC/SEC is emitted once outside the byte loop so u16 carry propagation works correctly. - Emulator tests:
add_u8,sub_u8,add_const. TAC_NEG(unary minus) —SEC; LDA #0; SBC [src1]; STA [dst]. SEC is emitted once before the byte loop so borrow propagates correctly for u16.- Emulator test:
neg_u8(double negate round-trips back to original value). - u16 comparisons —
COMPARE_OPmacro replaced with backpatched comparison handlers that support both u8 and u16 operands. u16 ordering (LT/GTE/GT/LTE) uses a high-byte-first pattern: compare high bytes first to determine definite ordering, fall through to low bytes when equal. u16 EQ/NEQ compare both bytes. All forward branch offsets use backpatching instead of hardcoded values, making them robust to variable-size loads (zpg=2 vs abs=3 vs imm=2). - Emulator tests:
cmp_u16_lt(255<256, high-byte decides),cmp_u16_eq(500==500, both bytes match),cmp_u16_gt(1000>255, high-byte decides). - Signed comparisons (i8/i16) — ordering comparisons (LT/GTE/GT/LTE) now
detect signed operand types and emit the N XOR V pattern:
SEC; SBC; BVC +2; EOR #$80; BMI truefor i8, with an extended high-byte-first pattern for i16 (signed high byte via N^V, unsigned low byte fallback via BCC). EQ/NEQ remain sign-agnostic. GTE inverts the LT result. GT/LTE swap operands. New opcode emitter:bvc_rel($50). - Emulator tests:
cmp_i8_lt(-5<3),cmp_i8_gt(3>-5),cmp_i8_neg(-10<-3),cmp_i16_signed(-300<300). - u16 arithmetic —
TAC_ADD/TAC_SUBare width-aware from the start (CLC/SEC outside byte loop, carry propagates between bytes). Signed i8/i16 arithmetic works via two's complement — same ADC/SBC instructions. - Emulator tests:
add_u16(300+200=500),sub_u16(1000-500=500),add_i8(-5+47=42),add_i16(-300+800=500).
- c02-objdump: label markers — jump target labels (
L0:,L1:, ...) now print at the correct positions in the disassembly. The reset vector is read to translate between ROM buffer offsets and absolute addresses. - c02-objdump: full opcode size table — the label pre-scan uses a 256-entry instruction size table covering the entire 65C02 instruction set, replacing the previous partial list that would lose sync on unrecognized opcodes.
- c02-objdump: section-aware output — the code generator now writes a
code/data boundary address at
$FFF8in the ROM. The disassembler reads this to stop before the data section, with a NOP-fill heuristic fallback for older binaries. - c02-objdump: new CLI flags —
-a/--allshows disassembly followed by a.datahex dump with ASCII.-d/--datadumps just the.datasection.-s/--sectionsprints the section layout (.text,.data, vectors with addresses).-S/--sizeprints an Arduino-style ROM usage summary with.text,.data, and vectors breakdown.
- Implicit void return in IR —
lower_functionnow emits a trailingTAC_RETURNwhen the last instruction isn't already a return, so void functions without an explicitreturnproduce correct IR. - Data section & global variable support — global variables are allocated
RAM addresses ($0200 upward) and initialized in the bootstrap before
JSR main. String literals are placed in ROM after all function code. Adata_fixup_tbackpatching system resolves string ROM addresses into the bootstrap init code after the data section is emitted.allocate_globalsassigns RAM addresses with type-aware stride.emit_global_initemitsLDA #imm; STA absper global, with fixup placeholders for string-initialized pointers.emit_data_sectionwrites null-terminated string bytes into ROM and resolves all data fixups.emit_load_byte/emit_store_bytedetect globals and use absolute addressing (LDA abs/STA abs) instead of zero-page.- Bootstrap split into
emit_bootstrap+emit_call_mainso global init code runs between hardware setup andJSR main.
TAC_LOAD(pointer dereference & register reads) — pointer dereference viaLDA ($nn),Yindirect indexed addressing. Hardware register reads useLDA abs. Global pointers are copied from RAM to their ZP slot before indirect access.- 16-bit
TAC_INC/TAC_DEC—INC zpg; BNE +2; INC zpg+1for pointers and u16 values. DEC usesLDA zpg; BNE +2; DEC zpg+1; DEC zpgto propagate borrow. - New opcode emitters:
lda_abs($AD),lda_ind_y($B1),ldy_imm($A0),bne_rel($D0). - Emulator test:
string_deref— global string pointer, loop with*pdereference and 16-bit++p, verifies correct characters reach PORTB. - Hardware verified —
lcd_hello_world_simplified.c02prints "Hello C02!" on a real 65C02 breadboard with HD44780 LCD.
- Line count: disassembler section — refactored
count_linesinto a reusablecount_lines_in(dir, exts)helper and added a Disassembler section for c02-objdump (Rust + Makefile). Also addedtargettoIGNORED_DIRSto exclude Cargo build artifacts. - Control flow codegen — complete control flow generation for the
65C02 target, enabling
for,while, andif/elseto compile and run on real hardware.- Local label system —
TAC_LABELrecords label addresses during emission;TAC_JUMPemitsJMP abswith backward-ref direct patching or forward-ref backpatching vialocal_fixups, resolved at the end of each function. TAC_COND_JUMP— inverted-branch-over-JMP pattern (BEQ +3; JMP target) gives unlimited jump range from a 1-byte boolean source.TAC_NOT— boolean negation viaEOR #$01.- All six comparison ops —
TAC_LT,TAC_GTE,TAC_EQ,TAC_NEQ,TAC_GT,TAC_LTEvia aCOMPARE_OPmacro that stamps out theCMP/branch/LDAsequence with a single branch-opcode parameter.GTandLTEswap operands to reuseLT/GTElogic. Uses a branch-before-load pattern to avoid the 6502LDA #0clobbering the Zero flag beforeBEQ/BNE. TAC_INC/TAC_DEC— in-placeINC zpg/DEC zpgfor u8 variables.- New opcode emitters:
beq_rel,cmp_imm,cmp_zpg,eor_imm,inc_zpg,dec_zpg. - Emulator tests:
forward_jump,cmp_gt,cmp_gte,cmp_eq,cmp_neq,cmp_lte. - Hardware verified —
led_counter.c02(nested while + for loop cycling PORTB through 0–254) compiled and flashed to real 65C02 breadboard.
- Local label system —
- Comparisons are u8-only —
COMPARE_OPloads only byte 0 of each operand. A u16 comparison will silently compare only the low byte, giving wrong results when values differ in the high byte. - INC/DEC are u8-only —
INC zpg/DEC zpgoperate on a single byte with no carry into a high byte. Incrementing a u16 past$00FFor decrementing below$0100will wrap the low byte without touching the high byte. - Comparisons are unsigned-only — the
CMP+ carry-flag branch sequence implements unsigned ordering. Signed comparisons (i8/i16) require checking the Negative and Overflow flags (N ⊕ V), which needs a different branch sequence not yet implemented.
- CFG walk —
generate_rom()now iterates overir_module_t.cfgsand emits real function bodies from the TAC instruction stream, replacing the previous stub main. - Zero-page operand map — per-function allocation table that assigns each variable and temporary a zero-page slot ($04 upward), striding by type size (1 byte for u8/i8, 2 bytes for u16/i16/pointers). Params are seeded first from the CFG's parameter list, then locals and temps are collected from all instructions.
- TAC_COPY — variable/temporary assignment via
LDA/STAthrough the operand map, with width-aware byte loops for 16-bit types. - TAC_STORE — writes to absolute addresses (hardware registers), supporting both constant and variable sources with multi-byte emission for wider types.
- TAC_RETURN — emits
RTSfor void returns; for value returns, copies the result into the RET register ($02/$03) with width derived from the function's return type signature. emit_load_bytehelper — byte-indexed operand loader that handles constants (shift + mask), variables, and temporaries uniformly, used by all TAC ops to avoid duplicating width logic.- New opcode emitters:
lda_zpg($A5),sta_abs($8D). - Added emulator tests:
store_const_to_abs,copy_var_to_abs,u16_copy_and_return, plus the existing py65 bootstrap tests.
- Driver refactor — extracted the compilation pipeline from
main.cintodriver.c/driver.h. Each stage (file loading, frontend, IR, codegen) is now a separate function chained by return-code checks, replacing the previousgoto finishcontrol flow.main.cis now just CLI parsing and the timing report. - Code generation stub — added
generate_rom()entry point insrc/code-gen/generator.cwithemitter_tstruct for flat ROM buffer output. Dump flags (--ast-dump,--symbol-dump,--ir-dump) now skip codegen since they are for inspecting compiler internals, not building binaries. - Bootstrap runtime — the code generator emits a 65C02 reset stub at the
start of ROM:
SEI,CLD, hardware stack init ($01FF), frame pointer init (FPat ZP$00),JSR main, and an infinite halt loop. Interrupt vectors are written at$FFFA–$FFFFwith the reset vector pointing to ROM start. - Label resolution and fixup system — function calls (
JSR) record a fixup with a placeholder address at emit time. Function entry points are registered in a label table as they are emitted. After all code is emitted, fixups are resolved by patching the placeholder addresses. A parallel per-function system (local_labels/local_fixups) is in place for control-flow labels (TAC_LABEL/TAC_JUMP/TAC_COND_JUMP). - Opcode emitter macros —
OP_EMITTER_NO_ARG,OP_EMITTER_SINGLE_ARG, andOP_EMITTER_ABSgenerate typed emit functions from an opcode constant, keeping the codegen readable without raw hex throughout. - Zero-page layout revised — scratch registers now span
$04–$EE(compiler-managed temporaries, locals, and globals), with$EF–$FFreserved for function ABI parameter passing. The previous user-space carve-out ($30–$FF) is removed; all variable placement is compiler-managed.
- Forward declarations (
decl) — addeddecl fn name(...) -> type;anddecl type name;syntax for declaring functions and globals defined in other translation units. Forward declarations are registered in the symbol table (redeclaration in the same file is an error), validated in the type-checking pass, and collected intoir_module_t.externsfor the linker. Extern symbols are fully serialized in the.oformat (IR_VERSION bumped to 2).
- Register reads now lower to
TAC_LOAD— reading a hardware register (e.g.x = PORTA) previously leaked the register name as a plain variable in the IR. TheNODE_IDENTIFIERcase inlower_exprnow mirrors the existing register-write path: it looks up the name inmodule.regsand emits aTAC_LOADfrom the fixed hardware address. - Out-of-order struct declarations rejected — a by-value struct field
referencing a struct declared later in the source (or referencing itself)
previously produced silently wrong field offsets. The analyzer now checks
that every by-value
TYPE_STRUCTfield names a struct already declared earlier in the source, and rejects self-referential structs with a clear diagnostic. New error kindERR_INCOMPLETE_STRUCT_FIELD. &&and||now short-circuit — logical AND/OR previously lowered as flat binary TAC ops that unconditionally evaluated both operands. On a memory-mapped 6502 target this is a semantic bug (e.g.flag && *pwould dereferencepeven whenflagis false). Both operators now lower to conditional jumps so the right-hand side is only evaluated when needed.- Number literal types match the analyzer —
NODE_NUMBERpreviously recomputed its type in IR gen (<= 0xFF → u8, else → u16), discarding the analyzer'sresolved_type. Negated literals like-5appeared asu8instead ofi8.NODE_NUMBERnow carries aresolved_typefield stamped during semantic analysis, and IR gen uses it directly.
- IR generation (in progress) — new
src/ir-gen/module for lowering the analysed AST into a self-contained intermediate representation:ir_module_t: a complete IR output containing struct layouts (with computed field offsets and sizes), global variable declarations, register definitions, and one CFG per function — designed so codegen never needs to consult the AST or symbol table.- Pass 1 (declaration collection): walks top-level declarations and populates register definitions (name, type, hardware address), global variables (with integer or string initialiser support), and struct layouts with sequential field offsets.
- Pass 2 (function lowering): complete expression lowering into TAC —
numbers, strings, identifiers, binary ops, unary ops (including
TAC_INC/TAC_DECmapped to 6502INC/DEC), address-of, pointer dereference (TAC_LOAD), casts, function calls with arguments, struct field access (TAC_FIELD_LOAD), and struct initialiser literals (TAC_FIELD_STOREper field). - Complete statement lowering: variable declarations, assignments
(to variables, pointer derefs, struct fields, and hardware registers
via
TAC_STOREat fixed addresses),return,if/else if/else(with negate-and-skip conditional jumps),whileloops,forloops (with optional init/cond/increment), and nested blocks. --ir-dumpCLI flag for inspecting the IR module after lowering, with full TAC instruction printer showing readable output for all instruction types (hex addresses for register stores, labelled jumps for control flow).- IR generation timing integrated into
--time-report. - Test harness properly generates goldens for ir test files.
- Incremental compilation (
-cflag) — compiles a.c02source file through the full frontend and IR generation, then serializes their_module_tto a binary.ofile. The.ocan be loaded back withcc02 file.oto skip the frontend entirely and resume from the IR.- Binary format uses length-prefixed strings, a magic header (
C02Iv1), and allocates all data from the arena on read — no dangling pointers,ir_gen_freeworks uniformly regardless of whether the IR came from source or a.ofile. -oflag specifies the output path (defaults toa.o).
- Binary format uses length-prefixed strings, a magic header (
- Smoke tests for IR generation covering the full pipeline (tokenize →
parse → analyse → IR gen → free) with assertion coverage for
declarations, CFG creation, expression lowering, statement lowering
(var decl, register store, if/else, while, for), and serialization
round-trip (write to
.o, free all source data, read back, verify contents match). - Per-module line count breakdown in
test.py --clocoutput.
NODE_IDENTIFIERrefactored from a barechar *to a struct carrying the name and aresolved_typestamped by the analyzer during semantic analysis. Required for IR generation to know variable types without re-walking scopes.NODE_CALLextended withresolved_return_type, stamped by the analyzer.NODE_FIELD_ACCESSextended withresolved_type, stamped by the analyzer.
- Semantic analysis — two-pass analyzer that validates the full AST:
- Pass 1: registers all top-level declarations (functions, structs, registers, global variables) into the global symbol table with redeclaration checking.
- Pass 2: recursive walk of function bodies with scoped symbol tables, checking for undeclared identifiers, type mismatches, wrong argument counts/types, unknown struct fields, and redeclarations.
resolve_expr_type()for full expression type resolution: literals (with smallest-fitting integer type), identifiers, function calls, binary/unary operators, dereferences, address-of, casts, field access, and struct initializers.- Integer widening:
u8→u16is allowed implicitly; narrowing requires an explicit cast. null/0is compatible with both pointer and integer types.- For-loops get their own scope so loop variables don't leak.
mainfunction existence check after pass 1.
analyzer_print.cand--symbol-dumpCLI flag for printing the global symbol table after analysis.analyzer_tstruct owning its own arena (mirroringparser_t), withanalyzer_init()/analyzer_free()lifecycle.- Smoke tests for the analyzer's scope stack, symbol insertion, lookup, and shadowing behavior.
- Compiler test cases covering analyzer error paths (undeclared identifiers, type mismatches, call errors, struct errors) and success paths (scoping, widening, pointers, basic analysis).
- Analyzer hardening — additional validation closing gaps where invalid
programs were previously accepted silently:
- Named struct types are checked for existence in every declaration form (locals, parameters, globals, registers, struct fields, function return types), not just struct initializers and field access. A symbol whose declared type is invalid is poisoned, so later uses don't cascade duplicate diagnostics.
- A non-pointer
voidis rejected as a variable/parameter/field/global type (void*remains valid as a null pointer). - Integer literals that don't fit any supported type are now an error
(previously swallowed); the lexer rejects literals that overflow
long. Negative literals are typed from their value (-5isi8,-300isi16). - Assignment targets and the operands of
&,++, and--must be lvalues. - Global variable initializers are type-checked (resolved in the global scope), just like local declarations - previously they were ignored.
- A bare
return;in a non-void function is rejected, and a non-void function that can fall off its end without returning a value is flagged. (This last check is shallow: a function ending in a control-flow statement — e.g. a one-armedifthat falls through, or anif/elsewhere only some branches return — is assumed to return and is not flagged. Full path-coverage analysis is future work.)
- Struct-typed globals (
Point p;at file scope) now parse, matching the form used for locals. - Field access auto-dereferences a single-level struct pointer (
c.fieldon aStruct*), since there is no->operator. - A negative-test corpus exercising each new check, plus positive tests for pointer field auto-deref and negative-literal typing.
- Renamed
scope_stack_t→analyzer_tand all associated functions toanalyzer_*prefix for consistency withparser_t. - Consolidated cleanup in
main.cinto a singlegoto finishexit path, eliminating repeated free-lists at each error point. - Test harness now routes
--ast-dumpto parser tests and--symbol-dumpto analyzer tests based on filename prefix. - Refactored program error codes so each failure stage gets its own exit code.
- Generalized error handling and printing for parsing onwards
(including sem. analysis). Moved source location tracking to a
dedicated type,
token_location_tto support pretty error messages after tokens aren't directly accessible. - Updated test harness to run smoke binaries and check for memory leaks.
- Generalized the arena allocator out of
parser.cinto shared infrastructure, so semantic analysis can reuse it for its own allocations. - Diagnostics now print the full pointer depth (
u16**, notu16*) and name the actual type in field-access-on-non-struct errors; a malformed number literal and a repeated unknown-type are each reported once rather than per character / per use.
- Fixed a segfault when a parse error's offending token was a numeric literal.
- Fixed a memory leak in the arena allocator when a standard chunk allocation followed an oversized one.
- String literals with escape sequences no longer drop a trailing character
per escape, and common escapes (
\n,\t,\\,\", ...) are decoded.
No code changes - this release formalizes the project's licensing and contribution process.
LICENSE: GPLv3, with a compiler-output exception so programs compiled with C02 are not themselves subject to GPL terms.CONTRIBUTING.mddocumenting the branch naming, PR, and changelog conventions for the project.- "Current Status & Limitations" section in the README, consolidating what's implemented vs. not in one place.
- Third-party license attribution for
c02-objdump'sclapdependency (dual-licensed MIT/Apache-2.0).
Initial public release. Frontend only - tokenizer, parser, and AST printer. No semantic analysis or code generation yet; see the README's "Current Status & Limitations" section for the full list of what's not implemented.
- Source-tracking tokenizer with file/line/column on every token.
- Recursive descent parser covering:
- Full expression precedence chain:
|| && | ^ & == != < > <= >= << >> + - * / %, plus unary! - & ~ ++ -- * @. - Statements: variable declarations, assignment (including compound
+= -= *= /= %=) to any lvalue (identifier, field access, or dereference),return,if/else if/else,while,for(with optional clauses), function calls. - Top-level declarations:
fn,reg(hardware register pinned to an absolute address),struct, and global variables. - Structs: field declarations, chained field access (
a.b.c), and designated-initializer struct literals (Point { .x = 0, .y = 0 }). - C-style casts (
(u16)x) and grouped expressions.
- Full expression precedence chain:
- Arena allocator for AST nodes, with growable scratch buffers for variable-length lists (statements, params, struct fields, call args) committed into the arena on completion.
- Clang-style error reporting: colorized output, caret-span source highlighting, and "expected / context" diagnostics for every parse error.
- AST printer (
--ast-dump) producing a readable tree view of any parsed program. - Golden-file test suite (Python harness) covering bitwise ops, conditionals, global vars, registers, and struct declarations.
--token-dump,--time-report, and--syntax-check-onlyCLI flags.c02-objdump, a companion disassembler for decoding compiled.binfiles back into annotated 65C02 assembly.
- No code generation:
cc02does not yet produce a working 65C02 binary. - No array type or subscript syntax.
->is not implemented; field access through a pointer is intended to auto-dereference via semantic analysis once that exists.