#!/usr/bin/env bash # Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. # See LICENSE.md for license information. # SPDX-License-Identifier: MIT # # clang-build.sh - Build an Apple IIgs binary with the llvm-mos w65816 # toolchain (clang + llvm-mc + link816). # # Usage: # clang-build.sh [-I path]... [-Dname[=val]]... [-s stacksize] [-b] \ # [-M depfile] -o OUTPUT MAIN.c [EXTRA.c|.s ...] # # -I path extra include dir (repeatable) # -Dname preprocessor define, passed to clang (probe builds) # -s SIZE accepted for compatibility; llvm-mos sizes the stack in # crt0, so this is a no-op here. # -b accepted for compatibility; no-op (clang pointers are # already 32-bit). # -M depfile emit a combined header-dependency file (clang -MM) so make # rebuilds the binary when a shared header changes. # -o OUTPUT output path for the GS/OS S16 OMF (ExpressLoad-wrapped, # relocatable) that the System Loader / Finder can launch. # # C sources are compiled with clang; .s / .asm sources are assembled with # llvm-mc; everything links against the prebuilt w65816 runtime (with the # GS/OS-aware crt0Gsos). link816 dead-strips unreferenced sections, then # omfEmit wraps the relocatable image as an S16 OMF so the Loader can place # it at any Memory-Manager-chosen address. # # Requires LLVM816_ROOT (toolchain root) with a built runtime # ($LLVM816_ROOT/runtime/*.o). make exports it; from a shell, source # toolchains/env.sh. set -euo pipefail : "${LLVM816_ROOT:?clang-build.sh: LLVM816_ROOT unset; run ./install.sh install toolchain:iigs, then use make or source toolchains/env.sh}" CLANG="$LLVM816_ROOT/tools/llvm-mos-build/bin/clang" LLVM_MC="$LLVM816_ROOT/tools/llvm-mos-build/bin/llvm-mc" LINK="$LLVM816_ROOT/tools/link816" OMFEMIT="$LLVM816_ROOT/tools/omfEmit" RT="$LLVM816_ROOT/runtime" RTINC="$RT/include" for t in "$CLANG" "$LLVM_MC" "$LINK" "$OMFEMIT"; do [ -x "$t" ] || { echo "clang-build.sh: missing tool: $t" >&2; exit 1; } done [ -f "$RT/crt0Gsos.o" ] || { echo "clang-build.sh: runtime not built ($RT/crt0Gsos.o missing); run runtime/build.sh" >&2; exit 1; } includes=() output="" depfile="" srcs=() while [ $# -gt 0 ]; do case "$1" in -I) includes+=(-I "$2"); shift 2 ;; -I*) includes+=("$1"); shift ;; # combined -Ipath form -D*) includes+=("$1"); shift ;; # preprocessor define (probe builds) -o) output="$2"; shift 2 ;; -s) shift 2 ;; # stack size: no-op (crt0 handles it) -b) shift ;; # accepted for compatibility: no-op -M) depfile="$2"; shift 2 ;; -*) echo "clang-build.sh: unknown flag $1" >&2; exit 2 ;; *) srcs+=("$1"); shift ;; esac done [ -n "$output" ] || { echo "clang-build.sh: -o OUTPUT required" >&2; exit 2; } [ ${#srcs[@]} -gt 0 ] || { echo "clang-build.sh: no source files" >&2; exit 2; } tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpdir"' EXIT CFLAGS=(--target=w65816 -O2 -ffreestanding -ffunction-sections -I"$RTINC" "${includes[@]}") # Curated runtime object set for GS/OS-launched OMFs. crt0Gsos.o (the # System-Loader-aware crt) MUST be first so __start lands at --text-base. # link816 dead-strips whatever a given binary doesn't use, so offering the # whole runtime (libc helpers, soft-float, toolbox, GS/OS) is free. runtimeObjs=( "$RT/crt0Gsos.o" "$RT/libgcc.o" "$RT/libc.o" "$RT/strtol.o" "$RT/snprintf.o" "$RT/sscanf.o" "$RT/qsort.o" "$RT/strtok.o" "$RT/extras.o" "$RT/timeExt.o" "$RT/math.o" "$RT/softFloat.o" "$RT/softDouble.o" "$RT/iigsToolbox.o" "$RT/iigsToolboxExtra.o" "$RT/iigsGsos.o" ) objs=() depsrcs=() objIdx=0 for s in "${srcs[@]}"; do # Unique object name per source. A plain basename collides when two # sources share a name (e.g. src/core/input.c and src/iigs/ # input.c both -> input.o), silently clobbering one and dropping its # symbols. Index-prefix to guarantee uniqueness. b="$(basename "$s")" o="$tmpdir/${objIdx}_${b%.*}.o" objIdx=$((objIdx + 1)) case "$s" in *.c) # Route through the regalloc fallback so a register-heavy TU # (greedy "ran out of registers" on the single-accumulator # 65816 -- e.g. AGI's main()) auto-retries with -regalloc=basic # instead of breaking the build, exactly like runtime/build.sh. "$LLVM816_ROOT/scripts/ccRegallocFallback.sh" "$CLANG" "${CFLAGS[@]}" -c "$s" -o "$o" depsrcs+=("$s") ;; *.s|*.asm) "$LLVM_MC" -arch=w65816 -filetype=obj "$s" -o "$o" ;; *.o|*.a) # Prebuilt object or static library (e.g. libjoey.a): link816 reads # both directly and --gc-sections selects what's referenced. Pass # the real path through untouched. objs+=("$s") continue ;; *) echo "clang-build.sh: skipping unrecognized source: $s" >&2 continue ;; esac objs+=("$o") done # Object order is no longer layout-sensitive. The multi-segment crash that # made it matter was the OMF segment header carrying ALIGN=0, so the System # Loader placed overflow segments at a non-bank-aligned address (e.g. # $07:BB11). Code uses 16-bit self-references (computed-goto `rts` tables, # intra-segment branches) whose operands equal the *link* offset, which only # resolve when the segment sits at bank:0000. omfEmit now emits ALIGN=0x10000 # so every code segment is bank-aligned and runtime_offset == link_offset in # ANY split. Objects therefore build in compile order (deterministic from the # source list); no sort needed. (Root-caused via the MAME debugger: seg2 main # landed at $07:BB11, jlInit at +0x1E02=$07:D913, computed-gotos jumped wild.) # Relocatable GS/OS OMF build. The System Loader places the image at a # Memory-Manager-chosen address, so the OMF carries relocation records # (--reloc-out -> --relocs). --text-base 0 (NOT 0x1000): the Loader # bank-aligns KIND=0x1000 segments, so link-time IMM16 operands already # equal their runtime segment offsets and link816 skips the IMM16 cRELOC # entries (50-90% fewer relocs -> proportionally faster Loader boot). # --bss-base 0xA000 keeps BSS above the SHR shadow ($2000-$9FFF in bank 0) # so global writes don't scribble the screen. crt0Gsos.o (first object) # puts __start at the segment base. linkBin="$tmpdir/link.bin" linkMap="$tmpdir/link.map" linkReloc="$tmpdir/link.reloc" linkManifest="$tmpdir/link.json" linkErr="$tmpdir/link.err" # A kept map (JOEY_LINK_MAP, below) lists the local symbols too: static # functions, which a profile otherwise folds into the global before them. # link816 only: omfEmit rejects the flag. mapLocals=() if [ -n "${JOEY_LINK_MAP:-}" ]; then mapLocals=(--map-locals) fi # The GS/OS load name is the output basename, uppercased to <=8 alnum chars. omfName="$(basename "$output" | tr '[:lower:]' '[:upper:]' | tr -cd 'A-Z0-9' | cut -c1-8)" [ -n "$omfName" ] || omfName="JOEYAPP" # link816 emits an ExpressLoad S16 OMF. Two distinct overflows force the # --full-bank: a GS/OS OMF image is loaded into a Memory-Manager-chosen # bank, not bank 0, so there is no IO window or language card to dodge and # all 64K is usable. link816 defaults to the bank-0-safe layout because its # other consumers (clang-smoke.sh and the bench/probe scripts) really do # raw-load into bank 0 -- see the fullBank comment in link816.cpp. # # MULTI-segment path: (a) text overrunning the entry bank, or # (b) BSS overflowing the entry bank's leftover space. crt0Gsos sets # DBR = the entry segment's bank and C globals are reached DBR-relative, so # BSS can NOT be moved to its own bank -- instead we push TEXT into overflow # banks (a low --segment-cap) to free the entry bank for rodata + BSS, and # raise --bss-base so BSS sits at the top of the entry bank. The GS/OS # Loader places each bank-aligned segment + applies the per-seg relocations. emitMultiSeg() { # $1 = segment-cap, $2 = bss-base "$LINK" -o "$linkBin" --text-base 0 --bss-base "$2" --segment-cap "$1" \ --overflow-cap "$JOEY_OVERFLOW_CAP" --full-bank \ --manifest "$linkManifest" --map "$linkMap" --reloc-out "$linkReloc" \ --multi-seg-expressload "${mapLocals[@]}" "${runtimeObjs[@]}" "${objs[@]}" 2>"$linkErr" || return 1 "$OMFEMIT" --manifest "$linkManifest" --expressload --relocs "$linkReloc" \ --output "$output" } # The C heap only has to be LARGE ENOUGH. It used to also have to sit below # "the bank-0 IO window ($C000)", which was wrong: the program does not run # in bank 0. Measured under MAME, joeylib's examples execute in banks # $08-$0B, where there is no IO window and no language card, and link816's # map is bank-relative (--text-base 0), so that test was comparing a bank # OFFSET against a bank-0 address. link816's __heap_start comment has the # full reasoning; the heap now runs to the top of the entry bank. # # This used to test only that the heap STARTED below $C000, which let the # descending-cap search stop at the first layout that merely cleared the # window -- even when almost nothing was left under it. A program that # REDUCED its bss could therefore get a worse image: shrinking bss moves # __heap_start down, so a layout that previously failed the test now # passes it with a few hundred bytes of heap, and the search stops there. # Space Taxi hit exactly this, linking a 778-byte heap (vs 4802 before); # it drew its first screen and then froze, because every allocation after # that failed. The big buffers (codegen arena, stage pixels, audio) come # from the Memory Manager, but stdio FILE buffers and codegen arena slots # come from this heap, so "a few hundred bytes" is never enough. # # If no cap reaches the minimum the caller still falls back to the highest # cap that linked, so this can only reject layouts that were broken. # Minimum size for the link-time heap WINDOW, in bytes. DEFAULT 0, and # no target needs to raise it any more. # # What this window is: the free space above bss in the ENTRY BANK. In a # program that links libc it is NOT the heap -- __heapInitMM asks the # Memory Manager for a handle (which carries no attrBank, so it can land # in any bank) and malloc runs in that. The window is only addressed # directly if that allocation fails. # # The old measurements below were read as "each program needs its own # minimum heap", with the odd result that MORE heap could break a program: # # STAXI 778 B -> froze on its first allocation # STAXI 2426 B -> still froze # AGI 3337 B -> runs KQ3 (19 segments) # STAXI 4802 B -> runs (25 segments) # AGI 7689 B -> BROKE: launched, then did nothing # # That last row is not mysterious: libc used to pass the window straight # to NewHandle when it fell in [0x2000, 0xC000] and substitute a 32 KB # default otherwise. 3337 is below 0x2000, so AGI got 32 KB and ran; 7689 # is inside the range, so AGI asked for 7689 -- less than it needs -- and # drew nothing. The freezes are the same story with the Memory Manager # exhausted by a many-segment image, so NewHandle failed and malloc fell # back to the tiny window. Reproduced exactly while fixing the IO-window # assumption, and fixed in libc.c: the window is now a floor to beat, not # the request, so a bigger window can never mean a smaller heap. JOEY_MIN_HEAP="${JOEY_MIN_HEAP:-0}" # How much text an OVERFLOW segment may hold. --segment-cap bounds the # ENTRY bank, which also carries rodata, bss and the heap -- for Space # Taxi only ~12K of its 64K is left for text. An # overflow bank carries text ONLY, so capping it the same way wasted ~50K # per bank: 177K of code needed 17 bank-aligned segments instead of 4. # Each segment must still fit one bank (16-bit intra-segment addressing), # so this stays below 0x10000. # # DEFAULT 0 = overflow segments use --segment-cap (the historical # behavior); a program opts in with ID_IIGS_OVERFLOW_CAP in its description # (make/programs.mk). AGI links 5 segments instead of 19, STAXI 4 instead of # 26 (both now separate projects built on JoeyLib). # # Raising this on STAXI used to produce a dead image, which looked like a # segment-size limit. It was not -- AGI ships 28798-byte segments and runs. # The real chain: 26 bank-aligned segments starved the Memory Manager, so # NTP's 34K NewHandle failed, gNTPReady stayed 0 and every SFX call # short-circuited. IIgs builds were simply SILENT. Freeing that memory let # audio initialize, and a latent bug fired the first time NTPstreamsound # was ever reached (fixed in src/iigs/audio_full.c: SFX now requires a # running replayer, as jlpAudioStopSfx already did). # # Each segment must still fit one bank (16-bit intra-segment addressing), # so this stays below 0x10000. JOEY_OVERFLOW_CAP="${JOEY_OVERFLOW_CAP:-0}" heapUsable() { local hs he hs=$(grep -wE '__heap_start' "$linkMap" 2>/dev/null \ | grep -oE '0x[0-9a-fA-F]+' | head -1) he=$(grep -wE '__heap_end' "$linkMap" 2>/dev/null \ | grep -oE '0x[0-9a-fA-F]+' | head -1) [ -n "$hs" ] && [ -n "$he" ] || return 1 # No IO-window test: the heap is not in bank 0. See the __heap_start # comment in link816.cpp -- GS/OS loads bank-aligned segments into # free banks ($08-$0B here), where the whole 64K is plain RAM, and in # a program that links libc this window is only a size request to the # Memory Manager anyway. [ $(( he - hs )) -ge $(( JOEY_MIN_HEAP )) ] } # Multi-seg, choosing the HIGHEST --segment-cap that BOTH links AND leaves a # usable C heap. A lower cap pushes more TEXT into overflow banks, freeing # entry-bank space for rodata + BSS + the heap; BSS packs # tight (--bss-base 0x2000 -- safe, a relocatable multi-seg image loads high, # never bank 0/1). The highest qualifying cap is preferred: fewest segments, # fastest Loader boot. # # Do NOT "improve" this by falling back to whichever cap yields the BIGGEST # heap. Tried 2026-09-19: for Space Taxi that selects cap 0x1000, and the # resulting 49-segment image renders garbage (SCB byte reads back $DD). Low # caps break for reasons this script cannot see, so the search stays anchored # to the highest cap that qualifies, and the fallback stays the highest cap # that merely linked. # Any of these means seg 1 is too full for bank 0 and a smaller cap may # fit: BSS, the trampoline table, or rodata + init_array (which follow # seg 1's text) ran past the ceiling. ENTRY_BANK_OVERFLOW='bss .*exceeds bank-0 ceiling|trampoline table pushes seg 1 over|rodata .*exceeds bank-0' emitMultiSegFit() { local cap local fallback="" for cap in 0xB000 0xA000 0x9000 0x8000 0x7000 0x6000 0x5000 0x4000 0x3000 0x2000 0x1000; do if emitMultiSeg "$cap" 0x2000; then [ -z "$fallback" ] && fallback="$cap" heapUsable && return 0 else # Keep shrinking only on a fixable entry-bank overflow; bail on # anything else (unsplittable text, an unresolved symbol, ...). grep -qE "$ENTRY_BANK_OVERFLOW" "$linkErr" || break fi done # Nothing reached JOEY_MIN_HEAP; re-link the highest that linked and say so, # because the image may well fail at run time for want of heap. if [ -n "$fallback" ]; then echo "clang-build: WARNING no segment cap reached a ${JOEY_MIN_HEAP}-byte" \ "heap; using $fallback -- the image may fail to allocate" >&2 emitMultiSeg "$fallback" 0x2000 return $? fi # Nothing linked at all. This used to fall off the end and return the # status of the false `if` above -- 0 -- so the caller never printed # the linker error and the build "succeeded" with no output file. return 1 } if "$LINK" -o "$linkBin" --text-base 0 --bss-base 0xA000 --full-bank \ --map "$linkMap" --reloc-out "$linkReloc" \ "${mapLocals[@]}" "${runtimeObjs[@]}" "${objs[@]}" 2>"$linkErr" && heapUsable; then # Single-segment AND the heap is big enough: wrap as-is. "$OMFEMIT" --input "$linkBin" --map "$linkMap" --base 0 --entry __start \ --output "$output" --name "$omfName" --expressload --relocs "$linkReloc" else # Single-seg overflowed (text/BSS/trampoline) OR it linked with too # small a heap -- go multi-seg with a heap-aware descending cap. emitMultiSegFit || { cat "$linkErr" >&2; exit 1; } fi # The link map is the only record of where a symbol landed, and the OMF # carries no symbols at all, so anything that inspects a running program # (the PC profiler, the MAME state gates) needs it kept. Off by default: # JOEY_LINK_MAP names a directory, which gets .map and the # segment images beside it, .seg.bin (segment 1 is # link816's main output), with link816's segment manifest, .json, for a multi-segment one. The Loader puts each segment in whatever bank it # likes, and scripts/profile-iigs.sh finds them there by their bytes. A # directory, not a file, because one make links several programs (a game # disk's Start launcher, say) and each keeps its own. if [ -n "${JOEY_LINK_MAP:-}" ] && [ -f "$linkMap" ]; then mapBase="$JOEY_LINK_MAP/$(basename "$output")" mkdir -p "$JOEY_LINK_MAP" cp "$linkMap" "$mapBase.map" rm -f "$mapBase".seg*.bin cp "$linkBin" "$mapBase.seg1.bin" for seg in "$tmpdir"/link.seg*.bin; do [ -f "$seg" ] || continue cp "$seg" "$mapBase.${seg##*/link.}" done rm -f "$mapBase.json" if [ -f "$linkManifest" ]; then cp "$linkManifest" "$mapBase.json" fi fi # Combined header-dependency file: union of clang -MM over the C sources, # rewritten to a single target ($output) so make rebuilds the binary when # any transitively-included header changes. if [ -n "$depfile" ] && [ ${#depsrcs[@]} -gt 0 ]; then mkdir -p "$(dirname "$depfile")" { printf '%s:' "$output" for s in "${depsrcs[@]}"; do "$CLANG" "${CFLAGS[@]}" -MM "$s" 2>/dev/null \ | sed -e 's/^[^:]*://' -e 's/\\$//' | tr -d '\n' done printf '\n' } > "$depfile" fi