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}
+
+ )}
+
+ )}
+
+ Disconnect
+
+
+
+ {/* 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 */}
+
+
+ Save File
+
+
+
+
+
+
+ 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 {