Atari ST track
Step 7 ยท Atari ST

How the code is laid out.

The template is running. Now find out what is in it: which half is which, how they share memory, and which limits you have to design around rather than discover at three in the morning. This step is the practical read of the upstream programming guide .

Two source trees

The split you read about in step 1 is literally the directory structure:

project layout
rp/                     C, runs on the RP2040 (Pico W)
  src/
    main.c              entry point and the main loop
    chandler.c          command handler: drains the ring, dispatches
    commemul.c/.pio     the command channel
    romemul.c/.pio      ROM emulation, do not touch
    sdcard.c            FatFs on the microSD card
    network.c           Wi-Fi
    term.c              terminal rendering
    aconfig.c           this app's settings
  CMakeLists.txt
  build.sh

target/atarist/         68000 assembly, runs on the Atari
  src/
    inc/                helper macros
    main.s              boot and dispatch (2 KB)
    userfw.s            your Atari-side code (6 KB)
  Makefile
  build.sh

desc/
  app.json              your app's descriptor, edited by you (step 10)
  app.json.template     the blank form to copy from

version.txt             the version stamped into the build
dist/                   build output, wiped and rebuilt each time
.vscode/                launch.json, settings.json

rp/ is portable. It is the part that would survive a port to another host computer, and it targets the RP2040 described in Raspberry Pi's microcontroller chips documentation . target/atarist/ is Atari-specific by definition. If another platform is ever supported, it appears alongside as a sibling directory, and the C half barely changes.

Leave the emulation alone.

romemul.c, romemul.pio, commemul.c and commemul.pio are the cycle-accurate machinery that makes the board look like a ROM chip. They are timed against the Atari's bus and there is no margin in them. Nothing you want to build requires editing these files. If you want to know what they are doing, the hardware interface documentation walks through the PIO state machine step by step.

The shared memory map

Understand this part properly, because it is how your two halves actually communicate. One region of memory has two addresses: the Atari sees it in its cartridge address space, the microcontroller sees it in its RAM.

Atari ST sees microcontroller sees Cartridge code, 8 KB main.s, boot and dispatch (2 KB) userfw.s, your code (6 KB) $FA0000 0x20030000 Command sentinel, 4 bytes $FA2000 0x20032000 Token reply, 4 bytes $FA2004 0x20032004 Framebuffer, 8000 bytes used by the framebuffer template $FAE0C0 0x2003E0C0 Not to scale. The gaps between these regions are much larger than shown.
The same bytes, two addresses. Writing at 0x20032000 on the microcontroller is writing at $FA2000 as far as the Atari is concerned.

The cartridge image is 8 KB in total, and target/atarist/src/userfw.ld splits it: 2 KB for main.s, which boots and dispatches, and 6 KB for userfw.s, which is yours. Six kilobytes is the real budget for your Atari-side code, and it is the constraint people most often forget to mention to an AI assistant.

How a command gets across

The C half registers a handler and then pumps a loop. Commands arriving from the Atari are drained from a ring buffer and dispatched:

rp/src/main.c, setup
init_romemul(false);
commemul_init();
chandler_init();
chandler_addCB(my_command_handler);
rp/src/main.c, the main loop
while (keepActive) {
  chandler_loop();   // drain the ring, dispatch commands
  term_loop();       // render output
  // ... your logic
}

Commands are polled, not interrupt-driven. That changed in v1.1.0. It means your main loop has to keep turning: block inside it and the Atari stops being answered. Long work needs to be broken into pieces across iterations.

On the Atari side, sending a command is a macro:

target/atarist/src/userfw.s
send_sync APP_TERMINAL_KEYSTROKE, 4

It expands to set up registers and call the routine that talks to the board. The pattern is always the same. The Atari raises a command, the microcontroller notices it in chandler_loop(), does the work, then writes back into shared memory.

The rules you have to design around

These are not style preferences. Each one is a consequence of how the hardware works, and breaking one produces failures that are very hard to read.

The Atari side cannot use normal RAM

Your cartridge code executes in place, out of the ROM address space above, and is never copied into memory first. That is why it cannot rely on the Atari's ordinary RAM being available to it. Anything that needs storage either lives in the address space you already have, or gets pushed across to the microcontroller's shared memory. This surprises everyone once.

Six kilobytes of Atari-side code

userfw.s gets 6 KB of the 8 KB cartridge image. If you find yourself writing a lot of 68000, that is usually a signal the work belongs in the C half instead.

128 KB of the microcontroller's RAM is spoken for

The chip has 264 KB, and 128 KB of it is reserved as the shared region the Atari reads. That leaves roughly half for your application. Generous compared to 6 KB, not unlimited.

Flash writes are not free

Writing to flash has a limited lifetime and cannot sit on the critical path of ROM emulation. Save settings at deliberate moments, not every frame and not in a loop.

Where these limits come from.

All four trace back to a handful of hardware realities: the RP2040's internal bus contention, the number of usable GPIOs, the 500 ns window the Atari gives a cartridge to answer. The architecture and design documentation explains each properly, and the hardware interface page covers the timing side. You do not need it to build something, but it turns these rules from arbitrary into obvious.

Settings and the SD card

Two things almost every app ends up needing. Defaults are declared as a table:

rp/src/aconfig.c
static SettingsConfigEntry defaultEntries[] = {
    {ACONFIG_PARAM_FOLDER, SETTINGS_TYPE_STRING, "/test"},
    {ACONFIG_PARAM_MODE, SETTINGS_TYPE_INT, "255"},  // 255: Menu mode
};

And the filesystem is FatFs, mounted against a folder:

mounting the microSD card
FATFS fsys;
char *folderName = "/test";
int sdcardErr = sdcard_initFilesystem(&fsys, folderName);
if (sdcardErr != SDCARD_INIT_OK) {
  DPRINTF("Error initializing SD card: %i\n", sdcardErr);
}

DPRINTF is how you will see what your code is doing. Step 9 covers where that output actually goes.