Repository navigation
v1.1.0 - #14
Merged
Merged
v1.1.0#14
Conversation
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.
Mouse and joysticks, decoded on the RP
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
CHANGELOG: date v1.1.0
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release v1.1.0 into main. What it brings, from the changelog:
Sound
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.
audio_render_frame(); a samplereaches 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 stalesound, and
audioUnderrunscounts it.Two profiles: 50 fps, or 25 fps with rich sound
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_25FPSwhenbuilding (
rp/src/include/profile.h). Story games with rich sound get the second; the STkeeps 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'srate, and
.YMSfiles and the built-in jingle keep their 5,585 Hz (the RP resamples them).(
$6F000):APP_FREEstarts at$FA5180(about 12.4 KB).TIME_STUDYinuserfw.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 stopwatchprints them.STE DMA sound
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.
audio_play_pcm_loop(),audio_set_pcm_callback()) or YMpairs as before; either plays on both outputs, converted on the RP.
audio_prefer_ym()keepsan STE on the YM;
tools/wav_to_ym4.py --mode pcmwrites PCM headers.Games
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_gamebuilds a firmware that bootsstraight into one. They draw again only what moved, and compute their tune and sound
effects as they play.
tools/png_to_bitmap.pyturns an indexed PNG (16 colours, one of them transparent) intoFB_BITMAPs forfb_blit()/fb_blit_key(), a whole image or cut into tiles, with itspalette for
palette_set(). Plain Python, no Pillow.Blitter
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; theblitter on a Mega ST gives an app without sound about 3 ms more.
st_session_features()).Mouse and joysticks
input mode with
ikbd_set_input_mode()(keyboard only, the default; mouse; mouse andjoystick 1; two joysticks) and read
ikbd_read_mouse()/ikbd_read_joystick()once aframe. 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.
cartridge window (
ikbd_send_commands()for other settings). At boot the ST no longerswitches the mouse and joysticks off itself.
every frame) and never turn mouse data into keys.
counters of the input path.
moved fast makes the frame rate dip to 30-40 a second; the sound stays clean.
The ST's stopwatch (
TIME_STUDYinuserfw.s) measures how much of each frame the ST has left.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), andswd.py ikbd-logrecords the keyboard's bytestream.
tools/dev/README.mddescribes them.make -C tests/host testchecks the pure logic on the host, and CI runs it onevery 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.
inc/sidecart_layout.s, whichmain.sanduserfw.sshare;userfw.sno longer repeats its addresses.