Spec M41 -- override, debloat and re-arch: <mc/core> becomes composable, and a recreated compiler is smaller than mc by what it omits

Owner's direction (2026-09-04, docs/specs/M40.md § Amendment, quoted): "the developer RECREATES the compiler -- without touching src/ -- with overrides of the core's remaining fixed decisions, new primitives, a new machine, and the REMOVAL of the primitives, writers and machines the target does not use; then builds the program with that compiler." And: "<mc/core> becomes composable (a minimal core -- lexer, parser, resolver, walker, hooks -- with the object writers, the two host machines, the driver, the bundle and the sysroot resolver as parts an entry file includes or omits), and primitives the target does not use can be removed (types and intrinsics unregistered; writers, machines and targets simply not registered). examples/avr is the first recreated, debloated compiler: no Mach-O, ELF64 or COFF writer, no arm64 or x86-64 machine, uptr of two bytes, the AVR machine and its ELF32 writer only."

Goal: five bundled parts, one core-provided mc_main(), three registries that make the optional parts self-registering, two removal mechanisms for core language primitives, and one machine-declared override (type_width(TY_UPTR)). Measured target: a compiler built from the minimal core plus one machine and one writer is about a third of build/mc1's size, and the full assembly still equals src/mc.mc byte for byte.

Sequencing: after M24 (type_new/type_width registry, walk_depth_type, slot_new(type_width), syntax_lit, intrinsic, machine_tab/machine_slot). M40 is the first consumer. Line references are to main at 73d1b97; all sizes were measured on this machine on 2026-09-04 with build/mc1 --dump-syms src/mc.mc.

What already exists #

Design #

1. Five parts, and src/core.mc is their sum #

Two file splits, five new part files, one rewritten core.mc.

part (bundle name)members, in order__text measured
mc/core_minarena lz objmodel lex ast parse gen_resolve gen_walk hooks cli136 536
mc/core_machinesmachine_arm64 machine_x86_6436 464
mc/core_writerssha256 macho backend_exe backend_elf backend_coff48 348
mc/core_buildtoml driver sysroots sysroot stubs limits (+ sha256, once-only)56 580
mc/core_bundlebundle_data bundle3 456 + 285 102 __data
src/core.mcthe five parts, then main.mc282 168 total

src/core.mc becomes six #include lines. That is the design's load-bearing property: the full assembly is literally the parts, so there is no second list to drift.

Split 1 -- src/macho.mc -> src/objmodel.mc + src/macho.mc. The walker and the parser do not need a Mach-O writer, they need the object model: parse.mc:349 calls sec_new (via sec_make) and parse.mc:338-343 registers the R_* constants as internal defs; gen_walk.mc uses sec_at/sec_data/sec_zsize (:358-364), sym_new/sym_ref (:434, :559) and reloc_add (:375). objmodel.mc takes macho.mc:1-244 (the three record layouts, R_*, S_*, TEXT_FLAGS, sec_new, sym_new, sym_set_value, reloc_add) plus sym_class/sym_order/out_name16/ dump_syms (:258-324) -- sym_order because backend_elf.mc:292 and backend_coff.mc:315 need it and neither is Mach-O, dump_syms because it prints the model and is check-obj's oracle. macho.mc keeps the MH_*/LC_*/N_*/CPU_* defines (:246-256, also used by backend_exe.mc:553,634,763) and macho_write (:325-467). Measured: 6 592 bytes of model, 5 092 of writer. Function definition order across the two files is unchanged, so the split alone is byte-neutral.

Split 2 -- src/main.mc -> src/cli.mc + src/main.mc. See § 2.

Naming. mc/core_min, not mc/core-min: a bundle name is the file's basename (tools/bundle.list is NAME<TAB>PATH and the last-component fallback matches on the basename), and core_min.mc cannot be reached as core-min. core_* as the prefix keeps mc/driver (the module) distinct from mc/core_build (the part).

What an entry file says. Nothing new: it names the parts.

#include <mc/host>
#include <mc/core_min>
#include "machine_avr.mc"
#include "image_avr.mc"
i64 main(i64 argc, uptr argv, uptr envp) { host_init(envp); return mc_main(argc, argv, envp); }
void user_init() {
    machine_avr_init();
    backend("avr-image", &backend_avr);
    backend_default("avr-image");
}

For mc build: [compiler].core is a path today (src/driver.mc:362-364). One rule, ~4 lines: if the value starts with <, emit it verbatim instead of rewriting it with ../. A project then writes core = "<mc/core_min>" and its module includes whatever else it wants. examples/kernel and examples/lang, which already #include <mc/core> in their module, are unaffected.

2. The entry: mc_main() and three self-registering parts #

src/main.mc:129-242 does eight things a recreated compiler must not have to copy: host_init, the machine/backend/target/sysroot registrations, the bundle hook, the subcommand dispatch, lim_plan, the argv loop with -o/--backend=/--machine=/--include=/--dump-*/--host, user_init() at the one correct moment, and the parse -> passes -> fold -> dump/backend pipeline. Six of the eight are core-min's; two are the optional parts'.

src/cli.mc (core_min) gets opt_val, usage, dump_host, the M_* constants and i64 mc_main(i64 argc, uptr argv, uptr envp) -- everything from src/main.mc:158 on, unchanged, with four edits:

  1. machine_use(host_machine()) becomes conditional (machine_find(...) >= 0), so a compiler with only an AVR machine does not die at startup. New: i64 machine_use_if(uptr name) in hooks.mc.
  2. the default backend (main.mc:198-203) tries the target registry first and, if ntargets == 0, falls back to what backend_default(name) recorded; with neither, die("no backend: use --backend=NAME"). New: void backend_default(uptr name) (~8 lines). No "if exactly one backend is registered" magic -- the module says which one.
  3. lim_plan(...) (:212) moves behind void on_plan(uptr fn) (~12 lines, the on_stmt shape), registered by limits.mc. Unregistered, nothing is pre-sized and the tables grow from the seeds in arena.mc:115 -- which is exactly what astdump does today.
  4. the three str_eq(argv[1], "build"|"limits"|"sysroot") lines (:154-157) move behind void subcommand(uptr name, uptr fn, uptr usage) (~30 lines in hooks.mc), and usage() prints the core line plus each registered subcommand's usage string. core_build registers three, sysroot's string carrying its two lines, so mc with no arguments prints byte-identical text.

src/main.mc keeps backend_macho (it calls macho_write, so it moves into macho.mc), host_bundle_open (moves into bundle.mc beside bundle_open, and bundle.mc gains mc_bundle_init() = lex_set_bundle(&host_bundle_open)), and shrinks to a main() that calls host_init, then mc_machines_init(), mc_writers_init() (the six backend() and five target() calls of :132-146), sysroots_init(), mc_bundle_init(), mc_build_init() (the three subcommand() calls and the on_plan registration), then return mc_main(argc, argv, envp). Each *_init lives in its own part; main.mc is the only file that names all five, and it is the file a recreated compiler replaces.

3. Removal, honestly #

(a) Not including a part. Zero mechanism. This is what the owner's list actually asks for: no Mach-O/ELF64/COFF writer is core_writers omitted; no arm64/x86-64 machine is core_machines omitted; no mc build, no mc sysroot, no mc limits is core_build omitted; no bundle is core_bundle omitted. Measured saving: 141 392 bytes of __text and 285 102 of __data.

(b) Unregistering. Not needed, and recommended against. Once the registrations move into mc_machines_init / mc_writers_init, "not registered" is the default and backend_remove / machine_remove / target_remove have no caller -- the same reason M24's D8 refused type_set_width as a free-standing setter. The tables stay append-only.

(c) Core language primitives. Two mechanisms, both consulted before the core's own answer.

4. Overrides of the core's remaining fixed decisions #

decisionwhere it lives todayverdict
word width of uptrsrc/ast.mc:213-218mechanism, M41. ~15 lines, § 4a
frame cap 4095src/gen_walk.mc:893already the machine's problem (docs/reference/machine.md:176-199); a machine may diagnose a smaller one. No mechanism.
local-array cap 4095src/gen_walk.mc:686, src/parse.mc:1047, :2011follows type_width for free, and is M40 § 2B's semantic trap: the same source is refused on arm64 and accepted on AVR. Documented, not fixed.
MAXDEPTH 64src/gen_walk.mc:55expression depth, not a target fact. Leave.
MAXPARAMS 12src/arena.mc:58the ABI, and M38 made the machines implement the stack half. Leave.
section names __TEXT,__text etc.src/gen_walk.mc:973-979opaque labels the writer maps -- backend_elf and backend_coff already do. No mechanism.
entry symbol _mainsrc/backend_exe.mc:758the writer's. Already replaceable by not including it.
default backend / machinesrc/main.mc:198-203, :131backend_default() + machine_use_if(), § 2
string-literal sectionsrc/gen_walk.mc:977as the section names. No mechanism.
HEAP_SIZE 32 MiBsrc/arena.mc:157-158no. It is bss, absent from the file, and dynamic since M23 (grow/mmap). A static array's size cannot be set from user_init in this language. Document it.
the bundlecore_bundleomit the part, or ship your own (§ 5)

4a. type_width(TY_UPTR), machine-declared. M40 § 1b's C1+C3+C4+C5, on top of M24's M1/M5, priced there at ~15 core lines and adopted here as the mechanism M40 consumes:

Inert by construction: with nothing declared the width is 8, the granule 8, the alignment 16 and the initializer 8 bytes -- exactly today.

5. Measurement and the debloated bundle #

Per-file __text was measured by taking the offset of each file's first function out of build/mc1 --dump-syms src/mc.mc and differencing (the walker lays functions out in definition order). The acceptance harness re-measures the same way, so no new tool is needed.

compiler__text__dataon disk
build/mc1 today282 168293 864 (285 102 blob)600 032
core_min only (+ host, no machine, no writer)~137 100~8 800~176 000
recreated AVR compiler (core_min + AVR machine ~20 K + ELF32/image writer ~8 K)~166 000~9 000~205 000

About a third of mc. Of the ~395 KB saved, 285 KB is the bundle and ~141 KB is code.

Can a debloated compiler ship a smaller bundle? Yes, at zero core lines. tools/bundle.mc:22-25 is arena + lz + bundle_data + bundle, and all four are already bundled names, so a project writes the same four-line tool against <mc/arena>, <lz>, <mc/bundle_data>, <mc/bundle>, generates its own bundle_data.mc from its own manifest, and its compiler includes that file plus <mc/bundle> instead of <mc/core_bundle>. bundle.mc needs only BUNDLE_COUNT and the two arrays, which the generator emits. Worth a paragraph in docs/reference/bundle.md; no code.

What a debloated compiler gives up, stated plainly in the guide: without core_build it has no mc build, so it is a leaf -- it compiles programs, not compilers; without core_bundle it has no #include <name> at all, so its programs use relative includes, as examples/kernel/lib already does.

6. The seed, and how the manifest grows #

stage0 compiles src/mc.mc and cannot parse <name>: stage0/parse.c:799 requires a T_STR after #include. So every part is a file under src/ included relatively by src/core.mc, and separately bundled under an mc/... name for taught compilers -- which is already how mc/core works. Nesting goes from 4 (mc.mc -> core.mc -> arena.mc -> prelude.mc) to 5, against stage0/lex.c:9 MAXOPEN 16; file count goes from ~30 to ~37 against MAXINC 256; string literals stay at 840 against MAXSTRS 2048 (stage0/gen_arm64.c:28) and functions at ~1 100 against MAXFUNCS 2048. No stage0 change, no budget change.

tools/bundle.list grows from 50 to 57 entries: mc/cli, mc/core_build, mc/core_bundle, mc/core_machines, mc/core_min, mc/core_writers, mc/objmodel, each NAME<TAB>src/NAME.mc with a unique last component. scripts/check-bundle.sh:84 derives its expected index size from the manifest (entries * 4), so it needs no edit; src/bundle_data.mc is regenerated by make bundle and grows by roughly 4 KB (the part files are comments and includes; objmodel/cli are moves).

Out of scope #

Files and estimated deltas #

filelineswhat
src/core.mc69 -> ~55six includes and the prose that explains the parts
src/core_min.mc~45new: ten includes + the contract (what a minimal compiler is)
src/core_machines.mc~15new
src/core_writers.mc~25new; mc_writers_init() (the six backend, five target)
src/core_build.mc~25new; mc_build_init() (three subcommand, on_plan)
src/core_bundle.mc~15new
src/objmodel.mc~330moved out of macho.mc (model + sym_order + dump_syms)
src/macho.mc467 -> ~150the Mach-O writer alone, plus backend_macho moved in
src/cli.mc~205moved out of main.mc: mc_main, opt_val, usage, dump_host
src/main.mc242 -> ~55main(): host_init, five *_init, mc_main
src/hooks.mc+95subcommand (30), on_plan (12), backend_default (8), machine_use_if (5), type_disable (10), intrinsic_disable (14), type_set_width (8), accessors
src/parse.mc+6type_of_token consults the disable mask
src/gen_resolve.mc+8intrin_id consults the disable table
src/ast.mc+6type_width for TY_UPTR (on top of M24's registry read)
src/gen_walk.mc+12/-6slot granule, frame alignment, pointer initializer (C3/C4/C5)
src/bundle.mc+14host_bundle_open moved in, mc_bundle_init()
src/limits.mc+4registers on_plan
src/driver.mc+6[compiler].core starting with < is emitted verbatim
tools/bundle.list+7the new entries
src/bundle_data.mcregenerated+~4 KB
scripts/check-parts.sh~170new: the four proofs of Acceptance 1-4
scripts/check-docs.sh+1the coverage families gain type_, intrinsic, subcommand
Makefile+6check-parts, inside check:
docs/reference/hooks.md+90the eight new registrations and their limits
docs/reference/bundle.md+50the parts table, the own-bundle recipe, the naming rule
docs/build.md+25core = "<mc/core_min>"
docs/reference/cli.md, docs/surface.md, docs/plan.md+30mc_main as Tier 3 surface
docs/guide/98-recreating-the-compiler.md~200new: "I want a compiler for X and nothing else"
stage0/, lib/, tests/0untouched

Net new src/ lines ~150; moved ~530.

Acceptance #

  1. The parts are the core. An object built from #include <mc/host> + the five parts spelled out + <mc/main> + <user_default> is byte-identical (cmp) to one built from #include <mc/host> + <mc/core> + <user_default>. This is the anti-drift proof and it fails the moment core.mc and the part files disagree.
  2. check-standalone unchanged and green: step 4 still compares <mc/host> + <mc/core> + <user_default> against build/mc2.o, byte for byte. The reference moves with the milestone; the invariant ("the core inside the bundle is the core in src/") does not.
  3. Inertness, in M17 step A's protocol. src/mc.mc's own source order changes (three files move: the Mach-O writer, hooks, limits), so tests/golden/mc2.sha256 is rewritten once, and only after: a copy of build/mc1 taken before the change and the post-change build/mc1 produce byte-identical objects for all 32 tests/*.mc, for examples/api, examples/lang, examples/conc and examples/kernel; the --dump-asm diff between mc1 and mc2 is empty; cmp build/mc2.o build/mc3.o passes. check-obj 32/32 against the frozen C seed; check-lex/check-ast/check-asm/check-surface/test-exe/test-linux/test-linux-x86_64/ test-windows/check-kernel unchanged.
  4. Debloat is measured, not asserted. scripts/check-parts.sh builds a core_min-only compiler (with a two-slot probe machine and a null writer registered from user_init) and prints the examples/minimal table for it: file size, __text, __cstring, __data. It asserts __data under 16 KB (no blob), and that --dump-syms of its own object contains no _macho_write, _elf_write, _coff_write, _bundle_open, _drv_build, _m_arm64. The ceiling is a number in the script, in check-minimal's style.
  5. Removal is proved by refusal. The same source that build/mc1 compiles is refused by a compiler that called intrinsic_disable("ld64"), with ld64: removed by this compiler, and by one that called type_disable(TY_U32), with u32: removed by this compiler. Both compilers are built inside check-parts.sh from the bundle, in check-standalone's style.
  6. The override is proved and inert. A probe module calling type_set_width(TY_UPTR, 2) shows a uptr local in a 2-byte slot and a uptr[] global at 2 bytes per element under --dump-asm / --dump-syms; type_set_width(TY_U64, 2) is refused; and with no call, every object in Acceptance 3 is unchanged. The real consumer is M40.
  7. mc behaves identically. mc with no arguments prints byte-identical usage text (the subcommand strings reassemble drv_usage's four lines); mc build, mc limits, mc sysroot list and tests/golden/sysroot-list.txt are unchanged; check-build's four diagnostics and check-limits are unchanged.
  8. The bundle is reproducible. make bundle twice gives identical bytes; check-bundle green at 57 entries with <mc/bundle_data> still one #embed node plus a 228-value index.
  9. Docs. make check-docs green with every new symbol documented and the guide page compiled as a sample.
  10. git diff --stat stage0/ lib/ tests/ is empty.

Risks #

  1. The golden rewrite is the milestone's only irreversible step. It is a reorder, not a behaviour change, and Acceptance 3 is the M17 step A protocol that proved exactly this class before. Do not fold any other src/ change into the same commit.
  2. core.mc and the parts drifting apart. The design makes core.mc be the parts, and Acceptance 1 cmps the two spellings anyway. If the owner prefers to keep core.mc's flat list for a zero-golden-rewrite landing (D2's alternative), this risk becomes the milestone's main one and Acceptance 1 becomes mandatory rather than belt-and-braces.
  3. mc_main growing a flag that only an optional part understands. --dump-machine (M24), --sysroot-dir (core_build) and --config are the precedent. Rule to write down in cli.mc: a flag whose handler lives in a part is registered, not if-ed. --sysroot-dir moves into the sysroot subcommand's own parsing where it already is.
  4. user_init timing regressing. mc_main must keep user_init() after tok_init() and before the first token (src/main.mc:218), and the part *_init calls must stay in main(), before mc_main, because they only touch the backend/target/machine tables and must not tok_add. A part that ever needs tok_add is a user_init client like everyone else.
  5. A recreated compiler with no machine at all silently produces nothing until the first mach(...) call dereferences mach_tab == 0. mc_main should say no machine registered before gen_lower; three lines, in the same commit.
  6. type_disable read as "the type is gone". It removes a word from the surface; ld8 still yields TY_U8 internally. Every documentation sentence about it has to say so, or the next milestone will file a bug.
  7. Two more taught compilers to keep alive (check-parts's probes). They are leaves and they are the only things that exercise a partial core, which is the regression class M41 introduces.

Decisions (architect, 2026-09-04 -- every recommendation below is adopted) #

Adopted, one line each: D1 five coarse parts; D2 core.mc is the sum and the golden is rewritten once under M17 step A's protocol; D3 objmodel.mc split; D4 cli.mc/mc_main split; D5 the subcommand table; D6 no removal API for the registries; D7 type_disable and intrinsic_disable both in, documented as word removal; D8 no directive removal; D9 the uptr width mechanism lands here and M40 consumes it; D10 HEAP_SIZE stays; D11 the <...> form of [compiler].core; D12 underscore names. The architect's additions: (a) Acceptance 1 (cmp of the two spellings) is a required check, whichever way D2 goes; (b) mc_main says no machine registered before gen_lower (risk 5), in the same commit; (c) docs/guide/98-recreating-the-compiler.md is written for the developer of M39's original question and states, in one table, what each omitted part costs in bytes and in capability.

Implementation notes (mc-dev, 2026-09-04): where the landed milestone deviates #

Ten places where what landed differs from § Design, each with the reason. Everything else is the spec as written, including D1..D12 and the architect's three additions.

  1. backend_macho lives in src/core_writers.mc, not in src/macho.mc. § 1 puts it in the writer's file; it calls gen_lower/gen_encode_all, which are <mc/core_min>'s, so putting it there would make src/macho.mc unincludable on its own -- and src/m0.mc (the M6 driver that builds the object model by hand and writes it) includes exactly objmodel.mc + macho.mc. The part that REGISTERS a backend is the part that defines it.
  2. host_bundle_open lives in src/core_bundle.mc, not in src/bundle.mc. It calls host_include(), and tools/bundle.mc includes src/bundle.mc with no host layer at all.
  3. sysroots_init() is called from mc_build_init(), not from main() as § 2 sketches. A part owning its own initialisation is what makes main() a list of parts and nothing else.
  4. intrinsic_disable is tested at the head of res_call, not inside intrin_id. Two reasons: the diagnostic gets a POSITION (prog.mc:4: ld64: removed by this compiler instead of a bare mc: line), and intrinsic()'s own guard, if (intrin_id(name)) die2("cannot shadow a core intrinsic", name), keeps working -- a dying intrin_id would have refused a module that disables ld64 and then registers its own.
  5. subcommand, on_plan and the intrinsic_disable name table use fixed ceilings (16, 8,

    1. rather than M23's growable tables, by M23's own rule: none of the three scales with the program being compiled. Same reasoning as MAXMACHINES and MAXTARGETS.
  6. Four names had to move for the parts to be parts, and § 1's file list does not mention them: tm_cat and tm_num_str from src/toml.mc to src/arena.mc, MODE_755 from src/backend_exe.mc to src/arena.mc, and R_X86_PC32/R_X86_PLT32 from src/machine_x86_64.mc to src/objmodel.mc. The full assembly hides a cross-part dependency completely; scripts/check-parts.sh case 1b (below) is what found all four.
  7. glob_place's al = 16 for a global ARRAY is left alone. § 4a C4 names three roundings to 16 (the two zerofill lines, the local array, the frame) and this is a fourth, in the data section rather than the frame. It is wasteful on a two-byte word and harmless; changing it is M40's call, with a real target to measure against.
  8. The probe modules are files in lib/ and are NOT bundled. § 5's acceptance says "built inside check-parts.sh from the bundle, in check-standalone's style"; the entry files do take their core from the bundle, but the five lib/user_*.mc fixtures are reached by relative #include from build/parts/. Adding them to tools/bundle.list would move the blob -- and the five goldens -- for something no compiler includes.
  9. The goldens were rewritten once per commit, four times, not once for the milestone. Every commit that touches src/ regenerates src/bundle_data.mc, which is part of src/mc.mc, so build/mc2.o moves in each of them; make check runs check-bundle before bootstrap and a stale bundle is a hard failure, so the alternative was a red make check in three of the four commits. Risk 1's intent -- "do not fold any other src/ change into the same commit" -- is honoured: each rewrite follows an empty --dump-asm diff between mc1 and mc2 and a passing cmp build/mc2.o build/mc3.o, and scripts/check-inert.sh runs across each step.
  10. scripts/check-parts.sh proves one thing more than § Acceptance asks: case 1b, that <mc/core_min> plus EACH optional part compiles on its own. That is the property the five names of deviation 6 were violating while the whole assembly stayed green, and it is the regression class this milestone introduces.

Edit this page