Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Build flow (orchestrated by `build.sh`):
- `upload_s3.sh <file>` 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 <debug|release>` 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).

Expand Down Expand Up @@ -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 `<module>_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_<name>` 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_<name>` 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 |
| --- | --- | --- | --- |
Expand All @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion rp/src/fb.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions rp/src/include/cart_shared.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand Down
84 changes: 84 additions & 0 deletions target/atarist/src/inc/sidecart_layout.s
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading