* new launcher and save converts and pipeline * fixing bugs
9.7 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 import
On Android, love.system.pickFile() opens the Storage Access Framework
picker (GameActivity.showRomFilePicker); the chosen file is copied to
picked_rom.gb in the app save directory. RomImporter then imports on
refocus / Choose via findPendingRom: only a 1 MiB .gb whose SHA-1 maps
to a version that is not yet ready counts as pending. A leftover
picked_rom.gb from Red therefore cannot block Blue's Choose (issue #167).
After a successful import the consumed save-dir .gb 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.
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" } }. - 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.
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.
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). It opens
a native
.savpicker (chooseSav, the per-OS dialogs mirrorchooseZip; Android has no picker and shows a drop hint).SaveFileIO.importToSlotreads the bytes (an absolute path, 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. - 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/gen1recomp-<version>-<slotId>.savin the save directory (love.filesystem.createDirectory("exports")). It returns the absolute path (love.filesystem.getSaveDirectory()), which the notice line shows with a desktop "Open folder" affordance (love.system.openURL("file://" .. dir)). - 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.