diff --git a/THIRD-PARTY-LICENSES b/THIRD-PARTY-LICENSES index b8b6155..de0dc7e 100644 --- a/THIRD-PARTY-LICENSES +++ b/THIRD-PARTY-LICENSES @@ -3,6 +3,19 @@ open-source projects. Their original license texts are reproduced below. ================================================================================ +ndsplus +https://github.com/Thulinma/ndsplus + +EMS NDS Adapter+ protocol commands, save type detection, and bulk transfer +sequences in src/lib/drivers/ems-nds/ were derived from the ndsplus Linux +command-line tool by Thulinma. + +License: GNU General Public License v3.0 + +See LICENSE in this repository (same license applies to nabu as a whole). + +================================================================================ + FlashGBX https://github.com/lesserkuma/FlashGBX diff --git a/linux/99-nabu.rules b/linux/99-nabu.rules index ba3b0b1..d574292 100644 --- a/linux/99-nabu.rules +++ b/linux/99-nabu.rules @@ -19,3 +19,5 @@ KERNEL=="hidraw*", ATTRS{idVendor}=="0e6f", ATTRS{idProduct}=="0129", TAG+="uacc SUBSYSTEM=="usb", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="02ea", TAG+="uaccess", MODE="0660" # SMS4 — Neoflash SMS4 SUBSYSTEM=="usb", ATTRS{idVendor}=="ffab", ATTRS{idProduct}=="dd03", TAG+="uaccess", MODE="0660" +# EMS_NDS — EMS NDS Adaptor Plus +SUBSYSTEM=="usb", ATTRS{idVendor}=="4670", ATTRS{idProduct}=="9394", TAG+="uaccess", MODE="0660" diff --git a/src/lib/core/connection-registry.ts b/src/lib/core/connection-registry.ts index 09b324f..1a0bf0a 100644 --- a/src/lib/core/connection-registry.ts +++ b/src/lib/core/connection-registry.ts @@ -12,6 +12,8 @@ import { Ps3McaDriver } from "@/lib/drivers/ps3-mca/ps3-mca-driver"; import { DEVICE_FILTERS as PS3_MCA_FILTERS } from "@/lib/drivers/ps3-mca/ps3-mca-commands"; import { SMS4Driver } from "@/lib/drivers/sms4/sms4-driver"; import { DEVICE_FILTERS as SMS4_FILTERS } from "@/lib/drivers/sms4/sms4-commands"; +import { EMSNDSDriver } from "@/lib/drivers/ems-nds/ems-nds-driver"; +import { EMS_NDS_FILTER } from "@/lib/drivers/ems-nds/ems-nds-commands"; import type { DeviceDriver, DeviceIdentity, @@ -95,4 +97,15 @@ export const CONNECTION_ENTRIES: Record = { : (t as UsbTransport).connect(), createDriver: (t) => new SMS4Driver(t as UsbTransport), }, + + EMS_NDS: { + createTransport: () => new UsbTransport([EMS_NDS_FILTER]), + connect: (t, { authorized }) => + authorized + ? (t as UsbTransport).connectWithDevice(authorized as USBDevice) + : (t as UsbTransport).connect(), + createDriver: (t) => new EMSNDSDriver(t as UsbTransport), + postInitLog: (info) => + `Connected: ${info.deviceName} (fw: ${info.firmwareVersion})`, + }, }; diff --git a/src/lib/core/devices.ts b/src/lib/core/devices.ts index b04cf6f..ea9b3d9 100644 --- a/src/lib/core/devices.ts +++ b/src/lib/core/devices.ts @@ -53,6 +53,17 @@ export const DEVICES: Record = { "Datel cartridge adapter. Despite the 3DS branding, it backs up " + "DS cartridge saves only — 3DS cartridges are not accessible.", }, + EMS_NDS: { + id: "EMS_NDS", + name: "EMS NDS Adaptor Plus", + vendorId: 0x4670, + productId: 0x9394, + transport: "webusb", + systems: [{ id: "nds_save", name: "DS / 3DS (Saves Only)" }], + description: + "EMS save backup/restore adaptor for DS / 3DS cartridges. " + + "Does not dump ROMs.", + }, DISNEY_INFINITY: { id: "DISNEY_INFINITY", name: "Disney Infinity Base", diff --git a/src/lib/drivers/ems-nds/ems-nds-commands.ts b/src/lib/drivers/ems-nds/ems-nds-commands.ts new file mode 100644 index 0000000..c488ffd --- /dev/null +++ b/src/lib/drivers/ems-nds/ems-nds-commands.ts @@ -0,0 +1,155 @@ +/** + * EMS NDS Adapter+ — protocol constants and save type definitions. + * + * Protocol reverse-engineered by Thulinma (github.com/Thulinma/ndsplus). + * All communication uses USB bulk transfers with 10-byte command packets. + * + * WARNING: This device shares VID/PID with the EMS Game Boy USB 64M Smart Card. + * They have completely different protocols — the driver validates via the + * status response marker byte (0xAA at offset 5). + */ + +export const EMS_NDS_VID = 0x4670; +export const EMS_NDS_PID = 0x9394; + +export const EMS_NDS_FILTER = { + vendorId: EMS_NDS_VID, + productId: EMS_NDS_PID, +}; + +/** Command codes — byte 0 of the 10-byte packet. */ +export const CMD = { + GET_STATUS: 0x9c, + PREPARE_1: 0x9f, + PREPARE_2: 0x90, + READ_HEADER: 0x00, + READ_SAVE: 0x2c, + WRITE_SAVE: 0x7b, + ERASE_A: 0x5b, // For save type 0x93 + ERASE_B: 0x5e, // For save types 0x53, 0xA3 +} as const; + +/** + * Additional opcodes the official EMS Windows app uses but the public + * ndsplus reference does not document. Kept here as documentation of + * the device surface even though the driver does not use them. All + * follow the standard 10-byte packet framing with MAGIC=0xA5 byte[1], + * except UPGRADE_* which use different MAGIC values (see below). + */ +export const UNDOCUMENTED_CMD = { + /** Tell MCU to drop cart power. App sleeps 1000 ms before next op. */ + EJECT: 0x5f, + /** Auth/challenge step 1 (encrypted-cart handshake). */ + AUTH_1: 0x3c, + /** Auth/challenge step 2. */ + AUTH_2: 0x4f, + /** Auth/challenge step 3 — response is 64 bytes (session key). */ + AUTH_3: 0x1f, + /** Encrypted bulk save-read (2320-byte XOR stream after read). */ + ENCRYPTED_READ: 0x2b, + /** Variant of ENCRYPTED_READ for a different chip family. */ + ENCRYPTED_READ_ALT: 0xaf, + /** Encrypted 512 B save chunk — requires AUTH_1..3 to have run. */ + ENCRYPTED_READ_512: 0xb7, +} as const; + +/** + * Firmware upgrade opcode. **All upgrade packets use opcode 0x55 but + * switch the MAGIC byte from 0xA5 to one of {0xAA, 0x40, 0x20, 0x80}** + * to indicate which upgrade operation is being performed: + * + * 0xAA = enter-bootloader (no payload) + * 0x40 = erase-page at addr (13 pages × 512 B starting at 0xE000) + * 0x20 = program-page at addr (followed by 512 B page payload) + * 0x80 = finish / reboot into new firmware + * + * Crucially, **the device stays enumerated as the EMS vendor-bulk + * device through the entire upgrade** — it does NOT re-enumerate as + * HID. So a driver could theoretically implement firmware upgrade + * over the existing bulk endpoints. Not currently implemented here + * because bricking-on-failure is a risk that needs explicit user + * consent + recovery tooling. + */ +export const UPGRADE = { + OPCODE: 0x55, + MAGIC_ENTER: 0xaa, + MAGIC_ERASE: 0x40, + MAGIC_PROGRAM: 0x20, + MAGIC_FINISH: 0x80, + PAGE_SIZE: 512, + PAGE_COUNT: 13, + FLASH_BASE: 0xe000, +} as const; + +/** Magic/sync byte — byte 1 of every *normal-mode* command. */ +export const MAGIC = 0xa5; + +/** Byte 5 of status response is always 0xAA on genuine NDS adapters. */ +export const STATUS_MARKER = 0xaa; + +/** Save type byte when no card is inserted. */ +export const NO_CARD = 0xff; + +/** Device returns 512 bytes per read command. */ +export const READ_CHUNK = 512; + +/** Device accepts 256 bytes per write command. */ +export const WRITE_CHUNK = 256; + +/** Known EEPROM save types with fixed sizes. */ +export const EEPROM_SIZES: Record = { + 0x01: { name: "EEPROM", size: 512 }, + 0x02: { name: "EEPROM", size: 8_192 }, + 0x12: { name: "EEPROM", size: 65_536 }, +}; + +/** FLASH save types that require an erase command before each write. */ +export const FLASH_ERASE_CMD: Partial> = { + 0x93: CMD.ERASE_A, + 0x53: CMD.ERASE_B, + 0xa3: CMD.ERASE_B, +}; + +/** + * Decode the firmware-version word returned in statusBytes[6,7]. + * + * The word is little-endian (hi*256 + lo). Observed on real hardware: + * v3.04 returns raw=304, so the firmware packs it as major*100 + minor. + * This matches the public archive naming (v2.1, v3.01, v3.02, ... v3.05). + * Treat `raw` as authoritative and the {major, minor} decode as best-effort. + */ +export interface FirmwareVersion { + raw: number; + major: number; + minor: number; + /** + * Bit 7 of statusBytes[7]. The official EMS Windows app treats this + * bit as a "firmware is in recovery state" indicator: when set, the + * app displays `"error code : 1001A"` and disables every operation + * (no backup, no restore, no upgrade button responses). So this is + * NOT a cosmetic release/beta flag — it's a hard signal that the + * adapter's firmware is damaged or mid-update and should not be + * commanded. + * + * We still surface it rather than throwing, because "the adapter + * replied but its firmware is degraded" is distinct from "can't + * reach the adapter at all" and the caller (scanner UI) may want + * to show a different error than a connection failure. + */ + recovery: boolean; + /** Display form, e.g. "v3.04" (or "v3.04R" when recovery bit set). */ + display: string; +} + +export function parseFirmwareVersion( + statusBytes6: number, + statusBytes7: number, +): FirmwareVersion { + const recovery = (statusBytes7 & 0x80) !== 0; + const raw = (statusBytes7 & 0x7f) * 256 + statusBytes6; + const major = Math.floor(raw / 100); + const minor = raw % 100; + const display = + `v${major}.${minor.toString().padStart(2, "0")}` + (recovery ? "R" : ""); + return { raw, major, minor, recovery, display }; +} diff --git a/src/lib/drivers/ems-nds/ems-nds-driver.ts b/src/lib/drivers/ems-nds/ems-nds-driver.ts new file mode 100644 index 0000000..eb8785b --- /dev/null +++ b/src/lib/drivers/ems-nds/ems-nds-driver.ts @@ -0,0 +1,637 @@ +/** + * EMS NDS Adapter+ — device driver for Nintendo DS / 3DS save backup and + * restore. + * + * This device can read and write save data from DS / 3DS cartridges but + * cannot dump ROMs (readROM throws). Save data flows through readSave. + * + * Protocol: github.com/Thulinma/ndsplus + */ + +import type { + DeviceDriverEvents, + DeviceCapability, + DeviceInfo, + ReadConfig, + DumpProgress, + SystemId, + DetectSystemResult, +} from "@/lib/types"; +import type { UsbTransport } from "@/lib/transport/usb-transport"; +import { + CMD, + MAGIC, + STATUS_MARKER, + NO_CARD, + READ_CHUNK, + WRITE_CHUNK, + EEPROM_SIZES, + FLASH_ERASE_CMD, + parseFirmwareVersion, + type FirmwareVersion, +} from "./ems-nds-commands"; +import { MAKER_CODES } from "@/lib/systems/nds/nds-maker-codes"; +import { + parseNDSHeader as parseNDSHeaderShared, + buildNDSCartInfoFromHeader, + type CardHeader, + type NDSCartridgeInfo, + type NDSDeviceDriver, +} from "@/lib/systems/nds/nds-header"; +import { formatBytes } from "@/lib/core/hashing"; + +interface CardStatus { + saveType: number; + saveSize: number; + saveTypeName: string; + firmwareVersion: FirmwareVersion; + raw: Uint8Array; +} + +const parseNDSHeader = (raw: Uint8Array): CardHeader => + parseNDSHeaderShared(raw, MAKER_CODES); + +export class EMSNDSDriver implements NDSDeviceDriver { + readonly id = "EMS_NDS"; + readonly name = "EMS NDS Adaptor Plus"; + readonly capabilities: DeviceCapability[] = [ + { + systemId: "nds_save", + operations: ["dump_save", "write_save"], + autoDetect: true, + }, + ]; + + readonly transport: UsbTransport; + private events: Partial = {}; + private firmwareVersion: FirmwareVersion | null = null; + private status: CardStatus | null = null; + private header: CardHeader | null = null; + /** NDS cart chip ID (NTR opcode 0x90), captured during prepareCard. */ + private cardChipId = ""; + /** + * Fingerprint of the last status response so we can detect cart swaps + * between polls. Without this, a fast swap keeps the cached header and + * mislabels cart B's dump with cart A's title/gameCode. + */ + private lastStatusFingerprint = ""; + + constructor(transport: UsbTransport) { + this.transport = transport; + } + + async initialize(): Promise { + let statusBytes: Uint8Array; + try { + statusBytes = await this.getStatus(); + } catch (e) { + // No GB-device probe here. The former probe path closed and reopened + // the USB device, and close+reopen has been observed to cause the + // EMS firmware to issue stray SPI writes to any cart currently + // inserted — permanently corrupting save data. Safer to just surface + // the original failure and let the user diagnose (could be a GB- + // variant EMS cart with the same VID/PID, a flaky connection, or a + // cart in a bad state). + const msg = (e as Error).message ?? String(e); + throw new Error( + `Device did not respond to GET_STATUS: ${msg}. ` + + `If this is an EMS Game Boy USB Smart Card (same VID/PID as the ` + + `NDS Adaptor+), it's not supported by this driver. Otherwise, try ` + + `unplugging the adaptor, waiting 3 seconds, and reconnecting.`, + ); + } + + if (statusBytes[5] !== STATUS_MARKER) { + throw new Error( + `Device did not respond as an NDS Adaptor ` + + `(marker=0x${statusBytes[5].toString(16).padStart(2, "0")}, ` + + `expected 0x${STATUS_MARKER.toString(16).padStart(2, "0")}). ` + + "This may be an EMS Game Boy flash cart (same USB IDs, different protocol).", + ); + } + + const fw = parseFirmwareVersion(statusBytes[6], statusBytes[7]); + this.firmwareVersion = fw; + this.log(`Firmware ${fw.display} (raw=${fw.raw})`); + if (fw.recovery) { + throw new Error( + `Adaptor reports firmware in recovery state (${fw.display}). ` + + `The firmware is damaged or mid-update; re-flash via the ` + + `official EMS upgrader before attempting cart I/O.`, + ); + } + + return { + firmwareVersion: fw.display, + deviceName: this.name, + capabilities: this.capabilities, + }; + } + + async detectSystem(): Promise { + const info = await this.detectCartridge("nds_save"); + if (!info) return null; + return { systemId: "nds_save", cartInfo: info }; + } + + /** + * Poll for a cart and — on first detection — prepare the cart and read its + * header so the UI can show the game title immediately. Returns null if + * no cart is present, full CartridgeInfo otherwise. Header is cached until + * the cart is removed, so repeated polling is cheap. + * + * The ndsplus reference sequence is status → prepare → header → save; by + * doing the first three here we let readSave start straight into the save + * read, with no interleaved commands between header and save. + */ + async detectCartridge(_systemId: SystemId): Promise { + const statusBytes = await this.getStatus(); + + if (statusBytes[0] === NO_CARD || statusBytes[1] === NO_CARD) { + this.status = null; + this.header = null; + this.cardChipId = ""; + this.lastStatusFingerprint = ""; + return null; + } + + // Detect cart swap across polls: if the status bytes changed, the cart + // was replaced (or a previously-transient read has stabilised), so + // invalidate the cached header so the next reader re-reads it. + const fingerprint = Array.from(statusBytes).join(","); + if (fingerprint !== this.lastStatusFingerprint) { + this.header = null; + this.cardChipId = ""; + this.lastStatusFingerprint = fingerprint; + } + + if (!this.firmwareVersion) { + throw new Error("detectCartridge() called before initialize()"); + } + + const { name, size } = this.parseSaveType(statusBytes); + this.status = { + saveType: statusBytes[0], + saveSize: size, + saveTypeName: name, + firmwareVersion: this.firmwareVersion, + raw: statusBytes, + }; + + if (!this.header) { + await this.prepareCard(); + const headerBytes = await this.readCardHeader(); + this.header = parseNDSHeader(headerBytes); + if (this.header.headerAllFF) { + this.log( + "Cartridge returned an all-0xFF header — likely a 3DS cart " + + "(slot-1 format mismatch) or an NDS cart with dirty contacts.", + "warn", + ); + } else { + this.log( + `Card: ${this.header.title} [${this.header.gameCode}] — ${this.header.romSizeMiB} MiB ROM`, + ); + } + this.log(`Save: ${name} (${formatBytes(size)})`); + } + + return this.buildCartInfo(); + } + + /** Full cart info including header data, populated by detectCartridge(). */ + get cartInfo(): NDSCartridgeInfo | null { + if (!this.status) return null; + return this.buildCartInfo(); + } + + async readROM(): Promise { + throw new Error( + "EMS NDS Adaptor Plus: ROM dump is not supported — this device " + + "backs up DS / 3DS cartridge saves only.", + ); + } + + /** + * Dump save data. Assumes detectCartridge() has already run prepare + header + * read (normal scanner flow); falls back to a detect pass if called directly. + */ + async readSave( + config: ReadConfig, + signal?: AbortSignal, + ): Promise { + if (!this.status || !this.header) { + const info = await this.detectCartridge("nds_save"); + if (!info) throw new Error("No card present"); + } + + const saveData = await this.readSaveData(config, signal); + + // Re-verify the cart is still the one we started with. Save data has + // no canonical reference to hash against (unlike ROMs), so a cart + // swap mid-dump would otherwise produce silent, undetectable + // corruption — the .sav file would look fine but be wrong. + await this.verifyCartUnchanged(); + + return saveData; + } + + async writeSave( + data: Uint8Array, + config: ReadConfig, + signal?: AbortSignal, + ): Promise { + if (!this.status) throw new Error("Device not initialized"); + + // Skip start-of-write identity check — see verifyCartUnchanged for why + // a fresh getStatus here would disrupt the save session. The end-of-write + // verify (after the readback compare) catches cart-swapped-mid-write and + // is non-disruptive since the write session is already done by then. + + const saveSize = this.resolveSaveSize(config); + this.assertSupportedSave(saveSize); + this.assertWritableSave(); + + if (data.length !== saveSize) { + throw new Error( + `Save file size (${data.length} bytes) does not match cart save size ` + + `(${saveSize} bytes). Refusing to write.`, + ); + } + + const { saveType } = this.status; + + this.log(`Writing ${formatBytes(saveSize)} save...`); + + for (let offset = 0; offset < saveSize; offset += WRITE_CHUNK) { + if (signal?.aborted) throw new Error("Aborted"); + + const chunk = data.slice(offset, offset + WRITE_CHUNK); + await this.putSave(saveType, offset, chunk, signal); + + this.emitProgress( + "save", + Math.min(offset + WRITE_CHUNK, saveSize), + saveSize, + ); + } + + // Readback verify — save data is irreplaceable; confirm the device + // actually accepted every byte before declaring success. + this.log(`Verifying ${formatBytes(saveSize)} save...`); + + for (let offset = 0; offset < saveSize; offset += READ_CHUNK) { + if (signal?.aborted) throw new Error("Aborted"); + + const chunk = await this.getSave(saveType, offset); + const n = Math.min(READ_CHUNK, saveSize - offset); + if (chunk.length < n) { + throw new Error( + `Verify failed: short read at offset 0x${offset.toString(16)} ` + + `(expected ${n} bytes, got ${chunk.length}).`, + ); + } + for (let i = 0; i < n; i++) { + if (chunk[i] !== data[offset + i]) { + const addr = (offset + i).toString(16).padStart(6, "0"); + throw new Error( + `Verify failed at offset 0x${addr}: wrote 0x${data[offset + i] + .toString(16) + .padStart(2, "0")}, read back 0x${chunk[i] + .toString(16) + .padStart(2, "0")}. ` + + `The cart may have rejected the write; save integrity is not guaranteed.`, + ); + } + } + this.emitProgress("verify", offset + n, saveSize); + } + + // Final cart-identity check. If the cart was swapped during the write + // or readback, the byte-for-byte compare above would pass (we'd be + // reading back from the new cart, which now has our data on it) — + // only the chip ID tells us we wrote to the wrong cart. + await this.verifyCartUnchanged(); + + this.log("Save verified."); + } + + on( + event: K, + handler: DeviceDriverEvents[K], + ): void { + this.events[event] = handler; + } + + // ─── Protocol commands ────────────────────────────────────────────────── + + private buildCommand(cmd: number, address = 0, saveType = 0): Uint8Array { + const pkt = new Uint8Array(10); + pkt[0] = cmd; + pkt[1] = MAGIC; + pkt[2] = address & 0xff; + pkt[3] = (address >> 8) & 0xff; + pkt[4] = (address >> 16) & 0xff; + pkt[5] = (address >> 24) & 0xff; + pkt[6] = 0x02; + pkt[7] = saveType; + return pkt; + } + + private async getStatus(): Promise { + const cmd = new Uint8Array(10); + cmd[0] = CMD.GET_STATUS; + cmd[1] = MAGIC; + cmd[6] = 0x02; + await this.transport.send(cmd); + return this.transport.receive(8); + } + + /** + * Abort the dump/write if the currently-inserted cart isn't the one the + * scanner cached. Uses a fresh GET_STATUS and compares the raw status + * bytes (save type, size, firmware version) against the ones captured + * at detect time. + * + * We specifically do NOT re-run prepareCard here — that's 0x9F + 0x90 + * (NTR wake + GET_CHIP_ID), and the ndsplus firmware's save-read session + * state is fragile to interleaved prepare commands between header and + * save. GET_STATUS is a side-channel info query that doesn't touch the + * cart's NTR state, so it's safe to run mid-session. + */ + private async verifyCartUnchanged(): Promise { + const cached = this.status?.raw; + if (!cached) return; + + const statusBytes = await this.getStatus(); + + if (statusBytes[0] === NO_CARD || statusBytes[1] === NO_CARD) { + throw new Error( + "Cartridge removed since scan — re-insert and re-scan before dumping.", + ); + } + + // Compare bytes — saveType, saveSize exponent, and firmware version + // all change when the cart changes. Same cart reseated returns the + // same bytes. + for (let i = 0; i < cached.length && i < statusBytes.length; i++) { + if (cached[i] !== statusBytes[i]) { + throw new Error( + "Cartridge changed since scan (status bytes differ). " + + "Re-scan the new cart before dumping.", + ); + } + } + } + + private async prepareCard(): Promise { + // Step 1: command 0x9F with address bytes also set to 0x9F + const req1 = new Uint8Array(10); + req1[0] = CMD.PREPARE_1; + req1[1] = MAGIC; + req1[2] = CMD.PREPARE_1; + await this.transport.send(req1); + + // Step 2: command 0x90 is NDS NTR GET_CHIP_ID — capture the 4-byte + // response so the UI and bug reports can show the cart's chip ID, + // matching what the PowerSaves driver surfaces. + const req2 = new Uint8Array(10); + req2[0] = CMD.PREPARE_2; + req2[1] = MAGIC; + req2[2] = CMD.PREPARE_2; + await this.transport.send(req2); + const chipIdBytes = await this.transport.receive(4); + this.cardChipId = Array.from(chipIdBytes, (b) => + b.toString(16).padStart(2, "0"), + ).join(""); + } + + private async readCardHeader(): Promise { + // Command 0x00 returns the first 512 bytes of the NDS ROM header + const cmd = new Uint8Array(10); + cmd[0] = CMD.READ_HEADER; + cmd[1] = MAGIC; + await this.transport.send(cmd); + return this.transport.receive(512); + } + + private async getSave( + saveType: number, + address: number, + ): Promise { + const cmd = this.buildCommand(CMD.READ_SAVE, address, saveType); + await this.transport.send(cmd); + // All bulk IN responses — status, header, save — use EP1 (the default). + // + // 3 s timeout: empirically, ndsplus responses are bimodal — either the + // chunk arrives in well under 500 ms or the firmware has wedged and + // will never respond. There's no "slow but successful" band in between + // (the slow-read instrumentation in readSaveData confirms this). + // Waiting longer than 3 s on a wedge is pure dead time before the + // outer loop's close-and-reopen recovery kicks in. + return this.transport.receive(READ_CHUNK, { timeout: 3_000 }); + } + + private async putSave( + saveType: number, + address: number, + data: Uint8Array, + signal?: AbortSignal, + ): Promise { + // FLASH types need an erase command before writing + const eraseCmd = FLASH_ERASE_CMD[saveType]; + if (eraseCmd !== undefined) { + if (signal?.aborted) throw new Error("Aborted"); + const erase = this.buildCommand(eraseCmd, address, saveType); + await this.transport.send(erase); + } + + if (signal?.aborted) throw new Error("Aborted"); + const write = this.buildCommand(CMD.WRITE_SAVE, address, saveType); + await this.transport.send(write); + + if (signal?.aborted) throw new Error("Aborted"); + await this.transport.send(data); + } + + // ─── Parsing helpers ────────────────────────────────────────────────── + + private readSaveData = async ( + config: ReadConfig, + signal?: AbortSignal, + ): Promise => { + if (!this.status) throw new Error("Device not initialized"); + + const saveSize = this.resolveSaveSize(config); + this.assertSupportedSave(saveSize); + + const { saveType } = this.status; + const result = new Uint8Array(saveSize); + + this.log(`Reading ${formatBytes(saveSize)} save...`); + + let offset = 0; + while (offset < saveSize) { + if (signal?.aborted) throw new Error("Aborted"); + + let chunk: Uint8Array; + try { + chunk = await this.getSave(saveType, offset); + } catch (e) { + const msg = (e as Error).message ?? String(e); + if (!msg.includes("timeout")) throw e; + + // Fail fast on getSave timeout — DO NOT attempt an in-driver + // recovery. A previous version of this driver called + // transport.disconnect() + reopen here to drain orphaned + // transferIn requests from Chromium's WebUSB queue; that + // "recovery" path turned out to cause the EMS firmware to issue + // actual SPI writes to the cart's save chip (0x5A written to the + // first and last pages), permanently corrupting user save data. + // Confirmed across two separate adapters reading the same pattern + // and a physical-disconnect required to clear it (page buffer + // theory ruled out — a restore-from-backup was needed). + // + // Only the user's physical cable disconnect guarantees no further + // writes. Tell them to do that. + throw new Error( + `Save read stalled at offset 0x${offset.toString(16)}. ` + + `Unplug the EMS adaptor from USB, wait 3 seconds, plug it back in, ` + + `and reconnect to retry. Do not attempt to continue without a ` + + `physical disconnect — the driver intentionally does not try to ` + + `auto-recover because doing so has been observed to corrupt save data.`, + ); + } + + const n = Math.min(READ_CHUNK, saveSize - offset); + if (chunk.length < n) { + throw new Error( + `Short read at offset 0x${offset.toString(16)}: expected ${n} bytes, ` + + `got ${chunk.length}. Save dump aborted to avoid silent zero-fill.`, + ); + } + result.set(chunk.subarray(0, n), offset); + offset += n; + + this.emitProgress("save", offset, saveSize); + } + + return result; + }; + + /** + * Resolve the effective save size for a read/write operation. Rejects a + * caller-provided saveSize that would read past the cart chip — a bug + * there would produce wrapped/ghost data that still passes integrity + * heuristics. + */ + private resolveSaveSize(config: ReadConfig): number { + if (!this.status) throw new Error("Device not initialized"); + const configured = config.params.saveSizeBytes as number | undefined; + if (configured === undefined) return this.status.saveSize; + if (configured > this.status.saveSize) { + throw new Error( + `Requested save size (${configured} bytes) exceeds the cart's save ` + + `size (${this.status.saveSize} bytes). Refusing to operate past the chip.`, + ); + } + return configured; + } + + /** + * Throw a clear "unsupported cart" error before attempting any save I/O. + * saveSize === 0 reaches this driver's save paths only when parseSaveType + * couldn't classify the chip (NO_CARD is filtered earlier in detectCartridge). + */ + private assertSupportedSave(saveSize: number): void { + if (!this.status) throw new Error("Device not initialized"); + if (saveSize !== 0) return; + const rawHex = Array.from(this.status.raw, (b) => + b.toString(16).padStart(2, "0"), + ).join(" "); + throw new Error( + `Save chip not recognized (type=0x${this.status.saveType + .toString(16) + .padStart(2, "0")}). The cart is detected but its save chip is not ` + + `in this driver's database. Please report this cart with the raw ` + + `status bytes: ${rawHex}`, + ); + } + + /** + * Reject writes to save types where we don't know the erase command. + * Without this guard, putSave silently skips erase for unknown FLASH + * types and issues WRITE_SAVE — which on real FLASH corrupts the cart. + */ + private assertWritableSave(): void { + if (!this.status) throw new Error("Device not initialized"); + const type = this.status.saveType; + const isEeprom = EEPROM_SIZES[type] !== undefined; + const isKnownFlash = FLASH_ERASE_CMD[type] !== undefined; + if (isEeprom || isKnownFlash) return; + throw new Error( + `Refusing to write: don't know how to erase save type 0x${type + .toString(16) + .padStart(2, "0")}. Reading this cart's save works, but writing ` + + `without the correct erase sequence would corrupt the chip.`, + ); + } + + private parseSaveType(status: Uint8Array): { name: string; size: number } { + const type = status[0]; + if (type === NO_CARD) return { name: "None", size: 0 }; + + const eeprom = EEPROM_SIZES[type]; + if (eeprom) return eeprom; + + // Real NDS save-FLASH exponents are 0x11..0x17 (2 KB..8 MB). Anything + // outside is either a bus glitch, an EEPROM with an unknown type byte, + // or a chip we don't support — refuse to guess. + const exponent = status[4]; + if (exponent < 0x11 || exponent > 0x17) { + this.log( + `Unrecognized save chip (type=0x${type.toString(16).padStart(2, "0")}, ` + + `exp=0x${exponent.toString(16).padStart(2, "0")})`, + "warn", + ); + return { + name: `Unrecognized (type=0x${type + .toString(16) + .padStart(2, "0")}, exp=0x${exponent.toString(16).padStart(2, "0")})`, + size: 0, + }; + } + return { name: "FLASH", size: 1 << exponent }; + } + + private buildCartInfo(): NDSCartridgeInfo { + return buildNDSCartInfoFromHeader({ + header: this.header, + chipIdHex: this.cardChipId || undefined, + saveSize: this.status?.saveSize, + saveType: this.status?.saveTypeName, + }); + } + + // ─── Event helpers ──────────────────────────────────────────────────── + + private emitProgress( + phase: DumpProgress["phase"], + bytesRead: number, + totalBytes: number, + ): void { + this.events.onProgress?.({ + phase, + bytesRead, + totalBytes, + fraction: bytesRead / totalBytes, + }); + } + + private log( + message: string, + level: "info" | "warn" | "error" = "info", + ): void { + this.events.onLog?.(message, level); + } +}