Reading a Real Cartridge Through a Fake CH340

The plan is simple to say: insert a Game Boy cartridge into a reader, plug the reader into the console's USB host port, wait a few seconds for the import, play. The reader is a GeekSimon GBFlash v1.3, a cartridge reader/writer that normally talks to FlashGBX on a PC. The console end is CN13, the same OTG port that already hosts the games-store drive and the PICO-8 devkit mouse. Today the firmware got as far as powering the physical cartridge and validating its header, which leaves the remaining work as a bounded dump over a proven transport.

Getting there took a full day, and most of it went to things that had nothing to do with the GBFlash.

First, the console had to exist again

The bench started the morning with a boot-looping console and a black screen. Three separate faults, none related.

The boot loop came from the tree itself. Recent work on other runtimes had left main in a state where the two PICO-8 images did not compile at all: eleven items of persona and runtime machinery were defined for every usb-storage image while only being constructed in the images that use them, and the firmware builds with deny(warnings), so dead code is a hard error. Worse, once the gates were fixed, the PICO-8 storage image no longer linked: its .bss wants 623 KiB of AXI SRAM and the region has 512 KiB, because the 249 KiB Lua heap now sits beside a 95 KiB drive catalogue that grew while nobody could link this combination to notice. That diet is still owed. The plain PICO-8 image and the Game Boy images fit fine.

The black screen was the HDMI coprocessor. I wrote a whole post about proving that receiver innocent once; today it was guilty, though with an alibi. Its telemetry told the story in one line: 997,093 bytes per second arriving on the quad link, zero records accepted, and a resync counter at 49,659,274 and climbing. The Feather was still running the GBA persona receiver from an earlier bench session, and that image deliberately speaks a different framing and can present nothing else. Flashing the universal receiver over picotool fixed it without touching the H7: sixty records accepted, sixty presented, within the first reported second.

The third fault was the USB hub the ST-LINK shares with the ethernet dongle. It killed one firmware flash mid-erase, took the probe's endpoints down twice more, and had eaten the DHCP path so thoroughly that bootpd had never spawned. Every symptom matched the documented incidents from July; the fix, as documented, was a replug. The board's ethernet came good on its own the moment the path healed.

G0: the capture

With a stable Game Boy image on the console, the GBFlash went into CN13 behind an OTG adapter, cartridge already seated. The host engine enumerated it 118 milliseconds after boot and logged everything on the first try:

usbh: attach detected (full speed path), enumerating
usbh: dev 1a86:7523 rev 03.04 usb 1.10 ep0 mps=8
usbh: mfr=- prod=- ser=-
usbh: unhandled config, 39 bytes: [09, 02, 27, 00, 01, 01, 00, 80, f0,
  09, 04, 00, 00, 03, ff, 01, 02, 00, 07, 05, 82, 02, 20, 00, 00,
  07, 05, 02, 02, 20, 00, 00, 07, 05, 81, 03, 08, 00, 01]

That is a textbook CH340 serial bridge: WCH's vendor/product id, a vendor-specific interface, two 32-byte bulk endpoints, an 8-byte interrupt endpoint, and no string descriptors at all. The GBFlash's documentation says it simulates a CH340 and speaks the GBxCart protocol over it, and the descriptor capture confirmed the shape exactly. Firmware L14, per the unit's own info block later, build time 2025-05-23T19:18:04+10:00.

One tell that a microcontroller is doing the simulating: the CH340 version query returned a different byte on every attach. I measured 0x30, 0x9f, 0xff and 0xdf across four sessions. Real silicon reports a constant. The driver now logs the byte and gates nothing on it.

G1: three wrong theories, then the right one

The first driver build brought the serial line up at 1,000,000 baud 8N1 and asked for the device's identity the way FlashGBX's GBxCart backend does: two legacy one-byte version queries before anything else. The unit answered both with the byte 2. The pair (2, 2) is, in the GBxCart backend's own logic, the signature of foreign hardware. A cross-check against FlashGBX's source explained it: there is a separate GBFlash backend, and it never sends the legacy queries at all. It opens with the firmware-info command, 0xA1, and nothing before it. The unit answers junk to commands it does not implement. Theory one gone.

Theory two blamed the CH340 line setup. The first build used the combined init transfer that TinyUSB's host driver sends; I rewrote it as the Linux driver lineage's sequence (a bare init, then separate register writes for the baud divisor and line control, then the modem lines). Same silence.

Theory three blamed the modem-control encoding, which genuinely does have two conventions in the wild (an inverted mask versus direct bits), and a device that gates its command processor on DTR would stay mute if the host got it backwards. I built a fallback that tries both. It was never needed.

What actually fixed it was none of the protocol theories. I had instrumented the pipe errors with their direction, and the log said the command byte was accepted while the reply never arrived: "no response bytes". The reply was arriving. The driver could not receive it. The unit coalesces its whole identity answer into full USB packets, and the driver was requesting bulk IN transfers one byte at a time, sized to the field it wanted next. A host request smaller than the packet the device sends can never complete. Every read now goes through a reassembly buffer that requests into its own 128-byte space and hands bytes out as asked:

/// Fill `out` completely, pulling more packets as needed.
async fn take<P: MscPipes>(&mut self, pipes: &mut P, out: &mut [u8],
                           budget_ms: u32) -> Result<(), GbfError> {
    for slot in out.iter_mut() {
        if self.used == self.have {
            self.used = 0;
            self.have = 0;
            while self.have == 0 {
                self.have = pipes.bulk_in(&mut self.buf, budget_ms)
                    .await.map_err(GbfError::PipeIn)?;
            }
        }
        *slot = self.buf[self.used];
        self.used += 1;
    }
    Ok(())
}

With that in place the identity arrived 107 milliseconds after line-up, first attempt:

usbh: gbflash: identity GBFlash, cfw L14, pcb v13, built 1747991884,
  features 01, bootloader-reset true, unregistered false

That matches what FlashGBX reported on the Mac down to the build timestamp. Whether the Linux-style line setup was also necessary is unknown; it landed one flash before the reassembly buffer and I kept it. The firmware re-reads the identity every two seconds as a liveness probe, and it has matched byte for byte on every probe since.

The identity exchange: one command byte out, a coalesced stream back

G2: the cartridge answers

The header probe is FlashGBX's own sequence, reduced to its read-only core: select DMG mode, 5 volts, set the read-method and cart-mode variables, power the cartridge only if the firmware reports it unpowered, reset the memory bank controller, then one bounded read of the first 0x180 bytes from address zero. The cartridge is powered off again in every exit path. Each exchange is acknowledged by the unit with a status byte, which makes the whole sequence checkable step by step.

The first run failed, and kept failing at the same place. I gave the probe a stage counter so the log names the failing exchange, and it answered: stage 13 of 14, the read itself, with a hard USB transfer error (a different failure class from G1's silence), after every prior stage acknowledged cleanly. The identity reads never failed this way. The difference between them: identity replies are short bursts, and the header read is the first time the unit streams hundreds of bytes flat out.

The explanation that fits every observation is packet size. The descriptor declares 32-byte bulk endpoints, and short traffic honours that. Under a long stream the simulator fills bigger packets, and a full-speed host channel opened at 32 bytes rejects the first oversized packet as a protocol violation. I opened the IN pipe with a 64-byte ceiling, which accepts both sizes, and the failure vanished permanently. I say "explanation that fits" deliberately: there was no USB analyser on the wire, so the oversized packet was never photographed. The fix is proven functionally; the mechanism is inferred.

What the descriptor promises versus what the stream does

Two hardware lessons rode along, each costing a bench cycle. A read that fails mid-stream leaves the unit still trying to transmit, and that jam survives a full H7 reflash, because CN13's VBUS never drops across an H7 reset; the only recovery is pulling the cable. And reseating the cartridge while the unit is powered wedges its controller outright, which the GBFlash's own bring-up notes warn about. The driver now drains the pipe generously after any failed probe, and with the packet fix in place the jams have not recurred.

With all of that settled, the cartridge itself finally spoke:

usbh: gbflash: cart header: title NETTOU TOSHINDEN, type 01,
  rom-code 04, ram-code 00
usbh: gbflash: header VALID (logo + checksum), declared rom 524288 bytes

Three independent reads across cable cycles, byte-for-byte identical. The cartridge is a glob-top 22-in-1 multicart whose internal title is NETTOU TOSHINDEN; the header declares MBC1, 512 KiB of ROM and no save memory, and both the Nintendo logo and the header checksum validate. The existing Game Boy runtime's header validator did the judging; on a header-only window its whole-ROM length check is the expected stopping point, and reaching it means everything the window can prove has passed.

Where this stands

The transport is done and committed: descriptor match, line bring-up, identity, liveness, and a validated read of real cartridge bytes through the console's own USB host port, on a port that already knows how to be a games drive when a drive is plugged in instead. Everything so far is read-only by construction; the driver exposes no write primitive, and the only bus-control operations are the mode, power and bank-reset commands the read path needs.

Next is the full dump: bank-aware sequential reads of all 512 KiB with a CRC32 computed while streaming into the flash installer, so the image never needs to fit in RAM. The cartridge was preserved on the Mac before any of this started, and both of those dumps agreed on CRC32 e402d204, so the acceptance test writes itself: two independent console dumps must produce that number, the flash readback must produce it again, and only then does the launch path get to run. After that, the last step is the one the console already knows how to do, because a dumped cartridge and a game copied off the drive land in the same place and launch through the same arm.