feat: cross-compile Jerboa binaries for foreign OS/arch

ober

d3258f20ac86baf3a17309e2444ff274987ce793

diff --git a/.gitignore b/.gitignore
index 4473154..dfff9c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,8 +20,14 @@
 /jerboa-bin-main.c
 /jerboa-bin.wp.so
 
+# `make binary-cross` outputs (per-target ELF/Mach-O + intermediates)
+/jerboa-bin-*
+/jerboa-bin-*-main.c
+/jerboa-bin-*.wp.so
+
 # Repo-local Chez build/install artifacts
 /.chez/
+/.chez-cross-*/
 /build/
 
 # Rust build artifacts
diff --git a/Makefile b/Makefile
index 62034e1..d88c7f9 100644
--- a/Makefile
+++ b/Makefile
@@ -24,7 +24,7 @@ CHEZ_EXT_LIBDIRS = $(CHEZ_EXT_DIR)/chez-https/src:$(CHEZ_EXT_DIR)/chez-ssl/src:$
 # Shared object paths for legacy FFI-based chez-* libraries
 CHEZ_EXT_LDPATH = $(CHEZ_EXT_DIR)/chez-ssl:$(CHEZ_EXT_DIR)/chez-zlib:$(CHEZ_EXT_DIR)/chez-pcre2:$(CHEZ_EXT_DIR)/chez-leveldb:$(CHEZ_EXT_DIR)/chez-epoll:$(CHEZ_EXT_DIR)/chez-inotify:$(CHEZ_EXT_DIR)/chez-crypto:$(CHEZ_EXT_DIR)/chez-sqlite:$(CHEZ_EXT_DIR)/chez-postgresql
 
-.PHONY: help chez build binary test test-reader test-core test-runtime test-stdlib test-ffi test-modules test-expanded test-features test-wrappers test-phase4a test-phase4b test-phase4c test-phase4d test-phase4e test-phase4f test-phase5 test-phase5e test-phase6 test-phase7 test-phase8 test-functional test-repl test-security test-native test-gaps native clean-native audit-native clean fuzz fuzz-smoke fuzz-deep fuzz-reader-fuzz fuzz-json-fuzz fuzz-http2-fuzz fuzz-websocket-fuzz fuzz-dns-fuzz fuzz-pregexp-fuzz fuzz-csv-fuzz fuzz-base64-fuzz fuzz-hex-fuzz fuzz-uri-fuzz fuzz-format-fuzz fuzz-router-fuzz fuzz-sandbox-fuzz test-rawstring test-regex test-rx test-peg test-regex-all check-docs check-docs-strict docker-build docker-push
+.PHONY: help chez chez-cross build binary binary-cross test test-reader test-core test-runtime test-stdlib test-ffi test-modules test-expanded test-features test-wrappers test-phase4a test-phase4b test-phase4c test-phase4d test-phase4e test-phase4f test-phase5 test-phase5e test-phase6 test-phase7 test-phase8 test-functional test-repl test-security test-native test-gaps native clean-native audit-native clean fuzz fuzz-smoke fuzz-deep fuzz-reader-fuzz fuzz-json-fuzz fuzz-http2-fuzz fuzz-websocket-fuzz fuzz-dns-fuzz fuzz-pregexp-fuzz fuzz-csv-fuzz fuzz-base64-fuzz fuzz-hex-fuzz fuzz-uri-fuzz fuzz-format-fuzz fuzz-router-fuzz fuzz-sandbox-fuzz test-rawstring test-regex test-rx test-peg test-regex-all check-docs check-docs-strict docker-build docker-push
 
 help:
 	@echo "Usage: make <target>"
@@ -33,6 +33,9 @@ help:
 	@echo "  chez             Build and install vendored Chez Scheme locally"
 	@echo "  build            Compile all Jerboa libraries"
 	@echo "  binary           Build a self-contained jerboa-bin binary (FreeBSD/Linux/macOS)"
+	@echo "  chez-cross       Build cross Chez (target libkernel.a + boot files + xpatch)"
+	@echo "                   Args: CHEZ_TARGET_MACHINE=<mt> CROSS_CC=<cross-cc>"
+	@echo "  binary-cross     Build a jerboa-bin for a foreign target (same args as chez-cross)"
 	@echo "  native           Build Rust native library"
 	@echo "  clean            Remove compiled .so and .wpo artifacts"
 	@echo "  clean-native     Remove Rust build artifacts"
@@ -117,6 +120,95 @@ BINARY_OUTPUT ?= jerboa-bin
 binary: chez build
 	SCHEME=$(SCHEME) JERBOA_CHEZ_PREFIX=$(CHEZ_PREFIX) support/build-binary.sh $(BINARY_ENTRY) $(BINARY_OUTPUT)
 
+# ── Cross-compilation ────────────────────────────────────────────────────────
+# Override these to cross-compile:
+#   CHEZ_TARGET_MACHINE — Chez machine type (e.g. ta6osx, ta6le, tarm64le, ta6fb)
+#   CROSS_CC            — cross C compiler (e.g. "clang -arch x86_64",
+#                                              x86_64-linux-musl-gcc)
+#   CROSS_CC_FOR_BUILD  — host-runnable cc used to build zuo (defaults to cc)
+#   CROSS_AR / CROSS_RANLIB — target archiver/ranlib. Default: derived from CROSS_CC
+#       prefix when CROSS_CC matches "<triple>-gcc" (e.g. x86_64-linux-musl-gcc
+#       implies x86_64-linux-musl-ar / x86_64-linux-musl-ranlib).
+#   CROSS_STRIP        — target strip (same derivation as AR; "true" to skip).
+CHEZ_TARGET_MACHINE ?=
+CROSS_CC ?=
+CROSS_CC_FOR_BUILD ?= cc
+
+# Derive AR/RANLIB/STRIP from CROSS_CC when it has a "<triple>-gcc" shape.
+# Caller can still override explicitly.
+CROSS_TOOL_PREFIX := $(patsubst %-gcc,%-,$(firstword $(CROSS_CC)))
+CROSS_AR     ?= $(if $(filter %-,$(CROSS_TOOL_PREFIX)),$(CROSS_TOOL_PREFIX)ar,)
+CROSS_RANLIB ?= $(if $(filter %-,$(CROSS_TOOL_PREFIX)),$(CROSS_TOOL_PREFIX)ranlib,)
+CROSS_STRIP  ?= $(if $(filter %-,$(CROSS_TOOL_PREFIX)),$(CROSS_TOOL_PREFIX)strip,true)
+
+CHEZ_CROSS_PREFIX = $(JERBOA_HOME)/.chez-cross-$(CHEZ_TARGET_MACHINE)
+CHEZ_CROSS_BUILD_DIR = $(JERBOA_HOME)/build/chez-cross-$(CHEZ_TARGET_MACHINE)
+CHEZ_XPATCH = $(CHEZ_BUILD_DIR)/xc-$(CHEZ_TARGET_MACHINE)/s/xpatch
+
+# Extra flags for the cross Chez configure. Callers can add e.g. `--static`
+# for fully-static musl builds. We default to disabling curses/x11/iconv
+# because (a) self-contained binaries don't need a REPL or X11, and (b) the
+# musl-cross sysroot on macOS typically lacks ncurses/x11 headers.
+CROSS_CHEZ_CONFIGURE_FLAGS ?= --threads --disable-x11 --disable-curses --disable-iconv
+
+CHEZ_CROSS_INSTALL_FLAGS = \
+	--installprefix=$(CHEZ_CROSS_PREFIX) \
+	--installbin=$(CHEZ_CROSS_PREFIX)/bin \
+	--installlib=$(CHEZ_CROSS_PREFIX)/lib \
+	--installman=$(CHEZ_CROSS_PREFIX)/share/man \
+	--installdoc=$(CHEZ_CROSS_PREFIX)/share/doc \
+	--as-is
+
+# Build target boot files + xpatch (in host build dir) + target libkernel.a
+# (in a separate cross build dir). Requires the host Chez to already be built.
+.PHONY: chez-cross
+chez-cross: $(SCHEME)
+	@test -n "$(CHEZ_TARGET_MACHINE)" \
+		|| { echo "ERROR: set CHEZ_TARGET_MACHINE=<machine-type> (e.g. ta6osx, ta6le)" >&2; exit 1; }
+	@test -n "$(CROSS_CC)" \
+		|| { echo "ERROR: set CROSS_CC=<cross-cc> (e.g. \"clang -arch x86_64\")" >&2; exit 1; }
+	@echo "==> [chez-cross] bootquick XM=$(CHEZ_TARGET_MACHINE) (produces target boot files + xpatch)"
+	$(MAKE) -C $(CHEZ_BUILD_DIR) bootquick XM=$(CHEZ_TARGET_MACHINE)
+	@test -f $(CHEZ_XPATCH) || { echo "ERROR: xpatch not produced at $(CHEZ_XPATCH)" >&2; exit 1; }
+	@echo "==> [chez-cross] configure --cross --force -m=$(CHEZ_TARGET_MACHINE)"
+	@mkdir -p $(CHEZ_CROSS_BUILD_DIR)
+	cd $(CHEZ_CROSS_BUILD_DIR) && $(JERBOA_HOME)/vendor/ChezScheme/configure \
+		--cross --force -m=$(CHEZ_TARGET_MACHINE) \
+		$(CROSS_CHEZ_CONFIGURE_FLAGS) \
+		CC="$(CROSS_CC)" CC_FOR_BUILD="$(CROSS_CC_FOR_BUILD)" \
+		$(if $(CROSS_AR),AR="$(CROSS_AR)") \
+		$(if $(CROSS_RANLIB),RANLIB="$(CROSS_RANLIB)") \
+		$(CHEZ_CROSS_INSTALL_FLAGS)
+	@echo "==> [chez-cross] stage target boot files + generated headers into cross dir"
+	@mkdir -p $(CHEZ_CROSS_BUILD_DIR)/boot/$(CHEZ_TARGET_MACHINE)
+	@mkdir -p $(CHEZ_CROSS_BUILD_DIR)/$(CHEZ_TARGET_MACHINE)/boot/$(CHEZ_TARGET_MACHINE)
+	cp $(CHEZ_BUILD_DIR)/boot/$(CHEZ_TARGET_MACHINE)/* \
+	   $(CHEZ_CROSS_BUILD_DIR)/boot/$(CHEZ_TARGET_MACHINE)/
+	cp $(CHEZ_BUILD_DIR)/boot/$(CHEZ_TARGET_MACHINE)/* \
+	   $(CHEZ_CROSS_BUILD_DIR)/$(CHEZ_TARGET_MACHINE)/boot/$(CHEZ_TARGET_MACHINE)/
+	@echo "==> [chez-cross] build target libkernel.a"
+	@# CHOST tells the bundled zlib's configure to use the cross-prefix toolchain
+	@# instead of running host-OS detection (which on macOS picks libtool, not ar).
+	CHOST="$(CROSS_TOOL_PREFIX:%-=%)" \
+	AR="$(CROSS_AR)" \
+	RANLIB="$(CROSS_RANLIB)" \
+	$(MAKE) -C $(CHEZ_CROSS_BUILD_DIR)
+	$(MAKE) -C $(CHEZ_CROSS_BUILD_DIR) install
+	@echo "==> [chez-cross] done. xpatch=$(CHEZ_XPATCH)  prefix=$(CHEZ_CROSS_PREFIX)"
+
+# Cross-compiled binary. Drives support/build-binary.sh with TARGET_* env.
+.PHONY: binary-cross
+binary-cross: chez build chez-cross
+	@test -n "$(CHEZ_TARGET_MACHINE)" \
+		|| { echo "ERROR: set CHEZ_TARGET_MACHINE=<machine-type>" >&2; exit 1; }
+	SCHEME=$(SCHEME) \
+	JERBOA_CHEZ_PREFIX=$(CHEZ_PREFIX) \
+	JERBOA_CROSS_PREFIX=$(CHEZ_CROSS_PREFIX) \
+	JERBOA_XPATCH=$(CHEZ_XPATCH) \
+	TARGET_MACHINE=$(CHEZ_TARGET_MACHINE) \
+	CC="$(CROSS_CC)" \
+	support/build-binary.sh $(BINARY_ENTRY) $(BINARY_OUTPUT)-$(CHEZ_TARGET_MACHINE)
+
 test: test-reader test-core test-runtime test-stdlib test-ffi test-modules test-expanded test-regex-all
 
 check-docs:
diff --git a/docs/cross-compile.md b/docs/cross-compile.md
new file mode 100644
index 0000000..a8698ce
--- /dev/null
+++ b/docs/cross-compile.md
@@ -0,0 +1,230 @@
+# Cross-Compiling Jerboa Binaries
+
+Jerboa's `make binary` target produces a single self-contained ELF/Mach-O
+that bundles Chez Scheme's runtime, boot files, and a WPO-compiled program
+(see [single-binary.md](single-binary.md) for the bundling details).
+Cross-compilation extends that pipeline so the **host** Chez emits boot
+files, FASL, and a linked binary for a **target** machine type.
+
+The two new Makefile entry points are:
+
+```sh
+make chez-cross   CHEZ_TARGET_MACHINE=<mt> CROSS_CC=<cross-cc>
+make binary-cross CHEZ_TARGET_MACHINE=<mt> CROSS_CC=<cross-cc>
+```
+
+`binary-cross` depends on `chez-cross`, so most users invoke only
+`binary-cross`.
+
+---
+
+## Supported targets
+
+| `CHEZ_TARGET_MACHINE` | OS         | Arch     | Typical `CROSS_CC`              |
+|-----------------------|------------|----------|----------------------------------|
+| `ta6le`               | Linux      | x86_64   | `x86_64-linux-musl-gcc`          |
+| `tarm64le`            | Linux      | arm64    | `aarch64-linux-musl-gcc`         |
+| `ta6osx`              | macOS      | x86_64   | `clang -arch x86_64`             |
+| `tarm64osx`           | macOS      | arm64    | `clang -arch arm64`              |
+| `ta6fb`               | FreeBSD    | x86_64   | `x86_64-freebsd-gcc` (or sysroot)|
+| `tarm64fb`            | FreeBSD    | arm64    | `aarch64-freebsd-gcc`            |
+
+Any machine type Chez itself supports (`vendor/ChezScheme/configure
+--help` lists them) will work, provided you supply a matching C
+cross-compiler and the target's libc / sysroot.
+
+On macOS arm64, the easiest Linux toolchain is `musl-cross` from Homebrew:
+
+```sh
+brew install musl-cross --with-aarch64
+# provides:
+#   /opt/homebrew/bin/x86_64-linux-musl-gcc
+#   /opt/homebrew/bin/aarch64-linux-musl-gcc
+```
+
+---
+
+## End-to-end example
+
+Cross-compile a Linux x86_64 static binary from macOS arm64:
+
+```sh
+make binary-cross \
+    CHEZ_TARGET_MACHINE=ta6le \
+    CROSS_CC=x86_64-linux-musl-gcc
+```
+
+Output: `./jerboa-bin-ta6le` — a statically-linked ELF that runs on any
+Linux x86_64 kernel with no shared-library dependencies.
+
+```
+$ file jerboa-bin-ta6le
+jerboa-bin-ta6le: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
+                  statically linked, with debug_info, not stripped
+```
+
+---
+
+## How it works
+
+Chez 10 supports two compilation modes for cross targets:
+
+1. `make bootquick XM=<target>` in a host Chez build dir produces a
+   `xc-<target>/s/xpatch` file.
+2. Loading that xpatch into a running host Chez patches its compiler so
+   that `compile-file` / `compile-program` / `compile-whole-program` emit
+   FASL for `<target>` instead of for the host.
+
+`support/build-boot.ss` (the script that drives WPO) honors a
+`JERBOA_XPATCH` env var. When set, it loads the xpatch file before
+compiling, switching the host Chez into cross-emit mode. This is faster
+and simpler than building a second Chez binary.
+
+The pipeline therefore has three phases:
+
+### Phase 1 — `chez-cross`
+
+1. **Build target boot files** in the host Chez build directory:
+
+   ```sh
+   make -C build/chez bootquick XM=<target>
+   ```
+
+   Produces `build/chez/boot/<target>/{petite.boot,scheme.boot,
+   libkernel.a,scheme.h,...}` and the all-important
+   `build/chez/xc-<target>/s/xpatch`.
+
+2. **Configure a cross build dir** with `--cross --force` so Chez's
+   Makefile-cross only builds the C kernel for the target (no Scheme
+   bootstrap — the boot files came from step 1):
+
+   ```sh
+   cd build/chez-cross-<target>
+   ../../vendor/ChezScheme/configure \
+       --cross --force -m=<target> \
+       --threads --disable-x11 --disable-curses --disable-iconv \
+       CC=<cross-cc> CC_FOR_BUILD=cc \
+       AR=<cross-ar> RANLIB=<cross-ranlib>
+   ```
+
+   The `--disable-*` flags matter: the musl-cross sysroots on macOS lack
+   `curses.h` / `iconv.h`. Override with
+   `CROSS_CHEZ_CONFIGURE_FLAGS=...` if you have a sysroot that includes
+   them.
+
+3. **Stage the target boot files** into the cross build dir at *both*
+   `boot/<target>/` and `<target>/boot/<target>/` (zuo expects them in
+   the top-level input dir AND inside the workarea).
+
+4. **`make && make install`** — this builds the target `libkernel.a`,
+   `libz.a`, and `liblz4.a` using the cross toolchain. The zlib subbuild
+   honors `CHOST` to pick the cross toolchain instead of running its
+   own host detection (which on macOS hardcodes Apple's `libtool` as
+   `AR`).
+
+### Phase 2 — Cross WPO
+
+`support/build-binary.sh` exports `JERBOA_XPATCH=<path/to/xpatch>` and
+invokes the host scheme on `build-boot.ss`. `build-boot.ss`:
+
+```scheme
+(let ([orig-libdirs (library-directories)]
+      [xpatch       (getenv "JERBOA_XPATCH")])
+  (when (and xpatch (not (string=? xpatch "")))
+    (load xpatch)                       ; switch compiler to target mode
+    (library-directories orig-libdirs))) ; xpatch resets this; restore
+```
+
+> ⚠️  `load` of the xpatch resets `library-directories` to
+> `(("." . "."))`. Capture and restore around the load or imports break
+> with "library (jerboa prelude) not found".
+
+After the xpatch is loaded, `compile-program` + `compile-whole-program`
+emit a single `.wp.so` containing target FASL.
+
+### Phase 3 — Cross link
+
+`build-binary.sh` then embeds `petite.boot`, `scheme.boot`, and the
+`.wp.so` as C byte arrays in `<output>-main.c`, and links with the cross
+toolchain:
+
+```sh
+<cross-cc> -I<csv-dir> -O2 \
+    -o jerboa-bin-<target> \
+    jerboa-bin-<target>-main.c \
+    <csv-dir>/libkernel.a \
+    <csv-dir>/liblz4.a <csv-dir>/libz.a \
+    -lm -ldl -lpthread -static    # Linux musl
+```
+
+For cross builds, `build-binary.sh` automatically omits curses/iconv
+from the link line (matching the `--disable-*` flags it configures
+Chez with). Override the link libs with `OS_LIBS_OVERRIDE=...` if your
+target needs different system libraries.
+
+---
+
+## Tunables
+
+All Makefile variables are overridable on the command line:
+
+| Variable | Default | Purpose |
+|----------|---------|---------|
+| `CHEZ_TARGET_MACHINE` | *(required)* | Chez machine type, e.g. `ta6le` |
+| `CROSS_CC` | *(required)* | C compiler for the target |
+| `CROSS_CC_FOR_BUILD` | `cc` | Host-runnable cc, used to build zuo |
+| `CROSS_AR` | derived from `CROSS_CC` prefix | Target archiver |
+| `CROSS_RANLIB` | derived | Target ranlib |
+| `CROSS_STRIP` | derived | Target strip |
+| `CROSS_CHEZ_CONFIGURE_FLAGS` | `--threads --disable-x11 --disable-curses --disable-iconv` | Extra flags for the cross Chez configure |
+| `BINARY_ENTRY` | `support/binary-entry.ss` | Entry script to bundle |
+| `BINARY_OUTPUT` | `jerboa-bin` | Output prefix; final name is `<prefix>-<target>` |
+
+For a fully-static musl Linux build, the default already passes
+`-static` via `build-binary.sh`. Override with
+`OS_LIBS_OVERRIDE="-lm -ldl -lpthread"` to get a dynamically-linked
+binary.
+
+---
+
+## Validation
+
+Cross-built binaries can be smoke-tested with Docker (or any Linux
+runtime):
+
+```sh
+docker run --rm --platform linux/amd64 -v "$PWD":/w alpine:3 \
+    /w/jerboa-bin-ta6le -e '(display (machine-type)) (newline)'
+# → ta6le
+```
+
+For macOS x86_64 targets cross-built from arm64, run via Rosetta:
+
+```sh
+arch -x86_64 ./jerboa-bin-ta6osx -e '(machine-type)'
+# → ta6osx
+```
+
+---
+
+## Limitations
+
+1. **Rust native lib** (`jerboa-native-rs`) is not auto-cross-compiled
+   yet. For features that require the Rust lib, build it separately:
+
+   ```sh
+   cd jerboa-native-rs
+   cargo build --release --target=x86_64-unknown-linux-musl --no-default-features
+   ```
+
+2. **Curses-dependent features disabled in cross builds** — the REPL's
+   line editor (expeditor) is omitted. Cross-built binaries run scripts,
+   not interactive REPLs, unless you supply a sysroot with `curses.h`
+   and override `CROSS_CHEZ_CONFIGURE_FLAGS`.
+
+3. **No xc-pb shortcut** — we always go through a full `bootquick` for
+   the target. This takes ~10–15 seconds per fresh target.
+
+4. **Windows targets untested.** The pipeline should work for `ta6nt`
+   etc. but `build-binary.sh` assumes POSIX `od`, `sed`, and `mkstemp`
+   in main.c.
diff --git a/support/build-binary.sh b/support/build-binary.sh
index 3160204..d12df73 100755
--- a/support/build-binary.sh
+++ b/support/build-binary.sh
@@ -24,41 +24,101 @@ SCHEME="${SCHEME:-$JERBOA_HOME/.chez/bin/scheme}"
 JERBOA_CHEZ_PREFIX="${JERBOA_CHEZ_PREFIX:-$JERBOA_HOME/.chez}"
 LIBDIRS="${JERBOA_HOME}/lib"
 
+# ── Cross-compilation parameters (all optional; unset = native build) ────────
+# TARGET_MACHINE       Chez machine type to emit (e.g. ta6osx, ta6le).
+# JERBOA_CROSS_PREFIX  Install prefix of the cross-built Chez (target libkernel.a + boot files).
+# JERBOA_XPATCH        Path to xc-<target>/s/xpatch (makes host Scheme emit target FASL).
+TARGET_MACHINE="${TARGET_MACHINE:-}"
+JERBOA_CROSS_PREFIX="${JERBOA_CROSS_PREFIX:-}"
+JERBOA_XPATCH="${JERBOA_XPATCH:-}"
+
+if [ -n "$TARGET_MACHINE" ]; then
+    CROSS_BUILD=yes
+    [ -n "$JERBOA_CROSS_PREFIX" ] || { echo "ERROR: TARGET_MACHINE set but JERBOA_CROSS_PREFIX is not" >&2; exit 1; }
+    [ -n "$JERBOA_XPATCH" ]       || { echo "ERROR: TARGET_MACHINE set but JERBOA_XPATCH is not"       >&2; exit 1; }
+    [ -f "$JERBOA_XPATCH" ]       || { echo "ERROR: xpatch not found at $JERBOA_XPATCH"                >&2; exit 1; }
+else
+    CROSS_BUILD=no
+fi
+
+# ── Derive target OS from machine type (for picking OS_LIBS) ─────────────────
+# Machine type suffix encodes the OS: le=Linux, osx=macOS, fb=FreeBSD, nt=Windows.
+machine_type_os() {
+    case "$1" in
+        *osx)   echo Darwin ;;
+        *nt)    echo Windows ;;
+        *fb)    echo FreeBSD ;;
+        *ob)    echo OpenBSD ;;
+        *nb)    echo NetBSD ;;
+        *s2)    echo Solaris ;;
+        *le|*)  echo Linux ;;
+    esac
+}
+
 # ── Detect OS and pick compiler + base link flags ────────────────────────────
-OS=$(uname -s)
-case "$OS" in
-    FreeBSD)
-        CC_DEFAULT=cc
-        OS_LIBS="-lm -lpthread -lncurses -L/usr/local/lib -liconv"
-        ;;
-    Darwin)
-        CC_DEFAULT=cc
-        OS_LIBS="-lm -lpthread -lncurses -liconv"
-        ;;
-    Linux)
-        CC_DEFAULT=gcc
-        # Try both -lncurses and -ltinfo; -ltinfo is split out on some distros.
-        if [ -f /usr/lib/x86_64-linux-gnu/libtinfo.so ] || [ -f /usr/lib64/libtinfo.so ]; then
-            OS_LIBS="-lm -ldl -lpthread -ltinfo"
-        else
-            OS_LIBS="-lm -ldl -lpthread -lncurses"
-        fi
-        ;;
-    *)
-        CC_DEFAULT=cc
-        OS_LIBS="-lm -lpthread -lncurses"
-        ;;
-esac
+HOST_OS=$(uname -s)
+if [ "$CROSS_BUILD" = yes ]; then
+    TARGET_OS=$(machine_type_os "$TARGET_MACHINE")
+else
+    TARGET_OS="$HOST_OS"
+fi
+
+# Cross builds use `make chez-cross` which configures Chez with
+# --disable-curses --disable-x11 --disable-iconv by default, so libkernel.a
+# won't reference those libs. Native builds still link them because the
+# host Chez was built --as-is (typically with curses/iconv enabled).
+if [ "$CROSS_BUILD" = yes ]; then
+    case "$TARGET_OS" in
+        FreeBSD) CC_DEFAULT=cc;  OS_LIBS="-lm -lpthread" ;;
+        Darwin)  CC_DEFAULT=cc;  OS_LIBS="-lm -lpthread" ;;
+        Linux)   CC_DEFAULT=gcc; OS_LIBS="-lm -ldl -lpthread -static" ;;
+        *)       CC_DEFAULT=cc;  OS_LIBS="-lm -lpthread" ;;
+    esac
+else
+    case "$TARGET_OS" in
+        FreeBSD)
+            CC_DEFAULT=cc
+            OS_LIBS="-lm -lpthread -lncurses -L/usr/local/lib -liconv"
+            ;;
+        Darwin)
+            CC_DEFAULT=cc
+            OS_LIBS="-lm -lpthread -lncurses -liconv"
+            ;;
+        Linux)
+            CC_DEFAULT=gcc
+            # Try both -lncurses and -ltinfo; -ltinfo is split out on some distros.
+            if [ -f /usr/lib/x86_64-linux-gnu/libtinfo.so ] || [ -f /usr/lib64/libtinfo.so ]; then
+                OS_LIBS="-lm -ldl -lpthread -ltinfo"
+            else
+                OS_LIBS="-lm -ldl -lpthread -lncurses"
+            fi
+            ;;
+        *)
+            CC_DEFAULT=cc
+            OS_LIBS="-lm -lpthread -lncurses"
+            ;;
+    esac
+fi
 CC="${CC:-$CC_DEFAULT}"
 
-# ── Find the Chez install dir (libkernel.a + scheme.h + boot files) ──────────
-MACHINE_TYPE=$("$SCHEME" -q <<'EOF'
+# Allow caller to override link libs entirely (musl-static, etc.).
+OS_LIBS="${OS_LIBS_OVERRIDE:-$OS_LIBS}"
+
+# ── Determine target machine type ───────────────────────────────────────────
+if [ "$CROSS_BUILD" = yes ]; then
+    MACHINE_TYPE="$TARGET_MACHINE"
+    CSV_SEARCH_PREFIX="$JERBOA_CROSS_PREFIX/lib"
+else
+    MACHINE_TYPE=$("$SCHEME" -q <<'EOF'
 (display (machine-type)) (exit)
 EOF
 )
+    CSV_SEARCH_PREFIX="$JERBOA_CHEZ_PREFIX/lib"
+fi
 
+# ── Find the Chez install dir (libkernel.a + scheme.h + boot files) ──────────
 CSV_DIR=""
-for prefix in "$JERBOA_CHEZ_PREFIX/lib" /usr/local/lib /usr/lib /usr/lib64 /opt/homebrew/lib /opt/local/lib; do
+for prefix in "$CSV_SEARCH_PREFIX" /usr/local/lib /usr/lib /usr/lib64 /opt/homebrew/lib /opt/local/lib; do
     for d in "$prefix"/csv*/"$MACHINE_TYPE"; do
         if [ -f "$d/libkernel.a" ] && [ -f "$d/scheme.h" ] && [ -f "$d/petite.boot" ]; then
             CSV_DIR="$d"
@@ -70,14 +130,19 @@ done
 if [ -z "$CSV_DIR" ]; then
     echo "ERROR: cannot find Chez install for machine-type '$MACHINE_TYPE'." >&2
     echo "       Searched csv*/$MACHINE_TYPE under:" >&2
-    echo "         $JERBOA_CHEZ_PREFIX/lib /usr/local/lib /usr/lib /usr/lib64 /opt/homebrew/lib /opt/local/lib" >&2
+    echo "         $CSV_SEARCH_PREFIX /usr/local/lib /usr/lib /usr/lib64 /opt/homebrew/lib /opt/local/lib" >&2
     exit 1
 fi
 
 echo "=== Jerboa binary build: $SCRIPT -> $OUTPUT ==="
-echo "    OS:    $OS"
-echo "    CC:    $CC"
-echo "    Chez:  $CSV_DIR"
+if [ "$CROSS_BUILD" = yes ]; then
+    echo "    Mode:   CROSS ($HOST_OS -> $TARGET_OS, $MACHINE_TYPE)"
+    echo "    xpatch: $JERBOA_XPATCH"
+else
+    echo "    Mode:   native ($HOST_OS, $MACHINE_TYPE)"
+fi
+echo "    CC:     $CC"
+echo "    Chez:   $CSV_DIR"
 echo ""
 
 # ── Step 1: WPO-compile entry script -> program.so ───────────────────────────
@@ -86,7 +151,7 @@ OBJ_DIR=$(mktemp -d "/tmp/jerboa-bin-obj.XXXXXX")
 trap 'rm -rf "$OBJ_DIR" "$WPO_SO" petite_boot.h scheme_boot.h program_boot.h "${OUTPUT}-main.c"' EXIT
 
 echo "==> [1/4] WPO compile"
-"$SCHEME" --libdirs "$LIBDIRS" \
+JERBOA_XPATCH="$JERBOA_XPATCH" "$SCHEME" --libdirs "$LIBDIRS" \
     --script "$JERBOA_HOME/support/build-boot.ss" "$SCRIPT" "$WPO_SO" "$OBJ_DIR"
 echo ""
 
diff --git a/support/build-boot.ss b/support/build-boot.ss
index 3413e8a..da4e511 100644
--- a/support/build-boot.ss
+++ b/support/build-boot.ss
@@ -29,6 +29,26 @@
         [output-so  (list-ref args 1)]
         [obj-dir    (and (>= (length args) 3) (list-ref args 2))])
 
+    ;; Cross-compilation: if JERBOA_XPATCH is set, load the xpatch file so
+    ;; that compile-program / compile-whole-program emit FASL for the target
+    ;; machine instead of the host. See ChezScheme/BUILDING §"CROSS COMPILING
+    ;; SCHEME PROGRAMS".
+    ;;
+    ;; Loading xpatch swaps in a cross compiler and resets parameters like
+    ;; `library-directories` to defaults — so we must capture --libdirs before
+    ;; the load and re-apply after.
+    (let ([orig-libdirs (library-directories)]
+          [xpatch       (getenv "JERBOA_XPATCH")])
+      (when (and xpatch (not (string=? xpatch "")))
+        (unless (file-exists? xpatch)
+          (display (format "ERROR: JERBOA_XPATCH=~a does not exist\n" xpatch)
+                   (current-error-port))
+          (exit 1))
+        (display (format "  loading xpatch ~a (cross-compile mode)\n" xpatch)
+                 (current-error-port))
+        (load xpatch)
+        (library-directories orig-libdirs)))
+
     ;; When a separate object directory is requested, redirect compiled library
     ;; output there while keeping source lookup in the original lib directories.
     ;; This lets us compile against a read-only source tree (e.g. Docker :ro mount).