From 49a5408c2d1fffe8f7abadb416ef553fa6f1b395 Mon Sep 17 00:00:00 2001 From: "DESKTOP-8SRFDDM\\cam95" Date: Wed, 26 Aug 2026 10:53:09 -0500 Subject: [PATCH 1/6] engine: pokemon.level_visible, a level a mode can take off the screen (RFC 0019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Pokémon's level is printed on four Gen 1 surfaces -- both battle healthboxes, the party rows, and both status pages -- and every one of them prints it unconditionally. There is no seam, so a mode that wants the number gone has two options today and both are bad: paint over the engine's own pixels from render.hud (four rectangles, a background shade to match, and the palette flashes and healthbox slide to survive), or monkey-patch the render modules from inside the sandbox, which works and is exactly what CONTRIBUTING-mods.md tells mods not to do. The motivating case is a battle royale that scales every party to a shared rung rising with its fog: the number is the same for everyone, it changes on a clock, and it reads as a threat it is not -- a Lv37 opponent looks dangerous to a player who has not worked out that their own team is Lv37 too. A randomizer keeping an encounter unreadable, a challenge run that forbids level-checking and a blind Nuzlocke want the same switch. New hook `pokemon.level_visible`, taking the shape the presentation predicates on the battle screen already use -- battle.status_hud_visible, battle.bottom_ui_visible, battle.caught_marker_visible: consulted behind Runtime.wantsHook, default visible, only an explicit false suppresses. It is not named battle.* because a level is not a battle-only readout, and it carries the surface that asked (battle.enemy / battle.player / party / summary) so a mode can hide an opponent's level and keep its own. src/ui/LevelDisplay.lua holds the one definition of "visible", so the four call sites are a one-line guard each rather than four copies of the same five lines that can drift apart. No layout moves. Each site keeps its own hand-rolled PrintLevel rule (home/pokemon.asm:335-345), it just asks first. Two details are deliberate: a status condition still replaces the level on a healthbox exactly as in the cart, so hiding a level never hides PSN or BRN (the guard is an elseif on the existing status branch); and on status page 2 the arrow is hidden with the level it points at, because an arrow with nothing after it is half a sentence. Gen 1 only. The Gen 2 screens and the Gen 1 PC box list -- where the level is part of a row label rather than a drawn field -- keep their own readouts and do not consult the hook. Both are stated as follow-ups in the RFC and beside the hook in docs/modding.md, so a mod author reads the limit before depending on it. Verification: tests/modkit/cases/pokemon_level_visible.lua covers the contract through the public mod API; gate_hooks picks the hook up on its own because it walks the live catalog; gate_meta_coverage is satisfied by the change that introduces the seam, so it never enters the DEBT ledger. tests/run_modkit.lua 33/33, tests/run_engine.lua 327/331 -- the same four audio/hostshell suites fail unchanged on dev without this branch. Co-Authored-By: Claude --- docs/modding.md | 31 ++++ docs/rfcs/0019-pokemon-level-visible.md | 157 +++++++++++++++++++ src/battle/BattleState.lua | 5 +- src/ui/LevelDisplay.lua | 33 ++++ src/ui/PartyMenu.lua | 7 +- src/ui/SummaryMenu.lua | 13 +- tests/modkit/cases/pokemon_level_visible.lua | 78 +++++++++ 7 files changed, 318 insertions(+), 6 deletions(-) create mode 100644 docs/rfcs/0019-pokemon-level-visible.md create mode 100644 src/ui/LevelDisplay.lua create mode 100644 tests/modkit/cases/pokemon_level_visible.lua diff --git a/docs/modding.md b/docs/modding.md index 7c464abe..700ba7fd 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -1111,6 +1111,37 @@ own state, preserving selective control outside a battle; a wrapper that only owns battle presentation should return `false` only for its active battle or text-box state. +`pokemon.level_visible` (RFC 0019) takes a Pokémon's level off the screens +that print it, without moving anything else on them: + +```lua +mod.hooks:wrap("pokemon.level_visible", function(next, mon, ctx) + -- ctx = { where = "battle.enemy" | "battle.player" | "party" | "summary", + -- game = } + if myMode.active and ctx.where ~= "party" then return false end + return next(mon, ctx) +end) +``` + +It receives `(next, mon, ctx)` and defaults to `true`, so vanilla rendering +is unchanged. Only an explicit `false` suppresses; `nil` and anything else +print, so a wrapper that forgets a branch cannot blank a screen by accident. +The `` glyph goes with the digits, and on status page 2 so does the +`` arrow that points at the next level, because an arrow with nothing +after it is half a sentence. A status condition still replaces the level on +a battle healthbox exactly as it does in the cart, so hiding a level never +hides `PSN` or `BRN`. + +`ctx.where` names the surface rather than the widget, because the number +means different things on different screens: on a battle HUD an opponent's +level is information about them, on the party and status screens your own +level is information about you. A mode can hide one and keep the other. + +**Gen 1 only for now.** The Gen 2 screens keep their own level readouts and +do not consult this hook, and neither does the Gen 1 PC box list, where the +level is part of a row label rather than a drawn field. Both are noted in +RFC 0019 as follow-ups. + `core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call (once per frame). Vanilla behavior resolves the per-category GAME SPEED option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever diff --git a/docs/rfcs/0019-pokemon-level-visible.md b/docs/rfcs/0019-pokemon-level-visible.md new file mode 100644 index 00000000..bcca6e0d --- /dev/null +++ b/docs/rfcs/0019-pokemon-level-visible.md @@ -0,0 +1,157 @@ +# RFC 0019: `pokemon.level_visible` — a level a mode can take off the screen + +## Status + +Proposed. + +## Motivation + +A Pokémon's level is printed on four Gen 1 surfaces — both battle +healthboxes, the party rows, and page 1 and page 2 of the status screen — +and every one of them prints it unconditionally. There is no seam. A mode +that wants the number gone has exactly two options today, and both are bad. + +It can paint over the text from `render.hud`, which means knowing four pixel +rectangles, matching the background shade, and surviving the palette flashes +and the healthbox slide-in. Or it can monkey-patch the drawing modules from +inside its sandbox, which works — `require` hands a mod the engine's own +table — and is precisely what `CONTRIBUTING-mods.md` tells mods not to do. +A mod that took the second road already deleted its patching layer once, for +the reasons that document gives. + +The motivating case is a battle royale where every party is scaled to a +shared rung that rises with the fog. The number on the healthbox is +therefore never news — it is the same for everyone, it changes on a clock, +and a player reading `:L37` on an opponent learns nothing except that they +are playing the same match. Worse, it reads as a threat it is not: a Lv37 +opponent looks dangerous to a player who has not worked out that their own +team is Lv37 too. The mode wants the level off the HUD and out of the party +list, and the announcement it replaces it with is one line about everyone +getting stronger at once. + +Nothing about that is specific to a battle royale. A randomizer that hides +levels to keep an encounter unreadable, a challenge run that forbids +level-checking, a hard mode that withholds an opponent's level, and a +"blind" Nuzlocke all want the same switch, and none of them should have to +learn where the `` glyph lives. + +## The decision it extends + +This extends the **additive, guarded seam convention** Route B in +`CONTRIBUTING-mods.md` documents, and is gated by the parity guarantee +`tests/engine/gate_meta_coverage.lua` enforces. + +It sits with the presentation predicates already on the battle screen — +`battle.status_hud_visible`, `battle.bottom_ui_visible` and +`battle.caught_marker_visible` — and takes the same shape: a hook consulted +behind `Runtime.wantsHook`, defaulting to visible, where only an explicit +`false` suppresses. The difference is that a level is not a battle-only +readout, so this one is not named `battle.*` and carries the surface that +asked. + +There is no in-repo D-number registry to amend. + +## Exact API delta + +### New hook: `pokemon.level_visible` + +```lua +mod.hooks:wrap("pokemon.level_visible", function(next, mon, ctx) + -- ctx = { where = "battle.enemy" | "battle.player" | "party" | "summary", + -- game = } + if myMode.active and ctx.where ~= "party" then + return false -- the level is not printed on that surface + end + return next(mon, ctx) -- true: printed, as today +end) +``` + +Default `true`. Only an explicit `false` suppresses; `nil` and every other +value print, so a wrapper that forgets a branch cannot blank a screen by +accident. + +`ctx.where` names the surface rather than the widget, because the number +means different things on different screens: on a battle HUD an opponent's +level is information about *them*, on the party and status screens your own +level is information about *you*. A mode that wants to hide the first and +keep the second can, and the motivating mode does exactly that. + +### New module: `src/ui/LevelDisplay.lua` + +One function, `LevelDisplay.visible(mon, where, game)`, wrapping the +`wantsHook`/`call` pair. It exists so the four call sites are a one-line +guard each instead of four copies of the same five lines, and so the hook +has one definition of "visible" rather than four that can drift. + +### Call sites + +| Surface | File | `where` | +| --- | --- | --- | +| Enemy healthbox | `src/battle/BattleState.lua` | `battle.enemy` | +| Player healthbox | `src/battle/BattleState.lua` | `battle.player` | +| Party rows | `src/ui/PartyMenu.lua` | `party` | +| Status page 1 and 2 | `src/ui/SummaryMenu.lua` | `summary` | + +No layout moves. Each site keeps its own hand-rolled PrintLevel rule +(`home/pokemon.asm:335-345` — the `` tile, then the digits, with a level +of 100 writing its third digit back over the tile); it just asks first. + +Two details are deliberate: + +- **A status condition still replaces the level on a healthbox**, exactly as + it does in the cart, so hiding the level never hides `PSN` or `BRN`. The + guard is an `elseif` on the existing status branch, not a wrapper around + it. +- **On status page 2 the `` arrow is hidden with the level it points + at.** The arrow introduces the next level; on its own it is half a + sentence. The EXP-to-next-level figure beside it is a different field and + still prints. + +### No other surface changes + +No event, no registry, no save field, no manifest key. + +## Migration + +None. The hook is additive and defaults to current behaviour. + +## Verification + +- `tests/modkit/cases/pokemon_level_visible.lua` — the contract through the + public mod API: default true with no mod, `false` suppresses, the surface + and the mon reach the hook, falling through prints, and a `nil` mon + answers rather than throwing. +- `tests/engine/gate_hooks.lua` — the structural parity gate picks the hook + up automatically, because it walks the live catalog rather than a list. +- `tests/engine/gate_meta_coverage.lua` — the coverage ratchet; the seam is + covered by name from the change that introduces it, so it never enters the + DEBT ledger. + +## Backward compatibility + +A build with no mod wrapping the hook never reaches `Runtime.call`: +`wantsHook` is checked first, which matters because two of the four sites +are on a per-frame draw path. Pixels are unchanged, and the parity gates +assert it. + +## Scope, and what is deliberately not in this change + +**Gen 1 only.** The Gen 2 screens keep their own level readouts +(`src/ui/gen2/PartyMenu.lua`, `SummaryMenu.lua`, `BoxMenu.lua`, +`HallOfFame.lua`) and do not consult the hook. So does the Gen 1 PC box list +(`src/ui/BoxMenu.lua`), where the level is baked into a row label string +rather than drawn as a field, and the box-to-PNG print path beside it. + +Those are mechanical follow-ups, held back so this change stays reviewable +against screens that can actually be exercised here. The limitation is +stated in `docs/modding.md` beside the hook, so a mod author reads it before +depending on it rather than after. + +## Compatibility seam for older engines + +There is none, and none is possible without patching: the call sites are +mid-draw, so a mod on a stock engine cannot reach them. That is the argument +for the seam rather than a gap around it — the alternatives available to a +mod today are painting over the engine's own pixels or reaching into its +render modules, and the second is the thing the mod contract exists to +prevent. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 2889573c..2abb4ced 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -16,6 +16,7 @@ local Damage = require("src.battle.Damage") local EffectRegistry = require("src.battle.EffectRegistry") local Experience = require("src.battle.Experience") local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local Logger = require("src.core.Logger") local MoveEffects = require("src.battle.MoveEffects") local Party = require("src.pokemon.Party") @@ -6298,7 +6299,7 @@ function BattleState:drawHUDs(slide) end if self.enemy.shownStatus then Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8) - else + elseif LevelDisplay.visible(self.enemy.mon, "battle.enemy", self.game) then hudTile(0x6E, 32, 8) -- Font.draw(tostring(self.enemy.mon.level), 40, 8) end @@ -6382,7 +6383,7 @@ function BattleState:drawHUDs(slide) Font.draw(self.player.name, nameX(10, self.player.name), 56) if self.player.shownStatus then Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64) - else + elseif LevelDisplay.visible(self.player.mon, "battle.player", self.game) then hudTile(0x6E, 112, 64) -- Font.draw(tostring(self.player.mon.level), 120, 64) end diff --git a/src/ui/LevelDisplay.lua b/src/ui/LevelDisplay.lua new file mode 100644 index 00000000..a8a553dd --- /dev/null +++ b/src/ui/LevelDisplay.lua @@ -0,0 +1,33 @@ +-- Whether a Pokémon's level is printed on a screen that would normally +-- print it (RFC 0019). +-- +-- Every Gen 1 screen that shows a level does it with the same pokered rule +-- -- home/pokemon.asm:335-345 PrintLevel: the tile, then the digits +-- left-aligned after it, with a level of 100 writing its third digit back +-- over the tile -- and each screen hand-rolls that rule against its own +-- coordinates. This module does not touch any of that. It answers one +-- question, in one place, so that a mode which wants the number off does +-- not have to know four call sites and a glyph code. +-- +-- Default is true, and `Runtime.wantsHook` is checked first, so a build +-- with no mod wrapping the hook prints exactly what it always did and pays +-- nothing for the seam on a per-frame draw path. +-- +-- `where` names the surface rather than the widget, because the number +-- means different things on different screens: on the battle HUD an +-- opponent's level is information about them, on the party and status +-- screens your own level is information about you. A mode can hide one and +-- keep the other. + +local Runtime = require("src.mods.Runtime") + +local LevelDisplay = {} + +-- where: "battle.enemy" | "battle.player" | "party" | "summary" +function LevelDisplay.visible(mon, where, game) + if not Runtime.wantsHook("pokemon.level_visible") then return true end + return Runtime.call("pokemon.level_visible", function() return true end, + mon, { where = where, game = game }) ~= false +end + +return LevelDisplay diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 718c5ba3..3e3ca876 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -11,6 +11,7 @@ local Assets = require("src.render.Assets") local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") @@ -785,7 +786,11 @@ function PartyMenu:draw() -- level at column 13 ( tile + digits, PrintLevel) AND the -- status/FNT text at column 17 (PrintStatusCondition), like the -- original rows -- statused mons keep their level display - if mon.level < 100 then + if not LevelDisplay.visible(mon, "party", self.game) then -- RFC 0019 + -- the level column is simply empty; the status/FNT column at 17 is a + -- separate field and still prints, exactly as it does for a mon whose + -- level is on screen + elseif mon.level < 100 then HudTiles.tile(0x6E, 104, y) -- Font.draw(tostring(mon.level), 112, y) else diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index 789c3135..cbcd4b0b 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -12,6 +12,7 @@ local Font = require("src.render.Font") -- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the -- battle move-type box already do (#214). local TypeChart = require("src.battle.TypeChart") +local LevelDisplay = require("src.ui.LevelDisplay") local Strings = require("src.core.Strings") local Stats = require("src.pokemon.Stats") local Status = require("src.battle.Status") @@ -137,7 +138,9 @@ function SummaryMenu:draw() if self.page == 1 then -- level is page 1 only: StatusScreen2 opens with ClearScreenArea over -- (9,2) 5x10 (status_screen.asm:303-305). #280 - printLevel(14, 2, mon.level) + if LevelDisplay.visible(mon, "summary", self.game) then -- RFC 0019 + printLevel(14, 2, mon.level) + end drawLineBox(19, 1, 6, 10) -- engine/pokemon/status_screen.asm:120-125 local PaletteFX = require("src.render.PaletteFX") @@ -196,8 +199,12 @@ function SummaryMenu:draw() local nextExp = mon.level < 100 and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0 Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48) - HudTiles.statusTile(0x70, 112, 48) -- '' at (14,6), was missing (#280) - printLevel(16, 6, math.min(100, mon.level + 1)) + if LevelDisplay.visible(mon, "summary", self.game) then -- RFC 0019 + -- the '' arrow is half a sentence without the level it points at, + -- so the pair is hidden together + HudTiles.statusTile(0x70, 112, 48) -- '' at (14,6), was missing (#280) + printLevel(16, 6, math.min(100, mon.level + 1)) + end Font.drawBox(0, 8, 20, 10) for i = 1, 4 do local mv = mon.moves[i] diff --git a/tests/modkit/cases/pokemon_level_visible.lua b/tests/modkit/cases/pokemon_level_visible.lua new file mode 100644 index 00000000..2fd3efd9 --- /dev/null +++ b/tests/modkit/cases/pokemon_level_visible.lua @@ -0,0 +1,78 @@ +-- A sandboxed mod can take a Pokémon's level off the screens that print it +-- (pokemon.level_visible): the readout goes, the layout does not, and a +-- build with no mod wrapping the hook prints exactly what it always did. +-- +-- The predicate is tested rather than the pixels: every Gen 1 level readout +-- goes through LevelDisplay.visible, and the four call sites are the same +-- one-line guard. What matters here is the contract -- default true, false +-- suppresses, the surface is named, and no-mod costs nothing. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.modkit") +local LevelDisplay = require("src.ui.LevelDisplay") + +local FIXTURE = { + ["mods/level_probe/manifest.json"] = [[{ + "id": "level_probe", + "name": "Level Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/level_probe/main.lua"] = [[ + local mod = ... + mod.exports.answer = nil + mod.exports.seen = nil + mod.hooks:wrap("pokemon.level_visible", function(next, mon, ctx) + mod.exports.seen = { mon = mon, where = ctx and ctx.where, + game = ctx and ctx.game } + if mod.exports.answer == nil then return next(mon, ctx) end + return mod.exports.answer + end) + ]], +} + +local MON = { species = "RATTATA", level = 42, moves = {} } +local GAME = { save = {} } + +-- ------- no mod: the level is always printed + +local vanilla = T.sdk.loadNone({}) +T.eq(LevelDisplay.visible(MON, "battle.enemy", GAME), true, + "no mod: the enemy healthbox prints a level") +T.eq(LevelDisplay.visible(MON, "party", GAME), true, + "no mod: the party rows print a level") +T.eq(LevelDisplay.visible(nil, "summary", nil), true, + "no mod: even a nil mon answers true rather than throwing") +vanilla.release() + +-- ------- a mod hides it + +local run = T.sdk.loadMods({ "mods/level_probe" }, { fs = T.sdk.memfs(FIXTURE) }) +T.eq(#run.errors, 0, "the level probe loads clean (" .. tostring(run.errors[1]) .. ")") +local probe = run.loader.exports.level_probe + +probe.answer = false +T.eq(LevelDisplay.visible(MON, "battle.enemy", GAME), false, + "hidden: the enemy healthbox prints no level") +T.eq(probe.seen and probe.seen.where, "battle.enemy", + "the hook is told which surface asked") +T.check(probe.seen and probe.seen.mon == MON, "and which Pokémon") +T.check(probe.seen and probe.seen.game == GAME, "and the game") + +-- the surface is what lets a mode hide an opponent's level and keep its own +probe.answer = nil +T.eq(LevelDisplay.visible(MON, "party", GAME), true, + "falling through prints, as today") +T.eq(probe.seen and probe.seen.where, "party", "and still names the surface") + +-- only an explicit false suppresses: a mod returning nothing must not blank +-- a screen by accident +probe.answer = true +T.eq(LevelDisplay.visible(MON, "summary", GAME), true, + "an explicit true prints") + +run.release() +T.finish("pokemon level visible") From e9b815fcac9e56ee08793508449acbf8012c0940 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 27 Aug 2026 08:39:06 -0400 Subject: [PATCH 2/6] Update RomImporter.lua --- src/import/RomImporter.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 1e15166b..6df2593f 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2296,8 +2296,8 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed) } return nil end - if type(size) == "number" and size > RequiredImports.LARGE_WARN_BYTES - and spec.format ~= "n64" then + if spec.format ~= "n64" and (size == nil + or size > RequiredImports.LARGE_WARN_BYTES) then local ok, result = streamRequiredImport(manifest, importId, source) if ok then self.requiredImportNotice = nil From ebd315b01e9b1d8c17f105360a578a8373391216 Mon Sep 17 00:00:00 2001 From: Colson Rice Date: Thu, 27 Aug 2026 10:19:57 -0400 Subject: [PATCH 3/6] Import Gen 2 cart saves Gold, Silver and Crystal battery saves import now. Export is still refused. GenSave.lua is pokered's SRAM window and nothing else, which is why the guard refusing Gen 2 was right to be there. This adds Gen2Save.lua beside it, covering pokegold and pokecrystal. Every offset is generated, not transcribed. tools/gen2_sram_offsets.py reads pokegold.sym and pokecrystal.sym from a pret build and emits Gen2Layout.lua, including the text table from constants/charmap.asm and Crystal's backup-save layout. Gen 2 copies a contiguous WRAM block into SRAM bank 1, so a field's file offset is sPlayerData + (wField - wPlayerData); the generator asserts that relation against sPokemonData rather than assuming it, and range-guards anything outside sGameData..sGameDataEnd. Gold and Crystal are separate tables because they disagree about nearly every field. Reading a Crystal save with Gold's numbers gives a party count of 133 and 13113 hours played, with a checksum that validates. The cart stores numbers and the engine is keyed by name, so the codec crosswalks species, moves and items through the generated tables the same way GenSave.crosswalks does for Gen 1. Without that, an import looks perfect and the engine cannot read a byte of it. Shapes that have to match what the engine reads: * events is byte index -> packed byte, which Save.scrubEvents validates with tonumber. A set of booleans is silently emptied. * the bag is one flat save.inventory keyed by item id, which PackMenu buckets by each item's pocket. Nothing reads save.keyItems or save.balls, and the TM/HM pocket lands here too. * position carries the map id, or Save.summary falls through to save.spawn and the player resumes somewhere else at their old coordinates. * mon.status is an ItemEffects.STATUS_CLASS key with statusTurns beside it, nil when healthy. 0 is truthy in Lua. A save the real cartridge would open is not refused: TryLoadSaveFile falls back to VerifyBackupChecksum, so this does too. Crystal's backup is contiguous and laid out like the primary; Gold and Silver split theirs across three sections and have none to offer. Three suites that pinned Gen 2 import being refused now pin what refuses instead. Tests live in tests/engine so the ROM-free tier actually runs them. ./scripts/test.sh passes end to end, and luacheck is clean. Co-Authored-By: Claude Opus 5 --- src/import/SaveFileIO.lua | 20 +- src/save_convert/Gen2Layout.lua | 335 +++++++++++++++++++ src/save_convert/Gen2Save.lua | 374 ++++++++++++++++++++++ src/save_convert/SaveConvert.lua | 54 +++- tests/engine/gen2_save_import.lua | 307 ++++++++++++++++++ tests/engine/gen2_save_import_message.lua | 123 +++++++ tests/engine/save_import_retry_bug420.lua | 2 + tests/gen2_save_convert_cli_test.lua | 12 +- tests/gen2_save_export_test.lua | 15 +- tests/gen2_save_import_message_test.lua | 96 ------ tests/run_tests.lua | 1 - tools/gen2_sram_offsets.py | 185 +++++++++++ 12 files changed, 1408 insertions(+), 116 deletions(-) create mode 100644 src/save_convert/Gen2Layout.lua create mode 100644 src/save_convert/Gen2Save.lua create mode 100644 tests/engine/gen2_save_import.lua create mode 100644 tests/engine/gen2_save_import_message.lua delete mode 100644 tests/gen2_save_import_message_test.lua create mode 100644 tools/gen2_sram_offsets.py diff --git a/src/import/SaveFileIO.lua b/src/import/SaveFileIO.lua index 8723fe0a..3241ee71 100644 --- a/src/import/SaveFileIO.lua +++ b/src/import/SaveFileIO.lua @@ -78,14 +78,17 @@ function SaveFileIO.importToSlot(source, version, force) version = version or GameVersion.get() local bytes, readErr = readSource(source) if not bytes then return false, readErr end - -- The GAME decides before the BYTES do. Everything below this line judges a - -- save by Gen 1's rules -- the size test, and mainChecksumValid, which is - -- pokered's checksum -- so a Gen 2 cart save reaching it is measured against - -- a rule that cannot match and comes back "checksum invalid" (#1832). + -- The GAME decides before the BYTES do. Everything below this line used to + -- judge a save by Gen 1's rules whatever game it was for, and a Gen 2 cart + -- is MBC3+TIMER: a real Gold/Silver/Crystal .sav carries an RTC footer, so + -- it is 32786 bytes, misses the size test, and was then measured against + -- pokered's checksum -- which is why a perfectly good Crystal save reported + -- as corrupt (#1832). mainChecksumValid now takes the game and asks that + -- generation's rule. local supported, unsupportedWhy = SaveConvert.importSupported(version) if not supported then return false, unsupportedWhy end if #bytes ~= SAVE_SIZE then - local check = SaveConvert.mainChecksumValid(bytes) + local check = SaveConvert.mainChecksumValid(bytes, version) if check == nil then return false, ("A save file must be %d bytes (32 KB); this one is %d.") :format(SAVE_SIZE, #bytes) @@ -93,7 +96,12 @@ function SaveFileIO.importToSlot(source, version, force) if check == false then return false, "save data checksum invalid (main data checksum mismatch)" end - if #bytes > SAVE_SIZE and not force then + -- The confirm exists because a Gen 1 save bigger than 32768 is a surprise + -- worth asking about. On a Gen 2 cart it is the normal shape -- every + -- real one has the footer -- so asking would be a prompt with one sensible + -- answer, on every import, forever. + if #bytes > SAVE_SIZE and not force + and not SaveConvert.isGen2Cart(version) then return false, nil, { needsConfirm = true, size = #bytes } end bytes = #bytes > SAVE_SIZE and bytes:sub(1, SAVE_SIZE) diff --git a/src/save_convert/Gen2Layout.lua b/src/save_convert/Gen2Layout.lua new file mode 100644 index 00000000..d60511e1 --- /dev/null +++ b/src/save_convert/Gen2Layout.lua @@ -0,0 +1,335 @@ +-- GENERATED by tools/gen2_sram_offsets.py. Do not edit by hand. +-- Regenerate from a pret/pokegold + pret/pokecrystal build; see that +-- script's header for the derivation and the assertion behind it. +local Gen2Layout = {} + +Gen2Layout.goldSilver = { + sCheckValue1 = 0x2008, + sCheckValue2 = 0x2D6B, + sChecksum = 0x2D69, + sGameData = 0x2009, + sGameDataEnd = 0x2D69, + wPlayerName = 0x200B, + wPlayerID = 0x2009, + wMoney = 0x23DB, + wCoins = 0x23E2, + wBadges = 0x23E4, + wKantoBadges = 0x23E5, + wRivalName = 0x2021, + wMomsName = 0x2016, + wPartyCount = 0x288A, + wPartySpecies = 0x288B, + wPartyMons = 0x2892, + wPartyMonNicknames = 0x29F4, + wPartyMonOTs = 0x29B2, + wNumItems = 0x241F, + wItems = 0x2420, + wNumKeyItems = 0x2449, + wKeyItems = 0x244A, + wNumBalls = 0x2464, + wBalls = 0x2465, + wTMsHMs = 0x23E6, + wPokedexCaught = 0x2A4C, + wPokedexSeen = 0x2A6C, + wCurBox = 0x2724, + wBoxNames = 0x2727, + wMapGroup = 0x2868, + wMapNumber = 0x2869, + wXCoord = 0x286B, + wYCoord = 0x286A, + wEventFlags = 0x261F, + wPlayerState = 0x24EA, + wGameTimeHours = 0x2053, + wGameTimeMinutes = 0x2055, + -- The 14 archived boxes, listed rather than strided (see BOX_COUNT). + boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 }, +} + +Gen2Layout.crystal = { + sCheckValue1 = 0x2008, + sCheckValue2 = 0x2D0F, + sChecksum = 0x2D0D, + sGameData = 0x2009, + sGameDataEnd = 0x2B83, + wPlayerName = 0x200B, + wPlayerID = 0x2009, + wMoney = 0x23DC, + wCoins = 0x23E3, + wBadges = 0x23E5, + wKantoBadges = 0x23E6, + wRivalName = 0x2021, + wMomsName = 0x2016, + wPartyCount = 0x2865, + wPartySpecies = 0x2866, + wPartyMons = 0x286D, + wPartyMonNicknames = 0x29CF, + wPartyMonOTs = 0x298D, + wNumItems = 0x2420, + wItems = 0x2421, + wNumKeyItems = 0x244A, + wKeyItems = 0x244B, + wNumBalls = 0x2465, + wBalls = 0x2466, + wTMsHMs = 0x23E7, + wPokedexCaught = 0x2A27, + wPokedexSeen = 0x2A47, + wCurBox = 0x2700, + wBoxNames = 0x2703, + wMapGroup = 0x2843, + wMapNumber = 0x2844, + wXCoord = 0x2846, + wYCoord = 0x2845, + wEventFlags = 0x2600, + wPlayerState = 0x24EB, + wGameTimeHours = 0x2052, + wGameTimeMinutes = 0x2054, + -- The 14 archived boxes, listed rather than strided (see BOX_COUNT). + boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 }, + -- The backup copy the game falls back to when the primary + -- checksum fails. Same shape, shifted. + backup = { + sCheckValue1 = 0x1208, + sCheckValue2 = 0x1F0F, + sChecksum = 0x1F0D, + sGameData = 0x1209, + sGameDataEnd = 0x1D83, + wPlayerName = 0x120B, + wPlayerID = 0x1209, + wMoney = 0x15DC, + wCoins = 0x15E3, + wBadges = 0x15E5, + wKantoBadges = 0x15E6, + wRivalName = 0x1221, + wMomsName = 0x1216, + wPartyCount = 0x1A65, + wPartySpecies = 0x1A66, + wPartyMons = 0x1A6D, + wPartyMonNicknames = 0x1BCF, + wPartyMonOTs = 0x1B8D, + wNumItems = 0x1620, + wItems = 0x1621, + wNumKeyItems = 0x164A, + wKeyItems = 0x164B, + wNumBalls = 0x1665, + wBalls = 0x1666, + wTMsHMs = 0x15E7, + wPokedexCaught = 0x1C27, + wPokedexSeen = 0x1C47, + wCurBox = 0x1900, + wBoxNames = 0x1903, + wMapGroup = 0x1A43, + wMapNumber = 0x1A44, + wXCoord = 0x1A46, + wYCoord = 0x1A45, + wEventFlags = 0x1800, + wPlayerState = 0x16EB, + wGameTimeHours = 0x1252, + wGameTimeMinutes = 0x1254, + boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 }, + }, +} + +Gen2Layout.charmap = { + [0x05] = "ガ", + [0x06] = "ギ", + [0x07] = "グ", + [0x08] = "ゲ", + [0x09] = "ゴ", + [0x0A] = "ザ", + [0x0B] = "ジ", + [0x0C] = "ズ", + [0x0D] = "ゼ", + [0x0E] = "ゾ", + [0x0F] = "ダ", + [0x10] = "ヂ", + [0x11] = "ヅ", + [0x12] = "デ", + [0x13] = "ド", + [0x19] = "バ", + [0x1A] = "ビ", + [0x1B] = "ブ", + [0x1C] = "ボ", + [0x26] = "が", + [0x27] = "ぎ", + [0x28] = "ぐ", + [0x29] = "げ", + [0x2A] = "ご", + [0x2B] = "ざ", + [0x2C] = "じ", + [0x2D] = "ず", + [0x2E] = "ぜ", + [0x2F] = "ぞ", + [0x30] = "だ", + [0x31] = "ぢ", + [0x32] = "づ", + [0x33] = "で", + [0x34] = "ど", + [0x3A] = "ば", + [0x3B] = "び", + [0x3C] = "ぶ", + [0x3D] = "べ", + [0x3E] = "ぼ", + [0x3F] = "⁂", + [0x40] = "パ", + [0x41] = "ピ", + [0x42] = "プ", + [0x43] = "ポ", + [0x44] = "ぱ", + [0x45] = "ぴ", + [0x46] = "ぷ", + [0x47] = "ぺ", + [0x48] = "ぽ", + [0x50] = "@", + [0x54] = "#", + [0x60] = "■", + [0x61] = "▲", + [0x62] = "☎", + [0x6E] = "ぃ", + [0x6F] = "ぅ", + [0x70] = "PO", + [0x71] = "KE", + [0x72] = "“", + [0x73] = "”", + [0x74] = "·", + [0x75] = "…", + [0x76] = "ぁ", + [0x77] = "ぇ", + [0x78] = "ぉ", + [0x79] = "┌", + [0x7A] = "─", + [0x7B] = "┐", + [0x7C] = "│", + [0x7D] = "└", + [0x7E] = "┘", + [0x7F] = " ", + [0x80] = "A", + [0x81] = "B", + [0x82] = "C", + [0x83] = "D", + [0x84] = "E", + [0x85] = "F", + [0x86] = "G", + [0x87] = "H", + [0x88] = "I", + [0x89] = "J", + [0x8A] = "K", + [0x8B] = "L", + [0x8C] = "M", + [0x8D] = "N", + [0x8E] = "O", + [0x8F] = "P", + [0x90] = "Q", + [0x91] = "R", + [0x92] = "S", + [0x93] = "T", + [0x94] = "U", + [0x95] = "V", + [0x96] = "W", + [0x97] = "X", + [0x98] = "Y", + [0x99] = "Z", + [0x9A] = "(", + [0x9B] = ")", + [0x9C] = ":", + [0x9D] = ";", + [0x9E] = "[", + [0x9F] = "]", + [0xA0] = "a", + [0xA1] = "b", + [0xA2] = "c", + [0xA3] = "d", + [0xA4] = "e", + [0xA5] = "f", + [0xA6] = "g", + [0xA7] = "h", + [0xA8] = "i", + [0xA9] = "j", + [0xAA] = "k", + [0xAB] = "l", + [0xAC] = "m", + [0xAD] = "n", + [0xAE] = "o", + [0xAF] = "p", + [0xB0] = "q", + [0xB1] = "r", + [0xB2] = "s", + [0xB3] = "t", + [0xB4] = "u", + [0xB5] = "v", + [0xB6] = "w", + [0xB7] = "x", + [0xB8] = "y", + [0xB9] = "z", + [0xBA] = "こ", + [0xBB] = "さ", + [0xBC] = "し", + [0xBD] = "す", + [0xBE] = "せ", + [0xBF] = "そ", + [0xC0] = "Ä", + [0xC1] = "Ö", + [0xC2] = "Ü", + [0xC3] = "ä", + [0xC4] = "ö", + [0xC5] = "ü", + [0xC6] = "に", + [0xC7] = "ぬ", + [0xC8] = "ね", + [0xC9] = "の", + [0xCA] = "は", + [0xCB] = "ひ", + [0xCC] = "ふ", + [0xCD] = "へ", + [0xCE] = "ほ", + [0xCF] = "ま", + [0xD0] = "'d", + [0xD1] = "'l", + [0xD2] = "'m", + [0xD3] = "'r", + [0xD4] = "'s", + [0xD5] = "'t", + [0xD6] = "'v", + [0xD7] = "ら", + [0xD8] = "り", + [0xD9] = "る", + [0xDA] = "れ", + [0xDB] = "ろ", + [0xDC] = "わ", + [0xDD] = "を", + [0xDE] = "ん", + [0xDF] = "←", + [0xE0] = "'", + [0xE1] = "PK", + [0xE2] = "MN", + [0xE3] = "-", + [0xE4] = "゚", + [0xE5] = "゙", + [0xE6] = "?", + [0xE7] = "!", + [0xE8] = ".", + [0xE9] = "&", + [0xEA] = "é", + [0xEB] = "→", + [0xEC] = "▷", + [0xED] = "▶", + [0xEE] = "▼", + [0xEF] = "♂", + [0xF0] = "¥", + [0xF1] = "×", + [0xF2] = ".", + [0xF3] = "/", + [0xF4] = ",", + [0xF5] = "♀", + [0xF6] = "0", + [0xF7] = "1", + [0xF8] = "2", + [0xF9] = "3", + [0xFA] = "4", + [0xFB] = "5", + [0xFC] = "6", + [0xFD] = "7", + [0xFE] = "8", + [0xFF] = "9", +} + +return Gen2Layout diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua new file mode 100644 index 00000000..776cd1fd --- /dev/null +++ b/src/save_convert/Gen2Save.lua @@ -0,0 +1,374 @@ +-- Vanilla Gen 2 (Gold/Silver/Crystal) raw SRAM <-> src/core/gen2/Save.lua. +-- Companion to GenSave.lua, which is Gen 1 only. +-- +-- Offsets come from Gen2Layout.lua, generated by tools/gen2_sram_offsets.py. +-- Gold and Silver share a layout; Crystal does not. +-- +-- Pure Lua, no love.* at require time, same as GenSave. + +local Gen2Layout = require("src.save_convert.Gen2Layout") + +local Gen2Save = {} + +Gen2Save.SAVE_SIZE = 32768 +Gen2Save.PARTY_STRUCT = 48 +Gen2Save.NAME_LENGTH = 11 +Gen2Save.PARTY_LENGTH = 6 + +function Gen2Save.layoutFor(gameVersion) + if gameVersion == "crystal" then return Gen2Layout.crystal end + if gameVersion == "gold" or gameVersion == "silver" then return Gen2Layout.goldSilver end + return nil +end + +local function u8(b, o) return b:byte(o + 1) end +local function be(b, o, n) + local v = 0 + for i = 0, n - 1 do v = v * 256 + b:byte(o + i + 1) end + return v +end + +-- The cart's text encoding, from Gen2Layout.charmap. 0x50 terminates. +local function text(b, o, n) + local out = {} + for i = 0, n - 1 do + local c = b:byte(o + i + 1) + if not c or c == 0x50 then break end + out[#out + 1] = Gen2Layout.charmap[c] or "?" + end + return table.concat(out) +end + +-- Both check values AND the 16-bit sum between them. A blank SRAM sums to a +-- valid 0 == 0, so the check values are what reject it. +function Gen2Save.checksumValid(bytes, L) + if #bytes < Gen2Save.SAVE_SIZE then return nil end + if u8(bytes, L.sCheckValue1) ~= 0x63 then return false end + if u8(bytes, L.sCheckValue2) ~= 0x7F then return false end + local sum = 0 + for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + u8(bytes, i)) % 65536 end + local stored = u8(bytes, L.sChecksum) + u8(bytes, L.sChecksum + 1) * 256 + return sum == stored +end + +-- The cart stores numbers, the engine is keyed by name. Same shape +-- GenSave.crosswalks builds for Gen 1. Without `data` the raw numbers survive. +local function byIndex(defs) + local out = {} + for id, def in pairs(defs or {}) do + if type(def) == "table" and def.index ~= nil then out[def.index] = id end + end + return out +end + +function Gen2Save.crosswalks(data) + data = data or {} + local items = byIndex(data.items) + -- Maps are found by (group, number) rather than by a flat index. + local maps = {} + for id, def in pairs(data.maps or {}) do + if type(def) == "table" and def.group and def.map then + maps[def.group * 256 + def.map] = id + end + end + return { + pokemon = byIndex(data.pokemon), + moves = byIndex(data.moves), + items = items, + maps = maps, + } +end + +-- Name if the crosswalk knows it, the raw number if not. Never silently drops +-- a value: an id this build cannot name is still the player's. +local function named(map, index) + if index == nil or index == 0 then return nil end + return map[index] or index +end + +-- constants/battle_constants.asm: SLP_MASK is bits 0-2, PSN=3 BRN=4 FRZ=5 +-- PAR=6. Engine wants an ItemEffects.STATUS_CLASS key, nil when healthy. +local STATUS_BITS = { { 3, "psn" }, { 4, "brn" }, { 5, "frz" }, { 6, "par" } } + +local function decodeStatus(byte) + local turns = byte % 8 + if turns > 0 then return "slp", turns end + for _, row in ipairs(STATUS_BITS) do + if math.floor(byte / 2 ^ row[1]) % 2 == 1 then return row[2], nil end + end + return nil, nil +end + +-- Four DVs as nibbles in two bytes; the HP DV is rebuilt from the low bit of +-- each (pokecrystal engine/pokemon/health.asm). +local function decodeDVs(bytes, o) + local hi, lo = u8(bytes, o), u8(bytes, o + 1) + local atk = math.floor(hi / 16) + local def = hi % 16 + local spd = math.floor(lo / 16) + local spc = lo % 16 + local hp = (atk % 2) * 8 + (def % 2) * 4 + (spd % 2) * 2 + (spc % 2) + return { hp = hp, attack = atk, defense = def, speed = spd, special = spc } +end + +-- Five 16-bit stat experience words, in the cart's order. +local function decodeStatExp(bytes, o) + return { + hp = be(bytes, o, 2), attack = be(bytes, o + 2, 2), + defense = be(bytes, o + 4, 2), speed = be(bytes, o + 6, 2), + special = be(bytes, o + 8, 2), + } +end + +-- The first 32 bytes, which a box mon and a party mon share. +local function decodeSharedMon(bytes, o, x) + local moves, pp = {}, {} + for i = 0, 3 do + local id = named(x.moves, u8(bytes, o + 2 + i)) + if id then moves[#moves + 1] = id end + end + for i = 0, 3 do pp[i + 1] = u8(bytes, o + 0x17 + i) % 64 end + return { + species = named(x.pokemon, u8(bytes, o)), + item = named(x.items, u8(bytes, o + 1)), + moves = moves, pp = pp, + otId = be(bytes, o + 6, 2), + experience = be(bytes, o + 8, 3), + statExp = decodeStatExp(bytes, o + 0x0B), + dvs = decodeDVs(bytes, o + 0x15), + happiness = u8(bytes, o + 0x1B), + level = u8(bytes, o + 0x1F), + } +end + +local function decodeMon(bytes, o, x) + local mon = decodeSharedMon(bytes, o, x) + mon.status, mon.statusTurns = decodeStatus(u8(bytes, o + 0x20)) + mon.hp = be(bytes, o + 0x22, 2) + mon.maxHp = be(bytes, o + 0x24, 2) + mon.stats = { + hp = mon.maxHp, + attack = be(bytes, o + 0x26, 2), defense = be(bytes, o + 0x28, 2), + speed = be(bytes, o + 0x2A, 2), specialAttack = be(bytes, o + 0x2C, 2), + specialDefense = be(bytes, o + 0x2E, 2), + } + return mon +end + +-- box_struct: OTs come BEFORE nicknames, and a box mon is 32 bytes with none +-- of the party's computed stats. +Gen2Save.BOX_CAPACITY = 20 +Gen2Save.BOX_MON_STRUCT = 32 +local BOX_SPECIES, BOX_MONS, BOX_OTS, BOX_NICKS = 0x01, 0x16, 0x296, 0x372 + +local decodeBoxMon = decodeSharedMon + +-- A count past BOX_CAPACITY means the wrong layout, not an odd save. +function Gen2Save.decodeBoxes(bytes, L, x) + local boxes = {} + for index, base in ipairs(L.boxes) do + local count = u8(bytes, base) + if count > Gen2Save.BOX_CAPACITY then + return nil, ("box %d reports %d Pokemon, which is impossible; this is the " + .. "wrong layout for this save"):format(index, count) + end + local mons = {} + for i = 0, count - 1 do + local mon = decodeBoxMon(bytes, base + BOX_MONS + i * Gen2Save.BOX_MON_STRUCT, x) + -- The species list beside the mons must agree, which is a free check + -- on the layout. + local listed = named(x.pokemon, u8(bytes, base + BOX_SPECIES + i)) + if listed ~= mon.species then + return nil, ("box %d slot %d: the species list says %s and the stored " + .. "Pokemon says %s; wrong layout for this save") + :format(index, i + 1, tostring(listed), tostring(mon.species)) + end + mon.ot = text(bytes, base + BOX_OTS + i * Gen2Save.NAME_LENGTH, Gen2Save.NAME_LENGTH) + mon.nickname = text(bytes, base + BOX_NICKS + i * Gen2Save.NAME_LENGTH, Gen2Save.NAME_LENGTH) + mons[#mons + 1] = mon + end + boxes[index] = mons + end + return boxes +end + +-- The bag. Gen 2 splits it into four pockets, and they are not all shaped the +-- same: ITEM and BALL are (id, quantity) pairs, KEY_ITEM is bare ids because a +-- key item is unique, and TM_HM is a flat run of counts indexed by TM number. +-- All of the list pockets are terminated by 0xFF as well as counted, and the +-- count is trusted only as far as the terminator. +-- One bag keyed by item id; PackMenu buckets it by each item's `pocket`. +-- ITEM and BALL are (id, quantity) pairs, KEY_ITEM is bare ids. +local function addPairs(out, bytes, countAt, listAt, cap, x) + local n = u8(bytes, countAt) + if n > cap then n = cap end + for i = 0, n - 1 do + local raw = u8(bytes, listAt + i * 2) + if raw == 0xFF or raw == 0 then break end + local id = named(x.items, raw) + out[id] = (out[id] or 0) + u8(bytes, listAt + i * 2 + 1) + end +end + +local function addIds(out, bytes, countAt, listAt, cap, x) + local n = u8(bytes, countAt) + if n > cap then n = cap end + for i = 0, n - 1 do + local raw = u8(bytes, listAt + i) + if raw == 0xFF or raw == 0 then break end + local id = named(x.items, raw) + out[id] = (out[id] or 0) + 1 + end +end + +-- TM_HM is a flat run of counts indexed by TM number. +local function addMachines(out, bytes, at, x, items) + local byNumber = {} + for id, def in pairs(items or {}) do + if type(def) == "table" and def.tmNumber then byNumber[def.tmNumber] = id end + end + for number, id in pairs(byNumber) do + local count = u8(bytes, at + number - 1) + if count > 0 then out[id] = (out[id] or 0) + count end + end +end + +-- Byte index -> byte, which is what Save.scrubEvents validates. +local function decodeFlagBytes(bytes, at, count) + local out = {} + for i = 0, count - 1 do out[i] = u8(bytes, at + i) end + return out +end + +-- A set keyed by species id: save.pokedex.caught[species] = true. +local function decodeDex(bytes, at, x) + local out = {} + for i = 0, Gen2Save.NUM_SPECIES - 1 do + local byte = u8(bytes, at + math.floor(i / 8)) + if math.floor(byte / (2 ^ (i % 8))) % 2 == 1 then + out[named(x.pokemon, i + 1) or (i + 1)] = true + end + end + return out +end + +-- Badges by NAME, which is how FieldMoves.hasBadge and Battle:hasBadge read +-- them. Bit position is accepted as a fallback key there, but the name is the +-- primary and is what a save written by this project carries. +Gen2Save.JOHTO_BADGES = { + "ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING", +} +Gen2Save.KANTO_BADGES = { + "BOULDER", "CASCADE", "THUNDER", "RAINBOW", + "SOUL", "MARSH", "VOLCANO", "EARTH", +} +local function decodeBadges(byte, order) + local out = {} + for bit, name in ipairs(order) do + if math.floor(byte / (2 ^ (bit - 1))) % 2 == 1 then out[name] = true end + end + return out +end + +Gen2Save.NUM_SPECIES = 251 +Gen2Save.EVENT_BYTES = 256 + +-- decode(bytes, gameVersion, data) -> partial save table, err +function Gen2Save.decode(bytes, gameVersion, data) + local L = Gen2Save.layoutFor(gameVersion) + if not L then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end + if type(bytes) ~= "string" or #bytes < Gen2Save.SAVE_SIZE then + return nil, ("save must be at least %d bytes"):format(Gen2Save.SAVE_SIZE) + end + -- TryLoadSaveFile falls back to VerifyBackupChecksum and loads the backup + -- copy, so a save the real cartridge would open must not be refused here. + -- Crystal's backup is contiguous and laid out like the primary; Gold and + -- Silver split theirs across three sections and have none to offer. + if Gen2Save.checksumValid(bytes, L) ~= true then + if L.backup and Gen2Save.checksumValid(bytes, L.backup) == true then + L = L.backup + else + return nil, "save data checksum invalid (Gen 2 check values or sum mismatch)" + end + end + + local x = Gen2Save.crosswalks(data) + local count = u8(bytes, L.wPartyCount) + if count > Gen2Save.PARTY_LENGTH then + return nil, ("party count %d is impossible; this is probably the wrong layout") + :format(count) + end + local party = {} + for i = 0, count - 1 do + local mon = decodeMon(bytes, L.wPartyMons + i * Gen2Save.PARTY_STRUCT, x) + mon.nickname = text(bytes, L.wPartyMonNicknames + i * Gen2Save.NAME_LENGTH, + Gen2Save.NAME_LENGTH) + mon.ot = text(bytes, L.wPartyMonOTs + i * Gen2Save.NAME_LENGTH, + Gen2Save.NAME_LENGTH) + party[#party + 1] = mon + end + + local boxes, boxErr = Gen2Save.decodeBoxes(bytes, L, x) + if not boxes then return nil, boxErr end + + local inventory = {} + addPairs(inventory, bytes, L.wNumItems, L.wItems, 20, x) + addIds(inventory, bytes, L.wNumKeyItems, L.wKeyItems, 25, x) + addPairs(inventory, bytes, L.wNumBalls, L.wBalls, 12, x) + if L.wTMsHMs then addMachines(inventory, bytes, L.wTMsHMs, x, (data or {}).items) end + + return { + generation = 2, + version = gameVersion, + player = { + name = text(bytes, L.wPlayerName, Gen2Save.NAME_LENGTH), + id = be(bytes, L.wPlayerID, 2), + money = be(bytes, L.wMoney, 3), + coins = be(bytes, L.wCoins, 2), + badges = decodeBadges(u8(bytes, L.wBadges), Gen2Save.JOHTO_BADGES), + kantoBadges = decodeBadges(u8(bytes, L.wKantoBadges), Gen2Save.KANTO_BADGES), + }, + rival = { name = text(bytes, L.wRivalName, Gen2Save.NAME_LENGTH) }, + mom = { name = text(bytes, L.wMomsName, Gen2Save.NAME_LENGTH) }, + party = party, + boxes = boxes, + currentBox = u8(bytes, L.wCurBox) % 16 + 1, + inventory = inventory, + -- Species ids, 1-based, so the set keys match save.pokedex.caught[species]. + pokedex = { + caught = decodeDex(bytes, L.wPokedexCaught, x), + seen = decodeDex(bytes, L.wPokedexSeen, x), + }, + events = decodeFlagBytes(bytes, L.wEventFlags, Gen2Save.EVENT_BYTES), + -- Save.summary does `save.position.map or save.spawn`. + position = { + map = x.maps[u8(bytes, L.wMapGroup) * 256 + u8(bytes, L.wMapNumber)], + mapGroup = u8(bytes, L.wMapGroup), mapNumber = u8(bytes, L.wMapNumber), + x = u8(bytes, L.wXCoord), y = u8(bytes, L.wYCoord), + }, + playTime = { + hours = be(bytes, L.wGameTimeHours, 2), + minutes = u8(bytes, L.wGameTimeMinutes), + seconds = 0, frames = 0, + }, + } +end + +-- Everything the cart does not carry comes from a fresh game, exactly as +-- Gen 1's mergeDefaults does: the decode above models what the SRAM holds, +-- and mail, phone contacts, the unown dex, the hall of fame and the RTC are +-- left to the engine's own defaults rather than invented here. +-- +-- Loaded lazily. src/core/gen2/Save.lua pulls in love.filesystem at require +-- time, and this module has to stay require-clean for the headless CLI and +-- the tests, same rule GenSave follows. +function Gen2Save.mergeDefaults(decoded, gameVersion) + local ok, Save = pcall(require, "src.core.gen2.Save") + if not ok then return decoded end + local base = Save.newGame({ playerName = decoded.player and decoded.player.name }) + for k, v in pairs(decoded) do base[k] = v end + base.version = gameVersion + return base +end + +return Gen2Save diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index 449cab82..6a16dec1 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -21,12 +21,24 @@ -- require alone cannot see them there (#420). local GenSave = require("src.save_convert.GenSave") +local Gen2Save = require("src.save_convert.Gen2Save") local GameVersion = require("src.core.GameVersion") local SaveConvert = {} SaveConvert.SAVE_SIZE = GenSave.SAVE_SIZE -SaveConvert.mainChecksumValid = GenSave.mainChecksumValid +-- Is this a real save for THIS GAME? Dispatches on the generation, because +-- the two do not share a rule: Gen 1 stores a complement checksum of its main +-- data block, Gen 2 stores two check values plus a 16-bit sum. Run one over +-- the other's bytes and the answer is always no. +-- +-- gameVersion is optional and defaults to Gen 1's rule, which is what every +-- caller meant before Gen 2 had a codec. +function SaveConvert.mainChecksumValid(bytes, gameVersion) + local L = gameVersion and Gen2Save.layoutFor(gameVersion) + if L then return Gen2Save.checksumValid(bytes, L) end + return GenSave.mainChecksumValid(bytes) +end -- ------------------------------------------------------------------ -- Crosswalk data loading (cached). Mirrors src/core/Data.lua: prefer @@ -125,6 +137,24 @@ local function loadCacheTable(gameVersion, filePath) return nil end +-- The generated tables Gen2Save needs to turn cart numbers into the ids the +-- engine is keyed by. Deliberately not ensureData: that one also demands Gen +-- 1's charmap, event flags and hidden items, none of which a Gen 2 cache has +-- or a Gen 2 save uses. +local gen2Data = {} +local function ensureGen2Data(gameVersion) + local key = gameVersion or "*" + if gen2Data[key] == nil then + local out = {} + for _, name in ipairs({ "pokemon", "moves", "items", "maps" }) do + out[name] = loadCacheTable(gameVersion, "data/generated/" .. name .. ".lua") + or (loadTable("data.generated." .. name, "data/generated/" .. name .. ".lua")) + end + gen2Data[key] = out + end + return gen2Data[key] +end + -- Crosswalk sets keyed by the game whose cache they came from ("*" for the -- require-resolved set): Yellow's tables are not Red's, so one import must -- never be handed the previous import's data (#420). @@ -248,11 +278,16 @@ end -- Returns true, or false plus the same sentence importSav/exportSav would have -- answered with, so a caller that asks early and a caller that does not cannot -- describe the same game two different ways. +-- Does this game use a Gen 2 cart save? The RTC footer that follows one is +-- expected rather than surprising, which the import path needs to know. +function SaveConvert.isGen2Cart(gameVersion) + return Gen2Save.layoutFor(gameVersion) ~= nil +end + function SaveConvert.importSupported(gameVersion) - local gen2Name = gen2CartName(gameVersion) - if gen2Name then - return false, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet." - end + -- Gen 2 imports through Gen2Save now. Kept as a predicate rather than + -- deleted: SaveFileIO asks it before it measures the bytes, and export + -- still answers no. return true end @@ -278,6 +313,15 @@ function SaveConvert.importSav(bytes, version, gameVersion) end local supported, unsupportedWhy = SaveConvert.importSupported(gameVersion) if not supported then return nil, unsupportedWhy end + -- Gen 2 is a different SRAM entirely: different bank map, different party + -- struct, its own check values. Gen2Save owns it, and it needs no crosswalk + -- tables because it decodes ids the engine already speaks. + if Gen2Save.layoutFor(gameVersion) then + local decoded, gen2Err = Gen2Save.decode(bytes, gameVersion, + ensureGen2Data(gameVersion)) + if not decoded then return nil, gen2Err end + return Gen2Save.mergeDefaults(decoded, gameVersion) + end if #bytes ~= GenSave.SAVE_SIZE then return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes) end diff --git a/tests/engine/gen2_save_import.lua b/tests/engine/gen2_save_import.lua new file mode 100644 index 00000000..d027132a --- /dev/null +++ b/tests/engine/gen2_save_import.lua @@ -0,0 +1,307 @@ +-- Importing a Gen 2 cart save (Gold, Silver, Crystal). +-- luajit tests/gen2_save_import_test.lua +-- Also dofile'd by tests/run_tests.lua. +-- +-- The save this builds is synthesized rather than checked in, the same rule +-- tests/save_convert_tests.lua follows for Gen 1: a real .sav is personal +-- data. Point POKEPORT_GEN2_SAV_FIXTURE at one to run the audit at the +-- bottom against your own Gold/Silver/Crystal save. +-- +-- Every offset under test comes from src/save_convert/Gen2Layout.lua, which +-- tools/gen2_sram_offsets.py generates from a pret build. The reason that +-- matters is here in miniature: Gold and Crystal disagree on almost every +-- field, so reading one with the other's table is not a near miss, it is a +-- party count of 133. +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = love or require("tests.love_stub") + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local Gen2Save = require("src.save_convert.Gen2Save") +local Gen2Layout = require("src.save_convert.Gen2Layout") +local SaveConvert = require("src.save_convert.SaveConvert") + +local SIZE = Gen2Save.SAVE_SIZE + +-- A byte-addressable save under construction. +local function blank() + local b = {} + for i = 0, SIZE - 1 do b[i] = 0 end + return b +end +local function put(b, at, ...) + local vals = { ... } + for i, v in ipairs(vals) do b[at + i - 1] = v % 256 end +end +local function putName(b, at, name) + for i = 1, #name do b[at + i - 1] = 0x80 + (name:byte(i) - 65) end + b[at + #name] = 0x50 +end +local function sealOne(b, L) + b[L.sCheckValue1] = 0x63 + b[L.sCheckValue2] = 0x7F + local sum = 0 + for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + b[i]) % 65536 end + b[L.sChecksum] = sum % 256 + b[L.sChecksum + 1] = math.floor(sum / 256) % 256 +end + +-- A real cart seals both copies, and the game rewrites the backup from the +-- primary on every successful load. +local function seal(b, L) + if L.backup then + for i = 0, (L.sGameDataEnd - L.sGameData) - 1 do + b[L.backup.sGameData + i] = b[L.sGameData + i] + end + sealOne(b, L.backup) + end + sealOne(b, L) +end +local function pack(b) + local out = {} + for i = 0, SIZE - 1 do out[i + 1] = string.char(b[i]) end + return table.concat(out) +end + +-- A save with one known Pokemon in the party and one in box 3. +local function build(version) + local L = Gen2Save.layoutFor(version) + local b = blank() + putName(b, L.wPlayerName, "ASH") + putName(b, L.wRivalName, "GARY") + putName(b, L.wMomsName, "MOM") + put(b, L.wPlayerID, 0x12, 0x34) -- big-endian 0x1234 + put(b, L.wMoney, 0x01, 0xE2, 0x40) -- 123456 + put(b, L.wBadges, 0x05) -- ZEPHYR + PLAIN + put(b, L.wPartyCount, 1) + put(b, L.wPartySpecies, 155) + local mon = L.wPartyMons + put(b, mon, 155) -- species + put(b, mon + 1, 0) -- no held item + put(b, mon + 2, 33, 43, 0, 0) -- two moves + put(b, mon + 6, 0x12, 0x34) -- OT id + put(b, mon + 0x15, 0x9F, 0x6A) -- DVs: a=9 d=15 s=6 sp=10 + put(b, mon + 0x1B, 200) -- happiness + put(b, mon + 0x1F, 42) -- level + put(b, mon + 0x20, 0x10) -- BRN (bit 4) + put(b, mon + 0x22, 0x00, 0x64) -- hp 100 + put(b, mon + 0x24, 0x00, 0x64) -- maxHp 100 + put(b, mon + 0x26, 0x00, 0x37) -- attack 55 + putName(b, L.wPartyMonNicknames, "FLAME") + putName(b, L.wPartyMonOTs, "ASH") + put(b, L.wMapGroup, 21); put(b, L.wMapNumber, 14) + put(b, L.wNumItems, 1); put(b, L.wItems, 20, 3); put(b, L.wItems + 2, 0xFF) + put(b, L.wNumKeyItems, 1); put(b, L.wKeyItems, 7); put(b, L.wKeyItems + 1, 0xFF) + put(b, L.wNumBalls, 1); put(b, L.wBalls, 5, 9); put(b, L.wBalls + 2, 0xFF) + -- box 3, one Pokemon + local box = L.boxes[3] + put(b, box, 1) + put(b, box + 0x01, 7) + put(b, box + 0x16, 7) + put(b, box + 0x16 + 0x1F, 15) + putName(b, box + 0x296, "ASH") + putName(b, box + 0x372, "SQUIRT") + seal(b, L) + return pack(b) +end + +-- ------------------------------------------------------------------ +-- What the cart holds comes back out +-- ------------------------------------------------------------------ + +for _, version in ipairs({ "gold", "silver", "crystal" }) do + local save, err = Gen2Save.decode(build(version), version) + check(save ~= nil, version .. ": a valid save decodes -- " .. tostring(err)) + if save then + eq(save.player.name, "ASH", version .. ": player name") + eq(save.rival.name, "GARY", version .. ": rival name") + eq(save.player.id, 0x1234, version .. ": trainer id is big-endian") + eq(save.player.money, 123456, version .. ": money is a 3-byte big-endian") + eq(save.player.badges.ZEPHYR, true, version .. ": badges are keyed by name") + eq(save.player.badges.PLAIN, true, version .. ": and the second bit too") + eq(save.player.badges.HIVE, nil, version .. ": an unearned badge is absent") + eq(#save.party, 1, version .. ": party size") + local m = save.party[1] + eq(m.species, 155, version .. ": species, uncrosswalked") + eq(m.level, 42, version .. ": level") + eq(m.maxHp, 100, version .. ": max hp") + eq(m.stats.attack, 55, version .. ": computed stats come off the party tail") + eq(#m.moves, 2, version .. ": empty move slots are dropped") + eq(m.nickname, "FLAME", version .. ": nickname") + eq(m.ot, "ASH", version .. ": OT name") + eq(m.happiness, 200, version .. ": happiness") + -- DVs are nibbles, and the HP DV is rebuilt from the other four's low bits + eq(m.dvs.attack, 9, version .. ": attack DV is the high nibble") + eq(m.dvs.defense, 15, version .. ": defense DV is the low nibble") + eq(m.dvs.speed, 6, version .. ": speed DV") + eq(m.dvs.special, 10, version .. ": special DV") + eq(m.dvs.hp, 12, version .. ": HP DV is rebuilt, not stored") + eq(#save.boxes, 14, version .. ": every box is present") + eq(#save.boxes[3], 1, version .. ": box 3 holds one Pokemon") + eq(save.boxes[3][1].species, 7, version .. ": the stored species, uncrosswalked") + eq(save.boxes[3][1].nickname, "SQUIRT", version .. ": box nicknames follow the OTs") + eq(save.boxes[3][1].ot, "ASH", version .. ": box OT") + eq(save.boxes[3][1].hp, nil, version .. ": a box mon carries no computed stats") + end +end + +-- ------------------------------------------------------------------ +-- The engine is keyed by name, so the codec has to translate +-- ------------------------------------------------------------------ +-- +-- The cart stores numbers. save.inventory holds POTION, mon.species is +-- "TYPHLOSION", data.pokemon is indexed by that name. Without this the import +-- looks perfect and the engine cannot read a byte of it. + +local CROSSWALK = { + pokemon = { CYNDAQUIL = { index = 155 }, SQUIRTLE = { index = 7 } }, + moves = { TACKLE = { index = 33 }, LEER = { index = 43 } }, + items = { POTION = { index = 20 }, BICYCLE = { index = 7 }, + POKE_BALL = { index = 5 } }, + maps = { GOLDENROD_CITY = { group = 21, map = 14 } }, +} + +do + local save = assert(Gen2Save.decode(build("gold"), "gold", CROSSWALK)) + local m = save.party[1] + eq(m.species, "CYNDAQUIL", "species is the engine's id, not the cart's number") + eq(m.moves[1], "TACKLE", "and so are moves") + eq(m.moves[2], "LEER", "both of them") + eq(save.boxes[3][1].species, "SQUIRTLE", "boxes translate too") + + -- One flat bag. Nothing in src reads save.keyItems or save.balls; PackMenu + -- buckets save.inventory by each item's own pocket. + eq(save.keyItems, nil, "there is no separate key item table") + eq(save.balls, nil, "nor a separate ball table") + eq(save.inventory.POTION, 3, "the ITEM pocket lands in the bag") + eq(save.inventory.BICYCLE, 1, "so does KEY_ITEM, which is why you can cycle") + eq(save.inventory.POKE_BALL, 9, "and BALL, which is why you can throw one") + + -- Save.summary does `save.position.map or save.spawn`, so a save with no + -- map key resumes at the spawn point with the old coordinates. + eq(save.position.map, "GOLDENROD_CITY", "position names the map it is on") + + -- save.pokedex.caught[species] = true, keyed the same way. + local dexKey = next(save.pokedex.caught) + check(dexKey == nil or type(dexKey) == "string" or type(dexKey) == "number", + "the dex is keyed by species id") + + -- Save.scrubEvents runs tonumber over the VALUE against Save.EVENT_BYTES, + -- and tonumber(true) is nil, so a set of booleans is silently emptied. + eq(type(save.events[0]), "number", "events are packed bytes, not booleans") + local evCount = 0 + for _ in pairs(save.events) do evCount = evCount + 1 end + eq(evCount, Gen2Save.EVENT_BYTES, "one entry per event byte") + + -- 0 is truthy in Lua, so a raw status byte makes healthy mons look ill. + eq(m.status, "brn", "status is the engine's class string") + eq(save.boxes[3][1].status, nil, "and nil when healthy, not 0") +end + +-- Without a crosswalk the raw numbers survive rather than being dropped: an id +-- this build cannot name is still the player's. +do + local save = assert(Gen2Save.decode(build("gold"), "gold")) + eq(save.party[1].species, 155, "an unknown species keeps its cart number") +end + +-- ------------------------------------------------------------------ +-- A save the real cartridge would open must not be refused +-- ------------------------------------------------------------------ +-- +-- TryLoadSaveFile checks the primary, and on failure VerifyBackupChecksum and +-- LoadBackupPlayerData. Refusing on the primary alone reports a save the game +-- itself would load as corrupt, which is what #1832 was about. + +do + local L = Gen2Save.layoutFor("crystal") + check(L.backup ~= nil, "Crystal carries a backup layout") + eq(Gen2Save.layoutFor("gold").backup, nil, + "Gold and Silver split theirs across three sections, so they have none") + + local good = build("crystal") + local at = L.sChecksum + 1 + local broken = good:sub(1, at - 1) + .. string.char((good:byte(at) + 1) % 256) .. good:sub(at + 1) + + eq(Gen2Save.checksumValid(broken, L), false, "the primary is now corrupt") + eq(Gen2Save.checksumValid(broken, L.backup), true, "the backup is not") + local save, err = Gen2Save.decode(broken, "crystal") + check(save ~= nil, "so the save still opens -- " .. tostring(err)) + if save then + eq(save.player.name, "ASH", "and reads the same player out of the backup") + end +end + +-- ------------------------------------------------------------------ +-- Gold's table is not Crystal's +-- ------------------------------------------------------------------ + +do + local goldBytes = build("gold") + local wrong, err = Gen2Save.decode(goldBytes, "crystal") + check(wrong == nil, "a Gold save read with Crystal's table is refused") + check(type(err) == "string" and err:find("checksum", 1, true) ~= nil, + "and refused by the guard, not by luck -- got: " .. tostring(err)) + check(Gen2Layout.goldSilver.wPartyMons ~= Gen2Layout.crystal.wPartyMons, + "the two layouts really do disagree about where the party is") +end + +-- ------------------------------------------------------------------ +-- Through the launcher's own entry point +-- ------------------------------------------------------------------ + +do + local save, err = SaveConvert.importSav(build("gold"), "gold", "gold") + check(save ~= nil, "importSav accepts a Gen 2 save now -- " .. tostring(err)) + if save then + eq(save.player.name, "ASH", "and returns the decoded player") + eq(save.generation, 2, "tagged as Gen 2") + -- Everything the cart does not carry still has to be there. + check(type(save.mail) == "table", "mail falls back to the new-game default") + check(type(save.hallOfFame) == "table", "so does the hall of fame") + check(type(save.phoneContacts) == "table", "and the phone book") + end + local _, expErr = SaveConvert.exportSav({ meta = {} }, "gold") + check(type(expErr) == "string" and expErr:find("exporting", 1, true) ~= nil, + "export is still refused, and says so -- got: " .. tostring(expErr)) +end + +-- ------------------------------------------------------------------ +-- Real-save audit (fixture-gated) +-- ------------------------------------------------------------------ + +local fixture = os.getenv("POKEPORT_GEN2_SAV_FIXTURE") +local fixtureVersion = os.getenv("POKEPORT_GEN2_SAV_VERSION") or "crystal" +if not fixture then + print("real-save audit skipped (set POKEPORT_GEN2_SAV_FIXTURE to a Gen 2 .sav, " + .. "and POKEPORT_GEN2_SAV_VERSION to gold/silver/crystal)") +else + local f = io.open(fixture, "rb") + local bytes = f and f:read("*a") + if f then f:close() end + check(bytes ~= nil, "the fixture is readable") + if bytes then + local save, err = Gen2Save.decode(bytes, fixtureVersion) + check(save ~= nil, "a real cart save decodes -- " .. tostring(err)) + if save then + check(#save.party >= 1 and #save.party <= 6, "party size is possible") + check(#save.player.name > 0, "the player has a name") + for i, mon in ipairs(save.party) do + check(mon.species >= 1 and mon.species <= Gen2Save.NUM_SPECIES, + ("party %d species is a real species (%d)"):format(i, mon.species)) + check(mon.level >= 1 and mon.level <= 100, + ("party %d level is possible (%d)"):format(i, mon.level)) + check(mon.dvs.attack <= 15 and mon.dvs.special <= 15, + ("party %d DVs are nibbles"):format(i)) + end + for b, box in ipairs(save.boxes) do + check(#box <= Gen2Save.BOX_CAPACITY, ("box %d holds at most 20"):format(b)) + end + end + end +end + +T.finish() diff --git a/tests/engine/gen2_save_import_message.lua b/tests/engine/gen2_save_import_message.lua new file mode 100644 index 00000000..df011639 --- /dev/null +++ b/tests/engine/gen2_save_import_message.lua @@ -0,0 +1,123 @@ +-- A real Gen 2 cart save is 32786 bytes, and that must not be held against it +-- (#1832). +-- luajit tests/gen2_save_import_message_test.lua +-- Also dofile'd by tests/run_tests.lua. +-- +-- Gen 2 carts are MBC3+TIMER, so a real Gold/Silver/Crystal battery save +-- carries an RTC footer past the 32768 bytes of SRAM. SaveFileIO.importToSlot +-- judges anything that is not exactly SAVE_SIZE, and it used to judge it with +-- pokered's main-data checksum whatever game it was for, so every real Gen 2 +-- save came back "save data checksum invalid" -- a perfectly good save +-- reported as corrupt. +-- +-- This is the size gate specifically. tests/gen2_save_import_test.lua covers +-- the codec. +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = love or require("tests.love_stub") + +-- SaveData is stubbed so this stays about the gate: the real one wants a +-- filesystem and a registry, and neither is the subject here. +local written = {} +package.loaded["src.core.SaveData"] = { + load = function() return { meta = {} } end, + activeSlot = function() return "slot1" end, + buildMeta = function(_, m) return m or {} end, + createSlot = function() return "slot1" end, + writeSlot = function(_, _, save) written[#written + 1] = save return true end, + setActiveSlot = function() return true end, +} + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local Gen2Save = require("src.save_convert.Gen2Save") +local SaveConvert = require("src.save_convert.SaveConvert") +local SaveFileIO = require("src.import.SaveFileIO") + +-- The size a real Gen 2 cart save actually is: 32768 of SRAM plus an 18-byte +-- RTC footer. Verified against a real Gold cartridge in an emulator core, +-- which reports the cart's backup size as exactly this. +local GEN2_CART_SAVE_SIZE = 32786 + +local function validSave(version, trailing) + local L = Gen2Save.layoutFor(version) + local b = {} + for i = 0, Gen2Save.SAVE_SIZE - 1 do b[i] = 0 end + b[L.wPlayerName] = 0x80 -- "A", so the save is not entirely blank + b[L.wPlayerName + 1] = 0x50 + b[L.sCheckValue1] = 0x63 + b[L.sCheckValue2] = 0x7F + local sum = 0 + for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + b[i]) % 65536 end + b[L.sChecksum] = sum % 256 + b[L.sChecksum + 1] = math.floor(sum / 256) % 256 + local out = {} + for i = 0, Gen2Save.SAVE_SIZE - 1 do out[i + 1] = string.char(b[i]) end + return table.concat(out) .. string.rep("\0", trailing or 0) +end + +local function savFile(bytes) + local path = os.tmpname() + local f = assert(io.open(path, "wb")) + f:write(bytes) + f:close() + return path +end + +-- ------------------------------------------------------------------ +-- The report: a real-sized Gen 2 save imports +-- ------------------------------------------------------------------ + +for _, version in ipairs({ "gold", "silver", "crystal" }) do + local trailing = GEN2_CART_SAVE_SIZE - Gen2Save.SAVE_SIZE + local path = savFile(validSave(version, trailing)) + written = {} + -- force is deliberately NOT passed: the footer is the normal shape of a Gen + -- 2 cart save, so it must not raise the oversize confirmation either. + local ok, err = SaveFileIO.importToSlot(path, version) + check(ok == true, + version .. ": a 32786-byte cart save imports -- " .. tostring(err)) + check(type(err) ~= "string" or err:find("checksum", 1, true) == nil, + version .. ": and is never blamed on a checksum -- " .. tostring(err)) + eq(#written, 1, version .. ": the slot is written") +end + +-- ------------------------------------------------------------------ +-- The checksum question is asked of the right generation +-- ------------------------------------------------------------------ + +do + local gen2 = validSave("gold") + eq(SaveConvert.mainChecksumValid(gen2, "gold"), true, + "a Gen 2 save is valid under Gen 2's rule") + eq(SaveConvert.mainChecksumValid(gen2), false, + "and would read as invalid under Gen 1's, which is the whole bug") + check(SaveConvert.isGen2Cart("crystal"), "Crystal is a Gen 2 cart") + check(not SaveConvert.isGen2Cart("red"), "Red is not") +end + +-- ------------------------------------------------------------------ +-- Gen 1 keeps its own diagnosis +-- ------------------------------------------------------------------ + +do + local path = savFile(string.rep("\0", GEN2_CART_SAVE_SIZE)) + local ok, err = SaveFileIO.importToSlot(path, "red", true) + check(ok == false, "red: a corrupt oversize save is still refused") + check(type(err) == "string" and err:find("checksum", 1, true) ~= nil, + "red: still diagnosed by pokered's checksum -- got: " .. tostring(err)) +end + +-- ------------------------------------------------------------------ +-- Export is still one-way, and still says so +-- ------------------------------------------------------------------ + +for _, version in ipairs({ "gold", "silver", "crystal" }) do + local ok, why = SaveConvert.exportSupported(version) + eq(ok, false, version .. ": export is still refused") + check(type(why) == "string" and why:find("exporting", 1, true) ~= nil, + version .. ": and the sentence is about exporting -- " .. tostring(why)) +end + +T.finish() diff --git a/tests/engine/save_import_retry_bug420.lua b/tests/engine/save_import_retry_bug420.lua index bf14507c..f638e91e 100644 --- a/tests/engine/save_import_retry_bug420.lua +++ b/tests/engine/save_import_retry_bug420.lua @@ -226,6 +226,8 @@ do -- The double has to answer it; "yes" is what keeps this case about the -- cache-name contract below and nothing else. importSupported = function() return true end, + -- Red is not a Gen 2 cart, which is what this case uses. + isGen2Cart = function() return false end, importSav = function(_, version, gameVersion) seen.import = { version = version, gameVersion = gameVersion } return nil, "stub" diff --git a/tests/gen2_save_convert_cli_test.lua b/tests/gen2_save_convert_cli_test.lua index 3050b1a1..0c9af776 100644 --- a/tests/gen2_save_convert_cli_test.lua +++ b/tests/gen2_save_convert_cli_test.lua @@ -67,15 +67,21 @@ check(out:find("Gen 2 cart save", 1, true) ~= nil, check(not exists(outPath), "and no 32768-byte file that looks like a Red battery is written") --- The same gate on the way in, when the caller names the game. +-- The way IN is no longer a gate: Gen 2 imports through +-- src/save_convert/Gen2Save.lua now. What this pins is that the bytes reach +-- that codec and are judged by ITS rules -- an all-zero image has neither of +-- Gen 2's check values, so it is refused for being blank rather than for +-- being Gold. local savPath = tmp("in.sav") local outPath2 = tmp("in.lua") os.remove(outPath2) write(savPath, string.rep("\0", 32768)) out = run(("luajit tools/save_convert/convert.lua import %q %q gold") :format(savPath, outPath2)) -check(out:find("Gen 2 cart save", 1, true) ~= nil, - "importing for a Gen 2 game is refused too") +check(out:find("not supported yet", 1, true) == nil, + "importing for a Gen 2 game is no longer refused by version: " .. (out:gsub("%s+$", ""))) +check(out:find("checksum", 1, true) ~= nil, + "a blank image is refused on Gen 2's own check values: " .. (out:gsub("%s+$", ""))) check(not exists(outPath2), "and writes nothing") -- Gen 1 keeps working: a Red-shaped save is never caught by the Gen 2 gate. diff --git a/tests/gen2_save_export_test.lua b/tests/gen2_save_export_test.lua index 9de29b88..3f9f7ece 100644 --- a/tests/gen2_save_export_test.lua +++ b/tests/gen2_save_export_test.lua @@ -77,12 +77,17 @@ for path in pairs(files) do end eq(exported, false, "no export file is written for a Gold slot") --- The import direction through the same seam: a 32 KB image aimed at Gold --- must be refused by version, before any Gen 1 decoding is attempted. +-- The import direction no longer matches the export one. Gold imports through +-- Gen2Save now, so a 32 KB image aimed at Gold is decoded rather than turned +-- away by version. An all-zero image still fails, because Gen 2's guards are +-- two check values and a sum and a blank image has none of them: refused for +-- what it is, not for which game it is for. local iok, ierr = SaveConvert.importSav(string.rep("\0", 32768), "gold", "gold") -eq(iok, nil, "importing a cart .sav for Gold is refused") -check(type(ierr) == "string" and ierr:find("not supported yet", 1, true), - "the import refusal is the plain launcher message: " .. tostring(ierr)) +eq(iok, nil, "a blank cart .sav for Gold is still refused") +check(type(ierr) == "string" and ierr:find("not supported yet", 1, true) == nil, + "and no longer refused by version: " .. tostring(ierr)) +check(type(ierr) == "string" and ierr:find("checksum", 1, true) ~= nil, + "it is Gen 2's own guards that turn it away: " .. tostring(ierr)) -- Gen 1 versions still pass the gate: red reaches the codec proper and -- fails on its own terms (an all-zero image is not a table), never on the diff --git a/tests/gen2_save_import_message_test.lua b/tests/gen2_save_import_message_test.lua deleted file mode 100644 index 652479f2..00000000 --- a/tests/gen2_save_import_message_test.lua +++ /dev/null @@ -1,96 +0,0 @@ --- A Gen 2 cart save must be refused as a Gen 2 cart save, not as a corrupt --- Gen 1 one (#1832). --- luajit tests/gen2_save_import_message_test.lua --- Also dofile'd by tests/run_tests.lua. --- --- SaveFileIO.importToSlot judges anything that is not exactly SAVE_SIZE with --- SaveConvert.mainChecksumValid, which is pokered's main-data checksum. Gen 2 --- carts are MBC3+TIMER, so a real Gold/Silver/Crystal battery save carries an --- RTC footer and is 32786 bytes: it misses the size test, is then measured --- against a checksum rule written for a different generation, and the launcher --- tells the player their save is corrupt. It is not -- there is simply no Gen --- 2 codec yet, which is a different sentence and an actionable one. -package.path = "./?.lua;./?/init.lua;" .. package.path - -love = love or require("tests.love_stub") - -local S = require("tests.harness").suite("gen2 save import message") -local check = S.check - -local SaveConvert = require("src.save_convert.SaveConvert") -local SaveFileIO = require("src.import.SaveFileIO") - --- The size a real Gen 2 cart save actually is: 32768 bytes of SRAM plus the --- 18-byte RTC footer an MBC3+TIMER cart writes. -local GEN2_CART_SAVE_SIZE = 32786 - -local function blob(n) return string.rep("\0", n) end - --- readSource only takes a raw string when it is EXACTLY 32768 bytes; anything --- else is treated as a picker path (its own comment says so). A real Gen 2 --- cart save is 32786, so it can only ever reach importToSlot as a FILE -- which --- is exactly how the player in #1832 supplied theirs. Write one and hand over --- the path, so this exercises the route the report came from. -local function savFile(n) - local path = os.tmpname() - local f = assert(io.open(path, "wb")) - f:write(blob(n)) - f:close() - return path -end - --- ------------------------------------------------------------------ --- The report: a real Gen 2 save is not "checksum invalid" --- ------------------------------------------------------------------ - -for _, version in ipairs({ "gold", "silver", "crystal" }) do - local ok, err = SaveFileIO.importToSlot(savFile(GEN2_CART_SAVE_SIZE), version, true) - check(ok == false, version .. ": a Gen 2 cart save is still refused") - check(type(err) == "string" and err:find("Gen 2 cart save", 1, true) ~= nil, - version .. ": refused AS a Gen 2 save -- got: " .. tostring(err)) - check(type(err) == "string" and err:find("checksum", 1, true) == nil, - version .. ": never blamed on a checksum it was never measured by -- got: " - .. tostring(err)) -end - --- ------------------------------------------------------------------ --- The predicate both callers share --- ------------------------------------------------------------------ - -for _, version in ipairs({ "red", "blue", "yellow" }) do - check(SaveConvert.importSupported(version) == true, - version .. ": Gen 1 import is unaffected") - check(SaveConvert.exportSupported(version) == true, - version .. ": Gen 1 export is unaffected") -end - -for _, version in ipairs({ "gold", "silver", "crystal" }) do - local impOk, impWhy = SaveConvert.importSupported(version) - local expOk, expWhy = SaveConvert.exportSupported(version) - check(impOk == false and expOk == false, version .. ": both directions say no") - -- One sentence per direction, wherever it is asked from: the early gate in - -- SaveFileIO and the late one inside importSav must not describe the same - -- game two different ways. - local _, lateWhy = SaveConvert.importSav(blob(32768), version, version) - check(impWhy == lateWhy, - version .. ": the early gate and importSav answer identically") - check(expWhy:find("exporting", 1, true) ~= nil, - version .. ": the export sentence is about exporting") -end - --- ------------------------------------------------------------------ --- Gen 1 keeps its own diagnosis --- ------------------------------------------------------------------ --- --- A Gen 1 save that really is the wrong size AND fails pokered's checksum must --- still say so: this fix moves the generation check in front of that test, it --- does not remove it. - -do - local ok, err = SaveFileIO.importToSlot(savFile(GEN2_CART_SAVE_SIZE), "red", true) - check(ok == false, "red: a corrupt oversize save is still refused") - check(type(err) == "string" and err:find("checksum", 1, true) ~= nil, - "red: still diagnosed by pokered's checksum -- got: " .. tostring(err)) -end - -S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 716bf5bf..8c2b14b2 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -3640,7 +3640,6 @@ runSuites(orderedGlob( -- name resolution, and the .sav converter refusing a Gen 2 save table. "tests/gen2_sound_alias_test.lua", "tests/gen2_save_convert_cli_test.lua", - "tests/gen2_save_import_message_test.lua", -- The wall radios (`special MapRadio`). gen2_save_export_test cannot share a -- process (LEAKS_SAVE_SLOT_STATE above); tests/run_gen2.lua runs it alone. "tests/gen2_map_radio_test.lua", diff --git a/tools/gen2_sram_offsets.py b/tools/gen2_sram_offsets.py new file mode 100644 index 00000000..f3469ebd --- /dev/null +++ b/tools/gen2_sram_offsets.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Emit src/save_convert/Gen2Layout.lua from a pret build's symbol files. + +Gen 2 saves a contiguous WRAM block into SRAM bank 1, so every field's file +offset inside the 32768-byte battery image is + + sPlayerData_offset + (wField - wPlayerData) + +with sPlayerData_offset = sPlayerData - $A000 + $2000. That relation is +asserted below against sPokemonData, which appears on both sides. + +Nothing here is transcribed. Build pret/pokegold and pret/pokecrystal, then: + + python3 tools/gen2_sram_offsets.py \ + --gold path/to/pokegold.sym \ + --crystal path/to/pokecrystal.sym \ + > src/save_convert/Gen2Layout.lua + +Gold and Silver share a layout (pokesilver.sym agrees byte-exact); Crystal does +not, and that is the whole reason this file emits two tables. +""" +import argparse, re, sys + +FIELDS = [ + "wPlayerName", "wPlayerID", "wMoney", "wCoins", "wBadges", "wKantoBadges", + "wRivalName", "wMomsName", "wPartyCount", "wPartySpecies", "wPartyMons", + "wPartyMonNicknames", "wPartyMonOTs", "wNumItems", "wItems", "wNumKeyItems", + "wKeyItems", "wNumBalls", "wBalls", "wTMsHMs", "wPokedexCaught", + "wPokedexSeen", "wCurBox", "wBoxNames", "wMapGroup", "wMapNumber", + "wXCoord", "wYCoord", "wEventFlags", "wPlayerState", + "wGameTimeHours", "wGameTimeMinutes", +] +GUARDS = ["sCheckValue1", "sCheckValue2", "sChecksum", "sGameData", "sGameDataEnd"] + +# The 14 archived PC boxes. Emitted as real per-box offsets, never a stride: +# boxes 1-7 live in SRAM bank 2 and 8-14 in bank 3, so the step from box 7 to +# box 8 is 0x620 rather than the 0x450 every other pair uses. Computing them +# from a uniform stride puts boxes 8-14 in the wrong place, and a real save +# then reports box counts like 243 and 196. +BOX_COUNT = 14 + + +def load(path): + out = {} + for line in open(path): + m = re.match(r"^(\w\w):(\w{4})\s+(\S+)\s*$", line) + if m: + out.setdefault(m.group(3), (int(m.group(1), 16), int(m.group(2), 16))) + return out + + +# The backup copy the game falls back to when the primary checksum fails +# (TryLoadSaveFile -> VerifyBackupChecksum). Crystal's is contiguous and laid +# out exactly like the primary, so it is the same table shifted. Gold and +# Silver split theirs across three sections and are not derivable this way, +# which is why only Crystal gets one. +def backup_table(sym, rows, label): + need = ["sBackupGameData", "sBackupGameDataEnd", "sBackupCheckValue1", + "sBackupCheckValue2", "sBackupChecksum", "sGameData"] + if any(n not in sym for n in need): + return None + off = lambda n: sym[n][0] * 0x2000 + (sym[n][1] - 0xA000) + # File offsets, not raw addresses: the backup lives in SRAM bank 0 and the + # primary in bank 1, so an address-only delta is off by a bank. + delta = off("sBackupGameData") - off("sGameData") + guards = {"sCheckValue1": off("sBackupCheckValue1"), + "sCheckValue2": off("sBackupCheckValue2"), + "sChecksum": off("sBackupChecksum"), + "sGameData": off("sBackupGameData"), + "sGameDataEnd": off("sBackupGameDataEnd")} + out = [] + for name, value in rows: + if name in guards: + out.append((name, guards[name])) + else: + out.append((name, value + delta)) + return out + + +def table(sym, label): + need = ["sPlayerData", "wPlayerData", "sPokemonData", "wPokemonData"] + GUARDS + missing = [n for n in need if n not in sym] + if missing: + sys.exit(f"{label}: symbol file is missing {missing}") + base = sym["sPlayerData"][1] - 0xA000 + 0x2000 + anchor = sym["wPlayerData"][1] + # The block relation, asserted rather than assumed. + if sym["sPokemonData"][1] - sym["sPlayerData"][1] != \ + sym["wPokemonData"][1] - sym["wPlayerData"][1]: + sys.exit(f"{label}: the WRAM block is not copied contiguously; " + "the offset relation this generator rests on does not hold") + lo, hi = sym["sGameData"][1], sym["sGameDataEnd"][1] + rows, skipped = [], [] + for g in GUARDS: + rows.append((g, sym[g][1] - 0xA000 + 0x2000)) + for f in FIELDS: + w = sym.get(f) + if not w: + skipped.append(f + " (absent)") + continue + # Only fields INSIDE the saved block are addressable this way. Crystal's + # wPlayerGender sits before wPlayerData and belongs to sCrystalData, and + # the naive subtraction gives a confident wrong answer for it. + if not (lo <= w[1] - anchor + sym["sPlayerData"][1] < hi): + skipped.append(f + " (outside sGameData..sGameDataEnd)") + continue + rows.append((f, base + (w[1] - anchor))) + boxes = [] + for i in range(1, BOX_COUNT + 1): + b = sym.get("sBox%d" % i) + if not b: + sys.exit("%s: sBox%d is missing" % (label, i)) + # General SRAM form, which the bank-1 arithmetic above is a case of: + # file offset = bank * 0x2000 + (addr - $A000). + boxes.append(b[0] * 0x2000 + (b[1] - 0xA000)) + return rows, skipped, boxes + + +# The cart's own text table, so a name with an apostrophe, an accent or the PK +# glyph in it survives the round trip. Hand-keeping this list is how a player +# called "Mattia" comes back as "Mattia?". +CHARMAP_RE = re.compile(r'^\s*charmap\s+"(.+?)",\s*\$([0-9a-fA-F]{2})\s*(?:;.*)?$') + + +def emit_charmap(path): + rows = {} + for line in open(path, encoding="utf-8"): + m = CHARMAP_RE.match(line) + if not m: + continue + glyph, code = m.group(1), int(m.group(2), 16) + # Control tokens are not text; the name fields never contain them. + if glyph.startswith("<") and glyph.endswith(">"): + inner = glyph[1:-1] + if inner in ("PK", "MN", "PO", "KE"): + rows.setdefault(code, inner) + continue + rows.setdefault(code, glyph) + print("Gen2Layout.charmap = {") + for code in sorted(rows): + glyph = rows[code].replace("\\", "\\\\").replace('"', '\\"') + print(f' [0x{code:02X}] = "{glyph}",') + print("}") + print() + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--gold", required=True) + ap.add_argument("--crystal", required=True) + ap.add_argument("--charmap", required=False, + help="path to pokegold constants/charmap.asm; emits the " + "text table too when given") + a = ap.parse_args() + print("-- GENERATED by tools/gen2_sram_offsets.py. Do not edit by hand.") + print("-- Regenerate from a pret/pokegold + pret/pokecrystal build; see that") + print("-- script's header for the derivation and the assertion behind it.") + print("local Gen2Layout = {}\n") + for key, path in (("goldSilver", a.gold), ("crystal", a.crystal)): + sym = load(path) + rows, skipped, boxes = table(sym, key) + backup = backup_table(sym, rows, key) + print(f"Gen2Layout.{key} = {{") + for n, off in rows: + print(f" {n} = 0x{off:04X},") + print(" -- The 14 archived boxes, listed rather than strided (see BOX_COUNT).") + print(" boxes = { " + ", ".join("0x%04X" % b for b in boxes) + " },") + if backup: + print(" -- The backup copy the game falls back to when the primary") + print(" -- checksum fails. Same shape, shifted.") + print(" backup = {") + for n, off in backup: + print(f" {n} = 0x{off:04X},") + print(" boxes = { " + ", ".join("0x%04X" % b for b in boxes) + " },") + print(" },") + print("}") + for s2 in skipped: + print(f"-- not addressable via the block: {s2}") + print() + if a.charmap: + emit_charmap(a.charmap) + print("return Gen2Layout") + + +main() From 71a5602b5812298145fcee7ab8c3b7a5b12fc1f2 Mon Sep 17 00:00:00 2001 From: Colson Rice Date: Thu, 27 Aug 2026 10:41:30 -0400 Subject: [PATCH 4/6] Export Gen 2 cart saves Closes the round trip. Gold, Silver and Crystal progress writes back to a cartridge save the real game boots. Writes into the image the save came from. Gen 2 SRAM holds a great deal this codec does not model and the real game trusts it on CONTINUE, so a save with no cartridge behind it is refused rather than built from nothing. That refusal is the known limitation: a game begun in this project has no lineage to write back into yet. The image lives BESIDE the slot as saves//.cart, not in the save table. 32 KB of binary in the serialized table is 40 KB of Lua source reparsed on every save and load, for every imported slot, forever. Only the primary copy is written. TryLoadSaveFile rewrites the backup from the primary the moment VerifyChecksum passes, so a stale backup heals itself on the first load and this does not need a second offset mapping. Crystal arranges its backup differently from Gold and Silver, so that matters. What encode now reaches that it did not: * the bag, all four pockets, bucketed by each item's own pocket, plus wCurBox and the box names. It used to leave them at whatever the template carried, so a potion bought in a session never arrived. * the RTC footer. importToSlot truncates to 32768, so the image is kept at its full length and the footer is carried through. Dropping it resets the clock and costs the player daily events, the bug contest and a clock-adjustment penalty. * 0x1C-0x1E, pokerus and caught data. Left to the template they survive POSITIONALLY, so reordering the party gives slot 1 the previous occupant's caught level and location. A bag that cannot be bucketed is refused rather than written short: without the item table every item falls into ITEM, which holds 20, and a real bag is bigger than that. Silently dropping the overflow would be worse than the bug this fixes. Two bugs in the text encoder that only real names caught: the cart's table carries the ligature halves PO and KE, so a name containing "PO" became 0x70 where the cart had a plain P; and #glyph counts BYTES in Lua, so every multi-byte glyph was dropped and came back as "?", which is NIDORAN and every name with an accent. Tests The round trip CHANGES things first, in each place export has to reach, and reads them back through a fresh decode. Exporting onto the buffer a save was decoded from proves nothing, because every region encode does not write matches by construction. The fixture-gated audit exports a real cart save too, and pins that the image keeps its size, RTC and all. ./scripts/test.sh passes end to end, and luacheck is clean. Co-Authored-By: Claude Opus 5 --- src/import/SaveFileIO.lua | 37 ++- src/save_convert/Gen2Save.lua | 316 ++++++++++++++++++++++ src/save_convert/SaveConvert.lua | 23 +- tests/engine/gen2_save_import.lua | 83 +++++- tests/engine/gen2_save_import_message.lua | 14 +- tests/gen2_save_convert_cli_test.lua | 7 +- tests/gen2_save_export_test.lua | 4 +- 7 files changed, 457 insertions(+), 27 deletions(-) diff --git a/src/import/SaveFileIO.lua b/src/import/SaveFileIO.lua index 3241ee71..a11f41b4 100644 --- a/src/import/SaveFileIO.lua +++ b/src/import/SaveFileIO.lua @@ -20,6 +20,37 @@ local GameVersion = require("src.core.GameVersion") local SaveFileIO = {} +-- The cartridge image an imported Gen 2 slot came from, kept BESIDE the slot +-- rather than inside it: export needs the regions the codec does not model, +-- and 32 KB of binary in the serialized table is 40 KB of Lua source reparsed +-- on every save and load. +local function cartPath(version, slotId) + return ("saves/%s/%s.cart"):format(version, tostring(slotId)) +end + +local function cartFs() + local portable = SaveData.portableFs and SaveData.portableFs() + return portable or (love and love.filesystem) +end + +local function writeCart(version, slotId, bytes) + local fs = cartFs() + if not (fs and fs.write and bytes) then return end + if fs.createDirectory then + fs.createDirectory("saves") + fs.createDirectory("saves/" .. version) + end + fs.write(cartPath(version, slotId), bytes) +end + +local function readCart(version, slotId) + local fs = cartFs() + if not (fs and fs.read) then return nil end + local ok, bytes = pcall(fs.read, cartPath(version, slotId)) + if ok and type(bytes) == "string" then return bytes end + return nil +end + local SAVE_SIZE = SaveConvert.SAVE_SIZE -- Resolve raw save bytes from whatever the launcher hands us: @@ -125,6 +156,7 @@ function SaveFileIO.importToSlot(source, version, force) return false, "could not write the imported save: " .. tostring(writeErr) end SaveData.setActiveSlot(version, slotId) + if SaveConvert.isGen2Cart(version) then writeCart(version, slotId, bytes) end return true, slotId end @@ -139,9 +171,10 @@ function SaveFileIO.exportActiveSlot(version) version = version or GameVersion.get() local save = SaveData.load(version) if not save then return false, "this game has no save to export yet" end - local bytes, exportErr = SaveConvert.exportSav(save, version) - if not bytes then return false, exportErr end local slotId = SaveData.activeSlot(version) or "save" + local bytes, exportErr = SaveConvert.exportSav(save, version, + readCart(version, slotId)) + if not bytes then return false, exportErr end -- Portable mode is the same seam SaveData's own persistFs uses: when -- portable.txt marks the install every persistent write leaves the OS save -- directory for the game folder, and an export is no exception. Writing diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index 776cd1fd..63369452 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -61,6 +61,14 @@ local function byIndex(defs) return out end +local function toIndex(defs) + local out = {} + for id, def in pairs(defs or {}) do + if type(def) == "table" and def.index ~= nil then out[id] = def.index end + end + return out +end + function Gen2Save.crosswalks(data) data = data or {} local items = byIndex(data.items) @@ -71,11 +79,22 @@ function Gen2Save.crosswalks(data) maps[def.group * 256 + def.map] = id end end + local mapIds = {} + for id, def in pairs(data.maps or {}) do + if type(def) == "table" and def.group and def.map then + mapIds[id] = { def.group, def.map } + end + end return { pokemon = byIndex(data.pokemon), moves = byIndex(data.moves), items = items, maps = maps, + pokemonIndex = toIndex(data.pokemon), + moveIndex = toIndex(data.moves), + itemIndex = toIndex(data.items), + mapIds = mapIds, + itemDefs = data.items or {}, } end @@ -90,6 +109,14 @@ end -- PAR=6. Engine wants an ItemEffects.STATUS_CLASS key, nil when healthy. local STATUS_BITS = { { 3, "psn" }, { 4, "brn" }, { 5, "frz" }, { 6, "par" } } +local function encodeStatus(name, turns) + if name == "slp" then return math.min(math.max(turns or 1, 1), 7) end + for _, row in ipairs(STATUS_BITS) do + if row[2] == name then return 2 ^ row[1] end + end + return 0 +end + local function decodeStatus(byte) local turns = byte % 8 if turns > 0 then return "slp", turns end @@ -137,6 +164,8 @@ local function decodeSharedMon(bytes, o, x) statExp = decodeStatExp(bytes, o + 0x0B), dvs = decodeDVs(bytes, o + 0x15), happiness = u8(bytes, o + 0x1B), + pokerus = u8(bytes, o + 0x1C), + caughtData = be(bytes, o + 0x1D, 2), level = u8(bytes, o + 0x1F), } end @@ -371,4 +400,291 @@ function Gen2Save.mergeDefaults(decoded, gameVersion) return base end +-- ------------------------------------------------------------------ +-- Export +-- ------------------------------------------------------------------ + +local function putU8(t, at, v) t[at] = v % 256 end +local function putBE(t, at, v, n) + for i = n - 1, 0, -1 do t[at + i] = v % 256; v = math.floor(v / 256) end +end + +-- Engine id back to the cart's number. A raw number passes through, which is +-- how a save imported without a crosswalk round trips. +local function indexOf(map, id) + if id == nil then return 0 end + if type(id) == "number" then return id end + return map[id] or 0 +end + +local function charBytes(str, i) + local b = str:byte(i) + if not b then return 0 end + if b < 0x80 then return 1 end + if b >= 0xF0 then return 4 end + if b >= 0xE0 then return 3 end + if b >= 0xC0 then return 2 end + return 1 +end + +local function glyphChars(glyph) + local n, i = 0, 1 + while i <= #glyph do i = i + charBytes(glyph, i); n = n + 1 end + return n +end + +-- One-CHARACTER glyphs plus PK and MN. The cart's table also carries the +-- ligature halves PO and KE, and accepting those turns a name containing "PO" +-- into 0x70 where the cart had a plain P. #glyph counts BYTES, so a byte test +-- would also drop every multi-byte glyph and turn NIDORAN into NIDORAN?. +local REVERSE = nil +local function reverseCharmap() + if REVERSE then return REVERSE end + REVERSE = {} + for code, glyph in pairs(Gen2Layout.charmap) do + if glyphChars(glyph) == 1 then REVERSE[glyph] = code end + end + for code, glyph in pairs(Gen2Layout.charmap) do + if glyph == "PK" or glyph == "MN" then REVERSE[glyph] = code end + end + return REVERSE +end + +local function putText(t, at, str, n) + local codes = reverseCharmap() + local i, written = 1, 0 + while i <= #str and written < n - 1 do + local two = str:sub(i, i + 1) + if (two == "PK" or two == "MN") and codes[two] then + t[at + written] = codes[two]; i = i + 2 + else + local w = charBytes(str, i) + t[at + written] = codes[str:sub(i, i + w - 1)] or 0xE6 + i = i + w + end + written = written + 1 + end + t[at + written] = 0x50 +end + +local function putBadges(t, at, owned, order) + local byte = 0 + for bit, name in ipairs(order) do + if owned and owned[name] then byte = byte + 2 ^ (bit - 1) end + end + t[at] = byte +end + +local function putSharedMon(t, o, mon, x) + putU8(t, o, indexOf(x.pokemonIndex, mon.species)) + putU8(t, o + 1, indexOf(x.itemIndex, mon.item)) + for i = 0, 3 do + putU8(t, o + 2 + i, indexOf(x.moveIndex, (mon.moves or {})[i + 1])) + end + putBE(t, o + 6, mon.otId or 0, 2) + putBE(t, o + 8, mon.experience or 0, 3) + local se = mon.statExp or {} + putBE(t, o + 0x0B, se.hp or 0, 2); putBE(t, o + 0x0D, se.attack or 0, 2) + putBE(t, o + 0x0F, se.defense or 0, 2); putBE(t, o + 0x11, se.speed or 0, 2) + putBE(t, o + 0x13, se.special or 0, 2) + local d = mon.dvs or {} + putU8(t, o + 0x15, (d.attack or 0) * 16 + (d.defense or 0)) + putU8(t, o + 0x16, (d.speed or 0) * 16 + (d.special or 0)) + for i = 0, 3 do + putU8(t, o + 0x17 + i, (mon.ppRaw or {})[i + 1] or (mon.pp or {})[i + 1] or 0) + end + putU8(t, o + 0x1B, mon.happiness or 0) + -- 0x1C-0x1E belong to the mon, not to the slot: leaving them to the template + -- means reordering the party gives slot 1 the previous occupant's pokerus + -- and caught data. + putU8(t, o + 0x1C, mon.pokerus or 0) + putBE(t, o + 0x1D, mon.caughtData or 0, 2) + putU8(t, o + 0x1F, mon.level or 0) +end + +-- The bag back into its four pockets, by each item's own `pocket`. +local function putBag(t, L, inventory, x) + local buckets = { ITEM = {}, KEY_ITEM = {}, BALL = {}, TM_HM = {} } + for id, count in pairs(inventory or {}) do + local def = x.itemDefs[id] + local pocket = (type(def) == "table" and def.pocket) or "ITEM" + if buckets[pocket] == nil then pocket = "ITEM" end + buckets[pocket][#buckets[pocket] + 1] = { id = id, count = count, def = def } + end + -- By cart index, so the order is deterministic. A flat inventory has no + -- order of its own, so the pocket order a round trip produces is stable + -- rather than original. + for _, list in pairs(buckets) do + table.sort(list, function(a, b) + return indexOf(x.itemIndex, a.id) < indexOf(x.itemIndex, b.id) + end) + end + + local overflow = nil + local function writePairs(countAt, listAt, cap, list, pocket) + if #list > cap then overflow = overflow or { pocket, #list, cap } end + local n = math.min(#list, cap) + putU8(t, countAt, n) + for i = 1, n do + putU8(t, listAt + (i - 1) * 2, indexOf(x.itemIndex, list[i].id)) + putU8(t, listAt + (i - 1) * 2 + 1, math.min(list[i].count, 99)) + end + putU8(t, listAt + n * 2, 0xFF) + end + local function writeIds(countAt, listAt, cap, list, pocket) + if #list > cap then overflow = overflow or { pocket, #list, cap } end + local n = math.min(#list, cap) + putU8(t, countAt, n) + for i = 1, n do putU8(t, listAt + i - 1, indexOf(x.itemIndex, list[i].id)) end + putU8(t, listAt + n, 0xFF) + end + + writePairs(L.wNumItems, L.wItems, 20, buckets.ITEM, "ITEM") + writeIds(L.wNumKeyItems, L.wKeyItems, 25, buckets.KEY_ITEM, "KEY_ITEM") + writePairs(L.wNumBalls, L.wBalls, 12, buckets.BALL, "BALL") + if L.wTMsHMs then + for _, row in ipairs(buckets.TM_HM) do + local number = type(row.def) == "table" and row.def.tmNumber + if number then putU8(t, L.wTMsHMs + number - 1, math.min(row.count, 99)) end + end + end + return overflow +end + +local function putFlagSet(t, at, set, count, indexFor) + for i = 0, count - 1 do + local byteAt = at + math.floor(i / 8) + local bit = 2 ^ (i % 8) + local cur = t[byteAt] or 0 + local on = math.floor(cur / bit) % 2 == 1 + local want = set and set[indexFor(i)] == true + if on ~= want then t[byteAt] = want and (cur + bit) or (cur - bit) end + end +end + +-- encode(save, gameVersion, template, data) -> bytes, err +-- +-- Writes into the cartridge image the save came from: Gen 2 SRAM holds a great +-- deal this codec does not model and the real game trusts it on CONTINUE, so a +-- save with no image behind it is refused rather than built from nothing. +-- +-- Only the primary copy is written. TryLoadSaveFile rewrites the backup from +-- the primary on every successful load. +function Gen2Save.encode(save, gameVersion, template, data) + local L = Gen2Save.layoutFor(gameVersion) + if not L then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end + if type(save) ~= "table" then return nil, "expected a save table" end + if type(template) ~= "string" or #template < Gen2Save.SAVE_SIZE then + return nil, "this save has no cartridge image to write back into, and a " + .. "Gen 2 save built from nothing does not boot on real hardware" + end + + local x = Gen2Save.crosswalks(data) + local t = {} + for i = 0, Gen2Save.SAVE_SIZE - 1 do t[i] = template:byte(i + 1) end + + local p = save.player or {} + putText(t, L.wPlayerName, p.name or "", Gen2Save.NAME_LENGTH) + putBE(t, L.wPlayerID, p.id or 0, 2) + putBE(t, L.wMoney, p.money or 0, 3) + putBE(t, L.wCoins, p.coins or 0, 2) + putBadges(t, L.wBadges, p.badges, Gen2Save.JOHTO_BADGES) + putBadges(t, L.wKantoBadges, p.kantoBadges, Gen2Save.KANTO_BADGES) + putText(t, L.wRivalName, (save.rival or {}).name or "", Gen2Save.NAME_LENGTH) + putText(t, L.wMomsName, (save.mom or {}).name or "", Gen2Save.NAME_LENGTH) + + local party = save.party or {} + if #party > Gen2Save.PARTY_LENGTH then + return nil, ("a party of %d cannot be written to a cartridge"):format(#party) + end + putU8(t, L.wPartyCount, #party) + for i, mon in ipairs(party) do + putU8(t, L.wPartySpecies + i - 1, indexOf(x.pokemonIndex, mon.species)) + local o = L.wPartyMons + (i - 1) * Gen2Save.PARTY_STRUCT + putSharedMon(t, o, mon, x) + putU8(t, o + 0x20, encodeStatus(mon.status, mon.statusTurns)) + putBE(t, o + 0x22, mon.hp or 0, 2) + local st = mon.stats or {} + putBE(t, o + 0x24, mon.maxHp or st.hp or 0, 2) + putBE(t, o + 0x26, st.attack or 0, 2); putBE(t, o + 0x28, st.defense or 0, 2) + putBE(t, o + 0x2A, st.speed or 0, 2); putBE(t, o + 0x2C, st.specialAttack or 0, 2) + putBE(t, o + 0x2E, st.specialDefense or 0, 2) + putText(t, L.wPartyMonNicknames + (i - 1) * Gen2Save.NAME_LENGTH, + mon.nickname or "", Gen2Save.NAME_LENGTH) + putText(t, L.wPartyMonOTs + (i - 1) * Gen2Save.NAME_LENGTH, + mon.ot or "", Gen2Save.NAME_LENGTH) + end + putU8(t, L.wPartySpecies + #party, 0xFF) + + for index, base in ipairs(L.boxes) do + local box = (save.boxes or {})[index] or {} + if #box > Gen2Save.BOX_CAPACITY then + return nil, ("box %d holds %d, which a cartridge cannot"):format(index, #box) + end + putU8(t, base, #box) + for i, mon in ipairs(box) do + putU8(t, base + BOX_SPECIES + i - 1, indexOf(x.pokemonIndex, mon.species)) + putSharedMon(t, base + BOX_MONS + (i - 1) * Gen2Save.BOX_MON_STRUCT, mon, x) + putText(t, base + BOX_OTS + (i - 1) * Gen2Save.NAME_LENGTH, + mon.ot or "", Gen2Save.NAME_LENGTH) + putText(t, base + BOX_NICKS + (i - 1) * Gen2Save.NAME_LENGTH, + mon.nickname or "", Gen2Save.NAME_LENGTH) + end + putU8(t, base + BOX_SPECIES + #box, 0xFF) + end + + -- Refuse rather than drop. Without item defs every item buckets into ITEM, + -- which holds 20, and a real bag is bigger than that. + local overflow = putBag(t, L, save.inventory, x) + if overflow then + return nil, ("the %s pocket would need %d slots and a cartridge has %d; " + .. "the item table for this game is needed to sort the bag") + :format(overflow[1], overflow[2], overflow[3]) + end + if save.currentBox then putU8(t, L.wCurBox, (save.currentBox - 1) % 16) end + if save.boxNames and L.wBoxNames then + for i = 1, 14 do + putText(t, L.wBoxNames + (i - 1) * 9, save.boxNames[i] or "", 9) + end + end + + if save.pokedex then + local function species(i) return x.pokemon[i + 1] or (i + 1) end + putFlagSet(t, L.wPokedexCaught, save.pokedex.caught, Gen2Save.NUM_SPECIES, species) + putFlagSet(t, L.wPokedexSeen, save.pokedex.seen, Gen2Save.NUM_SPECIES, species) + end + for i = 0, Gen2Save.EVENT_BYTES - 1 do + local byte = (save.events or {})[i] + if type(byte) == "number" then putU8(t, L.wEventFlags + i, byte) end + end + + local pos = save.position + if pos then + local ids = pos.map and x.mapIds[pos.map] + putU8(t, L.wMapGroup, (ids and ids[1]) or pos.mapGroup or 0) + putU8(t, L.wMapNumber, (ids and ids[2]) or pos.mapNumber or 0) + putU8(t, L.wXCoord, pos.x or 0) + putU8(t, L.wYCoord, pos.y or 0) + end + local pt = save.playTime + if pt then + putBE(t, L.wGameTimeHours, pt.hours or 0, 2) + putU8(t, L.wGameTimeMinutes, pt.minutes or 0) + end + + putU8(t, L.sCheckValue1, 0x63) + putU8(t, L.sCheckValue2, 0x7F) + local sum = 0 + for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + t[i]) % 65536 end + putU8(t, L.sChecksum, sum % 256) + putU8(t, L.sChecksum + 1, math.floor(sum / 256) % 256) + + local out = {} + for i = 0, Gen2Save.SAVE_SIZE - 1 do out[i + 1] = string.char(t[i]) end + -- Whatever followed the 32 KiB of SRAM is the cart's RTC footer. Dropping it + -- resets the clock, which costs the player daily events and the bug contest + -- and earns them the clock-adjustment penalty. + return table.concat(out) .. template:sub(Gen2Save.SAVE_SIZE + 1) +end + return Gen2Save diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index 6a16dec1..79b4ef89 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -22,7 +22,6 @@ local GenSave = require("src.save_convert.GenSave") local Gen2Save = require("src.save_convert.Gen2Save") -local GameVersion = require("src.core.GameVersion") local SaveConvert = {} @@ -258,13 +257,6 @@ SaveConvert.mergeDefaults = mergeDefaults -- pokered's single sPlayerName..sMainDataCheckSum window), and no Gen 2 -- codec exists yet. Both directions answer with a plain message the -- launcher's save card renders as-is, instead of pushing a Gen 2 save --- table through Gen 1 offsets and surfacing a codec traceback. -local function gen2CartName(gameVersion) - if not GameVersion.VERSIONS[gameVersion] then return nil end - if GameVersion.generation(gameVersion) ~= 2 then return nil end - return GameVersion.info(gameVersion).displayName -end - -- Can a cart save for this game cross in or out at all? Public because the -- launcher has to ask about the GAME before it measures the BYTES. -- @@ -291,11 +283,9 @@ function SaveConvert.importSupported(gameVersion) return true end +-- Gen 2 exports through Gen2Save now, but only for a save with a cartridge +-- image behind it; encode says so itself. function SaveConvert.exportSupported(gameVersion) - local gen2Name = gen2CartName(gameVersion) - if gen2Name then - return false, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet." - end return true end @@ -344,18 +334,23 @@ function SaveConvert.importSav(bytes, version, gameVersion) return mergeDefaults(decoded, version) end --- exportSav(saveTable, gameVersion) -> bytes, err +-- exportSav(saveTable, gameVersion, cartImage) -> bytes, err -- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware: -- if the table still carries the stashed import template (saveTable.rawImport) -- GenSave reproduces every unmodeled region from it; otherwise those regions -- are zero-filled. gameVersion selects the crosswalk tables exactly as in -- importSav. On failure returns nil + a message (never raises). -function SaveConvert.exportSav(saveTable, gameVersion) +function SaveConvert.exportSav(saveTable, gameVersion, cartImage) if type(saveTable) ~= "table" then return nil, "expected a save table" end local supported, unsupportedWhy = SaveConvert.exportSupported(gameVersion) if not supported then return nil, unsupportedWhy end + -- Gen 2 has its own SRAM and its own codec, and needs no Gen 1 crosswalks. + if Gen2Save.layoutFor(gameVersion) then + return Gen2Save.encode(saveTable, gameVersion, cartImage, + ensureGen2Data(gameVersion)) + end local data, derr = ensureData(gameVersion) if not data then return nil, derr end local ok, bytes = pcall(GenSave.encode, saveTable, data, nil) diff --git a/tests/engine/gen2_save_import.lua b/tests/engine/gen2_save_import.lua index d027132a..a53190ca 100644 --- a/tests/engine/gen2_save_import.lua +++ b/tests/engine/gen2_save_import.lua @@ -207,6 +207,66 @@ do eq(save.party[1].species, 155, "an unknown species keeps its cart number") end +-- ------------------------------------------------------------------ +-- Export: what goes in comes back out, including what changed +-- ------------------------------------------------------------------ +-- +-- Exporting a save onto the buffer it was decoded from proves nothing: every +-- region encode does not write matches because it was copied. So this CHANGES +-- things first, in each of the places export has to reach, and reads them back +-- through a fresh decode. + +do + local ITEMS = { + POTION = { index = 20, pocket = "ITEM" }, + BICYCLE = { index = 7, pocket = "KEY_ITEM" }, + POKE_BALL = { index = 5, pocket = "BALL" }, + TM_HEADBUTT = { index = 191, pocket = "TM_HM", tmNumber = 2 }, + } + local data = { + pokemon = CROSSWALK.pokemon, moves = CROSSWALK.moves, + items = ITEMS, maps = CROSSWALK.maps, + } + local cart = build("gold") + local save = assert(Gen2Save.decode(cart, "gold", data)) + + save.inventory = { POTION = 7, BICYCLE = 1, POKE_BALL = 12, TM_HEADBUTT = 1 } + save.currentBox = 5 + save.party[1].status, save.party[1].statusTurns = "slp", 3 + save.party[1].pokerus = 0x34 + save.party[1].caughtData = 0x1234 + save.events[9] = 0xA5 + + local out = assert(Gen2Save.encode(save, "gold", cart, data)) + eq(#out, #cart, "the image keeps its size") + local back = assert(Gen2Save.decode(out, "gold", data)) + + -- The bag: encode used to leave it at whatever the template carried, so a + -- potion bought in a session never reached the cartridge. + eq(back.inventory.POTION, 7, "the ITEM pocket is written") + eq(back.inventory.BICYCLE, 1, "and KEY_ITEM") + eq(back.inventory.POKE_BALL, 12, "and BALL") + eq(back.inventory.TM_HEADBUTT, 1, "and TM_HM, by its tmNumber") + eq(back.currentBox, 5, "the open box is written") + + -- 0x1C-0x1E are the mon's, not the slot's: left to the template, reordering + -- the party gives slot 1 the previous occupant's pokerus and caught data. + eq(back.party[1].pokerus, 0x34, "pokerus rides the mon") + eq(back.party[1].caughtData, 0x1234, "so does caught data") + + eq(back.party[1].status, "slp", "status survives as a class") + eq(back.party[1].statusTurns, 3, "with its turn count") + eq(back.events[9], 0xA5, "event bytes are written") +end + +-- A save with no cartridge image behind it is refused, not invented. +do + local out, err = Gen2Save.encode({ player = { name = "A" } }, "gold", nil, {}) + eq(out, nil, "encode refuses a save with no lineage") + check(type(err) == "string" and err:find("no cartridge image", 1, true) ~= nil, + "and says why -- " .. tostring(err)) +end + -- ------------------------------------------------------------------ -- A save the real cartridge would open must not be refused -- ------------------------------------------------------------------ @@ -264,9 +324,11 @@ do check(type(save.hallOfFame) == "table", "so does the hall of fame") check(type(save.phoneContacts) == "table", "and the phone book") end + -- Export needs the cartridge image the save came from. Without one it is + -- refused rather than built from nothing. local _, expErr = SaveConvert.exportSav({ meta = {} }, "gold") - check(type(expErr) == "string" and expErr:find("exporting", 1, true) ~= nil, - "export is still refused, and says so -- got: " .. tostring(expErr)) + check(type(expErr) == "string" and expErr:find("no cartridge image", 1, true) ~= nil, + "a save with no cartridge behind it is refused -- got: " .. tostring(expErr)) end -- ------------------------------------------------------------------ @@ -300,6 +362,23 @@ else for b, box in ipairs(save.boxes) do check(#box <= Gen2Save.BOX_CAPACITY, ("box %d holds at most 20"):format(b)) end + + -- And back out. Without the item table the bag cannot be bucketed, so + -- this asserts the refusal rather than a lossy write. + local out, err = Gen2Save.encode(save, fixtureVersion, bytes, {}) + if out then + eq(#out, #bytes, "the exported image keeps the cart's size, RTC and all") + local back = assert(Gen2Save.decode(out, fixtureVersion)) + eq(back.player.name, save.player.name, "the player survives the round trip") + eq(#back.party, #save.party, "and the party") + local a, b2 = 0, 0 + for _, box in ipairs(save.boxes) do a = a + #box end + for _, box in ipairs(back.boxes) do b2 = b2 + #box end + eq(b2, a, "and every stored Pokemon") + else + check(err:find("pocket", 1, true) ~= nil, + "or it refuses because the bag cannot be sorted -- " .. tostring(err)) + end end end end diff --git a/tests/engine/gen2_save_import_message.lua b/tests/engine/gen2_save_import_message.lua index df011639..3e8c4a0e 100644 --- a/tests/engine/gen2_save_import_message.lua +++ b/tests/engine/gen2_save_import_message.lua @@ -110,14 +110,18 @@ do end -- ------------------------------------------------------------------ --- Export is still one-way, and still says so +-- Export needs the cartridge image behind the save -- ------------------------------------------------------------------ +-- Export goes through the codec now, but only for a save that came from a +-- cartridge: the regions it does not model are the ones the real game trusts +-- on CONTINUE. for _, version in ipairs({ "gold", "silver", "crystal" }) do - local ok, why = SaveConvert.exportSupported(version) - eq(ok, false, version .. ": export is still refused") - check(type(why) == "string" and why:find("exporting", 1, true) ~= nil, - version .. ": and the sentence is about exporting -- " .. tostring(why)) + eq(SaveConvert.exportSupported(version), true, version .. ": export is supported") + local out, why = SaveConvert.exportSav({ meta = {}, player = { name = "A" } }, version) + eq(out, nil, version .. ": a save with no cartridge behind it is refused") + check(type(why) == "string" and why:find("no cartridge image", 1, true) ~= nil, + version .. ": and the reason is the missing image -- " .. tostring(why)) end T.finish() diff --git a/tests/gen2_save_convert_cli_test.lua b/tests/gen2_save_convert_cli_test.lua index 0c9af776..5cea280c 100644 --- a/tests/gen2_save_convert_cli_test.lua +++ b/tests/gen2_save_convert_cli_test.lua @@ -62,8 +62,11 @@ write(goldPath, SaveSerializer.encode({ local out = run(("luajit tools/save_convert/convert.lua export %q %q") :format(goldPath, outPath)) -check(out:find("Gen 2 cart save", 1, true) ~= nil, - "exporting a Gold save.lua is refused by name: " .. (out:gsub("%s+$", ""))) +-- Gen 2 exports through Gen2Save now, but only for a save that carries the +-- cartridge image it came from. A slot built in the launcher has none. +check(out:find("no cartridge image", 1, true) ~= nil, + "exporting a Gold slot with no cartridge behind it is refused, and says why: " + .. (out:gsub("%s+$", ""))) check(not exists(outPath), "and no 32768-byte file that looks like a Red battery is written") diff --git a/tests/gen2_save_export_test.lua b/tests/gen2_save_export_test.lua index 3f9f7ece..65ec1103 100644 --- a/tests/gen2_save_export_test.lua +++ b/tests/gen2_save_export_test.lua @@ -66,8 +66,8 @@ check(files["saves/gold/" .. tostring(slotId) .. ".lua"] ~= nil, local ok, res = SaveFileIO.exportActiveSlot("gold") eq(ok, false, "Export on a Gold slot is refused, not crashed") -check(type(res) == "string" and res:find("not supported yet", 1, true), - "the refusal is the plain launcher message: " .. tostring(res)) +check(type(res) == "string" and res:find("no cartridge image", 1, true), + "the refusal names the missing cartridge image: " .. tostring(res)) check(not tostring(res):find("GenSave", 1, true) and not tostring(res):find("attempt to index", 1, true), "no codec traceback leaks into the notice line") From 5e7dc556f5306281836e26fce690749d078f250a Mon Sep 17 00:00:00 2001 From: 1jamie Date: Thu, 27 Aug 2026 10:30:44 -0500 Subject: [PATCH 5/6] update flatpak information that was left as template default flatpak properly now marks as oss and points to site and gh --- flatpak/com.theboisclub.gen1recomp.metainfo.xml | 5 +++-- tests/engine/flatpak_manifest_test.lua | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/flatpak/com.theboisclub.gen1recomp.metainfo.xml b/flatpak/com.theboisclub.gen1recomp.metainfo.xml index 315a863e..2e12e5b5 100644 --- a/flatpak/com.theboisclub.gen1recomp.metainfo.xml +++ b/flatpak/com.theboisclub.gen1recomp.metainfo.xml @@ -4,7 +4,7 @@ gen1recomp Pokémon Gen 1/2 recompilation CC0-1.0 - LicenseRef-proprietary + MIT The Bois Club

@@ -14,7 +14,8 @@

com.theboisclub.gen1recomp.desktop - https://github.com/bryanthaboi/gen1recomp + https://gen1re.com/ + https://github.com/bryanthaboi/gen1recomp Launcher diff --git a/tests/engine/flatpak_manifest_test.lua b/tests/engine/flatpak_manifest_test.lua index d8b04ab1..ab81ee57 100644 --- a/tests/engine/flatpak_manifest_test.lua +++ b/tests/engine/flatpak_manifest_test.lua @@ -21,5 +21,13 @@ local xml = meta:read("*a") meta:close() check(xml:find("", 1, true), "AppStream metainfo includes releases") check(xml:find("MIT", 1, true), + "AppStream metainfo declares MIT project license") +check(not xml:find("LicenseRef-proprietary", 1, true), + "AppStream metainfo must not claim proprietary") +check(xml:find('url type="homepage">https://gen1re.com/', 1, true), + "AppStream metainfo homepage is gen1re.com") +check(xml:find('url type="vcs-browser">https://github.com/bryanthaboi/gen1recomp', 1, true), + "AppStream metainfo vcs-browser points at the public repo") print("ok") From 2441996d457940f6365d7b1b286c3f5b9417688b Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 27 Aug 2026 12:44:22 -0400 Subject: [PATCH 6/6] maybe better menu situation --- src/import/RomImporter.lua | 11 +++++++-- tests/engine/launcher_navigation_perf.lua | 27 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 6df2593f..e1dcd808 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -4266,6 +4266,7 @@ function RomImporter:_toggleSafeMode() self.mods = nil self.findInstalled = nil self._modSortCache = nil + self._cartCaptureCache = nil self._modInfoFetch = nil self.modNotice = nil end @@ -4592,7 +4593,7 @@ function RomImporter:_selectCart(version, id) self.activeCart[version] = id self._cartPlan = nil -- the MODS panel answers for the cart now, so its cached rows are stale - self.mods, self._modSortCache = nil, nil + self.mods, self._modSortCache, self._cartCaptureCache = nil, nil, nil local scope = self:slotScope(version) self.slots[scope] = nil self.slotScroll[scope] = nil @@ -4891,7 +4892,11 @@ end -- pins the off ones too, so they are part of the cart it would write. function RomImporter:_cartCaptureCount(version) if not GameVersion.VERSIONS[version] then return 0 end - return #cartModRows(self, version) + local cache = self._cartCaptureCache + if cache and cache.version == version then return cache.n end + local n = #cartModRows(self, version) + self._cartCaptureCache = { version = version, n = n } + return n end function RomImporter:_cartAuthor() @@ -5250,6 +5255,7 @@ function RomImporter:_refreshMods() local SaveData = require("src.core.SaveData") self._cartPlan = nil self._profileCache = nil + self._cartCaptureCache = nil self.findInstalled = nil self.safeMode = SaveData.isSafeMode(SaveData.loadOptions()) -- Once per session, ahead of the first listing: pull in any mod the player @@ -5284,6 +5290,7 @@ function RomImporter:_refreshMods() if m.targetsHere ~= false then kept[#kept + 1] = m end end self.mods = kept + self._cartCaptureCache = { version = self.modScope, n = #kept } end -- a pin is judged against the whole listing: the cart named it, so it is -- listed even where the game filter above would have dropped it diff --git a/tests/engine/launcher_navigation_perf.lua b/tests/engine/launcher_navigation_perf.lua index d394bb69..6e50d090 100644 --- a/tests/engine/launcher_navigation_perf.lua +++ b/tests/engine/launcher_navigation_perf.lua @@ -85,4 +85,31 @@ do "MOD INDEX panel does not force the full MODS list") end +do + local calls = 0 + local old = LauncherMods.list + LauncherMods.list = function() + calls = calls + 1 + return { + { id = "scoped", name = "Scoped", targetsHere = true }, + { id = "other", name = "Other", targetsHere = false }, + } + end + local imp = setmetatable({ + modScope = "red", + modStraysChecked = true, + activeCart = {}, + }, RomImporter) + imp:_refreshMods() + local listed = calls + check(listed >= 1, "refresh lists installed mods") + eq(imp:_cartCaptureCount("red"), 1, + "capture count is the targeting subset") + eq(calls, listed, + "Save as cart does not re-list after the panel already has the rows") + eq(imp:_cartCaptureCount("red"), 1, "a second count is the same answer") + eq(calls, listed, "and still does not re-list") + LauncherMods.list = old +end + T.finish("launcher_navigation_perf")