Spec M38 -- mc hosted on Windows (arm64 and x64)
Owner's rule (2026-09-04): every landed OS/arch gets a release asset of mc itself. Goal: the
self-hosted compiler runs natively on Windows on ARM64 and x64, bootstraps there to its own fixed
point, mc build spawns lld-link and a taught compiler through kernel32, and
mc-<VER>-windows-{arm64,x86_64} are release assets verified on the Windows runners.
Depends on M19 (COFF arm64, lib/sys_windows.mc, scripts/sysroot-windows.sh, the
windows-11-arm leg), on M20 (x86_64-win machine, coff-obj-x86_64, the x64 leg,
lib/sys_windows_start.mc, scripts/test-windows.sh --arch x86_64) and on M37 (the host layer).
Line references are to main at 77c4a62 (before M20).
What already exists #
- The host layer, fixed by M37. One host file per entry point, included before
src/core.mc(src/mc.mc:11,src/mc_linux.mc:13). Its contract (docs/reference/hooks.md§ 6,src/driver.mc:57-58):host_init(envp),host_environ(),host_os/arch/machine/sys/include(),host_has_sdk(),#define O_CREAT/O_TRUNC, and theexternsposix_spawnp,posix_spawn_file_actions_{init,addopen,destroy},waitpid,mkdir,unlink(src/host_macos.mc:35-41). - Everything else the compiler needs from the OS is
externoutside the host file and must resolve at link time:open,read,write,close,_exit,creat,mmap(src/arena.mc:5-21) andchmod(src/backend_exe.mc:34). Fifteen symbols in all. lib/sys_windows.mcimplementswrite/read/open/creat/close/exitover kernel32 with HANDLE-as-fd (lib/sys_windows.mc:83-122); after M20 the entry point lives inlib/sys_windows_start.mc.scripts/sysroot-windows.shbuildskernel32.libfrom a.defwithllvm-dlltool, no SDK and no download.scripts/test-windows.shalready runs the suite in the split shape M38 needs for the compiler:--build-onlyon macOS,--run-onlyon the runner.release.ymlcarriesbuild-future-hostsunderif: falsewith exactly the two entries M38 enables.
Design #
1. The blocker: CreateProcessA has ten parameters #
src/arena.mc:51 -- #define MAXPARAMS 8 // never passes an argument on the stack -- is
enforced at parse time (src/parse.mc:1935, at most 8 parameters) and again in
src/gen_resolve.mc:510. CreateProcessA takes 10, so extern uptr CreateProcessA(...) does not
compile today. This is the largest piece of M38 and the reason it is not a port of
host_linux.mc.
Decision: caller- and callee-side stack parameters, MAXPARAMS 12 in src/. The x86-64
machine already passes its 7th and 8th arguments on the stack (x86_push_args, x86_param), and
M20 does the same for the Win64 5th and later. The arm64 machine gains the same shape: arguments
9..12 stored at [sp, #0..#24] by the caller (the outgoing area is reserved with sub sp before
the argument moves and released after the bl, 16-byte aligned, so back is 16 or 32), and read
by the callee at [x29, #16 + 8*(i-8)] -- exactly where x86-64 reads its 7th at [rbp+16]. The
ABI contract in docs/reference/objects.md § 4 changes from "parameters in x0..x7" to
"parameters 1..8 in x0..x7, 9..12 on the stack", and scripts/check-surface.sh's assertions
follow. MAXPARAMS is one #define in src/arena.mc, and the two checks that quote it change
nothing else. The C seed keeps 8: stage0 only ever compiles src/mc.mc, which has no function
with more than 8 parameters (scripts/check-limits.sh keeps proving every seed limit), so this is a
documented divergence of the same kind as MAXSTRS/MAXGLOBALS/MAXOPEN (docs/build.md § limits).
Tests: tests/mc/080-twelve-params.mc (a 12-parameter mc function summing its arguments, called
directly and through callp, and a 10-parameter one whose arguments 9 and 10 are calls) runs on
every target and every machine; the extern side is proved by CreateProcessA itself on the Windows
runners.
Rejected: (B) #opcode/emit() trampolines per machine (M20 just deleted the last hand-written
words from the Windows layer; two more would not generalise to the next Win32 entry point);
(C) WinExec/ShellExecuteExA (no exit code / shell32).
2. src/host_windows.mc and the compiler runtime object #
host_windows.mc cannot define open/read/write/mmap: src/arena.mc:5-21 declares them
extern, and func_add rejects a second definition (src/gen_resolve.mc:168,
function declared twice). The Windows-hosted compiler resolves them the way every Windows test
object already does -- from a runtime object linked next to it.
src/host_windows.mc(arch-neutral, ~60 lines) mirrorssrc/host_linux.mc:O_CREAT 0x100/O_TRUNC 0x200(the valueslib/sys_windows.mc:67-68publishes), the sevenexternspawn/mkdir/unlink declarations unchanged,host_os() = "windows",host_sys() = "sys_windows",host_has_sdk() = 0(noxcrun;src/driver.mc:171turns{sdk}into a config error),host_environ() = 0(Decision 5), plus the newhost_exe_suffix()(Decision 4).src/host_windows_aarch64.mc/src/host_windows_x86_64.mc:host_arch,host_machine("arm64"/"x86_64-win"),host_include, like the Linux pair.src/mc_windows.mc/src/mc_windows_x86_64.mc: three#includes each.lib/sys_windows_host.mc(new, ~200 lines, bundled assys_windows_host): the POSIX-shaped shims the compiler's ownexterns need, over kernel32 --_exit(ExitProcess),chmod(a no-op returning 0: NTFS has no mode bits, andbackend_exe.mc:715is only reached by the Mach-O backend, which a Windows host never selects but must still link),mkdir(CreateDirectoryA(path, 0)),unlink(DeleteFileA),mmap(§ 3),posix_spawnp/waitpid(§ 4) and one-line stubs returning -1 for the threeposix_spawn_file_actions_*(§ 5). It#includeslib/sys_windows.mcfor the file wrappers and is compiled once tobuild/mcrt-windows-<arch>.obj.
3. mmap: keep it, shim it #
src/arena.mc:32-46 keeps MAP_ANONP = 0x1022 portable across two kernels so that
src/lexdump.mc, src/tomldump.mc, tools/bundle.mc and site/gen/main.mc -- which include
arena.mc with no host file -- keep compiling. lib/sys_windows_host.mc provides an mmap with
the identical prototype over VirtualAlloc(0, len, MEM_COMMIT|MEM_RESERVE (0x3000),
PAGE_READWRITE (0x04)), ignoring addr, prot, flags, fd and off, returning 0 on failure.
arena_map (src/arena.mc:199-208) already rounds to 64 KiB, VirtualAlloc's allocation
granularity, and already treats both 0 and -1 as failure. arena.mc does not change.
4. Spawning #
src/driver.mc spawns in two places -- the linker (drv_link, :393) and the taught compiler
(drv_teach, :468) -- through one function, drv_spawn (:151-162). Neither the driver's
spawn path nor main.mc changes. lib/sys_windows_host.mc provides:
posix_spawnp(pid, file, fa, attr, av, envp): joinavinto one command line with the MSVCRT quoting rules (quote an argument containing a space or a quote, backslash-escape embedded quotes), lay outSTARTUPINFOA(104 bytes,cb= 104,dwFlags= 0 so the child inherits the console) andPROCESS_INFORMATION(24 bytes) inu8arrays, callCreateProcessA(0, cmdline, 0, 0, 1, 0, envp, 0, &si, &pi), storepi.hProcessthroughpid,CloseHandle(pi.hThread), return 0 or -1.waitpid(h, status, options):WaitForSingleObject(h, INFINITE),GetExitCodeProcess(h, &code),CloseHandle(h), store(code & 255) << 8throughstatus--drv_spawn:159-161reads(s >> 8) & 255and checkss & 127for a signal; Windows has no signals, so the low seven bits stay 0 and the mask keeps a DWORD exit code from reading as 0.- Environment:
mc_startcallsmain(argc, argv, 0)(M20's shim passes a defined third argument),host_initignores it (likesrc/host_macos.mc:50) andhost_environ()returns 0, whichCreateProcessAreads as "inherit the parent's environment".
5. {sdk} and stdout capture #
The only child whose stdout mc captures is xcrun, through posix_spawn_file_actions_addopen
(src/driver.mc:175-184). drv_sdk bails out before touching the file actions when
host_has_sdk() is 0 (:171-172), so Windows needs no child stdout capture at all; the three
symbols are stubs returning -1.
6. Paths and the .exe suffix #
path_norm/path_join(src/lex.mc:346-433) are/-only. Under Git Bash every path in anmc.tomland on the command line is/-separated, and every Win32 file API accepts/. Two gaps are documented, not fixed (Decision 6): a drive-qualified path (C:/x) is classified as relative, anddrv_mkdirs(src/driver.mc:101-113) callsmkdir("C:"), whichCreateDirectoryArejects harmlessly.drv_runnable(src/driver.mc:329-338) prefixes./when the path has no/; right on Windows too, sinceCreateProcessAwithlpApplicationName = 0searchesPATH.- The
.exesuffix is the one driver change.[compiler].outis written without one anddrv_teachspawnsdrv_path(cout)directly (src/driver.mc:456).host_exe_suffix()(""on macOS/Linux,".exe"on Windows) joins the host interface;drv_teachappends it to the linked compiler's path and to what it spawns;docs/reference/hooks.md§ 6 gains a row.
7. The bootstrap chain #
scripts/bootstrap-windows.sh [SEED], bootstrap-linux.sh's sibling (no mc0: the C seed emits
Mach-O only):
SEED src/mc_windows[_x86_64].mc -> build/mc1w.obj -> link -> build/mc1w.exe
build/mc1w.exe same source -> build/mc2w.obj -> link -> build/mc2w.exe
build/mc2w.exe same source -> build/mc3w.obj
cmp build/mc2w.obj build/mc3w.obj <- the criterion
sha256 build/mc2w.obj vs tests/golden/mc2-windows-<arch>.sha256
build/mc2w.exe --host => os windows / arch <arch> <- the seed check
scripts/test-windows.sh --arch <arch> --run-only build/tests-windows-<arch>
build/mc2w.exe --backend=macho src/mc.mc -o build/x-cross.o; cmp with build/mc2.o
scripts/link-windows.sh [--arch A] OUT IN.obj wraps lld-link -machine:<m> -subsystem:console
-entry:mc_start -nodefaultlib -out:OUT IN.obj winstart.obj mcrt-windows-<arch>.obj kernel32.lib
(dash-form options only: MSYS rewrites /out: into a path, PR #4). mcrt-windows-<arch>.obj,
winstart.obj and kernel32.lib travel in the CI artifact as winrt.obj already does; with mc
available locally the script rebuilds them. scripts/sysroot-windows.sh's .def grows
CreateProcessA, WaitForSingleObject, GetExitCodeProcess, CreateDirectoryA, DeleteFileA,
VirtualAlloc.
MSYS on the Windows runners: .exe names written explicitly everywhere ([ -x build/mc2w ] is
not reliable); MSYS2_ARG_CONV_EXCL='*' in the job environment as the belt to the dash-form
braces; CRLF: the repository has no .gitattributes and GitHub's Windows images ship
core.autocrlf=true. M19/M20 survive because their runner only links and runs prebuilt objects;
M38 makes it compile .mc sources and run shell scripts from the checkout, where #!/bin/sh\r is
a hard failure. The lexer already treats \r as whitespace (src/lex.mc:655), so a CRLF checkout
still produces the same tokens and the same object -- the golden is safe either way -- but the
scripts are not: .gitattributes with * -text (Decision 11) and core.autocrlf=false set in the
job before checkout.
8. CI and releases #
ci.yml, macOS job:make mc-windows-obj/make mc-windows-x86_64-objfrom twokind = "obj"configs (src/mc.windows-aarch64-obj.toml,src/mc.windows-x86_64-obj.toml, modelled onsrc/mc.linux-aarch64-obj.toml) plusmcrt-windows-<arch>.obj, uploaded asmc-windows-hosts.- Two new jobs mirroring the Linux host jobs:
mc on windows/arm64 host(windows-11-arm) andmc on windows/x86_64 host(windows-2025, Decision 9). Each: obtainlld-linkwith the cache-and-fallback steps the suite legs already contain, install GNU make (Decision 7), link the downloaded object,mc --host,scripts/bootstrap-windows.sh, the Windowsmake checksubset, and the cross proof. Perdocs/plan.md§ Rule for every new target, both join the required checks at merge. release.yml:build-future-hostsis deleted andbuild-windows, a two-entry matrix inbuild-linux's shape, takes its place --build(macOS) uploadsmc-windows-objects, each Windows runner links, proves withscripts/bootstrap-windows.sh, and packages.publishneeds[build, build-linux, build-windows]: five assets.scripts/release-assets.shgains awindows-*INSTALL.txt case and stages the binary asmc.exe; the format stays.tar.gz(Decision 8):mc-<VER>-windows-arm64.tar.gz,mc-<VER>-windows-x86_64.tar.gz, each with.sha256.
9. Makefile #
HOST := $(shell uname -s) reports MINGW64_NT-... or MSYS_NT-... under Git Bash, so the
switch becomes a findstring; uname -m already reports aarch64/x86_64. REF = build/mc1w.exe,
MC = build/mc2w.exe. Windows check = budget bootstrap-windows check-lex check-ast check-asm
check-obj check-bundle check-mc check-toml check-limits check-skipped; everything else needs mc
plus lld-link through scripts/link-windows.sh, and the four standalone tools link against the
same mcrt object. check-skipped gains the Windows reasons.
Out of scope #
No .pdata/.xdata (M19's accepted gap, inherited). No mc sysroot fetch windows-* (M25). No
wide-character paths (*A APIs, ANSI code page). No --exe for Windows (a PE writer): Windows,
like Linux, always links through [linker]. No 32-bit Windows.
Files and estimated deltas #
| file | delta |
|---|---|
src/host_windows.mc; src/host_windows_{aarch64,x86_64}.mc; src/mc_windows{,_x86_64}.mc | new, ~60 + 7 + 7 + 15 + 15 |
lib/sys_windows_host.mc | new, ~200 |
src/machine_arm64.mc, src/machine_x86_64.mc, src/gen_walk.mc, src/arena.mc, src/parse.mc, src/gen_resolve.mc | +60-120: stack parameters 9..12, MAXPARAMS 12 |
src/driver.mc | +10 (host_exe_suffix at three sites); src/host_macos.mc, src/host_linux.mc +3 each |
lib/sys_windows_start.mc | +1 (third argument 0) |
src/mc.windows-{aarch64,x86_64}{,-obj}.toml | new, ~45 each |
scripts/bootstrap-windows.sh, scripts/link-windows.sh | new, ~220 + ~50 |
scripts/sysroot-windows.sh | +8; scripts/check-*.sh +10 each (a Windows branch beside the Linux one) |
scripts/check-surface.sh | the ABI assertions for parameters 9..12 |
Makefile | +70; .gitattributes new |
tools/bundle.list + src/bundle_data.mc | +4 entries, regenerated |
.github/workflows/ci.yml / release.yml | +180 / +140 net; scripts/release-assets.sh +20 |
tests/mc/080-twelve-params.mc; tests/golden/mc2-windows-{arm64,x86_64}.sha256 | new |
docs/guide/95-windows-host.md (new, ~300), docs/reference/{hooks,objects,machine}.md, docs/bootstrap.md, docs/ci.md, docs/build.md, docs/core-language.md (parameter limit), docs/plan.md, CLAUDE.md | updated |
Acceptance #
scripts/bootstrap-windows.shreaches the fixed point on bothwindows-11-armandwindows-2025, and the golden matches.mc2w.exe --hostsaysos windows/arch aarch64(resp.x86_64) /sys sys_windows.- Cross proof:
mc2w.exe --backend=macho src/mc.mc -o x.ois byte-identical to the macOS job'sbuild/mc2.o. scripts/test-windows.sh --run-onlygreen on both architectures, run by a compiler that was itself bootstrapped on the runner.- The Windows
make checksubset green; skipped targets each print a reason. mc buildon Windows spawnslld-linkand a taught compiler: a[compiler]project builds end to end with.exesuffixes (atests/projconfig in the Windows subset).- Both release assets produced from a tag build, with
.sha256, unpacked and run on the runner. - macOS
make checkgreen;tests/mc/080-twelve-params.mcpasses on every target incl. Linux and both Windows legs;check-surface's ABI assertions hold with the new stack-parameter rule;check-standalonestill proves<mc/host> + <mc/core> + <user_default> == src/mc.mc. The three existing goldens move once (the parameter change touches the compiler); the two Windows goldens are recorded.
Risks #
- Stack parameters (§ 1) are the schedule. It touches both machines and the walker; the
arm64 objects of every existing test must stay byte-identical to
mc0's (no existing function has more than 8 parameters, so nothing in the corpus may move). - A running
.execannot be deleted.drv_compilecallsunlink(out)before every write (src/driver.mc:251);DeleteFileAon a running image fails.bootstrap-windows.shnever writes the compiler that is running (mc2w.exefrommc1w.exe). lld-linkon the x64 runner: the fallback URL differs (x86_64-pc-windows-msvc).- CRLF: § 7.
Decisions (architect, 2026-09-04) #
- Stack parameters, not trampolines.
MAXPARAMS 12insrc/for definitions and calls, on all three machines; the seed keeps 8 as a documented divergence. M20 leavesMAXPARAMSat 8; this is M38's first step and its own commit. - The POSIX shims live in a new bundled
lib/sys_windows_host.mc, not inlib/sys_windows.mc. mmapshim overVirtualAllocwith the same prototype;arena.mcuntouched.host_exe_suffix()joins the host interface (three call sites in the driver).host_environ()returns 0 on Windows; the child inherits.- Paths stay
/-only; the drive-letter gap is documented in the guide. - GNU make on the Windows runners (
choco install make), reusing the Makefilechecksubset. .tar.gzfor the Windows assets too,mc.exeinside.- Runners pinned:
windows-11-armandwindows-2025in bothci.ymlandrelease.yml; the M20 x64 suite leg moves fromwindows-latesttowindows-2025in the same change. chmodis a no-op returning 0..gitattributeswith* -text.scripts/test-windows.sh --arch x86_64is M20's.