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>
16 KiB
Launcher
The launcher is src/import/RomImporter.lua, the first-run / title screen
that runs before Game:load. Besides ROM import (see the file's own header)
it hosts a tabbed shell covering per-game save slots and a mod manager. This
file documents the runtime model; the visual spec lives separately.
Android multi-ROM / mod / save import
On Android, love.system.pickFile([kind]) opens the Storage Access Framework
picker (GameActivity.showFilePicker); the chosen file is copied into the app
save directory as:
kind |
Destination |
|---|---|
nil / "rom" |
picked_rom.gb (open) |
"mod" |
picked_mod.zip (open) |
"sav" / "save" |
picked_save.sav (open) |
Export uses a separate API: love.system.createFile(suggestedName) →
GameActivity.showCreateDocument (ACTION_CREATE_DOCUMENT), which copies
staged pending_export.sav to the user-chosen URI and writes export_done.flag
for the launcher to acknowledge on refocus.
RomImporter then imports on refocus / Choose:
- ROMs via
findPendingRom: only a 1 MiB.gbwhose SHA-1 maps to a version that is not yet ready counts as pending. A leftoverpicked_rom.gbfrom Red therefore cannot block Blue's Choose (issue #167). - Mods via
findPendingMod: Preferpicked_mod.zip, or (on Choose) any other.zipat the save-dir root (USB copy). - Saves via
findPendingSav: Preferpicked_save.sav, or (on Choose) any other.savat the save-dir root.
After a successful import the consumed save-dir file is removed.
Manual check (device/emulator): import Red → switch to Blue → Choose → system file picker must appear (not a silent Red re-extract) → pick Blue → Blue becomes ready beside Red. On the MODS tab, Import mod .zip must open the same system picker and install the chosen archive on return.
Tab structure
self.tab is one of "red", "blue", "yellow", "mods". The tab bar
draws one chip per game plus a MODS chip and rebuilds self.tabRects every
frame so mousepressed can dispatch clicks; switching tabs mid-import is
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
- A game tab (
_drawGamePanel) shows the ROM card, the SAVE FILES card, the Play button, and the SAVE SLOT card in a responsive two-column grid (see Responsiveness). The MODS tab (_drawModsPanel) shows the mod list instead. - The self-updater banner (
self.Check, seedocs/updater.md) draws as a centered pill in a reserved band just above the footer, on every tab. That position is unchanged by this redesign, sodocs/updater.mdneeded no edits.
Save slot model
All slot I/O lives in src/core/SaveData.lua and goes through the same fs
abstraction (persistFs) every other save/options call uses, so portable
mode (an io.* filesystem used when portable.txt marks the install)
keeps working unchanged.
- Files. A version's playthroughs live under
saves/<version>/, one file per slot:saves/<version>/slot1.luaplus a rolling.bakand staged.tmpwitness (slotNames), mirroring the write/recovery disciplineSaveData.save/loadalready use for the flat legacy file. Slot ids matchslot%d+;createSlotallocates one past the highest existing number so a reused id can never collide with a lingering file. - Registry. The ordered slot list and which one is active persist in
options.lua(via the existingSaveData.loadOptions/saveOptions):options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }. Custom slot labels (#205) live alongside them in the same registry:options.saveSlots[version].names = { slot1 = "Nuzlocke" }, written bySaveData.renameSlot(trimmed; an empty label clears it) and surfaced on eachlistSlotsrow aslabel(the launcher row showslabel, falling back to the player name).deleteSlotdrops the label with the slot. Renaming never touches the save file, so an empty slot can be labeled. On desktop, right-clicking a slot row opens the inline rename modal (Enter commits, Esc cancels); touch has no secondary button, so the affordance is desktop-only. - Active slot resolution.
saveNames(version), the function every existing caller (TitleStatehasSave/load/save, recovery order) already goes through, now resolves the active slot instead of a fixed flat name. Resolved once per version per process (ensureVersionSlots, cached inactiveSlotCache/slotsChecked): a registry entry wins; otherwise a lazy legacy migration may create one; otherwise the flat legacy path is used (save.lua/save_blue.lua), so a pre-slots install keeps working as before. - Legacy migration. One-time per version, lazy on first
listSlots/load/saveNamescall (tryMigrateLegacy): if a flat legacy file exists and nosaves/<version>/registry does, its main +.bakare copied intosaves/<version>/slot1.lua(.bak), verified readable (decodeSlot: main, then.tmp, then.bak), and only then are the originals removed andslot1registered as active. A copy that fails to verify leaves the originals in place; migration never loses data.
The launcher-facing API:
SaveData.listSlots(version)-> array of{id, exists, name, meta}for every registered slot.nameis the save's player name, ornilfor an empty slot;metais{badges, timeText, dexCount}(the same fields the title screen'sContinueInfoshows) ornil. The pure part,SaveData.slotSummary(save), is unit-testable with no filesystem.SaveData.setActiveSlot(version, slotId)registers the id if new, persists it as active, and updates the process cache so the very next save/load lands there. The launcher calls this the moment a slot row is clicked (RomImporter:_selectSlot); pressing Play needs no signature change, sinceGame.lua/main.luastill just callSaveData.load()/save().SaveData.createSlot(version)-> new slot id, registered but with no save file written. An empty slot means the title screen offers NEW GAME only, which needs no further changes.SaveData.deleteSlot(version, slotId)removes the slot's main/.bak/.tmpfiles, drops it from the registry, and if it was active points active at another remaining slot (or clears active when the list is empty). The launcher's SAVE SLOT panel Delete control calls this.
Launcher mod manager
src/mods/LauncherMods.lua is a launcher-only read of the mod set. It runs
before Game:load, so it never loads a mod's entry chunk; only
manifest.json is read and validated (src/mods/Manifest.validate), the way
Loader:_discover finds mods without running them. The real loader
(src/mods/Loader.lua) still owns the actual load at boot.
LauncherMods.list()scansmods/one level deep (first id wins on a duplicate) and returns one row per mod:{id, name, version, badge, description, enabled, status, statusDetail}.badgeis the manifest'scategory, falling back toprofile, then"MOD", uppercased.enabledreadsoptions.mods[id](missing means enabled, matching the loader's own default).statusis"ok","warn", or"conflict", computed by the pureLauncherMods.deriveList/statusForagainstManagerState.resolveToggleand the validated manifests:conflictwhen enabling this mod collides with another enabled one;warnfor an out-of-rangegame_versionor an absent/disabled/wrong-version hard dependency;okotherwise. Having nolove.*calls, this half is table-driven by the test suite on its own.LauncherMods.setEnabled(id, bool)persistsoptions.mods[id]as a plain boolean, the exact shapeLoader:_saveStatewrites, so the running game and the in-gameManagerStatesee the change on next boot. The mods panel calls this on every toggle and re-derives the list right away (RomImporter:_refreshMods) so a status change (e.g. a new conflict) shows without waiting for a reload.LauncherMods.installZip(path)mounts the archive withlove.filesystem.mount, locates the mod root vialocateRoot(manifest at the zip root, or inside one top-level folder), validates its manifest, and copies the tree into the save-dirmods/<id>/before unmounting. Rejects a duplicate of an already-installed mod id, and accepts either an external path string or a LOVEDroppedFile, staging a dropped file into a save-dir temp first (mount only reaches save-dir-relative paths), the same wayRomImporterhandles a dropped ROM. A failed copy rolls its partial tree back, and every path unmounts and clears the staged temp file.LauncherMods.uninstall(id)removesmods/<id>/and clearsoptions.mods[id]so a later reinstall starts from the loader's default (enabled). The mods panel Delete control calls this and re-derives the list.
Import / Export save
The SAVE FILES card wires a raw Gen1 .sav battery image to the save slots
through src/import/SaveFileIO.lua, which sits on top of
src/save_convert/SaveConvert.lua and the slot API in SaveData.
- Import save is live once the game's ROM is imported (playable).
On desktop it opens a native
.savpicker (chooseSav); on Android,love.system.pickFile("sav")→picked_save.sav, same SAF path as ROMs. On NX (Switch) there is no picker: copy a.savintogetSaveDirectory()/imports/saves/<red|blue|yellow>/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 ROMimports/and modimports/mods/inboxes). Hidden._*.savAppleDouble sidecars are skipped.SaveFileIO.importToSlotreads the bytes (an absolute path, a save-dir relative name, a dropped LOVE file, or raw bytes), guards the 32768-byte size, runsSaveConvert.importSav(which also rejects a bad main-data checksum), then registers a fresh slot (SaveData.createSlot), writes it (SaveData.writeSlot), and makes it active (SaveData.setActiveSlot). The meta stamp is re-stamped offgen1_importto the current numeric format soSaveData.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.importedand records a content hash inimports/saves/<game>/.imported-sha1so 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.exportActiveSlotloads the active slot, encodes it back withSaveConvert.exportSav(a slot never keepsrawImport, so this is a zero-filled template export, which is valid), and writesexports/<version>/gen1recomp-<version>-<slotId>.savin the save directory (exports/andexports/<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 aspending_export.savandlove.system.createFile(suggestedName)opensACTION_CREATE_DOCUMENTso the player can save to Downloads / Drive / etc.; on returnexport_done.flagmakes focus show "Save exported." On NX, export success sets a notice with theexports/<game>/path and an MTP-oriented hint — noopenURL/ Open folder (pull the file via MTP / SD / FTP instead). - Drag-drop.
filedroppedroutes a.savto the import path for the currently active game tab; when a non-game tab (mods, or the locked yellow placeholder) is showing it defaults to red, the always-present first game (_savedropTarget)..gb(ROM) and.zip(mod) routing is unchanged. - Failure UX. Every error path (wrong size, bad checksum, write failure, nothing to export, ROM not imported yet) surfaces as a red notice line on the card. Nothing raises and nothing silently no-ops.
SaveFileIO is love-free enough to unit-test through the same in-memory
filesystem stub the slot backend uses (tests/engine/save_file_io_tests.lua).
Responsiveness
Every measurement derives from love.graphics.getDimensions() each frame
plus the existing global scale s = clamp(height / 768, 0.7, 1.6); nothing
assumes a fixed window size. The game panel's two-column grid (ROM/SAVE
FILES/Play on the left, SAVE SLOT on the right) collapses to one stacked
column, slot card below Play, when the window is too narrow for both
~300 * s-wide columns. The save-slot list and the mod list both scroll
(wheel, or drag on touch/desktop) clamped to their own content extent,
recomputed every draw. The tab bar labels only the active chip so it stays
narrow-safe, and content caps out at ~1440 * s wide, centered.
The desktop window has a floor of 480x360 (conf.lua minwidth/minheight),
under which the cards stop being readable at all. Mobile ignores it: those
windows are fullscreen.
Page scroll
Two columns fit any window the launcher is likely to open in; one stacked column does not. On a phone-shaped window the ROM card, SAVE FILES, Play and SAVE SLOT together run past the bottom, and a footer pinned to the window bottom painted over them with the overflow unreachable.
So the whole column under the tab bar -- panel, updater banner, footer -- scrolls as one page whenever it is taller than the room below the tab bar:
- The strip, logo and tab bar stay pinned, so navigation is always on screen.
Everything else draws at
contentTop - pageScrollinside a scissor, and the footer is laid out downward fromfooterTopright after the content instead of upward from the window bottom. RomImporter.pageScrollFor(naturalH, viewportH, scroll)is the whole decision, pure and pinned bytests/engine/launcher_page_scroll.lua. A window that grows back drags the offset down with it, so the page can never stay parked past its own end.- The panels report their natural height as they draw (
_drawGamePaneland_drawModsPanelreturn it), so the decision reads the previous frame's measurement -- the same one-frame settle the two lists already rely on. - One scroll axis at a time. While the page scrolls, the panels draw
paged: the slot and mod lists take their natural height, keep no inner scroll region and report a max of 0, so the wheel, the right stick and a drag all move the page and never fight a list for the same gesture. Two-column layouts do not overflow,pagedstays false, and every one of these behaves exactly as it did before. - Hit testing follows the clip:
inside(clicks) and_ptIn(hover) reject a rect that scrolled out of the viewport, so a control that slid under the tab bar cannot be clicked through it. Tab chips carrypinned = trueand are exempt.pageScrollresets on a tab change, each tab being a different length. - A press on empty background pans the page, resolved in
_updateSlotDraglike every other drag here.
Dragging on Android
The launcher is handed no move events on any platform: main.lua forwards
neither touchmoved nor mousemoved while it is up, which is why every drag
here is resolved by polling inside draw instead. Desktop polls the mouse;
Android used to poll nothing at all ("no reliable pointer polling" meant its
mouse emulation), so it had no scroll gesture whatsoever -- fine while every
scroll region was an inner list with a wheel alternative, useless the moment
the page itself became the thing that scrolls, since a phone is exactly where
it overflows.
love.touch is pollable, so _pointerHold reads the first active touch there
and hands _updateSlotDrag the same (held, y) pair the mouse gives on desktop.
Consequences:
- Slot rows and mod toggles ARM on press and commit on release on Android too, matching desktop, so a swipe that starts on a card scrolls instead of selecting the row it started on.
touchPollable(set once innew) gates all of it. Wherelove.touchis missing, every Android path is exactly what it was: act on press, never arm, no drag.