mirror of
https://github.com/marketideas/qasm.git
synced 2026-10-25 13:02:45 +00:00
Move the assembler to src/qasm. qlink links OMF 1.0 and 2.0 from a script. makeBin flattens a load file, qomf dumps OMF, and qpro builds ProDOS volumes. APW and MPW assemble through the ORCA front end. Relocatable modules emit OMF objects. Listings stay off unless -l is set. make boot assembles the GS/OS 6.0.1 ProDOS 8 kernel and BASIC.SYSTEM into an 800K volume. BASIC.SYSTEM accepts ls and pfx, and its splash reports PRODOS BASIC 2.0, copyright 1983-2026. IIGS.601.SRC is the source and the BASIC.SYSTEM oracle that image is built from.
516 lines
16 KiB
Python
516 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Pack the ProDOS 8 kernel and BASIC.SYSTEM, and check boot.2mg.
|
|
|
|
The assembler concatenates each PROC. The listing PC is the address the
|
|
bytes occupy. This script places those bytes at the file offsets the
|
|
ProDOS 8 loader expects.
|
|
|
|
python3 boot/pack.py join-basic -o OUT LOADER MAIN GLOBALS
|
|
python3 boot/pack.py prodos OUT mli.lst ram.lst clock.lst
|
|
python3 boot/pack.py basic OUT basic.lst --oracle BASIC.SYSTEM
|
|
python3 boot/pack.py check IMAGE PRODOS BASIC.SYSTEM
|
|
"""
|
|
|
|
import argparse
|
|
import sys
|
|
|
|
PRODOS_SIZE = 0x4300
|
|
# Apple's BASIC.SYSTEM is $2800. The alias page at $BD00 adds one page,
|
|
# and the globals move from file $2700 to $2800.
|
|
ORACLE_BASIC_SIZE = 0x2800
|
|
BASIC_SIZE = 0x2900
|
|
# LDX #CILEN in the loader. $23 becomes $24 so the alias page is copied.
|
|
CILEN_OFF = 0x56
|
|
# LDX #ZLDREND-COPRIT-1. The copyright line grew from 32 to 34 characters.
|
|
COPRIT_LEN_OFF = 0x1AC
|
|
# Third instruction of the globals page: JMP EXTRNCMD target.
|
|
EXTRN_LO = 7
|
|
BLOCK = 512
|
|
|
|
|
|
class PackError(Exception):
|
|
pass
|
|
|
|
|
|
def die(msg):
|
|
raise PackError(msg)
|
|
|
|
|
|
def read_text(path):
|
|
data = open(path, "rb").read()
|
|
data = data.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
|
return data.decode("latin1")
|
|
|
|
|
|
def strip_code(line):
|
|
"""Drop an APW ';' comment. A full-line '*' comment stays a comment."""
|
|
if line.lstrip().startswith("*"):
|
|
return None
|
|
out = []
|
|
sq = False
|
|
dq = False
|
|
for c in line:
|
|
if not sq and not dq and c == ";":
|
|
break
|
|
if not sq and not dq:
|
|
if c == "'":
|
|
sq = True
|
|
elif c == '"':
|
|
dq = True
|
|
elif sq and c == "'":
|
|
sq = False
|
|
elif dq and c == '"':
|
|
dq = False
|
|
out.append(c)
|
|
return "".join(out).strip()
|
|
|
|
|
|
def is_end_directive(line):
|
|
body = strip_code(line.rstrip("\n"))
|
|
if body is None:
|
|
return False
|
|
parts = body.split()
|
|
if len(parts) == 1 and parts[0].upper() == "END":
|
|
return True
|
|
if len(parts) == 2 and parts[1].upper() == "END":
|
|
return True
|
|
return False
|
|
|
|
|
|
def cmd_join_basic(out_path, sources):
|
|
chunks = ["* Assembler: APW\n", " org $2000\n"]
|
|
for path in sources:
|
|
text = read_text(path)
|
|
kept = []
|
|
for line in text.split("\n"):
|
|
if is_end_directive(line):
|
|
continue
|
|
kept.append(line)
|
|
body = "\n".join(kept)
|
|
if body and not body.endswith("\n"):
|
|
body += "\n"
|
|
chunks.append(body)
|
|
open(out_path, "w", encoding="latin1", newline="\n").write("".join(chunks))
|
|
|
|
|
|
def parse_hex_bytes(field):
|
|
out = []
|
|
i = 0
|
|
while i + 2 <= len(field):
|
|
tok = field[i:i + 2]
|
|
if tok == "..":
|
|
break
|
|
if len(tok) != 2 or any(c not in "0123456789ABCDEF" for c in tok):
|
|
break
|
|
if i + 2 < len(field) and field[i + 2] not in " .":
|
|
break
|
|
out.append(int(tok, 16))
|
|
i += 3
|
|
return out
|
|
|
|
|
|
def parse_listing(path):
|
|
text = read_text(path)
|
|
lines = text.split("\n")
|
|
if not lines or "bytes=" not in lines[0] or "errors=" not in lines[0]:
|
|
die(path + ": not a qasm listing")
|
|
header = lines[0]
|
|
try:
|
|
nbytes = int(header.split("bytes=", 1)[1].split()[0])
|
|
nerr = int(header.split("errors=", 1)[1].split()[0])
|
|
except (IndexError, ValueError):
|
|
die(path + ": bad listing header")
|
|
if nerr != 0:
|
|
shown = []
|
|
for line in lines:
|
|
if line.startswith("; "):
|
|
shown.append(line[3:].rstrip())
|
|
if len(shown) >= 12:
|
|
break
|
|
extra = ""
|
|
if shown:
|
|
extra = "\n" + "\n".join(shown)
|
|
die(path + ": listing reports " + str(nerr) + " errors" + extra)
|
|
|
|
mem = {}
|
|
count = 0
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
if len(line) < 8 or not _is_pc_line(line):
|
|
i += 1
|
|
continue
|
|
pc = int(line[0:6], 16)
|
|
field = line[8:24] if len(line) >= 24 else line[8:]
|
|
chunk = parse_hex_bytes(field)
|
|
i += 1
|
|
while i < len(lines) and lines[i].startswith(" "):
|
|
more = parse_hex_bytes(lines[i][8:])
|
|
if not more:
|
|
break
|
|
chunk.extend(more)
|
|
i += 1
|
|
for n, b in enumerate(chunk):
|
|
addr = pc + n
|
|
# `org *-n` rewrites a placeholder (jmp 0 / jsr 0). The same byte
|
|
# is not a second piece of code. A different byte is a real overlap.
|
|
if addr in mem and mem[addr] != b:
|
|
die("%s: overlap at $%06X" % (path, addr))
|
|
mem[addr] = b
|
|
count += 1
|
|
if count != nbytes:
|
|
die("%s: parsed %d bytes, listing says %d" % (path, count, nbytes))
|
|
return mem
|
|
|
|
|
|
def _is_pc_line(line):
|
|
if len(line) < 8 or line[6:8] != " ":
|
|
return False
|
|
for c in line[0:6]:
|
|
if c not in "0123456789ABCDEF":
|
|
return False
|
|
return True
|
|
|
|
|
|
def take_range(mem, name, start, maxlen, exact):
|
|
end = start
|
|
limit = start + maxlen
|
|
while end < limit and end in mem:
|
|
end += 1
|
|
holes = [a for a in mem if start <= a < limit and a >= end]
|
|
if holes:
|
|
die("%s: hole at $%04X before $%04X" % (name, end, holes[0]))
|
|
n = end - start
|
|
if n == 0:
|
|
die("%s: no bytes at $%04X" % (name, start))
|
|
if exact and n != maxlen:
|
|
die("%s: $%X bytes at $%04X, expected $%X" % (name, n, start, maxlen))
|
|
if n > maxlen:
|
|
die("%s: $%X bytes exceeds $%X" % (name, n, maxlen))
|
|
return bytes(mem[start + i] for i in range(n)), set(range(start, end))
|
|
|
|
|
|
def place(image, owner, name, blob, offset, slot):
|
|
if offset < 0 or offset + slot > len(image):
|
|
die("%s: file slot $%X+$%X does not fit" % (name, offset, slot))
|
|
if len(blob) > slot:
|
|
die("%s: $%X bytes does not fit in $%X" % (name, len(blob), slot))
|
|
for i in range(slot):
|
|
if owner[offset + i] is not None:
|
|
die("%s overlaps %s at file offset $%X" % (name, owner[offset + i], offset + i))
|
|
owner[offset + i] = name
|
|
image[offset:offset + len(blob)] = blob
|
|
|
|
|
|
def pack_ranges(listings, groups, size):
|
|
"""listings is a list of (tag, mem). groups[tag] is the slice list."""
|
|
image = bytearray(size)
|
|
owner = [None] * size
|
|
for tag, mem in listings:
|
|
seen = set()
|
|
for name, start, maxlen, offset, exact in groups[tag]:
|
|
blob, addrs = take_range(mem, tag + " " + name, start, maxlen, exact)
|
|
place(image, owner, name, blob, offset, maxlen)
|
|
seen |= addrs
|
|
stray = sorted(a for a in mem if a not in seen)
|
|
if stray:
|
|
die("%s: %d unexpected bytes, first at $%06X" % (tag, len(stray), stray[0]))
|
|
return bytes(image)
|
|
|
|
|
|
# name, runtime PC, exact-or-max length, file offset, exact
|
|
PRODOS_GROUPS = {
|
|
"mli": [
|
|
("PROCONE", 0x2000, 0xC80, 0x0000, True),
|
|
("PROCTWO", 0xBF00, 0x100, 0x0E00, True),
|
|
("PROCTHREE", 0xDE00, 0x2100, 0x1000, True),
|
|
("PROCFOUR", 0xFF9B, 0x65, 0x0D9B, True),
|
|
],
|
|
"ram": [
|
|
("RAM2", 0x2C80, 0x80, 0x0C80, False),
|
|
("RAM3", 0xFF00, 0x9B, 0x0D00, False),
|
|
("RAM1", 0x0200, 0x200, 0x3100, False),
|
|
],
|
|
"clock": [
|
|
("CCLOCK", 0xD742, 125, 0x0F80, True),
|
|
],
|
|
}
|
|
|
|
BASIC_GROUPS = {
|
|
"basic": [
|
|
("LOADER", 0x2000, 0x400, 0x0000, True),
|
|
("MAIN", 0x9A00, 0x2400, 0x0400, False),
|
|
("GLOBALS", 0xBE00, 0x100, 0x2800, False),
|
|
],
|
|
}
|
|
|
|
|
|
def cmd_prodos(out_path, mli, ram, clock):
|
|
listings = [
|
|
("mli", parse_listing(mli)),
|
|
("ram", parse_listing(ram)),
|
|
("clock", parse_listing(clock)),
|
|
]
|
|
image = pack_ranges(listings, PRODOS_GROUPS, PRODOS_SIZE)
|
|
if len(image) != PRODOS_SIZE:
|
|
die("PRODOS size")
|
|
open(out_path, "wb").write(image)
|
|
print("PRODOS: %d bytes ($%X)" % (len(image), len(image)))
|
|
|
|
|
|
def pc_of_basic(offset):
|
|
if offset < 0x400:
|
|
return 0x2000 + offset
|
|
if offset < 0x2800:
|
|
return 0x9A00 + (offset - 0x400)
|
|
return 0xBE00 + (offset - 0x2800)
|
|
|
|
|
|
def _same(image, img_at, oracle, ora_at, n, skip=()):
|
|
diffs = []
|
|
for i in range(n):
|
|
if i in skip:
|
|
continue
|
|
if image[img_at + i] == oracle[ora_at + i]:
|
|
continue
|
|
if len(diffs) < 8:
|
|
off = img_at + i
|
|
diffs.append(" $%04X (pc $%04X): got $%02X want $%02X" % (
|
|
off, pc_of_basic(off), image[off], oracle[ora_at + i]))
|
|
return diffs
|
|
|
|
|
|
SPLASH_VER = b" PRODOS BASIC 2.0 "
|
|
SPLASH_COP = b" COPYRIGHT APPLE 1983-2026"
|
|
|
|
|
|
def _splash_span(loader):
|
|
"""Byte range of the two high-bit splash lines in the loader."""
|
|
text = bytes(b & 0x7F for b in loader)
|
|
ver = text.find(SPLASH_VER)
|
|
cop = text.find(SPLASH_COP)
|
|
if ver < 0 or cop != ver + len(SPLASH_VER):
|
|
return None
|
|
return range(ver, cop + len(SPLASH_COP))
|
|
|
|
|
|
def cmd_basic(out_path, listing, oracle_path):
|
|
"""Keep Apple's bytes. The loader length, the splash lines, the alias
|
|
page, and the EXTRNCMD vector are the intended differences."""
|
|
image = pack_ranges([("basic", parse_listing(listing))], BASIC_GROUPS, BASIC_SIZE)
|
|
oracle = open(oracle_path, "rb").read()
|
|
if len(oracle) != ORACLE_BASIC_SIZE:
|
|
die("oracle is %d bytes, expected %d" % (len(oracle), ORACLE_BASIC_SIZE))
|
|
if len(image) != BASIC_SIZE:
|
|
die("BASIC.SYSTEM is %d bytes, expected %d" % (len(image), BASIC_SIZE))
|
|
diffs = []
|
|
if not (image[CILEN_OFF] == 0x24 and oracle[CILEN_OFF] == 0x23
|
|
and image[CILEN_OFF - 1] == 0xA2):
|
|
diffs.append(" CILEN at $%04X: got $%02X want $24" % (
|
|
CILEN_OFF, image[CILEN_OFF]))
|
|
splash = _splash_span(image[:0x400])
|
|
if splash is None:
|
|
diffs.append(" splash is not PRODOS BASIC 2.0 / 1983-2026")
|
|
splash = range(0)
|
|
coprit_len = len(SPLASH_COP) - 1
|
|
if not (image[COPRIT_LEN_OFF] == coprit_len
|
|
and image[COPRIT_LEN_OFF - 1] == 0xA2
|
|
and oracle[COPRIT_LEN_OFF] == 0x1F):
|
|
diffs.append(" copyright length at $%04X: got $%02X want $%02X" % (
|
|
COPRIT_LEN_OFF, image[COPRIT_LEN_OFF], coprit_len))
|
|
diffs += _same(image, 0, oracle, 0, 0x400,
|
|
skip=set(splash) | {CILEN_OFF, COPRIT_LEN_OFF})
|
|
diffs += _same(image, 0x400, oracle, 0x400, 0x2300)
|
|
diffs += _same(image, 0x2800, oracle, 0x2700, 0x100, skip=(EXTRN_LO, EXTRN_LO + 1))
|
|
jmp = image[0x2806:0x2809]
|
|
target = jmp[1] | (jmp[2] << 8)
|
|
if jmp[0] != 0x4C or not (0xBD00 <= target < 0xBE00):
|
|
diffs.append(" EXTRNCMD is %s, want JMP into $BD00" % jmp.hex())
|
|
if image[0x2700] == 0:
|
|
diffs.append(" alias page at $BD00 is empty")
|
|
if diffs:
|
|
die("BASIC.SYSTEM drifted from the oracle\n" + "\n".join(diffs))
|
|
open(out_path, "wb").write(image)
|
|
print("BASIC.SYSTEM: %d bytes, aliases at $%04X" % (len(image), target))
|
|
|
|
|
|
def r16(b, off):
|
|
return b[off] | (b[off + 1] << 8)
|
|
|
|
|
|
def r24(b, off):
|
|
return b[off] | (b[off + 1] << 8) | (b[off + 2] << 16)
|
|
|
|
|
|
def r32(b, off):
|
|
return b[off] | (b[off + 1] << 8) | (b[off + 2] << 16) | (b[off + 3] << 24)
|
|
|
|
|
|
def load_2mg(path):
|
|
file = open(path, "rb").read()
|
|
if len(file) < 64 or file[0:4] != b"2IMG":
|
|
die(path + ": not a 2MG image")
|
|
if r32(file, 0x0C) != 1:
|
|
die(path + ": not a ProDOS-order image")
|
|
blocks = r32(file, 0x14)
|
|
off = r32(file, 0x18)
|
|
length = r32(file, 0x1C)
|
|
if blocks != 1600 or off != 64 or length != 1600 * BLOCK:
|
|
die("%s: expected 1600 blocks at offset 64, got blocks=%d off=%d len=%d" % (
|
|
path, blocks, off, length))
|
|
if off + length > len(file):
|
|
die(path + ": truncated image")
|
|
data = file[off:off + length]
|
|
return [data[i * BLOCK:(i + 1) * BLOCK] for i in range(blocks)]
|
|
|
|
|
|
def index_ptr(block, i):
|
|
return block[i] | (block[256 + i] << 8)
|
|
|
|
|
|
def read_prodos_file(blocks, storage, key, eof):
|
|
if storage == 1:
|
|
if eof > BLOCK or key == 0 or key >= len(blocks):
|
|
die("bad seedling")
|
|
return bytes(blocks[key][:eof])
|
|
if storage not in (2, 3) or key == 0 or key >= len(blocks):
|
|
die("unsupported storage $%X" % storage)
|
|
master = blocks[key]
|
|
out = bytearray(eof)
|
|
pos = 0
|
|
while pos < eof:
|
|
n = eof - pos
|
|
if n > BLOCK:
|
|
n = BLOCK
|
|
index = pos // BLOCK
|
|
if storage == 2:
|
|
if index >= 256:
|
|
die("sapling index")
|
|
db = index_ptr(master, index)
|
|
else:
|
|
sub = index // 256
|
|
ix = index % 256
|
|
if sub >= 128:
|
|
die("tree index")
|
|
ib = index_ptr(master, sub)
|
|
if ib == 0:
|
|
db = 0
|
|
elif ib >= len(blocks):
|
|
die("bad index block")
|
|
else:
|
|
db = index_ptr(blocks[ib], ix)
|
|
if db != 0:
|
|
if db >= len(blocks):
|
|
die("bad data block")
|
|
out[pos:pos + n] = blocks[db][:n]
|
|
pos += n
|
|
return bytes(out)
|
|
|
|
|
|
def volume_files(blocks):
|
|
files = []
|
|
b = 2
|
|
guard = 0
|
|
while b:
|
|
if b >= len(blocks) or guard > len(blocks):
|
|
die("bad directory")
|
|
guard += 1
|
|
blk = blocks[b]
|
|
for i in range(13):
|
|
if b == 2 and i == 0:
|
|
continue
|
|
ent = blk[4 + i * 0x27:4 + (i + 1) * 0x27]
|
|
storage = ent[0] >> 4
|
|
if storage == 0:
|
|
continue
|
|
nlen = ent[0] & 0x0F
|
|
name = ent[1:1 + nlen].decode("latin1")
|
|
files.append({
|
|
"name": name,
|
|
"storage": storage,
|
|
"type": ent[0x10],
|
|
"key": r16(ent, 0x11),
|
|
"eof": r24(ent, 0x15),
|
|
"aux": r16(ent, 0x1F),
|
|
})
|
|
b = r16(blk, 2)
|
|
return files
|
|
|
|
|
|
def cmd_check(image, prodos_path, basic_path):
|
|
blocks = load_2mg(image)
|
|
vol = blocks[2]
|
|
nlen = vol[4] & 0x0F
|
|
name = vol[5:5 + nlen].decode("latin1")
|
|
if (vol[4] >> 4) != 0x0F or name != "BASIC":
|
|
die("volume name is %r, expected BASIC" % name)
|
|
if r16(vol, 0x29) != 1600:
|
|
die("volume header block count is %d" % r16(vol, 0x29))
|
|
if vol[0x23] != 0x27 or vol[0x24] != 0x0D:
|
|
die("directory entry size is not $27 / $0D")
|
|
if blocks[0][0] != 0xD8 or blocks[0][1] != 0xD8:
|
|
die("boot block does not start with CLD CLD")
|
|
files = volume_files(blocks)
|
|
if len(files) != 2:
|
|
die("volume has %d files, expected PRODOS and BASIC.SYSTEM" % len(files))
|
|
expect = [
|
|
("PRODOS", 0xFF, 0x0000, PRODOS_SIZE, prodos_path),
|
|
("BASIC.SYSTEM", 0xFF, 0x2000, BASIC_SIZE, basic_path),
|
|
]
|
|
for got, (ename, etype, eaux, eeof, host) in zip(files, expect):
|
|
if got["name"] != ename or got["type"] != etype or got["aux"] != eaux or got["eof"] != eeof:
|
|
die("%s: type $%02X aux $%04X eof $%X (want %s $%02X $%04X $%X)" % (
|
|
got["name"], got["type"], got["aux"], got["eof"], ename, etype, eaux, eeof))
|
|
if got["storage"] != 2:
|
|
die("%s: storage $%X, expected a sapling" % (ename, got["storage"]))
|
|
data = read_prodos_file(blocks, got["storage"], got["key"], got["eof"])
|
|
want = open(host, "rb").read()
|
|
if data != want:
|
|
die("%s on the image does not match %s" % (ename, host))
|
|
print("boot.2mg: BASIC, 1600 blocks, PRODOS $%X, BASIC.SYSTEM $%X" % (
|
|
PRODOS_SIZE, BASIC_SIZE))
|
|
|
|
|
|
def main(argv):
|
|
p = argparse.ArgumentParser(prog="pack.py")
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
j = sub.add_parser("join-basic")
|
|
j.add_argument("-o", required=True)
|
|
j.add_argument("sources", nargs=3)
|
|
|
|
pr = sub.add_parser("prodos")
|
|
pr.add_argument("out")
|
|
pr.add_argument("mli")
|
|
pr.add_argument("ram")
|
|
pr.add_argument("clock")
|
|
|
|
b = sub.add_parser("basic")
|
|
b.add_argument("out")
|
|
b.add_argument("listing")
|
|
b.add_argument("--oracle", required=True)
|
|
|
|
c = sub.add_parser("check")
|
|
c.add_argument("image")
|
|
c.add_argument("prodos")
|
|
c.add_argument("basic")
|
|
|
|
args = p.parse_args(argv)
|
|
try:
|
|
if args.cmd == "join-basic":
|
|
cmd_join_basic(args.o, args.sources)
|
|
elif args.cmd == "prodos":
|
|
cmd_prodos(args.out, args.mli, args.ram, args.clock)
|
|
elif args.cmd == "basic":
|
|
cmd_basic(args.out, args.listing, args.oracle)
|
|
elif args.cmd == "check":
|
|
cmd_check(args.image, args.prodos, args.basic)
|
|
except PackError as exc:
|
|
print("pack.py: " + str(exc), file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|