Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ keeper of knowledge. Seemed fitting for a preservation tool.

| Device | Connection | Systems |
| --- | --- | --- |
| [ClusterM Famicom Dumper/Writer](https://github.com/ClusterM/famicom-dumper-writer) | Web Serial | NES / Famicom |
| [GBxCart RW](https://www.gbxcart.com/) v1.4 Pro | Web Serial | Game Boy, Game Boy Color, Game Boy Advance |
| [INL Retro Programmer](https://www.infiniteneslives.com/inlretro.php) | WebUSB | NES / Famicom |
| PowerSaves for Amiibo | WebHID | Amiibo (NTAG215) |
Expand All @@ -28,6 +29,11 @@ keeper of knowledge. Seemed fitting for a preservation tool.
| PS3 Memory Card Adaptor | WebUSB | PS1 Memory Card |
| Neoflash SMS4 | WebUSB | DS cartridge saves |

The Famicom Dumper/Writer is an open hardware design, so compatible
third-party builds work too — including the one by
[@hualazimo7](https://github.com/hualazimo7/retro-console-mod-collection),
which this driver was developed and tested against.

This is still early. More hardware and more systems are in the works.

## Linux setup
Expand All @@ -48,7 +54,7 @@ Then unplug and replug the device. macOS and Windows don't need this.
## What It Does

- **Dumps ROMs** from Game Boy, Game Boy Color, and Game Boy Advance cartridges
- **Dumps NES / Famicom ROMs** (PRG + CHR) across a growing list of mappers via the INL Retro Programmer
- **Dumps NES / Famicom ROMs** (PRG + CHR) across a growing list of mappers via the INL Retro Programmer or the ClusterM Famicom Dumper/Writer
- **Backs up save data** (SRAM, Flash, EEPROM)
- **Backs up DS cartridge saves** via the PowerSaves 3DS adapter
- **Reads Amiibo** tags (and generic NTAG215 tags, best-effort)
Expand Down
3 changes: 3 additions & 0 deletions THIRD-PARTY-LICENSES
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ https://github.com/ClusterM/famicom-dumper-client
The Mapper 268 (CoolBoy / Mindkids) GNROM-mode dump register sequence in
src/lib/systems/nes/mappers/coolboy.ts was derived from the reference
implementations in FamicomDumper/mappers/AA6023Sub0.cs and AA6023Sub1.cs.
The ClusterM Famicom Dumper/Writer serial frame format, CRC-8, command
set, and version handshake in src/lib/drivers/clusterm/ were ported from
FamicomDumperConnection/SerialClient.cs and FamicomDumperLocal.cs.

License: GNU General Public License v3.0

Expand Down
2 changes: 2 additions & 0 deletions linux/99-nabu.rules
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
# TAG+="uaccess" grants the active desktop user an ACL via systemd-logind.
# MODE="0660" keeps everyone else out as a floor.

# CLUSTERM — ClusterM Famicom Dumper/Writer
SUBSYSTEM=="tty", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="baba", TAG+="uaccess", MODE="0660"
# GBXCART — GBxCart RW
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", TAG+="uaccess", MODE="0660"
# POWERSAVE — PowerSaves for Amiibo
Expand Down
21 changes: 20 additions & 1 deletion src/lib/core/connection-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import { SerialTransport } from "@/lib/transport/serial-transport";
import { HidTransport } from "@/lib/transport/hid-transport";
import { UsbTransport } from "@/lib/transport/usb-transport";
import { GBxCartDriver } from "@/lib/drivers/gbxcart/gbxcart-driver";
import { DEVICE_FILTERS as GBXCART_FILTERS } from "@/lib/drivers/gbxcart/gbxcart-commands";
import { ClusterMDriver } from "@/lib/drivers/clusterm/clusterm-driver";
import {
DEVICE_FILTERS as CLUSTERM_FILTERS,
BAUD_RATE as CLUSTERM_BAUD,
} from "@/lib/drivers/clusterm/clusterm-commands";
import { PowerSaveDriver } from "@/lib/drivers/powersave/powersave-driver";
import { DEVICE_FILTERS as POWERSAVE_FILTERS } from "@/lib/drivers/powersave/powersave-commands";
import { PowerSave3DSDriver } from "@/lib/drivers/powersave-3ds/powersave-3ds-driver";
Expand Down Expand Up @@ -38,7 +44,7 @@ export interface ConnectionEntry {

export const CONNECTION_ENTRIES: Record<string, ConnectionEntry> = {
GBXCART: {
createTransport: () => new SerialTransport(),
createTransport: () => new SerialTransport(GBXCART_FILTERS),
connect: (t, { authorized }) =>
authorized
? (t as SerialTransport).connectWithPort(authorized as SerialPort, {
Expand All @@ -50,6 +56,19 @@ export const CONNECTION_ENTRIES: Record<string, ConnectionEntry> = {
`Connected: ${info.deviceName} (fw: ${info.firmwareVersion}, ${info.hardwareRevision})`,
},

CLUSTERM: {
createTransport: () => new SerialTransport(CLUSTERM_FILTERS),
connect: (t, { authorized }) =>
authorized
? (t as SerialTransport).connectWithPort(authorized as SerialPort, {
baudRate: CLUSTERM_BAUD,
})
: (t as SerialTransport).connect({ baudRate: CLUSTERM_BAUD }),
createDriver: (t) => new ClusterMDriver(t as SerialTransport),
postInitLog: (info) =>
`Connected: ${info.deviceName} (fw: ${info.firmwareVersion}, hw rev ${info.hardwareRevision})`,
},

INL_RETRO: {
createTransport: () => new InlTransport(),
connect: (t, { authorized }) =>
Expand Down
13 changes: 13 additions & 0 deletions src/lib/core/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ export const DEVICES: Record<string, DeviceDef> = {
"Open-source Game Boy / Game Boy Color / Game Boy Advance cartridge " +
"reader by insideGadgets. Uses a CH340 USB-serial chip.",
},
CLUSTERM: {
id: "CLUSTERM",
name: "ClusterM Famicom Dumper/Writer",
vendorId: 0x1209,
productId: 0xbaba,
transport: "serial",
systems: [{ id: "nes", name: "NES / Famicom" }],
homepage: "https://github.com/ClusterM/famicom-dumper-writer",
description:
"Open-source Famicom/NES cartridge dumper-writer by ClusterM. " +
"Simulates the console bus with a continuously-clocked M2 via a " +
"CPLD-synchronized memory controller.",
},
INL_RETRO: {
id: "INL_RETRO",
name: "INL Retro Programmer",
Expand Down
145 changes: 145 additions & 0 deletions src/lib/drivers/clusterm/clusterm-bus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* `NesBus` adapter for the Famicom Dumper/Writer.
*
* The device memory-maps both cart buses through its FSMC + CPLD, so the
* generic primitives map 1:1 onto protocol operations at any address and
* length — none of the alignment restrictions other dumpers impose.
* Large reads are split into chunks here so progress callbacks tick and
* an abort lands between chunks rather than at region boundaries.
*
* Deliberately omitted optional capabilities:
* - `writeSerialRegister` — every `writeCpu` is a real M2-timed bus
* write executed in a tight firmware loop, the same way the reference
* client's MMC1 scripts drive the shift register; the mapper's
* per-bit fallback is the native path here.
* - `readChrBankLatched` — only for devices without a generic PPU read;
* `readPpu` is the real thing on this hardware.
*/

import type { NesBus, BusProgressCb } from "@/lib/systems/nes/bus";
import type { ClusterMProtocol } from "./clusterm-protocol";

/**
* Per-request read size. The firmware streams arbitrary lengths, so this
* only sets progress/abort granularity: 8 KiB ≈ 10 ms per chunk at CDC
* full speed while keeping request-turnaround overhead negligible.
*/
const READ_CHUNK = 8 * 1024;

// Mapper 413's serial flash CANNOT be dumped with this device's stock
// firmware — hardware-established 2026-06-13 with the cart on the bus:
//
// - Within one continuous read burst the rolling-window technique works
// perfectly (strict one-SPI-clock-per-read, the whole $C000-$CFFF page
// returns the shift register).
// - But the firmware's CDC send path flushes a 64-byte staging buffer,
// PAUSING the FSMC read stream every 64 reads mid-request — and every
// pause (USB gaps too) can inject spurious SPI clocks that shear the
// bit phase. Corruption lands at exactly the 64-read boundaries.
// - Pause-free requests cap at 64 reads = 7 extracted bytes; with ~36
// round trips of re-framing per request, the 8 MiB flash works out to
// hours. Multi-byte $C000-page writes do NOT shift clean command bits
// either (single writes to $C000 exactly are required for framing).
//
// The mapper's pre-flight probe rejects all of this before any long
// dump. A paced read needs firmware help (read a block gaplessly into
// RAM, then transmit) — the same conclusion as the INL's pending
// NESCPU_SPI413 memtype. Until a firmware path exists, this bus does
// not advertise `readSpiDataPort`, so mapper 413's misc dump throws its
// clear capability error (PRG/CHR dump fine).

export class ClusterMNesBus implements NesBus {
private readonly protocol: ClusterMProtocol;
private readonly signal?: AbortSignal;

constructor(protocol: ClusterMProtocol, signal?: AbortSignal) {
this.protocol = protocol;
this.signal = signal;
}

/**
* Simulate a console reset (bus floats ~500 ms, then M2 free-runs
* again) so every dump region starts from power-on mapper state
* instead of whatever a previous run left latched.
*/
async setup(): Promise<void> {
await this.protocol.reset();
}

async writeCpu(addr: number, value: number): Promise<void> {
this.signal?.throwIfAborted();
await this.protocol.writeCpu(addr, [value]);
}

async readCpu(
addr: number,
length: number,
onProgress?: BusProgressCb,
): Promise<Uint8Array> {
return this.readChunked(
(a, n) => this.protocol.readCpuBlock(a, n),
addr,
length,
onProgress,
);
}

async readPpu(
addr: number,
length: number,
onProgress?: BusProgressCb,
): Promise<Uint8Array> {
return this.readChunked(
(a, n) => this.protocol.readPpuBlock(a, n),
addr,
length,
onProgress,
);
}

/** Single-byte CPU read — mapper 413's SPI arming read needs one. */
async readCpuByte(addr: number): Promise<number> {
this.signal?.throwIfAborted();
const data = await this.protocol.readCpuBlock(addr, 1);
return data[0];
}

/**
* Latch-write + read for mapper 470's per-chunk re-latch cadence.
* Implemented as two sequential protocol operations — the firmware
* accepts only one command in flight (see the pipelining note in
* clusterm-protocol.ts) — and that is sufficient here: M2 free-runs
* through the USB turnaround, so the inner latch holds across the gap
* for the same reason it holds between CPU writes on a real console.
* Supplying the capability buys the vendor's 2 KiB cadence rather
* than atomicity.
*/
async readCpuBankLatched(
latchAddr: number,
latchValue: number,
addr: number,
length: number,
): Promise<Uint8Array> {
this.signal?.throwIfAborted();
await this.protocol.writeCpu(latchAddr, [latchValue]);
return this.protocol.readCpuBlock(addr, length);
}

private async readChunked(
readBlock: (addr: number, length: number) => Promise<Uint8Array>,
addr: number,
length: number,
onProgress?: BusProgressCb,
): Promise<Uint8Array> {
const result = new Uint8Array(length);
let offset = 0;
while (offset < length) {
this.signal?.throwIfAborted();
const n = Math.min(READ_CHUNK, length - offset);
result.set(await readBlock(addr + offset, n), offset);
offset += n;
onProgress?.(offset, length);
}
return result;
}
}
142 changes: 142 additions & 0 deletions src/lib/drivers/clusterm/clusterm-commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Famicom Dumper/Writer (ClusterM) — wire-protocol constants.
*
* The device is a USB-CDC serial dumper built around an STM32F103ZET +
* EPM3064 CPLD that memory-maps the cartridge's CPU and PPU buses through
* the MCU's FSMC, with every PRG access synchronized to a free-running
* ~1.8 MHz M2 clock — a faithful 2A03 bus simulation. Command IDs and
* framing are derived from the GPL-3.0 famicom-dumper-client
* (github.com/ClusterM/famicom-dumper-client,
* FamicomDumperConnection/FamicomDumperLocal.cs + SerialClient.cs).
*
* Frame format, both directions:
* 0x46 ('F') · command · length LE16 · payload · CRC-8
* The CRC is Dallas/Maxim 1-Wire (reflected poly 0x8C, init 0) over every
* preceding byte; a received frame is valid when the CRC over the WHOLE
* frame, trailing byte included, comes out 0.
*/

/** WebSerial chooser filter — pid.codes VID, "Famicom Dumper/Writer". */
export const DEVICE_FILTERS: SerialPortFilter[] = [
{ usbVendorId: 0x1209, usbProductId: 0xbaba },
];

/** Frame start byte ('F'). */
export const MAGIC = 0x46;

/**
* Nominal baud rate from the reference client. The device is a true
* USB-CDC ACM function, so the rate is ignored on the wire, but
* `SerialPort.open` requires one.
*/
export const BAUD_RATE = 250_000;

/**
* Complete command set (protocol version 5, firmware 3.4). Unreferenced
* entries are kept deliberately — they document the device surface
* (flash/FDS writing, on-device CRC reads) for future use.
*/
export const CMD = {
/** Reply to PRG_INIT: payload carries protocol/firmware/hardware versions. */
STARTED: 0,
/** Deprecated init ack from pre-3.x firmware. */
CHR_STARTED: 1,
ERROR_INVALID: 2,
ERROR_CRC: 3,
ERROR_OVERFLOW: 4,
/** Init/version handshake; also resets COOLBOY GPIO mode device-side. */
PRG_INIT: 5,
CHR_INIT: 6,
/** payload: addr LE16 · length LE16 → PRG_READ_RESULT with the bytes. */
PRG_READ_REQUEST: 7,
PRG_READ_RESULT: 8,
/** payload: addr LE16 · length LE16 · data → PRG_WRITE_DONE. */
PRG_WRITE_REQUEST: 9,
PRG_WRITE_DONE: 10,
/** payload: addr LE16 · length LE16 → CHR_READ_RESULT with the bytes. */
CHR_READ_REQUEST: 11,
CHR_READ_RESULT: 12,
/** payload: addr LE16 · length LE16 · data → CHR_WRITE_DONE. */
CHR_WRITE_REQUEST: 13,
CHR_WRITE_DONE: 14,
/** → MIRRORING_RESULT: CIRAM A10 at PPU $2000/$2400/$2800/$2C00. */
MIRRORING_REQUEST: 17,
MIRRORING_RESULT: 18,
/** Float the cart bus (M2 included) for ~500 ms — a console reset. */
RESET: 19,
RESET_ACK: 20,
// COOLBOY/COOLGIRL + UNROM-512 flash writing and on-device CRC reads —
// unused by the dump paths but part of the firmware surface.
FLASH_ERASE_SECTOR_REQUEST: 37,
FLASH_WRITE_REQUEST: 38,
/** Like PRG_READ_REQUEST but answers PRG_READ_RESULT with a CRC16. */
PRG_CRC_READ_REQUEST: 39,
/** Like CHR_READ_REQUEST but answers CHR_READ_RESULT with a CRC16. */
CHR_CRC_READ_REQUEST: 40,
FLASH_WRITE_ERROR: 41,
FLASH_WRITE_TIMEOUT: 42,
FLASH_ERASE_ERROR: 43,
FLASH_ERASE_TIMEOUT: 44,
// Famicom Disk System, via the RAM adapter cabled into the cart slot
// (protocol >= 3). Block payloads exclude the on-disk CRC; read-result
// frames append CrcOk + EndOfHeadMeet trailer bytes.
FDS_READ_REQUEST: 45,
FDS_READ_RESULT_BLOCK: 46,
FDS_READ_RESULT_END: 47,
FDS_TIMEOUT: 48,
FDS_NOT_CONNECTED: 49,
FDS_BATTERY_LOW: 50,
FDS_DISK_NOT_INSERTED: 51,
FDS_END_OF_HEAD: 52,
FDS_WRITE_REQUEST: 53,
FDS_WRITE_DONE: 54,
SET_FLASH_BUFFER_SIZE: 55,
SET_VALUE_DONE: 56,
FDS_DISK_WRITE_PROTECTED: 57,
FDS_BLOCK_CRC_ERROR: 58,
/** Reroute $8000+ writes to the COOLBOY flash /WE header (protocol >= 4). */
COOLBOY_GPIO_MODE: 59,
UNROM512_ERASE_REQUEST: 60,
UNROM512_WRITE_REQUEST: 61,
/** Debug chatter from DEBUG firmware builds; may interleave anywhere. */
DEBUG: 0xff,
} as const;

/**
* Dallas/Maxim 1-Wire CRC-8 (reflected poly 0x8C, init 0) — the frame
* checksum. Transcribed from SerialClient.cs.
*/
export function crc8(data: Uint8Array | number[]): number {
let crc = 0;
for (let inbyte of data) {
for (let i = 0; i < 8; i++) {
const mix = (crc ^ inbyte) & 0x01;
crc >>= 1;
if (mix) crc ^= 0x8c;
inbyte >>= 1;
}
}
return crc;
}

/** Build a complete frame: magic · command · length LE16 · payload · CRC. */
export function buildFrame(
command: number,
payload: Uint8Array | number[] = [],
): Uint8Array {
// The length field is LE16; a longer payload would wrap it while the
// frame still carries every byte, desyncing the device's byte stream.
if (payload.length > 0xffff) {
throw new Error(
`ClusterM frame payload too large: ${payload.length} bytes exceeds the 0xFFFF LE16 length field`,
);
}
const frame = new Uint8Array(payload.length + 5);
frame[0] = MAGIC;
frame[1] = command;
frame[2] = payload.length & 0xff;
frame[3] = (payload.length >> 8) & 0xff;
frame.set(payload, 4);
frame[frame.length - 1] = crc8(frame.subarray(0, frame.length - 1));
return frame;
}
Loading