= Native Multi-Target Architecture — Ports, Adapters, and Target Toolkits
:author: Graeme Geldenhuys
:revdate: 2026-06-24
:toc: left
:toclevels: 3
:source-highlighter: rouge
:icons: font
:sectanchors:
:sectnums:

== Purpose

Blaise's native backend must grow from one target (`linux-x86_64`) to many
(`freebsd-x86_64` next, then Windows/macOS, then arm64).  This document
defines the *structural* approach for that growth so that target divergence
is expressed through object selection at run time, never through `{$IFDEF}`
directives or `include`-file substitution.

The explicit non-goal is the FPC model, where the target is frozen at compile
time and platform code is woven through every unit with conditional
compilation.  That model is hard to read, hard to test (you can only test the
target you built for), and couples every unit to every platform.  Blaise
already rejects it: `runtime/src/main/pascal/rtl.platform.pas` states "No
`{$IFDEF}` directives appear anywhere in this unit or its concrete
implementations."  This document generalises that principle across the whole
native toolchain.

== What already exists

Blaise has *already* adopted the hexagonal (ports-and-adapters) pattern in two
places.  The work in this document finishes and unifies that start; it does
not introduce a foreign idea.

[cols="2,2,2,3",options="header"]
|===
| Layer | Port (abstract) | Adapter (concrete) | Source

| Runtime OS surface
| `TRtlPlatform`
| `TRtlPlatformPosix`
| `runtime/.../rtl.platform.pas` + `rtl.platform.posix.pas`

| Code generation
| `TNativeBackend`
| `TX86_64Backend`
| `compiler/.../blaise.codegen.native.backend.pas` + `...native.x86_64.pas`

| Link target facts
| `TLinkTarget`
| `LinuxX86_64Target()`
| `compiler/.../blaise.linker.elf.pas`
|===

`TNativeBackend` already applies *Template Method* deliberately: its header
records that the ARC field-kind walk and the record-return classifier
(`ClassifyRecordReturn`) are target-independent and live in the base, while
"only the leaf emission (which register holds the base, which mnemonic
loads/stores it) is CPU-specific," with a plan to "lift those walkers here as
template methods whose leaf steps are abstract per-CPU primitives."  That is
exactly the right instinct; this document names it and extends it.

== What is leaky today

Two gaps make the current seams insufficient for a second target:

. *`TRtlPlatformPosix` hides a Linux dependency.* It collapses Linux and
  FreeBSD into one adapter, but its `TStatBuf` / `TTm` record layouts and its
  `O_CREAT` / `O_TRUNC` / `WNOHANG` / `CLOCK_REALTIME` / `S_IFDIR` constants
  are hard-coded *Linux* values.  A FreeBSD build reusing this adapter would
  read garbage at the wrong struct offsets.  This is the `include`-file
  problem wearing a class's clothing.

. *There is no port for the kernel leaf.* Today the runtime reaches the
  kernel through `external name 'open'` libc bindings — the "syscall leaf" is
  implicit in libc.  Strategy B (direct syscalls, no libc — see
  `freebsd-x86_64-backend-design.adoc`) needs that leaf to become an explicit,
  swappable port.

== The architecture

=== Ports (Hexagonal / Dependency-Inversion)

A *port* is an abstract base class plus a global instance pointer selected at
startup.  Callers depend on the port, never on a concrete target.

[cols="2,4",options="header"]
|===
| Port | Responsibility

| `TRtlPlatform` (exists)
| High-level OS operations (file/dir/process/time/console).  Unchanged.

| Kernel leaf (NOT a runtime port — see below)
| The *syscall + startup leaf*: `open/read/write/close/mmap/exit/…` and the
  `_start` entry stub.  This is a *link-time symbol swap*, not a runtime
  virtual port: each target's RTL archive resolves the same `external name`
  symbols differently.  See <<kernel-leaf>> for the rationale and the design
  that was tried and rejected.

| `TPlatformLayout` (new)
| The target's *struct layouts and OS constants*: `struct stat` field
  offsets, `struct tm` layout, `O_*` flag bits, signal numbers, `SEEK_*`,
  `CLOCK_*`.  This is where the Linux constants leaking into
  `TRtlPlatformPosix` go to live properly, per target.  Implemented in Step 0b.
|===

[#kernel-leaf]
[NOTE]
.The kernel leaf is a link-time swap, not a runtime port
====
An earlier revision of this design specified a runtime `TKernelABI` virtual
class (≈30 abstract methods) with a `GKernel` global, through which every
`TRtlPlatformPosix` kernel call was routed; the Linux adapter forwarded each
method to the existing libc binding, and a FreeBSD adapter would later issue
raw syscalls.

*That approach was implemented and reverted (2026-06-25).*  Introducing the
class and the third managed RTL global into `rtl.platform`/`rtl.platform.posix`
— the most layout-sensitive units in the self-hosting RTL — destabilised the
bootstrap.  Binaries built against the modified RTL segfaulted at startup with
a corrupted/overflowed stack that no debugger (gdb or pdr) could unwind.  The
failure was bootstrap-stage-dependent and non-converging: the release binary
built a *working* hello-world from the modified RTL source, but the
release-built stage-1 emitted a *crashing* one, and stage-2 was worse.  The
precise cause was not isolated (candidates: a `TimeNow` method-name collision
with `TRtlPlatform.TimeNow` in the vtable, the compiler baking layout for a new
managed global, or the large abstract vtable), but the verdict is independent
of the cause: *the abstraction carried real self-hosting risk for zero present
benefit* — the Linux adapter was pure libc delegation, so nothing functional
changed until a FreeBSD syscall adapter existed.

The replacement design is a *link-time symbol swap*, the pattern Blaise already
uses for `blaise_setjmp_x86_64.s` and `blaise_atomic_x86_64.s`:

* The RTL keeps its `external name 'open'` / `'read'` / `'write'` / … bindings
  exactly as today.
* On Linux (and any libc-linked build), those symbols resolve to libc.
* For FreeBSD Strategy B, the FreeBSD RTL archive instead links a small
  syscall-stub object that *exports the same symbol names* (`open`, `read`,
  …) implemented as raw `syscall` instructions with the FreeBSD numbers and
  carry-flag error translation, plus a `_start`.
* The kernel leaf therefore varies by *which object the per-target archive
  composes* — selected at link time by the toolkit/RTL-archive choice — with
  *no new runtime virtual dispatch, no new global, and no change to the
  layout-sensitive RTL units*.

Why this is better here: the kernel mechanism is genuinely a build-time
property of a target (a binary is libc-linked *or* freestanding; it never
switches at run time), so a link-time swap models it exactly, while a runtime
port adds vtable/global surface to the RTL for a choice that is never made at
run time.  `TPlatformLayout` stays a runtime object because its values
(struct offsets, flag bits) are read by shared method bodies and genuinely
differ per target instance; the kernel leaf does not need that — it needs a
different *symbol implementation*, which the linker already selects.

`TPlatformLayout` and the kernel leaf still vary on *different axes* (a future
`freebsd-arm64` may reuse FreeBSD syscall numbers but need different struct
packing), so they remain separate concerns; they are simply realised by
*different mechanisms* — a runtime object for layout, a link-time symbol set
for the kernel leaf.
====

Compiler-side ports (already present, kept as ports):

* `TNativeBackend` — instruction selection / ABI lowering / asm printing.
* `TLinkTarget` + the linker's container writer — ELF today (PE/Mach-O later).

=== Which runtime units change, and which do not

The runtime binds libc / syscalls across *eight* units today
(`rtl.platform.posix.pas`, `blaise_arc.pas`, `blaise_exc.pas`,
`blaise_mem.pas`, `blaise_thread.pas`, `blaise_weak.pas`, `blaise_float.pas`,
`blaise_str.pas`, plus `streams.pas` in stdlib).  It is tempting to conclude
that all of them must be "made hexagonal."  They must not.  Refactoring each
unit into its own platform abstraction would produce eight mini-hexagons and
*recreate* the scattered-target-code problem this architecture exists to
avoid.

The governing distinction is *not* "does this unit call libc" but *"does this
call vary by target"*:

[cols="3,2,3",options="header"]
|===
| Bound symbol(s) | Varies by OS? | Action

| `open/read/write/close/lseek/fstat/stat/mkdir/unlink/rename/getcwd/chdir/
  getenv/exit/nanosleep/pipe/dup2/fork/execvp/waitpid`
| *Yes* — different syscall numbers; FreeBSD reports errors via the carry
  flag, not a negative return.
| Link-time symbol swap: the FreeBSD archive links a syscall-stub object
  exporting these names; the RTL's `external name` bindings are unchanged
  (see <<kernel-leaf>>).

| `mmap/munmap/mremap` (`blaise_mem.pas`)
| *Yes* — and FreeBSD has no `mremap`.
| Link-time symbol swap (memory leaf, with a grow-path fallback for the
  missing `mremap`).

| `pthread_create/join/mutex_*` (`blaise_thread.pas`, and the mutexes used by
  `blaise_exc.pas`/`blaise_weak.pas`)
| *Yes* — no pthreads without libc; FreeBSD uses `thr_new`/`_umtx_op`.
| Link-time symbol swap (threads leaf — the hardest, done last).

| `localtime_r/gmtime_r/timegm` (`rtl.platform.posix.pas`)
| *Yes* — libc-dependent, no syscall equivalent.
| Replace with a *target-independent* pure-Pascal calendar/timezone unit
  (no port needed — same code everywhere).

| `memcpy/memset/memcmp`
| *No* — pure computation, never touches the kernel.
| Provide ONE freestanding implementation (CPU-keyed `.s`/Pascal primitive,
  like the existing atomic stubs).  *Not* a `TKernelABI` method — this is a
  "no libc is present" concern, not a "target differs" concern.

| `_AtomicAddInt32`/`_AtomicSubInt32` (`blaise_atomic_x86_64.s`),
  `_blaise_longjmp` (`blaise_setjmp_x86_64.s`)
| *No* — same x86_64 instruction sequence on every OS.
| Leave as-is (CPU-keyed assembly, already correctly isolated).  For FreeBSD,
  only *verify* the `jmp_buf`/stack layout used by setjmp/longjmp.

| `_IntToStr`/`_DoubleToStr`/string helpers (`blaise_str.pas`,
  `blaise_float.pas`)
| *No* — pure computation over Blaise string headers.
| Leave as-is.
|===

So the refactor is *narrow and link-time*, not unit-by-unit:

. *No RTL unit changes shape for the kernel leaf* — `rtl.platform.posix.pas`
  keeps its `external name 'open'` bindings verbatim.  The FreeBSD difference is
  *which object resolves those symbols* (libc vs a raw-syscall stub), chosen
  when the per-target RTL archive is composed — not a code change in the unit.
. *Units keep all their logic* — `blaise_mem`, `blaise_thread`, `blaise_exc`,
  `blaise_weak` are untouched; their `mmap`/`pthread_*` `external name`
  bindings are satisfied by the target's chosen kernel-stub object.  The units
  are *not* re-architected.
. *Most units are left alone* — pure-computation and CPU-keyed-asm units
  (`blaise_str`, `blaise_float`, the atomic/setjmp stubs) need nothing.
  `localtime_r`/etc. become one target-independent Pascal unit.

The principle: *the kernel-varying leaves are satisfied by one per-target
symbol set chosen at link time; everything that does not vary by target stays
where it is.*  That single stub object is the one place "what does FreeBSD do
differently at the syscall level" lives.  Routing these through a runtime
virtual port instead was tried and reverted (<<kernel-leaf>>): it added
vtable/global surface to the layout-sensitive RTL for a choice that is never
made at run time.  Forcing target-invariant code (memcpy, string formatting,
atomics) through any indirection would likewise be abstraction for its own sake
— the over-engineering this design rejects.

The migration sequence below exploits this: Step 0c introduces the kernel leaf
with a Linux adapter that *still delegates to the existing libc bindings*, so
every caller is repointed at the port with *zero behaviour change* (verified
against the current Linux suite) before a single syscall is rewritten.  No unit
is touched twice.

=== Abstract Factory — the Target Toolkit

The single most important new type.  A `TTargetToolkit` takes a `TTargetDesc`
and produces the *consistent family* of adapters for that target:

[source,pascal]
----
type
  TTargetToolkit = class
  public
    function Name: string; virtual; abstract;               { "linux-x86_64" }
    function MakeBackend: TNativeBackend; virtual; abstract;
    function MakeLinkTarget: TLinkTarget; virtual; abstract;
  end;
----

The compiler-side toolkit (Step 0a, as built) makes the backend and the link
target.  It does *not* have a `MakeKernelABI` — the kernel leaf is a link-time
symbol set in the per-target RTL archive, not a runtime object the compiler
constructs (<<kernel-leaf>>).  `TPlatformLayout` is also not made here: it is a
runtime RTL object selected at the produced binary's startup, in that binary's
own RTL archive, not in the compiler.  (The method is named `Name`, not
`TargetName`, to avoid colliding with the unit-level `TargetName` free
function.)

Why Abstract Factory and not free functions: the produced objects must be
*coherent as a set*.  A FreeBSD backend paired with a Linux link target is a
silent bug.  A factory guarantees the family is internally consistent — this is
the textbook
Abstract Factory motivation (families of related products that must be used
together).

Concrete factories: `TLinuxX86_64Toolkit`, `TFreeBSDX86_64Toolkit`.

=== Registry — name → toolkit

A `TTargetRegistry` maps a canonical name (`"freebsd-x86_64"`) to its
`TTargetToolkit`.  Adding a target is *one registration call plus the new
adapter files* — no existing `case` statement is edited, satisfying the
Open/Closed Principle.

[source,pascal]
----
TargetRegistry.Register('linux-x86_64',   TLinuxX86_64Toolkit.Create);
TargetRegistry.Register('freebsd-x86_64', TFreeBSDX86_64Toolkit.Create);

{ --target resolution, and the test harness, both go through this: }
Toolkit := TargetRegistry.Resolve(ParseTargetName(Opts.Target));
----

This replaces the current `TargetHasNativeBackend` / `LinuxX86_64Target()`
hard-wiring with a lookup that the CLI *and* the test runner share.

=== Pattern summary

[cols="2,3,3",options="header"]
|===
| GoF pattern | Applied to | Why it earns its place

| Strategy
| `TNativeBackend`, `TRtlPlatform`, `TPlatformLayout`
| Interchangeable target behaviour chosen at run time.  Already in use
  (`TProfilerStrategy`, `TNativeBackend`).  Note the kernel leaf is *not* a
  Strategy object — it is a link-time symbol swap (<<kernel-leaf>>).

| Abstract Factory
| `TTargetToolkit`
| Produces a *consistent family* of target adapters; prevents
  mismatched-target bugs.

| Template Method
| `TNativeBackend` base (ARC walk, record classifier)
| Shared algorithm, target-specific leaves.  Already started in the backend
  base class; lift the ARC walkers here as planned.

| Bridge
| Linker symbol/reloc core × container writer (ELF/PE/Mach-O)
| Decouples the OS-agnostic relocation core from the container format so the
  `(container × CPU × OS)` axes vary independently instead of multiplying into
  an `N×M×K` subclass explosion.  The linker header already anticipates "a
  sibling writer behind the same TLinker symbol/relocation core."

| Registry / Service Locator
| `TTargetRegistry`
| Resolve a toolkit by name without a central dispatch every new target must
  edit.
|===

== Runtime end-state: one binary, embedded RTL source, no external archives

Today the runtime is a separate PasBuild module whose Makefile compiles the RTL
units and `ar`-archives them into `blaise_rtl.a`, which `make install` copies
beside the compiler; the internal linker then pulls members from that archive
(`AddArchive`).  The archive is a *cache/convenience*, not a necessity — the
compiler already compiles `.pas` units to objects in-process (`AssembleToObject`),
links them in-process (`TLinker.AddOwnedObject`), and resolves units from source
via `TUnitLoader`.

The chosen end-state (gated on finishing the `.s`→inline-asm migration, after
which the RTL is pure Pascal): *`blaise` embeds the RTL Pascal source once and
compiles it per target on demand*.

* The single `blaise` binary carries the RTL source as embedded data.  The RTL
  is overwhelmingly target-*invariant* (ARC, strings, the memory-manager logic,
  exceptions); only the leaves vary (`TPlatformLayout` values, the kernel-stub
  `_start`/syscall unit, the `TLinkTarget`).
* For `--target X`, the compiler compiles that embedded RTL source *for X* —
  codegen already takes a `TTargetDesc`; the toolkit/registry already selects
  the per-target `TPlatformLayout` adapter, kernel-stub unit, and link target —
  and links the resulting objects in-process.
* Result: a single `blaise` binary, with *nothing* beside it, cross-compiles to
  any registered target.  There is no `blaise_rtl.a` on disk, no `ar`, no
  `make install`, and no per-target `.a` files: the per-target RTL is *produced
  on demand* from one embedded source plus a small per-target adapter set.  This
  is "multiple target runtimes in one binary" realised as *one embedded source +
  N adapter sets*, not N copies of a runtime.

This also collapses the bootstrap to a single `pasbuild compile -m
blaise-compiler` (the `runtime` module folds into the compiler; `stdlib` stays
separate as it is opt-in, not always-linked).

*Performance note.* Compiling the RTL source on every program build is the cost
to evaluate first.  If it proves noticeably slower than the archive model, an
in-memory / `--unit-cache`-backed object cache (compile the RTL once per process
or persist the objects) recovers the speed without reintroducing an on-disk
archive.  We adopt the simple compile-per-target model first and measure before
adding the cache.

== Two selection moments (a subtlety, not a contradiction)

The same ports-and-adapters pattern applies in two binaries with *different*
selection timing.  Being explicit about this avoids accidentally linking every
target's runtime into every produced binary.

[cols="2,4",options="header"]
|===
| Binary | Selection moment & rule

| The *compiler* (`blaise`)
| Holds *all registered* compiler-side toolkits at once (`TNativeBackend`,
  `TLinkTarget`, container writers), so one `blaise` can cross-compile to any
  target.  Selection is per-invocation: `--target` (default `HostTarget()`)
  resolves one toolkit from the registry.

| A *produced* binary (e.g. a FreeBSD app)
| Must contain *only its own* target's RTL: the `TRtlPlatform`/`TPlatformLayout`
  objects and the kernel-leaf (`_start`/syscalls) for that target.  In the
  end-state (above) the compiler produces those objects on demand by compiling
  the embedded RTL source for the target with that target's adapter set, then
  links them in-process — so the produced binary contains exactly one target's
  RTL with no run-time multi-target dispatch and no bloat.  (Transitional
  mechanism today: a pre-built `blaise_rtl.a` linked via `AddArchive`; the
  end-state replaces it with in-process per-target compilation.)
|===

So: the compiler chooses a target *to emit for*; the produced binary chooses a
platform *to run on*.  Both use the registry/factory; the produced binary's RTL
is fixed by which target it was compiled for — there is no run-time
multi-target dispatch inside an application binary, and therefore no bloat.

== Testing — the payoff of run-time selection

Because a toolkit is resolved from a `TTargetDesc` rather than frozen by
`{$IFDEF}`, the test runner gains two modes the FPC model cannot offer:

. *Native self-target.*  On a FreeBSD host, `HostTarget()` resolves the
  FreeBSD toolkit, so running the compiler's own test suite there exercises
  the FreeBSD kernel-stub object, `TPlatformLayout`, and `TNativeBackend`
  natively — no rebuild, no special flag.

. *Cross-target static coverage.*  On any host, the e2e harness can loop over
  *every registered toolkit*, compile a probe program for each, and assert the
  emitted object/executable is correctly shaped for that target (ELF
  `EI_OSABI`, `e_type`, entry symbol, absence/presence of `PT_INTERP`,
  relocation kinds).  This gives Linux CI real coverage of the FreeBSD
  backend's *codegen and linking* even though the FreeBSD binary cannot
  execute on the Linux host.  Behavioural (run-the-binary) coverage still
  needs an emulated/native FreeBSD lane, but the structural regressions are
  caught everywhere.

This is a strict improvement over conditional compilation, where the only
testable target is the one the test binary was built for.

== Migration sequence (structural)

This is the *structural* refactor; the FreeBSD-specific content (syscall
numbers, struct layouts, `_start` shape) lives in
`freebsd-x86_64-backend-design.adoc`.

. *(DONE — Step 0a)* Introduce `TTargetToolkit` + `TTargetRegistry`; register
  the existing Linux target through them.  Re-express `--target` resolution via
  the registry.  *No behaviour change* — full suite + all fixpoints verified.
. *(DONE — Step 0b)* Extract `TPlatformLayout` from `TRtlPlatformPosix`: move the
  `struct stat` layout (now an opaque buffer + offset accessors) and the
  `O_*`/`S_*`/`CLOCK_*`/`WNOHANG`/`SEEK_*` constants behind the port; the Linux
  adapter (`rtl.platform.layout.linux`) supplies today's values.  Verified.
. *(REVISED — kernel leaf)* The kernel leaf is a *link-time symbol swap*, NOT a
  runtime port.  A runtime `TKernelABI` virtual port was attempted here and
  reverted because it destabilised self-hosting for zero present benefit
  (<<kernel-leaf>>).  No refactor is needed in this step: the RTL keeps its
  `external name` bindings, and the FreeBSD syscall-stub object is introduced
  when the FreeBSD RTL archive is built (FreeBSD design doc, Step 4).
. *(DEFERRED)* Lift the ARC field-kind walkers and record classifier into
  `TNativeBackend` as template methods with abstract per-CPU leaves.  This is
  *arm64 groundwork* — FreeBSD reuses the x86_64 backend unchanged, so it does
  not block the FreeBSD target and is deferred until a second CPU lands.
. Add the `TFreeBSDX86_64Toolkit` adapters + FreeBSD kernel-stub/layout (per the
  FreeBSD design doc).  Adding the target touches *no* existing target's code —
  new files plus one registry line.

The completed steps are behaviour-preserving refactors verified against the
existing Linux suite and all four fixpoints; the FreeBSD step is the actual
FreeBSD work.  Sequencing the refactors first means the FreeBSD adapter slots
into clean seams rather than forcing them open mid-implementation.

=== RTL unification (separate track, gated on the `.s` migration)

Independent of the per-target work above, the runtime is being unified into the
compiler so bootstrap is a single command and there are no external archives
(see "Runtime end-state" above).  Ordered sub-steps:

. *(IN PROGRESS)* Migrate the hand-written `runtime/src/main/asm/*_x86_64.s`
  bodies to inline-assembler Pascal units (`asm … end`, `nostackframe`), one
  file per commit, expanding the internal assembler as needed.  Done:
  `blaise_atomic`, `blaise_setjmp`.  Remaining: `blaise_start`, `blaise_utf8`
  (the latter needs the SSE2/AVX2 instruction set).  When complete the RTL is
  pure Pascal and the Makefile's `$(CC) -c` asm rule and `$(ASM_DIR)` are gone.
. Compile the RTL units in-process into in-memory objects and link them via the
  in-process `TLinker`, dropping `ar`, `blaise_rtl.a`, and `make install`.
. Embed the RTL Pascal source in the `blaise` binary and compile it per
  `--target` on demand (selecting that target's adapter set), so one binary
  cross-compiles to any registered target with nothing beside it.  Fold the
  `runtime` PasBuild module into the compiler; bootstrap becomes a single
  `pasbuild compile -m blaise-compiler`.  Measure build time; add an in-memory /
  `--unit-cache` RTL object cache only if the recompile cost proves noticeable.

== Summary

Hexagonal architecture is not just viable in Blaise — it is already the chosen
idiom for the runtime platform layer and the codegen backend, explicitly
`{$IFDEF}`-free.  The path to many targets is to *complete* that idiom, using
the right mechanism for each axis of divergence:

* the struct/constant layout becomes a runtime port (`TPlatformLayout`) —
  its per-target values are read by shared method bodies;
* the kernel leaf becomes a *link-time symbol swap* — a per-target syscall/
  startup object resolving the RTL's `external name` bindings (a runtime
  `TKernelABI` port was tried and reverted, <<kernel-leaf>>);
* each target's compiler-side adapter family (backend + link target) is
  composed by an Abstract Factory (`TTargetToolkit`) and resolved by name
  through a Registry;
* the linker's container writer stays behind a Bridge and the backend reuses
  the Template-Method base.

Target divergence then lives in *which object is instantiated or which archive
is linked*, decided once — the compiler picks a target to emit for, a produced
binary picks a platform to run on — with no `{$IFDEF}` and no scattered
per-target code.  The test runner can select adapters at run time to test any
target, natively or structurally.
