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/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/src/battle/BattleState.lua b/src/battle/BattleState.lua index 53984c2e..4a65cec8 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") @@ -6337,7 +6338,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 @@ -6421,7 +6422,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/import/RomImporter.lua b/src/import/RomImporter.lua index 1e15166b..e1dcd808 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 @@ -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/src/import/SaveFileIO.lua b/src/import/SaveFileIO.lua index 8723fe0a..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: @@ -78,14 +109,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 +127,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) @@ -117,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 @@ -131,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/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..63369452 --- /dev/null +++ b/src/save_convert/Gen2Save.lua @@ -0,0 +1,690 @@ +-- 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 + +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) + -- 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 + 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 + +-- 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 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 + 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), + pokerus = u8(bytes, o + 0x1C), + caughtData = be(bytes, o + 0x1D, 2), + 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 + +-- ------------------------------------------------------------------ +-- 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 449cab82..79b4ef89 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -21,12 +21,23 @@ -- require alone cannot see them there (#420). local GenSave = require("src.save_convert.GenSave") -local GameVersion = require("src.core.GameVersion") +local Gen2Save = require("src.save_convert.Gen2Save") 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 +136,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). @@ -228,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. -- @@ -248,19 +270,22 @@ 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 +-- 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 @@ -278,6 +303,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 @@ -300,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/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 5995ae13..34b1a700 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") @@ -794,7 +795,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/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") diff --git a/tests/engine/gen2_save_import.lua b/tests/engine/gen2_save_import.lua new file mode 100644 index 00000000..a53190ca --- /dev/null +++ b/tests/engine/gen2_save_import.lua @@ -0,0 +1,386 @@ +-- 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 + +-- ------------------------------------------------------------------ +-- 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 +-- ------------------------------------------------------------------ +-- +-- 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 + -- 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("no cartridge image", 1, true) ~= nil, + "a save with no cartridge behind it is refused -- 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 + + -- 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 + +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..3e8c4a0e --- /dev/null +++ b/tests/engine/gen2_save_import_message.lua @@ -0,0 +1,127 @@ +-- 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 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 + 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/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") 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..5cea280c 100644 --- a/tests/gen2_save_convert_cli_test.lua +++ b/tests/gen2_save_convert_cli_test.lua @@ -62,20 +62,29 @@ 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") --- 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..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") @@ -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/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") 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()