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 {