Files
gen1recomp/docs/launcher.md
T
bryanthaboi 3069b2e2a9 The new experience (#201)
* new launcher and save converts and pipeline

* fixing bugs
2026-07-25 12:36:53 -04:00

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, see docs/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, so docs/updater.md needed 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.lua plus a rolling .bak and staged .tmp witness (slotNames), mirroring the write/recovery discipline SaveData.save/load already use for the flat legacy file. Slot ids match slot%d+; createSlot allocates 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 existing SaveData.loadOptions/saveOptions): options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }.
  • Active slot resolution. saveNames(version), the function every existing caller (TitleState hasSave/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 in activeSlotCache/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/saveNames call (tryMigrateLegacy): if a flat legacy file exists and no saves/<version>/ registry does, its main + .bak are copied into saves/<version>/slot1.lua(.bak), verified readable (decodeSlot: main, then .tmp, then .bak), and only then are the originals removed and slot1 registered 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. name is the save's player name, or nil for an empty slot; meta is {badges, timeText, dexCount} (the same fields the title screen's ContinueInfo shows) or nil. 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, since Game.lua/main.lua still just call SaveData.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() scans mods/ one level deep (first id wins on a duplicate) and returns one row per mod: {id, name, version, badge, description, enabled, status, statusDetail}. badge is the manifest's category, falling back to profile, then "MOD", uppercased. enabled reads options.mods[id] (missing means enabled, matching the loader's own default).
  • status is "ok", "warn", or "conflict", computed by the pure LauncherMods.deriveList/statusFor against ManagerState.resolveToggle and the validated manifests: conflict when enabling this mod collides with another enabled one; warn for an out-of-range game_version or an absent/disabled/wrong-version hard dependency; ok otherwise. Having no love.* calls, this half is table-driven by the test suite on its own.
  • LauncherMods.setEnabled(id, bool) persists options.mods[id] as a plain boolean, the exact shape Loader:_saveState writes, so the running game and the in-game ManagerState see 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 with love.filesystem.mount, locates the mod root via locateRoot (manifest at the zip root, or inside one top-level folder), validates its manifest, and copies the tree into the save-dir mods/<id>/ before unmounting. Rejects a duplicate of an already-installed mod id, and accepts either an external path string or a LOVE DroppedFile, staging a dropped file into a save-dir temp first (mount only reaches save-dir-relative paths), the same way RomImporter handles 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 .sav picker (chooseSav, the per-OS dialogs mirror chooseZip; Android has no picker and shows a drop hint). SaveFileIO.importToSlot reads the bytes (an absolute path, a dropped LOVE file, or raw bytes), guards the 32768-byte size, runs SaveConvert.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 off gen1_import to the current numeric format so SaveData.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.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")). 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. filedropped routes a .sav to 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.