From 2967d9b94f5a353b4e7c2025a8cf48f276b8580b Mon Sep 17 00:00:00 2001 From: Diego Parrilla Date: Tue, 29 Sep 2026 22:35:18 +0200 Subject: [PATCH 1/7] Host tests: a make target and a CI step 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. --- .github/workflows/build.yml | 5 ++++ .gitignore | 3 +++ tests/host/Makefile | 50 +++++++++++++++++++++++++++++++++++ tests/host/compile_flags.txt | 3 +++ tests/host/shim/pico/stdlib.h | 13 +++++++++ tests/host/test.h | 41 ++++++++++++++++++++++++++++ 6 files changed, 115 insertions(+) create mode 100644 tests/host/Makefile create mode 100644 tests/host/compile_flags.txt create mode 100644 tests/host/shim/pico/stdlib.h create mode 100644 tests/host/test.h diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8c355bb..20e2543 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,6 +34,11 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive apt update sudo DEBIAN_FRONTEND=noninteractive apt install -y cmake build-essential + # The firmware's pure logic, on the host, in seconds (tests/host/Makefile): + # a failing check fails the pull request before the firmware is built. + - name: Host tests + run: make -C tests/host test + - name: Cache the ARM GNU Toolchain id: arm-toolchain uses: actions/cache@v4 diff --git a/.gitignore b/.gitignore index d02cc8b..7203a5c 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,6 @@ __pycache__/ # Host-side developer tools: capture and build output tools/dev/logs/ tools/dev/builds/ + +# Host tests: build output +tests/host/build/ diff --git a/tests/host/Makefile b/tests/host/Makefile new file mode 100644 index 0000000..6637632 --- /dev/null +++ b/tests/host/Makefile @@ -0,0 +1,50 @@ +# Host tests: plain C and Python checks of the firmware's pure logic, built +# with the host compiler (no Pico SDK) and run in seconds. +# +# make -C tests/host test # build and run everything; fails on any failure +# make -C tests/host clean +# +# Every test_*.c here is one program: it includes the firmware units it tests +# from rp/src, with shim/ standing in for the few SDK headers they pull in, and +# exits non-zero when a check fails. Every test_*.py is a unittest module. +# Address and undefined-behaviour sanitizers make a write past a buffer fail +# the test instead of passing by luck. + +CC ?= cc +PYTHON ?= python3 +REPO := ../.. +BUILD := build + +CFLAGS := -std=c11 -g -O1 -Wall -Wextra -Werror -Wno-unknown-pragmas \ + -fsanitize=address,undefined -fno-omit-frame-pointer \ + -fno-sanitize-recover=undefined \ + -I shim -I $(REPO)/rp/src/include +LDFLAGS := -fsanitize=address,undefined + +C_TESTS := $(patsubst %.c,$(BUILD)/%,$(wildcard test_*.c)) + +.PHONY: test c-tests py-tests clean FORCE + +test: c-tests py-tests + +c-tests: $(C_TESTS) + @set -e; for t in $(C_TESTS); do echo "== $$t"; ./$$t; done + +py-tests: + @$(if $(wildcard test_*.py),$(PYTHON) -m unittest discover -s . -p 'test_*.py',echo "no Python tests") + +# A test's own firmware sources are listed in its first line: +# // units: rp/src/fb_blit.c +# Always rebuilt (it takes about a second): a test must never pass on a binary +# older than the firmware source it checks. +$(BUILD)/%: %.c FORCE | $(BUILD) + $(CC) $(CFLAGS) -o $@ $< \ + $(addprefix $(REPO)/,$(shell sed -n '1s|^// units: ||p' $<)) $(LDFLAGS) + +$(BUILD): + mkdir -p $@ + +clean: + rm -rf $(BUILD) + +FORCE: diff --git a/tests/host/compile_flags.txt b/tests/host/compile_flags.txt new file mode 100644 index 0000000..190bd9a --- /dev/null +++ b/tests/host/compile_flags.txt @@ -0,0 +1,3 @@ +-std=c11 +-Ishim +-I../../rp/src/include diff --git a/tests/host/shim/pico/stdlib.h b/tests/host/shim/pico/stdlib.h new file mode 100644 index 0000000..e3381e0 --- /dev/null +++ b/tests/host/shim/pico/stdlib.h @@ -0,0 +1,13 @@ +/* Host stand-in for the Pico SDK's pico/stdlib.h: only what the firmware + * units under test use. Code placement attributes mean nothing on the host. */ +#ifndef HOST_SHIM_PICO_STDLIB_H +#define HOST_SHIM_PICO_STDLIB_H + +#include +#include +#include + +#define __not_in_flash_func(func_name) func_name +#define __time_critical_func(func_name) func_name + +#endif diff --git a/tests/host/test.h b/tests/host/test.h new file mode 100644 index 0000000..ff18ab7 --- /dev/null +++ b/tests/host/test.h @@ -0,0 +1,41 @@ +/* The whole test framework: CHECK a condition, CHECK_EQ two integers, and + * end main() with TEST_END(), which prints the tally and returns non-zero + * when a check failed. */ +#ifndef HOST_TEST_H +#define HOST_TEST_H + +#include + +static int test_checks; +static int test_failures; + +#define CHECK(cond) \ + do { \ + test_checks++; \ + if (!(cond)) { \ + test_failures++; \ + fprintf(stderr, "%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, \ + #cond); \ + } \ + } while (0) + +#define CHECK_EQ(actual, expected) \ + do { \ + long long test_a_ = (long long)(actual); \ + long long test_e_ = (long long)(expected); \ + test_checks++; \ + if (test_a_ != test_e_) { \ + test_failures++; \ + fprintf(stderr, "%s:%d: %s is %lld, expected %lld\n", __FILE__, \ + __LINE__, #actual, test_a_, test_e_); \ + } \ + } while (0) + +#define TEST_END() \ + do { \ + printf("%s: %d checks, %d failed\n", __FILE__, test_checks, \ + test_failures); \ + return test_failures ? 1 : 0; \ + } while (0) + +#endif From a38cf46d8d65c41739dee899f5c7f343350b1062 Mon Sep 17 00:00:00 2001 From: Diego Parrilla Date: Tue, 29 Sep 2026 22:35:18 +0200 Subject: [PATCH 2/7] Host test: the blits' clipping 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). --- tests/host/test_fb_blit.c | 154 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 tests/host/test_fb_blit.c diff --git a/tests/host/test_fb_blit.c b/tests/host/test_fb_blit.c new file mode 100644 index 0000000..bd05fe7 --- /dev/null +++ b/tests/host/test_fb_blit.c @@ -0,0 +1,154 @@ +// units: rp/src/fb_blit.c +/* fb_fill_rect, fb_blit, fb_blit_key and their band variants against a + * pixel-by-pixel reference: inside, across each edge and corner, fully + * outside, larger than the screen, and clipped to bands. The keyed row + * routine is Thumb assembly on the RP (fb_blit_asm.S); here it is the C it + * stands for. The sanitizers catch any write outside the buffer. */ +#include + +#include "fb_blit.h" +#include "fb_chunked.h" +#include "test.h" + +#define W FB_CHUNKED_W +#define H FB_CHUNKED_H +#define BG 0xEEu +#define KEY 0x55u + +uint8_t fb_chunked_buffer[FB_CHUNKED_SIZE]; +static uint8_t expect[FB_CHUNKED_SIZE]; + +void fb_blit_key_row(uint8_t *dst, const uint8_t *src, uint32_t cw, + uint8_t key) { + for (uint32_t i = 0; i < cw; i++) { + if (src[i] != key) dst[i] = src[i]; + } +} + +static void reset(void) { + memset(fb_chunked_buffer, BG, sizeof fb_chunked_buffer); + memset(expect, BG, sizeof expect); +} + +static void ref_fill(int x, int y, int w, int h, uint8_t color) { + for (int yy = y; yy < y + h; yy++) { + for (int xx = x; xx < x + w; xx++) { + if (xx >= 0 && xx < W && yy >= 0 && yy < H) expect[yy * W + xx] = color; + } + } +} + +static void ref_blit(const struct FB_BITMAP *bm, int x, int y, int y0, int y1, + int keyed) { + for (int r = 0; r < bm->height; r++) { + for (int c = 0; c < bm->width; c++) { + int xx = x + c, yy = y + r; + uint8_t p = bm->data[r * bm->width + c]; + if (xx < 0 || xx >= W || yy < y0 || yy >= y1 || yy < 0 || yy >= H) + continue; + if (keyed && p == KEY) continue; + expect[yy * W + xx] = p; + } + } +} + +static int matches(void) { + return memcmp(fb_chunked_buffer, expect, sizeof expect) == 0; +} + +/* Positions that put a w x h shape inside, across every edge and corner, + * just outside every edge, and far outside. */ +static int positions(int w, int h, int (*out)[2]) { + const int xs[] = {-w - 40, -w, -w + 1, -3, 0, 17, W - w, W - 3, W - 1, W, + W + 40}; + const int ys[] = {-h - 40, -h, -h + 1, -3, 0, 23, H - h, H - 3, H - 1, H, + H + 40}; + int n = 0; + for (unsigned i = 0; i < sizeof xs / sizeof xs[0]; i++) { + for (unsigned j = 0; j < sizeof ys / sizeof ys[0]; j++) { + out[n][0] = xs[i]; + out[n][1] = ys[j]; + n++; + } + } + return n; +} + +static uint8_t bitmap_data[400 * 260]; + +static struct FB_BITMAP make_bitmap(int w, int h) { + for (int i = 0; i < w * h; i++) { + uint8_t p = (uint8_t)((i * 7 + i / w * 13) & 0x0F); + bitmap_data[i] = (i % 5 == 0) ? KEY : p; /* some transparent pixels */ + } + struct FB_BITMAP bm = {(uint16_t)w, (uint16_t)h, bitmap_data}; + return bm; +} + +int main(void) { + static int pos[121][2]; + const int sizes[][2] = {{1, 1}, {16, 16}, {37, 5}, {320, 200}, {400, 260}}; + + /* fb_fill_rect: every size and position. */ + for (unsigned s = 0; s < sizeof sizes / sizeof sizes[0]; s++) { + int n = positions(sizes[s][0], sizes[s][1], pos); + for (int i = 0; i < n; i++) { + reset(); + fb_fill_rect(pos[i][0], pos[i][1], sizes[s][0], sizes[s][1], 3); + ref_fill(pos[i][0], pos[i][1], sizes[s][0], sizes[s][1], 3); + CHECK(matches()); + } + } + /* Empty and negative sizes draw nothing. */ + const int empty[][2] = {{0, 10}, {10, 0}, {-5, 10}, {10, -5}}; + for (unsigned e = 0; e < sizeof empty / sizeof empty[0]; e++) { + reset(); + fb_fill_rect(10, 10, empty[e][0], empty[e][1], 3); + CHECK(matches()); + } + + /* fb_blit and fb_blit_key: every size and position. */ + for (unsigned s = 0; s < sizeof sizes / sizeof sizes[0]; s++) { + struct FB_BITMAP bm = make_bitmap(sizes[s][0], sizes[s][1]); + int n = positions(sizes[s][0], sizes[s][1], pos); + for (int i = 0; i < n; i++) { + reset(); + fb_blit(&bm, pos[i][0], pos[i][1]); + ref_blit(&bm, pos[i][0], pos[i][1], 0, H, 0); + CHECK(matches()); + reset(); + fb_blit_key(&bm, pos[i][0], pos[i][1], KEY); + ref_blit(&bm, pos[i][0], pos[i][1], 0, H, 1); + CHECK(matches()); + } + } + + /* The band variants draw only rows y0 <= y < y1, so two cores can each + * draw one band of the same frame. */ + const int bands[][2] = {{0, 100}, {100, 200}, {37, 150}, {0, 200}, {50, 50}}; + for (unsigned b = 0; b < sizeof bands / sizeof bands[0]; b++) { + struct FB_BITMAP bm = make_bitmap(37, 61); + int n = positions(37, 61, pos); + for (int i = 0; i < n; i++) { + reset(); + fb_blit_band(&bm, pos[i][0], pos[i][1], bands[b][0], bands[b][1]); + ref_blit(&bm, pos[i][0], pos[i][1], bands[b][0], bands[b][1], 0); + CHECK(matches()); + reset(); + fb_blit_key_band(&bm, pos[i][0], pos[i][1], KEY, bands[b][0], + bands[b][1]); + ref_blit(&bm, pos[i][0], pos[i][1], bands[b][0], bands[b][1], 1); + CHECK(matches()); + } + } + + /* No bitmap, or one without data, draws nothing. */ + reset(); + fb_blit(NULL, 0, 0); + struct FB_BITMAP nodata = {8, 8, NULL}; + fb_blit(&nodata, 0, 0); + fb_blit_key(&nodata, 0, 0, KEY); + CHECK(matches()); + + TEST_END(); +} From b351d5c2f14e6f7169ba09a0c6233374d83a0e44 Mon Sep 17 00:00:00 2001 From: Diego Parrilla Date: Tue, 29 Sep 2026 22:38:23 +0200 Subject: [PATCH 3/7] One copy of the cartridge window's layout on the ST side 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. --- target/atarist/src/inc/sidecart_layout.s | 84 ++++++++++++++++++++++ target/atarist/src/main.s | 74 ++------------------ target/atarist/src/userfw.s | 88 ++++++++++-------------- 3 files changed, 126 insertions(+), 120 deletions(-) create mode 100644 target/atarist/src/inc/sidecart_layout.s diff --git a/target/atarist/src/inc/sidecart_layout.s b/target/atarist/src/inc/sidecart_layout.s new file mode 100644 index 0000000..b271481 --- /dev/null +++ b/target/atarist/src/inc/sidecart_layout.s @@ -0,0 +1,84 @@ +; SidecarTridge Multi-device: the cartridge window and the ROM3 signalling +; windows, as the ST sees them. +; +; Included by main.s and userfw.s, and by every module that talks to the RP: +; never hard-code an address in the window, use these names. The RP side is +; rp/src/include/cart_shared.h; tests/host/test_layout.py checks that the two +; agree. +; +; $FA0000 CARTRIDGE m68k header + code (max 16 KB), +; userfw's inline MOVEM blit included +; $FA4000 CMD_MAGIC_SENTINEL_ADDR 4 B RP->ST command word +; $FA4004 RANDOM_TOKEN_ADDR 4 B (legacy: the senders' handshake) +; $FA4008 RANDOM_TOKEN_SEED_ADDR 4 B (legacy: the senders' handshake) +; $FA400C FB_FRAME_COUNTER_ADDR 4 B bumped as the last write of a publish +; $FA4010 SHARED_VARIABLES 240 B (60 x 4-byte slots, the app's) +; $FA4040 PALETTE_ADDR 32 B (slots 12..19: 16 palette words) +; $FA4100 AUDIO_BUFFER_ADDR 1024 B (YM volume pairs) +; $FA4500 BOOT_STATUS_ADDR 2 B read once in pre_auto: 0 = start +; $FA4502 BOOT_MESSAGE_ADDR 126 B why the RP refused; printed before GEM +; $FA4580 APP_FREE_ADDR ~15.4 KB for the app, up to the framebuffer +; $FA8300 FRAMEBUFFER_ADDR 32000 B (320x200 4bpp, flush at the top) +; $FAFFFF end of the window + +ROM4_ADDR equ $FA0000 +CARTRIDGE_CODE_SIZE equ $4000 ; 16 KB max for the cartridge header + code +SHARED_BLOCK_ADDR equ (ROM4_ADDR + CARTRIDGE_CODE_SIZE) ; $FA4000 +CMD_MAGIC_SENTINEL_ADDR equ SHARED_BLOCK_ADDR ; $FA4000 +RANDOM_TOKEN_ADDR equ (CMD_MAGIC_SENTINEL_ADDR + 4) ; $FA4004 +RANDOM_TOKEN_SEED_ADDR equ (RANDOM_TOKEN_ADDR + 4) ; $FA4008 +; Bumped by the RP as the last write of every publish; userfw blits only when +; its low word has changed since the last blit. +FB_FRAME_COUNTER_ADDR equ (RANDOM_TOKEN_SEED_ADDR + 4) ; $FA400C +SHARED_VARIABLES equ (FB_FRAME_COUNTER_ADDR + 4) ; $FA4010 + +; 16-entry ST palette, published by the RP and applied by userfw every VBL. +; Slots 12..19 of SHARED_VARIABLES. +PALETTE_ADDR equ (SHARED_BLOCK_ADDR + $40) ; $FA4040 +PALETTE_SIZE equ 32 ; 16 words + +; Audio: (vA, vB) YM volume pairs, one pair per Timer-B interrupt; the VBL +; handler points A0 back at the start every VBL. Filled by the RP. +AUDIO_BUFFER_ADDR equ (SHARED_BLOCK_ADDR + $100) ; $FA4100 +AUDIO_BUFFER_SIZE equ 1024 +AUDIO_BUFFER_END equ (AUDIO_BUFFER_ADDR + AUDIO_BUFFER_SIZE) ; $FA4500 + +; Boot block. The RP can refuse to start the app (st_session_veto_boot()): a +; non-zero status makes pre_auto print the NUL-terminated message and return +; to GEM. +BOOT_STATUS_ADDR equ AUDIO_BUFFER_END ; $FA4500 +BOOT_MESSAGE_ADDR equ (BOOT_STATUS_ADDR + 2) ; $FA4502 +BOOT_MESSAGE_SIZE equ 126 + +; The app's own buffers, up to the framebuffer. +APP_FREE_ADDR equ (BOOT_MESSAGE_ADDR + BOOT_MESSAGE_SIZE) ; $FA4580 + +; 320x200 low resolution, 4 bitplanes, at the top of the window so that an +; overrun walks off its end rather than over the block above. +FRAMEBUFFER_SIZE equ 32000 +FRAMEBUFFER_ADDR equ (ROM4_ADDR + $10000 - FRAMEBUFFER_SIZE) ; $FA8300 +; Lines of it the blit copies each frame (the RP lays the framebuffer out +; for exactly this many: CART_FB_BLIT_LINES), and the bytes of one line. +FB_COPY_LINES equ 200 +FB_ROW_BYTES equ 160 ; 320 px * 4 bpp / 8 + +; User firmware entry point: userfw.s at offset $0800 of the image +; (target/atarist/src/userfw.ld). +USERFW equ (ROM4_ADDR + $800) ; $FA0800 + +; Values of the command word at CMD_MAGIC_SENTINEL_ADDR. +CMD_NOP equ 0 ; nothing to do +CMD_RESET equ 1 ; cold-reset the ST (the RP goes to Booster) +CMD_BOOT_GEM equ 2 ; leave for GEM +CMD_TERMINAL equ 3 ; (legacy: the terminal of the upstream template) +CMD_START equ 4 ; hand control to the user firmware (USERFW) + +; The ST tells the RP things by reading ROM3 addresses: the high byte of the +; address says what the read means, the low byte carries a value. +ROMCMD_START_ADDR equ $FB0000 +IKBD_WINDOW_BASE equ (ROMCMD_START_ADDR + $8200) ; + an IKBD byte +VBLSYNC_ADDR equ (ROMCMD_START_ADDR + $8400) ; blit done: the framebuffer is free +IKBD_OVERRUN_ADDR equ (ROMCMD_START_ADDR + $8500) ; the keyboard ACIA overran +ST_HELLO_WINDOW equ (ROMCMD_START_ADDR + $8800) ; + the machine: hello, a new session +ST_TOS_HI_WINDOW equ (ROMCMD_START_ADDR + $8900) ; + TOS version, high byte +ST_TOS_LO_WINDOW equ (ROMCMD_START_ADDR + $8A00) ; + TOS version, low byte diff --git a/target/atarist/src/main.s b/target/atarist/src/main.s index cf739fa..48dc1eb 100644 --- a/target/atarist/src/main.s +++ b/target/atarist/src/main.s @@ -23,54 +23,10 @@ ; bit 30: TOS application . ; bit 31: TTP -ROM4_ADDR equ $FA0000 - -; Shared 64 KB region layout (must match rp/src/include/cart_shared.h). -; -; $FA0000 CARTRIDGE m68k header + code (max 16 KB) -; Includes userfw's inline MOVEM blit -; (FBDRV_INLINE). -; $FA4000 CMD_MAGIC_SENTINEL_ADDR 4 B -; $FA4004 RANDOM_TOKEN_ADDR 4 B (legacy / unused since the handshake was removed) -; $FA4008 RANDOM_TOKEN_SEED_ADDR 4 B (legacy / unused since the handshake was removed) -; $FA400C FB_FRAME_COUNTER_ADDR 4 B -; $FA4010 SHARED_VARIABLES 240 B (60 x 4-byte slots, app-free). -; $FA4100 AUDIO_BUFFER_ADDR 1024 B (YM volume pairs) -; $FA4500 BOOT_STATUS_ADDR 2 B (read once in pre_auto: 0 = start the app) -; $FA4502 BOOT_MESSAGE_ADDR 126 B (why the RP refused; printed before GEM) -; $FA4580 APP_FREE_ADDR ~15.4 KB free arena, ends at FRAMEBUFFER -; $FA8300 FRAMEBUFFER_ADDR 32000 B (320x200 4bpp, flush at top) -; $FAFFFF end of region - -CARTRIDGE_CODE_SIZE equ $4000 ; 16 KB max for cartridge header + code + fbdrv -SHARED_BLOCK_ADDR equ (ROM4_ADDR + CARTRIDGE_CODE_SIZE) ; $FA4000 -CMD_MAGIC_SENTINEL_ADDR equ SHARED_BLOCK_ADDR ; $FA4000 - -; 16-entry ST palette slot. 32 bytes of 16-bit palette -; words published by the RP, applied to $FFFF8240..$FFFF825E by -; userfw_vbl_loop. Slot 12 of SHARED_VARIABLES (offset +$30). -PALETTE_ADDR equ (SHARED_BLOCK_ADDR + $40) ; $FA4040 -PALETTE_SIZE equ 32 ; 16 words - -FRAMEBUFFER_SIZE equ 32000 ; 320x200 low-res (4bpp) framebuffer -FRAMEBUFFER_ADDR equ (ROM4_ADDR + $10000 - FRAMEBUFFER_SIZE) ; $FA8300 - -; Audio sample buffer (256 bytes). YM ch A 4-bit DAC nibbles, one -; byte per sample. Read sequentially by the m68k Timer-B IRQ handler -; in userfw.s; filled by RP-side audio.c with log-LUT-mapped samples. -AUDIO_BUFFER_ADDR equ (SHARED_BLOCK_ADDR + $100) ; $FA4100 -AUDIO_BUFFER_SIZE equ 1024 -AUDIO_BUFFER_END equ (AUDIO_BUFFER_ADDR + AUDIO_BUFFER_SIZE) ; $FA4500 - -; Boot block, after the audio buffer. The RP can refuse to start the app -; (st_session_veto_boot() in rp/src/st_session.c): a non-zero status makes -; pre_auto print the NUL-terminated message and return to GEM. -BOOT_STATUS_ADDR equ AUDIO_BUFFER_END ; $FA4500 -BOOT_MESSAGE_ADDR equ (BOOT_STATUS_ADDR + 2) ; $FA4502 -BOOT_MESSAGE_SIZE equ 126 - -; APP_FREE starts after the boot block. -APP_FREE_ADDR equ (BOOT_MESSAGE_ADDR + BOOT_MESSAGE_SIZE) ; $FA4580 +; The cartridge window's layout, the command values and the ROM3 signalling +; windows, shared with userfw.s (must match rp/src/include/cart_shared.h). + include inc/sidecart_layout.s + FBDRV_ADDR equ (ROM4_ADDR + $2000) ; $FA2000 (MOVEM loop cart->ST screen copy) ; Left over from the removed mono boot UI, which filled the first 8000 @@ -79,13 +35,6 @@ FBDRV_ADDR equ (ROM4_ADDR + $2000) ; $FA2000 (MOVEM loop cart->ST screen cop ; native 4bpp copy replaced it; nothing references this constant. MONO_UI_BUFFER_SIZE equ 8000 -; User firmware entry point. The cartridge image places userfw.s at -; offset $0800 of BOOT.BIN via target/atarist/src/userfw.ld; main.s -; gets the first 2 KB ($0000..$07FF), userfw gets the next 6 KB -; ($0800..$1FFF), and fbdrv.s occupies the rest of the 16 KB cart -; budget ($2000..$3FFF). The CARTRIDGE_CODE_SIZE = 16 KB cap covers all. -USERFW equ (ROM4_ADDR + $800) ; $FA0800 - SCREEN_SIZE equ (-4096) ; Use the memory before the screen memory to store the copied code PRE_RESET_WAIT equ $FFFFF @@ -94,29 +43,14 @@ PRE_RESET_WAIT equ $FFFFF ; When not using the framebuffer, the endianness swap must be done in the atari ST DISPLAY_BYPASS_FRAMEBUFFER equ 1 -CMD_NOP equ 0 ; No operation command -CMD_RESET equ 1 ; Reset command -CMD_BOOT_GEM equ 2 ; Boot GEM command -CMD_TERMINAL equ 3 ; Terminal command -CMD_START equ 4 ; Hand control to the user firmware (USERFW) - _conterm equ $484 ; Conterm device number ; Constants needed for the commands -RANDOM_TOKEN_ADDR: equ (CMD_MAGIC_SENTINEL_ADDR + 4) ; $FA4004 -RANDOM_TOKEN_SEED_ADDR: equ (RANDOM_TOKEN_ADDR + 4) ; $FA4008 -; $FA400C: Framebuffer dirty-frame counter. RP increments it after -; every fb_render_frame() (with a memory barrier); userfw.s reads it -; each VBL and skips the cart->ST blit + video flip when unchanged. -FB_FRAME_COUNTER_ADDR: equ (RANDOM_TOKEN_SEED_ADDR + 4) ; $FA400C RANDOM_TOKEN_POST_WAIT: equ $1 ; Wait cycles after the RNG is ready COMMAND_TIMEOUT equ $0000FFFF ; Timeout for the command COMMAND_WRITE_TIMEOUT equ COMMAND_TIMEOUT ; Timeout for write commands -SHARED_VARIABLES: equ (FB_FRAME_COUNTER_ADDR + 4) ; $FA4010 (60 indexed 4-byte slots, app-free) - -ROMCMD_START_ADDR: equ $FB0000 ; We are going to use ROM3 address CMD_MAGIC_NUMBER equ ($ABCD) ; Magic number header to identify a command CMD_RETRIES_COUNT equ 3 ; Number of retries for the command CMD_SET_SHARED_VAR equ 1 ; This is a fake command to set the shared variables diff --git a/target/atarist/src/userfw.s b/target/atarist/src/userfw.s index 45b59d1..865faed 100644 --- a/target/atarist/src/userfw.s +++ b/target/atarist/src/userfw.s @@ -9,10 +9,10 @@ ; main loop by clearing a flag in ST RAM. We DON'T use XBIOS ; Vsync (trap #14, #37) -- that trips through TOS's GEMDOS-aware ; dispatch and adds latency / jitter. -; 2. Read the low word of FB_FRAME_COUNTER ($FA400C), which the RP +; 2. Read the low word of FB_FRAME_COUNTER_ADDR ($FA400C), which the RP ; bumps as the last write of every publish. If it has not moved ; since the last blit (UFW_LAST_FRAME), there is nothing new: skip -; the blit, the flip and the ack (see FB_FRAME_COUNTER). +; the blit, the flip and the ack (see FB_FRAME_COUNTER_ADDR). ; 3. Copy the 32 KB cart framebuffer ($FA8300) into the hidden ; ST screen page selected by A4. The copy is a pure 68000 CPU ; MOVEM burst expanded inline via FBDRV_INLINE -- same code on @@ -38,6 +38,13 @@ ; ; --- Constants ---------------------------------------------------- +; The cartridge window, the command values and the ROM3 signalling windows +; (FB_FRAME_COUNTER_ADDR, CMD_MAGIC_SENTINEL_ADDR, PALETTE_ADDR, +; AUDIO_BUFFER_ADDR, FRAMEBUFFER_ADDR, FB_COPY_LINES, IKBD_WINDOW_BASE, +; VBLSYNC_ADDR...), shared with main.s and checked against the RP's +; cart_shared.h. + include inc/sidecart_layout.s + ; Atari ST shifter video base registers (68000-compatible, present ; on every ST/STE/MegaSTE/TT/Falcon). Only HIGH+MID are written; ; the STE-only LOW byte at $FFFF820D stays at TOS's default of 0, @@ -78,8 +85,8 @@ UFW_RESET_STUB equ $00077F00 ; up to $77FDF UFW_VBL_VEC_SAVE equ $00077FE0 ; longword: TOS VBL vector ($70) UFW_PHYSBASE_SAVE equ $00077FE8 ; longword: XBIOS Physbase result UFW_SCREEN_PAGE equ $00077FEC ; longword: current draw page address -; Low word of FB_FRAME_COUNTER at the last blit: .vbl_loop blits only when -; the counter has moved on since (see FB_FRAME_COUNTER). +; Low word of FB_FRAME_COUNTER_ADDR at the last blit: .vbl_loop blits only when +; the counter has moved on since (see FB_FRAME_COUNTER_ADDR). UFW_LAST_FRAME equ $00077FF0 ; word ; .vbl_loop arms this to -1 then `stop`s; userfw_vbl clears it. The ; m68k re-stops on any non-VBL IRQ (Timer-B etc.) and only exits the @@ -148,7 +155,7 @@ FBDRV_TAIL_DISP equ FBDRV_MAIN_BYTES ; tail goes at ; + 6 B setup = ~5 KB inline, plus the small d16(a5) tail MOVEM at ; the end. FBDRV_INLINE macro - movea.l #UFW_FB_SRC, a6 + movea.l #FRAMEBUFFER_ADDR, a6 rept FBDRV_MAIN_ITERS movem.l (a6)+, d0-d7/a1-a4 movem.l d0-d7/a1-a4, -(a5) @@ -157,7 +164,7 @@ FBDRV_INLINE macro ; Tail: copy the last FBDRV_TAIL_BYTES bytes of the blitted ; region that the chunked main loop can't reach (FB_COPY_LINES * ; 160 isn't a multiple of FBDRV_ITER_BYTES=48). A6 is at - ; UFW_FB_SRC + FBDRV_MAIN_BYTES after the REPT; A5 is back at + ; FRAMEBUFFER_ADDR + FBDRV_MAIN_BYTES after the REPT; A5 is back at ; page_start. The RP-side fb_chunky_to_planar leaves these tail ; bytes in NATURAL (non-reversed) order in cart-FB, so this is a ; straight forward-direction copy via d16(a5). @@ -183,7 +190,7 @@ FBDRV_INLINE macro ; framebuffer template because we own the screen until ESC exit. VBL_VECTOR equ $70 -; FB frame counter (cart shared region, $FA400C). The RP publishes a +; FB frame counter (FB_FRAME_COUNTER_ADDR, $FA400C). The RP publishes a ; whole frame into the cart framebuffer and then bumps this counter as ; its LAST write. .vbl_loop blits only when the counter's low word has ; changed since the last blit (UFW_LAST_FRAME), and acknowledges only @@ -193,25 +200,19 @@ VBL_VECTOR equ $70 ; counter means the frame is complete. A slow app gets fewer frames, ; never a torn one. The low word is read with one move.w: a longword is ; two bus reads with its halves swapped and could mix old and new. -FB_FRAME_COUNTER equ $00FA400C - -; RP→m68k command sentinel at $FA4000. The RP IKBD demux writes -; CMD_BOOT_GEM here when it decodes an ESC keypress; userfw's main -; loop polls and exits back to GEM on match. CMD_RESET comes before the -; RP reboots into Booster: userfw then cold-resets the ST (.cold_reset). -; Must agree with main.s's CMD_MAGIC_SENTINEL_ADDR / CMD_* equs. -CMD_MAGIC_SENTINEL equ $00FA4000 -CMD_RESET equ 1 -CMD_BOOT_GEM equ 2 + +; RP→m68k command sentinel (CMD_MAGIC_SENTINEL_ADDR, $FA4000). The RP +; IKBD demux writes CMD_BOOT_GEM there when it decodes an ESC keypress; +; userfw's main loop polls and exits back to GEM on match. CMD_RESET comes +; before the RP reboots into Booster: userfw then cold-resets the ST +; (.cold_reset). ; Delay before the cold reset, so TOS scans the cartridge only once Booster ; serves it. Mirrors PRE_RESET_WAIT in main.s, the delay Booster uses too. UFW_PRE_RESET_WAIT equ $FFFFF -; 16-entry ST palette slot. 32 bytes of palette words -; published by the RP; .vbl_loop applies them to PALETTE_BASE each -; frame via a MOVEM-load + MOVEM-store. Mirrors main.s PALETTE_ADDR. -PALETTE_ADDR equ $00FA4040 -PALETTE_SIZE equ 32 +; The 16-entry ST palette at PALETTE_ADDR, published by the RP: +; .vbl_loop applies it to PALETTE_BASE each frame via a MOVEM-load + +; MOVEM-store. ; Screen pages live just below TOS RAM top (TT-style 256 KB ST RAM ; assumption -- screens land at $70000/$78000, matching md-sprites-demo). @@ -219,8 +220,6 @@ UFW_SCREEN_A equ $00070000 UFW_SCREEN_B equ $00078000 UFW_SCREEN_XOR equ (UFW_SCREEN_A ^ UFW_SCREEN_B) -UFW_FB_SRC equ $00FA8300 ; FRAMEBUFFER_ADDR - ; --- YM2149 sound chip (single-channel A 4-bit DAC) ---------------- ; ; PSG access: write a register number to $FFFF8800 (latch), then @@ -237,21 +236,16 @@ YM_REG_CHB_VOL equ 9 ; channel B volume (low 4 bits) YM_MIXER_DAC_CHA equ $FE ; tone A enabled, all other tones/noise off, ports out YM_MIXER_DAC_AB equ $FC ; tones A AND B enabled, tone C off, all noise off, ports out (Ghostbusters dual-channel fake DAC) -; Cart-shared audio sample buffer (mirrors AUDIO_BUFFER_ADDR / -; AUDIO_BUFFER_SIZE in main.s and CART_AUDIO_BUFFER_OFFSET in -; rp/src/include/cart_shared.h). (vA, vB) YM volume pairs, one pair -; per Timer-B fire; the VBL handler points A0 back at the start every +; The audio buffer at AUDIO_BUFFER_ADDR: (vA, vB) YM volume pairs, one +; pair per Timer-B fire; the VBL handler points A0 back at the start every ; VBL, so a frame reads its first ~224 bytes. Filled by the RP. -AUDIO_BUFFER_ADDR equ $00FA4100 -AUDIO_BUFFER_SIZE equ 1024 -AUDIO_BUFFER_END equ (AUDIO_BUFFER_ADDR + AUDIO_BUFFER_SIZE) -; Number of 320-px lines the cart->ST blit covers per frame. Full ST -; low-res is 200; copying fewer leaves the bottom band of the -; destination ST page untouched (useful for a status row or to bound -; the blitter cost). -FB_COPY_LINES equ 200 ; M68k copies all 200 lines (32000 bytes = 666 chunks * 48 B + 32-byte tail). Full screen blitted. -FB_ROW_BYTES equ 160 ; 320 px * 4 bpp / 8 +; FB_COPY_LINES (inc/sidecart_layout.s) is the number of 320-px lines the +; cart->ST blit covers per frame. Full ST low-res is 200 (32000 bytes = 666 +; chunks * 48 B + a 32-byte tail); copying fewer leaves the bottom band of +; the destination ST page untouched (useful for a status row or to bound +; the blit's cost). The RP lays the framebuffer out for exactly this many +; lines (CART_FB_BLIT_LINES). ; --- IKBD ownership ------------------------------------------------ @@ -337,7 +331,6 @@ VEC_TIMERA equ $134 ; to RP; the RP side filters commemul ring samples whose low 16 bits ; fall in [$8200, $8300) and extracts the IKBD byte from the low 8 ; bits. -IKBD_WINDOW_BASE equ $FB8200 ; The keyboard ACIA's receive interrupt reads every IKBD byte as it ; arrives (userfw_acia_irq). The 6850 holds one byte and the IKBD sends @@ -349,7 +342,6 @@ IKBD_WINDOW_BASE equ $FB8200 ; The MIDI ACIA shares the GPIP4 line: its receive interrupt is turned ; off while userfw runs, or a MIDI byte nobody reads would hold the line ; down and stop the keyboard. -IKBD_OVERRUN_ADDR equ $FB8500 ACIA_MIDI_CTRL equ $FFFFFC04 ACIA_KBD_CTRL_TOS equ $96 ; RX interrupt on, 8N1, /64 (TOS's setting) ACIA_MIDI_CTRL_OFF equ $15 ; RX interrupt off, 8N1, /16 @@ -364,7 +356,6 @@ MFP_GPIP4_BIT equ 6 ; IERB / IMRB bit of the ACIA interrupt ; -- the same mechanism IKBD uses. Distinct high byte ($84) from the ; IKBD window ($82) so the RP can tell the two apart. The value read ; is irrelevant; only the address matters. -VBLSYNC_ADDR equ $FB8400 ; Hello (rp/src/include/st_session.h): the ST and the RP reboot ; independently and the RP keeps its state across an ST reset, the command @@ -373,9 +364,6 @@ VBLSYNC_ADDR equ $FB8400 ; ROM3 reads, the TOS version's two bytes then the machine; on the hello the ; RP writes CMD_NOP to the sentinel and starts its session over. Windows and ; the machine byte: see cart_shared.h and st_session.h. -ST_HELLO_WINDOW equ $FB8800 ; + machine: family in bits 7..4, model in 3..0 -ST_TOS_HI_WINDOW equ $FB8900 ; + TOS version, high byte -ST_TOS_LO_WINDOW equ $FB8A00 ; + TOS version, low byte P_COOKIES equ $5A0 ; _p_cookies: the cookie jar, 0 on TOS 1.0x RESET_VECTOR_HI equ $4 ; high word of the reset PC: $00FC on a 192 KB TOS TOS_ROM_192K equ $FC0000 ; TOS header (version word at +2), 192 KB TOS @@ -470,7 +458,7 @@ userfw: movea.l UFW_PHYSBASE_SAVE, a5 lea -UFW_SAVE_SIZE(a5), a5 - ; The command sentinel at CMD_MAGIC_SENTINEL is RP-owned (m68k + ; The command sentinel at CMD_MAGIC_SENTINEL_ADDR is RP-owned (m68k ; can't write to the cart shared region) and is zeroed by the ; RP's ERASE_FIRMWARE_IN_RAM at boot, so we don't need to clear ; it from here. It's already CMD_NOP=0 on first userfw entry. @@ -623,8 +611,8 @@ userfw: move.l #UFW_SCREEN_A, UFW_SCREEN_PAGE ; The frame now in the cart framebuffer counts as seen: the first blit - ; waits for the RP's next publish (see FB_FRAME_COUNTER). - move.w FB_FRAME_COUNTER, UFW_LAST_FRAME + ; waits for the RP's next publish (see FB_FRAME_COUNTER_ADDR). + move.w FB_FRAME_COUNTER_ADDR, UFW_LAST_FRAME ; Shifter base HIGH byte ($07) is the same for both screen pages ; ($70000 and $78000), so we write it ONCE here and only update @@ -669,8 +657,8 @@ userfw: movem.l d0-d7, PALETTE_BASE.w ; Blit only a frame the RP has finished publishing, and only once - ; (see FB_FRAME_COUNTER). Nothing new: no blit, no flip, no ack. - move.w FB_FRAME_COUNTER, d0 + ; (see FB_FRAME_COUNTER_ADDR). Nothing new: no blit, no flip, no ack. + move.w FB_FRAME_COUNTER_ADDR, d0 cmp.w UFW_LAST_FRAME, d0 beq .input_check move.w d0, UFW_LAST_FRAME @@ -731,10 +719,10 @@ userfw: .input_check: ; ESC detection: the RP-side IKBD demux writes - ; CMD_BOOT_GEM into CMD_MAGIC_SENTINEL on ESC press. CMD_RESET: + ; CMD_BOOT_GEM into CMD_MAGIC_SENTINEL_ADDR on ESC press. CMD_RESET: ; the RP is about to reboot into Booster. Any other ; sentinel value (NOP, future commands) leaves the loop running. - move.l CMD_MAGIC_SENTINEL, d0 + move.l CMD_MAGIC_SENTINEL_ADDR, d0 cmp.l #CMD_RESET, d0 beq .cold_reset cmp.l #CMD_BOOT_GEM, d0 From a1429bcac6970770f59eb110c2d263b13b38e613 Mon Sep 17 00:00:00 2001 From: Diego Parrilla Date: Tue, 29 Sep 2026 22:40:50 +0200 Subject: [PATCH 4/7] Host test: the ST side and the RP side agree on what they share 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. --- rp/src/fb.c | 2 +- rp/src/include/cart_shared.h | 11 +- tests/host/test_layout.py | 228 +++++++++++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 tests/host/test_layout.py diff --git a/rp/src/fb.c b/rp/src/fb.c index 7264df8..3bd1212 100644 --- a/rp/src/fb.c +++ b/rp/src/fb.c @@ -26,7 +26,7 @@ #include "st_session.h" /* Blit-done ack. The m68k blits only a frame it has not blitted yet (the - * frame counter's low word changed; see FB_FRAME_COUNTER in userfw.s) and + * frame counter's low word changed; see FB_FRAME_COUNTER_ADDR in userfw.s) and * then does a cart-bus read at $FB8400 (VBLSYNC_ADDR); the commemul ring * captures it with low-16 = 0x84xx. fb_pump_rom3 routes ROM3 samples to * the IKBD demux, the ST's hello and this detector. fb_publish() waits for diff --git a/rp/src/include/cart_shared.h b/rp/src/include/cart_shared.h index 7d909fb..db5d5b4 100644 --- a/rp/src/include/cart_shared.h +++ b/rp/src/include/cart_shared.h @@ -14,7 +14,8 @@ * removed; the layout constants survived the * cull because IKBD and the framebuffer pipeline both need them. * - * Layout must match target/atarist/src/main.s on the m68k side. + * Layout must match target/atarist/src/inc/sidecart_layout.s on the m68k + * side; tests/host/test_layout.py checks that they agree. */ #ifndef CART_SHARED_H @@ -25,7 +26,7 @@ /* All offsets are relative to __rom_in_ram_start__, which mirrors * ROM4_ADDR ($FA0000) on the m68k side. Layout (single source of - * truth, must match target/atarist/src/main.s): + * truth, must match target/atarist/src/inc/sidecart_layout.s): * * $FA0000 CARTRIDGE m68k header + code (max 16 KB). * Includes the unrolled MOVEM-loop @@ -165,7 +166,7 @@ * - Net: ~285 us VBL slack reclaimed on the m68k side */ #define CART_FB_CHUNK_BYTES 48 /* size of one m68k MOVEM-burst group (12 longwords; A0 and A7 omitted -- A0 is the dedicated Timer-B audio pointer, A7 is the SP) */ -#define CART_FB_BLIT_LINES 200 /* must match FB_COPY_LINES in target/atarist/src/userfw.s */ +#define CART_FB_BLIT_LINES 200 /* must match FB_COPY_LINES in target/atarist/src/inc/sidecart_layout.s */ #define CART_FB_BLIT_BYTES (CART_FB_BLIT_LINES * 160) /* total bytes the m68k blits per VBL (160 = ST 4bpp scanline) */ #define CART_FB_CHUNK_COUNT (CART_FB_BLIT_BYTES / CART_FB_CHUNK_BYTES) /* iterations of the unrolled MOVEM-pair */ #define CART_FB_CHUNK_COVERED (CART_FB_CHUNK_BYTES * CART_FB_CHUNK_COUNT) @@ -174,7 +175,7 @@ /* RP→m68k command sentinel values. The m68k polls the longword at * CART_CMD_SENTINEL_OFFSET; non-zero values steer it out of the * userfw loop or the bootstrap dispatcher. Must match the m68k-side - * equs in target/atarist/src/main.s. */ + * equs in target/atarist/src/inc/sidecart_layout.s. */ #define CART_CMD_NOP 0u #define CART_CMD_RESET 1u #define CART_CMD_BOOT_GEM 2u @@ -184,7 +185,7 @@ * tells the RP things by reading ROM3 addresses ($FB0000-$FBFFFF), which * commemul's ring captures: the high byte of the captured address says what * the read means, the low byte carries a value. Must match the equs in - * target/atarist/src/userfw.s. + * target/atarist/src/inc/sidecart_layout.s. * * $FB82xx an IKBD byte (ikbd.c, IKBD_WINDOW_LO16) * $FB84xx blit done: the cart framebuffer is free (fb.c) diff --git a/tests/host/test_layout.py b/tests/host/test_layout.py new file mode 100644 index 0000000..e48e69a --- /dev/null +++ b/tests/host/test_layout.py @@ -0,0 +1,228 @@ +"""The ST side and the RP side agree on what they share. + +The ST's names come from target/atarist/src/inc/sidecart_layout.s (the +window, the command values, the ROM3 signalling windows) and userfw.s (the +blit's chunk size, the audio timer); the RP's from the headers and sources +under rp/src. Each row of PAIRS is one shared constant: adding a constant +both sides must agree on means adding a row. A mismatch is the "works on the +RP, garbage on the ST" bug, so fix the sources, never the table.""" + +import ast +import os +import re +import unittest + +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ST_SRC = os.path.join(REPO, "target", "atarist", "src") +RP_SRC = os.path.join(REPO, "rp", "src") + +MFP_CLOCK_HZ = 2457600 +# MFP timer control values in delay mode -> prescaler. +MFP_PRESCALER = {1: 4, 2: 10, 3: 16, 4: 50, 5: 64, 6: 100, 7: 200} + + +def evaluate(expr, known): + """An integer expression of numbers, known names and + - * / << >> & | ^, + or None when it uses anything else.""" + expr = expr.strip() + for name in sorted(set(re.findall(r"\b[A-Za-z_]\w*", expr)), key=len, reverse=True): + if name not in known: + return None + expr = re.sub(rf"\b{name}\b", str(known[name]), expr) + try: + tree = ast.parse(expr, mode="eval") + except SyntaxError: + return None + allowed = (ast.Expression, ast.BinOp, ast.UnaryOp, ast.Constant, ast.Add, + ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, ast.LShift, + ast.RShift, ast.BitAnd, ast.BitOr, ast.BitXor, ast.USub, + ast.Invert) + if not all(isinstance(n, allowed) for n in ast.walk(tree)): + return None + value = eval(compile(tree, "", "eval"), {"__builtins__": {}}) + return int(value) if isinstance(value, (int, float)) else None + + +def asm_equs(*paths): + """NAME equ EXPR from vasm sources: $hex, %binary and decimal numbers.""" + raw = [] + for path in paths: + with open(path, encoding="latin-1") as f: + for line in f: + m = re.match(r"^([A-Za-z_]\w*):?\s+equ\s+([^;]+)", line, re.I) + if m: + expr = re.sub(r"\$([0-9A-Fa-f]+)", r"0x\1", m.group(2)) + expr = re.sub(r"%([01]+)", r"0b\1", expr) + raw.append((m.group(1), expr)) + return resolve(raw) + + +def c_defines(*paths): + """#define NAME EXPR from C sources, without casts and number suffixes.""" + raw = [] + for path in paths: + with open(path, encoding="utf-8", errors="replace") as f: + text = f.read() + text = re.sub(r"/\*.*?\*/", " ", text, flags=re.S) + text = re.sub(r"//[^\n]*", " ", text).replace("\\\n", " ") + for name, expr in re.findall(r"^\s*#\s*define\s+([A-Za-z_]\w*)[ \t]+([^\n]+)$", + text, re.M): + expr = re.sub(r"\b(0x[0-9a-fA-F]+|\d+)[uUlL]+\b", r"\1", expr) + expr = re.sub(r"\(\s*(?:u?int\d+_t|unsigned|int|long|size_t)\s*\)", " ", expr) + raw.append((name, expr)) + return resolve(raw) + + +def resolve(raw): + known = {} + for _ in range(len(raw) + 1): # definitions may refer to later ones + progress = False + for name, expr in raw: + if name not in known: + value = evaluate(expr, known) + if value is not None: + known[name] = value + progress = True + if not progress: + break + return known + + +def st_names(): + return asm_equs(os.path.join(ST_SRC, "inc", "sidecart_layout.s"), + os.path.join(ST_SRC, "userfw.s")) + + +def rp_names(): + inc = os.path.join(RP_SRC, "include") + return c_defines(os.path.join(inc, "cart_shared.h"), os.path.join(inc, "ikbd.h"), + os.path.join(RP_SRC, "fb.c"), os.path.join(RP_SRC, "audio.c")) + + +def cartridge_budget(): + """target/atarist/build.sh's limit on BOOT.BIN.""" + with open(os.path.join(REPO, "target", "atarist", "build.sh")) as f: + return int(re.search(r"^cartridge_max=(\d+)", f.read(), re.M).group(1)) + + +def window(st, name): + """An ST address in the cartridge window, as an offset into it.""" + return st[name] - st["ROM4_ADDR"] + + +def rom3(st, name): + """A ROM3 signalling address, as the low 16 bits the RP's ring captures.""" + return st[name] & 0xFFFF + + +# (what it is, the ST's value, the RP's value) +PAIRS = [ + # The cartridge code budget, three times: the ST's layout, the RP's, and + # the check on BOOT.BIN in target/atarist/build.sh. + ("cartridge code budget", lambda st: st["CARTRIDGE_CODE_SIZE"], + lambda rp: rp["CART_CARTRIDGE_CODE_SIZE"]), + ("cartridge code budget in target/atarist/build.sh", lambda st: cartridge_budget(), + lambda rp: rp["CART_CARTRIDGE_CODE_SIZE"]), + # The command sentinel, and the values the RP writes to it. + ("command sentinel", lambda st: window(st, "CMD_MAGIC_SENTINEL_ADDR"), + lambda rp: rp["CART_CMD_SENTINEL_OFFSET"]), + ("CMD_NOP", lambda st: st["CMD_NOP"], lambda rp: rp["CART_CMD_NOP"]), + ("CMD_RESET", lambda st: st["CMD_RESET"], lambda rp: rp["CART_CMD_RESET"]), + ("CMD_BOOT_GEM", lambda st: st["CMD_BOOT_GEM"], lambda rp: rp["CART_CMD_BOOT_GEM"]), + ("CMD_START", lambda st: st["CMD_START"], lambda rp: rp["CART_CMD_START"]), + # The frame counter the blit is gated on. + ("frame counter", lambda st: window(st, "FB_FRAME_COUNTER_ADDR"), + lambda rp: rp["CART_FB_FRAME_COUNTER_OFFSET"]), + # The shared-variable slots and the palette inside them. + ("shared variables", lambda st: window(st, "SHARED_VARIABLES"), + lambda rp: rp["CART_SHARED_VARIABLES_OFFSET"]), + ("palette", lambda st: window(st, "PALETTE_ADDR"), lambda rp: rp["CART_PALETTE_OFFSET"]), + ("palette size", lambda st: st["PALETTE_SIZE"], lambda rp: rp["CART_PALETTE_SIZE"]), + # The audio buffer. + ("audio buffer", lambda st: window(st, "AUDIO_BUFFER_ADDR"), + lambda rp: rp["CART_AUDIO_BUFFER_OFFSET"]), + ("audio buffer size", lambda st: st["AUDIO_BUFFER_SIZE"], + lambda rp: rp["CART_AUDIO_BUFFER_SIZE"]), + # The boot block the ST reads in pre_auto. + ("boot status", lambda st: window(st, "BOOT_STATUS_ADDR"), + lambda rp: rp["CART_BOOT_STATUS_OFFSET"]), + ("boot message", lambda st: window(st, "BOOT_MESSAGE_ADDR"), + lambda rp: rp["CART_BOOT_MESSAGE_OFFSET"]), + ("boot message size", lambda st: st["BOOT_MESSAGE_SIZE"], + lambda rp: rp["CART_BOOT_MESSAGE_SIZE"]), + # The app's own arena. + ("APP_FREE", lambda st: window(st, "APP_FREE_ADDR"), lambda rp: rp["CART_APP_FREE_OFFSET"]), + # The framebuffer, and the blit's shape the RP lays it out for. + ("framebuffer", lambda st: window(st, "FRAMEBUFFER_ADDR"), + lambda rp: rp["CART_FRAMEBUFFER_OFFSET"]), + ("framebuffer size", lambda st: st["FRAMEBUFFER_SIZE"], + lambda rp: rp["CART_FRAMEBUFFER_SIZE"]), + ("lines the blit copies", lambda st: st["FB_COPY_LINES"], + lambda rp: rp["CART_FB_BLIT_LINES"]), + ("bytes the blit copies", lambda st: st["FBDRV_TOTAL_BYTES"], + lambda rp: rp["CART_FB_BLIT_BYTES"]), + ("MOVEM chunk", lambda st: st["FBDRV_ITER_BYTES"], lambda rp: rp["CART_FB_CHUNK_BYTES"]), + ("MOVEM chunks", lambda st: st["FBDRV_MAIN_ITERS"], lambda rp: rp["CART_FB_CHUNK_COUNT"]), + ("MOVEM tail", lambda st: st["FBDRV_TAIL_BYTES"], lambda rp: rp["CART_FB_CHUNK_TAIL"]), + # The ROM3 signalling windows the RP's ring filters on. + ("IKBD byte window", lambda st: rom3(st, "IKBD_WINDOW_BASE"), + lambda rp: rp["IKBD_WINDOW_LO16"]), + ("blit-done window", lambda st: rom3(st, "VBLSYNC_ADDR"), + lambda rp: rp["FB_VBLSYNC_HIBYTE"]), + ("ACIA overrun window", lambda st: rom3(st, "IKBD_OVERRUN_ADDR"), + lambda rp: rp["CART_ROM3_IKBD_OVERRUN_WINDOW"]), + ("hello window", lambda st: rom3(st, "ST_HELLO_WINDOW"), + lambda rp: rp["CART_ROM3_HELLO_WINDOW"]), + ("TOS high byte window", lambda st: rom3(st, "ST_TOS_HI_WINDOW"), + lambda rp: rp["CART_ROM3_TOS_HI_WINDOW"]), + ("TOS low byte window", lambda st: rom3(st, "ST_TOS_LO_WINDOW"), + lambda rp: rp["CART_ROM3_TOS_LO_WINDOW"]), + # The sample rate: Timer-B plays what the RP converts at its rate. + ("audio sample rate (Hz)", lambda st: round( + MFP_CLOCK_HZ / (MFP_PRESCALER[st["TIMERB_PRESCALER"]] * st["TIMERB_COUNT"])), + lambda rp: rp["AUDIO_NATIVE_RATE_HZ"]), +] + + +class Layout(unittest.TestCase): + + def test_pairs_agree(self): + st, rp = st_names(), rp_names() + for what, st_value, rp_value in PAIRS: + with self.subTest(what): + self.assertEqual(st_value(st), rp_value(rp), + f"{what}: the ST side and the RP side differ") + + def test_audio_bytes_per_frame(self): + """The RP refills what the ST plays in one frame: two bytes per + Timer-B interrupt, within one sample.""" + st, rp = st_names(), rp_names() + rate = MFP_CLOCK_HZ / (MFP_PRESCALER[st["TIMERB_PRESCALER"]] * st["TIMERB_COUNT"]) + samples = rate * rp["AUDIO_FRAME_PERIOD_US"] / 1e6 + self.assertLess(abs(rp["AUDIO_FILL_BYTES_PER_VBL"] / 2 - samples), 1, + f"{rp['AUDIO_FILL_BYTES_PER_VBL']} bytes per frame for " + f"{samples:.1f} samples") + self.assertLessEqual(rp["AUDIO_FILL_BYTES_PER_VBL"], rp["CART_AUDIO_BUFFER_SIZE"]) + + def test_window_blocks_in_order(self): + """The blocks do not overlap, and the framebuffer ends the window.""" + rp = rp_names() + blocks = [ + ("CART_CMD_SENTINEL_OFFSET", 4), ("CART_FB_FRAME_COUNTER_OFFSET", 4), + ("CART_SHARED_VARIABLES_OFFSET", rp["CART_SHARED_VARIABLES_SLOTS"] * 4), + ("CART_AUDIO_BUFFER_OFFSET", rp["CART_AUDIO_BUFFER_SIZE"]), + ("CART_BOOT_STATUS_OFFSET", 2), + ("CART_BOOT_MESSAGE_OFFSET", rp["CART_BOOT_MESSAGE_SIZE"]), + ("CART_APP_FREE_OFFSET", 0), + ("CART_FRAMEBUFFER_OFFSET", rp["CART_FRAMEBUFFER_SIZE"]), + ] + self.assertGreaterEqual(rp["CART_CMD_SENTINEL_OFFSET"], rp["CART_CARTRIDGE_CODE_SIZE"]) + for (a, size), (b, _) in zip(blocks, blocks[1:]): + with self.subTest(f"{a} before {b}"): + self.assertLessEqual(rp[a] + size, rp[b]) + self.assertEqual(rp["CART_FRAMEBUFFER_OFFSET"] + rp["CART_FRAMEBUFFER_SIZE"], + rp["CART_REGION_END"]) + + +if __name__ == "__main__": + unittest.main() From 295cefba37b525b2c744f1b417436e8c14f51b28 Mon Sep 17 00:00:00 2001 From: Diego Parrilla Date: Tue, 29 Sep 2026 22:44:25 +0200 Subject: [PATCH 5/7] Host test: what the app draws is what the ST shows 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. --- tests/host/Makefile | 2 +- tests/host/shim/pico/multicore.h | 81 ++++++++++++++++++++ tests/host/test_chunk_layout.c | 125 +++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 tests/host/shim/pico/multicore.h create mode 100644 tests/host/test_chunk_layout.c diff --git a/tests/host/Makefile b/tests/host/Makefile index 6637632..5b5795b 100644 --- a/tests/host/Makefile +++ b/tests/host/Makefile @@ -19,7 +19,7 @@ CFLAGS := -std=c11 -g -O1 -Wall -Wextra -Werror -Wno-unknown-pragmas \ -fsanitize=address,undefined -fno-omit-frame-pointer \ -fno-sanitize-recover=undefined \ -I shim -I $(REPO)/rp/src/include -LDFLAGS := -fsanitize=address,undefined +LDFLAGS := -fsanitize=address,undefined -pthread C_TESTS := $(patsubst %.c,$(BUILD)/%,$(wildcard test_*.c)) diff --git a/tests/host/shim/pico/multicore.h b/tests/host/shim/pico/multicore.h new file mode 100644 index 0000000..c2d8b74 --- /dev/null +++ b/tests/host/shim/pico/multicore.h @@ -0,0 +1,81 @@ +/* Host stand-in for the Pico SDK's pico/multicore.h: core 1 is a thread, + * and the two inter-core FIFOs are blocking queues, so code that hands work + * to core 1 runs on two threads here as on the two cores of the RP. + * Include it from one translation unit only (it holds the queues). */ +#ifndef HOST_SHIM_PICO_MULTICORE_H +#define HOST_SHIM_PICO_MULTICORE_H + +#include +#include + +#include "pico/stdlib.h" + +typedef struct { + uint32_t items[64]; + unsigned head, count; + pthread_mutex_t lock; + pthread_cond_t changed; +} host_fifo_t; + +/* host_fifo[n]: what core n reads. */ +static host_fifo_t host_fifo[2] = { + {.lock = PTHREAD_MUTEX_INITIALIZER, .changed = PTHREAD_COND_INITIALIZER}, + {.lock = PTHREAD_MUTEX_INITIALIZER, .changed = PTHREAD_COND_INITIALIZER}}; +static _Thread_local int host_core_num; /* 0 on the main thread */ +static void (*host_core1_entry)(void); + +static inline void host_fifo_push(host_fifo_t *f, uint32_t value) { + pthread_mutex_lock(&f->lock); + while (f->count == sizeof f->items / sizeof f->items[0]) { + pthread_cond_wait(&f->changed, &f->lock); + } + f->items[(f->head + f->count++) % (sizeof f->items / sizeof f->items[0])] = value; + pthread_cond_broadcast(&f->changed); + pthread_mutex_unlock(&f->lock); +} + +static inline uint32_t host_fifo_pop(host_fifo_t *f) { + pthread_mutex_lock(&f->lock); + while (f->count == 0) { + pthread_cond_wait(&f->changed, &f->lock); + } + uint32_t value = f->items[f->head]; + f->head = (f->head + 1) % (sizeof f->items / sizeof f->items[0]); + f->count--; + pthread_cond_broadcast(&f->changed); + pthread_mutex_unlock(&f->lock); + return value; +} + +static inline void multicore_fifo_push_blocking(uint32_t value) { + host_fifo_push(&host_fifo[1 - host_core_num], value); +} + +/* The RP's code passes pointers (a job, its argument) through the 32-bit + * FIFO. On a 64-bit host that keeps only their low half; every such pointer + * points into this program's own image, so the high half is taken from an + * address in it. Values that are not pointers are unaffected in their low + * 32 bits, which is all the RP's code keeps of them. */ +static inline uintptr_t multicore_fifo_pop_blocking(void) { + uint32_t low = host_fifo_pop(&host_fifo[host_core_num]); + return ((uintptr_t)&host_fifo & ~(uintptr_t)0xFFFFFFFFu) | low; +} + +#define multicore_fifo_push_blocking_inline multicore_fifo_push_blocking +#define multicore_fifo_pop_blocking_inline multicore_fifo_pop_blocking + +static void *host_core1_main(void *unused) { + (void)unused; + host_core_num = 1; + host_core1_entry(); + return NULL; +} + +static inline void multicore_launch_core1(void (*entry)(void)) { + pthread_t thread; + host_core1_entry = entry; + pthread_create(&thread, NULL, host_core1_main, NULL); + pthread_detach(thread); +} + +#endif diff --git a/tests/host/test_chunk_layout.c b/tests/host/test_chunk_layout.c new file mode 100644 index 0000000..f62dd42 --- /dev/null +++ b/tests/host/test_chunk_layout.c @@ -0,0 +1,125 @@ +// units: rp/src/fb_chunked.c +/* The framebuffer's chunk layout, end to end: what the app draws in the + * chunky buffer is what the ST shows. + * + * fb_chunky_to_planar() transposes the chunky buffer to the ST's planes on + * both cores (core 1 is a thread here) and publishes it to the cartridge + * framebuffer with the 48-byte chunks in reverse order, because the m68k's + * blit stores each chunk with a predecrementing MOVEM: chunk k read from the + * cartridge lands at CART_FB_CHUNK_COVERED - 48 (k + 1) in the screen page, + * and the tail is copied as it is (cart_shared.h, "Framebuffer chunk layout", + * and FBDRV_INLINE in userfw.s). This test runs the RP's side, models the + * blit from the constants of cart_shared.h, decodes the ST's planes and + * compares every pixel with what was drawn. + * + * The two workers are Thumb assembly on the RP (fb_chunked_asm.S); here they + * are the C they stand for: the ST's low-resolution planar format, and a + * chunk-reversed copy. */ +#include + +#include "cart_shared.h" +#include "fb_chunked.h" +#include "test.h" + +#define W FB_CHUNKED_W +#define H FB_CHUNKED_H + +/* Pixels [src, src_end) to planar words at dst: per 16 pixels, one word per + * plane (0..3), the leftmost pixel in bit 15. */ +void fb_c2p_half(uint16_t *dst, const uint8_t *src, const uint8_t *src_end) { + for (; src < src_end; src += 16, dst += 4) { + for (int plane = 0; plane < 4; plane++) { + uint16_t word = 0; + for (int i = 0; i < 16; i++) { + word |= (uint16_t)(((src[i] >> plane) & 1u) << (15 - i)); + } + dst[plane] = word; + } + } +} + +/* count 48-byte chunks to dst, in order, from src_last backwards. */ +void fb_chunk_reverse_copy48(uint8_t *dst, const uint8_t *src_last, + uint32_t count) { + for (uint32_t k = 0; k < count; k++) { + memcpy(dst + 48u * k, src_last - 48u * k, 48u); + } +} + +/* The cartridge framebuffer, as 16-bit words (the ST reads the RP's words + * with their bytes swapped, which leaves their values as they are). */ +static uint16_t cart[CART_FRAMEBUFFER_SIZE / 2]; +static uint16_t screen[CART_FRAMEBUFFER_SIZE / 2]; + +/* FBDRV_INLINE: CART_FB_CHUNK_COUNT MOVEM pairs, each reading one chunk + * forward and storing it below the previous one, then the tail in place. */ +static void m68k_blit(void) { + const unsigned chunk = CART_FB_CHUNK_BYTES / 2; + for (unsigned k = 0; k < CART_FB_CHUNK_COUNT; k++) { + unsigned to = (CART_FB_CHUNK_COVERED / 2) - chunk * (k + 1); + memcpy(&screen[to], &cart[chunk * k], CART_FB_CHUNK_BYTES); + } + memcpy(&screen[CART_FB_CHUNK_COVERED / 2], &cart[CART_FB_CHUNK_COVERED / 2], + CART_FB_CHUNK_TAIL); +} + +static uint8_t st_pixel(int x, int y) { + const uint16_t *group = &screen[y * 80 + (x / 16) * 4]; + int bit = 15 - (x % 16); + return (uint8_t)(((group[0] >> bit) & 1) | (((group[1] >> bit) & 1) << 1) | + (((group[2] >> bit) & 1) << 2) | + (((group[3] >> bit) & 1) << 3)); +} + +static int shows_what_was_drawn(void) { + for (int y = 0; y < H; y++) { + for (int x = 0; x < W; x++) { + if (st_pixel(x, y) != (fb_chunked_buffer[y * W + x] & 0x0Fu)) { + fprintf(stderr, "pixel (%d, %d): ST shows %u, drawn %u\n", x, y, + st_pixel(x, y), fb_chunked_buffer[y * W + x] & 0x0Fu); + return 0; + } + } + } + return 1; +} + +int main(void) { + /* The blit covers the whole framebuffer, as userfw's FBDRV_INLINE does + * with FB_COPY_LINES = 200 (tests/host/test_layout.py checks the two + * agree). */ + CHECK_EQ(CART_FB_BLIT_BYTES, CART_FRAMEBUFFER_SIZE); + CHECK_EQ(CART_FB_CHUNK_COVERED + CART_FB_CHUNK_TAIL, CART_FB_BLIT_BYTES); + + fb_chunked_init(); /* core 1 */ + + /* A pattern with every colour in every position, a gradient, a single + * pixel at each corner, and the last line, which is the tail. */ + for (int y = 0; y < H; y++) { + for (int x = 0; x < W; x++) { + fb_chunked_buffer[y * W + x] = (uint8_t)((x * 7 + y * 3) & 0x0F); + } + } + fb_chunky_to_planar(cart); + m68k_blit(); + CHECK(shows_what_was_drawn()); + + fb_chunked_clear(0); + fb_chunked_buffer[0] = 1; + fb_chunked_buffer[W - 1] = 2; + fb_chunked_buffer[(H - 1) * W] = 3; + fb_chunked_buffer[H * W - 1] = 4; + for (int x = 0; x < W; x++) fb_chunked_buffer[(H - 1) * W + x] = (uint8_t)(x & 15); + fb_chunky_to_planar(cart); + m68k_blit(); + CHECK(shows_what_was_drawn()); + + /* The high nibble of a chunky byte is not a colour: the ST sees only the + * palette index in the low nibble. */ + for (int i = 0; i < W * H; i++) fb_chunked_buffer[i] = (uint8_t)(0xA0 | (i % 11)); + fb_chunky_to_planar(cart); + m68k_blit(); + CHECK(shows_what_was_drawn()); + + TEST_END(); +} From a72f7fdb805fee1822326bc0814b96cc609147b7 Mon Sep 17 00:00:00 2001 From: Diego Parrilla Date: Tue, 29 Sep 2026 22:45:48 +0200 Subject: [PATCH 6/7] Host test: the sample converter and the .YMS files it writes 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. --- tests/host/test_wav_to_ym4.py | 96 +++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/host/test_wav_to_ym4.py diff --git a/tests/host/test_wav_to_ym4.py b/tests/host/test_wav_to_ym4.py new file mode 100644 index 0000000..7d9a4d8 --- /dev/null +++ b/tests/host/test_wav_to_ym4.py @@ -0,0 +1,96 @@ +"""tools/wav_to_ym4.py, the sample converter behind the .YMS files the RP +streams from the SD card and the built-in jingle: its 64-entry table, the +length it resamples to, and a .YMS file the RP's reader (audio.c) accepts.""" + +import importlib.util +import math +import os +import struct +import subprocess +import sys +import tempfile +import unittest +import wave + +from test_layout import REPO, RP_SRC, c_defines + +TOOL = os.path.join(REPO, "tools", "wav_to_ym4.py") +_spec = importlib.util.spec_from_file_location("wav_to_ym4", TOOL) +ym = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(ym) + + +def audio_defines(): + return c_defines(os.path.join(RP_SRC, "audio.c")) + + +class Table(unittest.TestCase): + + def test_sixty_four_volume_pairs(self): + self.assertEqual(len(ym.GHOSTBUSTERS_LUT), 64) + for va, vb in ym.GHOSTBUSTERS_LUT: + self.assertTrue(0 <= va <= 15 and 0 <= vb <= 15, (va, vb)) + + def test_every_pcm_byte_picks_its_entry(self): + """PCM byte b (0..255) plays table entry b >> 2, as (vA, vB).""" + for byte in range(256): + sample = (byte + 0.5) / 128.0 - 1.0 + self.assertEqual(ym._float_to_ym_dual_ghost([sample]), + list(ym.GHOSTBUSTERS_LUT[byte >> 2]), byte) + + def test_silence_and_clipping(self): + mid = list(ym.GHOSTBUSTERS_LUT[32]) + self.assertEqual(ym._float_to_ym_dual_ghost([0.0]), mid) + self.assertEqual(ym._float_to_ym_dual_ghost([5.0]), list(ym.GHOSTBUSTERS_LUT[63])) + self.assertEqual(ym._float_to_ym_dual_ghost([-5.0]), list(ym.GHOSTBUSTERS_LUT[0])) + + +class Resample(unittest.TestCase): + + def test_length_follows_the_rate(self): + for n, src, dst in ((1000, 11025, 5585), (4410, 44100, 5585), + (777, 8000, 16000), (1, 22050, 5585)): + out = ym._resample_linear([0.25] * n, src, dst) + self.assertEqual(len(out), max(1, round(n * dst / src)), (n, src, dst)) + + def test_same_rate_is_unchanged(self): + samples = [0.1, -0.2, 0.3] + self.assertEqual(ym._resample_linear(samples, 5585, 5585), samples) + + +class YmsFile(unittest.TestCase): + + def test_a_wav_becomes_a_yms_the_rp_accepts(self): + """A half-second tone at 11,025 Hz, converted for the RP's rate.""" + rp = audio_defines() + rate = rp["AUDIO_NATIVE_RATE_HZ"] + src_rate, n = 11025, 5512 + with tempfile.TemporaryDirectory() as tmp: + wav_path = os.path.join(tmp, "tone.wav") + with wave.open(wav_path, "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(src_rate) + w.writeframes(b"".join( + struct.pack(" Date: Tue, 29 Sep 2026 22:48:13 +0200 Subject: [PATCH 7/7] Document the host tests and the shared layout include 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. --- CHANGELOG.md | 6 ++++++ CLAUDE.md | 8 ++++---- README.md | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 583277f..f588631 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ PNG (what the ST shows, one frame or two consecutive ones), press SELECT, and explain a crash or halt it for a postmortem. In debug builds a mailbox lets the host type on the ST's keyboard and send the app commands (`devhooks.h`). `tools/dev/README.md` describes them. +- Host tests: `make -C tests/host test` checks the pure logic on the host, and CI runs it on + every pull request: the blits' clipping, the framebuffer layout from the chunky buffer to + the ST's screen, the agreement of the ST's and the RP's copies of every shared constant, and + the sample converter. +- The cartridge window's layout lives in one m68k include, `inc/sidecart_layout.s`, which + `main.s` and `userfw.s` share; `userfw.s` no longer repeats its addresses. ## v1.0.0beta (2026-06-04) diff --git a/CLAUDE.md b/CLAUDE.md index 49be2e7..40f6ce1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ Build flow (orchestrated by `build.sh`): - `upload_s3.sh ` is a manual one-off uploader; needs `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`. ### Tests -There is no unit test suite. Verification is on hardware: the build succeeds, the UF2 boots, and the app behaves on the ST. +**Host tests** (`tests/host/`: `make -C tests/host test`, a second or two; CI runs them before the firmware build, and a failure fails the pull request) cover the pure logic. `test_fb_blit.c`: the blits' clipping at every edge, against a pixel-by-pixel reference. `test_chunk_layout.c`: what the app draws is what the ST shows, through the RP's transpose and chunk-reversed publish and a model of the m68k's MOVEM blit. `test_layout.py`: the ST's and the RP's copies of every shared constant agree (the window, the command values, the ROM3 windows, the blit's shape, the audio rate). `test_wav_to_ym4.py`: the sample converter and the `.YMS` files the RP's reader accepts. The C tests build firmware units unchanged with the host compiler, under the address and undefined-behaviour sanitizers; `shim/` stands in for the few SDK headers they include (core 1 is a thread). To add one: a `tests/host/test_*.c` whose first line names the firmware sources it compiles (`// units: rp/src/fb_blit.c`), with `test.h`'s `CHECK` / `CHECK_EQ` / `TEST_END`, or a `test_*.py` unittest module. A new constant both sides share gets a row in `test_layout.py`'s `PAIRS`. Everything that needs the SDK, the bus or timing is verified on hardware: the build succeeds, the UF2 boots, and the app behaves on the ST. **With the Debug Probe attached** (SWD and the debug UART), `tools/dev/` does most of that from the host; `tools/dev/README.md` has the details. `flash.sh ` builds out of tree, flashes and checks over SWD that the RP runs that build. `console.py watch` captures the debug console (921,600 baud; release builds have none) into `tools/dev/logs/console.log`. `swd.py` reads the running RP without its help: `counters` (frames published, blits acknowledged, IKBD and ring overruns, per second with `--watch`), `heap`, `shared`, `fb` (the framebuffer as the ST shows it, as a PNG), `crash`, `postmortem`, `select`, `reset`. In debug builds `key` and `app` drive the app through the devhooks mailbox (`rp/src/include/devhooks.h`): keystrokes as from the ST, and the demo dispatcher's `demo N`, `menu`, `overlay`, `slow_frame`. `tools_harness.py --build --flash --reset` checks all of it against the device. Flashing stops the cartridge bus, so reset the ST afterwards; never use OpenOCD's own `reset` or `program` (core 1 is the c2p worker and must start with core 0). @@ -133,11 +133,11 @@ Tuning knobs: - **No Atari RAM**: the cartridge code deliberately uses none of the ST's RAM (no `.bss`, no heap). Data lives in registers or the shared region. Adding naive BSS to `userfw.s` will silently corrupt system variables — keep state in `APP_FREE` / `SHARED_VARIABLES`, the cart code itself (which is **read-only** from m68k — writes bus-error), or registers. Key live registers across the `userfw.s` VBL loop: A0 = Timer-B audio read cursor (reset to `AUDIO_BUFFER_ADDR` by `userfw_vbl` each VBL, never in any MOVEM list), A5 = per-VBL FB destination scratch (predec target for `FBDRV_INLINE`), A6 = per-VBL FB source scratch (postinc reader of the cart FB), A7 = SP (also kept out of MOVEM so IRQs stay safe). D0-D7 / A1-A4 are scratch and are clobbered by the MOVEM macro. - `src/userfw.s` — **the primary extension point for app-specific m68k code.** `src/userfw.ld` places `main.s` at offset `0x0000` (≤ 2 KB) and `userfw.s` at offset `0x0800` (`0x0800..0x3FFF`, 14 KB budget including the inline `FBDRV_INLINE` macro expansion). The previously-separate `fbdrv.s` module at offset `0x2000` was eliminated: the unrolled MOVEM block is now emitted inline at the userfw entry via the `FBDRV_INLINE` macro, so there's no jsr/rts overhead and no separate hand-written file to maintain. Total cart code ≤ 16 KB (`CARTRIDGE_CODE_SIZE`); current build is ~8.3 KB. `main.s` exposes the userfw entry as `USERFW equ (ROM4_ADDR + $800)`. The default `userfw.s` runs the FB blit + audio + IKBD VBL loop and exits on ESC; replace its body with your own per-frame logic, or extend it. - **Every m68k module ends with a NOP tail**: `even`, eight `nop`s and a `_end:` label, after its last code (after `include "inc/sidecart_functions.s"` in a module that includes it). `firmware.py` strips trailing zero bytes from the image, `pre_auto` relocates `start_rom_code..end_rom_code` in whole longwords, and the write sender and the 68000's prefetch read past the senders' wait loop: the last words of a module must be harmless to drop or to read past. `main.s` and `userfw.s` both end this way. -- Adding more m68k modules: add a new `.text_` section in `userfw.ld`, mirror the offset with an `equ (ROM4_ADDR + $????)` in `main.s`, and add the `.o` target to `target/atarist/Makefile`. +- Adding more m68k modules: add a new `.text_` section in `userfw.ld`, mirror the offset with an `equ (ROM4_ADDR + $????)` in `inc/sidecart_layout.s`, and add the `.o` target to `target/atarist/Makefile`. - Built via `stcmd make release` (m68k assembler in Docker). A 64 KB padded copy of the BOOT.BIN is then converted to `target_firmware.h` for inclusion in the RP build. ### Shared 64 KB cartridge region -The Atari ST sees a 64 KB window at `$FA0000`–`$FAFFFF` (mirrored RP-side at `0x20030000`). This is the **single source of truth** for any cross-target data layout — both sides derive every offset symbolically from constants in `rp/src/include/cart_shared.h` (RP-side) and `target/atarist/src/main.s` (m68k side). **Apps must never hard-code an address inside this region** — always reference the named offset/symbol. +The Atari ST sees a 64 KB window at `$FA0000`–`$FAFFFF` (mirrored RP-side at `0x20030000`). This is the **single source of truth** for any cross-target data layout — both sides derive every offset symbolically from constants in `rp/src/include/cart_shared.h` (RP-side) and `target/atarist/src/inc/sidecart_layout.s` (m68k side, included by `main.s` and `userfw.s`); `tests/host/test_layout.py` checks that the two agree. **Apps must never hard-code an address inside this region** — always reference the named offset/symbol. | Offset | Symbol | Size | Purpose | | --- | --- | --- | --- | @@ -153,7 +153,7 @@ The Atari ST sees a 64 KB window at `$FA0000`–`$FAFFFF` (mirrored RP-side at ` | `$FA8300` | `FRAMEBUFFER_ADDR` | 32000 B | 320×200×4bpp color framebuffer. Top of the region so an overrun walks off the end of the 64 KB window instead of corrupting the metadata block. Read by `FBDRV_INLINE` every VBL. | ### ROM3 signalling windows and ST sessions -The cartridge window is read-only from the ST, so the ST tells the RP things by *reading* ROM3 addresses (`$FB0000`–`$FBFFFF`), which the commemul ring captures: the high byte of the address says what the read means, the low byte carries a value. The windows are listed in `cart_shared.h` (`CART_ROM3_*`) and mirrored as equs in `userfw.s`: `$FB82xx` an IKBD byte, `$FB84xx` blit done, `$FB8500` the keyboard ACIA overran, `$FB88xx` the hello (xx = the machine), `$FB89xx` / `$FB8Axx` the TOS version's high and low byte. A new signal gets a window of its own. +The cartridge window is read-only from the ST, so the ST tells the RP things by *reading* ROM3 addresses (`$FB0000`–`$FBFFFF`), which the commemul ring captures: the high byte of the address says what the read means, the low byte carries a value. The windows are listed in `cart_shared.h` (`CART_ROM3_*`) and mirrored as equs in `inc/sidecart_layout.s`: `$FB82xx` an IKBD byte, `$FB84xx` blit done, `$FB8500` the keyboard ACIA overran, `$FB88xx` the hello (xx = the machine), `$FB89xx` / `$FB8Axx` the TOS version's high and low byte. A new signal gets a window of its own. What each side may assume about the other: - **The ST and the RP reboot independently.** A power cycle restarts both; an ST reset restarts only the ST, and the RP keeps everything: the command sentinel, the app's state. An RP-only restart (a flash, SELECT) rebuilds the window while the ST keeps running. diff --git a/README.md b/README.md index da31001..d4c5fd5 100644 --- a/README.md +++ b/README.md @@ -450,6 +450,20 @@ optimization toolbox: per-file `#pragma GCC optimize("O3")`, loops, fixed-point + sin/cos LUTs, the SIO interpolator for texture addressing, and a dual-core band split via `fb_core1_dispatch()`. +## Testing and debugging + +- **Host tests**: `make -C tests/host test` runs the template's pure logic on your computer in a + second or two: the blits' clipping, the framebuffer layout end to end (what you draw is what + the ST shows), the ST and RP copies of every shared constant, and the audio converter. CI runs + them on every pull request. Add yours as `tests/host/test_*.c` (its first line names the + firmware sources it compiles) or `test_*.py`. +- **A constant both sides share** (a new block in the cartridge window, a new ROM3 signal) goes + in `target/atarist/src/inc/sidecart_layout.s` and `rp/src/include/cart_shared.h`, with a row in + `tests/host/test_layout.py`. +- **With a Raspberry Pi Debug Probe** on the RP's SWD and debug UART, `tools/dev/` builds, flashes + and verifies from the host, captures the console, reads counters while the app runs, grabs the + screen as a PNG and types on the ST's keyboard: see `tools/dev/README.md`. + ## More docs - `CLAUDE.md` — architecture deep-dive (the framebuffer pipeline, shared