Add full compiler toolchain, libc, examples and reference docs

First substantive commit: the entire Sprinter C compiler tree on top of
the bare README+gitignore initial commit.

What's in here:
  bin/sprinter-cc        — driver script invoking SDCC + linker + mkexe
  libc/                  — Sprinter-specific libc layer over ESTEX/BIOS
                           (conio, gfx, io, mem, stdio + headers)
  runtime/               — crt0 variants (default/small/banked/minimal)
                           + heap + bank trampolines
  toolchain/             — mkexe (SprintEXE packer, C + tests)
  examples/              — 30 demo programs (gfx, file I/O, env, time, …)
  lib/Makefile           — builds the libc archive (sprinter.lib)
  docs/                  — converted Sprinter manuals + asm reference samples
  third_party/           — solid-c reference compiler dump + sdcc setup script
  release_docs/          — packaging / release notes

gitignore overhaul:
  • Drop dangerous blanket patterns: *.asm (would hide docs/samples/*.asm)
    and *.exe (case-insensitive match was hiding third_party/solid-c/*.EXE
    on macOS APFS).  Replaced with examples/*/*.{asm,exe,…} and lib/*.lib.
  • Restore tracking of toolchain/mkexe/tests/{one,big}.bin — those are
    INPUT fixtures, not build outputs.
  • Collapse the duplicated SDCC/C/Sdcc sections into one section per
    concern (build outputs / vendored / OS-junk).
  • Add .sprinter-cc-*/, build/ (catches lib/build/ too), .claude/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 16:13:21 +03:00
parent f542608b3f
commit c71e249a4e
404 changed files with 75155 additions and 58 deletions
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""
Parse an SDCC .map file produced by sdldz80 and verify that every named
bank fits inside its 16 KB window.
The Sprinter toolchain expects each `_BANKn` area (with n >= 1) to occupy
at most 16384 bytes — that is the size of CPU window 3 where banked code
runs at execution time. The SDCC linker itself does not enforce this
limit, so we catch it post-link.
We also surface the HOME budget: anything left between the end of _CODE
and the start of window 2 (0x8000) is leftover space for adding code/data
without banking.
Usage: check_banks.py <path-to.map>
Exits non-zero with a clear message if any bank is over its limit.
"""
import re
import sys
BANK_LIMIT = 16 * 1024
# HOME upper bound depends on layout:
# HUGE: CODE at 0x4100 (W1), HOME may spill into W2 → ceiling 0xC000
# BIG: CODE at 0x8100 (W2), HOME stays in W2 → ceiling 0xC000
# tiny/small: same ceiling — anything in W3 is bank territory.
# We pick the ceiling per-image based on where _CODE lives, so we don't
# falsely flag W2-resident code as "spilled into stack/heap".
HOME_CEILING = 0xC000
def parse_map(path):
"""
Returns a dict {area_name: (addr, size)} for area lines like:
_CODE 00004100 00000313 = ...
"""
line_re = re.compile(r"^\s*(_\w+)\s+([0-9A-Fa-f]{8})\s+([0-9A-Fa-f]{8})\s*=")
areas = {}
with open(path) as f:
for ln in f:
m = line_re.match(ln)
if not m:
continue
name = m.group(1)
addr = int(m.group(2), 16)
size = int(m.group(3), 16)
areas[name] = (addr, size)
return areas
def main():
if len(sys.argv) != 2:
sys.exit("usage: check_banks.py <path-to.map>")
areas = parse_map(sys.argv[1])
fails = []
bank_names = sorted(
n for n in areas if re.fullmatch(r"_BANK\d+", n)
)
for name in bank_names:
addr, size = areas[name]
pct = size * 100.0 / BANK_LIMIT
marker = "OK"
if size > BANK_LIMIT:
marker = "OVERFLOW"
fails.append((name, size))
print(f" {name:<12} @ 0x{addr:08X} size {size:>5} / {BANK_LIMIT} ({pct:5.1f}%) {marker}")
# HOME budget — _CODE can land in W1 (huge/small) or W2 (big/tiny).
if "_CODE" in areas:
addr, size = areas["_CODE"]
end = addr + size
budget_remaining = HOME_CEILING - end
which_window = "W2 (0x8000-0xBFFF)" if addr >= 0x8000 else "HOME (0x4100-0xBFFF)"
print(f" _CODE @ 0x{addr:08X} size {size:>5} → ends at 0x{end:04X}, "
f"{which_window} has {budget_remaining} bytes free before 0x{HOME_CEILING:04X}")
if budget_remaining < 0:
print(f" ERROR: _CODE extends past 0x{HOME_CEILING:04X} (stack/heap territory)")
fails.append(("_CODE", size))
if fails:
print()
for name, size in fails:
print(f" {name} too big: {size} bytes (limit {BANK_LIMIT})")
sys.exit(1)
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
#!/bin/bash
#
# make_release.sh — pack a redistributable Sprinter C Compiler release tarball.
#
# Whitelist-based: only files explicitly listed below go into the tarball.
# Anything else (internal dev tools, research docs, build artefacts, mac
# xattr sidecars, .git, .claude memory, etc.) is excluded by construction.
#
# Output: build/sprinter-c-v<VERSION>-<host>.tar.gz with:
# - bin/sprinter-cc : compiler driver
# - toolchain/mkexe/ : .ihx → .exe utility (prebuilt for host)
# - toolchain/check_banks.py : bank size checker used by sprinter-cc
# - runtime/ : crt0 + bank/heap support
# - libc/include/ : public headers
# - libc/{io,stdio,mem,gfx}/ : libc sources (assembled into sprinter.lib)
# - lib/Makefile + lib/sprinter.lib : prebuilt library + recipe to rebuild
# - examples/ : 27 ready-to-build programs
# - third_party/sdcc/ : vendored SDCC 4.5
# - release_docs/ → docs/ : user-facing documentation
# - examples.mk-style root Makefile, README, RELEASE_NOTES, LICENSE
#
# NOT included (developer-only):
# - docs/ (research notes, original PetersPlus reference material)
# - third_party/solid-c/ (reference for Solid-C compat target)
# - mame/ (large emulator + Sprinter HDD/CD images)
# - .git/, .claude/, .DS_Store, ._* (macOS xattrs)
# - this script itself (toolchain/make_release.sh)
# - any build artefacts (.sprinter-cc-*, *.rel, *.ihx, *.lk, *.map, ...)
#
# Usage: ./toolchain/make_release.sh [VERSION]
set -euo pipefail
VERSION="${1:-1.0}"
ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
HOST_ARCH="$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m)"
NAME="sprinter-c-v${VERSION}-${HOST_ARCH}"
STAGE="$ROOT/build/$NAME"
TARBALL="$ROOT/build/$NAME.tar.gz"
cd "$ROOT"
echo ">> verifying build is clean and current"
make all >/dev/null 2>&1
echo ">> staging $STAGE"
rm -rf "$STAGE"
mkdir -p "$STAGE"
# --- WHITELIST: only these directories/files end up in the tarball ---
# Top-level metadata
cp README.md RELEASE_NOTES.md LICENSE Makefile "$STAGE/"
# Toolchain
cp -R bin runtime libc examples "$STAGE/"
# toolchain/ — only mkexe (prebuilt + sources) and check_banks.py
mkdir -p "$STAGE/toolchain/mkexe"
cp toolchain/mkexe/mkexe "$STAGE/toolchain/mkexe/"
cp toolchain/mkexe/mkexe.c "$STAGE/toolchain/mkexe/"
cp toolchain/mkexe/Makefile "$STAGE/toolchain/mkexe/"
[[ -f toolchain/mkexe/check.sh ]] && cp toolchain/mkexe/check.sh "$STAGE/toolchain/mkexe/"
cp toolchain/check_banks.py "$STAGE/toolchain/"
# Lib — keep Makefile and the prebuilt archive only; sources are in libc/
mkdir -p "$STAGE/lib"
cp lib/Makefile "$STAGE/lib/"
cp lib/sprinter.lib "$STAGE/lib/"
# Vendored SDCC — follow symlinks so the real content lands in the tarball
mkdir -p "$STAGE/third_party"
[[ -d third_party/sdcc ]] && cp -RL third_party/sdcc "$STAGE/third_party/"
[[ -f third_party/setup-sdcc.sh ]] && cp third_party/setup-sdcc.sh "$STAGE/third_party/"
# User-facing docs. Source: release_docs/{en,ru}/ → docs/{en,ru}/ in tarball.
# The internal docs/ tree (research notes, original Peters Plus material) is
# NOT shipped.
[[ -d release_docs ]] && cp -R release_docs "$STAGE/docs"
# --- Strip build artefacts that crept in via examples/ ---
echo ">> stripping build artefacts and macOS metadata"
find "$STAGE" \( \
-name '.sprinter-cc-*' -o \
-name '*.rel' -o -name '*.lst' -o -name '*.sym' -o -name '*.asm' -o \
-name '*.ihx' -o -name '*.lk' -o -name '*.map' -o -name '*.noi' -o \
-name '*.exe' -o \
-name '.DS_Store' -o -name '._*' \
\) -print0 | xargs -0 rm -rf
# Bonus: clean any *_test or scratch files I might have left in libc dirs
find "$STAGE/libc" -name 'build' -type d -exec rm -rf {} + 2>/dev/null || true
# --- Tarball ---
echo ">> packaging $TARBALL"
cd "$ROOT/build"
COPYFILE_DISABLE=1 tar --no-mac-metadata -czf "$TARBALL" "$NAME" 2>/dev/null || \
COPYFILE_DISABLE=1 tar -czf "$TARBALL" "$NAME"
rm -rf "$NAME"
ls -lh "$TARBALL"
echo
echo "Release ready: $TARBALL"
echo
echo "Try it:"
echo " tar xzf $(basename "$TARBALL")"
echo " cd $NAME"
echo " make all"
echo " bin/sprinter-cc -o hello.exe examples/hello/hello.c"
+15
View File
@@ -0,0 +1,15 @@
CFLAGS ?= -O2 -Wall -Wextra -std=c99 -pedantic
CC ?= cc
all: mkexe
mkexe: mkexe.c
$(CC) $(CFLAGS) -o $@ mkexe.c
check: mkexe
./run-tests.sh
clean:
rm -f mkexe tests/*.actual tests/*.exe
.PHONY: all check clean
+485
View File
@@ -0,0 +1,485 @@
/*
* mkexe — convert a flat binary or Intel HEX image into a Sprinter ESTEX .EXE.
*
* Single-image mode (no banks):
* +00 3 bytes "EXE"
* +03 1 byte version (0)
* +04 4 bytes offset to image (0x00000200)
* +08 2 bytes loader size (0 — whole file at once)
* +0A 6 bytes reserved (0)
* +10 2 bytes load address
* +12 2 bytes start (PC)
* +14 2 bytes initial SP
* +16 490 bytes reserved (0)
* +200 ... contiguous image bytes
*
* Multi-bank mode (detected from IHX ELA records):
* header.loader = HOME image size (so ESTEX loads only HOME and keeps the
* .EXE file open with the handle in IX-3; crt0_banked.s
* then reads each bank with ESTEX READ)
* file layout: header (512) + HOME image + bank1 (16KB) + bank2 (16KB) + ...
*
* IHX convention from SDCC `-Wl-b _BANKn=0xNC000`:
* ELA = 0x00 → HOME (low16 = 0x4100..0x7FFF range)
* ELA = 0x01 → BANK1 (low16 = 0xC000..0xFFFF)
* ELA = 0x02 → BANK2
* ...
*
* Build: cc -O2 -Wall -Wextra -std=c99 -o mkexe mkexe.c
*/
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <ctype.h>
#define EXE_HEADER_SIZE 512
#define SEGMENT_SIZE 0x10000u
#define BANK_SIZE 0x4000u /* 16 KB per bank */
#define DEFAULT_BANK_BASE 0xC000u /* HUGE mode (banks live in window 3)*/
#define BIG_BANK_BASE 0x4000u /* BIG mode (banks live in window 1)*/
#define MAX_BANKS 15
#define HOME_SEG 0
#define DEFAULT_STACK 0xBFFEu
#define DEFAULT_LOAD 0x4100u
typedef struct {
uint8_t bytes[SEGMENT_SIZE];
uint8_t present[SEGMENT_SIZE];
uint32_t lo;
uint32_t hi;
int any;
} segment_t;
static segment_t segments[MAX_BANKS + 1];
static void segments_init(void) {
for (int i = 0; i <= MAX_BANKS; i++) {
memset(segments[i].bytes, 0, sizeof(segments[i].bytes));
memset(segments[i].present, 0, sizeof(segments[i].present));
segments[i].lo = 0xFFFFFFFFu;
segments[i].hi = 0;
segments[i].any = 0;
}
}
static int segment_set(unsigned seg, uint32_t addr, uint8_t v) {
if (seg > MAX_BANKS) {
fprintf(stderr, "mkexe: bank id %u exceeds MAX_BANKS=%d\n", seg, MAX_BANKS);
return -1;
}
if (addr >= SEGMENT_SIZE) {
fprintf(stderr, "mkexe: address 0x%X outside the 64K Z80 space\n", addr);
return -1;
}
segment_t *s = &segments[seg];
s->bytes[addr] = v;
s->present[addr] = 1;
if (addr < s->lo) s->lo = addr;
if (addr > s->hi) s->hi = addr;
s->any = 1;
return 0;
}
static int hex_nib(int c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
return -1;
}
static int hex_byte(const char *p) {
int h = hex_nib((unsigned char)p[0]);
int l = hex_nib((unsigned char)p[1]);
if (h < 0 || l < 0) return -1;
return (h << 4) | l;
}
static int load_ihx(const char *path) {
FILE *f = fopen(path, "rb");
if (!f) {
fprintf(stderr, "mkexe: cannot open '%s': %s\n", path, strerror(errno));
return -1;
}
char line[1024];
int rc = 0, lineno = 0;
unsigned seg = 0; /* upper 8 bits of 24-bit virtual addr */
uint32_t para_base = 0; /* paragraph base from type-02 records */
while (fgets(line, sizeof(line), f)) {
lineno++;
size_t n = strlen(line);
while (n > 0 && (line[n-1] == '\n' || line[n-1] == '\r' || line[n-1] == ' ')) {
line[--n] = 0;
}
if (n == 0) continue;
if (line[0] != ':') {
fprintf(stderr, "mkexe: %s:%d: expected ':' record start\n", path, lineno);
rc = -1; break;
}
if (n < 11) {
fprintf(stderr, "mkexe: %s:%d: record too short\n", path, lineno);
rc = -1; break;
}
int count = hex_byte(line + 1);
int aHi = hex_byte(line + 3);
int aLo = hex_byte(line + 5);
int type = hex_byte(line + 7);
if (count < 0 || aHi < 0 || aLo < 0 || type < 0) {
fprintf(stderr, "mkexe: %s:%d: bad hex in header\n", path, lineno);
rc = -1; break;
}
if (n < (size_t)(11 + 2 * count)) {
fprintf(stderr, "mkexe: %s:%d: truncated record\n", path, lineno);
rc = -1; break;
}
unsigned sum = (unsigned)count + (unsigned)aHi + (unsigned)aLo + (unsigned)type;
uint16_t addr = (uint16_t)((aHi << 8) | aLo);
if (type == 0x00) {
for (int i = 0; i < count; i++) {
int b = hex_byte(line + 9 + 2 * i);
if (b < 0) { rc = -1; goto done; }
sum += (unsigned)b;
uint32_t a = para_base + (uint32_t)addr + (uint32_t)i;
if (segment_set(seg, a, (uint8_t)b) < 0) { rc = -1; goto done; }
}
} else if (type == 0x01) {
int cc = hex_byte(line + 9);
if (cc < 0) { rc = -1; goto done; }
sum += (unsigned)cc;
if ((sum & 0xFF) != 0) {
fprintf(stderr, "mkexe: %s:%d: bad checksum on EOF record\n", path, lineno);
rc = -1; goto done;
}
break;
} else if (type == 0x02) {
int bHi = hex_byte(line + 9);
int bLo = hex_byte(line + 11);
if (bHi < 0 || bLo < 0) { rc = -1; goto done; }
sum += (unsigned)bHi + (unsigned)bLo;
para_base = ((uint32_t)((bHi << 8) | bLo)) << 4;
} else if (type == 0x04) {
int bHi = hex_byte(line + 9);
int bLo = hex_byte(line + 11);
if (bHi < 0 || bLo < 0) { rc = -1; goto done; }
sum += (unsigned)bHi + (unsigned)bLo;
uint32_t high = ((uint32_t)bHi << 8) | (uint32_t)bLo;
if (high > MAX_BANKS) {
fprintf(stderr, "mkexe: %s:%d: ELA upper16=0x%04X means bank id %u, exceeds MAX_BANKS=%d\n",
path, lineno, (unsigned)high, (unsigned)high, MAX_BANKS);
rc = -1; goto done;
}
seg = (unsigned)high;
} else if (type == 0x03 || type == 0x05) {
for (int i = 0; i < count; i++) {
int b = hex_byte(line + 9 + 2 * i);
if (b < 0) { rc = -1; goto done; }
sum += (unsigned)b;
}
} else {
fprintf(stderr, "mkexe: %s:%d: unsupported HEX type 0x%02X\n", path, lineno, type);
rc = -1; goto done;
}
int cc = hex_byte(line + 9 + 2 * count);
if (cc < 0) {
fprintf(stderr, "mkexe: %s:%d: missing checksum\n", path, lineno);
rc = -1; goto done;
}
sum += (unsigned)cc;
if ((sum & 0xFF) != 0) {
fprintf(stderr, "mkexe: %s:%d: checksum mismatch (sum=%02X)\n", path, lineno, sum & 0xFF);
rc = -1; goto done;
}
}
done:
fclose(f);
return rc;
}
static int load_bin(const char *path, uint32_t load_addr) {
FILE *f = fopen(path, "rb");
if (!f) {
fprintf(stderr, "mkexe: cannot open '%s': %s\n", path, strerror(errno));
return -1;
}
uint8_t buf[4096];
uint32_t addr = load_addr;
size_t n;
while ((n = fread(buf, 1, sizeof(buf), f)) > 0) {
for (size_t i = 0; i < n; i++) {
if (segment_set(HOME_SEG, addr++, buf[i]) < 0) { fclose(f); return -1; }
}
}
fclose(f);
return 0;
}
static int has_suffix(const char *s, const char *suf) {
size_t ls = strlen(s), lf = strlen(suf);
if (lf > ls) return 0;
return strcasecmp(s + ls - lf, suf) == 0;
}
static void usage(FILE *o) {
fprintf(o,
"mkexe — assemble a Sprinter ESTEX .EXE from an IHX or BIN image\n"
"\n"
"Usage:\n"
" mkexe [options] -o OUT.exe INPUT(.ihx|.bin)\n"
"\n"
"Options:\n"
" -o FILE output .exe path (required)\n"
" -L ADDR load address (default: lowest in .ihx, or 0x%04X for .bin)\n"
" -E ADDR entry point / start PC (default: same as load)\n"
" -S ADDR initial SP (default: 0x%04X)\n"
" -p PAD padding byte for gaps in image (default: 0xFF)\n"
" -B ADDR bank low-16 base (default: 0x%04X HUGE; pass 0x%04X BIG)\n"
" -v verbose\n"
" -h this help\n"
"\n"
"If the input .ihx contains ELA records (type 04) with high16 = N where\n"
"1 <= N <= %d, those bytes are packed as bank N (16 KB each, base 0x%04X).\n"
"The header.loader is then set to the HOME image size so ESTEX keeps the\n"
"file open for crt0_banked.s to read each bank with RST 10h C=13h.\n"
"\n"
"Addresses accept 0x.. / $.. / decimal.\n",
DEFAULT_LOAD, DEFAULT_STACK,
DEFAULT_BANK_BASE, BIG_BANK_BASE,
MAX_BANKS, DEFAULT_BANK_BASE);
}
static int parse_addr(const char *s, uint32_t *out) {
if (!s || !*s) return -1;
char *end = NULL;
int base = 10;
if (s[0] == '$') { s++; base = 16; }
else if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) { s += 2; base = 16; }
unsigned long v = strtoul(s, &end, base);
if (end == s || (end && *end)) return -1;
*out = (uint32_t)v;
return 0;
}
static void put16_le(uint8_t *p, uint16_t v) {
p[0] = (uint8_t)(v & 0xFF);
p[1] = (uint8_t)(v >> 8);
}
int main(int argc, char **argv) {
const char *out_path = NULL;
const char *in_path = NULL;
uint32_t load_addr = 0xFFFFFFFFu;
uint32_t start_addr = 0xFFFFFFFFu;
uint32_t stack_addr = DEFAULT_STACK;
uint32_t pad_byte = 0xFF;
uint32_t bank_base = DEFAULT_BANK_BASE;
int verbose = 0;
for (int i = 1; i < argc; i++) {
const char *a = argv[i];
if (!strcmp(a, "-h") || !strcmp(a, "--help")) { usage(stdout); return 0; }
if (!strcmp(a, "-v")) { verbose = 1; continue; }
if (!strcmp(a, "-o") && i + 1 < argc) { out_path = argv[++i]; continue; }
if (!strcmp(a, "-L") && i + 1 < argc) {
if (parse_addr(argv[++i], &load_addr) < 0) { fprintf(stderr, "mkexe: bad -L\n"); return 1; }
continue;
}
if (!strcmp(a, "-E") && i + 1 < argc) {
if (parse_addr(argv[++i], &start_addr) < 0) { fprintf(stderr, "mkexe: bad -E\n"); return 1; }
continue;
}
if (!strcmp(a, "-S") && i + 1 < argc) {
if (parse_addr(argv[++i], &stack_addr) < 0) { fprintf(stderr, "mkexe: bad -S\n"); return 1; }
continue;
}
if (!strcmp(a, "-p") && i + 1 < argc) {
if (parse_addr(argv[++i], &pad_byte) < 0) { fprintf(stderr, "mkexe: bad -p\n"); return 1; }
continue;
}
if (!strcmp(a, "-B") && i + 1 < argc) {
if (parse_addr(argv[++i], &bank_base) < 0) { fprintf(stderr, "mkexe: bad -B\n"); return 1; }
if (bank_base + BANK_SIZE > SEGMENT_SIZE) {
fprintf(stderr, "mkexe: -B 0x%X + 16KB exceeds 0x10000\n", bank_base);
return 1;
}
continue;
}
if (a[0] == '-') { fprintf(stderr, "mkexe: unknown option '%s'\n", a); usage(stderr); return 1; }
if (in_path) { fprintf(stderr, "mkexe: extra positional '%s'\n", a); return 1; }
in_path = a;
}
if (!in_path || !out_path) { usage(stderr); return 1; }
segments_init();
if (has_suffix(in_path, ".ihx") || has_suffix(in_path, ".hex")) {
if (load_ihx(in_path) < 0) return 2;
} else if (has_suffix(in_path, ".bin")) {
if (load_addr == 0xFFFFFFFFu) load_addr = DEFAULT_LOAD;
if (load_bin(in_path, load_addr) < 0) return 2;
} else {
fprintf(stderr, "mkexe: input must end with .ihx, .hex, or .bin\n");
return 1;
}
segment_t *home = &segments[HOME_SEG];
if (!home->any) {
fprintf(stderr, "mkexe: input has no HOME (segment 0) bytes\n");
return 2;
}
if (load_addr == 0xFFFFFFFFu) load_addr = home->lo;
if (start_addr == 0xFFFFFFFFu) start_addr = load_addr;
if (load_addr > 0xFFFFu) { fprintf(stderr, "mkexe: load address 0x%X > 0xFFFF\n", load_addr); return 1; }
if (start_addr > 0xFFFFu) { fprintf(stderr, "mkexe: start address 0x%X > 0xFFFF\n", start_addr); return 1; }
if (stack_addr > 0xFFFFu) { fprintf(stderr, "mkexe: stack address 0x%X > 0xFFFF\n", stack_addr); return 1; }
if (pad_byte > 0xFFu) { fprintf(stderr, "mkexe: pad byte 0x%X > 0xFF\n", pad_byte); return 1; }
if (home->lo < load_addr) {
fprintf(stderr, "mkexe: HOME has bytes at 0x%04X below requested load 0x%04X\n",
home->lo, load_addr);
return 2;
}
uint32_t home_size = home->hi + 1 - load_addr;
if (load_addr + home_size > 0x10000u) {
fprintf(stderr, "mkexe: HOME extends past 0xFFFF (load=0x%04X, size=0x%X)\n",
load_addr, home_size);
return 2;
}
/* Inventory banks. */
int max_bank = 0;
for (int i = 1; i <= MAX_BANKS; i++) {
if (segments[i].any) {
if (segments[i].lo < bank_base) {
/* Bytes below the bank base inside a BANK segment almost
certainly mean the *previous* bank overflowed 16 KB and
bled into this 64 KB virtual slot. Tell the user clearly. */
int prev = i - 1;
if (prev >= 1) {
fprintf(stderr,
"mkexe: BANK%d appears to overflow its 16 KB limit:\n"
" data lands at virtual 0x%X%04X (low16 < 0x%04X)\n"
" Move some code from BANK%d's .c files into a new bank.\n",
prev, i, segments[i].lo, bank_base, prev);
} else {
fprintf(stderr,
"mkexe: bank %d has bytes at 0x%04X, below the bank base 0x%04X\n",
i, segments[i].lo, bank_base);
}
return 2;
}
if (segments[i].hi >= bank_base + BANK_SIZE) {
fprintf(stderr,
"mkexe: BANK%d overflows its 16 KB limit:\n"
" last byte at 0x%04X (limit 0x%04X)\n",
i, segments[i].hi, bank_base + BANK_SIZE - 1);
return 2;
}
if (i > max_bank) max_bank = i;
}
}
/* HOME must not extend past 0xBFFF (W3 = bank territory in HUGE,
free in BIG but still off-limits to HOME). */
if (home->hi >= 0xC000u) {
fprintf(stderr,
"mkexe: HOME image extends to 0x%04X, past window 2 end (0xBFFF).\n"
" Code grew too big for HOME — move some .c files into a bank.\n",
home->hi);
return 2;
}
/* In BIG mode HOME starts at 0x8100 (W2). Reject if it accidentally
overlaps the bank window in W1 (0x4000..0x7FFF) — that means the
user passed a bad --code-loc and the image would clobber banks. */
if (bank_base == BIG_BANK_BASE && home->lo < 0x8000u) {
fprintf(stderr,
"mkexe: HOME image starts at 0x%04X in BIG mode, but banks own\n"
" 0x4000..0x7FFF — HOME must live in W2 (0x8000..0xBFFF).\n"
" Check --code-loc / -L.\n",
home->lo);
return 2;
}
/* Banks must be densely numbered from 1 so the loader index matches. */
for (int i = 1; i <= max_bank; i++) {
if (!segments[i].any) {
fprintf(stderr, "mkexe: bank %d is empty but bank %d exists — banks must be consecutive from 1\n",
i, max_bank);
return 2;
}
}
uint32_t loader_size = (max_bank > 0) ? home_size : 0u;
/* --- Write the EXE. --- */
uint8_t header[EXE_HEADER_SIZE];
memset(header, 0, sizeof(header));
header[0x00] = 'E';
header[0x01] = 'X';
header[0x02] = 'E';
header[0x03] = 0x00;
header[0x04] = 0x00;
header[0x05] = 0x02;
header[0x06] = 0x00;
header[0x07] = 0x00;
put16_le(header + 0x08, (uint16_t)loader_size);
put16_le(header + 0x10, (uint16_t)load_addr);
put16_le(header + 0x12, (uint16_t)start_addr);
put16_le(header + 0x14, (uint16_t)stack_addr);
FILE *out = fopen(out_path, "wb");
if (!out) {
fprintf(stderr, "mkexe: cannot open '%s' for writing: %s\n", out_path, strerror(errno));
return 2;
}
if (fwrite(header, 1, EXE_HEADER_SIZE, out) != EXE_HEADER_SIZE) {
fprintf(stderr, "mkexe: short write on header\n"); fclose(out); return 2;
}
/* HOME image */
for (uint32_t a = load_addr; a < load_addr + home_size; a++) {
uint8_t b = home->present[a] ? home->bytes[a] : (uint8_t)pad_byte;
if (fwrite(&b, 1, 1, out) != 1) {
fprintf(stderr, "mkexe: short write on HOME at 0x%04X\n", a);
fclose(out); return 2;
}
}
/* Banks — full 16 KB each, padding empty cells. */
for (int i = 1; i <= max_bank; i++) {
segment_t *bnk = &segments[i];
for (uint32_t a = bank_base; a < bank_base + BANK_SIZE; a++) {
uint8_t b = bnk->present[a] ? bnk->bytes[a] : (uint8_t)pad_byte;
if (fwrite(&b, 1, 1, out) != 1) {
fprintf(stderr, "mkexe: short write on bank %d at 0x%04X\n", i, a);
fclose(out); return 2;
}
}
}
fclose(out);
if (verbose) {
fprintf(stderr,
"mkexe: wrote %s\n"
" load=0x%04X start=0x%04X stack=0x%04X loader=0x%04X\n"
" HOME 0x%04X..0x%04X (%u bytes)\n",
out_path, load_addr, start_addr, stack_addr, loader_size,
home->lo, home->hi, home_size);
for (int i = 1; i <= max_bank; i++) {
fprintf(stderr, " BANK%-2d 0x%04X..0x%04X (%u live bytes, padded to 16 KB)\n",
i, segments[i].lo, segments[i].hi,
segments[i].hi + 1 - segments[i].lo);
}
uint32_t total = EXE_HEADER_SIZE + home_size + (uint32_t)max_bank * BANK_SIZE;
fprintf(stderr, " total .exe = %u bytes\n", total);
}
return 0;
}
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
MKEXE=./mkexe
TESTS=tests
fail=0
total=0
assert_hex_eq() {
local file=$1 offset=$2 expected=$3 label=$4
local actual
actual=$(xxd -s "$offset" -l $((${#expected} / 2)) -p "$file")
if [ "$actual" != "$expected" ]; then
echo "FAIL: $label"
echo " file: $file"
echo " offset: $offset"
echo " expected: $expected"
echo " actual: $actual"
fail=$((fail + 1))
fi
}
run_case() {
local name=$1; shift
total=$((total + 1))
if "$@" >/dev/null 2>&1; then
:
else
echo "FAIL: $name (exit nonzero)"
fail=$((fail + 1))
return
fi
}
expect_failure() {
local name=$1; shift
total=$((total + 1))
if "$@" >/dev/null 2>&1; then
echo "FAIL: $name (expected nonzero exit, got success)"
fail=$((fail + 1))
fi
}
# Case 1: minimal .ihx with one byte RET at 0x4100
run_case "build-single-byte" \
$MKEXE -L 0x4100 -E 0x4100 -S 0xBFFE -o $TESTS/case1.exe $TESTS/hello.ihx
assert_hex_eq $TESTS/case1.exe 0 "455845" "case1: signature EXE"
assert_hex_eq $TESTS/case1.exe 3 "00" "case1: version 0"
assert_hex_eq $TESTS/case1.exe 4 "00020000" "case1: offset 0x00000200"
assert_hex_eq $TESTS/case1.exe 8 "0000" "case1: loader=0"
assert_hex_eq $TESTS/case1.exe 0x10 "0041" "case1: load=0x4100"
assert_hex_eq $TESTS/case1.exe 0x12 "0041" "case1: start=0x4100"
assert_hex_eq $TESTS/case1.exe 0x14 "febf" "case1: stack=0xBFFE"
assert_hex_eq $TESTS/case1.exe 0x200 "c9" "case1: image byte = RET (C9)"
# File size = 512 + 1
size=$(stat -f "%z" $TESTS/case1.exe 2>/dev/null || stat -c "%s" $TESTS/case1.exe)
if [ "$size" != "513" ]; then
echo "FAIL: case1: file size $size != 513"
fail=$((fail + 1))
fi
total=$((total + 1))
# Case 2: defaults — load and start auto-derived from .ihx, stack default = 0xBFFE
run_case "defaults-from-ihx" \
$MKEXE -o $TESTS/case2.exe $TESTS/hello.ihx
assert_hex_eq $TESTS/case2.exe 0x10 "0041" "case2: load defaults to 0x4100 (from ihx)"
assert_hex_eq $TESTS/case2.exe 0x12 "0041" "case2: start defaults to load"
assert_hex_eq $TESTS/case2.exe 0x14 "febf" "case2: stack defaults to 0xBFFE"
# Case 3: .bin input
printf '\xC9' > $TESTS/one.bin
run_case "build-from-bin" \
$MKEXE -L 0x4100 -o $TESTS/case3.exe $TESTS/one.bin
assert_hex_eq $TESTS/case3.exe 0x10 "0041" "case3: bin load=0x4100"
assert_hex_eq $TESTS/case3.exe 0x200 "c9" "case3: bin image"
# Case 4: image extending past 0xFFFF must fail
printf '\xFF%.0s' {1..4096} > $TESTS/big.bin
expect_failure "reject-past-FFFF" \
$MKEXE -L 0xF800 -o $TESTS/case4.exe $TESTS/big.bin
# Case 5: load above 0xFFFF must fail
expect_failure "reject-load-out-of-range" \
$MKEXE -L 0x10000 -o $TESTS/case5.exe $TESTS/one.bin
# Case 6: bad checksum in .ihx must fail
cat > $TESTS/bad.ihx <<'EOF'
:01410000C900
:00000001FF
EOF
expect_failure "reject-bad-checksum" \
$MKEXE -o $TESTS/case6.exe $TESTS/bad.ihx
# Case 7: multi-bank IHX → loader auto-set to HOME size, bank 1 appended (16 KB)
# ELA "0001" puts the next records into bank 1 (virtual 0x1C000+).
# Note: Intel HEX address fields are big-endian inside the record
# :01 C000 00 77 C8 means addr=0xC000 (record bytes "C000" = high then low)
cat > $TESTS/banked.ihx <<'EOF'
:01410000C9F5
:020000040001F9
:01C0000077C8
:00000001FF
EOF
run_case "banked-auto-loader" \
$MKEXE -L 0x4100 -o $TESTS/case7.exe $TESTS/banked.ihx
assert_hex_eq $TESTS/case7.exe 8 "0100" "case7: loader=HOME size (1 byte)"
size=$(stat -f "%z" $TESTS/case7.exe 2>/dev/null || stat -c "%s" $TESTS/case7.exe)
if [ "$size" != "16897" ]; then
echo "FAIL: case7: file size $size != 16897 (512 hdr + 1 HOME + 16384 bank1)"
fail=$((fail + 1))
fi
# Byte at file offset 0x201 (first byte of bank1) should be the 0x77 we put at bank1's 0xC000
assert_hex_eq $TESTS/case7.exe 0x201 "77" "case7: bank1 first byte"
total=$((total + 9))
if [ "$fail" -gt 0 ]; then
echo
echo "FAILED: $fail of $total assertions/cases"
exit 1
fi
echo "OK: $total cases passed"
+2
View File
@@ -0,0 +1,2 @@
:01410000C900
:00000001FF
+4
View File
@@ -0,0 +1,4 @@
:01410000C9F5
:020000040001F9
:01C0000077C8
:00000001FF
+1
View File
@@ -0,0 +1 @@
+2
View File
@@ -0,0 +1,2 @@
:01410000C9F5
:00000001FF
+1
View File
@@ -0,0 +1 @@
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
#
# sprinter-cc — wrapper around SDCC for building Sprinter .EXE files.
#
# Usage:
# sprinter-cc -o foo.exe foo.c [more.c ...] [options]
#
# Options:
# -o NAME output executable name (required)
# --crt0=TYPE crt0 variant: default | minimal | banked (default: default)
# -I PATH additional include path (repeatable)
# -L 0xADDR code load address (default: 0x4100)
# -E 0xADDR entry address (default: same as -L)
# -S 0xADDR stack address (default: 0xBFFE)
# --code-loc 0xN forwarded to SDCC --code-loc (default: 0x4100)
# --data-loc 0xN forwarded to SDCC --data-loc (default: 0x8000)
# -Wl FLAG extra linker flag (repeatable)
# --bank N=FILE.c compile FILE.c as bank N; repeatable; pulls crt0_banked
# automatically and adds -Wl-b_BANKN=0x{N}C000
# --mkexe FLAG extra mkexe flag (repeatable; e.g. --mkexe -p --mkexe 0)
# -v verbose (echo every command)
# -h, --help show this help
#
# Example:
# sprinter-cc -o hello.exe hello.c
# sprinter-cc -o app.exe main.c --bank 1=engine.c
# sprinter-cc -o tiny.exe tiny.c --crt0=minimal
set -eo pipefail
# ------- Locate the toolchain ------------------------------------------------
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJ_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
SDCC_BIN="$PROJ_ROOT/third_party/sdcc/bin"
SDCC="$SDCC_BIN/sdcc"
SDASZ80="$SDCC_BIN/sdasz80"
MKEXE="$PROJ_ROOT/toolchain/mkexe/mkexe"
CHECK_BANKS="$PROJ_ROOT/toolchain/check_banks.py"
RUNTIME="$PROJ_ROOT/runtime"
LIB_DIR="$PROJ_ROOT/lib"
INC_DIR="$PROJ_ROOT/libc/include"
# ------- Defaults ------------------------------------------------------------
OUT=""
CRT0_TYPE="default"
CODE_LOC="0x8100" # TINY mode default — everything in W2 (single page)
DATA_LOC="0" # 0 = let linker auto-place after code areas
LOAD_ADDR=""
ENTRY_ADDR=""
STACK_ADDR="0xBFFE"
VERBOSE=0
USER_INCS=()
SOURCES=()
LD_EXTRA=()
MKEXE_EXTRA=()
BANK_SPECS=() # entries like "1=engine.c"
# ------- Parse args ----------------------------------------------------------
usage() {
sed -n '3,28p' "$0" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
-o) OUT="$2"; shift 2;;
--crt0=*) CRT0_TYPE="${1#*=}"; shift;;
-I) USER_INCS+=("-I" "$2"); shift 2;;
-L) LOAD_ADDR="$2"; shift 2;;
-E) ENTRY_ADDR="$2"; shift 2;;
-S) STACK_ADDR="$2"; shift 2;;
--code-loc) CODE_LOC="$2"; shift 2;;
--data-loc) DATA_LOC="$2"; shift 2;;
-Wl) LD_EXTRA+=("$2"); shift 2;;
--bank) BANK_SPECS+=("$2"); shift 2;;
--mkexe) MKEXE_EXTRA+=("$2"); shift 2;;
-v) VERBOSE=1; shift;;
-h|--help) usage 0;;
-*) echo "sprinter-cc: unknown option: $1" >&2; usage 1;;
*) SOURCES+=("$1"); shift;;
esac
done
[[ -z "$OUT" ]] && { echo "sprinter-cc: -o NAME is required" >&2; exit 1; }
[[ ${#SOURCES[@]} -eq 0 ]] && { echo "sprinter-cc: no input files" >&2; exit 1; }
LOAD_ADDR="${LOAD_ADDR:-$CODE_LOC}"
ENTRY_ADDR="${ENTRY_ADDR:-$LOAD_ADDR}"
# Pick crt0 source.
case "$CRT0_TYPE" in
default) CRT0_SRC="$RUNTIME/crt0.s"; ;;
minimal) CRT0_SRC="$RUNTIME/crt0_minimal.s";;
banked) CRT0_SRC="$RUNTIME/crt0_banked.s"; ;;
*) echo "sprinter-cc: bad --crt0 type: $CRT0_TYPE" >&2; exit 1;;
esac
# Banked builds always need crt0_banked, regardless of what the user asked.
if [[ ${#BANK_SPECS[@]} -gt 0 ]] && [[ "$CRT0_TYPE" != "banked" ]]; then
[[ $VERBOSE -eq 1 ]] && echo " --bank present → switching to crt0_banked"
CRT0_SRC="$RUNTIME/crt0_banked.s"
fi
# ------- Build in a per-output workdir ---------------------------------------
WORK="$(dirname "$OUT")/.sprinter-cc-$(basename "$OUT" .exe)"
mkdir -p "$WORK"
run() {
[[ $VERBOSE -eq 1 ]] && echo " $*"
"$@"
}
# 1. crt0
CRT0_REL="$WORK/$(basename "$CRT0_SRC" .s).rel"
run "$SDASZ80" -o "$CRT0_REL" "$CRT0_SRC"
# 2. user sources → .rel (HOME)
USER_RELS=()
CC_FLAGS=(-mz80 --no-std-crt0 --std-c99 --opt-code-size -I "$INC_DIR" "${USER_INCS[@]}")
for src in "${SOURCES[@]}"; do
rel="$WORK/$(basename "$src" .c).rel"
run "$SDCC" "${CC_FLAGS[@]}" -c -o "$rel" "$src"
USER_RELS+=("$rel")
done
# 3. bank sources
BANK_RELS=()
BANK_LD_FLAGS=()
for spec in "${BANK_SPECS[@]}"; do
bank_n="${spec%%=*}"
bank_src="${spec#*=}"
rel="$WORK/bank${bank_n}_$(basename "$bank_src" .c).rel"
run "$SDCC" "${CC_FLAGS[@]}" \
--codeseg "BANK${bank_n}" --constseg "BANK${bank_n}" --dataseg "BANK${bank_n}" \
-c -o "$rel" "$bank_src"
BANK_RELS+=("$rel")
# virtual address: bank_n in upper byte, 0xC000 low
addr=$(printf "0x%X" $(( (bank_n << 16) | 0xC000 )))
BANK_LD_FLAGS+=("-Wl-b_BANK${bank_n}=${addr}")
done
# 4. link → .ihx
IHX="$WORK/$(basename "$OUT" .exe).ihx"
LINK_FLAGS=(-mz80 --no-std-crt0 --std-c99 --opt-code-size
--code-loc "$CODE_LOC" --data-loc "$DATA_LOC")
LINK_FLAGS+=("${BANK_LD_FLAGS[@]}")
for f in "${LD_EXTRA[@]}"; do LINK_FLAGS+=("$f"); done
# libsprinter.lib via -l/-L (sdcc passes -lsprinter through to sdldz80)
run "$SDCC" "${LINK_FLAGS[@]}" -o "$IHX" \
"$CRT0_REL" "${USER_RELS[@]}" "${BANK_RELS[@]}" \
"-L$LIB_DIR" "-lsprinter"
# Quick bank-size check (only meaningful if there are banks).
if [[ ${#BANK_SPECS[@]} -gt 0 ]] && [[ -f "${IHX%.ihx}.map" ]]; then
python3 "$CHECK_BANKS" "${IHX%.ihx}.map" || true
fi
# 5. mkexe → .exe
MK_FLAGS=(-L "$LOAD_ADDR" -E "$ENTRY_ADDR" -S "$STACK_ADDR" -o "$OUT" "$IHX")
[[ $VERBOSE -eq 1 ]] && MK_FLAGS=(-v "${MK_FLAGS[@]}")
for f in "${MKEXE_EXTRA[@]}"; do MK_FLAGS=("$f" "${MK_FLAGS[@]}"); done
run "$MKEXE" "${MK_FLAGS[@]}"
echo "sprinter-cc: wrote $OUT"