Files
gen1recomp/tools/save_convert/convert.lua
T
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

124 lines
4.9 KiB
Lua

#!/usr/bin/env luajit
-- CLI: import a vanilla Gen1 (Red/Blue, international) save -- either a
-- raw 32768-byte .sav file, or a JSON wrapper carrying one as its
-- `raw_base64` field -- into this project's save.lua format, or export a
-- save.lua back out to a raw .sav. Run from the repo root:
--
-- luajit tools/save_convert/convert.lua import <in.json|in.sav> <out.lua> [game]
-- luajit tools/save_convert/convert.lua export <in.lua> <out.sav> [game]
--
-- This is a thin shell: all the actual work (size/checksum validation, the
-- GenSave codec, crosswalk data loading, the merge over new-game defaults)
-- lives in src/save_convert/SaveConvert.lua, shared with the runtime. This
-- file only handles the filesystem + the JSON/base64 input framing. See
-- src/save_convert/GenSave.lua for the codec and its documented scope.
--
-- `game` names the game a save belongs to ("red"/"blue"/"yellow"). It is what
-- picks the crosswalk tables -- Yellow renumbers the event bits -- and what
-- SaveConvert answers "Gen 2 cart save" to, so it has to reach both entry
-- points or a Gold save.lua would be pushed straight through the Gen 1 SRAM
-- offsets and written out as a 32768-byte file that looks like a Red battery.
package.path = "./?.lua;" .. package.path
local SaveConvert = require("src.save_convert.SaveConvert")
local SaveSerializer = require("src.core.SaveSerializer")
local Version = require("src.core.Version")
-- A raw .sav carries no game name, and the un-named crosswalk set resolves to
-- Red's tables anyway, so that is what an unqualified run means.
local DEFAULT_GAME = "red"
-- Which game a save.lua belongs to. Gen 2 stamps `version` at the top level
-- (src/core/gen2/Save.lua Save.normalize); a Gen 1 slot written by the game
-- carries the game name in meta.version, while one written by this CLI carries
-- the save-FORMAT number there instead, which names no game.
local function gameOf(save)
if type(save.version) == "string" then return save.version end
local meta = save.meta
if type(meta) == "table" and type(meta.version) == "string" then
return meta.version
end
return DEFAULT_GAME
end
local B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local B64_LOOKUP = {}
for i = 1, #B64_CHARS do B64_LOOKUP[B64_CHARS:sub(i, i)] = i - 1 end
local function b64decode(s)
s = s:gsub("[^%w+/=]", "")
local out = {}
local i = 1
while i <= #s do
local c1, c2, c3, c4 = s:sub(i, i), s:sub(i + 1, i + 1), s:sub(i + 2, i + 2), s:sub(i + 3, i + 3)
local n1, n2 = B64_LOOKUP[c1], B64_LOOKUP[c2]
local n3 = c3 ~= "=" and c3 ~= "" and B64_LOOKUP[c3] or nil
local n4 = c4 ~= "=" and c4 ~= "" and B64_LOOKUP[c4] or nil
out[#out + 1] = string.char(n1 * 4 + math.floor(n2 / 16))
if n3 then
out[#out + 1] = string.char((n2 % 16) * 16 + math.floor(n3 / 4))
if n4 then out[#out + 1] = string.char((n3 % 4) * 64 + n4) end
end
i = i + 4
end
return table.concat(out)
end
local function readFile(path, mode)
local f = assert(io.open(path, mode or "r"), "cannot open " .. path)
local content = f:read("*a")
f:close()
return content
end
local function cmdImport(inPath, outPath, game)
local content = readFile(inPath, "rb")
local bytes
if #content == SaveConvert.SAVE_SIZE then
bytes = content
else
local b64 = content:match('"raw_base64"%s*:%s*"([^"]+)"')
assert(b64, "input is neither a 32768-byte .sav nor JSON with a raw_base64 field")
bytes = b64decode(b64)
assert(#bytes == SaveConvert.SAVE_SIZE,
("decoded raw_base64 is %d bytes, want %d"):format(#bytes, SaveConvert.SAVE_SIZE))
end
local save, err = SaveConvert.importSav(bytes, Version.saveFormat,
game or DEFAULT_GAME)
assert(save, err)
local out = assert(io.open(outPath, "w"))
out:write(SaveSerializer.encode(save))
out:close()
print(("wrote %s (party %d, boxed %d, %d flags)"):format(
outPath, #save.party,
(function() local n = 0 for _, b in ipairs(save.boxes) do n = n + #b end return n end)(),
(function() local n = 0 for _ in pairs(save.flags) do n = n + 1 end return n end)()))
end
local function cmdExport(inPath, outPath, game)
local content = readFile(inPath)
local save = assert(SaveSerializer.decode(content))
local bytes, err = SaveConvert.exportSav(save, game or gameOf(save))
assert(bytes, err)
local out = assert(io.open(outPath, "wb"))
out:write(bytes)
out:close()
print(("wrote %s (%d bytes)"):format(outPath, #bytes))
end
local cmd = arg[1]
if cmd == "import" and arg[2] and arg[3] then
cmdImport(arg[2], arg[3], arg[4])
elseif cmd == "export" and arg[2] and arg[3] then
cmdExport(arg[2], arg[3], arg[4])
else
io.stderr:write(
"usage: luajit tools/save_convert/convert.lua import <in.json|in.sav> <out.lua> [game]\n" ..
" luajit tools/save_convert/convert.lua export <in.lua> <out.sav> [game]\n" ..
" game: red (default) | blue | yellow\n")
os.exit(1)
end