Add Gold save-editor support, rearrange the launcher, and ship a patch-notes modal.

The save editor now routes Gold vs RBY through a generation adapter so boxes, items, events, and maps write the right fields. The launcher layout is shuffled a bit, and patch notes can come from the updater, a packed file, or the iOS sidecar.
This commit is contained in:
1jamie
2026-08-14 18:43:38 -05:00
parent fb738fa1ce
commit df0be1cba6
36 changed files with 3429 additions and 1632 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
-- Mods "Show for" chips collapse to a dropdown when they cannot all fit.
-- Landscape / desktop keep the individual game chips. No pokered cite: the
-- launcher is port-only chrome.
-- luajit tests/engine/launcher_mod_scope_dropdown.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = love or require("tests.love_stub")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
local Kit = require("src.ui.kit.Kit")
local RomImporter = require("src.import.RomImporter")
local LauncherView = require("src.import.LauncherView")
local function window(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
local function navIds()
local ids = {}
for i = 1, (Kit._navPrevN or 0) do
local slot = Kit._nav[i]
if slot and slot.id then ids[slot.id] = true end
end
return ids
end
local function drawMods(W, H)
window(W, H)
local imp = RomImporter.new(function() end, { launcher = true })
imp.tab = "mods"
imp.ready = { red = true, blue = true, yellow = true, gold = true }
local ok, err = pcall(LauncherView.draw, imp)
check(ok, ("%dx%d mods draws: %s"):format(W, H, tostring(err)))
return navIds()
end
local portrait = drawMods(360, 780)
check(portrait["mod-scope-menu"] == true,
"portrait: Show-for collapses to a dropdown when the chips cannot all fit")
check(portrait["mod-scope-gold"] == nil
and portrait["mod-scope-red"] == nil
and portrait["mod-scope-blue"] == nil
and portrait["mod-scope-yellow"] == nil,
"portrait: individual version chips are not drawn beside the dropdown")
local desktop = drawMods(1280, 720)
check(desktop["mod-scope-menu"] == nil,
"desktop: chips fit, so there is no dropdown")
check(desktop["mod-scope-all"] == true
and desktop["mod-scope-red"] == true
and desktop["mod-scope-blue"] == true
and desktop["mod-scope-yellow"] == true
and desktop["mod-scope-gold"] == true,
"desktop: every Show-for chip stays reachable")
print("ok launcher mod scope dropdown")
@@ -23,7 +23,7 @@ local function importer(field)
end
local modalFields = {
"_modConfirm", "_modVersions", "_modReleaseNotes", "_findDetails",
"_modConfirm", "_modVersions", "_modReleaseNotes", "_appPatchNotes", "_findDetails",
}
for _, field in ipairs(modalFields) do
@@ -77,6 +77,12 @@ do
imp:keypressed("escape")
eq(imp._modReleaseNotes, nil, "Escape closes release notes")
end
do
resetFocus()
local imp = importer("_appPatchNotes")
imp:keypressed("escape")
eq(imp._appPatchNotes, nil, "Escape closes patch notes")
end
do
resetFocus()
local imp = importer("_modVersions")
+89
View File
@@ -0,0 +1,89 @@
-- Launcher footer Patch notes control. The GitHub release body is already
-- fetched by the updater; this is the in-app viewer for it.
-- luajit tests/engine/launcher_patch_notes.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
local Kit = require("src.ui.kit.Kit")
local RomImporter = require("src.import.RomImporter")
local LauncherView = require("src.import.LauncherView")
local PatchNotes = require("src.update.PatchNotes")
local function window(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
local function freshLauncher()
return RomImporter.new(function() end, { launcher = true })
end
local realPrint = love.graphics.print
local function drawAndCapture(imp)
local seen = {}
love.graphics.print = function(str, ...)
seen[#seen + 1] = tostring(str)
return realPrint(str, ...)
end
local ok, err = pcall(LauncherView.draw, imp)
love.graphics.print = realPrint
check(ok, "the frame draws: " .. tostring(err))
return table.concat(seen, "\n")
end
window(1280, 720)
local imp = freshLauncher()
local text = drawAndCapture(imp)
check(text:find("Patch notes", 1, true) ~= nil,
"desktop footer prints Patch notes")
window(360, 780)
local phone = freshLauncher()
check(drawAndCapture(phone):find("Patch notes", 1, true) ~= nil,
"portrait footer still prints Patch notes")
do
local body, ver = PatchNotes.body({
state = function()
return { notes = "## Issues closed\n\n- #12 cart padding", latest = "1.4.2" }
end,
})
eq(body, "## Issues closed\n\n- #12 cart padding",
"PatchNotes prefers the updater's GitHub body")
eq(ver, "1.4.2", "PatchNotes carries the release version")
end
do
local body, ver = PatchNotes.body(nil)
check(type(body) == "string" and body:find("Issues closed", 1, true),
"without a check result PatchNotes uses the stashed iOS app-repo notes")
check(type(ver) == "string" and ver:find("^%d+%.%d+%.%d+$") ~= nil,
"stashed notes name a release version")
end
do
local f = assert(io.open("mobile/ios/app-repo.json", "rb"))
local list = PatchNotes.parseRepo(f:read("*a"))
f:close()
check(#list >= 2, "app-repo.json stashes more than one release")
local notes, ver = PatchNotes.fromRepo(list[2].version)
eq(ver, list[2].version, "fromRepo can pick a specific stashed version")
eq(notes, list[2].notes, "fromRepo returns that version's notes")
end
imp._appPatchNotes = true
local modal = drawAndCapture(imp)
check(modal:find("Patch notes", 1, true) ~= nil, "the modal titles itself")
check(modal:find("Close", 1, true) ~= nil, "the modal can be closed")
imp:keypressed("escape")
eq(imp._appPatchNotes, nil, "Escape dismisses patch notes")
T.finish("launcher patch notes")
+9
View File
@@ -32,6 +32,15 @@ eq(rel.payloadName, "gen1recomp-1.4.2.love", "payload name derived from version"
eq(rel.payload.url, "http://x/love", "payload asset url picked")
eq(rel.payload.size, 12345, "payload asset size picked")
eq(rel.sums.url, "http://x/sums", "sums asset url picked")
eq(rel.notes, "", "missing release body becomes empty notes")
local withNotes = Check.parseRelease(Json.encode({
tag_name = "v1.4.2",
body = "## Issues closed\n\n- #1 cart padding",
assets = {},
}))
eq(withNotes.notes, "## Issues closed\n\n- #1 cart padding",
"parseRelease keeps the GitHub release body")
-- a newer release that ships no .love yet: parses, but the payload/sums are nil
-- so the worker will route to needs_full rather than an in-place update
+1
View File
@@ -287,6 +287,7 @@ eq(rel.payload.url, "http://x/love", "parseRelease picks the payload asset url")
eq(rel.payload.size, 12345, "parseRelease picks the payload asset size")
eq(rel.sums.url, "http://x/sums", "parseRelease picks the sums asset url")
eq(rel.sums.size, 99, "parseRelease picks the sums asset size")
eq(rel.notes, "", "parseRelease treats a missing body as empty notes")
-- a release with no .love yet still parses; payload/sums are nil so the worker
-- routes to a full reinstall rather than an in-place update
+1
View File
@@ -9,6 +9,7 @@
-- tests/save_editor_task7_tests.lua
-- tests/save_editor_task8_tests.lua
-- tests/save_editor_mod_tests.lua
-- tests/save_editor_gen2_tests.lua
-- See tools/save-editor/README.md for the full list.
--
-- All of them drive tools/save-editor/Ops.lua rather than clicking pixel
+2
View File
@@ -3475,6 +3475,8 @@ do
local lua = (arg and arg[-1]) or "luajit"
local status = os.execute(("%q tests/save_editor_mod_tests.lua"):format(lua))
check(status == 0 or status == true, "save_editor_mod_tests suite")
status = os.execute(("%q tests/save_editor_gen2_tests.lua"):format(lua))
check(status == 0 or status == true, "save_editor_gen2_tests suite")
end
-- ---------------------------------------------- input hold regressions
+352
View File
@@ -0,0 +1,352 @@
-- Headless Gold save-editor rules. Run from repo root:
-- luajit tests/save_editor_gen2_tests.lua
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
.. ";./tools/save-editor/panels/?.lua"
local love_stub = require("tests.love_stub")
love = love_stub
local passed, failed = 0, 0
local function check(cond, msg)
if cond then
passed = passed + 1
else
failed = failed + 1
print("FAIL: " .. msg)
end
end
local function eq(a, b, msg)
check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b)))
end
print("== save editor gen2 tests ==")
local Gen = require("Gen")
local Catalog = require("Catalog")
local MonOps = require("MonOps")
local Ops = require("Ops")
local State = require("State")
local Save2 = require("src.core.gen2.Save")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local data = {
pokemon = {
CYNDAQUIL = {
id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155,
types = { "FIRE" },
baseStats = {
hp = 39, attack = 52, defense = 43, speed = 65,
specialAttack = 60, specialDefense = 50,
},
catchRate = 45, baseExp = 65,
growthRate = "MEDIUM_FAST",
levelMoves = { { level = 1, move = "TACKLE" } },
genderRatio = 31,
},
TOTODILE = {
id = "TOTODILE", name = "TOTODILE", dex = 158,
types = { "WATER" },
baseStats = {
hp = 50, attack = 65, defense = 64, speed = 43,
specialAttack = 44, specialDefense = 48,
},
catchRate = 45, baseExp = 66,
growthRate = "MEDIUM_FAST",
levelMoves = { { level = 1, move = "SCRATCH" } },
genderRatio = 31,
},
},
moves = {
TACKLE = { pp = 35 },
SCRATCH = { pp = 35 },
},
items = {
POTION = { pocket = "ITEM" },
MASTER_BALL = { pocket = "BALL" },
FLOWER_MAIL = { pocket = "ITEM" },
},
maps = {},
}
local function newState()
local S = State.new()
S.data = data
S.cat = Catalog.build(data)
S.save = Save2.newGame()
S.version = "gold"
Gen.ensureBoxes(S.save)
return S
end
do
GameVersion.set("gold")
eq(Gen.of({ generation = 2 }), 2, "Gen.of generation field")
eq(Gen.of({ version = "gold" }), 2, "Gen.of version gold")
eq(Gen.of(SaveData.newGame()), 1, "Gen.of gen1 newGame")
eq(Gen.of(Save2.newGame()), 2, "Gen.of gold newGame")
end
do
local S = newState()
check(Ops.speciesUsable(S, "CYNDAQUIL"), "spa/spd species is usable")
local S1 = State.new()
S1.data = {
pokemon = {
PIDGEY = { baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, special = 35 } },
BROKEN = { baseStats = { hp = 1 } },
},
}
check(Ops.speciesUsable(S1, "PIDGEY"), "gen1 special species is usable")
check(not Ops.speciesUsable(S1, "BROKEN"), "partial record is not usable")
end
do
local S = newState()
Ops.partyAdd(S)
eq(#S.save.party, 1, "partyAdd on gold")
local mon = S.save.party[1]
eq(mon.species, "CYNDAQUIL", "first catalog species")
check(mon.experience ~= nil, "gold mon has experience")
check(mon.exp == nil or mon.experience ~= nil, "does not rely on gen1 exp")
check(mon.stats.specialAttack and mon.stats.specialDefense,
"gold stats have spa/spd")
check(mon.happiness ~= nil, "gold mon has happiness")
eq(mon.ot, S.save.player.name, "stampOT copies player name")
Ops.setLevel(S, mon, 20)
eq(mon.level, 20, "setLevel 20")
check(mon.experience > 0, "experience resynced")
Ops.setHappiness(S, mon, 200)
eq(mon.happiness, 200, "happiness 200")
Ops.setPokerus(S, mon, 15)
eq(mon.pokerus, 15, "pokerus byte")
Ops.setHeldItem(S, mon, "POTION")
eq(mon.item, "POTION", "held item")
eq(mon.name, "CYNDAQUIL", "new mon copies species display name")
Ops.setSpecies(S, mon, "TOTODILE")
eq(mon.species, "TOTODILE", "setSpecies id")
eq(mon.name, "TOTODILE", "setSpecies rewrites the Gold display name")
check(mon.nickname == nil, "setSpecies does not invent a nickname")
eq(mon.types[1], "WATER", "setSpecies rewrites copied types")
end
do
local S = newState()
Ops.partyAdd(S)
Ops.partyAdd(S)
local Mail = require("src.core.gen2.Mail")
Mail.set(S.save, 1, Mail.entry("FLOWER_MAIL", "hi", "GOLD", 1, "CYNDAQUIL"))
Mail.set(S.save, 2, Mail.entry("SURF_MAIL", "bye", "GOLD", 1, "CYNDAQUIL"))
S.selectedParty = 1
Ops.partyMove(S, 1)
eq(Mail.state(S.save).party[1].message, "bye", "partyMove carries mail with the mon")
eq(Mail.state(S.save).party[2].message, "hi", "partyMove swaps the other letter")
S.selectedParty = 1
check(Ops.partyRemove(S) == false, "partyRemove arms")
check(Ops.partyRemove(S) == true, "partyRemove commits")
eq(Mail.state(S.save).party[1].message, "hi", "partyRemove shifts leftover mail up")
check(Mail.state(S.save).party[2] == nil, "partyRemove clears the vacated slot")
end
do
local S = newState()
Ops.partyAdd(S)
local mon = S.save.party[1]
local Mail = require("src.core.gen2.Mail")
Ops.setHeldItem(S, mon, "FLOWER_MAIL")
eq(mon.item, "FLOWER_MAIL", "held mail item")
local letter = Mail.state(S.save).party[1]
check(letter ~= nil, "giving mail writes sPartyMail")
eq(letter.species, "CYNDAQUIL", "new letter stamps current species")
Ops.setSpecies(S, mon, "TOTODILE")
eq(Mail.state(S.save).party[1].species, "TOTODILE",
"setSpecies updates the letter's species copy")
Ops.setHeldItem(S, mon, "POTION")
check(Mail.state(S.save).party[1] == nil, "non-mail held item drops the letter")
end
do
local Mon = require("src.battle.gen2.Mon")
local S = newState()
local stale = Mon.new(data, "CYNDAQUIL", 5)
stale.species = "TOTODILE"
stale.name = "CYNDAQUIL"
stale.types = { "FIRE" }
S.save.dayCare = { man = { mon = stale }, lady = {} }
Mon.syncSaveIdentity(S.save, data)
eq(stale.name, "TOTODILE", "syncSaveIdentity rewrites Day-Care display name")
eq(stale.types[1], "WATER", "syncSaveIdentity rewrites Day-Care types")
eq(Mon.displayName({ nickname = nil, name = "ABRA", species = "RAYQUAZA" }),
"ABRA", "displayName prefers the species copy over the id")
eq(Mon.displayName({ nickname = "BOB", name = "ABRA", species = "RAYQUAZA" }),
"BOB", "displayName prefers nickname")
end
do
local S = newState()
eq(Ops.boxCount(S), 14, "14 gold boxes")
eq(Ops.boxCapacity(S), 20, "20 per box")
Ops.boxAdd(S)
eq(#Ops.boxes(S)[1], 1, "boxAdd into box 1")
end
do
local S = newState()
Ops.partyAdd(S)
S.save.party[1].hp = 0
Ops.partyAdd(S)
S.selectedParty = 1
S.selectedBox = 1
local ok = Ops.deposit(S)
check(ok, "deposit fainted mon while a healthy remains")
end
do
local S = newState()
Ops.partyAdd(S)
Ops.partyAdd(S)
S.selectedParty = 1
S.selectedBox = 1
local ok = Ops.deposit(S)
check(ok, "deposit one of two healthy mons")
end
do
local S = newState()
Ops.partyAdd(S)
S.selectedParty = 1
S.selectedBox = 1
local ok = Ops.deposit(S)
check(not ok, "refuse depositing last healthy mon")
check(S.status:lower():find("last", 1, true) or S.status:find("POKéMON")
or S.status:find("POKEMON") or S.status:find("last"),
"deposit refusal names the last-healthy rule: " .. tostring(S.status))
eq(#S.save.party, 1, "party still has the mon")
end
do
local S = newState()
eq(Gen.money(S.save), 3000, "gold start money on player")
Ops.addMoney(S, 1000)
eq(S.save.player.money, 4000, "money writes player.money")
check(S.save.money == nil or S.save.money ~= 4000, "does not write save.money")
Ops.maxMoney(S)
eq(S.save.player.money, 999999, "money cap")
Ops.addCoins(S, 250)
eq(S.save.player.coins, 250, "coins write player.coins")
end
do
local S = newState()
check(not Gen.hasBadge(S.save, "ZEPHYR"), "no zephyr yet")
Ops.toggleBadge(S, "ZEPHYR")
check(Gen.hasBadge(S.save, "ZEPHYR"), "zephyr earned")
check(S.save.player.badges.ZEPHYR, "stored on player.badges")
Ops.toggleBadge(S, "BOULDER")
check(S.save.player.kantoBadges.BOULDER, "kanto badge store")
end
do
local S = newState()
Ops.dexOwned(S, "CYNDAQUIL", true)
check(S.save.pokedex.caught.CYNDAQUIL, "dex writes caught")
check(S.save.pokedex.owned == nil or S.save.pokedex.owned.CYNDAQUIL == nil,
"does not write owned on gold")
check(S.save.pokedex.seen.CYNDAQUIL, "owned implies seen")
local _, owned = Ops.dexCounts(S)
eq(owned, 1, "dexCounts reads caught")
end
do
local S = newState()
local name = "EVENT_BEAT_FALKNER"
Ops.setFlag(S, name, true)
check(Gen.getFlag(S.save, name), "gold EVENT_ sets bitfield")
check(S.save.flags[name] == nil, "numeric flags are not string keys")
Ops.setFlag(S, "MOD_EDITMON_GIFT", true)
check(S.save.flags.MOD_EDITMON_GIFT, "mod flags stay named on gold")
end
do
local S = newState()
S.mapId = "NEW_BARK_TOWN"
S.mapClickCell = { cx = 4, cy = 5 }
Ops.setPlayerHere(S)
eq(S.save.position.map, "NEW_BARK_TOWN", "position.map")
eq(S.save.position.x, 4, "position.x")
eq(S.save.position.y, 5, "position.y")
check(S.save.player.map == nil or S.save.player.map ~= "NEW_BARK_TOWN",
"does not write player.map on gold")
end
do
local encoded = SaveData.encode(Save2.newGame())
local back = SaveData.decode(encoded)
eq(back.generation, 2, "round-trip keeps generation 2")
end
do
local names = Catalog.goldEventList()
local hasFalkner = false
for _, n in ipairs(names) do
if n == "EVENT_BEAT_FALKNER" then hasFalkner = true break end
end
check(hasFalkner, "gold event list includes EVENT_BEAT_FALKNER")
end
do
local maps = Gen.maps({ gen2Maps = { AZALEA_GYM = true }, maps = { PALLET_TOWN = true } })
check(maps.AZALEA_GYM, "Gen.maps includes gen2Maps")
check(maps.PALLET_TOWN, "Gen.maps keeps Data:load maps beside gen2Maps")
check(Gen.maps({ maps = { PALLET_TOWN = true } }).PALLET_TOWN,
"Gen.maps falls back to maps")
local mansion = Gen.maps({
maps = {
CELADON_MANSION_2F = { id = "CELADON_MANSION_2F", width = 4, height = 5 },
},
gen2Maps = {
CELADON_MANSION_2F = { objects = { { name = "NPC" } } },
BERRY_FARM = { id = "BERRY_FARM", width = 19, height = 12 },
},
})
eq(mansion.CELADON_MANSION_2F.width, 4,
"Gen.maps keeps extractor width under a gen2Maps objects patch")
eq(mansion.CELADON_MANSION_2F.objects[1].name, "NPC",
"Gen.maps still applies the gen2Maps patch fields")
eq(mansion.BERRY_FARM.width, 19, "Gen.maps keeps mod maps only on gen2Maps")
local bound = Gen.bindGoldData({ maps = { A = true }, tilesets = { T = true } })
check(bound.gen2Maps == bound.maps, "bindGoldData aliases gen2Maps")
check(bound.gen2Tilesets == bound.tilesets, "bindGoldData aliases gen2Tilesets")
check(Gen.tilesets({ gen2Tilesets = { TILESET_GYM = true } }).TILESET_GYM,
"Gen.tilesets prefers gen2Tilesets")
end
do
local Map2 = require("src.world.gen2.Map")
local MapPreview = require("src.world.gen2.MapPreview")
local def = {
id = "AZALEA_GYM", tileset = "TILESET_GYM",
width = 1, height = 1, blocks = { 1 }, borderBlock = 1,
warps = {}, environment = "INDOOR",
}
local tileset = {
id = "TILESET_GYM",
image = "assets/generated/tilesets/gym.png",
tilesPerRow = 16,
blocks = { { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } },
}
local map = Map2.new(def, tileset)
check(map.renderer == nil, "Map2 does not ship a renderer")
local baker = MapPreview.baker({ tilesets = { TILESET_GYM = tileset } })
local renderer = MapPreview.renderer(baker, map)
check(renderer ~= nil and renderer.draw ~= nil,
"MapPreview attaches a draw for a Gold map")
end
print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end
+36 -1
View File
@@ -142,6 +142,41 @@ local ok, err = pcall(function()
local report = SaveData.validate(probe, Data)
check(#report.lostMons == 0 and probe.party[1].species == "EDITMON",
"validate keeps the modded mon while the mod is enabled")
-- Gold-targeted mods: spa/spd records are usable, and a Gen 1-only
-- manifest stays out of Gold (no ROM cache / App.load("gold") required).
local ModTargets = require("src.mods.ModTargets")
local Ops = require("Ops")
check(not ModTargets.supports({}, "gold"),
"legacy gen1-only fixture does not support gold")
check(not ModTargets.supports({ games = { "red" } }, "gold"),
"explicit gen1 games list does not support gold")
check(ModTargets.supports({ games = { "gold" } }, "gold"),
"gold-targeted manifest supports gold")
check(ModTargets.supports({ gen2compat = true }, "gold"),
"gen2compat legacy still supports gold")
local goldS = {
data = {
pokemon = {
EDITMON = {
baseStats = {
hp = 50, attack = 50, defense = 50, speed = 50,
specialAttack = 50, specialDefense = 50,
},
},
G1ONLY = {
baseStats = {
hp = 50, attack = 50, defense = 50, speed = 50, special = 50,
},
},
},
},
}
check(Ops.speciesUsable(goldS, "EDITMON"),
"spa/spd EDITMON is usable on gold")
check(Ops.speciesUsable(goldS, "G1ONLY"),
"gen1 special record remains usable (dual-key gate)")
end)
os.remove(MOD_ROOT .. "/main.lua")
@@ -154,7 +189,7 @@ os.remove(tmpPath)
love.filesystem = savedFS
-- leave shared singletons the way we found them (the fixture merged one
-- record into Data.pokemon)
Data.pokemon.EDITMON = nil
if Data.pokemon then Data.pokemon.EDITMON = nil end
Assets.loader = savedBridge
Assets.invalidate()
Runtime.install(savedEvents, savedHooks, savedErrors)