Restore cross-compilation: binary=native, linux/freebsd=cross-only

ober

86e57796bcd036e44d3777ac91066f28149237a3

diff --git a/Makefile b/Makefile
index 2cbdfd1..69c2558 100644
--- a/Makefile
+++ b/Makefile
@@ -46,7 +46,7 @@ else
 JCODE_OS_LIBS := -lm -ldl -lpthread -luuid -lncurses -lstdc++
 endif
 
-.PHONY: all help build gen run test test-providers clean repl binary install tui-shim run-tui native-rs sqlite-bundled linux linux-amd64 linux-arm64 jcode-linux-amd64 jcode-linux-arm64 jcode-linux-native jcode-linux-arm64-native freebsd freebsd-amd64 jcode-freebsd-amd64 jcode-freebsd-native purge-stale sqlite-shim sqlite-lib android android-clean vendor-deps vendor-clean
+.PHONY: all help build gen run test test-providers clean repl binary install tui-shim run-tui native-rs sqlite-bundled linux linux-check linux-amd64 linux-arm64 jcode-linux-amd64 jcode-linux-arm64 test-linux test-linux-amd64 freebsd freebsd-amd64 jcode-freebsd-amd64 purge-stale sqlite-shim sqlite-lib android android-clean vendor-deps vendor-clean
 
 all: help
 
@@ -63,10 +63,12 @@ help:
 	@echo "  sqlite-bundled Build bundled SQLite archive via cargo"
 	@echo "  binary       Build native binary via 'jerbuild build' (.jerbuild)"
 	@echo "  install      Install binary to ~/.local/bin"
-	@echo "  linux        Build Linux x86_64 binary with jerbuild (run on Linux)"
-	@echo "  linux-amd64  Build native Linux x86_64 jcode-linux-amd64 with jerbuild"
-	@echo "  linux-arm64  Build native Linux arm64 jcode-linux-arm64 with jerbuild"
-	@echo "  freebsd      Build FreeBSD amd64 binary with jerbuild (run on FreeBSD)"
+	@echo "  binary       Build jcode for THIS host's os/arch (jerbuild, native)"
+	@echo "  linux        Alias for linux-amd64"
+	@echo "  linux-amd64  CROSS-compile static jcode-linux-amd64 (musl) from this host"
+	@echo "  linux-arm64  CROSS-compile static jcode-linux-arm64 (musl) from this host"
+	@echo "  freebsd      CROSS-compile dynamic jcode-freebsd-amd64 from this host"
+	@echo "  linux-check  Fast drift check of the cross-build (no link, ~10s)"
 	@echo "  clean        Remove compiled artifacts and binaries"
 	@echo "  purge-stale  Remove stale .so/.wpo files"
 	@echo "  sqlite-shim  Rebuild jerboa_sqlite_shim.so"
@@ -264,72 +266,104 @@ install: binary
 	fi
 	@echo "Installed to ~/.local/bin/jcode"
 
-# `make linux` builds on a Linux amd64 host through installed jerbuild.
+# ─── Cross-compile machinery (host → Linux/FreeBSD) ─────────────────────────
+# `make binary` builds for the LOCAL os/arch via jerbuild. The targets below
+# CROSS-compile from this host to another platform using the host Chez
+# ($(SCHEME)) + a cross-built Chez kernel in $(JERBOA_HOME) + a musl/clang
+# cross toolchain. These vars are used ONLY by the cross targets.
+JERBOA_HOME      ?= $(HOME)/mine/jerboa
+SCHEME           ?= $(JERBOA_HOME)/.chez/bin/scheme
+FREEBSD_AMD64_CC ?= $(JERBOA_HOME)/support/cross-cc-freebsd-amd64
+XC_LIBDIRS        = ./lib:vendor/jerboa-sqlite/lib:vendor/jerboa-websearch/src:$(JH)/lib:$(JERBOA_HOME)/lib
+
+# `make linux` defaults to the linux amd64 cross-build.
 linux: linux-amd64
 
-# ─── Linux amd64 binary ─────────────────────────────────────────────────────
-# Native Linux build through installed jerbuild. Requires cc and cargo.
-
-linux-amd64:
-	@case "$$(uname -s)-$$(uname -m)" in \
-	  Linux-x86_64|Linux-amd64) ;; \
-	  *) echo "ERROR: linux-amd64 is a native jerbuild target; run it on Linux amd64" >&2; exit 1 ;; \
-	esac
-	@$(MAKE) jcode-linux-native
-
-linux-arm64:
-	@case "$$(uname -s)-$$(uname -m)" in \
-	  Linux-aarch64|Linux-arm64) ;; \
-	  *) echo "ERROR: linux-arm64 is a native jerbuild target; run it on Linux arm64/aarch64" >&2; exit 1 ;; \
-	esac
-	@$(MAKE) jcode-linux-arm64-native
-
-jcode-linux-native: gen $(NATIVE_DIR)
-	@case "$$(uname -s)-$$(uname -m)" in \
-	  Linux-x86_64|Linux-amd64) ;; \
-	  *) echo "ERROR: .jerbuild.linux-amd64 must run on Linux amd64" >&2; exit 1 ;; \
-	esac
-	@echo "=== Building native Linux amd64 jcode with jerbuild ==="
-	$(JERBUILD) build --config .jerbuild.linux-amd64
+# ── Fast local drift check for the cross-build path ─────────────────────────
+# Runs compile-program on main-binary.ss with the same flags the cross build
+# uses, but skips the C compile and link. Catches drift (new imports, removed
+# stdlib exports) in ~10s before burning minutes in the full cross build.
+linux-check: gen
+	@echo "=== Running linux-check (fast cross-build drift check) ==="
+	DYLD_LIBRARY_PATH=$(LDPATH) LD_LIBRARY_PATH=$(LDPATH) \
+	$(SCHEME) -q --libdirs "$(XC_LIBDIRS)" --script linux-check.ss
+
+# ─── Cross-compile: host → Linux x86_64 / arm64 (musl static) ───────────────
+# Produces a fully static jcode-linux-<arch>. No Docker. Requires:
+#   - <arch>-linux-musl-gcc on PATH   (brew install FiloSottile/musl-cross/musl-cross)
+#   - $(JERBOA_HOME)/.chez-cross-<m>  (cd $(JERBOA_HOME) && make chez-cross[-tarm64le])
+#   - rustup target add <arch>-unknown-linux-musl
+linux-amd64: gen
+	@command -v x86_64-linux-musl-gcc >/dev/null 2>&1 || { \
+	  echo "ERROR: x86_64-linux-musl-gcc not found on PATH."; \
+	  echo "Install with: brew install FiloSottile/musl-cross/musl-cross"; \
+	  exit 1; }
+	@test -d $(JERBOA_HOME)/.chez-cross-ta6le || { \
+	  echo "ERROR: cross-built Chez not found at $(JERBOA_HOME)/.chez-cross-ta6le"; \
+	  echo "Build it with: cd $(JERBOA_HOME) && make chez-cross"; \
+	  exit 1; }
+	@command -v cargo >/dev/null 2>&1 || { \
+	  echo "ERROR: cargo not found on PATH. Install rustup from rustup.rs"; \
+	  exit 1; }
+	JERBOA_HOME=$(JERBOA_HOME) TARGET_ARCH=amd64 $(SCHEME) -q --libdirs "$(XC_LIBDIRS)" --script build-jcode-cross.ss
 	@ls -lh jcode-linux-amd64
 	@file jcode-linux-amd64
 
-jcode-linux-amd64: linux-amd64
-
-jcode-linux-arm64-native: gen $(NATIVE_DIR)
-	@case "$$(uname -s)-$$(uname -m)" in \
-	  Linux-aarch64|Linux-arm64) ;; \
-	  *) echo "ERROR: .jerbuild.linux-arm64 must run on Linux arm64/aarch64" >&2; exit 1 ;; \
-	esac
-	@echo "=== Building native Linux arm64 jcode with jerbuild ==="
-	$(JERBUILD) build --config .jerbuild.linux-arm64
+linux-arm64: gen
+	@command -v aarch64-linux-musl-gcc >/dev/null 2>&1 || { \
+	  echo "ERROR: aarch64-linux-musl-gcc not found on PATH."; \
+	  echo "Install with: brew install FiloSottile/musl-cross/musl-cross"; \
+	  exit 1; }
+	@test -d $(JERBOA_HOME)/.chez-cross-tarm64le || { \
+	  echo "ERROR: cross-built Chez not found at $(JERBOA_HOME)/.chez-cross-tarm64le"; \
+	  echo "Build it with: cd $(JERBOA_HOME) && make chez-cross-tarm64le"; \
+	  exit 1; }
+	@command -v cargo >/dev/null 2>&1 || { \
+	  echo "ERROR: cargo not found on PATH. Install rustup from rustup.rs"; \
+	  exit 1; }
+	JERBOA_HOME=$(JERBOA_HOME) TARGET_ARCH=arm64 $(SCHEME) -q --libdirs "$(XC_LIBDIRS)" --script build-jcode-cross.ss
 	@ls -lh jcode-linux-arm64
 	@file jcode-linux-arm64
 
+# Friendly aliases.
+jcode-linux-amd64: linux-amd64
 jcode-linux-arm64: linux-arm64
 
-# ─── FreeBSD amd64 binary ───────────────────────────────────────────────────
-# Native FreeBSD build through installed jerbuild. Requires cc and cargo.
-
-freebsd-amd64:
-	@case "$$(uname -s)-$$(uname -m)" in \
-	  FreeBSD-x86_64|FreeBSD-amd64) ;; \
-	  *) echo "ERROR: freebsd-amd64 is a native jerbuild target; run it on FreeBSD amd64" >&2; exit 1 ;; \
-	esac
-	@$(MAKE) jcode-freebsd-native
-
-jcode-freebsd-native: gen $(NATIVE_DIR)
-	@case "$$(uname -s)-$$(uname -m)" in \
-	  FreeBSD-x86_64|FreeBSD-amd64) ;; \
-	  *) echo "ERROR: .jerbuild.freebsd-amd64 must run on FreeBSD amd64" >&2; exit 1 ;; \
-	esac
-	@echo "=== Building native FreeBSD amd64 jcode with jerbuild ==="
-	$(JERBUILD) build --config .jerbuild.freebsd-amd64
+# Smoke-test the cross-built amd64 binary under alpine x86_64 via podman/qemu.
+test-linux-amd64: linux-amd64
+	@command -v podman >/dev/null 2>&1 || { \
+	  echo "ERROR: podman not found. Install with: brew install podman"; exit 1; }
+	@echo "=== Smoke testing jcode-linux-amd64 under podman/qemu alpine ==="
+	podman run --rm --platform linux/amd64 \
+	  -v "$(CURDIR):/work:ro" -w /work \
+	  alpine:3 ./jcode-linux-amd64 --version
+
+test-linux: test-linux-amd64
+
+# ─── Cross-compile: host → FreeBSD amd64 (dynamic ELF) ──────────────────────
+# Produces a dynamic x86_64 FreeBSD ELF (jcode-freebsd-amd64). Requires:
+#   - $(FREEBSD_AMD64_CC)                       (macOS clang+lld wrapper)
+#   - $(JERBOA_HOME)/.chez-cross-ta6fb          (cd $(JERBOA_HOME) && make binary)
+#   - FreeBSD sysroot at $(JERBOA_HOME)/.freebsd-sysroot/amd64
+#   - rustup target add x86_64-unknown-freebsd
+freebsd-amd64: gen
+	@command -v $(firstword $(FREEBSD_AMD64_CC)) >/dev/null 2>&1 || { \
+	  echo "ERROR: $(FREEBSD_AMD64_CC) not found or not executable" >&2; \
+	  echo "  See top of Makefile freebsd-amd64 target for setup notes." >&2; \
+	  exit 1; }
+	@test -d $(JERBOA_HOME)/.chez-cross-ta6fb || { \
+	  echo "ERROR: $(JERBOA_HOME)/.chez-cross-ta6fb not found" >&2; \
+	  echo "  cd $(JERBOA_HOME) && make binary" >&2; \
+	  exit 1; }
+	@command -v cargo >/dev/null 2>&1 || { \
+	  echo "ERROR: cargo not found on PATH. Install rustup from rustup.rs"; \
+	  exit 1; }
+	JERBOA_HOME=$(JERBOA_HOME) CROSS_CC="$(FREEBSD_AMD64_CC)" $(SCHEME) -q --libdirs "$(XC_LIBDIRS)" --script build-jcode-freebsd-cross.ss
 	@ls -lh jcode-freebsd-amd64
 	@file jcode-freebsd-amd64
 
+# Friendly aliases.
 jcode-freebsd-amd64: freebsd-amd64
-
 freebsd: freebsd-amd64
 
 
diff --git a/build-jcode-cross.ss b/build-jcode-cross.ss
new file mode 100644
index 0000000..95ece00
--- /dev/null
+++ b/build-jcode-cross.ss
@@ -0,0 +1,542 @@
+#!chezscheme
+;;; build-jcode-cross.ss — Cross-compile jcode from macOS to Linux musl
+;;;
+;;; Usage:
+;;;   JERBOA_HOME=/Users/user/mine/jerboa TARGET_ARCH=amd64 scheme \
+;;;     --libdirs <libs> --script build-jcode-cross.ss
+;;;
+;;; TARGET_ARCH selects the cross target:
+;;;   amd64 (default) — chez ta6le    + x86_64-linux-musl-gcc  → jcode-linux-amd64
+;;;   arm64           — chez tarm64le + aarch64-linux-musl-gcc → jcode-linux-arm64
+;;;
+;;; Cross-compile analogue of build-jcode-musl.ss that runs on a non-Linux host.
+;;; Uses:
+;;;   - $JERBOA_HOME/.chez-cross-<machine>/   — cross-built Chez install
+;;;   - $JERBOA_HOME/build/chez/xc-<machine>/s/xpatch — host compiler emit mode
+;;;   - <toolchain>-linux-musl-gcc            — C compile + final static link
+;;;
+;;; This is the "WPO as program" pattern: main-binary.ss + all imports are
+;;; bundled into one .so via compile-whole-program, loaded at runtime via
+;;; Sscheme_program — no jcode.boot bundling required.
+
+(import (chezscheme))
+
+;; ── Params ──────────────────────────────────────────────────────────────────
+(define jerboa-home
+  (or (getenv "JERBOA_HOME") "/Users/user/mine/jerboa"))
+
+(define target-arch (or (getenv "TARGET_ARCH") "amd64"))
+
+;; Per-arch dispatch: (arch chez-machine c-toolchain rust-target output-name)
+(define arch-table
+  '(("amd64" "ta6le"    "x86_64-linux-musl-gcc"  "x86_64-unknown-linux-musl"  "jcode-linux-amd64")
+    ("arm64" "tarm64le" "aarch64-linux-musl-gcc" "aarch64-unknown-linux-musl" "jcode-linux-arm64")))
+
+(define arch-row
+  (or (assoc target-arch arch-table)
+      (error 'build-jcode-cross
+             (format "unknown TARGET_ARCH (expected amd64|arm64): ~a" target-arch))))
+
+(define chez-machine (list-ref arch-row 1))
+(define rust-target  (list-ref arch-row 3))
+
+(define cross-prefix (format "~a/.chez-cross-~a" jerboa-home chez-machine))
+(define xpatch       (format "~a/build/chez/xc-~a/s/xpatch" jerboa-home chez-machine))
+(define cross-cc     (or (getenv "CROSS_CC") (list-ref arch-row 2)))
+
+(define output       (list-ref arch-row 4))
+(define entry-script "main-binary.ss")
+
+;; jcode uses only this subset of jerboa-native-rs features:
+;;   tls    — provider/provider.ss (rustls)
+;;   sqlite — (std db sqlite) via jerboa_sqlite_*
+;;   crypto — secrets.ss, hashing, hmac, aead, scrypt, argon2id
+(define cargo-features "tls,sqlite,crypto")
+
+(define jerboa-native-a
+  (or (getenv "JERBOA_NATIVE_A")
+      (format "~a/jerboa-native-rs/target/~a/release/libjerboa_native.a"
+              jerboa-home rust-target)))
+
+(define cross-csv-dir
+  (let ([lib (format "~a/lib" cross-prefix)])
+    (unless (file-directory? lib)
+      (error 'build-jcode-cross "cross prefix lib dir missing — run 'make chez' in jerboa first" lib))
+    (let* ([entries (directory-list lib)]
+           [csvs    (filter (lambda (e)
+                              (and (>= (string-length e) 3)
+                                   (string=? (substring e 0 3) "csv")))
+                            entries)])
+      (when (null? csvs)
+        (error 'build-jcode-cross "no csv* in cross lib" lib))
+      (format "~a/~a/~a" lib (car csvs) chez-machine))))
+
+(define (require-file p)
+  (unless (file-exists? p)
+    (error 'build-jcode-cross "missing file" p)))
+
+(require-file xpatch)
+(require-file (format "~a/libkernel.a"  cross-csv-dir))
+(require-file (format "~a/scheme.h"     cross-csv-dir))
+(require-file (format "~a/petite.boot"  cross-csv-dir))
+(require-file (format "~a/scheme.boot"  cross-csv-dir))
+(require-file entry-script)
+
+(printf "==> build-jcode-cross~n")
+(printf "    JERBOA_HOME:   ~a~n" jerboa-home)
+(printf "    cross csv-dir: ~a~n" cross-csv-dir)
+(printf "    xpatch:        ~a~n" xpatch)
+(printf "    cross-cc:      ~a~n" cross-cc)
+(printf "    output:        ~a~n" output)
+(printf "    features:      ~a~n~n" cargo-features)
+
+;; ── Step 0: Build/rebuild jerboa-native-rs for x86_64-linux-musl ───────────
+;; libjerboa_native.a is shared across jerboa-shell, jerboa-code, etc. If a
+;; sibling cross build (e.g. jsh-cross) built it with a different feature
+;; set, rebuild to match what jcode needs. Sentinel file records features.
+
+(define native-features-sentinel
+  (format "~a/jerboa-native-rs/target/~a/release/.built-with-~a"
+          jerboa-home rust-target
+          (let ([s (string-copy cargo-features)])
+            (let loop ([i 0])
+              (cond [(= i (string-length s)) s]
+                    [(char=? (string-ref s i) #\,)
+                     (string-set! s i #\-) (loop (+ i 1))]
+                    [else (loop (+ i 1))])))))
+
+(define (rs-source-newer-than? a-path)
+  (let ([src-dir (format "~a/jerboa-native-rs/src" jerboa-home)])
+    (and (file-directory? src-dir)
+         (file-exists? a-path)
+         (let ([a-mtime (file-modification-time a-path)])
+           (let walk ([dirs (list src-dir)])
+             (and (pair? dirs)
+                  (let* ([d (car dirs)]
+                         [entries (map (lambda (e) (format "~a/~a" d e))
+                                       (directory-list d))]
+                         [files (filter (lambda (p) (not (file-directory? p))) entries)]
+                         [subs  (filter file-directory? entries)])
+                    (or (ormap (lambda (f)
+                                 (and (let ([n (string-length f)])
+                                        (and (> n 3)
+                                             (string=? ".rs" (substring f (- n 3) n))))
+                                      (time>? (file-modification-time f) a-mtime)))
+                               files)
+                        (walk (append subs (cdr dirs)))))))))))
+
+;; Force cargo to use rustup-managed rustc. Homebrew on macOS often ships its
+;; own rustc ahead of rustup proxies on PATH; that homebrew rustc has no
+;; cross targets installed, so the musl build fails with "can't find crate
+;; for core". Detect rustup's active toolchain via `rustup which rustc` and
+;; pin both PATH and RUSTC to it (same trick as build-jcode-musl.ss).
+(define (path-dirname s)
+  (let loop ([i (- (string-length s) 1)])
+    (cond [(< i 0) "."]
+          [(char=? (string-ref s i) #\/) (substring s 0 i)]
+          [else (loop (- i 1))])))
+
+(define (capture-line cmd)
+  ;; open-process-ports returns 4 values: stdin, stdout, stderr, pid.
+  (call-with-values
+    (lambda () (open-process-ports cmd (buffer-mode block) (native-transcoder)))
+    (lambda (to-stdin from-stdout from-stderr pid)
+      (let ([line (get-line from-stdout)])
+        (close-port to-stdin)
+        (close-port from-stdout)
+        (close-port from-stderr)
+        (if (or (eof-object? line) (zero? (string-length line))) #f line)))))
+
+(define rustup-rustc-path (capture-line "rustup which rustc 2>/dev/null"))
+(define rustup-bin-dir    (and rustup-rustc-path (path-dirname rustup-rustc-path)))
+
+(define (try-cargo-build cmd)
+  (printf "  $ ~a~n" cmd)
+  (zero? (system cmd)))
+
+(define (rebuild-native-lib!)
+  (let* ([nrs-dir (format "~a/jerboa-native-rs" jerboa-home)]
+         [cargo-args (format "build --release --no-default-features --features ~a --target ~a"
+                             cargo-features rust-target)]
+         [via-rustup
+          (and rustup-bin-dir
+               (format "cd '~a' && env PATH='~a':$PATH RUSTC='~a/rustc' '~a/cargo' ~a"
+                       nrs-dir rustup-bin-dir rustup-bin-dir rustup-bin-dir cargo-args))]
+         [via-path (format "cd '~a' && cargo ~a" nrs-dir cargo-args)]
+         [ok? (or (and via-rustup (try-cargo-build via-rustup))
+                  (try-cargo-build via-path))])
+    (unless ok?
+      (error 'build-jcode-cross
+        (format
+         (string-append
+          "failed to build jerboa-native-rs for ~a. "
+          "Required: `rustup target add ~a`, plus the "
+          "FiloSottile/musl-cross toolchain on PATH so cargo finds "
+          "~a as the linker.")
+         rust-target rust-target cross-cc)))
+    (unless (file-exists? jerboa-native-a)
+      (error 'build-jcode-cross "cargo succeeded but .a missing" jerboa-native-a))
+    (call-with-output-file native-features-sentinel
+      (lambda (out) (display cargo-features out) (newline out))
+      'truncate)))
+
+(cond
+  [(not (file-exists? jerboa-native-a))
+   (printf "==> jerboa-native-rs missing — building (features=~a)~n" cargo-features)
+   (rebuild-native-lib!)]
+  [(not (file-exists? native-features-sentinel))
+   (printf "==> jerboa-native-rs sentinel missing — rebuilding (features=~a)~n" cargo-features)
+   (rebuild-native-lib!)]
+  [(rs-source-newer-than? jerboa-native-a)
+   (printf "==> jerboa-native-rs .rs source newer than .a — rebuilding~n")
+   (rebuild-native-lib!)]
+  [else
+   (printf "==> jerboa-native-rs up to date (features=~a)~n" cargo-features)])
+
+(printf "~n")
+
+;; ── Step 0.5: isolate cross-compiled objects ───────────────────────────────
+;; Never patch source libraries in-place.  Chez xpatch can compile target
+;; objects while `library-directories` maps source directories to a separate
+;; writable object directory.
+
+(define obj-dir (format ".build/~a-obj" output))
+(define entry-so  (format "~a/program.so" obj-dir))
+(define entry-wpo (format "~a/program.wpo" obj-dir))
+
+(define (prepare-obj-dir!)
+  (system (format "rm -rf '~a'" obj-dir))
+  (system (format "mkdir -p '~a'" obj-dir)))
+
+(define (cleanup!)
+  (system (format "rm -rf '~a'" obj-dir)))
+
+(prepare-obj-dir!)
+
+;; ── Stage 1: load xpatch (target=ta6le emit mode) ──────────────────────────
+(define orig-libdirs (library-directories))
+(printf "==> [1/6] loading xpatch (compiler -> ~a emit mode)~n" chez-machine)
+(load xpatch)
+(library-directories
+  (map (lambda (pair)
+         (cons (if (pair? pair) (car pair) pair) obj-dir))
+       orig-libdirs))
+
+(compile-imported-libraries #t)
+(generate-wpo-files #t)
+
+;; ── Stage 2: compile-program main-binary.ss ────────────────────────────────
+(printf "==> [2/6] compile-program ~a -> ~a~n" entry-script entry-so)
+(guard (e [#t (cleanup!) (raise e)])
+  (compile-program entry-script entry-so))
+
+;; ── Stage 3: compile-whole-program → wpo .so ───────────────────────────────
+(define wpo-output (string-append output ".wp.so"))
+(printf "==> [3/6] compile-whole-program ~a -> ~a~n" entry-wpo wpo-output)
+(guard (e [#t (cleanup!) (raise e)])
+  (compile-whole-program entry-wpo wpo-output #t))
+
+;; ── Stage 4: embed boot files + program as C arrays ────────────────────────
+(define (embed-as-c-array in-path var-name out-path)
+  (let* ([bv (call-with-port (open-file-input-port in-path) get-bytevector-all)]
+         [n (bytevector-length bv)])
+    (call-with-port (open-file-output-port out-path
+                       (file-options no-fail)
+                       (buffer-mode block)
+                       (native-transcoder))
+      (lambda (out)
+        (display (format "static const unsigned char ~a[] = {\n" var-name) out)
+        (let loop ([i 0])
+          (when (< i n)
+            (display (format "0x~2,'0x," (bytevector-u8-ref bv i)) out)
+            (when (= (mod (+ i 1) 16) 0) (newline out))
+            (loop (+ i 1))))
+        (when (positive? n) (newline out))
+        (display "};\n" out)
+        (display (format "static const unsigned int ~a_size = sizeof(~a);\n"
+                         var-name var-name)
+                 out)))
+    (printf "    embed ~a (~a bytes) -> ~a~n" in-path n out-path)))
+
+(printf "==> [4/6] embed boot files + program as C arrays~n")
+(embed-as-c-array (format "~a/petite.boot" cross-csv-dir) "petite_boot"    "petite_boot.h")
+(embed-as-c-array (format "~a/scheme.boot" cross-csv-dir) "scheme_boot"    "scheme_boot.h")
+(embed-as-c-array wpo-output                              "jcode_program"  "jcode_program.h")
+
+;; ── Stage 5: generate main.c ───────────────────────────────────────────────
+;; Symbol surface mirrors build-jcode-musl.ss (FFI + libc), plus the dlopen
+;; stubs needed for fully-static binaries. dlerror MUST return non-NULL —
+;; Chez's load-shared-object error path strlen()s it.
+
+(define ffi-shim-symbols
+  '(;; jerboa-sqlite shim (vendor/jerboa-sqlite/jerboa_sqlite_shim.c)
+    "jerboa_sqlite_open" "jerboa_sqlite_close" "jerboa_sqlite_exec"
+    "jerboa_sqlite_prepare" "jerboa_sqlite_finalize" "jerboa_sqlite_reset"
+    "jerboa_sqlite_clear_bindings" "jerboa_sqlite_step"
+    "jerboa_sqlite_column_count" "jerboa_sqlite_column_name"
+    "jerboa_sqlite_column_type" "jerboa_sqlite_column_int64"
+    "jerboa_sqlite_column_double" "jerboa_sqlite_column_text"
+    "jerboa_sqlite_column_bytes" "jerboa_sqlite_column_blob"
+    "jerboa_sqlite_bind_int64" "jerboa_sqlite_bind_double"
+    "jerboa_sqlite_bind_text" "jerboa_sqlite_bind_blob"
+    "jerboa_sqlite_bind_null" "jerboa_sqlite_last_insert_rowid"
+    "jerboa_sqlite_changes" "jerboa_sqlite_errmsg"
+    "jerboa_SQLITE_ROW" "jerboa_SQLITE_DONE" "jerboa_SQLITE_OK"
+    ;; termbox2 TUI shim (src/jcode/ui/jcode_tui_shim.c)
+    "jcode_tb_init" "jcode_tb_shutdown"
+    "jcode_tb_width" "jcode_tb_height"
+    "jcode_tb_clear" "jcode_tb_present"
+    "jcode_tb_set_cursor" "jcode_tb_hide_cursor"
+    "jcode_tb_change_cell" "jcode_tb_set_clear_attrs"
+    "jcode_tb_print" "jcode_tb_printf"
+    "jcode_tb_set_input_mode" "jcode_tb_set_output_mode"
+    "jcode_tb_poll_event" "jcode_tb_peek_event"
+    "jcode_tb_event_type" "jcode_tb_event_mod"
+    "jcode_tb_event_key" "jcode_tb_event_ch"
+    "jcode_tb_event_w" "jcode_tb_event_h"
+    "jcode_tb_event_x" "jcode_tb_event_y"
+    ;; landlock-shim (~/mine/jerboa/support/landlock-shim.c)
+    "jerboa_landlock_sandbox"))
+
+(define jerboa-native-symbols
+  '(;; jerboa-native (TLS/rustls)
+    "jerboa_tls_server_new" "jerboa_tls_server_new_mtls"
+    "jerboa_tls_server_free" "jerboa_tls_accept"
+    "jerboa_tls_connect" "jerboa_tls_connect_pinned"
+    "jerboa_tls_connect_mtls" "jerboa_tls_close"
+    "jerboa_tls_read" "jerboa_tls_write" "jerboa_tls_flush"
+    "jerboa_tls_get_fd" "jerboa_tls_set_nonblock"
+    "jerboa_last_error"
+    ;; jerboa-native (crypto)
+    "jerboa_sha1" "jerboa_sha256" "jerboa_sha384" "jerboa_sha512"
+    "jerboa_random_bytes" "jerboa_timing_safe_equal"
+    "jerboa_hmac_sha256" "jerboa_hmac_sha256_verify"
+    "jerboa_aead_seal" "jerboa_aead_open"
+    "jerboa_chacha20_seal" "jerboa_chacha20_open"
+    "jerboa_scrypt"
+    "jerboa_pbkdf2_derive" "jerboa_pbkdf2_verify"
+    "jerboa_argon2id_hash" "jerboa_argon2id_verify"
+    ;; jerboa-native (regex) — visit-time even if jcode never matches
+    "jerboa_regex_compile" "jerboa_regex_is_match"
+    "jerboa_regex_find" "jerboa_regex_replace_all" "jerboa_regex_free"
+    ;; jerboa-native (aproc) — (std os aproc) for bash tool
+    "jerboa_aproc_spawn" "jerboa_aproc_spawn_pty"
+    "jerboa_aproc_set_nonblock" "jerboa_aproc_killpg" "jerboa_aproc_wait4"
+    ;; jerboa-native (sqlite via rusqlite) — used by (std db sqlite)
+    "jerboa_sqlite_open" "jerboa_sqlite_close" "jerboa_sqlite_exec"
+    "jerboa_sqlite_prepare" "jerboa_sqlite_finalize" "jerboa_sqlite_reset"
+    "jerboa_sqlite_step" "jerboa_sqlite_changes" "jerboa_sqlite_errmsg"
+    "jerboa_sqlite_last_insert_rowid"
+    "jerboa_sqlite_bind_int" "jerboa_sqlite_bind_double"
+    "jerboa_sqlite_bind_text" "jerboa_sqlite_bind_blob" "jerboa_sqlite_bind_null"
+    "jerboa_sqlite_column_count" "jerboa_sqlite_column_type"
+    "jerboa_sqlite_column_int" "jerboa_sqlite_column_double"
+    "jerboa_sqlite_column_text" "jerboa_sqlite_column_blob"
+    "jerboa_sqlite_column_name"
+    ;; jerboa-native (net/io)
+    "jerboa_writev2"))
+
+(define posix-symbols
+  '(;; sockets — std/net/tcp, std/net/udp
+    "socket" "bind" "listen" "accept" "connect" "close"
+    "setsockopt" "getsockopt" "getsockname"
+    "htons" "ntohs" "inet_pton" "inet_addr"
+    "read" "write" "recvfrom" "sendto"
+    ;; DNS — std/net/resolve
+    "getaddrinfo" "freeaddrinfo" "inet_ntop"
+    ;; process/signals — std/misc/process, std/os/signal, aproc
+    "fork" "waitpid" "kill" "getpid" "getppid"
+    "getpgid" "setpgid" "setsid"
+    "getuid" "geteuid" "getgid" "getegid"
+    "sigemptyset" "sigfillset" "sigaddset" "sigdelset"
+    "sigismember" "sigprocmask"
+    ;; terminal/fd — TUI + REPL
+    "isatty" "tcgetattr" "tcsetattr" "tcgetpgrp" "tcsetpgrp"
+    "pipe" "dup" "dup2" "lseek"
+    ;; env/misc — std/os/env, std/text/time
+    "setenv" "unsetenv" "strerror" "localtime" "strftime"
+    "sysconf" "getpagesize" "getrlimit"
+    "system" "getenv" "putenv" "_exit" "exit" "execvp" "execve"
+    ;; temporary files/directories — std/os/temp
+    "mkstemp" "mkdtemp" "unlink" "rmdir"
+    ;; errno location
+    "__errno_location" "fcntl"))
+
+(define main-c-path (string-append output "-main.c"))
+
+(define (emit-c out)
+  (display "/* Generated by build-jcode-cross.ss — do not edit by hand. */\n" out)
+  (display "#define _GNU_SOURCE\n" out)
+  (display "#include <stdlib.h>\n" out)
+  (display "#include <string.h>\n" out)
+  (display "#include <stdio.h>\n" out)
+  (display "#include <unistd.h>\n" out)
+  (display "#include <sys/mman.h>\n" out)
+  (display "#include <sys/types.h>\n" out)
+  (display "#include <sys/stat.h>\n" out)
+  (display "#include <sys/wait.h>\n" out)
+  (display "#include <sys/resource.h>\n" out)
+  (display "#include <sys/socket.h>\n" out)
+  (display "#include <netinet/in.h>\n" out)
+  (display "#include <arpa/inet.h>\n" out)
+  (display "#include <netdb.h>\n" out)
+  (display "#include <termios.h>\n" out)
+  (display "#include <fcntl.h>\n" out)
+  (display "#include <signal.h>\n" out)
+  (display "#include <time.h>\n" out)
+  (display "#include <errno.h>\n" out)
+  (display "#include \"scheme.h\"\n" out)
+  (display "#include \"petite_boot.h\"\n" out)
+  (display "#include \"scheme_boot.h\"\n" out)
+  (display "#include \"jcode_program.h\"\n\n" out)
+  ;; dlopen/dlsym stubs — in a fully-static -static binary musl's dlopen
+  ;; always fails. Jerboa stdlib does (load-shared-object #f) and, in some
+  ;; libc fallback modules, (load-shared-object "") to expose the main
+  ;; executable; with these stubs those succeed and Chez routes lookups
+  ;; through Sforeign_symbol.
+  (display "/* dlopen stubs for static linking.\n" out)
+  (display " * (load-shared-object #f) passes f=NULL — succeed (main exe).\n" out)
+  (display " * (load-shared-object \"\") passes f=\"\" — also succeed (main exe).\n" out)
+  (display " * (load-shared-object \"libfoo\") passes f=\"libfoo\" — fail so the\n" out)
+  (display " * Scheme guard falls back to a no-op stub and we never try to\n" out)
+  (display " * dlsym a function that's not statically linked. */\n" out)
+  (display "void *dlopen(const char *f, int flags) { (void)flags; return (!f || f[0] == '\\0') ? (void*)1 : NULL; }\n" out)
+  (display "void *dlsym(void *h, const char *s)    { (void)h; (void)s; return NULL; }\n" out)
+  (display "int   dlclose(void *h)                  { (void)h; return 0; }\n" out)
+  ;; dlerror MUST return a non-NULL string — Chez's load-shared-object error
+  ;; path calls strlen(dlerror()), and strlen(NULL) is a SIGSEGV inside libc
+  ;; with no useful diagnostic. Return a stable message instead.
+  (display "static char dlerror_msg[] = \"static binary: dlopen of named libraries is stubbed\";\n" out)
+  (display "char *dlerror(void)                     { return dlerror_msg; }\n\n" out)
+  ;; extern decls for shim functions linked via .c files
+  (display "/* Shim function decls — jerboa_sqlite_shim.c + jcode_tui_shim.c + landlock-shim.c */\n" out)
+  (for-each (lambda (n) (fprintf out "extern void ~a();\n" n)) ffi-shim-symbols)
+  ;; extern decls for libjerboa_native.a symbols
+  (display "\n/* libjerboa_native.a — features=tls,sqlite,crypto */\n" out)
+  (for-each (lambda (n) (fprintf out "extern void ~a();\n" n)) jerboa-native-symbols)
+  ;; POSIX libc symbols already declared by system headers above.
+  (display "\nextern int *__errno_location(void);\n" out)
+  ;; register all symbols at startup
+  (newline out)
+  (display "static void register_ffi_symbols(void) {\n" out)
+  (for-each (lambda (n)
+              (fprintf out "    Sforeign_symbol(\"~a\", (void*)~a);\n" n n))
+            ffi-shim-symbols)
+  (for-each (lambda (n)
+              (fprintf out "    Sforeign_symbol(\"~a\", (void*)~a);\n" n n))
+            jerboa-native-symbols)
+  (for-each (lambda (n)
+              (fprintf out "    Sforeign_symbol(\"~a\", (void*)~a);\n" n n))
+            posix-symbols)
+  (display "}\n\n" out)
+  (display "int main(int argc, char *argv[]) {\n" out)
+  ;; Tell jerboa stdlib we are statically linked — must precede Sscheme_init.
+  (display "    setenv(\"JERBOA_STATIC\", \"1\", 1);\n\n" out)
+  ;; memfd_create for the WPO program, with tmpfile fallback
+  (display "    int fd = memfd_create(\"jcode-program\", MFD_CLOEXEC);\n" out)
+  (display "    char prog_path[64];\n" out)
+  (display "    if (fd >= 0) {\n" out)
+  (display "        if (write(fd, jcode_program, jcode_program_size) != (ssize_t)jcode_program_size) {\n" out)
+  (display "            perror(\"write memfd\"); close(fd); return 1;\n" out)
+  (display "        }\n" out)
+  (display "        snprintf(prog_path, sizeof(prog_path), \"/proc/self/fd/%d\", fd);\n" out)
+  (display "    } else {\n" out)
+  (display "        const char *tmp = getenv(\"TMPDIR\"); if (!tmp) tmp = \"/tmp\";\n" out)
+  (display "        snprintf(prog_path, sizeof(prog_path), \"%s/.jcode-prog-%d.so\", tmp, getpid());\n" out)
+  (display "        FILE *fp = fopen(prog_path, \"wb\");\n" out)
+  (display "        if (!fp) { perror(\"fopen tmpfile\"); return 1; }\n" out)
+  (display "        if (fwrite(jcode_program, 1, jcode_program_size, fp) != jcode_program_size) {\n" out)
+  (display "            perror(\"fwrite\"); fclose(fp); unlink(prog_path); return 1;\n" out)
+  (display "        }\n" out)
+  (display "        fclose(fp);\n" out)
+  (display "    }\n\n" out)
+  ;; Boot Chez.
+  (display "    Sscheme_init(NULL);\n" out)
+  (display "    Sregister_boot_file_bytes(\"petite\", (void *)petite_boot, petite_boot_size);\n" out)
+  (display "    Sregister_boot_file_bytes(\"scheme\", (void *)scheme_boot, scheme_boot_size);\n" out)
+  (display "    Sbuild_heap(NULL, NULL);\n" out)
+  (display "    register_ffi_symbols();\n\n" out)
+  ;; argv is passed through directly to Sscheme_program — main-binary.ss
+  ;; reads (command-line-arguments) for --tui detection.
+  (display "    int status = Sscheme_program(prog_path, argc, (const char **)argv);\n\n" out)
+  (display "    if (fd >= 0) close(fd); else unlink(prog_path);\n" out)
+  (display "    Sscheme_deinit();\n" out)
+  (display "    return status;\n" out)
+  (display "}\n" out))
+
+(call-with-port (open-file-output-port main-c-path
+                  (file-options no-fail) (buffer-mode block) (native-transcoder))
+  emit-c)
+(printf "==> [5/6] generated ~a (~a shim + ~a native + ~a posix)~n"
+        main-c-path
+        (length ffi-shim-symbols)
+        (length jerboa-native-symbols)
+        (length posix-symbols))
+
+;; ── Stage 6: compile + link with cross-cc ──────────────────────────────────
+(printf "==> [6/6] compile + link with ~a~n" cross-cc)
+
+(define sqlite-shim-c    "vendor/jerboa-sqlite/jerboa_sqlite_shim.c")
+(define jcode-tui-shim-c "src/jcode/ui/jcode_tui_shim.c")
+(define landlock-shim-c  (format "~a/support/landlock-shim.c" jerboa-home))
+
+(require-file sqlite-shim-c)
+(require-file jcode-tui-shim-c)
+(require-file landlock-shim-c)
+(require-file jerboa-native-a)
+
+;; The jerboa_sqlite_shim.c calls into sqlite3_* directly (declared via
+;; <sqlite3.h>). At link time, those symbols come from libjerboa_native.a
+;; which has rusqlite bundling its own sqlite3. We need ANY sqlite3.h for
+;; compile, since the public sqlite3 C API is stable across versions —
+;; macOS Homebrew's header is fine. Find via brew.
+(define brew-sqlite-prefix
+  (capture-line "brew --prefix sqlite 2>/dev/null"))
+
+(define sqlite-include-dir
+  (cond
+    [(and brew-sqlite-prefix
+          (file-exists? (format "~a/include/sqlite3.h" brew-sqlite-prefix)))
+     (format "~a/include" brew-sqlite-prefix)]
+    [(file-exists? "/usr/include/sqlite3.h") "/usr/include"]
+    [(file-exists? "/usr/local/include/sqlite3.h") "/usr/local/include"]
+    [else
+     (error 'build-jcode-cross
+       "no sqlite3.h found. Install via: brew install sqlite")]))
+
+(printf "    sqlite3.h:  ~a/sqlite3.h~n" sqlite-include-dir)
+(printf "    tls/sqlite/crypto via libjerboa_native.a~n")
+
+;; -Wl,--export-dynamic exports symbols into the dynamic symbol table so
+;; Chez's foreign-procedure / dlsym(RTLD_DEFAULT) can find them in a
+;; -static binary. (Same trick as build-jsh-cross.ss.)
+(define link-cmd
+  (format
+   (string-append
+    "~a -O2 -static -Wl,--export-dynamic "
+    "-I~a "                                                ;; scheme.h
+    "-I~a "                                                ;; sqlite3.h
+    "-Ivendor/termbox2 -DTB_OPT_ATTR_W=32 "                ;; jcode_tui_shim
+    "-o ~a "                                               ;; output
+    "~a ~a ~a ~a "                                         ;; main.c + 3 shims
+    "~a/libkernel.a ~a/libz.a ~a/liblz4.a "                ;; chez kernel
+    "~a "                                                  ;; libjerboa_native.a
+    "-Wl,--allow-multiple-definition "
+    "-Wl,--defsym=_dl_find_object=0 "
+    "-lm -ldl -lpthread")
+   cross-cc cross-csv-dir sqlite-include-dir output
+   main-c-path sqlite-shim-c jcode-tui-shim-c landlock-shim-c
+   cross-csv-dir cross-csv-dir cross-csv-dir
+   jerboa-native-a))
+(printf "    ~a~n" link-cmd)
+(let ([rc (system link-cmd)])
+  (unless (zero? rc)
+    (cleanup!)
+    (error 'build-jcode-cross "cross-link failed" rc)))
+
+;; ── Cleanup: remove isolated cross objects ─────────────────────────────────
+(cleanup!)
+
+(printf "~n=== Build complete: ~a ===~n" output)
+(system (format "ls -lh ~a" output))
+(system (format "file ~a" output))
diff --git a/build-jcode-freebsd-cross.ss b/build-jcode-freebsd-cross.ss
new file mode 100644
index 0000000..3884a5c
--- /dev/null
+++ b/build-jcode-freebsd-cross.ss
@@ -0,0 +1,538 @@
+#!chezscheme
+;;; build-jcode-freebsd-cross.ss — Cross-compile jcode from macOS arm64 to FreeBSD 14 amd64
+;;;
+;;; Usage:
+;;;   JERBOA_HOME=/Users/user/mine/jerboa scheme --libdirs <libs> \
+;;;     --script build-jcode-freebsd-cross.ss
+;;;
+;;; FreeBSD analogue of build-jcode-cross.ss. Uses:
+;;;   - $JERBOA_HOME/.chez-cross-ta6fb/   — cross-built Chez install (FreeBSD)
+;;;   - $JERBOA_HOME/build/chez/xc-ta6fb/s/xpatch — host compiler → ta6fb emit
+;;;   - $JERBOA_HOME/support/cross-cc-freebsd-amd64 — macOS clang+lld wrapper
+;;;
+;;; Produces: jcode-freebsd-amd64  (dynamic FreeBSD x86_64 ELF; depends on
+;;; libc.so.7, libm.so, libthr.so, libutil.so on the target host).
+;;;
+;;; Why dynamic and not static: FreeBSD libc uses symbol versioning
+;;; (wait4@FBSD_1.0 etc.) that libc.a + libc_nonshared.a from base.txz
+;;; cannot satisfy. Dynamic linking is the standard FreeBSD distribution
+;;; model anyway.
+
+(import (chezscheme))
+
+;; ── Params ──────────────────────────────────────────────────────────────────
+(define jerboa-home
+  (or (getenv "JERBOA_HOME") "/Users/user/mine/jerboa"))
+
+(define cross-prefix (format "~a/.chez-cross-ta6fb" jerboa-home))
+(define xpatch       (format "~a/build/chez/xc-ta6fb/s/xpatch" jerboa-home))
+(define cross-cc     (or (getenv "CROSS_CC")
+                         (format "~a/support/cross-cc-freebsd-amd64" jerboa-home)))
+
+(define output       "jcode-freebsd-amd64")
+(define entry-script "main-binary.ss")
+
+;; jcode uses only this subset of jerboa-native-rs features:
+;;   tls    — provider/provider.ss (rustls)
+;;   sqlite — (std db sqlite) via jerboa_sqlite_*
+;;   crypto — secrets.ss, hashing, hmac, aead, scrypt, argon2id
+(define cargo-features "tls,sqlite,crypto")
+
+(define jerboa-native-a
+  (or (getenv "JERBOA_NATIVE_A")
+      (format "~a/jerboa-native-rs/target/x86_64-unknown-freebsd/release/libjerboa_native.a"
+              jerboa-home)))
+
+(define cross-csv-dir
+  (let ([lib (format "~a/lib" cross-prefix)])
+    (unless (file-directory? lib)
+      (error 'build-jcode-freebsd-cross "cross prefix lib dir missing — run 'make binary' in jerboa first" lib))
+    (let* ([entries (directory-list lib)]
+           [csvs    (filter (lambda (e)
+                              (and (>= (string-length e) 3)
+                                   (string=? (substring e 0 3) "csv")))
+                            entries)])
+      (when (null? csvs)
+        (error 'build-jcode-freebsd-cross "no csv* in cross lib" lib))
+      (format "~a/~a/ta6fb" lib (car csvs)))))
+
+(define (require-file p)
+  (unless (file-exists? p)
+    (error 'build-jcode-freebsd-cross "missing file" p)))
+
+(require-file xpatch)
+(require-file (format "~a/libkernel.a"  cross-csv-dir))
+(require-file (format "~a/scheme.h"     cross-csv-dir))
+(require-file (format "~a/petite.boot"  cross-csv-dir))
+(require-file (format "~a/scheme.boot"  cross-csv-dir))
+(require-file entry-script)
+
+(printf "==> build-jcode-freebsd-cross~n")
+(printf "    JERBOA_HOME:   ~a~n" jerboa-home)
+(printf "    cross csv-dir: ~a~n" cross-csv-dir)
+(printf "    xpatch:        ~a~n" xpatch)
+(printf "    cross-cc:      ~a~n" cross-cc)
+(printf "    output:        ~a~n" output)
+(printf "    features:      ~a~n~n" cargo-features)
+
+;; ── Step 0: Build/rebuild jerboa-native-rs for x86_64-unknown-freebsd ──────
+;; libjerboa_native.a is shared across jerboa-shell, jerboa-code, etc. If a
+;; sibling cross build (e.g. jsh-cross) built it with a different feature
+;; set, rebuild to match what jcode needs. Sentinel file records features.
+
+(define native-features-sentinel
+  (format "~a/jerboa-native-rs/target/x86_64-unknown-freebsd/release/.built-with-~a"
+          jerboa-home
+          (let ([s (string-copy cargo-features)])
+            (let loop ([i 0])
+              (cond [(= i (string-length s)) s]
+                    [(char=? (string-ref s i) #\,)
+                     (string-set! s i #\-) (loop (+ i 1))]
+                    [else (loop (+ i 1))])))))
+
+(define (rs-source-newer-than? a-path)
+  (let ([src-dir (format "~a/jerboa-native-rs/src" jerboa-home)])
+    (and (file-directory? src-dir)
+         (file-exists? a-path)
+         (let ([a-mtime (file-modification-time a-path)])
+           (let walk ([dirs (list src-dir)])
+             (and (pair? dirs)
+                  (let* ([d (car dirs)]
+                         [entries (map (lambda (e) (format "~a/~a" d e))
+                                       (directory-list d))]
+                         [files (filter (lambda (p) (not (file-directory? p))) entries)]
+                         [subs  (filter file-directory? entries)])
+                    (or (ormap (lambda (f)
+                                 (and (let ([n (string-length f)])
+                                        (and (> n 3)
+                                             (string=? ".rs" (substring f (- n 3) n))))
+                                      (time>? (file-modification-time f) a-mtime)))
+                               files)
+                        (walk (append subs (cdr dirs)))))))))))
+
+;; Force cargo to use rustup-managed rustc. Homebrew on macOS often ships its
+;; own rustc ahead of rustup proxies on PATH; that homebrew rustc has no
+;; cross targets installed, so the musl build fails with "can't find crate
+;; for core". Detect rustup's active toolchain via `rustup which rustc` and
+;; pin both PATH and RUSTC to it (same trick as build-jcode-musl.ss).
+(define (path-dirname s)
+  (let loop ([i (- (string-length s) 1)])
+    (cond [(< i 0) "."]
+          [(char=? (string-ref s i) #\/) (substring s 0 i)]
+          [else (loop (- i 1))])))
+
+(define (capture-line cmd)
+  ;; open-process-ports returns 4 values: stdin, stdout, stderr, pid.
+  (call-with-values
+    (lambda () (open-process-ports cmd (buffer-mode block) (native-transcoder)))
+    (lambda (to-stdin from-stdout from-stderr pid)
+      (let ([line (get-line from-stdout)])
+        (close-port to-stdin)
+        (close-port from-stdout)
+        (close-port from-stderr)
+        (if (or (eof-object? line) (zero? (string-length line))) #f line)))))
+
+(define rustup-rustc-path (capture-line "rustup which rustc 2>/dev/null"))
+(define rustup-bin-dir    (and rustup-rustc-path (path-dirname rustup-rustc-path)))
+
+(define (try-cargo-build cmd)
+  (printf "  $ ~a~n" cmd)
+  (zero? (system cmd)))
+
+(define (rebuild-native-lib!)
+  (let* ([nrs-dir (format "~a/jerboa-native-rs" jerboa-home)]
+         [cargo-args (format "build --release --no-default-features --features ~a --target x86_64-unknown-freebsd"
+                             cargo-features)]
+         ;; cc-rs needs LLVM's llvm-ar (not Apple's /usr/bin/ar) and the
+         ;; FreeBSD clang wrapper. Forward these to the cargo invocation.
+         [cc-env (format
+                  "CARGO_TARGET_X86_64_UNKNOWN_FREEBSD_LINKER='~a' CC_x86_64_unknown_freebsd='~a' AR_x86_64_unknown_freebsd=/opt/homebrew/opt/llvm/bin/llvm-ar "
+                  cross-cc cross-cc)]
+         [via-rustup
+          (and rustup-bin-dir
+               (format "cd '~a' && env ~aPATH='~a':$PATH RUSTC='~a/rustc' '~a/cargo' ~a"
+                       nrs-dir cc-env rustup-bin-dir rustup-bin-dir rustup-bin-dir cargo-args))]
+         [via-path (format "cd '~a' && env ~acargo ~a" nrs-dir cc-env cargo-args)]
+         [ok? (or (and via-rustup (try-cargo-build via-rustup))
+                  (try-cargo-build via-path))])
+    (unless ok?
+      (error 'build-jcode-freebsd-cross
+        (string-append
+         "failed to build jerboa-native-rs for x86_64-unknown-freebsd. "
+         "Required: `rustup target add x86_64-unknown-freebsd`, plus "
+         cross-cc " and "
+         "/opt/homebrew/opt/llvm/bin/llvm-ar from the LLVM Homebrew formula.")))
+    (unless (file-exists? jerboa-native-a)
+      (error 'build-jcode-freebsd-cross "cargo succeeded but .a missing" jerboa-native-a))
+    (call-with-output-file native-features-sentinel
+      (lambda (out) (display cargo-features out) (newline out))
+      'truncate)))
+
+(cond
+  [(not (file-exists? jerboa-native-a))
+   (printf "==> jerboa-native-rs missing — building (features=~a)~n" cargo-features)
+   (rebuild-native-lib!)]
+  [(not (file-exists? native-features-sentinel))
+   (printf "==> jerboa-native-rs sentinel missing — rebuilding (features=~a)~n" cargo-features)
+   (rebuild-native-lib!)]
+  [(rs-source-newer-than? jerboa-native-a)
+   (printf "==> jerboa-native-rs .rs source newer than .a — rebuilding~n")
+   (rebuild-native-lib!)]
+  [else
+   (printf "==> jerboa-native-rs up to date (features=~a)~n" cargo-features)])
+
+(printf "~n")
+
+;; ── Step 0.5: isolate cross-compiled objects ───────────────────────────────
+;; Never patch source libraries in-place.  Chez xpatch can compile target
+;; objects while `library-directories` maps source directories to a separate
+;; writable object directory.
+
+(define obj-dir (format ".build/~a-obj" output))
+(define entry-so  (format "~a/program.so" obj-dir))
+(define entry-wpo (format "~a/program.wpo" obj-dir))
+
+(define (prepare-obj-dir!)
+  (system (format "rm -rf '~a'" obj-dir))
+  (system (format "mkdir -p '~a'" obj-dir)))
+
+(define (cleanup!)
+  (system (format "rm -rf '~a'" obj-dir)))
+
+(prepare-obj-dir!)
+
+;; ── Stage 1: load xpatch (target=ta6le emit mode) ──────────────────────────
+(define orig-libdirs (library-directories))
+(printf "==> [1/6] loading xpatch (compiler -> ta6fb emit mode)~n")
+(load xpatch)
+(library-directories
+  (map (lambda (pair)
+         (cons (if (pair? pair) (car pair) pair) obj-dir))
+       orig-libdirs))
+
+(compile-imported-libraries #t)
+(generate-wpo-files #t)
+
+;; ── Stage 2: compile-program main-binary.ss ────────────────────────────────
+(printf "==> [2/6] compile-program ~a -> ~a~n" entry-script entry-so)
+(guard (e [#t (cleanup!) (raise e)])
+  (compile-program entry-script entry-so))
+
+;; ── Stage 3: compile-whole-program → wpo .so ───────────────────────────────
+(define wpo-output (string-append output ".wp.so"))
+(printf "==> [3/6] compile-whole-program ~a -> ~a~n" entry-wpo wpo-output)
+(guard (e [#t (cleanup!) (raise e)])
+  (compile-whole-program entry-wpo wpo-output #t))
+
+;; ── Stage 4: embed boot files + program as C arrays ────────────────────────
+(define (embed-as-c-array in-path var-name out-path)
+  (let* ([bv (call-with-port (open-file-input-port in-path) get-bytevector-all)]
+         [n (bytevector-length bv)])
+    (call-with-port (open-file-output-port out-path
+                       (file-options no-fail)
+                       (buffer-mode block)
+                       (native-transcoder))
+      (lambda (out)
+        (display (format "static const unsigned char ~a[] = {\n" var-name) out)
+        (let loop ([i 0])
+          (when (< i n)
+            (display (format "0x~2,'0x," (bytevector-u8-ref bv i)) out)
+            (when (= (mod (+ i 1) 16) 0) (newline out))
+            (loop (+ i 1))))
+        (when (positive? n) (newline out))
+        (display "};\n" out)
+        (display (format "static const unsigned int ~a_size = sizeof(~a);\n"
+                         var-name var-name)
+                 out)))
+    (printf "    embed ~a (~a bytes) -> ~a~n" in-path n out-path)))
+
+(printf "==> [4/6] embed boot files + program as C arrays~n")
+(embed-as-c-array (format "~a/petite.boot" cross-csv-dir) "petite_boot"    "petite_boot.h")
+(embed-as-c-array (format "~a/scheme.boot" cross-csv-dir) "scheme_boot"    "scheme_boot.h")
+(embed-as-c-array wpo-output                              "jcode_program"  "jcode_program.h")
+
+;; ── Stage 5: generate main.c ───────────────────────────────────────────────
+;; Symbol surface mirrors build-jcode-musl.ss (FFI + libc), plus the dlopen
+;; stubs needed for fully-static binaries. dlerror MUST return non-NULL —
+;; Chez's load-shared-object error path strlen()s it.
+
+(define ffi-shim-symbols
+  '(;; jerboa-sqlite shim (vendor/jerboa-sqlite/jerboa_sqlite_shim.c)
+    "jerboa_sqlite_open" "jerboa_sqlite_close" "jerboa_sqlite_exec"
+    "jerboa_sqlite_prepare" "jerboa_sqlite_finalize" "jerboa_sqlite_reset"
+    "jerboa_sqlite_clear_bindings" "jerboa_sqlite_step"
+    "jerboa_sqlite_column_count" "jerboa_sqlite_column_name"
+    "jerboa_sqlite_column_type" "jerboa_sqlite_column_int64"
+    "jerboa_sqlite_column_double" "jerboa_sqlite_column_text"
+    "jerboa_sqlite_column_bytes" "jerboa_sqlite_column_blob"
+    "jerboa_sqlite_bind_int64" "jerboa_sqlite_bind_double"
+    "jerboa_sqlite_bind_text" "jerboa_sqlite_bind_blob"