Skip to content

Host tests in CI, and one copy of the window layout on the ST side - #6

Merged
diegoparrilla merged 7 commits into
release/v1.1.0from
epic/10-host-tests-ci
Sep 29, 2026
Merged

diegoparrilla merged 7 commits into
release/v1.1.0from
epic/10-host-tests-ci

Conversation

@diegoparrilla

Copy link
Copy Markdown
Contributor

Host tests for the firmware's pure logic, run on every pull request, and one copy of the cartridge window's layout on the ST side. The firmware binary is unchanged: release builds of this branch and of its base, with one RELEASE_DATE, differ only in the 7 bytes of the build ID, and BOOT.BIN is byte-identical.

Host tests

make -C tests/host test runs in about a second. It builds firmware units unchanged with the host compiler, under the address and undefined-behaviour sanitizers; shim/ stands in for the few SDK headers they include. A new build step runs it before the firmware build, so a failing check fails the PR.

  • test_fb_blit.c: fb_fill_rect, fb_blit, fb_blit_key and the band variants, against a pixel-by-pixel reference. It covers every edge and corner, just outside and far outside, one pixel to larger than the screen, and five bands: 3,030 checks.

  • test_chunk_layout.c: what the app draws is what the ST shows. It runs the real fb_chunky_to_planar(), with core 1 as a thread, and a model of the m68k's MOVEM blit built from cart_shared.h. It then decodes the ST's planes and compares every pixel.

  • test_layout.py: the ST's and the RP's copies of 31 shared constants agree. They cover:

    • the code budget, also against target/atarist/build.sh;
    • the sentinel and its values, the frame counter, the shared variables and palette, the audio buffer, the boot block, APP_FREE and the framebuffer;
    • the blit's lines, bytes, chunk, chunk count and tail;
    • the ROM3 windows.

    It also checks the Timer-B rate against the RP's sample rate and bytes per frame, and that the window's blocks don't overlap.

  • test_wav_to_ym4.py: the converter's 64-entry table and its resampled length, and a .YMS file that audio.c's reader accepts.

Every check was proven against a deliberate bug: an off-by-one clip, a publish one chunk short, a misplaced tail, a wrong mode tag, and eleven one-sided constant edits. The suite passes with Apple clang and with GCC 13 on Ubuntu.

One copy of the layout on the ST side

The layout lived in main.s, and userfw.s repeated its addresses as literals ($00FA400C, $00FA4040, $00FA4100, $00FA8300, $FB8200...). It now lives in target/atarist/src/inc/sidecart_layout.s, as in md-microfirmware-template, which both include. test_layout.py compares it with cart_shared.h.

CLAUDE.md, the README and the changelog describe the tests and how to add one.

tests/host/ builds the firmware's pure-C units with the host compiler, no
Pico SDK, and runs them: make -C tests/host test. Each test_*.c is one
program that names the firmware sources it tests on its first line and
exits non-zero on a failed check (test.h: CHECK, CHECK_EQ, TEST_END);
shim/ stands in for the SDK headers those units include. Tests are always
rebuilt, so none passes on a binary older than the source it checks, and
run under the address and undefined-behaviour sanitizers, so a write past
a buffer fails. test_*.py files run from the same command, as unittest
modules.

The build workflow runs it before the firmware build: a failing check
fails the pull request.
fb_fill_rect, fb_blit, fb_blit_key and the band variants against a
pixel-by-pixel reference, at 121 positions per size (inside, across each
edge and corner, just and far outside), for sizes from one pixel to
larger than the screen, and for five bands; empty and negative sizes and
missing bitmaps draw nothing. The keyed row routine is Thumb assembly on
the RP; here it is the C it stands for. 3,030 checks. A right-edge clip
off by one pixel fails it (a write past the framebuffer).
The layout lived in main.s, and userfw.s repeated its addresses as
literals ($00FA400C, $00FA4040, $00FA4100, $00FA8300, $FB8200...), each
with a comment asking it to agree with main.s. It now lives in
inc/sidecart_layout.s, as in md-microfirmware-template, which both
include: the window's blocks, the command values, the ROM3 signalling
windows and FB_COPY_LINES, under main.s's names (userfw's
FB_FRAME_COUNTER, CMD_MAGIC_SENTINEL and UFW_FB_SRC become
FB_FRAME_COUNTER_ADDR, CMD_MAGIC_SENTINEL_ADDR and FRAMEBUFFER_ADDR).

BOOT.BIN is byte-identical, built with the same RELEASE_DATE before and
after.
test_layout.py reads the equs of inc/sidecart_layout.s and userfw.s and
the defines of cart_shared.h, ikbd.h, fb.c and audio.c, evaluating the
simple expressions both use, and compares them row by row: the code
budget (also against target/atarist/build.sh's check), the sentinel and
its values, the frame counter, the shared variables, the palette, the
audio buffer, the boot block, APP_FREE, the framebuffer, the blit's lines,
bytes, MOVEM chunk, chunk count and tail, and the ROM3 windows. The
Timer-B rate must round to the RP's audio rate, and the bytes the RP
refills per frame must be what the ST plays in one, within a sample. The
window's blocks must not overlap and the framebuffer must end the window.

Green on this tree; each of eleven one-sided edits (FB_COPY_LINES, the
audio buffer size, the boot message size, CMD_BOOT_GEM, the blit-done
window, the palette entries, the hello window, the IKBD window,
TIMERB_COUNT, the bytes per frame, the build script's budget) fails it.
cart_shared.h's comments now point to inc/sidecart_layout.s.
test_chunk_layout.c draws patterns into the chunky buffer, runs the RP's
fb_chunky_to_planar() (the transpose on both cores, then the publish
with the 48-byte chunks reversed), models the m68k's MOVEM blit from
cart_shared.h's constants (each chunk read forward and stored below the
last, the tail in place), decodes the ST's planes and compares every
pixel with what was drawn: a gradient, the four corners and the tail's
line, and bytes whose high nibble is set.

The two Thumb workers stand in as the C they mean: the ST's planar
format and a chunk-reversed copy. shim/pico/multicore.h runs core 1 as
a thread with the FIFOs as blocking queues; the RP passes pointers
through the 32-bit FIFO, so on a 64-bit host the shim restores their
high half from an address in the program. A reversed copy one chunk
short, or a tail copied from the wrong place, fails it.
test_wav_to_ym4.py checks tools/wav_to_ym4.py: the 64-entry Ghostbusters
table holds 4-bit volume pairs, every PCM byte plays entry byte >> 2,
silence the middle entry and clipping the ends; the resampler's length
follows the rate. End to end, a half-second tone converted for the RP's
rate becomes a .YMS file that audio.c's reader accepts: the magic, the
rate and mode tag it checks (read from audio.c), the header size, the
data length, and two bytes of 4-bit volumes per sample. A mode tag the
reader refuses fails it.
CLAUDE.md's Tests section says what the host tests cover, how to add one
and what still needs hardware; the shared-region and ROM3 sections name
inc/sidecart_layout.s as the ST's copy and test_layout.py as the check.
The README gets a "Testing and debugging" section, and the changelog's
v1.1.0 "for developers" entry the host tests and the shared include.

The firmware is unchanged: release builds of this branch and of its base,
with one RELEASE_DATE, differ only in the build ID.
@diegoparrilla
diegoparrilla merged commit 96c1f31 into release/v1.1.0 Sep 29, 2026
2 checks passed
@diegoparrilla
diegoparrilla deleted the epic/10-host-tests-ci branch September 29, 2026 21:04
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.

1 participant