Skip to content

Roadmap

Eira edited this page Nov 2, 2026 · 13 revisions

Roadmap

Lamina is a compiler backend for a small, statically typed IR. It lowers that IR to a machine-level IR, optimizes it, and writes native assembly, with no LLVM and no Cranelift underneath. ras assembles the result and weld links it, so the toolchain can build a program end to end on its own.

This page is the plan. It says what works, what does not, and what each milestone has to prove. For how the pieces fit together see Architecture, for what the version numbers and nightly mean see Releases, and for what is already built on Lamina see Ecosystem.

Contents

  1. Status
  2. Milestones — 0.1 · 0.2 · 0.3 · 0.4 · 1.0
  3. Testing
  4. Design decisions
  5. Artery
  6. Non-goals

Status

Component Role
lamina IR, MIR, optimizer, code generation, compiler driver
ras Assembler. The default, with GNU as as the fallback
weld Linker. The default, with GNU ld as the fallback
lamina-c C bindings. Not published. lia_version() returns the crate version
lamina-web Website and documentation

Each component versions independently. The current numbers are the latest tagged releases on those repositories; see Releases.

What works

A typed IR with a text form and an IRBuilder for constructing modules from Rust. Integers in both signednesses at 8, 16, 32 and 64 bits. f32 and f64 with conversions to and from the integers. Pointers, arrays, structs, globals that start from a string or an element list, switch, the memory intrinsics, atomics including atomic_cmpxchg, function addresses and calls through them, and @extern declarations for functions the module does not define. @extern calls into libc on Linux.

A separate machine-level IR keeps target concerns out of the layer frontends see. The optimizer runs from --opt-level 0 to --opt-level 3. Assembly output exists for the targets in the table below. The default toolchain is ras and weld, with GNU as and ld as the fallback. The -g flag writes a line table into the object file. C bindings give explicit ownership of every allocation. --jit runs a program in a default build. The C bindings' JIT extension is nightly.

Vectors are in the surface IR: <N x T> as an instruction's type suffix, with the element-wise arithmetic reusing the scalar opcodes, uniform and per-lane shifts, splat, extractlane, insertlane, shuffle, and load and store with a vector suffix. 128-bit vectors lower on x86_64, AArch64 and WebAssembly in a default build. This is not behind nightly; the auto-vectorizer that finds them for you is.

Thread-local storage works on ELF (local-exec and initial-exec), on Windows x86_64 through the TEB TLS array and _tls_index, and on macOS AArch64 through Mach-O TLV descriptors. Inline assembly lowers on x86_64 (Linux and Windows), AArch64 (Linux and macOS) and s390x. Variadic functions work on System V x86_64, Win64 and AArch64 on Darwin. @section placement works on ELF, COFF and Mach-O. On Mach-O only .text.<name> maps, to __TEXT,__<name>, and the name is limited to 14 characters of [A-Za-z0-9_]. Anything else is refused.

Every property of an architecture is stated once, in one block of one table: pointer width, register width, byte order, stack alignment, argument alignment, interpreter paths, toolchain triples, accepted environments and sub-architecture axis. Adding an architecture is one block and adding a property is one field, and the supported-target list is derived from the same table rather than maintained beside it.

A target is an architecture, an operating system, a sub-architecture and an environment: <arch>_<os>[_<env>], where the environment is gnu, musl, lamina, eabi, eabihf or none. The C library, the ELF interpreter, the C runtime startup objects and the compiler runtime all come from that axis, so s390x_linux declares glibc rather than inheriting it from whichever cross compiler the build machine has. A combination an architecture does not accept is refused by name, with the accepted set in the hint.

lamina is the exception on that list: it names Lamina's own C library, which is not written. It parses on x86_64 and aarch64 and the link refuses it by name, saying there is no Lamina libc, no startup objects and no loader, and naming the environments that do link. It is a declared target, not a feature. It stays that way until the libc is written, which comes after camina.

Lamina ships its own compiler runtime, and it is now the whole of one. builtins/ holds the 128-bit division, remainder and float-conversion routines and the 64-bit forms of the same set, written in Lamina IR, compiled per program for the requested target and linked ahead of -lc. Nothing on any target needs the host libgcc: a 128-bit program on x86_64 and an i64 division on i686 both link against routines the driver built. Where the host does have a libgcc, the GNU link path still names it after the compiled objects, so a symbol neither side covers still resolves. The float-to-integer routines define what libgcc leaves undefined: NaN gives zero and an out-of-range value saturates, which is what the scalar conversion contract already says everywhere else.

What the IR means is written down separately from how it is spelled. IR semantics is the normative page: numbered clauses for values, arithmetic, traps, floating point, memory, atomics, I/O, vectors and the target-dependent facts, each saying what an operation computes and which targets refuse it. A clause marked unverified was read out of the implementation rather than run.

A backend either implements an operation or refuses it by name. Computing something else is the one outcome that is never acceptable, and a run of changes closed the cases where it still happened. rv64i_linux emitted fadd.d on an ISA with no floating-point unit and now refuses, naming the extension. i686 answered −128 for div.i8 -128, -1 and now traps, as every other backend does. A loop that wrote its induction variable twice was widened by the vectorizer as though the stride were one element. atomic_cmpxchg checked its failure ordering on some paths and now checks it on all of them, and an atomic access whose address can be shown to be under-aligned is refused rather than lowered. Cortex-M refuses an access that declares less than the natural alignment of its type.

Several things a module can write are now errors instead of quietly meaning something else. inttoptr's suffix names the integer the pointer is built from, and inttoptr.ptr, inttoptr.f64 and inttoptr.bool no longer parse. A shift by a constant count outside the operand's width is a compile error; a count computed at run time is still masked. @extern on a function that has a body is rejected. A removed opcode names its replacement in assignment form as well as in statement form.

On x86_64, the same input produces byte-identical assembly across runs.

The benchmark programs in benchmarks/ have a runner that compares them against C (through clang), Rust, Go and Zig builds.

What does not work

  • 128-bit globals are rejected.
  • A vector type may not appear in a declaration: not a parameter, a return type, a global, a struct field or a stackalloc. No backend passes one across a call.
  • 256-bit vectors parse and validate and no backend lowers them.
  • The RVV lowering is reachable now — rv64gcv_linux turns V on — but nothing has assembled or run its output.
  • Aggregates passed or returned by value depend on the target. See 0.1.
  • 64-bit integers are not lowered on every 32-bit target. wasm32 rejects i64 parameters and returns. riscv32_unknown rejects 64-bit division and float-to-integer conversion: it is freestanding, and a freestanding target links no compiler runtime, so the routine the operation needs is refused at compile time by name rather than left to the linker. The routine exists; nothing puts it on the link line.
  • The musl environment is declared and not demonstrated. The GNU link path refuses without a musl cross compiler, and the weld path writes its own interpreter, so a _musl build on a glibc host carries the glibc interpreter. No musl binary has been built and run.
  • armv7 returns a 5-to-8-byte composite in registers. The AAPCS says memory.
  • i686 line numbers come from an instruction counter, not the source.
  • -g line tables are dropped when the default weld links the program. -c gnu keeps them.
  • The Windows x86_64 path has one open cause of its old bug: no CRT is requested for Windows links. See 0.1.
  • Thread-local storage is refused on AArch64 Linux and AArch64 Windows.
  • Variadic definitions are still refused on AArch64 Linux, which uses AAPCS64.
  • Inline assembly is refused on every target not named under What works.
  • A struct of up to four floats (an HFA) goes in x0-x7 on AArch64 instead of v0-v7. That is consistent for Lamina-to-Lamina calls and disagrees with C.
  • weld has no per-target library search paths, so --target freebsd still searches Linux paths.

Targets

The driver accepts eighteen architectures in a default build and all eighteen emit code. arm32 was the nineteenth. It is now refused where the target string is parsed, with a hint naming armv7, so it is no longer an advertised target. Three more — arx64, riscv128 and lisa — parse only behind the nightly Cargo feature, are refused by the driver in a default build, and are not available targets. arx64 in particular is an experimental architecture of this project's own with no silicon and no assembler behind it; it is not a chip anyone can target.

The table says what the driver accepts and how far each target gets. Run means the output was executed and checked against an expected result. Assembled means a real assembler accepted every instruction, which catches a malformed operand or relocation and nothing more. Compiled means assembly text was produced and read. Run under emulation means the output was linked against a cross C library, executed under QEMU user-mode and checked against an expected result — a real run of the generated code, but not a run on that machine.

Target Status How far it gets Notes
x86_64_linux Tested Run The reference path, and the only one that goes from .lamina to a running binary without a tool Lamina did not write
x86_64_windows Experimental Run Win64. The testcase suite passes on windows-latest. Variadic definitions, thread-local storage and inline assembly lower. The CRT item in 0.1 is still open
x86_64_freebsd Experimental Compiled ELF. The other BSDs are refused at the parser with a hint naming this one
aarch64_macos Partial Run Apple Silicon, AAPCS64. The testcase suite passes on macos-latest (arm64). Thread-local storage through Mach-O TLV descriptors, variadic definitions and inline assembly lower. Keep production use conservative
aarch64_linux Experimental Assembled Lowers inline assembly and everything except a variadic definition and thread-local storage. weld cannot link it, so the link falls back to GNU ld
aarch64_windows Experimental Compiled Refused at assembly: neither ras nor GNU as writes Windows ARM64 objects
riscv64_unknown Experimental Assembled LP64D. The one cross target weld links by itself, for a program that needs no C library symbol. rv64gcv turns on the V extension and reaches the RVV lowering
riscv32_linux Experimental Assembled 64-bit integers legalized into register pairs. ras writes an ELF32 object for it. Nothing links it: weld does not take riscv32 and no cross ld is installed
riscv32_unknown Experimental Compiled The same backend, and no C library, so it refuses the division helpers rather than emit a call that cannot link. ras writes objects for Linux and the BSDs and not for unknown, so this spelling stops at the assembler
ppc64le_linux Experimental Assembled Little-endian ELFv2, assembled by ras, which has a PowerPC encoder
ppc64_linux Experimental Compiled Big-endian ELFv2, same backend. A separate architecture to ras, which has no encoder for it, so it needs GNU as. ppc64-elfv1 is refused: ELFv1 is a different ABI, not a byte order
s390x_linux Experimental Run under emulation 64-bit z/Architecture, big-endian, and the only cross target whose cross C library is installed here. Atomics, bit intrinsics, rotates, variadic definitions, checked arithmetic, TLS and inline assembly all lower; vectors are refused, because nothing implements the z13 vector facility. No register allocator; every virtual register lives in a frame slot. Needs z196, because the bit intrinsics use popcnt. Its va_list is the 32-byte block the s390x ELF ABI defines, so it crosses into C
mips64_linux, mips64el_linux Experimental Compiled N64, both byte orders, one backend. Scalars, aggregates by value, indirect and variadic calls; atomics, bit intrinsics, rotates, vectors, inline assembly and variadic definitions refused. O32 is not implemented and mips is refused with a hint
loongarch64_linux Experimental Compiled Nothing here assembles LoongArch, so nothing catches a mistake in the text
thumbv6m_*, thumbv7m_* Experimental Compiled Cortex-M, bare-metal ELF, Thumb only. No floating point, no 64-bit integers, no atomics, no vectors. Both refuse an access that declares less than the natural alignment of its type: ARMv6-M faults on one outright, and ARMv7-M faults on one whenever CCR.UNALIGN_TRP is set, which this backend emits no startup code to clear. ARMv6-M also refuses division
armv7_linux Experimental Compiled AAPCS-VFP hard float. Composite return values of 5 to 8 bytes are wrong, see above
i686_linux Experimental Run cdecl with x87. Returns a 64-bit integer in %edx:%eax and reaches the 64-bit division and float-conversion routines through the compiler runtime. Refuses atomics and vectors. Traps div.i8 -128, -1 and the other narrow signed overflows, as every other backend does
wasm32_*, wasm64_* Experimental Compiled WebAssembly text. Assembling it needs wat2wasm
nvptx64_cuda, amdgcn_amdhsa Experimental Compiled GPU text, never linked. A module needs a @kernel function
arm32_* Rejected on purpose — Refused where the target string is parsed. arm32 names no ARM revision, floating-point ABI or profile, so there is no machine to generate code for; the hint names armv7, thumbv6m and thumbv7m
*_artery Planned — The OS name parses behind nightly and the driver accepts no target using it. See Artery target

The driver accepts more identifiers than this table lists. Recognition is not support: parsing, code generation, assembly, and linking are separate stages, and a recognized name promises nothing about the ones after it.

Tested here has a specific meaning. It means the output is compiled, run and checked against an expected result on a machine of that architecture. It is not a judgement about how good the target is. Most cross targets are not run, and the reason is their missing C libraries rather than a missing emulator — QEMU user-mode is installed for all of them. s390x is the one cross target whose cross C library is installed, so it is the one cross target that gets a checked run. The Windows and macOS rows were checked on windows-latest and macos-latest.


Milestones

A milestone is a set of outcomes. Versions give the ordering, not a schedule.

Theme The one thing it has to prove
0.1 Correct on the targets we claim Every target labeled Tested has its output run and checked on a machine of that architecture
0.2 A backend you can add a target to Adding an architecture touches one module and a registration line
0.3 Depth A regression in generated code fails a check instead of going unnoticed
0.4 Freestanding targets A program runs with no OS under it and no libc in it
1.0 Stable IR and ABI The IR and the C ABI can be depended on

Status column below: ○ not started · ◐ in progress · ● done.

0.1 is in progress. Its toolchain default is done and the Linux suite runs. 0.2 and 0.3 have a few items started. 0.4 and 1.0 are not started.

0.1 Correct on the targets we claim

Right now the target table makes claims nothing verifies. This milestone closes that gap and makes the self-built toolchain the default path.

Correctness

Item Detail
◐ Fix Windows output Three causes, below. The first is fixed, and the second has run: the testcase suite passes on windows-latest. The third is open
◐ Valid RV64G from the RISC-V backend Every recorded program assembles under clang --target=riscv64 -march=rv64g, and the argument registers match what clang picks for the same signature. What is left is execution: nothing has run an RV64 binary, so the ABI is checked at the call boundary only. #21
◐ Deterministic output on every backend Fixed on x86_64 by ordering register sets, and checked: three compiles of one program gave identical assembly. The other backends need the same audit, because hash-ordered emission is invisible to a test that only compares program output

The Windows bug (#20) has three causes:

  1. writebyte emits a Linux syscall. Fixed: the Windows arm routes the byte through putchar, and write and read have Windows arms of their own. Every BSD, Redox and later Artery still land on the Linux fallback.
  2. The PE emitter imports everything from kernel32.dll. weld no longer guesses msvcrt.dll. Since weld 0.2.1, an undefined symbol on PE is a link error. The testcase suite passes on windows-latest.
  3. No CRT is requested for Windows links. The Windows arm of build_weld_linker_args in src/mir_codegen/link.rs is still empty, while Linux gets -lc and macOS gets -lSystem. Open.

Language

Item Detail
◐ Aggregates across a function boundary x86_64 passes an integer aggregate of any size and returns one, in registers up to 16 bytes and through memory or the hidden pointer above that, checked against clang in both directions. aarch64 passes by reference above 16 bytes but cannot yet return that size. riscv, ppc64, s390x, mips64, armv7 and Cortex-M classify one. loongarch64, wasm and the gpu backends reject an aggregate parameter by name rather than guess an ABI. Float and mixed aggregates are not classified anywhere
◐ External function declarations @extern on a signature with no body declares a function the module does not define, and calls to it use the platform ABI. The puts example in Ecosystem runs. What is left is the attributes that go with it. #7

This one unblocks more of the rest than anything else here. It lets the I/O builtins stop being per-backend intrinsics with a Linux-shaped fallback, it is what WebAssembly needs before it can do anything observable, and it is how an Artery program will reach the kernel. See where the I/O builtins go.

Toolchain

Item Detail
◐ Move driver work out of weld The driver computes the startup objects, the libraries and the interpreter for its own GNU-style path, from the target rather than from the host, and a cross link that cannot find the target's gcc is an error naming it instead of a binary that runs wrong. On the default weld path weld still supplies CRT objects and still invents an interpreter path, including the hardcoded /lib64/ld-linux-x86-64.so.2. The Lamina driver supplies crt1.o, -lc, --dynamic-linker, and the search paths, derived from the target, the way gcc does before invoking ld. See why
● ras and weld as the default on x86_64 Linux The default in lamina --help. If either is missing, the driver falls back to GNU as and ld with a warning
◐ Split the toolchain flags --assembler and --compiler exist. --linker does not, so the separate choice is not there yet. --compiler keeps the toolchain aliases (gnu, clang, msvc, lamina). A named tool beats an alias, an alias beats detection, and an unknown value is an error rather than a silent fallback. Rename AssemblerBackend::Lld, which actually picks clang's integrated assembler, to Clang

Testing. See Testing for what the tier numbers mean.

Item Detail
● Run the testcase suite on three platforms (T1) Every recorded program compiles, runs and produces the expected output on Linux, Windows and macOS
○ Determinism check (T4) Compile twice, compare bytes

Release and docs

Item Detail
◐ First C bindings release The source layer is done: owned builder, opaque handles, thread-local errors, panic containment, AOT entry points, both headers, nightly JIT, 16 smoke tests. What is missing is publication: archives with checksums, a rebuild of the C examples so exports and headers cannot drift, and the install page. The SDK is built for one target, x86_64 Linux
○ One story everywhere The README sells x86_64, AArch64, RISC-V, and WebAssembly as flat capabilities. The website separates tested from experimental. The website is right. The nightly line is still inconsistent: --jit runs in a default build while the docs call the JIT nightly-only

Header auto-generation stays blocked upstream. cbindgen does not understand #[unsafe(no_mangle)] under the Rust 2024 edition, so include/lamina.h stays hand-maintained, and nothing yet rebuilds the C examples to catch drift between them and the exports.

0.2 A backend you can add a target to

Structure. Adding an architecture should mean writing one module, not editing a dozen match statements.

Item Detail
● One table per architecture Every property an architecture states — pointer width, register width, byte order, stack and argument alignment, output artifact, interpreter paths per environment, toolchain triples, accepted environments, Windows library directory, sub-architecture axis — lives in one block. A block missing a field does not compile, the generated lookup has no wildcard arm, and both the architecture list and the supported-target list come from the same table. The remaining per-target match statements are in the emitters, not in the properties
○ A codegen trait One interface for what a backend can do: assembly output, binary output, file extension, supported options. #14
○ Calling conventions described in MIR Conventions vary enough between targets to belong in the machine IR as data rather than in per-backend code. WebAssembly differs again. #15
○ AArch64 binary emission The assembly path works. A direct binary path to a linked Linux executable does not, since weld cannot link AArch64 Linux yet
○ Promote AArch64 aarch64_linux to tested and aarch64_macos from partial to tested, once their output is run and checked rather than read
◐ ld command-line parity in weld Accepted: -static, -shared, -r, --gc-sections, --rpath, --start-group, --strip-all, --build-id, --Map, -z, and response files. -e is honoured for the entry point and -h is the soname option, as in ld. --target names the target operating system and an unknown value is an error listing the accepted set, rather than a silent default. -static fails when the program needs glibc, because glibc's IFUNC resolvers are not supported. -shared writes an ET_DYN file that nothing has loaded. Missing: -T, --whole-archive, --defsym, -u, --wrap. -T and -static come first, since 0.4 depends on them
● Archive support in weld Members of a .a are pulled in on demand when named with -l or given as a file, including cycles inside --start-group/--end-group. Lookup uses .a files exclusively
◐ Hostile input in weld Symbol and relocation reads in the object readers are bounds-checked, the Mach-O symbol table and the ELF RELA stride are validated against the section they came from, and a reservation derived from a header field cannot be unbounded. A malformed object is a link error rather than a panic
◐ Mach-O objects from ras ras writes x86_64 and arm64 Mach-O objects, and the Lamina driver uses them for x86_64_macos and aarch64_macos. The aarch64_macos ones run on macos-latest. Nothing has run an x86_64_macos one. ELF carries every encoded architecture, COFF/PE is x86_64 only
◐ Architectures ras encodes x86_64, aarch64, riscv32, riscv64 and little-endian ppc64le, with riscv32 writing ELF32 and the rest ELF64. Big-endian ppc64, s390x, mips64, LoongArch64, armv7 and Cortex-M have no encoder and fall back to GNU as
○ Relocation parity in weld Even coverage across ELF, Mach-O, and PE instead of the current ELF-first depth, and a PE path with a real import table
● Support predicates in ras that match the writers The object-support predicate now names what each writer does: ELF for every encoded architecture, Mach-O for x86_64 and aarch64, COFF for x86_64 alone. A combination outside that is refused by name instead of advertised
○ Differential testing (T2, T3) Assemble with ras and with as, link with weld and with ld, compare. The bar is a program that behaves the same way, not one built from identical bytes
○ Cross-compilation smoke tests (T6) Emit assembly for every triple the driver accepts. Unexercised targets are where the panics hide, and the codegen trait makes them cheap to enumerate

0.3 Depth

0.1 is about being correct and 0.2 is about being extensible. This one is about giving someone a reason to pick Lamina over the alternatives.

Item Detail
◐ Register allocation everywhere --regalloc takes incremental, linear or graph. The default follows what a backend implements rather than what it is called: a backend with a global allocation pass takes linear at --opt-level 1 and above, and one without takes incremental whatever the level. x86_64, aarch64, riscv32, riscv64, ppc64le and ppc64 have the pass; the rest do not. Bringing the remaining backends up and measuring what the choices buy is what is left
◐ Debug information -g writes .file and .loc into the assembly, and ras writes a line table into the object. The line table is correct. The default weld link drops it, so the executable has no source lines. -c gnu keeps it. Only line tables exist. No variable or type information. i686 still gets line numbers from an instruction counter
◐ Vectorization and scheduling with results Vectors are in the surface IR and lower on x86_64, AArch64 and WebAssembly in a default build, so a frontend can emit them today. The auto-vectorizer is a separate thing: nightly only, x86_64 only, --opt-level 3 only, and it widens one loop shape that the alias analysis can prove safe. Treat it as unproven. It refuses a loop it cannot show is safe at a list of guards, several of which no test exercises, and one of them was wrong until recently: a loop writing its induction variable a second time was widened as though the stride were one element. It stays experimental until the guards are covered and it shows measured wins
○ Optimization-level equivalence (T5) Every program must produce identical output at --opt-level 0 through --opt-level 3. The cheapest check on the list, and it catches a pass that changes behavior instead of speed
○ Emulated execution (T7) AArch64, RISC-V 64 and PowerPC64 under QEMU as part of the checked suite rather than by hand, and the rest once their cross C libraries are in place
○ Performance tracked per commit (T9) Compile time, generated-code speed, binary size
○ Internal cleanups Transform metadata deduplication, splitting the modules that outgrew their file, and an audit of the sign-loss casts in codegen offset arithmetic. Some of those are probably latent bugs rather than style problems

0.4 Freestanding targets

Compiling for an environment the backend does not already assume. No libc, no C runtime startup objects, no host to fall back on. Freestanding is a declared property now — the none environment — rather than a naming convention. What is left is the link and the code generation it implies. Artery is the driving case.

Item Detail
◐ Hosted and freestanding as a target property Done at the model layer: freestanding is the none environment, it selects no libc, no startup objects and no compiler runtime, and a module that needs a runtime helper is refused at compile time naming the symbol. An operating system that supplies its own runtime declares it, so Darwin, Windows, WASI and the GPU targets take none by default. What is left is the link: the default weld path still supplies an interpreter of its own
○ Soft-float code generation and ABI For targets with the vector units switched off entirely, no SSE or AVX on x86_64 and no NEON on AArch64. Floating point lowers to a support layer and the ABI passes floats in integer registers
○ No red zone A backend option, honored everywhere, forbidding use of memory below the stack pointer. The @no_red_zone annotation was removed, since no backend read it; the x86_64 backend never uses the red zone and the other targets have none. Anything with interrupt handlers needs the real option
○ Strict alignment No unaligned loads or stores, for targets that trap on them
○ No unwinding Under abort-on-panic there are no unwind tables to produce, and none should be required by the emitted code or the link
○ Linker scripts via -T Section placement, load addresses, and the entry symbol from a script instead of built-in defaults. Same -T work as 0.2, and the reason it is not optional

1.0 Stable IR and ABI

A compatibility promise, and one that should not be made early.

Item
○ The IR text form and IRBuilder are stable, with a documented versioning and deprecation policy
○ The C ABI is stable and versioned independently of the crate
○ Every tested target has an end-to-end path through code generation, assembly, and linking with no system toolchain required
○ A correctness suite, differential testing against an established toolchain, and fuzzing that the parser and validator survive (T8)
○ Debugger integration on more than one platform
○ A documented bootstrap story

Testing

This cuts across every milestone, so it lives here rather than inside one.

Where it stands

Unit tests and the testcase suite run on Linux, Windows and macOS: every recorded program is compiled, run and its output diffed against the expected one. Codegen metrics are compared against a committed baseline, so a change in instruction, spill, call, branch or frame counts is reported rather than noticed later.

In 0.2.0 the suite passes all 176 cases on ubuntu-latest, windows-latest and macos-latest. The macOS runner is arm64. Before that, macOS passed 10 of 176 and Windows 171.

What still only runs by hand matters more: the differential fuzzers that compare generated programs against clang, the cross-backend assemble sweep, and the allocator and optimization-level matrices. They are what found most of the miscompilations fixed so far, so they belong in T3 and T5 rather than in a shell history.

The rule

A target may be labeled Tested only if its output is compiled, run and checked against an expected result on a machine of that architecture. No exceptions. Reading assembly catches a malformed instruction; it does not catch a wrong answer.

Tiers, in the order they get added

Each is cheap once the one before it exists, and each catches something the earlier ones cannot.

Tier What it does What it catches Milestone
T0 Unit tests across the workspace Logic errors inside a function Have it
T1 Testcase suite: compile, run, diff the recorded programs Miscompilation, ABI mistakes, bad codegen 0.1
T4 Determinism: compile twice, compare assembly bytes Hash-ordered emission and other irreproducibility 0.1
T2 Toolchain matrix: the same programs through as+ld, clang+lld, ras+weld Divergence between our tools and the ones they replace 0.2
T3 Differential against an equivalent C program Answers that are wrong but self-consistently wrong 0.2
T6 Cross-compilation smoke: emit assembly for every recognized target name Panics and unimplemented arms in targets nobody exercises 0.2
T5 Optimization equivalence: --opt-level 0 through --opt-level 3 produce identical output Passes that change behavior instead of speed 0.3
T7 Emulated execution under QEMU Correctness on targets with no native runner 0.3
T9 Compile time, runtime, and binary size recorded per commit Slow regressions nobody notices 0.3
T8 Fuzz the parser and the IR validator Panics on hostile input 1.0

T5 is the one to add early. It is the cheapest tier here and it catches the most embarrassing class of bug, since a pass that changes a program's output is always wrong and stays invisible until a user reports it.

What stops the cross targets being run

Not the emulator. QEMU user-mode is installed for every architecture here. What is missing is the cross C libraries, so most programs never link far enough to hand QEMU anything, and a freestanding program that does link has no startup code and faults when its entry function returns. s390x is the one architecture whose cross libc is present, and it is the one whose recorded programs are built, run and checked under emulation. A recorded program is skipped there only where the reason is written down against it: inline_asm_rdtsc reads the x86 timestamp counter, which has no z equivalent, and vector_operations needs the z13 vector facility, which the backend does not implement. Inline assembly itself lowers and runs. AArch64, RISC-V 64 and PowerPC64 cannot be linked here at all: the driver stops with an error naming the cross gcc it looked for, because the startup objects come from it.

Growing the suite

The recorded programs cover arithmetic, control flow, calls, memory, composites, and a set of regression cases named after the bugs that produced them. That last group is the pattern to keep: every bug fixed gets a testcase named after it, added in the same change as the fix. The suite then grows out of real defects instead of guesses about what might break.

After that, roughly in order of value: programs mixing floating point with calls, programs that push the register allocator past spilling, deep recursion, large stack frames, structs passed and returned by value across the ABI boundary, and programs built from several objects so linking gets exercised rather than assumed.


Design decisions

Three questions come up often enough to answer here rather than in issue threads.

Does a Lamina program link libc?

On a hosted target, yes. That is what the platform requires, not a compromise. The three platforms differ more than they look.

Linux publishes a stable syscall ABI, so a program with no libc is normal and supported there. Lamina should keep being able to produce one.

macOS treats syscall numbers as unstable and expects everything to go through libSystem. Emitting a numbered service call works against the platform.

Windows has no stable syscall ABI at all. Numbers change between builds, every program reaches the kernel through ntdll, and formatted I/O comes from the C runtime above that. There is no supported alternative, so calling the CRT on Windows is the only option rather than a preference.

The rule is per-target. Use the platform's supported entry mechanism, and never assume the Linux one is a sensible default for anyone else.

ras replaces as, weld replaces ld

That framing answers a lot of open questions. A drop-in is judged against the thing it replaces. Where the original has a flag, a behavior, or a diagnostic, the replacement matches it instead of inventing something nicer. Where the original declines to do something, the replacement declines too.

ras is already built this way. Its options mirror GAS closely (-o, -I, -L, -g/--gdwarf-, -K, -J, -Z, -q, -Q, -k, -w, -mbss-plt), so it drops into a build that expects as.

weld is further off, in two ways.

Several ld options are still missing. weld accepts -o, -e, -L, -l, --dynamic-linker, -nostdlib, -nodefaultlibs, -static, -shared, --gc-sections, --rpath, --start-group, --strip-all, --build-id, --Map, -r, -z, --target and response files. It does not have -T, --whole-archive, --defsym, -u, or --wrap. -T is how a linker script is supplied, and -static is how you link without a dynamic loader, so this gap and the Artery target are the same gap.

It does the driver's job. ld and lld never link libc on their own. The driver does. gcc and clang add crt1.o, crti.o, -lc, the dynamic-linker path, and the search paths before ld runs, and bare ld hello.o -o hello leaving printf unresolved is correct behavior, not a bug. On Windows, link.exe and lld-link additionally need an import library to know which DLL a symbol comes from.

weld currently supplies startup objects and an interpreter path itself, hardcoding /lib64/ld-linux-x86-64.so.2 regardless of architecture. Lamina's link step leans on that, passing -lc and passing no CRT objects on purpose, with a test asserting their absence. For a tool whose job is to be a drop-in, that is backwards. The correction:

  • weld links what it is given. No implicit CRT objects, no implicit default libraries, no invented interpreter path. --dynamic-linker is honored when passed and absent when not.
  • Lamina's driver takes over that work, deriving it from the target, because that is the layer that knows the target.
  • Nothing changes for the user. lamina app.lamina -c weld still works with no system toolchain installed.

A freestanding link then stops being a special case. It is a target whose driver adds nothing, handed to a linker that never added anything on its own.

Where do the I/O builtins go?

writebyte, write and read are intrinsics that each backend expands inline, through a per-OS match with a fallback arm. That design is the direct cause of the Windows bug in 0.1, and it cannot express a target like Artery at all.

The destination is an IR with no built-in I/O. A program declares what it calls, the target decides what that resolves to, and the backend emits an ordinary call. That one change unblocks the Windows fix, WebAssembly, and Artery together.


Artery

Artery is a from-scratch Rust kernel built alongside Lamina, and the reason 0.4 exists. Supporting it means compiling for an environment with no libc, no startup objects, and no host underneath.

The constraints it imposes, the binary format, how system calls are reached, and the work items to get there are on their own page: Artery target.

Non-goals

  • Replacing LLVM for every workload. Lamina is a backend with a small surface, and it stays that way on purpose.
  • Exposing Rust struct layouts or MIR internals through the C ABI.
  • Making the JIT a plugin mechanism. It exists for language implementations such as dynamic languages and bytecode VMs, not as an extension ABI.
  • Forcing one toolchain on downstream projects. A frontend should be free to pick Lamina, LLVM, or something else.

Where work is tracked

Issues live on SkuldNorniern/lamina. ras, weld, and lamina-web under Project-Lamina carry their own issues for component-specific work.