Spec M45 -- i32, and a call returns what it declares
Owner's ruling (2026-09-04, translated from the Portuguese): "A clear sign that not having a 32-bit primitive is a problem that needs correcting (implement a 32-bit signed integer primitive)." Read as: mc gains a signed 32-bit integer, i32 -- registered by the core itself through the M24 mechanism, not added to the ladder (see the Amendment below, which overrides § 1, § 2, D1, D2 and D12 of this draft) -- and, because the defect that surfaced it is about RETURN narrowing, a call's result takes the callee's DECLARED type, extended from the 32/16/8-bit register view by that type's signedness, for i32 and for the existing u8/u16/u32.
The defect, as M42 exposed it (docs/specs/M42.md § Implementation notes 10, branch m42-elf-exe, now merged): C's open returns an int; AAPCS64 and the SysV x86-64 ABI leave bits 63..32 of the result register UNSPECIFIED for a 32-bit return; mc declares extern i64 open(...) (lib/sys.mc:16, src/arena.mc:5) and reads all 64 bits, so i64 fd = open(...); if (fd < 0) is ALLOWED to be wrong for a -1. On re-measurement (ubuntu:latest and alpine:3, both architectures) every libc happened to hand back a fully sign-extended 0xffffffffffffffff, so the hazard is latent today, not observed -- and that is precisely why it must be fixed by construction rather than by trusting the libc of the day. The same holds on both Windows ABIs (BOOL/DWORD are 32-bit). Every C function returning int/unsigned/short/char on every target is trusted as a 64-bit value today.
Sequencing: after M41 (main at 611671a). The glibc proof in Acceptance 1 needs a dynamically linked Linux executable; see D14 for the two ways to get one (M42's --exe, or the distro linker inside the container). Line references are to main at 611671a; branch files are named with their branch.
What already exists #
- The resolver already answers the declared type.
res_callsets a call'sres_typetofs_type(fs_at(fi))(src/gen_resolve.mc:469-474), i.e. theN_FUNC/N_EXTERN/N_PROTO's declared return type, andgen_exprwrites that type into the depth after the call (src/gen_walk.mc:779-787). TheTY_I64inparse_call(src/parse.mc:831) is the PARSER's node type, seen only by--dump-astand byfold_binary's inherited type; codegen never reads it. The gap is not in resolution: no machine narrows --a64_callends inmov rd, x0(src/machine_arm64.mc:435),x86_callinmov rd, rax(src/machine_x86_64.mc:503),rv_callinmv rd, a0(examples/kernel/machine_riscv64.mc:468), and the walker issues nothing afterMTASK_CALL(src/gen_walk.mc:771). MTASK_CAST(ty, d)is the slot that already extends. It is "narrow depthdto that width" (docs/reference/machine.md:130):and #0xff/and #0xffff/mov wd, wnon arm64 (gen_cast,src/machine_arm64.mc:192-196),movzx/mov r32,r32on x86-64 (x86_cast, :383-388),andi/shift pairs on riscv64 (rv_cast, :363-375),avr_fzon AVR (machine_avr.mc:382, branchm40-avr). Zero-extension from a width IS what au8/u16/u32return needs, and every taught machine already implements it.- The depth type is maintained around a call (
src/gen_walk.mc:369-410):walk_ret_type()is the call's own type whileMTASK_CALLruns, anddtype[d]is rewritten withres_type(n)after the dispatch.lib/machine_arm64_float.mcandlib/machine_x86_64_float.mcalready readwalk_ret_type()in theirMTASK_CALL(machine_x86_64_float.mc:573) to return a float inv0/xmm0. - Signedness is one test in two places.
bin_op(op, res_type(a) == TY_I64)(src/gen_walk.mc:566-577, :622) andconst_bin'stype != TY_I64/type == TY_I64(src/parse.mc:928, :944). Comparisons are always signed (docs/reference/language.md:73-75).fold_castmasks for the three unsigned widths (src/parse.mc:1011-1013). - The ladder and its rule.
TY_VOID..TY_UPTR= 0..6,TY_MAX7 (src/ast.mc:62-69);type_names[](:253),type_width(:289-296),type_kindanswersTK_INTfor every core id (:307-310). "A type id belowTY_MAXis a core type and behaves exactly as it always has; at or above it, every core decision is delegated" --fold_taught(src/parse.mc:966),res_lit_type(src/gen_resolve.mc:479-482),lib/machine_probe.mc:53. Registered ids are never written down as numbers:lib/float.mc:420-422,lib/f16.mc:163,lib/i128.mc:464,examples/avx/avx.mc:233all keep them in globals;examples/lang,examples/api/oop.mcandlib/user_syntax_demo.mcuse onlyTY_I64/TY_UPTR/TY_VOID. Nobody depends onty_f64 == 7(measured by grep). - A word can be appended to
tok_initwithout shifting anything.:and=>were added AFTER="so as not to renumber" (src/lex.mc:290-291, ids 299 and 300);tok_addhands out256 + ntokin insertion order (:207-223).K_U8..K_EXTERNare 256..269 and are whatword_add(src/hooks.mc:233-238), the#ruleguard (src/parse.mc:1379) andlib/user_tokadd.mcprotect. Nothing in the tree depends on 301 being free. - The parity gates and their escape.
check-lexcomparesmc0 --dump-tokensagainstbuild/lexdump(built bymc0fromsrc/lexdump.mc),check-astcomparesmc0 --dump-astagainstbuild/astdump,check-asmcomparesmc0 --dump-asmagainstmc1, all overtests/*.mc tests/lib/*.mc lib/*.mc src/*.mc;check-objcomparesmc0andmc1objects overtests/*.mc. A file the frozen seed cannot compile carries// seed-skip: REASONand is reported, not counted (scripts/check-lex.sh:31-38,check-ast.sh:31-38,check-asm.sh:22-28); two files use it today (lib/float_rt.mc:4,lib/sys_windows_host.mc:4). The bootstrap chain compilessrc/mc.mcWITH THE SEED (scripts/bootstrap.sh, stage 1), and the Linux/Windows chains compilesrc/mc_linux*.mc/src/mc_windows*.mcwith a PUBLISHED RELEASE as seed (docs/specs/M42.mdnote 11,tests/golden/README.md). Ten of the 32tests/*.mcinclude../lib/sys.mcor../lib/sys_svc.mcby relative path (measured), andsrc/arena.mc:5-8declaresopen/read/write/closefor the compiler itself. - Sources the seed never sees.
tests/mc/exists for exactly this (scripts/check-mc.sh:1-8);tests/mc/080-twelve-params.mcis the one casetest-linux.sh(:425-433) andtest-windows.sh(:340-344) run on every target, special-cased by name.examples/*are built by the taught compilermc1builds (scripts/check-inert.sh,taught). - Narrow-declared callees in the tree today: none in any
.mcfile.grep -rE 'extern (u8|u16|u32) |^(u8|u16|u32) [a-z_0-9]+\('oversrc lib tests examples sitefinds nothing. The only one anywhere is a heredoc inscripts/check-surface.sh:236-244(u8 low(i64 x) { return x; }, the M31widencase, whose expected answer 44 comes from theu8LOCAL's store today). Two places already work around the hazard by hand:examples/desktop/main.mc:262andlib/gtk.mc:111cast(u32)aroundg_application_runandgtk_check_button_get_active;lib/sys_windows.mc:76-120andlib/sys_windows_host.mc:211-244mask every kernel32BOOL/DWORDresult with& BOOL_MASK. decl_ret/decl_param_type(src/parse.mc:1899-1945, M31) read the samend_typethe resolver uses; nothing new is needed there.
Design #
1. The word: i32 is a core keyword appended at the end (no shift) #
tok_init gains one line after tok_add("=>", 2): tok_add("i32", 3) -- id K_I32 = 301 (src/lex.mc). K_U8..K_EXTERN stay 256..269; : and => stay 299/300. type_of_token gains else if (id == K_I32) t = TY_I32; (src/parse.mc:683-696), which is the one line M24 avoided for a REGISTERED type and is exactly right for a CORE one: the name is valid in all seven type positions (global, local, parameter, extern, cast, array element, p_type()) because they all end in that lookup. type_disable(TY_I32) works through the same function (M41), which is what the AVR compiler will call (§ 6).
Three guards learn the new id, +1 line each: word_add's "cannot redefine core keyword" (src/hooks.mc:235), the #rule dispatch-literal guard (src/parse.mc:1379) and the cannot redefine core keyword: if path type_new already takes through word_add. Written as id == K_I32 || (id >= K_U8 && id <= K_EXTERN), so nothing about the contiguous range moves.
What the frozen seed sees. stage0/lex.c lexes i32 as an identifier (T_IDENT, id 1); stage0/parse.c then fails at the first use with type expected (or type expected at top level). Consequences, stated exactly:
- A file in the seed set (
tests/*.mc tests/lib/*.mc lib/*.mc src/*.mc) that spellsi32breakscheck-lex(lexdump, built from the newsrc/lex.mc, answers 301 wheremc0answers 1),check-astandcheck-asm(the seed refuses, the new compiler accepts) and, if it is undertests/,check-obj. Such a file must carry// seed-skip: spells i32, which the frozen stage0 cannot lex-- the mechanismlib/float_rt.mc:4uses. This milestone adds no such file to the seed set: thei32tests live intests/mc/andtests/windows//tests/linux/, and the libraries that gaini32declarations are underexamples/(§ 5). src/*.mcand the libraries the bootstrap chain includes (lib/sys*.mc,lib/io.mc,lib/prelude.mc) cannot spelli32at all, on any host: the macOS chain's seed is the frozen C, and the Linux/Windows chains' seed is a published release that predates this milestone. They get the mechanism (TY_I32is a number,K_I32a token the running compiler creates) but not the word. § 5 says what they do instead. This constraint lifts only whenstage0/is re-seeded, which is an owner-level decision outside this spec (D1).mc1 --dump-tokensprints 301 fori32wheremc0prints 1. Not gated (check-lexcomparesmc0againstlexdump), and the same thing everytype_new/syntaxword already does throughtok_add.
The alternatives are argued in D1.
2. The type: TY_I32 = 7, TY_MAX = 8, signed, width 4 #
src/ast.mc: #define TY_I32 7, #define TY_MAX 8, type_names[] gains "i32", type_width gains if (t == TY_I32) return 4;, type_align and type_kind need nothing (a core type aligns to its width and answers TK_INT). One new predicate, the only place signedness is written down from now on:
i64 type_signed(i64 t) { return t == TY_I64 || t == TY_I32; }
bin_op(op, type_signed(res_type(nd_a(n)))) (src/gen_walk.mc:622) and const_bin's two tests (src/parse.mc:928, :944) read it. res_binary's rule is unchanged: a binary keeps its LEFT operand's type, so i32 / i64 is sdiv, u32 / i32 is udiv, and a comparison is i64.
Because 7 was the first registered id, every type_new id shifts by one (ty_f64 7 -> 8, and so on). Measured: no file writes a registered id as a number (§ What already exists); lib/machine_probe.mc:53 keys on TY_MAX symbolically; ty_disable_set's mask reaches 62. The two documents that print "(7)" -- docs/reference/language.md:87, docs/guide/96-a-new-primitive.md:9 -- say 8.
Semantics, in the model u32 already has (§ 2 of docs/reference/language.md), so that one sentence covers the four narrow types:
u8 u16 u32 (today, unchanged) | i32 (new) | |
|---|---|---|
| width / align | 1 / 2 / 4 | 4 / 4 |
a load into a depth (MTASK_LOCAL_LOAD, MTASK_GLOBAL_LOAD, a parameter's re-read) | zero-extends: ldrb/ldrh/ldr w; movzx/mov r32; lbu/lhu/lwu | sign-extends: ldrsw; movsxd; lw |
a store (MTASK_*_STORE, MTASK_PARAM) | truncates to the width | truncates: str w / mov [m], r32 / sw -- the same instruction as u32 |
| arithmetic | 64-bit on the extended value; wraps at the next store or cast | the same: 64-bit on the sign-extended value |
/ % >> | unsigned | signed (sdiv/idiv/div, asr) |
| comparisons | signed on the 64-bit value (always were) | signed on the 64-bit value -- correct, because the value is sign-extended |
(T) x cast (MTASK_CAST) | and #mask / mov wd, wn | sxtw xd, wn / movsxd rd, rd / addiw rd, rd, 0 |
fold_cast of a constant | masks | sign-extends from bit 31 (v & 0xffffffff, then - 0x100000000 if bit 31) |
| literal | TY_I64 (no i32 literal; i32 x = -1 stores 0xffffffff and loads -1; i32 x = 0xffffffff does the same) | |
ld32(p) / st32(p, v) | ld32 stays u32, zero-extending -- unchanged, inert | the signed read of memory is spelled (i32) ld32(p): one ldr w + one sxtw |
u32 <-> i32 | a cast between them is a no-op ON THE STORED BYTES and on bits 31..0 of the depth; bits 63..32 of the depth differ ((i32) 0xffffffffu is -1, (u32) of an i32 -1 is 4294967295) | |
#define | folds in 64 bits; #define M (i32) 0x80000000 is -2147483648 |
The one observable difference from C, stated once: INT_MIN / -1. With i32 a = -2147483648; i32 b = -1; i64 q = a / b; the division is sdiv x on sign-extended operands and q is 2147483648 on every machine -- no trap on x86-64 (a 32-bit idiv would #DE), and INT_MIN again only after a store to an i32. That is the price of "64-bit on the extended value", the model u32 has had since M0 (u32 a = 0xffffffff; i64 x = a + 1 is 4294967296), and it is what keeps fold() and the runtime in agreement (docs/reference/language.md:160-166). True 32-bit operations are D3's alternative.
Printing negatives: lib/io.mc's putnum stays non-negative and lib/io.mc is untouched (D11) -- any change to it moves every test object under check-inert in the mechanism commit. The i32 tests carry their own six-line puti(i64); if the owner wants putint in <sys>, it lands in the declarations commit (§ 7), never in the first.
3. Narrow returns: the walker extends through the existing MTASK_CAST #
No new slot. gen_call (src/gen_walk.mc:762-772) becomes:
callp(mach(MTASK_CALL), depth, i, sym_ref(usym(fs_name(fs_at(fi)))));
i64 rt = res_type(n);
if (walk_narrow(rt)) { // M45: what the callee declared
set_walk_depth_type(depth, rt); // depth d now holds the RESULT, not argument 0
callp(mach(MTASK_CAST), rt, depth);
}
with, beside walk_word():
// M45: a core type narrower than the word. Its value at a depth is DEFINED by
// extension from its width -- zero for u8/u16/u32, sign for i32 -- and the
// walker performs that extension wherever a foreign register hands it a bare
// 32-bit view: after a call, and before a return. A registered type is not
// narrow in this sense: what its value looks like at a depth is the module's
// (M24's rule), and <float>'s MTASK_CALL already returns f32 from s0 itself.
i64 walk_narrow(i64 t) { return t > TY_VOID && t < TY_MAX && type_width(t) < 8; }
The set_walk_depth_type line is load-bearing: after MTASK_CALL, dtype[d] still holds ARGUMENT 0's type (gen_value(a, depth + i) wrote it), and lib/machine_arm64_float.mc's fa_cast (:378-382) reads walk_depth_type(d) as the SOURCE type -- for extern i32 f(f64 x) it would otherwise see f64 and emit fcvtzs. With the line, fa_cast sees int -> int and delegates to the pristine a64_cast, which is sxtw.
The callee side, for symmetry (D5). N_RETURN (src/gen_walk.mc:904-908) issues MTASK_CAST(fnret, 0) before MTASK_RET(0) when the FUNCTION's declared type is narrow (walk_fn_ret, a global gen_func sets from nd_type(f); +4 lines). So an mc function declared u8 low(i64 x) { return x; } hands back 44 for 300 in a fully extended x0/rax/a0, which is what a C caller compiled by clang expects of a narrow result (its de-facto x86-64 convention is "extended to 32 bits by the callee") and is valid under every ABI. A function without an explicit return is untouched, so the #opcode syscall wrappers that rely on "the epilogue leaves x0 alone" (docs/reference/objects.md:260-265) still return the kernel's raw value.
Why arguments were never the problem, and why C-to-mc calls now are fixed too. mc -> C: every argument is a depth, and a depth is a 64-bit register holding a value already extended by the load or literal that produced it; a C int parameter reads bits 31..0 of it, which are right, and Apple's stricter rule (the caller extends narrow arguments to 32 bits) is satisfied for free. C -> mc (a callback): a C int argument arrives with bits 63..32 unspecified; an mc parameter declared i32 is stored at 4 bytes by MTASK_PARAM (str w, src/machine_arm64.mc:296-300) and re-read with ldrsw, so the garbage never reaches a depth. An i64 parameter fed a C int stays wrong -- declare it.
What does not change. callp keeps TY_I64 (src/gen_resolve.mc:442-448): a pointer call has no declaration, and D6 keeps it that way (a caller that knows writes (i32) callp(...)). Core intrinsics are not calls (gen_intrin, :660-667; ld8/16/32 are zero-extending by construction and their types unchanged). Taught intrinsics run the module's handler instead of the call sequence (lower_uintrin), and their result convention is the module's. A callee declared i64/u64/uptr/void or of a registered type issues no cast.
Inertness, exactly. The corpus has no narrow-declared callee and no narrow-declared mc function IN ITS SOURCE TEXT (§ What already exists), so the mechanism commit produces byte-identical objects for all 32 tests/*.mc, src/mc.mc and tests/float/* (registered returns are excluded by walk_narrow). The grep that established that is over source text, and a Tier 3 module can declare a narrow function without writing one: examples/api's class handler builds u8 todo_done(uptr self) out of AST nodes for the field bool done; (bool is type_alias("bool", TY_U8)), which no grep over examples/ can see. That example is therefore NOT inert, by design and harmlessly -- see § Implementation notes 3, which measures it. The bytes that WILL change, later and by design, are: scripts/check-surface.sh's decl_find-widen heredoc (u8 low: one and x9, x9, #0xff on each side; the answer stays 42 and the case asserts the exit code, not bytes), and every source whose declarations § 5 changes.
4. The machines: two bundled, two taught -- contract version 4, no slot added #
The ty a task receives may now be TY_I32, which every ty-carrying slot must honour (PARAM, CAST, LOAD, STORE, LOCAL_LOAD/STORE, GLOBAL_LOAD/STORE). That widens "what a machine outside src/ may rely on" without moving a signature, which is the same kind of bump as 2 -> 3 (docs/reference/machine.md:24-29); the page becomes contract version 4 and says: "a machine that does not implement TY_I32 must type_disable(TY_I32) from its user_init, or its MTASK_CAST is silently a no-op and its MTASK_LOCAL_LOAD silently reads eight bytes" -- which is precisely what rv_cast (:363-375) and rv_mem_op (:188-192) would do today.
| machine | load of an i32 slot | cast to i32 | lines |
|---|---|---|---|
src/machine_arm64.mc | I_LDRSW (0xB9800000, scale 4) appended to the mem_* tables; mem_op(TY_I32, 0) -> it, mem_op(TY_I32, 1) -> I_STRW; d_mem prints an x register for it | I_SXTW (0x93407C00 | rn<<5 | rd), one line each in gen_cast, dump_ins, encode | ~14 |
src/machine_x86_64.mc | X_LDS32: XF_LD, w=1, opc 0x63 (movsxd r64, [m]), a x86_desc row and a name; x86_mem_op(TY_I32, 0) | X_MOVSXD: XF_RD, w=1, opc 0x63; one line in x86_cast | ~10 |
lib/backend_arm64.mc (the surface encoder check-surface compares 32/32) | the same two rows in sur_mem_* | one line for I_SXTW | ~4 |
examples/kernel/machine_riscv64.mc | lw (opcode 0x03, funct3 010) in rv_mem_op | addiw rd, rd, 0 (sext.w) in rv_cast | ~8 |
examples/avr/machine_avr.mc (branch m40-avr) | -- its invariant "every core type but i64 is unsigned, so extend always means zero" (:20-27) is false for i32 | type_disable(TY_I32) in mc-avr.mc's user_init, plus a README sentence; sign-fill is a later ask | ~3 |
lib/machine_arm64_float.mc / lib/machine_x86_64_float.mc need nothing: fa_cast/fx_cast delegate an int-to-int cast to the pristine table (:378, :438), their MTASK_CALL reads walk_ret_type() only to pick the float file, and fa_is_float keys on type_kind (:180) -- unaffected, confirmed. lib/machine_probe.mc asserts t < type_count() and t < TY_MAX for an untaught program; both hold.
Every new instruction goes through the llvm-mc sweep that is mandatory for a machine in lib/ (CLAUDE.md § M24 step 1): ldrsw/sxtw (mach-o and elf aarch64), movsxd register and memory forms (elf and coff x86-64), lw/addiw (riscv64).
5. The host layers and the libraries: truthful declarations, one seed-safe helper #
Per function, what C returns and what the declaration becomes. Two rules decide the spelling: a source the seed compiles (src/*.mc, lib/sys.mc, lib/sys_windows.mc, lib/io.mc) may say u32 -- the truthful WIDTH the seed knows -- and reads the sign through a helper; a source only mc1 or a taught compiler compiles says i32.
src/arena.mc gains the helper, pure arithmetic, correct under every seed and every machine:
// M45: the value of a C `int` result. bits 63..32 of a narrow return are
// unspecified on every ABI this compiler targets; this file is compiled by
// the frozen seed, which cannot spell `i32`, so the declarations below say
// `u32` (the width) and the sign comes from here.
i64 c_int(i64 v) { v = v & 0xffffffff; if (v & 0x80000000) return v - 0x100000000; return v; }
| file | declarations that change | reads that change |
|---|---|---|
src/arena.mc:5-8, :17 | open, creat, close -> u32 (C int); read/write stay i64 (ssize_t); mmap stays uptr; _exit void | i64 fd = c_int(open(...)) (:539), c_int(creat(...)) (:557) |
src/sysroot.mc:58 | -- | c_int(open(...)) |
src/host_macos.mc:35-41, src/host_linux.mc:17-23, src/host_windows.mc:27-33 | posix_spawnp, posix_spawn_file_actions_init/addopen/destroy, waitpid (pid_t), mkdir, unlink -> u32; _NSGetEnviron stays uptr | src/driver.mc:229, :242: if (c_int(waitpid(...)) < 0) -- the same latent defect as open, today undetectable on glibc; posix_spawnp's e == ENOENT / != 0 tests are already right on a zero-extended value |
src/backend_exe.mc:34 | chmod -> u32 (result unused) | -- |
src/host_windows.mc:65 | GetEnvironmentVariableA -> u32 (DWORD) | n <= 0 becomes n == 0 |
lib/sys_windows.mc:44-51, lib/sys_windows_host.mc:39-47 | WriteFile, ReadFile, CloseHandle, CreateProcessA, WaitForSingleObject, GetExitCodeProcess, CreateDirectoryA, DeleteFileA, GetLastError -> u32 (BOOL/DWORD); handles/pointers stay uptr | the & BOOL_MASK masks stay (D9): the Windows chain's seed is a published release that does not narrow, and the masks are what keeps mc1w right under it |
lib/sys.mc:16-21, :46-51 (macOS programs; ten seed tests include it) | decided by measurement (Acceptance 1): if libSystem's cerror writes x0 as a 64-bit -1, the declarations stay i64 with the measurement recorded in the header and a sentence in docs/reference/language.md § extern that a program wanting the truthful declaration writes its own extern i32 open; if it does not, they become u32 and c_int moves to lib/io.mc in the declarations commit | |
lib/sys_linux.mc | nothing: raw svc wrappers, the kernel returns 64-bit -errno | |
examples/api/lib/sqlite.mc:26-38 | sqlite3_open/close/exec/prepare_v2/step/finalize/bind_int/bind_text/column_int/changes -> i32; sqlite3_last_insert_rowid stays i64 (sqlite3_int64); errmsg/column_text stay uptr; sqlite3_libversion_number (test_sqlite_lib.mc) -> i32 | rc != SQLITE_OK-style tests were right only because compilers write w0; sqlite3_column_int of a NEGATIVE column was wrong |
examples/api/lib/http.mc:25-29 | socket, setsockopt, bind, listen, accept -> i32 | http_listen's three < 0 tests (:55-66) and main.mc:336's cfd < 0 -- the same defect class as open |
examples/api/tests/lib_test.mc:17 | unlink -> i32 | |
examples/conc/lib/{macos,linux}/thread.mc | pthread_create/join/detach/mutex_*/cond_*, sem_*, sysctlbyname -> i32; dispatch_semaphore_wait/signal (intptr_t) and getauxval (unsigned long) stay i64 | |
examples/desktop/lib/gtk.mc:52, :92 | g_application_run, gtk_check_button_get_active -> i32; the two (u32) casts (main.mc:262, gtk.mc:111) go |
Bootstrap consequences. Both commits touch src/, so the five goldens (tests/golden/mc2*.sha256) are rewritten once per commit, after an empty mc1/mc2 --dump-asm diff and cmp mc2.o mc3.o (M41's rule, Implementation note 9). The Linux and Windows chains from a PUBLISHED seed keep working: the seed compiles extern u32 open (a word it knows), does not narrow, and c_int makes mc1l/mc1w right anyway; mc1l then narrows when it builds mc2l, and mc2l == mc3l holds because both are built by narrowing compilers. Windows: kernel32 results are BOOL/DWORD, 32-bit; u32 is exactly their width and the masks keep the released seed honest.
6. Tests and gates #
tests/mc/090-i32-basic.mc(negative local/global/parameter round trip,i32 x = -1printing-1, ani32[4]global read back through(i32) ld32),091-i32-cast.mc((i32)fromu32/i64with bit 31 set,(u32)of ani32, theu32<->i32byte no-op through memory,#definefolding),092-i32-div.mc(signed/ % >>at 32,INT_MIN / -1=2147483648-- the documented answer, on every machine, no trap),093-i32-return.mc(i32 neg()returning -1 andu8 low(i64)returning 44 for 300 -- the caller and callee extension formccallees, plusextern i32 close(i64)on -1 -> -1). All carry// expect-exit/// expect-stdout, their ownputi, and run on macOS throughcheck-mc(object +--exe), on linux/aarch64 and linux/x86_64 throughtest-linux.sh, and on windows/arm64 and windows/x86_64 throughtest-windows.sh: the two scripts' by-name special case for080becomes a loop overtests/mc/0[89]*.mcunder the existingskip-*headers (+~8 lines each).tests/linux/072-int-return.mc:extern i32 open(uptr, i64, i64)on/nonexistent-m45, expectsopen=-1and exit 0; declares its own externs (it cannot includelib/sys.mc, whoseextern i64 openwould bedeclaration does not match prototype). Run against glibc inubuntu:lateston both architectures (D14 says how it is linked) and against musl inalpine:3. The same source withextern i64 openis the measurement program of Acceptance 1, not a test.tests/windows/073-int-return.mc:extern i32 GetFileAttributesA(uptr)on a missing path returnsINVALID_FILE_ATTRIBUTES= -1 asi32; expects exit 0.GetFileAttributesAjoinsscripts/sysroot-windows.sh'skernel32.def(+1).- macOS:
093above coversclose(-1)through libSystem; Acceptance 1 records whatopenreturns raw. scripts/check-surface.sh: thedecl_find-widencase still answers 42; a newabi_caseassertssxtw x9, x9afterblfor anextern i32callee andand x9, x9, #0xffforu8, and no cast fori64.scripts/check-inert.shbetween the pre-milestonebuild/mc1and the mechanism commit: identical everywhere (33 objects and the five taught examples,examples/kernelincluded -- its machine gains two arms and its image must not move a byte, since the kernel declares nothing narrow).
Out of scope #
i8andi16. The mechanism (type_signed,walk_narrow, a signed load and a signed cast per machine) is written so that each is ~12 lines when a caller appears; no caller today.- A signed 32-bit memory intrinsic (
lds32).(i32) ld32(p)is the spelling; one instruction more (D-list, recommended no). - True 32-bit arithmetic for
i32(D3's alternative). callpreturning anything buti64(D6).- Spelling
i32insrc/*.mcor in the libraries the bootstrap includes. Waits for a re-seededstage0/, which this spec does not propose. - Any change to
stage0/. - Dropping the
& BOOL_MASKmasks inlib/sys_windows*.mc(D9): after the first release that carries M45 becomes the Windows seed. - Sign-fill on the AVR machine (
m40-avr):type_disable(TY_I32)instead. - A
putintinlib/io.mcin the mechanism commit (D11).
Files and estimated deltas #
Commit 1 -- the mechanism (inert):
| file | lines | what |
|---|---|---|
src/lex.mc | +2 | K_I32 301; tok_add("i32", 3) after => |
src/ast.mc | +8/-1 | TY_I32 7, TY_MAX 8, "i32" in type_names, type_width, type_signed |
src/parse.mc | +6 | type_of_token; const_bin via type_signed (2 edits); fold_cast sign-extension; the #rule guard |
src/hooks.mc | +1 | word_add refuses K_I32 |
src/gen_walk.mc | +30 (12 code) | walk_narrow, walk_fn_ret, the cast after MTASK_CALL, the cast before MTASK_RET, bin_op via type_signed |
src/gen_resolve.mc | +3 (comment) | res_call already answers the declared type; say so |
src/machine_arm64.mc | +14 | I_LDRSW, I_SXTW: tables, mem_op, gen_cast, dump, encode |
src/machine_x86_64.mc | +10 | X_LDS32, X_MOVSXD: two x86_desc rows, names, x86_mem_op, x86_cast |
lib/backend_arm64.mc | +4 | the two encodings |
examples/kernel/machine_riscv64.mc | +8 | lw in rv_mem_op, addiw in rv_cast |
examples/avr/mc-avr.mc, README.md (branch m40-avr) | +4 | type_disable(TY_I32) and why |
tests/mc/090..093-i32-*.mc | ~160 | new |
tests/linux/072-int-return.mc, tests/windows/073-int-return.mc | ~50 | new |
scripts/test-linux.sh, scripts/test-windows.sh | +12/-14 each | the tests/mc/0[89]*.mc loop; a glibc image leg for tests/linux/072 (D14) |
scripts/check-surface.sh | +20 | the sxtw/and/no-cast abi_case |
scripts/sysroot-windows.sh | +1 | GetFileAttributesA |
src/bundle_data.mc, five goldens | regenerated | |
docs/reference/language.md | +45 | § 2 an eighth word and the extension table; § 3 the INT_MIN / -1 sentence; § 6 "a call returns what it declares", callp excepted |
docs/reference/machine.md | +35 | version 4 header; MTASK_CAST "extend by the type's signedness"; the walker's cast after CALL and before RET; the i32 row per machine; the type_disable obligation |
docs/reference/objects.md | +20 | § 4/4b/4c: what a narrow result guarantees on each side |
docs/core-language.md:26, docs/plan.md:79, docs/reference/hooks.md, docs/guide/96, docs/reference/diagnostics.md | +12 | "no i8/i16", TY_MAX 8, K_I32 refused by word_add/#rule |
CLAUDE.md, docs/plan.md | +30 | the M45 entry and row |
Commit 2 -- the declarations (bytes move by design):
| file | lines | what |
|---|---|---|
src/arena.mc | +6 | c_int; open/creat/close as u32; two reads |
src/host_macos.mc, src/host_linux.mc, src/host_windows.mc | ~7 each | u32 declarations |
src/driver.mc, src/sysroot.mc, src/backend_exe.mc | +4 | c_int at the two waitpid reads and the open |
lib/sys_windows.mc, lib/sys_windows_host.mc | ~9 each | u32 declarations; masks kept |
lib/sys.mc | 0 or ~8 | per Acceptance 1's macOS measurement |
examples/api/lib/{sqlite,http}.mc, tests/lib_test.mc, test_sqlite_lib.mc | ~18 | i32 declarations |
examples/conc/lib/{macos,linux}/thread.mc | ~30 | i32 declarations |
examples/desktop/lib/gtk.mc, main.mc | +2/-2 | i32 declarations, casts dropped |
| five goldens | regenerated again |
Net new src/ lines in commit 1: ~75, of which ~40 are code. stage0/: 0.
Acceptance #
- The defect is measured before anything changes, with the pre-milestone
build/mc1, and the numbers go into this spec's Implementation notes: theextern i64 openprogram printing the raw result in hex on (a) glibc aarch64 and x86_64 inubuntu:latest, expectedffffffffand thefd < 0branch NOT taken (the failing case); (b) musl inalpine:3, both architectures (whatever it prints -- the claim "musl zero-extends by accident" is UNVERIFIED and is replaced by the measurement); (c) macOS libSystem (open,close(-1),waitpid(-1)) -- unverified today, and it decideslib/sys.mc's row in § 5; (d) Windows:GetFileAttributesAof a missing path through the pre-milestone compiler, on thewindows-2025legs. - Commit 1 is inert.
scripts/check-inert.shbetween the pre-milestonebuild/mc1and the mechanism commit: identical for all 32tests/*.mc,src/mc.mc, and the five taught examples (kernelincluded, with its updated machine).check-obj32/32 against the frozen seed;check-lex/check-ast/check-asmat their current counts with no new skip;check-float12/12 on all five legs andcheck-wideunchanged (registered returns excluded);check-surface32/32 objects identical plus the newabi_case;bootstrapat a fixed point; the five goldens rewritten once. i32round-trips on all five targets.tests/mc/090..093green throughcheck-mc(macOS, object and--exe),test-linux(aarch64),test-linux-x86_64,test-windows(arm64) andtest-windows-x86_64.092prints2147483648forINT_MIN / -1on every one of them, with noSIGFPEon x86-64.- The narrow-return proof, on every host with a libc.
tests/linux/072-int-return.mcfails through the pre-milestone compiler under glibc (open=4294967295, exit 1) and passes through the new one (open=-1, exit 0) on both architectures; passes under musl on both;tests/mc/093passes on macOS (close(-1)is -1,neg()is -1,low(300)is 44);tests/windows/073passes on both Windows legs. - The sweep. Every distinct instruction the two bundled machines emit over
tests/mc/09*andsrc/mc.mcre-assembles underllvm-mcbyte for byte:ldrsw/sxtwon mach-o and elf aarch64, bothmovsxdforms on elf and coff x86-64;lw/addiwon riscv64 throughexamples/kernel/test.sh's sweep. - The taught machines' gates stay green:
check-kernel(image bytes unchanged from Acceptance 2), andcheck-avronm40-avrafter the merge, where a source spellingi32is refused withi32: removed by this compiler. - Commit 2 fixes the compiler itself.
mc-linux-aarch64built for glibc (D14) exits 1 withcannot openon a missing input instead of reading a bogus descriptor;check-objstill 32/32 (both compilers see the same changed sources);bootstrapat a fixed point;make check-linux-hostand the Windows CI chain reach their fixed points from the CURRENT published seed;examples/api,conc,desktopandcheck-examples/check-conc/check-desktopgreen with the truthful declarations; goldens rewritten once more. - Docs.
check-docsgreen:type_signed,walk_narrow,c_intdocumented; everymcsample indocs/reference/language.md§ 2's new table compiles and runs;site/check-sitegreen. git diff --stat stage0/is empty, and no file in the seed set gained aseed-skipheader.
Risks #
- The parity gates, if
i32leaks into the seed set. Onei32inlib/sys.mcbreakscheck-lex/check-ast/check-asmand, through the ten tests that include it,check-obj. Acceptance 9 is the guard; § 5's spelling rule is the discipline. - Registered ids shift by one. Measured: no numeric dependence anywhere;
type_count(),TY_MAXand module globals carry every id. The two "(7)" sentences in the docs are the whole cost. A module outside this tree that wrote8for its second type would break, anddocs/reference/hooks.mdshould say the number is not surface. - The three taught machines' silent no-op. A machine that predates
TY_I32does nothing onMTASK_CAST(TY_I32)and reads eight bytes onMTASK_LOCAL_LOAD(TY_I32). The riscv64 machine gets its arms here; AVR disables the word; the contract page makes the obligation explicit.lib/machine_probe.mccould additionally refuseTY_I32on a machine derived from a pristine table it did not extend -- optional. walk_depth_typeafter a call. Without theset_walk_depth_type(depth, rt)line, the float-derived machines would treat ani32result as argument 0's type. It is one line and theextern i32 f(f64)shape belongs intests/float/as a regression (022-int-return.mc, +25 lines, on the five float legs).- The
u32-declared functions whose bytes change. None in the corpus today; after commit 2, every host layer andexamples/api/conc/desktop. Those changes are the FIX, verified by Acceptance 4 and 7 -- but they also mean commit 2 cannot be proved inert and must not be folded into commit 1. examples/api's sqlite wrappers returni32values intoi64locals everywhere; nothing breaks, butsqlite3_column_intof a negative value silently changes from a large positive to the negative it always was in C. Say so inexamples/api/README.md.- The seed-skip headers: the milestone adds none; the two existing ones stay. If the owner later re-seeds
stage0/, everyu32+c_intin § 5 becomes a mechanicali32, and this spec's § 5 table is the list. MAXDEPTH, spill, frame: unchanged. Ani32depth is an 8-byte slot holding the sign-extended value; ani32local is a 4-byte store in an 8-byte-granule slot (slot_newrounds), byte-identical in layout to au32local.- Interaction with
<float>:fa_is_floatkeys ontype_kindandTY_I32answersTK_INT;fa_cast/fx_castdelegate int-to-int;MTASK_CALLin both float machines readswalk_ret_typeonly for the float file. Confirmed by reading; Acceptance 2'scheck-floaton five legs is the proof. - The glibc gate's dependency (D14): if the proof is linked with M42's
--exe, M45 sequences afterm42-elf-exe; if with the distro linker inside the container, it is independent but slower (apt-getin the run, or a prepared image).
Decisions (architect) -- to ratify with the owner #
- D1 -- how the word enters. Recommend a core keyword appended at the end of
tok_init(K_I32 = 301), the:/=>precedent. Not (a) a core-registered M24 type (type_new("i32", 4, 4, TK_INT)at init): its id would be at or aboveTY_MAX, so every core decision -- the signed divide, the sign-extending load, the fold -- would have to be re-taken for "a registered type that is secretly core",TK_INTwould need a signedness column,lib/machine_probe.mcwould flag everyi32program as taught, and--dump-tokenswould differ anyway. Not (b) an alias-table registration of the word onto a coreTY_I32: same parity profile as the keyword, four more lines, an init-order question forastdump, and "name reserved by a type_alias registration" as the error for a program that names a variablei32. Not (c) addingi32tostage0/: it is not a bug fix underdocs/plan.md:349, the Linux/Windows chains bootstrap from a published release that would not know it, and the task fixesstage0/as frozen. - D2 --
TY_I32 = 7,TY_MAX = 8. Recommend yes; the registered ids shift by one and nothing depends on the number (risk 2). - D3 -- 64-bit on the extended value, or true 32-bit operations? Recommend 64-bit,
u32's model: no newMOP_*,fold()and the runtime agree, no x86-64 trap onINT_MIN / -1, and the one observable difference is written down. - D4 -- where the extension after a call lives. Recommend the walker, through the existing
MTASK_CAST: no slot (the contract stays at 31), the taught riscv64 machine gets zero-extension for free, and one place ingen_walk.mcis auditable. Not inside each machine'sMTASK_CALL(four copies, and a taught machine that forgets is silently wrong); not a newMTASK_NARROW(a v4 slot every taught machine must fill for somethingCASTalready does). - D5 -- extend on the callee side too (
return). Recommend yes: symmetric, inert on the corpus, and what a C caller of anmcu8/i32function is entitled to. - D6 --
callpstaysi64. Recommend yes; a pointer call has no declaration and(i32) callp(...)is the spelling. - D7 -- registered types are excluded from the walker's cast. Recommend yes, M24's rule;
<float>already returnsf32itself. - D8 -- the compiler's own sources spell
u32+c_int, noti32. Recommend yes (§ 1's seed constraint); the alternative of keepingi64and addingc_intalone is fewer edits but leaves the compiler as the one place whose declarations lie. - D9 -- Windows:
u32declarations, masks kept. Recommend yes until a release carrying M45 is the Windows seed. - D10 -- AVR:
type_disable(TY_I32). Recommend yes onm40-avr; sign-fill is a separate ask. - D11 --
lib/io.mcuntouched; tests carryputi. Recommend yes for commit 1 (inertness);putintin commit 2 only if the owner wants it in<sys>. - D12 -- extend the
word_addand#ruleguards toK_I32. Recommend yes, three lines. - D13 -- two commits, goldens rewritten twice. Recommend yes: commit 1 is proved by
check-inert, commit 2 by the executable proofs; folding them would lose the first proof. - D14 -- how the glibc proof is linked. Recommend M42's
--exefor Linux (a dynamic executable withlibc.so.6asDT_NEEDED,docs/specs/M42.md), which makes M45 land afterm42-elf-exe; the alternative, independent of M42, is--backend=elf-obj-<arch>plusgcc/ldinsideubuntu:latest(an image withbinutilsprepared once), which is what the pre-milestone MEASUREMENT of Acceptance 1 should use in any case, since the compiler under measurement predates M42 too. - D15 -- a signed memory intrinsic
lds32. Recommend no:(i32) ld32(p)is one instruction more and one name fewer.
Amendment (owner, 2026-09-04): i32 through the M24 mechanism, signedness by kind #
Owner's ruling (translated from the Portuguese): "Touching stage0 is out of the question. Since we have u32, I do not believe applying an i32 is that hard; we have already proved that new primitives can come from the developer, so just do the same from src/."
This overrides D1 (a core keyword K_I32 = 301 appended to tok_init) and D2 (TY_I32 = 7, TY_MAX = 8) in favour of the alternative D1 rejected as (a): the core registers i32 at init through type_new, before user_init(), with an id at or above TY_MAX, in the same alias table type_new already writes. Sections 1, 2 and 4 of the draft are replaced by A1-A3 below; A4 says what survives and how it is renumbered; A5 restates the decisions to ratify. The draft's own objection to (a) -- "every core decision would have to be re-taken for a registered type that is secretly core" -- is answered in A2, one site at a time. Nothing in stage0/ moves under either design; the ruling picks the mechanism the tree already proved over a second way of introducing a word.
A1. The word and the id: one type_new call in src/, and a new kind (replaces § 1 and § 2) #
Registration. src/hooks.mc gains, beside type_new (src/hooks.mc:490-497):
// M45: the core's own registered primitives. The same call a module makes
// (lib/float.mc:420), made from src/ before user_init(), so the word enters
// the alias table type_of_token already consults and the id is at or above
// TY_MAX. A recreated compiler that cannot honour it says type_disable(ty_i32).
i64 ty_i32 = 0;
void core_types_init() { ty_i32 = type_new("i32", 4, 4, TK_SINT); }
called from src/cli.mc immediately before user_init() (src/cli.mc:230): after tok_init() (:215), so K_U8..K_EXTERN are fixed at 256..269 -- the comment at :226-229 is exactly the constraint -- and before the first token is read. ty_i32 is a global the core exports, the lib/float.mc:30-32 convention; it is never written as a number.
What that one call does, by the code that already exists: ty_reg_add (src/ast.mc:101-115) appends the registry row and returns TY_MAX + 0 = 7; type_name(7) is "i32" (:255-257, so --dump-ast and i32: removed by this compiler both print it); type_width 4, type_align 4 (:289-303); type_kind TK_SINT (:307-310); alias_add (src/hooks.mc:456-463) reserves the word through word_add (:237-242) -> tok_add (src/lex.mc:207-223), and type_of_token's last arm, alias_find(id) (src/parse.mc:713), answers it in all seven type positions. Zero lines in tok_init, type_of_token, word_add's guard, the #rule guard (src/parse.mc:1411) or type_names[]. D12 (three guard lines for K_I32) is withdrawn: the word is protected the way every registered word is.
Who does not register it. src/lexdump.mc includes only arena.mc and lex.mc (:4-5) and calls only tok_init() (:13): it lexes i32 as an identifier, id 1, exactly as stage0/lex.c does, so check-lex parity over the seed set is structural under this design where the keyword design had lexdump answering 301 against mc0's 1. src/astdump.mc states its purpose at :13 ("this driver does not call user_init, so parsing is the same as mc0's") and keeps it: it does not call core_types_init either, so its answer to a file spelling i32 is the seed's (type expected). One call site, src/cli.mc.
What the frozen seed sees: unchanged from § 1. stage0/parse.c fails at the first use with type expected; a seed-set file that spells i32 breaks check-ast/check-asm/check-obj and must carry // seed-skip: spells i32, which the frozen stage0 cannot parse; this milestone adds none (Acceptance 9 stands); src/*.mc and the seed-compiled libraries cannot spell the word on any host (D8 stands, see A4). mc1 --dump-tokens prints the registered id for i32 where mc0 prints 1 -- what every type_new word already does, ungated.
Three consequences that are new and measured.
i32is reserved program-wide, asf32is when<float>is loaded (src/hooks.mc:487-489): a program that names a variable or functioni32is refused withname reserved by a syntax/type_alias registration: i32(docs/reference/hooks.md:251). Measured at6fab014:grep -rlw i32 --include='*.mc' .finds no file; the cost the draft charged to alternative (b) is zero on this tree.- A module may register
i32again:tok_addreturns the existing id for a known lexeme (src/lex.mc:208-213),alias_addappends a second row, andalias_findwalks from the end (src/hooks.mc:572, "the last registration wins"). That is M24's rule for any word and stays: a recreated compiler that wants a differenti32gets it by registering one after the core did. - The registered ids shift by one, as under D2 but for the opposite reason:
TY_MAXstays 7, the core takes 7, and a module's first id is nowTY_MAX + 1= 8 (ty_f647 -> 8,ty_f328 -> 9, and so on). Measured the same way as the draft's § What already exists: no file writes a registered id as a number (lib/float.mc:420-422,lib/f16.mc:163,lib/i128.mc:464,examples/avx/avx.mc:233,lib/user_syntax_demo.mc:899-900all keep globals);lib/machine_probe.mc:53keys onTY_MAXsymbolically;ty_disable_set's mask reaches 62 (src/ast.mc:278). The two doc sentences that say "TY_MAX(7)" (docs/reference/language.md:87,docs/guide/96-a-new-primitive.md:9) stay true; the one that saystype_new"appends an eighth id" (language.md:86) becomes "the core appends the eighth,i32; a module's first is the ninth".
The kind: TK_SINT. src/ast.mc:83-86 gains a fifth value, appended so the four existing numbers do not move:
#define TK_SINT 4 // M45: an integer the core's operators fit, SIGNED
type_new's range check (src/hooks.mc:493) becomes kind > TK_SINT. One predicate, the only place signedness is written down from now on, in src/ast.mc beside type_kind:
// M45: the core decides "signed?" by KIND, not by id. TY_I64 keeps answering
// TK_INT from type_kind (every core type does, and modules key on that), so it
// is named here once.
i64 type_signed(i64 t) { return t == TY_I64 || type_kind(t) == TK_SINT; }
bin_op(op, type_signed(res_type(nd_a(n)))) (src/gen_walk.mc:622, replacing == TY_I64) and const_bin's two tests (src/parse.mc:960, :976, replacing type != TY_I64 / type == TY_I64) read it. res_binary is unchanged: a binary keeps its LEFT operand's type, so i32 / i64 is sdiv, u32 / i32 is udiv, a comparison is i64 and always signed on the 64-bit extended value.
What the kind buys. Every core decision that the draft's type_signed keyed on TY_I32 now keys on TK_SINT plus width: the signed / % >> (bin_op, const_bin), the sign-extending fold of a cast (A2), the sign-extending load, store-at-width and sign-extending cast in the bundled machines (A3), and the walker's narrowing after a call and before a return (A3). So a module -- or the core, later -- registers i16 or i8 with one line, type_new("i16", 2, 2, TK_SINT), and gets ldrsh/sxth, movsx, lh, signed division and comparison, and a call result extended from bit 15, with no line in src/ and no line in a machine that already honours the kind. A signed 24-bit type is type_new("i24", 3, 4, TK_SINT) and gets the same from a machine whose mem_op handles width 3 (neither bundled machine does; that is the machine's answer, per A3's obligation). The draft's Out-of-scope item "i8 and i16 ... ~12 lines each when a caller appears" becomes "one type_new line, from wherever the caller is".
The alternative, named and rejected: a signedness column on type_new. type_new(name, width, align, kind, signed) would change the signature every caller in the tree uses (lib/float.mc:420-422, lib/f16.mc:163, lib/i128.mc:464, examples/avx/avx.mc:233, lib/user_syntax_demo.mc:899-900, lib/user_dupty.mc:7) and every doc line that spells it, add a fifth parallel array to src/ast.mc:88-91 and a fifth grow_to, and give a TK_FLOAT or TK_OPAQUE a "signed" bit that means nothing to the machine. kind is already defined as "what a machine dispatches on when it does not know the exact id" (src/ast.mc:76-77, docs/reference/hooks.md:406), and signedness is exactly a dispatch property of an integer. A new TK_* value is additive: no signature moves, TK_INT..TK_OPAQUE keep 0..3, and a machine that never sees a TK_SINT id emits byte for byte what it emits today. Adopt TK_SINT.
Semantics (the draft's § 2 table stands with TY_I32 read as ty_i32, generalised to any TK_SINT of width w): a load into a depth sign-extends from bit 8w-1; a store truncates to w -- the same instruction as the unsigned type of that width; arithmetic is 64-bit on the extended value and wraps at the next store or cast (D3); / % >> are signed; comparisons are signed on the 64-bit value; (T) x sign-extends from bit 8w-1; fold_cast of a constant does the same at compile time (A2); there is no i32 literal (i32 x = -1 stores 0xffffffff and loads -1); (i32) ld32(p) is the signed read of memory (D15); u32 <-> i32 is a no-op on the stored bytes and on bits 31..0 of a depth. The INT_MIN / -1 paragraph stands verbatim: sdiv on sign-extended operands, 2147483648 on every machine, no trap, INT_MIN again only after a store.
A2. Reconciling with M24's rule, one site at a time #
The rule (src/ast.mc:72-78, CLAUDE.md:1544-1546): "a type id below TY_MAX is a core type and behaves exactly as it always has, byte for byte; an id at or above it was registered, and every core decision about it is delegated." i32 is at or above TY_MAX and every core decision about it is delegated -- to the registry's four columns: width, align and name to the three the core already reads, and signedness to kind, the column the core wrote and, until now, never read (docs/reference/hooks.md:406, "never read by the core"). After M45 the core reads kind in exactly three places -- type_signed, fold_taught and walk_narrow -- and the docs say so. That is the honest restatement; the draft's phrase "a registered type that is secretly core" does not apply, because nothing tests the id.
The four sites the draft named as treating a registered id as taught:
(i) fold_unary/fold_binary/fold_cast and fold_taught (src/parse.mc:998-1049). Today fold_taught(n) is nd_type(n) >= TY_MAX. Left alone, #define M (i32) 0x80000000 is not folded: the node reaches the walker as MTASK_CONST + MTASK_CAST(ty_i32), and the machine's sxtw computes -2147483648 at runtime -- the same value, one instruction, and M / x is still sdiv because res_type of the cast node is ty_i32 and type_signed says so. That is acceptable as a fallback but it is not the recommendation, because fold() and the runtime would then agree by accident rather than by rule, and because a #define of an i32 constant would be the only #define in the language that leaves an instruction behind.
Recommend the kind-based guard. fold_taught becomes:
// M45: the core folds what its own operators compute -- TK_INT and TK_SINT,
// core or registered -- and delegates FLOAT, WIDE and OPAQUE, whose arithmetic
// is the module's. This is the definition of TK_INT in hooks.md ("an integer
// the core's own operators fit") applied to the folder.
i64 fold_taught(i64 n) {
i64 k = type_kind(nd_type(n));
return k != TK_INT && k != TK_SINT;
}
with fold_cast's three mask lines (:1043-1045) replaced by a width-and-kind rule that is byte-identical for u8/u16/u32 (same masks) and sign-extends for a TK_SINT:
i64 w = type_width(t);
if (w < 8) {
u64 m = (1 << (8 * w)) - 1;
v = v & m;
if (type_kind(t) == TK_SINT && (v >> (8 * w - 1)) & 1) v = v - (m + 1);
}
It keeps fold() and the runtime in agreement by construction. For a TK_INT or TK_SINT id the runtime IS the core's integer operators: bin_op picks signed or unsigned by type_signed, const_bin picks by the same predicate, and MTASK_CAST extends by the same width and kind the fold masks by (A3). For f64raw (lib/float.mc:422, TK_INT, width 8) the runtime cast is fa_cast -> "int to int, delegate" (lib/machine_arm64_float.mc:378) -> gen_cast, which has no arm for width 8 and emits nothing; the fold masks nothing. For fix (lib/user_syntax_demo.mc:899, TK_INT, 16.16 in eight bytes) 1.5 + 2.5 at runtime is the machine's integer add of the two representations, which is what the fold now computes.
Priced. (a) src/parse.mc: ~10 lines. (b) scripts/check-surface.sh:804-835, the M24 "fold guards" case, asserts the current behaviour on fix: add = 2, neg = 1, mvn = 1, cset = 1 in _main of fix a = 1.5 + 2.5; fix b = -1.5; ..., and CAST type=fix in --dump-ast. Under D17 the four instruction counts flip to what the m24-nofold.mc line asserts for core literals (1/0/0/0), and the case is re-pointed to say so: "a TK_INT literal folds like a core one; a TK_FLOAT/TK_WIDE/TK_OPAQUE one does not" -- the non-folding half moves onto <float>'s f64 (check-float's 12/12 already proves a folded integer add of two IEEE patterns would be caught by value; the instruction-count assertion is the extra, ~6 lines). The --dump-ast assertion stands: fold() runs after the AST dump (:808). (c) lib/user_syntax_demo.mc:621-638's comment ("exercise ... the three folding guards") is edited to say what fix exercises now: the width of a slot and a global, the name, the cast and parameter positions -- and that as a TK_INT it folds. (d) tests/float/015-nan.mc:15 and 019-putf64.mc:18-20 spell (f64) (f64raw) <constant>; the inner cast now folds to an N_INT of type f64raw, res_lit_type (src/gen_resolve.mc:480) preserves that type, and the outer cast sees walk_depth_type(d) == ty_f64raw -> fmov d, x (lib/machine_arm64_float.mc:404, lib/machine_x86_64_float.mc:465) exactly as it does today after the no-op inner cast. Expected: --dump-asm identical for both files; measured, not assumed, in Acceptance 2 (check-float 12/12 plus an object diff of those two). (e) docs/reference/language.md:165-167 ("Folding stops at a type the core did not define") becomes "stops at a kind the core's integer operators do not fit: TK_FLOAT, TK_WIDE, TK_OPAQUE"; CLAUDE.md:1558-1560's M3 sentence is history and stays.
The alternative -- fold only a "core-registered band" (nd_type(n) >= TY_MAX + ncore) -- is two lines and keeps check-surface untouched, but it makes a module's i16 a second-class i32 (same kind, folded differently) and re-introduces an id boundary the ruling moved away from. Rejected (D17).
(ii) res_lit_type (src/gen_resolve.mc:479-482). No change. A literal is TY_I64; there is no i32 literal. The only i32-typed N_INT is one fold_cast produced, and the >= TY_MAX arm returns its type unchanged -- which is what makes M / x signed for a folded #define M (i32) .... Under the draft's TY_I32 = 7 that constant would have resolved to TY_I64; the registered route is at least as good here.
(iii) lib/machine_probe.mc. pr_ty asserts 0 <= t < type_count() at every depth, and --backend=macho-probe-core additionally asserts t < TY_MAX "which is what an untaught program must produce" (:22-24, :53). Both hold: type_count() is 8 after core_types_init, and src/mc.mc -- the program the probe runs over -- does not spell i32 (D8), so no depth ever carries 7. A program that uses i32 is teaching in the M24 sense -- it has a depth whose type came from the registry -- and probe-core would refuse it by design, exactly as it refuses a <float> program; that flag means "no registered type at any depth", and stays that. No change to the probe; a sentence in its header.
(iv) type_disable. type_disable(TY_I32) becomes type_disable(ty_i32); type_disable checks ty < type_count() (src/hooks.mc:535) and core_types_init ran before user_init, so the AVR compiler's user_init (examples/avr/mc-avr.mc, branch m40-avr) calls it and every position that goes through type_of_token (src/parse.mc:714-715) answers i32: removed by this compiler, with the name from the registry. Bit 7 of ty_off.
A3. The machines, by kind: two bundled, two taught -- contract version 4, no slot added (replaces § 4) #
Today all four machines key the width-carrying slots on the three core ids: mem_op (src/machine_arm64.mc:171-178), x86_mem_op (src/machine_x86_64.mc:208-214), rv_mem_op (examples/kernel/machine_riscv64.mc:188-192) map TY_U8/U16/U32 to an index and everything else to the 8-byte pair; gen_cast (:192-196), x86_cast (:383-388), rv_cast (:363-375) have three arms and no-op for everything else. For a registered id of width 4 that is already silently wrong -- an 8-byte str into a 4-byte global clobbers its neighbour, and a cast to it does nothing -- so the obligation below is not new in kind, only now written down.
The rule every machine owes, per ty-carrying slot (MTASK_PARAM, MTASK_CAST, MTASK_LOAD, MTASK_STORE, MTASK_LOCAL_LOAD/STORE, MTASK_GLOBAL_LOAD/STORE): dispatch on type_width(ty) and type_kind(ty), never on the id.
slot, for w = type_width(ty), k = type_kind(ty) | TK_INT, w < 8 (u8/u16/u32 and any registered one) | TK_SINT, w < 8 (i32, or a module's i16/i8) | w = 8, or TK_FLOAT/TK_WIDE/TK_OPAQUE |
|---|---|---|---|
LOAD, LOCAL_LOAD, GLOBAL_LOAD | zero-extend: ldrb/ldrh/ldr w; movzx/movzx/mov r32; lbu/lhu/lwu | sign-extend: ldrsb/ldrsh/ldrsw; movsx/movsx/movsxd; lb/lh/lw | 8-byte load; the module's for the three kinds |
STORE, LOCAL_STORE, GLOBAL_STORE, PARAM | truncate to w: strb/strh/str w etc. | the same instruction | 8-byte store; the module's |
CAST | zero-fill from w: and #0xff / and #0xffff / mov wd, wn | sign-fill from bit 8w-1: sxtb/sxth/sxtw; movsx/movsx/movsxd r64, r32; shift pairs / addiw rd, rd, 0 | no-op; the module's |
MTASK_PARAM needs no new instruction: it stores at w (a64_param, src/machine_arm64.mc:296-300), and the sign lives in the re-read.
The walker (D4 and D5 restated by kind). Beside walk_word() (src/gen_walk.mc:358):
// M45: a type whose value at a depth is DEFINED by extension from its width --
// zero for TK_INT, sign for TK_SINT -- and narrower than the 8-byte depth. The
// walker performs that extension where a foreign register hands it a bare view:
// after a call and before a return. Core or registered makes no difference;
// FLOAT, WIDE and OPAQUE are the module's (D7). uptr is excluded: a pointer is
// the machine's word and is never narrow relative to itself (AVR: 2 bytes).
i64 walk_narrow(i64 t) {
i64 k = type_kind(t);
if (k != TK_INT && k != TK_SINT) return 0;
if (t == TY_UPTR) return 0;
return type_width(t) < 8;
}
gen_call (src/gen_walk.mc:762-772) issues set_walk_depth_type(depth, rt); callp(mach(MTASK_CAST), rt, depth); after MTASK_CALL when walk_narrow(rt) -- the draft's § 3 code, with the new predicate; the set_walk_depth_type line stays load-bearing for fa_cast/fx_cast (lib/machine_arm64_float.mc:377). N_RETURN (:904-908) issues MTASK_CAST(walk_fn_ret, 0) before MTASK_RET(0) when walk_narrow(walk_fn_ret). type_width(TY_VOID) answers 8 (src/ast.mc:295), so void is excluded without a test. So a REGISTERED narrow integer -- a module's i16 -- is narrowed by the walker after a call exactly as i32 and u8 are; f32 (TK_FLOAT, width 4) stays <float>'s, whose MTASK_CALL already returns it from s0 itself.
One consequence for m40-avr, to measure: u8/u16 are TK_INT of width 1/2, so on AVR the walker now issues MTASK_CAST after every call declared u8/u16 and before every such return. The AVR callee already zero-extends its result to eight bytes (docs/reference/machine.md:380), so avr_fz after the call is redundant, not wrong, and the image bytes of examples/avr move where the corpus declares narrow functions (the draft measured none in examples/kernel; examples/avr/tests must be measured on the branch). The bundled machines are unaffected: nothing on main declares a narrow callee (§ What already exists).
Per machine.
| machine | loads (mem_op-class) | cast | lines |
|---|---|---|---|
src/machine_arm64.mc | mem_op(t, store) by w and k: three new pairs appended to mem_ins[] (:140) in the same even-load/odd-store shape, I_LDRSB 0x39800000, I_LDRSH 0x79800000, I_LDRSW 0xB9800000 (unsigned offset, scaled 1/2/4), sharing I_STRB/STRH/STRW; d_mem prints an x destination for them | gen_cast(rd, ty) by w and k: I_SXTB 0x93401C00, I_SXTH 0x93403C00, I_SXTW 0x93407C00 (SBFM xd, xn, #0, #7/15/31), | rn<<5 | rd; the three TK_INT arms keep their instructions | ~22 |
src/machine_x86_64.mc | x86_mem_op by w/k: X_LDS8 (XF_LD, 1, 0x1be), X_LDS16 (XF_LD, 1, 0x1bf), X_LDS32 (XF_LD, 1, 0x63) -- movsx r64, [m] twice and movsxd r64, [m]; REX.W is already present (w = 1), so sil/dil need no forced-REX column (compare row 39, :159) | x86_cast by w/k: X_MOVSXB (XF_RD, 1, 0x1be), X_MOVSXW (XF_RD, 1, 0x1bf), X_MOVSXD (XF_RD, 1, 0x63), modelled on rows 29/30 (:149-150) | ~16 |
lib/backend_arm64.mc (the surface encoder check-surface compares 32/32) | the three signed-load rows | the three SBFM forms | ~8 |
examples/kernel/machine_riscv64.mc (taught) | rv_mem_op by w/k: lb/lh/lw (opcode 0x03, funct3 000/001/010), names in rv_mname (:123) | rv_cast: slli 56; srai 56, slli 48; srai 48, addiw rd, rd, 0 (sext.w) | ~14 |
examples/avr/machine_avr.mc (branch m40-avr) | -- its invariant "every core type but i64 is unsigned, so extend always means zero" (:20-27) is stated for a compiler that registers no TK_SINT and disables the one the core does | type_disable(ty_i32) in mc-avr.mc's user_init, a README sentence; sign-fill is a later ask | ~3 |
Byte identity for the seven core ids is structural: type_width and type_kind answer for TY_U8/U16/U32 exactly the numbers the id tests selected, and the TK_INT arms keep their instructions. lib/machine_arm64_float.mc / lib/machine_x86_64_float.mc need nothing: fa_cast/fx_cast delegate int-to-int (:378, :438), fa_is_float keys on type_kind == TK_FLOAT, and TK_SINT is not that. lib/machine_probe.mc delegates every slot and asserts nothing about kinds.
Contract version 4. docs/reference/machine.md's header (:3) becomes "version 4 -- ... and the kind obligation (M45)". Like 2 -> 3 (:26-31) it appends no slot and changes no signature; the bump is what a machine outside src/ must honour: the ty of every ty-carrying slot may be a registered TK_INT or TK_SINT id of width 1, 2, 4 or 8, and the machine owes extension by kind -- zero for TK_INT, sign for TK_SINT -- in its loads and casts, and truncation by width in its stores. § 2's rows are reworded: MTASK_CAST "extend depth d to the type's width by its kind" (:132, was "narrow ... to that width"); MTASK_LOAD "zero- or sign-extended by kind" (:133); :163-166 "only i64 divides and shifts with sign" becomes "i64 and every TK_SINT"; the slot invariant (:421-424, "bytes above w zeroed (every core type but i64 is unsigned)") becomes "bytes above w filled by the kind: zero for TK_INT, sign for TK_SINT and i64". The obligation, stated once: a machine that keys on the core ids and ignores TK_SINT is silently wrong -- its MTASK_CAST(ty_i32) is a no-op and its MTASK_LOCAL_LOAD(ty_i32) reads eight bytes -- and a machine that will not implement the kind must type_disable every TK_SINT word from its user_init. The bundled two and riscv64 get their arms in this milestone; AVR disables the word.
Every new instruction goes through the llvm-mc sweep (CLAUDE.md § M24 step 1): ldrsb/ldrsh/ldrsw and sxtb/sxth/sxtw (mach-o and elf aarch64), movsx byte/word and movsxd, register and memory forms (elf and coff x86-64), lb/lh/lw/addiw/srai (riscv64).
A4. What survives, and what is renumbered #
- D3 (64-bit on the extended value), D4 (the walker, through
MTASK_CAST), D5 (return-side symmetry, nowwalk_narrow(walk_fn_ret)), D6 (callpstaysTY_I64,src/gen_resolve.mc:446), D7 (registeredFLOAT/WIDE/OPAQUEexcluded from the walker's cast -- restated: exclusion is by kind, so a registered narrow INTEGER is included), D9, D10 (type_disable(ty_i32)), D11, D13, D14, D15: unchanged. - D8 stands, unchanged in every word:
src/*.mcand the seed-compiled libraries spellu32+c_int(), because the seed cannot resolve the word regardless of how the running compiler registers it --stage0/parse.chas no alias table to consult. § 5 (host layers, libraries,c_int) and its table stand. - § 3 stands with
walk_narrowas in A3. § 6 (tests and gates) stands, plus: acheck-surfacecase that proves the kind buysi16for one line --lib/user_syntax_demo.mcregisterssd_ty_i16 = type_new("i16", 2, 2, TK_SINT)(a registration the seed compiles, the word it never spells), and the case compilesi16 f(i64 x) { return x; } i64 main() { i16 a = -2; return f(40000) == -25536 && a * 2 == -4; }with$demo, assertingldrsh/sxthin--dump-asmand exit 1 (~30 lines).tests/float/022-int-return.mc(risk 4) stands. - Files table, commit 1:
src/lex.mc+0 (row deleted);src/ast.mc+~10 (TK_SINT,type_signed; noTY_I32, noTY_MAXmove, notype_names[]entry, notype_widtharm);src/hooks.mc+~10 (ty_i32,core_types_init, the range check);src/cli.mc+1;src/parse.mc+~12 (fold_taughtby kind,fold_castby width and kind,const_binviatype_signed);src/gen_walk.mcas drafted with the newwalk_narrow; the four machines per A3;scripts/check-surface.sh+~20 (the fold case re-pointed, thei16case, thesxtwabi_case);lib/user_syntax_demo.mc+2 (i16, the comment); docs:machine.mdversion 4 as above,hooks.md§type_newgains theTK_SINTrow and "read by the core in three places",language.md§ 2 "the eighth word, registered by the core" and the fold sentence,guide/96the ninth id,diagnostics.mdnoK_I32line. Net newsrc/lines: ~70, of which ~40 code.stage0/: 0. - Acceptance: 1, 3, 4, 5, 7, 8, 9 unchanged. 2 adds:
check-surface's fold case green in its re-pointed form, andtests/float/015/019objects diffed against the pre-milestonebuild/mc-float(expected identical, A2 (i)(d)); 6 addsexamples/avrimage bytes measured onm40-avr(A3, the redundantavr_fz); new 10: thei16case, andprobe-corestill green oversrc/mc.mc. - Risks: 2 becomes "the core registers first, so a module's first id is
TY_MAX + 1; measured as before, no numeric dependence;hooks.mdsays the number is not surface"; 3 becomes "a machine keyed on ids is silently wrong for any registered integer,TK_SINTor narrowTK_INT; version 4 states the obligation;riscv64gets its arms, AVR disables"; new 11: the kind-based fold guard changes what a registeredTK_INTliteral compiles to (fix,f64raw) -- priced in A2, measured in Acceptance 2; new 12: a program that already usesi32as an identifier is refused by name -- measured zero in the tree, and the diagnostic names the registration.
A5. Amended decisions (architect) -- to ratify with the owner #
- D1' -- how the word enters. The core calls
type_new("i32", 4, 4, TK_SINT)fromsrc/beforeuser_init()(core_types_init,src/cli.mc:230), per the ruling. Rejects the draft's keyword (K_I32 = 301intok_initplus a line intype_of_tokenand three guard lines): both arestage0-free, but the keyword is a second way of introducing a type word beside the one M24 proved, and itscheck-lexprofile is worse (lexdump301 againstmc01, where the registered route lexes as the seed does). Rejects (b), an alias-table registration onto a coreTY_I32 = 7: it still needs the id in the ladder, so signedness could not live inkind, and it would be (a) without the generality. Rejects (c), touchingstage0/, by the ruling. - D2' -- the id.
ty_i32is a registered id,TY_MAXstays 7, no ladder entry, notype_names[]entry; the id is a global the core exports and is never written as a number. RejectsTY_I32 = 7, TY_MAX = 8: a core id cannot carry a kind, and every core decision would be keyed on the id -- the draft's owntype_signedwas. - D12' -- withdrawn. No guard lines:
word_add's core-keyword range (src/hooks.mc:239) and the#ruleguard (src/parse.mc:1411) are untouched, andi32is protected as every registered word is (the alias table,word_is_taught). Rejects extending the guards to a token that no longer exists. - D16 --
TK_SINT, a fifth kind, andtype_signed(t) = t == TY_I64 || type_kind(t) == TK_SINT. Recommend yes: signedness becomes a property of the kind,TY_I64keeps answeringTK_INT(byte identity; modules key on it), and onetype_newline buysi16/i8signed loads, casts, division, comparison and call narrowing from the core and the bundled machines. Rejects a signedness column ontype_new(a signature change for every caller and a fifth array, for a bit that means nothing to three of the four kinds) and rejects makingtype_kind(TY_I64)answerTK_SINT(it changes a documented answer,hooks.md:427, for no gain). - D17 -- the fold guards key on kind:
TK_INT/TK_SINTfold,TK_FLOAT/TK_WIDE/TK_OPAQUEdelegate. Recommend yes: it keepsfold()and the runtime in agreement by construction (for those two kinds the runtime IS the core's integer operators),#define M (i32) 0x80000000is a constant, and a module'si16folds like the core'si32. Priced: ~10 lines insrc/parse.mc, thecheck-surfaceM24 fold case re-pointed (it asserts today thatfixdoes not fold), one comment inlib/user_syntax_demo.mc, one doc sentence, andtests/float/015/019measured. Rejects leavingfold_taughtat>= TY_MAX(ani32#definewould be the one#definethat emits an instruction; agreement by accident) and rejects a "core-registered band" (an id boundary again, and a second-classi16). - D7' -- exclusion from the walker's cast is by kind. Recommend yes:
walk_narrowadmitsTK_INT/TK_SINTof width < 8 exceptuptr, core or registered;FLOAT/WIDE/OPAQUEstay the module's. Rejects the draft'st < TY_MAXtest, which would have left a module'si16un-narrowed after a call. - D18 -- contract version 4 is the kind obligation, no slot. Recommend yes:
docs/reference/machine.mdstates that everyty-carrying slot may receive a registered integer id and that a machine keyed on the core ids is silently wrong;riscv64gets its arms here, AVRtype_disables. Rejects aMTASK_NARROWslot (the draft's D4 reasoning stands) and rejects leaving the obligation implicit (the failure mode is silent).
Implementation notes #
Written while implementing, in the order the work uncovered them. Every number here was measured
on this host (macOS 15, Apple silicon, Docker Desktop, LLVM 21 in /opt/homebrew/opt/llvm) and
each is reproducible with the command shown.
1. Acceptance 1 -- the measurement, with the PRE-milestone compiler #
The program is extern i64 f(...) for every function, exactly as lib/sys.mc and src/arena.mc
declare them today, printing the raw 64 bits of the result register in hex. Built with the
build/mc1 of 6fab014 (the merge-base), never with a compiler carrying this milestone. Linux
binaries were produced with M42's --exe (D14's recommendation), so no linker and no sysroot are
involved; interp/libc in mc.toml pick glibc.
(a) and (b), Linux, all four combinations. open("/nonexistent-m45", 0, 0), close(-1) and
waitpid(-1, 0, 0) came back as a full 64-bit -1 everywhere:
open | close(-1) | waitpid(-1) | fd < 0 taken? | |
|---|---|---|---|---|
musl aarch64 (alpine:3) | 0xffffffffffffffff | 0xffffffffffffffff | 0xffffffffffffffff | yes |
glibc aarch64 (ubuntu:latest) | 0xffffffffffffffff | 0xffffffffffffffff | 0xffffffffffffffff | yes |
musl x86_64 (alpine:3) | 0xffffffffffffffff | 0xffffffffffffffff | 0xffffffffffffffff | yes |
glibc x86_64 (ubuntu:latest) | 0xffffffffffffffff | 0xffffffffffffffff | 0xffffffffffffffff | yes |
So the open hazard is latent and not observed, which is what the spec's opening paragraph
now says after M42's own re-measurement, and this run confirms it independently on four
combinations. A syscall wrapper is hand-written assembly in
both libcs and both of them happen to leave a sign-extended value in the result register.
The defect class itself does reproduce, on ORDINARY compiled C, which is what the milestone is
actually about. Same program, same pre-milestone compiler, two more extern i64 declarations:
atoi("-1") | strcmp("a","b") | |
|---|---|---|
| musl aarch64 | 0x00000000ffffffff -- < 0 NOT taken | 0x00000000ffffffff -- NOT taken |
| glibc aarch64 | 0xffffffffffffffff | 0xffffffffffffffff |
| musl x86_64 | 0x00000000ffffffff -- NOT taken | 0x00000000ffffffff -- NOT taken |
| glibc x86_64 | 0xffffffffffffffff | 0x00000000ffffffff -- NOT taken |
Every one of the four legs has at least one function whose int result the pre-milestone compiler
reads as a large positive number. tests/linux/072-int-return.mc therefore declares all three --
open, atoi, strcmp -- and its header says which is the reproducing one on which leg.
(c) macOS. libSystem's open, close(-1), waitpid(-1), atoi("-1") and strcmp("a","b")
all answer 0xffffffffffffffff. This decides lib/sys.mc's row in § 5: its declarations STAY
i64. But an ordinary C function is a different matter, and the sharpest single measurement in
this milestone is on macOS: int m45_neg(void) { return -1; } compiled by clang -O2 into a
dylib is
_m45_neg:
mov w0, #-0x1
ret
and the pre-milestone compiler, calling it through extern i64 m45_neg(), reads
0x00000000ffffffff and does not take v < 0. That is clang's own code generation, on the
host, and it is the same shape examples/api's SQLite wrappers and examples/desktop's GTK4 ones
have been relying on by accident.
(d) Windows: not measurable here. There is no Windows machine and no container that could hold
one in this repository's development loop (tests/golden/README.md says the same about the two
Windows goldens). GetFileAttributesA returns a DWORD and both Windows ABIs leave the upper half
of the result register unspecified, which is exactly why lib/sys_windows.mc has masked every
BOOL/DWORD with & BOOL_MASK by hand since M19. tests/windows/073-int-return.mc is the
assertion, and the windows-11-arm / windows-2025 CI legs are the only place it runs.
2. Deviations from the amended design #
core_types_init()has THREE call sites, not one. A1 namessrc/cli.mconly.mc buildandmc limitsdo not go throughmc_main's pipeline:drv_parse(src/driver.mc) andlim_compile_file(src/limits.mc) each run their owntok_init/lex_init/user_initsequence. Found by evidence, not by reading: with the single call site,make check-avrfailed withexamples/avr/lib/sys_avr.mc:45: void: removed by this compiler--ty_i32was still 0 in the spawned child, sotype_disable(ty_i32)disabledTY_VOID. One line in each of the two files, in the same place and with the same comment ascli.mc's.- The
return-side cast does NOT rewrite the depth type before the cast, only after. A3's text carries the call-sideset_walk_depth_typeover to both. On the call side the line is load-bearing becausedtype[d]is stale (it describes argument 0); on the return side it is already correct -- it describes the valuegen_valuejust produced, which is precisely the SOURCE a derived machine'sMTASK_CASTneeds. Rewriting it first would tell<float>'sfa_castthat af64being returned from au8function was already an integer. The write is done AFTER the cast instead, soMTASK_RETstill sees the declared type. mem_opandgen_castnow key ontype_width(TY_UPTR)too, because A3's rule is "width and kind, never the id" anduptrhas no exemption there. With the default 8-byte word nothing moves. Withtype_set_width(TY_UPTR, 2)(M41) the arm64 machine now stores and loads auptrlocal at TWO bytes where it used to use the 8-byte pair -- which is a fix, not a regression: the frame slot for such a local is 2 bytes wide, so the old 8-bytestrwrote past it.check-partsasserts the frame size and not the instruction, so it is green either way, and no shipped compiler is affected (examples/avrhas its own machine).walk_narrowDOES exemptuptr, as A3 specifies -- a pointer is the machine's word and is never narrow relative to itself.fold_castnarrows a constant(uptr)cast when the declared word is narrower than 8. Same cause as 3 and the same conclusion: it agrees with whatavr_castdoes at run time (avr_fz(d, avr_tw(TY_UPTR))), sofold()and the runtime still agree. Measured: the AVR image does not move (below).examples/kernel/machine_riscv64.mcneeded a funct7 column on the I-type table, becausesraiissrliwith0x20in the high seven bits of the immediate field and A3 asks for theslli/sraipair. Every existing row has 0 there, so the encoding of every instruction that existed before is unchanged.sext.wis a new opcode (V_ADDIW, opcode0x1b), as A3 asks.lib/machine_arm64_float.mcandlib/machine_x86_64_float.mcwere NOT touched, which A3 predicted. What was checked rather than assumed:fa_cast's float-to-integer arm picksfcvtzsonly forTY_I64andfcvtzuotherwise, so(i32) <a double>converts unsigned and then sign-extends. That is reachable for the first time in M45 and is a real difference from C for a negative double. It is left alone in this milestone -- changing it would move bytes in a library the float legs gate -- and is written down here as the one known rough edge.-
scripts/check-inert.shcannot run itsexamples/kernelcase across this milestone. The PRE compiler builds the taught compiler out of ITS OWN bundle, and the updatedmachine_riscv64.mcnamesTK_SINT, a constant that bundle does not have (unknown name). The equivalent proof was done by hand and is in § 3 below: pre compiler + pre machine, post compiler- post machine,
cmpon the image.
- post machine,
3. Commit 1 -- the mechanism, measured #
Inertness. scripts/check-inert.sh build/mc1.pre build/mc1, where build/mc1.pre is a copy of
build/mc1 taken from 6fab014 before the first edit:
ok 33 objects identical (tests/*.mc and src/mc.mc)
DIFF taught examples/api -> build/api
ok taught examples/lang -> build/lang-demo
ok taught examples/conc -> build/conc-demo
ok taught examples/desktop -> build/desktop-ui
Corrected (review, 2026-09-05). An earlier version of this note recorded ok on the api row.
It is a DIFF, reproduced with mc1.pre rebuilt from 6fab014 and mc1 from 9e27e06, and the
correction matters more than the row: the corpus grep § What already exists relies on cannot see
a narrow declaration a Tier 3 module SYNTHESIZES, because there is no source text to grep.
examples/api/oop.mc's class handler builds a getter per field out of AST nodes
(oop_getter -> oop_func(ty, ...)), and class Todo { ... bool done; } in
examples/api/main.mc -- with bool = type_alias("bool", TY_U8) from mc-api.mc -- is a
declaration of u8 todo_done(uptr self) that no grep over examples/ can find. D5 then applies to
it on BOTH sides, which is the design and not an accident. Measured, --dump-asm of
examples/api/main.mc through the taught mc-api each compiler builds differs by exactly two
instructions in 3300 lines:
_todo_done: ... ldrb w9, [x9] the return side: walk_fn_ret is u8
+ and x9, x9, #255
... bl _todo_done; mov x9, x0 the call side: res_type(n) is u8
+ and x9, x9, #255
Both are no-ops on the value -- ldrb already zero-extends, and the callee now guarantees the
extension the caller repeats -- so build/api comes out the same 55632 bytes and check-examples
(the eleven route checks of examples/api/test.sh) is green on the post side. It is the price of "a call returns what it declares" being a
property of the DECLARATION rather than of the spelling, and the same two instructions would appear
for a hand-written u8 function. The other four taught examples have no narrow-declared function,
synthesized or written, and are byte-identical.
The examples/kernel row of the script cannot run across this milestone at all: the pre compiler's
bundle has no TK_SINT, so it cannot build the post machine_riscv64.mc and the row is
FAIL taught examples/kernel (pre, entry). It, and the AVR image, are measured by hand instead --
the two taught machines whose SOURCE this milestone changes:
examples/kernel/build/kernel.bin: pre compiler with the pre machine, post compiler with the post machine -- 3304 bytes,cmpidentical.examples/avr/build/avr.elf: pre compiler with the premc-avr.mc, post with the post --cmpidentical, so the redundantMTASK_CASTA3 warns about after au8/u16call costs this corpus nothing (it declares no narrow callee).
The seed. git diff stage0/ is empty; check-obj is 32/32 against build/mc0.
The llvm-mc sweep, over a program that uses i8, i16 and i32 (the two extra widths come
from a throwaway module in build/, which is gitignored -- the point is to reach every arm of the
new tables, including the two the tree has no caller for):
| target | distinct new instructions | mismatches |
|---|---|---|
| arm64 (mach-o) | 16 | 0 |
| aarch64 (elf) | 16 | 0 |
| aarch64 (coff) | 16 | 0 |
| x86_64 (elf) | 16 | 0 |
| x86_64 (coff) | 16 | 0 |
and for riscv64, through examples/kernel/test.sh's own sweep, with tests/sweep.mc extended by
an i32 section: tests/sweep.mc 285 distinct instructions, 0 mismatches (262 before), plus a
separate scratch run with i8/i16 registered -- 62 distinct instructions, 0 mismatches,
containing lb, lh and both srai shapes.
lib/backend_arm64.mc, the surface encoder, was checked the way check-surface checks it but on a
program that uses the new instructions: --backend=arm64-surface and the built-in backend produce
byte-identical objects for a source with i8/i16/i32 locals, globals, arrays, parameters,
casts and calls.
The load-bearing line. With set_walk_depth_type(depth, rt) removed from gen_call, the
<float> compiler lowers extern i32 ilogb(f64 x) as
bl _ilogb
mov x9, x0
fcvtzu x9, d16 <- a float register that never held this value
sxtw x9, w9
and with it as mov x9, x0 + sxtw x9, w9. tests/float/022-int-return.mc is the regression.
Cost in src/ (git diff --numstat, and added lines that are neither a comment nor blank):
| file | added | of which code |
|---|---|---|
src/ast.mc | 14 | 2 |
src/hooks.mc | 19 | 3 |
src/cli.mc | 4 | 1 |
src/driver.mc | 1 | 1 |
src/limits.mc | 1 | 1 |
src/parse.mc | 23 | 12 |
src/gen_walk.mc | 58 | 22 |
src/machine_arm64.mc | 67 | 46 |
src/machine_x86_64.mc | 47 | 40 |
| total | 234 | 128 |
against A4's estimate of ~70 new src/ lines / ~40 code. The gap is entirely in the two machines
(86 of the 128), which A4 counted in the per-machine table rather than in the src/ total.
make check green end to end, RC 0, zero FAIL (5m09s): test 32/32, check-lex 126/126
(2 skipped), check-ast 126/126, check-asm 126/126, check-obj 32/32 identical to the frozen
seed, check-bundle, bootstrap at a fixed point (mc2.o == mc3.o, 926448 bytes; the
--dump-asm diff between mc1 and mc2 is empty), check-surface 32/32 plus the three new
abi_cases, the re-pointed fold case and the i16 case, test-exe 32/32, check-mc 11/11,
check-standalone, check-toml 10/10, check-build 31/31, check-stubs 9/9, check-sysroots,
check-limits 17/17 under 90%, check-minimal, test-linux 39/39, test-linux-x86_64 36/36,
test-linux-exe 42/42 musl + 42/42 glibc, test-linux-x86_64-exe 39/39 + 39/39,
test-windows 40/40 objects + 3 linked, test-windows-x86_64 38/38 + 3 linked,
check-examples, check-lang, check-conc, check-desktop, check-kernel (QEMU, exit 0),
check-avr (simavr + QEMU), check-float, check-wide, check-docs (185 symbols, 19 flags,
19 TOML keys, 10 directives, 48 samples, 273 links), site 85 pages + check-site 0 problems.
make check-linux-host green for both architectures (RC 0), with the cross proof
(mc2l --backend=macho src/mc.mc byte for byte the macOS build/mc2.o) on musl and glibc.
4. Commit 2 -- the declarations, and the one place D8 did not survive #
D8 as written is incompatible with check-asm, and the code said so before any argument did.
§ 5 asks src/*.mc and the seed-compiled libraries to declare an int result as u32 and read
the sign through c_int(). Implemented literally, make check came back RC 2 with 23 FAILs:
FAIL src/arena.mc
FAIL src/mc.mc
FAIL lib/sys_windows.mc
FAIL src/host_windows.mc
... 23 in all
103/126 files identical
make: *** [check-asm] Error 1
scripts/check-asm.sh compares build/mc0 --dump-asm FILE against build/mc1 --dump-asm FILE
over tests/*.mc tests/lib/*.mc lib/*.mc src/*.mc. A u32 declaration makes mc1 emit a
mov w9, w9 after the call that the frozen seed, which has no narrowing, does not — the delta
over src/mc.mc was exactly 27 mov w9, w9 instructions and nothing else, measured with
diff <(mc1 --dump-asm src/mc.mc) <(mc1.pre --dump-asm src/mc.mc). There is no way to have both:
either the seed set keeps the seed's codegen, or its declarations are narrow. The two escapes are
closed by the task's own rules — a seed-skip header is forbidden, and stage0/ is frozen.
Resolution: c_int() alone in the seed set, i32 everywhere else. That is D8's own named
alternative ("keeping i64 and adding c_int alone is fewer edits but leaves the compiler as the
one place whose declarations lie"), and it is taken here because a hard gate demands it and not as
a preference. It is also not weaker: c_int(v) takes the low 32 bits and sign-extends from bit 31,
so it produces the right answer whether the callee left the sign in place or not, on every host and
under every seed. The compiler's own open/creat/waitpid hazard is fixed either way; what is
lost is only that the declaration itself would have carried the information.
So, per file:
| file | declarations | reads |
|---|---|---|
src/arena.mc | unchanged (i64) | c_int() added (14 lines, the helper); c_int(open(...)), c_int(creat(...)) |
src/sysroot.mc | — | c_int(open(...)) |
src/driver.mc | — | c_int(waitpid(...)) at both sites -- the same latent defect as open, and the one a spawned linker's pid_t would hit |
src/backend_exe.mc | unchanged | c_int(creat(...)) |
src/host_{macos,linux,windows}.mc | unchanged, with the reason in a comment | — |
lib/sys_windows.mc, lib/sys_windows_host.mc | unchanged; the & BOOL_MASK masks stay (D9) | — |
lib/sys.mc | unchanged, by measurement (Acceptance 1c), with the numbers in its header | — |
examples/api/lib/sqlite.mc | 11 -> i32 (last_insert_rowid stays i64) | — |
examples/api/lib/http.mc | 5 -> i32 | the three < 0 tests are now sound |
examples/api/tests/lib_test.mc | unlink -> i32 | — |
examples/conc/lib/{macos,linux}/thread.mc | 12 and 12 -> i32 | — |
examples/desktop/lib/gtk.mc, main.mc | 2 -> i32; both (u32) casts dropped | — |
examples/api/test_sqlite_lib.mc is in § 5's table but was left alone: nothing builds it (no
Makefile target, no script, no CI step) and it does not compile at all --
test_sqlite_lib.mc:4: call to unknown function, because sqlite3_libversion_number is declared
nowhere. Recorded rather than fixed; it is an orphan from M12 and not this milestone's business.
Measured.
scripts/check-inert.sh build/mc1.pre build/mc1after this commit: the 33 core objects (tests/*.mc+src/mc.mc) are still identical, which is the direct consequence of leaving the seed set's declarations alone;examples/langidentical;api,concanddesktopnow REFUSE to build under the pre-milestone compiler (type expected in extern), which is the fix and is what "commit 2 cannot be proved inert" means in practice.- The narrowing is there where it should be:
bl _sqlite3_stepis followed bymov x9, x0+sxtw x9, w9in the taught compiler's--dump-asmofexamples/api/main.mc. diff <(mc1 --dump-asm src/mc.mc) <(mc1.pre --dump-asm src/mc.mc)is empty: this commit'ssrc/change is source-only, and the golden moves becausec_intis a new function, not because any instruction selection changed.- The published-seed question, answered locally. A pre-M45
build/mc1-- which is exactly what a published release is -- compiles the newsrc/for all four host entry points and produces byte-identical objects to the new compiler:src/mc_windows.mc(coff arm64),src/mc_windows_x86_64.mc(coff x86-64),src/mc_linux.mc(elf aarch64) andsrc/mc_linux_x86_64.mc(elf x86-64). So both foreign chains bootstrap from 0.12.0 unchanged. mc-linux-arm64(musl,alpine:3) andmc-linux-arm64-gnu(glibc,ubuntu:latest) both answermc: cannot open: /no/such/file.mc, exit 1, on a missing input -- Acceptance 7's proof, which the pre-milestone compiler also passed here becauseopenwas never the reproducing case (note 1).
make check RC 0, zero FAIL (5m08s), with check-asm back at 126/126 and check-obj 32/32;
make check-linux-host RC 0 on both architectures with the cross proof on musl and on glibc;
check-examples, check-conc, check-desktop green with the truthful declarations. All five
goldens rewritten a second time.
5. Commit 3 -- p_cp() #
Not part of the spec; asked for alongside it because the ngen consumer hit it. p_start() is where
the CURRENT TOKEN starts, and on a token p_subst_name() replaced, subst_apply (src/lex.mc)
swaps tok_start/tok_len for the replacement string, which lives in the arena. So a
syntax_lit-style handler that scans raw source forward from p_start() inside a
p_push_source frame reads the arena lexeme and not the source it meant to read. p_cp() is one
line in src/parse.mc, beside p_start() and p_src_end(): the lexer's cursor, which still
points into the pushed text just past the token.
Measured, in scripts/check-surface.sh (p_cp-under-substitution, through the demo compiler):
srcbyte reports ld8(p_cp()), srcbyte0 reports ld8(p_start()), and probe p1; pushes
i64 p1() { return W * 1000 + V; } with W -> srcbyte and V -> srcbyte0, so both run on
SUBSTITUTED tokens.
| value | what it means | |
|---|---|---|
srcbyte in ordinary source | 59 | the ; that really follows the word |
srcbyte0 in ordinary source | 115 | 's' — outside a substitution the two positions agree |
W inside the pushed frame, through p_cp() | 32 | the SPACE that really follows W in the pushed text |
V inside the pushed frame, through p_start() | 115 | 's', the arena copy of "srcbyte0" — the source there holds V, 86 |
Inert: scripts/check-inert.sh between the commit-2 compiler and this one is identical everywhere
— 33 objects and all five taught examples, examples/kernel included — and the
mc1/mc2 --dump-asm diff over src/mc.mc is empty. The goldens move because p_cp is a new
function in src/parse.mc, not because anything the compiler emits changed.
6. The review batch (2026-09-05) #
Four findings from the reviewer of the branch. The first is a correction to this document (§ 3 above and § 3's "Inertness, exactly" in the Design, both rewritten in place); the other three are code and documentation.
examples/apiwas never inert under commit 1 -- corrected where it was claimed, with the reason: the "no narrow-declared callee anywhere" grep is over source text and a Tier 3 module can SYNTHESIZE one. See § 3.c_intwas not indocs/reference/. Acceptance 8 promised it documented and it appeared only in this spec and inCLAUDE.md;scripts/check-docs.sh's symbol regex had no prefix that matched it, so the gate could not notice. Documented indocs/reference/language.md§ 6 (beside "a call returns what it declares" and theexternrule) and cross-referenced fromdocs/reference/hooks.md§ The host layer, which is where the declarations it exists for live. The extraction regex gainedc_-- the same wideningon_anddecl_got (docs/specs/M26.md) -- and the gate was verified in both directions: with the two mentions renamed it printsFAIL undocumented public symbols, and with them it printsdocs ok: 187 symbols.rv_if7[]was a funct6. RV64I's shift-immediate forms take a 6-bit shamt (bits 25:20), so their differentiator is a funct6 at bits 31:26, not the funct7 of the register forms (5-bit shamt). The single value in the column,0x20 << 5, lands on bit 30 -- exactly where0x10 << 6lands -- so the encoding was right and only the name and the shift were wrong; a second, multi-bit value would have been misplaced. Renamed torv_if6/rv_if6_at, value0x10, shift<< 6, with the reason in the comment.examples/kernel/build/kernel.biniscmp-identical before and after (3304 bytes) andmake check-kernelis green, including the llvm-mc sweep (main.mc234,tests/sweep.mc285, the generated source 1079 distinct instructions, 0 mismatches). The kernel corpus has nosrai-- it comes only fromrv_cast's sign-extension pair, which needs a 1- or 2-byteTK_SINT-- so the arm was re-proved on the scratch compiler of § 3,mc-kernelplustype_new("i16", 2, 2, TK_SINT)andi8: 36 distinct instructions, 0 mismatches, withsrai t3, t3, 48=135e0e43andsrai t4, t4, 56=93de8e43re-assembled byte for byte byllvm-mc -triple=riscv64 -mattr=+m.p_skip_balancedrefused a region that ends flush with the end of an included file. The frame depth was compared AFTER the lookaheadnext()that follows the closing delimiter, andlex_nextpops an exhausted#includeframe before it produces a token -- so a perfectly balanced region whose}was the include's last token leftnopenone lower and was refused withregion crosses a file boundary. Reported by the teko/ngen consumer. The depth is now sampled at the CLOSER, inside the loop, which is exactly the "both delimiters live in one buffer" the rule always meant; an#includeopened and closed inside the region still movesnopenup and back down and is still fine, and a region that really does cross is still refused at the opening token. Two cases inscripts/check-surface.sh(p_skip_balanced-include-eof, which compiles atmplliving alone in its own file and runs it for exit 42, andp_skip_balanced-cross, which asserts the message and its position); the message had no test at all before. Documented indocs/reference/hooks.md§ Record and replay,docs/reference/diagnostics.mdanddocs/surface.md.
7. What the Windows CI legs found (2026-09-05) #
The four Windows jobs -- the only place in this project where a Windows binary is LINKED against a
real toolchain and RUN -- were red on the branch while everything on the development machine was
green. Two findings, the same on windows/arm64 and on windows/x86_64, plus the gate change that
makes the first of them impossible to miss again.
-
tests/windows/073-int-return.mcwas in the wrong link mode.scripts/test-windows.shput it in theselflist, andselfmeans one thing only: the source includes<sys_windows>, so it carries the layer itself andwinrt.objmust not be linked next to it. 073 deliberately does NOT include the layer -- its header says so, and that is the point of the file: it declares the three kernel32 entry points it uses and depends on nothing. Butwinstart.objis in EVERY link line (M20:mc_startlives there and nowhere else) andmc_startcallswin_setup/win_argv, which live inlib/sys_windows.mc=winrt.obj. So the link failed with two undefined symbols:lld-link: error: undefined symbol: win_setup
referenced by C:/a/mc/mc/build/windows-objs/winstart.obj:(mc_start)
lld-link: error: undefined symbol: win_argv
073 is now a
kernel32link (test object +winrt.obj+winstart.obj+kernel32.lib): its ownexterns resolve from the import library and none of its names (wr,puti,nbuf,nio,main) collides with the layer's (write,read,open,creat,close,exit,win_*). The manifest is what both halves of the split read, so moving the test moves it for--build-onlyand--run-onlyat once.
close(-1)answered 0 on Windows, and-1everywhere else.lib/sys_windows.mc'sclosepassed anything outside 0..2 straight toCloseHandle, and(HANDLE)-1is not onlyINVALID_HANDLE_VALUE: it is the pseudo-handleGetCurrentProcess()returns, andCloseHandleon a pseudo-handle SUCCEEDS. So the wrapper reported success for a close of an invalid descriptor,close(0 - 1) < 0was false, andtests/mc/093-i32-return.mcprinted-1 44 -32768 1 0where the four other targets print-1 44 -32768 1 1. That is a defect of the LAYER, not of the test -- a POSIXcloseof an invalid descriptor is-1/EBADF-- soclosenow refuses a negative descriptor itself, with the pseudo-handle reason written next to the line.lib/sys_windows_host.mcneeded nothing: it#includeslib/sys_windows.mcand has nocloseof its own. 093's header carried the false claim in prose ("close(-1) isCloseHandle(INVALID_HANDLE_VALUE)and therefore -1 too") and was corrected with the fix.lib/sys_windows.mcis bundled (sys_windows), so the fix moves the blob and therefore all five goldens -- and only the blob: the--dump-asmdiff betweenmc1andmc2is empty andscripts/check-inert.shis identical everywhere.
-
The local gate could have seen (1) and did not. The default mode of
scripts/test-windows.shlinked THREE objects out of forty -- one per link mode, plus the one that pulls the layer in through anextern-- so a test classified into the wrong mode was invisible here and CI was the first to say so. An undefined symbol is a property of the pair(object, link mode), andlld-linkis on the development machine, so the default mode now links every object in the manifest with its recorded mode, keeps theIMAGE_FILE_MACHINE_*assertion per linked.exe, and reports the count. Measured here: 40 executables linked forwindows/aarch64, 38 forwindows/x86_64, nothing executed. Proved to have teeth by putting 073 back in theselflist:make test-windowsthen fails with the exact CI message (FAIL 073-int-return (link: lld-link: error: undefined symbol: win_setup), and passes with the classification fixed. The--run-onlyhalf is unchanged.Widening the gate immediately paid for itself a second time: it reported
undefined symbol: GetFileAttributesAon a machine whosebuild/sysroot/windows-aarch64had been populated BEFORE M45 added that name to the list.scripts/sysroot-windows.shtreated the directory as a cache keyed on the mere existence ofkernel32.lib; it now compares the generatedkernel32.defagainst the one on disk and repopulates when they differ, so a stale cache cannot present itself as a missing entry point. CI never saw this -- it builds the sysroot fresh in every run -- which is exactly the class of divergence between the local gate and CI this section is about.
What only the Windows runners can prove. Both fixes are verified here as far as a macOS
machine can verify them: the objects cross-compile, every one of them links with lld-link, and
close's new first line is ordinary mc code compiled by the same compiler for all five targets.
Whether close(-1) now RETURNS -1 on Windows -- and therefore whether 093 prints
-1 44 -32768 1 1 there -- is a claim only the windows-11-arm and windows-2025 legs can
settle, because nothing Windows is ever executed in this repository's development loop.