Skip to content

v1.1.0 - #14

Merged
diegoparrilla merged 73 commits into
mainfrom
release/v1.1.0
Sep 30, 2026
Merged

diegoparrilla merged 73 commits into
mainfrom
release/v1.1.0

Conversation

@diegoparrilla

Copy link
Copy Markdown
Contributor

Release v1.1.0 into main. What it brings, from the changelog:

Sound

  • The sound never tears and never repeats. The ST plays one VBL of samples per slice of
    the audio buffer and tells the RP which slice it plays; the RP writes only the slices
    ahead, from a timer interrupt, so a slow frame no longer holds the sound back. Before,
    the ST replayed a stale 20 ms of sound about four times a second even at 50 frames a
    second, and the last sample or two of every frame came from the next one.
  • An app may take up to 80 ms between two calls to audio_render_frame(); a sample
    reaches the speaker at most 120 ms after its fill callback made it
    (AUDIO_FIFO_SLICES). When nothing is ready the last sample is held, never stale
    sound, and audioUnderruns counts it.

Two profiles: 50 fps, or 25 fps with rich sound

  • Apps choose at compile time between 50 frames a second with the sound as before (the YM at
    5,585 Hz, the DMA chip at 12,517 Hz), the default, and 25 frames a second with four times the
    YM's rate and twice the DMA chip's (21,943 Hz and 25,033 Hz): APP_PROFILE=PROFILE_25FPS when
    building (rp/src/include/profile.h). Story games with rich sound get the second; the ST
    keeps about 5 ms of each 25 fps frame free on a plain ST, and a mouse moved fast costs no
    frame there. The ST code is the same for both; fb_publish() paces the app at the profile's
    rate, and .YMS files and the built-in jingle keep their 5,585 Hz (the RP resamples them).
  • The audio buffer in the cartridge window grows to 4 KB, the DMA ring in the ST's RAM to 4 KB
    ($6F000): APP_FREE starts at $FA5180 (about 12.4 KB).
  • The ST's stopwatch (TIME_STUDY in userfw.s, off by default) replaces the slack report:
    MFP Timer-A at 4 µs a tick, reported at points of the ST's loop on one timeline;
    tools/dev/swd.py stopwatch prints them.

STE DMA sound

  • On an STE or a Mega STE the sound plays through the DMA sound chip: 8-bit at 12,517 Hz, no
    interrupt per sample. The ST copies each VBL's samples into a ring in its RAM (below the
    screen pages); Timer-B stays off, which gives the ST 1.6 ms of every frame back: a mouse moved
    fast no longer costs frames there. A plain ST or Mega ST plays through the YM as before, from
    the same firmware.
  • Apps give 8-bit PCM at any rate (audio_play_pcm_loop(), audio_set_pcm_callback()) or YM
    pairs as before; either plays on both outputs, converted on the RP. audio_prefer_ym() keeps
    an STE on the YM; tools/wav_to_ym4.py --mode pcm writes PCM headers.

Games

  • Two small games, on the public APIs only, as the reference for starting a game: Arena (the
    joystick: grab gems, shoot enemies, three lives) and Zap (the mouse: the left button zaps
    enemies, the right one takes gems; the HUD lights L and R while they are held). They are
    entries 6 and 7 of the demo menu, and examples/mini_game builds a firmware that boots
    straight into one. They draw again only what moved, and compute their tune and sound
    effects as they play.
  • tools/png_to_bitmap.py turns an indexed PNG (16 colours, one of them transparent) into
    FB_BITMAPs for fb_blit() / fb_blit_key(), a whole image or cut into tiles, with its
    palette for palette_set(). Plain Python, no Pillow.
  • The demo menu is centred on the screen.

Blitter

  • On an STE or a Mega STE the blitter copies each frame to the screen instead of the 68000,
    which gives the ST about 1.4 ms of every frame back (measured on a Mega STE). Where the YM plays the sound (a plain
    ST, a Mega ST fitted with a blitter) the 68000 still copies: the blitter holds the CPU off,
    and the YM's samples come late and sound rough. fb_set_copy_mode() picks either; the
    blitter on a Mega ST gives an app without sound about 3 ms more.
  • The ST reports whether it has a blitter (st_session_features()).

Mouse and joysticks

  • Apps get the mouse and both joysticks next to the keyboard, decoded on the RP: pick an
    input mode with ikbd_set_input_mode() (keyboard only, the default; mouse; mouse and
    joystick 1; two joysticks) and read ikbd_read_mouse() / ikbd_read_joystick() once a
    frame. Clicks and fire presses shorter than a frame are never missed. The mode survives an
    ST reset and a keyboard unplugged and plugged back in; keys held when it went away are
    released.
  • The RP now sends the IKBD its commands: the ST passes on whatever the RP puts in the
    cartridge window (ikbd_send_commands() for other settings). At boot the ST no longer
    switches the mouse and joysticks off itself.
  • Bytes lost between the keyboard and the app are detected (the ST reports how many it read
    every frame) and never turn mouse data into keys.
  • A new menu entry, the input test, shows the mouse, both joysticks, the keys and the
    counters of the input path.
  • Every IKBD byte costs the ST an interrupt. With the full-screen blit and the sound, a mouse
    moved fast makes the frame rate dip to 30-40 a second; the sound stays clean.
    The ST's stopwatch (TIME_STUDY in userfw.s) measures how much of each frame the ST has left.
  • The release of keypad Enter is no longer dropped.

For developers

  • tools/dev/: build, flash and verify the RP from the host through a Raspberry Pi Debug Probe,
    capture its debug console, read its counters and heap while it runs, grab the framebuffer as a
    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), and swd.py ikbd-log records the keyboard's byte
    stream. 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, the
    sample converter, the audio slices, the DMA sound output and the IKBD decoder.
  • 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.

A version now starts on a release/vX.Y.Z branch cut from main; each
piece of work gets its own epic/NN-<slug> branch whose pull request
targets the release branch after hardware verification, and main
receives the release branch once, when the version is tagged.

Nothing committed or pushed may cite local planning notes, including
commit messages and PR descriptions; a git grep check to run before
tagging is documented.

CLAUDE.md also catches up with the code: the dev UUID in the build
example, the CMake presets that pin MinSizeRel, the inline
FBDRV_INLINE blit and userfw's 14 KB budget, fb_core1_loop as the
Core 1 dispatcher, the palette slot and API, and the hello_text
example as the starting point for a new app.
The 8x8 strike left row 0 blank and put the baseline on row 7, so
descenders were squashed onto it: 'g' was byte-identical to 'q' and
',' to '.', and 'j', 'p', 'y' had one-pixel stubs. Every glyph moves
up one row (lossless: row 0 was blank on all 96), putting the baseline
on row 6 and leaving row 7 for g q p y j , and _. Those glyphs are
re-authored, '_' gets ink for the first time, and the corrupt '"', '8'
and '~' from the extracted asset are redrawn.

Same glyph data as md-oric's font8x8.h. fb_font.c already walks all
eight rows, so the renderer is unchanged.

CLAUDE.md described a font6x8.h that no longer exists; it now
describes font8x8.h.
Comments in the m68k sources, the Atari build scripts and the texture
tool pointed at internal planning items a reader of this repository
cannot look up. They now say what the code does. Where the old wording
had also gone stale, it is corrected: there is no separate fbdrv.s any
more (the copy is the FBDRV_INLINE macro in userfw.s), and userfw has
no STE blitter path.

Comments only: BOOT.BIN is byte-identical apart from the build time in
the cartridge header.
The image reaches the cartridge window through the XIP stream, which
moves 32-bit words, and the copy asked for length / 2 of them: with an
odd number of 16-bit words the last one was never copied. The image was
4,173 words, so the ST saw 0x0000 where userfw_dummy_irq's rte should
be. As in md-microfirmware-template: the copy macro clears the window
itself, copies an odd trailing word on its own, falls back to a word
copy for an unaligned source, and COPY_FIRMWARE_TO_RAM_MEMCPY takes
words like the other copy macros. firmware.py emits the array 4-byte
aligned, since the stream's address register has no bits 1:0. Debug
builds check the window against the image after the copy and count the
non-zero bytes past it.

main.s and userfw.s end with a NOP tail, so the last words of each are
harmless to drop, to relocate in whole longwords or to read past.

build.sh and target/atarist/build.sh stop at the first failed step. The
m68k build deletes the old header before firmware.py runs and exits 6
when none is produced, so a failed m68k build can no longer leave a
stale target_firmware.h for the RP build to embed. build.sh falls back
to md5 where md5sum is missing.

The regenerated image differs from the previous one only by the two
NOP tails, main.s's size in the cartridge header and its relocation
count, and the build date.
__StackLimit, the heap's limit for the SDK's sbrk, was the end of RAM
plus the 64 KB ROM_IN_RAM region, so the heap could grow into the
cartridge window the ST executes from. On the device it did: the boot
left the heap's break at 0x20031000, 4 KB inside the window. It worked
only because those chunks were freed before emul_start wipes the window
and copies the image in; any later growth would have written into the
image. The limit is now the end of RAM, as in md-microfirmware-template.

The cap alone leaves 9 KB of heap and the boot needs 13.2 KB (newlib
grows the heap in 4 KB steps): the first settings allocation failed and
the app jumped to Booster. The room comes from the ROM3 capture ring. At
32 KB, aligned to its size, it and the padding in front of it took
46 KB for traffic of one blit ack per frame and the IKBD bytes. It is
now 4 KB, about 40 s of acks or 2.6 s of a mouse moved flat out, and
the heap is 49 KB. PICO_HEAP_SIZE makes 32 KB of heap a link-time
guarantee, so a build that leaves less fails to link instead of booting
into Booster.

PICO_MALLOC_PANIC=0 is defined before pico_sdk_init(), where it reaches
the SDK's malloc.c: a failed allocation returns NULL instead of
panicking, which FatFs needs to halve its cluster-sized buffer request.
Every allocation already checks its result.

The cartridge window is declared as an array of unknown size instead of
a 4-byte int, so accesses past its first word are no longer undefined
behaviour; the debug image is byte-identical with that change alone.

On the device: the boot completes, the heap peaks at 13.2 KB with 36 KB
never used below the window, and the window matches the image.
userfw sent no IKBD commands, so the mouse stayed in TOS's relative mode
and its packets reached the RP's demux, which drops the header and decodes
the payload as keys. On the ST a nudge of the mouse sent F8 01 00: dx = +1
is $01, an ESC press, and the menu exited to GEM.

userfw now resets the IKBD itself, reads and drops everything up to the
reset's answer ($F0, or $F1 on a later IKBD), waits a short settle delay
and sends $12 (mouse off) and $1A (joysticks off). The reset comes first
because for a while after a reset the IKBD undoes a mouse-off command, and
TOS resets it moments before the cartridge code runs: without it, $12 was
lost on a cold boot of a Mega ST and the mouse stayed on. The wait ends on
the answer, with a timeout that only matters when no keyboard answers, so
it does not depend on the CPU's speed.

On the way out to GEM, $14 (joystick events) and then $08 (relative mouse)
give TOS back its own setup; $14 also turns the mouse off, so the order
matters.

On a Mega ST: moving the mouse in the menu sends nothing to the RP, ESC
exits to GEM, and the GEM pointer moves.
The example ships its own copy of rp/src/CMakeLists.txt, which apply.sh
installs over the template's. It still had the SDK's defaults: malloc
panicking on a failed allocation instead of returning NULL, and no link
guarantee for the heap. An app started from it now gets the same
PICO_MALLOC_PANIC=0, defined before pico_sdk_init(), and the same 32 KB
PICO_HEAP_SIZE as the template.
The ST and the RP reboot independently, and an ST reset leaves the RP
running with all its state. After an exit to GEM the command sentinel
still held CMD_BOOT_GEM, so the next ST reset ran userfw, which read it
and left at once; the demo dispatcher, meanwhile, sat in its exiting
state and drew nothing. Only a power cycle recovered.

userfw now says hello at every boot, as md-microfirmware-template's ST
does, with three ROM3 reads before its setup: the TOS version's two
bytes, from the ROM header, then the hello, whose low byte is the machine
from the _MCH cookie. The RP's new st_session module writes CMD_NOP to
the sentinel on the hello, long before the ST's VBL loop first reads it
(the IKBD reset waits for the keyboard's answer in between), and flags a
new session. The main loop then sends the demo dispatcher back to its
menu, tearing down a demo that was running. The machine and TOS version
are kept for the app.

The ROM3 signalling windows are listed in cart_shared.h and mirrored in
userfw.s: $FB82xx IKBD bytes, $FB84xx blit done, $FB88xx the hello,
$FB89xx and $FB8Axx the TOS version. CLAUDE.md says what each side may
assume about the other. examples/hello_text builds st_session.c too.

On a Mega ST (TOS 1.04, reported as machine $00): reset, ESC to GEM,
reset brings the menu back; a reset while a demo runs brings the menu,
not the demo; ESC still exits to GEM.
The IKBD sends a byte every 1.28 ms and the 6850 holds one. userfw read
the ACIA from a poll inside the unrolled blit, every 40 MOVEM iterations:
nothing read it between blits, and the audio interrupts stretched the
poll interval past a byte's time, so a second byte could overrun the
first and a key press or release was lost. md-oric measured overruns and
a stuck key with the same kind of polling.

As in md-oric, the keyboard ACIA's receive interrupt (MFP GPIP4, $118)
now forwards every byte as it arrives and drains the ACIA before
returning, since the MFP only sees a new edge when the ACIA's IRQ line
goes down. The handler saves D0 and A1 because it can fire between a
MOVEM load and its store, never touches A0 (the audio cursor), and needs
no end-of-interrupt write with the MFP in auto-EOI mode. The MIDI ACIA
shares the line, so its receive interrupt is off while userfw runs and
TOS's setting is written back on exit. The poll is gone from the blit,
296 bytes of cartridge code and about 50 us per frame.

When the ACIA reports an overrun the handler also reads $FB8500; the RP
counts it (ikbd_overruns(), printed in debug builds), so a lost byte is
visible instead of a silent stuck key.

On a Mega ST, ten seconds of hammering the keyboard with rollover and
held keys: 211 IKBD bytes, every press matched by its release, no
overrun.
DPRINTF blocks on the UART, and at the SDK's 115,200 baud the debug
build's boot trace delayed the cartridge image by about 0.3 s. On a
power-on the ST checks the cartridge about 0.4 s after the RP starts, so
a debug build often lost the race and TOS booted to GEM without the app;
only a reset brought the app up. md-devops saw the same, and
md-microfirmware-template runs its console at 921,600 baud for it.

At 921,600 baud the image is ready about 45 ms after the first trace
line (it was 350 ms) and the main loop starts at about 130 ms. On a
Mega ST a power-on now comes up in the app. Release builds have no
console and were not affected. The example's build file gets the same
setting. Serial terminals and tools/dev/console.py must use 921600.
userfw blitted the cart framebuffer every VBL and acknowledged every
VBL, whether or not the RP had published a new frame. fb_publish()
waited for any acknowledgement since its last publish, so when an app
took longer than a frame to draw, acknowledgements piled up meanwhile
and the next publish went ahead at once, at any point of the ST's blit:
a torn frame. The frame counter the RP bumps after each publish was
documented as gating the blit, but nothing read it.

As in md-oric, the ST now blits only when the counter's low word has
changed since its last blit, and only then flips and acknowledges. The
RP bumps the counter as the last write of a publish and starts the next
publish only after that acknowledgement, and between the two the ST
reads nothing, so they cannot overlap and a publish can happen at any
moment. A slow app gets fewer frames, never a torn one; an app within
its frame budget still runs at 50 frames a second. The low word is read
with one move.w, because a longword arrives as two bus reads with its
halves swapped and could mix an old and a new value.

The VBL flag moves out of TOS's _dskbufp ($4C6) into userfw's own state
area at the end of screen page A, next to the last-seen counter. Stale
comments in userfw.s and cart_shared.h (a 256-byte buffer, 12.5 kHz and
6.27 kHz audio) now describe the current audio path.

On a Mega ST the menu and the four demos run at 50 frames a second with
no visible break.
…es flash

SELECT did nothing while the app ran: only held at power-on did it reach
Booster. The module still carried blocking helpers and a watcher that
launched and reset core 1, which here is the c2p worker, so calling them
would have killed the framebuffer's second half and left core 0 hung in
fb_core1_wait(). select.c and select.h are now md-microfirmware-template's:
select_poll() never blocks and a GPIO edge interrupt keeps presses made
while core 0 is busy. The main loop polls it; a short press restarts the
RP, a press held 10 s is a factory reset (the global settings are erased,
and Booster then clears every app's settings). SELECT is configured once,
in main(), which keeps its jump to Booster when SELECT is held at power-on;
examples/hello_text is wired the same way. reset.h is md-microfirmware-
template's too: every interrupt is masked and cleared before the jump to
Booster, so one arriving at the jump cannot lock the core up.

The long press erases flash at run time, and core 1 must not fetch from
flash meanwhile. Its parked loop called the SDK's out-of-line FIFO
functions, which live in flash; it now uses the inline ones, and the
disassembly of the loop shows no call out of RAM besides the job itself.
Jobs are always joined before the main loop goes on, so core 1 is parked
whenever select_poll() runs.

On the device, SELECT pressed over SWD: 15 ms is ignored, 300 ms restarts
the RP, which is back in its main loop 240 ms later. The factory reset
was not pressed: it would erase the test machine's settings.
Core 0 ran on the SDK's 2 KB stack. The deepest static path from main,
the boot's SD error trace, needed 2,192 bytes of this firmware's own
frames plus newlib's printf, which the call-graph analysis cannot see:
an overflow would have walked through the rest of SCRATCH_Y into core 1's
stack, silently. As in md-microfirmware-template, core 0's stack is now
all of SCRATCH_Y, 4 KB, with an MPU guard at its bottom, so an overflow
is a HardFault; core 1's stack follows the same setting, all of
SCRATCH_X with its own guard. The FATFS object leaves emul_start's
frame: the deepest path is 1,576 bytes now, and core 1's jobs use under
100. examples/hello_text gets the same settings.

commemul.c takes md-microfirmware-template's poll: a reader that falls a
whole ring behind is counted in commOverruns (commemul_getOverruns())
and resynchronised instead of replaying a mix of old and new samples,
and the DMA channel is re-armed before its transfer count runs out,
where capture used to stop after 2^32 samples. The ring keeps this
template's 4 KB.

On the device the debug build boots with the guarded stacks, mounts the
card, streams audio and reaches its main loop; overruns read 0 over SWD.
fatfs-sdk moves to v3.6.2 (rp/build.sh and the submodule), as in
md-microfirmware-template: the old pin predates the fix that releases the
card and SPI locks when a read fails, so the second disk call after the
card was pulled waited forever. The .YMS audio stream reads the card
every frame, so pulling it would have frozen the main loop within about
40 ms. On a Mega ST, with the card pulled while the menu streamed audio,
the RP kept publishing 50 frames a second and took the next keys. A
failed audio read is now logged once per streak in debug builds.

hw_config.c is md-microfirmware-template's: sd_get_by_num() no longer
accepts an index one past the end of the table, spi_get_by_num() bounds
by the number of SPI buses, and the SPI mode and drive strength are
named constants.

settings_init() checked the flash size, the offset and the number of
defaults with assert(), which every build type compiles out; they are
run-time checks returning an error now. The settings dump prints <set>
or <none> for any key whose name contains PASSWORD, since debug logs
get saved and shared; the number formatting stays hand-rolled, keeping
newlib's printf engine out of the binary. aconfig.c says never to remove
a default, only to add at the end. The global defaults, which only show
for a key missing from flash, match Booster's.
The link stripped every symbol from rp.elf, so a crash address could not
be resolved and the SWD tools could not find the cartridge window, the
counters or the build ID by name. The symbols never reach the .uf2: a
release build with a fixed date is byte-identical with and without
--strip-all. As in md-microfirmware-template, the ELF keeps them.
It was documented as a build target, but neither this repository nor
the Pico SDK has a board header for it, so a build for it could not even
configure. The builds are pico_w and pico. BOARD_TYPE_CUSTOM16MB, which
only that board defined and no code reads, goes with it.
build_id.cmake writes build_id.h and build_id.c on every build, with
release_build_id: the commit, then -dirty.<hash of the diff> for a tree
with changes, +<type> for a CMake type other than Release and +debug for
a debug build. The files are rewritten only when the ID changes. The
symbol is kept through --gc-sections, so the SWD tools can read which
build a device runs from its flash.

Both CMakeLists, the template's and hello_text's.
md-microfirmware-template accepts pico and sidecartos_16mb too, but CI and
the release build only pico_w, and rp/build.sh here builds through presets
that exist for pico_w alone, so "./build.sh pico ..." never worked. Both
CMakeLists now stop on any other board, and build.sh's usage, the README
and CLAUDE.md name pico_w only.
As md-microfirmware-template's build scripts:

- build.sh sets one RELEASE_DATE for both images, unless the caller sets
  one, and rp/build.sh keeps it. The cartridge header's GEMDOS date and
  time follow it (target/atarist/build.sh passes them to make).
- The build type is release or debug, in any case; anything else stops
  the build. It sets DEBUG_MODE for both targets, so a release build
  assembles the m68k code with _DEBUG=0 instead of the Makefile's 1. The
  m68k sources do not use _DEBUG today, so the image is unchanged.
- build.sh empties dist/ before anything else, computes the MD5 once and
  no longer exports VERSION: the stcmd of older atarist-toolkit-docker
  installers takes it as its Docker image tag.

Beyond md-microfirmware-template: the SDK's binary info carried __DATE__,
the day of the compile, so builds of one commit made on different days
still differed. Both CMakeLists set PICO_PROGRAM_BUILD_DATE to
RELEASE_DATE.

Two m68k builds and two RP release builds with the same RELEASE_DATE are
byte-identical, and the only date left in the RP image is RELEASE_DATE.
This template has no HTTPS build; the HTTPS_BUILD input came with the copy
from md-microfirmware-template and was never set.
As md-microfirmware-template's workflows:

- ARM GNU Toolchain 14.2.rel1, downloaded from ARM and cached, instead of
  Ubuntu's gcc-arm-none-eabi. A different compiler gives a different image.
- STCMD_NO_TTY=1 instead of editing -it out of stcmd. The v1.3.0 installer's
  stcmd honours it, and takes its image tag from STCMD_IMAGE_TAG, not
  VERSION.
- release and debug in a matrix, with fail-fast off. actions/checkout@v4
  and setup-python@v5.
- Builds are dated by their commit. A job summary gives the build ID, the
  ELF's size and the UF2's MD5.

The release workflow gets the same toolchain, toolkit, actions and date,
and keeps skipping the S3 upload for the dev UUID.
Up to now every build was MinSizeRel, because a Release build once broke
at runtime and no reproducer was recorded. Both presets now set CMake
Release (-O3), as md-microfirmware-template, md-drives-emulator and
md-devops ship, and a debug build differs from a release build only by
DEBUG_MODE=1, so it runs the same optimised code.

RP_CMAKE_BUILD_TYPE overrides the CMake type in rp/build.sh, with a
warning: MinSizeRel to compare against the old builds, Debug to step
through the code in a debugger. The build ID carries it (+minsizerel,
+cmakedebug).

The hot compute files keep their per-file -O3 pragma, so they stay fast in
a MinSizeRel build; their comments no longer say the global build is -Os.

Measured: Release takes about 27 KB more flash than MinSizeRel. The heap
keeps the same room in a release build and has 4 KB less in a debug build
(45.6 KB, the ROM3 ring's alignment padding), against a 13.2 KB boot peak.
Core 1's park loop still runs from RAM only, and the deepest static stack
path from main is unchanged at about 1.6 KB plus newlib's printf.
The RP can refuse to start the app. st_session_veto_boot(reason) sets a
boot status word; pre_auto reads it once per ST boot, before it starts
userfw, and on a veto prints the reason and returns to GEM, as it does in
high resolution. The reason is the app's own text (md-oric's is fixed in
the m68k code): up to 125 characters, stored byte-swapped within each
word as the cart bus reads it. The veto holds until
st_session_allow_boot(), so an ST reset retries once the cause is gone.
The status word and the message take 128 bytes after the audio buffer
($FA4500); APP_FREE moves to $FA4580.

st_session_return_to_booster() leaves for Booster without a power cycle.
Not reset_jump_to_booster(), which is only safe at the top of main(): it
saves BOOT_FEATURE = BOOSTER in the global settings, writes CMD_RESET to
the sentinel and restarts the RP, and main() then takes the jump. On
CMD_RESET userfw masks interrupts (its handlers live in the cartridge,
which stops answering once the RP restarts), copies a 28-byte stub into
the unused tail of screen page A and runs it there: a couple of seconds'
wait, so TOS scans the cartridge once Booster serves it, then a cold
reset through $4, which reinitialises everything userfw took. The demo
menu does this on B.

Cartridge code 8334 -> 8400 bytes. Verified on a Mega ST (TOS 1.04),
debug build: a veto set over SWD sent the ST back to GEM with no hello;
after an RP restart the app booted; B wrote the settings, restarted the
RP into Booster and the ST cold-booted into it; relaunching the app from
Booster worked.
Back-ports from md-microfirmware-template and md-oric
fb_publish() waits for the ST's blit acknowledgement before it overwrites
the cartridge framebuffer, and gives up after 60 ms. fbAckTimeouts counts
those: expected until the ST runs userfw, never while it does, where each
one is a frame that could have torn. A plain global, readable over SWD.
md-microfirmware-template's swd.py, cut to what this firmware has: running,
verify, build-id, read, program, reset, resume, heap, select, crash and
postmortem, all through the Debug Probe and OpenOCD, without the firmware's
help. The command channel's tools (ring, inject, key, app, screen, text,
shared) are not ported: this firmware has no command channel, terminal or
mono framebuffer.

counters reads this firmware's own: the ST's hellos, frames published,
blits acknowledged, publishes that timed out, IKBD bytes lost and ROM3
ring overruns. --watch prints what changed, with the frame and blit
rates (50 a second while the ST runs the app).

OpenOCD's work area moves to the lower 2 KB of SCRATCH_X, not all 4 KB:
that is core 1's stack, and core 1 always runs here (the c2p worker);
it uses about 100 bytes at the top.
console.py is md-microfirmware-template's: it captures the Debug Probe's
UART at 921,600 baud into tools/dev/logs/console.log with timestamps, and
reads it back (tail, since-boot, grep, wait).

flash.sh <release|debug> builds incrementally in tools/dev/builds/<type>
with the settings of the pico_w-<type> preset (never touching rp/build-*
or the submodules), keeps each ELF under its build ID, flashes with
picotool or the Debug Probe, and checks over SWD that the RP runs the
ELF, that its flash matches and that the build ID is the new one. It
warns when target/atarist/ changed after target_firmware.h was made:
the m68k image needs target/atarist/build.sh first.
After a postmortem core 0 locked up at once. Its MSP still held the stack
of the routine OpenOCD runs in its work area to probe the flash size when
GDB connects and asks for a memory map (0x20040400), while its SP was
right. Without the memory map there is no probe: GDB reads the stacks and
variables all the same, and the RP runs on after the resume (four
postmortems in a row on a release build, one on a debug build, frames
published before and after each).
fb OUT.png reads the cartridge framebuffer, undoes the reversed 48-byte
chunks the MOVEM blit needs, decodes the ST's low-resolution planes with
the published palette and writes an RGB PNG: what the ST shows. A dump
takes longer than a frame, so core 0 is stopped by a watchpoint on the
frame counter, which fb_publish() writes last: at each hit a frame is
complete, and the next cannot start until the dump is done. --pair grabs
two consecutive frames the same way, --raw also writes the planar bytes,
--now reads the window without waiting. Core 1 and the ST keep running;
core 0 stops for the dump (about 0.1 s). The session leaves core 1 out
(USE_CORE 0: rp2040.cfg makes the cores an SMP pair, and a watchpoint on
one then needs the other halted), and a second run always clears core
0's watchpoints and releases it, so a grab that found no frame cannot
leave the core to halt later.

shared prints the command sentinel (as the ST's move.l reads it), the
frame counter's low word, the shared-variable slots (named by any
*_SVAR_* define), the palette, the head of the audio buffer and the boot
status with its message. Offsets come from cart_shared.h.
audioSlicesWritten (with its rate under --watch: 50 a second while the ST
plays), audioLateSlices and audioUnderruns.
test_audio.c runs audio.c on the host against a simulated ST: at every
VBL the ST moves to the next slice and reports it, the slice writer's
timer runs five times per VBL, and the main loop tops up the FIFO once
per frame unless stalled. Every slice the ST plays is the next piece of
the stream, or the last sample held when the FIFO ran dry, and no slice
changes while it plays; a main loop missing 3 frames costs nothing, one
missing 8 holds the last sample for the difference; a writer stopped for
2 VBLs counts one late slice; an ST that leaves and boots again starts
the count over. The .YMS reader: the header checks, a failed open that
keeps the loop playing, the wrap at the end of the file to its data, a
failed read that plays one slice of silence and starts the file again.

shim/ gains pico/time.h (the test's clock and timers), hardware/sync.h,
hardware/vreg.h and FatFs's ff.h (files in memory). Slices written one
position off, a missing hold, or a wrap to the header each fail it.
The slices written ahead come from the FIFO too, so they add latency,
not tolerance: the main loop may go AUDIO_FIFO_SLICES VBLs (80 ms) between
two calls to audio_render_frame() without an underrun, and a sample
still plays at most AUDIO_FIFO_SLICES + 2 VBLs (120 ms) after the
callback made it. The commit that introduced the slices said the main
loop may stall 120 ms; the host test showed 80, as did the hardware (one
underrun at 13 frames a second, none with a 60 ms frame every second).

Also noted in the writer: the ST reports its slice modulo 4, so a writer
stalled 4 VBLs or more (80 ms with the interrupts off) counts no late
slices for it; the slices still go where they should.
The comment above TIMERB_COUNT in userfw.s states the whole rate chain
(Hz, bytes per VBL, slice size, the RP's rate, the .YMS and jingle
rate), and test_layout.py now also checks the built-in jingle's rate
against Timer-B. The comments that said the jingle was still converted
at 6,269 Hz and detuned (it is at 5,585 Hz), that the YM plays one
channel, and that the VBL handler resets A0 to the buffer's base are
corrected.

CLAUDE.md's audio pipeline describes the slices, the VBL handler, the
timer-driven writer and its FIFO, the latency and stall limits, the
underrun rule and the counters, with the before and after numbers; its
region table and ROM3 windows list $FB86xx. The README's audio section,
the app skill, the tools' docs and the changelog say the same for app
writers.
Sound never drops: audio slices written ahead of the ST
ikbd_demux.c turns the bytes the IKBD sends into key events, mouse
movement and buttons, and joystick states, with "pressed since the last
read" latches so a click shorter than a frame is never missed. Plain C,
no SDK, so the host tests build it as is.

Nothing in the stream marks where a packet ends, so a lost byte shifts
everything after it and the tail of a mouse packet decodes as keys. The
decoder knows when bytes were lost: the ACIA reported an overrun, the
RP's ring was full, the ST's byte count at a VBL is not what arrived, a
packet is still open at its second VBL, a joystick byte is invalid, or a
packet never asked for arrives. It then drops the open packet and holds
back key presses, mouse and joystick data until a VBL in which the ST
read no byte, where the stream is certainly back in step. Key releases
always pass, so a key never stays down.

$F0 / $F1 when keypad 0 / '.' is not down is the IKBD powering up again
(a keyboard plugged back in): the keys still down are released and the
restart is counted, so the input mode can be sent again.

tests/host/test_ikbd_demux.c: every packet type and loss rule, then a
simulated IKBD and ST with damage injected (ACIA overruns, ring drops,
unreported drops, double reads). With reported losses no key press ever
comes from packet data; with every kind the stream comes back in step
once the damage stops.
The app chooses an input mode (ikbd_set_input_mode): keyboard only, the
mouse, the mouse and joystick 1, or both joysticks; the keyboard works in
every one. Port 0 is the mouse or joystick 0, never both.

The IKBD's commands now come from the RP. A 16-byte block in slots 2..5
of the shared variables ($FA4018) holds a generation, a length and up to
12 command bytes; whenever the generation changes, userfw sends the bytes
one per VBL, only when the ACIA can take one, then reports the generation
at $FB87xx. The RP sets a busy bit while it rewrites the block and the ST
re-reads the generation before each byte, so it never mixes two command
strings. At boot userfw still resets the IKBD and lets it settle, but no
longer sends $12 $1A: the RP's commands for the current mode go out after
every boot.

The modes' commands (ikbd.c) avoid $1A: on a Mega ST's IKBD, once $1A had
been sent, $14 turned joystick events back on for port 0 but stick 1 stayed
silent with the mouse on. $15 (sticks report only when asked) stops the
events instead. The mouse modes set a threshold of 4 counts: every IKBD
byte costs the ST an interrupt, and a mouse moved fast at threshold 1
fills the line, which made the sound worse.

userfw reports the number of IKBD bytes it has read at every VBL ($FB83xx),
which the decoder checks and uses as its clock. The ACIA interrupt now
takes one byte and returns: the 6850 holds a single byte, so the drain
loop never found another, and the interrupt is paid for every byte.

ikbd.c feeds the decoder in order, keeps the mode asked for and the one
the ST reported, sends the mode again after an ST reset or an IKBD
restart, and gives the app ikbd_read_mouse(), ikbd_read_joystick() and
ikbd_send_commands(). Keystrokes from host tools no longer enter the
count (ikbd_inject_byte). Key releases now run to $F5: keypad Enter's
($F2) was dropped. Cartridge code: 8,584 bytes.
A fifth menu entry, written against ikbd.h only, as an app would be. F1
to F4 pick the input mode; the screen shows the mode asked for and the one
the ST reported, a pointer the mouse moves, both buttons and both sticks
(fire included), the last key, the clicks and fire presses the latches
caught, and the counters: bytes and packets decoded, ACIA overruns,
resyncs, count mismatches, guards and dropped presses. It starts in mouse
mode and puts the IKBD back in keyboard mode when left.

The menu rows are packed a little tighter to fit the fifth entry. Two host
commands for the dispatcher (debug builds): input_mode sets the input mode
in any demo, ikbd_cmd sends raw IKBD command bytes.
counters also reads the IKBD bytes, mouse and joystick packets decoded,
the decoder's resyncs and count mismatches, and the IKBD's restarts.
ikbd-log records, in debug builds, every sample the IKBD decoder sees, in
order (ikbdLog, 2,048 samples), reading the log several times a second and
saying how many samples it missed; at the end it checks the ST's byte
counts against the bytes between them, without the decoder. The IkbdLog
class lets a script record while it drives the app. key knows f1 to f10.
With FB_SLACK_REPORT = 1 in userfw.s, the ST tells the RP after every
blit how long after the VBL it ended: Timer-B counts (1.63 us each) since
the VBL, from the audio cursor's position in its slice and Timer-B's
counter, as two ROM3 reads ($FB8Bxx, $FB8Cxx), $FFFF when the next VBL
came first. Debug builds of the RP keep a histogram of the slack left
before the VBL in 50 us steps, the smallest seen and the late blits
(fbSlackHist, fbSlackMinUs, fbSlackLate in fb.c).

On a Mega ST the full-screen blit with the audio leaves about 0.27 ms
(sometimes 0.07 ms); every IKBD byte costs 30-40 us, so a mouse moved
fast makes some blits end after the next VBL. The report costs about
30 us after each blit, so it is off by default; the cartridge image is
unchanged.
CLAUDE.md: the IKBD pipeline rewritten for the RP-owned IKBD commands, the
input modes and why none uses $1A, the one-byte ACIA interrupt and the
byte-count report, the decoder's loss and power-up rules; "The ST's
budget": about 0.27 ms left after the full-screen blit and the audio, what
the mouse costs, FB_SLACK_REPORT; the new ROM3 windows, the shared slots,
the tests, the tools and the modules.

README.md: section 6, keyboard, mouse and joysticks (the modes, an
example, what the hardware decides, the cost to the ST); the later
sections renumbered. The framebuffer-app skill, tools/dev/README.md (the
counters, ikbd-log, input_mode, ikbd_cmd, f1-f10) and the changelog.
examples/hello_text: apply.sh also removes demo_input.c.
On an STE or a Mega STE the sound goes through the DMA sound chip: 8-bit
samples at 12,517 Hz, mono, no interrupt per sample. The chip reads ST RAM
only, so userfw loops it over a 2 KB ring just below screen page A
($6F800) and fills the ring every VBL, right after the wake: it reads
where the chip plays (the frame counter's middle and low bytes, the
middle one read again as it moves), reports it by 8 bytes at $FB8Dxx, and
copies the cart buffer, the ring's mirror, from where its last copy ended
up to 768 bytes (3 VBLs) ahead of the chip, so a missed VBL still finds
samples. About 0.35 ms at 8 MHz; Timer-B stays off on this path and its
1.6 ms per VBL are free. At boot userfw sets the Microwire as TOS does
(0 dB, tone flat, the YM mixed in); it stops the chip on the way out to GEM
and before a cold reset.

The ST takes the DMA path when the hello's machine family is the STE's
and the RP has not asked for the YM (a word in slot 6 of the shared
variables); audio.c applies the same rule at each hello. The audio buffer
grows to 2 KB, the mirror; on the YM path its first 1 KB holds the slices
as before. APP_FREE moves to $FA4980.

audio.c's writer keeps the mirror written 928 bytes ahead of where the
chip plays now (its last report plus the samples played since: a missed
VBL sends no report), never where the ST copies; late writes and
underruns are counted, and an empty FIFO holds the last sample. The FIFO
holds a VBL more than AUDIO_FIFO_SLICES so the main loop may still go
80 ms between top-ups.

Apps give YM pairs as before or signed 8-bit PCM at any rate
(audio_play_pcm_loop, audio_set_pcm_callback); either plays on both
outputs, converted at runtime through the 64-entry table both ways (a pair
outside it by the YM's volume curve) with linear resampling. A YM source
on the YM path goes through byte for byte as before. audio_prefer_ym()
keeps an STE on the YM from its next boot; audio_uses_dma() and the
audioOutput counter say which output plays.

tests/host/test_audio_dma.c runs the output against a simulated STE:
steady play, main-loop stalls within and beyond the tolerance, a missed
VBL, an ST reset, YM pairs on the DMA chip, PCM on the YM, the output
switch. Every sample played is the source in order, the held last sample,
or silence before the start. Cartridge code: 8,928 bytes.
swd.py app audio_out 0|1 picks, from the ST's next boot, the DMA chip
where there is one or the YM, so both outputs play on one STE with a
reset in between. tone HZ plays a sine through the PCM path, whose clicks
are easy to hear; tone 0 goes back to DEMO.YMS. swd.py counters shows the
audio output.
--mode pcm writes a C header of signed 8-bit samples at the target rate
for audio_play_pcm_loop() (12,517 Hz plays unchanged on the DMA chip; the
RP converts it for the YM). The tests also check that audio.c's copies of
the two-channel table and the YM volume curve match the tool's.
CLAUDE.md: the audio pipeline for two outputs (the rule both sides apply,
the ring, the per-VBL copy, the writer ahead of the chip, the conversions,
latency and tuning), the Atari RAM the DMA path uses, the 2 KB audio
buffer and the layout after it, $FB8Dxx, and the ST's budget on the DMA
path. README's audio section (PCM, the YM preference), the framebuffer-app
skill, tools/dev/README.md and the changelog.
STE DMA sound: 8-bit PCM through the DMA chip, the YM elsewhere
userfw asks TOS for a blitter at boot (XBIOS Blitmode) and reports it
after the hello ($FB8Exx, st_session_features()). The blitter copies the
frame where the MOVEM loop would: a 48-byte chunk per line going forward
in the cart framebuffer, the destination going back a chunk per line
(Y increment -94) from the page's last chunk, then the tail. It owns the
bus 40 chunks at a time (about 1 ms, under an IKBD byte's 1.28 ms), and
the interrupts run between pieces.

A word in shared slot 7, read every VBL, picks the copy
(fb_set_copy_mode()): auto takes the blitter when the sound goes through
the DMA chip and the CPU on the YM path, where the blitter's pieces delay
Timer-B's samples. On a Mega ST even 4-chunk pieces made a test tone
audibly rougher for 0.15 ms gained, and sharing the bus took longer than
a VBL. On a Mega STE the blit now ends 3.3 ms before the next VBL against
1.9 ms with MOVEM, with the sound unchanged and 50 fps with the mouse
moved flat out. The RP can force the CPU or the blitter.

FB_SLACK_REPORT measures with MFP Timer-A, restarted at each VBL without
its interrupt, so it works on both sound paths; the ST reports one byte
at $FB8Bxx. The host test models the blitter's copy, in one run and in
pieces; test_layout.py checks the new shared constants. Debug builds
take copy_mode from the host; swd.py counters shows the ST's features.
What the blitter does and where it copies by default, the measurements
behind that choice on a Mega ST and a Mega STE, fb_set_copy_mode(),
shared slot 7, the $FB8Bxx and $FB8Exx windows, the Timer-A slack report,
and the copy_mode debug command.
Blitter screen copy where the DMA chip plays the sound
Converts a PNG saved with a palette of at most 16 colours into a C header
for fb_blit() / fb_blit_key(): one FB_BITMAP, or with --tile an array of
them cut from a sheet (tiles, animation frames), the palette as ST colour
words for palette_set(), and the fully transparent entry as NAME_KEY. A
pixel's palette index is its colour, so an app's images share the palette
it sets.

Plain Python: it reads indexed PNGs itself (zlib, every filter, 1 to 8
bits per pixel) and refuses what the ST cannot show (a truecolour image,
a colour past 15, more than one transparent entry, half transparency).
The host test writes PNGs with every filter and depth, checks the palette
and the key, and compiles the headers with fb_blit.h to read every pixel
back through FB_BITMAP.
Two small games on the public APIs only, the reference for starting a
game. Arena: steer with joystick 1 or the cursor keys, fire shoots the
way you last moved; grab gems, shoot enemies, three lives. Zap: enemies
and gems wander the arena, the left button zaps an enemy under the
crosshair and the right one takes a gem, sixty seconds a round; the HUD
lights L and R while the buttons are held.

They share game_kit.c: an arena of 16x16 tiles under a HUD line, sprites
drawn over it and restored under the next frame (a few tile blits per
sprite, not the whole arena), labels, and a tune and sound effects
computed as they play, as 8-bit PCM on both outputs. The art is indexed
PNGs in rp/src/assets on one palette, converted by png_to_bitmap.py.
Debug builds of Arena take keys that vary the scene (enemy count and
size, freeze, full redraws).

They are entries 6 and 7 of the demo menu (demo_games.c): ESC goes back
to the menu, which gets its keyboard mode and its music back. The menu's
music moves from emul.c into the dispatcher (demo_menu_music()) for that,
and the menu panel is centred. examples/mini_game builds a firmware that
boots straight into Arena (Zap with three calls changed); hello_text's
apply.sh also removes the new demo_games.c.
README "Starting a game" and the example's README walk through what a
game needs: an input mode, keys held down, the joystick's and the mouse's
press latches, drawing only what moved, art from indexed PNGs, and sound
computed as it plays. CLAUDE.md, the skill, the dev tools README (menu
entries 6 and 7, Arena's knobs from swd.py key) and the changelog follow.
Two games for the joystick and the mouse, and png_to_bitmap.py
The app chooses at compile time how the ST's time is shared between the
frame copy and the sound (APP_PROFILE, rp/src/include/profile.h):
PROFILE_50FPS, the default, keeps a frame every VBL with the YM at
5,585 Hz and the DMA chip at 12,517 Hz; PROFILE_25FPS takes a frame every
second VBL, on every machine, and plays the YM at 21,943 Hz (Timer-B count
28) and the DMA chip at 25,033 Hz. On a plain ST the full-screen copy and
22 kHz sound then take about 35 ms of each 40 ms frame.

The ST image is the same for both: fb_init() writes the profile into
shared slot 8, and userfw reads it once at boot for Timer-B's count, the
DMA chip's mode and lead, and the VBLs a frame (it takes a new frame no
sooner than that many VBLs after the last, counted by its VBL handler).
The constants of both profiles are named on both sides and checked by
test_layout.py; APP_PROFILE comes from the environment through CMake, and
a 25 fps build's ID ends in +25fps (flash.sh builds it in its own folder).

The audio buffer grows to 4 KB (YM slices of 1 KB: 878 bytes a VBL at
21,943 Hz), the DMA ring to 4 KB at $6F000 with its position reported by
16 bytes; APP_FREE starts at $FA5180. YM sources (.YMS files, the jingle)
stay at 5,585 Hz and are resampled to the output's rate. The two outputs'
FIFOs share their memory, which keeps the 25 fps debug build within the
RP's RAM. test_audio_dma.c runs under both profiles.

The ST's stopwatch replaces the slack report, which used the same Timer-A:
TIME_STUDY = 1 in userfw.s (off by default) runs Timer-A free at 4.07 us
a tick, counts its wraps with an interrupt, and reports the VBL and four
points of the loop on one timeline ($FB8B/8C/8F). Debug builds keep the
points and the slack before the VBL that may start the next frame;
tools/dev/swd.py stopwatch reads them.

Measured: Mega ST at 25 fps, the copy with 22 kHz sound takes 29.9 ms and
leaves 9.75 ms before the next frame (8.9 with the mouse moved flat out,
no IKBD error); Mega STE at 25 fps plays the DMA chip at 25 kHz. At
50 fps both machines behave as before.
What each profile trades, how to choose it (APP_PROFILE) and what was
measured, in CLAUDE.md, the README (with the audio section's rates and the
FIFO), the skill and the changelog; the moved audio buffer, DMA ring and
APP_FREE; the stopwatch in place of the slack report, and swd.py
stopwatch in the dev tools' README.
Two profiles: 50 fps, or 25 fps with 22 kHz and 25 kHz sound
@diegoparrilla
diegoparrilla merged commit 1690de0 into main Sep 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant