From 4982e60dfd0be06dae3310691836558a0bba5a38 Mon Sep 17 00:00:00 2001 From: Pat Hawks Date: Sat, 25 Apr 2026 09:19:45 -0500 Subject: [PATCH] =?UTF-8?q?Add=20PowerSaves=20for=203DS=20device=20support?= =?UTF-8?q?=20=E2=80=94=20DS=20cartridge=20save=20backup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PowerSaves for 3DS is a USB HID slot-1 adapter (VID 0x1C1A PID 0x03D5) made by Datel. Despite the 3DS branding, the device's generic NTR and SPI passthroughs work cleanly on DS cartridges, so this driver uses it purely as a save-backup tool for DS carts. What ships: - Driver (src/lib/drivers/powersave-3ds/) — speaks the 64-byte HID protocol, switches between ROM and SPI modes, identifies the cart via NTR chip-ID, validates the on-cart header (CRC + boot-logo), probes the save chip (FLASH via JEDEC, EEPROM via wrap-detection), dumps the save with cart-swap detection at start and end. Protocol ported from github.com/kitlith/powerslaves (MIT). - Shared DS system (src/lib/systems/nds/) — header parsing with CRC-16/MODBUS validation, maker/licensee code lookup (derived from devkitPro/ndstool, GPL-3.0), and a save-only system handler with generic dump-quality sanity checks. - Scanner UI (src/components/wizard/nds-scanner.tsx, src/hooks/use-nds-scanner.ts) — polls for a cart, displays detected game info enriched with No-Intro lookup by serial, runs the dump, and surfaces hashes + .sav download. - Wiring — App.tsx routes nds_save devices to the new scanner; use-connection.ts adds a connect/reconnect path for the device's WebHID filter; devices.ts registers the device. Adjacent changes: - nointro.ts — add nds_save No-Intro system mapping; prefer retail entries over (Beta)/(Proto)/(Demo)/(Sample)/(Unl) tags when multiple entries share a serial, so lookupBySerial returns the cart users actually have. - hashing.ts — formatBytes now scales to MB for ROM-size displays. - types.ts — declare "nds_save" as a known SystemId. Sources cited in THIRD-PARTY-LICENSES: kitlith/powerslaves and devkitPro/ndstool. The README's supported-hardware table and license attribution list are updated accordingly. --- README.md | 9 +- THIRD-PARTY-LICENSES | 50 +- src/App.tsx | 18 +- src/components/wizard/nds-scanner.tsx | 306 +++++++ src/hooks/use-connection.ts | 58 +- src/hooks/use-nds-scanner.ts | 182 ++++ src/lib/core/devices.ts | 12 + src/lib/core/hashing.ts | 3 +- src/lib/core/nointro.ts | 22 +- .../powersave-3ds/powersave-3ds-commands.ts | 70 ++ .../powersave-3ds/powersave-3ds-driver.ts | 806 ++++++++++++++++++ src/lib/systems/nds/nds-header.ts | 256 ++++++ src/lib/systems/nds/nds-maker-codes.ts | 318 +++++++ .../systems/nds/nds-save-system-handler.ts | 218 +++++ src/lib/types.ts | 12 +- 15 files changed, 2330 insertions(+), 10 deletions(-) create mode 100644 src/components/wizard/nds-scanner.tsx create mode 100644 src/hooks/use-nds-scanner.ts create mode 100644 src/lib/drivers/powersave-3ds/powersave-3ds-commands.ts create mode 100644 src/lib/drivers/powersave-3ds/powersave-3ds-driver.ts create mode 100644 src/lib/systems/nds/nds-header.ts create mode 100644 src/lib/systems/nds/nds-maker-codes.ts create mode 100644 src/lib/systems/nds/nds-save-system-handler.ts 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} + + )} + + )} + +
+ + {/* 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 */} +
+ +
+
+
+ +

+ 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 {