// link816 — minimal flat-binary linker for W65816 ELF .o files.
//
// Reads one or more ELF32 object files (produced by llvm-mc / clang -c
// with the W65816 backend), concatenates their .text* / .rodata* /
// .data* sections at consecutive addresses starting from a given base,
// builds a global symbol table, resolves the W65816 ELF relocations,
// and writes a flat binary suitable for loading into a 65816 emulator
// or further wrapping by omfEmit.
//
// Standalone — no LLVM dependency.  Parses ELF32-LE structures
// directly with the layout from /usr/include/elf.h.
//
// Supported relocation types (per W65816ELFObjectWriter):
//   1  R_W65816_IMM8       — 1-byte absolute
//   2  R_W65816_IMM16      — 2-byte LE absolute
//   3  R_W65816_IMM24      — 3-byte LE absolute  (JSL targets)
//   4  R_W65816_PCREL8     — 1-byte signed PC-relative
//   5  R_W65816_PCREL16    — 2-byte signed PC-relative
//   6  R_W65816_BANK16     — 2-byte, high byte = bank of target, low = 0
//   7  R_W65816_DATA32     — 4-byte LE absolute  (DWARF, .long)
//   8  R_W65816_PCREL32    — 4-byte signed PC-relative (DWARF diffs)
//
// CLI mirrors the Python tool exactly:
//   link816 -o out.bin --text-base 0x8000 --bss-base 0x2000 a.o b.o ...
//          [--rodata-base ADDR] [--map FILE]

#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <map>
#include <memory>
#include <set>
#include <string>
#include <utility>
#include <vector>

namespace {

// ---------------------------------------------------------------- ELF32 layout
// We only need the LE host-side parsing path.  Field names mirror
// /usr/include/elf.h so a reader can cross-check against the spec.

struct Elf32Ehdr {
    uint8_t  e_ident[16];
    uint16_t e_type;
    uint16_t e_machine;
    uint32_t e_version;
    uint32_t e_entry;
    uint32_t e_phoff;
    uint32_t e_shoff;
    uint32_t e_flags;
    uint16_t e_ehsize;
    uint16_t e_phentsize;
    uint16_t e_phnum;
    uint16_t e_shentsize;
    uint16_t e_shnum;
    uint16_t e_shstrndx;
};

struct Elf32Shdr {
    uint32_t sh_name;
    uint32_t sh_type;
    uint32_t sh_flags;
    uint32_t sh_addr;
    uint32_t sh_offset;
    uint32_t sh_size;
    uint32_t sh_link;
    uint32_t sh_info;
    uint32_t sh_addralign;
    uint32_t sh_entsize;
};

[[maybe_unused]] static constexpr uint32_t SHT_NULL     = 0;
[[maybe_unused]] static constexpr uint32_t SHT_PROGBITS = 1;
static constexpr uint32_t SHT_SYMTAB   = 2;
static constexpr uint32_t SHT_STRTAB   = 3;
static constexpr uint32_t SHT_RELA     = 4;
[[maybe_unused]] static constexpr uint32_t SHT_NOBITS   = 8;

struct Elf32Sym {
    uint32_t st_name;
    uint32_t st_value;
    uint32_t st_size;
    uint8_t  st_info;
    uint8_t  st_other;
    uint16_t st_shndx;
};

static constexpr uint16_t SHN_UNDEF  = 0;
static constexpr uint16_t SHN_ABS    = 0xFFF1;
static constexpr uint16_t SHN_COMMON = 0xFFF2;

// W65816 ELF e_machine value.  Vendor-private slot in the 0xFF00-0xFFFF
// experimental range reserved by the ELF spec.  Must match the value used
// by W65816ELFObjectWriter and the EM_W65816 enumerator in
// llvm/include/llvm/BinaryFormat/ELF.h.  See docs/USAGE.md "ELF
// e_machine value" section.
static constexpr uint16_t EM_W65816 = 0xFF16;
static constexpr uint16_t EM_NONE   = 0;

inline uint8_t  ELF32_ST_TYPE(uint8_t i) { return i & 0x0F; }
inline uint8_t  ELF32_ST_BIND(uint8_t i) { return (i >> 4) & 0x0F; }
static constexpr uint8_t STB_LOCAL  = 0;
[[maybe_unused]] static constexpr uint8_t STB_GLOBAL = 1;
static constexpr uint8_t STB_WEAK   = 2;

[[maybe_unused]] static constexpr uint8_t STT_NOTYPE  = 0;
[[maybe_unused]] static constexpr uint8_t STT_OBJECT  = 1;
[[maybe_unused]] static constexpr uint8_t STT_FUNC    = 2;
static constexpr uint8_t STT_SECTION = 3;

struct Elf32Rela {
    uint32_t r_offset;
    uint32_t r_info;
    int32_t  r_addend;
};

inline uint32_t ELF32_R_SYM (uint32_t i) { return i >> 8; }
inline uint32_t ELF32_R_TYPE(uint32_t i) { return i & 0xFF; }

// W65816 reloc type numbers — match W65816ELFObjectWriter.
static constexpr uint8_t R_W65816_IMM8     = 1;
static constexpr uint8_t R_W65816_IMM16    = 2;
static constexpr uint8_t R_W65816_IMM24    = 3;
static constexpr uint8_t R_W65816_PCREL8   = 4;
static constexpr uint8_t R_W65816_PCREL16  = 5;
// Bank byte of a 24-bit absolute address.  Patches 2 bytes:
// byte 0 = (target >> 16) & 0xFF, byte 1 = 0.  Used for the high
// half of `&symbol` 32-bit pointers so the bank tracks the actual
// OMF Loader segment placement at runtime.  cRELOC emission uses
// ByteCnt=2 BitShift=16 so the Loader patches with
// (segPlacedBase + offsetRef) >> 16.
static constexpr uint8_t R_W65816_BANK16   = 6;
// 4-byte LE absolute fixup.  Generated for FK_Data_4 (non-PCRel) —
// DWARF .debug_* section-relative addresses, .long directives.
// The 65816 has a 24-bit address space, so the high byte is always
// zero; we still write all 4 bytes so the slot width matches the
// DWARF reader's expectation (every 4-byte address field decodes
// as a clean 32-bit value, not 3 bytes + neighbour byte).
static constexpr uint8_t R_W65816_DATA32   = 7;
// 4-byte signed PC-relative fixup.  Generated for FK_Data_4 (PCRel) —
// section-relative DWARF diffs that the assembler can't resolve
// in-section come through as PC-relative per
// ELFObjectWriter::recordRelocation.
static constexpr uint8_t R_W65816_PCREL32  = 8;

// ---------------------------------------------------------------- IIgs memory map
// Bank-0 hazard zones the placement logic must route around.  Kept as
// named constants to avoid sprinkling magic 0xC000 / 0xD000 across the
// rodata/init/bss/heap placement code (previously: ~13 raw uses across
// five distinct decisions).  Update both halves together if the IIgs
// memory map ever needs revisiting.
//
//   $C000..$CFFF — IO and soft switches.  Reads return hardware
//                  register values, writes hit soft switches.  Code,
//                  data, and BSS placement all bump past this zone.
//   $D000..$DFFF — Language Card 1.  Read-only ROM by default; crt0
//                  enables LC1 RAM via the $C083 read-twice trick so
//                  rodata/BSS/heap placed here is writable.
//   $0001:0000   — Bank-0 ceiling; any range whose top exceeds this
//                  must be split across banks (BSS handles up to 4
//                  consecutive banks; rodata/init are bank-0 only).
static constexpr uint32_t kIoWindowStart = 0xC000;  // $C000
static constexpr uint32_t kIoWindowEnd   = 0xD000;  // first usable byte past IO
static constexpr uint32_t kBank0Ceiling  = 0x10000; // first byte of bank 1

// Returns true iff `[start, start+size)` overlaps the IO window OR
// starts inside it.  Used by rodata / init_array / BSS placement.
static inline bool overlapsIoWindow(uint32_t start, uint32_t size) {
    return start < kIoWindowEnd && (start + size) > kIoWindowStart;
}

// ---------------------------------------------------------------- Helpers

[[noreturn]] static void die(const std::string &msg) {
    std::fprintf(stderr, "link816: %s\n", msg.c_str());
    std::exit(1);
}

static std::vector<uint8_t> readFile(const std::string &path) {
    std::ifstream f(path, std::ios::binary);
    if (!f) die("cannot open '" + path + "' for reading");
    std::vector<uint8_t> buf((std::istreambuf_iterator<char>(f)),
                              std::istreambuf_iterator<char>());
    return buf;
}

// Section-name parser for the per-segment placement attribute.  Returns 0 if
// the section is unpinned (default greedy packing), or N >= 1 if the section
// is pinned to seg N.  Convention: a text section whose name starts with
// `.text.segN` (decimal, 1-based) — directly or with a trailing dotted
// qualifier — pins that section's contents into segment N.  Users author
// via `__attribute__((section(".text.seg2")))` or similar.  Rationale: the
// existing sectionKind() classifier already treats `.text.<anything>` as
// the "text" kind, so the new convention costs nothing in the kind axis.
static uint32_t requestedSegment(const std::string &name) {
    const std::string prefix = ".text.seg";
    if (name.rfind(prefix, 0) != 0) return 0;
    size_t p = prefix.size();
    if (p >= name.size() || !std::isdigit((unsigned char)name[p])) return 0;
    size_t e = p;
    while (e < name.size() && std::isdigit((unsigned char)name[e])) ++e;
    if (e < name.size() && name[e] != '.') return 0;  // `.text.seg2foo` is not a pin
    uint32_t n = static_cast<uint32_t>(std::stoul(name.substr(p, e - p)));
    return n;
}


static std::string sectionKind(const std::string &name) {
    if (name == ".text"   || name.rfind(".text.",   0) == 0) return "text";
    if (name == ".rodata" || name.rfind(".rodata.", 0) == 0) return "rodata";
    if (name == ".data"   || name.rfind(".data.",   0) == 0) return "rodata";
    if (name == ".bss"    || name.rfind(".bss.",    0) == 0) return "bss";
    // .init_array entries are 16-bit function pointers; treat as
    // rodata so they end up in the read-only image and get a stable
    // address.  The linker emits __init_array_start/_end so crt0 can
    // walk them.  (.fini_array is not yet wired up; ELF input is
    // accepted but the sections are dropped — runtime has no
    // destructor-walk path today.)
    if (name == ".init_array" || name.rfind(".init_array.", 0) == 0) return "init_array";
    // DWARF debug sections that are *targets* of intra-debug relocs
    // (e.g. .debug_info -> .debug_str via R_W65816_DATA32, or
    // .debug_str_offsets -> .debug_str via R_W65816_DATA32).  Treat
    // them as a separate "debug" kind so resolveSym() can patch
    // intra-debug references with section-relative offsets (the
    // sidecar concatenates section data per object, preserving the
    // original object-local offset semantics).  Without this, the
    // .debug_str_offsets entries stay zeroed and llvm-dwarfdump
    // can't resolve strx-form DW_AT_name attributes — every variable
    // name comes through as @strxN.
    if (name == ".debug_str"         ||
        name == ".debug_line_str"    ||
        name == ".debug_str_offsets" ||
        name == ".debug_addr")
        return "debug";
    return "";
}

// ---------------------------------------------------------------- ELF parser

struct Section {
    std::string name;
    uint32_t    type;
    uint32_t    size;
    uint32_t    fileOffset;
    uint32_t    link;
    uint32_t    info;
};

struct Symbol {
    std::string name;
    uint32_t    value;     // st_value
    uint16_t    shndx;
    uint8_t     type;      // STT_*
    uint8_t     bind;      // STB_LOCAL / STB_GLOBAL / STB_WEAK
};

struct Reloc {
    uint32_t offset;       // within target section
    uint32_t symIdx;
    uint8_t  type;
    int32_t  addend;
};

struct InputObject {
    std::string                       path;
    std::vector<uint8_t>              raw;
    std::vector<Section>              sections;
    std::vector<Symbol>               symbols;
    // relocs indexed by target section id
    std::map<uint32_t, std::vector<Reloc>> relocs;

    void parse() {
        if (raw.size() < sizeof(Elf32Ehdr))
            die("'" + path + "': file too small to be ELF");
        if (raw[0] != 0x7f || raw[1] != 'E' || raw[2] != 'L' || raw[3] != 'F')
            die("'" + path + "': not an ELF file");
        if (raw[4] != 1)  // ELFCLASS32
            die("'" + path + "': not 32-bit ELF");
        if (raw[5] != 1)  // ELFDATA2LSB
            die("'" + path + "': not little-endian ELF");

        Elf32Ehdr hdr;
        std::memcpy(&hdr, raw.data(), sizeof(hdr));
        // e_machine: accept EM_W65816 (canonical, set by our object writer)
        // and EM_NONE (pre-Phase-1.13 objects, in case anyone still has stale
        // .o files in a build tree).  Reject anything else — a host-arch .o
        // accidentally fed in here would silently link otherwise.
        if (hdr.e_machine != EM_W65816 && hdr.e_machine != EM_NONE) {
            char msg[256];
            std::snprintf(msg, sizeof(msg),
                "'%s': wrong e_machine (got 0x%04X, expected EM_W65816=0xFF16)",
                path.c_str(), hdr.e_machine);
            die(msg);
        }
        if (hdr.e_shoff == 0 || hdr.e_shnum == 0)
            die("'" + path + "': no section table");
        if (hdr.e_shentsize != sizeof(Elf32Shdr))
            die("'" + path + "': unexpected section header size");

        // Section header string table — used to look up section names.
        Elf32Shdr shstrhdr;
        std::memcpy(&shstrhdr,
                    raw.data() + hdr.e_shoff + hdr.e_shstrndx * sizeof(Elf32Shdr),
                    sizeof(shstrhdr));
        const char *shstrtab = reinterpret_cast<const char *>(
            raw.data() + shstrhdr.sh_offset);

        sections.resize(hdr.e_shnum);
        std::vector<Elf32Shdr> shdrs(hdr.e_shnum);
        for (size_t i = 0; i < hdr.e_shnum; ++i) {
            std::memcpy(&shdrs[i],
                        raw.data() + hdr.e_shoff + i * sizeof(Elf32Shdr),
                        sizeof(Elf32Shdr));
            sections[i].name       = std::string(shstrtab + shdrs[i].sh_name);
            sections[i].type       = shdrs[i].sh_type;
            sections[i].size       = shdrs[i].sh_size;
            sections[i].fileOffset = shdrs[i].sh_offset;
            sections[i].link       = shdrs[i].sh_link;
            sections[i].info       = shdrs[i].sh_info;
        }

        // Find the symbol table and its string table.
        size_t symtabIdx = (size_t)-1, symstrtabIdx = (size_t)-1;
        for (size_t i = 0; i < sections.size(); ++i) {
            if (sections[i].type == SHT_SYMTAB) {
                symtabIdx = i;
                symstrtabIdx = sections[i].link;
                break;
            }
        }
        if (symtabIdx == (size_t)-1) {
            // Object with no symbols is unusual but legal — treat as empty.
            return;
        }
        const char *symstrtab = reinterpret_cast<const char *>(
            raw.data() + sections[symstrtabIdx].fileOffset);

        size_t numSyms = sections[symtabIdx].size / sizeof(Elf32Sym);
        symbols.resize(numSyms);
        for (size_t i = 0; i < numSyms; ++i) {
            Elf32Sym sym;
            std::memcpy(&sym,
                        raw.data() + sections[symtabIdx].fileOffset
                            + i * sizeof(Elf32Sym),
                        sizeof(Elf32Sym));
            symbols[i].name  = std::string(symstrtab + sym.st_name);
            symbols[i].value = sym.st_value;
            symbols[i].shndx = sym.st_shndx;
            symbols[i].type  = ELF32_ST_TYPE(sym.st_info);
            symbols[i].bind  = ELF32_ST_BIND(sym.st_info);
        }

        // Walk RELA sections; index by their target section (sh_info).
        for (size_t i = 0; i < sections.size(); ++i) {
            if (sections[i].type != SHT_RELA) continue;
            uint32_t targetSec = sections[i].info;
            size_t numRels = sections[i].size / sizeof(Elf32Rela);
            std::vector<Reloc> &out = relocs[targetSec];
            out.reserve(numRels);
            for (size_t j = 0; j < numRels; ++j) {
                Elf32Rela r;
                std::memcpy(&r,
                            raw.data() + sections[i].fileOffset
                                + j * sizeof(Elf32Rela),
                            sizeof(Elf32Rela));
                Reloc R;
                R.offset = r.r_offset;
                R.symIdx = ELF32_R_SYM(r.r_info);
                R.type   = static_cast<uint8_t>(ELF32_R_TYPE(r.r_info));
                R.addend = r.r_addend;
                out.push_back(R);
            }
        }
    }

    const uint8_t *sectionData(uint32_t idx) const {
        return raw.data() + sections[idx].fileOffset;
    }

    std::vector<uint32_t> sectionsByKind(const std::string &kind) const {
        std::vector<uint32_t> out;
        for (size_t i = 0; i < sections.size(); ++i) {
            if (sections[i].size == 0) continue;
            if (sectionKind(sections[i].name) == kind)
                out.push_back(static_cast<uint32_t>(i));
        }
        return out;
    }
};

// ---------------------------------------------------------------- Linker

// Multi-segment text layout.  Segment 1 lives in bank 0 alongside
// rodata/bss/init_array (the existing single-segment layout).  When
// --segment-cap is set and total text exceeds it, additional code
// segments get bank-aligned bases starting at --segment-bank-base
// (default 0x040000 = bank 4 — chosen to leave bank 0 + LC RAM for
// segment 1, banks 1-3 free for data / markers / future use).
struct TextSeg {
    uint32_t segNum    = 1;        // 1-based; 1 is the bank-0 segment
    uint32_t base      = 0;        // 24-bit load address
    uint32_t size      = 0;        // bytes occupied
    std::vector<uint8_t> body;     // patched bytes ready to write
};

struct Layout {
    uint32_t textBase, textSize;   // segment 1's text (bank 0)
    uint32_t rodataBase, rodataSize;
    uint32_t bssBase, bssSize;
    uint32_t initBase, initSize;
    // segments[0] = segment 1 (bank 0); segments[1..] = bank-N+ overflow.
    // Always at least one entry.
    std::vector<TextSeg> segments;
};

// One IMM-N relocation site (N=2 or 3), recorded for OMF cRELOC
// emission. The Loader rewrites the N bytes at `patchOff` to be
// (segPlacedBase + offsetRef) when the segment is placed at runtime
// — this fixes both 24-bit JSL/JML/STAlong AND 16-bit absolute data
// references when the segment doesn't land at link-time-base.
struct Imm24Site {
    uint32_t patchOff;   // offset within text image (== patchAddr - textBase)
    uint32_t offsetRef;  // offset within text image of target symbol
    uint8_t  byteCnt;    // 2 = IMM16 or BANK16, 3 = IMM24
    uint8_t  bitShift;   // 0 for IMM16/IMM24, 16 for BANK16
};
static std::vector<Imm24Site> gImm24Sites;
static uint32_t gTextBaseForSites = 0;
static bool    gRecordSites = false;
static unsigned long gBank0DataSites = 0;

// --report-self-mod: audit diagnostic.  Reports every intra-.text-section
// IMM16 reference made with a STORE opcode (absolute, DBR-relative) into the
// section's OWN code -- i.e. self-modifying code.  Such a store only reaches
// the real instruction when the code's bank equals DBR; if the linker places
// the section in a non-DBR bank (seg2) it writes to the wrong bank and
// corrupts the patch.  The fix is to make the self-mod bank-explicit (DBR=PBR
// around it).  PC-relative branches, jsr/jmp (PBR), and immediate (#) operands
// are bank-safe and not reported.
static bool    gReportSelfMod = false;

// Phase C.2 — per-segment + cross-segment IMM24 site tracking, used
// when multi-segment ExpressLoad OMFs need the GS/OS Loader to patch
// cross-seg JSLs after picking banks dynamically.
//
// gIntraImm24SitesPerSeg[N] = sites within seg N whose target is also
// in seg N (cRELOC 0xF5 — Loader rewrites bytes to (segPlacedBase +
// offsetRef) when seg N lands).
//
// gInterImm24Sites = sites whose patch is in one seg but target lives
// in a different seg (cINTERSEG 0xF6 — Loader rewrites bytes to
// (targetSegPlacedBase + offsetRef) after picking banks for both
// segs).  link816 bakes 0 at the patch site so the Loader's add does
// not double-count.
struct InterImm24Site {
    uint16_t patchSeg;   // 1-based seg containing the patch
    uint16_t patchOff;   // offset within patchSeg
    uint8_t  targetSeg;  // 1-based seg containing the target
    uint16_t targetOff;  // offset within targetSeg
    uint8_t  byteCnt;    // 3 = IMM24 (only kind supported for cINTERSEG today)
    uint8_t  bitShift;   // 0
};
static std::map<uint32_t, std::vector<Imm24Site>> gIntraImm24SitesPerSeg;
static std::vector<InterImm24Site> gInterImm24Sites;
static bool gMultiSegRelocMode = false;   // set when text layout has >1 segment

// ---- Cross-segment function-pointer TRAMPOLINE table (GS/OS jump-table
// model, all-resident form). clang forms &func as a 16-bit offset + the bank
// from $00BE (the LOAD/seg1 bank), so &func of a function the linker placed
// in seg2+ (a different bank) gets the WRONG (seg1) bank and crashes when the
// pointer is called.  Fix: for each cross-seg-function IMM16 address-take,
// emit a `JML func` trampoline in SEG1 (offset + $BE seg1 bank are then
// correct) and redirect the IMM16 to it; the JML's 24-bit target is cINTERSEG
// -relocated so the Loader patches the real seg2 bank.  Calling through the
// pointer reaches the seg1 trampoline and tail-jumps (JML) into the function.
// (The matching call-site half -- libgcc __jsl_indir widened to JML[$00B8],
// 24-bit -- lets the dispatch honor the bank.)  Keyed by the target's
// (segNum, offsetWithinSeg) so one trampoline serves every reference.
struct TrampKey {
    uint32_t seg;
    uint32_t off;
    bool operator<(const TrampKey &o) const {
        return seg != o.seg ? seg < o.seg : off < o.off;
    }
};
static std::map<TrampKey, uint32_t> gTrampMap;   // key -> trampoline index
static std::vector<TrampKey>        gTrampList;  // index -> key (emit order)
static uint32_t gTrampBase = 0;   // byte offset of the table within seg1
static constexpr uint32_t kTrampBytes = 4;       // JML abs-long: 0x5C + 24-bit


// Record an intra-segment patch site for cRELOC emission.  A target
// below the text base is never intra-segment (it is an undefined-weak
// resolving to 0, or an absolute address) and is skipped — see the
// commentary at the R_W65816_IMM16 callsite for why this matters.
static void recordCRelocSite(uint32_t patchAddr, uint32_t target,
                             uint8_t byteCnt, uint8_t bitShift) {
    if (!gRecordSites) return;
    uint32_t targetBank = target & 0xFF0000;
    uint32_t baseBank   = gTextBaseForSites & 0xFF0000;
    if (targetBank != baseBank) return;
    if (target < gTextBaseForSites) return;
    Imm24Site s;
    s.patchOff  = patchAddr - gTextBaseForSites;
    s.offsetRef = target    - gTextBaseForSites;
    s.byteCnt   = byteCnt;
    s.bitShift  = bitShift;
    gImm24Sites.push_back(s);
}

// Phase C.2 helper — find which text segment contains a 24-bit
// address.  Returns the 1-based segNum, or 0 if the address is
// outside every text seg (e.g. rodata, bss, init_array, or an
// absolute address like an MMIO register).
static uint32_t findSegContaining(uint32_t addr,
                                   const std::vector<TextSeg> &segments) {
    for (const auto &seg : segments) {
        if (addr >= seg.base && addr < seg.base + seg.size)
            return seg.segNum;
    }
    return 0;
}

// Number of bytes patched by a given reloc type.  Used by callers
// that need to range-check a reloc offset against a buffer size
// without re-deriving the width inline.  Returns 0 for unknown
// types (the caller should reject the reloc).
static uint32_t relocWidth(uint8_t rtype) {
    switch (rtype) {
    case R_W65816_IMM8:
    case R_W65816_PCREL8:
        return 1;
    case R_W65816_IMM16:
    case R_W65816_PCREL16:
    case R_W65816_BANK16:
        return 2;
    case R_W65816_IMM24:
        return 3;
    case R_W65816_DATA32:
    case R_W65816_PCREL32:
        return 4;
    default:
        return 0;
    }
}


static void applyReloc(std::vector<uint8_t> &buf, uint32_t off,
                       uint32_t patchAddr, uint32_t target,
                       uint8_t rtype, const std::string &symName) {
    int64_t pcrelDisp;
    switch (rtype) {
    case R_W65816_IMM8:
        if (target > 0xFF)
            die("R_W65816_IMM8 to '" + symName + "' = 0x" +
                std::to_string(target) + " out of range");
        buf[off] = static_cast<uint8_t>(target & 0xFF);
        break;
    case R_W65816_IMM16:
        // Keep only the low 16 bits.  In single-bank programs this
        // is a tautology (target IS 16-bit); in multi-segment
        // programs the target may live in a different bank, but
        // IMM16 absolute uses DBR for the bank at runtime, so
        // patching just the low 16 bits is correct as long as the
        // caller's DBR points at the target's bank.
        buf[off]     = static_cast<uint8_t>(target & 0xFF);
        buf[off + 1] = static_cast<uint8_t>((target >> 8) & 0xFF);
        // Record for cRELOC emission so the Loader patches this
        // 2-byte operand to (segPlacedBase + offsetRef) at load
        // time. Without this, `lda absConst` reads from the wrong
        // address when the segment doesn't land at link-time-base
        // (e.g., link-time-base=0x1000 but Loader places at bank:0).
        // A target below the text base is never an intra-segment
        // relocatable site: it is an undefined-weak symbol (resolveSym
        // resolves those to 0) or an absolute address.  Recording a
        // cRELOC for it would (a) underflow offsetRef = target - textBase
        // (omfEmit rejects it as out-of-range) and (b) make the Loader
        // rewrite a genuine null to segPlacedBase, breaking the
        // `if (weakFn) weakFn()` null test that the null is meant to fail.
        // recordCRelocSite handles the gate; byteCnt=2 distinguishes
        // from IMM24 (3) so omfEmit emits cRELOC ByteCnt=2 here.
        // Optimization: when text-base's low 16 bits are zero, the
        // link-time baked operand (target & 0xFFFF) is already equal
        // to (segPlacedBase + offsetRef) & 0xFFFF — because the
        // Loader places KIND=0x1000 segments at bank-aligned bases
        // (segPlacedBase.lo16 = 0), and we baked target = textBase +
        // offsetRef, so the patch is a no-op.  Skipping it eliminates
        // 50–90% of cRELOC entries in typical builds (most data refs
        // are IMM16); the GS/OS Loader walks each cRELOC site in a
        // linear loop, so the per-boot reloc-application time drops
        // proportionally.  Programs that link with a non-bank-aligned
        // text-base (e.g. bare-metal at $1000) still get the cRELOC.
        if ((gTextBaseForSites & 0xFFFFu) != 0)
            recordCRelocSite(patchAddr, target, /*byteCnt=*/2, /*bitShift=*/0);
        break;
    case R_W65816_BANK16:
        // 2-byte patch: byte 0 = bank of target, byte 1 = 0 (pad).
        // The Loader's cRELOC pass overwrites these bytes with
        // (segPlacedBase + offsetRef) >> 16 at load time, so at runtime
        // the value reflects the actually-placed bank.
        buf[off]     = static_cast<uint8_t>((target >> 16) & 0xFF);
        buf[off + 1] = 0;
        // bitShift=16: cRELOC Loader patches the bank byte from
        // (segPlacedBase + offsetRef) >> 16 at load time.
        recordCRelocSite(patchAddr, target, /*byteCnt=*/2, /*bitShift=*/16);
        break;
    case R_W65816_IMM24:
        if (target > 0xFFFFFF)
            die("R_W65816_IMM24 to '" + symName + "' = 0x" +
                std::to_string(target) + " out of range");
        buf[off]     = static_cast<uint8_t>(target & 0xFF);
        buf[off + 1] = static_cast<uint8_t>((target >> 8) & 0xFF);
        buf[off + 2] = static_cast<uint8_t>((target >> 16) & 0xFF);
        // Only intra-segment refs need cRELOC; cross-bank refs (to
        // GS/OS dispatcher etc.) target absolute fixed addresses
        // and shouldn't be relocated by the Loader.  recordCRelocSite
        // applies the same gates as R_W65816_IMM16.
        recordCRelocSite(patchAddr, target, /*byteCnt=*/3, /*bitShift=*/0);
        break;
    case R_W65816_PCREL8:
        pcrelDisp = static_cast<int64_t>(target) - (static_cast<int64_t>(patchAddr) + 1);
        if (pcrelDisp < -128 || pcrelDisp > 127) {
            char msg[256];
            std::snprintf(msg, sizeof(msg),
                "R_W65816_PCREL8 to '%s' out of branch range (%lld bytes)",
                symName.c_str(), (long long)pcrelDisp);
            die(msg);
        }
        buf[off] = static_cast<uint8_t>(pcrelDisp & 0xFF);
        break;
    case R_W65816_PCREL16:
        pcrelDisp = static_cast<int64_t>(target) - (static_cast<int64_t>(patchAddr) + 2);
        if (pcrelDisp < -32768 || pcrelDisp > 32767)
            die("R_W65816_PCREL16 to '" + symName +
                "' out of BRL range");
        buf[off]     = static_cast<uint8_t>(pcrelDisp & 0xFF);
        buf[off + 1] = static_cast<uint8_t>((pcrelDisp >> 8) & 0xFF);
        break;
    case R_W65816_DATA32:
        // 4-byte LE absolute.  Used in DWARF .debug_* sections
        // (sectionBase + addend) and user `.long` directives.  The
        // 65816 has only a 24-bit address space, so the high byte
        // is always zero — but we MUST write all 4 bytes because
        // DWARF readers expect a clean 32-bit slot.  Writing only 3
        // bytes corrupts the next field (often a length / opcode in
        // .debug_line, leading to unit_length = 0 footgun this
        // patch series exists to fix).
        buf[off]     = static_cast<uint8_t>(target & 0xFF);
        buf[off + 1] = static_cast<uint8_t>((target >> 8) & 0xFF);
        buf[off + 2] = static_cast<uint8_t>((target >> 16) & 0xFF);
        buf[off + 3] = 0;
        // Record a cRELOC site for intra-segment DATA32 references so
        // the OMF Loader patches the 24-bit address (low/mid/bank)
        // when the segment is placed at a non-zero bank.  This is the
        // C-ABI-critical path: a `void *` field in a static parm
        // block (e.g. `__GsosOpenParm.pathname`) gets emitted by the
        // compiler as `.long path`, which we lower as DATA32.  Without
        // a cRELOC the slot stays at link-time bank=0, GS/OS reads
        // the parm block's pathname pointer as $00:offset, and Open
        // fails with $40 (invalidAccess on a garbage path).  The
        // DWARF .debug_* path is excluded by the target-in-segment
        // check (debug sections live in the sidecar, addresses below
        // textBase) -- same guard as IMM16/BANK16/IMM24.  ByteCnt=3
        // patches the low 3 bytes of the 4-byte slot at load time,
        // leaving the high (pad) byte at 0 (writes the resolved
        // 24-bit value bank:offset with bitShift=0 == no shift).
        recordCRelocSite(patchAddr, target, /*byteCnt=*/3, /*bitShift=*/0);
        break;
    case R_W65816_PCREL32:
        // 4-byte signed PC-relative.  PCREL displacements have the
        // PC pointing past the slot — the convention used by every
        // other PCREL reloc in this file (PCREL8 adds 1, PCREL16
        // adds 2), so PCREL32 adds 4.
        pcrelDisp = static_cast<int64_t>(target) - (static_cast<int64_t>(patchAddr) + 4);
        // No range check: 32-bit signed displacement covers the
        // full address space.  In practice this fires for DWARF
        // intra-section diffs where target and patchAddr live in
        // the same section, so pcrelDisp is small.
        buf[off]     = static_cast<uint8_t>(pcrelDisp & 0xFF);
        buf[off + 1] = static_cast<uint8_t>((pcrelDisp >> 8) & 0xFF);
        buf[off + 2] = static_cast<uint8_t>((pcrelDisp >> 16) & 0xFF);
        buf[off + 3] = static_cast<uint8_t>((pcrelDisp >> 24) & 0xFF);
        break;
    default: {
        char msg[128];
        std::snprintf(msg, sizeof(msg),
            "unhandled relocation type %u to '%s'", rtype, symName.c_str());
        die(msg);
    }
    }
}

// Phase C.2 — text-section reloc patcher with per-seg / cross-seg
// classification.  In multi-seg-reloc mode (gMultiSegRelocMode), an
// IMM24 site whose target lives in a different text segment is
// recorded as a cINTERSEG (Loader patches at load time after picking
// banks); the 3 bytes at the patch site are zeroed so the Loader's
// ADD computes (targetSegPlacedBase + targetOff) cleanly.  Intra-seg
// IMM24/IMM16 sites still bake link-time-absolute bytes and are
// recorded as cRELOC (per-seg list).  Sites whose target is outside
// every text segment (rodata / bss / init_array, or an undefined-weak
// resolving to 0) fall through to the legacy applyReloc path.
//
// Outside multi-seg-reloc mode, this is a thin wrapper that just
// calls applyReloc + recordCRelocSite via the legacy code path.
static void applyTextReloc(std::vector<uint8_t> &buf, uint32_t off,
                            uint32_t patchAddr, uint32_t target,
                            uint8_t rtype,
                            const std::string &symName,
                            uint32_t patchSeg,
                            const std::vector<TextSeg> &segments,
                            bool targetIsWeakNull = false) {
    // UNDEFINED-WEAK reference resolved to absolute 0 (resolveSym's
    // STB_WEAK branch).  This is the `if (fp) fp();` null-stub pattern:
    // e.g. libc's getchar() does `if (__getByte) return __getByte();`
    // against the weak, link-time-unresolved __getByte console hook, so
    // clang emits a 2-byte near JSR whose IMM16 operand is the hook's
    // address -- 0 -- guarded by a null test that is dead at runtime.
    //
    // Two things force special handling here rather than the normal
    // classification below:
    //   1. The baked operand MUST stay literally 0 so the `if (fp)` test
    //      keeps failing.  A cRELOC/cINTERSEG that rewrote those bytes to
    //      a placed segment base would make the null test pass and run the
    //      dead call into garbage.  So we emit NO relocation record.
    //   2. With --text-base 0 the null address (0) falls inside segment
    //      1's [base, base+size) range, so findSegContaining() reports
    //      targetSeg == 1.  A patch site in an overflow segment
    //      (patchSeg > 1) then looks like a cross-segment reference and
    //      the 2-byte IMM16/PCREL form is rejected further down (a near
    //      JSR has no room for the bank byte the Loader would need).  With
    //      a non-zero --text-base the same reference resolves cleanly via
    //      the targetSeg == 0 path; baking the literal 0 here restores
    //      that behaviour regardless of the text base or the patch seg.
    //
    // Bake the literal little-endian value (0, or 0 + a weak addend) into
    // the absolute/data operand and return with no side effects.  PCREL
    // forms (a branch to a null symbol) are nonsensical and never weak-
    // null in practice, so they fall through to the normal path.
    if (targetIsWeakNull) {
        switch (rtype) {
        case R_W65816_IMM8:
        case R_W65816_IMM16:
        case R_W65816_BANK16:
        case R_W65816_IMM24:
        case R_W65816_DATA32: {
            uint32_t w = relocWidth(rtype);
            for (uint32_t i = 0; i < w; ++i)
                buf[off + i] = static_cast<uint8_t>((target >> (8 * i)) & 0xFF);
            return;
        }
        default:
            break;
        }
    }
    if (!gMultiSegRelocMode) {
        applyReloc(buf, off, patchAddr, target, rtype, symName);
        return;
    }
    if (patchSeg == 0 || patchSeg > segments.size()) {
        applyReloc(buf, off, patchAddr, target, rtype, symName);
        return;
    }
    const TextSeg &pseg = segments[patchSeg - 1];
    uint32_t targetSeg = findSegContaining(target, segments);
    // Cross-segment FUNCTION POINTER (IMM16 &func to a function in seg2+):
    // clang forms the pointer as func's 16-bit offset + the $00BE seg1 bank,
    // which is wrong for a function in another bank.  Redirect the IMM16 to
    // the seg1 JML trampoline reserved for this target (pre-pass) so the
    // pointer (seg1 offset + seg1 bank) is valid and tail-jumps into the real
    // function.  Runs BEFORE the cross-seg/intra split because the common
    // crash is an INTRA-seg2 &func (targetSeg == patchSeg); targetSeg>1 (a
    // non-seg1 bank) is the real key.  No cRELOC: the baked value is a seg1
    // offset and seg1 (KIND=0x1000) always bank-aligns, so the low16 is
    // already the runtime offset (matches the --text-base 0 IMM16 contract).
    if (rtype == R_W65816_IMM16 && targetSeg > 1) {
        uint32_t targetOff = target - segments[targetSeg - 1].base;
        auto tIt = gTrampMap.find(TrampKey{targetSeg, targetOff});
        if (tIt != gTrampMap.end()) {
            uint32_t trampAddr = segments[0].base
                               + gTrampBase + kTrampBytes * tIt->second;
            applyReloc(buf, off, patchAddr, trampAddr, rtype, symName);
            return;
        }
    }
    // Non-text target (rodata / bss / init_array / weak-zero) — use
    // the legacy applyReloc path (which records via recordCRelocSite
    // into gImm24Sites for back-compat AND also pushes into the new
    // per-seg list for seg 1, since rodata is in seg 1's bank by
    // design).
    if (targetSeg == 0) {
        // BANK-0 DATA/BSS target (inside SEG1's payload) referenced from an overflow segment: the
        // GS/OS Loader honors at most PAGE alignment (it ignores OMF ALIGN=$10000), so seg1 loads
        // at an arbitrary in-bank offset and every baked data operand from another segment misses
        // by that offset (measured live on the RetroNet IIgs client: functions packed into seg1
        // worked, everything else read/wrote unowned memory - MAME memory-tap proven).  Emit
        // cINTERSEG records against seg 1 so the Loader writes the true placed address: 3 bytes
        // for IMM24/DATA32, 2 for IMM16 (bank comes from DBR - BANKSIZE=$10000 keeps seg1 within
        // one bank), 2+shift16 for BANK16.
        if (gRecordSites && patchSeg >= 1 && !segments.empty() &&
            (rtype == R_W65816_IMM24 || rtype == R_W65816_DATA32 ||
             rtype == R_W65816_IMM16 || rtype == R_W65816_BANK16) &&
            (target & 0xFF0000u) == 0u && target != 0u) {
            uint8_t cnt = (rtype == R_W65816_IMM24 || rtype == R_W65816_DATA32) ? 3 : 2;
            for (uint8_t i = 0; i < cnt; ++i)
                buf[off + i] = 0;
            InterImm24Site s;
            s.patchSeg  = static_cast<uint16_t>(patchSeg);
            s.patchOff  = static_cast<uint16_t>(patchAddr - pseg.base);
            s.targetSeg = 1;
            s.targetOff = static_cast<uint16_t>(target);
            s.byteCnt   = cnt;
            s.bitShift  = (rtype == R_W65816_BANK16) ? 16 : 0;
            gInterImm24Sites.push_back(s);
            gBank0DataSites++;
            return;
        }
        applyReloc(buf, off, patchAddr, target, rtype, symName);
        // Also push into the per-seg list with the same gates as
        // recordCRelocSite (target in same bank as textBase and
        // >= textBase).  Without these gates a target=0 reference
        // (undefined-weak) would underflow offsetRef.
        if (gRecordSites &&
            (rtype == R_W65816_IMM24 || rtype == R_W65816_DATA32 ||
             rtype == R_W65816_IMM16 || rtype == R_W65816_BANK16)) {
            uint32_t targetBank = target & 0xFF0000;
            uint32_t baseBank   = gTextBaseForSites & 0xFF0000;
            if (targetBank == baseBank && target >= gTextBaseForSites) {
                Imm24Site s;
                s.patchOff  = patchAddr - pseg.base;
                s.offsetRef = target - gTextBaseForSites;
                s.byteCnt   = (rtype == R_W65816_IMM24 ||
                               rtype == R_W65816_DATA32) ? 3 : 2;
                s.bitShift  = (rtype == R_W65816_BANK16) ? 16 : 0;
                gIntraImm24SitesPerSeg[patchSeg].push_back(s);
            }
        }
        return;
    }
    // Cross-segment target: emit cINTERSEG.  Currently only IMM24
    // (3-byte JSL/JML/STAlong/LDAlong/DATA32) is supported.  IMM16
    // cross-seg refs can't be safely patched at load time (the bank
    // byte the Loader could write doesn't fit), so they're rejected.
    if (targetSeg != patchSeg) {
        if (rtype != R_W65816_IMM24 && rtype != R_W65816_DATA32) {
            char msg[200];
            std::snprintf(msg, sizeof(msg),
                "cross-segment reloc to '%s' uses %u-byte form; only "
                "IMM24 / DATA32 (3-byte JSL/JML/STAlong/LDAlong) supports "
                "cINTERSEG patching under dynamic Loader placement",
                symName.c_str(), relocWidth(rtype));
            die(msg);
        }
        // Zero the 3 bytes so the Loader's cINTERSEG ADD doesn't
        // double-count.  DATA32's high byte (the pad) stays untouched.
        buf[off]     = 0;
        buf[off + 1] = 0;
        buf[off + 2] = 0;
        if (gRecordSites) {
            const TextSeg &tseg = segments[targetSeg - 1];
            InterImm24Site s;
            s.patchSeg  = static_cast<uint16_t>(patchSeg);
            s.patchOff  = static_cast<uint16_t>(patchAddr - pseg.base);
            s.targetSeg = static_cast<uint8_t>(targetSeg);
            s.targetOff = static_cast<uint16_t>(target - tseg.base);
            s.byteCnt   = 3;
            s.bitShift  = 0;
            gInterImm24Sites.push_back(s);
        }
        return;
    }
    // Intra-segment in multi-seg mode: bake bytes + record per-seg
    // cRELOC.  Do the byte write via applyReloc (which handles the
    // type-specific layout) then push into the per-seg list.
    applyReloc(buf, off, patchAddr, target, rtype, symName);
    if (gRecordSites && (rtype == R_W65816_IMM24 || rtype == R_W65816_DATA32 ||
                          rtype == R_W65816_IMM16 || rtype == R_W65816_BANK16)) {
        Imm24Site s;
        s.patchOff  = patchAddr - pseg.base;
        s.offsetRef = target    - pseg.base;
        s.byteCnt   = (rtype == R_W65816_IMM24 || rtype == R_W65816_DATA32) ? 3 : 2;
        s.bitShift  = (rtype == R_W65816_BANK16) ? 16 : 0;
        gIntraImm24SitesPerSeg[patchSeg].push_back(s);
    }
}

struct Linker {
    std::vector<std::unique_ptr<InputObject>> objs;
    uint32_t textBase   = 0x8000;
    uint32_t rodataBase = 0;
    uint32_t bssBase    = 0x2000;
    bool     gcSections = true;
    // Multi-segment support.  segmentCap == 0 means "no cap" — produce
    // a single-segment image (existing behaviour).  Non-zero caps the
    // bytes per text segment; overflow text sections get bank-aligned
    // bases starting at segmentBankBase.
    uint32_t segmentCap        = 0;
    uint32_t segmentBankBase   = 0x040000;
    std::string manifestPath;
    // Phase C.2 — opt-in for ExpressLoad-multi-seg cINTERSEG emission.
    // When true AND --reloc-out is set, link816 zeros cross-seg IMM24
    // patch sites and records them in gInterImm24Sites for omfEmit to
    // emit as cINTERSEG (0xF6) opcodes.  When false, cross-seg IMM24
    // sites stay link-time-absolute (the existing static-multi-seg
    // behaviour used by runMultiSeg.sh).
    bool multiSegExpressLoad = false;
    // ProDOS file metadata for the resulting OMF.  Set via --filetype /
    // --aux.  Used by disk-image builders (e.g. cppo, Cadius) to set
    // the on-disk filetype/aux when copying the .bin onto a 2mg.
    // For GS/OS apps: filetype=$B3 (S16), aux=0.
    // For GNO shell commands: filetype=$B5 (EXE), aux=0 (or $DC00 for
    // non-compliant programs that bypass GNO's keyboard buffer).
    // -1 sentinel = "not set" (caller hasn't asked for a sidecar).
    int32_t  fileType = -1;
    int32_t  auxType  = -1;
    // When true, writeMap() also dumps STB_LOCAL symbols (function-
    // internal labels like __udivmod_core, file-static C objects, etc).
    // Required by the function-attribution profiler so PC samples that
    // fall inside libgcc helpers / file-static functions get attributed
    // to a meaningful name instead of '?'.  OFF by default to keep
    // smoke greps that depend on global-only map output stable.
    bool     mapLocals = false;

    // Per-section identity: (object index, section index within obj).
    using SecID = std::pair<size_t, uint32_t>;
    std::set<SecID>                liveSecs;
    std::map<std::string, SecID>   symToSection;

    // Build the "global symbol name -> (objIdx, secIdx) where defined"
    // map.  Honors weak vs strong: strong def overrides weak; first
    // weak-only def wins.  Used by computeLiveSet() to follow cross-
    // object reloc references back to their defining section.
    void buildSymToSection() {
        std::map<std::string, bool> strongSeen;
        for (size_t fi = 0; fi < objs.size(); ++fi) {
            const auto &obj = *objs[fi];
            for (const Symbol &sym : obj.symbols) {
                if (sym.name.empty()) continue;
                if (sym.bind == STB_LOCAL) continue;
                if (sym.shndx == SHN_UNDEF || sym.shndx == SHN_ABS ||
                    sym.shndx == SHN_COMMON ||
                    sym.shndx >= obj.sections.size())
                    continue;
                bool thisStrong = (sym.bind != STB_WEAK);
                auto sit = strongSeen.find(sym.name);
                if (sit == strongSeen.end()) {
                    symToSection[sym.name] = {fi, sym.shndx};
                    strongSeen[sym.name] = thisStrong;
                } else if (thisStrong && !sit->second) {
                    symToSection[sym.name] = {fi, sym.shndx};
                    sit->second = true;
                }
            }
        }
    }

    // Compute the live-section set via BFS from roots (entry point,
    // init_array sections — crt0 walks them at runtime).  Without
    // gc-sections, every section is implicitly live.
    void computeLiveSet() {
        if (!gcSections) return;
        buildSymToSection();
        std::vector<SecID> work;
        auto markLive = [&](SecID s) {
            if (liveSecs.insert(s).second) work.push_back(s);
        };
        // Roots: entry symbols.  __start is the canonical crt0 entry;
        // also keep main (crt0 calls it) and __indirTarget (used by
        // __jsl_indir).  Plus any defined symbol whose name starts
        // with __ (linker-defined globals like __heap_start are also
        // synthesized but their section refs follow naturally).
        for (const char *root : {"__start", "_start", "main",
                                 "__indirTarget", "__jsl_indir"}) {
            auto it = symToSection.find(root);
            if (it != symToSection.end()) markLive(it->second);
        }
        // crt0's init-loop walks .init_array via the linker-defined
        // boundary symbols __init_array_start/_end.  All init_array
        // sections must therefore be considered live.  Same for
        // .fini_array if any object provides it.
        for (size_t fi = 0; fi < objs.size(); ++fi) {
            for (uint32_t idx : objs[fi]->sectionsByKind("init_array"))
                markLive({fi, idx});
        }
        // BFS: each live section's relocs reference symbols whose
        // defining sections are in turn live.  Local refs via section
        // symbols (STT_SECTION) resolve within the same object.
        for (size_t i = 0; i < work.size(); ++i) {
            SecID cur = work[i];
            const auto &obj = *objs[cur.first];
            auto relIt = obj.relocs.find(cur.second);
            if (relIt == obj.relocs.end()) continue;
            for (const Reloc &r : relIt->second) {
                if (r.symIdx >= obj.symbols.size()) continue;
                const Symbol &sym = obj.symbols[r.symIdx];
                if (sym.shndx != SHN_UNDEF &&
                    sym.shndx != SHN_ABS &&
                    sym.shndx != SHN_COMMON &&
                    sym.shndx < obj.sections.size()) {
                    // Local def (incl. STT_SECTION refs).
                    markLive({cur.first, sym.shndx});
                    continue;
                }
                // External — look up the global definition.
                auto sit = symToSection.find(sym.name);
                if (sit != symToSection.end()) markLive(sit->second);
                // Else: undefined external; resolveSym() will die later
                // (or the user explicitly declared the ref weak).
            }
        }
    }

    bool isLive(size_t fi, uint32_t idx) const {
        if (!gcSections) return true;
        return liveSecs.count({fi, idx}) > 0;
    }

    // Per-object, per-section: in-merged-text/rodata/bss offset.
    // For text: textWithin gives the offset within the *segment* the
    // section is placed in; textSegOf names which segment (1-based).
    // Single-segment builds put everything in segment 1; multi-segment
    // builds may scatter sections across segments.
    struct ObjOffsets {
        uint32_t                     textBaseInMerged   = 0;
        uint32_t                     rodataBaseInMerged = 0;
        uint32_t                     bssBaseInMerged    = 0;
        uint32_t                     initBaseInMerged   = 0;
        std::map<uint32_t, uint32_t> textWithin;     // offset within its segment
        std::map<uint32_t, uint32_t> textSegOf;      // section idx -> segment num (1-based)
        std::map<uint32_t, uint32_t> rodataWithin;
        std::map<uint32_t, uint32_t> bssWithin;
        std::map<uint32_t, uint32_t> initWithin;
    };
    std::vector<ObjOffsets>          objOff;
    std::map<std::string, uint32_t>  globalSyms;
    // Local symbol map (STB_LOCAL).  Populated alongside globalSyms but
    // kept separate so resolution never accidentally picks a name-collided
    // local from another TU.  Emitted by writeMap when --map-locals is set.
    // Key format: "name@objBasename" so two TUs each with a file-static
    // helper of the same name don't collide on insertion.
    std::map<std::string, uint32_t>  localSyms;

    // Load one ELF object or, when `path` is a Unix `ar` archive, every object
    // member it holds.  Archive support lets JoeyLib examples link against a
    // real libjoey.a exactly like the other ports; --gc-sections then drops
    // whatever the image doesn't reference, matching classic member selection.
    void addObject(const std::string &path) {
        std::vector<uint8_t> data = readFile(path);
        static const unsigned char AR_MAGIC[8] =
            {'!', '<', 'a', 'r', 'c', 'h', '>', '\n'};
        if (data.size() >= 8 && std::memcmp(data.data(), AR_MAGIC, 8) == 0) {
            addArchive(path, data);
            return;
        }
        auto o = std::make_unique<InputObject>();
        o->path = path;
        o->raw  = std::move(data);
        o->parse();
        objs.push_back(std::move(o));
    }

    // Split a GNU-format `ar` archive into its object members and load each as
    // its own InputObject (path "archive.a(member)", so the file-static
    // local-symbol key name@basename stays unique per member).  Skips the
    // symbol index ("/") and long-name string table ("//").
    void addArchive(const std::string &path,
                    const std::vector<uint8_t> &data) {
        size_t      pos = 8;                  // past "!<arch>\n"
        std::string longNames;                // "//" GNU long-name table
        size_t      members = 0;

        while (pos + 60 <= data.size()) {
            const char *hdr = reinterpret_cast<const char *>(&data[pos]);
            if (hdr[58] != '`' || hdr[59] != '\n')
                die("'" + path + "': corrupt archive member header");

            char sizeField[11];
            std::memcpy(sizeField, hdr + 48, 10);
            sizeField[10] = '\0';
            long msize = std::strtol(sizeField, nullptr, 10);
            if (msize < 0)
                die("'" + path + "': bad archive member size");
            size_t dataOff = pos + 60;
            if (dataOff + static_cast<size_t>(msize) > data.size())
                die("'" + path + "': archive member overruns file");

            char nameField[17];
            std::memcpy(nameField, hdr, 16);
            nameField[16] = '\0';
            std::string name(nameField);
            while (!name.empty() && name.back() == ' ') name.pop_back();

            bool isObject = true;
            if (name == "/" || name == "/SYM64/" ||
                name == "__.SYMDEF" || name == "__.SYMDEF SORTED") {
                isObject = false;             // archive symbol index
            } else if (name == "//") {
                longNames.assign(reinterpret_cast<const char *>(&data[dataOff]),
                                 static_cast<size_t>(msize));
                isObject = false;             // GNU long-name string table
            } else if (name.size() >= 2 && name[0] == '/' &&
                       std::isdigit((unsigned char)name[1])) {
                size_t off = static_cast<size_t>(
                    std::strtoul(name.c_str() + 1, nullptr, 10));
                name.clear();                 // "/<offset>" -> resolve in "//"
                for (size_t i = off; i < longNames.size() &&
                                     longNames[i] != '\n' && longNames[i] != '/';
                     ++i)
                    name.push_back(longNames[i]);
            } else if (!name.empty() && name.back() == '/') {
                name.pop_back();              // GNU short name "foo.o/"
            }

            if (isObject) {
                auto o = std::make_unique<InputObject>();
                o->path = path + "(" + name + ")";
                o->raw.assign(data.begin() + dataOff,
                              data.begin() + dataOff +
                                  static_cast<size_t>(msize));
                o->parse();
                objs.push_back(std::move(o));
                ++members;
            }

            pos = dataOff + static_cast<size_t>(msize);
            if (pos & 1) ++pos;               // members are 2-byte aligned
        }
        if (members == 0)
            die("'" + path + "': archive holds no object members");
    }

    // Resolve a reloc to (target, name) using the symbol table and the
    // per-object section base map.  Requires link() to have populated
    // objOff/globalSyms/lastLayout first.  Returns false when the
    // referenced section is one we don't track (e.g. another .debug_*
    // section); strict callers should die() on false, lenient callers
    // (the DWARF sidecar) should leave the bytes object-local.
    // `weakNull` (optional) reports whether the symbol was an UNDEFINED
    // WEAK reference that ELF semantics resolve to absolute 0 (the
    // `if (fp) fp();` null-stub pattern -- see the STB_WEAK branch below).
    // The text-reloc patcher needs to know this because, with --text-base
    // 0, that 0 collides with segment 1's bank-0 address range and would
    // otherwise be mis-classified as a real cross-segment reference.
    bool resolveSym(const InputObject &obj, const ObjOffsets &oo,
                    const Reloc &r,
                    uint32_t &target, std::string &resolvedName,
                    bool *weakNull = nullptr) const {
        if (weakNull) *weakNull = false;
        if (r.symIdx >= obj.symbols.size())
            die(obj.path + ": reloc symIdx out of range");
        const Symbol &sym = obj.symbols[r.symIdx];
        if (sym.type == STT_SECTION) {
            if (sym.shndx >= obj.sections.size())
                die(obj.path + ": section symbol shndx out of range");
            const auto &refSec = obj.sections[sym.shndx];
            std::string kind = sectionKind(refSec.name);
            uint32_t base = 0;
            if (kind == "text") {
                auto wIt = oo.textWithin.find(sym.shndx);
                auto sIt = oo.textSegOf.find(sym.shndx);
                uint32_t segNum = (sIt == oo.textSegOf.end()) ? 1 : sIt->second;
                uint32_t segBase = (segNum >= 1 && segNum <= lastLayout.segments.size())
                                       ? lastLayout.segments[segNum - 1].base
                                       : lastLayout.textBase;
                base = segBase + (wIt == oo.textWithin.end() ? 0 : wIt->second);
            } else if (kind == "rodata") {
                auto wIt = oo.rodataWithin.find(sym.shndx);
                base = lastLayout.rodataBase + oo.rodataBaseInMerged
                     + (wIt == oo.rodataWithin.end() ? 0 : wIt->second);
            } else if (kind == "bss") {
                auto wIt = oo.bssWithin.find(sym.shndx);
                base = lastLayout.bssBase + oo.bssBaseInMerged
                     + (wIt == oo.bssWithin.end() ? 0 : wIt->second);
            } else if (kind == "init_array") {
                auto wIt = oo.initWithin.find(sym.shndx);
                base = lastLayout.initBase + oo.initBaseInMerged
                     + (wIt == oo.initWithin.end() ? 0 : wIt->second);
            } else if (kind == "debug") {
                // Intra-debug reference (e.g., .debug_info entry that
                // refers to a string at offset N in .debug_str).  The
                // sidecar emits each object's debug sections back-to-
                // back without recompacting offsets, so a section-
                // relative target IS the right value to patch — base
                // is 0 and the addend carries the in-section offset.
                base = 0;
            } else {
                resolvedName = refSec.name;
                return false;
            }
            target = base + r.addend;
            resolvedName = refSec.name;
            return true;
        }
        auto sIt = globalSyms.find(sym.name);
        if (sIt == globalSyms.end()) {
            // Undefined symbol.  If the reference itself is weak, ELF
            // semantics say "leave the address as 0" — do that and
            // return resolved-true so the caller doesn't error out.
            // This lets libc reference optional helpers (e.g. the
            // GS/OS file backend in iigsGsos.s) without forcing every
            // caller to link iigsGsos.o.  Strong unresolved → caller
            // dies.
            resolvedName = sym.name;
            if (sym.bind == STB_WEAK) {
                target = 0 + r.addend;
                if (weakNull) *weakNull = true;
                return true;
            }
            return false;
        }
        target = sIt->second + r.addend;
        resolvedName = sym.name;
        return true;
    }

    Layout link(std::vector<uint8_t> &outImage) {
        // 1. Layout: each obj's sections at running offsets.
        // Text is segment-aware: when --segment-cap is set and total
        // text would exceed it, sections spill into segments 2, 3, ...
        // each based at successive bank boundaries starting from
        // segmentBankBase.  Other sections (rodata/bss/init_array)
        // stay in segment 1's bank for v1 — multi-bank data refs
        // would need IMM16 promotion to long which we don't do yet.
        objOff.resize(objs.size());
        uint32_t curText = 0, curRodata = 0, curBss = 0, curInit = 0;
        std::vector<uint32_t> segSizes = {0};   // bytes packed into each segment (1-based; index 0 = seg 1)
        uint32_t curSeg = 1;
        computeLiveSet();

        // Pass 1: place sections that carry an explicit `.text.segN` pin
        // (Phase A — per-segment placement attribute).  Each pinned
        // section is placed directly into segSizes[N-1], growing the
        // segSizes vector as needed; the section's textBaseInMerged is
        // accumulated in source-order alongside non-pinned sections so
        // intra-object section ordering stays stable for `__text_*`
        // boundary symbols.
        auto growToSeg = [&](uint32_t seg) {
            while (segSizes.size() < seg) segSizes.push_back(0);
        };
        for (size_t fi = 0; fi < objs.size(); ++fi) {
            ObjOffsets &oo = objOff[fi];
            oo.textBaseInMerged = curText;
            for (uint32_t idx : objs[fi]->sectionsByKind("text")) {
                if (!isLive(fi, idx)) continue;
                uint32_t sz = objs[fi]->sections[idx].size;
                uint32_t wanted = requestedSegment(objs[fi]->sections[idx].name);
                if (wanted == 0) continue;   // unpinned — pass 2 handles it
                growToSeg(wanted);
                oo.textSegOf[idx]  = wanted;
                oo.textWithin[idx] = segSizes[wanted - 1];
                segSizes[wanted - 1] += sz;
                curText += sz;
                if (segmentCap && segSizes[wanted - 1] > segmentCap) {
                    char msg[200];
                    std::snprintf(msg, sizeof(msg),
                        "pinned text in seg %u exceeds --segment-cap %u "
                        "(have %u bytes pinned, cap %u)",
                        wanted, segmentCap,
                        segSizes[wanted - 1], segmentCap);
                    die(msg);
                }
            }
        }

        // Pass 2: greedy-pack non-pinned text sections.  When the cap is
        // exceeded — or when curSeg's slot is already full from pinned
        // content — advance to the next slot with room.  Empty sections
        // fit anywhere; sections larger than the cap get their own seg
        // (we don't split a single section across banks — it'd violate
        // intra-section PCREL and 16-bit absolute addressing).
        for (size_t fi = 0; fi < objs.size(); ++fi) {
            ObjOffsets &oo = objOff[fi];
            for (uint32_t idx : objs[fi]->sectionsByKind("text")) {
                if (!isLive(fi, idx)) continue;
                if (requestedSegment(objs[fi]->sections[idx].name) != 0) continue;
                uint32_t sz = objs[fi]->sections[idx].size;
                if (sz > 0) {
                    while (true) {
                        if (curSeg > segSizes.size()) segSizes.push_back(0);
                        if (segSizes[curSeg - 1] == 0) break;
                        if (!segmentCap) break;
                        if (segSizes[curSeg - 1] + sz <= segmentCap) break;
                        curSeg++;
                    }
                } else if (curSeg > segSizes.size()) {
                    segSizes.push_back(0);
                }
                oo.textSegOf[idx]  = curSeg;
                oo.textWithin[idx] = segSizes[curSeg - 1];
                segSizes[curSeg - 1] += sz;
                curText += sz;
            }
        }

        for (size_t fi = 0; fi < objs.size(); ++fi) {
            ObjOffsets &oo = objOff[fi];
            oo.rodataBaseInMerged = curRodata;
            for (uint32_t idx : objs[fi]->sectionsByKind("rodata")) {
                if (!isLive(fi, idx)) continue;
                oo.rodataWithin[idx] = curRodata - oo.rodataBaseInMerged;
                curRodata += objs[fi]->sections[idx].size;
            }
            oo.bssBaseInMerged = curBss;
            for (uint32_t idx : objs[fi]->sectionsByKind("bss")) {
                if (!isLive(fi, idx)) continue;
                oo.bssWithin[idx] = curBss - oo.bssBaseInMerged;
                curBss += objs[fi]->sections[idx].size;
            }
            oo.initBaseInMerged = curInit;
            for (uint32_t idx : objs[fi]->sectionsByKind("init_array")) {
                if (!isLive(fi, idx)) continue;
                oo.initWithin[idx] = curInit - oo.initBaseInMerged;
                curInit += objs[fi]->sections[idx].size;
            }
        }

        // ---- TRAMPOLINE pre-pass (multi-seg only).  Find every IMM16
        // address-take of a function the layout placed in a text seg N>1 (a
        // different bank than seg1, whose bank is what $00BE/&func will use)
        // and reserve one `JML func` trampoline in seg1 per distinct target.
        // Done HERE -- after section->seg packing, before the segment bases --
        // so growing segSizes[0] shifts rodata/bss/globalSyms automatically
        // (no second pass).  A name->(seg,off) map mirrors the globalSyms
        // strong/weak resolution so these keys match findSegContaining()'s in
        // applyTextReloc; offsets-within-seg are base-independent.
        gTrampMap.clear();
        gTrampList.clear();
        gTrampBase = 0;
        if (gMultiSegRelocMode) {
            std::map<std::string, TrampKey> symSeg;     // name -> (seg, off)
            std::map<std::string, bool>     symStrong;
            for (size_t fi = 0; fi < objs.size(); ++fi) {
                const auto &obj = *objs[fi];
                const auto &oo  = objOff[fi];
                for (const Symbol &sym : obj.symbols) {
                    if (sym.name.empty()) continue;
                    if (sym.shndx == SHN_UNDEF || sym.shndx == SHN_ABS ||
                        sym.shndx == SHN_COMMON || sym.shndx >= obj.sections.size())
                        continue;
                    if (!isLive(fi, sym.shndx)) continue;
                    if (sectionKind(obj.sections[sym.shndx].name) != "text") continue;
                    auto sIt = oo.textSegOf.find(sym.shndx);
                    uint32_t segNum = (sIt == oo.textSegOf.end()) ? 1 : sIt->second;
                    auto wIt = oo.textWithin.find(sym.shndx);
                    uint32_t off = (wIt == oo.textWithin.end() ? 0 : wIt->second)
                                 + sym.value;
                    bool thisStrong = (sym.bind != STB_WEAK);
                    auto it = symStrong.find(sym.name);
                    if (it == symStrong.end()) {
                        symSeg[sym.name] = {segNum, off};
                        symStrong[sym.name] = thisStrong;
                    } else if (thisStrong && !it->second) {
                        symSeg[sym.name] = {segNum, off};
                        it->second = true;
                    }   // weak-after-* / strong-after-strong: keep first
                }
            }
            // Resolve a reloc's target to a (text-seg>1, off) key, matching
            // resolveSym's section-symbol vs named-symbol handling.
            auto crossSegFunc = [&](const InputObject &obj, const ObjOffsets &oo,
                                    const Reloc &r, TrampKey &key) -> bool {
                if (r.symIdx >= obj.symbols.size()) return false;
                const Symbol &sym = obj.symbols[r.symIdx];
                if (sym.type == STT_SECTION) {
                    if (sym.shndx >= obj.sections.size()) return false;
                    if (sectionKind(obj.sections[sym.shndx].name) != "text")
                        return false;
                    auto sIt = oo.textSegOf.find(sym.shndx);
                    uint32_t segNum = (sIt == oo.textSegOf.end()) ? 1 : sIt->second;
                    if (segNum <= 1) return false;
                    auto wIt = oo.textWithin.find(sym.shndx);
                    key.seg = segNum;
                    key.off = (wIt == oo.textWithin.end() ? 0 : wIt->second)
                            + (uint32_t)r.addend;
                    return true;
                }
                auto it = symSeg.find(sym.name);
                if (it == symSeg.end() || it->second.seg <= 1) return false;
                key.seg = it->second.seg;
                key.off = it->second.off + (uint32_t)r.addend;
                return true;
            };
            for (size_t fi = 0; fi < objs.size(); ++fi) {
                const auto &obj = *objs[fi];
                const auto &oo  = objOff[fi];
                for (uint32_t textIdx : obj.sectionsByKind("text")) {
                    if (!isLive(fi, textIdx)) continue;
                    auto rIt = obj.relocs.find(textIdx);
                    if (rIt == obj.relocs.end()) continue;
                    for (const Reloc &r : rIt->second) {
                        if (r.type != R_W65816_IMM16) continue;
                        // Skip INTRA-section refs: asm self-modification
                        // (`sta label+2`), local branch/label address-takes,
                        // any abs operand into the section's OWN code.  Those
                        // use the offset with the current bank (DBR/PBR), NOT
                        // the $00BE function-pointer form, so trampolining them
                        // would redirect the write/branch to the table and
                        // break it.  A real C &func crosses sections (clang
                        // -ffunction-sections), so it is never skipped here.
                        if (r.symIdx < obj.symbols.size() &&
                            obj.symbols[r.symIdx].shndx == textIdx) {
                            if (gReportSelfMod) {
                                const uint8_t *sd = obj.sectionData(textIdx);
                                uint8_t opc = (r.offset >= 1) ? sd[r.offset - 1] : 0;
                                // Absolute STORE opcodes (DBR-relative): sta/
                                // stx/sty/stz abs, abs,x, abs,y -> self-mod.
                                bool isStore =
                                    opc == 0x8D || opc == 0x9D || opc == 0x99 ||
                                    opc == 0x8E || opc == 0x8C ||
                                    opc == 0x9C || opc == 0x9E;
                                // Absolute READ opcodes (DBR-relative): a read
                                // of a table embedded in the code's OWN .text
                                // gets the wrong bank when DBR != the code bank
                                // (e.g. code in seg2, DBR=globals seg1).
                                bool isLoad =
                                    opc == 0xAD || opc == 0xBD || opc == 0xB9 ||
                                    opc == 0xAE || opc == 0xBE ||
                                    opc == 0xAC || opc == 0xBC ||
                                    opc == 0x0D || opc == 0x2D || opc == 0x4D ||
                                    opc == 0x6D || opc == 0xCD || opc == 0xED ||
                                    opc == 0x2C;
                                if (isStore || isLoad) {
                                    const Symbol &ts = obj.symbols[r.symIdx];
                                    std::fprintf(stderr,
                                        "link816: SELF-REF %s (bank-sensitive): "
                                        "%s .text+0x%x opcode 0x%02X -> %s+%d "
                                        "[fix: DBR=PBR around it]\n",
                                        isStore ? "STORE" : "READ",
                                        obj.path.c_str(), r.offset, opc,
                                        ts.name.empty() ? "<sec>" : ts.name.c_str(),
                                        r.addend);
                                }
                            }
                            continue;
                        }
                        TrampKey key{};
                        if (!crossSegFunc(obj, oo, r, key)) continue;
                        if (gTrampMap.find(key) == gTrampMap.end()) {
                            gTrampMap[key] = (uint32_t)gTrampList.size();
                            gTrampList.push_back(key);
                        }
                    }
                }
            }
            if (!gTrampList.empty()) {
                gTrampBase = segSizes[0];
                segSizes[0] += kTrampBytes * (uint32_t)gTrampList.size();
                // The trampolines are executable code appended to seg 1's text,
                // so the HARD limit is the IO window ($C000), not the soft
                // --segment-cap (a text-distribution target that leaves margin
                // below the window).  The small trampoline table may use that
                // margin; an actual IO-window crossing is still caught here
                // (and again by the textSize check below).
                if (segSizes[0] > kIoWindowStart)
                    die("seg 1 text + cross-segment trampoline table crosses "
                        "the IIgs IO window 0xC000");
                std::fprintf(stderr,
                    "link816: %zu cross-seg function trampolines (%u bytes) "
                    "in seg 1\n", gTrampList.size(),
                    kTrampBytes * (uint32_t)gTrampList.size());
            }
        }

        // Build the segment list with bases.
        std::vector<TextSeg> segments;
        segments.resize(segSizes.size());
        segments[0].segNum = 1;
        segments[0].base   = textBase;
        segments[0].size   = segSizes[0];
        for (size_t k = 1; k < segSizes.size(); ++k) {
            segments[k].segNum = static_cast<uint32_t>(k + 1);
            segments[k].base   = segmentBankBase + 0x10000u * (k - 1);
            segments[k].size   = segSizes[k];
        }

        Layout L;
        L.textBase   = textBase;
        L.textSize   = segSizes[0];   // segment-1 text size (bank 0)
        L.bssSize    = curBss;
        L.rodataBase = rodataBase ? rodataBase : (textBase + segSizes[0]);
        L.rodataSize = curRodata;
        L.segments   = std::move(segments);
        // Reject a --rodata-base that overlaps text.  Without this
        // check, the gap between text-end and rodata-base goes
        // negative, the unsigned subtraction wraps to ~4GB, and the
        // image-write loop creates a multi-gigabyte file with no
        // diagnostic.  Caught while sweeping --rodata-base values
        // in a strtok layout-sensitivity test.
        if (rodataBase && L.rodataBase < L.textBase + L.textSize) {
            char msg[160];
            std::snprintf(msg, sizeof(msg),
                "--rodata-base 0x%X overlaps text 0x%X+%u "
                "(rodata must start at or after 0x%X)",
                L.rodataBase, L.textBase, L.textSize,
                L.textBase + L.textSize);
            die(msg);
        }
        // Hard-fail if text crosses into the IO window.  Code there
        // would fetch instructions from hardware registers.  Programs
        // that grow this big need to split into bank 1 (not currently
        // supported by this linker).
        if (overlapsIoWindow(L.textBase, L.textSize) &&
            L.textBase < kIoWindowStart) {
            char msg[160];
            std::snprintf(msg, sizeof(msg),
                "text [0x%X+%u] crosses IIgs IO window 0x%X-0x%X — "
                "shrink the program or split into bank 1",
                L.textBase, L.textSize,
                kIoWindowStart, kIoWindowEnd - 1);
            die(msg);
        }
        // Auto-skip the IO window if rodata would land there.  Loads
        // from the IO range return hardware register values (and
        // writes hit the soft switches), so any rodata data that
        // landed there would silently corrupt at runtime — caught
        // when math.o grew past ~28KB and pushed string literals into
        // the IO range, breaking smoke #86 (hash table strcmp
        // returned garbage because the keys read back as IO register
        // values).  Catches both "starts before IO, crosses in" and
        // "starts inside IO" cases.
        if (!rodataBase && overlapsIoWindow(L.rodataBase, L.rodataSize)) {
            // Page-align upward past the IO window.
            L.rodataBase = kIoWindowEnd;
            // Pad the image so the gap between text-end and rodata-
            // start is just zeros.  The runInMame loader skips
            // writes to the IO range so the soft switches stay
            // intact.
        }
        // .init_array goes immediately after .rodata in the image.
        L.initBase = L.rodataBase + L.rodataSize;
        L.initSize = curInit;
        // Init_array can also land in IO if rodata ends just before
        // or starts inside.
        if (overlapsIoWindow(L.initBase, L.initSize)) {
            L.initBase = kIoWindowEnd;
        }
        // After all skips, sanity-check we haven't gone past the LC
        // ceiling.  The IIgs LC area is $D000-$FFFF (12KB usable when
        // bank 1 is selected; the $E000-$FFFF chunk is common to both
        // banks).  crt0's `lda $C083` read-twice enables RAM read+write
        // for the entire LC range, so we can use through $FFFF.
        if (L.initBase + L.initSize > kBank0Ceiling) {
            char msg[160];
            std::snprintf(msg, sizeof(msg),
                "rodata + init_array [0x%X+%u] exceeds bank-0 LC "
                "ceiling 0x%X — shrink the runtime or split into bank 1",
                L.rodataBase,
                (unsigned)(L.initBase + L.initSize - L.rodataBase),
                kBank0Ceiling);
            die(msg);
        }
        uint32_t initBase = L.initBase;
        // bss-base safety: default 0x2000 only works if text doesn't
        // grow past it.  When text + rodata + init_array would
        // overflow the 0x2000 bss start, shift bss above them so
        // crt0's bss-init doesn't zero loaded text bytes.  Caller
        // can still force a specific bssBase via --bss-base.
        //
        // IIgs bank-0 hazard zones:
        //   $C000-$CFFF: IO and soft switches (ALWAYS unusable —
        //                reads/writes hit hardware registers).
        //   $D000-$DFFF: Language Card 1 area.  Read-only ROM by
        //                default; crt0 enables LC1 RAM via the
        //                $C083 soft switch (read-twice trick) so
        //                BSS placed here is writable.
        //   $E000-$FFFF: bank-0 ROM area, also LC-switched but
        //                we don't enable it (less common need).
        // Skip past the IO window if BSS would land there; LC1
        // ($D000-$DFFF) IS now usable thanks to crt0's soft-switch
        // enable.  Above $DFFF means BSS exceeds 16-bit range —
        // bail clearly rather than silently corrupt.
        uint32_t loadEnd = L.initBase + L.initSize;
        L.bssBase    = bssBase;
        // If --bss-base specifies a non-bank-0 address, the user is
        // explicitly placing BSS in a different bank — don't auto-
        // adjust around bank-0 hazard zones (IO window, LC1) or
        // collide with the text/rodata/init load area.  Validate the
        // bank choice + intra-bank fit instead.
        bool bssOutOfBank0 = (L.bssBase >= 0x010000u);
        if (!bssOutOfBank0) {
            if (L.bssBase < loadEnd) {
                // Page-align upward for nicer addresses in the map.
                L.bssBase = (loadEnd + 0xFF) & ~0xFFu;
                if (L.bssBase >= kIoWindowStart && L.bssBase < kIoWindowEnd) {
                    L.bssBase = kIoWindowEnd;
                }
            }
            // Also bump past the IO window if BSS would SPAN it
            // (starts below kIoWindowStart, extends into or past it).
            // BSS writes to the IO range hit soft switches — caught
            // by smoke #128 hex dumper, where ~954-byte BSS pushed
            // past kIoWindowStart and BSS-clear writes crashed MAME.
            if (overlapsIoWindow(L.bssBase, L.bssSize)) {
                L.bssBase = kIoWindowEnd;
            }
            if (L.bssBase + L.bssSize > kBank0Ceiling) {
                char msg[256];
                std::snprintf(msg, sizeof(msg),
                    "bss [0x%X+%u] exceeds bank-0 ceiling 0x%X — "
                    "shrink runtime, or pass --bss-base 0xNN0000 "
                    "(multi-bank BSS up to 4 banks now supported)",
                    L.bssBase, L.bssSize, kBank0Ceiling);
                die(msg);
            }
        } else {
            // Multi-bank BSS: BSS may now span multiple consecutive
            // banks.  crt0 clears bank-by-bank using DBR-relative
            // STZ abs,X — see __bss_seg_count / __bss_segN_* symbols
            // below.  Capped at 4 segments (= 256KB BSS max) which
            // covers any realistic IIgs program.
            uint32_t firstBank = (L.bssBase >> 16) & 0xFF;
            uint32_t lastBank  = ((L.bssBase + L.bssSize - 1) >> 16) & 0xFF;
            uint32_t segCount  = lastBank - firstBank + 1;
            if (segCount > 4) {
                char msg[200];
                std::snprintf(msg, sizeof(msg),
                    "bss [0x%X+%u] spans %u banks — limit is 4 (256KB)",
                    L.bssBase, L.bssSize, segCount);
                die(msg);
            }
        }
        // Publish layout now so resolveSym() can read it during reloc
        // application (it's a const member that uses lastLayout).
        lastLayout = L;

        // Synthesize linker-defined symbols so crt0 / startup code
        // can find the section extents.  These must NOT be in the
        // input objects; we provide them.
        globalSyms["__text_start"]        = L.textBase;
        globalSyms["__text_end"]          = L.textBase + L.textSize;
        globalSyms["__rodata_start"]      = L.rodataBase;
        globalSyms["__rodata_end"]        = L.rodataBase + L.rodataSize;
        globalSyms["__init_array_start"]  = initBase;
        globalSyms["__init_array_end"]    = initBase + curInit;
        globalSyms["__bss_start"]         = L.bssBase;
        globalSyms["__bss_end"]           = L.bssBase + L.bssSize;
        // Multi-bank-BSS support: split __bss_start into the 16-bit
        // intra-bank offset and the 8-bit bank byte.  crt0 needs the
        // bank byte separately so it can temporarily set DBR to that
        // bank for the BSS-clear loop (which uses STZ abs,X — DBR-
        // relative — and so reads bytes from the wrong bank if BSS
        // is placed in a non-zero bank).  Also emit __bss_size as a
        // 16-bit count for the loop boundary; doing so saves crt0
        // from doing the (__bss_end - __bss_start) subtraction at
        // runtime, and keeps the count clean even when __bss_start
        // and __bss_end straddle bank-boundary arithmetic.
        globalSyms["__bss_lo16"]          = L.bssBase & 0xFFFF;
        globalSyms["__bss_bank"]          = (L.bssBase >> 16) & 0xFF;
        globalSyms["__bss_size"]          = L.bssSize <= 0xFFFFu ? L.bssSize
                                                                  : 0xFFFFu;
        // Multi-bank BSS segment table — up to 4 entries.  Each segment
        // has (lo16, bank, size16).  Segment 0 starts at __bss_lo16 in
        // __bss_bank; segments 1..N-1 start at offset 0 in successive
        // banks.  crt0 walks __bss_seg{0..N-1}_size and skips when 0.
        {
            uint32_t curBase = L.bssBase;
            uint32_t curRem  = L.bssSize;
            uint32_t segIdx  = 0;
            const char *sizeNames[4] = {
                "__bss_seg0_size", "__bss_seg1_size",
                "__bss_seg2_size", "__bss_seg3_size"
            };
            const char *bankNames[4] = {
                "__bss_seg0_bank", "__bss_seg1_bank",
                "__bss_seg2_bank", "__bss_seg3_bank"
            };
            const char *lo16Names[4] = {
                "__bss_seg0_lo16", "__bss_seg1_lo16",
                "__bss_seg2_lo16", "__bss_seg3_lo16"
            };
            // Cap segment size to 0xFF00 (= 65280) so the 16-bit
            // CPX in crt0 doesn't wrap to 0 on a full-bank segment.
            // Excess bytes in that bank stay uncleared at link time —
            // we'd need to chain a second segment in the same bank to
            // cover them.  Implementation: track per-segment max as
            // 0xFF00, and if a single bank needs more, allocate two
            // segments in that bank.
            constexpr uint32_t MAX_SEG = 0xFF00u;
            for (segIdx = 0; segIdx < 4; segIdx++) {
                uint32_t bankEnd = (curBase & 0xFF0000u) + 0x10000u;
                uint32_t avail   = bankEnd - curBase;
                uint32_t seg     = curRem < avail ? curRem : avail;
                if (seg > MAX_SEG) seg = MAX_SEG;
                globalSyms[lo16Names[segIdx]] = curBase & 0xFFFF;
                globalSyms[bankNames[segIdx]] = (curBase >> 16) & 0xFF;
                globalSyms[sizeNames[segIdx]] = seg;
                curRem  -= seg;
                if (curRem == 0) { segIdx++; break; }
                curBase += seg;  // advance within bank or to next
            }
            // Zero out any unused segment slots so crt0 sees size=0.
            for (uint32_t i = segIdx; i < 4; i++) {
                globalSyms[lo16Names[i]] = 0;
                globalSyms[bankNames[i]] = 0;
                globalSyms[sizeNames[i]] = 0;
            }
        }
        // __heap_start / __heap_end: pick the largest contiguous safe
        // range above bss_end.  Without this, the previous hardcoded
        // heap_end=$BF00 gave heap_end < heap_start whenever BSS
        // spilled into LC1 — malloc immediately returned NULL.
        // If bank-0 heap would be tiny (<512B) push to LC1 (just past
        // the IO window).
        //
        // Bank-0 heap top sits one page below the IO window so heap
        // alloc bumps never touch soft switches.  kIoWindowStart - 0x100
        // = $BF00; encoded here for clarity rather than as a raw
        // constant.
        constexpr uint32_t kBank0HeapTop = kIoWindowStart - 0x100;  // $BF00
        constexpr uint32_t MIN_HEAP = 512;
        // Prefer a bank-0 heap below the IO window.  Pushing the heap into the
        // bank-0 LC ($D000-$FFFF) is a LAST RESORT and unreliable: GS/OS uses
        // that RAM, corrupting heap-resident state (a surface struct landing
        // there had its palette read back as garbage -- both the $D000-$DFFF
        // bank-switched half AND the $E000-$FFFF common half are unsafe).  The
        // driver (clang-build.sh) avoids it by lowering the multi-seg
        // --segment-cap until rodata + BSS + heap fit below the IO window; this
        // LC fallback only fires for a program too large for even that.
        uint32_t heapStart = L.bssBase + L.bssSize;
        if (heapStart >= kBank0HeapTop && heapStart < kIoWindowEnd) {
            heapStart = kIoWindowEnd;        // skip IO window + tiny tail
        } else if (heapStart < kBank0HeapTop &&
                   (kBank0HeapTop - heapStart) < MIN_HEAP) {
            heapStart = kIoWindowEnd;        // bank-0 sliver too small; use LC
        }
        globalSyms["__heap_start"] = heapStart;
        if (heapStart < kIoWindowStart) {
            globalSyms["__heap_end"] = kBank0HeapTop;
        } else if (heapStart < kBank0Ceiling) {
            // Heap in LC area ($D000-$FFFF).  crt0's $C083 read-twice
            // enables read+write for the whole range.  Cap at 0xFFFE
            // (not kBank0Ceiling) — relocation patching at the use
            // site is 16-bit and 0x10000 truncates to 0; malloc would
            // then think heap_end < heap_start and return NULL.
            globalSyms["__heap_end"] = 0xFFFE;
        } else {
            // Unreachable — bssBase + bssSize > kBank0Ceiling check above.
            globalSyms["__heap_end"] = heapStart;
        }

        // 2. Build global symbol map.  Honor weak vs strong binding:
        //   - strong def overrides any prior weak def
        //   - strong + strong is a multiple-definition error
        //   - weak + weak: first wins (any choice would be valid)
        //   - weak after strong: ignored
        // Without this, the previous "last def wins" rule meant a weak
        // libc stub (e.g. putchar) could silently overwrite a user's
        // strong override depending on link order.
        std::map<std::string, bool> isStrong;  // name -> strong-def seen
        for (size_t fi = 0; fi < objs.size(); ++fi) {
            const auto &obj = *objs[fi];
            const auto &oo  = objOff[fi];
            for (const Symbol &sym : obj.symbols) {
                if (sym.name.empty()) continue;
                if (sym.shndx == SHN_UNDEF || sym.shndx == SHN_ABS ||
                    sym.shndx == SHN_COMMON || sym.shndx >= obj.sections.size())
                    continue;
                // Skip dead sections under gc-sections — their symbols
                // would otherwise resolve to whatever junk address the
                // missing oo.{text,rodata,bss,init}Within entry implies.
                if (!isLive(fi, sym.shndx)) continue;
                const auto &sec = obj.sections[sym.shndx];
                std::string kind = sectionKind(sec.name);
                uint32_t addr = 0;
                if (kind == "text") {
                    auto it = oo.textWithin.find(sym.shndx);
                    auto sIt = oo.textSegOf.find(sym.shndx);
                    uint32_t segNum = (sIt == oo.textSegOf.end()) ? 1 : sIt->second;
                    uint32_t segBase = (segNum >= 1 && segNum <= L.segments.size())
                                           ? L.segments[segNum - 1].base
                                           : textBase;
                    addr = segBase + (it == oo.textWithin.end() ? 0 : it->second)
                         + sym.value;
                } else if (kind == "rodata") {
                    auto it = oo.rodataWithin.find(sym.shndx);
                    addr = L.rodataBase + oo.rodataBaseInMerged
                         + (it == oo.rodataWithin.end() ? 0 : it->second)
                         + sym.value;
                } else if (kind == "bss") {
                    auto it = oo.bssWithin.find(sym.shndx);
                    addr = L.bssBase + oo.bssBaseInMerged
                         + (it == oo.bssWithin.end() ? 0 : it->second)
                         + sym.value;
                } else if (kind == "init_array") {
                    auto it = oo.initWithin.find(sym.shndx);
                    addr = initBase + oo.initBaseInMerged
                         + (it == oo.initWithin.end() ? 0 : it->second)
                         + sym.value;
                } else {
                    continue;
                }
                if (sym.bind == STB_LOCAL) {
                    // Per-object disambiguated key for writeMap()/--map-locals.
                    std::string base = obj.path;
                    size_t slash = base.find_last_of('/');
                    if (slash != std::string::npos) base = base.substr(slash + 1);
                    localSyms[sym.name + "@" + base] = addr;
                    // Also expose under the bare name (FIRST-WINS) for smoke
                    // tests that grep the map for file-static names (e.g.
                    // ctor1) -- but do NOT enter the strong/weak resolution
                    // below: file-local statics are file-scoped, so the SAME
                    // static name in two TUs (e.g. uber.c and surface.c each
                    // with `static gStage`) is NOT a multiple-definition error.
                    // A real global of the same name still overrides this (the
                    // `sit == end` branch below replaces the bare entry).
                    if (globalSyms.find(sym.name) == globalSyms.end())
                        globalSyms[sym.name] = addr;
                    continue;
                }
                bool thisStrong = (sym.bind != STB_WEAK);
                auto sit = isStrong.find(sym.name);
                if (sit == isStrong.end()) {
                    globalSyms[sym.name] = addr;
                    isStrong[sym.name] = thisStrong;
                } else if (thisStrong && !sit->second) {
                    // strong over weak — replace.
                    globalSyms[sym.name] = addr;
                    sit->second = true;
                } else if (thisStrong && sit->second) {
                    die("multiple strong definitions of '" + sym.name + "'");
                }
                // weak after strong, or weak after weak: keep first.
            }
        }

        // 3. Build per-segment text buffers + rodata.  Skip dead
        // sections under gc-sections.
        std::vector<std::vector<uint8_t>> segTextBufs(L.segments.size());
        for (size_t k = 0; k < L.segments.size(); ++k)
            segTextBufs[k].reserve(L.segments[k].size);
        for (size_t fi = 0; fi < objs.size(); ++fi) {
            const auto &oo = objOff[fi];
            for (uint32_t idx : objs[fi]->sectionsByKind("text")) {
                if (!isLive(fi, idx)) continue;
                auto sIt = oo.textSegOf.find(idx);
                uint32_t segNum = (sIt == oo.textSegOf.end()) ? 1 : sIt->second;
                const uint8_t *p = objs[fi]->sectionData(idx);
                auto &buf = segTextBufs[segNum - 1];
                buf.insert(buf.end(), p, p + objs[fi]->sections[idx].size);
            }
        }
        // ---- Emit the cross-seg trampoline table at the end of seg 1's text.
        // Each entry is `JML func` (0x5C + 24-bit target, zeroed); its cINTERSEG
        // site makes the Loader patch the target to func's runtime bank:offset.
        // segTextBufs[0] holds exactly gTrampBase bytes here (all seg-1 text),
        // so entry i lands at offset gTrampBase + 4*i, and applyTextReloc
        // redirects each cross-seg IMM16 &func to that offset.
        if (!gTrampList.empty()) {
            auto &b0 = segTextBufs[0];
            for (size_t i = 0; i < gTrampList.size(); ++i) {
                b0.push_back(0x5C);                    // JML abs-long opcode
                b0.push_back(0); b0.push_back(0); b0.push_back(0);  // 24-bit tgt
                if (gRecordSites) {
                    InterImm24Site s;
                    s.patchSeg  = 1;
                    s.patchOff  = static_cast<uint16_t>(gTrampBase + 4 * i + 1);
                    s.targetSeg = static_cast<uint8_t>(gTrampList[i].seg);
                    s.targetOff = static_cast<uint16_t>(gTrampList[i].off);
                    s.byteCnt   = 3;
                    s.bitShift  = 0;
                    gInterImm24Sites.push_back(s);
                }
            }
        }
        std::vector<uint8_t> rodataBuf;
        rodataBuf.reserve(curRodata);
        for (size_t fi = 0; fi < objs.size(); ++fi) {
            for (uint32_t idx : objs[fi]->sectionsByKind("rodata")) {
                if (!isLive(fi, idx)) continue;
                const uint8_t *p = objs[fi]->sectionData(idx);
                rodataBuf.insert(rodataBuf.end(), p,
                                 p + objs[fi]->sections[idx].size);
            }
        }

        // 4. Apply relocations to text buffers (each in its own segment).
        for (size_t fi = 0; fi < objs.size(); ++fi) {
            const auto &obj = *objs[fi];
            const auto &oo  = objOff[fi];
            for (uint32_t textIdx : obj.sectionsByKind("text")) {
                if (!isLive(fi, textIdx)) continue;
                auto it = obj.relocs.find(textIdx);
                if (it == obj.relocs.end()) continue;
                auto sIt = oo.textSegOf.find(textIdx);
                uint32_t segNum = (sIt == oo.textSegOf.end()) ? 1 : sIt->second;
                uint32_t inSeg = oo.textWithin.at(textIdx);
                uint32_t segBase = L.segments[segNum - 1].base;
                auto &textBuf = segTextBufs[segNum - 1];
                for (const Reloc &r : it->second) {
                    uint32_t patchOff = inSeg + r.offset;
                    uint32_t patchAddr = segBase + patchOff;
                    uint32_t target;
                    std::string resolvedName;
                    bool targetWeakNull = false;
                    if (!resolveSym(obj, oo, r, target, resolvedName,
                                    &targetWeakNull))
                        die(obj.path + ": .text reloc to unresolved '"
                            + resolvedName + "'");
                    // PCREL relocs can't span banks (the displacement
                    // is intra-bank only).  Detect and report so the
                    // user can adjust packing.  IMM16 cross-bank is
                    // tolerated: 16-bit absolute uses DBR for the
                    // bank, which we keep at 0 by default (so refs
                    // to bank-0 data work from any code segment),
                    // and we can't statically know the target bank
                    // intent anyway.  An undefined-weak null target
                    // (address 0) is exempt: it is not a real cross-bank
                    // reference, just the `if (fp) fp();` null sentinel,
                    // and applyTextReloc bakes the literal 0 for it.
                    if (segmentCap && !targetWeakNull &&
                        (r.type == R_W65816_PCREL16 ||
                                       r.type == R_W65816_PCREL8)) {
                        uint32_t targetSegBank = target & 0xFF0000;
                        uint32_t patchSegBank  = segBase & 0xFF0000;
                        if (targetSegBank != patchSegBank) {
                            char msg[200];
                            std::snprintf(msg, sizeof(msg),
                                "%s: cross-bank PCREL reloc to '%s' (target bank "
                                "0x%X, code bank 0x%X) — adjust --segment-cap "
                                "or pack referenced section into the same segment",
                                obj.path.c_str(), resolvedName.c_str(),
                                targetSegBank, patchSegBank);
                            die(msg);
                        }
                    }
                    applyTextReloc(textBuf, patchOff, patchAddr, target,
                                    r.type, resolvedName, segNum, L.segments,
                                    targetWeakNull);
                }
            }
        }
        // Move per-segment patched text into the Layout for output.
        for (size_t k = 0; k < L.segments.size(); ++k)
            L.segments[k].body = std::move(segTextBufs[k]);
        // Re-publish layout now that segment bodies are populated —
        // writeMultiSegment reads from lastLayout.
        lastLayout = L;

        // 4b. Apply relocations to rodata/data buffer.  Globals like
        // `int *p = &v;` or `static const char *p = "x";` need their
        // initializer patched (the .o emits a placeholder 0 + an
        // R_W65816_IMM24 / R_W65816_DATA32 reloc — pointers are 24-bit
        // far on this target, so a `char *` initializer is a 3-byte
        // bank:offset value, not a 2-byte near offset).  Without this,
        // every initialized pointer or function-pointer table reads 0.
        //
        // This MUST go through applyTextReloc (NOT a bare applyReloc) so
        // that, in the multi-segment ExpressLoad model, the pointer's
        // BANK byte is patched at LOAD time the same way text-segment
        // pointer operands are.  The merged .data/.rodata image lives in
        // segment 1 (the entry/load bank) — it is appended to seg 1's
        // output image after the seg-1 text (see the outImage compose
        // below) — so patchSeg is always 1 here.  Routing through
        // applyTextReloc lets the existing per-segment classifier fire:
        //
        //   - target in .data/.rodata (outside every text seg's [base,
        //     base+textSize) range, so findSegContaining()==0): recorded
        //     as an intra-seg cRELOC in gIntraImm24SitesPerSeg[1].  The
        //     GS/OS Loader rewrites the 3 bytes to (seg1PlacedBase +
        //     offsetRef), supplying the runtime LOAD bank.  This is the
        //     gCurName case: the link-time value is $00:offset but the
        //     image loads at e.g. bank $08, and only the cRELOC carries
        //     the $08 to the pointer's bank byte.
        //   - target in a seg2+ text bank (a function the layout placed
        //     in an overflow bank): recorded as a cINTERSEG so the Loader
        //     supplies that segment's placed bank.
        //
        // Before this change the .data path recorded only into the FLAT
        // gImm24Sites list, which omfEmit consumes ONLY in single-segment
        // mode; in --multi-seg-expressload it reads the per-seg sidecars
        // (and suppresses the flat fallback for seg 1 once that per-seg
        // list is non-empty from the text relocs), so every .data pointer
        // initializer silently kept its link-time bank ($00) and crashed
        // the moment it was dereferenced from the real load bank.
        //
        // Outside multi-seg mode applyTextReloc is a thin wrapper that
        // delegates straight to applyReloc, so the single-segment path
        // (and its flat gImm24Sites cRELOC list) is byte-for-byte
        // unchanged.
        for (size_t fi = 0; fi < objs.size(); ++fi) {
            const auto &obj = *objs[fi];
            const auto &oo  = objOff[fi];
            for (uint32_t rdIdx : obj.sectionsByKind("rodata")) {
                if (!isLive(fi, rdIdx)) continue;
                auto it = obj.relocs.find(rdIdx);
                if (it == obj.relocs.end()) continue;
                uint32_t inMerged = oo.rodataBaseInMerged + oo.rodataWithin.at(rdIdx);
                for (const Reloc &r : it->second) {
                    uint32_t patchOff = inMerged + r.offset;
                    uint32_t patchAddr = L.rodataBase + patchOff;
                    uint32_t target;
                    std::string resolvedName;
                    bool targetWeakNull = false;
                    if (!resolveSym(obj, oo, r, target, resolvedName,
                                    &targetWeakNull))
                        die(obj.path + ": .rodata reloc to unresolved '"
                            + resolvedName + "'");
                    // patchSeg = 1: the merged rodata/.data image is part
                    // of segment 1's output image (text || gap || rodata).
                    applyTextReloc(rodataBuf, patchOff, patchAddr, target,
                                   r.type, resolvedName, /*patchSeg=*/1,
                                   L.segments, targetWeakNull);
                }
            }
        }

        // 5. Compose output: segment-1 text || (gap) || rodata.
        // bss is virtual.  Multi-segment builds emit additional text
        // segments separately (see writeSegmentImages); the main -o
        // output stays segment 1's image so existing single-segment
        // smoke checks still work unchanged.
        outImage.clear();
        const uint32_t seg1TextSize = static_cast<uint32_t>(L.segments[0].body.size());
        outImage = L.segments[0].body;
        if (L.rodataBase != textBase + seg1TextSize) {
            uint32_t gap = L.rodataBase - (textBase + seg1TextSize);
            outImage.insert(outImage.end(), gap, 0);
        }
        outImage.insert(outImage.end(), rodataBuf.begin(), rodataBuf.end());

        // Build init_array buffer + apply its relocations (entries are
        // 16-bit function pointers needing IMM16 reloc).
        std::vector<uint8_t> initBuf;
        initBuf.reserve(curInit);
        for (size_t fi = 0; fi < objs.size(); ++fi) {
            for (uint32_t idx : objs[fi]->sectionsByKind("init_array")) {
                if (!isLive(fi, idx)) continue;
                const uint8_t *p = objs[fi]->sectionData(idx);
                initBuf.insert(initBuf.end(), p,
                               p + objs[fi]->sections[idx].size);
            }
        }
        for (size_t fi = 0; fi < objs.size(); ++fi) {
            const auto &obj = *objs[fi];
            const auto &oo  = objOff[fi];
            for (uint32_t idx : obj.sectionsByKind("init_array")) {
                if (!isLive(fi, idx)) continue;
                auto it = obj.relocs.find(idx);
                if (it == obj.relocs.end()) continue;
                uint32_t inMerged = oo.initBaseInMerged + oo.initWithin.at(idx);
                for (const Reloc &r : it->second) {
                    uint32_t target;
                    std::string resolvedName;
                    if (!resolveSym(obj, oo, r, target, resolvedName))
                        die(obj.path + ": .init_array reloc to unresolved '"
                            + resolvedName + "'");
                    uint32_t patchOff  = inMerged + r.offset;
                    uint32_t patchAddr = initBase + patchOff;
                    applyReloc(initBuf, patchOff, patchAddr, target, r.type,
                               resolvedName);
                }
            }
        }
        outImage.insert(outImage.end(), initBuf.begin(), initBuf.end());
        return L;
    }

    // ----------------------------------------------------------------
    // DWARF sidecar.  Walks each input object and concatenates every
    // section whose name starts with `.debug_`.  Each section is
    // prefixed by an ASCII-readable header line:
    //
    //     ; OBJ <objname> SEC <sectionname> SIZE <bytes> RELOCS <n>
    //
    // followed by the section bytes after applying any text/rodata/bss/
    // init_array relocations from `.rela.<sec>`.  This means PCs in
    // .debug_info / .debug_line / .debug_aranges resolve to final-image
    // addresses and a consumer like llvm-dwarfdump (or a custom MAME
    // overlay) can map them back to source lines.
    //
    // Intra-debug references (e.g., .debug_info -> .debug_str offsets)
    // are *not* renumbered; we concatenate sections without recompacting,
    // so the original object-local offsets stay correct relative to each
    // object's slice of the sidecar.  A multi-TU consumer would need to
    // walk the slice headers to find the right base.
    void writeDebugSidecar(const std::string &path) const {
        std::ofstream f(path, std::ios::binary);
        if (!f) die("cannot open '" + path + "' for writing");
        f << "; llvm816 link816 DWARF sidecar v1\n";
        f << "; text/rodata/bss/init_array relocs applied to final-image addresses\n";
        f << "; intra-debug refs left object-local (per-OBJ slice scope)\n";
        size_t total = 0;
        size_t kept = 0;
        size_t patched = 0;
        for (size_t fi = 0; fi < objs.size(); ++fi) {
            const InputObject &obj = *objs[fi];
            const ObjOffsets  &oo  = objOff[fi];
            for (uint32_t idx = 0; idx < obj.sections.size(); ++idx) {
                const Section &sec = obj.sections[idx];
                if (sec.name.rfind(".debug_", 0) != 0) continue;
                if (sec.size == 0) continue;
                std::vector<uint8_t> data(sec.size);
                std::memcpy(data.data(), obj.raw.data() + sec.fileOffset,
                            sec.size);
                size_t applied = 0;
                size_t skipped = 0;
                auto it = obj.relocs.find(idx);
                if (it != obj.relocs.end()) {
                    for (const Reloc &r : it->second) {
                        uint32_t target;
                        std::string resolvedName;
                        if (!resolveSym(obj, oo, r, target, resolvedName)) {
                            skipped++;
                            continue;
                        }
                        uint32_t w = relocWidth(r.type);
                        if (w == 0 || r.offset + w > sec.size) {
                            // Unknown reloc type, or offset+width
                            // would walk off the section end.
                            // Defensively skip.
                            skipped++;
                            continue;
                        }
                        // patchAddr is only meaningful for PCREL types.
                        // DWARF .debug_* sections don't get a runtime
                        // load address, so PCREL within debug is
                        // structurally weird (the assembler converts
                        // intra-section diffs to PCREL, but the
                        // resulting displacement is sidecar-relative,
                        // not runtime-relative).  Pass 0 — same as
                        // the prior behaviour.  applyReloc ignores
                        // patchAddr for absolute DATA32 / IMMNN.
                        applyReloc(data, r.offset, 0, target, r.type,
                                   resolvedName);
                        applied++;
                    }
                }
                patched += applied;
                char hdr[256];
                std::snprintf(hdr, sizeof(hdr),
                    "; OBJ %s SEC %s SIZE %u RELOCS_APPLIED %zu RELOCS_SKIPPED %zu\n",
                    obj.path.c_str(), sec.name.c_str(), sec.size,
                    applied, skipped);
                f.write(hdr, std::strlen(hdr));
                f.write(reinterpret_cast<const char *>(data.data()), sec.size);
                f << "\n";
                total += sec.size;
                kept++;
            }
        }
        std::fprintf(stderr,
            "debug sidecar: %zu sections, %zu bytes, %zu relocs applied -> %s\n",
            kept, total, patched, path.c_str());
    }

    void writeMap(const std::string &path) const {
        std::ofstream f(path);
        if (!f) die("cannot open '" + path + "' for writing");
        char buf[256];
        // Section layout summary at top.
        std::snprintf(buf, sizeof(buf),
                      "# section layout\n"
                      ".text   : 0x%06x .. 0x%06x  (%6u bytes)\n"
                      ".rodata : 0x%06x .. 0x%06x  (%6u bytes)\n"
                      ".bss    : 0x%06x .. 0x%06x  (%6u bytes)\n",
                      lastLayout.textBase,
                      lastLayout.textBase + lastLayout.textSize,
                      lastLayout.textSize,
                      lastLayout.rodataBase,
                      lastLayout.rodataBase + lastLayout.rodataSize,
                      lastLayout.rodataSize,
                      lastLayout.bssBase,
                      lastLayout.bssBase + lastLayout.bssSize,
                      lastLayout.bssSize);
        f.write(buf, std::strlen(buf));
        // Per-input-file contributions to .text (size in bytes).
        std::snprintf(buf, sizeof(buf), "\n# per-input-file .text contributions\n");
        f.write(buf, std::strlen(buf));
        for (size_t fi = 0; fi < objs.size(); ++fi) {
            uint32_t bytes = 0;
            for (uint32_t idx : objs[fi]->sectionsByKind("text"))
                bytes += objs[fi]->sections[idx].size;
            std::snprintf(buf, sizeof(buf), "%6u  %s\n", bytes,
                          objs[fi]->path.c_str());
            f.write(buf, std::strlen(buf));
        }
        // Symbol table sorted by address.
        std::snprintf(buf, sizeof(buf), "\n# global symbols (sorted by address)\n");
        f.write(buf, std::strlen(buf));
        std::vector<std::pair<uint32_t, std::string>> sorted;
        for (const auto &kv : globalSyms) sorted.emplace_back(kv.second, kv.first);
        std::sort(sorted.begin(), sorted.end());
        for (const auto &p : sorted) {
            std::snprintf(buf, sizeof(buf), "0x%06x  %s\n",
                          p.first, p.second.c_str());
            f.write(buf, std::strlen(buf));
        }
        // Backwards-compat: also emit the old `name = 0x...` lines so
        // existing smoke greps still match.
        for (const auto &kv : globalSyms) {
            std::snprintf(buf, sizeof(buf), "%s = 0x%06x\n",
                          kv.first.c_str(), kv.second);
            f.write(buf, std::strlen(buf));
        }
        // Optional STB_LOCAL section.  Gated by --map-locals because the
        // pc2line.py funcAt() resolver matches "0x... name" lines anywhere
        // in the file; adding locals unconditionally would change function
        // attribution for any tool that reads the map without expecting
        // local names.  When the flag is on, emit a `# local symbols`
        // banner + the same `0x... name` line format used for globals,
        // but with the @objfile suffix stripped (so pc2line sees the
        // bare symbol name).  The profiler is the primary consumer.
        if (mapLocals && !localSyms.empty()) {
            std::snprintf(buf, sizeof(buf),
                          "\n# local symbols (sorted by address)\n");
            f.write(buf, std::strlen(buf));
            std::vector<std::pair<uint32_t, std::string>> localsSorted;
            for (const auto &kv : localSyms)
                localsSorted.emplace_back(kv.second, kv.first);
            std::sort(localsSorted.begin(), localsSorted.end());
            for (const auto &p : localsSorted) {
                // Strip "@objpath" disambiguation suffix for pc2line.
                std::string nm = p.second;
                size_t at = nm.find('@');
                if (at != std::string::npos) nm = nm.substr(0, at);
                std::snprintf(buf, sizeof(buf), "0x%06x  %s\n",
                              p.first, nm.c_str());
                f.write(buf, std::strlen(buf));
            }
        }
    }

    // Write per-segment images for segments 2..N (segment 1 is the
    // main -o output) and a JSON manifest describing all segments.
    // Image filename convention: <outBase>.seg<N>.bin where outBase is
    // the -o path with any trailing extension stripped.  Manifest JSON
    // is at the user-supplied --manifest path.
    void writeMultiSegment(const std::string &mainOutPath,
                            const std::string &mfPath,
                            const std::string &entrySym) const {
        if (lastLayout.segments.empty()) return;
        // Strip the extension from mainOutPath for per-segment names.
        std::string outBase = mainOutPath;
        size_t dot = outBase.find_last_of('.');
        size_t slash = outBase.find_last_of('/');
        if (dot != std::string::npos &&
            (slash == std::string::npos || dot > slash)) {
            outBase = outBase.substr(0, dot);
        }
        // Per-segment images for K >= 2.
        for (size_t k = 1; k < lastLayout.segments.size(); ++k) {
            const auto &seg = lastLayout.segments[k];
            char name[256];
            std::snprintf(name, sizeof(name), "%s.seg%u.bin",
                          outBase.c_str(), seg.segNum);
            std::ofstream f(name, std::ios::binary);
            if (!f) die(std::string("cannot open '") + name + "' for writing");
            f.write(reinterpret_cast<const char *>(seg.body.data()),
                    seg.body.size());
        }
        // Manifest.  Hand-rolled JSON (no external dep).
        if (mfPath.empty()) return;
        std::ofstream mf(mfPath);
        if (!mf) die("cannot open '" + mfPath + "' for writing");
        // Sized for the per-segment block below plus a long image path; the
        // block's fixed text is ~250 bytes, leaving generous path headroom.
        char buf[1024];
        std::snprintf(buf, sizeof(buf),
            "{\n"
            "  \"version\": 1,\n"
            "  \"main\": \"%s\",\n"
            "  \"entry\": \"%s\",\n"
            "  \"segments\": [\n", mainOutPath.c_str(), entrySym.c_str());
        mf << buf;
        for (size_t k = 0; k < lastLayout.segments.size(); ++k) {
            const auto &seg = lastLayout.segments[k];
            std::string imgPath = mainOutPath;
            if (k > 0) {
                char nm[256];
                std::snprintf(nm, sizeof(nm), "%s.seg%u.bin",
                              outBase.c_str(), seg.segNum);
                imgPath = nm;
            }
            uint32_t entryOff = 0;
            // Set entry_offset on whichever segment actually contains
            // the entry symbol — usually segment 1 (crt0's __start)
            // but could be any segment if user picks a non-standard
            // entry point.
            auto it = globalSyms.find(entrySym);
            if (it != globalSyms.end() && it->second >= seg.base &&
                it->second < seg.base + seg.body.size()) {
                entryOff = it->second - seg.base;
            }
            // BSS attribution: link() places ALL of rodata/bss/init_array
            // in segment 1's bank (see the layout comment near link() —
            // "Other sections ... stay in segment 1's bank").  So the
            // whole merged BSS span belongs to segment 1 (segments[0]);
            // every other segment carries no BSS.  Emit bss_start (the
            // absolute link address of BSS) and bss_size so omfEmit can
            // embed the span as LCONST zeros — giving the GS/OS Loader's
            // per-segment NewHandle coverage of BSS.  Without this the BSS
            // is phantom RAM the Memory Manager can hand to a later
            // NewHandle (segment/heap overlap corruption), and it is never
            // zeroed under GS/OS (crt0Gsos relies on the Loader LCONST-fill).
            uint32_t segBssStart = (k == 0) ? lastLayout.bssBase : 0u;
            uint32_t segBssSize  = (k == 0) ? lastLayout.bssSize : 0u;
            // omfEmit embeds this segment's BSS as trailing LCONST zeros, so
            // BSS must live in THIS segment's bank.  Multi-bank BSS
            // (--bss-base in a higher bank) cannot be embedded here, and
            // crt0Gsos relies on the Loader's LCONST fill (it runs no
            // bank-by-bank STZ clear like the bare-metal/GNO path), so the
            // out-of-bank BSS would be neither reserved nor zeroed.  Reject
            // the combination loudly rather than emit a broken file.
            if (segBssSize > 0u &&
                (segBssStart & 0xFF0000u) != (seg.base & 0xFF0000u)) {
                die("multi-segment ExpressLoad build has BSS outside the "
                    "text segment's bank; multi-bank BSS + --manifest is not "
                    "supported (BSS cannot be embedded or zeroed for GS/OS)");
            }
            std::snprintf(buf, sizeof(buf),
                "    {\n"
                "      \"num\": %u,\n"
                "      \"name\": \"SEG%u\",\n"
                "      \"base\": \"0x%06x\",\n"
                "      \"size\": %zu,\n"
                "      \"bss_start\": \"0x%06x\",\n"
                "      \"bss_size\": %u,\n"
                "      \"image\": \"%s\",\n"
                "      \"entry_offset\": \"0x%04x\"\n"
                "    }%s\n",
                seg.segNum, seg.segNum, seg.base, seg.body.size(),
                segBssStart, segBssSize,
                imgPath.c_str(), entryOff,
                (k + 1 < lastLayout.segments.size()) ? "," : "");
            mf << buf;
        }
        mf << "  ]\n}\n";
    }

    // Stash the last layout so writeMap can use it.
    Layout lastLayout;
};

// ---------------------------------------------------------------- CLI

static uint32_t parseInt(const std::string &s) {
    char *end = nullptr;
    unsigned long v = std::strtoul(s.c_str(), &end, 0);
    if (end == s.c_str() || *end != '\0')
        die("bad numeric value '" + s + "'");
    // 65816 addresses are 24-bit; reject anything that doesn't fit so
    // a typo like `--text-base 0x100000000` doesn't silently wrap to 0.
    if (v > 0xFFFFFF)
        die("address '" + s + "' exceeds 24-bit range");
    return static_cast<uint32_t>(v);
}

static void usage(const char *argv0) {
    std::fprintf(stderr,
        "usage: %s -o <output> [--text-base ADDR] [--rodata-base ADDR]\n"
        "           [--bss-base ADDR] [--map FILE] [--map-locals]\n"
        "           [--debug-out FILE] [--reloc-out FILE]\n"
        "           [--no-gc-sections] [--filetype N] [--aux N]\n"
        "           <input.o> ...\n"
        "\n"
        "  --reloc-out FILE   write IMM24 relocation site list (binary:\n"
        "                     <count:u32><patchOff:u32 offsetRef:u32>...)\n"
        "                     consumed by omfEmit --relocs to emit cRELOC\n"
        "                     opcodes for runtime bank-byte fixup.\n"
        "  --filetype N       ProDOS filetype (0..0xFF) for the OMF.  $B3=S16\n"
        "                     (GS/OS app), $B5=EXE (GNO shell command).  Writes\n"
        "                     <output>.meta sidecar consumed by disk-image\n"
        "                     builders.\n"
        "  --aux N            ProDOS auxtype (0..0xFFFF).  Typically 0 for\n"
        "                     compliant programs; $DC00 = GNO non-compliant\n"
        "                     (bypasses interrupt-driven keyboard buffer).\n",
        argv0);
    std::exit(2);
}

} // anonymous namespace

int main(int argc, char **argv) {
    std::string outPath;
    std::string mapPath;
    std::string debugOutPath;
    std::string relocOutPath;
    Linker linker;

    int i = 1;
    while (i < argc) {
        std::string a = argv[i];
        if (a == "-o" || a == "--output") {
            if (++i >= argc) usage(argv[0]);
            outPath = argv[i++];
        } else if (a == "--text-base") {
            if (++i >= argc) usage(argv[0]);
            linker.textBase = parseInt(argv[i++]);
        } else if (a == "--rodata-base") {
            if (++i >= argc) usage(argv[0]);
            linker.rodataBase = parseInt(argv[i++]);
        } else if (a == "--bss-base") {
            if (++i >= argc) usage(argv[0]);
            linker.bssBase = parseInt(argv[i++]);
        } else if (a == "--map") {
            if (++i >= argc) usage(argv[0]);
            mapPath = argv[i++];
        } else if (a == "--map-locals") {
            // Augment --map output with STB_LOCAL symbols.  Required for
            // function-attribution profiling so PC samples that fall into
            // libgcc helpers / file-static functions resolve to a name.
            linker.mapLocals = true;
            i++;
        } else if (a == "--debug-out") {
            if (++i >= argc) usage(argv[0]);
            debugOutPath = argv[i++];
        } else if (a == "--reloc-out") {
            if (++i >= argc) usage(argv[0]);
            relocOutPath = argv[i++];
        } else if (a == "--gc-sections") {
            // GC of unreachable sections is on by default; --gc-sections
            // is accepted as a no-op alias for clarity.  Use
            // --no-gc-sections to disable.
            i++;
        } else if (a == "--no-gc-sections") {
            linker.gcSections = false;
            i++;
        } else if (a == "--segment-cap") {
            if (++i >= argc) usage(argv[0]);
            linker.segmentCap = parseInt(argv[i++]);
        } else if (a == "--segment-bank-base") {
            if (++i >= argc) usage(argv[0]);
            linker.segmentBankBase = parseInt(argv[i++]);
        } else if (a == "--manifest") {
            if (++i >= argc) usage(argv[0]);
            linker.manifestPath = argv[i++];
        } else if (a == "--multi-seg-expressload") {
            linker.multiSegExpressLoad = true;
            i++;
        } else if (a == "--report-self-mod") {
            gReportSelfMod = true;
            i++;
        } else if (a == "--filetype") {
            if (++i >= argc) usage(argv[0]);
            linker.fileType = (int32_t)parseInt(argv[i++]);
            if (linker.fileType < 0 || linker.fileType > 0xFF)
                die("--filetype must be 0..0xFF");
        } else if (a == "--aux") {
            if (++i >= argc) usage(argv[0]);
            linker.auxType = (int32_t)parseInt(argv[i++]);
            if (linker.auxType < 0 || linker.auxType > 0xFFFF)
                die("--aux must be 0..0xFFFF");
        } else if (a == "-h" || a == "--help") {
            usage(argv[0]);
        } else if (!a.empty() && a[0] == '-') {
            die("unknown option '" + a + "'");
        } else {
            linker.addObject(a);
            i++;
        }
    }
    if (outPath.empty() || linker.objs.empty()) usage(argv[0]);

    // Enable IMM24 site recording before linking, so applyReloc populates
    // gImm24Sites for cRELOC sidecar emission.
    if (!relocOutPath.empty()) {
        gRecordSites = true;
        gTextBaseForSites = linker.textBase;
        gImm24Sites.clear();
        gIntraImm24SitesPerSeg.clear();
        gInterImm24Sites.clear();
    }
    // Phase C.2 — multi-seg-relocation mode is opt-in via
    // `--multi-seg-expressload`.  It changes the cross-segment IMM24
    // bake from "link-time absolute address" (the static-multi-seg
    // default consumed by runMultiSeg.sh's Lua loader) to "zero bytes
    // + cINTERSEG sidecar entry" (consumed by omfEmit --manifest
    // --expressload, which emits cINTERSEG 0xF6 opcodes so the GS/OS
    // Loader patches cross-seg JSLs after picking banks dynamically).
    gMultiSegRelocMode = linker.multiSegExpressLoad;

    std::vector<uint8_t> image;
    Layout L = linker.link(image);

    std::ofstream f(outPath, std::ios::binary);
    if (!f) die("cannot open '" + outPath + "' for writing");
    f.write(reinterpret_cast<const char *>(image.data()), image.size());

    // ProDOS filetype/aux sidecar.  Emit as `<output>.meta` — simple
    // line-oriented `key: 0xVALUE` format consumed by the disk-image
    // builder (cppo/Cadius/our scripts).  Default filetype if not
    // specified: 0xB3 (S16) for single-segment, the multi-segment
    // builder writes 0xB3 anyway.
    if (linker.fileType >= 0 || linker.auxType >= 0) {
        std::string metaPath = outPath + ".meta";
        std::ofstream mf(metaPath);
        if (!mf) die("cannot open '" + metaPath + "' for writing");
        if (linker.fileType >= 0)
            mf << "filetype: 0x" << std::hex << linker.fileType << "\n";
        if (linker.auxType >= 0)
            mf << "aux: 0x" << std::hex << linker.auxType << "\n";
    }

    if (!mapPath.empty()) linker.writeMap(mapPath);
    if (!debugOutPath.empty()) linker.writeDebugSidecar(debugOutPath);
    if (!relocOutPath.empty()) {
        // Single-flat sidecar binary format (v3):
        //   u32 count
        //   { u32 patchOff; u32 offsetRef; u8 byteCnt; u8 bitShift;
        //     u8 pad[2]; } × count
        // Both offsets are within the segment image (== link-time addr
        // minus textBase).  byteCnt = 2 for IMM16 or BANK16, 3 for IMM24.
        // bitShift = 0 normally, 16 for BANK16 (so the Loader patches
        // with the bank byte instead of the offset).  v2 used pad[0]=0
        // for byteCnt 2/3, so v3 is backwards-compatible: pad[0] was
        // always 0 then, equivalent to bitShift=0.
        //
        // Phase C.2 — under --multi-seg-expressload, we instead write a
        // per-segment sidecar file alongside the main one:
        //   <relocOutPath>.seg<N>.reloc
        // Each per-seg file uses v4 format:
        //   u32 intraCount
        //   u32 interCount
        //   intraCount × 12B   (same v3 layout, offsets within this seg)
        //   interCount × 12B   inter entries:
        //       u32 patchOff (in this seg)
        //       u32 targetOff (in target seg)
        //       u8  targetSeg
        //       u8  byteCnt
        //       u8  bitShift
        //       u8  pad
        // omfEmit reads the per-seg files when --manifest+--expressload
        // is in effect; otherwise it reads the flat file (single-seg
        // back-compat).
        std::ofstream rf(relocOutPath, std::ios::binary);
        if (!rf) die("cannot open '" + relocOutPath + "' for writing");
        uint32_t count = (uint32_t)gImm24Sites.size();
        rf.write(reinterpret_cast<const char *>(&count), 4);
        for (const auto &s : gImm24Sites) {
            uint32_t po = s.patchOff, off = s.offsetRef;
            uint8_t  bc = s.byteCnt, bs = s.bitShift, pad[2] = {0, 0};
            rf.write(reinterpret_cast<const char *>(&po), 4);
            rf.write(reinterpret_cast<const char *>(&off), 4);
            rf.write(reinterpret_cast<const char *>(&bc), 1);
            rf.write(reinterpret_cast<const char *>(&bs), 1);
            rf.write(reinterpret_cast<const char *>(pad), 2);
        }
        if (linker.multiSegExpressLoad) {
            for (size_t k = 0; k < L.segments.size(); k++) {
                uint32_t segNum = L.segments[k].segNum;
                char perSegPath[512];
                std::snprintf(perSegPath, sizeof(perSegPath),
                              "%s.seg%u.reloc", relocOutPath.c_str(), segNum);
                std::ofstream pf(perSegPath, std::ios::binary);
                if (!pf) die(std::string("cannot open '") + perSegPath +
                              "' for writing");
                const auto &intra = gIntraImm24SitesPerSeg[segNum];
                uint32_t interForSeg = 0;
                for (const auto &s : gInterImm24Sites) {
                    if (s.patchSeg == segNum) interForSeg++;
                }
                uint32_t intraCount = (uint32_t)intra.size();
                pf.write(reinterpret_cast<const char *>(&intraCount), 4);
                pf.write(reinterpret_cast<const char *>(&interForSeg), 4);
                for (const auto &s : intra) {
                    uint32_t po = s.patchOff, off = s.offsetRef;
                    uint8_t bc = s.byteCnt, bs = s.bitShift, pad[2] = {0, 0};
                    pf.write(reinterpret_cast<const char *>(&po), 4);
                    pf.write(reinterpret_cast<const char *>(&off), 4);
                    pf.write(reinterpret_cast<const char *>(&bc), 1);
                    pf.write(reinterpret_cast<const char *>(&bs), 1);
                    pf.write(reinterpret_cast<const char *>(pad), 2);
                }
                for (const auto &s : gInterImm24Sites) {
                    if (s.patchSeg != segNum) continue;
                    uint32_t po = s.patchOff;
                    uint32_t to = s.targetOff;
                    uint8_t ts = s.targetSeg, bc = s.byteCnt, bs = s.bitShift, pad = 0;
                    pf.write(reinterpret_cast<const char *>(&po), 4);
                    pf.write(reinterpret_cast<const char *>(&to), 4);
                    pf.write(reinterpret_cast<const char *>(&ts), 1);
                    pf.write(reinterpret_cast<const char *>(&bc), 1);
                    pf.write(reinterpret_cast<const char *>(&bs), 1);
                    pf.write(reinterpret_cast<const char *>(&pad), 1);
                }
            }
        }
    }
    // Multi-segment: write per-segment images + manifest if there's
    // more than one segment OR --manifest was requested.
    if (L.segments.size() > 1 || !linker.manifestPath.empty()) {
        // Default entry symbol is __start (crt0's program entry,
        // which calls main).  GS/OS Loader runs from segment 1's
        // entry; crt0 lives in segment 1 by convention (first
        // input object is typically the runtime/crt0).
        linker.writeMultiSegment(outPath, linker.manifestPath, "__start");
    }

    std::fprintf(stderr, "link816: %lu bank0-data cINTERSEG sites\n", gBank0DataSites);
    std::fprintf(stderr,
        "linked: text=[0x%04x+%u] rodata=[0x%04x+%u] bss=[0x%04x+%u] "
        "-> %s (%zu bytes)",
        L.textBase, L.textSize, L.rodataBase, L.rodataSize,
        L.bssBase, L.bssSize,
        outPath.c_str(), image.size());
    if (L.segments.size() > 1) {
        std::fprintf(stderr, " + %zu extra segments",
                     L.segments.size() - 1);
        for (size_t k = 1; k < L.segments.size(); ++k) {
            std::fprintf(stderr, " seg%u=[0x%06x+%zu]",
                         L.segments[k].segNum, L.segments[k].base,
                         L.segments[k].body.size());
        }
    }
    std::fprintf(stderr, "\n");

    return 0;
}
