Add PowerSaves for 3DS device support — DS cartridge save backup#5
Conversation
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),
and dumps the save with cart-swap detection at start and end.
Protocol ported from github.com/kitlith/powerslaves (MIT).
- Shared NDS 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. The header/family classifier
and shared scanner UI are set up so future NDS-capable drivers can
slot in without reshaping the layer.
- Scanner UI (src/components/wizard/nds-scanner.tsx,
src/hooks/use-nds-scanner.ts, src/components/shared/{cart-heading,
nds-cart-info,save-dump-result}.tsx) — 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;
connection-registry.ts adds the device entry; devices.ts registers
it; linux/99-nabu.rules grants hidraw access.
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.
- hashing.ts: formatBytes scales to MB for ROM-size displays.
- types.ts: declare nds_save as a known SystemId; CartridgeInfo is
generic in its meta field; DeviceDriver gets an optional dispose()
hook that use-connection's handleDisconnect now calls.
- alert.tsx: add a warning variant for amber save-quality alerts.
Tests (Vitest, new):
- crc16Modbus, parseNDSHeader, and classifyNDSCart coverage
- prerelease-vs-retail serial preference in NoIntroVerificationDB
- eepromWrapsAt SPI boundary-detection helper
Sources cited in THIRD-PARTY-LICENSES: kitlith/powerslaves and
devkitPro/ndstool. README's supported-hardware table is updated and
includes a Datel trademark disclaimer.
53b5fb1 to
b57a12c
Compare
There was a problem hiding this comment.
Pull request overview
Adds DS save-backup support for the Datel PowerSaves for 3DS adapter, including a new HID driver, shared NDS parsing/save handling, scanner UI, device wiring, tests, and documentation.
Changes:
- Adds PowerSaves 3DS WebHID driver plus NDS header parsing, save probing, dump validation, and tests.
- Adds NDS scanner UI/results components and routes
nds_savedevices through the scanner flow. - Updates No-Intro serial handling, device metadata, Linux udev rules, docs, licenses, and Vitest setup.
Reviewed changes
Copilot reviewed 27 out of 28 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
vitest.config.ts |
Adds Vitest configuration and path alias. |
tsconfig.node.json |
Includes Vitest config in node TS project. |
THIRD-PARTY-LICENSES |
Adds PowerSlaves and ndstool attribution. |
src/lib/types.ts |
Adds nds_save, generic cartridge metadata, and driver disposal hook. |
src/lib/systems/nds/nds-save-system-handler.ts |
Adds save-only NDS system handler and dump sanity checks. |
src/lib/systems/nds/nds-maker-codes.ts |
Adds NDS maker-code lookup table. |
src/lib/systems/nds/nds-header.ts |
Adds NDS header parsing, CRC validation, and cart-family metadata helpers. |
src/lib/systems/nds/nds-header.test.ts |
Adds tests for CRC/header parsing/cart classification. |
src/lib/drivers/powersave-3ds/powersave-3ds-driver.ts |
Adds PowerSaves 3DS HID driver and save-dump flow. |
src/lib/drivers/powersave-3ds/powersave-3ds-commands.ts |
Adds protocol constants, filters, and JEDEC size decoding. |
src/lib/drivers/powersave-3ds/eeprom-wraps.test.ts |
Adds tests for EEPROM wrap detection helper. |
src/lib/core/nointro.ts |
Adds NDS No-Intro mapping and retail-over-prerelease serial preference. |
src/lib/core/nointro.test.ts |
Adds tests for serial lookup preference behavior. |
src/lib/core/hashing.ts |
Extends byte formatting to MB. |
src/lib/core/devices.ts |
Registers PowerSaves for 3DS device metadata. |
src/lib/core/connection-registry.ts |
Wires PowerSaves 3DS transport and driver creation. |
src/hooks/use-nds-scanner.ts |
Adds polling/read hook for NDS save scanner flow. |
src/hooks/use-connection.ts |
Calls optional driver disposal during disconnect. |
src/components/wizard/nds-scanner.tsx |
Adds NDS save scanner UI. |
src/components/ui/alert.tsx |
Adds warning alert variant. |
src/components/shared/save-dump-result.tsx |
Adds reusable save-dump result panel. |
src/components/shared/nds-cart-info.tsx |
Adds reusable NDS cart information display. |
src/components/shared/cart-heading.tsx |
Adds cart-family heading component. |
src/App.tsx |
Routes nds_save devices to the new scanner. |
README.md |
Documents PowerSaves 3DS support and attribution. |
package.json |
Adds Vitest test script and dependency. |
package-lock.json |
Locks Vitest and transitive dependencies. |
linux/99-nabu.rules |
Adds Linux hidraw rule for PowerSaves 3DS. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
| } | ||
| } | ||
| driver?.dispose?.(); |
| </span> | ||
| )} | ||
| </div> | ||
| <div className="h-2 overflow-hidden rounded-full bg-muted"> |
| const makerCode = makerCodes[makerRaw] ?? makerRaw; | ||
| const romVersion = hdr[0x1e]; | ||
| const capacity = hdr[0x14]; | ||
| const romSizeMiB = capacity > 3 ? 1 << (capacity - 3) : 0; |
| */ | ||
| const STANDARD_NDS_SAVE_SIZES = { | ||
| EEPROM: [ | ||
| 256, // 0.5 KB / 4 Kbit "tiny" EEPROM (1-byte address) |
| const basename = (title ?? gameCode ?? "nds_save") | ||
| .replace(/[^a-zA-Z0-9_ -]/g, "") | ||
| .trim() | ||
| .replace(/\s+/g, "_"); |
| /** | ||
| * 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<void> { | ||
| 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}). ` + |
- handleDisconnect: read driver via ref so surprise disconnects fire dispose() - Cart-swap guard: also compare header CRC, not just NTR chip ID - Reset saveTypeName alongside saveSize on cart swap / no-cart - Narrow flashSizeFromJedec range to 0x10-0x18 (avoid 32-bit shift overflow) - ROM size formula: capacity >= 3 (was > 3) for the 1 MiB boundary - Save-file basename: fall back to "nds_save" after sanitization, not before - Replace custom progress div with shared Progress for ARIA semantics - Fix "tiny" EEPROM size comment (256 B = 2 Kbit, not 4 Kbit)
NDS header parser - 'O' is the combined USA + Europe release code (a single ROM that ships on carts sold in both regions). Verified against the 2026-04-22 No-Intro DS DAT: 4 titles, all labelled "(USA, Europe)". - 'Z' has no consistent geographic meaning across that DAT — appears on Nordic / Iberian / Netherlands European sub-releases, US retailer exclusives, and one Canadian SKU. Labelled "Other" rather than mislabelled as a specific region. Without these entries, findHeaderStart's region-letter check rejects affected carts at offset 1 of a preamble-shifted header read, CRCs never validate, and the wizard falls straight to the save-dump step with no cart info displayed. PowerSaves 3DS driver A handful of DS carts put both a save chip and an IR transceiver on the SPI bus, selected by separate chip-select lines. The PowerSaves firmware can only drive CS1; on those carts CS1 lands on the IR module instead of the save chip, so a JEDEC-ID probe returns the IR module's response (00 7F XX, with byte 2 observed as 0x00, 0xE0, or 0xFF across sessions) instead of a real save-chip ID. isIrModuleJedecSignature matches on the first two bytes; on a hit, probeSaveChip throws the new UnsupportedCartError with a clear "use a different DS-cart adapter" message. Confirmed unrecoverable by full Ghidra RE of Datel's PowerSaves3DS.exe v1.55 — the firmware has no CS2-select opcode. Wizard / scanner hook - New errorRecoverable flag on useNDSScanner; the generic "unplug and retry" recovery hint is suppressed when the failure is a hard limitation of the device against the inserted cart. - Keep the cart info panel visible when phase becomes "error". A successful header read followed by a failed save-chip dump previously hid all the cart-identification context behind a bare error banner. - On header-CRC retry, log the first 32 bytes as "hh hh hh … | ascii" instead of just "CRC mismatch". Lets a human tell at a glance whether the read is structured data with a preamble offset, uniform 0x00 / 0xFF (dead bus / dirty contacts), or scrambled bytes. Same logging on the verifyCartUnchanged path. Mock driver: replace specific game-title placeholders with generic MOCK CART labels.
| * Together these reject the nondeterministic preamble bytes some | ||
| * firmwares return before the real header. | ||
| */ | ||
| export function findHeaderStart(raw: Uint8Array): number { |
There was a problem hiding this comment.
Addressed in 50bd024 — nds-header.test.ts now has a findHeaderStart describe block with five cases: offset 0, single-byte preamble (the PowerSaves observed case), multi-byte preamble, garbage-buffer rejection, and unknown-region-letter rejection.
| "Dump is all 0xFF — the save chip didn't respond and the bus " + | ||
| "stayed idle. Re-seat the cartridge and dump again.", |
There was a problem hiding this comment.
Addressed in 50bd024 — the all-0xFF warning now mirrors the all-0x00 one's wording, covering the blank/erased case (FLASH chips ship erased to 0xFF) alongside the bus-idle interpretation.
| 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"], |
There was a problem hiding this comment.
Addressed in d6dea3c — extracted a matchesSystemName(datName, candidate) helper in nointro.ts that requires exact match OR the No-Intro variant pattern (candidate + ' ('). Tests in nointro.test.ts verify the matcher rejects DSi against the DS alias, 3DS against the bare DS fallback, and Game Boy Color / Advance against the Game Boy alias.
| return { | ||
| systemId: "nds_save", | ||
| params: { | ||
| saveSize: values.saveSizeBytes as number | undefined, |
There was a problem hiding this comment.
Addressed in 168aecc — renamed params.saveSize to params.saveSizeBytes to match the GB / GBA convention. No consumers currently read the field (PowerSaves 3DS readROM explicitly voids its config arg), so this is a forward-looking consistency fix.
| // 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", |
There was a problem hiding this comment.
Addressed in 561025e — the two cases are now split: all-0xFF DS-format header (the 3DS signature) is rejected with UnsupportedCartError, while the CRC-fail branch (dirty contacts / counterfeit-encrypted) still proceeds with a warning. Testing showed a real ST 512 KB FLASH dump recovered cleanly from a cart whose header read returned all zeros, so eager bail on CRC fail would prevent recoverable cases.
| ); | ||
| } | ||
|
|
||
| if (jedecResp.some((b) => b !== 0x00 && b !== 0xff)) { |
There was a problem hiding this comment.
Addressed in 50bd024 — tightened the FLASH-path guard from some over all 3 bytes to jedecResp[0] !== 0x00 && jedecResp[0] !== 0xff. Real JEDEC manufacturer codes start at 0x01; the 0x00 and 0xFF cases now correctly fall through to the EEPROM probe path (or the IR-module detection ahead of it).
| <CardContent className="flex items-center gap-3 py-8"> | ||
| <span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" /> | ||
| <span className="text-sm text-muted-foreground"> | ||
| Insert a DS cartridge... |
There was a problem hiding this comment.
Addressed in fef0f23 — narrowed the ConnectStep safety text from the blanket 'always unplug before connecting/disconnecting a cartridge' to 'unplug before swapping cartridges; polled-insertion devices will prompt when ready.' The scanner's first-insertion-after-connect flow is intentional for PowerSaves and the other polled drivers (PS3 MCA, Disney Infinity, amiibo PowerSaves).
- powersave-3ds-driver.ts (probeSaveChip): tighten the FLASH-path guard from "any byte non-00/FF" to "manufacturer byte non-00/FF". Real JEDEC manufacturer codes start at 0x01; 0x00 and 0xFF both mean "no chip responded" (EEPROMs don't implement JEDEC, the bus stays at its idle level). Garbage like `00 00 13` would previously slip past the IR check, be misread as FLASH, and dump as a 512 KB image of nothing. - powersave-3ds-driver.ts (sendCommand): rewrite the doc comment so it describes what the code actually does (start-of-call drain only); the "drain at both ends" wording referred to a behaviour that was tried and dropped — leftovers from this call get cleared by the NEXT caller's start-of-call drain, which is sufficient. - nds-save-system-handler.ts (validateDump): broaden the all-0xFF warning to cover the blank-FLASH case, matching how the all-0x00 warning already covers blank/dirty/failed-read. FLASH ships erased to 0xFF, so an all-FF dump can be a legitimately new chip rather than a bus failure. - nds-header.test.ts: add findHeaderStart coverage for the preamble- offset case (the entire reason the helper exists, previously uncovered), plus the no-valid-header and bad-region-letter rejection paths.
The previous matcher used substring containment, so the "Nintendo -
Nintendo DS" alias for nds_save would silently match a "Nintendo -
Nintendo DSi" DAT, the bare "DS" fallback would match "Nintendo -
Nintendo 3DS", and the "Game Boy" alias for gb would match "Game Boy
Color" and "Game Boy Advance" DATs. When a more-specific DAT wasn't
loaded, the wizard would bind to the wrong verification database and
show titles from a different system.
Replace with a boundary-anchored matcher: a DAT name matches a
candidate when it equals the candidate exactly OR when it starts with
the candidate followed by " (" — the No-Intro convention for
parenthesised variants ("(Encrypted)", "(Decrypted)", "(Rev 1)",
etc). Lift the matcher into nointro.ts as a pure helper so it can be
unit-tested directly rather than only through the React hook.
Align with the convention used by the GB and GBA system handlers (see gb-system-handler.ts:110 and gba-system-handler.ts:138). The field is a configuration parameter passed to drivers via ReadConfig, not a Cartridge metadata field — keeping the same key across system handlers means shared dump code or future drivers can consume ReadConfig generically without per-system shims. No callers currently read this field (the PowerSaves 3DS readROM explicitly voids its config argument), so the rename is purely a forward-looking consistency fix.
The PowerSaves 3DS firmware exposes two cart-read paths: a DS NTR path (CMD.NTR, 0x13) implemented by this driver for DS save backup, and a 3DS CTR path (CMD.CTR, 0x14) that this driver does not implement. The DS-style SPI save path is meaningless for 3DS carts — 3DS slot-1 doesn't even share the same SPI wiring conventions as DS. The all-0xFF DS-format header read is the well-known signature of a 3DS cart (3DS carts use a completely different header format and return all 0xFF when probed with NTR 0x00). Previously we logged a warning and ploughed on to the SPI probe, which would either error out at JEDEC parsing or produce a meaningless .sav. Throw UnsupportedCartError instead so the cart card stays visible (header metadata is shown as "3DS cartridge") and the user is directed to a 3DS-cart-aware tool. The header-CRC-fail branch (non-0xFF garbage that doesn't validate) is kept as "warn and try the save dump anyway" — that path actually recovered a clean ST 512 KB FLASH dump in testing on a cart whose header read returned all zeros, so bailing eagerly would prevent recoverable cases. Comment expanded to capture that observation.
The previous wording, "Always unplug the device before connecting or disconnecting a cartridge," conflicted with the NDS scanner's "Insert a DS cartridge..." prompt and with the polled-insertion flow that PowerSaves 3DS, PS3 Memory Card Adaptor, Disney Infinity Base, and amiibo PowerSaves all rely on — these drivers detect the cartridge AFTER the USB connection is up. The actual hardware-safety concern is swapping carts while powered (the bus is energised and active reads can collide with mechanical insertion). That risk doesn't apply to the *first* insertion after the USB link comes up, because there's no in-progress operation to collide with — the polled scanner is just waiting. Reword to flag the swap case specifically, with a note that polled devices will prompt when they're ready.
No-Intro doesn't publish a separate "Nintendo - Nintendo DSi" DAT — DSi-Enhanced and DSi-Exclusive cart titles all live inside the single "Nintendo - Nintendo DS" DAT (visible as "(NDSi Enhanced)" in the game name). The DSi cross-bind case my earlier test cited was repeating Copilot's misframing and would never trigger against a real No-Intro DAT collection. Replace the DSi assertion with a second Game Boy variant (Game Boy Advance) so the test exercises the actual real-world bug class — multiple sibling DATs whose names share a prefix with our short alias — without describing a system separation that doesn't exist. Update the matchesSystemName docstring to match.
PowerSaves for 3DS is a USB HID slot-1 adapter (VID
0x1C1APID0x03D5) 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
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), and dumps the save with cart-swap detection at start and end. Protocol ported from kitlith/powerslaves (MIT).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. The header/family classifier and shared scanner UI are set up so future NDS-capable drivers can slot in without reshaping the layer.src/components/wizard/nds-scanner.tsx,src/hooks/use-nds-scanner.ts,src/components/shared/{cart-heading,nds-cart-info,save-dump-result}.tsx) — polls for a cart, displays detected game info enriched with No-Intro lookup by serial, runs the dump, and surfaces hashes +.savdownload. Post-dump panel mirrorsCompleteStep's status badge.App.tsxroutesnds_savedevices to the new scanner;connection-registry.tsadds the device entry;devices.tsregisters it;linux/99-nabu.rulesgrants hidraw access.Adjacent changes
nointro.ts— addnds_saveNo-Intro system mapping; prefer retail entries over(Beta)/(Proto)/(Demo)/(Sample)/(Unl)tags when multiple entries share a serial, solookupBySerialreturns the cart users actually have.hashing.ts—formatBytesnow scales to MB for ROM-size displays.types.ts— declarends_saveas a knownSystemId;CartridgeInfois now generic in itsmetafield;DeviceDrivergets an optionaldispose()hook thatuse-connection'shandleDisconnectnow calls.alert.tsx— add awarningvariant for amber save-quality alerts.Tests
Vitest is added as a dev dependency (
npm run test). Initial coverage:crc16Modbus,parseNDSHeader, andclassifyNDSCart(success, corruption, and boundary cases)NoIntroVerificationDBeepromWrapsAtSPI boundary-detection helperAttribution
Sources cited in
THIRD-PARTY-LICENSES:kitlith/powerslavesanddevkitPro/ndstool. TheREADMEsupported-hardware table is updated and includes a Datel trademark disclaimer.