diff --git a/README.md b/README.md
index 844440a..52b51e8 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ keeper of knowledge. Seemed fitting for a preservation tool.
| --- | --- | --- |
| [GBxCart RW](https://www.gbxcart.com/) v1.4 Pro | Web Serial | Game Boy, Game Boy Color, Game Boy Advance |
| [PowerSaves for Amiibo](https://www.yourpowersaves.com/) | WebHID | Amiibo (NTAG215) |
+| PowerSaves for 3DS | WebHID | DS cartridge saves |
This is still early. More hardware and more systems are in the works.
@@ -29,6 +30,7 @@ This is still early. More hardware and more systems are in the works.
- **Dumps ROMs** from Game Boy, Game Boy Color, and Game Boy Advance cartridges
- **Backs up save data** (SRAM, Flash, EEPROM)
+- **Backs up DS cartridge saves** via the PowerSaves 3DS adapter
- **Reads Amiibo** tags (and generic NTAG215 tags, best-effort)
- **Verifies dumps** against the No-Intro database using CRC32, SHA-1, and SHA-256
- **Auto-detects** the inserted cartridge -- title, mapper, ROM size, save type
@@ -58,5 +60,10 @@ npm run lint
See [THIRD-PARTY-LICENSES](THIRD-PARTY-LICENSES) for attribution of code
derived from [FlashGBX](https://github.com/lesserkuma/FlashGBX),
-[amiigo](https://github.com/malc0mn/amiigo), and
+[amiigo](https://github.com/malc0mn/amiigo),
+[powerslaves](https://github.com/kitlith/powerslaves),
+[ndstool](https://github.com/devkitPro/ndstool), and
[AmiiboAPI](https://github.com/N3evin/AmiiboAPI).
+
+PowerSaves and Datel are trademarks of Datel Ltd. nabu is not affiliated
+with or endorsed by Datel.
diff --git a/THIRD-PARTY-LICENSES b/THIRD-PARTY-LICENSES
index 9f82875..034851a 100644
--- a/THIRD-PARTY-LICENSES
+++ b/THIRD-PARTY-LICENSES
@@ -9,9 +9,10 @@ https://github.com/lesserkuma/FlashGBX
GBxCart RW command opcodes and device protocol sequences in
src/lib/drivers/gbxcart/ were ported from FlashGBX's LK_Device.py.
-License: GNU General Public License v3.0
+License: GNU General Public License v3.0 only (GPL-3.0-only)
-See LICENSE in this repository (same license applies to nabu as a whole).
+See LICENSE in this repository — the full GPL-3.0 text reproduced
+there applies to this component as well.
================================================================================
@@ -46,6 +47,51 @@ 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.
+
+================================================================================
+
+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 only (GPL-3.0-only)
+
+See LICENSE in this repository — the full GPL-3.0 text reproduced
+there applies to this component as well.
+
+================================================================================
+
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,
+ );
+
+ 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 adapter 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 && (
+ // Placeholders keep the grid layout stable — Save Type and
+ // Save Size are populated mid-read after probeSaveChip
+ // runs, so without placeholders cells would pop in.
+
+
+
+
+
+
+
+
+ )}
+
+
+ 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 && (
+
+ )}
+
+ )}
+
+ {/* Header-CRC failure — title/gameCode are not trustworthy
+ but the SPI save dump may still be valid. */}
+ {result.cartInfo.meta?.headerVerified === false &&
+ !result.cartInfo.meta?.is3DS && (
+
+ Cartridge identification uncertain
+
+ The on-cart header didn't pass CRC validation, so
+ the title and game code shown above may be wrong.
+ The save data dump may still be usable — download
+ it and try loading it in your emulator. If the
+ cart is a regular DS cart, re-seat it (its
+ contacts may be dirty) and dump again.
+
+
+ )}
+
+ {/* Dump-quality warnings — non-blocking, the user can still
+ download and try the file. */}
+ {result.warnings.length > 0 && (
+
+ Dump quality warning
+
+
+ {result.warnings.map((w, i) => (
+
{w}
+ ))}
+
+
+
+ )}
+
+ {/* Hashes + size / duration */}
+
+
+ CRC32:
+
+ {hexStr(result.hashes.crc32)}
+
+
+
+ SHA-1:
+
+ {result.hashes.sha1}
+
+
+
+ Size:
+
+ {formatBytes(result.data.length)}
+
+ · Duration:
+
+ {(result.durationMs / 1000).toFixed(1)}s
+
+
+
+
+ {/* Actions */}
+
+
+ Save .sav
+
+
+
+
+
+
+ Disconnect the adapter from USB to back up another save.
+
+ >
+ )}
+
+ );
+}
+
+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..f4cf75c 100644
--- a/src/hooks/use-connection.ts
+++ b/src/hooks/use-connection.ts
@@ -6,6 +6,8 @@ import { HidTransport } from "@/lib/transport/hid-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 { PowerSave3DSDriver } from "@/lib/drivers/powersave-3ds/powersave-3ds-driver";
+import { DEVICE_FILTERS as POWERSAVE_3DS_FILTERS } from "@/lib/drivers/powersave-3ds/powersave-3ds-commands";
import { InfinityDriver } from "@/lib/drivers/infinity/infinity-driver";
import { DEVICE_FILTERS as INFINITY_FILTERS } from "@/lib/drivers/infinity/infinity-commands";
import type { DeviceDriver, DeviceInfo, Transport } from "@/lib/types";
@@ -250,7 +252,7 @@ export function useConnection({ log, onReady }: UseConnectionOptions) {
}
});
- // Try HID (PowerSaves Portal or Disney Infinity Base)
+ // Try HID (PowerSaves Portal, PowerSaves 3DS, or Disney Infinity Base)
navigator.hid?.getDevices().then(async (devices) => {
const psDevice = devices.find((d) =>
POWERSAVE_FILTERS.some(
@@ -281,6 +283,35 @@ export function useConnection({ log, onReady }: UseConnectionOptions) {
return;
}
+ const ps3Device = devices.find((d) =>
+ POWERSAVE_3DS_FILTERS.some(
+ (f) => f.vendorId === d.vendorId && f.productId === d.productId,
+ ),
+ );
+ if (ps3Device) {
+ log("Reconnecting to HID device...");
+ try {
+ const transport = new HidTransport(POWERSAVE_3DS_FILTERS);
+ const identity = await transport.connectWithDevice(ps3Device);
+ log(`HID device opened: ${identity.name}`);
+
+ transport.on("onDisconnect", () => {
+ log("Device disconnected", "warn");
+ handleDisconnect();
+ });
+
+ const ps3Driver = new PowerSave3DSDriver(transport);
+ ps3Driver.on("onLog", (msg, level) => log(msg, level));
+
+ const info = await ps3Driver.initialize();
+ log(`Connected: ${info.deviceName}`);
+ finishConnect(ps3Driver, info, "POWERSAVE_3DS");
+ } catch (e) {
+ log(`Auto-reconnect failed: ${(e as Error).message}`, "warn");
+ }
+ return;
+ }
+
const infDevice = devices.find((d) =>
INFINITY_FILTERS.some(
(f) => f.vendorId === d.vendorId && f.productId === d.productId,
@@ -389,6 +420,31 @@ export function useConnection({ log, onReady }: UseConnectionOptions) {
break;
}
+ case "POWERSAVE_3DS": {
+ const transport = new HidTransport(POWERSAVE_3DS_FILTERS);
+ if (authorized) {
+ log("Connecting...");
+ await transport.connectWithDevice(authorized as HIDDevice);
+ } else {
+ log("Requesting HID device...");
+ await transport.connect();
+ }
+
+ transport.on("onDisconnect", () => {
+ log("Device disconnected", "warn");
+ handleDisconnect();
+ });
+
+ const ps3Driver = new PowerSave3DSDriver(transport);
+ ps3Driver.on("onLog", (msg, level) => log(msg, level));
+
+ log("Initializing device...");
+ const info = await ps3Driver.initialize();
+ log(`Connected: ${info.deviceName}`);
+ finishConnect(ps3Driver, info, deviceId);
+ break;
+ }
+
case "DISNEY_INFINITY": {
const transport = new HidTransport(INFINITY_FILTERS);
if (authorized) {
diff --git a/src/hooks/use-nds-scanner.ts b/src/hooks/use-nds-scanner.ts
new file mode 100644
index 0000000..4da18b7
--- /dev/null
+++ b/src/hooks/use-nds-scanner.ts
@@ -0,0 +1,182 @@
+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;
+ warnings: string[];
+}
+
+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,
+ warnings: validation.warnings,
+ });
+ setPhase("done");
+
+ // Deliberately do NOT resume polling here. Require the user to
+ // reconnect the device between cartridges; the scanner is
+ // rebuilt with a fresh driver instance on reconnect.
+ log(
+ "Dump complete. Disconnect the adapter 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 — require the user to reconnect the device.
+ }
+ };
+
+ 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..93907ae 100644
--- a/src/lib/core/devices.ts
+++ b/src/lib/core/devices.ts
@@ -37,6 +37,18 @@ export const DEVICES: Record = {
"Datel NFC portal. Also supports MaxLander/NaMiio clones. " +
"Protocol: github.com/malc0mn/amiigo",
},
+ POWERSAVE_3DS: {
+ id: "POWERSAVE_3DS",
+ name: "PowerSaves for 3DS",
+ vendorId: 0x1c1a,
+ productId: 0x03d5,
+ transport: "webhid",
+ systems: [{ id: "nds_save", name: "DS (Saves Only)" }],
+ notes:
+ "Datel PowerSaves — despite the 3DS branding, reads DS cart saves " +
+ "via the device's generic NTR + SPI passthrough. Protocol: " +
+ "github.com/kitlith/powerslaves (MIT).",
+ },
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..df6f320 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", "DS"],
};
// ─── IndexedDB persistence ──────────────────────────────────────────────
diff --git a/src/lib/drivers/powersave-3ds/powersave-3ds-commands.ts b/src/lib/drivers/powersave-3ds/powersave-3ds-commands.ts
new file mode 100644
index 0000000..c7a3d03
--- /dev/null
+++ b/src/lib/drivers/powersave-3ds/powersave-3ds-commands.ts
@@ -0,0 +1,70 @@
+// PowerSaves for 3DS — HID command bytes and packet framing.
+// Protocol reverse-engineered by kitlith: github.com/kitlith/powerslaves (MIT).
+//
+// Packet framing (64-byte HID output report, report-id 0x00):
+// byte 0 : opcode
+// bytes 1-2 : command length (little-endian)
+// bytes 3-4 : response length (little-endian)
+// bytes 5+ : command bytes, zero-padded to 64
+//
+// Opcode `0x08` triggers ARM SYSRESETREQ (confirmed 2026-04-23: device
+// disconnects in ~140 ms, re-enumerates as bcdDevice 0x0011 normal-mode in
+// ~236 ms total). We use it as a soft-reset primitive. Opcode `0x09` has
+// the same handler on the stub; if kept in the running firmware it would
+// also trigger reset. Opcode `0x99` plus a magic sequence triggers
+// firmware reflash — the `99 44 46 55` prefix kitlith documented drops
+// the device into bcdDevice 0.01 recovery mode. Never send `0x99`.
+
+export const CMD = {
+ /** Returns a 64-byte device identifier starting with ASCII "App". */
+ TEST: 0x02,
+ /** ARM SYSRESETREQ — soft-reset the MCU. USB re-enumerates ~236 ms later. */
+ RESET: 0x08,
+ /** Reset the MCU so a mode change can be issued. */
+ SWITCH_MODE: 0x10,
+ /** Enable cartridge-ROM protocol (NTR/CTR). */
+ ROM_MODE: 0x11,
+ /** Enable SPI passthrough to the save chip. */
+ SPI_MODE: 0x12,
+ /** NDS cartridge-ROM command (fixed 8 command bytes). */
+ NTR: 0x13,
+ /** 3DS cartridge-ROM command (fixed 16 command bytes). Not used here. */
+ CTR: 0x14,
+ /** Raw SPI passthrough (variable command length). */
+ SPI: 0x15,
+} as const;
+
+export const PACKET_SIZE = 64;
+export const COMMAND_TIMEOUT_MS = 2000;
+
+/** NTR ROM-protocol commands — byte 0 is the opcode, rest zero-padded. */
+export const NTR_CMD = {
+ /** Read 0x200-byte chunk from current ROM address (no args). */
+ READ_ROM: 0x00,
+ /** Read 4-byte chip ID. */
+ GET_CHIP_ID: 0x90,
+} as const;
+
+/** Standard SPI save-chip opcodes. */
+export const SPI_CMD = {
+ /** Read status register (1 reply byte). */
+ RDSR: 0x05,
+ /** Read data starting at 3-byte address (reply length = bytes wanted). */
+ READ: 0x03,
+ /** JEDEC ID query (3 reply bytes). FLASH only; EEPROM returns zeros. */
+ JEDEC_ID: 0x9f,
+} as const;
+
+/**
+ * Decode FLASH capacity byte (third byte of JEDEC response) to size in bytes.
+ * Common NDS save-FLASH chips:
+ * 0x13 = 512 KB 0x14 = 1 MB 0x15 = 2 MB 0x16 = 4 MB
+ */
+export function flashSizeFromJedec(capacityByte: number): number | null {
+ if (capacityByte < 0x10 || capacityByte > 0x1f) return null;
+ return 1 << capacityByte;
+}
+
+export const DEVICE_FILTERS: HIDDeviceFilter[] = [
+ { vendorId: 0x1c1a, productId: 0x03d5 }, // Datel PowerSaves for 3DS
+];
diff --git a/src/lib/drivers/powersave-3ds/powersave-3ds-driver.ts b/src/lib/drivers/powersave-3ds/powersave-3ds-driver.ts
new file mode 100644
index 0000000..6008353
--- /dev/null
+++ b/src/lib/drivers/powersave-3ds/powersave-3ds-driver.ts
@@ -0,0 +1,806 @@
+/**
+ * Datel PowerSaves for 3DS — device driver for DS cartridge save backup.
+ *
+ * Hardware
+ * --------
+ * USB HID slot-1 adapter. VID 0x1C1A PID 0x03D5 (normal firmware mode;
+ * bcdDevice 0.11). Speaks a 64-byte HID protocol over a single output /
+ * single input report and drives the cart slot-1 bus plus an SPI
+ * passthrough to the cart's save chip. A second USB configuration at
+ * bcdDevice 0.01 is a bootloader / recovery mode used by the vendor's
+ * firmware-update tool.
+ *
+ * Scope
+ * -----
+ * Read-only save backup for DS cartridges. Save writing and 3DS save
+ * editing are not implemented; ROM dumping is not supported by the
+ * device's firmware on DS carts.
+ *
+ * Firmware HID opcodes used by this driver
+ * ----------------------------------------
+ * 0x02 TEST Device identifier probe. Returns "App" + 61 bytes
+ * of fixed state.
+ * 0x08 RESET ARM SYSRESETREQ. USB disconnects and re-enumerates
+ * ~236 ms later. Used by softReset().
+ * 0x10 SWITCH_MODE Firmware reset so a mode change can land.
+ * 0x11 ROM_MODE Cart-ROM protocol path.
+ * 0x12 SPI_MODE SPI passthrough to the save chip.
+ * 0x13 NTR DS cart-bus command (8-byte cmd, variable response).
+ * The running firmware filters cmd[0]; for save backup
+ * we only need 0x9F (wake-up dummy), 0x90 (chip ID),
+ * and 0x00 (header read), all of which are forwarded
+ * to the cart unchanged.
+ * 0x15 SPI Raw SPI passthrough to the save chip. We send a
+ * 3-byte READ command `[0x03, addr_hi, addr_lo]` for
+ * 16-bit-addressed save chips (4 / 64 / 512 Kbit
+ * EEPROM) and a 4-byte command for 24-bit-addressed
+ * FLASH (2 / 4 / 8 Mbit). Up to 32 KB per response
+ * works; the packet header's 16-bit responseLen
+ * truncates 64 KB to zero, so larger requests fail.
+ *
+ * Protocol source: github.com/kitlith/powerslaves (MIT). The packet-
+ * framing layout, mode-switch sequence, and SPI/NTR primitives in this
+ * driver were ported from that reference.
+ */
+
+import type {
+ DeviceDriverEvents,
+ DeviceCapability,
+ DeviceInfo,
+ ReadConfig,
+ DumpProgress,
+ SystemId,
+ DetectSystemResult,
+} from "@/lib/types";
+import type { HidTransport } from "@/lib/transport/hid-transport";
+import {
+ CMD,
+ NTR_CMD,
+ SPI_CMD,
+ PACKET_SIZE,
+ COMMAND_TIMEOUT_MS,
+ flashSizeFromJedec,
+} from "./powersave-3ds-commands";
+import { MAKER_CODES } from "@/lib/systems/nds/nds-maker-codes";
+import {
+ parseNDSHeader,
+ type CardHeader,
+ type NDSCartridgeInfo,
+ type NDSDeviceDriver,
+} from "@/lib/systems/nds/nds-header";
+import { formatBytes } from "@/lib/core/hashing";
+
+function parseHeader(raw: Uint8Array): CardHeader {
+ return parseNDSHeader(raw, MAKER_CODES);
+}
+
+function buildPacket(
+ opcode: number,
+ cmdBytes: Uint8Array,
+ responseLen: number,
+): Uint8Array {
+ const packet = new Uint8Array(PACKET_SIZE);
+ packet[0] = opcode;
+ packet[1] = cmdBytes.length & 0xff;
+ packet[2] = (cmdBytes.length >> 8) & 0xff;
+ packet[3] = responseLen & 0xff;
+ packet[4] = (responseLen >> 8) & 0xff;
+ packet.set(cmdBytes.subarray(0, Math.min(cmdBytes.length, PACKET_SIZE - 5)), 5);
+ return packet;
+}
+
+// Each SPI READ request streams its entire response back as a sequence of
+// 64-byte HID input reports while CS stays asserted. Up to 32 KB reads
+// work correctly with the 2-byte addressing format; beyond that, 64 KB
+// truncates to 0 when cast to the 16-bit responseLen field of the HID
+// protocol header. But there's no measurable speedup from bigger chunks:
+// a 64 KB save dumps in 1042 ms at 4 KB chunks vs 1029 ms at 32 KB. USB
+// Full-Speed's 64-byte interrupt packets at 1 ms intervals cap the bus
+// at ~62 KB/s, so the limit is packet count, not request overhead.
+const SAVE_READ_CHUNK = 0x1000;
+
+export class PowerSave3DSDriver implements NDSDeviceDriver {
+ readonly id = "POWERSAVE_3DS";
+ readonly name = "PowerSaves for 3DS";
+ readonly capabilities: DeviceCapability[] = [
+ { systemId: "nds_save", operations: ["dump_save"], autoDetect: true },
+ ];
+
+ readonly transport: HidTransport;
+ private events: Partial = {};
+ private header: CardHeader | null = null;
+ private headerChipId = "";
+ private saveSize = 0;
+ private saveTypeName: string | undefined = undefined;
+
+ /** Firmware mode currently selected, or null if not yet known. */
+ private currentMode: number | null = null;
+
+ /**
+ * Stashed during readROM for sendCommand's wait loop to observe. Without
+ * it, a stalled receive blocks on the 2 s timeout before the user's
+ * cancel is seen — painful across hundreds of chunks.
+ */
+ private currentSignal: AbortSignal | null = null;
+
+ /**
+ * Whether the cart has received its NTR wake-up dummy since the last
+ * entry into ROM_MODE. Every mode change resets this — the cart loses
+ * NTR state whenever the firmware switches modes.
+ */
+ private romInited = false;
+
+ /**
+ * Persistent inbox for HID input reports. The firmware occasionally sends
+ * slightly more bytes than requested (internal pipeline artefact); if we
+ * swapped per-request listeners those stragglers would land in the next
+ * response and shift its framing. With one lifetime listener draining into
+ * this queue, we drain the queue before each send() and then pull exactly
+ * responseLen bytes out.
+ */
+ private inbox: Uint8Array[] = [];
+ private inboxLen = 0;
+ private inboxWaiter: (() => void) | null = null;
+
+ constructor(transport: HidTransport) {
+ this.transport = transport;
+ this.transport.setInputListener((bytes) => {
+ this.inbox.push(bytes);
+ this.inboxLen += bytes.length;
+ this.inboxWaiter?.();
+ });
+ }
+
+ async initialize(): Promise {
+ const id = await this.sendCommand(CMD.TEST, new Uint8Array(0), 0x40);
+ // Expected prefix: ASCII "App" (0x41 0x70 0x70).
+ if (id[0] !== 0x41 || id[1] !== 0x70 || id[2] !== 0x70) {
+ throw new Error(
+ "Device did not respond with the expected identifier. " +
+ "This may not be a PowerSaves for 3DS.",
+ );
+ }
+ this.log(`Identifier: ${Array.from(id.slice(0, 16), hex).join(" ")}`);
+
+ return {
+ firmwareVersion: "",
+ 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 };
+ }
+
+ /**
+ * Enter ROM mode, probe the cart via NTR Get Chip ID, and read the header
+ * on first detection (cached until the chip ID changes or the cart is
+ * removed). Returns enriched CartridgeInfo so the scanner can show the
+ * detected game before the user confirms a (slow) save dump.
+ */
+ async detectCartridge(
+ _systemId: SystemId,
+ ): Promise {
+ await this.ensureRomInit();
+ const ntr = new Uint8Array(8);
+ ntr[0] = NTR_CMD.GET_CHIP_ID;
+ let id = await this.sendCommand(CMD.NTR, ntr, 4);
+
+ // If the chip-ID read comes back all-zero or all-0xFF, the cart may
+ // be genuinely absent — or the cart bus may be in a transient state
+ // from an earlier read. Before reporting "no cartridge," force a
+ // full mode cycle (SWITCH_MODE → ROM_MODE → NTR 0x9F dummy) and
+ // retry once.
+ if (id.every((b) => b === 0x00) || id.every((b) => b === 0xff)) {
+ this.currentMode = null;
+ this.romInited = false;
+ await this.ensureRomInit();
+ id = await this.sendCommand(CMD.NTR, ntr, 4);
+ }
+
+ if (id.every((b) => b === 0x00) || id.every((b) => b === 0xff)) {
+ this.header = null;
+ this.headerChipId = "";
+ this.saveSize = 0;
+ return null;
+ }
+
+ const chipIdHex = Array.from(id, hex).join("");
+ if (!this.header || this.headerChipId !== chipIdHex) {
+ // Read the header, and retry up to 3 times if CRC-16 validation
+ // fails. parseNDSHeader validates both the header CRC (at 0x15E)
+ // and the cart-logo CRC (at 0x15C); if either fails the read was
+ // corrupted (e.g. cart bus in a transitional state, or an
+ // ephemeral preamble quirk that dodged findHeaderStart's
+ // heuristic). A full mode cycle + re-wake is usually enough to
+ // get a clean read on the next attempt.
+ let parsed: CardHeader | null = null;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const headerBytes = await this.sendCommand(
+ CMD.NTR,
+ new Uint8Array(8),
+ 0x200,
+ );
+ parsed = parseHeader(headerBytes);
+ if (parsed.validHeader) break;
+ this.log(
+ `Header CRC mismatch on attempt ${attempt + 1}; cycling ROM mode and retrying.`,
+ "warn",
+ );
+ this.currentMode = null;
+ this.romInited = false;
+ await this.ensureRomInit();
+ }
+ this.header = parsed;
+ this.headerChipId = chipIdHex;
+ this.saveSize = 0;
+ }
+
+ return this.buildCartInfo();
+ }
+
+ /**
+ * Read cart header (NTR) + identify save chip (SPI) + dump save data.
+ * The save data is returned as the primary output: this is a save-only
+ * device, so readROM surfaces the save bytes directly.
+ */
+ async readROM(config: ReadConfig, signal?: AbortSignal): Promise {
+ void config;
+ this.currentSignal = signal ?? null;
+ try {
+ // Re-verify the cart hasn't been swapped since the scanner's last
+ // detect. No-op on first dump (nothing cached yet).
+ await this.verifyCartUnchanged();
+
+ // Header is normally read by detectCartridge() during polling. If the
+ // user jumped past polling (mock flows, re-entry), run a detect first.
+ if (!this.header) {
+ const info = await this.detectCartridge("nds_save");
+ if (!info) {
+ throw new Error("No cartridge present — insert a DS cart and retry.");
+ }
+ if (!this.header) {
+ throw new Error(
+ "Cartridge detected but header read failed. Re-seat and retry.",
+ );
+ }
+ }
+ if (this.header.validHeader) {
+ this.log(
+ `Card: ${this.header.title} [${this.header.gameCode}] — ${this.header.romSizeMiB} MiB ROM`,
+ );
+ } else if (this.header.headerAllFF) {
+ // TODO: 3DS save dumping via this SPI path is unconfirmed — we
+ // have no test reports either way. If you have a 3DS cart on
+ // hand, dump it through this branch and report whether the
+ // resulting save file is valid.
+ this.log(
+ "3DS cartridge detected (all-0xFF DS-format header) — dumping " +
+ "save via the SPI path. No DS-format header available.",
+ "warn",
+ );
+ } else {
+ // Header returned non-0xFF data that failed CRC validation. The
+ // most likely cause is a 3DS cart returning encrypted bytes on
+ // the DS header-read path. The save chip sits on a separate SPI
+ // bus and doesn't participate in cart-bus encryption, so the
+ // save dump can still work. Could also be a DS cart with dirty
+ // contacts — same recovery: try the dump.
+ this.log(
+ "Header failed CRC validation — attempting save dump anyway.",
+ "warn",
+ );
+ }
+
+ // Step 1: switch to SPI mode and probe the save chip.
+ await this.modeChange(CMD.SPI_MODE);
+ const { size, typeName, addrWidth } = await this.probeSaveChip();
+ this.saveSize = size;
+ this.saveTypeName = typeName;
+ this.log(`Save: ${typeName}`);
+
+ // Step 2: dump save data.
+ const saveData = await this.readSaveData(size, addrWidth, signal);
+
+ // Step 3: 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;
+ } finally {
+ this.currentSignal = null;
+ }
+ }
+
+ async readSave(
+ config: ReadConfig,
+ signal?: AbortSignal,
+ ): Promise {
+ return this.readROM(config, signal);
+ }
+
+ async writeSave(
+ _data: Uint8Array,
+ _config: ReadConfig,
+ _signal?: AbortSignal,
+ ): Promise {
+ throw new Error("Save writing not yet implemented for PowerSaves 3DS");
+ }
+
+ /**
+ * Fire ARM SYSRESETREQ on the MCU. Confirmed behaviour: opcode 0x08 causes
+ * the firmware to write `0x05FA0004` to `SCB->AIRCR`, the CPU resets,
+ * USB disconnects in ~140 ms, and the device re-enumerates as
+ * bcdDevice 0x0011 (normal mode) in ~236 ms. The firmware does not
+ * ACK — by the time the MCU would send a reply it's already resetting,
+ * so we don't wait for one. Returns once the disconnect event fires.
+ *
+ * **Only works on a healthy firmware.** Once the firmware is already
+ * wedged (HID OUT not being serviced — any `sendReport` throws
+ * "Failed to write the report"), 0x08 can't be delivered either. In
+ * that state a physical power-cycle is the only recovery.
+ *
+ * Even on a successful reset, Chrome on Linux loses the HID permission
+ * grant on USB re-enumeration (new device instance → permission store
+ * mismatch). Caller must trigger a fresh `navigator.hid.requestDevice()`
+ * from a user gesture to re-pair.
+ */
+ async softReset(timeoutMs = 2000): Promise {
+ const disconnected = new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () =>
+ reject(
+ new Error(
+ "Soft-reset timed out — 0x08 may have been repurposed on this firmware build.",
+ ),
+ ),
+ timeoutMs,
+ );
+ const onDisconnect = (ev: HIDConnectionEvent) => {
+ if (
+ ev.device.vendorId === 0x1c1a &&
+ ev.device.productId === 0x03d5
+ ) {
+ clearTimeout(timer);
+ navigator.hid!.removeEventListener(
+ "disconnect",
+ onDisconnect as EventListener,
+ );
+ resolve();
+ }
+ };
+ navigator.hid!.addEventListener(
+ "disconnect",
+ onDisconnect as EventListener,
+ );
+ });
+
+ try {
+ await this.sendCommand(CMD.RESET, new Uint8Array(0), 0, 500);
+ } catch (e) {
+ if (
+ !(e instanceof WedgeError) &&
+ !(e as Error).message.includes("timeout")
+ ) {
+ throw e;
+ }
+ }
+
+ await disconnected;
+ }
+
+ on(
+ event: K,
+ handler: DeviceDriverEvents[K],
+ ): void {
+ this.events[event] = handler;
+ }
+
+ /**
+ * Release the HID input listener and clear buffered state. In practice
+ * the transport only stores one listener (so a replacement driver
+ * overrides the old one automatically), but calling this explicitly on
+ * teardown avoids relying on that detail and stops inbox growth from
+ * any late-arriving stragglers between drivers.
+ */
+ dispose(): void {
+ this.transport.setInputListener(null);
+ this.inbox.length = 0;
+ this.inboxLen = 0;
+ this.inboxWaiter = null;
+ this.currentSignal = null;
+ }
+
+ /** Enriched cart info — available as soon as detectCartridge() succeeds. */
+ get cartInfo(): NDSCartridgeInfo | null {
+ return this.buildCartInfo();
+ }
+
+ private buildCartInfo(): NDSCartridgeInfo | null {
+ if (!this.header) return null;
+ const valid = this.header.validHeader;
+ return {
+ title: valid ? this.header.title : "Unrecognized cartridge",
+ saveSize: this.saveSize || undefined,
+ saveType: this.saveTypeName,
+ rawHeader: valid ? this.header.raw : undefined,
+ 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.headerChipId || undefined,
+ is3DS: this.header.headerAllFF,
+ headerVerified: valid,
+ },
+ };
+ }
+
+ /**
+ * Abort the dump if the currently-inserted cart isn't the one the scanner
+ * cached. Compares a fresh NTR GET_CHIP_ID against the chip ID captured
+ * at detect time. Without this, a cart swapped between scan and dump
+ * would be dumped under the old cart's title and hashed against the
+ * wrong No-Intro entry.
+ */
+ private async verifyCartUnchanged(): Promise {
+ const cached = this.headerChipId;
+ if (!cached) return;
+
+ await this.ensureRomInit();
+ const ntr = new Uint8Array(8);
+ ntr[0] = NTR_CMD.GET_CHIP_ID;
+ const id = await this.sendCommand(CMD.NTR, ntr, 4);
+ const fresh = Array.from(id, hex).join("");
+
+ if (fresh === cached) return;
+
+ if (id.every((b) => b === 0x00) || id.every((b) => b === 0xff)) {
+ throw new Error(
+ "Cartridge removed since scan — re-insert and re-scan before dumping.",
+ );
+ }
+ throw new Error(
+ `Cartridge changed since scan (chip ID ${cached} → ${fresh}). ` +
+ "Re-scan the new cart before dumping.",
+ );
+ }
+
+ // ─── Protocol helpers ───────────────────────────────────────────────────
+
+ /**
+ * SWITCH_MODE → target mode → TEST, as the reference `powerslaves_mode()` does.
+ * No-op if the firmware is already in the requested mode.
+ */
+ private async modeChange(targetMode: number): Promise {
+ if (this.currentMode === targetMode) return;
+ await this.sendCommand(CMD.SWITCH_MODE, new Uint8Array(0), 0);
+ await this.sendCommand(targetMode, new Uint8Array(0), 0);
+ await this.sendCommand(CMD.TEST, new Uint8Array(0), 0x40);
+ this.currentMode = targetMode;
+ // Any mode transition resets NDS cart state.
+ this.romInited = false;
+ }
+
+ /**
+ * Ensure the firmware is in ROM mode AND the inserted cart has received
+ * its wake-up dummy command. Without this, some carts return garbage
+ * for header reads. The dummy is NTR 0x9F with 0x2000 response bytes
+ * that we discard, per kitlith/powerslaves's header.c example.
+ */
+ private async ensureRomInit(): Promise {
+ await this.modeChange(CMD.ROM_MODE);
+ if (this.romInited) return;
+ const dummy = new Uint8Array(8);
+ dummy[0] = 0x9f;
+ await this.sendCommand(CMD.NTR, dummy, 0x2000);
+ this.romInited = true;
+ }
+
+ /**
+ * DS save chips use different SPI address widths depending on type:
+ * - "Tiny" 256-byte EEPROM: 1-byte address
+ * - 4 / 64 / 512 Kbit EEPROM: 2-byte address (big-endian)
+ * - 2 Mbit+ FLASH: 3-byte address
+ *
+ * The firmware sends `cmd` on MOSI and captures MISO for `len` bytes,
+ * but it doesn't know the chip's address width — so if we send a
+ * 4-byte cmd to a 16-bit-addressed chip the 4th byte gets interpreted
+ * as something else, shifting every read. probeSaveChip picks the
+ * right width per chip.
+ */
+ private async spiReadAddr(
+ addr: number,
+ len: number,
+ addrWidth: 1 | 2 | 3,
+ ): Promise {
+ const cmd = new Uint8Array(1 + addrWidth);
+ cmd[0] = SPI_CMD.READ;
+ if (addrWidth === 3) {
+ cmd[1] = (addr >> 16) & 0xff;
+ cmd[2] = (addr >> 8) & 0xff;
+ cmd[3] = addr & 0xff;
+ } else if (addrWidth === 2) {
+ cmd[1] = (addr >> 8) & 0xff;
+ cmd[2] = addr & 0xff;
+ } else {
+ cmd[1] = addr & 0xff;
+ }
+ return this.sendCommand(CMD.SPI, cmd, len);
+ }
+
+ /**
+ * Identify save chip and determine size.
+ *
+ * - SPI FLASH chips answer JEDEC ID (0x9F); capacity byte gives the size.
+ * FLASH uses 3-byte SPI addressing (2 Mbit+).
+ * - "Tiny" 256-byte EEPROM (the smallest DS save size) uses 1-byte SPI
+ * addressing. We probe that case first — 1-byte addressing is wrong
+ * for any larger chip, so a wrap match at 256 bytes uniquely
+ * identifies it.
+ * - 4 / 64 / 512 Kbit EEPROM and FRAM all use 2-byte SPI addressing.
+ * Wrap-detection: for each standard size `sz`, the 16-byte read at
+ * address `sz - 1` straddles the chip boundary; byte 0 is `chip[sz-1]`
+ * and bytes 1..15 wrap to `chip[0..14]` — which equals `base[0..14]`.
+ * If the match holds, the chip is exactly `sz` bytes.
+ */
+ private async probeSaveChip(): Promise<{
+ size: number;
+ typeName: string;
+ addrWidth: 1 | 2 | 3;
+ }> {
+ const jedecResp = await this.sendCommand(
+ CMD.SPI,
+ new Uint8Array([SPI_CMD.JEDEC_ID]),
+ 3,
+ );
+ const jedecHex = Array.from(jedecResp, hex).join(" ");
+
+ if (jedecResp.some((b) => b !== 0x00 && b !== 0xff)) {
+ const flashSize = flashSizeFromJedec(jedecResp[2]);
+ if (!flashSize) {
+ throw new Error(
+ `FLASH chip with unrecognised capacity byte 0x${hex(jedecResp[2])} ` +
+ `(JEDEC: ${jedecHex}). Please report this cart.`,
+ );
+ }
+ this.log(`Save chip JEDEC ID: ${jedecHex}`);
+ return {
+ size: flashSize,
+ typeName: `FLASH ${formatBytes(flashSize)}`,
+ addrWidth: 3,
+ };
+ }
+
+ // Tiny-EEPROM probe (256 B, 1-byte addressing). Try this before the
+ // 2-byte path: a 256-byte chip given a 2-byte SPI address treats the
+ // second address byte as data, shifting every subsequent read.
+ const tinyBase = await this.spiReadAddr(0, 16, 1);
+ if (!tinyBase.every((b) => b === tinyBase[0])) {
+ const wrap = await this.spiReadAddr(0xff, 16, 1);
+ let wraps = true;
+ for (let i = 0; i < 15; i++) {
+ if (wrap[i + 1] !== tinyBase[i]) {
+ wraps = false;
+ break;
+ }
+ }
+ if (wraps) {
+ return { size: 0x100, typeName: "EEPROM 256 B", addrWidth: 1 };
+ }
+ }
+
+ // 2-byte addressing path: 4 / 64 / 512 Kbit EEPROM.
+ const base = await this.spiReadAddr(0, 16, 2);
+ if (base.every((b) => b === base[0])) {
+ this.log(
+ "Save-chip size could not be auto-detected (save may be empty); " +
+ "defaulting to 64 KB (512 Kbit EEPROM).",
+ "warn",
+ );
+ return { size: 65536, typeName: "EEPROM 64 KB (assumed)", addrWidth: 2 };
+ }
+
+ const candidates = [0x200, 0x2000, 0x10000];
+ for (const sz of candidates) {
+ const r = await this.spiReadAddr(sz - 1, 16, 2);
+ let wraps = true;
+ for (let i = 0; i < 15; i++) {
+ if (r[i + 1] !== base[i]) {
+ wraps = false;
+ break;
+ }
+ }
+ if (wraps) {
+ return {
+ size: sz,
+ typeName: `EEPROM ${formatBytes(sz)}`,
+ addrWidth: 2,
+ };
+ }
+ }
+
+ // No wrap detected at any standard size — chip is bigger than 64 KB.
+ // DS EEPROM doesn't go above 64 KB; a larger chip would usually
+ // answer JEDEC_ID and take the FLASH path. Default to 64 KB.
+ this.log(
+ "No EEPROM wrap boundary detected at 0x200 / 0x2000 / 0x10000; " +
+ "defaulting to 64 KB.",
+ "warn",
+ );
+ return { size: 65536, typeName: "EEPROM 64 KB (assumed)", addrWidth: 2 };
+ }
+
+ private async readSaveData(
+ size: number,
+ addrWidth: 1 | 2 | 3,
+ signal?: AbortSignal,
+ ): Promise {
+ const out = new Uint8Array(size);
+ let offset = 0;
+ while (offset < size) {
+ if (signal?.aborted) throw new Error("Aborted");
+ const n = Math.min(SAVE_READ_CHUNK, size - offset);
+ const chunk = await this.spiReadAddr(offset, n, addrWidth);
+ out.set(chunk, offset);
+ offset += n;
+ this.emitProgress("save", offset, size);
+ }
+ return out;
+ }
+
+ /**
+ * Send one PowerSaves packet and (optionally) collect a response.
+ *
+ * The firmware occasionally sends MORE bytes than the requested
+ * responseLen asks for — and sometimes sends data even when
+ * responseLen=0 (opcode 0x02 TEST, for example, always pushes a
+ * 64-byte HID report back regardless). Those extra bytes arrive
+ * asynchronously via the HID input listener. If we clear the inbox
+ * at the START of the next command, there's a race: stragglers in
+ * flight may land AFTER our clear, contaminating the new response's
+ * leading bytes as a fake "preamble" that looks like cart data but
+ * isn't.
+ *
+ * Fix: drain at both ends. Before sending, wait briefly for any
+ * in-flight stragglers to land, THEN clear. After receiving the
+ * requested bytes, wait briefly again and discard anything extra.
+ * That way the inbox boundary is clean when the next caller starts.
+ *
+ * HID write failures ("Failed to write the report") are surfaced as
+ * `WedgeError` so callers can distinguish firmware-wedge from ordinary
+ * protocol errors and trigger `softReset()`.
+ */
+ private async sendCommand(
+ opcode: number,
+ cmdBytes: Uint8Array,
+ responseLen: number,
+ timeoutMs: number = COMMAND_TIMEOUT_MS,
+ ): Promise {
+ if (this.currentSignal?.aborted) throw new Error("Aborted");
+
+ // Let any in-flight stragglers from a prior command land, THEN clear.
+ // A single 5 ms yield is long enough for pending HID input events to
+ // be dispatched through the browser's event loop; without it they'd
+ // arrive after our clear and contaminate the next response.
+ await new Promise((r) => setTimeout(r, 5));
+ this.inbox.length = 0;
+ this.inboxLen = 0;
+
+ const packet = buildPacket(opcode, cmdBytes, responseLen);
+ try {
+ await this.transport.send(packet);
+ } catch (e) {
+ const msg = (e as Error).message ?? String(e);
+ if (msg.includes("Failed to write the report")) {
+ throw new WedgeError(msg);
+ }
+ throw e;
+ }
+
+ if (responseLen === 0) return new Uint8Array(0);
+
+ await new Promise((resolve, reject) => {
+ const sig = this.currentSignal;
+ let aborted = false;
+ const onAbort = () => {
+ aborted = true;
+ clearTimeout(timer);
+ this.inboxWaiter = null;
+ reject(new Error("Aborted"));
+ };
+ const timer = setTimeout(() => {
+ this.inboxWaiter = null;
+ sig?.removeEventListener("abort", onAbort);
+ // Race guard: if bytes arrived at the exact deadline, resolve
+ // rather than reject. The listener-side check might have missed
+ // the final event if it landed the same microtask as the timer.
+ if (this.inboxLen >= responseLen) {
+ resolve();
+ return;
+ }
+ reject(
+ new Error(
+ `PowerSaves receive timeout (got ${this.inboxLen}/${responseLen} bytes)`,
+ ),
+ );
+ }, timeoutMs);
+
+ const check = () => {
+ if (aborted) return;
+ if (this.inboxLen >= responseLen) {
+ clearTimeout(timer);
+ this.inboxWaiter = null;
+ sig?.removeEventListener("abort", onAbort);
+ resolve();
+ }
+ };
+ this.inboxWaiter = check;
+
+ if (sig?.aborted) {
+ onAbort();
+ return;
+ }
+ sig?.addEventListener("abort", onAbort, { once: true });
+ check();
+ });
+
+ const result = new Uint8Array(responseLen);
+ let offset = 0;
+ while (offset < responseLen) {
+ const chunk = this.inbox.shift();
+ if (!chunk) break; // shouldn't happen — we waited for inboxLen >= responseLen
+ const take = Math.min(chunk.length, responseLen - offset);
+ result.set(chunk.subarray(0, take), offset);
+ offset += take;
+ this.inboxLen -= take;
+ if (take < chunk.length) {
+ // Leftover from this chunk is residue; drop it on the next send().
+ this.inbox.unshift(chunk.subarray(take));
+ }
+ }
+ return result;
+ }
+
+ 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);
+ }
+}
+
+function hex(b: number): string {
+ return b.toString(16).padStart(2, "0");
+}
+
+/**
+ * Thrown when the HID endpoint refuses a write (typically because the
+ * firmware has wedged). Distinct from normal protocol errors so callers
+ * can offer `softReset()` as a recovery path.
+ */
+export class WedgeError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "WedgeError";
+ }
+}
diff --git a/src/lib/systems/nds/nds-header.ts b/src/lib/systems/nds/nds-header.ts
new file mode 100644
index 0000000..87bbf79
--- /dev/null
+++ b/src/lib/systems/nds/nds-header.ts
@@ -0,0 +1,256 @@
+import type {
+ DeviceDriver,
+ CartridgeInfo,
+ SystemId,
+} from "@/lib/types";
+
+/**
+ * DS cart-header parsing.
+ *
+ * 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 Boot logo bitmap (156 bytes, byte-identical on every
+ * real DS 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 DS-format
+ * 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 boot logo at 0x0C0..0x15B on every real DS cart. */
+export const BOOT_LOGO_CRC = 0xcf56;
+
+/** Parsed DS 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 uses a non-DS slot-1 format (i.e. it's a 3DS cart). */
+ headerAllFF: boolean;
+ raw: Uint8Array;
+}
+
+/**
+ * CRC-16/MODBUS — polynomial 0xA001 (reflected 0x8005), init 0xFFFF,
+ * no final XOR. The DS cart format 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 DS
+ * header begins. Some firmwares return a short preamble before the
+ * real header bytes. 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 DS 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 a DS 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 === BOOT_LOGO_CRC;
+ }
+
+ return {
+ title,
+ gameCode,
+ makerCode,
+ region,
+ romVersion,
+ romSizeMiB,
+ validHeader,
+ headerAllFF: false,
+ raw,
+ };
+}
+
+/**
+ * Typed cart metadata returned by DS-system drivers. 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;
+ /** 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 a DS cart with
+ * dirty contacts — the driver logs a warning in both cases.
+ */
+ is3DS?: boolean;
+ /**
+ * True if the on-cart DS header passed both CRC-16 checks (header CRC
+ * at 0x15E plus the boot-logo CRC at 0x15C). False means the read
+ * returned non-0xFF data that didn't validate — possible causes are a
+ * DS cart with dirty contacts or a 3DS cart returning encrypted bytes
+ * on the DS-format read path. In that case, title/gameCode/region are
+ * not trustworthy, but the SPI save dump can still succeed.
+ */
+ headerVerified?: boolean;
+};
+
+export type NDSCartridgeInfo = CartridgeInfo;
+
+/**
+ * Specialization of DeviceDriver for DS-system drivers. Consumers that
+ * need the enriched cart info (scanner hook, wizard UI) should accept
+ * this narrower type.
+ */
+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..6e7c9f2
--- /dev/null
+++ b/src/lib/systems/nds/nds-maker-codes.ts
@@ -0,0 +1,318 @@
+/**
+ * Maker / licensee codes (2-character ASCII).
+ * Used in DS cart 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..4a5cba8
--- /dev/null
+++ b/src/lib/systems/nds/nds-save-system-handler.ts
@@ -0,0 +1,218 @@
+/**
+ * System handler for DS / 3DS save files. Save-only devices surface the
+ * save data 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
+ * DS-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.** DS 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([
+ 256, // 0.5 KB / 4 Kbit "tiny" EEPROM (1-byte address)
+ 512, // 4 Kbit EEPROM
+ 8192, // 64 Kbit EEPROM
+ 65536, // 512 Kbit EEPROM
+ 262144, // 2 Mbit FLASH
+ 524288, // 4 Mbit FLASH
+ 1048576, // 8 Mbit FLASH
+ 2097152, // 16 Mbit FLASH
+ 4194304, // 32 Mbit FLASH
+ 8388608, // 64 Mbit FLASH
+ 16777216, // 128 Mbit FLASH (rare)
+ ]);
+ if (!STANDARD_SIZES.has(data.length)) {
+ warnings.push(
+ `Save size ${formatBytes(data.length)} is not a standard DS 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 zero bytes. The cartridge may have been blank " +
+ "(brand-new save), the contacts may be dirty, or the read " +
+ "may have failed. Re-seat the cartridge and dump again to compare.",
+ );
+ } else if (data[0] === 0xff) {
+ warnings.push(
+ "Dump is all 0xFF — the save chip didn't respond and the bus " +
+ "stayed idle. Re-seat the cartridge and dump again.",
+ );
+ } else {
+ warnings.push(
+ `Dump is all 0x${data[0].toString(16).padStart(2, "0")} — the ` +
+ "save chip appears stuck. Re-seat the cartridge and dump again.",
+ );
+ }
+ 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/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 {