diff --git a/THIRD-PARTY-LICENSES b/THIRD-PARTY-LICENSES index 9f82875..e1ee779 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 @@ -46,6 +59,63 @@ SOFTWARE. ================================================================================ +powerslaves +https://github.com/kitlith/powerslaves + +PowerSaves 3DS HID protocol (packet framing, opcodes, mode-switch sequence, +SPI/NTR passthrough) in src/lib/drivers/powersave-3ds/ was ported from the +powerslaves C library. + +License: MIT + +Copyright (c) 2017 kitlith + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + +ndsplus +https://github.com/Thulinma/ndsplus + +EMS NDS Adapter+ USB protocol commands and save read/write sequences in +src/lib/drivers/ems-nds/ were implemented from the ndsplus reference +implementation by Thulinma. + +License: GNU General Public License v3.0 + +See LICENSE in this repository (same license applies to nabu as a whole). + +================================================================================ + +ndstool (devkitPro) +https://github.com/devkitPro/ndstool + +NDS maker-code → publisher name lookup table in +src/lib/systems/nds/nds-maker-codes.ts was derived from ndstool's +ndscodes.cpp. + +License: GNU General Public License v3.0 + +See LICENSE in this repository (same license applies to nabu as a whole). + +================================================================================ + AmiiboAPI https://github.com/N3evin/AmiiboAPI diff --git a/src/App.tsx b/src/App.tsx index 6d9f313..57de63d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,7 +17,9 @@ import { GBSystemHandler } from "@/lib/systems/gb/gb-system-handler"; import { GBASystemHandler } from "@/lib/systems/gba/gba-system-handler"; import { AmiiboScanner } from "@/components/wizard/amiibo-scanner"; import { InfinityScanner } from "@/components/wizard/infinity-scanner"; +import { NDSScanner } from "@/components/wizard/nds-scanner"; import type { InfinityDriver } from "@/lib/drivers/infinity/infinity-driver"; +import type { NDSDeviceDriver } from "@/lib/systems/nds/nds-header"; import type { DeviceDriver, DeviceInfo, @@ -142,7 +144,10 @@ function App() { (_driver: DeviceDriver, _info: DeviceInfo) => { // Scanner-based devices handle detection in their own polling loop const isScanner = _driver.capabilities.some( - (c) => c.systemId === "amiibo" || c.systemId === "disney-infinity", + (c) => + c.systemId === "amiibo" || + c.systemId === "disney-infinity" || + c.systemId === "nds_save", ); if (!isScanner) autoDetectSystem(_driver); }, @@ -176,6 +181,9 @@ function App() { connection.driver?.capabilities.some( (c) => c.systemId === "disney-infinity", ) ?? false; + const isNDSSaveDevice = + connection.driver?.capabilities.some((c) => c.systemId === "nds_save") ?? + false; const badgeState = useMemo(() => { if (dumpJob.state !== "idle") return dumpJob.state; @@ -331,6 +339,14 @@ function App() { onDisconnect={handleDisconnect} log={log} /> + ) : isNDSSaveDevice ? ( + ) : (
void; + log: (msg: string, level?: "info" | "warn" | "error") => void; + nointroDb?: VerificationDB | null; +} + +export function NDSScanner({ + driver, + deviceInfo, + onDisconnect, + log, + nointroDb = null, +}: NDSScannerProps) { + const { phase, result, error, progress, cartInfo } = useNDSScanner( + driver, + log, + nointroDb, + ); + + // Expose the driver on window for devtools console experiments. Useful + // for iterating protocol probes without a rebuild; no secrets exposed. + useEffect(() => { + (window as unknown as { __nabuDriver?: NDSDeviceDriver }).__nabuDriver = + driver; + }, [driver]); + + const handleDownload = useCallback(() => { + if (!result) return; + saveFile(result.outputFile.data, result.outputFile.filename, [".sav"]); + }, [result]); + + return ( +
+ {/* Device info bar */} +
+ {deviceInfo && ( + + {deviceInfo.deviceName} + {deviceInfo.firmwareVersion && ( + + fw {deviceInfo.firmwareVersion} + + )} + + )} + +
+ + {/* Error alert */} + {error && ( + + + {error} +
+ Unplug the adaptor from USB, wait 3 seconds, plug it back in, and + reconnect to try again. +
+
+ )} + + {/* Polling state */} + {(phase === "polling" || phase === "idle") && ( + + + + + Insert a DS cartridge... + + + + )} + + {/* Reading state */} + {phase === "reading" && ( + + + + Save Backup In Progress + + + + {cartInfo && ( +
+ {cartInfo.title && ( + + )} + {cartInfo.meta?.gameCode != null && ( + + )} + {cartInfo.meta?.makerCode != null && ( + + )} + {cartInfo.meta?.region != null && ( + + )} + {cartInfo.saveType && ( + + )} + {cartInfo.saveSize != null && ( + + )} +
+ )} +
+
+ Reading save data... + {progress && ( + + {formatBytes(progress.bytesRead)} /{" "} + {formatBytes(progress.totalBytes)} + + )} +
+
+
+
+
+ + + )} + + {/* Result state */} + {phase === "done" && result && ( + <> + + + + Save Backup Complete + + + + {/* Cart info */} + {result.cartInfo.meta?.is3DS ? ( +
+ + {result.cartInfo.saveType && ( + + )} + {result.cartInfo.saveSize != null && ( + + )} +
+ ) : ( +
+ {result.cartInfo.title && ( + + )} + {result.cartInfo.meta?.gameCode != null && ( + + )} + {result.cartInfo.meta?.makerCode != null && ( + + )} + {result.cartInfo.meta?.region != null && ( + + )} + {result.cartInfo.saveType && ( + + )} + {result.cartInfo.saveSize != null && ( + + )} +
+ )} + + {/* Hashes */} +
+
+ CRC32: + + {hexStr(result.hashes.crc32)} + +
+
+ SHA-1: + + {result.hashes.sha1} + +
+
+ + {/* Actions */} +
+ +
+
+
+ +

+ Remove cartridge to back up another game. +

+ + )} +
+ ); +} + +function InfoRow({ + label, + value, + mono, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+ + {label} + + + {value} + +
+ ); +} diff --git a/src/hooks/use-connection.ts b/src/hooks/use-connection.ts index 4a36888..4c892d3 100644 --- a/src/hooks/use-connection.ts +++ b/src/hooks/use-connection.ts @@ -3,11 +3,14 @@ import { MockDriver } from "@/lib/core/mock-driver"; import { DEVICES, type DeviceDef } from "@/lib/core/devices"; 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 { PowerSaveDriver } from "@/lib/drivers/powersave/powersave-driver"; import { DEVICE_FILTERS as POWERSAVE_FILTERS } from "@/lib/drivers/powersave/powersave-commands"; import { InfinityDriver } from "@/lib/drivers/infinity/infinity-driver"; import { DEVICE_FILTERS as INFINITY_FILTERS } from "@/lib/drivers/infinity/infinity-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, DeviceInfo, Transport } from "@/lib/types"; // ─── Device probing ────────────────────────────────────────────────────── @@ -250,6 +253,36 @@ export function useConnection({ log, onReady }: UseConnectionOptions) { } }); + // Try WebUSB (EMS NDS Adapter) + navigator.usb?.getDevices().then(async (devices) => { + const emsDev = devices.find( + (d) => + d.vendorId === EMS_NDS_FILTER.vendorId && + d.productId === EMS_NDS_FILTER.productId, + ); + if (emsDev) { + log("Reconnecting to USB device..."); + try { + const transport = new UsbTransport([EMS_NDS_FILTER]); + await transport.connectWithDevice(emsDev); + + transport.on("onDisconnect", () => { + log("Device disconnected", "warn"); + handleDisconnect(); + }); + + const emsDriver = new EMSNDSDriver(transport); + emsDriver.on("onLog", (msg, level) => log(msg, level)); + + const info = await emsDriver.initialize(); + log(`Connected: ${info.deviceName} (fw: ${info.firmwareVersion})`); + finishConnect(emsDriver, info, "EMS_NDS"); + } catch (e) { + log(`Auto-reconnect failed: ${(e as Error).message}`, "warn"); + } + } + }); + // Try HID (PowerSaves Portal or Disney Infinity Base) navigator.hid?.getDevices().then(async (devices) => { const psDevice = devices.find((d) => @@ -413,6 +446,31 @@ export function useConnection({ log, onReady }: UseConnectionOptions) { finishConnect(infDriver, info, deviceId); break; } + + case "EMS_NDS": { + const transport = new UsbTransport([EMS_NDS_FILTER]); + if (authorized) { + log("Connecting..."); + await transport.connectWithDevice(authorized as USBDevice); + } else { + log("Requesting USB device..."); + await transport.connect(); + } + + transport.on("onDisconnect", () => { + log("Device disconnected", "warn"); + handleDisconnect(); + }); + + const emsDriver = new EMSNDSDriver(transport); + emsDriver.on("onLog", (msg, level) => log(msg, level)); + + log("Initializing device..."); + const info = await emsDriver.initialize(); + log(`Connected: ${info.deviceName} (fw: ${info.firmwareVersion})`); + finishConnect(emsDriver, info, deviceId); + break; + } } } catch (e) { const msg = (e as Error).message; diff --git a/src/hooks/use-nds-scanner.ts b/src/hooks/use-nds-scanner.ts new file mode 100644 index 0000000..256ae0e --- /dev/null +++ b/src/hooks/use-nds-scanner.ts @@ -0,0 +1,186 @@ +import { useState, useEffect, useMemo, useRef } from "react"; +import type { + DumpProgress, + OutputFile, + VerificationHashes, + VerificationDB, +} from "@/lib/types"; +import type { + NDSCartridgeInfo, + NDSDeviceDriver, +} from "@/lib/systems/nds/nds-header"; +import { NDSSaveSystemHandler } from "@/lib/systems/nds/nds-save-system-handler"; + +export type NDSScannerPhase = "idle" | "polling" | "reading" | "done" | "error"; + +export interface NDSScannerResult { + data: Uint8Array; + outputFile: OutputFile; + hashes: VerificationHashes; + cartInfo: NDSCartridgeInfo; + durationMs: number; +} + +export function useNDSScanner( + driver: NDSDeviceDriver | null, + log: (msg: string, level?: "info" | "warn" | "error") => void, + nointroDb: VerificationDB | null = null, +) { + // Read the latest DB through a ref so the polling effect below doesn't + // need to re-run (and interrupt an in-flight dump) when the user loads a + // DAT mid-session. + const nointroRef = useRef(nointroDb); + useEffect(() => { + nointroRef.current = nointroDb; + }, [nointroDb]); + const system = useMemo(() => new NDSSaveSystemHandler(), []); + + const [phase, setPhase] = useState("idle"); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [progress, setProgress] = useState(null); + const [cartInfo, setCartInfo] = useState(null); + + // Reset state when driver changes + const [prevDriver, setPrevDriver] = useState(driver); + if (driver !== prevDriver) { + setPrevDriver(driver); + setPhase(driver ? "polling" : "idle"); + setResult(null); + setError(null); + setCartInfo(null); + } + + useEffect(() => { + if (!driver) return; + + const abort = new AbortController(); + const { signal } = abort; + let timer: ReturnType; + + driver.on("onLog", (msg, level) => log(msg, level)); + + const enrichWithNoIntro = (info: NDSCartridgeInfo): NDSCartridgeInfo => { + const db = nointroRef.current; + const gameCode = info.meta?.gameCode; + if (!db?.lookupBySerial || !gameCode) return info; + const match = db.lookupBySerial(gameCode); + if (!match) return info; + return { ...info, title: match.name }; + }; + + driver.on("onProgress", (p: DumpProgress) => { + setProgress(p); + // The driver learns save type/size during readROM()'s SPI probe — after + // detectCartridge already set our cartInfo. Pick the enriched info back + // up on progress so the UI can show it alongside the progress bar. + if (driver.cartInfo) setCartInfo(enrichWithNoIntro(driver.cartInfo)); + }); + + const schedule = (fn: () => Promise, ms: number) => { + timer = setTimeout(() => { + fn().catch((e) => { + if (!signal.aborted) { + console.error("[nds-scanner]", e); + schedule(pollForCart, 1000); + } + }); + }, ms); + }; + + const pollForCart = async () => { + if (signal.aborted) return; + + const info = await driver.detectCartridge("nds_save"); + if (signal.aborted) return; + + if (info) { + const enriched = enrichWithNoIntro(info); + setCartInfo(enriched); + setPhase("reading"); + setError(null); + setProgress(null); + await readSave(enriched); + } else { + schedule(pollForCart, 500); + } + }; + + const readSave = async (initialInfo: NDSCartridgeInfo) => { + const startTime = Date.now(); + try { + const config = system.buildReadConfig({ + saveSizeBytes: initialInfo.saveSize, + }); + + const saveData = await driver.readROM(config, signal); + if (signal.aborted) return; + + // After readROM, the driver has full cart info (save size, save type). + // Re-apply No-Intro enrichment: the driver's cartInfo getter doesn't + // know about No-Intro, so its title would otherwise overwrite the + // nicer one we surfaced during reading. + const fullInfo = enrichWithNoIntro(driver.cartInfo ?? initialInfo); + + const finalConfig = system.buildReadConfig({ + saveSizeBytes: fullInfo.saveSize, + title: fullInfo.title, + gameCode: fullInfo.meta?.gameCode, + }); + + const hashes = await system.computeHashes(saveData); + const outputFile = system.buildOutputFile(saveData, finalConfig); + + const validation = system.validateDump(saveData); + for (const warning of validation.warnings) { + log(warning, "warn"); + } + + log( + `CRC32: ${hashes.crc32.toString(16).toUpperCase().padStart(8, "0")} SHA-1: ${hashes.sha1}`, + ); + + setResult({ + data: saveData, + outputFile, + hashes, + cartInfo: fullInfo, + durationMs: Date.now() - startTime, + }); + setPhase("done"); + + // Deliberately do NOT resume polling here. Hot-swapping a cart + // while the adapter is powered has been observed to leave the + // save chip in a state where subsequent driver operations can + // issue stray SPI writes, corrupting the next cart's save. + // To scan another cartridge, the user must physically disconnect + // the adapter from USB and reconnect — that's handled by + // rebuilding the scanner with a fresh driver instance. + log( + "Dump complete. Disconnect the adaptor from USB to dump another cartridge.", + ); + } catch (e) { + if (signal.aborted) return; + const msg = (e as Error).message; + log(`Read error: ${msg}`, "error"); + setError(msg); + setPhase("error"); + // Do not auto-retry. A stalled dump may have left the adapter in + // a state where further operations could write to the cart — + // require the user to physically disconnect and reconnect. + } + }; + + schedule(() => { + log("Waiting for cartridge..."); + return pollForCart(); + }, 0); + + return () => { + clearTimeout(timer); + abort.abort(); + }; + }, [driver, system, log]); + + return { phase, result, error, progress, cartInfo }; +} diff --git a/src/lib/core/devices.ts b/src/lib/core/devices.ts index 2c8d7c4..2a2750c 100644 --- a/src/lib/core/devices.ts +++ b/src/lib/core/devices.ts @@ -37,6 +37,17 @@ export const DEVICES: Record = { "Datel NFC portal. Also supports MaxLander/NaMiio clones. " + "Protocol: github.com/malc0mn/amiigo", }, + EMS_NDS: { + id: "EMS_NDS", + name: "EMS NDS Adaptor Plus", + vendorId: 0x4670, + productId: 0x9394, + transport: "webusb", + systems: [{ id: "nds_save", name: "NDS / 3DS (Saves Only)" }], + notes: + "Save backup/restore only — does NOT dump ROMs. " + + "Protocol: github.com/Thulinma/ndsplus", + }, DISNEY_INFINITY: { id: "DISNEY_INFINITY", name: "Disney Infinity Base", diff --git a/src/lib/core/hashing.ts b/src/lib/core/hashing.ts index 9b1c255..5d73703 100644 --- a/src/lib/core/hashing.ts +++ b/src/lib/core/hashing.ts @@ -60,5 +60,6 @@ export function hexStr(n: number, pad = 8): string { export function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; - return `${(bytes / 1024).toFixed(0)} KB`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; } diff --git a/src/lib/core/nointro.ts b/src/lib/core/nointro.ts index cbe0180..3b56c27 100644 --- a/src/lib/core/nointro.ts +++ b/src/lib/core/nointro.ts @@ -98,6 +98,17 @@ export function buildVerificationDb( return new NoIntroVerificationDB(dat, systemId); } +/** + * No-Intro tags prerelease/unofficial dumps in parentheses in gameName, + * e.g. "Foo (USA) (Beta)". Treat any of these as lower-priority than clean + * retail entries when multiple entries share a serial. + */ +const PRERELEASE_TAG = /\((?:Beta|Proto|Prototype|Demo|Sample|Unl)[^)]*\)/i; + +function isPrerelease(entry: NoIntroEntry): boolean { + return PRERELEASE_TAG.test(entry.gameName); +} + export class NoIntroVerificationDB implements VerificationDB { readonly systemId: SystemId; readonly source: string; @@ -119,7 +130,15 @@ export class NoIntroVerificationDB implements VerificationDB { for (const entry of dat.entries) { if (entry.crc32) this.byCrc.set(entry.crc32, entry); if (entry.sha1) this.bySha1.set(entry.sha1, entry); - if (entry.serial) this.bySerial.set(entry.serial, entry); + if (entry.serial) { + // Prefer retail/verified entries over beta/proto/demo when multiple + // DAT entries share a serial — otherwise lookupBySerial can surface + // "(Beta)" titles for users who have the retail cart. + const existing = this.bySerial.get(entry.serial); + if (!existing || (isPrerelease(existing) && !isPrerelease(entry))) { + this.bySerial.set(entry.serial, entry); + } + } } } @@ -165,6 +184,7 @@ export const NOINTRO_SYSTEM_NAMES: Readonly> = gba: ["Nintendo - Game Boy Advance", "Game Boy Advance"], nes: ["Nintendo - Nintendo Entertainment System", "NES"], snes: ["Nintendo - Super Nintendo Entertainment System", "SNES"], + nds_save: ["Nintendo - Nintendo DS", "Nintendo DS"], }; // ─── IndexedDB persistence ────────────────────────────────────────────── 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..7c4eee6 --- /dev/null +++ b/src/lib/drivers/ems-nds/ems-nds-driver.ts @@ -0,0 +1,643 @@ +/** + * 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. The readROM() method returns save data as the + * primary output, following the same pattern as the Amiibo driver. + * + * 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, + 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 readROM 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(); + } + + /** + * Dump save data. Assumes detectCartridge() has already run prepare + header + * read (normal scanner flow); falls back to a detect pass if called directly. + */ + async readROM(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 readSave( + config: ReadConfig, + signal?: AbortSignal, + ): Promise { + return this.readROM(config, signal); + } + + 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 readROM for why a 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.saveSize 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 { + const valid = this.header?.validHeader ?? false; + return { + title: valid ? this.header?.title : undefined, + saveSize: this.status?.saveSize, + saveType: this.status?.saveTypeName, + rawHeader: this.header?.raw, + meta: { + gameCode: valid ? this.header?.gameCode : undefined, + makerCode: valid ? this.header?.makerCode : undefined, + region: valid ? this.header?.region : undefined, + romVersion: valid ? this.header?.romVersion : undefined, + romSizeMiB: valid ? this.header?.romSizeMiB : undefined, + chipId: this.cardChipId || undefined, + is3DS: this.header?.headerAllFF ?? false, + }, + }; + } + + // ─── 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); + } +} diff --git a/src/lib/systems/nds/nds-header.ts b/src/lib/systems/nds/nds-header.ts new file mode 100644 index 0000000..9fc00ac --- /dev/null +++ b/src/lib/systems/nds/nds-header.ts @@ -0,0 +1,252 @@ +import type { + DeviceDriver, + CartridgeInfo, + SystemId, +} from "@/lib/types"; + +/** + * Nintendo DS cart-header parsing — shared between all NDS-capable drivers. + * + * Reference: https://problemkaputt.de/gbatek.htm#dscartridgeheader + * + * The on-cart header is 0x200 bytes at ROM offset 0. Fields we care + * about for UI display + integrity validation: + * + * 0x000..0x00B Title (12 bytes, ASCII, null-padded) + * 0x00C..0x00F Game code (4 bytes, A-Z/0-9; last char is region) + * 0x010..0x011 Maker code (2 bytes, A-Z/0-9) + * 0x014 Capacity (ROM size = 1 << (cap - 3) MiB; typical 4..12) + * 0x01E ROM version + * 0x0C0..0x15B Nintendo logo (156 bytes, byte-identical on every + * real NDS cart) + * 0x15C..0x15D Logo CRC-16/MODBUS (fixed at 0xCF56 on all real carts) + * 0x15E..0x15F Header CRC-16/MODBUS (covers bytes 0x000..0x15D) + * + * Validation uses the two CRCs plus the fixed-logo-CRC check to give + * strong confidence the header was read verbatim — an off-by-one or + * bus-corrupted read will fail at least one of the three checks. + * + * 3DS carts return 0x200 bytes of all-0xFF when asked for the NDS + * header (they use a different slot-1 header format). `headerAllFF` + * lets a driver flag that case without misclassifying the cart as + * "no cartridge." + */ + +const NDS_REGIONS: Record = { + J: "Japan", + E: "USA", + P: "Europe", + K: "Korea", + U: "Australia", + C: "China", + D: "Germany", + F: "France", + I: "Italy", + S: "Spain", + H: "Netherlands", + R: "Russia", + W: "International", +}; + +const VALID_REGION_CHARS = new Set( + Object.keys(NDS_REGIONS).map((c) => c.charCodeAt(0)), +); + +/** Fixed CRC-16 of the 156-byte Nintendo logo at 0x0C0..0x15B on every real NDS cart. */ +export const NINTENDO_LOGO_CRC = 0xcf56; + +/** Parsed NDS cart header. */ +export interface CardHeader { + title: string; + gameCode: string; + makerCode: string; + region: string; + romVersion: number; + romSizeMiB: number; + /** All three CRC signals matched and gameCode is alphanumeric. */ + validHeader: boolean; + /** The input buffer was all 0xFF — the cart is a 3DS cart, not an NDS cart. */ + headerAllFF: boolean; + raw: Uint8Array; +} + +/** + * CRC-16/MODBUS — polynomial 0xA001 (reflected 0x8005), init 0xFFFF, + * no final XOR. Nintendo uses this variant for both the logo CRC at + * 0x15C and the header CRC at 0x15E. + */ +export function crc16Modbus(buf: Uint8Array): number { + let crc = 0xffff; + for (let i = 0; i < buf.length; i++) { + crc ^= buf[i]; + for (let j = 0; j < 8; j++) { + crc = crc & 1 ? (crc >>> 1) ^ 0xa001 : crc >>> 1; + } + } + return crc & 0xffff; +} + +/** + * Scan the first 64 bytes of `raw` for an offset where a valid NDS + * header begins. Most drivers read the header cleanly at offset 0 (EMS, + * GBxCart); some (PowerSaves 3DS) get a short firmware-produced + * preamble before the real header. Returns -1 if no valid position + * found. + * + * Validation signals per candidate offset: + * - title bytes (0..11) are printable ASCII or null + * - title[0] is alphanumeric (real titles don't start with punctuation) + * - title has ≥ 3 alphanumeric characters + * - gameCode (0x0C..0x0F) is all alphanumeric + * - gameCode[3] is a known NDS region letter + * - capacity byte (0x14) is ≤ 0x0F (real carts are 3..12) + * + * Together these reject the nondeterministic preamble bytes some + * firmwares return before the real header. + */ +export function findHeaderStart(raw: Uint8Array): number { + const isAlnum = (b: number) => + (b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5a); + const isTitleByte = (b: number) => + b === 0x00 || (b >= 0x20 && b <= 0x7e); + + const limit = Math.min(raw.length - 0x20, 64); + for (let offset = 0; offset < limit; offset++) { + let titleOk = true; + let titleAlnum = 0; + for (let i = 0; i < 12; i++) { + const b = raw[offset + i]; + if (!isTitleByte(b)) { + titleOk = false; + break; + } + if (isAlnum(b)) titleAlnum++; + } + if (!titleOk) continue; + if (!isAlnum(raw[offset])) continue; + if (titleAlnum < 3) continue; + + let codeOk = true; + for (let i = 0x0c; i < 0x12; i++) { + if (!isAlnum(raw[offset + i])) { + codeOk = false; + break; + } + } + if (!codeOk) continue; + + if (!VALID_REGION_CHARS.has(raw[offset + 0x0f])) continue; + + const cap = raw[offset + 0x14]; + if (cap > 0x0f) continue; + + return offset; + } + return -1; +} + +/** + * Parse and validate an NDS cart header buffer (at least 0x160 bytes; + * 0x200 is the standard full-header size). Drivers whose reads produce + * a small preamble before the real header should pass the raw buffer + * here — findHeaderStart will scan for the correct offset. + * + * Resolves makerCode through the caller-supplied lookup (pass a map of + * 2-char maker codes → publisher names). + */ +export function parseNDSHeader( + raw: Uint8Array, + makerCodes: Record, +): CardHeader { + const headerAllFF = raw.length > 0 && raw.every((b) => b === 0xff); + const blank: CardHeader = { + title: "Unknown", + gameCode: "????", + makerCode: "", + region: "", + romVersion: 0, + romSizeMiB: 0, + validHeader: false, + headerAllFF, + raw, + }; + if (raw.length < 0x20) return blank; + if (headerAllFF || raw.every((b) => b === 0x00)) return blank; + + const start = findHeaderStart(raw); + if (start < 0 || raw.length < start + 0x20) return blank; + const hdr = raw.subarray(start); + + const decoder = new TextDecoder("ascii"); + const title = decoder.decode(hdr.slice(0, 12)).replace(/\0+$/, "").trim(); + const gameCode = decoder.decode(hdr.slice(0x0c, 0x10)); + const makerRaw = decoder.decode(hdr.slice(0x10, 0x12)); + const makerCode = makerCodes[makerRaw] ?? makerRaw; + const romVersion = hdr[0x1e]; + const capacity = hdr[0x14]; + const romSizeMiB = capacity > 3 ? 1 << (capacity - 3) : 0; + const regionChar = gameCode[3] ?? ""; + const region = NDS_REGIONS[regionChar] ?? regionChar; + + let validHeader = false; + if (hdr.length >= 0x160 && /^[A-Z0-9]{4}$/.test(gameCode)) { + const storedHeaderCrc = hdr[0x15e] | (hdr[0x15f] << 8); + const computedHeaderCrc = crc16Modbus(hdr.subarray(0, 0x15e)); + const storedLogoCrc = hdr[0x15c] | (hdr[0x15d] << 8); + const computedLogoCrc = crc16Modbus(hdr.subarray(0xc0, 0x15c)); + validHeader = + storedHeaderCrc === computedHeaderCrc && + storedLogoCrc === computedLogoCrc && + computedLogoCrc === NINTENDO_LOGO_CRC; + } + + return { + title, + gameCode, + makerCode, + region, + romVersion, + romSizeMiB, + validHeader, + headerAllFF: false, + raw, + }; +} + +/** + * Typed cart metadata returned by NDS drivers. Both the PowerSaves 3DS + * driver and the EMS NDS Adapter+ driver populate every field — the + * shape is a firm contract, not "whatever the driver happens to put in + * the meta bag." The `Record` intersection keeps + * `CartridgeInfo` assignable to the default + * `CartridgeInfo>`, so NDSDeviceDriver can + * widen-return cleanly into the base DeviceDriver interface. + */ +export type NDSCartMeta = Record & { + gameCode?: string; + makerCode?: string; + region?: string; + romVersion?: number; + romSizeMiB?: number; + /** NDS cart chip ID (NTR opcode 0x90, 4 bytes) as a hex string. */ + chipId?: string; + /** + * True if the cart returned an all-0xFF slot-1 header. Usually a 3DS + * cart (slot-1 format mismatch), but can also indicate an NDS cart + * with dirty contacts — the driver logs a warning in both cases. + */ + is3DS?: boolean; +}; + +export type NDSCartridgeInfo = CartridgeInfo; + +/** + * Specialization of DeviceDriver for NDS-system drivers. Consumers that + * need the enriched cart info (scanner hook, wizard UI) should accept + * this narrower type; it's what replaces the old + * `driver as unknown as DriverWithCartInfo` pattern. + */ +export interface NDSDeviceDriver extends DeviceDriver { + readonly cartInfo: NDSCartridgeInfo | null; + detectCartridge(systemId: SystemId): Promise; +} diff --git a/src/lib/systems/nds/nds-maker-codes.ts b/src/lib/systems/nds/nds-maker-codes.ts new file mode 100644 index 0000000..e928568 --- /dev/null +++ b/src/lib/systems/nds/nds-maker-codes.ts @@ -0,0 +1,318 @@ +/** + * Nintendo maker/licensee codes (2-character ASCII). + * Used in NDS ROM headers at offset 0x10-0x11. + * + * Source: devkitPro ndstool ndscodes.cpp (GPL-3.0), cross-referenced with + * Pan Docs (gbdev.io) and GBATEK. + */ +export const MAKER_CODES: Record = { + "01": "Nintendo", + "02": "Rocket Games", + "03": "Imagineer", + "04": "Gray Matter", + "05": "Zamuse", + "06": "Falcom", + "07": "Enix", + "08": "Capcom", + "09": "Hot B", + "0A": "Jaleco", + "0B": "Coconuts Japan", + "0C": "Coconuts Japan/G.X.Media", + "0D": "Micronet", + "0E": "Technos", + "0F": "Mebio Software", + "0G": "Shouei System", + "0H": "Starfish", + "0J": "Mitsui Fudosan/Dentsu", + "0L": "Warashi", + "0N": "Nowpro", + "0P": "Game Village", + "12": "Infocom", + "13": "Electronic Arts Japan", + "15": "Cobra Team", + "16": "Human/Field", + "17": "KOEI", + "18": "Hudson Soft", + "19": "S.C.P.", + "1A": "Yanoman", + "1C": "Tecmo", + "1D": "Japan Glary Business", + "1E": "Forum/OpenSystem", + "1F": "Virgin Games", + "1G": "SMDE", + "1J": "Daikokudenki", + "1P": "Creatures", + "1Q": "TDK Deep Impression", + "20": "KSS", + "21": "Sunsoft", + "22": "POW/VR 1 Japan", + "23": "Micro World", + "25": "San-X", + "26": "Enix", + "27": "Loriciel/Electro Brain", + "28": "Kemco Japan", + "29": "Seta", + "2A": "Culture Brain", + "2C": "Palsoft", + "2D": "Visit", + "2E": "Intec", + "2F": "System Sacom", + "2G": "Poppo", + "2H": "Ubisoft Japan", + "2J": "Media Works", + "2K": "NEC InterChannel", + "2L": "Tam", + "2M": "Jordan", + "2N": "Smilesoft", + "2Q": "Mediakite", + "30": "Viacom", + "31": "Carrozzeria", + "32": "Dynamic", + "34": "Magifact", + "35": "Hect", + "36": "Codemasters", + "37": "Taito/GAGA", + "38": "Laguna", + "39": "Telstar/Event/Taito", + "3B": "Arcade Zone", + "3C": "Entertainment International", + "3D": "Loriciel", + "3E": "Gremlin Graphics", + "3F": "K.Amusement Leasing", + "40": "Seika", + "41": "Ubisoft", + "42": "Sunsoft US", + "44": "Life Fitness", + "46": "System 3", + "47": "Spectrum Holobyte", + "49": "IREM", + "4B": "Raya Systems", + "4C": "Renovation Products", + "4D": "Malibu Games", + "4F": "Eidos", + "4G": "Playmates Interactive", + "4J": "Fox Interactive", + "4K": "Time Warner Interactive", + "4Q": "Disney Interactive", + "4S": "Black Pearl", + "4U": "Advanced Productions", + "4X": "GT Interactive", + "4Y": "Rare", + "4Z": "Crave Entertainment", + "50": "Absolute Entertainment", + "51": "Acclaim", + "52": "Activision", + "53": "American Sammy", + "54": "Take 2 Interactive", + "55": "Hi Tech", + "56": "LJN", + "57": "Matchbox", + "58": "Mattel", + "59": "Milton Bradley", + "5A": "Mindscape", + "5B": "Romstar", + "5C": "Taxan", + "5D": "Midway", + "5F": "American Softworks", + "5G": "Majesco", + "5H": "3DO", + "5K": "Hasbro", + "5L": "NewKidCo", + "5M": "Telegames", + "5N": "Metro3D", + "5P": "Vatical Entertainment", + "5Q": "LEGO Media", + "5S": "Xicat Interactive", + "5T": "Cryo Interactive", + "5W": "Red Storm Entertainment", + "5X": "Microids", + "5Z": "Conspiracy/Swing", + "60": "Titus", + "61": "Virgin Interactive", + "62": "Maxis", + "64": "LucasArts", + "67": "Ocean", + "69": "Electronic Arts", + "6B": "Laser Beam", + "6E": "Elite Systems", + "6F": "Electro Brain", + "6G": "The Learning Company", + "6H": "BBC", + "6J": "Software 2000", + "6L": "BAM! Entertainment", + "6M": "Studio 3", + "6Q": "Classified Games", + "6S": "TDK Mediactive", + "6U": "DreamCatcher", + "6V": "JoWood Productions", + "6W": "Sega", + "6X": "Wannado Edition", + "6Y": "LSP", + "6Z": "ITE Media", + "70": "Infogrames", + "71": "Interplay", + "72": "JVC", + "73": "Parker Brothers", + "75": "Sales Curve", + "78": "THQ", + "79": "Accolade", + "7A": "Triffix Entertainment", + "7C": "Microprose", + "7D": "Sierra", + "7F": "Kemco", + "7G": "Rage Software", + "7H": "Encore", + "7J": "Zoo Digital", + "7K": "BVM", + "7L": "Simon & Schuster", + "7M": "Asmik Ace", + "7N": "Empire Interactive", + "7Q": "Jester Interactive", + "7T": "Scholastic", + "7U": "Ignition Entertainment", + "7W": "Stadlbauer", + "80": "Misawa", + "81": "Teichiku", + "82": "Namco", + "83": "LOZC", + "84": "KOEI", + "86": "Tokuma Shoten", + "87": "Tsukuda Original", + "88": "DATAM-Polystar", + "8B": "Bulletproof Software", + "8C": "Vic Tokai", + "8E": "Character Soft", + "8F": "I'Max", + "8G": "Saurus", + "8J": "General Entertainment", + "8N": "Success", + "8P": "Sega Japan", + "90": "Takara Amusement", + "91": "Chunsoft", + "92": "Video System", + "93": "BEC", + "95": "Varie", + "96": "Yonezawa/S'pal", + "97": "Kaneko", + "99": "Victor Interactive Software", + "9A": "Nichibutsu", + "9B": "Tecmo", + "9C": "Imagineer", + "9F": "Nova", + "9G": "Den'Z", + "9H": "Bottom Up", + "9J": "TGL", + "9L": "Hasbro Japan", + "9N": "Marvelous Entertainment", + "9P": "Keynet", + "9Q": "Hands-On Entertainment", + A0: "Telenet", + A1: "Hori", + A4: "Konami", + A5: "K.Amusement Leasing", + A6: "Kawada", + A7: "Takara", + A9: "Technos Japan", + AA: "JVC", + AC: "Toei Animation", + AD: "Toho", + AF: "Namco", + AG: "Media Rings", + AH: "J-Wing", + AJ: "Pioneer LDC", + AK: "KID", + AL: "Mediafactory", + AP: "Infogrames Hudson", + AQ: "Kiratto/Ludic", + B0: "Acclaim Japan", + B1: "ASCII/Nexoft", + B2: "Bandai", + B4: "Enix", + B6: "HAL Laboratory", + B7: "SNK", + B9: "Pony Canyon", + BA: "Culture Brain", + BB: "Sunsoft", + BC: "Toshiba EMI", + BD: "Sony Imagesoft", + BF: "Sammy", + BG: "Magical", + BH: "Visco", + BJ: "Compile", + BL: "MTO", + BN: "Sunrise Interactive", + BP: "Global A Entertainment", + BQ: "Fuuki", + C0: "Taito", + C2: "Kemco", + C3: "Square", + C4: "Tokuma Shoten", + C5: "Data East", + C6: "Tonkin House", + C8: "Koei", + CA: "Konami/Palcom", + CB: "NTVIC/VAP", + CC: "Use", + CD: "Meldac", + CE: "Pony Canyon", + CF: "Angel/Sotsu Agency", + CG: "Yumedia", + CJ: "Boss", + CK: "Axela/Crea-Tech", + CL: "Sekaibunka-Sha", + CM: "Konami CE Osaka", + CP: "Enterbrain", + D0: "Taito/Disco", + D1: "Sofel", + D2: "Quest", + D3: "Sigma", + D4: "Ask Kodansha", + D6: "Naxat", + D7: "Copya System", + D8: "Capcom", + D9: "Banpresto", + DA: "TOMY", + DB: "LJN Japan", + DD: "NCS", + DE: "Human Entertainment", + DF: "Altron", + DG: "Jaleco", + DH: "Gaps", + DK: "Kodansha", + DN: "Elf", + E0: "Jaleco", + E2: "Yutaka", + E3: "Varie", + E4: "T&ESoft", + E5: "Epoch", + E7: "Athena", + E8: "Asmik", + E9: "Natsume", + EA: "King Records", + EB: "Atlus", + EC: "Epic/Sony Records", + EE: "IGS", + EG: "Chatnoir", + EH: "Right Stuff", + EL: "Spike", + EM: "Konami CE Tokyo", + EN: "Alphadream", + F0: "A Wave", + F1: "Motown Software", + F2: "Left Field Entertainment", + F3: "Extreme Entertainment", + F4: "TecMagik", + F9: "Cybersoft", + FB: "Psygnosis", + FE: "Davidson/Western Tech.", + G1: "PCCW Japan", + G4: "KiKi", + G5: "Open Sesame", + G6: "Sims", + G7: "Broccoli", + G8: "Avex", + G9: "D3 Publisher", + GB: "Konami CE Japan", + GD: "Square Enix", + IH: "Yojigen", +}; diff --git a/src/lib/systems/nds/nds-save-system-handler.ts b/src/lib/systems/nds/nds-save-system-handler.ts new file mode 100644 index 0000000..f340bcd --- /dev/null +++ b/src/lib/systems/nds/nds-save-system-handler.ts @@ -0,0 +1,213 @@ +/** + * System handler for Nintendo DS / 3DS save files. + * + * Used with the EMS NDS Adapter+, which can only read/write saves (not ROMs). + * The save data arrives via readROM() as the primary output. + */ + +import type { + SystemHandler, + ConfigValues, + CartridgeInfo, + ResolvedConfigField, + ValidationResult, + ReadConfig, + OutputFile, + VerificationHashes, + VerificationDB, + VerificationResult, +} from "@/lib/types"; +import { crc32, sha1Hex, sha256Hex, formatBytes } from "@/lib/core/hashing"; + +export class NDSSaveSystemHandler implements SystemHandler { + readonly systemId = "nds_save"; + readonly displayName = "NDS / 3DS Save"; + readonly fileExtension = ".sav"; + + getConfigFields( + _currentValues: ConfigValues, + autoDetected?: CartridgeInfo, + ): ResolvedConfigField[] { + const fields: ResolvedConfigField[] = []; + + if (autoDetected?.title) { + fields.push({ + key: "title", + label: "Game", + type: "readonly", + value: autoDetected.title, + autoDetected: true, + group: "cartridge", + order: 0, + }); + } + + if (autoDetected?.meta?.gameCode) { + fields.push({ + key: "gameCode", + label: "Game Code", + type: "readonly", + value: autoDetected.meta.gameCode as string, + autoDetected: true, + group: "cartridge", + order: 1, + }); + } + + if (autoDetected?.saveType) { + fields.push({ + key: "saveType", + label: "Save Type", + type: "readonly", + value: autoDetected.saveType, + autoDetected: true, + group: "cartridge", + order: 2, + }); + } + + if (autoDetected?.saveSize) { + fields.push({ + key: "saveSizeDisplay", + label: "Save Size", + type: "readonly", + value: formatBytes(autoDetected.saveSize), + autoDetected: true, + group: "cartridge", + order: 3, + }); + } + + return fields; + } + + validate(_values: ConfigValues): ValidationResult { + return { valid: true }; + } + + buildReadConfig(values: ConfigValues): ReadConfig { + return { + systemId: "nds_save", + params: { + saveSize: values.saveSizeBytes as number | undefined, + title: values.title as string | undefined, + gameCode: values.gameCode as string | undefined, + }, + }; + } + + buildOutputFile(rawData: Uint8Array, config: ReadConfig): OutputFile { + const title = config.params.title as string | undefined; + const gameCode = config.params.gameCode as string | undefined; + const basename = (title ?? gameCode ?? "nds_save") + .replace(/[^a-zA-Z0-9_ -]/g, "") + .trim() + .replace(/\s+/g, "_"); + + return { + data: rawData, + filename: `${basename}.sav`, + mimeType: "application/octet-stream", + meta: { + Format: "NDS Save", + ...(title ? { Title: title } : {}), + ...(gameCode ? { "Game Code": gameCode } : {}), + }, + }; + } + + async computeHashes(rawData: Uint8Array): Promise { + const [sha1, sha256] = await Promise.all([ + sha1Hex(rawData), + sha256Hex(rawData), + ]); + return { crc32: crc32(rawData), sha1, sha256, size: rawData.length }; + } + + verify( + _hashes: VerificationHashes, + _db: VerificationDB | null, + ): VerificationResult { + // No verification database for save files + return { matched: false, confidence: "none" }; + } + + /** + * Generic sanity checks on a freshly-read save dump. There's no + * NDS-wide save file format, so we can't verify content — but we + * CAN catch the three most common dumper bugs without game-specific + * knowledge: + * + * 1. **Size not a standard SPI chip size.** NDS save chips come + * in known capacities (4 Kbit / 64 Kbit / 512 Kbit EEPROM and + * 2/4/8 Mbit FLASH). A dump of any other size points at a + * miscounted-chunks bug. + * 2. **First half byte-identical to second half.** Classic sign + * of a stuck high-address line or chip misidentified as twice + * its real size — the driver reads each byte twice (once for + * the low half, once for the high half that wraps back). + * 3. **All bytes zero.** A real SPI chip always drives MISO to + * some value; an unresponsive chip either times out or returns + * all 0xFF (bus pulled high). All-0x00 means the firmware + * returned zero-padded response without ever clocking the + * chip. + */ + validateDump(data: Uint8Array): { ok: boolean; warnings: string[] } { + const warnings: string[] = []; + + const STANDARD_SIZES = new Set([ + 512, // 4 Kbit EEPROM + 8192, // 64 Kbit EEPROM + 65536, // 512 Kbit EEPROM + 131072, // 1 Mbit FLASH + 262144, // 2 Mbit FLASH + 524288, // 4 Mbit FLASH + 1048576, // 8 Mbit FLASH + 16777216, // 128 Mbit FLASH (rare; e.g. Professional baseball) + ]); + if (!STANDARD_SIZES.has(data.length)) { + warnings.push( + `Save size ${formatBytes(data.length)} is not a standard NDS save-chip capacity — likely a chunking bug`, + ); + } + + // Uniform-byte check first. If every byte is the same value, the mirror + // check below would trivially also fire but with less useful wording; + // skip it. all-0x00 means the firmware returned zero-padded responses + // without ever clocking the chip; all-0xFF means the bus was idle. + if (data.length > 0 && data.every((b) => b === data[0])) { + if (data[0] === 0) { + warnings.push( + "Dump is all zeros — the chip didn't respond; real dumps always contain some non-zero bytes (0xFF for unwritten regions)", + ); + } else if (data[0] === 0xff) { + warnings.push( + "Dump is all 0xFF — the chip is returning idle-bus bytes; save-read command may not be reaching the chip", + ); + } else { + warnings.push( + `Dump is all 0x${data[0].toString(16).padStart(2, "0")} — the chip is stuck, save-read command likely failed`, + ); + } + return { ok: false, warnings }; + } + + if (data.length >= 2 && data.length % 2 === 0) { + const half = data.length >>> 1; + let mirrored = true; + for (let i = 0; i < half; i++) { + if (data[i] !== data[half + i]) { + mirrored = false; + break; + } + } + if (mirrored) { + warnings.push( + "First half of the dump equals the second half — the chip may be smaller than the detected size (address wraps)", + ); + } + } + + return { ok: warnings.length === 0, warnings }; + } +} diff --git a/src/lib/transport/usb-transport.ts b/src/lib/transport/usb-transport.ts new file mode 100644 index 0000000..083fd02 --- /dev/null +++ b/src/lib/transport/usb-transport.ts @@ -0,0 +1,150 @@ +/** + * WebUSB transport for vendor-specific USB devices. + * + * Wraps the WebUSB API for devices that expose a vendor-specific interface + * with bulk endpoints. Supports configurable endpoint numbers and + * per-transfer endpoint overrides for devices with multiple IN endpoints. + */ + +import type { + Transport, + TransportEvents, + TransportConnectOptions, + TransferOptions, + DeviceIdentity, +} from "@/lib/types"; + +interface UsbDeviceFilter { + vendorId: number; + productId?: number; +} + +export class UsbTransport implements Transport { + readonly type = "webusb" as const; + + private device: USBDevice | null = null; + private events: Partial = {}; + private endpointIn = 1; + private endpointOut = 2; + private readonly filters: UsbDeviceFilter[]; + + constructor( + filters: UsbDeviceFilter[], + endpointIn?: number, + endpointOut?: number, + ) { + this.filters = filters; + if (endpointIn !== undefined) this.endpointIn = endpointIn; + if (endpointOut !== undefined) this.endpointOut = endpointOut; + } + + get connected(): boolean { + return this.device?.opened ?? false; + } + + /** + * Expose the underlying USBDevice so drivers can run out-of-band probes + * (e.g. sibling-protocol detection on shared VID/PID) without relying on + * `navigator.usb.getDevices().find(...)`, which can pick the wrong device + * when multiple matching units are paired. + */ + getDevice(): USBDevice | null { + return this.device; + } + + /** Prompt the user to select a USB device. */ + async connect(_options?: TransportConnectOptions): Promise { + const device = await navigator.usb!.requestDevice({ + filters: this.filters, + }); + return this.openDevice(device); + } + + /** Reconnect to a previously authorized device (no user gesture needed). */ + async connectWithDevice(device: USBDevice): Promise { + return this.openDevice(device); + } + + private async openDevice(device: USBDevice): Promise { + await device.open(); + await device.selectConfiguration(1); + await device.claimInterface(0); + + this.device = device; + + navigator.usb!.addEventListener("disconnect", this.onDisconnect); + + return { + vendorId: device.vendorId, + productId: device.productId, + name: device.productName ?? "USB Device", + serial: device.serialNumber, + transport: "webusb", + raw: device, + }; + } + + async disconnect(): Promise { + navigator.usb!.removeEventListener("disconnect", this.onDisconnect); + if (this.device?.opened) { + try { + await this.device.releaseInterface(0); + } catch { + // Best-effort + } + try { + await this.device.close(); + } catch { + // Best-effort + } + } + this.device = null; + } + + async send(data: Uint8Array, _options?: TransferOptions): Promise { + if (!this.device) throw new Error("USB device not connected"); + await this.device.transferOut( + this.endpointOut, + data as unknown as BufferSource, + ); + } + + async receive( + length: number, + options?: TransferOptions & { endpointIn?: number }, + ): Promise { + if (!this.device) throw new Error("USB device not connected"); + + const ep = options?.endpointIn ?? this.endpointIn; + const timeout = options?.timeout ?? 5000; + const result = await Promise.race([ + this.device.transferIn(ep, length), + new Promise((_, reject) => + setTimeout(() => reject(new Error("USB receive timeout")), timeout), + ), + ]); + + if (result.data) { + return new Uint8Array( + result.data.buffer, + result.data.byteOffset, + result.data.byteLength, + ); + } + return new Uint8Array(0); + } + + on( + event: K, + handler: TransportEvents[K], + ): void { + this.events[event] = handler; + } + + private onDisconnect = (event: USBConnectionEvent) => { + if (event.device === this.device) { + this.device = null; + this.events.onDisconnect?.(); + } + }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index a833672..bad5ed9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -52,7 +52,13 @@ export interface DeviceIdentity { // ─── Device Driver ────────────────────────────────────────────────────────── -export type SystemId = "gb" | "gbc" | "gba" | "amiibo" | string; +export type SystemId = + | "gb" + | "gbc" + | "gba" + | "amiibo" + | "nds_save" + | string; export interface DeviceCapability { systemId: SystemId; @@ -121,14 +127,14 @@ export interface DumpProgress { // ─── System Handler ───────────────────────────────────────────────────────── -export interface CartridgeInfo { +export interface CartridgeInfo> { title?: string; mapper?: MapperInfo; romSize?: number; saveSize?: number; saveType?: string; rawHeader?: Uint8Array; - meta?: Record; + meta?: M; } export interface MapperInfo {