mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-28 18:06:09 +02:00
@@ -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
|
owns battle presentation should return `false` only for its active battle or
|
||||||
text-box state.
|
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 = <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 `<LV>` glyph goes with the digits, and on status page 2 so does the
|
||||||
|
`<to>` 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
|
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
|
||||||
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
|
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
|
||||||
option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever
|
option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever
|
||||||
|
|||||||
@@ -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 `<LV>` 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 = <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 `<LV>` 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 `<to>` 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.
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<name>gen1recomp</name>
|
<name>gen1recomp</name>
|
||||||
<summary>Pokémon Gen 1/2 recompilation</summary>
|
<summary>Pokémon Gen 1/2 recompilation</summary>
|
||||||
<metadata_license>CC0-1.0</metadata_license>
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
<project_license>LicenseRef-proprietary</project_license>
|
<project_license>MIT</project_license>
|
||||||
<developer_name>The Bois Club</developer_name>
|
<developer_name>The Bois Club</developer_name>
|
||||||
<description>
|
<description>
|
||||||
<p>
|
<p>
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</description>
|
</description>
|
||||||
<launchable type="desktop-id">com.theboisclub.gen1recomp.desktop</launchable>
|
<launchable type="desktop-id">com.theboisclub.gen1recomp.desktop</launchable>
|
||||||
<url type="homepage">https://github.com/bryanthaboi/gen1recomp</url>
|
<url type="homepage">https://gen1re.com/</url>
|
||||||
|
<url type="vcs-browser">https://github.com/bryanthaboi/gen1recomp</url>
|
||||||
<screenshots>
|
<screenshots>
|
||||||
<screenshot type="default">
|
<screenshot type="default">
|
||||||
<caption>Launcher</caption>
|
<caption>Launcher</caption>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ local Damage = require("src.battle.Damage")
|
|||||||
local EffectRegistry = require("src.battle.EffectRegistry")
|
local EffectRegistry = require("src.battle.EffectRegistry")
|
||||||
local Experience = require("src.battle.Experience")
|
local Experience = require("src.battle.Experience")
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local LevelDisplay = require("src.ui.LevelDisplay")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local MoveEffects = require("src.battle.MoveEffects")
|
local MoveEffects = require("src.battle.MoveEffects")
|
||||||
local Party = require("src.pokemon.Party")
|
local Party = require("src.pokemon.Party")
|
||||||
@@ -6337,7 +6338,7 @@ function BattleState:drawHUDs(slide)
|
|||||||
end
|
end
|
||||||
if self.enemy.shownStatus then
|
if self.enemy.shownStatus then
|
||||||
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8)
|
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) -- <LV>
|
hudTile(0x6E, 32, 8) -- <LV>
|
||||||
Font.draw(tostring(self.enemy.mon.level), 40, 8)
|
Font.draw(tostring(self.enemy.mon.level), 40, 8)
|
||||||
end
|
end
|
||||||
@@ -6421,7 +6422,7 @@ function BattleState:drawHUDs(slide)
|
|||||||
Font.draw(self.player.name, nameX(10, self.player.name), 56)
|
Font.draw(self.player.name, nameX(10, self.player.name), 56)
|
||||||
if self.player.shownStatus then
|
if self.player.shownStatus then
|
||||||
Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64)
|
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) -- <LV>
|
hudTile(0x6E, 112, 64) -- <LV>
|
||||||
Font.draw(tostring(self.player.mon.level), 120, 64)
|
Font.draw(tostring(self.player.mon.level), 120, 64)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2296,8 +2296,8 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
if type(size) == "number" and size > RequiredImports.LARGE_WARN_BYTES
|
if spec.format ~= "n64" and (size == nil
|
||||||
and spec.format ~= "n64" then
|
or size > RequiredImports.LARGE_WARN_BYTES) then
|
||||||
local ok, result = streamRequiredImport(manifest, importId, source)
|
local ok, result = streamRequiredImport(manifest, importId, source)
|
||||||
if ok then
|
if ok then
|
||||||
self.requiredImportNotice = nil
|
self.requiredImportNotice = nil
|
||||||
@@ -4266,6 +4266,7 @@ function RomImporter:_toggleSafeMode()
|
|||||||
self.mods = nil
|
self.mods = nil
|
||||||
self.findInstalled = nil
|
self.findInstalled = nil
|
||||||
self._modSortCache = nil
|
self._modSortCache = nil
|
||||||
|
self._cartCaptureCache = nil
|
||||||
self._modInfoFetch = nil
|
self._modInfoFetch = nil
|
||||||
self.modNotice = nil
|
self.modNotice = nil
|
||||||
end
|
end
|
||||||
@@ -4592,7 +4593,7 @@ function RomImporter:_selectCart(version, id)
|
|||||||
self.activeCart[version] = id
|
self.activeCart[version] = id
|
||||||
self._cartPlan = nil
|
self._cartPlan = nil
|
||||||
-- the MODS panel answers for the cart now, so its cached rows are stale
|
-- 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)
|
local scope = self:slotScope(version)
|
||||||
self.slots[scope] = nil
|
self.slots[scope] = nil
|
||||||
self.slotScroll[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.
|
-- pins the off ones too, so they are part of the cart it would write.
|
||||||
function RomImporter:_cartCaptureCount(version)
|
function RomImporter:_cartCaptureCount(version)
|
||||||
if not GameVersion.VERSIONS[version] then return 0 end
|
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
|
end
|
||||||
|
|
||||||
function RomImporter:_cartAuthor()
|
function RomImporter:_cartAuthor()
|
||||||
@@ -5250,6 +5255,7 @@ function RomImporter:_refreshMods()
|
|||||||
local SaveData = require("src.core.SaveData")
|
local SaveData = require("src.core.SaveData")
|
||||||
self._cartPlan = nil
|
self._cartPlan = nil
|
||||||
self._profileCache = nil
|
self._profileCache = nil
|
||||||
|
self._cartCaptureCache = nil
|
||||||
self.findInstalled = nil
|
self.findInstalled = nil
|
||||||
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
|
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
|
||||||
-- Once per session, ahead of the first listing: pull in any mod the player
|
-- 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
|
if m.targetsHere ~= false then kept[#kept + 1] = m end
|
||||||
end
|
end
|
||||||
self.mods = kept
|
self.mods = kept
|
||||||
|
self._cartCaptureCache = { version = self.modScope, n = #kept }
|
||||||
end
|
end
|
||||||
-- a pin is judged against the whole listing: the cart named it, so it is
|
-- 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
|
-- listed even where the game filter above would have dropped it
|
||||||
|
|||||||
@@ -20,6 +20,37 @@ local GameVersion = require("src.core.GameVersion")
|
|||||||
|
|
||||||
local SaveFileIO = {}
|
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
|
local SAVE_SIZE = SaveConvert.SAVE_SIZE
|
||||||
|
|
||||||
-- Resolve raw save bytes from whatever the launcher hands us:
|
-- 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()
|
version = version or GameVersion.get()
|
||||||
local bytes, readErr = readSource(source)
|
local bytes, readErr = readSource(source)
|
||||||
if not bytes then return false, readErr end
|
if not bytes then return false, readErr end
|
||||||
-- The GAME decides before the BYTES do. Everything below this line judges a
|
-- The GAME decides before the BYTES do. Everything below this line used to
|
||||||
-- save by Gen 1's rules -- the size test, and mainChecksumValid, which is
|
-- judge a save by Gen 1's rules whatever game it was for, and a Gen 2 cart
|
||||||
-- pokered's checksum -- so a Gen 2 cart save reaching it is measured against
|
-- is MBC3+TIMER: a real Gold/Silver/Crystal .sav carries an RTC footer, so
|
||||||
-- a rule that cannot match and comes back "checksum invalid" (#1832).
|
-- 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)
|
local supported, unsupportedWhy = SaveConvert.importSupported(version)
|
||||||
if not supported then return false, unsupportedWhy end
|
if not supported then return false, unsupportedWhy end
|
||||||
if #bytes ~= SAVE_SIZE then
|
if #bytes ~= SAVE_SIZE then
|
||||||
local check = SaveConvert.mainChecksumValid(bytes)
|
local check = SaveConvert.mainChecksumValid(bytes, version)
|
||||||
if check == nil then
|
if check == nil then
|
||||||
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
|
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
|
||||||
:format(SAVE_SIZE, #bytes)
|
:format(SAVE_SIZE, #bytes)
|
||||||
@@ -93,7 +127,12 @@ function SaveFileIO.importToSlot(source, version, force)
|
|||||||
if check == false then
|
if check == false then
|
||||||
return false, "save data checksum invalid (main data checksum mismatch)"
|
return false, "save data checksum invalid (main data checksum mismatch)"
|
||||||
end
|
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 }
|
return false, nil, { needsConfirm = true, size = #bytes }
|
||||||
end
|
end
|
||||||
bytes = #bytes > SAVE_SIZE and bytes:sub(1, SAVE_SIZE)
|
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)
|
return false, "could not write the imported save: " .. tostring(writeErr)
|
||||||
end
|
end
|
||||||
SaveData.setActiveSlot(version, slotId)
|
SaveData.setActiveSlot(version, slotId)
|
||||||
|
if SaveConvert.isGen2Cart(version) then writeCart(version, slotId, bytes) end
|
||||||
return true, slotId
|
return true, slotId
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -131,9 +171,10 @@ function SaveFileIO.exportActiveSlot(version)
|
|||||||
version = version or GameVersion.get()
|
version = version or GameVersion.get()
|
||||||
local save = SaveData.load(version)
|
local save = SaveData.load(version)
|
||||||
if not save then return false, "this game has no save to export yet" end
|
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 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 mode is the same seam SaveData's own persistFs uses: when
|
||||||
-- portable.txt marks the install every persistent write leaves the OS save
|
-- portable.txt marks the install every persistent write leaves the OS save
|
||||||
-- directory for the game folder, and an export is no exception. Writing
|
-- directory for the game folder, and an export is no exception. Writing
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -21,12 +21,23 @@
|
|||||||
-- require alone cannot see them there (#420).
|
-- require alone cannot see them there (#420).
|
||||||
|
|
||||||
local GenSave = require("src.save_convert.GenSave")
|
local GenSave = require("src.save_convert.GenSave")
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local Gen2Save = require("src.save_convert.Gen2Save")
|
||||||
|
|
||||||
local SaveConvert = {}
|
local SaveConvert = {}
|
||||||
|
|
||||||
SaveConvert.SAVE_SIZE = GenSave.SAVE_SIZE
|
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
|
-- Crosswalk data loading (cached). Mirrors src/core/Data.lua: prefer
|
||||||
@@ -125,6 +136,24 @@ local function loadCacheTable(gameVersion, filePath)
|
|||||||
return nil
|
return nil
|
||||||
end
|
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
|
-- 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
|
-- require-resolved set): Yellow's tables are not Red's, so one import must
|
||||||
-- never be handed the previous import's data (#420).
|
-- 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
|
-- pokered's single sPlayerName..sMainDataCheckSum window), and no Gen 2
|
||||||
-- codec exists yet. Both directions answer with a plain message the
|
-- 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
|
-- 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
|
-- 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.
|
-- 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
|
-- 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
|
-- answered with, so a caller that asks early and a caller that does not cannot
|
||||||
-- describe the same game two different ways.
|
-- 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)
|
function SaveConvert.importSupported(gameVersion)
|
||||||
local gen2Name = gen2CartName(gameVersion)
|
-- Gen 2 imports through Gen2Save now. Kept as a predicate rather than
|
||||||
if gen2Name then
|
-- deleted: SaveFileIO asks it before it measures the bytes, and export
|
||||||
return false, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet."
|
-- still answers no.
|
||||||
end
|
|
||||||
return true
|
return true
|
||||||
end
|
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)
|
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
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -278,6 +303,15 @@ function SaveConvert.importSav(bytes, version, gameVersion)
|
|||||||
end
|
end
|
||||||
local supported, unsupportedWhy = SaveConvert.importSupported(gameVersion)
|
local supported, unsupportedWhy = SaveConvert.importSupported(gameVersion)
|
||||||
if not supported then return nil, unsupportedWhy end
|
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
|
if #bytes ~= GenSave.SAVE_SIZE then
|
||||||
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
|
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
|
||||||
end
|
end
|
||||||
@@ -300,18 +334,23 @@ function SaveConvert.importSav(bytes, version, gameVersion)
|
|||||||
return mergeDefaults(decoded, version)
|
return mergeDefaults(decoded, version)
|
||||||
end
|
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:
|
-- 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)
|
-- if the table still carries the stashed import template (saveTable.rawImport)
|
||||||
-- GenSave reproduces every unmodeled region from it; otherwise those regions
|
-- GenSave reproduces every unmodeled region from it; otherwise those regions
|
||||||
-- are zero-filled. gameVersion selects the crosswalk tables exactly as in
|
-- are zero-filled. gameVersion selects the crosswalk tables exactly as in
|
||||||
-- importSav. On failure returns nil + a message (never raises).
|
-- 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
|
if type(saveTable) ~= "table" then
|
||||||
return nil, "expected a save table"
|
return nil, "expected a save table"
|
||||||
end
|
end
|
||||||
local supported, unsupportedWhy = SaveConvert.exportSupported(gameVersion)
|
local supported, unsupportedWhy = SaveConvert.exportSupported(gameVersion)
|
||||||
if not supported then return nil, unsupportedWhy end
|
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)
|
local data, derr = ensureData(gameVersion)
|
||||||
if not data then return nil, derr end
|
if not data then return nil, derr end
|
||||||
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
|
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
|
||||||
|
|||||||
@@ -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 <LV> 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
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
local Assets = require("src.render.Assets")
|
local Assets = require("src.render.Assets")
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local LevelDisplay = require("src.ui.LevelDisplay")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
@@ -794,7 +795,11 @@ function PartyMenu:draw()
|
|||||||
-- level at column 13 (<LV> tile + digits, PrintLevel) AND the
|
-- level at column 13 (<LV> tile + digits, PrintLevel) AND the
|
||||||
-- status/FNT text at column 17 (PrintStatusCondition), like the
|
-- status/FNT text at column 17 (PrintStatusCondition), like the
|
||||||
-- original rows -- statused mons keep their level display
|
-- 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) -- <LV>
|
HudTiles.tile(0x6E, 104, y) -- <LV>
|
||||||
Font.draw(tostring(mon.level), 112, y)
|
Font.draw(tostring(mon.level), 112, y)
|
||||||
else
|
else
|
||||||
|
|||||||
+10
-3
@@ -12,6 +12,7 @@ local Font = require("src.render.Font")
|
|||||||
-- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the
|
-- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the
|
||||||
-- battle move-type box already do (#214).
|
-- battle move-type box already do (#214).
|
||||||
local TypeChart = require("src.battle.TypeChart")
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
local LevelDisplay = require("src.ui.LevelDisplay")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
local Status = require("src.battle.Status")
|
local Status = require("src.battle.Status")
|
||||||
@@ -137,7 +138,9 @@ function SummaryMenu:draw()
|
|||||||
if self.page == 1 then
|
if self.page == 1 then
|
||||||
-- level is page 1 only: StatusScreen2 opens with ClearScreenArea over
|
-- level is page 1 only: StatusScreen2 opens with ClearScreenArea over
|
||||||
-- (9,2) 5x10 (status_screen.asm:303-305). #280
|
-- (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)
|
drawLineBox(19, 1, 6, 10)
|
||||||
-- engine/pokemon/status_screen.asm:120-125
|
-- engine/pokemon/status_screen.asm:120-125
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
@@ -196,8 +199,12 @@ function SummaryMenu:draw()
|
|||||||
local nextExp = mon.level < 100
|
local nextExp = mon.level < 100
|
||||||
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
|
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
|
||||||
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
|
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
|
||||||
HudTiles.statusTile(0x70, 112, 48) -- '<to>' at (14,6), was missing (#280)
|
if LevelDisplay.visible(mon, "summary", self.game) then -- RFC 0019
|
||||||
printLevel(16, 6, math.min(100, mon.level + 1))
|
-- the '<to>' arrow is half a sentence without the level it points at,
|
||||||
|
-- so the pair is hidden together
|
||||||
|
HudTiles.statusTile(0x70, 112, 48) -- '<to>' at (14,6), was missing (#280)
|
||||||
|
printLevel(16, 6, math.min(100, mon.level + 1))
|
||||||
|
end
|
||||||
Font.drawBox(0, 8, 20, 10)
|
Font.drawBox(0, 8, 20, 10)
|
||||||
for i = 1, 4 do
|
for i = 1, 4 do
|
||||||
local mv = mon.moves[i]
|
local mv = mon.moves[i]
|
||||||
|
|||||||
@@ -21,5 +21,13 @@ local xml = meta:read("*a")
|
|||||||
meta:close()
|
meta:close()
|
||||||
check(xml:find("<releases>", 1, true), "AppStream metainfo includes releases")
|
check(xml:find("<releases>", 1, true), "AppStream metainfo includes releases")
|
||||||
check(xml:find("<release ", 1, true), "AppStream metainfo includes a release entry")
|
check(xml:find("<release ", 1, true), "AppStream metainfo includes a release entry")
|
||||||
|
check(xml:find("<project_license>MIT</project_license>", 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/</url>', 1, true),
|
||||||
|
"AppStream metainfo homepage is gen1re.com")
|
||||||
|
check(xml:find('url type="vcs-browser">https://github.com/bryanthaboi/gen1recomp</url>', 1, true),
|
||||||
|
"AppStream metainfo vcs-browser points at the public repo")
|
||||||
|
|
||||||
print("ok")
|
print("ok")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -85,4 +85,31 @@ do
|
|||||||
"MOD INDEX panel does not force the full MODS list")
|
"MOD INDEX panel does not force the full MODS list")
|
||||||
end
|
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")
|
T.finish("launcher_navigation_perf")
|
||||||
|
|||||||
@@ -226,6 +226,8 @@ do
|
|||||||
-- The double has to answer it; "yes" is what keeps this case about the
|
-- The double has to answer it; "yes" is what keeps this case about the
|
||||||
-- cache-name contract below and nothing else.
|
-- cache-name contract below and nothing else.
|
||||||
importSupported = function() return true end,
|
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)
|
importSav = function(_, version, gameVersion)
|
||||||
seen.import = { version = version, gameVersion = gameVersion }
|
seen.import = { version = version, gameVersion = gameVersion }
|
||||||
return nil, "stub"
|
return nil, "stub"
|
||||||
|
|||||||
@@ -62,20 +62,29 @@ write(goldPath, SaveSerializer.encode({
|
|||||||
|
|
||||||
local out = run(("luajit tools/save_convert/convert.lua export %q %q")
|
local out = run(("luajit tools/save_convert/convert.lua export %q %q")
|
||||||
:format(goldPath, outPath))
|
:format(goldPath, outPath))
|
||||||
check(out:find("Gen 2 cart save", 1, true) ~= nil,
|
-- Gen 2 exports through Gen2Save now, but only for a save that carries the
|
||||||
"exporting a Gold save.lua is refused by name: " .. (out:gsub("%s+$", "")))
|
-- 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),
|
check(not exists(outPath),
|
||||||
"and no 32768-byte file that looks like a Red battery is written")
|
"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 savPath = tmp("in.sav")
|
||||||
local outPath2 = tmp("in.lua")
|
local outPath2 = tmp("in.lua")
|
||||||
os.remove(outPath2)
|
os.remove(outPath2)
|
||||||
write(savPath, string.rep("\0", 32768))
|
write(savPath, string.rep("\0", 32768))
|
||||||
out = run(("luajit tools/save_convert/convert.lua import %q %q gold")
|
out = run(("luajit tools/save_convert/convert.lua import %q %q gold")
|
||||||
:format(savPath, outPath2))
|
:format(savPath, outPath2))
|
||||||
check(out:find("Gen 2 cart save", 1, true) ~= nil,
|
check(out:find("not supported yet", 1, true) == nil,
|
||||||
"importing for a Gen 2 game is refused too")
|
"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")
|
check(not exists(outPath2), "and writes nothing")
|
||||||
|
|
||||||
-- Gen 1 keeps working: a Red-shaped save is never caught by the Gen 2 gate.
|
-- Gen 1 keeps working: a Red-shaped save is never caught by the Gen 2 gate.
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ check(files["saves/gold/" .. tostring(slotId) .. ".lua"] ~= nil,
|
|||||||
|
|
||||||
local ok, res = SaveFileIO.exportActiveSlot("gold")
|
local ok, res = SaveFileIO.exportActiveSlot("gold")
|
||||||
eq(ok, false, "Export on a Gold slot is refused, not crashed")
|
eq(ok, false, "Export on a Gold slot is refused, not crashed")
|
||||||
check(type(res) == "string" and res:find("not supported yet", 1, true),
|
check(type(res) == "string" and res:find("no cartridge image", 1, true),
|
||||||
"the refusal is the plain launcher message: " .. tostring(res))
|
"the refusal names the missing cartridge image: " .. tostring(res))
|
||||||
check(not tostring(res):find("GenSave", 1, true)
|
check(not tostring(res):find("GenSave", 1, true)
|
||||||
and not tostring(res):find("attempt to index", 1, true),
|
and not tostring(res):find("attempt to index", 1, true),
|
||||||
"no codec traceback leaks into the notice line")
|
"no codec traceback leaks into the notice line")
|
||||||
@@ -77,12 +77,17 @@ for path in pairs(files) do
|
|||||||
end
|
end
|
||||||
eq(exported, false, "no export file is written for a Gold slot")
|
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
|
-- The import direction no longer matches the export one. Gold imports through
|
||||||
-- must be refused by version, before any Gen 1 decoding is attempted.
|
-- 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")
|
local iok, ierr = SaveConvert.importSav(string.rep("\0", 32768), "gold", "gold")
|
||||||
eq(iok, nil, "importing a cart .sav for Gold is refused")
|
eq(iok, nil, "a blank cart .sav for Gold is still refused")
|
||||||
check(type(ierr) == "string" and ierr:find("not supported yet", 1, true),
|
check(type(ierr) == "string" and ierr:find("not supported yet", 1, true) == nil,
|
||||||
"the import refusal is the plain launcher message: " .. tostring(ierr))
|
"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
|
-- 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
|
-- fails on its own terms (an all-zero image is not a table), never on the
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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")
|
||||||
@@ -3640,7 +3640,6 @@ runSuites(orderedGlob(
|
|||||||
-- name resolution, and the .sav converter refusing a Gen 2 save table.
|
-- name resolution, and the .sav converter refusing a Gen 2 save table.
|
||||||
"tests/gen2_sound_alias_test.lua",
|
"tests/gen2_sound_alias_test.lua",
|
||||||
"tests/gen2_save_convert_cli_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
|
-- 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.
|
-- process (LEAKS_SAVE_SLOT_STATE above); tests/run_gen2.lua runs it alone.
|
||||||
"tests/gen2_map_radio_test.lua",
|
"tests/gen2_map_radio_test.lua",
|
||||||
|
|||||||
@@ -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<PK>" 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()
|
||||||
Reference in New Issue
Block a user