Skip to content

Add PowerSaves for 3DS device support — DS cartridge save backup#5

Merged
pathawks merged 9 commits into
mainfrom
powersaves-for-3ds
May 17, 2026
Merged

Add PowerSaves for 3DS device support — DS cartridge save backup#5
pathawks merged 9 commits into
mainfrom
powersaves-for-3ds

Conversation

@pathawks
Copy link
Copy Markdown
Owner

@pathawks pathawks commented Apr 25, 2026

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 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. Post-dump panel mirrors CompleteStep's status badge.
  • WiringApp.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, so lookupBySerial returns the cart users actually have.
  • hashing.tsformatBytes now scales to MB for ROM-size displays.
  • types.ts — declare nds_save as a known SystemId; CartridgeInfo is now 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 is added as a dev dependency (npm run test). Initial coverage:

  • crc16Modbus, parseNDSHeader, and classifyNDSCart (success, corruption, and boundary cases)
  • Prerelease-vs-retail serial preference in NoIntroVerificationDB
  • eepromWrapsAt SPI boundary-detection helper

Attribution

Sources cited in THIRD-PARTY-LICENSES: kitlith/powerslaves and devkitPro/ndstool. The README supported-hardware table is updated and includes a Datel trademark disclaimer.

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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_save devices 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.

Comment thread src/lib/drivers/powersave-3ds/powersave-3ds-driver.ts
Comment thread src/lib/drivers/powersave-3ds/powersave-3ds-commands.ts
Comment thread src/lib/drivers/powersave-3ds/powersave-3ds-driver.ts
Comment thread src/hooks/use-connection.ts Outdated
}
}
}
driver?.dispose?.();
Comment thread src/components/wizard/nds-scanner.tsx Outdated
</span>
)}
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
Comment thread src/lib/systems/nds/nds-header.ts Outdated
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)
Comment on lines +126 to +129
const basename = (title ?? gameCode ?? "nds_save")
.replace(/[^a-zA-Z0-9_ -]/g, "")
.trim()
.replace(/\s+/g, "_");
Comment on lines +371 to +396
/**
* 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}). ` +
pathawks added 2 commits May 16, 2026 15:55
- 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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 30 changed files in this pull request and generated 10 comments.

Comment thread src/lib/drivers/powersave-3ds/powersave-3ds-driver.ts Outdated
Comment thread src/lib/drivers/powersave-3ds/powersave-3ds-driver.ts
* Together these reject the nondeterministic preamble bytes some
* firmwares return before the real header.
*/
export function findHeaderStart(raw: Uint8Array): number {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 50bd024nds-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.

Comment thread src/lib/drivers/powersave-3ds/powersave-3ds-driver.ts Outdated
Comment on lines +208 to +209
"Dump is all 0xFF — the save chip didn't respond and the bus " +
"stayed idle. Re-seat the cartridge and dump again.",
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/core/nointro.ts
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"],
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +291 to +299
// 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",
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)) {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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...
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

pathawks added 5 commits May 16, 2026 21:49
- 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.
@pathawks pathawks marked this pull request as ready for review May 17, 2026 13:17
@pathawks pathawks merged commit b5d3456 into main May 17, 2026
1 check passed
@pathawks pathawks deleted the powersaves-for-3ds branch May 17, 2026 20:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants