mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 19:24:01 +02:00
new launcher and save converts and pipeline
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
#!/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>
|
||||
-- luajit tools/save_convert/convert.lua export <in.lua> <out.sav>
|
||||
--
|
||||
-- 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.
|
||||
|
||||
package.path = "./?.lua;" .. package.path
|
||||
|
||||
local SaveConvert = require("src.save_convert.SaveConvert")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
local content = readFile(inPath)
|
||||
local save = assert(SaveSerializer.decode(content))
|
||||
local bytes, err = SaveConvert.exportSav(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])
|
||||
elseif cmd == "export" and arg[2] and arg[3] then
|
||||
cmdExport(arg[2], arg[3])
|
||||
else
|
||||
io.stderr:write(
|
||||
"usage: luajit tools/save_convert/convert.lua import <in.json|in.sav> <out.lua>\n" ..
|
||||
" luajit tools/save_convert/convert.lua export <in.lua> <out.sav>\n")
|
||||
os.exit(1)
|
||||
end
|
||||
@@ -0,0 +1,446 @@
|
||||
-- crosscheck.lua -- adversarial cross-validation of GenSave.decode against
|
||||
-- the INDEPENDENT vendor parser (vendor/gen1lib.lua, a PKHeX-derived generic
|
||||
-- Gen1 .sav<->JSON codec) on a real 32768-byte battery save. Two codecs
|
||||
-- triangulated from different authorities (GenSave from the pokered
|
||||
-- disassembly, the vendor from PKHeX.Core) reading the same bytes: any
|
||||
-- semantic field they disagree on is a bug in one of them.
|
||||
--
|
||||
-- run: lua5.4 tools/save_convert/crosscheck.lua (needs POKEPORT_SAV_FIXTURE)
|
||||
--
|
||||
-- INTERPRETER NOTE. The vendor lib is written in Lua 5.3+ (native << >> & //
|
||||
-- operators, utf8 library) and cannot even be PARSED by LuaJIT, while GenSave
|
||||
-- `require("bit")`s LuaJIT's BitOp. So this harness must run under a stock
|
||||
-- Lua 5.3/5.4, and we hand GenSave a tiny `bit` shim backed by 5.4's native
|
||||
-- operators. (The main suite, tests/save_convert_tests.lua, still runs under
|
||||
-- luajit; this maintenance tool is the one place both codecs coexist.)
|
||||
--
|
||||
-- COVERAGE ASYMMETRY / ORACLE CHOICE. The vendor decodes trainer block,
|
||||
-- party, and all 12 boxes, but deliberately leaves items, Pokedex, options,
|
||||
-- event flags, map and coords inside its opaque `raw_base64` blob (it only
|
||||
-- round-trips them, never interprets them). For every such field this harness
|
||||
-- reads the RAW BYTES straight out of the vendor's own byte buffer at the
|
||||
-- vendor's own M.OFS.* offsets (an authority fully independent of GenSave's
|
||||
-- offset table) and compares GenSave's decode against that. So even the
|
||||
-- "vendor doesn't model it" fields still get a genuine second-source check.
|
||||
--
|
||||
-- NO STANDING VENDOR DISAGREEMENTS. Every field the vendor DOES interpret
|
||||
-- agrees with GenSave on the real fixture, so there is no vendor bug to work
|
||||
-- around here. (Were one found, the rule per the task is: leave vendor code
|
||||
-- untouched, keep GenSave's value, and document the disagreement in this
|
||||
-- block.) The one bug this cross-check flushed out was on GenSave's side: its
|
||||
-- flag_array reader packed bits MSB-first, but pokered's FlagAction (and
|
||||
-- PKHeX) pack LSB-first. It survived the round-trip suite because encode and
|
||||
-- decode shared the wrong convention; it did NOT survive contact with a real
|
||||
-- save, where boxed ZAPDOS (dex 145) read back as un-owned. Fixed in GenSave
|
||||
-- (bitGet/bitSet); this harness re-decodes the dex from raw bytes LSB-first
|
||||
-- and now agrees.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- `bit` shim for GenSave, backed by native Lua 5.4 operators.
|
||||
------------------------------------------------------------------------
|
||||
if not pcall(require, "bit") then
|
||||
package.preload["bit"] = function()
|
||||
local M = {}
|
||||
-- LuaJIT's BitOp band/bor/bxor are VARIADIC (fold over all args); GenSave
|
||||
-- relies on that in decodeDVs' 4-arg bit.bor for the HP DV. A 2-arg shim
|
||||
-- silently drops the tail args -- so fold explicitly here.
|
||||
function M.band(a, ...) local r = a; for _, v in ipairs({...}) do r = r & v end; return r & 0xFFFFFFFF end
|
||||
function M.bor(a, ...) local r = a; for _, v in ipairs({...}) do r = r | v end; return r & 0xFFFFFFFF end
|
||||
function M.bxor(a, ...) local r = a; for _, v in ipairs({...}) do r = r ~ v end; return r & 0xFFFFFFFF end
|
||||
function M.bnot(a) return (~a) & 0xFFFFFFFF end
|
||||
function M.lshift(a, n) return (a << n) & 0xFFFFFFFF end
|
||||
function M.rshift(a, n) return (a & 0xFFFFFFFF) >> n end
|
||||
return M
|
||||
end
|
||||
end
|
||||
|
||||
package.path = "./?.lua;" .. package.path
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- Load GenSave + the same generated data the codec uses in production.
|
||||
------------------------------------------------------------------------
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
local charmap = loadfile("src/save_convert/data/charmap.lua")()
|
||||
GenSave.setCharmap(charmap)
|
||||
local data = {
|
||||
pokemon = loadfile("data/generated/pokemon.lua")(),
|
||||
moves = loadfile("data/generated/moves.lua")(),
|
||||
items = loadfile("data/generated/items.lua")(),
|
||||
maps = loadfile("data/generated/maps.lua")(),
|
||||
eventFlags = loadfile("src/save_convert/data/event_flags.lua")(),
|
||||
}
|
||||
local cw = GenSave.crosswalks(data)
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- Load the fixture.
|
||||
------------------------------------------------------------------------
|
||||
local fixturePath = os.getenv("POKEPORT_SAV_FIXTURE")
|
||||
if not fixturePath then
|
||||
io.stderr:write("POKEPORT_SAV_FIXTURE is not set (point it at a 32768-byte .sav)\n")
|
||||
os.exit(2)
|
||||
end
|
||||
local ff, oerr = io.open(fixturePath, "rb")
|
||||
if not ff then
|
||||
io.stderr:write("cannot open POKEPORT_SAV_FIXTURE: " .. tostring(oerr) .. "\n")
|
||||
os.exit(2)
|
||||
end
|
||||
local rawStr = ff:read("*a"); ff:close()
|
||||
if #rawStr ~= GenSave.SAVE_SIZE then
|
||||
io.stderr:write(("fixture is %d bytes, expected %d\n"):format(#rawStr, GenSave.SAVE_SIZE))
|
||||
os.exit(2)
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- Parse the SAME bytes through both codecs.
|
||||
------------------------------------------------------------------------
|
||||
local gen = GenSave.decode(rawStr, data)
|
||||
|
||||
local gen1 = dofile("tools/save_convert/vendor/gen1lib.lua")
|
||||
local vbuf = gen1.string_to_bytes(rawStr) -- vendor's 1-indexed byte array
|
||||
local ven = gen1.parse_save(vbuf)
|
||||
|
||||
-- Raw readers over the vendor's byte buffer (fileOffset -> byte). Used as an
|
||||
-- independent oracle for the fields the vendor leaves inside raw_base64.
|
||||
-- These numeric offsets are exactly the ones gen1lib uses internally (its
|
||||
-- M.OFS), transcribed here so the oracle is anchored to the VENDOR's layout,
|
||||
-- not GenSave's.
|
||||
local RAW = {
|
||||
DexCaught = 0x25A3, DexSeen = 0x25B6, Items = 0x25C9, Options = 0x2601,
|
||||
PCItems = 0x27E6,
|
||||
}
|
||||
local function rb(off) return vbuf[off + 1] end -- raw byte at file offset
|
||||
local function dexbit_lsb(base, idx) return ((rb(base + (idx // 8)) >> (idx % 8)) & 1) == 1 end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- Diff collector.
|
||||
------------------------------------------------------------------------
|
||||
local fails, notes = {}, {}
|
||||
local function fail(field, msg) fails[#fails + 1] = { field = field, msg = msg } end
|
||||
local function eq(field, a, b, ctx)
|
||||
if a ~= b then
|
||||
fail(field, ("%s: GenSave=%s vendor=%s"):format(ctx or "", tostring(a), tostring(b)))
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
local function note(msg) notes[#notes + 1] = msg end
|
||||
|
||||
local NAME_LEN = 11 -- GenSave NAME_LENGTH == vendor STRING_LENGTH
|
||||
-- Name equivalence. GenSave and the vendor spell a few glyphs differently:
|
||||
-- GenSave uses bracket control tokens ("<DOT>" for byte 0xE8, "<TRAINER>" for
|
||||
-- the 0x5D in-game-trade OT marker); the vendor uses the raw Unicode glyph
|
||||
-- ("\u{2024}") / "*". These are the SAME underlying Gen1 byte, so instead of
|
||||
-- comparing the decoded text we canonicalize each side back to its 11-byte
|
||||
-- Gen1 sequence via its own table and compare THOSE. A genuine offset or
|
||||
-- coverage bug (wrong name bytes) still surfaces as a byte-sequence diff.
|
||||
local function genNameToBytes(text) -- mirrors GenSave's local encodeName
|
||||
local out, i, pos = {}, 0, 1
|
||||
text = text or ""
|
||||
while i < NAME_LEN - 1 and pos <= #text do
|
||||
local bracket = text:match("^(<[^<>]*>)", pos)
|
||||
local ch, clen
|
||||
if bracket and charmap.byToken[bracket] then
|
||||
ch, clen = bracket, #bracket
|
||||
else
|
||||
local b0 = text:byte(pos)
|
||||
clen = (b0 < 0x80 and 1) or (b0 < 0xE0 and 2) or (b0 < 0xF0 and 3) or 4
|
||||
ch = text:sub(pos, pos + clen - 1)
|
||||
end
|
||||
out[i + 1] = string.char(charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
|
||||
i, pos = i + 1, pos + clen
|
||||
end
|
||||
for j = i, NAME_LEN - 1 do out[j + 1] = string.char(0x50) end
|
||||
return table.concat(out)
|
||||
end
|
||||
local function venNameToBytes(text) -- via the vendor's own encoder
|
||||
local b = {}
|
||||
gen1.encode_string(b, 0, NAME_LEN, text or "")
|
||||
local out = {}
|
||||
for k = 1, NAME_LEN do out[k] = string.char((b[k] or 0x50) & 0xFF) end
|
||||
return table.concat(out)
|
||||
end
|
||||
local repNoted = {}
|
||||
local function compareName(field, g, v, ctx)
|
||||
if g == v then return end
|
||||
if genNameToBytes(g) == venNameToBytes(v) then
|
||||
local key = tostring(g) .. "|" .. tostring(v)
|
||||
if not repNoted[key] then
|
||||
repNoted[key] = true
|
||||
note(("name glyph representation differs, bytes identical: GenSave %q == vendor %q")
|
||||
:format(tostring(g), tostring(v)))
|
||||
end
|
||||
else
|
||||
fail(field, ("%s: GenSave=%q vendor=%q (byte sequences differ)")
|
||||
:format(ctx or "", tostring(g), tostring(v)))
|
||||
end
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 1. Trainer block (vendor decodes all of these).
|
||||
------------------------------------------------------------------------
|
||||
compareName("player.name", gen.player.name, ven.trainer.name, "player name")
|
||||
eq("player.id", gen.player.id, ven.trainer.id, "trainer id")
|
||||
compareName("player.rival", gen.player.rival, ven.trainer.rival_name, "rival name")
|
||||
eq("money", gen.money, ven.trainer.money, "money")
|
||||
eq("coins", gen.coins, ven.trainer.coins, "coins")
|
||||
|
||||
-- badges: GenSave exposes them as truthy inventory entries; vendor gives the
|
||||
-- raw wObtainedBadges byte. Compare bit for bit (BIT_BOULDERBADGE=0 .. =7).
|
||||
local BADGE = { [0]="BOULDERBADGE",[1]="CASCADEBADGE",[2]="THUNDERBADGE",
|
||||
[3]="RAINBOWBADGE",[4]="SOULBADGE",[5]="MARSHBADGE",[6]="VOLCANOBADGE",[7]="EARTHBADGE" }
|
||||
for i = 0, 7 do
|
||||
local vset = ((ven.trainer.badges >> i) & 1) == 1
|
||||
local gset = gen.inventory[BADGE[i]] == 1
|
||||
eq("badges." .. BADGE[i], gset, vset, "badge bit " .. i)
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 2. Play time (vendor decodes H/M/S/F/maxed; GenSave folds to one float).
|
||||
------------------------------------------------------------------------
|
||||
do
|
||||
local pt = ven.trainer.play_time
|
||||
local expected = pt.hours * 3600 + pt.minutes * 60 + pt.seconds + pt.frames / 60
|
||||
if math.abs(gen.playTime - expected) > 1e-6 then
|
||||
fail("playTime", ("GenSave=%s vendor=%dh%02dm%02ds%02df"):format(
|
||||
tostring(gen.playTime), pt.hours, pt.minutes, pt.seconds, pt.frames))
|
||||
end
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 3. Options -- GenSave does not model this field; verify that's the only
|
||||
-- reason for silence, and record the raw value the vendor would carry.
|
||||
------------------------------------------------------------------------
|
||||
if gen.options == nil then
|
||||
note(("options: not modeled by GenSave (raw wOptions byte = 0x%02X, preserved "
|
||||
.. "verbatim via the export template)"):format(rb(RAW.Options)))
|
||||
else
|
||||
eq("options", gen.options, rb(RAW.Options), "options byte")
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 4. Current box + map/coords.
|
||||
------------------------------------------------------------------------
|
||||
eq("currentBox", gen.currentBox, ven.current_box, "current box number")
|
||||
-- Map + coords: the vendor does not decode wCurMap/X/Y at all. Sanity-check
|
||||
-- GenSave's values against its own data tables (no second source available).
|
||||
if gen.player.map and data.maps[gen.player.map] then
|
||||
note(("map/coords: vendor does not decode these; GenSave says map=%s x=%s y=%s "
|
||||
.. "(a valid map id)"):format(gen.player.map, tostring(gen.player.x), tostring(gen.player.y)))
|
||||
else
|
||||
fail("player.map", "GenSave decoded an unknown/absent current map")
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 5. Bag + PC items -- vendor leaves these in raw_base64; read the raw
|
||||
-- (id,qty) lists straight from its byte buffer as the oracle.
|
||||
------------------------------------------------------------------------
|
||||
local function rawItemList(base, capacity)
|
||||
local count = rb(base)
|
||||
local list = {}
|
||||
for i = 0, math.min(count, capacity) - 1 do
|
||||
local idb = rb(base + 1 + i * 2)
|
||||
if idb == 0xFF then break end
|
||||
list[#list + 1] = { idByte = idb, qty = rb(base + 2 + i * 2) }
|
||||
end
|
||||
return count, list
|
||||
end
|
||||
|
||||
-- Bag: GenSave preserves order in save.bagOrder; compare id+qty in sequence.
|
||||
do
|
||||
local count, raw = rawItemList(RAW.Items, 20)
|
||||
eq("bag.count", #gen.bagOrder, count, "bag item count")
|
||||
local n = math.max(#gen.bagOrder, #raw)
|
||||
for i = 1, n do
|
||||
local gid = gen.bagOrder[i]
|
||||
local r = raw[i]
|
||||
local gidx = gid and cw.itemsIndex[gid]
|
||||
eq("bag[" .. i .. "].id", gidx, r and r.idByte, "bag slot " .. i .. " item")
|
||||
if gid and r then eq("bag[" .. i .. "].qty", gen.inventory[gid], r.qty, "bag slot " .. i .. " qty") end
|
||||
end
|
||||
end
|
||||
|
||||
-- PC: GenSave keeps only a {id->qty} map (order dropped); compare as a set.
|
||||
do
|
||||
local count, raw = rawItemList(RAW.PCItems, 50)
|
||||
local rawMap, rawCount = {}, 0
|
||||
for _, r in ipairs(raw) do
|
||||
local id = cw.itemsByIndex[r.idByte]
|
||||
if id then rawMap[id] = r.qty; rawCount = rawCount + 1 end
|
||||
end
|
||||
local genCount = 0
|
||||
for id, qty in pairs(gen.pcItems) do
|
||||
genCount = genCount + 1
|
||||
eq("pcItems." .. id, qty, rawMap[id], "PC item " .. id .. " qty")
|
||||
end
|
||||
eq("pcItems.count", genCount, rawCount, "PC item count")
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 6. Pokedex owned/seen -- vendor leaves these in raw_base64; decode the
|
||||
-- raw bitsets LSB-first (pokered/PKHeX convention) as the oracle.
|
||||
------------------------------------------------------------------------
|
||||
do
|
||||
for dex, species in pairs(cw.pokemonByDex) do
|
||||
if dex >= 1 and dex <= 151 then
|
||||
local rawOwned = dexbit_lsb(RAW.DexCaught, dex - 1)
|
||||
local rawSeen = dexbit_lsb(RAW.DexSeen, dex - 1)
|
||||
eq("dex.owned." .. species, gen.pokedex.owned[species] == true, rawOwned, "owned " .. species)
|
||||
eq("dex.seen." .. species, gen.pokedex.seen[species] == true, rawSeen, "seen " .. species)
|
||||
end
|
||||
end
|
||||
-- physical invariant: every possessed species must be owned AND seen.
|
||||
local possessed = {}
|
||||
for _, m in ipairs(gen.party) do possessed[m.species] = true end
|
||||
for b = 1, 12 do for _, m in ipairs(gen.boxes[b]) do possessed[m.species] = true end end
|
||||
for sp in pairs(possessed) do
|
||||
if not gen.pokedex.owned[sp] then fail("dex.invariant", sp .. " is possessed but not owned") end
|
||||
if not gen.pokedex.seen[sp] then fail("dex.invariant", sp .. " is possessed but not seen") end
|
||||
end
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 7. Event flags -- vendor leaves these in raw_base64; there is no vendor
|
||||
-- field to compare against, but the physical invariant that the very
|
||||
-- first story flag (EVENT_FOLLOWED_OAK_INTO_LAB, bit 0) is set on any
|
||||
-- save past the intro is a real LSB-vs-MSB discriminator.
|
||||
------------------------------------------------------------------------
|
||||
do
|
||||
local nflags = 0
|
||||
for _ in pairs(gen.flags) do nflags = nflags + 1 end
|
||||
if nflags == 0 then fail("flags", "no event flags decoded at all") end
|
||||
if not gen.flags.EVENT_FOLLOWED_OAK_INTO_LAB then
|
||||
fail("flags.EVENT_FOLLOWED_OAK_INTO_LAB",
|
||||
"bit 0 not set -- expected on any save past the intro (LSB-order check)")
|
||||
end
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 8. Mon-by-mon comparison (party + boxes) against the vendor's PK1 parse.
|
||||
------------------------------------------------------------------------
|
||||
local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 }
|
||||
local function statusFromByte(b)
|
||||
if (b & 7) > 0 then return "SLP" end
|
||||
for name, bi in pairs(STATUS_BIT) do if (b & (1 << bi)) ~= 0 then return name end end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function compareMon(tag, g, v, isParty)
|
||||
if not g or not v then
|
||||
fail(tag, "one side missing this slot (GenSave=" .. tostring(g) .. " vendor=" .. tostring(v) .. ")")
|
||||
return
|
||||
end
|
||||
-- species: GenSave id string -> national dex must equal vendor's dex number.
|
||||
eq(tag .. ".species", cw.pokemonDex[g.species], v.species, tag .. " species (" .. tostring(g.species) .. ")")
|
||||
eq(tag .. ".level", g.level, v.level, tag .. " level")
|
||||
eq(tag .. ".hp", g.hp, v.current_hp, tag .. " current HP")
|
||||
eq(tag .. ".otId", g.otId, v.ot_id, tag .. " OT id")
|
||||
eq(tag .. ".exp", g.exp, v.exp, tag .. " exp")
|
||||
eq(tag .. ".catchRate", g.catchRate, v.catch_rate, tag .. " catch rate")
|
||||
compareName(tag .. ".ot", g.ot, v.ot_name, tag .. " OT name")
|
||||
compareName(tag .. ".nickname", g.nickname, v.nickname, tag .. " nickname")
|
||||
-- DVs / IVs
|
||||
eq(tag .. ".dv.atk", g.dvs.attack, v.ivs.atk, tag .. " DV atk")
|
||||
eq(tag .. ".dv.def", g.dvs.defense, v.ivs.def, tag .. " DV def")
|
||||
eq(tag .. ".dv.spe", g.dvs.speed, v.ivs.spe, tag .. " DV spe")
|
||||
eq(tag .. ".dv.spc", g.dvs.special, v.ivs.spc, tag .. " DV spc")
|
||||
eq(tag .. ".dv.hp", g.dvs.hp, v.ivs.hp, tag .. " DV hp")
|
||||
-- stat EXP / EVs
|
||||
eq(tag .. ".ev.hp", g.statExp.hp, v.evs.hp, tag .. " statExp hp")
|
||||
eq(tag .. ".ev.atk", g.statExp.attack, v.evs.atk, tag .. " statExp atk")
|
||||
eq(tag .. ".ev.def", g.statExp.defense, v.evs.def, tag .. " statExp def")
|
||||
eq(tag .. ".ev.spe", g.statExp.speed, v.evs.spe, tag .. " statExp spe")
|
||||
eq(tag .. ".ev.spc", g.statExp.special, v.evs.spc, tag .. " statExp spc")
|
||||
-- status: GenSave string vs vendor raw byte (decoded the same way)
|
||||
eq(tag .. ".status", g.status, statusFromByte(v.status_condition), tag .. " status")
|
||||
-- moves: GenSave keeps only nonzero slots, in order; line them up against
|
||||
-- the vendor's 4 raw slots skipping zeros.
|
||||
local vmoves = {}
|
||||
for j = 1, 4 do
|
||||
if v.moves[j] and v.moves[j] > 0 then
|
||||
vmoves[#vmoves + 1] = { idx = v.moves[j], pp = v.pp[j], ppUps = v.pp_ups[j] }
|
||||
end
|
||||
end
|
||||
eq(tag .. ".moves.count", #g.moves, #vmoves, tag .. " move count")
|
||||
for j = 1, math.max(#g.moves, #vmoves) do
|
||||
local gm, vm = g.moves[j], vmoves[j]
|
||||
local gidx = gm and cw.movesIndex[gm.id]
|
||||
eq(tag .. ".move[" .. j .. "].id", gidx, vm and vm.idx, tag .. " move " .. j)
|
||||
if gm and vm then
|
||||
eq(tag .. ".move[" .. j .. "].pp", gm.pp, vm.pp, tag .. " move " .. j .. " PP")
|
||||
eq(tag .. ".move[" .. j .. "].ppUps", gm.ppUps, vm.ppUps, tag .. " move " .. j .. " PP-ups")
|
||||
end
|
||||
end
|
||||
-- party-only computed stats
|
||||
if isParty then
|
||||
eq(tag .. ".stat.hp", g.stats.hp, v.stats.hp_max, tag .. " stat maxHP")
|
||||
eq(tag .. ".stat.atk", g.stats.attack, v.stats.atk, tag .. " stat atk")
|
||||
eq(tag .. ".stat.def", g.stats.defense, v.stats.def, tag .. " stat def")
|
||||
eq(tag .. ".stat.spe", g.stats.speed, v.stats.spe, tag .. " stat spe")
|
||||
eq(tag .. ".stat.spc", g.stats.special, v.stats.spc, tag .. " stat spc")
|
||||
end
|
||||
end
|
||||
|
||||
-- party
|
||||
eq("party.count", #gen.party, #ven.party, "party size")
|
||||
for i = 1, math.max(#gen.party, #ven.party) do
|
||||
compareMon("party[" .. i .. "]", gen.party[i], ven.party[i], true)
|
||||
end
|
||||
|
||||
-- boxes: the vendor returns all 12 boxes 1..12 (current box read from its
|
||||
-- live offset). GenSave stores them the same 1-based way.
|
||||
for b = 1, 12 do
|
||||
local vbox = ven.boxes[b] and ven.boxes[b].pokemon or {}
|
||||
local gbox = gen.boxes[b] or {}
|
||||
eq("box[" .. b .. "].count", #gbox, #vbox, "box " .. b .. " size")
|
||||
for i = 1, math.max(#gbox, #vbox) do
|
||||
compareMon(("box[%d][%d]"):format(b, i), gbox[i], vbox[i], false)
|
||||
end
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 9. Checksum cross-check: both codecs sum [0x2598,0x3523). The fixture is a
|
||||
-- real save, so GenSave's stored-checksum verification must pass, and the
|
||||
-- vendor's independent recompute must match the same stored byte.
|
||||
------------------------------------------------------------------------
|
||||
do
|
||||
if #gen.warnings > 0 then
|
||||
for _, w in ipairs(gen.warnings) do fail("checksum", "GenSave warning: " .. w) end
|
||||
end
|
||||
local sum = 0
|
||||
for i = 0x2598, 0x3523 - 1 do sum = (sum + rb(i)) & 0xFF end
|
||||
local vend = (255 - sum) & 0xFF
|
||||
eq("checksum.byte", rb(0x3523), vend, "stored main checksum vs vendor recompute")
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- Report.
|
||||
------------------------------------------------------------------------
|
||||
print("== crosscheck: GenSave.decode vs vendor gen1lib on the real fixture ==")
|
||||
print(("player=%s id=%d money=%d coins=%d party=%d")
|
||||
:format(gen.player.name, gen.player.id, gen.money, gen.coins, #gen.party))
|
||||
do
|
||||
local owned, seen = 0, 0
|
||||
for _ in pairs(gen.pokedex.owned) do owned = owned + 1 end
|
||||
for _ in pairs(gen.pokedex.seen) do seen = seen + 1 end
|
||||
local boxed = 0
|
||||
for b = 1, 12 do boxed = boxed + #gen.boxes[b] end
|
||||
print(("dex owned/seen=%d/%d boxed=%d currentBox=%d")
|
||||
:format(owned, seen, boxed, gen.currentBox))
|
||||
end
|
||||
|
||||
if #notes > 0 then
|
||||
print("\n-- notes (fields the vendor does not model; checked against raw bytes) --")
|
||||
for _, m in ipairs(notes) do print(" * " .. m) end
|
||||
end
|
||||
|
||||
if #fails == 0 then
|
||||
print("\nALL SHARED FIELDS AGREE -- GenSave.decode matches the vendor parser (and\n"
|
||||
.. "the raw-byte oracle for the fields the vendor leaves opaque).")
|
||||
os.exit(0)
|
||||
else
|
||||
print(("\n%d MISMATCH(ES):"):format(#fails))
|
||||
for _, f in ipairs(fails) do
|
||||
print((" [%s] %s"):format(f.field, f.msg))
|
||||
end
|
||||
os.exit(1)
|
||||
end
|
||||
+884
@@ -0,0 +1,884 @@
|
||||
-- gen1lib.lua
|
||||
--
|
||||
-- Shared library for converting Pokemon Generation 1 (Red/Blue/Yellow)
|
||||
-- save files between their native binary format and JSON.
|
||||
--
|
||||
-- Scope: International (non-Japanese) 32768-byte (0x8000) save files only.
|
||||
-- Logic ported from PKHeX.Core:
|
||||
-- PKHeX.Core/Saves/SAV1.cs, SAV1Offsets.cs
|
||||
-- PKHeX.Core/PKM/PK1.cs, GBPKM.cs, GBPKML.cs
|
||||
-- PKHeX.Core/PKM/Strings/StringConverter1.cs
|
||||
-- PKHeX.Core/PKM/Util/Conversion/SpeciesConverter.cs
|
||||
-- PKHeX.Core/Saves/Storage/PokeList1.cs
|
||||
--
|
||||
-- Requires Lua 5.3+ (native bitwise operators, floor division, utf8 library).
|
||||
|
||||
local M = {}
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- Layout constants (International save layout only)
|
||||
------------------------------------------------------------------------
|
||||
|
||||
M.SIZE_SAVE = 0x8000
|
||||
|
||||
M.OFS = {
|
||||
OT = 0x2598,
|
||||
DexCaught = 0x25A3,
|
||||
DexSeen = 0x25B6,
|
||||
Items = 0x25C9,
|
||||
Money = 0x25F3,
|
||||
Rival = 0x25F6,
|
||||
Options = 0x2601,
|
||||
Badges = 0x2602,
|
||||
TID16 = 0x2605,
|
||||
PikaFriendship = 0x271C,
|
||||
PikaBeachScore = 0x2741,
|
||||
PrinterBrightness = 0x2744,
|
||||
PCItems = 0x27E6,
|
||||
CurrentBoxIndex = 0x284C,
|
||||
HallOfFameCount = 0x284E,
|
||||
Coin = 0x2850,
|
||||
ObjectSpawnFlags = 0x2852,
|
||||
EventWork = 0x289C,
|
||||
Starter = 0x29C3,
|
||||
EventFlag = 0x29F3,
|
||||
PlayTime = 0x2CED,
|
||||
Daycare = 0x2CF4,
|
||||
Party = 0x2F2C,
|
||||
CurrentBox = 0x30C0,
|
||||
ChecksumOfs = 0x3523,
|
||||
}
|
||||
|
||||
M.BOX_COUNT = 12
|
||||
M.BOX_SLOT_COUNT = 20
|
||||
M.STRING_LENGTH = 11 -- OT name / rival name / nickname raw buffer length (incl. terminator)
|
||||
M.SIZE_STORED = 33 -- boxed PK1 struct size
|
||||
M.SIZE_PARTY = 44 -- party PK1 struct size
|
||||
|
||||
M.SIZE_BOX_LIST = ((M.STRING_LENGTH * 2) + M.SIZE_STORED + 1) * M.BOX_SLOT_COUNT + 2
|
||||
M.SIZE_PARTY_LIST = ((M.STRING_LENGTH * 2) + M.SIZE_PARTY + 1) * 6 + 2
|
||||
|
||||
-- Non-current boxes live in two banked arrays at 0x4000 (boxes 1-6) and
|
||||
-- 0x6000 (boxes 7-12). Whichever box is "current" is instead read from/
|
||||
-- written to M.OFS.CurrentBox, and mirrored back into its normal slot here
|
||||
-- on save (SAV1.cs Initialize()/GetFinalData()).
|
||||
function M.box_bank_offset(boxIndexZero)
|
||||
local half = M.BOX_COUNT // 2
|
||||
if boxIndexZero < half then
|
||||
return 0x4000 + boxIndexZero * M.SIZE_BOX_LIST
|
||||
else
|
||||
return 0x6000 + (boxIndexZero - half) * M.SIZE_BOX_LIST
|
||||
end
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- Byte buffer helpers
|
||||
-- `buf` is a plain Lua array of integers 0-255, 1-indexed, where
|
||||
-- buf[fileOffset + 1] holds the byte at `fileOffset`.
|
||||
------------------------------------------------------------------------
|
||||
|
||||
local function get(buf, ofs) return buf[ofs + 1] end
|
||||
local function set(buf, ofs, v) buf[ofs + 1] = v & 0xFF end
|
||||
|
||||
local function read_u16be(buf, ofs) return (get(buf, ofs) << 8) | get(buf, ofs + 1) end
|
||||
local function write_u16be(buf, ofs, v)
|
||||
set(buf, ofs, (v >> 8) & 0xFF)
|
||||
set(buf, ofs + 1, v & 0xFF)
|
||||
end
|
||||
|
||||
local function read_u24be(buf, ofs)
|
||||
return (get(buf, ofs) << 16) | (get(buf, ofs + 1) << 8) | get(buf, ofs + 2)
|
||||
end
|
||||
local function write_u24be(buf, ofs, v)
|
||||
set(buf, ofs, (v >> 16) & 0xFF)
|
||||
set(buf, ofs + 1, (v >> 8) & 0xFF)
|
||||
set(buf, ofs + 2, v & 0xFF)
|
||||
end
|
||||
|
||||
-- n-byte binary-coded decimal (two decimal digits per byte).
|
||||
local function bcd_read(buf, ofs, n, littleEndian)
|
||||
local bytes = {}
|
||||
for i = 0, n - 1 do bytes[i + 1] = get(buf, ofs + i) end
|
||||
if littleEndian then
|
||||
local rev = {}
|
||||
for i = 1, n do rev[i] = bytes[n - i + 1] end
|
||||
bytes = rev
|
||||
end
|
||||
local v = 0
|
||||
for _, b in ipairs(bytes) do
|
||||
v = v * 100 + ((b >> 4) * 10) + (b & 0xF)
|
||||
end
|
||||
return v
|
||||
end
|
||||
|
||||
local function bcd_write(buf, ofs, n, littleEndian, value)
|
||||
local bytes = {}
|
||||
local v = value
|
||||
for i = n, 1, -1 do
|
||||
local d = v % 100
|
||||
v = (v - d) // 100
|
||||
bytes[i] = ((d // 10) << 4) | (d % 10)
|
||||
end
|
||||
if littleEndian then
|
||||
local rev = {}
|
||||
for i = 1, n do rev[i] = bytes[n - i + 1] end
|
||||
bytes = rev
|
||||
end
|
||||
for i = 0, n - 1 do set(buf, ofs + i, bytes[i + 1]) end
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- Species: Gen 1 internal index <-> National Dex ID
|
||||
-- (PKHeX.Core/PKM/Util/Conversion/SpeciesConverter.cs, Table1*)
|
||||
------------------------------------------------------------------------
|
||||
|
||||
-- index (1-based) = National Dex ID + 1; value = Gen1 internal species byte
|
||||
local NAT_TO_INTERNAL = {
|
||||
0x00, 0x99, 0x09, 0x9A, 0xB0, 0xB2, 0xB4, 0xB1, 0xB3, 0x1C, 0x7B, 0x7C, 0x7D, 0x70, 0x71, 0x72,
|
||||
0x24, 0x96, 0x97, 0xA5, 0xA6, 0x05, 0x23, 0x6C, 0x2D, 0x54, 0x55, 0x60, 0x61, 0x0F, 0xA8, 0x10,
|
||||
0x03, 0xA7, 0x07, 0x04, 0x8E, 0x52, 0x53, 0x64, 0x65, 0x6B, 0x82, 0xB9, 0xBA, 0xBB, 0x6D, 0x2E,
|
||||
0x41, 0x77, 0x3B, 0x76, 0x4D, 0x90, 0x2F, 0x80, 0x39, 0x75, 0x21, 0x14, 0x47, 0x6E, 0x6F, 0x94,
|
||||
0x26, 0x95, 0x6A, 0x29, 0x7E, 0xBC, 0xBD, 0xBE, 0x18, 0x9B, 0xA9, 0x27, 0x31, 0xA3, 0xA4, 0x25,
|
||||
0x08, 0xAD, 0x36, 0x40, 0x46, 0x74, 0x3A, 0x78, 0x0D, 0x88, 0x17, 0x8B, 0x19, 0x93, 0x0E, 0x22,
|
||||
0x30, 0x81, 0x4E, 0x8A, 0x06, 0x8D, 0x0C, 0x0A, 0x11, 0x91, 0x2B, 0x2C, 0x0B, 0x37, 0x8F, 0x12,
|
||||
0x01, 0x28, 0x1E, 0x02, 0x5C, 0x5D, 0x9D, 0x9E, 0x1B, 0x98, 0x2A, 0x1A, 0x48, 0x35, 0x33, 0x1D,
|
||||
0x3C, 0x85, 0x16, 0x13, 0x4C, 0x66, 0x69, 0x68, 0x67, 0xAA, 0x62, 0x63, 0x5A, 0x5B, 0xAB, 0x84,
|
||||
0x4A, 0x4B, 0x49, 0x58, 0x59, 0x42, 0x83, 0x15,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
}
|
||||
|
||||
-- index (1-based) = Gen1 internal species byte + 1; value = National Dex ID
|
||||
local INTERNAL_TO_NAT = {
|
||||
0x00, 0x70, 0x73, 0x20, 0x23, 0x15, 0x64, 0x22, 0x50, 0x02, 0x67, 0x6C, 0x66, 0x58, 0x5E, 0x1D,
|
||||
0x1F, 0x68, 0x6F, 0x83, 0x3B, 0x97, 0x82, 0x5A, 0x48, 0x5C, 0x7B, 0x78, 0x09, 0x7F, 0x72, 0x00,
|
||||
0x00, 0x3A, 0x5F, 0x16, 0x10, 0x4F, 0x40, 0x4B, 0x71, 0x43, 0x7A, 0x6A, 0x6B, 0x18, 0x2F, 0x36,
|
||||
0x60, 0x4C, 0x00, 0x7E, 0x00, 0x7D, 0x52, 0x6D, 0x00, 0x38, 0x56, 0x32, 0x80, 0x00, 0x00, 0x00,
|
||||
0x53, 0x30, 0x95, 0x00, 0x00, 0x00, 0x54, 0x3C, 0x7C, 0x92, 0x90, 0x91, 0x84, 0x34, 0x62, 0x00,
|
||||
0x00, 0x00, 0x25, 0x26, 0x19, 0x1A, 0x00, 0x00, 0x93, 0x94, 0x8C, 0x8D, 0x74, 0x75, 0x00, 0x00,
|
||||
0x1B, 0x1C, 0x8A, 0x8B, 0x27, 0x28, 0x85, 0x88, 0x87, 0x86, 0x42, 0x29, 0x17, 0x2E, 0x3D, 0x3E,
|
||||
0x0D, 0x0E, 0x0F, 0x00, 0x55, 0x39, 0x33, 0x31, 0x57, 0x00, 0x00, 0x0A, 0x0B, 0x0C, 0x44, 0x00,
|
||||
0x37, 0x61, 0x2A, 0x96, 0x8F, 0x81, 0x00, 0x00, 0x59, 0x00, 0x63, 0x5B, 0x00, 0x65, 0x24, 0x6E,
|
||||
0x35, 0x69, 0x00, 0x5D, 0x3F, 0x41, 0x11, 0x12, 0x79, 0x01, 0x03, 0x49, 0x00, 0x76, 0x77, 0x00,
|
||||
0x00, 0x00, 0x00, 0x4D, 0x4E, 0x13, 0x14, 0x21, 0x1E, 0x4A, 0x89, 0x8E, 0x00, 0x51, 0x00, 0x00,
|
||||
0x04, 0x07, 0x05, 0x08, 0x06, 0x00, 0x00, 0x00, 0x00, 0x2B, 0x2C, 0x2D, 0x45, 0x46, 0x47, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
}
|
||||
|
||||
function M.national_to_internal(species)
|
||||
return NAT_TO_INTERNAL[species + 1] or 0
|
||||
end
|
||||
|
||||
function M.internal_to_national(raw)
|
||||
return INTERNAL_TO_NAT[raw + 1] or 0
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- Species names (English, National Dex 1-151; PKHeX.Core Resources/text/other/en/text_Species_en.txt)
|
||||
-- Informational only ("species_name" in JSON) -- ignored when converting back to a save.
|
||||
------------------------------------------------------------------------
|
||||
|
||||
M.SPECIES_NAMES = {
|
||||
[1] = "Bulbasaur", [2] = "Ivysaur", [3] = "Venusaur", [4] = "Charmander",
|
||||
[5] = "Charmeleon", [6] = "Charizard", [7] = "Squirtle", [8] = "Wartortle",
|
||||
[9] = "Blastoise", [10] = "Caterpie", [11] = "Metapod", [12] = "Butterfree",
|
||||
[13] = "Weedle", [14] = "Kakuna", [15] = "Beedrill", [16] = "Pidgey",
|
||||
[17] = "Pidgeotto", [18] = "Pidgeot", [19] = "Rattata", [20] = "Raticate",
|
||||
[21] = "Spearow", [22] = "Fearow", [23] = "Ekans", [24] = "Arbok",
|
||||
[25] = "Pikachu", [26] = "Raichu", [27] = "Sandshrew", [28] = "Sandslash",
|
||||
[29] = "Nidoran\u{2640}", [30] = "Nidorina", [31] = "Nidoqueen", [32] = "Nidoran\u{2642}",
|
||||
[33] = "Nidorino", [34] = "Nidoking", [35] = "Clefairy", [36] = "Clefable",
|
||||
[37] = "Vulpix", [38] = "Ninetales", [39] = "Jigglypuff", [40] = "Wigglytuff",
|
||||
[41] = "Zubat", [42] = "Golbat", [43] = "Oddish", [44] = "Gloom",
|
||||
[45] = "Vileplume", [46] = "Paras", [47] = "Parasect", [48] = "Venonat",
|
||||
[49] = "Venomoth", [50] = "Diglett", [51] = "Dugtrio", [52] = "Meowth",
|
||||
[53] = "Persian", [54] = "Psyduck", [55] = "Golduck", [56] = "Mankey",
|
||||
[57] = "Primeape", [58] = "Growlithe", [59] = "Arcanine", [60] = "Poliwag",
|
||||
[61] = "Poliwhirl", [62] = "Poliwrath", [63] = "Abra", [64] = "Kadabra",
|
||||
[65] = "Alakazam", [66] = "Machop", [67] = "Machoke", [68] = "Machamp",
|
||||
[69] = "Bellsprout", [70] = "Weepinbell", [71] = "Victreebel", [72] = "Tentacool",
|
||||
[73] = "Tentacruel", [74] = "Geodude", [75] = "Graveler", [76] = "Golem",
|
||||
[77] = "Ponyta", [78] = "Rapidash", [79] = "Slowpoke", [80] = "Slowbro",
|
||||
[81] = "Magnemite", [82] = "Magneton", [83] = "Farfetch\u{2019}d", [84] = "Doduo",
|
||||
[85] = "Dodrio", [86] = "Seel", [87] = "Dewgong", [88] = "Grimer",
|
||||
[89] = "Muk", [90] = "Shellder", [91] = "Cloyster", [92] = "Gastly",
|
||||
[93] = "Haunter", [94] = "Gengar", [95] = "Onix", [96] = "Drowzee",
|
||||
[97] = "Hypno", [98] = "Krabby", [99] = "Kingler", [100] = "Voltorb",
|
||||
[101] = "Electrode", [102] = "Exeggcute", [103] = "Exeggutor", [104] = "Cubone",
|
||||
[105] = "Marowak", [106] = "Hitmonlee", [107] = "Hitmonchan", [108] = "Lickitung",
|
||||
[109] = "Koffing", [110] = "Weezing", [111] = "Rhyhorn", [112] = "Rhydon",
|
||||
[113] = "Chansey", [114] = "Tangela", [115] = "Kangaskhan", [116] = "Horsea",
|
||||
[117] = "Seadra", [118] = "Goldeen", [119] = "Seaking", [120] = "Staryu",
|
||||
[121] = "Starmie", [122] = "Mr. Mime", [123] = "Scyther", [124] = "Jynx",
|
||||
[125] = "Electabuzz", [126] = "Magmar", [127] = "Pinsir", [128] = "Tauros",
|
||||
[129] = "Magikarp", [130] = "Gyarados", [131] = "Lapras", [132] = "Ditto",
|
||||
[133] = "Eevee", [134] = "Vaporeon", [135] = "Jolteon", [136] = "Flareon",
|
||||
[137] = "Porygon", [138] = "Omanyte", [139] = "Omastar", [140] = "Kabuto",
|
||||
[141] = "Kabutops", [142] = "Aerodactyl", [143] = "Snorlax", [144] = "Articuno",
|
||||
[145] = "Zapdos", [146] = "Moltres", [147] = "Dratini", [148] = "Dragonair",
|
||||
[149] = "Dragonite", [150] = "Mewtwo", [151] = "Mew",
|
||||
}
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- Gen 1 text encoding, international/English table
|
||||
-- (PKHeX.Core/PKM/Strings/StringConverter1.cs, TableEN)
|
||||
-- `false` marks a byte that decodes to the string terminator (NUL).
|
||||
------------------------------------------------------------------------
|
||||
|
||||
local TABLE_EN = {
|
||||
-- 0x00-0x0F
|
||||
false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,
|
||||
-- 0x10-0x1F
|
||||
false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,
|
||||
-- 0x20-0x2F
|
||||
false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,
|
||||
-- 0x30-0x3F
|
||||
false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,
|
||||
-- 0x40-0x4F
|
||||
false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,
|
||||
-- 0x50-0x5F (0x50 terminator, 0x5D in-game-trade marker)
|
||||
false, false, false, false, false, false, false, false, false, false, false, false, false, "*", false, false,
|
||||
-- 0x60-0x6F
|
||||
false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,
|
||||
-- 0x70-0x7F
|
||||
"@", "#", "\u{201C}", "\u{201D}", false, "\u{2026}", false, false, false, "\u{250C}", "\u{2500}", "\u{2510}", "\u{2502}", "\u{2514}", "\u{2518}", " ",
|
||||
-- 0x80-0x8F
|
||||
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P",
|
||||
-- 0x90-0x9F
|
||||
"Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "(", ")", ":", ";", "[", "]",
|
||||
-- 0xA0-0xAF
|
||||
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p",
|
||||
-- 0xB0-0xBF
|
||||
"q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "\u{E0}", "\u{E8}", "\u{E9}", "\u{F9}", "\u{C0}", "\u{C1}",
|
||||
-- 0xC0-0xCF
|
||||
"\u{C4}", "\u{D6}", "\u{DC}", "\u{E4}", "\u{F6}", "\u{FC}", "\u{C8}", "\u{C9}", "\u{CC}", "\u{CD}", "\u{D1}", "\u{D2}", "\u{D3}", "\u{D9}", "\u{DA}", "\u{E1}",
|
||||
-- 0xD0-0xDF
|
||||
"\u{EC}", "\u{ED}", "\u{F1}", "\u{F2}", "\u{F3}", "\u{FA}", "\u{BA}", false, false, false, false, false, false, false, "\u{2190}", "'",
|
||||
-- 0xE0-0xEF
|
||||
"\u{2019}", "{", "}", "-", false, false, "?", "!", "\u{2024}", "&", "%", "\u{2192}", "\u{25B7}", "\u{25B6}", "\u{25BC}", "\u{2642}",
|
||||
-- 0xF0-0xFF
|
||||
"\u{A5}", "\u{D7}", ".", "/", ",", "\u{2640}", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
|
||||
}
|
||||
|
||||
local CHAR_TO_BYTE = {}
|
||||
for b = 0, 255 do
|
||||
local c = TABLE_EN[b + 1]
|
||||
if c then CHAR_TO_BYTE[c] = b end
|
||||
end
|
||||
|
||||
-- Decodes a fixed-length Gen 1 string buffer into a Lua (UTF-8) string.
|
||||
function M.decode_string(buf, ofs, bufLen)
|
||||
if get(buf, ofs) == 0x5D then return "*" end -- in-game trade OT placeholder
|
||||
local out = {}
|
||||
for i = 0, bufLen - 1 do
|
||||
local c = TABLE_EN[get(buf, ofs + i) + 1]
|
||||
if not c then break end
|
||||
out[#out + 1] = c
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- Encodes a Lua (UTF-8) string into a fixed-length Gen 1 string buffer,
|
||||
-- padding the remainder with the 0x50 terminator byte.
|
||||
function M.encode_string(buf, ofs, bufLen, str)
|
||||
for i = 0, bufLen - 1 do set(buf, ofs + i, 0x50) end
|
||||
str = str or ""
|
||||
if str == "" then return end
|
||||
if str == "*" then
|
||||
set(buf, ofs, 0x5D)
|
||||
if bufLen > 1 then set(buf, ofs + 1, 0x50) end
|
||||
return
|
||||
end
|
||||
local i = 0
|
||||
for _, cp in utf8.codes(str) do
|
||||
if i >= bufLen then break end
|
||||
local ch = utf8.char(cp)
|
||||
local b = CHAR_TO_BYTE[ch]
|
||||
if not b then
|
||||
error(string.format("character %q is not representable in the Gen 1 international character set", ch))
|
||||
end
|
||||
set(buf, ofs + i, b)
|
||||
i = i + 1
|
||||
end
|
||||
if i < bufLen then set(buf, ofs + i, 0x50) end
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- PK1 struct (PKHeX.Core/PKM/PK1.cs, GBPKM.cs)
|
||||
-- Offsets below are relative to the start of the 33/44-byte struct.
|
||||
------------------------------------------------------------------------
|
||||
|
||||
local function parse_pk1_body(buf, ofs, isParty)
|
||||
local speciesInternal = get(buf, ofs + 0x00)
|
||||
local dv16 = read_u16be(buf, ofs + 0x1B)
|
||||
local ivAtk = (dv16 >> 12) & 0xF
|
||||
local ivDef = (dv16 >> 8) & 0xF
|
||||
local ivSpe = (dv16 >> 4) & 0xF
|
||||
local ivSpc = dv16 & 0xF
|
||||
local ivHp = ((ivAtk & 1) << 3) | ((ivDef & 1) << 2) | ((ivSpe & 1) << 1) | (ivSpc & 1)
|
||||
|
||||
local pp1b, pp2b, pp3b, pp4b = get(buf, ofs + 0x1D), get(buf, ofs + 0x1E), get(buf, ofs + 0x1F), get(buf, ofs + 0x20)
|
||||
local species = M.internal_to_national(speciesInternal)
|
||||
|
||||
local pairs_ = {
|
||||
{ "species", species },
|
||||
{ "species_name", M.SPECIES_NAMES[species] },
|
||||
{ "nickname", "" }, -- filled in by parse_mon_list; placeholder keeps the key ordered here
|
||||
{ "level", isParty and get(buf, ofs + 0x21) or get(buf, ofs + 0x03) },
|
||||
{ "ot_name", "" }, -- filled in by parse_mon_list
|
||||
{ "ot_id", read_u16be(buf, ofs + 0x0C) },
|
||||
{ "current_hp", read_u16be(buf, ofs + 0x01) },
|
||||
{ "status_condition", get(buf, ofs + 0x04) },
|
||||
{ "type1", get(buf, ofs + 0x05) },
|
||||
{ "type2", get(buf, ofs + 0x06) },
|
||||
{ "catch_rate", get(buf, ofs + 0x07) },
|
||||
{ "moves", { get(buf, ofs + 0x08), get(buf, ofs + 0x09), get(buf, ofs + 0x0A), get(buf, ofs + 0x0B) } },
|
||||
{ "pp", { pp1b & 0x3F, pp2b & 0x3F, pp3b & 0x3F, pp4b & 0x3F } },
|
||||
{ "pp_ups", { (pp1b >> 6) & 0x3, (pp2b >> 6) & 0x3, (pp3b >> 6) & 0x3, (pp4b >> 6) & 0x3 } },
|
||||
{ "exp", read_u24be(buf, ofs + 0x0E) },
|
||||
{ "evs", M.omap({
|
||||
{ "hp", read_u16be(buf, ofs + 0x11) }, { "atk", read_u16be(buf, ofs + 0x13) },
|
||||
{ "def", read_u16be(buf, ofs + 0x15) }, { "spe", read_u16be(buf, ofs + 0x17) },
|
||||
{ "spc", read_u16be(buf, ofs + 0x19) },
|
||||
}) },
|
||||
{ "ivs", M.omap({
|
||||
{ "atk", ivAtk }, { "def", ivDef }, { "spe", ivSpe }, { "spc", ivSpc }, { "hp", ivHp },
|
||||
}) },
|
||||
}
|
||||
|
||||
if isParty then
|
||||
pairs_[#pairs_ + 1] = { "stats", M.omap({
|
||||
{ "hp_max", read_u16be(buf, ofs + 0x22) }, { "atk", read_u16be(buf, ofs + 0x24) },
|
||||
{ "def", read_u16be(buf, ofs + 0x26) }, { "spe", read_u16be(buf, ofs + 0x28) },
|
||||
{ "spc", read_u16be(buf, ofs + 0x2A) },
|
||||
}) }
|
||||
end
|
||||
return M.omap(pairs_)
|
||||
end
|
||||
|
||||
local function write_pk1_body(buf, ofs, sizeBody, isParty, mon)
|
||||
for i = 0, sizeBody - 1 do set(buf, ofs + i, 0) end
|
||||
set(buf, ofs + 0x00, M.national_to_internal(mon.species))
|
||||
write_u16be(buf, ofs + 0x01, mon.current_hp or 0)
|
||||
set(buf, ofs + 0x03, mon.level or 1)
|
||||
set(buf, ofs + 0x04, mon.status_condition or 0)
|
||||
set(buf, ofs + 0x05, mon.type1 or 0)
|
||||
set(buf, ofs + 0x06, mon.type2 or 0)
|
||||
set(buf, ofs + 0x07, mon.catch_rate or 0)
|
||||
local moves = mon.moves or {0, 0, 0, 0}
|
||||
set(buf, ofs + 0x08, moves[1] or 0)
|
||||
set(buf, ofs + 0x09, moves[2] or 0)
|
||||
set(buf, ofs + 0x0A, moves[3] or 0)
|
||||
set(buf, ofs + 0x0B, moves[4] or 0)
|
||||
write_u16be(buf, ofs + 0x0C, mon.ot_id or 0)
|
||||
write_u24be(buf, ofs + 0x0E, mon.exp or 0)
|
||||
local evs = mon.evs or {}
|
||||
write_u16be(buf, ofs + 0x11, evs.hp or 0)
|
||||
write_u16be(buf, ofs + 0x13, evs.atk or 0)
|
||||
write_u16be(buf, ofs + 0x15, evs.def or 0)
|
||||
write_u16be(buf, ofs + 0x17, evs.spe or 0)
|
||||
write_u16be(buf, ofs + 0x19, evs.spc or 0)
|
||||
local ivs = mon.ivs or {}
|
||||
local dv16 = ((ivs.atk or 0) & 0xF) << 12 | ((ivs.def or 0) & 0xF) << 8 | ((ivs.spe or 0) & 0xF) << 4 | ((ivs.spc or 0) & 0xF)
|
||||
write_u16be(buf, ofs + 0x1B, dv16)
|
||||
local pp = mon.pp or {0, 0, 0, 0}
|
||||
local ppUps = mon.pp_ups or {0, 0, 0, 0}
|
||||
set(buf, ofs + 0x1D, ((ppUps[1] or 0) & 0x3) << 6 | ((pp[1] or 0) & 0x3F))
|
||||
set(buf, ofs + 0x1E, ((ppUps[2] or 0) & 0x3) << 6 | ((pp[2] or 0) & 0x3F))
|
||||
set(buf, ofs + 0x1F, ((ppUps[3] or 0) & 0x3) << 6 | ((pp[3] or 0) & 0x3F))
|
||||
set(buf, ofs + 0x20, ((ppUps[4] or 0) & 0x3) << 6 | ((pp[4] or 0) & 0x3F))
|
||||
|
||||
if isParty then
|
||||
set(buf, ofs + 0x21, mon.level or 1)
|
||||
local stats = mon.stats or {}
|
||||
write_u16be(buf, ofs + 0x22, stats.hp_max or 0)
|
||||
write_u16be(buf, ofs + 0x24, stats.atk or 0)
|
||||
write_u16be(buf, ofs + 0x26, stats.def or 0)
|
||||
write_u16be(buf, ofs + 0x28, stats.spe or 0)
|
||||
write_u16be(buf, ofs + 0x2A, stats.spc or 0)
|
||||
end
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- Packed box/party lists (PKHeX.Core/Saves/Storage/PokeList1.cs)
|
||||
-- u8 count of occupied slots
|
||||
-- u8[capacity+1] per-slot species marker (0xFF = empty); last byte always 0xFF
|
||||
-- pk1[capacity] PK1 struct data (no strings), `sizeBody` bytes each
|
||||
-- str[capacity] Original Trainer name table, STRING_LENGTH bytes each
|
||||
-- str[capacity] Nickname table, STRING_LENGTH bytes each
|
||||
------------------------------------------------------------------------
|
||||
|
||||
local function parse_mon_list(buf, absBase, capacity, sizeBody, isParty)
|
||||
local count = get(buf, absBase)
|
||||
if count > capacity then count = capacity end
|
||||
local start = 1 + (capacity + 1)
|
||||
local bodyBase = absBase + start
|
||||
local otBase = bodyBase + sizeBody * capacity
|
||||
local nickBase = otBase + capacity * M.STRING_LENGTH
|
||||
|
||||
local list = {}
|
||||
for i = 0, count - 1 do
|
||||
local mon = parse_pk1_body(buf, bodyBase + sizeBody * i, isParty)
|
||||
mon.ot_name = M.decode_string(buf, otBase + M.STRING_LENGTH * i, M.STRING_LENGTH)
|
||||
mon.nickname = M.decode_string(buf, nickBase + M.STRING_LENGTH * i, M.STRING_LENGTH)
|
||||
list[#list + 1] = mon
|
||||
end
|
||||
return list
|
||||
end
|
||||
|
||||
local function write_mon_list(buf, absBase, capacity, sizeBody, isParty, monList)
|
||||
local count = #monList
|
||||
if count > capacity then
|
||||
error(string.format("list has %d Pokemon, but capacity is only %d", count, capacity))
|
||||
end
|
||||
set(buf, absBase, count)
|
||||
|
||||
local start = 1 + (capacity + 1)
|
||||
for i = 0, capacity - 1 do
|
||||
local mon = monList[i + 1]
|
||||
set(buf, absBase + 1 + i, mon and M.national_to_internal(mon.species) or 0xFF)
|
||||
end
|
||||
set(buf, absBase + 1 + capacity, 0xFF) -- list terminator, always present
|
||||
|
||||
local bodyBase = absBase + start
|
||||
local otBase = bodyBase + sizeBody * capacity
|
||||
local nickBase = otBase + capacity * M.STRING_LENGTH
|
||||
|
||||
-- Only touch bytes for occupied slots (i < count). Slots beyond `count`
|
||||
-- are inert as far as the game is concerned (the marker byte above
|
||||
-- already flags them 0xFF/empty), so their body/name bytes are left
|
||||
-- exactly as they were in the original save instead of being normalized.
|
||||
for i = 0, count - 1 do
|
||||
local mon = monList[i + 1]
|
||||
local bodyOfs = bodyBase + sizeBody * i
|
||||
local otOfs = otBase + M.STRING_LENGTH * i
|
||||
local nickOfs = nickBase + M.STRING_LENGTH * i
|
||||
write_pk1_body(buf, bodyOfs, sizeBody, isParty, mon)
|
||||
M.encode_string(buf, otOfs, M.STRING_LENGTH, mon.ot_name)
|
||||
M.encode_string(buf, nickOfs, M.STRING_LENGTH, mon.nickname)
|
||||
end
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- Checksum (SAV1.cs: GetRBYChecksum / SetChecksums)
|
||||
-- One's complement of the byte-sum over [OFS.OT, OFS.ChecksumOfs).
|
||||
------------------------------------------------------------------------
|
||||
|
||||
local function compute_checksum(buf)
|
||||
local sum = 0
|
||||
for i = M.OFS.OT, M.OFS.ChecksumOfs - 1 do
|
||||
sum = sum + get(buf, i)
|
||||
end
|
||||
return (255 - (sum % 256)) & 0xFF
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- Bytes <-> Lua string, base64
|
||||
------------------------------------------------------------------------
|
||||
|
||||
function M.bytes_to_string(buf, len)
|
||||
len = len or #buf
|
||||
local chars = {}
|
||||
for i = 1, len do chars[i] = string.char(buf[i] & 0xFF) end
|
||||
return table.concat(chars)
|
||||
end
|
||||
|
||||
function M.string_to_bytes(s)
|
||||
local buf = {}
|
||||
for i = 1, #s do buf[i] = string.byte(s, i) end
|
||||
return buf
|
||||
end
|
||||
|
||||
local B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
local B64_REV = {}
|
||||
for idx = 1, #B64_CHARS do B64_REV[B64_CHARS:sub(idx, idx)] = idx - 1 end
|
||||
|
||||
function M.base64_encode(data)
|
||||
local out = {}
|
||||
local len = #data
|
||||
local i = 1
|
||||
while i <= len do
|
||||
local b1 = string.byte(data, i)
|
||||
local b2 = string.byte(data, i + 1)
|
||||
local b3 = string.byte(data, i + 2)
|
||||
local n = b1 << 16
|
||||
if b2 then n = n | (b2 << 8) end
|
||||
if b3 then n = n | b3 end
|
||||
out[#out + 1] = B64_CHARS:sub((n >> 18 & 0x3F) + 1, (n >> 18 & 0x3F) + 1)
|
||||
out[#out + 1] = B64_CHARS:sub((n >> 12 & 0x3F) + 1, (n >> 12 & 0x3F) + 1)
|
||||
out[#out + 1] = b2 and B64_CHARS:sub((n >> 6 & 0x3F) + 1, (n >> 6 & 0x3F) + 1) or "="
|
||||
out[#out + 1] = b3 and B64_CHARS:sub((n & 0x3F) + 1, (n & 0x3F) + 1) or "="
|
||||
i = i + 3
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
function M.base64_decode(s)
|
||||
s = s:gsub("[^A-Za-z0-9+/=]", "")
|
||||
local out = {}
|
||||
local i = 1
|
||||
local len = #s
|
||||
while i <= len do
|
||||
local c1 = B64_REV[s:sub(i, i)]
|
||||
local c2 = B64_REV[s:sub(i + 1, i + 1)]
|
||||
local s3, s4 = s:sub(i + 2, i + 2), s:sub(i + 3, i + 3)
|
||||
local c3, c4 = B64_REV[s3], B64_REV[s4]
|
||||
local n = (c1 << 18) | (c2 << 12) | ((c3 or 0) << 6) | (c4 or 0)
|
||||
out[#out + 1] = string.char((n >> 16) & 0xFF)
|
||||
if s3 ~= "=" and s3 ~= "" then out[#out + 1] = string.char((n >> 8) & 0xFF) end
|
||||
if s4 ~= "=" and s4 ~= "" then out[#out + 1] = string.char(n & 0xFF) end
|
||||
i = i + 4
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- Minimal pure-Lua JSON codec (no external dependencies).
|
||||
-- M.omap(list) builds an object that encodes with a fixed, readable key
|
||||
-- order instead of falling back to alphabetical sorting.
|
||||
------------------------------------------------------------------------
|
||||
|
||||
local ORDER_KEY = "__order__"
|
||||
|
||||
function M.omap(pairsList)
|
||||
local t = { [ORDER_KEY] = {} }
|
||||
for _, kv in ipairs(pairsList) do
|
||||
t[kv[1]] = kv[2]
|
||||
table.insert(t[ORDER_KEY], kv[1])
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
local function escape_str(s)
|
||||
local out = {}
|
||||
for i = 1, #s do
|
||||
local b = string.byte(s, i)
|
||||
if b == 34 then out[#out + 1] = '\\"'
|
||||
elseif b == 92 then out[#out + 1] = "\\\\"
|
||||
elseif b == 8 then out[#out + 1] = "\\b"
|
||||
elseif b == 9 then out[#out + 1] = "\\t"
|
||||
elseif b == 10 then out[#out + 1] = "\\n"
|
||||
elseif b == 12 then out[#out + 1] = "\\f"
|
||||
elseif b == 13 then out[#out + 1] = "\\r"
|
||||
elseif b < 0x20 then out[#out + 1] = string.format("\\u%04x", b)
|
||||
else out[#out + 1] = s:sub(i, i)
|
||||
end
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
local function array_len(t)
|
||||
local n = 0
|
||||
for k, _ in pairs(t) do
|
||||
if k == ORDER_KEY then goto continue end
|
||||
if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then return nil end
|
||||
if k > n then n = k end
|
||||
::continue::
|
||||
end
|
||||
for i = 1, n do if t[i] == nil then return nil end end
|
||||
return n
|
||||
end
|
||||
|
||||
local function encode_value(value, ind)
|
||||
local t = type(value)
|
||||
if value == nil then return "null" end
|
||||
if t == "boolean" then return tostring(value) end
|
||||
if t == "number" then
|
||||
if value == math.floor(value) and math.abs(value) < 1e15 then
|
||||
return string.format("%d", value)
|
||||
end
|
||||
return tostring(value)
|
||||
end
|
||||
if t == "string" then return '"' .. escape_str(value) .. '"' end
|
||||
if t == "table" then
|
||||
local nextIndent = ind .. " "
|
||||
if value[ORDER_KEY] then
|
||||
local keys = value[ORDER_KEY]
|
||||
if #keys == 0 then return "{}" end
|
||||
local parts = {}
|
||||
for _, k in ipairs(keys) do
|
||||
parts[#parts + 1] = nextIndent .. '"' .. escape_str(k) .. '": ' .. encode_value(value[k], nextIndent)
|
||||
end
|
||||
return "{\n" .. table.concat(parts, ",\n") .. "\n" .. ind .. "}"
|
||||
end
|
||||
if next(value) == nil then return "[]" end
|
||||
local n = array_len(value)
|
||||
if n then
|
||||
local parts = {}
|
||||
for i = 1, n do parts[#parts + 1] = nextIndent .. encode_value(value[i], nextIndent) end
|
||||
return "[\n" .. table.concat(parts, ",\n") .. "\n" .. ind .. "]"
|
||||
end
|
||||
local keys = {}
|
||||
for k, _ in pairs(value) do keys[#keys + 1] = k end
|
||||
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
|
||||
local parts = {}
|
||||
for _, k in ipairs(keys) do
|
||||
parts[#parts + 1] = nextIndent .. '"' .. escape_str(tostring(k)) .. '": ' .. encode_value(value[k], nextIndent)
|
||||
end
|
||||
return "{\n" .. table.concat(parts, ",\n") .. "\n" .. ind .. "}"
|
||||
end
|
||||
error("cannot JSON-encode a value of type " .. t)
|
||||
end
|
||||
|
||||
function M.json_encode(value)
|
||||
return encode_value(value, "")
|
||||
end
|
||||
|
||||
local function skip_ws(s, i)
|
||||
while i <= #s do
|
||||
local c = s:sub(i, i)
|
||||
if c == " " or c == "\t" or c == "\n" or c == "\r" then i = i + 1 else break end
|
||||
end
|
||||
return i
|
||||
end
|
||||
|
||||
local parse_value
|
||||
|
||||
local function parse_string(s, i)
|
||||
i = i + 1 -- opening quote
|
||||
local out = {}
|
||||
while true do
|
||||
local c = s:sub(i, i)
|
||||
if c == "" then error("unterminated string in JSON input") end
|
||||
if c == '"' then i = i + 1; break end
|
||||
if c == "\\" then
|
||||
local e = s:sub(i + 1, i + 1)
|
||||
if e == '"' then out[#out + 1] = '"'; i = i + 2
|
||||
elseif e == "\\" then out[#out + 1] = "\\"; i = i + 2
|
||||
elseif e == "/" then out[#out + 1] = "/"; i = i + 2
|
||||
elseif e == "b" then out[#out + 1] = string.char(8); i = i + 2
|
||||
elseif e == "f" then out[#out + 1] = string.char(12); i = i + 2
|
||||
elseif e == "n" then out[#out + 1] = string.char(10); i = i + 2
|
||||
elseif e == "r" then out[#out + 1] = string.char(13); i = i + 2
|
||||
elseif e == "t" then out[#out + 1] = string.char(9); i = i + 2
|
||||
elseif e == "u" then
|
||||
local cp = tonumber(s:sub(i + 2, i + 5), 16)
|
||||
i = i + 6
|
||||
if cp >= 0xD800 and cp <= 0xDBFF and s:sub(i, i + 1) == "\\u" then
|
||||
local cp2 = tonumber(s:sub(i + 2, i + 5), 16)
|
||||
if cp2 and cp2 >= 0xDC00 and cp2 <= 0xDFFF then
|
||||
cp = 0x10000 + (cp - 0xD800) * 0x400 + (cp2 - 0xDC00)
|
||||
i = i + 6
|
||||
end
|
||||
end
|
||||
out[#out + 1] = utf8.char(cp)
|
||||
else
|
||||
error("invalid escape sequence in JSON string: \\" .. e)
|
||||
end
|
||||
else
|
||||
out[#out + 1] = c
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
return table.concat(out), i
|
||||
end
|
||||
|
||||
local function parse_number(s, i)
|
||||
local j = i
|
||||
if s:sub(j, j) == "-" then j = j + 1 end
|
||||
while s:sub(j, j):match("%d") do j = j + 1 end
|
||||
if s:sub(j, j) == "." then
|
||||
j = j + 1
|
||||
while s:sub(j, j):match("%d") do j = j + 1 end
|
||||
end
|
||||
if s:sub(j, j) == "e" or s:sub(j, j) == "E" then
|
||||
j = j + 1
|
||||
if s:sub(j, j) == "+" or s:sub(j, j) == "-" then j = j + 1 end
|
||||
while s:sub(j, j):match("%d") do j = j + 1 end
|
||||
end
|
||||
return tonumber(s:sub(i, j - 1)), j
|
||||
end
|
||||
|
||||
parse_value = function(s, i)
|
||||
i = skip_ws(s, i)
|
||||
local c = s:sub(i, i)
|
||||
if c == '"' then return parse_string(s, i) end
|
||||
if c == "{" then
|
||||
i = skip_ws(s, i + 1)
|
||||
local t = {}
|
||||
if s:sub(i, i) == "}" then return t, i + 1 end
|
||||
while true do
|
||||
i = skip_ws(s, i)
|
||||
if s:sub(i, i) ~= '"' then error("expected string key in JSON object at position " .. i) end
|
||||
local key, ni = parse_string(s, i)
|
||||
i = skip_ws(s, ni)
|
||||
if s:sub(i, i) ~= ":" then error("expected ':' in JSON object at position " .. i) end
|
||||
local val, ni2 = parse_value(s, i + 1)
|
||||
t[key] = val
|
||||
i = skip_ws(s, ni2)
|
||||
local cc = s:sub(i, i)
|
||||
if cc == "," then i = i + 1
|
||||
elseif cc == "}" then i = i + 1; break
|
||||
else error("expected ',' or '}' in JSON object at position " .. i) end
|
||||
end
|
||||
return t, i
|
||||
end
|
||||
if c == "[" then
|
||||
i = skip_ws(s, i + 1)
|
||||
local t = {}
|
||||
if s:sub(i, i) == "]" then return t, i + 1 end
|
||||
local n = 0
|
||||
while true do
|
||||
local val, ni = parse_value(s, i)
|
||||
n = n + 1
|
||||
t[n] = val
|
||||
i = skip_ws(s, ni)
|
||||
local cc = s:sub(i, i)
|
||||
if cc == "," then i = i + 1
|
||||
elseif cc == "]" then i = i + 1; break
|
||||
else error("expected ',' or ']' in JSON array at position " .. i) end
|
||||
end
|
||||
return t, i
|
||||
end
|
||||
if s:sub(i, i + 3) == "true" then return true, i + 4 end
|
||||
if s:sub(i, i + 4) == "false" then return false, i + 5 end
|
||||
if s:sub(i, i + 3) == "null" then return nil, i + 4 end
|
||||
local num, ni = parse_number(s, i)
|
||||
if num == nil then error("invalid JSON at position " .. i .. ": " .. s:sub(i, i + 10)) end
|
||||
return num, ni
|
||||
end
|
||||
|
||||
function M.json_decode(s)
|
||||
return (parse_value(s, 1))
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- Top-level save <-> table conversion
|
||||
------------------------------------------------------------------------
|
||||
|
||||
local function is_yellow(buf)
|
||||
local starter = get(buf, M.OFS.Starter)
|
||||
if starter ~= 0 then return starter == 0x54 end -- 0x54 = internal species ID for Pikachu
|
||||
return get(buf, M.OFS.PikaFriendship) ~= 0
|
||||
end
|
||||
|
||||
-- Quick structural sanity check equivalent to SaveUtil's HasListAt/IsListValidG12.
|
||||
function M.looks_like_gen1_international_save(buf)
|
||||
if #buf ~= M.SIZE_SAVE then return false end
|
||||
local function has_list_at(ofs, maxCount)
|
||||
local count = get(buf, ofs)
|
||||
return count <= maxCount and get(buf, ofs + 1 + count) == 0xFF
|
||||
end
|
||||
return has_list_at(M.OFS.Party, 6) and has_list_at(M.OFS.CurrentBox, M.BOX_SLOT_COUNT)
|
||||
end
|
||||
|
||||
-- Parses a raw 32768-byte Gen 1 save (as a byte array) into a JSON-friendly table.
|
||||
function M.parse_save(buf)
|
||||
if not M.looks_like_gen1_international_save(buf) then
|
||||
error("this does not look like an international Gen 1 (Red/Blue/Yellow) save file (expected a 32768-byte file with valid party/box list headers)")
|
||||
end
|
||||
|
||||
local currentBoxZero = get(buf, M.OFS.CurrentBoxIndex) & 0x7F
|
||||
local boxesInitialized = (get(buf, M.OFS.CurrentBoxIndex) & 0x80) ~= 0
|
||||
|
||||
local playTimeOfs = M.OFS.PlayTime
|
||||
local trainer = M.omap({
|
||||
{ "name", M.decode_string(buf, M.OFS.OT, M.STRING_LENGTH) },
|
||||
{ "id", read_u16be(buf, M.OFS.TID16) },
|
||||
{ "rival_name", M.decode_string(buf, M.OFS.Rival, M.STRING_LENGTH) },
|
||||
{ "money", bcd_read(buf, M.OFS.Money, 3, false) },
|
||||
{ "coins", bcd_read(buf, M.OFS.Coin, 2, false) },
|
||||
{ "badges", get(buf, M.OFS.Badges) },
|
||||
{ "options", get(buf, M.OFS.Options) },
|
||||
{ "starter", get(buf, M.OFS.Starter) },
|
||||
{ "pikachu_friendship", get(buf, M.OFS.PikaFriendship) },
|
||||
{ "pikachu_beach_score", bcd_read(buf, M.OFS.PikaBeachScore, 2, true) },
|
||||
{ "play_time", M.omap({
|
||||
{ "hours", get(buf, playTimeOfs) },
|
||||
{ "minutes", get(buf, playTimeOfs + 2) },
|
||||
{ "seconds", get(buf, playTimeOfs + 3) },
|
||||
{ "frames", get(buf, playTimeOfs + 4) },
|
||||
{ "maxed_out", get(buf, playTimeOfs + 1) ~= 0 },
|
||||
}) },
|
||||
})
|
||||
|
||||
local boxes = {}
|
||||
for boxIndexZero = 0, M.BOX_COUNT - 1 do
|
||||
local absBase = (boxIndexZero == currentBoxZero) and M.OFS.CurrentBox or M.box_bank_offset(boxIndexZero)
|
||||
boxes[#boxes + 1] = M.omap({
|
||||
{ "box", boxIndexZero + 1 },
|
||||
{ "pokemon", parse_mon_list(buf, absBase, M.BOX_SLOT_COUNT, M.SIZE_STORED, false) },
|
||||
})
|
||||
end
|
||||
|
||||
return M.omap({
|
||||
{ "format", "gen1" },
|
||||
{ "region", "international" },
|
||||
{ "version_guess", is_yellow(buf) and "yellow" or "red_blue" },
|
||||
{ "trainer", trainer },
|
||||
{ "current_box", currentBoxZero + 1 },
|
||||
{ "boxes_initialized", boxesInitialized },
|
||||
{ "party", parse_mon_list(buf, M.OFS.Party, 6, M.SIZE_PARTY, true) },
|
||||
{ "boxes", boxes },
|
||||
{ "raw_base64", M.base64_encode(M.bytes_to_string(buf)) },
|
||||
})
|
||||
end
|
||||
|
||||
-- Builds a raw 32768-byte Gen 1 save (as a byte array) from a JSON-decoded table.
|
||||
-- `data.raw_base64` (as produced by parse_save) is required and used as the base
|
||||
-- buffer, so that anything not modeled above (items, Pokedex flags, event flags,
|
||||
-- Hall of Fame, etc.) survives the round trip unmodified.
|
||||
function M.build_save(data)
|
||||
if not data.raw_base64 or data.raw_base64 == "" then
|
||||
error("JSON is missing required field 'raw_base64' (the original save's base data)")
|
||||
end
|
||||
local buf = M.string_to_bytes(M.base64_decode(data.raw_base64))
|
||||
if #buf ~= M.SIZE_SAVE then
|
||||
error(string.format("decoded raw_base64 is %d bytes, expected %d", #buf, M.SIZE_SAVE))
|
||||
end
|
||||
|
||||
local trainer = data.trainer or {}
|
||||
M.encode_string(buf, M.OFS.OT, M.STRING_LENGTH, trainer.name)
|
||||
write_u16be(buf, M.OFS.TID16, trainer.id or 0)
|
||||
M.encode_string(buf, M.OFS.Rival, M.STRING_LENGTH, trainer.rival_name)
|
||||
bcd_write(buf, M.OFS.Money, 3, false, trainer.money or 0)
|
||||
bcd_write(buf, M.OFS.Coin, 2, false, trainer.coins or 0)
|
||||
set(buf, M.OFS.Badges, trainer.badges or 0)
|
||||
set(buf, M.OFS.Options, trainer.options or 0)
|
||||
set(buf, M.OFS.Starter, trainer.starter or 0)
|
||||
set(buf, M.OFS.PikaFriendship, trainer.pikachu_friendship or 0)
|
||||
bcd_write(buf, M.OFS.PikaBeachScore, 2, true, trainer.pikachu_beach_score or 0)
|
||||
|
||||
local playTime = trainer.play_time or {}
|
||||
set(buf, M.OFS.PlayTime, playTime.hours or 0)
|
||||
set(buf, M.OFS.PlayTime + 1, playTime.maxed_out and 1 or 0)
|
||||
set(buf, M.OFS.PlayTime + 2, playTime.minutes or 0)
|
||||
set(buf, M.OFS.PlayTime + 3, playTime.seconds or 0)
|
||||
set(buf, M.OFS.PlayTime + 4, playTime.frames or 0)
|
||||
|
||||
write_mon_list(buf, M.OFS.Party, 6, M.SIZE_PARTY, true, data.party or {})
|
||||
|
||||
local currentBoxZero = (data.current_box or 1) - 1
|
||||
if currentBoxZero < 0 or currentBoxZero >= M.BOX_COUNT then
|
||||
error("current_box must be between 1 and " .. M.BOX_COUNT)
|
||||
end
|
||||
|
||||
local boxes = data.boxes or {}
|
||||
for boxIndexZero = 0, M.BOX_COUNT - 1 do
|
||||
local boxEntry = boxes[boxIndexZero + 1]
|
||||
local pokemon = boxEntry and boxEntry.pokemon or {}
|
||||
local absBase = M.box_bank_offset(boxIndexZero)
|
||||
write_mon_list(buf, absBase, M.BOX_SLOT_COUNT, M.SIZE_STORED, false, pokemon)
|
||||
end
|
||||
|
||||
-- Mirror the current box's freshly-written bytes into the "live" buffer
|
||||
-- (SAV1.cs GetFinalData()), and mark boxes as initialized.
|
||||
local curBankOfs = M.box_bank_offset(currentBoxZero)
|
||||
for i = 0, M.SIZE_BOX_LIST - 1 do
|
||||
set(buf, M.OFS.CurrentBox + i, get(buf, curBankOfs + i))
|
||||
end
|
||||
set(buf, M.OFS.CurrentBoxIndex, (currentBoxZero & 0x7F) | 0x80)
|
||||
|
||||
set(buf, M.OFS.ChecksumOfs, compute_checksum(buf))
|
||||
|
||||
return M.bytes_to_string(buf, M.SIZE_SAVE)
|
||||
end
|
||||
|
||||
return M
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env lua
|
||||
-- json2sav.lua <input.json> [output.sav]
|
||||
--
|
||||
-- Converts a JSON file (as produced by sav2json.lua, optionally hand-edited)
|
||||
-- back into a binary Pokemon Red/Blue/Yellow save file, recomputing the
|
||||
-- save's checksum. See README.md for details and limitations.
|
||||
|
||||
local scriptDir = arg[0]:match("^(.*)[/\\]") or "."
|
||||
local gen1 = dofile(scriptDir .. "/gen1lib.lua")
|
||||
|
||||
local input = arg[1]
|
||||
if not input then
|
||||
io.stderr:write("usage: lua json2sav.lua <input.json> [output.sav]\n")
|
||||
os.exit(1)
|
||||
end
|
||||
local output = arg[2] or (input:gsub("%.[^.]*$", "") .. ".sav")
|
||||
|
||||
local f, err = io.open(input, "rb")
|
||||
if not f then
|
||||
io.stderr:write("error: could not open '" .. input .. "': " .. tostring(err) .. "\n")
|
||||
os.exit(1)
|
||||
end
|
||||
local text = f:read("a")
|
||||
f:close()
|
||||
|
||||
local ok, data = pcall(gen1.json_decode, text)
|
||||
if not ok then
|
||||
io.stderr:write("error: invalid JSON: " .. tostring(data) .. "\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
local ok2, savBytes = pcall(gen1.build_save, data)
|
||||
if not ok2 then
|
||||
io.stderr:write("error: " .. tostring(savBytes) .. "\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
local out, werr = io.open(output, "wb")
|
||||
if not out then
|
||||
io.stderr:write("error: could not write '" .. output .. "': " .. tostring(werr) .. "\n")
|
||||
os.exit(1)
|
||||
end
|
||||
out:write(savBytes)
|
||||
out:close()
|
||||
|
||||
print("wrote " .. output .. " (" .. #savBytes .. " bytes)")
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env lua
|
||||
-- sav2json.lua <input.sav> [output.json]
|
||||
--
|
||||
-- Converts an international (non-Japanese) Pokemon Red/Blue/Yellow save
|
||||
-- file into a human-editable JSON file. See README.md for details and
|
||||
-- limitations, and json2sav.lua for the reverse direction.
|
||||
|
||||
local scriptDir = arg[0]:match("^(.*)[/\\]") or "."
|
||||
local gen1 = dofile(scriptDir .. "/gen1lib.lua")
|
||||
|
||||
local input = arg[1]
|
||||
if not input then
|
||||
io.stderr:write("usage: lua sav2json.lua <input.sav> [output.json]\n")
|
||||
os.exit(1)
|
||||
end
|
||||
local output = arg[2] or (input:gsub("%.[^.]*$", "") .. ".json")
|
||||
|
||||
local f, err = io.open(input, "rb")
|
||||
if not f then
|
||||
io.stderr:write("error: could not open '" .. input .. "': " .. tostring(err) .. "\n")
|
||||
os.exit(1)
|
||||
end
|
||||
local raw = f:read("a")
|
||||
f:close()
|
||||
|
||||
local buf = gen1.string_to_bytes(raw)
|
||||
|
||||
local ok, result = pcall(gen1.parse_save, buf)
|
||||
if not ok then
|
||||
io.stderr:write("error: " .. tostring(result) .. "\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
local json = gen1.json_encode(result)
|
||||
|
||||
local out, werr = io.open(output, "wb")
|
||||
if not out then
|
||||
io.stderr:write("error: could not write '" .. output .. "': " .. tostring(werr) .. "\n")
|
||||
os.exit(1)
|
||||
end
|
||||
out:write(json)
|
||||
out:write("\n")
|
||||
out:close()
|
||||
|
||||
print("wrote " .. output)
|
||||
Reference in New Issue
Block a user