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..28433dc --- /dev/null +++ b/src/lib/drivers/clusterm/clusterm-commands.ts @@ -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; +} 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..9c5eb30 --- /dev/null +++ b/src/lib/drivers/clusterm/clusterm-protocol.test.ts @@ -0,0 +1,184 @@ +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); + }); + + 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", () => { + 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/, + ); + }); + + 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", () => { + 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..9f69570 --- /dev/null +++ b/src/lib/drivers/clusterm/clusterm-protocol.ts @@ -0,0 +1,246 @@ +/** + * 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[] { + // 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]; + } + + /** 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. + * `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), + ...data, + ]); + await this.expect(CMD.PRG_WRITE_DONE); + } + + /** + * 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), + ...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); }