#!/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 [game] -- luajit tools/save_convert/convert.lua export [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 [game]\n" .. " luajit tools/save_convert/convert.lua export [game]\n" .. " game: red (default) | blue | yellow\n") os.exit(1) end