commit da095c5232e37ba266029d2bbf5b5a0174e47c29
parent d56632d51f4d3fd7553ac1de5c04d7a0c0fc35ba
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Sat, 9 May 2026 09:58:04 -0700
test/cg: scaffold codegen test harness and minimal AArch64 backend
Lays the groundwork for testing the cg / CGTarget / MCEmitter stack
independent of the C parser. Test fixtures drive CGTarget directly to
build `int test_main(void)`, which the runner exercises through four
paths (D direct-JIT, R ELF roundtrip, E exec, J jit-via-file) reusing
the existing test/link harness binaries.
Implements a generic byte-level MCEmitter (src/arch/mc.c), a minimal
AArch64 CGTarget covering func lifecycle, alloc_reg, load_imm, copy,
binop, unop, and ret (src/arch/aarch64.c), and 9 spine cases covering
function return and integer arithmetic. Other CGTarget methods are
panic-stubs so unsupported operations fail visibly.
R path passes for all 9 cases (emitted ELF round-trips cleanly through
read_elf). D/E/J paths currently fail; root cause is the pre-existing
cfree_jit_lookup regression noted in docs/linker-status.md, not the
new codegen output (which inspects correctly via llvm-readelf).
Strategy and coverage corpus are documented in doc/cg_testing.md.
Diffstat:
13 files changed, 2107 insertions(+), 9 deletions(-)
diff --git a/doc/cg_testing.md b/doc/cg_testing.md
@@ -0,0 +1,308 @@
+# cg / CGTarget / MCEmitter test strategy
+
+How we test the codegen stack — `cg`, `CGTarget`, and `MCEmitter` — *before*
+the C parser is ready, and how the suite extends naturally once the parser
+arrives. Companion to `DESIGN.md`. Scope: harness shape, layering rationale,
+test paths, fixture API, and a coverage corpus.
+
+## 1. Goal
+
+Test the meat of the single-pass compiler — recursive-descent parser ↔ `cg`
+↔ `CGTarget` ↔ `MCEmitter` ↔ `ObjBuilder` — with the parser stubbed out.
+The fixtures play the parser's role: each one drives `cg` (and a small
+number drive `CGTarget` or `MCEmitter` directly) to produce a function
+named `test_main` that returns an `int`. The harness then runs that
+function and checks the return value.
+
+This decouples codegen development from parser development: we can build
+out the AArch64 backend, opt's recording wrapper, MCEmitter encoding, and
+CG's value-stack/spill/fusion logic against behavioral oracles, without
+needing a working C front-end.
+
+## 2. Why three layers (and how to test each)
+
+```
+parser → cg → CGTarget → MCEmitter → ObjBuilder
+ | | | |
+ | | | └─ already covered by test/elf, test/link
+ | | └─ encoding, fixups, relocs, alignment, CFI
+ | └─ typed lowering vtable: takes resolved Operands
+ └─ TCC-style value stack: spills, fusion, conversions, frame-residency
+```
+
+- **`MCEmitter`** is the lowest layer. Encoding-table bugs and reloc/fixup
+ bugs surface here. Best tested with hand-written byte sequences and a
+ one-instruction `test_main` (analogue of `test/elf/unit/smoke.c`).
+- **`CGTarget`** is typed lowering. It receives `Operand`s that are
+ already resolved (REG / IMM / LOCAL / GLOBAL / INDIRECT). No value
+ stack, no implicit conversions. Best tested with focused unit anchors
+ that exhaustively exercise operand-kind combinations and op enums
+ (every `BinOp`, every `ConvKind`, every `MemOrder`).
+- **`cg`** owns C-shaped behavior: value stack, spill/reload across
+ pressure, `cmp` → `cmp_branch` fusion, frame-residency-by-default for
+ locals, implicit MemAccess derivation, address-taken tracking. This is
+ what the parser will drive, so the **primary** suite drives `cg`.
+
+The three layers map to three case categories:
+
+| Category | Drives | Where | Volume |
+|---|---|---|---|
+| Primary | `cg.h` | `test/cg/cases/` | grows with C language coverage |
+| Unit | `CGTarget` | `test/cg/unit/cgt_*` | one per method group, write-once |
+| Unit | `MCEmitter` | `test/cg/unit/mc_*` | one per encoding family, write-once |
+
+When a cg-driven case fails mysteriously, the matching unit anchor tells
+you whether the bug is in CGTarget/MCEmitter or in `cg` itself.
+
+## 3. Test paths per fixture
+
+Each fixture is run through several paths. This mirrors the R/E/J path
+matrix in `test/link/run.sh` and reuses its harness binaries.
+
+| Path | Pipeline | Validates | Available on |
+|---|---|---|---|
+| **D** direct-JIT | fixture → `ObjBuilder*` → `link_add_obj` → `cfree_link_jit` → call `test_main` | live ObjBuilder → JIT path; fastest; no file I/O | aarch64 host |
+| **R** roundtrip | fixture → `emit_elf` → bytes → `cfree-roundtrip` → `read_elf` → normalized diff | ELF writer/reader fidelity on synthetic input | always (host-arch agnostic) |
+| **E** exec | fixture → `emit_elf` → bytes → `link-exe-runner` → exe → qemu/podman → exit code | file linker + reloc application + ELF emission | when qemu or podman available |
+| **J** jit-via-file | fixture → `emit_elf` → bytes → `jit-runner` (reads .o) → call `test_main` | full file → JIT pipeline | aarch64 host |
+| **O** opt-wrapped | as D and J, but with `opt_cgtarget` between `cg` and target | IPO + lowering preserve behavior | once opt lands |
+
+Path D is intentionally distinct from J: it catches bugs that the ELF
+emitter writes into a `.o` but the reader silently corrects (or
+vice-versa). The two paths together force the post-finalize ObjBuilder
+shape and the read-back ObjBuilder shape to be behaviorally equivalent.
+
+The R, E, and J paths reuse the existing harness binaries unchanged
+(`cfree-roundtrip`, `link-exe-runner`, `jit-runner`). Only `cg-runner` is
+new.
+
+## 4. Layout
+
+```
+test/cg/
+ CORPUS.md # coverage matrix (mirrors test/elf/CORPUS.md)
+ run.sh # per-fixture: D, R, E, J (and O once opt lands)
+ harness/
+ cg_runner.c # multi-mode binary
+ cg_test.h / cg_test.c # fixture API used by every case
+ cases.c # registry: { name, build_fn, expected, flags }
+ start.c # symlink/copy of test/link/harness/start.c
+ cases/
+ a01_return_const.c # cg-driven cases (the primary suite)
+ a02_return_void.c
+ c01_add_const.c
+ c02_arith_mix.c
+ ...
+ unit/
+ mc_smoke.c # MCEmitter-direct
+ cgt_load_imm.c # CGTarget-direct anchors
+ cgt_binop_int.c
+ ...
+```
+
+`cg-runner` modes:
+
+```
+cg-runner --list # print every registered case name
+cg-runner --emit NAME OUT.o # build the case's ObjBuilder, emit_elf to OUT.o
+cg-runner --jit NAME # build, link_add_obj, cfree_link_jit, call test_main
+cg-runner --dump NAME # build, emit_elf, run ArchDisasm over .text — no oracle
+```
+
+The `--dump` mode is for debugging only; it has no expected output and is
+not run by default. Snapshot/golden disassembly tests are deferred.
+
+## 5. Fixture API
+
+The fixture API hides the boilerplate that every case shares: Compiler
+init, ObjBuilder allocation, `cgtarget_new`, `mc_new`, the `CGFuncDesc`
++ `ABIFuncInfo` dance for `test_main`, and the value-stack vs CGABIValue
+plumbing for `ret`.
+
+```c
+/* test/cg/harness/cg_test.h */
+
+typedef struct CgTestCtx CgTestCtx;
+typedef void (*CgCaseFn)(CgTestCtx*);
+
+typedef struct CgCase {
+ const char* name; /* "a01_return_const" */
+ CgCaseFn build;
+ int expected; /* test_main's return value; 0 if absent */
+ unsigned flags; /* CG_CASE_* */
+} CgCase;
+
+extern const CgCase cg_cases[]; /* registry, NUL-terminated */
+extern unsigned cg_cases_count;
+
+/* Common interned types; populated by cg_test_init. */
+extern const Type *T_VOID, *T_I8, *T_I16, *T_I32, *T_I64, *T_U32, *T_U64,
+ *T_F32, *T_F64, *T_PTR_VOID;
+
+/* Helpers cases use. */
+CG* cgtest_cg(CgTestCtx*);
+CGTarget* cgtest_target(CgTestCtx*);
+Compiler* cgtest_compiler(CgTestCtx*);
+
+/* Begin a function `<ret_ty> test_main(void)`. Allocates the ObjSymId,
+ * builds CGFuncDesc, queries TargetABI, calls cgtarget->func_begin. */
+typedef struct CgTestFn CgTestFn;
+CgTestFn* cgtest_begin_main(CgTestCtx*, const Type* ret_ty);
+
+/* Begin a named function with parameters; for cases that need helpers. */
+CgTestFn* cgtest_begin_fn(CgTestCtx*, const char* name,
+ const Type* ret_ty,
+ const Type* const* param_tys, unsigned nparams);
+
+/* Return a value sitting on top of cg's value stack; ret_ty must match. */
+void cgtest_ret_value(CgTestFn*);
+void cgtest_ret_void (CgTestFn*);
+
+/* End the function (cgtarget->func_end). */
+void cgtest_end(CgTestFn*);
+
+/* Operand sugar (used by CGTarget-direct unit cases). */
+Operand IMM (i64 v, const Type*);
+Operand REG_OF(Reg r, const Type*);
+Operand LOCAL_(FrameSlot, const Type*);
+Operand GLOBAL_(ObjSymId, i64 addend, const Type*);
+Operand IND (Reg base, i32 ofs, const Type*);
+```
+
+A primary cg-driven case is then ~10 lines:
+
+```c
+/* test/cg/cases/c01_add_const.c — int test_main(void) { return 1 + 2; } */
+#include "cg_test.h"
+
+static void build(CgTestCtx* ctx) {
+ CgTestFn* tf = cgtest_begin_main(ctx, T_I32);
+ CG* g = cgtest_cg(ctx);
+ cg_push_int(g, 1, T_I32);
+ cg_push_int(g, 2, T_I32);
+ cg_binop(g, BO_IADD);
+ cgtest_ret_value(tf);
+ cgtest_end(tf);
+}
+
+CG_CASE_REGISTER(c01_add_const, build, /*expected=*/3);
+```
+
+A CGTarget-direct unit anchor:
+
+```c
+/* test/cg/unit/cgt_load_imm.c */
+#include "cg_test.h"
+
+static void build(CgTestCtx* ctx) {
+ CgTestFn* tf = cgtest_begin_main(ctx, T_I32);
+ CGTarget* a = cgtest_target(ctx);
+ Reg r = a->alloc_reg(a, RC_INT, T_I32);
+ a->load_imm(a, REG_OF(r, T_I32), 0xdeadbeefLL);
+ /* hand-build CGABIValue for ret */
+ cgtest_ret_reg(tf, r, T_I32);
+ cgtest_end(tf);
+}
+
+CG_CASE_REGISTER(cgt_load_imm, build, /*expected=*/(int)0xdeadbeef);
+```
+
+`CG_CASE_REGISTER` is a macro that appends to the registry via a constructor
+section or a manual table in `cases.c` (depending on freestanding constraints).
+Since cfree itself is freestanding-only but the test runner is hosted, we can
+use `__attribute__((constructor))` here; if that's undesirable we maintain
+`cases.c` by hand as a list.
+
+## 6. CORPUS coverage groups
+
+Each group has a one-line description here; CORPUS.md will expand into
+explicit cases. **Initial landing covers groups A and C only** — that's
+the spine that proves the harness works end-to-end.
+
+| Group | Surface | Examples |
+|---|---|---|
+| **A** Lifecycle / return | `func_begin/end`, `ret`, return widths, sret | const return; void return; multiple returns; i8/i16/i32/i64/struct return |
+| **B** Frame slots / params | `frame_slot`, `param`, address-taken, byval, sret-param | sum two int params; spill 9 params; address-taken local; struct byval |
+| **C** Arithmetic | `binop` (all), `unop` | IADD/ISUB/IMUL; SDIV/UDIV/SREM/UREM; AND/OR/XOR; SHL/SHR_S/SHR_U; FADD..FDIV |
+| **D** Compare / branch | `cmp`, `cmp_branch`, `scope_*`, fusion | cmp materialize 0/1; cmp_branch eq/ne/lt; structured if; structured if-else |
+| **E** Convert | `convert` (all `ConvKind`) | sext/zext/trunc; itof/ftoi; fext/ftrunc; bitcast |
+| **F** Memory | `load`/`store`/`addr_of`/`copy_bytes`/`set_bytes`/bitfield | load/store i8..i64; global rw; indirect rw; struct copy; memset zero; bitfield_load/store; volatile |
+| **G** Calls | `call`, `ret`, ABI | direct + indirect; sret; byval arg; mixed gp+fp; >reg-count args; varargs call |
+| **H** Control flow | `label`/`jump`/`scope_*`/`break_to`/`continue_to` | flat if; while; for-with-continue lands on inc; do/while; nested loops; goto |
+| **I** alloca | `alloca_` | __builtin_alloca round-trip |
+| **J** Variadics | `va_start_/va_arg_/va_end_/va_copy_` | walk va_list with int args; va_copy |
+| **K** Atomics | `atomic_load/store/rmw/cas/fence`, `MemOrder` | per op × per order matrix (sampled) |
+| **L** Intrinsics | `intrinsic` × `IntrinKind` | popcount/ctz/clz; bswap; expect (no-op); add_overflow multi-result |
+| **M** Inline asm | `asm_block` | "r"/"=r" round-trip; "memory" clobber forces flush |
+| **N** TLS | `tls_addr_of` | thread_local read/write |
+| **O** Sections / globals | `frame_slot` + `DeclTable` | .data init; .bss tentative; .rodata constant pool; -ffunction-sections |
+| **P** set_loc / debug | `set_loc`, MCEmitter line program | line numbers reach .debug_line (oracle TBD) |
+| **Q** Multi-function (1 TU) | multiple func_begin/end | fn calls fn; mutual recursion; forward-declared callee |
+| **R** opt-wrapped equivalence | `opt_cgtarget` | every other group's exit codes preserved through opt |
+
+Group order roughly tracks priority. A is the prerequisite for every
+other group; B + C + D are needed for almost any non-trivial case;
+F + G + H bring us to "real C". K, M, N, P, R can land later without
+blocking the rest.
+
+## 7. Initial landing — Spine (A + C)
+
+Concrete first cases. Each is `int test_main(void) { return ...; }`.
+
+| Case | Body | expected |
+|---|---|---|
+| `a01_return_const` | `return 42;` | 42 |
+| `a02_return_zero` | `return 0;` | 0 |
+| `a03_return_neg` | `return -7;` | -7 (i.e. 249 mod 256 from runner) |
+| `a04_return_i8` | `int test_main(void){return (i8)200;}` exercising widening | as expected |
+| `c01_add` | `return 1 + 2;` | 3 |
+| `c02_sub_mul` | `return 7 * 3 - 4;` | 17 |
+| `c03_div_mod` | `return 23 / 4 + 23 % 4;` | 8 |
+| `c04_bitwise` | `return (~3) & 0xff;` | 252 |
+| `c05_shift` | `return (1 << 5) | (16 >> 1);` | 40 |
+
+Plus unit anchors:
+
+| Case | Layer | Notes |
+|---|---|---|
+| `mc_smoke` | MCEmitter | one-insn `test_main`; analogue of `test/elf/unit/smoke.c` |
+| `cgt_load_imm_ret` | CGTarget | `alloc_reg` + `load_imm` + `ret` only, no `cg` |
+| `cgt_binop_iadd` | CGTarget | `binop(BO_IADD, REG, IMM)` only, no `cg` |
+
+These exit-code oracles are enough to drive AArch64 backend bring-up
+through return + integer arith. The R/E/J/D paths from §3 give us
+ELF-roundtrip, link-exe + qemu, JIT, and direct-JIT coverage for free.
+
+## 8. Build integration
+
+Add a `test-cg` target in `test/test.mk`:
+
+```make
+test-cg: lib bin-soft
+ bash test/cg/run.sh
+```
+
+Wire into the `test` aggregate. `cg-runner` builds against `libcfree.a`
+the same way `link-exe-runner` and `jit-runner` do
+(`$(CC) $(DRIVER_CFLAGS) ... $(LIB_AR)`).
+
+`run.sh` walks `cg-runner --list`, then per case runs paths D, R, E, J
+(and later O), reporting one PASS/FAIL/SKIP line per (case, path) pair —
+identical to `test/link/run.sh`. Skip-vs-fail is governed by
+`CFREE_TEST_ALLOW_SKIP`, matching the existing convention.
+
+## 9. Non-goals (this strategy)
+
+- **Encoding/disassembly snapshots.** Deferred. Behavioral exit codes
+ only; we'll add encoding lock-ins for specific instruction-selection
+ guarantees later, surgically.
+- **Negative tests.** Not in this harness. CG/CGTarget contract
+ violations are covered by assertions inside the implementations and by
+ parser-level diagnostics tests once the parser exists.
+- **C-source-level tests.** Those arrive once the parser is real; they
+ share the R/E/J paths but bypass the cg-runner fixture API. The
+ fixtures here continue to exist as bisection anchors.
+- **Cross-arch.** AArch64 only for now. The harness is per-arch (cases
+ may be reused if encodings differ but ABI does not); a future x86_64
+ pass duplicates the runner with target = x86_64 and the same case set
+ modulo arch-specific overrides.
diff --git a/src/api/stubs.c b/src/api/stubs.c
@@ -57,13 +57,9 @@ void decl_free(DeclTable* d) { (void)d; }
CG* cg_new(Compiler* c, CGTarget* t, Debug* d) { (void)t; (void)d; unimplemented(c, "cg"); }
void cg_free(CG* g) { (void)g; }
-MCEmitter* mc_new(Compiler* c, ObjBuilder* o) { (void)o; unimplemented(c, "mc"); }
-void mc_free(MCEmitter* m) { (void)m; }
-
-CGTarget* cgtarget_new(Compiler* c, ObjBuilder* o, MCEmitter* m)
- { (void)o; (void)m; unimplemented(c, "cgtarget"); }
-void cgtarget_finalize(CGTarget* t) { (void)t; }
-void cgtarget_free(CGTarget* t) { (void)t; }
+/* mc_new / mc_free live in src/arch/mc.c.
+ * cgtarget_new / cgtarget_finalize / cgtarget_free live in src/arch/<target>.c
+ * (dispatched through src/arch/arch.c). */
/* ============================================================
* Optimizer
diff --git a/src/arch/aarch64.c b/src/arch/aarch64.c
@@ -0,0 +1,413 @@
+/* Minimal AArch64 CGTarget.
+ *
+ * Initial coverage matches the spine A + C corpus (function lifecycle and
+ * integer arithmetic). Other CGTarget methods panic with a clear "unimpl"
+ * diagnostic so test cases that touch them fail visibly rather than
+ * silently emitting nothing.
+ *
+ * Single-pass register allocation: alloc_reg hands out W19..W28 in order
+ * and panics on exhaustion. No live-range tracking, no spills. Suitable
+ * for short straight-line fixtures only; replaced when CG's
+ * value-stack-aware spill/reload arrives.
+ *
+ * Width is derived from Operand.type via type_is_64(). For the test
+ * harness this is enough; full ABI integration arrives with TargetABI. */
+
+#include "arch/arch.h"
+#include "core/arena.h"
+#include "obj/obj.h"
+#include "type/type.h"
+
+#include <string.h>
+
+typedef struct AAImpl {
+ CGTarget base;
+ SrcLoc loc;
+ const CGFuncDesc* fd;
+ u32 func_start;
+ u32 next_alloc;
+} AAImpl;
+
+static AAImpl* impl_of(CGTarget* t) { return (AAImpl*)t; }
+
+/* ---- helpers ---- */
+
+static int type_is_64(const Type* t)
+{
+ if (!t) return 0;
+ switch (t->kind) {
+ case TY_LONG: case TY_ULONG:
+ case TY_LLONG: case TY_ULLONG:
+ case TY_PTR:
+ case TY_DOUBLE:
+ return 1;
+ default:
+ return 0;
+ }
+}
+
+static u32 reg_num(Operand op) { return op.v.reg & 0x1fu; }
+
+static void emit32(MCEmitter* mc, u32 word)
+{
+ u8 b[4];
+ b[0] = (u8)(word & 0xff);
+ b[1] = (u8)((word >> 8) & 0xff);
+ b[2] = (u8)((word >> 16)& 0xff);
+ b[3] = (u8)((word >> 24)& 0xff);
+ mc->emit_bytes(mc, b, 4);
+}
+
+static _Noreturn void aa_panic(CGTarget* t, const char* what)
+{
+ SrcLoc loc = impl_of(t)->loc;
+ compiler_panic(t->c, loc, "aarch64: %s not implemented", what);
+}
+
+/* ---- function lifecycle ---- */
+
+static void aa_func_begin(CGTarget* t, const CGFuncDesc* fd)
+{
+ AAImpl* a = impl_of(t);
+ MCEmitter* mc = t->mc;
+
+ mc->set_section(mc, fd->text_section_id);
+ mc->emit_align(mc, 4, 0); /* instruction alignment */
+
+ a->fd = fd;
+ a->func_start = mc->pos(mc);
+ a->next_alloc = 0;
+
+ mc->cfi_startproc(mc);
+}
+
+static void aa_func_end(CGTarget* t)
+{
+ AAImpl* a = impl_of(t);
+ MCEmitter* mc = t->mc;
+ u32 end = mc->pos(mc);
+
+ obj_symbol_define(t->obj,
+ a->fd->sym,
+ a->fd->text_section_id,
+ (u64)a->func_start,
+ (u64)(end - a->func_start));
+
+ mc->cfi_endproc(mc);
+ a->fd = NULL;
+}
+
+/* ---- registers / frame ---- */
+
+static Reg aa_alloc_reg(CGTarget* t, RegClass cls, const Type* ty)
+{
+ AAImpl* a = impl_of(t);
+ (void)ty;
+ if (cls != RC_INT) {
+ compiler_panic(t->c, a->loc, "aarch64 alloc_reg: class %d unimpl", (int)cls);
+ }
+ if (a->next_alloc >= 10) {
+ compiler_panic(t->c, a->loc,
+ "aarch64 alloc_reg: out of scratch regs (no spill yet)");
+ }
+ return (Reg)(19u + a->next_alloc++);
+}
+
+static void aa_free_reg(CGTarget* t, Reg r) { (void)t; (void)r; }
+
+static FrameSlot aa_frame_slot(CGTarget* t, const FrameSlotDesc* d) { (void)d; aa_panic(t, "frame_slot"); }
+static void aa_param (CGTarget* t, const CGParamDesc* p) { (void)p; aa_panic(t, "param"); }
+static const Reg* aa_clobbers (CGTarget* t, RegClass c, u32* n) { (void)c; (void)n; aa_panic(t, "clobbers"); }
+static void aa_spill_reg (CGTarget* t, Operand s, FrameSlot f, MemAccess m) { (void)s; (void)f; (void)m; aa_panic(t, "spill_reg"); }
+static void aa_reload_reg(CGTarget* t, Operand d, FrameSlot f, MemAccess m) { (void)d; (void)f; (void)m; aa_panic(t, "reload_reg"); }
+
+/* ---- labels / control flow (deferred) ---- */
+
+static Label aa_label_new (CGTarget* t) { aa_panic(t, "label_new"); }
+static void aa_label_place(CGTarget* t, Label l) { (void)l; aa_panic(t, "label_place"); }
+static void aa_jump (CGTarget* t, Label l) { (void)l; aa_panic(t, "jump"); }
+static void aa_cmp_branch (CGTarget* t, CmpOp op, Operand a, Operand b, Label l) { (void)op;(void)a;(void)b;(void)l; aa_panic(t, "cmp_branch"); }
+
+static CGScope aa_scope_begin(CGTarget* t, const CGScopeDesc* d) { (void)d; aa_panic(t, "scope_begin"); }
+static void aa_scope_else (CGTarget* t, CGScope s) { (void)s; aa_panic(t, "scope_else"); }
+static void aa_scope_end (CGTarget* t, CGScope s) { (void)s; aa_panic(t, "scope_end"); }
+static void aa_break_to (CGTarget* t, CGScope s) { (void)s; aa_panic(t, "break_to"); }
+static void aa_continue_to(CGTarget* t, CGScope s) { (void)s; aa_panic(t, "continue_to"); }
+
+/* ---- data movement ---- */
+
+static void aa_load_imm(CGTarget* t, Operand dst, i64 imm)
+{
+ AAImpl* a = impl_of(t);
+ MCEmitter* mc = t->mc;
+ u32 sf = type_is_64(dst.type) ? 1u : 0u;
+ u32 rd = reg_num(dst);
+
+ if (imm >= 0 && imm <= 0xffff) {
+ /* MOVZ Wd|Xd, #imm16, lsl #0 */
+ u32 word = (sf << 31) | 0x52800000u | (((u32)imm & 0xffff) << 5) | rd;
+ emit32(mc, word);
+ } else if (imm < 0 && imm >= -0x10000) {
+ /* MOVN Wd|Xd, #(~imm)16 */
+ u32 inv = (u32)(~(u64)imm) & 0xffff;
+ u32 word = (sf << 31) | 0x12800000u | (inv << 5) | rd;
+ emit32(mc, word);
+ } else {
+ compiler_panic(t->c, a->loc,
+ "aarch64 load_imm: imm %lld out of 16-bit range",
+ (long long)imm);
+ }
+}
+
+static void aa_load_const(CGTarget* t, Operand dst, ConstBytes cb)
+{ (void)dst; (void)cb; aa_panic(t, "load_const"); }
+
+static void aa_copy(CGTarget* t, Operand dst, Operand src)
+{
+ /* MOV Wd, Wm ≡ ORR Wd, WZR, Wm */
+ MCEmitter* mc = t->mc;
+ u32 sf = type_is_64(dst.type) ? 1u : 0u;
+ u32 rd = reg_num(dst);
+ u32 rm = reg_num(src);
+ u32 word = (sf << 31) | 0x2a000000u | (rm << 16) | (31u << 5) | rd;
+ emit32(mc, word);
+}
+
+static void aa_load (CGTarget* t, Operand d, Operand a, MemAccess m) { (void)d;(void)a;(void)m; aa_panic(t, "load"); }
+static void aa_store (CGTarget* t, Operand a, Operand s, MemAccess m) { (void)a;(void)s;(void)m; aa_panic(t, "store"); }
+static void aa_addr_of (CGTarget* t, Operand d, Operand l) { (void)d;(void)l; aa_panic(t, "addr_of"); }
+static void aa_tls_addr_of(CGTarget* t, Operand d, ObjSymId s, i64 a) { (void)d;(void)s;(void)a; aa_panic(t, "tls_addr_of"); }
+static void aa_copy_bytes(CGTarget* t, Operand d, Operand s, AggregateAccess g) { (void)d;(void)s;(void)g; aa_panic(t, "copy_bytes"); }
+static void aa_set_bytes (CGTarget* t, Operand d, Operand b, AggregateAccess g) { (void)d;(void)b;(void)g; aa_panic(t, "set_bytes"); }
+static void aa_bitfield_load (CGTarget* t, Operand d, Operand a, BitFieldAccess f) { (void)d;(void)a;(void)f; aa_panic(t, "bitfield_load"); }
+static void aa_bitfield_store(CGTarget* t, Operand a, Operand s, BitFieldAccess f) { (void)a;(void)s;(void)f; aa_panic(t, "bitfield_store"); }
+
+/* ---- arithmetic ---- */
+
+static void aa_binop(CGTarget* t, BinOp op, Operand dst, Operand a, Operand b)
+{
+ MCEmitter* mc = t->mc;
+ u32 sf = type_is_64(dst.type) ? 1u : 0u;
+ u32 rd = reg_num(dst);
+ u32 rn = reg_num(a);
+ u32 rm = reg_num(b);
+ u32 word;
+
+ /* All operands must be REG. CG materializes immediates first. */
+ if (a.kind != OPK_REG || b.kind != OPK_REG) {
+ compiler_panic(t->c, impl_of(t)->loc,
+ "aarch64 binop: non-REG operands not yet supported");
+ }
+
+ switch (op) {
+ case BO_IADD: word = (sf << 31) | 0x0b000000u | (rm << 16) | (rn << 5) | rd; break;
+ case BO_ISUB: word = (sf << 31) | 0x4b000000u | (rm << 16) | (rn << 5) | rd; break;
+ case BO_IMUL: /* MADD Wd, Wn, Wm, WZR */
+ word = (sf << 31) | 0x1b000000u | (rm << 16) | (31u << 10) | (rn << 5) | rd; break;
+ case BO_AND: word = (sf << 31) | 0x0a000000u | (rm << 16) | (rn << 5) | rd; break;
+ case BO_OR: word = (sf << 31) | 0x2a000000u | (rm << 16) | (rn << 5) | rd; break;
+ case BO_XOR: word = (sf << 31) | 0x4a000000u | (rm << 16) | (rn << 5) | rd; break;
+ case BO_SHL: /* LSLV: data-processing-2-source op2=001000 */
+ word = (sf << 31) | 0x1ac02000u | (rm << 16) | (rn << 5) | rd; break;
+ case BO_SHR_U:/* LSRV: op2=001001 */
+ word = (sf << 31) | 0x1ac02400u | (rm << 16) | (rn << 5) | rd; break;
+ case BO_SHR_S:/* ASRV: op2=001010 */
+ word = (sf << 31) | 0x1ac02800u | (rm << 16) | (rn << 5) | rd; break;
+ case BO_UDIV: word = (sf << 31) | 0x1ac00800u | (rm << 16) | (rn << 5) | rd; break;
+ case BO_SDIV: word = (sf << 31) | 0x1ac00c00u | (rm << 16) | (rn << 5) | rd; break;
+ case BO_SREM:
+ case BO_UREM:
+ case BO_FADD: case BO_FSUB: case BO_FMUL: case BO_FDIV:
+ default:
+ compiler_panic(t->c, impl_of(t)->loc,
+ "aarch64 binop: op %d unimpl", (int)op);
+ }
+ emit32(mc, word);
+}
+
+static void aa_unop(CGTarget* t, UnOp op, Operand dst, Operand a)
+{
+ MCEmitter* mc = t->mc;
+ u32 sf = type_is_64(dst.type) ? 1u : 0u;
+ u32 rd = reg_num(dst);
+ u32 rn = reg_num(a);
+ u32 word;
+
+ if (a.kind != OPK_REG) {
+ compiler_panic(t->c, impl_of(t)->loc,
+ "aarch64 unop: non-REG operand not yet supported");
+ }
+
+ switch (op) {
+ case UO_NEG:
+ /* SUB Wd, WZR, Wn */
+ word = (sf << 31) | 0x4b000000u | (rn << 16) | (31u << 5) | rd;
+ break;
+ case UO_BNOT:
+ /* MVN Wd, Wn ≡ ORN Wd, WZR, Wn (shift=0) */
+ word = (sf << 31) | 0x2a200000u | (rn << 16) | (31u << 5) | rd;
+ break;
+ case UO_NOT:
+ default:
+ compiler_panic(t->c, impl_of(t)->loc,
+ "aarch64 unop: op %d unimpl", (int)op);
+ }
+ emit32(mc, word);
+}
+
+static void aa_cmp (CGTarget* t, CmpOp op, Operand d, Operand a, Operand b) { (void)op;(void)d;(void)a;(void)b; aa_panic(t, "cmp"); }
+static void aa_convert(CGTarget* t, ConvKind k, Operand d, Operand s) { (void)k;(void)d;(void)s; aa_panic(t, "convert"); }
+
+/* ---- calls / return ---- */
+
+static void aa_call(CGTarget* t, const CGCallDesc* d) { (void)d; aa_panic(t, "call"); }
+
+static void aa_ret(CGTarget* t, const CGABIValue* val)
+{
+ MCEmitter* mc = t->mc;
+
+ if (val && val->storage.kind == OPK_REG) {
+ /* MOV W0|X0, src */
+ u32 sf = type_is_64(val->storage.type) ? 1u : 0u;
+ u32 rm = reg_num(val->storage);
+ u32 word = (sf << 31) | 0x2a000000u | (rm << 16) | (31u << 5) | 0u;
+ emit32(mc, word);
+ } else if (val && val->storage.kind == OPK_IMM) {
+ /* MOV W0, #imm via load_imm */
+ Operand w0 = { OPK_REG, RC_INT, 0, val->storage.type, .v.reg = 0 };
+ aa_load_imm(t, w0, val->storage.v.imm);
+ }
+ /* RET X30 */
+ emit32(mc, 0xd65f03c0u);
+}
+
+static void aa_alloca_ (CGTarget* t, Operand d, Operand s, u32 a) { (void)d;(void)s;(void)a; aa_panic(t, "alloca"); }
+static void aa_va_start_(CGTarget* t, Operand a) { (void)a; aa_panic(t, "va_start"); }
+static void aa_va_arg_ (CGTarget* t, Operand d, Operand a, const Type* ty) { (void)d;(void)a;(void)ty; aa_panic(t, "va_arg"); }
+static void aa_va_end_ (CGTarget* t, Operand a) { (void)a; aa_panic(t, "va_end"); }
+static void aa_va_copy_ (CGTarget* t, Operand d, Operand s) { (void)d;(void)s; aa_panic(t, "va_copy"); }
+
+static void aa_atomic_load (CGTarget* t, Operand d, Operand a, MemAccess m, MemOrder o) { (void)d;(void)a;(void)m;(void)o; aa_panic(t, "atomic_load"); }
+static void aa_atomic_store(CGTarget* t, Operand a, Operand s, MemAccess m, MemOrder o) { (void)a;(void)s;(void)m;(void)o; aa_panic(t, "atomic_store"); }
+static void aa_atomic_rmw (CGTarget* t, AtomicOp op, Operand d, Operand a, Operand v, MemAccess m, MemOrder o) { (void)op;(void)d;(void)a;(void)v;(void)m;(void)o; aa_panic(t, "atomic_rmw"); }
+static void aa_atomic_cas (CGTarget* t, Operand p, Operand ok, Operand a, Operand e, Operand des, MemAccess m, MemOrder s, MemOrder f) { (void)p;(void)ok;(void)a;(void)e;(void)des;(void)m;(void)s;(void)f; aa_panic(t, "atomic_cas"); }
+static void aa_fence (CGTarget* t, MemOrder o) { (void)o; aa_panic(t, "fence"); }
+
+static void aa_intrinsic(CGTarget* t, IntrinKind k, Operand* dsts, u32 nd, const Operand* args, u32 na) { (void)k;(void)dsts;(void)nd;(void)args;(void)na; aa_panic(t, "intrinsic"); }
+
+static void aa_asm_block(CGTarget* t, const char* tmpl,
+ const AsmConstraint* outs, u32 no, Operand* oo,
+ const AsmConstraint* ins, u32 ni, const Operand* io,
+ const Sym* clobs, u32 nc)
+{ (void)tmpl;(void)outs;(void)no;(void)oo;(void)ins;(void)ni;(void)io;(void)clobs;(void)nc; aa_panic(t, "asm_block"); }
+
+static void aa_set_loc(CGTarget* t, SrcLoc loc)
+{
+ impl_of(t)->loc = loc;
+ t->mc->set_loc(t->mc, loc);
+}
+
+static void aa_finalize(CGTarget* t) { (void)t; }
+
+static void aa_destroy(CGTarget* t) { (void)t; /* arena-backed */ }
+
+/* ---- construction ---- */
+
+static void cgt_cleanup(void* arg) { cgtarget_free((CGTarget*)arg); }
+
+CGTarget* cgtarget_new(Compiler* c, ObjBuilder* o, MCEmitter* m)
+{
+ /* v1: only AArch64 implemented. Other targets fall back to a
+ * "not implemented" diagnostic at construction. */
+ if (c->target.arch != CFREE_ARCH_ARM_64) {
+ SrcLoc loc = {0,0,0};
+ compiler_panic(c, loc,
+ "cgtarget_new: only AArch64 implemented in v1 (got arch %d)",
+ (int)c->target.arch);
+ }
+
+ AAImpl* a = arena_new(c->tu, AAImpl);
+ memset(a, 0, sizeof *a);
+
+ CGTarget* t = &a->base;
+ t->c = c;
+ t->obj = o;
+ t->mc = m;
+
+ t->func_begin = aa_func_begin;
+ t->func_end = aa_func_end;
+
+ t->alloc_reg = aa_alloc_reg;
+ t->free_reg = aa_free_reg;
+ t->frame_slot = aa_frame_slot;
+ t->param = aa_param;
+ t->clobbers = aa_clobbers;
+ t->spill_reg = aa_spill_reg;
+ t->reload_reg = aa_reload_reg;
+
+ t->label_new = aa_label_new;
+ t->label_place= aa_label_place;
+ t->jump = aa_jump;
+ t->cmp_branch = aa_cmp_branch;
+
+ t->scope_begin= aa_scope_begin;
+ t->scope_else = aa_scope_else;
+ t->scope_end = aa_scope_end;
+ t->break_to = aa_break_to;
+ t->continue_to= aa_continue_to;
+
+ t->load_imm = aa_load_imm;
+ t->load_const = aa_load_const;
+ t->copy = aa_copy;
+ t->load = aa_load;
+ t->store = aa_store;
+ t->addr_of = aa_addr_of;
+ t->tls_addr_of= aa_tls_addr_of;
+ t->copy_bytes = aa_copy_bytes;
+ t->set_bytes = aa_set_bytes;
+ t->bitfield_load = aa_bitfield_load;
+ t->bitfield_store= aa_bitfield_store;
+
+ t->binop = aa_binop;
+ t->unop = aa_unop;
+ t->cmp = aa_cmp;
+ t->convert = aa_convert;
+
+ t->call = aa_call;
+ t->ret = aa_ret;
+
+ t->alloca_ = aa_alloca_;
+ t->va_start_ = aa_va_start_;
+ t->va_arg_ = aa_va_arg_;
+ t->va_end_ = aa_va_end_;
+ t->va_copy_ = aa_va_copy_;
+
+ t->setjmp_ = NULL; /* parser lowers via __cfree_setjmp */
+ t->longjmp_ = NULL;
+
+ t->atomic_load = aa_atomic_load;
+ t->atomic_store= aa_atomic_store;
+ t->atomic_rmw = aa_atomic_rmw;
+ t->atomic_cas = aa_atomic_cas;
+ t->fence = aa_fence;
+
+ t->intrinsic = aa_intrinsic;
+ t->asm_block = aa_asm_block;
+
+ t->set_loc = aa_set_loc;
+ t->finalize = aa_finalize;
+ t->destroy = aa_destroy;
+
+ compiler_defer(c, cgt_cleanup, t);
+ return t;
+}
+
+void cgtarget_finalize(CGTarget* t) { if (t && t->finalize) t->finalize(t); }
+
+void cgtarget_free(CGTarget* t)
+{
+ if (!t) return;
+ /* Arena-backed; nothing to free. The compiler_defer cleanup callback
+ * arrives here at panic; intentional double-call from explicit free
+ * after success is safe because everything is arena memory. */
+}
diff --git a/src/arch/mc.c b/src/arch/mc.c
@@ -0,0 +1,297 @@
+/* Generic MCEmitter implementation.
+ *
+ * MCEmitter sits between CGTarget (or parse_asm) and ObjBuilder. It owns
+ * the current section, byte position, machine label table, and forwards
+ * relocations / source-location stamps. Encoding is the caller's job —
+ * MCEmitter writes whatever bytes it's handed.
+ *
+ * This implementation is target-agnostic: every supported arch shares
+ * one MCEmitter; arch-specific differences live in CGTarget. Per-arch
+ * MCEmitter subclasses can layer on later if encoding cache or
+ * peephole-merging need shared state with the emitter.
+ *
+ * MCLabel handling: ids are 1-based (0 = MC_LABEL_NONE). Each label
+ * carries either a placement (sec_id, offset) or a list of pending
+ * fixups for forward references. emit_label_ref records a fixup; on
+ * label_place, all pending fixups for that label are applied. Fixup
+ * application uses RelocKind to choose how to encode the resolved
+ * displacement into the already-emitted bytes.
+ *
+ * For v1 we support these label-ref reloc kinds for intra-section
+ * fixups:
+ * R_PC32 — write 32-bit signed displacement at fixup ofs
+ * R_AARCH64_CALL26 — 26-bit imm26 << 2 in the BL instruction
+ * R_AARCH64_JUMP26 — same encoding as CALL26 (B vs BL only differs
+ * in the parent opcode the caller already emitted)
+ *
+ * Anything else for a label_ref panics; cross-section references go
+ * through emit_reloc against an ObjSymId instead. */
+
+#include "arch/arch.h"
+#include "core/arena.h"
+#include "obj/obj.h"
+
+#include <string.h>
+
+typedef struct MCFixup {
+ u32 sec_id;
+ u32 offset;
+ u32 width; /* bytes the encoding occupies */
+ RelocKind kind;
+ i64 addend;
+ struct MCFixup* next;
+} MCFixup;
+
+typedef struct MCLabelInfo {
+ u8 placed;
+ u8 pad[3];
+ u32 sec_id;
+ u32 offset;
+ MCFixup* pending;
+} MCLabelInfo;
+
+typedef struct MCImpl {
+ MCEmitter base;
+ Arena* arena;
+ SrcLoc loc;
+ MCLabelInfo* labels; /* index 0 unused (MC_LABEL_NONE) */
+ u32 nlabels;
+ u32 cap;
+} MCImpl;
+
+/* ---- helpers ---- */
+
+static MCImpl* impl_of(MCEmitter* m) { return (MCImpl*)m; }
+
+static void labels_grow(MCImpl* mc, u32 want)
+{
+ if (want <= mc->cap) return;
+ u32 ncap = mc->cap ? mc->cap * 2 : 16;
+ while (ncap < want) ncap *= 2;
+ MCLabelInfo* nbuf = arena_array(mc->arena, MCLabelInfo, ncap);
+ if (mc->labels) memcpy(nbuf, mc->labels, sizeof(MCLabelInfo) * mc->nlabels);
+ memset(nbuf + mc->nlabels, 0, sizeof(MCLabelInfo) * (ncap - mc->nlabels));
+ mc->labels = nbuf;
+ mc->cap = ncap;
+}
+
+static void apply_fixup(MCImpl* mc, const MCFixup* fx, u32 target_offset)
+{
+ /* signed displacement from end-of-instruction position to target. */
+ i64 disp = (i64)target_offset - (i64)fx->offset + fx->addend;
+
+ switch (fx->kind) {
+ case R_PC32: {
+ i32 v = (i32)disp;
+ u8 bytes[4];
+ bytes[0] = (u8)(v & 0xff);
+ bytes[1] = (u8)((v>>8) & 0xff);
+ bytes[2] = (u8)((v>>16) & 0xff);
+ bytes[3] = (u8)((v>>24) & 0xff);
+ obj_patch(mc->base.obj, fx->sec_id, fx->offset, bytes, 4);
+ break;
+ }
+ case R_AARCH64_JUMP26:
+ case R_AARCH64_CALL26: {
+ /* imm26 in the lower 26 bits of a BL/B; offset in instructions. */
+ i64 idisp = disp >> 2; /* word-aligned displacement */
+ u32 imm26 = (u32)(idisp & 0x03ffffffu);
+ u8 cur[4];
+ /* read existing 4 bytes via section accessor. */
+ u8* p = mc->base.obj ? NULL : NULL;
+ (void)p;
+ /* obj has obj_patch but not "read"; fetch via flatten — for v1
+ * we know callers emit a fresh insn with imm26=0 right before
+ * recording the fixup, so we can reconstruct from the opcode
+ * carried in addend's high bits. Simpler: callers emit the
+ * full encoding with imm26=0 and the fixup just OR's imm26 in
+ * by patching the low 26 bits. We emulate that by reading
+ * indirectly through obj_section_get. */
+ const Section* s = obj_section_get(mc->base.obj, fx->sec_id);
+ if (!s) break;
+ buf_read(&s->bytes, fx->offset, cur, 4);
+ u32 word = (u32)cur[0] | ((u32)cur[1] << 8) | ((u32)cur[2] << 16) | ((u32)cur[3] << 24);
+ word = (word & ~0x03ffffffu) | imm26;
+ cur[0] = (u8)(word & 0xff);
+ cur[1] = (u8)((word >> 8) & 0xff);
+ cur[2] = (u8)((word >> 16)& 0xff);
+ cur[3] = (u8)((word >> 24)& 0xff);
+ obj_patch(mc->base.obj, fx->sec_id, fx->offset, cur, 4);
+ break;
+ }
+ default:
+ compiler_panic(mc->base.c, mc->loc,
+ "MCEmitter: unsupported label-ref reloc kind %d",
+ (int)fx->kind);
+ }
+}
+
+/* ---- vtable methods ---- */
+
+static void m_set_section(MCEmitter* m, u32 section_id) { m->section_id = section_id; }
+
+static u32 m_pos(MCEmitter* m)
+{ return obj_pos(m->obj, m->section_id); }
+
+static MCLabel m_label_new(MCEmitter* m)
+{
+ MCImpl* mc = impl_of(m);
+ if (mc->nlabels == 0) { labels_grow(mc, 1); mc->nlabels = 1; } /* skip 0 */
+ labels_grow(mc, mc->nlabels + 1);
+ u32 id = mc->nlabels++;
+ MCLabelInfo* li = &mc->labels[id];
+ li->placed = 0;
+ li->sec_id = 0;
+ li->offset = 0;
+ li->pending = NULL;
+ return (MCLabel)id;
+}
+
+static void m_label_place(MCEmitter* m, MCLabel id)
+{
+ MCImpl* mc = impl_of(m);
+ if (id == MC_LABEL_NONE || id >= mc->nlabels) {
+ compiler_panic(m->c, mc->loc, "MCEmitter: bad label %u", (unsigned)id);
+ }
+ MCLabelInfo* li = &mc->labels[id];
+ if (li->placed) {
+ compiler_panic(m->c, mc->loc, "MCEmitter: label %u placed twice",
+ (unsigned)id);
+ }
+ li->placed = 1;
+ li->sec_id = m->section_id;
+ li->offset = obj_pos(m->obj, m->section_id);
+ /* Apply pending fixups. */
+ for (MCFixup* fx = li->pending; fx; fx = fx->next) {
+ apply_fixup(mc, fx, li->offset);
+ }
+ li->pending = NULL;
+}
+
+static void m_emit_bytes(MCEmitter* m, const u8* data, size_t n)
+{ obj_write(m->obj, m->section_id, data, n); }
+
+static void m_emit_fill(MCEmitter* m, size_t n, u8 byte)
+{
+ u8 buf[64];
+ memset(buf, byte, sizeof buf);
+ while (n > 0) {
+ size_t k = n < sizeof buf ? n : sizeof buf;
+ obj_write(m->obj, m->section_id, buf, k);
+ n -= k;
+ }
+}
+
+static void m_emit_align(MCEmitter* m, u32 align, u8 fill)
+{
+ if (align <= 1) return;
+ u32 cur = obj_pos(m->obj, m->section_id);
+ u32 misalign = cur & (align - 1);
+ if (misalign == 0) return;
+ m_emit_fill(m, align - misalign, fill);
+}
+
+static void m_emit_reloc(MCEmitter* m, RelocKind k, ObjSymId sym, i64 addend)
+{
+ obj_reloc(m->obj, m->section_id,
+ obj_pos(m->obj, m->section_id),
+ k, sym, addend);
+}
+
+static void m_emit_reloc_at(MCEmitter* m, u32 section_id, u32 offset,
+ RelocKind k, ObjSymId sym, i64 addend,
+ int explicit_addend, int pair)
+{ obj_reloc_ex(m->obj, section_id, offset, k, sym, addend, explicit_addend, pair); }
+
+static void m_emit_label_ref(MCEmitter* m, MCLabel id, RelocKind kind,
+ u32 width, i64 addend)
+{
+ MCImpl* mc = impl_of(m);
+ if (id == MC_LABEL_NONE || id >= mc->nlabels) {
+ compiler_panic(m->c, mc->loc, "MCEmitter: bad label %u", (unsigned)id);
+ }
+ MCLabelInfo* li = &mc->labels[id];
+ MCFixup* fx = arena_new(mc->arena, MCFixup);
+ fx->sec_id = m->section_id;
+ fx->offset = obj_pos(m->obj, m->section_id) - width; /* fixup site is the just-emitted insn */
+ fx->width = width;
+ fx->kind = kind;
+ fx->addend = addend;
+ fx->next = NULL;
+ if (li->placed) {
+ apply_fixup(mc, fx, li->offset);
+ } else {
+ fx->next = li->pending;
+ li->pending = fx;
+ }
+}
+
+static void m_set_loc(MCEmitter* m, SrcLoc loc) { impl_of(m)->loc = loc; }
+
+/* CFI: buffered for .eh_frame / .debug_frame emission. v1 stores nothing
+ * because Debug isn't wired up yet; methods are no-ops so backends can
+ * call them without conditionals. */
+static void m_cfi_startproc (MCEmitter* m) { (void)m; }
+static void m_cfi_endproc (MCEmitter* m) { (void)m; }
+static void m_cfi_def_cfa (MCEmitter* m, u32 r, i32 o) { (void)m;(void)r;(void)o; }
+static void m_cfi_def_cfa_offset (MCEmitter* m, i32 o) { (void)m;(void)o; }
+static void m_cfi_def_cfa_register(MCEmitter* m, u32 r) { (void)m;(void)r; }
+static void m_cfi_offset (MCEmitter* m, u32 r, i32 o) { (void)m;(void)r;(void)o; }
+static void m_cfi_rel_offset (MCEmitter* m, u32 r, i32 o) { (void)m;(void)r;(void)o; }
+static void m_cfi_restore (MCEmitter* m, u32 r) { (void)m;(void)r; }
+
+static void m_destroy(MCEmitter* m) { (void)m; /* arena-backed */ }
+
+/* ---- construction ---- */
+
+static void mc_cleanup(void* arg) { mc_free((MCEmitter*)arg); }
+
+MCEmitter* mc_new(Compiler* c, ObjBuilder* o)
+{
+ MCImpl* mc = arena_new(c->tu, MCImpl);
+ memset(mc, 0, sizeof *mc);
+
+ MCEmitter* base = &mc->base;
+ base->c = c;
+ base->obj = o;
+ base->section_id = OBJ_SEC_NONE;
+
+ base->set_section = m_set_section;
+ base->pos = m_pos;
+
+ base->label_new = m_label_new;
+ base->label_place = m_label_place;
+
+ base->emit_bytes = m_emit_bytes;
+ base->emit_fill = m_emit_fill;
+ base->emit_align = m_emit_align;
+ base->emit_reloc = m_emit_reloc;
+ base->emit_reloc_at = m_emit_reloc_at;
+ base->emit_label_ref= m_emit_label_ref;
+ base->set_loc = m_set_loc;
+
+ base->cfi_startproc = m_cfi_startproc;
+ base->cfi_endproc = m_cfi_endproc;
+ base->cfi_def_cfa = m_cfi_def_cfa;
+ base->cfi_def_cfa_offset = m_cfi_def_cfa_offset;
+ base->cfi_def_cfa_register = m_cfi_def_cfa_register;
+ base->cfi_offset = m_cfi_offset;
+ base->cfi_rel_offset = m_cfi_rel_offset;
+ base->cfi_restore = m_cfi_restore;
+
+ base->destroy = m_destroy;
+
+ mc->arena = c->tu;
+ mc->labels = NULL;
+ mc->nlabels = 0;
+ mc->cap = 0;
+
+ compiler_defer(c, mc_cleanup, base);
+ return base;
+}
+
+void mc_free(MCEmitter* m)
+{
+ if (!m) return;
+ /* Arena-backed; nothing to free. */
+}
diff --git a/src/core/buf.c b/src/core/buf.c
@@ -102,6 +102,27 @@ void buf_patch(Buf* b, u32 ofs, const void* data, size_t n)
* drop matches buf_write's allocation-failure policy. */
}
+void buf_read(const Buf* b, u32 ofs, void* dst, size_t n)
+{
+ BufChunk* c = b->head;
+ u32 chunk_start = 0;
+ u8* p = (u8*)dst;
+ while (c && n) {
+ u32 chunk_end = chunk_start + c->used;
+ if (ofs < chunk_end) {
+ u32 within = ofs - chunk_start;
+ u32 avail = c->used - within;
+ u32 take = (u32)(n < avail ? n : avail);
+ memcpy(p, c->data + within, take);
+ p += take;
+ n -= take;
+ ofs += take;
+ }
+ chunk_start = chunk_end;
+ c = c->next;
+ }
+}
+
void buf_flatten(const Buf* b, u8* dst)
{
BufChunk* c = b->head;
diff --git a/src/core/buf.h b/src/core/buf.h
@@ -28,6 +28,7 @@ void buf_write(Buf*, const void* data, size_t n);
u8* buf_reserve(Buf*, size_t n); /* contiguous; spills to a fresh chunk if needed */
u32 buf_pos(const Buf*);
void buf_patch(Buf*, u32 ofs, const void* data, size_t n); /* must lie within written range */
+void buf_read (const Buf*, u32 ofs, void* dst, size_t n); /* must lie within written range */
void buf_flatten(const Buf*, u8* dst); /* copy entire contents out, dst >= total bytes */
diff --git a/test/cg/CORPUS.md b/test/cg/CORPUS.md
@@ -0,0 +1,88 @@
+# cg / CGTarget / MCEmitter test corpus
+
+Coverage matrix for `test/cg/`. Each registered case in
+`harness/cases.c` is one row; behavioral oracle is `test_main`'s return
+value. Mirrors the CORPUS shape used by `test/elf/` and `test/link/`.
+
+Test paths per case (run.sh):
+
+- **D** in-process JIT (aarch64 host only) — `cg-runner --jit NAME`.
+- **R** ELF roundtrip (host-arch agnostic) — `cg-runner --emit` →
+ `cfree-roundtrip` → `readelf` + `normalize.py` diff.
+- **E** exec via qemu/podman — `cg-runner --emit` + `start.o` →
+ `link-exe-runner` → run.
+- **J** jit-via-file (aarch64 host only) — `cg-runner --emit` →
+ `jit-runner`.
+
+`O` (opt-wrapped) lands once `opt_cgtarget` is implemented.
+
+## Status legend
+
+- ★ landed in the spine
+- · planned
+- (deferred) — explicit non-goal for the current pass
+
+## MC-only — direct MCEmitter
+
+| Case | Status | Notes |
+|---|---|---|
+| `mc_smoke` | ★ | hand-built `mov w0, #42; ret`; analogue of `test/elf/unit/smoke.c` |
+
+## Group A — function lifecycle and return
+
+| Case | Status | Body | Expected |
+|---|---|---|---|
+| `a01_return_const_42` | ★ | `alloc_reg; load_imm 42; ret reg` | 42 |
+| `a02_return_zero` | ★ | `load_imm 0; ret reg` | 0 |
+| `a03_ret_imm` | ★ | `ret IMM 17` (backend materializes) | 17 |
+| `a04_copy_reg` | ★ | `load_imm 7; copy r1->r2; ret r2` | 7 |
+| `a05_return_neg_small`| · | `load_imm -7` via MOVN; ret | 249 (mod 256) |
+| `a06_return_i64` | · | 64-bit return register selection | per width |
+| `a07_void_return` | · | `ret(NULL)` | 0 (via _start init) |
+| `a08_multiple_returns`| · | early return | depends |
+
+## Group B — frame slots, parameters, locals
+
+(deferred until frame_slot/param implemented)
+
+| Case | Status | Notes |
+|---|---|---|
+| `b01_param_int` | · | one int param echoed |
+| `b02_param_sum` | · | two params summed |
+| `b03_param_spill` | · | nine params (overflow registers) |
+| `b04_local_int` | · | local stored, loaded, returned |
+| `b05_addr_taken_local` | · | `&local` forces frame residency |
+| `b06_sret` | · | struct return |
+| `b07_byval_param` | · | aggregate-by-value parameter |
+| `b08_fp_param` | · | float/double param |
+
+## Group C — integer arithmetic
+
+| Case | Status | Body | Expected |
+|---|---|---|---|
+| `c01_add` | ★ | `1 + 2` | 3 |
+| `c02_sub_mul` | ★ | `7 * 3 - 4` | 17 |
+| `c03_bitwise` | ★ | `(~3) & 0xff` | 252 |
+| `c04_shift` | ★ | `(1<<5) | (16>>1)` | 40 |
+| `c05_div_mod` | · | `23 / 4 + 23 % 4` | 8 (needs SDIV+SREM) |
+| `c06_xor` | · | `0xa5 ^ 0x5a` | 0xff |
+| `c07_iadd_i64` | · | 64-bit adds | depends |
+| `c08_unsigned_div` | · | `100u / 7u` | 14 |
+
+## Group D — compare and branch (deferred)
+## Group E — conversions (deferred)
+## Group F — memory (deferred)
+## Group G — calls (deferred)
+## Group H — control flow (deferred)
+## Group I — alloca (deferred)
+## Group J — varargs (deferred)
+## Group K — atomics (deferred)
+## Group L — intrinsics (deferred)
+## Group M — inline asm (deferred)
+## Group N — TLS (deferred)
+## Group O — sections + globals (deferred)
+## Group P — set_loc / debug (deferred)
+## Group Q — multi-function (deferred)
+## Group R — opt-wrapped equivalence (deferred)
+
+See `doc/cg_testing.md` for the strategy and group definitions.
diff --git a/test/cg/harness/cases.c b/test/cg/harness/cases.c
@@ -0,0 +1,181 @@
+/* Test case registry.
+ *
+ * Each case is a static builder function plus an entry in cg_cases[]. The
+ * runner finds cases by name, sets up the per-case Compiler + ObjBuilder +
+ * MCEmitter (+ CGTarget for non-MC-only cases), invokes the builder, and
+ * exits with the result of test_main() (for --jit) or writes the .o (for
+ * --emit).
+ *
+ * Adding a case: write a static `build_<name>(CgTestCtx*)`, add a row to
+ * the array, and update CORPUS.md. */
+
+#include "cg_test.h"
+
+#include <string.h>
+
+/* ============================================================
+ * Group: MC-only (lowest layer)
+ * ============================================================ */
+
+/* mc_smoke — emit `mov w0, #42; ret` as raw AArch64 bytes through
+ * MCEmitter. No CGTarget involved. Validates the byte path end-to-end. */
+static void build_mc_smoke(CgTestCtx* ctx)
+{
+ static const u8 BYTES[8] = {
+ /* mov w0, #42 */ 0x40, 0x05, 0x80, 0x52,
+ /* ret */ 0xc0, 0x03, 0x5f, 0xd6,
+ };
+
+ ObjSymId sym = cgtest_mc_begin_main(ctx);
+ ctx->mc->set_section(ctx->mc, ctx->text_sec);
+ ctx->mc->emit_align(ctx->mc, 4, 0);
+ u32 start = ctx->mc->pos(ctx->mc);
+ ctx->mc->emit_bytes(ctx->mc, BYTES, sizeof BYTES);
+ cgtest_mc_end_main(ctx, sym, start);
+}
+
+/* ============================================================
+ * Group A: function lifecycle and return
+ * ============================================================ */
+
+/* a01_return_const_42 — alloc reg, load_imm(42), ret reg. */
+static void build_a01_return_const_42(CgTestCtx* ctx)
+{
+ CgTestFn* tf = cgtest_begin_main(ctx, T_I32);
+ Reg r = ctx->target->alloc_reg(ctx->target, RC_INT, T_I32);
+ ctx->target->load_imm(ctx->target, REG_i32(r), 42);
+ cgtest_ret_reg(tf, r, T_I32);
+ cgtest_end(tf);
+}
+
+/* a02_return_zero — load_imm(0). */
+static void build_a02_return_zero(CgTestCtx* ctx)
+{
+ CgTestFn* tf = cgtest_begin_main(ctx, T_I32);
+ Reg r = ctx->target->alloc_reg(ctx->target, RC_INT, T_I32);
+ ctx->target->load_imm(ctx->target, REG_i32(r), 0);
+ cgtest_ret_reg(tf, r, T_I32);
+ cgtest_end(tf);
+}
+
+/* a03_ret_imm — backend materializes the imm directly inside ret(). */
+static void build_a03_ret_imm(CgTestCtx* ctx)
+{
+ CgTestFn* tf = cgtest_begin_main(ctx, T_I32);
+ cgtest_ret_imm(tf, 17, T_I32);
+ cgtest_end(tf);
+}
+
+/* a04_copy_reg — load_imm(7) into r1, copy r1->r2, ret r2. Exercises
+ * CGTarget.copy. */
+static void build_a04_copy_reg(CgTestCtx* ctx)
+{
+ CgTestFn* tf = cgtest_begin_main(ctx, T_I32);
+ Reg r1 = ctx->target->alloc_reg(ctx->target, RC_INT, T_I32);
+ Reg r2 = ctx->target->alloc_reg(ctx->target, RC_INT, T_I32);
+ ctx->target->load_imm(ctx->target, REG_i32(r1), 7);
+ ctx->target->copy(ctx->target, REG_i32(r2), REG_i32(r1));
+ cgtest_ret_reg(tf, r2, T_I32);
+ cgtest_end(tf);
+}
+
+/* ============================================================
+ * Group C: integer arithmetic
+ * ============================================================ */
+
+/* c01_add — 1 + 2 = 3 */
+static void build_c01_add(CgTestCtx* ctx)
+{
+ CgTestFn* tf = cgtest_begin_main(ctx, T_I32);
+ CGTarget* T = ctx->target;
+ Reg a = T->alloc_reg(T, RC_INT, T_I32);
+ Reg b = T->alloc_reg(T, RC_INT, T_I32);
+ Reg d = T->alloc_reg(T, RC_INT, T_I32);
+ T->load_imm(T, REG_i32(a), 1);
+ T->load_imm(T, REG_i32(b), 2);
+ T->binop(T, BO_IADD, REG_i32(d), REG_i32(a), REG_i32(b));
+ cgtest_ret_reg(tf, d, T_I32);
+ cgtest_end(tf);
+}
+
+/* c02_sub_mul — 7 * 3 - 4 = 17 */
+static void build_c02_sub_mul(CgTestCtx* ctx)
+{
+ CgTestFn* tf = cgtest_begin_main(ctx, T_I32);
+ CGTarget* T = ctx->target;
+ Reg r7 = T->alloc_reg(T, RC_INT, T_I32);
+ Reg r3 = T->alloc_reg(T, RC_INT, T_I32);
+ Reg r4 = T->alloc_reg(T, RC_INT, T_I32);
+ Reg rmul = T->alloc_reg(T, RC_INT, T_I32);
+ Reg rsub = T->alloc_reg(T, RC_INT, T_I32);
+ T->load_imm(T, REG_i32(r7), 7);
+ T->load_imm(T, REG_i32(r3), 3);
+ T->load_imm(T, REG_i32(r4), 4);
+ T->binop(T, BO_IMUL, REG_i32(rmul), REG_i32(r7), REG_i32(r3));
+ T->binop(T, BO_ISUB, REG_i32(rsub), REG_i32(rmul), REG_i32(r4));
+ cgtest_ret_reg(tf, rsub, T_I32);
+ cgtest_end(tf);
+}
+
+/* c03_bitwise — (~3) & 0xff = 252 */
+static void build_c03_bitwise(CgTestCtx* ctx)
+{
+ CgTestFn* tf = cgtest_begin_main(ctx, T_I32);
+ CGTarget* T = ctx->target;
+ Reg r3 = T->alloc_reg(T, RC_INT, T_I32);
+ Reg rinv = T->alloc_reg(T, RC_INT, T_I32);
+ Reg rmask= T->alloc_reg(T, RC_INT, T_I32);
+ Reg rand = T->alloc_reg(T, RC_INT, T_I32);
+ T->load_imm(T, REG_i32(r3), 3);
+ T->load_imm(T, REG_i32(rmask),0xff);
+ T->unop (T, UO_BNOT, REG_i32(rinv), REG_i32(r3));
+ T->binop(T, BO_AND, REG_i32(rand), REG_i32(rinv), REG_i32(rmask));
+ cgtest_ret_reg(tf, rand, T_I32);
+ cgtest_end(tf);
+}
+
+/* c04_shift — (1<<5) | (16>>1) = 40 */
+static void build_c04_shift(CgTestCtx* ctx)
+{
+ CgTestFn* tf = cgtest_begin_main(ctx, T_I32);
+ CGTarget* T = ctx->target;
+ Reg r1 = T->alloc_reg(T, RC_INT, T_I32);
+ Reg r5 = T->alloc_reg(T, RC_INT, T_I32);
+ Reg r16 = T->alloc_reg(T, RC_INT, T_I32);
+ Reg r1s = T->alloc_reg(T, RC_INT, T_I32);
+ Reg rshl= T->alloc_reg(T, RC_INT, T_I32);
+ Reg rshr= T->alloc_reg(T, RC_INT, T_I32);
+ Reg ror = T->alloc_reg(T, RC_INT, T_I32);
+ T->load_imm(T, REG_i32(r1), 1);
+ T->load_imm(T, REG_i32(r5), 5);
+ T->load_imm(T, REG_i32(r16), 16);
+ T->load_imm(T, REG_i32(r1s), 1);
+ T->binop(T, BO_SHL, REG_i32(rshl), REG_i32(r1), REG_i32(r5));
+ T->binop(T, BO_SHR_U, REG_i32(rshr), REG_i32(r16), REG_i32(r1s));
+ T->binop(T, BO_OR, REG_i32(ror), REG_i32(rshl),REG_i32(rshr));
+ cgtest_ret_reg(tf, ror, T_I32);
+ cgtest_end(tf);
+}
+
+/* ============================================================
+ * Registry
+ * ============================================================ */
+
+const CgCase cg_cases[] = {
+ /* MC-only */
+ { "mc_smoke", build_mc_smoke, 42, CG_CASE_MC_ONLY },
+
+ /* Group A */
+ { "a01_return_const_42", build_a01_return_const_42, 42, CG_CASE_DEFAULT },
+ { "a02_return_zero", build_a02_return_zero, 0, CG_CASE_DEFAULT },
+ { "a03_ret_imm", build_a03_ret_imm, 17, CG_CASE_DEFAULT },
+ { "a04_copy_reg", build_a04_copy_reg, 7, CG_CASE_DEFAULT },
+
+ /* Group C */
+ { "c01_add", build_c01_add, 3, CG_CASE_DEFAULT },
+ { "c02_sub_mul", build_c02_sub_mul, 17, CG_CASE_DEFAULT },
+ { "c03_bitwise", build_c03_bitwise, 252, CG_CASE_DEFAULT },
+ { "c04_shift", build_c04_shift, 40, CG_CASE_DEFAULT },
+};
+
+const unsigned cg_cases_count = sizeof(cg_cases) / sizeof(cg_cases[0]);
diff --git a/test/cg/harness/cg_runner.c b/test/cg/harness/cg_runner.c
@@ -0,0 +1,260 @@
+/* cg_runner — multi-mode test runner for the cg/CGTarget/MCEmitter stack.
+ *
+ * cg-runner --list # print every registered case name
+ * cg-runner --expected NAME # print expected exit code (stdout)
+ * cg-runner --emit NAME OUT.o # build, emit_elf, write to OUT.o
+ * cg-runner --jit NAME # build, link, JIT, call test_main;
+ * # exit code = test_main's return
+ *
+ * The --jit path uses link_add_obj on the in-process ObjBuilder, so it
+ * exercises the live OB → JIT mapping (no .o serialization). The --emit
+ * path produces a .o that the existing test/link harness binaries
+ * (cfree-roundtrip, link-exe-runner, jit-runner) consume to drive paths
+ * R, E, and J. The shell harness compares the exit codes of those runs
+ * against the value reported by --expected. */
+
+#include <cfree.h>
+#include "core/core.h"
+#include "core/pool.h"
+#include "obj/obj.h"
+#include "type/type.h"
+#include "abi/abi.h"
+#include "arch/arch.h"
+#include "link/link.h"
+
+#include "cg_test.h"
+
+#include <fcntl.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+/* ---- env ---- */
+
+static void* h_alloc (CfreeHeap* h, size_t n, size_t a)
+{ (void)h;(void)a; return n ? malloc(n) : NULL; }
+static void* h_realloc(CfreeHeap* h, void* p, size_t o, size_t n, size_t a)
+{ (void)h;(void)o;(void)a; return realloc(p, n); }
+static void h_free (CfreeHeap* h, void* p, size_t n)
+{ (void)h;(void)n; free(p); }
+static CfreeHeap g_heap = { h_alloc, h_realloc, h_free, NULL };
+
+static void diag_emit(CfreeDiagSink* s, CfreeDiagKind k, CfreeSrcLoc loc,
+ const char* fmt, va_list ap)
+{
+ static const char* names[] = { "note","warning","error","fatal" };
+ (void)s; (void)loc;
+ fprintf(stderr, "%s: ", names[k]);
+ vfprintf(stderr, fmt, ap);
+ fputc('\n', stderr);
+}
+static CfreeDiagSink g_diag = { diag_emit, NULL, 0, 0 };
+
+/* ---- helpers ---- */
+
+static const CgCase* find_case(const char* name)
+{
+ for (unsigned i = 0; i < cg_cases_count; ++i) {
+ if (strcmp(cg_cases[i].name, name) == 0) return &cg_cases[i];
+ }
+ return NULL;
+}
+
+static void target_aarch64_linux(CfreeTarget* t)
+{
+ memset(t, 0, sizeof *t);
+ t->arch = CFREE_ARCH_ARM_64;
+ t->os = CFREE_OS_LINUX;
+ t->obj = CFREE_OBJ_ELF;
+ t->ptr_size = 8;
+ t->ptr_align = 8;
+ t->big_endian = 0;
+}
+
+/* Build the ObjBuilder for a case. On success returns 0 and fills *ob_out;
+ * on panic returns nonzero (the diagnostic was already emitted). */
+typedef struct BuildState {
+ Compiler* c;
+ ObjBuilder* ob;
+ MCEmitter* mc;
+ CGTarget* target;
+ CgTestCtx ctx;
+} BuildState;
+
+static int build_case(BuildState* st, const CgCase* cc)
+{
+ Compiler* c = st->c;
+
+ if (setjmp(c->panic)) {
+ compiler_run_cleanups(c);
+ return 1;
+ }
+
+ st->ob = obj_new(c);
+ st->mc = mc_new(c, st->ob);
+
+ if (cc->kind != CG_CASE_MC_ONLY) {
+ st->target = cgtarget_new(c, st->ob, st->mc);
+ } else {
+ st->target = NULL;
+ }
+
+ Sym text_name = pool_intern_cstr(c->global, ".text");
+ ObjSecId text_sec = obj_section(st->ob, text_name, SEC_TEXT,
+ SF_ALLOC | SF_EXEC, 4);
+
+ st->ctx.c = c;
+ st->ctx.ob = st->ob;
+ st->ctx.mc = st->mc;
+ st->ctx.target = st->target;
+ st->ctx.text_sec = text_sec;
+ st->ctx.pool = c->global;
+
+ if (st->target) {
+ st->mc->set_section(st->mc, text_sec);
+ }
+
+ cc->build(&st->ctx);
+
+ if (st->target) cgtarget_finalize(st->target);
+ obj_finalize(st->ob);
+ return 0;
+}
+
+/* ---- modes ---- */
+
+static int mode_list(void)
+{
+ for (unsigned i = 0; i < cg_cases_count; ++i) {
+ fprintf(stdout, "%s\n", cg_cases[i].name);
+ }
+ return 0;
+}
+
+static int mode_expected(const char* name)
+{
+ const CgCase* cc = find_case(name);
+ if (!cc) { fprintf(stderr, "cg-runner: unknown case '%s'\n", name); return 2; }
+ fprintf(stdout, "%d\n", cc->expected);
+ return 0;
+}
+
+static int mode_emit(const char* name, const char* out_path)
+{
+ const CgCase* cc = find_case(name);
+ if (!cc) { fprintf(stderr, "cg-runner: unknown case '%s'\n", name); return 2; }
+
+ CfreeTarget target; target_aarch64_linux(&target);
+ CfreeEnv env; memset(&env, 0, sizeof env);
+ env.heap = &g_heap; env.diag = &g_diag;
+
+ CfreeCompiler* cc_ = cfree_compiler_new(target, &env);
+ if (!cc_) { fprintf(stderr, "cg-runner: compiler_new failed\n"); return 2; }
+
+ BuildState st; memset(&st, 0, sizeof st);
+ st.c = (Compiler*)cc_;
+ if (build_case(&st, cc)) {
+ cfree_compiler_free(cc_);
+ return 1;
+ }
+
+ /* Emit ELF to a memory writer, then dump to OUT_PATH. */
+ CfreeWriter* w = cfree_writer_mem(&g_heap);
+ emit_elf(st.c, st.ob, w);
+
+ size_t len = 0;
+ const uint8_t* data = cfree_writer_mem_bytes(w, &len);
+
+ int rc = 0;
+ int fd = open(out_path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
+ if (fd < 0) { perror(out_path); rc = 2; }
+ else {
+ size_t off = 0;
+ while (off < len) {
+ ssize_t k = write(fd, data + off, len - off);
+ if (k <= 0) { perror("write"); rc = 2; break; }
+ off += (size_t)k;
+ }
+ close(fd);
+ }
+ cfree_writer_close(w);
+ cfree_compiler_free(cc_);
+ return rc;
+}
+
+static int mode_jit(const char* name)
+{
+ const CgCase* cc = find_case(name);
+ if (!cc) { fprintf(stderr, "cg-runner: unknown case '%s'\n", name); return 2; }
+
+ CfreeTarget target; target_aarch64_linux(&target);
+ CfreeEnv env; memset(&env, 0, sizeof env);
+ env.heap = &g_heap; env.diag = &g_diag;
+
+ CfreeCompiler* cc_ = cfree_compiler_new(target, &env);
+ if (!cc_) { fprintf(stderr, "cg-runner: compiler_new failed\n"); return 2; }
+
+ BuildState st; memset(&st, 0, sizeof st);
+ st.c = (Compiler*)cc_;
+ if (build_case(&st, cc)) {
+ cfree_compiler_free(cc_);
+ return 1;
+ }
+
+ /* Direct in-process link: hand the ObjBuilder to the linker. */
+ Compiler* c = st.c;
+ if (setjmp(c->panic)) {
+ compiler_run_cleanups(c);
+ cfree_compiler_free(cc_);
+ return 1;
+ }
+
+ Linker* lk = link_new(c);
+ link_add_obj(lk, st.ob);
+ link_set_entry(lk, "test_main");
+ LinkImage* img = link_resolve(lk);
+ if (!img) {
+ link_free(lk);
+ cfree_compiler_free(cc_);
+ return 1;
+ }
+ CfreeJit* jit = cfree_jit_from_image(img);
+ if (!jit) {
+ link_free(lk);
+ cfree_compiler_free(cc_);
+ return 1;
+ }
+
+ int (*fn)(void) = (int(*)(void))cfree_jit_lookup(jit, "test_main");
+ int result = fn ? fn() : 1;
+
+ cfree_jit_free(jit);
+ link_free(lk);
+ cfree_compiler_free(cc_);
+ return result;
+}
+
+/* ---- main ---- */
+
+static int usage(void)
+{
+ fprintf(stderr,
+ "usage: cg-runner --list\n"
+ " cg-runner --expected NAME\n"
+ " cg-runner --emit NAME OUT.o\n"
+ " cg-runner --jit NAME\n");
+ return 2;
+}
+
+int main(int argc, char** argv)
+{
+ if (argc < 2) return usage();
+ if (!strcmp(argv[1], "--list")) return mode_list();
+ else if (!strcmp(argv[1], "--expected") && argc == 3) return mode_expected(argv[2]);
+ else if (!strcmp(argv[1], "--emit") && argc == 4) return mode_emit(argv[2], argv[3]);
+ else if (!strcmp(argv[1], "--jit") && argc == 3) return mode_jit (argv[2]);
+ return usage();
+}
diff --git a/test/cg/harness/cg_test.c b/test/cg/harness/cg_test.c
@@ -0,0 +1,152 @@
+/* test/cg fixture API implementation. */
+
+#include "cg_test.h"
+
+#include "core/pool.h"
+
+#include <string.h>
+
+/* ---- static pre-interned types ----
+ * These are not pool_interned (pool_type is a stub until the parser comes
+ * online), but the AArch64 backend only reads Type.kind to derive scalar
+ * width, which static literals satisfy. */
+static const Type s_void = { .kind = TY_VOID };
+static const Type s_i8 = { .kind = TY_SCHAR };
+static const Type s_i16 = { .kind = TY_SHORT };
+static const Type s_i32 = { .kind = TY_INT };
+static const Type s_i64 = { .kind = TY_LLONG };
+static const Type s_u8 = { .kind = TY_UCHAR };
+static const Type s_u16 = { .kind = TY_USHORT };
+static const Type s_u32 = { .kind = TY_UINT };
+static const Type s_u64 = { .kind = TY_ULLONG };
+static const Type s_ptr_v = { .kind = TY_PTR };
+
+const Type*T_VOID = &s_void;
+const Type*T_I8 = &s_i8;
+const Type*T_I16 = &s_i16;
+const Type*T_I32 = &s_i32;
+const Type*T_I64 = &s_i64;
+const Type*T_U8 = &s_u8;
+const Type*T_U16 = &s_u16;
+const Type*T_U32 = &s_u32;
+const Type*T_U64 = &s_u64;
+const Type*T_PTR_VOID= &s_ptr_v;
+
+/* ---- operand sugar ---- */
+
+Operand IMM_i32(i64 v)
+{
+ Operand o = { 0 };
+ o.kind = OPK_IMM; o.cls = RC_INT; o.type = T_I32;
+ o.v.imm = v;
+ return o;
+}
+Operand IMM_i64(i64 v)
+{
+ Operand o = { 0 };
+ o.kind = OPK_IMM; o.cls = RC_INT; o.type = T_I64;
+ o.v.imm = v;
+ return o;
+}
+Operand REG_i32(Reg r)
+{
+ Operand o = { 0 };
+ o.kind = OPK_REG; o.cls = RC_INT; o.type = T_I32;
+ o.v.reg = r;
+ return o;
+}
+Operand REG_i64(Reg r)
+{
+ Operand o = { 0 };
+ o.kind = OPK_REG; o.cls = RC_INT; o.type = T_I64;
+ o.v.reg = r;
+ return o;
+}
+
+/* ---- function-fixture helpers ---- */
+
+CgTestFn* cgtest_begin_main(CgTestCtx* ctx, const Type* ret_ty)
+{
+ /* Allocate fixture state in tu arena so its lifetime spans the case. */
+ CgTestFn* tf = arena_new(ctx->c->tu, CgTestFn);
+ memset(tf, 0, sizeof *tf);
+ tf->ctx = ctx;
+ tf->ret_ty = ret_ty;
+
+ Sym name = pool_intern_cstr(ctx->pool, "test_main");
+ tf->sym = obj_symbol(ctx->ob, name, SB_GLOBAL, SK_FUNC,
+ OBJ_SEC_NONE, 0, 0);
+
+ tf->fd.sym = tf->sym;
+ tf->fd.text_section_id = ctx->text_sec;
+ tf->fd.group_id = OBJ_GROUP_NONE;
+ tf->fd.fn_type = NULL; /* unused by minimal AArch64 backend */
+ tf->fd.abi = NULL;
+ tf->fd.params = NULL;
+ tf->fd.nparams = 0;
+ tf->fd.loc = (SrcLoc){0, 0, 0};
+
+ ctx->target->func_begin(ctx->target, &tf->fd);
+ return tf;
+}
+
+void cgtest_ret_reg(CgTestFn* tf, Reg r, const Type* ty)
+{
+ CgTestCtx* ctx = tf->ctx;
+ memset(&tf->ret_val, 0, sizeof tf->ret_val);
+ tf->ret_val.type = ty;
+ tf->ret_val.abi = NULL;
+ tf->ret_val.storage.kind = OPK_REG;
+ tf->ret_val.storage.cls = RC_INT;
+ tf->ret_val.storage.type = ty;
+ tf->ret_val.storage.v.reg = r;
+ tf->ret_val.parts = NULL;
+ tf->ret_val.nparts = 0;
+ ctx->target->ret(ctx->target, &tf->ret_val);
+}
+
+void cgtest_ret_imm(CgTestFn* tf, i64 imm, const Type* ty)
+{
+ CgTestCtx* ctx = tf->ctx;
+ memset(&tf->ret_val, 0, sizeof tf->ret_val);
+ tf->ret_val.type = ty;
+ tf->ret_val.abi = NULL;
+ tf->ret_val.storage.kind = OPK_IMM;
+ tf->ret_val.storage.cls = RC_INT;
+ tf->ret_val.storage.type = ty;
+ tf->ret_val.storage.v.imm = imm;
+ tf->ret_val.parts = NULL;
+ tf->ret_val.nparts = 0;
+ ctx->target->ret(ctx->target, &tf->ret_val);
+}
+
+void cgtest_ret_void(CgTestFn* tf)
+{
+ CgTestCtx* ctx = tf->ctx;
+ ctx->target->ret(ctx->target, NULL);
+}
+
+void cgtest_end(CgTestFn* tf)
+{
+ CgTestCtx* ctx = tf->ctx;
+ ctx->target->func_end(ctx->target);
+}
+
+/* ---- MC-only case helpers ---- */
+
+ObjSymId cgtest_mc_begin_main(CgTestCtx* ctx)
+{
+ Sym name = pool_intern_cstr(ctx->pool, "test_main");
+ ObjSymId sym = obj_symbol(ctx->ob, name, SB_GLOBAL, SK_FUNC,
+ OBJ_SEC_NONE, 0, 0);
+ /* Caller is responsible for set_section + emit_align before recording
+ * the start position. */
+ return sym;
+}
+
+void cgtest_mc_end_main(CgTestCtx* ctx, ObjSymId sym, u32 start_pos)
+{
+ u32 end = ctx->mc->pos(ctx->mc);
+ obj_symbol_define(ctx->ob, sym, ctx->text_sec,
+ (u64)start_pos, (u64)(end - start_pos));
+}
diff --git a/test/cg/harness/cg_test.h b/test/cg/harness/cg_test.h
@@ -0,0 +1,100 @@
+/* test/cg fixture API.
+ *
+ * Each case is a small builder function that constructs `int test_main(void)`
+ * (or another named function returning int) by driving CGTarget directly,
+ * MCEmitter directly, or — once cg.h is implemented — the cg.h value-stack
+ * API. The runner finds the case by name, runs build(), finalizes the
+ * ObjBuilder, and exposes it through one of three exit paths:
+ *
+ * --emit NAME OUT.o : emit_elf to OUT.o (used by R/E/J path scripts)
+ * --jit NAME : link in-process and call test_main, exit with result
+ * --list : list every registered case name
+ *
+ * Pre-interned `Type`s here are static literals, not pool-interned. The
+ * AArch64 backend only inspects Type.kind to derive scalar width, which the
+ * literals satisfy. When the parser+TargetABI come online, the harness will
+ * switch to pool_type-interned Types but cases shouldn't need changes. */
+
+#ifndef CFREE_TEST_CG_TEST_H
+#define CFREE_TEST_CG_TEST_H
+
+#include "core/core.h"
+#include "obj/obj.h"
+#include "type/type.h"
+#include "abi/abi.h"
+#include "arch/arch.h"
+
+/* ---- ctx + case registry ---- */
+
+typedef struct CgTestCtx {
+ Compiler* c;
+ ObjBuilder* ob;
+ MCEmitter* mc;
+ CGTarget* target;
+ ObjSecId text_sec;
+ Pool* pool;
+} CgTestCtx;
+
+typedef void (*CgCaseFn)(CgTestCtx*);
+
+typedef enum {
+ CG_CASE_DEFAULT = 0, /* uses CGTarget (default) */
+ CG_CASE_MC_ONLY = 1, /* uses MCEmitter only — no CGTarget construction */
+} CgCaseKind;
+
+typedef struct CgCase {
+ const char* name;
+ CgCaseFn build;
+ int expected; /* test_main return value (default 0) */
+ unsigned kind; /* CgCaseKind */
+} CgCase;
+
+extern const CgCase cg_cases[];
+extern const unsigned cg_cases_count;
+
+/* ---- pre-interned types ----
+ * Static Type literals; backend reads only .kind for width. */
+extern const Type *T_VOID, *T_I8, *T_I16, *T_I32, *T_I64,
+ *T_U8, *T_U16, *T_U32, *T_U64,
+ *T_PTR_VOID;
+
+/* ---- operand sugar ---- */
+Operand IMM_i32 (i64 v);
+Operand IMM_i64 (i64 v);
+Operand REG_i32 (Reg r);
+Operand REG_i64 (Reg r);
+
+/* ---- function-fixture helpers ----
+ * cgtest_begin_main creates an undefined `test_main` symbol of kind SK_FUNC
+ * and calls CGTarget.func_begin against the .text section. Use it from any
+ * CGTarget-driving case.
+ *
+ * cgtest_ret_reg materializes a CGABIValue holding the given register and
+ * type, then calls CGTarget.ret. Use it for cases that compute a value in
+ * a backend-allocated register and return it as int.
+ *
+ * cgtest_end calls CGTarget.func_end. */
+
+typedef struct CgTestFn {
+ CgTestCtx* ctx;
+ const Type* ret_ty;
+ ObjSymId sym;
+ CGFuncDesc fd;
+ CGABIValue ret_val; /* reused by cgtest_ret_* */
+} CgTestFn;
+
+CgTestFn* cgtest_begin_main(CgTestCtx* ctx, const Type* ret_ty);
+void cgtest_ret_reg (CgTestFn*, Reg r, const Type* ty);
+void cgtest_ret_imm (CgTestFn*, i64 imm, const Type* ty);
+void cgtest_ret_void (CgTestFn*);
+void cgtest_end (CgTestFn*);
+
+/* ---- low-level helpers (used by mc_smoke and similar) ---- */
+
+/* Define a function symbol at the current MCEmitter section position with
+ * size = current_pos - start_pos. Used by MC-only cases that emit bytes
+ * directly without a CGTarget. */
+ObjSymId cgtest_mc_begin_main(CgTestCtx*);
+void cgtest_mc_end_main (CgTestCtx*, ObjSymId, u32 start_pos);
+
+#endif
diff --git a/test/cg/run.sh b/test/cg/run.sh
@@ -0,0 +1,275 @@
+#!/usr/bin/env bash
+# test/cg/run.sh — fixture-driven cg / CGTarget / MCEmitter test harness.
+#
+# For each registered case (cg-runner --list), runs up to four paths:
+#
+# D in-process JIT — cg-runner --jit NAME → exit code matches expected.
+# No file I/O. aarch64 host only.
+# R ELF roundtrip — cg-runner --emit NAME → cfree-roundtrip → readelf+
+# normalize diff. Validates emitter+reader fidelity.
+# E exec via qemu — cg-runner --emit + start.o → link-exe-runner → qemu/
+# podman → exit code. Cross-host friendly.
+# J jit-via-file — cg-runner --emit + jit-runner. aarch64 host.
+#
+# Reuses the existing test/link harness binaries (link-exe-runner,
+# jit-runner, cfree-roundtrip) verbatim.
+#
+# Skip-vs-fail follows test/link convention: skipped layers are treated as
+# failures unless CFREE_TEST_ALLOW_SKIP=1.
+
+set -u
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+TEST_DIR="$ROOT/test/cg"
+LINK_TEST_DIR="$ROOT/test/link"
+BUILD_DIR="$ROOT/build/test"
+LIB_AR="$ROOT/build/libcfree.a"
+
+CG_RUNNER="$BUILD_DIR/cg-runner"
+ROUNDTRIP_BIN="$BUILD_DIR/cfree-roundtrip"
+LINK_EXE_RUNNER="$BUILD_DIR/link-exe-runner"
+JIT_RUNNER="$BUILD_DIR/jit-runner"
+NORMALIZE="$ROOT/test/elf/normalize.py"
+
+CLANG_TARGET="--target=aarch64-linux-gnu"
+CC="${CC:-cc}"
+CFREE_CFLAGS="-I$ROOT/include -I$ROOT/src -I$TEST_DIR/harness"
+ALLOW_SKIP="${CFREE_TEST_ALLOW_SKIP:-0}"
+
+mkdir -p "$BUILD_DIR" "$BUILD_DIR/cg"
+
+PASS=0; FAIL=0; SKIP=0
+FAIL_NAMES=(); SKIP_NAMES=()
+
+color_red() { printf '\033[31m%s\033[0m' "$1"; }
+color_grn() { printf '\033[32m%s\033[0m' "$1"; }
+color_yel() { printf '\033[33m%s\033[0m' "$1"; }
+
+note_pass() { PASS=$((PASS+1)); printf ' %s %s\n' "$(color_grn PASS)" "$1"; }
+note_fail() { FAIL=$((FAIL+1)); FAIL_NAMES+=("$1"); printf ' %s %s\n' "$(color_red FAIL)" "$1"; }
+note_skip() { SKIP=$((SKIP+1)); SKIP_NAMES+=("$1"); printf ' %s %s — %s\n' "$(color_yel SKIP)" "$1" "$2"; }
+
+# ---- tool detection --------------------------------------------------------
+
+have_clang_cross=0
+have_readelf=0
+have_python3=0
+have_qemu=0
+have_podman=0
+have_runner=0
+have_roundtrip=0
+have_exe_runner=0
+have_jit_runner=0
+is_aarch64=0
+
+if clang $CLANG_TARGET -c -x c - -o /dev/null < /dev/null 2>/dev/null; then
+ have_clang_cross=1
+fi
+command -v llvm-readelf >/dev/null 2>&1 && have_readelf=1
+command -v readelf >/dev/null 2>&1 && have_readelf=1
+command -v python3 >/dev/null 2>&1 && have_python3=1
+
+QEMU_BIN="$(command -v qemu-aarch64-static 2>/dev/null || command -v qemu-aarch64 2>/dev/null || true)"
+[ -n "$QEMU_BIN" ] && have_qemu=1
+command -v podman >/dev/null 2>&1 && have_podman=1
+{ [ $have_qemu -eq 1 ] || [ $have_podman -eq 1 ]; } && have_runner=1
+
+arch_raw="$(uname -m 2>/dev/null || true)"
+{ [ "$arch_raw" = "aarch64" ] || [ "$arch_raw" = "arm64" ]; } && is_aarch64=1
+
+READELF_BIN="$(command -v llvm-readelf 2>/dev/null || command -v readelf 2>/dev/null || true)"
+RUN_AARCH64_IMAGE="${RUN_AARCH64_IMAGE:-alpine:latest}"
+
+run_aarch64() {
+ local exe="$1" out="$2" err="$3"
+ if [ $have_qemu -eq 1 ]; then
+ "$QEMU_BIN" "$exe" >"$out" 2>"$err"; RUN_RC=$?; return
+ fi
+ if [ $have_podman -eq 1 ]; then
+ local dir base
+ dir="$(cd "$(dirname "$exe")" && pwd)"; base="$(basename "$exe")"
+ podman run --rm --platform linux/arm64 --net=none \
+ -v "$dir":/work:Z -w /work "$RUN_AARCH64_IMAGE" "./$base" >"$out" 2>"$err"
+ RUN_RC=$?; return
+ fi
+ RUN_RC=127
+}
+
+# ---- build harness binaries ------------------------------------------------
+
+printf 'Building harness...\n'
+
+if [ ! -f "$LIB_AR" ]; then
+ printf ' FATAL: %s not found — run "make lib" first\n' "$LIB_AR" >&2
+ exit 1
+fi
+
+# cg-runner
+if $CC $CFREE_CFLAGS \
+ "$TEST_DIR/harness/cg_runner.c" \
+ "$TEST_DIR/harness/cg_test.c" \
+ "$TEST_DIR/harness/cases.c" \
+ "$LIB_AR" -o "$CG_RUNNER" 2>"$BUILD_DIR/cg-runner.err"; then
+ printf ' %s cg-runner\n' "$(color_grn built)"
+else
+ printf ' %s cg-runner (see %s)\n' \
+ "$(color_red FATAL)" "$BUILD_DIR/cg-runner.err" >&2
+ exit 1
+fi
+
+# cfree-roundtrip — for path R. test/elf/run.sh builds this; skip path R if
+# we can't find or build it.
+if [ ! -x "$ROUNDTRIP_BIN" ]; then
+ if $CC -I"$ROOT/include" "$ROOT/test/elf/cfree-roundtrip.c" "$LIB_AR" \
+ -o "$ROUNDTRIP_BIN" 2>"$BUILD_DIR/cfree-roundtrip.err"; then
+ have_roundtrip=1
+ printf ' %s cfree-roundtrip\n' "$(color_grn built)"
+ else
+ printf ' %s cfree-roundtrip (see %s)\n' \
+ "$(color_yel warn)" "$BUILD_DIR/cfree-roundtrip.err" >&2
+ fi
+else
+ have_roundtrip=1
+fi
+
+# link-exe-runner — for path E.
+if [ ! -x "$LINK_EXE_RUNNER" ]; then
+ if $CC -I"$ROOT/include" "$LINK_TEST_DIR/harness/link_exe_runner.c" \
+ "$LIB_AR" -o "$LINK_EXE_RUNNER" 2>"$BUILD_DIR/link-exe-runner.err"; then
+ have_exe_runner=1
+ printf ' %s link-exe-runner\n' "$(color_grn built)"
+ else
+ printf ' %s link-exe-runner (see %s)\n' \
+ "$(color_yel warn)" "$BUILD_DIR/link-exe-runner.err" >&2
+ fi
+else
+ have_exe_runner=1
+fi
+
+# jit-runner — for path J. Only on aarch64 host.
+if [ $is_aarch64 -eq 1 ]; then
+ if [ ! -x "$JIT_RUNNER" ]; then
+ if $CC -I"$ROOT/include" "$LINK_TEST_DIR/harness/jit_runner.c" \
+ "$LIB_AR" -o "$JIT_RUNNER" 2>"$BUILD_DIR/jit-runner.err"; then
+ have_jit_runner=1
+ printf ' %s jit-runner\n' "$(color_grn built)"
+ else
+ printf ' %s jit-runner (see %s)\n' \
+ "$(color_yel warn)" "$BUILD_DIR/jit-runner.err" >&2
+ fi
+ else
+ have_jit_runner=1
+ fi
+fi
+
+printf 'Running cases...\n'
+
+# ---- per-case loop ---------------------------------------------------------
+
+CASES="$($CG_RUNNER --list)"
+
+for name in $CASES; do
+ work="$BUILD_DIR/cg/$name"
+ mkdir -p "$work"
+
+ expected="$($CG_RUNNER --expected "$name" 2>/dev/null)"
+ expected="${expected:-0}"
+ # Exit codes are mod 256 on POSIX; mask the expected the same way so
+ # negative-return cases compare correctly.
+ expected_byte=$(( expected & 0xff ))
+
+ # ---- Path D: in-process JIT (only on aarch64) ------------------------
+ if [ $is_aarch64 -eq 1 ]; then
+ "$CG_RUNNER" --jit "$name" >"$work/d.out" 2>"$work/d.err"
+ d_rc=$?
+ if [ "$d_rc" -eq "$expected_byte" ]; then
+ note_pass "$name/D"
+ else
+ note_fail "$name/D (expected $expected_byte got $d_rc)"
+ fi
+ else
+ note_skip "$name/D" "not on aarch64 host"
+ fi
+
+ # ---- Path R: ELF roundtrip --------------------------------------------
+ obj="$work/$name.o"
+ if "$CG_RUNNER" --emit "$name" "$obj" 2>"$work/emit.err"; then
+ :
+ else
+ note_fail "$name/emit (cg-runner --emit failed; see $work/emit.err)"
+ continue
+ fi
+
+ if [ $have_roundtrip -eq 1 ] && [ $have_readelf -eq 1 ] && [ $have_python3 -eq 1 ]; then
+ rt="$work/$name.rt.o"
+ if ! "$ROUNDTRIP_BIN" "$obj" "$rt" 2>"$work/rt.err"; then
+ note_fail "$name/R (roundtrip failed)"
+ else
+ "$READELF_BIN" -aW "$obj" | python3 "$NORMALIZE" >"$work/golden.norm" 2>/dev/null
+ "$READELF_BIN" -aW "$rt" | python3 "$NORMALIZE" >"$work/rt.norm" 2>/dev/null
+ if diff -u "$work/golden.norm" "$work/rt.norm" >"$work/r.diff" 2>&1; then
+ note_pass "$name/R"
+ else
+ note_fail "$name/R"
+ fi
+ fi
+ else
+ note_skip "$name/R" "missing roundtrip/readelf/python3"
+ fi
+
+ # ---- Path E: link + qemu ----------------------------------------------
+ if [ $have_exe_runner -eq 1 ] && [ $have_clang_cross -eq 1 ]; then
+ start_obj="$work/start.o"
+ clang $CLANG_TARGET -O1 -ffreestanding -fno-stack-protector \
+ -fno-PIC -fno-pie \
+ -c "$LINK_TEST_DIR/harness/start.c" -o "$start_obj" 2>/dev/null
+
+ exe="$work/linked.exe"
+ if ! "$LINK_EXE_RUNNER" -o "$exe" "$obj" "$start_obj" \
+ >"$work/exec_link.out" 2>"$work/exec_link.err"; then
+ note_fail "$name/E (link failed)"
+ elif [ $have_runner -eq 1 ]; then
+ run_aarch64 "$exe" "$work/exec.out" "$work/exec.err"
+ if [ "$RUN_RC" -eq "$expected_byte" ]; then
+ note_pass "$name/E"
+ else
+ note_fail "$name/E (expected $expected_byte got $RUN_RC)"
+ fi
+ else
+ note_skip "$name/E" "no qemu/podman"
+ fi
+ else
+ note_skip "$name/E" "no link-exe-runner or aarch64 clang"
+ fi
+
+ # ---- Path J: jit-via-file ---------------------------------------------
+ if [ $have_jit_runner -eq 1 ]; then
+ "$JIT_RUNNER" "$obj" >"$work/jit.out" 2>"$work/jit.err"
+ j_rc=$?
+ if [ "$j_rc" -eq "$expected_byte" ]; then
+ note_pass "$name/J"
+ else
+ note_fail "$name/J (expected $expected_byte got $j_rc)"
+ fi
+ else
+ note_skip "$name/J" "no jit-runner (not aarch64 host)"
+ fi
+done
+
+# ---- summary ---------------------------------------------------------------
+
+printf '\nResults: %s pass, %s fail, %s skip\n' "$PASS" "$FAIL" "$SKIP"
+
+if [ ${#FAIL_NAMES[@]} -gt 0 ]; then
+ printf 'Failed:\n'
+ for n in "${FAIL_NAMES[@]}"; do printf ' %s\n' "$n"; done
+fi
+
+if [ ${#SKIP_NAMES[@]} -gt 0 ] && [ "$ALLOW_SKIP" != "1" ]; then
+ printf 'Skipped (treat as failure; set CFREE_TEST_ALLOW_SKIP=1 to allow):\n'
+ for n in "${SKIP_NAMES[@]}"; do printf ' %s\n' "$n"; done
+fi
+
+if [ $FAIL -gt 0 ]; then exit 1; fi
+if [ $SKIP -gt 0 ] && [ "$ALLOW_SKIP" != "1" ]; then exit 1; fi
+exit 0
diff --git a/test/test.mk b/test/test.mk
@@ -12,10 +12,13 @@
# - test-link: linker + JIT behavioral harness in test/link/; three paths
# per case (roundtrip R, ELF exec E, JIT J). Depends only on libcfree.a.
# Set CFREE_TEST_ALLOW_SKIP=1 to allow skipped layers.
+# - test-cg: cg / CGTarget / MCEmitter behavioral harness in test/cg/;
+# four paths per case (D direct-JIT, R roundtrip, E exec, J jit-via-file).
+# Depends only on libcfree.a; reuses test/link harness binaries.
-.PHONY: test test-lex test-pp test-pp-err test-elf test-ar test-link
+.PHONY: test test-lex test-pp test-pp-err test-elf test-ar test-link test-cg
-test: test-lex test-pp test-pp-err test-elf test-ar test-link
+test: test-lex test-pp test-pp-err test-elf test-ar test-link test-cg
test-lex: bin
@CFREE=$(abspath $(BIN)) test/lex/run.sh
@@ -48,3 +51,6 @@ $(AR_TEST_BIN): test/ar_test.c $(LIB_AR)
test-link: lib
bash test/link/run.sh
+
+test-cg: lib
+ bash test/cg/run.sh