One ROM, Three Chips, and the Missing Cable
Flashing a Rusty Nail cartridge had reached the awkward middle age of a hardware project. It worked, but only if I remembered which pieces of the machine the command did not touch.
A Master System ROM needs the Master System build on the STM32H753 and the matching exact-2x display receiver on the RP2040. A Mega Drive ROM needs a different H7 image and a different double-buffered receiver. Game Boy and PICO-8 share the standard receiver but not necessarily the same H7 runtime. The generic cartridge script could package a ROM perfectly, write it to the W25Q128, verify it, and still leave the console running yesterday's diagnostic payload.
That was the source of the recurring hi, robot... screen. The cartridge had
changed. The executable machine around it had not.
I wanted the operation to start with one ordinary ROM or ZIP and end with one compatible console, without requiring a handwritten matrix beside the terminal. That turned into a desktop application, a shared transactional provisioner, a new abort command in the wire protocol, a Mega Drive linker failure, a small campaign against C warnings, and finally a USB cable that was not there.

The ROM chooses the machine
The application is written in Rust with Iced. This first release is deliberately a developer build: it uses an existing Rusty Nail source checkout and the toolchains already configured there. A packaged release can later replace source builds with signed firmware artifacts without changing the deployment state machine.
The first useful thing it does is refuse to trust a filename alone. It accepts a
direct ROM or bounded ZIP, including one nested ZIP level for preservation sets,
bounds the input at 16 MiB, rejects SMD-interleaved Mega Drive images, normalises
a cartridge title to the RNCT 32-byte limit, and uses the ROM contents to select
a runtime pair. A nested set with one untagged canonical dump among [b] and
translation variants is resolved automatically; two clean candidates are still
an error rather than a guess.
| Detected content | H7 image | RP2040 receiver |
|---|---|---|
| PICO-8 / Nail app | PICO-8 shell | Standard 3x |
| Game Boy / Game Boy Color | Game Boy | Standard 3x |
| Game Gear | Game Gear | Standard 3x |
| Master System | Master System | Master System exact-2x |
| Mega Drive | Mega Drive | Mega Drive double-buffered |
| ABI diagnostic | Diagnostic loader | Standard 3x |
That matrix is code, not a label painted over a dropdown:
pub const fn runtime_pair(runtime: RuntimeId) -> RuntimePair {
match runtime {
RuntimeId::Pico8 | RuntimeId::FcVm => pair(Pico8, Standard),
RuntimeId::GameBoy | RuntimeId::GameBoyColor => pair(GameBoy, Standard),
RuntimeId::GameGear => pair(GameGear, Standard),
RuntimeId::MasterSystem => pair(MasterSystem, MasterSystemReceiver),
RuntimeId::MegaDrive => pair(MegaDrive, MegaDriveReceiver),
RuntimeId::Diagnostic => pair(Diagnostic, Standard),
}
}
The real implementation uses explicit structs rather than that shortened helper,
but the mapping is the same. It also finally prints Mega Drive (md; id 8) and
Master System (sms) in the command-line tools instead of asking a human to
remember what runtime 8 means.
This pairing exists because Rusty Nail is physically three programmable machines. The H7 runs the launcher and emulator. The Feather RP2040 DVI turns the H7's compact SPI video stream into HDMI. The removable cartridge holds the game, metadata, saves and crash journal in an RNCT v3 image. The previous Mega Drive bring-up post explains why its native frame path cannot share the standard receiver image.
One operation, three programming paths
Pressing the main button starts a typed worker state machine. The UI thread never touches a probe, serial port or C compiler. The worker:
- Builds and validates the RNCT v3 cartridge image.
- Builds the selected PicoDVI receiver with CMake.
- Builds the selected H7 release with Cargo and the existing firmware settings.
- Waits for the RP2040
RPI-RP2BOOTSEL volume and copies the UF2. - Downloads, verifies and resets the H7 through ST-LINK.
- Waits for the H7's Rusty Nail USB-CDC port to return.
- Gives cartridge polling and debounce a visible warm-up interval.
- Streams the cartridge and verifies the logical ROM CRC reported by the device.
BOOTSEL discovery covers /Volumes/RPI-RP2 on macOS, removable drives carrying
INFO_UF2.TXT on Windows, and the usual /media and /run/media mount points on
Linux. Only the macOS path has touched this hardware so far. Implemented is not
the same word as validated.
The builds happen before either processor is changed. That matters because a compiler failure should leave a working console alone. The cartridge header is also written last, after immutable data, partition checks, mutable partition erase and read-back verification. A failed installation therefore leaves a non-bootable partial cart rather than a valid-looking header pointing at half a game.
What happened at 43 per cent
The first ugly failure was refreshingly reproducible: interrupt a cartridge flash around 43 per cent, immediately start it again, and receive:
error: begin refused: device code 7, progress 0%, detail 0x00000000
Rebooting fixed it, which is another way of saying the protocol had left state behind and made the operator clean it up.
The cartridge engine has one owner. If a host process vanishes without finishing its stream, firmware quite reasonably keeps that owner until its 30-second receive timeout expires. The old client translated the resulting BUSY status as an unexplained 7 and stopped.
I pulled the physical provisioner out of the command-line program and made it a shared library used by both CLI and GUI. Its public progress vocabulary is small and typed:
pub enum FlashEvent {
Connecting { port: String },
RecoveringBusy { elapsed_seconds: u64 },
WaitingForCartridge { elapsed_seconds: u64 },
Ready { total: usize },
Sending { sent: usize, total: usize },
Verifying,
Complete { rom_crc32: u32 },
}
Cooperative cancellation now sends a new CartFlashAbort opcode, 0x94. The H7
checks the transfer ID and wakes the sole cartridge task with StreamMsg::Abort,
so a normal Cancel releases ownership immediately.
A killed process cannot send that message, so a fresh client also retries Begin
for up to 40 seconds while the firmware's bounded stale-owner timeout runs out.
Code 7 is rendered as another cartridge transfer is still winding down, with
elapsed recovery visible in the UI. Resetting the console is no longer part of
the flashing procedure.
None of this weakens the commit-last rule. Abort recovery is about making the writer reusable. It does not make a partial image runnable.
The progress bar that went backwards
The application's first screenshots exposed a less dangerous class of lie. At a short window height, the lower edge of the form clipped the labels off two buttons. All that remained was a cyan rectangle, then a red rectangle when a job started. The controls technically existed. The layout had hidden the only part that explained them.
The wide layout now pins destructive confirmation and the primary action below a scrolling setup panel. Below 860 pixels wide, everything becomes one scrollable column. The minimum useful window is 560x480. Both 1120x780 and 700x600 got visual passes because a responsive breakpoint that merely compiles is not a responsive UI.
Activity and Technical log were separated as well. Activity is the short stage journal. Technical log retains raw Cargo, CMake, probe and serial output. Either view is copyable, which turned out to matter as soon as a linker error occupied more lines than the panel.
Then I added an indeterminate progress bar. It filled from left to right, bounced back, and looked exactly like the installation had lost work. That bar was deleted. Long stages now show a spinner, elapsed time, and a five-step prepare/build/install/program/verify journey. The only bar with a percentage is the cartridge byte transfer, because it is the only stage with honest byte progress, and it only moves forwards.
The allocator hiding in Mega Drive
The first complete Mega Drive run did not reach hardware. Cargo ended with exit status 101, but the useful line several screens earlier was:
AXI SRAM overflow: less than 32 KiB remains for the descending main stack
The GUI had inherited the common PICO-8 feature list, including tlsf. Mega
Drive is a native runtime and does not use the Lua allocator, but enabling the
feature still reserved its AXI heap. The retained crash block began at
0x24078000; after its 4 KiB region, only 28 KiB remained below the top of AXI
SRAM. The linker assertion was doing exactly the job it was added to do.
Feature construction now starts with a TLSF-free native base and adds TLSF only for PICO-8:
const BASE_FEATURES: &str =
"standalone-panel,esp-net,picodvi,cart,fps-overlay,clock-480,crash,ethernet";
const PICO8_FEATURES: &str =
"standalone-panel,esp-net,picodvi,cart,fps-overlay,clock-480,crash,ethernet,tlsf";
Removing the inactive allocator moved the crash region to 0x2406F000 and left
a 64 KiB main-stack gap. The same profile now lives in the older deployment
script so the GUI and terminal cannot silently produce different machines. A
test requires the Mega Drive profile to include megadrive and omit tlsf,
while PICO-8 must still include it.
The UI now keeps the tail of every child process and promotes the most specific
diagnostic it can find. Cargo failed with status 101 is a fact, but it is not a
useful explanation.
Making the warning wall illegal
The corrected build linked, then filled Technical log with warnings from the
vendored gwenesis C core. They were familiar warnings: signed and unsigned SAT
address comparisons, unused callback parameters, and an int loop walking a
sizeof result. Familiar is not the same as harmless. A flashing tool should not
train its operator to ignore pages of amber text before the one line that
matters.
I fixed every warning at source. The YM2612 register loop uses size_t.
Intentional Z80, IRQ and Musashi callback arguments are explicitly consumed.
VDP sprite-table comparisons now stay in one unsigned domain. Enabling errors on
warnings found two more host-only Clang problems: a local M_PI redefinition on
macOS and a GCC-specific optimisation attribute. The core now uses a local pi
constant and applies that attribute only under the ARM GCC build.
The build script ends the argument:
build.warnings(true).warnings_into_errors(true);
The exact Cortex-M7 Mega Drive release now builds under ARM GCC with zero emitted warnings. All nine host core tests also compile and pass under warnings-as-errors.
The cable at the end of the state machine
With the software clean, I gave the GUI a 512 KiB Mega Drive ZIP. It detected the system, built the dedicated receiver, waited for BOOTSEL, copied the UF2, built the corrected H7 image, programmed and verified it through ST-LINK in about 29 seconds, and reset the board.
Then it waited for Rusty Nail USB.
And waited.
The NUCLEO-H753ZI
has two USB jobs in this process. CN1 is the ST-LINK connection used to program
and reset the H7. CN13 is the H7 USER-USB connection that exposes Rusty Nail CDC
and carries the cartridge stream. macOS could see ST-LINK and an unrelated
serial board. It could not see Rusty Nail CDC, because the second data path was
not connected or was not enumerating.

The first UI called that stage Waiting for USB-CDC after reset and sat there for
40 seconds. Accurate, technically. Useless, practically.
It now names CN13, counts the wait, explains that CN1 is a separate cable, lists the serial ports actually visible to the host, clears obsolete BOOTSEL prompts, and ends with an actionable connector error. The H7 and RP2040 images remain installed, so once CN13 appears the cartridge-only path can finish without reflashing either processor.
That was the honest boundary on 20 July. The cable-shaped gap did not become a software success because everything before it looked good. It also was not the last bug.
The bugs on the other side of the cable
Once CN13 was connected, the first native image reset after mounting the game index and never brought up USB. Its log said the previous session had crashed 570 times. Native builds declare a zero-byte AXI heap placeholder, but boot was still passing that region to the allocator. Both LLFF and TLSF require a real region. Native firmware now skips AXI heap initialisation entirely; it has no Lua heap to initialise.
The first desktop build had also been launched as a bare executable. macOS
displayed a generic process icon, had no stable Files & Folders identity for it,
and repeatedly denied the RP2040 volume. The flasher is now a real
Rusty Nail Flasher.app with an asset-catalog icon, usage descriptions, bundle
ID com.gotnull.rusty-nail-flasher, and an Apple Development signature. The
packager chooses a certificate by its unique SHA-1 fingerprint because this Mac
has two Apple Distribution certificates with the same human-readable name.
That exposed two more macOS-specific details. Finder applications do not inherit
the terminal's Homebrew and Cargo path, so the app resolves CMake and the Arm
toolchain from CMakeCache.txt and searches the Cargo, Homebrew, user-app and
system locations for the rest. And RPI-RP2 is not a normal USB stick. Raspberry
Pi recommends /bin/cp -X on macOS so extended attributes and resource forks do
not reach the bootloader's virtual FAT filesystem. The app now uses that path,
requires a readable stable INFO_UF2.TXT, and accepts the expected copy error
only when the RP2040 simultaneously reboots and removes the volume.
The last race was subtler. Rusty Nail CDC can enumerate before the firmware's
one-second cartridge poll, two-sample dock debounce and cartridge classification
have completed. The GUI saw USB, immediately sent CartFlashBegin, and received
the completely truthful NO_CARTRIDGE. There is now a visible three-second
console warm-up followed by a bounded 20-second retry for that one boot-time
status. Firmware still grants READY; the host merely stops confusing USB
presence with cartridge readiness.
With those fixes, the complete Game Boy Color path passed on the physical console: ROM selection, standard receiver build and BOOTSEL flash, matching H7 build and verified ST-LINK download, H7 reset, CDC return, cartridge warm-up, RNCT v3 stream, device read-back and exact ROM CRC completion. The final leg is no longer open.
What is now solid
The supporting test totals are 50 in the shared protocol crate, eight in the cartridge tool, eleven in the desktop flasher, and nine in the Mega Drive core. All six advertised H7 release profiles build. The Iced release app builds and the installed macOS bundle satisfies its designated signing requirement. The exact Mega Drive image retains its 64 KiB AXI stack gap, and the C core is warning-clean under both host Clang and the target ARM compiler.
Interrupted cartridge writers recover without rebooting, boot-time cartridge detection has a bounded grace period, and RNCT's header-last transaction still prevents partial cartridges from booting. Nested preservation ZIPs, signed-app permissions, Finder's stripped path, RP2040 virtual-volume semantics, H7 USB return and cartridge readiness are now named parts of the operation rather than bench folklore.
Windows and Linux device discovery are implemented but have not been through the same physical pass as macOS. This developer release also builds locally rather than downloading signed images. Those remain explicit next steps.
The broader cartridge story started with a flash chip in a Game Boy shell, gained nonvolatile crash records in the console that files its own bug reports, and now has a host tool that understands the whole machine around the cart.
The useful change is not merely that Rusty Nail has a GUI. It is that the GUI knows which three things constitute a playable cartridge, can unwind an interrupted write, survives the host operating system's peculiarities, and does not declare success until the cartridge returns the exact bytes expected.