Files
1jamie 76ede1171e feat: Gen 3 save editor, GBA extraction resilience, and UI/asset polish
- Save Editor (Gen 3):
  - Added full support for EV, IV, Nature, Ability, and Ribbons editing for Gen 3 Pokémon.
  - Implemented stat calculations, personality-based nature derivation, and Gen 3 save data structures.
  - Added comprehensive unit test suite in tests/save_editor_gen3_ev_iv_tests.lua.

- GBA Extraction & Graphics:
  - Added zero-copy ARM-safe ROM accessor and fast LZ77 string decompression.
  - Fixed bag chrome decompression string-buffer indexing preventing solid-color renders.
  - Fixed TM Case HM badge tile offset and palette mapping.
  - Added builtin moves fallback in src/core/game3/battle/builtin_moves.lua.
  - Improved parallel extraction resilience, desktop fallback search paths, and prefix handling.

- Android & Launchers:
  - Added dynamic high-res app launcher shortcuts and color palettes for all supported games.
  - Updated Android icon generator and intent dispatch handlers.
2026-09-25 16:20:40 -05:00

701 lines
22 KiB
Lua

-- Generation adapter for the save editor. Panels stay generation-blind;
-- Ops and App read Gold vs RBY vs FRLG through this module so a write never
-- lands in wrong-generation fields.
local GameVersion = require("src.core.GameVersion")
local Gen = {}
local function versionGeneration(version)
if type(version) ~= "string" then return nil end
local info = GameVersion.info(version)
if info then return info.generation or 1 end
return nil
end
function Gen.of(save, version)
if type(save) == "table" then
if save.generation == 3 or save.engine == "game3" or save.version == "firered" then return 3 end
if save.healMap ~= nil or (save.bag and save.bag.pockets ~= nil) or (save.dex and save.dex.national ~= nil) then return 3 end
if save.generation == 2 then return 2 end
local fromVersion = versionGeneration(save.version)
if fromVersion then return fromVersion end
end
local fromArg = versionGeneration(version)
if fromArg then return fromArg end
return GameVersion.generation()
end
local function versionOf(save, version)
if type(save) == "table" and GameVersion.VERSIONS[save.version] then
return save.version
end
if type(version) == "string" and GameVersion.VERSIONS[version] then
return version
end
return GameVersion.get()
end
Gen.versionOf = versionOf
function Gen.engineOf(save, version)
return GameVersion.engine(versionOf(save, version))
end
function Gen.editionLabel(save, version)
local info = GameVersion.info(versionOf(save, version))
if Gen.of(save, version) == 3 then
return tostring((info and info.label) or "FIRE RED"):upper()
end
return tostring((info and info.label) or "GEN 2"):upper()
end
function Gen.hasCaughtData(save, version)
if Gen.of(save, version) ~= 2 then return false end
return require("src.battle.gen2.Mon").hasCaughtData(versionOf(save, version))
end
-- engine/menus/init_gender.asm:23-38 (Crystal), FRLG Oak intro (gender 0/1)
function Gen.hasPlayerGender(save, version)
local g = Gen.of(save, version)
if g == 3 then return true end
if g == 2 then return Gen.engineOf(save, version) == "crystal" end
return false
end
function Gen.ofState(S)
if not S then return GameVersion.generation() end
return Gen.of(S.save, S.version)
end
function Gen.is2(save, version)
return Gen.of(save, version) == 2
end
function Gen.is3(save, version)
return Gen.of(save, version) == 3
end
local function overlayRecords(base, overlay)
if not overlay then return base or {} end
if not base or base == overlay then return overlay end
local out = {}
for id, def in pairs(base) do out[id] = def end
for id, def in pairs(overlay) do
local prior = out[id]
if type(def) == "table" and type(prior) == "table" then
local merged = {}
for k, v in pairs(prior) do merged[k] = v end
for k, v in pairs(def) do merged[k] = v end
out[id] = merged
else
out[id] = def
end
end
return out
end
function Gen.maps(data)
if type(data) ~= "table" then return {} end
local m = overlayRecords(data.maps, data.gen2Maps)
return overlayRecords(m, data.game3Maps)
end
function Gen.tilesets(data)
if type(data) ~= "table" then return {} end
local t = overlayRecords(data.tilesets, data.gen2Tilesets)
return overlayRecords(t, data.game3Tilesets)
end
-- Point the Gen 2 Data keys at the tables Data:load already filled
function Gen.bindGoldData(data)
if type(data) ~= "table" then return data end
if data.maps and data.gen2Maps == nil then data.gen2Maps = data.maps end
if data.tilesets and data.gen2Tilesets == nil then
data.gen2Tilesets = data.tilesets
end
if data.palettes and data.gen2Palettes == nil then
data.gen2Palettes = data.palettes
end
local loadGen = function(rel)
local CacheFs = require("src.import.CacheFs")
local bytes = CacheFs.readActive("data/generated/" .. rel .. ".lua")
if type(bytes) == "string" then
local chunk = loadstring(bytes, "@gold/data/generated/" .. rel .. ".lua")
if chunk then
local ok, res = pcall(chunk)
if ok and type(res) == "table" then return res end
end
end
local ok, res = pcall(require, "data.generated." .. rel)
if ok and type(res) == "table" then return res end
return nil
end
data.gen2Palettes = data.gen2Palettes or loadGen("palettes")
data.gen2Constants = data.gen2Constants or loadGen("constants")
data.gen2Icons = data.gen2Icons or loadGen("icons")
data.gen2Pokedex = data.gen2Pokedex or loadGen("pokedex")
data.gen2Landmarks = data.gen2Landmarks or loadGen("landmarks")
data.gen2Roofs = data.gen2Roofs or loadGen("roofs") or data.roofs
data.gen2Sprites = data.gen2Sprites or loadGen("sprites")
return data
end
function Gen.game3CacheReady()
local RomText = require("src.core.game3.rom_text")
if RomText.has(RomText.key("gNatureNamePointers", 0)) then return true end
require("src.core.game3.scripting.space").bundle = nil
return false
end
function Gen.missingCacheMessage(version)
local info = GameVersion.info(versionOf(nil, version))
local label = tostring((info and info.label) or "FireRed")
return ("No imported %s ROM cache found. Import the %s ROM from the launcher, then open this save again."):format(label, label)
end
-- Bind Game 3 (FireRed) data into Data table for Save Editor
function Gen.bindGame3Data(data)
if type(data) ~= "table" then return data end
local okD, Dataset = pcall(require, "src.core.game3.dataset")
if okD and Dataset then
if Dataset.mountExtractRoots then pcall(Dataset.mountExtractRoots) end
local okM, g3Maps = pcall(Dataset.buildMaps)
if okM and g3Maps then
local cache = Dataset.cache and Dataset.cache()
if cache and Dataset.attachMidLayouts then
pcall(Dataset.attachMidLayouts, g3Maps, cache)
end
local okN, NativeTileset = pcall(require, "src.core.game3.tileset_native")
if okN and NativeTileset and NativeTileset.install and cache then
pcall(NativeTileset.install, cache, nil)
end
data.game3Maps = g3Maps
data.maps = overlayRecords(data.maps, g3Maps)
end
end
local okP, Pokemon = pcall(require, "src.core.game3.pokemon")
if okP and Pokemon then
pcall(Pokemon.install, nil)
data.pokemon = data.pokemon or {}
for id = 1, Pokemon.SPECIES_EGG - 1 do
local okN, name = pcall(Pokemon.name, id)
if okN and name and name ~= "??????????" and name ~= "" then
local def = {
id = name,
name = name,
species = id,
speciesId = id,
dex = id,
growthRate = (Pokemon.speciesMeta and Pokemon.speciesMeta(id) and Pokemon.speciesMeta(id).growthRate) or 0,
}
data.pokemon[name] = def
data.pokemon[id] = def
end
end
data.moves = data.moves or {}
local okB, BuiltinMoves = pcall(require, "src.core.game3.battle.builtin_moves")
for id = 1, 354 do
local mName = Pokemon.moveName(id)
local bMove = Pokemon.battleMove(id) or (okB and BuiltinMoves and BuiltinMoves[id])
if mName and mName ~= "-------" and not mName:find("^MOVE %d+") then
local mDef = {
id = mName,
moveId = id,
name = mName,
pp = (bMove and tonumber(bMove.pp)) or 10,
power = (bMove and tonumber(bMove.power)) or 40,
type = (bMove and bMove.type) or "NORMAL",
}
data.moves[mName] = mDef
data.moves[id] = mDef
end
end
end
local okI, ItemsData = pcall(require, "src.core.game3.items_data")
if okI and ItemsData then
data.items = data.items or {}
for k, v in pairs(ItemsData.BY_HOST or {}) do
local iDef = { id = k, name = v.name or k, pocket = v.pocket, itemId = v.frlg }
data.items[k] = iDef
end
for num = 1, 375 do
local okInf, info = pcall(ItemsData.info, num)
if okInf and info and info.name and info.name ~= "none" and info.name ~= "" then
local normName = info.name:upper():gsub("[^A-Z0-9_]", "_"):gsub("_+", "_")
local iDef = { id = normName, name = info.name, pocket = info.pocket, itemId = num }
data.items[normName] = iDef
data.items[num] = iDef
data.items[tostring(num)] = iDef
end
end
end
return data
end
function Gen.newGame(version)
local id = type(version) == "string" and GameVersion.VERSIONS[version] and version
or nil
local g = versionGeneration(id) or GameVersion.generation()
if g == 3 then
return require("src.core.game3.save_schema_firered").newGame({ version = id })
elseif g == 2 then
local Save2 = require("src.core.gen2.Save")
local save = Save2.newGame({
playerName = id and Save2.defaultPlayerName(id) or nil,
})
if id then save.version = id end
return save
end
return require("src.core.SaveData").newGame({ version = id })
end
function Gen.validate(save, data)
local g = Gen.of(save)
if g == 3 then
return { lostMons = {}, lostItems = {}, remappedMaps = {} }
elseif g == 2 then
return require("src.core.gen2.Save").validate(save)
end
return require("src.core.SaveData").validate(save, data)
end
function Gen.emptyReport(save, report)
local g = Gen.of(save)
if g == 3 then
if not report then return true end
return #(report.lostMons or {}) == 0 and #(report.lostItems or {}) == 0 and #(report.remappedMaps or {}) == 0
elseif g == 2 then
return require("src.core.gen2.Save").emptyReport(report)
end
return require("src.core.SaveData").emptyReport(report)
end
function Gen.hydrateMon(data, mon)
if type(mon) ~= "table" then return mon end
local isG3 = mon.speciesId ~= nil or mon.personality ~= nil or mon.ivs ~= nil or (mon.evs ~= nil and mon.evs.spa ~= nil)
if isG3 then
local okP, Pokemon = pcall(require, "src.core.game3.pokemon")
if okP and Pokemon then
require("src.core.game3.save_mon").normalize(mon)
local spId = Pokemon.speciesOf(mon)
if spId then
mon.speciesId = spId
local name = Pokemon.name(spId)
if name and name ~= "" and name ~= "??????????" then
mon.species = spId
mon.name = mon.name or name
mon.speciesNumbering = Pokemon.NUMBERING_INTERNAL
end
end
mon.ivs = mon.ivs or { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 }
mon.evs = mon.evs or { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 }
mon.dvs = mon.dvs or {
attack = math.floor((mon.ivs.atk or 0) / 2),
defense = math.floor((mon.ivs.def or 0) / 2),
speed = math.floor((mon.ivs.spe or 0) / 2),
special = math.floor(((mon.ivs.spa or 0) + (mon.ivs.spd or 0)) / 4),
hp = math.floor((mon.ivs.hp or 0) / 2),
}
require("src.core.game3.save_mon").normalize(mon)
end
return mon
end
local def = data and data.pokemon and data.pokemon[mon.species]
local gen2 = (mon.stats and mon.stats.specialAttack)
or (def and def.baseStats and def.baseStats.specialAttack)
or mon.experience ~= nil
if gen2 then
require("src.battle.gen2.Mon").refreshStats(mon, data)
else
require("src.pokemon.Stats").ensure(def, mon)
end
return mon
end
function Gen.hydrateSave(data, save)
if type(save) ~= "table" then return save end
local g = Gen.of(save)
if g == 3 then
require("Game3Adapter").hydrate(data, save)
local Mons = require("src.core.game3.save_mon")
Mons.each(save, function(mon) Mons.normalize(mon); Gen.hydrateMon(data, mon) end)
return save
elseif g == 2 then
local Mon = require("src.battle.gen2.Mon")
Mon.eachSaveMon(save, function(mon) Mon.refreshStats(mon, data) end)
return save
end
for _, mon in ipairs(save.party or {}) do Gen.hydrateMon(data, mon) end
for _, box in ipairs(save.boxes or {}) do
if type(box) == "table" then
for _, mon in ipairs(box) do Gen.hydrateMon(data, mon) end
end
end
if save.daycare and save.daycare.mon then
Gen.hydrateMon(data, save.daycare.mon)
end
return save
end
function Gen.ensureBoxes(save)
local g = Gen.of(save)
if g == 3 then
return require("Game3Adapter").ensureStorage(save)
elseif g == 2 then
local Boxes2 = require("src.core.gen2.Boxes")
save.boxes = save.boxes or {}
for i = 1, Boxes2.NUM_BOXES do
save.boxes[i] = save.boxes[i] or {}
end
save.currentBox = math.max(1, math.min(Boxes2.NUM_BOXES, save.currentBox or 1))
return save.boxes
end
return require("src.pokemon.Boxes").ensure(save)
end
function Gen.boxCount(save)
local g = Gen.of(save)
if g == 3 then return 14 end
if g == 2 then return require("src.core.gen2.Boxes").NUM_BOXES end
return require("src.pokemon.Boxes").COUNT
end
function Gen.boxCapacity(save)
local g = Gen.of(save)
if g == 3 then return 30 end
if g == 2 then return require("src.core.gen2.Boxes").MONS_PER_BOX end
return require("src.pokemon.Boxes").CAPACITY
end
function Gen.money(save)
if type(save) ~= "table" then return 0 end
if Gen.of(save) == 2 then
return (save.player and save.player.money) or 0
end
return save.money or 0
end
function Gen.setMoney(save, amount)
if type(save) ~= "table" then return end
if Gen.of(save) == 2 then
save.player = save.player or {}
save.player.money = amount
else
save.money = amount
end
end
function Gen.coins(save)
if type(save) ~= "table" then return 0 end
if Gen.of(save) == 2 then
return (save.player and save.player.coins) or 0
end
return save.coins or 0
end
function Gen.setCoins(save, amount)
if type(save) ~= "table" then return end
if Gen.of(save) == 2 then
save.player = save.player or {}
save.player.coins = amount
else
save.coins = amount
end
end
function Gen.playerGender(save)
if type(save) ~= "table" then return "male" end
if save.gender == 1 or (save.player and (save.player.gender == 1 or save.player.gender == "female")) then
return "female"
end
return "male"
end
function Gen.setPlayerGender(save, gender)
if type(save) ~= "table" then return "male" end
local isFemale = (gender == "female" or gender == 1)
save.gender = isFemale and 1 or 0
if save.player then
save.player.gender = isFemale and "female" or "male"
end
return isFemale and "female" or "male"
end
function Gen.landmarkName(data, index)
index = math.floor(tonumber(index) or 0)
local Mon = require("src.battle.gen2.Mon")
if index == Mon.LANDMARK_EVENT then return "EVENT" end
if index == Mon.LANDMARK_GIFT then return "GIFT" end
if index == 0 then return "UNKNOWN" end
local rows = data and data.gen2Landmarks and data.gen2Landmarks.landmarks
for _, rec in pairs(rows or {}) do
if type(rec) == "table" and rec.index == index then
return (tostring(rec.name or rec.id):gsub("\n", " "))
end
end
return ("#%d"):format(index)
end
function Gen.dexOwnedKey(save)
if Gen.of(save) == 2 then return "caught" end
return "owned"
end
function Gen.playerMap(save)
if type(save) ~= "table" then return "FR_PALLET_TOWN", 0, 0 end
local g = Gen.of(save)
if g == 3 then
local map = save.map or (save.position and save.position.map) or (save.player and save.player.map) or "FR_PALLET_TOWN"
local x = save.x or (save.position and save.position.x) or (save.player and save.player.x) or 0
local y = save.y or (save.position and save.position.y) or (save.player and save.player.y) or 0
local facing = save.facing or (save.position and save.position.facing) or "down"
return map, x, y, facing
elseif g == 2 then
local p = save.position
if p and p.map then return p.map, p.x or 0, p.y or 0, p.facing end
if type(save.spawn) == "table" then
return save.spawn.map or "PLAYERS_HOUSE_2F", save.spawn.x or 0, save.spawn.y or 0, save.spawn.facing
elseif type(save.spawn) == "string" then
return save.spawn, 0, 0
end
return "PLAYERS_HOUSE_2F", 3, 3
end
local p = save.player or {}
return p.map or "REDS_HOUSE_2F", p.x or 0, p.y or 0
end
function Gen.setPlayerHere(save, mapId, x, y, facing)
if type(save) ~= "table" then return end
local g = Gen.of(save)
if g == 3 then
save.map = mapId
save.x = x
save.y = y
save.facing = facing or save.facing or "down"
save.position = {
map = mapId,
x = x,
y = y,
facing = save.facing,
}
if save.player then
save.player.map = mapId
save.player.x = x
save.player.y = y
end
return
elseif g == 2 then
local prev = save.position or {}
save.position = {
map = mapId,
x = x,
y = y,
facing = facing or prev.facing or "down",
}
return
end
save.player = save.player or {}
save.player.map = mapId
save.player.x = x
save.player.y = y
end
local JOHTO = {
"ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING",
}
local KANTO = {
"BOULDER", "CASCADE", "THUNDER", "RAINBOW",
"SOUL", "MARSH", "VOLCANO", "EARTH",
}
function Gen.badgeIds(save, cat)
local g = Gen.of(save)
if g == 3 then
local ids = {}
for _, name in ipairs(KANTO) do ids[#ids + 1] = name .. "BADGE" end
return ids
elseif g == 2 then
local ids = {}
for _, name in ipairs(JOHTO) do ids[#ids + 1] = name end
for _, name in ipairs(KANTO) do ids[#ids + 1] = name end
return ids
end
local ids = {}
for _, id in ipairs((cat and cat.items) or {}) do
if tostring(id):find("BADGE", 1, true) then ids[#ids + 1] = id end
end
return ids
end
local KANTO_SET = {}
for _, name in ipairs(KANTO) do KANTO_SET[name] = true end
function Gen.hasBadge(save, id)
if type(save) ~= "table" then return false end
local g = Gen.of(save)
if g == 3 then
local okF, Flags = pcall(require, "src.core.game3.scripting.flags")
if okF and Flags then
return Flags.hasBadge(save, id)
end
return save.flags and (save.flags[id] or save.flags[id:gsub("BADGE$", "")]) == true
elseif g == 2 then
local p = save.player or {}
local store = KANTO_SET[id] and (p.kantoBadges or {}) or (p.badges or {})
if store[id] then return true end
local list = KANTO_SET[id] and KANTO or JOHTO
for index, name in ipairs(list) do
if name == id then return store[index] == true end
end
return false
end
return save.inventory and save.inventory[id] and true or false
end
function Gen.toggleBadge(save, id)
if type(save) ~= "table" then return false end
local g = Gen.of(save)
if g == 3 then
local okF, Flags = pcall(require, "src.core.game3.scripting.flags")
if okF and Flags then
local on = Flags.hasBadge(save, id)
Flags.setBadge(save, id, not on)
return not on
end
local on = Gen.hasBadge(save, id)
save.flags = save.flags or {}
save.flags[id] = (not on) and true or nil
return not on
elseif g == 2 then
save.player = save.player or {}
local storeName = KANTO_SET[id] and "kantoBadges" or "badges"
save.player[storeName] = save.player[storeName] or {}
local store = save.player[storeName]
local on = Gen.hasBadge(save, id)
store[id] = (not on) and true or nil
for index, name in ipairs(KANTO_SET[id] and KANTO or JOHTO) do
if name == id then store[index] = nil end
end
return not on
end
save.inventory = save.inventory or {}
local on = save.inventory[id] and true or false
save.inventory[id] = (not on) and 1 or nil
return not on
end
local function gen2FlagId(save, name)
return require("Gen2Flags").byName(Gen.engineOf(save))[name]
end
function Gen.getFlag(save, name)
if type(save) ~= "table" then return false end
local g = Gen.of(save)
if g == 3 then
local okF, Flags = pcall(require, "src.core.game3.scripting.flags")
if okF and Flags then
return Flags.getFlag(save, nil, name)
end
return save.flags and (save.flags[name] == true or save.flags[tostring(name)] == true)
elseif g == 2 then
local id = gen2FlagId(save, name)
if id then
local Events2 = require("src.world.gen2.Events")
local ev = Events2.new()
ev:restore(save.events)
return ev:get(id)
end
return save.flags and save.flags[name] == true
end
return save.flags and save.flags[name] == true
end
function Gen.setFlag(save, name, on)
if type(save) ~= "table" then return end
local g = Gen.of(save)
if g == 3 then
local okF, Flags = pcall(require, "src.core.game3.scripting.flags")
if okF and Flags then
Flags.setFlag(save, nil, name, on)
return
end
save.flags = save.flags or {}
save.flags[name] = on and true or nil
return
elseif g == 2 then
local id = gen2FlagId(save, name)
if id then
local Events2 = require("src.world.gen2.Events")
local ev = Events2.new()
ev:restore(save.events)
ev:set(id, on and true or false)
save.events = ev:serialize()
return
end
save.flags = save.flags or {}
save.flags[name] = on and true or nil
return
end
save.flags = save.flags or {}
save.flags[name] = on and true or nil
end
function Gen.getVar(save, nameOrId)
if type(save) ~= "table" then return 0 end
local g = Gen.of(save)
if g == 3 then
local okF, Flags = pcall(require, "src.core.game3.scripting.flags")
if okF and Flags then
return Flags.getVar(save, nil, nameOrId)
end
if save.vars then
return save.vars[nameOrId] or save.vars[tonumber(nameOrId)] or save.vars[tostring(nameOrId)] or 0
end
end
return 0
end
function Gen.setVar(save, nameOrId, val)
if type(save) ~= "table" then return end
val = math.max(0, math.min(65535, math.floor(tonumber(val) or 0)))
local g = Gen.of(save)
if g == 3 then
local okF, Flags = pcall(require, "src.core.game3.scripting.flags")
if okF and Flags then
Flags.setVar(save, nil, nameOrId, val)
return
end
save.vars = save.vars or {}
save.vars[nameOrId] = val
if tonumber(nameOrId) then
save.vars[tonumber(nameOrId)] = val
end
end
end
function Gen.flagCount(save)
if type(save) ~= "table" then return 0 end
local g = Gen.of(save)
if g == 2 then
local n = 0
for _ in pairs(save.events or {}) do n = n + 1 end
for _ in pairs(save.flags or {}) do n = n + 1 end
return n
end
local n = 0
for _ in pairs(save.flags or {}) do n = n + 1 end
return n
end
function Gen.exp(mon)
return mon.exp or mon.experience or 0
end
return Gen