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 Rusty Nail Console Flasher with a Mega Drive ROM detected and the matching H7 and RP2040 images selected

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 contentH7 imageRP2040 receiver
PICO-8 / Nail appPICO-8 shellStandard 3x
Game Boy / Game Boy ColorGame BoyStandard 3x
Game GearGame GearStandard 3x
Master SystemMaster SystemMaster System exact-2x
Mega DriveMega DriveMega Drive double-buffered
ABI diagnosticDiagnostic loaderStandard 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 ROM is packaged for the cartridge while selecting a matching H7 runtime and RP2040 display receiver

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:

  1. Builds and validates the RNCT v3 cartridge image.
  2. Builds the selected PicoDVI receiver with CMake.
  3. Builds the selected H7 release with Cargo and the existing firmware settings.
  4. Waits for the RP2040 RPI-RP2 BOOTSEL volume and copies the UF2.
  5. Downloads, verifies and resets the H7 through ST-LINK.
  6. Waits for the H7's Rusty Nail USB-CDC port to return.
  7. Gives cartridge polling and debounce a visible warm-up interval.
  8. 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 flasher stops at the real hardware boundary: the H7 and receiver are installed, but Rusty Nail USB is absent

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.