From d77c7426ed4254d8fcdfad740bba3f913cee4310 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 14:24:49 -0500 Subject: [PATCH 01/15] tools: C128 GEOS crt builder, VICE IEC decoder, and a typing bench script geos128crt.py wraps a GEOS 128 v2 U36 ROM as a C128 .crt with the cartridge-port loader patch, plus the sd2iec 1581 hook for the English 1581 ROM. iecdecode.py and vicemon.py are how the 1581 restart loop was found: decode the drive commands from a VICE KERNAL trace, with sd2iec's fast-loader CRC. type.py types a line by DMA, in C64 or C128 mode. --- tools/bench/README.md | 1 + tools/bench/c64.py | 2 + tools/bench/type.py | 39 +++++++++++++++++++ tools/c128/README.md | 36 +++++++++++++++++ tools/c128/geos128crt.py | 84 ++++++++++++++++++++++++++++++++++++++++ tools/c128/iecdecode.py | 71 +++++++++++++++++++++++++++++++++ tools/c128/vicemon.py | 57 +++++++++++++++++++++++++++ 7 files changed, 290 insertions(+) create mode 100755 tools/bench/type.py create mode 100644 tools/c128/README.md create mode 100755 tools/c128/geos128crt.py create mode 100755 tools/c128/iecdecode.py create mode 100755 tools/c128/vicemon.py diff --git a/tools/bench/README.md b/tools/bench/README.md index 2e5844ec5..928e0ecbd 100644 --- a/tools/bench/README.md +++ b/tools/bench/README.md @@ -51,6 +51,7 @@ whatever node appeared beside it. | `grants.py` | Service bit 20 end to end: packages `grants/module.cpp` and `grants/client.a` as `/VMS/GRANTS`, then launches one content file per slice size. The client grants a slice from a CIA2 timer NMI every 200 cycles, when it will end before the next tick, and counts the NMIs it loses; the module writes 4 KiB to `$4000` for 1.5 s and exits with its job count and that loss, which the main image prints on the way back. Each row also checks a sum the client takes over what it holds after the last job, and a slice that never fits, like a client that never grants (the first row), ends the job `VM_C64_NO_GRANT`. `grants.py 120 140` runs only those rows. Needs `acme` on `PATH` and a host serving bit 20 installed. | | `screen.py` | The C64 text screen. | | `keypress.py [code]` | Put a key in the keyboard buffer (default `Y`; F1/F3/F5/F7 are `0x85`..`0x88`). | +| `type.py [--c128] ''` | Type a line into the keyboard buffer, ten keys at a time; `\n` is RETURN. `--c128` for a C128 in 128 mode. | | `colors.py` | Top row, its colour RAM and the VIC colour registers. | | `listen.py [secs] [hex]` | Echo serial output, optionally sending bytes first. | | `probe.py` | Which image is running -- main, minimal or silent -- and its build banner. | diff --git a/tools/bench/c64.py b/tools/bench/c64.py index 2ca423ed2..1db07c2e6 100644 --- a/tools/bench/c64.py +++ b/tools/bench/c64.py @@ -13,6 +13,8 @@ VIC_DEN = 0x10 KEYBUF, KEYCOUNT = 0x0277, 0x00C6 # KERNAL keyboard buffer, and the count GETIN reads +KEYBUF_128, KEYCOUNT_128 = 0x034A, 0x00D0 # the same pair in C128 mode +KEYBUF_SIZE = 10 PETSCII_U, PETSCII_Y = 0x55, 0x59 F1, F3, F5, F7, F8 = 0x85, 0x86, 0x87, 0x88, 0x8C diff --git a/tools/bench/type.py b/tools/bench/type.py new file mode 100755 index 000000000..1a4a95314 --- /dev/null +++ b/tools/bench/type.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Type a line into the KERNAL keyboard buffer by DMA, a buffer-full at a time. + + type.py [--c128] '' + +`\\n` is RETURN. Letters go in unshifted (uppercase on the power-on character set). +--c128 uses the C128-mode buffer; C64 mode, including a C128 in 64 mode, is the default. +""" +import sys +import time +from c64 import KEYBUF, KEYBUF_128, KEYBUF_SIZE, KEYCOUNT, KEYCOUNT_128 +from trlink import Link + +DRAIN_TIMEOUT = 30 + + +def main(): + args = sys.argv[1:] + c128 = '--c128' in args + args = [a for a in args if a != '--c128'] + if len(args) != 1: + sys.exit(__doc__.strip()) + keybuf, keycount = (KEYBUF_128, KEYCOUNT_128) if c128 else (KEYBUF, KEYCOUNT) + text = args[0].encode().decode('unicode_escape').upper().replace('\n', '\r') + data = text.encode('ascii') + with Link() as tr: + for i in range(0, len(data), KEYBUF_SIZE): + chunk = data[i:i + KEYBUF_SIZE] + if not (tr.poke(keybuf, chunk) and tr.poke(keycount, [len(chunk)])): + sys.exit(f'poke refused at character {i}') + start = time.time() + while tr.peek(keycount, 1)[0]: + if time.time() - start > DRAIN_TIMEOUT: + sys.exit(f'keyboard buffer not read for {DRAIN_TIMEOUT} s, at character {i}') + time.sleep(0.1) + + +main() diff --git a/tools/c128/README.md b/tools/c128/README.md new file mode 100644 index 000000000..67cc1cf66 --- /dev/null +++ b/tools/c128/README.md @@ -0,0 +1,36 @@ +# C128 tools + +Scripts for running C128 cartridges on the TR and for finding out why one does not. +Python 3, standard library only. + +| Script | Does | +|--------|------| +| `geos128crt.py [name]` | Wrap a GEOS 128 v2 U36 function ROM (32 KB raw) as a C128 `.crt`. Patches the loader to read the external function ROM (`$8046`: `$17`→`$2B`) and to stay at 1 MHz while copying (`$8033`: `STY $D030`→`BIT`). For the English 1581 ROM it also adds the sd2iec hook below. Also writes the patched ROM as `.bin`. | +| `iecdecode.py [prefix]` | Decode the drive commands in a VICE trace of the KERNAL serial entry points: each M-W, M-E and command string, with sd2iec's fast-loader CRC. With a prefix, dumps the drive RAM each M-E ran. | +| `vicemon.py "" ""` | Run VICE remote-monitor commands, resume, and run more once a breakpoint stops it, on one connection. | + +The C128 needs TR firmware that maps a 16 KB + 16 KB C128 `.crt` +(`c128-crt-load-address`). The GEOS ROMs and disks are copyrighted, so they are not here; +build the `.crt` from your own ROM dump and launch it from SD. + +## The sd2iec 1581 hook + +sd2iec runs GEOS's fast loader only when the CRC of the M-W upload stream is one it knows +(`doscmd.c`, `fl_crc_table`). The 1581 ROM uploads its own variant of Configure 2.0's 1581 +drive code, 12 bytes different (drive `$0410` and `$0462`), so sd2iec does not recognise +it and GEOS restarts about every half second. The drive code is compressed in the ROM, so +the hook goes in the loader: its call to the unpacker (`JSR $2C40`) goes to a routine in +the loader's `$BD` filler that unpacks, then copies Configure's bytes over the unpacked +code in RAM bank 1. The upload then matches Configure's byte for byte (CRC `$3F8D`), and a +real 1581 boots the same. The German 1581 ROM has the same loader, but where its drive +code unpacks to has not been checked, so it gets no hook. + +## Tracing drive commands in VICE + +The headless VICE build (`--enable-headlessui`) runs without a window. It cannot save +screenshots, so read results from memory through the monitor instead (`bsave`, or `m`). + + x128 -default -80col -drive8type 1581 -8 disk.d81 -cartcrt geos.crt \ + -remotemonitor -monlog -monlogname iec.log -limitcycles 2000000000 & + printf 'trace exec ffb1\ntrace exec ffa8\ntrace exec ffae\nx\n' | nc 127.0.0.1 6510 + python3 tools/c128/iecdecode.py iec.log diff --git a/tools/c128/geos128crt.py b/tools/c128/geos128crt.py new file mode 100755 index 000000000..2a329d256 --- /dev/null +++ b/tools/c128/geos128crt.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Wrap a GEOS 128 v2 U36 function ROM (32 KB, raw) as a C128 .crt the TR can launch. + + geos128crt.py [name] + +Also writes the patched raw ROM next to the .crt as .bin. +""" +import hashlib +import struct +import sys + +AM1581_SHA256 = 'cbe786823301f3f1830f0ab32341d480be20220b8d5d900b2b5ee20b28c8042a' + +STUB_RAM, STUB_ROM = 0x1300, 0x22 # the loader runs from $1300, copied from ROM offset $22 + + +def stub(addr): + return addr - STUB_RAM + STUB_ROM + + +def expect(rom, offset, want, what): + if rom[offset:offset + len(want)] != want: + sys.exit(f'{what}: expected {want.hex()} at ${0x8000 + offset:04X}, found ' + f'{rom[offset:offset + len(want)].hex()} -- not a GEOS 128 v2 U36 ROM?') + + +def patch_cart_port(rom): + expect(rom, 0x45, b'\xa2\x17', 'LDX #$17 (internal function ROM)') + rom[0x46] = 0x2B # read config: external function ROM lo+hi, RAM bank 0 + expect(rom, 0x33, b'\x8c\x30\xd0', 'STY $D030') + rom[0x33] = 0x2C # BIT $D030: stay at 1 MHz while reading the cartridge + + +def patch_sd2iec_1581(rom): + # sd2iec starts its GEOS fast loader only when the M-W stream's CRC is one it knows. The + # ROM uploads a variant of Configure 2.0's 1581 code that differs in 12 bytes, so the CRC + # misses 0x3F8D and GEOS restarts. The drive code is compressed in ROM: hook the stub's + # call to the unpacker and copy Configure's bytes over the unpacked copy in bank 1. + expect(rom, stub(0x1343), bytes.fromhex('20402c'), 'JSR $2C40 (unpack)') + expect(rom, stub(0x135C), b'\xbd' * 34, '$BD filler') + rom[stub(0x1343):stub(0x1346)] = bytes.fromhex('205c13') # JSR $135C + rom[stub(0x135C):stub(0x137D)] = bytes.fromhex( + '20402c' # JSR $2C40 + 'a205' # LDX #5 + 'bd7113' # LDA $1371,X + '9d737a' # STA $7A73,X drive $0410 + 'bd7713' # LDA $1377,X + '9dc57a' # STA $7AC5,X drive $0462 + 'ca10f1' # DEX / BPL + '60' # RTS + 'a54148a54248' # $1371: Configure's LDA $41 PHA LDA $42 PHA + '688542688541') # $1377: Configure's PLA STA $42 PLA STA $41 + + +def crt(rom, name): + header = (b'C128 CARTRIDGE ' + struct.pack('>IHHBBB', 0x40, 0x0100, 0, 1, 1, 0) + + b'\0' * 5 + name.encode()[:32].ljust(32, b'\0')) + chips = b'' + for i, load in enumerate((0x8000, 0xC000)): + chips += (b'CHIP' + struct.pack('>IHHHH', 0x4010, 0, 0, load, 0x4000) + + rom[i * 0x4000:(i + 1) * 0x4000]) + return header + chips + + +def main(): + if len(sys.argv) not in (3, 4): + sys.exit(__doc__.strip()) + src, dst = sys.argv[1], sys.argv[2] + name = sys.argv[3] if len(sys.argv) == 4 else 'GEOS 128' + rom = bytearray(open(src, 'rb').read()) + if len(rom) != 0x8000 or rom[7:10] != b'CBM': + sys.exit(f'{src}: not a 32 KB C128 function ROM') + sha = hashlib.sha256(rom).hexdigest() + patch_cart_port(rom) + if sha == AM1581_SHA256: + patch_sd2iec_1581(rom) + print('Am1581: added the sd2iec 1581 hook') + open(dst, 'wb').write(crt(rom, name)) + open(dst.rsplit('.', 1)[0] + '.bin', 'wb').write(rom) + print(f'wrote {dst}') + + +main() diff --git a/tools/c128/iecdecode.py b/tools/c128/iecdecode.py new file mode 100755 index 000000000..9b3f569a1 --- /dev/null +++ b/tools/c128/iecdecode.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Decode the drive commands a C128/C64 sent, from a VICE trace of the KERNAL entry points. + + iecdecode.py [image-prefix] + +Prints each command channel string (M-W, M-E and the rest) with the cycle it was sent on. +For M-W it also prints sd2iec's running CRC, which is what sd2iec matches against its table +of known fast loaders (doscmd.c, fl_crc_table) when the M-E arrives. With an image prefix, +writes the $0300-$04FF drive RAM image each M-E ran as _.bin. + +Make the log with VICE's monitor: `trace exec ffb1`, `trace exec ffa8` and `trace exec +ffae` (LISTEN, CIOUT, UNLSN), then `x`, with -monlog / -monlogname set. +""" +import re +import sys + +LISTEN, CIOUT, UNLSN = 'ffb1', 'ffa8', 'ffae' +TRACE_HIT = re.compile(r'\.C:(ff\w\w)\s+4C .*A:(\w\w).*\s(\d+)$') +UPLOAD_BASE, UPLOAD_SIZE = 0x0300, 0x200 + + +def crc16(crc, byte): + # avr-libc _crc16_update, which sd2iec uses: poly 0xA001, reflected + crc ^= byte + for _ in range(8): + crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 + return crc + + +def commands(path): + cmds, cur = [], None + for line in open(path, errors='replace'): + m = TRACE_HIT.match(line.strip()) + if not m: + continue + entry, acc, cycle = m.group(1), int(m.group(2), 16), int(m.group(3)) + if entry == LISTEN: + cur = (cycle, bytearray()) + elif entry == CIOUT and cur is not None: + cur[1].append(acc) + elif entry == UNLSN and cur is not None: + cmds.append((cur[0], bytes(cur[1]))) + cur = None + return cmds + + +def main(): + if len(sys.argv) not in (2, 3): + sys.exit(__doc__.strip()) + crc, ram, images = 0xFFFF, bytearray(UPLOAD_SIZE), [] + for cycle, cmd in commands(sys.argv[1]): + if cmd[:3] == b'M-W' and len(cmd) >= 6: + addr, n = cmd[3] | cmd[4] << 8, cmd[5] + for b in cmd[6:6 + n]: + crc = crc16(crc, b) + if UPLOAD_BASE <= addr and addr + n <= UPLOAD_BASE + UPLOAD_SIZE: + ram[addr - UPLOAD_BASE:addr - UPLOAD_BASE + n] = cmd[6:6 + n] + print(f'{cycle:>10} M-W ${addr:04x} len {n:3} crc-so-far {crc:04x}') + elif cmd[:3] == b'M-E' and len(cmd) >= 5: + print(f'{cycle:>10} M-E ${cmd[3] | cmd[4] << 8:04x} (crc {crc:04x})') + images.append(bytes(ram)) + crc = 0xFFFF + else: + print(f'{cycle:>10} {cmd[:40]!r}') + if len(sys.argv) == 3: + for i, image in enumerate(images): + open(f'{sys.argv[2]}_{i}.bin', 'wb').write(image) + + +main() diff --git a/tools/c128/vicemon.py b/tools/c128/vicemon.py new file mode 100755 index 000000000..033db1dae --- /dev/null +++ b/tools/c128/vicemon.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Run VICE remote-monitor commands across a breakpoint, on one connection. + + vicemon.py "" "" + +Commands are separated by ';'. The first set runs, then `x` resumes the emulator; once the +monitor prompt comes back (a breakpoint or watchpoint hit), the second set runs. Start +VICE with -remotemonitor (port 6510, or $VICE_MONITOR as host:port). +""" +import os +import socket +import sys + +PROMPT = b'(C:$' + + +def main(): + if len(sys.argv) != 3: + sys.exit(__doc__.strip()) + host, _, port = os.environ.get('VICE_MONITOR', '127.0.0.1:6510').partition(':') + s = socket.create_connection((host, int(port))) + + def send(cmd): + s.sendall((cmd + '\n').encode()) + + def drain(secs): + s.settimeout(secs) + out = b'' + try: + while True: + data = s.recv(65536) + if not data: + break + out += data + except socket.timeout: + pass + return out.decode('latin1') + + for cmd in sys.argv[1].split(';'): + send(cmd) + drain(1) + send('x') + s.settimeout(None) + buf = b'' + while PROMPT not in buf: + data = s.recv(4096) + if not data: + sys.exit('VICE closed the monitor connection before stopping') + buf += data + print(buf.decode('latin1')) + for cmd in sys.argv[2].split(';'): + send(cmd) + print(drain(2)) + + +main() From 582d5ade10532c087002ccf9b631a4d180e059e8 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 14:35:36 -0500 Subject: [PATCH 02/15] tools: geos128crt writes .bin beside the .crt, not at the first dot in the path --- tools/c128/geos128crt.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/c128/geos128crt.py b/tools/c128/geos128crt.py index 2a329d256..d7d22c82c 100755 --- a/tools/c128/geos128crt.py +++ b/tools/c128/geos128crt.py @@ -7,6 +7,7 @@ Also writes the patched raw ROM next to the .crt as .bin. """ import hashlib +import os import struct import sys @@ -68,6 +69,9 @@ def main(): sys.exit(__doc__.strip()) src, dst = sys.argv[1], sys.argv[2] name = sys.argv[3] if len(sys.argv) == 4 else 'GEOS 128' + raw = os.path.splitext(dst)[0] + '.bin' + if os.path.abspath(raw) == os.path.abspath(dst): + sys.exit(f'{dst}: the patched ROM is written as .bin, so the .crt cannot be a .bin') rom = bytearray(open(src, 'rb').read()) if len(rom) != 0x8000 or rom[7:10] != b'CBM': sys.exit(f'{src}: not a 32 KB C128 function ROM') @@ -77,7 +81,7 @@ def main(): patch_sd2iec_1581(rom) print('Am1581: added the sd2iec 1581 hook') open(dst, 'wb').write(crt(rom, name)) - open(dst.rsplit('.', 1)[0] + '.bin', 'wb').write(rom) + open(raw, 'wb').write(rom) print(f'wrote {dst}') From 4483cbb44c682475c3e88123a9436c84c5ae565e Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 14:36:04 -0500 Subject: [PATCH 03/15] tools: iecdecode keeps the drive RAM image 512 bytes when an M-W is cut short --- tools/c128/iecdecode.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tools/c128/iecdecode.py b/tools/c128/iecdecode.py index 9b3f569a1..e6c40f4f7 100755 --- a/tools/c128/iecdecode.py +++ b/tools/c128/iecdecode.py @@ -52,11 +52,13 @@ def main(): for cycle, cmd in commands(sys.argv[1]): if cmd[:3] == b'M-W' and len(cmd) >= 6: addr, n = cmd[3] | cmd[4] << 8, cmd[5] - for b in cmd[6:6 + n]: + payload = cmd[6:6 + n] + for b in payload: crc = crc16(crc, b) - if UPLOAD_BASE <= addr and addr + n <= UPLOAD_BASE + UPLOAD_SIZE: - ram[addr - UPLOAD_BASE:addr - UPLOAD_BASE + n] = cmd[6:6 + n] - print(f'{cycle:>10} M-W ${addr:04x} len {n:3} crc-so-far {crc:04x}') + if UPLOAD_BASE <= addr and addr + len(payload) <= UPLOAD_BASE + UPLOAD_SIZE: + ram[addr - UPLOAD_BASE:addr - UPLOAD_BASE + len(payload)] = payload + short = f' (only {len(payload)} sent)' if len(payload) < n else '' + print(f'{cycle:>10} M-W ${addr:04x} len {n:3}{short} crc-so-far {crc:04x}') elif cmd[:3] == b'M-E' and len(cmd) >= 5: print(f'{cycle:>10} M-E ${cmd[3] | cmd[4] << 8:04x} (crc {crc:04x})') images.append(bytes(ram)) From e7ed6c25db2698e103a2f89541f44d0301df3475 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 14:36:34 -0500 Subject: [PATCH 04/15] tools: iecdecode decodes only the command channel, by tracing SECOND --- tools/c128/README.md | 2 +- tools/c128/iecdecode.py | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/tools/c128/README.md b/tools/c128/README.md index 67cc1cf66..e7f456811 100644 --- a/tools/c128/README.md +++ b/tools/c128/README.md @@ -32,5 +32,5 @@ screenshots, so read results from memory through the monitor instead (`bsave`, o x128 -default -80col -drive8type 1581 -8 disk.d81 -cartcrt geos.crt \ -remotemonitor -monlog -monlogname iec.log -limitcycles 2000000000 & - printf 'trace exec ffb1\ntrace exec ffa8\ntrace exec ffae\nx\n' | nc 127.0.0.1 6510 + printf 'trace exec ffb1\ntrace exec ff93\ntrace exec ffa8\ntrace exec ffae\nx\n' | nc 127.0.0.1 6510 python3 tools/c128/iecdecode.py iec.log diff --git a/tools/c128/iecdecode.py b/tools/c128/iecdecode.py index e6c40f4f7..93ab8d14c 100755 --- a/tools/c128/iecdecode.py +++ b/tools/c128/iecdecode.py @@ -9,13 +9,16 @@ of known fast loaders (doscmd.c, fl_crc_table) when the M-E arrives. With an image prefix, writes the $0300-$04FF drive RAM image each M-E ran as _.bin. -Make the log with VICE's monitor: `trace exec ffb1`, `trace exec ffa8` and `trace exec -ffae` (LISTEN, CIOUT, UNLSN), then `x`, with -monlog / -monlogname set. +Make the log with VICE's monitor: `trace exec ffb1`, `trace exec ff93`, `trace exec ffa8` +and `trace exec ffae` (LISTEN, SECOND, CIOUT, UNLSN), then `x`, with -monlog / -monlogname +set. Only bytes sent after a SECOND to channel 15 are decoded; file data is skipped. """ import re import sys -LISTEN, CIOUT, UNLSN = 'ffb1', 'ffa8', 'ffae' +LISTEN, SECOND, CIOUT, UNLSN = 'ffb1', 'ff93', 'ffa8', 'ffae' +COMMAND_CHANNEL = 15 +SA_DATA, SA_OPEN = 0x60, 0xF0 TRACE_HIT = re.compile(r'\.C:(ff\w\w)\s+4C .*A:(\w\w).*\s(\d+)$') UPLOAD_BASE, UPLOAD_SIZE = 0x0300, 0x200 @@ -36,7 +39,11 @@ def commands(path): continue entry, acc, cycle = m.group(1), int(m.group(2), 16), int(m.group(3)) if entry == LISTEN: - cur = (cycle, bytearray()) + cur = None + elif entry == SECOND: + to_command_channel = (acc & 0x0F == COMMAND_CHANNEL + and acc & 0xF0 in (SA_DATA, SA_OPEN)) + cur = (cycle, bytearray()) if to_command_channel else None elif entry == CIOUT and cur is not None: cur[1].append(acc) elif entry == UNLSN and cur is not None: From 87a22aafd49b6e0a68ba8a576429e13c1863d405 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 14:36:44 -0500 Subject: [PATCH 05/15] tools: vicemon takes VICE_MONITOR as a bare host, on port 6510 --- tools/c128/vicemon.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/c128/vicemon.py b/tools/c128/vicemon.py index 033db1dae..5730b9107 100755 --- a/tools/c128/vicemon.py +++ b/tools/c128/vicemon.py @@ -6,20 +6,21 @@ Commands are separated by ';'. The first set runs, then `x` resumes the emulator; once the monitor prompt comes back (a breakpoint or watchpoint hit), the second set runs. Start -VICE with -remotemonitor (port 6510, or $VICE_MONITOR as host:port). +VICE with -remotemonitor (127.0.0.1:6510, or $VICE_MONITOR as host or host:port). """ import os import socket import sys PROMPT = b'(C:$' +DEFAULT_HOST, DEFAULT_PORT = '127.0.0.1', 6510 def main(): if len(sys.argv) != 3: sys.exit(__doc__.strip()) - host, _, port = os.environ.get('VICE_MONITOR', '127.0.0.1:6510').partition(':') - s = socket.create_connection((host, int(port))) + host, _, port = os.environ.get('VICE_MONITOR', DEFAULT_HOST).partition(':') + s = socket.create_connection((host or DEFAULT_HOST, int(port) if port else DEFAULT_PORT)) def send(cmd): s.sendall((cmd + '\n').encode()) From 553442cb48737d32e69220205d1f811d838ba966 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 14:37:46 -0500 Subject: [PATCH 06/15] tools: vicemon waits for each command's prompt instead of a fixed drain --- tools/c128/vicemon.py | 48 +++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/tools/c128/vicemon.py b/tools/c128/vicemon.py index 5730b9107..ae718a002 100755 --- a/tools/c128/vicemon.py +++ b/tools/c128/vicemon.py @@ -9,10 +9,13 @@ VICE with -remotemonitor (127.0.0.1:6510, or $VICE_MONITOR as host or host:port). """ import os +import re import socket import sys -PROMPT = b'(C:$' +PROMPT = re.compile(rb'\(C:\$[0-9a-fA-F]{4}\) $') +COMMAND_TIMEOUT = 30 +GREETING_WAIT = 0.5 DEFAULT_HOST, DEFAULT_PORT = '127.0.0.1', 6510 @@ -25,34 +28,39 @@ def main(): def send(cmd): s.sendall((cmd + '\n').encode()) - def drain(secs): - s.settimeout(secs) + def until_prompt(timeout, what): + s.settimeout(timeout) out = b'' try: - while True: + while not PROMPT.search(out): data = s.recv(65536) if not data: - break + sys.exit(f'VICE closed the monitor connection {what}') out += data except socket.timeout: - pass + sys.exit(f'no monitor prompt within {timeout} s {what}') return out.decode('latin1') - for cmd in sys.argv[1].split(';'): - send(cmd) - drain(1) + def run(cmds, show): + for cmd in commands(cmds): + send(cmd) + out = until_prompt(COMMAND_TIMEOUT, f'after {cmd!r}') + if show: + print(out) + + s.settimeout(GREETING_WAIT) + try: + s.recv(65536) + except socket.timeout: + pass + run(sys.argv[1], show=False) send('x') - s.settimeout(None) - buf = b'' - while PROMPT not in buf: - data = s.recv(4096) - if not data: - sys.exit('VICE closed the monitor connection before stopping') - buf += data - print(buf.decode('latin1')) - for cmd in sys.argv[2].split(';'): - send(cmd) - print(drain(2)) + print(until_prompt(None, 'before stopping')) + run(sys.argv[2], show=True) + + +def commands(cmds): + return [c.strip() for c in cmds.split(';') if c.strip()] main() From 64659d38b8339ddeb2c8a57e9f58679022686e76 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 14:38:17 -0500 Subject: [PATCH 07/15] tools: type.py rejects non-ASCII text and takes other key codes as \xNN --- tools/bench/type.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tools/bench/type.py b/tools/bench/type.py index 1a4a95314..368f24bfa 100755 --- a/tools/bench/type.py +++ b/tools/bench/type.py @@ -4,7 +4,8 @@ type.py [--c128] '' -`\\n` is RETURN. Letters go in unshifted (uppercase on the power-on character set). +`\\n` is RETURN and `\\xNN` is any key code. Letters go in unshifted (uppercase on the +power-on character set). --c128 uses the C128-mode buffer; C64 mode, including a C128 in 64 mode, is the default. """ import sys @@ -22,8 +23,13 @@ def main(): if len(args) != 1: sys.exit(__doc__.strip()) keybuf, keycount = (KEYBUF_128, KEYCOUNT_128) if c128 else (KEYBUF, KEYCOUNT) - text = args[0].encode().decode('unicode_escape').upper().replace('\n', '\r') - data = text.encode('ascii') + if not args[0].isascii(): + sys.exit('ASCII only: write any other key code as \\xNN') + try: + data = args[0].encode().decode('unicode_escape').encode('latin1') + except UnicodeEncodeError: + sys.exit('key codes go up to \\xFF') + data = data.upper().replace(b'\n', b'\r') with Link() as tr: for i in range(0, len(data), KEYBUF_SIZE): chunk = data[i:i + KEYBUF_SIZE] From dbb22aba735522a34e7353fb6346388bbde58205 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 14:38:36 -0500 Subject: [PATCH 08/15] tools: type.py waits for an empty keyboard buffer before each chunk, the first included --- tools/bench/type.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tools/bench/type.py b/tools/bench/type.py index 368f24bfa..e8c212fee 100755 --- a/tools/bench/type.py +++ b/tools/bench/type.py @@ -32,14 +32,19 @@ def main(): data = data.upper().replace(b'\n', b'\r') with Link() as tr: for i in range(0, len(data), KEYBUF_SIZE): + wait_until_read(tr, keycount, i) chunk = data[i:i + KEYBUF_SIZE] if not (tr.poke(keybuf, chunk) and tr.poke(keycount, [len(chunk)])): sys.exit(f'poke refused at character {i}') - start = time.time() - while tr.peek(keycount, 1)[0]: - if time.time() - start > DRAIN_TIMEOUT: - sys.exit(f'keyboard buffer not read for {DRAIN_TIMEOUT} s, at character {i}') - time.sleep(0.1) + wait_until_read(tr, keycount, len(data)) + + +def wait_until_read(tr, keycount, at): + start = time.time() + while tr.peek(keycount, 1)[0]: + if time.time() - start > DRAIN_TIMEOUT: + sys.exit(f'keyboard buffer not read for {DRAIN_TIMEOUT} s, at character {at}') + time.sleep(0.1) main() From 27185a075da85a29c705a10a21ea5dbe84badcd5 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 17:02:38 -0500 Subject: [PATCH 09/15] tools/c128: GEOS 128 boots with a clock record instead of 3/23/92 GEOS 128 handles years past 1999 on its own; what it lacks is a clock source, so every cold boot starts at 1:00 PM on 3/23/92. The loader's unpack hook now calls a routine in unused ROM filler that sets CIA1's TOD and GEOS's date from a tagged record, which the tool fills with the build time or --clock and the TR can keep current. The Am1581 sd2iec fix moves into the same ROM filler, since the stub's 34 free bytes no longer hold both. --- tools/c128/README.md | 33 ++++++-- tools/c128/geos128crt.py | 162 +++++++++++++++++++++++++++++++-------- 2 files changed, 157 insertions(+), 38 deletions(-) diff --git a/tools/c128/README.md b/tools/c128/README.md index e7f456811..1e7de53b6 100644 --- a/tools/c128/README.md +++ b/tools/c128/README.md @@ -5,7 +5,7 @@ Python 3, standard library only. | Script | Does | |--------|------| -| `geos128crt.py [name]` | Wrap a GEOS 128 v2 U36 function ROM (32 KB raw) as a C128 `.crt`. Patches the loader to read the external function ROM (`$8046`: `$17`→`$2B`) and to stay at 1 MHz while copying (`$8033`: `STY $D030`→`BIT`). For the English 1581 ROM it also adds the sd2iec hook below. Also writes the patched ROM as `.bin`. | +| `geos128crt.py [--clock T] [name]` | Wrap a GEOS 128 v2 U36 function ROM (32 KB raw) as a C128 `.crt`. Patches the loader to read the external function ROM (`$8046`: `$17`→`$2B`) and to stay at 1 MHz while copying (`$8033`: `STY $D030`→`BIT`), and gives GEOS a boot clock (below). For the English 1581 ROM it also adds the sd2iec fix below. Also writes the patched ROM as `.bin`. | | `iecdecode.py [prefix]` | Decode the drive commands in a VICE trace of the KERNAL serial entry points: each M-W, M-E and command string, with sd2iec's fast-loader CRC. With a prefix, dumps the drive RAM each M-E ran. | | `vicemon.py "" ""` | Run VICE remote-monitor commands, resume, and run more once a breakpoint stops it, on one connection. | @@ -13,17 +13,36 @@ The C128 needs TR firmware that maps a 16 KB + 16 KB C128 `.crt` (`c128-crt-load-address`). The GEOS ROMs and disks are copyrighted, so they are not here; build the `.crt` from your own ROM dump and launch it from SD. -## The sd2iec 1581 hook +## The boot clock + +GEOS 128 handles years past 1999 itself: it keeps a two-digit year, rolls 12/31/99 over to +01/01/00, and its leap-year test holds through 2099. What it lacks is a clock source. A cold +boot sets CIA1's time-of-day clock to 1:00 PM and the date to 3/23/92, as immediate operands +in the unpacked kernal (bank 1 `$2D88`, the same in all three ROMs). + +The loader's call to the unpacker (`JSR $2C40`) goes to a hook in its `$BD` filler. Once GEOS +is unpacked, the hook maps the cartridge in (`$FF00 = $6A`) and runs a routine in unused ROM +filler at `$8130`. It reads a clock record, sets the TOD from it, writes its year, month and +day over GEOS's defaults, and jumps GEOS past its own TOD set. If GEOS's defaults are not the +expected bytes, it leaves them alone. + +The record sits at `$8120`: the tag `TRCLOCK`, a version byte `$01`, then century, year +(0–99), month, day, hour (0–23), minute and second. The tool fills it with the time the +`.crt` was built, or `--clock`. TR firmware with C128 crt clock support (`c128-crt-clock`) +finds the record by its tag and keeps it at local time while the cart runs, so GEOS boots +with the TR's clock. The routine reads the +record until two reads agree, since the TR can rewrite it mid-read. + +## The sd2iec 1581 fix sd2iec runs GEOS's fast loader only when the CRC of the M-W upload stream is one it knows (`doscmd.c`, `fl_crc_table`). The 1581 ROM uploads its own variant of Configure 2.0's 1581 drive code, 12 bytes different (drive `$0410` and `$0462`), so sd2iec does not recognise it and GEOS restarts about every half second. The drive code is compressed in the ROM, so -the hook goes in the loader: its call to the unpacker (`JSR $2C40`) goes to a routine in -the loader's `$BD` filler that unpacks, then copies Configure's bytes over the unpacked -code in RAM bank 1. The upload then matches Configure's byte for byte (CRC `$3F8D`), and a -real 1581 boots the same. The German 1581 ROM has the same loader, but where its drive -code unpacks to has not been checked, so it gets no hook. +the boot hook above also runs a routine at `$81F9` that copies Configure's bytes over the +unpacked code in RAM bank 1. The upload then matches Configure's byte for byte (CRC `$3F8D`), +and a real 1581 boots the same. The German 1581 ROM has the same loader, but where its drive +code unpacks to has not been checked, so it gets no fix. ## Tracing drive commands in VICE diff --git a/tools/c128/geos128crt.py b/tools/c128/geos128crt.py index d7d22c82c..1c4e2aa71 100755 --- a/tools/c128/geos128crt.py +++ b/tools/c128/geos128crt.py @@ -2,10 +2,14 @@ # SPDX-License-Identifier: MIT """Wrap a GEOS 128 v2 U36 function ROM (32 KB, raw) as a C128 .crt the TR can launch. - geos128crt.py [name] + geos128crt.py [--clock YYYY-MM-DDTHH:MM[:SS]] [name] +GEOS boots with the date and time in the crt's clock record: the time it was built, or +--clock. TR firmware with C128 crt clock support keeps it at local time while the cart runs. Also writes the patched raw ROM next to the .crt as .bin. """ +import argparse +import datetime import hashlib import os import struct @@ -14,6 +18,91 @@ AM1581_SHA256 = 'cbe786823301f3f1830f0ab32341d480be20220b8d5d900b2b5ee20b28c8042a' STUB_RAM, STUB_ROM = 0x1300, 0x22 # the loader runs from $1300, copied from ROM offset $22 +ROUTINES = 0x8120 # ROM filler the loader never copies + +# The TR finds this record by its tag and keeps the seven bytes after it at local time. +CLOCK_TAG = b'TRCLOCK\x01' + +# GEOS's first-boot defaults, as unpacked to bank 1 $2D88: CIA1 TOD 1:00 PM, then 3/23/92. +BOOT4 = bytes.fromhex('ad0fdc297f8d0fdc' 'a9818d0bdc' 'a9008d0adc8d09dc8d08dc' + 'a95c8d1685' 'a9038d1785' 'a9178d1885') + +CLOCK = bytes.fromhex( # $8130, entered with $FF00 = $6A + 'a206' # 8130 ldx #6 read the record until two reads agree + 'bd2881' # 8132 lda $8128,x + '9d7013' # 8135 sta $1370,x + 'ca10f7' # 8138 dex / bpl $8132 + 'a206' # 813b ldx #6 + 'bd2881' # 813d lda $8128,x + 'dd7013' # 8140 cmp $1370,x + 'd0eb' # 8143 bne $8130 + 'ca10f5' # 8145 dex / bpl $813d + 'a226' # 8148 ldx #38 leave GEOS alone unless its defaults are there + 'bd882d' # 814a lda $2d88,x + 'ddd281' # 814d cmp $81d2,x + 'd069' # 8150 bne $81bb + 'ca10f5' # 8152 dex / bpl $814a + 'ad7113' # 8155 lda $1371 + '8da12d' # 8158 sta $2da1 LDA #year + 'ad7213' # 815b lda $1372 + '8da62d' # 815e sta $2da6 LDA #month + 'ad7313' # 8161 lda $1373 + '8dab2d' # 8164 sta $2dab LDA #day + 'a94c8d882d' # 8167 lda #$4c / sta $2d88 + 'a9a08d892d' # 816c lda #$a0 / sta $2d89 + 'a92d8d8a2d' # 8171 lda #$2d / sta $2d8a JMP $2DA0, past GEOS's own TOD set + 'ad0fdc' # 8176 lda $dc0f CRB bit 7 clear: writes set the time, not the alarm + '297f' # 8179 and #$7f + '8d0fdc' # 817b sta $dc0f + 'a000' # 817e ldy #0 0-23 -> 12-hour BCD with the PM flag + 'ad7413' # 8180 lda $1374 + 'c90c' # 8183 cmp #12 + '9004' # 8185 bcc $818b + 'e90c' # 8187 sbc #12 + 'a080' # 8189 ldy #$80 + '8c7713' # 818b sty $1377 + 'aa' # 818e tax + 'd00a' # 818f bne $819b + 'ad7713' # 8191 lda $1377 the 6526 flips AM/PM when 12 is written + '4980' # 8194 eor #$80 + '8d7713' # 8196 sta $1377 + 'a90c' # 8199 lda #12 + '20bc81' # 819b jsr $81bc + '0d7713' # 819e ora $1377 + '8d0bdc' # 81a1 sta $dc0b + 'ad7513' # 81a4 lda $1375 + '20bc81' # 81a7 jsr $81bc + '8d0adc' # 81aa sta $dc0a + 'ad7613' # 81ad lda $1376 + '20bc81' # 81b0 jsr $81bc + '8d09dc' # 81b3 sta $dc09 + 'a900' # 81b6 lda #0 + '8d08dc' # 81b8 sta $dc08 tenths last: restarts the clock + '60' # 81bb rts + 'a2ff38' # 81bc ldx #$ff / sec A = 0-99 -> BCD + 'e8' # 81bf inx + 'e90a' # 81c0 sbc #10 + 'b0fb' # 81c2 bcs $81bf + '690a' # 81c4 adc #10 + '8d7813' # 81c6 sta $1378 + '8a0a0a0a0a' # 81c9 txa / asl x4 + '0d7813' # 81ce ora $1378 + '60' # 81d1 rts +) + BOOT4 # 81d2 + +# sd2iec starts its GEOS fast loader only when the M-W stream's CRC is one it knows. The Am1581 +# ROM uploads a variant of Configure 2.0's 1581 code that differs in 12 bytes, so the CRC misses +# 0x3F8D and GEOS restarts. This copies Configure's bytes over the unpacked drive code. +FIX1581 = bytes.fromhex( # $81f9 + 'a205' # 81f9 ldx #5 + 'bd0b82' # 81fb lda $820b,x + '9d737a' # 81fe sta $7a73,x drive $0410 + 'bd1182' # 8201 lda $8211,x + '9dc57a' # 8204 sta $7ac5,x drive $0462 + 'ca10f1' # 8207 dex / bpl $81fb + '60' # 820a rts + 'a54148a54248' # 820b Configure's LDA $41 PHA LDA $42 PHA + '688542688541') # 8211 Configure's PLA STA $42 PLA STA $41 def stub(addr): @@ -33,25 +122,32 @@ def patch_cart_port(rom): rom[0x33] = 0x2C # BIT $D030: stay at 1 MHz while reading the cartridge -def patch_sd2iec_1581(rom): - # sd2iec starts its GEOS fast loader only when the M-W stream's CRC is one it knows. The - # ROM uploads a variant of Configure 2.0's 1581 code that differs in 12 bytes, so the CRC - # misses 0x3F8D and GEOS restarts. The drive code is compressed in ROM: hook the stub's - # call to the unpacker and copy Configure's bytes over the unpacked copy in bank 1. +def clock_record(t): + return CLOCK_TAG + bytes([t.year // 100, t.year % 100, t.month, t.day, + t.hour, t.minute, t.second, 0]) + + +def patch_boot_hook(rom, t, am1581): + # The stub's call to the unpacker goes to a hook in its $BD filler. Once GEOS is unpacked + # into bank 1, the hook maps the cartridge in and runs the routines in the ROM filler. expect(rom, stub(0x1343), bytes.fromhex('20402c'), 'JSR $2C40 (unpack)') expect(rom, stub(0x135C), b'\xbd' * 34, '$BD filler') + code = clock_record(t) + CLOCK + (FIX1581 if am1581 else b'') + start = ROUTINES - 0x8000 + fill = rom[start] + expect(rom, start, bytes([fill]) * len(code), 'filler for the boot routines') + rom[start:start + len(code)] = code rom[stub(0x1343):stub(0x1346)] = bytes.fromhex('205c13') # JSR $135C - rom[stub(0x135C):stub(0x137D)] = bytes.fromhex( + hook = bytes.fromhex( '20402c' # JSR $2C40 - 'a205' # LDX #5 - 'bd7113' # LDA $1371,X - '9d737a' # STA $7A73,X drive $0410 - 'bd7713' # LDA $1377,X - '9dc57a' # STA $7AC5,X drive $0462 - 'ca10f1' # DEX / BPL - '60' # RTS - 'a54148a54248' # $1371: Configure's LDA $41 PHA LDA $42 PHA - '688542688541') # $1377: Configure's PLA STA $42 PLA STA $41 + 'a96a' # LDA #$6A RAM bank 1, I/O, cartridge ROM + '8d00ff' # STA $FF00 + '203081' # JSR $8130 clock + + ('20f981' if am1581 else '') + # JSR $81F9 1581 fix + 'a97f' # LDA #$7F + '8d00ff' # STA $FF00 + '60') # RTS + rom[stub(0x135C):stub(0x135C) + len(hook)] = hook def crt(rom, name): @@ -65,24 +161,28 @@ def crt(rom, name): def main(): - if len(sys.argv) not in (3, 4): - sys.exit(__doc__.strip()) - src, dst = sys.argv[1], sys.argv[2] - name = sys.argv[3] if len(sys.argv) == 4 else 'GEOS 128' - raw = os.path.splitext(dst)[0] + '.bin' - if os.path.abspath(raw) == os.path.abspath(dst): - sys.exit(f'{dst}: the patched ROM is written as .bin, so the .crt cannot be a .bin') - rom = bytearray(open(src, 'rb').read()) + ap = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) + ap.add_argument('--clock', type=datetime.datetime.fromisoformat, + help='date and time GEOS boots with when the TR does not set it ' + '(default: now)') + ap.add_argument('rom') + ap.add_argument('out') + ap.add_argument('name', nargs='?', default='GEOS 128') + args = ap.parse_args() + raw = os.path.splitext(args.out)[0] + '.bin' + if os.path.abspath(raw) == os.path.abspath(args.out): + sys.exit(f'{args.out}: the patched ROM is written as .bin, so the .crt cannot be a .bin') + rom = bytearray(open(args.rom, 'rb').read()) if len(rom) != 0x8000 or rom[7:10] != b'CBM': - sys.exit(f'{src}: not a 32 KB C128 function ROM') - sha = hashlib.sha256(rom).hexdigest() + sys.exit(f'{args.rom}: not a 32 KB C128 function ROM') + am1581 = hashlib.sha256(rom).hexdigest() == AM1581_SHA256 patch_cart_port(rom) - if sha == AM1581_SHA256: - patch_sd2iec_1581(rom) - print('Am1581: added the sd2iec 1581 hook') - open(dst, 'wb').write(crt(rom, name)) + t = args.clock or datetime.datetime.now() + patch_boot_hook(rom, t, am1581) + print(f'boot clock {t:%Y-%m-%d %H:%M:%S}' + (', Am1581: added the sd2iec 1581 fix' if am1581 else '')) + open(args.out, 'wb').write(crt(rom, args.name)) open(raw, 'wb').write(rom) - print(f'wrote {dst}') + print(f'wrote {args.out}') main() From 51475cba3df00f70cccd02af8501c409bc96b515 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 17:06:07 -0500 Subject: [PATCH 10/15] tools/c128: geos128crt.py checks the boot routines land where their hard-coded addresses say --- tools/c128/geos128crt.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/c128/geos128crt.py b/tools/c128/geos128crt.py index 1c4e2aa71..311e7be03 100755 --- a/tools/c128/geos128crt.py +++ b/tools/c128/geos128crt.py @@ -19,6 +19,8 @@ STUB_RAM, STUB_ROM = 0x1300, 0x22 # the loader runs from $1300, copied from ROM offset $22 ROUTINES = 0x8120 # ROM filler the loader never copies +CLOCK_ADDR, FIX1581_ADDR = 0x8130, 0x81F9 # baked into the hook's JSRs and the routines' operands +CLOCK_SCRATCH = 0x1370 # CLOCK's buffer in the stub's filler, just past the hook # The TR finds this record by its tag and keeps the seven bytes after it at local time. CLOCK_TAG = b'TRCLOCK\x01' @@ -136,6 +138,8 @@ def patch_boot_hook(rom, t, am1581): start = ROUTINES - 0x8000 fill = rom[start] expect(rom, start, bytes([fill]) * len(code), 'filler for the boot routines') + assert ROUTINES + len(clock_record(t)) == CLOCK_ADDR + assert CLOCK_ADDR + len(CLOCK) == FIX1581_ADDR rom[start:start + len(code)] = code rom[stub(0x1343):stub(0x1346)] = bytes.fromhex('205c13') # JSR $135C hook = bytes.fromhex( @@ -147,6 +151,7 @@ def patch_boot_hook(rom, t, am1581): 'a97f' # LDA #$7F '8d00ff' # STA $FF00 '60') # RTS + assert 0x135C + len(hook) <= CLOCK_SCRATCH rom[stub(0x135C):stub(0x135C) + len(hook)] = hook From 62ca47cc2b84077af10af047b0ea06fa95c56ca6 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 10:12:11 -0500 Subject: [PATCH 11/15] tools/c128: geos128crt.py builds GEOS 128 from the boot disk's GEOBOOT128 instead of the U36 ROM The cart unpacks GEOBOOT128 into RAM bank 1 and enters it as GEOS's ROM loader does, so GEOS runs the same kernal a disk boot loads: GeoRAM works with GEOS 2.0r's GEOBOOT, and the U36 ROM's own kernal and its sd2iec 1581 fix are gone. It breaks GEOS's RAM reset key, which crashed every later C128 crt until a power cycle, and masks IRQs in the cold start, which the C128 enters with the cart's top page mapped over the CPU vectors. --- tools/c128/README.md | 55 +++-- tools/c128/geos128crt.py | 427 ++++++++++++++++++++++++++------------- 2 files changed, 322 insertions(+), 160 deletions(-) diff --git a/tools/c128/README.md b/tools/c128/README.md index 1e7de53b6..c37fde8a8 100644 --- a/tools/c128/README.md +++ b/tools/c128/README.md @@ -5,44 +5,57 @@ Python 3, standard library only. | Script | Does | |--------|------| -| `geos128crt.py [--clock T] [name]` | Wrap a GEOS 128 v2 U36 function ROM (32 KB raw) as a C128 `.crt`. Patches the loader to read the external function ROM (`$8046`: `$17`→`$2B`) and to stay at 1 MHz while copying (`$8033`: `STY $D030`→`BIT`), and gives GEOS a boot clock (below). For the English 1581 ROM it also adds the sd2iec fix below. Also writes the patched ROM as `.bin`. | +| `geos128crt.py [--clock T] [name]` | Build a C128 `.crt` that boots GEOS 128 v2 from its boot disk's GEOBOOT128 (below). Also writes the ROM as `.bin`. | | `iecdecode.py [prefix]` | Decode the drive commands in a VICE trace of the KERNAL serial entry points: each M-W, M-E and command string, with sd2iec's fast-loader CRC. With a prefix, dumps the drive RAM each M-E ran. | | `vicemon.py "" ""` | Run VICE remote-monitor commands, resume, and run more once a breakpoint stops it, on one connection. | The C128 needs TR firmware that maps a 16 KB + 16 KB C128 `.crt` -(`c128-crt-load-address`). The GEOS ROMs and disks are copyrighted, so they are not here; -build the `.crt` from your own ROM dump and launch it from SD. +(`c128-crt-load-address`). GEOS is copyrighted, so it is not here; build the `.crt` from +your own boot disk and launch it from SD. + +## The GEOS 128 crt + +The cart does what a disk boot does, without the disk's boot loader: it unpacks GEOBOOT128 +into RAM bank 1 at `$2B19`, where the boot loader would have loaded it, and enters it at +`$2F3C` with the setup GEOS's own ROM loader uses. GEOS then reads the rest of itself from drive 8, so the boot disk still has to be +there, and its configuration decides the RAM expansion: the stock GEOS 128 2.0 GEOBOOT for an +REU, or the GEOBOOT and `128 CONFIG 2.0R` from GEOS 2.0r's GeoRAM disk (`GR81128`) with the +TR's Special IO set to GeoRAM. Take the file off the disk with VICE's `c1541`: + + c1541 boot.d81 -read geoboot128 geoboot128.prg + python3 tools/c128/geos128crt.py geoboot128.prg geos128.crt + +GEOBOOT128 is packed (an LZ format of the tool's own, unpacked by the stub) into the cart's +32 KB from `$8300`. The C128 calls a function ROM's cold start with IRQs enabled and the +ROM's top page mapped over the CPU vectors, so the cold start masks IRQs first, and `$FFF9` +holds an `RTI` that the vectors point to. The stub runs from `$1300` with the bottom 8 KB +common to both banks, reading the ROM with `$FF00 = $2B` and writing bank 1 with `$7F`. + +GEOS keeps a RAM reset key in bank 1: `CBM` at `$FFF5` and a vector to `$03E4`. The C128 +KERNAL jumps through it on every reset into C128 mode, before it initializes anything, and +it survives every reset. The TR menu runs in C64 mode, which reuses +`$03E4`, so after GEOS the next C128 crt of any kind crashed at reset. The tool breaks the +`C` in GEOBOOT128 (`$6FF5`), as GEOS's own ROM build does. GEOS booted from disk still leaves +the key, so a C128 crt launched after a disk boot of GEOS needs a power cycle first. ## The boot clock GEOS 128 handles years past 1999 itself: it keeps a two-digit year, rolls 12/31/99 over to 01/01/00, and its leap-year test holds through 2099. What it lacks is a clock source. A cold boot sets CIA1's time-of-day clock to 1:00 PM and the date to 3/23/92, as immediate operands -in the unpacked kernal (bank 1 `$2D88`, the same in all three ROMs). +in GEOBOOT128 (`$2C29`). -The loader's call to the unpacker (`JSR $2C40`) goes to a hook in its `$BD` filler. Once GEOS -is unpacked, the hook maps the cartridge in (`$FF00 = $6A`) and runs a routine in unused ROM -filler at `$8130`. It reads a clock record, sets the TOD from it, writes its year, month and +Once GEOBOOT128 is unpacked, the stub maps the cartridge in (`$FF00 = $6A`) and runs a +routine at `$8200`. It reads a clock record, sets the TOD from it, writes its year, month and day over GEOS's defaults, and jumps GEOS past its own TOD set. If GEOS's defaults are not the expected bytes, it leaves them alone. -The record sits at `$8120`: the tag `TRCLOCK`, a version byte `$01`, then century, year +The record sits at `$81F0`: the tag `TRCLOCK`, a version byte `$01`, then century, year (0–99), month, day, hour (0–23), minute and second. The tool fills it with the time the `.crt` was built, or `--clock`. TR firmware with C128 crt clock support (`c128-crt-clock`) finds the record by its tag and keeps it at local time while the cart runs, so GEOS boots -with the TR's clock. The routine reads the -record until two reads agree, since the TR can rewrite it mid-read. - -## The sd2iec 1581 fix - -sd2iec runs GEOS's fast loader only when the CRC of the M-W upload stream is one it knows -(`doscmd.c`, `fl_crc_table`). The 1581 ROM uploads its own variant of Configure 2.0's 1581 -drive code, 12 bytes different (drive `$0410` and `$0462`), so sd2iec does not recognise -it and GEOS restarts about every half second. The drive code is compressed in the ROM, so -the boot hook above also runs a routine at `$81F9` that copies Configure's bytes over the -unpacked code in RAM bank 1. The upload then matches Configure's byte for byte (CRC `$3F8D`), -and a real 1581 boots the same. The German 1581 ROM has the same loader, but where its drive -code unpacks to has not been checked, so it gets no fix. +with the TR's clock. The routine reads the record until two reads agree, since the TR can +rewrite it mid-read. ## Tracing drive commands in VICE diff --git a/tools/c128/geos128crt.py b/tools/c128/geos128crt.py index 311e7be03..5419c16e5 100755 --- a/tools/c128/geos128crt.py +++ b/tools/c128/geos128crt.py @@ -1,127 +1,284 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT -"""Wrap a GEOS 128 v2 U36 function ROM (32 KB, raw) as a C128 .crt the TR can launch. +"""Build a C128 .crt the TR can launch that boots GEOS 128 v2 the way its boot disk does. - geos128crt.py [--clock YYYY-MM-DDTHH:MM[:SS]] [name] + geos128crt.py [--clock YYYY-MM-DDTHH:MM[:SS]] [name] -GEOS boots with the date and time in the crt's clock record: the time it was built, or ---clock. TR firmware with C128 crt clock support keeps it at local time while the cart runs. -Also writes the patched raw ROM next to the .crt as .bin. + is the GEOBOOT128 file from a GEOS 128 v2 boot disk, e.g. +`c1541 boot.d81 -read geoboot128 geoboot128.prg`. The cart unpacks it into RAM bank 1 and +starts it, and GEOS loads the rest from drive 8 as a disk boot does. GEOS boots with the date +and time in the crt's clock record: the time it was built, or --clock. TR firmware with C128 +crt clock support keeps it at local time while the cart runs. +Also writes the raw ROM next to the .crt as .bin. """ import argparse import datetime -import hashlib import os import struct import sys -AM1581_SHA256 = 'cbe786823301f3f1830f0ab32341d480be20220b8d5d900b2b5ee20b28c8042a' - -STUB_RAM, STUB_ROM = 0x1300, 0x22 # the loader runs from $1300, copied from ROM offset $22 -ROUTINES = 0x8120 # ROM filler the loader never copies -CLOCK_ADDR, FIX1581_ADDR = 0x8130, 0x81F9 # baked into the hook's JSRs and the routines' operands -CLOCK_SCRATCH = 0x1370 # CLOCK's buffer in the stub's filler, just past the hook +LOAD, ENTRY = 0x2B19, 0x2F3C # GEOBOOT128's load address, and where GEOS's ROM loader enters it +SIZE = 38226 +DEFAULTS = 0x2C29 # GEOS's first-boot TOD and date, operands of BOOT4 below +RESET_KEY = 0x6FF5 # unpacks to bank 1 $FFF5 with the rest of GEOS's top page +STUB_RAM, STUB_ROM = 0x1300, 0x8021 +RECORD, CLOCK_ADDR, DATA = 0x81F0, 0x8200, 0x8300 # baked into the stub and CLOCK operands +CLOCK_SCRATCH = 0x1B00 +VECTORS = 0xFFF9 # The TR finds this record by its tag and keeps the seven bytes after it at local time. CLOCK_TAG = b'TRCLOCK\x01' -# GEOS's first-boot defaults, as unpacked to bank 1 $2D88: CIA1 TOD 1:00 PM, then 3/23/92. +# GEOS's first-boot defaults at DEFAULTS: CIA1 TOD 1:00 PM, then 3/23/92. BOOT4 = bytes.fromhex('ad0fdc297f8d0fdc' 'a9818d0bdc' 'a9008d0adc8d09dc8d08dc' 'a95c8d1685' 'a9038d1785' 'a9178d1885') -CLOCK = bytes.fromhex( # $8130, entered with $FF00 = $6A - 'a206' # 8130 ldx #6 read the record until two reads agree - 'bd2881' # 8132 lda $8128,x - '9d7013' # 8135 sta $1370,x - 'ca10f7' # 8138 dex / bpl $8132 - 'a206' # 813b ldx #6 - 'bd2881' # 813d lda $8128,x - 'dd7013' # 8140 cmp $1370,x - 'd0eb' # 8143 bne $8130 - 'ca10f5' # 8145 dex / bpl $813d - 'a226' # 8148 ldx #38 leave GEOS alone unless its defaults are there - 'bd882d' # 814a lda $2d88,x - 'ddd281' # 814d cmp $81d2,x - 'd069' # 8150 bne $81bb - 'ca10f5' # 8152 dex / bpl $814a - 'ad7113' # 8155 lda $1371 - '8da12d' # 8158 sta $2da1 LDA #year - 'ad7213' # 815b lda $1372 - '8da62d' # 815e sta $2da6 LDA #month - 'ad7313' # 8161 lda $1373 - '8dab2d' # 8164 sta $2dab LDA #day - 'a94c8d882d' # 8167 lda #$4c / sta $2d88 - 'a9a08d892d' # 816c lda #$a0 / sta $2d89 - 'a92d8d8a2d' # 8171 lda #$2d / sta $2d8a JMP $2DA0, past GEOS's own TOD set - 'ad0fdc' # 8176 lda $dc0f CRB bit 7 clear: writes set the time, not the alarm - '297f' # 8179 and #$7f - '8d0fdc' # 817b sta $dc0f - 'a000' # 817e ldy #0 0-23 -> 12-hour BCD with the PM flag - 'ad7413' # 8180 lda $1374 - 'c90c' # 8183 cmp #12 - '9004' # 8185 bcc $818b - 'e90c' # 8187 sbc #12 - 'a080' # 8189 ldy #$80 - '8c7713' # 818b sty $1377 - 'aa' # 818e tax - 'd00a' # 818f bne $819b - 'ad7713' # 8191 lda $1377 the 6526 flips AM/PM when 12 is written - '4980' # 8194 eor #$80 - '8d7713' # 8196 sta $1377 - 'a90c' # 8199 lda #12 - '20bc81' # 819b jsr $81bc - '0d7713' # 819e ora $1377 - '8d0bdc' # 81a1 sta $dc0b - 'ad7513' # 81a4 lda $1375 - '20bc81' # 81a7 jsr $81bc - '8d0adc' # 81aa sta $dc0a - 'ad7613' # 81ad lda $1376 - '20bc81' # 81b0 jsr $81bc - '8d09dc' # 81b3 sta $dc09 - 'a900' # 81b6 lda #0 - '8d08dc' # 81b8 sta $dc08 tenths last: restarts the clock - '60' # 81bb rts - 'a2ff38' # 81bc ldx #$ff / sec A = 0-99 -> BCD - 'e8' # 81bf inx - 'e90a' # 81c0 sbc #10 - 'b0fb' # 81c2 bcs $81bf - '690a' # 81c4 adc #10 - '8d7813' # 81c6 sta $1378 - '8a0a0a0a0a' # 81c9 txa / asl x4 - '0d7813' # 81ce ora $1378 - '60' # 81d1 rts -) + BOOT4 # 81d2 - -# sd2iec starts its GEOS fast loader only when the M-W stream's CRC is one it knows. The Am1581 -# ROM uploads a variant of Configure 2.0's 1581 code that differs in 12 bytes, so the CRC misses -# 0x3F8D and GEOS restarts. This copies Configure's bytes over the unpacked drive code. -FIX1581 = bytes.fromhex( # $81f9 - 'a205' # 81f9 ldx #5 - 'bd0b82' # 81fb lda $820b,x - '9d737a' # 81fe sta $7a73,x drive $0410 - 'bd1182' # 8201 lda $8211,x - '9dc57a' # 8204 sta $7ac5,x drive $0462 - 'ca10f1' # 8207 dex / bpl $81fb - '60' # 820a rts - 'a54148a54248' # 820b Configure's LDA $41 PHA LDA $42 PHA - '688542688541') # 8211 Configure's PLA STA $42 PLA STA $41 - - -def stub(addr): - return addr - STUB_RAM + STUB_ROM - - -def expect(rom, offset, want, what): - if rom[offset:offset + len(want)] != want: - sys.exit(f'{what}: expected {want.hex()} at ${0x8000 + offset:04X}, found ' - f'{rom[offset:offset + len(want)].hex()} -- not a GEOS 128 v2 U36 ROM?') - - -def patch_cart_port(rom): - expect(rom, 0x45, b'\xa2\x17', 'LDX #$17 (internal function ROM)') - rom[0x46] = 0x2B # read config: external function ROM lo+hi, RAM bank 0 - expect(rom, 0x33, b'\x8c\x30\xd0', 'STY $D030') - rom[0x33] = 0x2C # BIT $D030: stay at 1 MHz while reading the cartridge +STUB = bytes.fromhex( # $8000, a C128 function ROM; copies $8021 on to $1300 + 'ea' # 8000 nop + 'ea' # 8001 nop + 'ea' # 8002 nop + '4c0a80' # 8003 jmp cold + 'ff' # 8006 !byte $ff + '43424d' # 8007 !text "CBM" + '78' # 800a cold sei IRQs are on, and this ROM's top page is the CPU vectors + 'ad00ff' # 800b lda $ff00 + '293f' # 800e and #$3f + '8d00ff' # 8010 sta $ff00 + 'a200' # 8013 ldx #0 + 'bd2180' # 8015 - lda stubrom,x + '9d0013' # 8018 sta $1300,x + 'e8' # 801b inx + 'd0f7' # 801c bne - + '4c0013' # 801e jmp $1300 + '78' # 1300 sei + 'a93e' # 1301 lda #$3e + '8d00ff' # 1303 sta $ff00 + 'a906' # 1306 lda #$06 + '8d06d5' # 1308 sta $d506 + 'a900' # 130b lda #0 + '8d11d0' # 130d sta $d011 + 'a900' # 1310 lda #DATA + '8503' # 1316 sta src+1 + 'a919' # 1318 lda #$19 + '8504' # 131a sta dst + 'a92b' # 131c lda #$2b + '8505' # 131e sta dst+1 + 'a97f' # 1320 lda #$7f + '8d00ff' # 1322 sta $ff00 + '208513' # 1325 next jsr get + 'f03d' # 1328 beq done + '300c' # 132a bmi copy + 'aa' # 132c tax + '208513' # 132d - jsr get + '209c13' # 1330 jsr put + 'ca' # 1333 dex + 'd0f7' # 1334 bne - + 'f0ed' # 1336 beq next + '297f' # 1338 copy and #$7f + '18' # 133a clc + '6903' # 133b adc #3 + 'aa' # 133d tax + '208513' # 133e jsr get + '8508' # 1341 sta tmp + '208513' # 1343 jsr get + '8509' # 1346 sta tmp+1 + '38' # 1348 sec + 'a504' # 1349 lda dst + 'e508' # 134b sbc tmp + '8506' # 134d sta ref + 'a505' # 134f lda dst+1 + 'e509' # 1351 sbc tmp+1 + '8507' # 1353 sta ref+1 + 'a000' # 1355 - ldy #0 + 'b106' # 1357 lda (ref),y + '209c13' # 1359 jsr put + 'e606' # 135c inc ref + 'd002' # 135e bne + + 'e607' # 1360 inc ref+1 + 'ca' # 1362 + dex + 'd0f0' # 1363 bne - + 'f0be' # 1365 beq next + 'a96a' # 1367 done lda #$6a + '8d00ff' # 1369 sta $ff00 + '200082' # 136c jsr CLOCK + 'a97e' # 136f lda #$7e + '8d00ff' # 1371 sta $ff00 + 'a91b' # 1374 lda #$1b + '8d11d0' # 1376 sta $d011 + 'a900' # 1379 lda #0 + '8d30d0' # 137b sta $d030 + 'a908' # 137e lda #8 + '85ba' # 1380 sta $ba + '4c3c2f' # 1382 jmp $2f3c + 'a92b' # 1385 get lda #$2b + '8d00ff' # 1387 sta $ff00 + 'a000' # 138a ldy #0 + 'b102' # 138c lda (src),y + 'a07f' # 138e ldy #$7f + '8c00ff' # 1390 sty $ff00 + 'e602' # 1393 inc src + 'd002' # 1395 bne + + 'e603' # 1397 inc src+1 + '48' # 1399 + pha + '68' # 139a pla + '60' # 139b rts + 'a000' # 139c put ldy #0 + '9104' # 139e sta (dst),y + 'e604' # 13a0 inc dst + 'd002' # 13a2 bne + + 'e605' # 13a4 inc dst+1 + '60') # 13a6 + rts + +CLOCK = bytes.fromhex( # $8200, entered with $FF00 = $6A + 'a206' # 8200 CLOCK ldx #6 + 'bdf881' # 8202 - lda REC,x + '9d001b' # 8205 sta SCR,x + 'ca' # 8208 dex + '10f7' # 8209 bpl - + 'a206' # 820b ldx #6 + 'bdf881' # 820d - lda REC,x + 'dd001b' # 8210 cmp SCR,x + 'd0eb' # 8213 bne CLOCK + 'ca' # 8215 dex + '10f5' # 8216 bpl - + 'a226' # 8218 ldx #38 + 'bd292c' # 821a - lda DEF,x + 'dda282' # 821d cmp BOOT4,x + 'd069' # 8220 bne out + 'ca' # 8222 dex + '10f5' # 8223 bpl - + 'ad011b' # 8225 lda SCR+1 + '8d422c' # 8228 sta DEF+$19 + 'ad021b' # 822b lda SCR+2 + '8d472c' # 822e sta DEF+$1e + 'ad031b' # 8231 lda SCR+3 + '8d4c2c' # 8234 sta DEF+$23 + 'a94c' # 8237 lda #$4c + '8d292c' # 8239 sta DEF + 'a941' # 823c lda #<(DEF+$18) + '8d2a2c' # 823e sta DEF+1 + 'a92c' # 8241 lda #>(DEF+$18) + '8d2b2c' # 8243 sta DEF+2 + 'ad0fdc' # 8246 lda $dc0f + '297f' # 8249 and #$7f + '8d0fdc' # 824b sta $dc0f + 'a000' # 824e ldy #0 + 'ad041b' # 8250 lda SCR+4 + 'c90c' # 8253 cmp #12 + '9004' # 8255 bcc + + 'e90c' # 8257 sbc #12 + 'a080' # 8259 ldy #$80 + '8c071b' # 825b + sty SCR+7 + 'aa' # 825e tax + 'd00a' # 825f bne + + 'ad071b' # 8261 lda SCR+7 + '4980' # 8264 eor #$80 + '8d071b' # 8266 sta SCR+7 + 'a90c' # 8269 lda #12 + '208c82' # 826b + jsr bcd + '0d071b' # 826e ora SCR+7 + '8d0bdc' # 8271 sta $dc0b + 'ad051b' # 8274 lda SCR+5 + '208c82' # 8277 jsr bcd + '8d0adc' # 827a sta $dc0a + 'ad061b' # 827d lda SCR+6 + '208c82' # 8280 jsr bcd + '8d09dc' # 8283 sta $dc09 + 'a900' # 8286 lda #0 + '8d08dc' # 8288 sta $dc08 + '60' # 828b out rts + 'a2ff' # 828c bcd ldx #$ff + '38' # 828e sec + 'e8' # 828f - inx + 'e90a' # 8290 sbc #10 + 'b0fb' # 8292 bcs - + '690a' # 8294 adc #10 + '8d081b' # 8296 sta SCR+8 + '8a' # 8299 txa + '0a' # 829a asl + '0a' # 829b asl + '0a' # 829c asl + '0a' # 829d asl + '0d081b' # 829e ora SCR+8 + '60' # 82a1 rts +) + BOOT4 # 82a2 + +# Addresses the code above has baked in, as (code, offset of the operand, what it must be). +OPERANDS = ((STUB, 0x16, STUB_ROM), (STUB, 0x19, STUB_RAM), (STUB, 0x1F, STUB_RAM), + (STUB, 0x8E, CLOCK_ADDR), (STUB, 0xA4, ENTRY), + (CLOCK, 0x03, RECORD + len(CLOCK_TAG)), (CLOCK, 0x06, CLOCK_SCRATCH), + (CLOCK, 0x1B, DEFAULTS), (CLOCK, 0x1E, CLOCK_ADDR + len(CLOCK) - len(BOOT4))) +LZ_MIN, LZ_MAX_COPY, LZ_MAX_RUN = 3, 127 + 3, 127 + + +def lz_pack(data): + """The stub's format. A control byte of 0 ends; 1-127 is a run of that many literal bytes; + $80 + n copies n + 3 bytes from the distance in the two bytes that follow (lo, hi).""" + out, lit, seen = bytearray(), bytearray(), {} + + def flush(): + while lit: + n = min(len(lit), LZ_MAX_RUN) + out.append(n) + out.extend(lit[:n]) + del lit[:n] + + i = 0 + while i < len(data): + best, dist = 0, 0 + for j in reversed(seen.get(data[i:i + LZ_MIN], [])[-256:]): + n = 0 + while i + n < len(data) and n < LZ_MAX_COPY and data[j + n] == data[i + n]: + n += 1 + if n > best: + best, dist = n, i - j + if n == LZ_MAX_COPY: + break + if best >= 4 or (best == LZ_MIN and not lit): + flush() + out += bytes([0x80 | (best - LZ_MIN), dist & 0xFF, dist >> 8]) + for k in range(i, i + best): + seen.setdefault(data[k:k + LZ_MIN], []).append(k) + i += best + else: + seen.setdefault(data[i:i + LZ_MIN], []).append(i) + lit.append(data[i]) + i += 1 + flush() + out.append(0) + return bytes(out) + + +def lz_unpack(packed): + out, i = bytearray(), 0 + while packed[i]: + n = packed[i] + if n < 0x80: + out += packed[i + 1:i + 1 + n] + i += 1 + n + else: + dist = packed[i + 1] | packed[i + 2] << 8 + for _ in range((n & 0x7F) + LZ_MIN): + out.append(out[-dist]) + i += 3 + return bytes(out) + + +def expect(image, addr, want, what): + at = addr - LOAD + if image[at:at + len(want)] != want: + sys.exit(f'{what}: expected {want.hex()} at ${addr:04X}, found ' + f'{image[at:at + len(want)].hex()} -- not a GEOS 128 v2 GEOBOOT128?') + + +def break_reset_key(image): + # GEOS puts "CBM" and $03E4 at bank 1 $FFF5, and the C128 KERNAL jumps through that on + # every C128-mode reset until the power goes off. The TR menu boots in C64 mode, which + # overwrites $03E4, so the next C128 crt crashes. GEOS's own ROM build breaks it too. + expect(image, RESET_KEY, b'CBM' + struct.pack(' 0xFF00: # the MMU registers at $FF00 hide the ROM from there up + sys.exit(f'GEOBOOT128 packs to {len(packed)} bytes, {DATA + len(packed) - 0xFF00} too many') + rom = bytearray(b'\xff' * 0x8000) + for addr, data in ((0x8000, STUB), (RECORD, clock_record(t)), (CLOCK_ADDR, CLOCK), + (DATA, packed), (VECTORS, b'\x40' + struct.pack('.bin, so the .crt cannot be a .bin') - rom = bytearray(open(args.rom, 'rb').read()) - if len(rom) != 0x8000 or rom[7:10] != b'CBM': - sys.exit(f'{args.rom}: not a 32 KB C128 function ROM') - am1581 = hashlib.sha256(rom).hexdigest() == AM1581_SHA256 - patch_cart_port(rom) + sys.exit(f'{args.out}: the ROM is written as .bin, so the .crt cannot be a .bin') + prg = open(args.geoboot, 'rb').read() + if len(prg) != 2 + SIZE or prg[:2] != struct.pack(' Date: Fri, 16 Oct 2026 10:15:46 -0500 Subject: [PATCH 12/15] tools/c128: geos128crt.py sends an IRQ before the cold start's SEI into the cold start, and checks the rest of its baked-in operands An IRQ taken through the RTI at $FFF9 is still asserted when the RTI returns, and the 6502 takes it again before the next instruction, so one arriving between the KERNAL's call and the SEI hung the boot. --- tools/c128/README.md | 2 +- tools/c128/geos128crt.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/tools/c128/README.md b/tools/c128/README.md index c37fde8a8..6ae6dc248 100644 --- a/tools/c128/README.md +++ b/tools/c128/README.md @@ -28,7 +28,7 @@ TR's Special IO set to GeoRAM. Take the file off the disk with VICE's `c1541`: GEOBOOT128 is packed (an LZ format of the tool's own, unpacked by the stub) into the cart's 32 KB from `$8300`. The C128 calls a function ROM's cold start with IRQs enabled and the ROM's top page mapped over the CPU vectors, so the cold start masks IRQs first, and `$FFF9` -holds an `RTI` that the vectors point to. The stub runs from `$1300` with the bottom 8 KB +holds an `RTI` that the NMI vector points to; the IRQ vector points at the cold start. The stub runs from `$1300` with the bottom 8 KB common to both banks, reading the ROM with `$FF00 = $2B` and writing bank 1 with `$7F`. GEOS keeps a RAM reset key in bank 1: `CBM` at `$FFF5` and a vector to `$03E4`. The C128 diff --git a/tools/c128/geos128crt.py b/tools/c128/geos128crt.py index 5419c16e5..c5f0c481e 100755 --- a/tools/c128/geos128crt.py +++ b/tools/c128/geos128crt.py @@ -24,7 +24,7 @@ STUB_RAM, STUB_ROM = 0x1300, 0x8021 RECORD, CLOCK_ADDR, DATA = 0x81F0, 0x8200, 0x8300 # baked into the stub and CLOCK operands CLOCK_SCRATCH = 0x1B00 -VECTORS = 0xFFF9 +VECTORS, COLD = 0xFFF9, 0x800A # The TR finds this record by its tag and keeps the seven bytes after it at local time. CLOCK_TAG = b'TRCLOCK\x01' @@ -206,10 +206,12 @@ ) + BOOT4 # 82a2 # Addresses the code above has baked in, as (code, offset of the operand, what it must be). -OPERANDS = ((STUB, 0x16, STUB_ROM), (STUB, 0x19, STUB_RAM), (STUB, 0x1F, STUB_RAM), +OPERANDS = ((STUB, 0x04, COLD), (STUB, 0x16, STUB_ROM), (STUB, 0x19, STUB_RAM), (STUB, 0x1F, STUB_RAM), (STUB, 0x8E, CLOCK_ADDR), (STUB, 0xA4, ENTRY), (CLOCK, 0x03, RECORD + len(CLOCK_TAG)), (CLOCK, 0x06, CLOCK_SCRATCH), - (CLOCK, 0x1B, DEFAULTS), (CLOCK, 0x1E, CLOCK_ADDR + len(CLOCK) - len(BOOT4))) + (CLOCK, 0x0E, RECORD + len(CLOCK_TAG)), (CLOCK, 0x1B, DEFAULTS), + (CLOCK, 0x29, DEFAULTS + 0x19), (CLOCK, 0x2F, DEFAULTS + 0x1E), + (CLOCK, 0x35, DEFAULTS + 0x23), (CLOCK, 0x3A, DEFAULTS), (CLOCK, 0x1E, CLOCK_ADDR + len(CLOCK) - len(BOOT4))) LZ_MIN, LZ_MAX_COPY, LZ_MAX_RUN = 3, 127 + 3, 127 @@ -289,16 +291,19 @@ def clock_record(t): def rom_image(image, t): for code, offset, want in OPERANDS: assert code[offset] | code[offset + 1] << 8 == want, (hex(offset), hex(want)) - assert STUB[0x32] | STUB[0x36] << 8 == DATA + assert STUB[0x32] | STUB[0x36] << 8 == DATA and STUB[0x3A] | STUB[0x3E] << 8 == LOAD + assert CLOCK[0x3D] | CLOCK[0x42] << 8 == DEFAULTS + 0x18 assert 0x8000 + len(STUB) <= RECORD and len(STUB) - (STUB_ROM - 0x8000) <= 0x100 assert RECORD + len(clock_record(t)) == CLOCK_ADDR and CLOCK_ADDR + len(CLOCK) <= DATA packed = lz_pack(bytes(image)) assert lz_unpack(packed) == image if DATA + len(packed) > 0xFF00: # the MMU registers at $FF00 hide the ROM from there up sys.exit(f'GEOBOOT128 packs to {len(packed)} bytes, {DATA + len(packed) - 0xFF00} too many') + # An IRQ that arrives before the cold start's SEI is still asserted when an RTI returns, + # and the 6502 takes it again before the next instruction, so IRQ enters the cold start. rom = bytearray(b'\xff' * 0x8000) for addr, data in ((0x8000, STUB), (RECORD, clock_record(t)), (CLOCK_ADDR, CLOCK), - (DATA, packed), (VECTORS, b'\x40' + struct.pack(' Date: Fri, 16 Oct 2026 10:22:35 -0500 Subject: [PATCH 13/15] tools/c128: geos128crt.py checks every baked-in operand of the stub and CLOCK, and refuses a pack that puts CBM at $C007 --- tools/c128/geos128crt.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tools/c128/geos128crt.py b/tools/c128/geos128crt.py index c5f0c481e..c1665f366 100755 --- a/tools/c128/geos128crt.py +++ b/tools/c128/geos128crt.py @@ -206,12 +206,22 @@ ) + BOOT4 # 82a2 # Addresses the code above has baked in, as (code, offset of the operand, what it must be). +GET, PUT, BCD = STUB_RAM + 0x85, STUB_RAM + 0x9C, CLOCK_ADDR + 0x8C OPERANDS = ((STUB, 0x04, COLD), (STUB, 0x16, STUB_ROM), (STUB, 0x19, STUB_RAM), (STUB, 0x1F, STUB_RAM), - (STUB, 0x8E, CLOCK_ADDR), (STUB, 0xA4, ENTRY), + (STUB, 0x47, GET), (STUB, 0x4F, GET), (STUB, 0x52, PUT), (STUB, 0x60, GET), + (STUB, 0x65, GET), (STUB, 0x7B, PUT), (STUB, 0x8E, CLOCK_ADDR), (STUB, 0xA4, ENTRY), (CLOCK, 0x03, RECORD + len(CLOCK_TAG)), (CLOCK, 0x06, CLOCK_SCRATCH), - (CLOCK, 0x0E, RECORD + len(CLOCK_TAG)), (CLOCK, 0x1B, DEFAULTS), - (CLOCK, 0x29, DEFAULTS + 0x19), (CLOCK, 0x2F, DEFAULTS + 0x1E), - (CLOCK, 0x35, DEFAULTS + 0x23), (CLOCK, 0x3A, DEFAULTS), (CLOCK, 0x1E, CLOCK_ADDR + len(CLOCK) - len(BOOT4))) + (CLOCK, 0x0E, RECORD + len(CLOCK_TAG)), (CLOCK, 0x11, CLOCK_SCRATCH), + (CLOCK, 0x1B, DEFAULTS), (CLOCK, 0x1E, CLOCK_ADDR + len(CLOCK) - len(BOOT4)), + (CLOCK, 0x26, CLOCK_SCRATCH + 1), (CLOCK, 0x29, DEFAULTS + 0x19), + (CLOCK, 0x2C, CLOCK_SCRATCH + 2), (CLOCK, 0x2F, DEFAULTS + 0x1E), + (CLOCK, 0x32, CLOCK_SCRATCH + 3), (CLOCK, 0x35, DEFAULTS + 0x23), + (CLOCK, 0x3A, DEFAULTS), (CLOCK, 0x3F, DEFAULTS + 1), (CLOCK, 0x44, DEFAULTS + 2), + (CLOCK, 0x51, CLOCK_SCRATCH + 4), (CLOCK, 0x5C, CLOCK_SCRATCH + 7), + (CLOCK, 0x62, CLOCK_SCRATCH + 7), (CLOCK, 0x67, CLOCK_SCRATCH + 7), + (CLOCK, 0x6C, BCD), (CLOCK, 0x6F, CLOCK_SCRATCH + 7), (CLOCK, 0x75, CLOCK_SCRATCH + 5), + (CLOCK, 0x78, BCD), (CLOCK, 0x7E, CLOCK_SCRATCH + 6), (CLOCK, 0x81, BCD), + (CLOCK, 0x97, CLOCK_SCRATCH + 8), (CLOCK, 0x9F, CLOCK_SCRATCH + 8)) LZ_MIN, LZ_MAX_COPY, LZ_MAX_RUN = 3, 127 + 3, 127 @@ -305,6 +315,8 @@ def rom_image(image, t): for addr, data in ((0x8000, STUB), (RECORD, clock_record(t)), (CLOCK_ADDR, CLOCK), (DATA, packed), (VECTORS, b'\x40' + struct.pack(' Date: Fri, 16 Oct 2026 10:25:04 -0500 Subject: [PATCH 14/15] tools/c128: geos128crt.py checks CLOCK's compare of GEOS's first-boot code covers all of BOOT4 --- tools/c128/geos128crt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/c128/geos128crt.py b/tools/c128/geos128crt.py index c1665f366..8baa8371a 100755 --- a/tools/c128/geos128crt.py +++ b/tools/c128/geos128crt.py @@ -302,7 +302,7 @@ def rom_image(image, t): for code, offset, want in OPERANDS: assert code[offset] | code[offset + 1] << 8 == want, (hex(offset), hex(want)) assert STUB[0x32] | STUB[0x36] << 8 == DATA and STUB[0x3A] | STUB[0x3E] << 8 == LOAD - assert CLOCK[0x3D] | CLOCK[0x42] << 8 == DEFAULTS + 0x18 + assert CLOCK[0x3D] | CLOCK[0x42] << 8 == DEFAULTS + 0x18 and CLOCK[0x19] == len(BOOT4) - 1 assert 0x8000 + len(STUB) <= RECORD and len(STUB) - (STUB_ROM - 0x8000) <= 0x100 assert RECORD + len(clock_record(t)) == CLOCK_ADDR and CLOCK_ADDR + len(CLOCK) <= DATA packed = lz_pack(bytes(image)) From ea5400aaa15d8abec395d90053885d88fb11f8ea Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Fri, 16 Oct 2026 10:25:54 -0500 Subject: [PATCH 15/15] tools/c128: README drops the disk-boot reset-key note; only TR launches are in scope --- tools/c128/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/c128/README.md b/tools/c128/README.md index 6ae6dc248..05f746edc 100644 --- a/tools/c128/README.md +++ b/tools/c128/README.md @@ -35,8 +35,7 @@ GEOS keeps a RAM reset key in bank 1: `CBM` at `$FFF5` and a vector to `$03E4`. KERNAL jumps through it on every reset into C128 mode, before it initializes anything, and it survives every reset. The TR menu runs in C64 mode, which reuses `$03E4`, so after GEOS the next C128 crt of any kind crashed at reset. The tool breaks the -`C` in GEOBOOT128 (`$6FF5`), as GEOS's own ROM build does. GEOS booted from disk still leaves -the key, so a C128 crt launched after a disk boot of GEOS needs a power cycle first. +`C` in GEOBOOT128 (`$6FF5`), as GEOS's own ROM build does. ## The boot clock