From 2591fde7649b7bd2fc822006bf3109a7b2c02016 Mon Sep 17 00:00:00 2001 From: Andrew Quenehen Date: Mon, 3 Aug 2026 08:34:13 -0300 Subject: [PATCH] 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 --- .specs/STATE.md | 10 +- docs/launcher.md | 25 ++-- docs/switch-development.md | 18 +-- docs/switch-install.md | 19 ++- docs/switch-transfer.md | 15 +- src/import/RomImporter.lua | 105 +++++++++----- src/import/SaveFileIO.lua | 18 ++- tests/engine/save_file_io_tests.lua | 10 +- tests/rom_importer_nx_saves_inbox_test.lua | 154 +++++++++++---------- 9 files changed, 214 insertions(+), 160 deletions(-) diff --git a/.specs/STATE.md b/.specs/STATE.md index be96a8a6..521e9232 100644 --- a/.specs/STATE.md +++ b/.specs/STATE.md @@ -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 tab’s folder; export writes `exports/{red,blue,yellow}/gen1recomp--.sav` and surfaces an MTP path notice (no `openURL`). After success the live `.sav` is retired to `*.sav.imported` and hashed in that folder’s `.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 diff --git a/docs/launcher.md b/docs/launcher.md index ea754ef9..e15be1de 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -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//` via MTP / SD / FTP + (one folder per game), then press **Import save** on that game’s 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//.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 game’s 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--.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//gen1recomp--.sav` in the save + directory (`exports/` and `exports//` 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//` 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 diff --git a/docs/switch-development.md b/docs/switch-development.md index 28d46365..bc22723a 100644 --- a/docs/switch-development.md +++ b/docs/switch-development.md @@ -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//`. 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//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 tab’s** 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//imports/saves//` (see launcher notice for the live `getSaveDirectory()` path) | +| Candidates | non-hidden `*.sav` only in **that game’s** 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//.imported-sha1` | +| Exports | **Export save** writes under `exports//gen1recomp--.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//`. **MTP tip:** the same AppleDouble `._*.sav` rule applies — see the mod inbox tip above. diff --git a/docs/switch-install.md b/docs/switch-install.md index f0e6b4e1..7e2ba99a 100644 --- a/docs/switch-install.md +++ b/docs/switch-install.md @@ -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 game’s ROM already imported, open that game’s tab → **SAVE FILES** - → **Import save**. The launcher rescans the inbox into **this tab’s** - 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 game’s inbox under the save dir + ([switch-transfer.md](switch-transfer.md)). +2. With the game’s ROM already imported, open **that game’s 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 game’s **`exports//`** 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. diff --git a/docs/switch-transfer.md b/docs/switch-transfer.md index cfab44d3..938a4f49 100644 --- a/docs/switch-transfer.md +++ b/docs/switch-transfer.md @@ -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//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 game’s 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//`, or `exports//` 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//`, + and `exports//` 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//`). Pull exported + `.sav` files from `exports//`. VoxelMod Joy-Con chords and Switch performance tips: [switch-install.md](switch-install.md#community-mods-voxelmod). diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index b502e04c..2928bc06 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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//. +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//: 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." diff --git a/src/import/SaveFileIO.lua b/src/import/SaveFileIO.lua index f6a1f04f..76a0ebf1 100644 --- a/src/import/SaveFileIO.lua +++ b/src/import/SaveFileIO.lua @@ -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// 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--.sav in the save directory (created if --- absent). Returns true + the absolute path on success, false + a friendly --- message otherwise. +-- exports//gen1recomp--.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 "" diff --git a/tests/engine/save_file_io_tests.lua b/tests/engine/save_file_io_tests.lua index 6645c7c9..90a7fd07 100644 --- a/tests/engine/save_file_io_tests.lua +++ b/tests/engine/save_file_io_tests.lua @@ -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//") - 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) diff --git a/tests/rom_importer_nx_saves_inbox_test.lua b/tests/rom_importer_nx_saves_inbox_test.lua index 47f3dd4a..28bd0cc7 100644 --- a/tests/rom_importer_nx_saves_inbox_test.lua +++ b/tests/rom_importer_nx_saves_inbox_test.lua @@ -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// 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// 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()