feat(nx): per-game save inbox and export folders

Split Import/Export paths into imports/saves/{red,blue,yellow}/ and
exports/{red,blue,yellow}/ so MTP destinations match each launcher tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-03 08:34:13 -03:00
parent a8f3a3155c
commit 2591fde764
9 changed files with 214 additions and 160 deletions
+5 -5
View File
@@ -91,12 +91,12 @@
- **Status**: active
### AD-012
- **Decision**: On NX, raw Gen1 `.sav` import uses a writable inbox at `love.filesystem.getSaveDirectory()/imports/saves/` with **Import save** ensuring the dir and rescanning into the **active game tab**; export success surfaces `exports/` via an MTP path notice (no `openURL` / Open folder). Resilience guards RES-01..11 apply. After a successful import the live `.sav` is retired to `*.sav.imported` and its content hash is recorded in `imports/saves/.imported-sha1` so re-press / same-bytes-new-name cannot clone slots; failures leave the original file.
- **Reason**: love-nx has no usable Horizon file picker (same scar as AD-003/AD-006); players need MTP/SD/FTP parity for continuing cart/PC saves and pulling slots off-console. Unlimited re-import of a retained `.sav` was a slot-clone footgun.
- **Trade-off**: Users must copy `.sav` into the shown inbox and pull exports manually; desktop/Android picker paths stay unchanged; retired `.imported` files may accumulate until the player deletes them.
- **Scope**: RomImporter SAVE FILES on Switch, Switch install/transfer/development/launcher docs, `tests/rom_importer_nx_saves_inbox_test.lua`
- **Decision**: On NX, raw Gen1 `.sav` import uses per-game inboxes at `love.filesystem.getSaveDirectory()/imports/saves/{red,blue,yellow}/` with **Import save** scanning only the active tabs folder; export writes `exports/{red,blue,yellow}/gen1recomp-<game>-<slot>.sav` and surfaces an MTP path notice (no `openURL`). After success the live `.sav` is retired to `*.sav.imported` and hashed in that folders `.imported-sha1`. Resilience guards RES-01..11 still apply.
- **Reason**: love-nx has no usable Horizon file picker (AD-003/AD-006); per-game folders make MTP destinations obvious and prevent Red/Blue/Yellow inbox mix-ups. Hash retire blocks slot clones on re-press.
- **Trade-off**: Users must drop `.sav` into the matching game folder; flat `imports/saves/*.sav` is no longer scanned; retired `.imported` files may accumulate until deleted.
- **Scope**: RomImporter SAVE FILES, SaveFileIO export paths, Switch/launcher docs, `tests/rom_importer_nx_saves_inbox_test.lua`
- **Date**: 2026-08-03
- **Status**: active (amended 2026-08-03 — retire + hash dedupe)
- **Status**: active (amended 2026-08-03 — per-game folders + retire/hash)
## Handoff
+13 -12
View File
@@ -161,10 +161,10 @@ through `src/import/SaveFileIO.lua`, which sits on top of
On desktop it opens a native `.sav` picker (`chooseSav`); on Android,
`love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs.
On **NX (Switch)** there is no picker: copy a `.sav` into
`getSaveDirectory()/imports/saves/` via MTP / SD / FTP, then press
**Import save** to ensure the inbox and rescan (same pattern as the ROM
`imports/` and mod `imports/mods/` inboxes). Hidden `._*.sav` AppleDouble
sidecars are skipped.
`getSaveDirectory()/imports/saves/<red|blue|yellow>/` via MTP / SD / FTP
(one folder per game), then press **Import save** on that games tab to
ensure the inbox and rescan (same pattern as the ROM `imports/` and mod
`imports/mods/` inboxes). Hidden `._*.sav` AppleDouble sidecars are skipped.
`SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
relative name, a dropped LOVE file, or raw bytes),
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
@@ -174,22 +174,23 @@ through `src/import/SaveFileIO.lua`, which sits on top of
so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT
panel is refreshed with the new slot selected. On **NX**, a successful inbox
import retires the file to `*.sav.imported` and records a content hash in
`imports/saves/.imported-sha1` so a second **Import save** (or the same bytes
under a new name) does not clone slots; failures leave the original `.sav`.
Imports always target the **active game tab** — use Red vs Blue accordingly.
`imports/saves/<game>/.imported-sha1` so a second **Import save** (or the same
bytes under a new name) does not clone slots; failures leave the original
`.sav`. Only that games folder is scanned.
- **Export save** is live only when the active slot actually holds a save
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
(`love.filesystem.createDirectory("exports")`). On desktop it returns the
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
writes `exports/<version>/gen1recomp-<version>-<slotId>.sav` in the save
directory (`exports/` and `exports/<version>/` are created as needed). On
desktop it returns the absolute path (`love.filesystem.getSaveDirectory()`),
which the notice line shows with an "Open folder" affordance
(`love.system.openURL("file://" .. dir)`).
On Android the bytes are also staged as `pending_export.sav` and
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
player can save to Downloads / Drive / etc.; on return `export_done.flag`
makes focus show "Save exported."
On **NX**, export success sets a notice with the `exports/` path and an
On **NX**, export success sets a notice with the `exports/<game>/` path and an
MTP-oriented hint — no `openURL` / Open folder (pull the file via MTP /
SD / FTP instead).
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
+9 -9
View File
@@ -44,7 +44,7 @@ the transfer runbook).
- Loose assemble + fused NRO build scripts (`scripts/build_switch.sh`, `scripts/switch/*`)
- Payload gates so ROM / generated cache / saves never enter `game.love`
- Community mod zip inbox at `imports/mods/` (rescan installs; FIND MODS stays network-gated)
- Raw `.sav` inbox at `imports/saves/` (**Import save** rescan) + export pull path `exports/` (MTP hint; no openURL)
- Raw `.sav` inbox at `imports/saves/{red,blue,yellow}/` (**Import save** rescan) + export pull path `exports/{red,blue,yellow}/` (MTP hint; no openURL)
- VoxelMod OPTIONS + Switch performance tips documented (WATER / 3D-BTL / extras)
- Hardware evidence for Phase 0 probe, ROM import, naming A/B, save/suspend, fused NRO — see `docs/switch-hardware-evidence.md`
- Path-gated CI selftest + canonical fused PR artifact; release Switch hard-fail
@@ -370,7 +370,7 @@ Community mods install from a **separate** MTP inbox (not mixed into the ROM `im
Do **not** commit third-party mod zip bytes into git. Drop the zip over MTP, rescan, enable in MODS, then Play.
**MTP tip (esp. macOS clients):** OpenMTP/Finder often creates AppleDouble sidecars named `._Something.zip` / `._cart.gb` / `._foo.sav`. Those are not real archives, ROMs, or saves — the launcher ignores hidden `.*` names under `imports/`, `imports/mods/`, and `imports/saves/`. If install still fails with “could not be opened” / “not a zip file”, delete any `._*` under the inbox and confirm the real zip starts with the `PK` magic (re-copy the release asset if unsure). This is a host-side annoyance of the current manual MTP loop, not something players should need forever.
**MTP tip (esp. macOS clients):** OpenMTP/Finder often creates AppleDouble sidecars named `._Something.zip` / `._cart.gb` / `._foo.sav`. Those are not real archives, ROMs, or saves — the launcher ignores hidden `.*` names under `imports/`, `imports/mods/`, and `imports/saves/<game>/`. If install still fails with “could not be opened” / “not a zip file”, delete any `._*` under the inbox and confirm the real zip starts with the `PK` magic (re-copy the release asset if unsure). This is a host-side annoyance of the current manual MTP loop, not something players should need forever.
**Example zip source:** [DramaticShape VoxelMod releases](https://github.com/DramaticShape/DramaticShapeVoxelMod/releases) — download a release `.zip`, copy into `imports/mods/`, rescan, enable. Player-facing install + performance tips: [switch-install.md](switch-install.md#community-mods-voxelmod).
@@ -380,14 +380,14 @@ Raw Gen1 battery images use a **separate** MTP inbox (not mixed into ROM `import
| Item | Value |
| ---- | ----- |
| Save-relative path | `imports/saves/` |
| MTP destination | `1: SD Card/<save identity>/imports/saves/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | non-hidden `*.sav` only |
| Rescan | SAVE FILES → **Import save** on the matching game tab (imports each *new* `.sav` via `SaveFileIO.importToSlot` into **that tabs** slots) |
| After success | Retire to `*.sav.imported` + append content hash to `imports/saves/.imported-sha1` (re-press / same bytes under a new name → skip, no clone slots). Failures leave the original `.sav` |
| Exports | **Export save** writes under `exports/`; NX shows an MTP path notice (no `openURL` / Open folder) |
| Save-relative path | `imports/saves/red/`, `imports/saves/blue/`, `imports/saves/yellow/` |
| MTP destination | `1: SD Card/<save identity>/imports/saves/<game>/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | non-hidden `*.sav` only in **that games** folder |
| Rescan | SAVE FILES → **Import save** on the matching game tab (scans only that folder) |
| After success | Retire to `*.sav.imported` + append content hash to `imports/saves/<game>/.imported-sha1` |
| Exports | **Export save** writes under `exports/<game>/gen1recomp-<game>-<slot>.sav`; NX shows an MTP path notice (no `openURL`) |
Do **not** commit `.sav` bytes into git. Drop the file over MTP, press **Import save** on the correct game tab, then play from the new slot. Pull exports from `exports/` the same way.
Do **not** commit `.sav` bytes into git. Drop the file into the matching game folder over MTP, press **Import save** on that tab, then play. Pull exports from `exports/<game>/`.
**MTP tip:** the same AppleDouble `._*.sav` rule applies — see the mod inbox tip above.
+12 -7
View File
@@ -67,18 +67,23 @@ you can replace only the `.nro` and keep your progress.
## 5. Import / Export a raw `.sav`
Continue a cart or PC battery save (or pull a slot off-console) via MTP /
SD / FTP — same transfer methods as ROMs:
SD / FTP — same transfer methods as ROMs. Paths are **per game**:
1. Copy a Gen1 `.sav` (32 KB) into the save-dir **`imports/saves/`** path the
launcher shows ([switch-transfer.md](switch-transfer.md)).
2. With the games ROM already imported, open that games tab → **SAVE FILES**
**Import save**. The launcher rescans the inbox into **this tabs**
slots (Red vs Blue matter — use the matching game tab).
| Game | Import inbox | Export folder |
| ---- | ------------ | ------------- |
| Red | `imports/saves/red/` | `exports/red/` |
| Blue | `imports/saves/blue/` | `exports/blue/` |
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
1. Copy a Gen1 `.sav` (32 KB) into that games inbox under the save dir
([switch-transfer.md](switch-transfer.md)).
2. With the games ROM already imported, open **that games tab**
**SAVE FILES****Import save**. Only that folder is scanned.
3. A successful import retires the file to `*.sav.imported` and records its
content hash so pressing **Import save** again does not clone slots.
Failed imports leave the original `.sav` in place.
4. To pull a slot off the console, use **Export save**, then copy the file
from **`exports/`** in the same save directory via MTP / SD / FTP.
from that games **`exports/<game>/`** folder via MTP / SD / FTP.
Do not put `.sav` files into git. Prefer clean copies — some MTP clients
create `._*.sav` AppleDouble sidecars that are not real saves.
+8 -7
View File
@@ -23,8 +23,8 @@ Player install (what to download, title override) stays in
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
| Save `.sav` inbox | Same save dir → `imports/saves/` then SAVE FILES → **Import save** |
| Save exports | Same save dir → `exports/` (pull after **Export save**; MTP / SD / FTP) |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that games SAVE FILES → **Import save** |
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) |
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
| Lua error log | `lua-error.log` in the save dir |
@@ -53,7 +53,8 @@ hardware evidence — **one contributor example**, not a Mac-only product rule.
2. Open OpenMTP → select the DBI device → **`1: SD Card`**.
3. Create `switch/gen1recomp/` if needed; copy NRO (and `game.love` for loose).
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
`imports/saves/`, or `exports/` path the launcher prints.
`imports/saves/<red|blue|yellow>/`, or `exports/<red|blue|yellow>/` path the
launcher prints.
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
macOS clients often create AppleDouble sidecars (`._Something.zip`,
@@ -111,8 +112,8 @@ only; pick what your CFW setup already uses).
1. Start the FTP server on the Switch; note IP/port/credentials from that app.
2. From the host, connect with any FTP client and upload to the same
`switch/gen1recomp/`, `imports/`, `imports/mods/`, `imports/saves/`,
and `exports/` paths.
`switch/gen1recomp/`, `imports/`, `imports/mods/`, `imports/saves/<game>/`,
and `exports/<game>/` paths.
3. Stop the FTP server cleanly before launching Gen1Recomp.
If credentials or chroots differ by app, trust the **destination paths**, not
@@ -127,8 +128,8 @@ a single vendor tutorial.
Mode is not supported** (not enough memory).
3. For ROMs: launcher → **Scan again** if the file was added after
boot. For mods: MODS → **Scan again** → enable → Play. For saves:
SAVE FILES → **Import save** (rescans `imports/saves/`). Pull exported
`.sav` files from `exports/`.
SAVE FILES → **Import save** (rescans `imports/saves/<game>/`). Pull exported
`.sav` files from `exports/<game>/`.
VoxelMod Joy-Con chords and Switch performance tips:
[switch-install.md](switch-install.md#community-mods-voxelmod).
+68 -37
View File
@@ -343,12 +343,20 @@ end
local IMPORTS_DIR = "imports"
local MODS_INBOX_DIR = "imports/mods"
local SAVES_INBOX_DIR = "imports/saves"
-- Ledger of successfully imported .sav content hashes (hidden → skipped by
-- listSavPaths). Prevents re-pressing Import save from cloning slots when the
-- same bytes are still in the inbox under a new name.
local SAVES_IMPORTED_HASHES = SAVES_INBOX_DIR .. "/.imported-sha1"
local ROM_BYTES = 1024 * 1024
local function savesInboxDir(version)
return SAVES_INBOX_DIR .. "/" .. tostring(version)
end
local function savesImportedHashesPath(version)
return savesInboxDir(version) .. "/.imported-sha1"
end
local function exportsDir(version)
return "exports/" .. tostring(version)
end
-- Strip only a validated sdmc:/ prefix for OpenMTP/DBI relative paths.
function RomImporter.mtpHintPath(saveDir)
if type(saveDir) ~= "string" then return "" end
@@ -379,17 +387,31 @@ function RomImporter:ensureModsInboxDir()
return false
end
-- NX raw .sav inbox (separate from ROM dumps + mod zips). Parent imports/
-- first — love.filesystem.createDirectory does not create nested parents.
function RomImporter:ensureSavesInboxDir()
-- NX raw .sav inbox per game: imports/saves/{red,blue,yellow}/.
-- Parent imports/ then imports/saves/ first — createDirectory is not nested.
-- Creates all three version folders so MTP browsing shows where each game goes.
function RomImporter:ensureSavesInboxDir(version)
self:ensureImportsDir()
local info = love.filesystem.getInfo(SAVES_INBOX_DIR)
if info and info.type == "directory" then return true end
if info then return false end
if love.filesystem.createDirectory then
return love.filesystem.createDirectory(SAVES_INBOX_DIR)
if info and info.type ~= "directory" then return false end
if not info then
if not (love.filesystem.createDirectory
and love.filesystem.createDirectory(SAVES_INBOX_DIR)) then
return false
end
end
return false
for v in pairs(GameVersion.VERSIONS) do
local dir = savesInboxDir(v)
local vInfo = love.filesystem.getInfo(dir)
if vInfo and vInfo.type ~= "directory" then return false end
if not vInfo then
if not (love.filesystem.createDirectory
and love.filesystem.createDirectory(dir)) then
return false
end
end
end
return true
end
function RomImporter:_setNxInboxNotice(version)
@@ -423,14 +445,16 @@ end
function RomImporter:_setNxSavesInboxNotice(version)
version = self:_resolveSaveVersion(version)
local inbox = savesInboxDir(version)
local saveDir = love.filesystem.getSaveDirectory()
local rel = RomImporter.mtpHintPath(saveDir)
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
local game = GameVersion.info(version).displayName
self.saveNotice = self.saveNotice or {}
self.saveNotice[version] = {
ok = true,
text = Strings("Copy your .sav into:\n%s/imports/saves/\nDBI MTP → 1: SD Card/%simports/saves/",
saveDir, rel),
text = Strings("Copy your %s .sav into:\n%s/%s/\nDBI MTP → 1: SD Card/%s%s/",
game, saveDir, inbox, rel, inbox),
}
end
@@ -501,15 +525,16 @@ function RomImporter:scanModsInbox()
return listZipPaths(MODS_INBOX_DIR)
end
-- NX saves inbox: only non-hidden *.sav under imports/saves/.
function RomImporter:scanSavesInbox()
self:ensureSavesInboxDir()
return listSavPaths(SAVES_INBOX_DIR)
-- NX saves inbox: only non-hidden *.sav under imports/saves/<version>/.
function RomImporter:scanSavesInbox(version)
version = self:_resolveSaveVersion(version)
self:ensureSavesInboxDir(version)
return listSavPaths(savesInboxDir(version))
end
local function loadImportedSavHashes()
local function loadImportedSavHashes(version)
local set = {}
local raw = love.filesystem.read(SAVES_IMPORTED_HASHES)
local raw = love.filesystem.read(savesImportedHashesPath(version))
if type(raw) ~= "string" then return set end
for line in raw:gmatch("[^\r\n]+") do
local h = line:match("^(%x+)$")
@@ -518,11 +543,12 @@ local function loadImportedSavHashes()
return set
end
local function appendImportedSavHash(hash)
local function appendImportedSavHash(version, hash)
if type(hash) ~= "string" or hash == "" then return end
local prev = love.filesystem.read(SAVES_IMPORTED_HASHES) or ""
local path = savesImportedHashesPath(version)
local prev = love.filesystem.read(path) or ""
if prev:find(hash, 1, true) then return end
love.filesystem.write(SAVES_IMPORTED_HASHES, prev .. hash .. "\n")
love.filesystem.write(path, prev .. hash .. "\n")
end
-- Keep bytes for the player (MTP recovery) but stop matching %.sav$ on rescan.
@@ -582,21 +608,21 @@ function RomImporter:rescanModsAction()
end
end
-- Rescan imports/saves/: import each new .sav via _importSave.
-- Failure retains the original .sav. Success records a content hash and
-- retires the file to `*.sav.imported` so a second Import save cannot clone
-- Rescan imports/saves/<version>/: import each new .sav via _importSave.
-- Failure retains the original .sav. Success records a per-game content hash
-- and retires the file to `*.sav.imported` so a second Import save cannot clone
-- slots (bytes stay in the inbox for MTP recovery). Already-hashed content
-- is skipped even under a new filename. Empty / AppleDouble-only → MTP notice.
function RomImporter:rescanSavesAction(version)
if self.workState == "working" then return end
version = self:_resolveSaveVersion(version)
self:ensureSavesInboxDir()
local candidates = self:scanSavesInbox()
self:ensureSavesInboxDir(version)
local candidates = self:scanSavesInbox(version)
if #candidates == 0 then
self:_setNxSavesInboxNotice(version)
return
end
local seenHashes = loadImportedSavHashes()
local seenHashes = loadImportedSavHashes(version)
local okCount, failCount, skipCount = 0, 0, 0
local lastOk, lastFail = nil, nil
local gameLabel = GameVersion.info(version).displayName
@@ -615,7 +641,7 @@ function RomImporter:rescanSavesAction(version)
lastOk = notice
if hash then
seenHashes[hash] = true
appendImportedSavHash(hash)
appendImportedSavHash(version, hash)
end
retireImportedSav(path)
else
@@ -1504,7 +1530,7 @@ function RomImporter:chooseSaveImport(version)
if self.workState == "working" then return end
version = self:_resolveSaveVersion(version)
if self.isNX then
self:ensureSavesInboxDir()
self:ensureSavesInboxDir(version)
self:rescanSavesAction(version)
return
end
@@ -1560,14 +1586,16 @@ function RomImporter:exportSave(version)
local saveDir = love.filesystem.getSaveDirectory()
local rel = RomImporter.mtpHintPath(saveDir)
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
local outDir = exportsDir(version)
self.saveNotice[version] = {
ok = true,
text = Strings("Exported to %s\nDBI MTP → 1: SD Card/%sexports/", res, rel),
text = Strings("Exported to %s\nDBI MTP → 1: SD Card/%s%s/", res, rel, outDir),
}
return
end
if self.android then
local rel = res:match("exports[/\\][^/\\]+$")
local rel = res:match("(exports[/\\].+%.[Ss][Aa][Vv])$")
or res:match("(exports[/\\].+)$")
local data = rel and love.filesystem.read(rel)
if not data then
self.saveNotice[version] = { ok = false,
@@ -3834,7 +3862,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h, paged)
elseif locked then
sfHintText, sfHintCol = "Not available yet.", PAL.warning
elseif self.isNX then
sfHintText, sfHintCol = self:_savesDefaultHint(), PAL.warning
sfHintText, sfHintCol = self:_savesDefaultHint(version), PAL.warning
elseif self.android then
sfHintText, sfHintCol =
"Import or export a .sav with the system file picker.", PAL.warning
@@ -4769,13 +4797,16 @@ function RomImporter:_modsDefaultHint()
return Strings("Or drop a mod .zip onto the window.")
end
function RomImporter:_savesDefaultHint()
function RomImporter:_savesDefaultHint(version)
if self.isNX then
version = self:_resolveSaveVersion(version)
local inbox = savesInboxDir(version)
local saveDir = love.filesystem.getSaveDirectory()
local rel = RomImporter.mtpHintPath(saveDir)
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
return Strings("Copy a .sav via MTP into %s/imports/saves/\n"
.. "DBI MTP → 1: SD Card/%simports/saves/", saveDir, rel)
local game = GameVersion.info(version).displayName
return Strings("Copy a %s .sav via MTP into %s/%s/\n"
.. "DBI MTP → 1: SD Card/%s%s/", game, saveDir, inbox, rel, inbox)
end
if self.android then
return "Import or export a .sav with the system file picker."
+11 -7
View File
@@ -6,8 +6,8 @@
-- bytes), runs them through SaveConvert.importSav (32768-byte + checksum
-- validated), then registers a fresh slot, writes it, and makes it active.
-- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and
-- drops it in the save directory's exports/ folder, returning the absolute path
-- so the launcher can offer an "open folder" affordance.
-- drops it in the save directory's exports/<version>/ folder, returning the
-- absolute path so the launcher can offer an "open folder" affordance.
--
-- Every failure returns false + a friendly one-line message (never raises), so
-- the card can surface it as a red notice line rather than crashing.
@@ -99,9 +99,9 @@ end
-- exportActiveSlot(version) -> ok, pathOrErr
-- Loads the version's active slot save (SaveData.load semantics), encodes it
-- back to a 32768-byte SRAM image, and writes it to
-- exports/gen1recomp-<version>-<slotId>.sav in the save directory (created if
-- absent). Returns true + the absolute path on success, false + a friendly
-- message otherwise.
-- exports/<version>/gen1recomp-<version>-<slotId>.sav in the save directory
-- (created if absent). Returns true + the absolute path on success, false + a
-- friendly message otherwise.
function SaveFileIO.exportActiveSlot(version)
version = version or GameVersion.get()
local save = SaveData.load(version)
@@ -111,8 +111,12 @@ function SaveFileIO.exportActiveSlot(version)
local slotId = SaveData.activeSlot(version) or "save"
local fs = love and love.filesystem
if not (fs and fs.write) then return false, "no filesystem available to export to" end
if fs.createDirectory then fs.createDirectory("exports") end
local rel = ("exports/gen1recomp-%s-%s.sav"):format(version, slotId)
if fs.createDirectory then
fs.createDirectory("exports")
fs.createDirectory("exports/" .. version)
end
-- Per-game folder so MTP browsing matches inbox layout (red/blue/yellow).
local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId)
local ok, writeErr = fs.write(rel, bytes)
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
+5 -5
View File
@@ -148,11 +148,11 @@ do
local ok, path = SaveFileIO.exportActiveSlot("red")
eq(ok, true, "exportActiveSlot succeeds for an active slot with a save")
eq(path, "/fake/save/exports/gen1recomp-red-slot1.sav",
"the export path is absolute and names the version + slot")
eq(path, "/fake/save/exports/red/gen1recomp-red-slot1.sav",
"the export path is absolute under exports/<version>/")
local outBytes = files["exports/gen1recomp-red-slot1.sav"]
check(outBytes ~= nil, "the export file lands in the save-dir exports/ folder")
local outBytes = files["exports/red/gen1recomp-red-slot1.sav"]
check(outBytes ~= nil, "the export file lands in the per-game exports/ folder")
eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "the export is exactly 32768 bytes")
check(outBytes and mainChecksumValid(outBytes),
"the export carries a valid main-data checksum")
@@ -281,7 +281,7 @@ do
if not SaveFileIO.importToSlot(GenSave.encode(seed, data, nil), "red") then return nil end
if not SaveData.load("red") then return nil end
if not SaveFileIO.exportActiveSlot("red") then return nil end
return files["exports/gen1recomp-red-slot1.sav"]
return files["exports/red/gen1recomp-red-slot1.sav"]
end
local function assertLoadable(label, mapId, x, y)
+83 -71
View File
@@ -59,6 +59,13 @@ package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
local function clearSavesInbox()
for _, ver in ipairs({ "red", "blue", "yellow" }) do
local dir = "imports/saves/" .. ver
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
love.filesystem.remove(dir .. "/" .. name)
end
love.filesystem.remove(dir .. "/.imported-sha1")
end
for _, name in ipairs(love.filesystem.getDirectoryItems("imports/saves") or {}) do
love.filesystem.remove("imports/saves/" .. name)
end
@@ -68,7 +75,6 @@ local function clearSavesInbox()
for _, name in ipairs(love.filesystem.getDirectoryItems("imports") or {}) do
love.filesystem.remove("imports/" .. name)
end
love.filesystem.remove("imports/saves/.imported-sha1")
end
local function freshImporter()
@@ -115,42 +121,48 @@ local ri = freshImporter()
eq(ri.isNX, true, "RES-07: fixture isNX=true")
eq(ri.android, false, "RES-07: fixture android=false")
-- RES-01: ensureSavesInboxDir creates imports/ then imports/saves/
-- Parent must be ensured first (nested createDirectory fails without it on NX).
-- RES-01: ensureSavesInboxDir creates imports/, imports/saves/, and per-game dirs
createdDirs = {}
ri = freshImporter()
ri:ensureSavesInboxDir()
ri:ensureSavesInboxDir("red")
check(createdDirs.imports == true,
"RES-01: ensureSavesInboxDir creates parent imports/")
check(createdDirs["imports/saves"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/")
check(createdDirs["imports/saves/red"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/red/")
check(createdDirs["imports/saves/blue"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/blue/")
check(createdDirs["imports/saves/yellow"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/yellow/")
-- NXSAV-02: notice/hint includes save dir + relative imports/saves/ MTP path
-- NXSAV-02: notice/hint includes save dir + per-game imports/saves/<version>/ MTP path
ri = freshImporter()
ri:_setNxSavesInboxNotice("red")
check(ri.saveNotice.red ~= nil, "NX saves inbox notice is set")
check(ri.saveNotice.red.text:find("sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/", 1, true),
"saves notice contains runtime save path + imports/saves/")
check(ri.saveNotice.red.text:find("sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/red/", 1, true),
"saves notice contains runtime save path + imports/saves/red/")
check(ri.saveNotice.red.text:find("DBI MTP", 1, true) ~= nil,
"saves notice contains OpenMTP-oriented hint")
check(ri.saveNotice.red.text:find("switch/gen1recomp/pokemon-love2d/imports/saves/", 1, true),
"hint uses sdmc-stripped relative imports/saves/ path")
check(ri.saveNotice.red.text:find("imports/saves/red/", 1, true),
"hint uses per-game imports/saves/red/ path")
-- NXSAV-01 / RES-08: scanSavesInbox returns only *.sav under imports/saves/
-- NXSAV-01 / RES-08: scanSavesInbox returns only *.sav under imports/saves/<version>/
ri = freshImporter()
love.filesystem.write("imports/saves/valid.sav", string.rep("S", 32))
love.filesystem.write("imports/saves/readme.txt", "nope")
love.filesystem.write("imports/saves/cart.gb", string.rep("R", 16))
love.filesystem.write("imports/saves/pack.zip", "ZIP")
love.filesystem.write("imports/saves/red/valid.sav", string.rep("S", 32))
love.filesystem.write("imports/saves/red/readme.txt", "nope")
love.filesystem.write("imports/saves/red/cart.gb", string.rep("R", 16))
love.filesystem.write("imports/saves/red/pack.zip", "ZIP")
love.filesystem.write("imports/saves/blue/other.sav", "WRONGGAME")
love.filesystem.write("imports/other.sav", "WRONGDIR")
local savs = ri:scanSavesInbox()
eq(#savs, 1, "scanSavesInbox returns one .sav candidate")
eq(savs[1], "imports/saves/valid.sav", "scanSavesInbox path is under imports/saves/")
local savs = ri:scanSavesInbox("red")
eq(#savs, 1, "scanSavesInbox returns one .sav candidate for red")
eq(savs[1], "imports/saves/red/valid.sav", "scanSavesInbox path is under imports/saves/red/")
-- RES-08: ROM scanInbox must not treat imports/saves/*.sav as ROM
ri = freshImporter()
love.filesystem.write("imports/saves/cart.sav", string.rep("S", 32))
love.filesystem.write("imports/saves/dump.gb", string.rep("G", 16))
love.filesystem.write("imports/saves/red/cart.sav", string.rep("S", 32))
love.filesystem.write("imports/saves/red/dump.gb", string.rep("G", 16))
local roms = ri:scanInbox(ri.ready)
for _, path in ipairs(roms) do
check(not path:lower():match("%.sav$"),
@@ -163,7 +175,7 @@ end
ri = freshImporter()
ri:ensureModsInboxDir()
love.filesystem.write("imports/mods/mod.zip", "ZIP")
love.filesystem.write("imports/saves/slot.sav", string.rep("S", 32))
love.filesystem.write("imports/saves/red/slot.sav", string.rep("S", 32))
local zips = ri:scanModsInbox()
for _, path in ipairs(zips) do
check(not path:lower():match("%.sav$"),
@@ -193,50 +205,50 @@ importCalls = {}
ri:rescanSavesAction("red")
eq(#importCalls, 0, "empty saves inbox does not call importToSlot")
check(ri.saveNotice.red ~= nil, "RES-04: empty rescan sets saveNotice")
check(ri.saveNotice.red.text:find("imports/saves/", 1, true),
check(ri.saveNotice.red.text:find("imports/saves/red/", 1, true),
"empty rescan shows saves MTP notice")
-- RES-02: AppleDouble-only inbox ≡ empty
ri = freshImporter()
importCalls = {}
love.filesystem.write("imports/saves/._foo.sav", "APPL")
love.filesystem.write("imports/saves/red/._foo.sav", "APPL")
ri:rescanSavesAction("red")
eq(#importCalls, 0, "RES-02: AppleDouble-only does not import")
check(ri.saveNotice.red ~= nil and ri.saveNotice.red.text:find("imports/saves/", 1, true),
check(ri.saveNotice.red ~= nil and ri.saveNotice.red.text:find("imports/saves/red/", 1, true),
"RES-02: AppleDouble-only shows MTP notice")
-- NXSAV-03 / RES-05: success → refresh; bytes kept as *.sav.imported (not re-scanned)
ri = freshImporter()
importCalls = {}
removed = {}
love.filesystem.write("imports/saves/good.sav", "GOODSAV")
importBehavior["imports/saves/good.sav"] = { ok = true, id = "slot-good" }
love.filesystem.write("imports/saves/red/good.sav", "GOODSAV")
importBehavior["imports/saves/red/good.sav"] = { ok = true, id = "slot-good" }
ri:rescanSavesAction("red")
eq(#importCalls, 1, "success path calls importToSlot once")
eq(importCalls[1].source, "imports/saves/good.sav", "importToSlot receives inbox path")
eq(importCalls[1].source, "imports/saves/red/good.sav", "importToSlot receives inbox path")
eq(importCalls[1].version, "red", "importToSlot uses panel version")
check(ri._refreshed and ri._refreshed >= 1, "success refreshes slots")
check(ri.saveNotice.red and ri.saveNotice.red.ok, "success sets ok notice")
check(ri.saveNotice.red.text:find("Pokemon Red", 1, true)
or ri.saveNotice.red.text:find("Red", 1, true),
"success notice names the game tab")
check(love.filesystem.getInfo("imports/saves/good.sav") == nil,
check(love.filesystem.getInfo("imports/saves/red/good.sav") == nil,
"RES-05: success retires live .sav (no longer a candidate)")
check(love.filesystem.read("imports/saves/good.sav.imported") == "GOODSAV",
check(love.filesystem.read("imports/saves/red/good.sav.imported") == "GOODSAV",
"RES-05: success keeps bytes under .sav.imported")
check(love.filesystem.getInfo("imports/saves/.imported-sha1") ~= nil,
check(love.filesystem.getInfo("imports/saves/red/.imported-sha1") ~= nil,
"success records content hash ledger")
-- Re-press Import save must not clone slots (hash ledger + retired file)
ri = freshImporter()
importCalls = {}
love.filesystem.write("imports/saves/again.sav", "SAMEBYTES")
importBehavior["imports/saves/again.sav"] = { ok = true, id = "slot-1" }
love.filesystem.write("imports/saves/red/again.sav", "SAMEBYTES")
importBehavior["imports/saves/red/again.sav"] = { ok = true, id = "slot-1" }
ri:rescanSavesAction("red")
eq(#importCalls, 1, "first import of again.sav")
-- Put the same bytes back under a new name (player re-copied / renamed)
love.filesystem.write("imports/saves/again-copy.sav", "SAMEBYTES")
importBehavior["imports/saves/again-copy.sav"] = { ok = true, id = "slot-clone" }
love.filesystem.write("imports/saves/red/again-copy.sav", "SAMEBYTES")
importBehavior["imports/saves/red/again-copy.sav"] = { ok = true, id = "slot-clone" }
importCalls = {}
ri:rescanSavesAction("red")
eq(#importCalls, 0, "harden: same content hash is not imported again")
@@ -245,39 +257,39 @@ check(ri.saveNotice.red and ri.saveNotice.red.ok,
check(ri.saveNotice.red.text:find("Already imported", 1, true)
or ri.saveNotice.red.text:find("skipped", 1, true),
"harden: notice explains skip")
check(love.filesystem.getInfo("imports/saves/again-copy.sav") == nil,
check(love.filesystem.getInfo("imports/saves/red/again-copy.sav") == nil,
"harden: leftover duplicate .sav is retired without importing")
-- NXSAV-04 / RES-05: failure → clear notice; .sav retained as-is
ri = freshImporter()
importCalls = {}
removed = {}
love.filesystem.write("imports/saves/bad.sav", "BADSAV")
importBehavior["imports/saves/bad.sav"] = { ok = false, err = "save file must be 32768 bytes" }
love.filesystem.write("imports/saves/red/bad.sav", "BADSAV")
importBehavior["imports/saves/red/bad.sav"] = { ok = false, err = "save file must be 32768 bytes" }
ri:rescanSavesAction("red")
eq(#importCalls, 1, "failure path still attempts importToSlot")
check(ri.saveNotice.red and not ri.saveNotice.red.ok, "failure sets clear error notice")
check(ri.saveNotice.red.text:find("32768", 1, true),
"failure notice includes import error")
check(not removed["imports/saves/bad.sav"], "RES-05: failure does not remove inbox .sav")
check(love.filesystem.read("imports/saves/bad.sav") == "BADSAV",
check(not removed["imports/saves/red/bad.sav"], "RES-05: failure does not remove inbox .sav")
check(love.filesystem.read("imports/saves/red/bad.sav") == "BADSAV",
"RES-05: failure leaves .sav in inbox")
-- Mixed valid/invalid: attempt each; bad retained, good retired
ri = freshImporter()
importCalls = {}
removed = {}
love.filesystem.write("imports/saves/a-bad.sav", "BAD")
love.filesystem.write("imports/saves/b-good.sav", "GOOD")
importBehavior["imports/saves/a-bad.sav"] = { ok = false, err = "bad checksum" }
importBehavior["imports/saves/b-good.sav"] = { ok = true, id = "slot-b" }
love.filesystem.write("imports/saves/red/a-bad.sav", "BAD")
love.filesystem.write("imports/saves/red/b-good.sav", "GOOD")
importBehavior["imports/saves/red/a-bad.sav"] = { ok = false, err = "bad checksum" }
importBehavior["imports/saves/red/b-good.sav"] = { ok = true, id = "slot-b" }
ri:rescanSavesAction("red")
eq(#importCalls, 2, "mixed inbox attempts each .sav")
check(love.filesystem.read("imports/saves/a-bad.sav") == "BAD",
check(love.filesystem.read("imports/saves/red/a-bad.sav") == "BAD",
"mixed: bad .sav retained")
check(love.filesystem.getInfo("imports/saves/b-good.sav") == nil,
check(love.filesystem.getInfo("imports/saves/red/b-good.sav") == nil,
"mixed: good .sav retired")
check(love.filesystem.read("imports/saves/b-good.sav.imported") == "GOOD",
check(love.filesystem.read("imports/saves/red/b-good.sav.imported") == "GOOD",
"mixed: good bytes kept as .imported")
check(ri.saveNotice.red and ri.saveNotice.red.ok, "mixed keeps overall success when one imports")
check(ri.saveNotice.red.text:find("failed", 1, true),
@@ -288,10 +300,10 @@ check(ri.saveNotice.red.text:find("bad checksum", 1, true),
-- Multi-success notice names count + active slot (not only last ok line)
ri = freshImporter()
importCalls = {}
love.filesystem.write("imports/saves/one.sav", "ONE")
love.filesystem.write("imports/saves/two.sav", "TWO")
importBehavior["imports/saves/one.sav"] = { ok = true, id = "slot-one" }
importBehavior["imports/saves/two.sav"] = { ok = true, id = "slot-two" }
love.filesystem.write("imports/saves/red/one.sav", "ONE")
love.filesystem.write("imports/saves/red/two.sav", "TWO")
importBehavior["imports/saves/red/one.sav"] = { ok = true, id = "slot-one" }
importBehavior["imports/saves/red/two.sav"] = { ok = true, id = "slot-two" }
ri:rescanSavesAction("red")
eq(#importCalls, 2, "multi-success imports each distinct .sav")
check(ri.saveNotice.red.text:find("Imported 2 saves", 1, true),
@@ -303,12 +315,12 @@ eq(ri.activeSlot.red, "slot-two", "multi-success leaves last import active")
-- RES-03: Mac MTP AppleDouble (._*.sav) must not be import candidates
ri = freshImporter()
importCalls = {}
love.filesystem.write("imports/saves/._cart.sav", "APPL")
love.filesystem.write("imports/saves/cart.sav", "GOOD")
importBehavior["imports/saves/cart.sav"] = { ok = true, id = "slot-cart" }
love.filesystem.write("imports/saves/red/._cart.sav", "APPL")
love.filesystem.write("imports/saves/red/cart.sav", "GOOD")
importBehavior["imports/saves/red/cart.sav"] = { ok = true, id = "slot-cart" }
ri:rescanSavesAction("red")
eq(#importCalls, 1, "RES-03: AppleDouble ._*.sav is skipped")
eq(importCalls[1].source, "imports/saves/cart.sav",
eq(importCalls[1].source, "imports/saves/red/cart.sav",
"only the real .sav is imported")
check(ri.saveNotice.red and ri.saveNotice.red.ok, "AppleDouble skip still shows import success")
check(not (ri.saveNotice.red.text or ""):find("failed", 1, true),
@@ -339,13 +351,13 @@ eq(hostShellCalls, 0, "RES-06: NX chooseSaveImport does not require HostShell")
ri = freshImporter()
importCalls = {}
hostShellCalls = 0
love.filesystem.write("imports/saves/from-choose.sav", "CHOOSE")
importBehavior["imports/saves/from-choose.sav"] = { ok = true, id = "slot-choose" }
love.filesystem.write("imports/saves/red/from-choose.sav", "CHOOSE")
importBehavior["imports/saves/red/from-choose.sav"] = { ok = true, id = "slot-choose" }
ri:chooseSaveImport("red")
eq(hostShellCalls, 0, "NX chooseSaveImport does not use HostShell")
eq(#importCalls, 1, "NX chooseSaveImport rescans and imports inbox .sav")
eq(importCalls[1].source, "imports/saves/from-choose.sav",
"NX chooseSaveImport imports from imports/saves/")
eq(importCalls[1].source, "imports/saves/red/from-choose.sav",
"NX chooseSaveImport imports from imports/saves/red/")
check(ri.saveNotice.red and ri.saveNotice.red.ok, "NX chooseSaveImport success notice")
-- Empty chooseSaveImport still sets notice (RES-04 via Import save button)
@@ -353,7 +365,7 @@ ri = freshImporter()
importCalls = {}
ri:chooseSaveImport("red")
eq(#importCalls, 0, "empty NX chooseSaveImport does not import")
check(ri.saveNotice.red ~= nil and ri.saveNotice.red.text:find("imports/saves/", 1, true),
check(ri.saveNotice.red ~= nil and ri.saveNotice.red.text:find("imports/saves/red/", 1, true),
"empty NX chooseSaveImport sets MTP notice")
-- Edge: ROM not ready → refuse with existing notice (no silent no-op)
@@ -361,24 +373,24 @@ ri = freshImporter()
importCalls = {}
removed = {}
ri.ready.red = false
love.filesystem.write("imports/saves/need-rom.sav", "NEEDROM")
importBehavior["imports/saves/need-rom.sav"] = { ok = true, id = "should-not-import" }
love.filesystem.write("imports/saves/red/need-rom.sav", "NEEDROM")
importBehavior["imports/saves/red/need-rom.sav"] = { ok = true, id = "should-not-import" }
ri:chooseSaveImport("red")
eq(#importCalls, 0, "ROM-not-ready: chooseSaveImport does not call importToSlot")
check(ri.saveNotice.red and not ri.saveNotice.red.ok,
"ROM-not-ready: chooseSaveImport sets error notice")
check(ri.saveNotice.red.text:find("Import the Pokemon Red ROM before importing a save", 1, true),
"ROM-not-ready: notice tells player to import ROM first")
check(not removed["imports/saves/need-rom.sav"],
check(not removed["imports/saves/red/need-rom.sav"],
"ROM-not-ready: retains inbox .sav")
check(love.filesystem.read("imports/saves/need-rom.sav") == "NEEDROM",
check(love.filesystem.read("imports/saves/red/need-rom.sav") == "NEEDROM",
"ROM-not-ready: leaves .sav bytes in inbox")
ri = freshImporter()
importCalls = {}
ri.ready.red = false
love.filesystem.write("imports/saves/need-rom2.sav", "NEEDROM2")
importBehavior["imports/saves/need-rom2.sav"] = { ok = true, id = "should-not" }
love.filesystem.write("imports/saves/red/need-rom2.sav", "NEEDROM2")
importBehavior["imports/saves/red/need-rom2.sav"] = { ok = true, id = "should-not" }
ri:rescanSavesAction("red")
eq(#importCalls, 0, "ROM-not-ready: rescan does not call importToSlot")
check(ri.saveNotice.red and not ri.saveNotice.red.ok,
@@ -391,8 +403,8 @@ ri = freshImporter()
importCalls = {}
ri.saveNotice.red = { ok = true, text = "PRESERVE_ME" }
ri.workState = "working"
love.filesystem.write("imports/saves/busy.sav", "BUSY")
importBehavior["imports/saves/busy.sav"] = { ok = true, id = "slot-busy" }
love.filesystem.write("imports/saves/red/busy.sav", "BUSY")
importBehavior["imports/saves/red/busy.sav"] = { ok = true, id = "slot-busy" }
ri:chooseSaveImport("red")
eq(#importCalls, 0, "workState working: chooseSaveImport does not import")
eq(ri.saveNotice.red.text, "PRESERVE_ME",
@@ -402,11 +414,11 @@ eq(#importCalls, 0, "workState working: rescanSavesAction does not import")
eq(ri.saveNotice.red.text, "PRESERVE_ME",
"workState working: rescanSavesAction leaves saveNotice unchanged")
-- RES-11 / NXSAV-07: NX default SAVE FILES hint mentions imports/saves/
-- RES-11 / NXSAV-07: NX default SAVE FILES hint mentions per-game inbox
ri = freshImporter()
local defaultHint = ri:_savesDefaultHint()
check(defaultHint:find("imports/saves/", 1, true),
"RES-11: NX default hint mentions imports/saves/")
local defaultHint = ri:_savesDefaultHint("red")
check(defaultHint:find("imports/saves/red/", 1, true),
"RES-11: NX default hint mentions imports/saves/red/")
check(defaultHint:find("DBI MTP", 1, true),
"RES-11: NX default hint mentions DBI MTP")
check(not defaultHint:find("system file picker", 1, true),
@@ -437,7 +449,7 @@ package.loaded["src.import.SaveFileIO"] = {
end,
exportActiveSlot = function(version)
exportCalls[#exportCalls + 1] = version
return true, "sdmc:/switch/gen1recomp/pokemon-love2d/exports/gen1recomp-red-slot-1.sav"
return true, "sdmc:/switch/gen1recomp/pokemon-love2d/exports/red/gen1recomp-red-slot-1.sav"
end,
}
ri = freshImporter()