From 49c4d707c6d97d4d8abcbc4b9f91596900429afc Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sat, 13 Jun 2026 13:46:34 -0500 Subject: [PATCH 1/3] Add ClusterM Famicom Dumper/Writer NES driver over Web Serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new Web Serial dumper at USB 1209:baba — an STM32F103 + CPLD Famicom-bus simulator whose M2 clock free-runs from power-on, so the full shared NES mapper catalog is available, including the CPLD multicart boards (mappers 268/470) that idle-M2 dumpers must pre-flight-reject. The frame format, CRC-8, command set, and init handshake are ported from the GPL-3.0 famicom-dumper-client (attributed in THIRD-PARTY-LICENSES); the handshake and version report are validated against real hardware (firmware 3.4.0 / protocol 5). The firmware holds a single command slot, so frames are issued one at a time rather than pipelined. SerialTransport now takes its Web Serial chooser filters from the connection entry rather than hardcoding them (the GBxCart's CH340 filter moves into its own command module). Mapper 413 (BATMAP) is refused on this device: its 8 MiB serial sample flash can't be paced through the stock firmware's CDC staging buffer, so the cartridge can't be fully dumped here — the mapper is greyed out and rejected up front rather than producing a partial file. A field note documents the @hualazimo7 compatible board this driver was developed against (an open-hardware build, firmware byte-verified against ClusterM's reference); the README credits it as well. --- README.md | 8 +- THIRD-PARTY-LICENSES | 3 + linux/99-nabu.rules | 2 + src/lib/core/connection-registry.ts | 21 +- src/lib/core/devices.ts | 13 + src/lib/drivers/clusterm/clusterm-bus.ts | 145 ++++++++ src/lib/drivers/clusterm/clusterm-commands.ts | 135 +++++++ .../drivers/clusterm/clusterm-driver.test.ts | 208 +++++++++++ src/lib/drivers/clusterm/clusterm-driver.ts | 350 ++++++++++++++++++ .../clusterm/clusterm-protocol.test.ts | 171 +++++++++ src/lib/drivers/clusterm/clusterm-protocol.ts | 229 ++++++++++++ .../drivers/clusterm/clusterm-test-utils.ts | 131 +++++++ src/lib/drivers/gbxcart/gbxcart-commands.ts | 5 + src/lib/transport/serial-transport.ts | 7 +- 14 files changed, 1425 insertions(+), 3 deletions(-) create mode 100644 src/lib/drivers/clusterm/clusterm-bus.ts create mode 100644 src/lib/drivers/clusterm/clusterm-commands.ts create mode 100644 src/lib/drivers/clusterm/clusterm-driver.test.ts create mode 100644 src/lib/drivers/clusterm/clusterm-driver.ts create mode 100644 src/lib/drivers/clusterm/clusterm-protocol.test.ts create mode 100644 src/lib/drivers/clusterm/clusterm-protocol.ts create mode 100644 src/lib/drivers/clusterm/clusterm-test-utils.ts diff --git a/README.md b/README.md index bbf4381..0a1ac80 100644 --- a/README.md +++ b/README.md @@ -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) | @@ -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 @@ -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) diff --git a/THIRD-PARTY-LICENSES b/THIRD-PARTY-LICENSES index 9461391..8d7d892 100644 --- a/THIRD-PARTY-LICENSES +++ b/THIRD-PARTY-LICENSES @@ -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 diff --git a/linux/99-nabu.rules b/linux/99-nabu.rules index ba3b0b1..8a9ced4 100644 --- a/linux/99-nabu.rules +++ b/linux/99-nabu.rules @@ -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 diff --git a/src/lib/core/connection-registry.ts b/src/lib/core/connection-registry.ts index ef84b9c..56cc659 100644 --- a/src/lib/core/connection-registry.ts +++ b/src/lib/core/connection-registry.ts @@ -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"; @@ -38,7 +44,7 @@ export interface ConnectionEntry { export const CONNECTION_ENTRIES: Record = { GBXCART: { - createTransport: () => new SerialTransport(), + createTransport: () => new SerialTransport(GBXCART_FILTERS), connect: (t, { authorized }) => authorized ? (t as SerialTransport).connectWithPort(authorized as SerialPort, { @@ -50,6 +56,19 @@ export const CONNECTION_ENTRIES: Record = { `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 }) => diff --git a/src/lib/core/devices.ts b/src/lib/core/devices.ts index 1b94793..b5b2f45 100644 --- a/src/lib/core/devices.ts +++ b/src/lib/core/devices.ts @@ -33,6 +33,19 @@ export const DEVICES: Record = { "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", diff --git a/src/lib/drivers/clusterm/clusterm-bus.ts b/src/lib/drivers/clusterm/clusterm-bus.ts new file mode 100644 index 0000000..bfcf7e8 --- /dev/null +++ b/src/lib/drivers/clusterm/clusterm-bus.ts @@ -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 { + await this.protocol.reset(); + } + + async writeCpu(addr: number, value: number): Promise { + this.signal?.throwIfAborted(); + await this.protocol.writeCpu(addr, [value]); + } + + async readCpu( + addr: number, + length: number, + onProgress?: BusProgressCb, + ): Promise { + return this.readChunked( + (a, n) => this.protocol.readCpuBlock(a, n), + addr, + length, + onProgress, + ); + } + + async readPpu( + addr: number, + length: number, + onProgress?: BusProgressCb, + ): Promise { + 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 { + 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 { + this.signal?.throwIfAborted(); + await this.protocol.writeCpu(latchAddr, [latchValue]); + return this.protocol.readCpuBlock(addr, length); + } + + private async readChunked( + readBlock: (addr: number, length: number) => Promise, + addr: number, + length: number, + onProgress?: BusProgressCb, + ): Promise { + 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; + } +} diff --git a/src/lib/drivers/clusterm/clusterm-commands.ts b/src/lib/drivers/clusterm/clusterm-commands.ts new file mode 100644 index 0000000..0b08ad9 --- /dev/null +++ b/src/lib/drivers/clusterm/clusterm-commands.ts @@ -0,0 +1,135 @@ +/** + * 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 { + 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; +} diff --git a/src/lib/drivers/clusterm/clusterm-driver.test.ts b/src/lib/drivers/clusterm/clusterm-driver.test.ts new file mode 100644 index 0000000..4592b9f --- /dev/null +++ b/src/lib/drivers/clusterm/clusterm-driver.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect } from "vitest"; +import type { SerialTransport } from "@/lib/transport/serial-transport"; +import { CMD } from "./clusterm-commands"; +import { ClusterMDriver, decodeMirroring } from "./clusterm-driver"; +import { ClusterMNesBus } from "./clusterm-bus"; +import { ClusterMProtocol } from "./clusterm-protocol"; +import { FakeClusterMDevice } from "./clusterm-test-utils"; + +function makeDriver(fake: FakeClusterMDevice): ClusterMDriver { + return new ClusterMDriver(fake.transport as SerialTransport); +} + +describe("decodeMirroring", () => { + it.each([ + [[false, false, true, true], "horizontal"], + [[false, true, false, true], "vertical"], + [[false, false, false, false], "one_screen_a"], + [[true, true, true, true], "one_screen_b"], + [[true, false, false, true], "unknown"], + ])("decodes %j as %s", (raw, expected) => { + expect(decodeMirroring(raw as boolean[])).toBe(expected); + }); + + it("decodes the 1-byte legacy reply", () => { + expect(decodeMirroring([true])).toBe("vertical"); + expect(decodeMirroring([false])).toBe("horizontal"); + }); +}); + +describe("ClusterMDriver.initialize", () => { + it("reports firmware and hardware versions from the handshake", async () => { + const fake = new FakeClusterMDevice(); + const info = await makeDriver(fake).initialize(); + expect(info.firmwareVersion).toBe("3.4.0"); + expect(info.hardwareRevision).toBe("3.2.0"); + expect(info.deviceName).toBe("ClusterM Famicom Dumper/Writer"); + }); +}); + +describe("ClusterMDriver.detectSystem", () => { + it("describes mirroring in summary, never title", async () => { + const fake = new FakeClusterMDevice(); + fake.mirroringRaw = [0, 1, 0, 1]; + const result = await makeDriver(fake).detectSystem(); + expect(result?.systemId).toBe("nes"); + expect(result?.cartInfo.title).toBeUndefined(); + expect(result?.cartInfo.summary).toBe( + "NES cartridge (mirroring: vertical)", + ); + expect(result?.cartInfo.meta).toEqual({ mirroring: "vertical" }); + }); + + it("survives a probe failure with mirroring unknown", async () => { + const fake = new FakeClusterMDevice(); + fake.ignoreNextCommands = 1; // mirroring request gets no reply → timeout + const result = await makeDriver(fake).detectSystem(); + expect(result?.cartInfo.summary).toBe( + "NES cartridge (mirroring: unknown)", + ); + }); +}); + +describe("ClusterMDriver.readROM", () => { + it("dumps NROM as PRG followed by CHR and resets on the way out", async () => { + const fake = new FakeClusterMDevice(); + fake.cpuRead = () => 0x42; + fake.ppuRead = () => 0x99; + const rom = await makeDriver(fake).readROM({ + systemId: "nes", + params: { mapper: 0, prgSizeBytes: 32768, chrSizeBytes: 8192 }, + }); + expect(rom.length).toBe(32768 + 8192); + expect(rom[0]).toBe(0x42); + expect(rom[32768]).toBe(0x99); + // Exit invariant: the device is left in power-on state. + expect(fake.commands.at(-1)?.command).toBe(CMD.RESET); + }); + + it("rejects mappers outside the shared catalog", async () => { + const fake = new FakeClusterMDevice(); + await expect( + makeDriver(fake).readROM({ systemId: "nes", params: { mapper: 5 } }), + ).rejects.toThrow(/Unsupported mapper: 5/); + }); + + it("refuses mapper 413 (BATMAP): its sample flash can't be paced here", async () => { + const fake = new FakeClusterMDevice(); + await expect( + makeDriver(fake).readROM({ systemId: "nes", params: { mapper: 413 } }), + ).rejects.toThrow(/BATMAP|cannot be fully dumped/i); + }); + + it("advertises mapper 413 as unsupported so the UI greys it out", async () => { + const info = await makeDriver(new FakeClusterMDevice()).initialize(); + expect(info.capabilities?.[0]?.unsupportedMappers).toContain(413); + }); + + it("still resets the cart when a dump aborts mid-read", async () => { + const fake = new FakeClusterMDevice(); + const controller = new AbortController(); + // Abort from the device side after two 8 KiB chunks have been served, + // so the bus's per-chunk signal check is what interrupts the dump. + let served = 0; + fake.cpuRead = () => { + if (++served === 16384) controller.abort(); + return 0x42; + }; + await expect( + makeDriver(fake).readROM( + { + systemId: "nes", + params: { mapper: 0, prgSizeBytes: 32768, chrSizeBytes: 8192 }, + }, + controller.signal, + ), + ).rejects.toThrow(); + expect(fake.commands.at(-1)?.command).toBe(CMD.RESET); + // The abort landed mid-PRG: only the two served chunks went out. + const reads = fake.commands.filter( + (c) => c.command === CMD.PRG_READ_REQUEST, + ); + expect(reads.length).toBe(2); + }); +}); + +describe("ClusterMDriver.readSave", () => { + it("reads the $6000 window through the default path", async () => { + const fake = new FakeClusterMDevice(); + fake.cpuRead = (addr) => (addr >= 0x6000 && addr < 0x8000 ? 0x5a : 0x00); + const save = await makeDriver(fake).readSave({ + systemId: "nes", + params: { mapper: 0, prgRamSizeBytes: 8192 }, + }); + expect(save.length).toBe(8192); + expect(save[0]).toBe(0x5a); + const read = fake.commands.find( + (c) => c.command === CMD.PRG_READ_REQUEST, + ); + expect([...(read?.payload ?? [])].slice(0, 2)).toEqual([0x00, 0x60]); + expect(fake.commands.at(-1)?.command).toBe(CMD.RESET); + }); + + it("throws when there is no SRAM to read", async () => { + const fake = new FakeClusterMDevice(); + await expect( + makeDriver(fake).readSave({ + systemId: "nes", + params: { mapper: 0, prgRamSizeBytes: 0 }, + }), + ).rejects.toThrow(/No SRAM to read/); + }); +}); + +describe("ClusterMNesBus", () => { + it("chunks large reads and reports progress", async () => { + const fake = new FakeClusterMDevice(); + const bus = new ClusterMNesBus(new ClusterMProtocol(fake.transport)); + const ticks: number[] = []; + const data = await bus.readCpu(0x8000, 32768, (read) => ticks.push(read)); + expect(data.length).toBe(32768); + expect(ticks).toEqual([8192, 16384, 24576, 32768]); + const reads = fake.commands.map((c) => c.payload[0] | (c.payload[1] << 8)); + expect(reads).toEqual([0x8000, 0xa000, 0xc000, 0xe000]); + }); + + it("aborts between chunks", async () => { + const fake = new FakeClusterMDevice(); + const controller = new AbortController(); + const bus = new ClusterMNesBus( + new ClusterMProtocol(fake.transport), + controller.signal, + ); + await expect( + bus.readCpu(0x8000, 32768, (read) => { + if (read >= 16384) controller.abort(); + }), + ).rejects.toThrow(); + expect(fake.commands.length).toBe(2); // chunks completed before the abort + }); + + it("does not advertise the mapper-413 paced SPI read", () => { + // Stock firmware pauses the read stream every 64 bytes for a CDC + // buffer flush, and every pause can inject spurious SPI clocks + // (hardware-established 2026-06-13) — so a trustworthy paced read + // is impossible without firmware help. The capability must stay + // absent so mapper 413's misc dump throws its clear error instead + // of producing a sheared 8 MiB file. + const bus = new ClusterMNesBus(new ClusterMProtocol(new FakeClusterMDevice().transport)); + expect((bus as { readSpiDataPort?: unknown }).readSpiDataPort).toBeUndefined(); + }); + + it("issues the fused latch+read as two sequential commands", async () => { + // The firmware drops the first of two frames sharing a send buffer + // (single command slot — see clusterm-protocol.ts), so the fused + // capability must be sequential ops; the free-running M2 keeps the + // inner latch alive across the gap. + const fake = new FakeClusterMDevice(); + const bus = new ClusterMNesBus(new ClusterMProtocol(fake.transport)); + const data = await bus.readCpuBankLatched(0x8000, 7, 0x8000, 2048); + expect(data.length).toBe(2048); + expect(fake.sendBuffers.length).toBe(2); + expect(fake.commands.map((c) => c.command)).toEqual([ + CMD.PRG_WRITE_REQUEST, + CMD.PRG_READ_REQUEST, + ]); + expect([...fake.commands[0].payload]).toEqual([0x00, 0x80, 0x01, 0x00, 0x07]); + }); +}); diff --git a/src/lib/drivers/clusterm/clusterm-driver.ts b/src/lib/drivers/clusterm/clusterm-driver.ts new file mode 100644 index 0000000..5d3d441 --- /dev/null +++ b/src/lib/drivers/clusterm/clusterm-driver.ts @@ -0,0 +1,350 @@ +/** + * Famicom Dumper/Writer (ClusterM) — NES/Famicom device driver. + * + * Drives the shared, device-agnostic NES mapper catalog + * (`@/lib/systems/nes/mappers`) through `ClusterMNesBus`. Unlike the + * INL Retro, this hardware free-runs M2 as a continuous ~1.8 MHz clock + * from power-on, so no catalog mapper is pre-flight-rejected — including + * the CPLD multicart boards (268/470) that need sustained M2 clocking. + */ + +/** + * Field note — an independent board variant in the wild (2026-06-13). + * + * ClusterM's Famicom Dumper/Writer is an open hardware design, and + * independent builds of it are sold (e.g. on AliExpress). This driver was + * exercised against one such build — a respin by GitHub user @hualazimo7: + * Famicom-slot only, a power-latch button, and (on at least one unit) the + * status LED's red/green channels swapped in hardware. It runs a RECOMPILE + * of ClusterM's 3.4.0 (not the stock release binary). Its builder could + * not get ClusterM's stock open-source bootloader's USB mass-storage + * ("U-drive") update mode to enumerate on these boards, so he modified the + * bootloader and abandoned that entry path — and was kind enough to + * explain all of this when asked (see the issue below). We did NOT + * determine why stock MSD won't enumerate here: the bootloader's USB + * descriptors and 48 MHz USB clock config are byte-identical to stock, + * which rules out the firmware and points to an (undetermined) + * board-level cause. Practical upshot: shorting cart /IRQ to GND does + * nothing useful on this variant — load custom firmware over the rear + * SWD pads (ST-Link / OpenOCD) instead. + * The CDC dump firmware is ClusterM 3.4.0 recompiled and drives the full + * mapper catalog normally; this note just spares the next person from + * chasing a "bootloader won't enter" ghost. + * + * Investigation, SWD backup + firmware analysis: + * https://github.com/hualazimo7/retro-console-mod-collection/issues/2 + * + * sha256 of firmware pulled from one such unit, vs ClusterM stock 3.4 + * (carve the regions from a 512 KB SWD flash dump to reproduce): + * bootloader 33648 B @ 0x08000000 + * @hualazimo7: 8a75e516f6c5c0c6e3d2a762eff384ea2200cbb2a5091a6d5a558747f7b15f3a + * stock 3.4 : 34d24e523f9560fb72aefdedba6e8b47fd06f33e756845d425e8116b2cdb6122 + * dump firmware 30336 B @ 0x08040000 + * @hualazimo7: dca3343f131ec3e8ba1a330b3b85166b6d985db36f58ae2a13f5c0777f32cdf7 + * stock 3.4 : a32d22e2eda6db9daa02617050a3243671aedd5a4b93e68fc02562e082106362 + * full 512 KB flash image @hualazimo7: + * fabd7d7d012cfa3c9e3d8aa947cf7e825e1f1771a29dc0e42cf2caf180a19e64 + */ + +import type { + DeviceDriver, + DeviceDriverEvents, + DeviceInfo, + DeviceCapability, + DetectSystemResult, + CartridgeInfo, + ReadConfig, + DumpProgress, + SystemId, +} from "@/lib/types"; +import type { SerialTransport } from "@/lib/transport/serial-transport"; +import { ClusterMProtocol } from "./clusterm-protocol"; +import { ClusterMNesBus } from "./clusterm-bus"; +import { getNesMapper } from "@/lib/systems/nes/mappers"; + +/** + * Decode the 4-byte CIRAM A10 probe (nametables $2000/$2400/$2800/$2C00) + * into the same vocabulary the INL driver reports. Solder-pad and + * mapper-controlled boards that match none of the fixed patterns read as + * "unknown". A 1-byte reply (ancient firmware) carries only V-vs-H. + */ +export function decodeMirroring(raw: boolean[]): string { + if (raw.length === 1) return raw[0] ? "vertical" : "horizontal"; + if (raw.length !== 4) return "unknown"; + const pattern = raw.map((v) => (v ? "1" : "0")).join(""); + switch (pattern) { + case "0011": + return "horizontal"; + case "0101": + return "vertical"; + case "0000": + return "one_screen_a"; + case "1111": + return "one_screen_b"; + default: + return "unknown"; + } +} + +/** + * Catalog mappers this device cannot fully dump, with the reason shown to + * the user. Mapper 413 (BATMAP) carries an 8 MiB serial sample flash whose + * SPI read this device's stock firmware cannot pace — the CDC buffer flush + * shears the bit phase mid-stream (see the note in clusterm-bus.ts). PRG + * and CHR dump fine, but a cartridge dump missing that 8 MiB section is + * incomplete, so the whole mapper is refused rather than writing a partial + * file. The key set also feeds `capability.unsupportedMappers`, which greys + * the option out in the config UI. + */ +const UNSUPPORTED_MAPPERS = new Map([ + [ + 413, + "BATMAP (mapper 413) carries an 8 MiB serial sample flash this device's " + + "stock firmware cannot pace, so the cartridge cannot be fully dumped " + + "here (only its PRG and CHR, which would be an incomplete dump).", + ], +]); + +export class ClusterMDriver implements DeviceDriver { + readonly id = "clusterm"; + readonly name = "ClusterM Famicom Dumper/Writer"; + readonly capabilities: DeviceCapability[] = [ + { + systemId: "nes", + operations: ["dump_rom", "dump_save"], + autoDetect: true, + unsupportedMappers: [...UNSUPPORTED_MAPPERS.keys()], + }, + ]; + + private events: Partial = {}; + readonly transport: SerialTransport; + private readonly protocol: ClusterMProtocol; + + constructor(transport: SerialTransport) { + this.transport = transport; + this.protocol = new ClusterMProtocol(transport); + } + + on( + event: K, + handler: DeviceDriverEvents[K], + ): void { + this.events[event] = handler; + } + + private log(message: string, level: "info" | "warn" | "error" = "info") { + this.events.onLog?.(message, level); + } + + private progress(p: DumpProgress) { + this.events.onProgress?.(p); + } + + async initialize(): Promise { + this.log("Initializing dumper..."); + + const info = await this.protocol.init(); + this.log(`Device ready (protocol v${info.protocolVersion})`); + if (info.protocolVersion < 5) { + // Protocol 5 = firmware 3.2+ (Nov 2022); later 3.x releases fixed + // flash-write and FDS bugs. The dump paths here only need the + // PRG/CHR primitives, so old firmware still works — but say so. + this.log( + "Firmware predates protocol v5 — consider updating " + + "(drop the release .bin/.svf onto the device's bootloader drive)", + "warn", + ); + } + + return { + firmwareVersion: info.firmwareVersion ?? `protocol v${info.protocolVersion}`, + hardwareRevision: info.hardwareVersion, + deviceName: this.name, + capabilities: this.capabilities, + }; + } + + async detectSystem(): Promise { + this.log("Detecting cartridge..."); + + let mirroring = "unknown"; + try { + mirroring = decodeMirroring(await this.protocol.getMirroringRaw()); + } catch (e) { + this.log( + `Mirroring detection failed (${(e as Error).message})`, + "warn", + ); + } + + return { + systemId: "nes", + cartInfo: { + // NES carts carry no self-reported title; describe what detection + // found and let the app emit the single "Detected: ..." log line + // through the same path as title-bearing systems. + summary: `NES cartridge (mirroring: ${mirroring})`, + meta: { mirroring }, + }, + }; + } + + async detectCartridge(systemId: SystemId): Promise { + if (systemId !== "nes") return null; + const result = await this.detectSystem(); + return result?.cartInfo ?? null; + } + + async readROM(config: ReadConfig, signal?: AbortSignal): Promise { + const mapperId = (config.params.mapper as number) ?? 0; + const prgKB = ((config.params.prgSizeBytes as number) ?? 32768) / 1024; + const chrKB = ((config.params.chrSizeBytes as number) ?? 8192) / 1024; + const miscKB = ((config.params.miscSizeBytes as number) ?? 0) / 1024; + + const mapper = getNesMapper(mapperId); + if (!mapper) throw new Error(`Unsupported mapper: ${mapperId}`); + const unsupported = UNSUPPORTED_MAPPERS.get(mapperId); + if (unsupported) throw new Error(unsupported); + + // Each mapper drives the cart through the bus; `bus.setup()` (issued + // inside the mapper before each region) performs the console-reset. + // The signal rides along so an abort interrupts per chunk. + const bus = new ClusterMNesBus(this.protocol, signal); + const startTime = Date.now(); + const totalBytes = (prgKB + chrKB + miscKB) * 1024; + + let prgData: Uint8Array; + let chrData: Uint8Array = new Uint8Array(0); + let miscData: Uint8Array = new Uint8Array(0); + try { + this.log(`Reading ${prgKB}KB PRG-ROM...`); + signal?.throwIfAborted(); + + prgData = await mapper.dumpPrgRom(bus, prgKB, (bytesRead) => { + const elapsed = (Date.now() - startTime) / 1000; + this.progress({ + phase: "rom", + bytesRead, + totalBytes, + fraction: bytesRead / totalBytes, + speed: elapsed > 0 ? bytesRead / elapsed : undefined, + }); + }); + + if (chrKB > 0) { + this.log(`Reading ${chrKB}KB CHR-ROM...`); + signal?.throwIfAborted(); + + chrData = await mapper.dumpChrRom(bus, chrKB, (bytesRead) => { + const elapsed = (Date.now() - startTime) / 1000; + const totalRead = prgKB * 1024 + bytesRead; + this.progress({ + phase: "rom", + bytesRead: totalRead, + totalBytes, + fraction: totalRead / totalBytes, + speed: elapsed > 0 ? totalRead / elapsed : undefined, + }); + }); + } + + // Dump the miscellaneous-ROM area (mapper 413's sample flash) — + // the NES 2.0 file section appended after CHR. + if (miscKB > 0) { + if (!mapper.dumpMiscRom) { + throw new Error( + `Mapper ${mapperId} (${mapper.name}) declares a miscellaneous ROM but supplies no dump path for it`, + ); + } + this.log(`Reading ${miscKB}KB miscellaneous ROM...`); + signal?.throwIfAborted(); + + miscData = await mapper.dumpMiscRom(bus, miscKB, (bytesRead) => { + const elapsed = (Date.now() - startTime) / 1000; + const totalRead = (prgKB + chrKB) * 1024 + bytesRead; + this.progress({ + phase: "rom", + bytesRead: totalRead, + totalBytes, + fraction: totalRead / totalBytes, + speed: elapsed > 0 ? totalRead / elapsed : undefined, + }); + }); + } + } finally { + // Leave the cart in power-on state on every exit, including abort + // or error, so the next dump can't inherit half-latched banks. + // Best-effort so a reset failure (e.g. the device was unplugged) + // doesn't mask the original cause. + try { + await this.protocol.reset(); + } catch { + /* best-effort cleanup */ + } + } + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + this.log( + `ROM read complete (${prgData.length + chrData.length + miscData.length} bytes in ${elapsed}s)`, + ); + + // Return PRG + CHR + misc concatenated in NES 2.0 file order (the + // system handler prepends the header). + const result = new Uint8Array( + prgData.length + chrData.length + miscData.length, + ); + result.set(prgData, 0); + result.set(chrData, prgData.length); + result.set(miscData, prgData.length + chrData.length); + return result; + } + + async readSave( + config: ReadConfig, + signal?: AbortSignal, + ): Promise { + const mapperId = (config.params.mapper as number) ?? 0; + const sramKB = ((config.params.prgRamSizeBytes as number) ?? 8192) / 1024; + + if (sramKB <= 0) throw new Error("No SRAM to read"); + + const mapper = getNesMapper(mapperId); + if (!mapper) throw new Error(`Unsupported mapper: ${mapperId}`); + + const bus = new ClusterMNesBus(this.protocol, signal); + this.log(`Reading ${sramKB}KB SRAM...`); + + let data: Uint8Array; + try { + if (mapper.dumpSave) { + data = await mapper.dumpSave(bus, sramKB); + } else { + // Default path: enable WRAM (where the mapper supports it) and + // read the $6000-$7FFF PRG-RAM window directly. + await bus.setup(); + if (mapper.enableSram) await mapper.enableSram(bus); + data = await bus.readCpu(0x6000, sramKB * 1024); + } + } finally { + // Reset on every exit so the next dump isn't left inheriting an + // SRAM-enabled bus state. + try { + await this.protocol.reset(); + } catch { + /* best-effort cleanup */ + } + } + + this.log(`SRAM read complete (${data.length} bytes)`); + return data; + } + + async writeSave( + _data: Uint8Array, + _config: ReadConfig, + _signal?: AbortSignal, + ): Promise { + throw new Error("Save RAM writing not yet implemented"); + } +} diff --git a/src/lib/drivers/clusterm/clusterm-protocol.test.ts b/src/lib/drivers/clusterm/clusterm-protocol.test.ts new file mode 100644 index 0000000..7314d12 --- /dev/null +++ b/src/lib/drivers/clusterm/clusterm-protocol.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from "vitest"; +import { CMD, crc8, buildFrame } from "./clusterm-commands"; +import { ClusterMProtocol } from "./clusterm-protocol"; +import { FakeClusterMDevice, HW_STARTED_PAYLOAD } from "./clusterm-test-utils"; + +/** + * Wire-protocol coverage against a scripted fake device that parses real + * frames out of `send()` buffers and queues real response frames, so the + * tests exercise the exact byte stream the firmware sees and produces. + * + * Reference vectors were generated with the CRC/framing algorithm + * transcribed from the GPL-3.0 famicom-dumper-client (SerialClient.cs) + * and confirmed against real hardware (fw 3.4.0): the PRG_INIT frame + * below is byte-for-byte what unlocked the STARTED reply. + */ + +describe("crc8 / buildFrame", () => { + it("produces the hardware-confirmed PRG_INIT frame", () => { + expect([...buildFrame(CMD.PRG_INIT)]).toEqual([0x46, 0x05, 0x00, 0x00, 0xdc]); + }); + + it("produces the documented read-request frame", () => { + // PRG read of 8192 bytes at $8000 + expect([ + ...buildFrame(CMD.PRG_READ_REQUEST, [0x00, 0x80, 0x00, 0x20]), + ]).toEqual([0x46, 0x07, 0x04, 0x00, 0x00, 0x80, 0x00, 0x20, 0xf4]); + }); + + it("validates a whole frame to zero, including the CRC byte", () => { + const frame = buildFrame(CMD.STARTED, HW_STARTED_PAYLOAD); + expect(crc8(frame)).toBe(0); + }); +}); + +describe("ClusterMProtocol.init", () => { + it("parses the hardware STARTED payload", async () => { + const fake = new FakeClusterMDevice(); + const info = await new ClusterMProtocol(fake.transport).init(); + expect(info).toEqual({ + protocolVersion: 5, + maxReadPacketSize: 0xffff, + maxWritePacketSize: 51192, + firmwareVersion: "3.4.0", + hardwareVersion: "3.2.0", + }); + }); + + it("retries the probe until the device answers", async () => { + const fake = new FakeClusterMDevice(); + fake.ignoreNextCommands = 2; + const info = await new ClusterMProtocol(fake.transport).init(); + expect(info.protocolVersion).toBe(5); + expect( + fake.commands.filter((c) => c.command === CMD.PRG_INIT).length, + ).toBe(3); + }); + + it("parses a short legacy payload by length", async () => { + const fake = new FakeClusterMDevice(); + // Old firmware: protocol version only. + fake.startedPayload = [0x03]; + const info = await new ClusterMProtocol(fake.transport).init(); + expect(info.protocolVersion).toBe(3); + expect(info.firmwareVersion).toBeUndefined(); + }); +}); + +describe("ClusterMProtocol framing", () => { + it("skips interleaved DEBUG frames", async () => { + const fake = new FakeClusterMDevice(); + fake.ignoreNextCommands = 1; + fake.push(CMD.DEBUG, [0xaa, 0xbb]); + fake.push(CMD.PRG_READ_RESULT, [0x11, 0x22]); + const protocol = new ClusterMProtocol(fake.transport); + expect([...(await protocol.readCpuBlock(0x8000, 2))]).toEqual([ + 0x11, 0x22, + ]); + }); + + it("resynchronises past garbage bytes before the magic", async () => { + const fake = new FakeClusterMDevice(); + fake.ignoreNextCommands = 1; + fake.pushRaw([0x00, 0x13, 0x37]); // stale non-magic bytes + fake.push(CMD.PRG_READ_RESULT, [0x11, 0x22]); + const protocol = new ClusterMProtocol(fake.transport); + expect([...(await protocol.readCpuBlock(0x8000, 2))]).toEqual([ + 0x11, 0x22, + ]); + }); + + it("rejects a frame with a corrupted CRC", async () => { + const fake = new FakeClusterMDevice(); + fake.ignoreNextCommands = 1; + const bad = buildFrame(CMD.PRG_READ_RESULT, [0x01, 0x02]); + bad[bad.length - 1] ^= 0xff; + fake.pushRaw(bad); + const protocol = new ClusterMProtocol(fake.transport); + await expect(protocol.readCpuBlock(0x8000, 2)).rejects.toThrow( + /CRC error/, + ); + }); + + it("throws on a short read result instead of shifting the stream", async () => { + const fake = new FakeClusterMDevice(); + fake.ignoreNextCommands = 1; + fake.push(CMD.PRG_READ_RESULT, [0x01, 0x02]); // 2 bytes, 4 requested + const protocol = new ClusterMProtocol(fake.transport); + await expect(protocol.readCpuBlock(0x8000, 4)).rejects.toThrow( + /returned 2 bytes, expected 4/, + ); + }); + + it("names commands in unexpected-reply errors", async () => { + const fake = new FakeClusterMDevice(); + fake.ignoreNextCommands = 1; + fake.push(CMD.ERROR_OVERFLOW); + const protocol = new ClusterMProtocol(fake.transport); + await expect(protocol.writeCpu(0x8000, [0x00])).rejects.toThrow( + /replied ERROR_OVERFLOW, expected PRG_WRITE_DONE/, + ); + }); +}); + +describe("ClusterMProtocol operations", () => { + it("reads CPU blocks with LE16 addr/length payloads", async () => { + const fake = new FakeClusterMDevice(); + const protocol = new ClusterMProtocol(fake.transport); + const data = await protocol.readCpuBlock(0xc000, 512); + expect(data.length).toBe(512); + expect(data[0]).toBe(0xc0); + const req = fake.commands[0]; + expect(req.command).toBe(CMD.PRG_READ_REQUEST); + expect([...req.payload]).toEqual([0x00, 0xc0, 0x00, 0x02]); + }); + + it("writes CPU bytes and awaits the done ack", async () => { + const fake = new FakeClusterMDevice(); + const protocol = new ClusterMProtocol(fake.transport); + await protocol.writeCpu(0x8000, [0x07]); + expect(fake.commands[0].command).toBe(CMD.PRG_WRITE_REQUEST); + expect([...fake.commands[0].payload]).toEqual([0x00, 0x80, 0x01, 0x00, 0x07]); + }); + + it("never shares a send buffer between command frames", async () => { + // The firmware's parser holds one command slot; frames sharing a CDC + // packet execute last-frame-wins (comm.c comm_proceed). The fake + // emulates that, so a pipelined operation would lose its first + // command here exactly as it does on hardware. + const fake = new FakeClusterMDevice(); + const protocol = new ClusterMProtocol(fake.transport); + await protocol.writeCpu(0x8000, [0x05]); + const data = await protocol.readCpuBlock(0x8000, 64); + expect(data.length).toBe(64); + expect(fake.sendBuffers.length).toBe(2); + expect(fake.commands.map((c) => c.command)).toEqual([ + CMD.PRG_WRITE_REQUEST, + CMD.PRG_READ_REQUEST, + ]); + }); + + it("reads the raw mirroring probe", async () => { + const fake = new FakeClusterMDevice(); + const protocol = new ClusterMProtocol(fake.transport); + expect(await protocol.getMirroringRaw()).toEqual([ + false, + true, + false, + true, + ]); + }); +}); diff --git a/src/lib/drivers/clusterm/clusterm-protocol.ts b/src/lib/drivers/clusterm/clusterm-protocol.ts new file mode 100644 index 0000000..d0b0238 --- /dev/null +++ b/src/lib/drivers/clusterm/clusterm-protocol.ts @@ -0,0 +1,229 @@ +/** + * Famicom Dumper/Writer — request/response protocol over WebSerial. + * + * One method per device operation; each sends a frame and awaits the + * expected reply, surfacing the firmware's error frames as descriptive + * exceptions. Chunking, progress, and abort granularity live a layer up + * in `ClusterMNesBus` — every read/write here is a single + * device-side operation (length ≤ the LE16 frame limit). + */ + +import type { SerialTransport } from "@/lib/transport/serial-transport"; +import { CMD, MAGIC, crc8, buildFrame } from "./clusterm-commands"; + +/** Parsed STARTED payload — the device's version/capacity report. */ +export interface DumperInfo { + protocolVersion: number; + /** 0xFFFF is the firmware's "streams any size" sentinel. */ + maxReadPacketSize: number; + maxWritePacketSize: number; + /** "major.minor.patch", absent on pre-3.x firmware's short payloads. */ + firmwareVersion?: string; + /** Stamped into flash by the bootloader; absent on pre-3.x firmware. */ + hardwareVersion?: string; +} + +interface Frame { + command: number; + payload: Uint8Array; +} + +const NAME_BY_CMD = new Map( + Object.entries(CMD).map(([name, value]) => [value, name]), +); + +function cmdName(command: number): string { + return NAME_BY_CMD.get(command) ?? `0x${command.toString(16)}`; +} + +/** Cap on resync scanning before declaring the byte stream lost. */ +const MAX_RESYNC_BYTES = 65536; + +const READ_TIMEOUT_MS = 5000; +/** RESET floats the bus for ~500 ms before the ack arrives. */ +const RESET_TIMEOUT_MS = 3000; +/** Init probes fast and retries, mirroring the reference client. */ +const INIT_PROBE_TIMEOUT_MS = 250; +const INIT_ATTEMPTS = 30; + +export class ClusterMProtocol { + private readonly transport: SerialTransport; + + constructor(transport: SerialTransport) { + this.transport = transport; + } + + private async sendCommand( + command: number, + payload: Uint8Array | number[] = [], + ): Promise { + await this.transport.send(buildFrame(command, payload)); + } + + /** + * Receive the next non-DEBUG frame. Scans byte-wise to the magic (the + * stream has no other sync marker), validates the trailing CRC, and + * transparently skips DEBUG frames, which debug firmware builds may + * interleave at any point. + */ + private async recvFrame(timeout = READ_TIMEOUT_MS): Promise { + for (;;) { + let skipped = 0; + let first = (await this.transport.receive(1, { timeout }))[0]; + while (first !== MAGIC) { + if (++skipped > MAX_RESYNC_BYTES) { + throw new Error( + "Famicom Dumper byte stream desynchronised (no frame magic found)", + ); + } + first = (await this.transport.receive(1, { timeout }))[0]; + } + const head = await this.transport.receive(3, { timeout }); + const command = head[0]; + const length = head[1] | (head[2] << 8); + const rest = await this.transport.receive(length + 1, { timeout }); + + const whole = new Uint8Array(4 + length + 1); + whole.set([MAGIC, command, head[1], head[2]], 0); + whole.set(rest, 4); + if (crc8(whole) !== 0) { + throw new Error( + `Famicom Dumper frame CRC error on ${cmdName(command)}`, + ); + } + if (command !== CMD.DEBUG) { + return { command, payload: rest.subarray(0, length) }; + } + } + } + + /** Receive a frame and require a specific reply command. */ + private async expect( + expected: number, + timeout = READ_TIMEOUT_MS, + ): Promise { + const { command, payload } = await this.recvFrame(timeout); + if (command !== expected) { + throw new Error( + `Famicom Dumper replied ${cmdName(command)}, expected ${cmdName(expected)}`, + ); + } + return payload; + } + + /** + * Version/connection handshake: drain stale bytes, then probe with + * PRG_INIT until STARTED arrives. The reply payload is parsed by length + * — old firmware sends shorter variants. + */ + async init(): Promise { + await this.transport.flush(); + let lastError: unknown; + for (let attempt = 0; attempt < INIT_ATTEMPTS; attempt++) { + try { + await this.sendCommand(CMD.PRG_INIT); + const data = await this.expect(CMD.STARTED, INIT_PROBE_TIMEOUT_MS); + const info: DumperInfo = { + protocolVersion: data.length >= 1 ? data[0] : 0, + maxReadPacketSize: data.length >= 3 ? data[1] | (data[2] << 8) : 0, + maxWritePacketSize: data.length >= 5 ? data[3] | (data[4] << 8) : 0, + }; + if (data.length >= 9) { + info.firmwareVersion = `${data[5] | (data[6] << 8)}.${data[7]}.${data[8]}`; + } + if (data.length >= 13) { + info.hardwareVersion = `${data[9] | (data[10] << 8)}.${data[11]}.${data[12]}`; + } + return info; + } catch (e) { + lastError = e; + await this.transport.flush(); + } + } + throw new Error( + `Famicom Dumper did not answer the init handshake: ${(lastError as Error)?.message}`, + ); + } + + /** + * Simulate a console reset: the device disconnects its level shifters + * for ~500 ms, so the cart sees the whole bus (M2 included) float. + */ + async reset(): Promise { + await this.sendCommand(CMD.RESET); + await this.expect(CMD.RESET_ACK, RESET_TIMEOUT_MS); + } + + private static addrLen(addr: number, length: number): number[] { + return [addr & 0xff, (addr >> 8) & 0xff, length & 0xff, (length >> 8) & 0xff]; + } + + /** Read `length` bytes from the CPU bus in one device operation. */ + async readCpuBlock(addr: number, length: number): Promise { + await this.sendCommand( + CMD.PRG_READ_REQUEST, + ClusterMProtocol.addrLen(addr, length), + ); + return this.expectBlock(CMD.PRG_READ_RESULT, length); + } + + /** Read `length` bytes from the PPU bus in one device operation. */ + async readPpuBlock(addr: number, length: number): Promise { + await this.sendCommand( + CMD.CHR_READ_REQUEST, + ClusterMProtocol.addrLen(addr, length), + ); + return this.expectBlock(CMD.CHR_READ_RESULT, length); + } + + private async expectBlock( + expected: number, + length: number, + ): Promise { + const payload = await this.expect(expected); + if (payload.length !== length) { + // A short result means host and firmware disagree about the request + // — surface it rather than silently shifting subsequent reads. + throw new Error( + `Famicom Dumper returned ${payload.length} bytes, expected ${length}`, + ); + } + return payload; + } + + /** Write bytes to the CPU bus, one M2-timed write per byte, ascending. */ + async writeCpu(addr: number, data: Uint8Array | number[]): Promise { + await this.sendCommand(CMD.PRG_WRITE_REQUEST, [ + ...ClusterMProtocol.addrLen(addr, data.length), + ...data, + ]); + await this.expect(CMD.PRG_WRITE_DONE); + } + + /** Write bytes to the PPU bus (CHR-RAM), ascending addresses. */ + async writePpu(addr: number, data: Uint8Array | number[]): Promise { + await this.sendCommand(CMD.CHR_WRITE_REQUEST, [ + ...ClusterMProtocol.addrLen(addr, data.length), + ...data, + ]); + await this.expect(CMD.CHR_WRITE_DONE); + } + + // NOTE: never pipeline two command frames into one transport send. The + // firmware's parser holds a single command slot: the next frame's first + // byte clears `comm_recv_done` and overwrites the buffer (comm.c, + // comm_proceed), so two frames landing in one CDC packet execute + // LAST-FRAME-WINS — the first command is silently dropped. + // Hardware-confirmed 2026-06-13: a "fused" write+read lost the write. + + /** + * Raw mirroring probe: CIRAM A10 levels after PPU reads at $0000, + * $0400, $0800, $0C00 (i.e. for nametables $2000/$2400/$2800/$2C00). + * Current firmware returns 4 bytes; ancient firmware returned 1. + */ + async getMirroringRaw(): Promise { + await this.sendCommand(CMD.MIRRORING_REQUEST); + const payload = await this.expect(CMD.MIRRORING_RESULT); + return [...payload].map((v) => v !== 0); + } +} diff --git a/src/lib/drivers/clusterm/clusterm-test-utils.ts b/src/lib/drivers/clusterm/clusterm-test-utils.ts new file mode 100644 index 0000000..9006d13 --- /dev/null +++ b/src/lib/drivers/clusterm/clusterm-test-utils.ts @@ -0,0 +1,131 @@ +import type { SerialTransport } from "@/lib/transport/serial-transport"; +import { CMD, MAGIC, crc8, buildFrame } from "./clusterm-commands"; + +/** + * Test-only fake of the Famicom Dumper/Writer: parses real frames out of + * `send()` buffers and queues real response frames, so suites exercise + * the exact byte stream the firmware sees and produces. + */ + +/** Real STARTED payload captured from hardware (fw 3.4.0 / hw 3.2.0). */ +export const HW_STARTED_PAYLOAD = [ + 0x05, 0xff, 0xff, 0xf8, 0xc7, 0x03, 0x00, 0x04, 0x00, 0x03, 0x00, 0x02, + 0x00, +]; + +export interface SentCommand { + command: number; + payload: Uint8Array; +} + +export class FakeClusterMDevice { + /** Every decoded command frame the host sent, in order. */ + commands: SentCommand[] = []; + /** Raw `send()` buffers, to assert frame pipelining. */ + sendBuffers: Uint8Array[] = []; + /** Queued device→host bytes served by `receive`. */ + private rx: number[] = []; + /** Payload for STARTED replies; override for legacy-firmware tests. */ + startedPayload: number[] = HW_STARTED_PAYLOAD; + /** When > 0, swallow that many incoming commands without replying. */ + ignoreNextCommands = 0; + /** CPU-bus read backing: address-dependent pattern by default. */ + cpuRead: (addr: number) => number = (addr) => (addr >> 8) & 0xff; + ppuRead: (addr: number) => number = (addr) => (addr ^ 0x55) & 0xff; + mirroringRaw: number[] = [0, 1, 0, 1]; + + readonly transport = { + send: async (data: Uint8Array): Promise => { + this.sendBuffers.push(data); + const frames: { command: number; payload: Uint8Array }[] = []; + let offset = 0; + while (offset < data.length) { + if (data[offset] !== MAGIC) throw new Error("fake: bad magic"); + const command = data[offset + 1]; + const length = data[offset + 2] | (data[offset + 3] << 8); + const frame = data.subarray(offset, offset + length + 5); + if (crc8(frame) !== 0) throw new Error("fake: bad CRC from host"); + frames.push({ command, payload: frame.subarray(4, 4 + length) }); + offset += length + 5; + } + // Real firmware holds a single command slot: a following frame's + // first byte clears comm_recv_done and overwrites the buffer + // (comm.c comm_proceed), so frames sharing one CDC packet execute + // LAST-FRAME-WINS. Emulate that so pipelining bugs fail tests the + // way they fail on hardware. + frames.forEach((f) => this.commands.push({ command: f.command, payload: f.payload.slice() })); + const last = frames.at(-1); + if (last) this.handle(last.command, last.payload); + }, + receive: async ( + length: number, + _options?: { timeout?: number }, + ): Promise => { + if (this.rx.length < length) { + throw new Error( + `Serial read timeout: got ${this.rx.length}/${length} bytes`, + ); + } + return new Uint8Array(this.rx.splice(0, length)); + }, + flush: async (): Promise => { + this.rx = []; + }, + } as unknown as SerialTransport; + + /** Queue a raw device→host frame. */ + push(command: number, payload: Uint8Array | number[] = []): void { + this.rx.push(...buildFrame(command, payload)); + } + + /** Queue raw bytes verbatim (garbage, corrupted frames). */ + pushRaw(bytes: Uint8Array | number[]): void { + this.rx.push(...bytes); + } + + // `commands` recording happens in send() (every decoded frame, even + // ones the single-slot parser drops); handle() only executes. + private handle(command: number, payload: Uint8Array): void { + if (this.ignoreNextCommands > 0) { + this.ignoreNextCommands--; + return; + } + switch (command) { + case CMD.PRG_INIT: + this.push(CMD.STARTED, this.startedPayload); + break; + case CMD.RESET: + this.push(CMD.RESET_ACK); + break; + case CMD.PRG_READ_REQUEST: { + const addr = payload[0] | (payload[1] << 8); + const length = payload[2] | (payload[3] << 8); + this.push( + CMD.PRG_READ_RESULT, + Array.from({ length }, (_, i) => this.cpuRead(addr + i)), + ); + break; + } + case CMD.CHR_READ_REQUEST: { + const addr = payload[0] | (payload[1] << 8); + const length = payload[2] | (payload[3] << 8); + this.push( + CMD.CHR_READ_RESULT, + Array.from({ length }, (_, i) => this.ppuRead(addr + i)), + ); + break; + } + case CMD.PRG_WRITE_REQUEST: + this.push(CMD.PRG_WRITE_DONE); + break; + case CMD.CHR_WRITE_REQUEST: + this.push(CMD.CHR_WRITE_DONE); + break; + case CMD.MIRRORING_REQUEST: + this.push(CMD.MIRRORING_RESULT, this.mirroringRaw); + break; + default: + this.push(CMD.ERROR_INVALID); + } + } +} diff --git a/src/lib/drivers/gbxcart/gbxcart-commands.ts b/src/lib/drivers/gbxcart/gbxcart-commands.ts index c76e4da..be4c76a 100644 --- a/src/lib/drivers/gbxcart/gbxcart-commands.ts +++ b/src/lib/drivers/gbxcart/gbxcart-commands.ts @@ -1,5 +1,10 @@ // GBxCart RW command opcodes (from FlashGBX LK_Device.py) +/** WebSerial chooser filter — the CH340 USB-serial bridge on the GBxCart RW. */ +export const DEVICE_FILTERS: SerialPortFilter[] = [ + { usbVendorId: 0x1a86, usbProductId: 0x7523 }, +]; + export const CMD = { // Original firmware OFW_PCB_VER: 0x68, diff --git a/src/lib/transport/serial-transport.ts b/src/lib/transport/serial-transport.ts index c6f6059..e943187 100644 --- a/src/lib/transport/serial-transport.ts +++ b/src/lib/transport/serial-transport.ts @@ -9,6 +9,7 @@ import type { export class SerialTransport implements Transport { readonly type: TransportType = "serial"; + private readonly filters: SerialPortFilter[]; private port: SerialPort | null = null; private reader: ReadableStreamDefaultReader | null = null; private writer: WritableStreamDefaultWriter | null = null; @@ -16,13 +17,17 @@ export class SerialTransport implements Transport { private pendingBytes: Uint8Array[] = []; private pendingTotal = 0; + constructor(filters: SerialPortFilter[]) { + this.filters = filters; + } + get connected(): boolean { return this.port !== null; } async connect(options?: TransportConnectOptions): Promise { const port = await navigator.serial!.requestPort({ - filters: [{ usbVendorId: 0x1a86, usbProductId: 0x7523 }], + filters: this.filters, }); return this.openPort(port, options); } From 4899b5ce7810210479f1cd5d9f3b8fd579b6c791 Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sat, 13 Jun 2026 14:18:10 -0500 Subject: [PATCH 2/3] ClusterM: validate frame payload and request lengths against LE16 fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR review: buildFrame() and the protocol's addrLen() encode lengths (and addresses) into LE16 wire fields without checking the value fits. An out-of-range value would wrap silently while the frame still carries every byte, desyncing the device's byte stream — host and device then disagree about the request. Throw a descriptive error up front instead, matching the response-side length check in expectBlock(). Current callers stay well within 16 bits (NES addresses are 16-bit, reads chunk to 8 KiB), so these are fail-fast guards at the wire boundary rather than a fix for an observed bug. --- src/lib/drivers/clusterm/clusterm-commands.ts | 7 +++++++ src/lib/drivers/clusterm/clusterm-protocol.test.ts | 13 +++++++++++++ src/lib/drivers/clusterm/clusterm-protocol.ts | 7 +++++++ 3 files changed, 27 insertions(+) diff --git a/src/lib/drivers/clusterm/clusterm-commands.ts b/src/lib/drivers/clusterm/clusterm-commands.ts index 0b08ad9..28433dc 100644 --- a/src/lib/drivers/clusterm/clusterm-commands.ts +++ b/src/lib/drivers/clusterm/clusterm-commands.ts @@ -124,6 +124,13 @@ 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; diff --git a/src/lib/drivers/clusterm/clusterm-protocol.test.ts b/src/lib/drivers/clusterm/clusterm-protocol.test.ts index 7314d12..9c5eb30 100644 --- a/src/lib/drivers/clusterm/clusterm-protocol.test.ts +++ b/src/lib/drivers/clusterm/clusterm-protocol.test.ts @@ -30,6 +30,12 @@ describe("crc8 / buildFrame", () => { const frame = buildFrame(CMD.STARTED, HW_STARTED_PAYLOAD); expect(crc8(frame)).toBe(0); }); + + it("rejects a payload too large for the LE16 length field", () => { + expect(() => + buildFrame(CMD.PRG_WRITE_REQUEST, new Uint8Array(0x10000)), + ).toThrow(/payload too large/); + }); }); describe("ClusterMProtocol.init", () => { @@ -119,6 +125,13 @@ describe("ClusterMProtocol framing", () => { /replied ERROR_OVERFLOW, expected PRG_WRITE_DONE/, ); }); + + it("rejects a request whose length can't fit the LE16 field", async () => { + const protocol = new ClusterMProtocol(new FakeClusterMDevice().transport); + await expect(protocol.readCpuBlock(0x8000, 0x10000)).rejects.toThrow( + /out of range/, + ); + }); }); describe("ClusterMProtocol operations", () => { diff --git a/src/lib/drivers/clusterm/clusterm-protocol.ts b/src/lib/drivers/clusterm/clusterm-protocol.ts index d0b0238..70ce0ba 100644 --- a/src/lib/drivers/clusterm/clusterm-protocol.ts +++ b/src/lib/drivers/clusterm/clusterm-protocol.ts @@ -155,6 +155,13 @@ export class ClusterMProtocol { } private static addrLen(addr: number, length: number): number[] { + // Both encode as LE16; an out-of-range value would wrap silently and + // make the host and device disagree about the request. + if (addr < 0 || addr > 0xffff || length < 0 || length > 0xffff) { + throw new Error( + `ClusterM request out of range: addr=${addr}, length=${length} (each must be 0..0xFFFF)`, + ); + } return [addr & 0xff, (addr >> 8) & 0xff, length & 0xff, (length >> 8) & 0xff]; } From 97f3ae88d97fc235510893b65eaa4d8c50203ce5 Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sat, 13 Jun 2026 15:02:01 -0500 Subject: [PATCH 3/3] ClusterM: document the writeCpu/writePpu per-frame payload bound Per PR review: rather than a runtime guard for an unreachable case (writeCpu/writePpu carry byte-sized mapper-register payloads, never within kilobytes of the 0xFFFF - 4 per-frame data ceiling), note the bound in the method docs. buildFrame still throws loudly if it is ever somehow exceeded. --- src/lib/drivers/clusterm/clusterm-protocol.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/lib/drivers/clusterm/clusterm-protocol.ts b/src/lib/drivers/clusterm/clusterm-protocol.ts index 70ce0ba..9f69570 100644 --- a/src/lib/drivers/clusterm/clusterm-protocol.ts +++ b/src/lib/drivers/clusterm/clusterm-protocol.ts @@ -198,7 +198,14 @@ export class ClusterMProtocol { return payload; } - /** Write bytes to the CPU bus, one M2-timed write per byte, ascending. */ + /** + * Write bytes to the CPU bus, one M2-timed write per byte, ascending. + * `data` is a mapper-register payload — a handful of bytes in practice. + * The frame's LE16 length bounds the whole payload to 0xFFFF, so with the + * 4-byte addr/len header `data` tops out at 0xFFFF - 4. Not worth a runtime + * check (no caller comes within kilobytes of that), and buildFrame throws + * loudly in the impossible overflow case. + */ async writeCpu(addr: number, data: Uint8Array | number[]): Promise { await this.sendCommand(CMD.PRG_WRITE_REQUEST, [ ...ClusterMProtocol.addrLen(addr, data.length), @@ -207,7 +214,10 @@ export class ClusterMProtocol { await this.expect(CMD.PRG_WRITE_DONE); } - /** Write bytes to the PPU bus (CHR-RAM), ascending addresses. */ + /** + * Write bytes to the PPU bus (CHR-RAM), ascending addresses. Same + * register-sized `data` and per-frame payload bound as `writeCpu`. + */ async writePpu(addr: number, data: Uint8Array | number[]): Promise { await this.sendCommand(CMD.CHR_WRITE_REQUEST, [ ...ClusterMProtocol.addrLen(addr, data.length),