Skip to content

tools/bench: host-side scripts for driving a TeensyROM, and CI that runs them - #35

Merged
SensoriumEmbedded merged 8 commits into
SensoriumEmbedded:mainfrom
kfox:bench
Sep 22, 2026
Merged

SensoriumEmbedded merged 8 commits into
SensoriumEmbedded:mainfrom
kfox:bench

Conversation

@kfox

@kfox kfox commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Host-side scripts for driving a real TeensyROM over USB serial: reflash it,
push files, launch things, list the card, read and poke the C64's memory.
Python 3, standard library only.

They were written to verify firmware with nobody at the machine, and they
replace a set of one-off scripts that each carried their own copy of the port
code and a hardcoded device name. tools/Debug/ keeps the scope and
bus-timing scripts, which are a separate stack on pyserial.

What is here

fwupdate.py   reflash from the host: push the hex, launch it, answer the
              C64's Y/N prompt by DMA, then check the board's banner
              against the build stamp in the hex
push.py       copy files to the SD card
ls.py         list a directory
launch.py     launch a file
probe.py      which image is running, and its build banner
reset.py      back to the menu, and out of the minimal image
peek.py       hex dump C64 memory
screen.py     the C64 text screen
keypress.py   put a key in the keyboard buffer
colors.py     top row, colour RAM and the VIC colour registers
listen.py     echo serial output

on trlink.py (the link), protocol.py (every wire value) and c64.py
(memory locations and screen codes).

No magic numbers

protocol.py holds every value that goes on the wire, named as the firmware
names it, and test_protocol.py reads those names back out of Source/Teensy/
and fails if any pair has drifted. So a moved token fails a test rather than a
board, and nothing else in the package restates a byte string.

Tests, without hardware

python3 -m unittest discover -s tools/bench runs a fake board on a pty. It
keeps its own byte-order encoding, so it and the code under test cannot agree
on a wrong answer.

CI, and one thing it was not checking

Nothing ran these tests, so the last commit adds a tools job for the suites
that need no Teensy toolchain. While adding it, a gap turned up that is worth
more than the bench scripts are: tools/build-c64.test.mjs reassembles the
C64 projects and byte-compares against the committed .prg.h headers, which
is the only thing standing between a .asm change and firmware built from a
stale header -- and it was skipping in CI, because it skips when ACME is not
on PATH and no job installed one. It also named three projects of twelve.

The job installs ACME, the test now derives its list from the project manifest
and asserts rather than skips under CI, and it fails if the build writes a
different set of headers than the manifest declares. Corrupting a byte of
ASIDPlayer.prg.h now fails; before, it passed. TRCustomBasicCommands stays
uncovered: KickAssembler needs a JRE.

collect now waits on both jobs. It owns the release publish, and moving
npm test out of the build matrix would otherwise have let a Release_v* tag
ship over a red header comparison.

ACME is deliberately unpinned in the workflow: tools/check-pins.mjs tracks
five ACME pin sites and reads build.yml for other pins, so a version named
there would be a sixth that its cross-file check never sees. The byte
comparison is the guarantee, which is what a version string could not give.

Reviewed, and what that found

Every commit here was reviewed on its own before it was pushed, including the
first, which is an unmodified cherry-pick of work that arrived from elsewhere
and had never been examined. Reviewing it turned out to be worth it -- see the
last bullet. Worth naming the findings, because each was a silent failure in
the direction that matters:

  • listdir() returned a truncated listing as a complete one when the board
    went quiet part way through, so a tool whose job is "did my push land?"
    would have reported a file that is there as missing.
  • fwcheck() died on the Loading IO handler: line the firmware prints
    unprompted -- which a reset or a launch causes, so probe.py failed in the
    one situation it exists for.
  • An assert.ok(built.length > 0) in the CI commit itself passed with fifteen
    of sixteen headers written, because the comparison loop only iterates what
    the build produced. It now compares against the set the manifest declares.
  • wr() retried os.write on BlockingIOError with no deadline, while every
    neighbouring method carries one. A 4 MB write to a reader that had gone away
    was still spinning when killed at 20s; it now gives up after 30s of no
    progress and says how far it got. Found in the cherry-picked commit.

Not covered

The fake board proves framing and byte order, not what a real board does.
exttest.py describes the extension image, which arrives with PR #31; the
other two images are here today.

The bench tests need macOS or Linux -- termios and pty.

This touches .github/workflows/build.yml, which PR #31 also touches; that
branch needs a one-line rebase to move its verify:extensions step into the
new job once this lands.

…erial

Reflash without the program button (SD push + remote launch + the C64's Y/N
answered by DMA), push files, launch, peek the C64's memory, read the screen,
queue a keypress, and launch an extension and report how it ended. They share one
small serial library and are covered by tests against a fake board on a pty.

These replace a set of one-off scripts that each carried its own copy of the
port code and a hardcoded device name.
They arrived from a branch that also carried the extension loader, so their
references point at files main does not have: tools/flash-firmware.mjs, and
vm/abi/README.md for the failure record. Point at docs/General_Usage.md and
tools/Debug/ instead, so the next reader finds the other flashing routes and
knows why there are two serial stacks in this tree.

The extension image these docs describe is PR SensoriumEmbedded#31's, and so are the
build/extensions/ paths in the hello recipe; say so rather than leaving a
reader hunting for them. Importing trlink leaves a __pycache__ behind, so
ignore it.
…mware

The scripts carried byte strings inline -- b'\x64\xDE' for a directory listing,
b'\xcc\x64' for an Ack -- and the fake board in the tests carried its own copy
of each. Two copies of a wire value is how they drift, and neither copy says
which firmware constant it came from.

protocol.py now holds every value that goes on the wire and c64.py every
machine address, each named as the firmware names it. test_protocol.py reads
those names back out of Source/Teensy/ and fails if any pair has drifted, and a
second test fails if protocol.py grows a value that mapping does not cover. The
fake board keeps its own byte-order encoding, so it and the code under test
still cannot agree on a wrong answer.

The commands and their bytes are unchanged, with one exception: colors.py reads
the 17 VIC registers it prints rather than 32.
Three commands the firmware has always answered and nothing here used.

FWCheckToken is the designed way to tell the main image from the minimal one,
and both answer it whatever else they are doing; probe.py now asks it instead
of sending 'b' and reading the reply, which could not tell the two apart and
depended on a debug command minimal does not have. It reads past anything the
board says unprompted -- IOHandlers.ino:51 prints a line whenever a handler
loads, which is exactly what a reset or a launch causes -- and still treats
silence as the extension image or a hung board, exiting non-zero.

ls.py closes the loop after push.py: until now nothing here could see what was
on the card. A listing that stops before its end marker is refused rather than
returned short, since a truncated listing would report a file that is there as
missing. The firmware pages at the take value it was given, so ls.py says when
it got a full page.

reset.py is the way back to the menu, and out of the minimal image, without
reaching for the C64's keyboard.

Two fixes to the sites these share rather than to the new code alone: raw()
and stream() both spun until their deadline on a closed port, because os.read
returns b'' at EOF while select keeps reporting the port readable -- measured
at 2.00s wall and 0.84s CPU against a 0.3s idle, now 0.31s and 0.00s -- and
stream() reported that hangup as no drop, the opposite of what it promises.
drive_number() replaces the bare int() that launch.py and ls.py both used, so
a mistyped drive is a message rather than a traceback.
Answering the C64's Y/N prompt is the step that silently does nothing when the
screen is not what fwupdate expects. The board then keeps running the old
firmware, the updater's serial output never appears, and nothing said so: the
symptom is a board that looks flashed and is not.

hexfile.py decodes the Intel HEX records, takes the region the main image
links into, and pulls GCC's __DATE__ and __TIME__ out of it. That pair is the
second line of the version banner, so fwupdate can read the banner back after
the reboot and compare it with the stamp in the hex it pushed.

The stamp alone would not be enough. This build pins SOURCE_DATE_EPOCH, so
both images in a hex it produces carry the same stamp, and the minimal image
prints it in the same form; a board that fell back to minimal would match the
stamp it was asked for. So the firmware check has to answer 'main' as well,
and it answers without reading the hex. A file this reader cannot get data
records out of is refused before anything is pushed; one it can, but cannot
get a stamp from, is flashed and still checked for the main image, with only
the stamp comparison skipped.

Reconnecting after the reboot cannot assume the old device node: reflashing a
TR+ moved it from cu.usbmodem192885901 to cu.usbmodem2101. reconnect prefers
the port it was given while that node exists, and otherwise takes one that
appeared beside it. On that board the port was away 1.34s and answered the
firmware check cleanly 5.37s after returning, printing boot chatter in
between, so await_image gives the chatter and the answer one deadline.

The flash bounds come from the FLASH lines of tools/BootLinkerFiles/
imxrt1062_t41.ld.orig and .ld.upper; test_hexfile.py reads those and fails if
either origin moves.
The README says macOS or Linux and the scripts use only termios, but port
discovery globbed /dev/cu.usbmodem*, which is a BSD name. On Linux a Teensy
comes up through the CDC-ACM driver as /dev/ttyACM*, so find_port found
nothing and said so by naming a pattern that does not name a Linux node.

Three of those sentences also said the port name tells the three images apart.
The macOS node carries the USB serial number, which is what makes that work;
the sentences now say so rather than claiming it of every platform.

The tests did not catch it because they set TR_PORT to a pty they made, which
skips discovery entirely. The new test drives ports() over a directory holding
both node names plus tty.usbmodem2101, ttyS0 and cu.Bluetooth, and fails
against the old single glob.
build-c64.test.mjs reassembles the C64 projects and byte-compares the result
against the committed ROM headers, which is the only thing that catches a
.asm change landing without its regenerated .prg.h. It skipped in CI, because
it skips when ACME is not on PATH and no job installed one. It also named
three projects of the twelve.

The new tools job installs ACME and runs both suites: npm test, which now
asserts rather than skips when CI is set, and the Python bench tests, which
nothing ran before. The project list comes from loadProjects, less the one
project KickAssembler builds, so a project added to the manifest is covered
without touching the test. That takes the header comparison from SettingsMenu,
TODCheck and BASIC to the whole manifest bar the KickAssembler one, and the
test now also fails if the build writes a different set of headers than the
manifest declares: corrupting a byte of ASIDPlayer.prg.h now fails the test,
where before it passed.

The job carries no `needs:`, so a compile failure still reports test results
and a test failure still reports whether the firmware builds. npm test moves
out of the build matrix, where it ran once per target without an assembler.
That move would otherwise have loosened a release gate: a red tool test used
to fail build and so skip collect, which owns the publish step, and collect
now waits on both jobs to keep that true.

ACME is deliberately unpinned here: tools/check-pins.mjs tracks the ACME pin
sites and reads this file for other pins, so a version named in the workflow
would drift out of that check silently. Ubuntu's package assembles the
committed headers byte for byte, which is the guarantee that matters and the
one a version string cannot give.

Three sentences said this checking did not exist. Source/C64/README.md and
docs/Architecture/Build-System.md described the gap this commit closes.
CONTRIBUTING.md's "There's no hosted CI" was wrong when it was written:
build.yml arrived in 31a920b and that sentence in 4bd8f52.
wr() retried os.write on BlockingIOError with no deadline, while rd, drain,
stream and reconnect each carry one. Against a pty nobody reads, a 4 MB write
was still spinning at 20 s; a no-progress bound turns a board that has stopped
draining its endpoint into "board stopped reading after 1024 of 4194304 bytes"
at 30 s. The bound is a `wr` argument so a test can use a short one.

post() opened the local file bare, so push.py on a missing path printed a
FileNotFoundError traceback where every other user error in these scripts is a
one-line SystemExit.

petscii_row's docstring claimed letters survive. Screen codes 65-90, which are
A-Z in the lower/uppercase charset, decode to '.'. The docstring now names the
charset it reads, a test pins the limit and the README's Limits section carries
what it costs fwupdate.py; the decoder is unchanged.
@SensoriumEmbedded
SensoriumEmbedded merged commit 9ca2018 into SensoriumEmbedded:main Sep 22, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants