From 0d68b3e696822462a5ba2710a12acabd52a25c21 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 27 Oct 2026 08:05:09 -0500 Subject: [PATCH 1/6] tools: a VICE remote-monitor runner and a typing bench script for C128 work vicemon.py runs VICE remote-monitor commands, resumes, and runs more once a breakpoint stops it, on one connection. 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 | 15 ++++++++++++ tools/c128/vicemon.py | 57 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+) create mode 100755 tools/bench/type.py create mode 100644 tools/c128/README.md create mode 100755 tools/c128/vicemon.py diff --git a/tools/bench/README.md b/tools/bench/README.md index b3734be8f..c2e9785d6 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..4e6b38253 --- /dev/null +++ b/tools/c128/README.md @@ -0,0 +1,15 @@ +# C128 tools + +Scripts for working on C128 code for the TR in VICE. Python 3, standard library only. + +| Script | Does | +|--------|------| +| `vicemon.py "" ""` | Run VICE remote-monitor commands, resume, and run more once a breakpoint stops it, on one connection. | + +## Headless 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 -remotemonitor -monlog -monlogname mon.log -limitcycles 2000000000 & + python3 tools/c128/vicemon.py "break 2f3c" "m 2f3c 2f4f" 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 788cc77cf0112305401635e7643843d55ec4aadc Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 14:36:44 -0500 Subject: [PATCH 2/6] 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 fb693bf4410a0d51c57b335bfe1d72c931bedc77 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 14:37:46 -0500 Subject: [PATCH 3/6] 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 5787b5b7fda1e057bd219b5826ae591389469342 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 14:38:17 -0500 Subject: [PATCH 4/6] 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 45e3525429e54a5498131499d77a2ec71517ebc6 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Thu, 15 Oct 2026 14:38:36 -0500 Subject: [PATCH 5/6] 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 1725e8a15f91adf82514a5dbbd1d3f6afc8a7718 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 27 Oct 2026 14:58:20 -0500 Subject: [PATCH 6/6] fix: review findings for 45e3525 --- tools/bench/test_type.py | 33 +++++++++++++++++++++++++++++++++ tools/bench/type.py | 35 +++++++++++++++++++++++++++-------- 2 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 tools/bench/test_type.py diff --git a/tools/bench/test_type.py b/tools/bench/test_type.py new file mode 100644 index 000000000..c515a27a2 --- /dev/null +++ b/tools/bench/test_type.py @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: MIT +"""Tests for type.py's text-to-key-codes step. python3 -m unittest discover -s tools/bench""" +import importlib +import unittest + +keys = importlib.import_module('type').keys + + +class Keys(unittest.TestCase): + def test_letters_go_in_unshifted(self): + self.assertEqual(keys('list'), b'LIST') + + def test_newline_is_return(self): + self.assertEqual(keys('run\\n'), b'RUN\r') + + def test_hex_escape_is_sent_as_written(self): + self.assertEqual(keys('\\x61\\x0a\\x85'), b'\x61\x0a\x85') + + def test_backslash_escapes_itself(self): + self.assertEqual(keys('a\\\\b'), b'A\\B') + + def test_malformed_escape_is_refused(self): + for text in ('ab\\', '\\xZZ', '\\x6', '\\t', '\\u0041'): + with self.subTest(text=text), self.assertRaises(ValueError): + keys(text) + + def test_non_ascii_is_refused(self): + with self.assertRaises(ValueError): + keys('café') + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/bench/type.py b/tools/bench/type.py index e8c212fee..3542841df 100755 --- a/tools/bench/type.py +++ b/tools/bench/type.py @@ -4,16 +4,19 @@ type.py [--c128] '' -`\\n` is RETURN and `\\xNN` is any key code. Letters go in unshifted (uppercase on the +`\\n` is RETURN, `\\\\` a backslash and `\\xNN` 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 re import sys import time from c64 import KEYBUF, KEYBUF_128, KEYBUF_SIZE, KEYCOUNT, KEYCOUNT_128 from trlink import Link DRAIN_TIMEOUT = 30 +RETURN = 0x0D +TOKEN = re.compile(r'\\x([0-9a-fA-F]{2})|\\(.?)|(.)', re.DOTALL) def main(): @@ -23,13 +26,10 @@ def main(): if len(args) != 1: sys.exit(__doc__.strip()) keybuf, keycount = (KEYBUF_128, KEYCOUNT_128) if c128 else (KEYBUF, KEYCOUNT) - 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') + data = keys(args[0]) + except ValueError as e: + sys.exit(str(e)) with Link() as tr: for i in range(0, len(data), KEYBUF_SIZE): wait_until_read(tr, keycount, i) @@ -39,6 +39,24 @@ def main(): wait_until_read(tr, keycount, len(data)) +def keys(text): + if not text.isascii(): + raise ValueError('ASCII only: write any other key code as \\xNN') + out = bytearray() + for code, escaped, plain in TOKEN.findall(text): + if code: + out.append(int(code, 16)) + elif plain: + out += plain.upper().encode() + elif escaped == 'n': + out.append(RETURN) + elif escaped == '\\': + out += b'\\' + else: + raise ValueError(f'unknown escape \\{escaped}: use \\n, \\\\ or \\xNN') + return bytes(out) + + def wait_until_read(tr, keycount, at): start = time.time() while tr.peek(keycount, 1)[0]: @@ -47,4 +65,5 @@ def wait_until_read(tr, keycount, at): time.sleep(0.1) -main() +if __name__ == '__main__': + main()