Validate save imports by main-data checksum, not raw file size

The SAVE FILES card only accepted saves of exactly 32768 bytes and
refused anything else. importToSlot now classifies a non-32768 file by
the integrity of its main-data checksum instead:

- Oversize + valid checksum -> an emulator RTC footer, so the launcher
  asks for confirmation, then truncates to 32768 on force.
- Oversize + invalid checksum -> rejected.
- Undersize + valid checksum -> imports zero-padded; otherwise refused.

Adds the "Oversized save file" confirm modal, a new vendor-oracle test
built by gen1lib (PKHeX-derived) run as its own Lua 5.4 tier, oversize/
truncated policy tests, and the LUA54 wiring in test.sh.

# Conflicts:
#	src/import/LauncherView.lua
This commit is contained in:
kikimanjaro
2026-08-04 22:34:43 +02:00
parent 0fe7a96781
commit 272305f3a4
8 changed files with 352 additions and 10 deletions
+2
View File
@@ -1505,6 +1505,8 @@ local function buildConfirmModal(imp, m)
imp:_confirmModUpdate(c.id, c.release)
elseif c.kind == "enableAll" then
imp:_setAllMods(true, true)
elseif c.kind == "importOversize" then
imp:_importSave(c.version, c.source, true)
else
imp:_toggleMod(c.id, true)
end
+24 -4
View File
@@ -1564,7 +1564,7 @@ end
-- the target tab forward so the notice (and, on success, the new active slot)
-- is visible. Requires the ROM to be imported first, since a save is only
-- playable with its game's data present.
function RomImporter:_importSave(version, source)
function RomImporter:_importSave(version, source, force)
if self.workState == "working" then return end
if GameVersion.VERSIONS[self.tab] or self.tab == "mods" then
self.tab = version
@@ -1574,15 +1574,35 @@ function RomImporter:_importSave(version, source)
.. GameVersion.info(version).displayName .. " ROM before importing a save." }
return
end
local ok, res = require("src.import.SaveFileIO").importToSlot(source, version)
local ok, res, info = require("src.import.SaveFileIO").importToSlot(source, version, force)
if ok then
self:_refreshSlots(version)
self.activeSlot[version] = res
self.slotScroll[version] = math.huge -- pin the new row on screen (clamped in draw)
self.saveNotice[version] = { ok = true, text = "Imported save into " .. tostring(res) .. "." }
else
self.saveNotice[version] = { ok = false, text = tostring(res) }
return
end
if res == nil and info and info.needsConfirm then
-- A .sav larger than 32 KB whose first 32768 bytes checksum: the surplus
-- is almost certainly an emulator RTC footer, so ask before truncating.
-- The yes arm re-enters with force=true; cancel leaves the file untouched.
self._modConfirm = {
kind = "importOversize",
version = version,
source = source,
title = "Oversized save file",
lines = {
("This save is %d bytes; a cartridge save is exactly %d bytes (32 KB).")
:format(info.size, 32768),
"It may come from a ROM that saved the battery image with an emulator.",
"The extra bytes would be discarded.",
"Import it anyway?",
},
yesLabel = "Import anyway",
}
return
end
self.saveNotice[version] = { ok = false, text = tostring(res) }
end
-- "Import save" button: open a native .sav picker and import the pick.
+22 -6
View File
@@ -64,18 +64,34 @@ local function readSource(source)
return nil, "could not read the save file: " .. tostring(openErr)
end
-- importToSlot(source, version) -> ok, slotIdOrErr
-- source: an absolute path, a LOVE DroppedFile, or raw 32768 bytes. On success
-- importToSlot(source, version, force) -> ok, slotIdOrErr | (false, nil, info)
-- source: an absolute path, a LOVE DroppedFile, or raw bytes. On success
-- registers a new slot for the version, writes the imported save into it, makes
-- it the active slot, and returns true + the new slot id. On any failure
-- returns false + a friendly message.
function SaveFileIO.importToSlot(source, version)
-- returns false + a friendly message. force only matters for a file LARGER
-- than 32768 bytes whose first 32768 bytes carry a valid main-data checksum
-- (i.e. a cartridge save padded with an emulator RTC footer): without force
-- this returns false, nil, { needsConfirm = true, size = #bytes } so the
-- launcher can ask the player before truncating; with force the extra bytes
-- are dropped and the 32768-byte save imports.
function SaveFileIO.importToSlot(source, version, force)
version = version or GameVersion.get()
local bytes, readErr = readSource(source)
if not bytes then return false, readErr end
if #bytes ~= SAVE_SIZE then
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
:format(SAVE_SIZE, #bytes)
local check = SaveConvert.mainChecksumValid(bytes)
if check == nil then
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
:format(SAVE_SIZE, #bytes)
end
if check == false then
return false, "save data checksum invalid (main data checksum mismatch)"
end
if #bytes > SAVE_SIZE and not force then
return false, nil, { needsConfirm = true, size = #bytes }
end
bytes = #bytes > SAVE_SIZE and bytes:sub(1, SAVE_SIZE)
or (bytes .. string.rep("\0", SAVE_SIZE - #bytes))
end
-- 3rd arg: the crosswalk has to come from THIS game's ROM cache. The
-- launcher imports before the cache is mounted on the un-prefixed paths, so