Merge pull request #334 from johnjohto/fix-export-fidelity

Export fidelity: name padding, wOptions, as-caught catchRate (#206)
This commit is contained in:
bryanthaboi
2026-07-28 12:19:17 -04:00
committed by GitHub
3 changed files with 123 additions and 5 deletions
+4
View File
@@ -75,6 +75,10 @@ function Pokemon.new(data, species, level, rng)
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
stats = stats,
hp = stats.hp,
-- the Gen1 catch-rate byte freezes at catch time: evolution does NOT
-- update it, so PKHeX expects a preevolution's rate on an evolved mon
-- (#206). Evolution.apply mutates in place and never touches this.
catchRate = def.catchRate,
status = nil, -- "SLP"|"PSN"|"BRN"|"FRZ"|"PAR"
moves = moves,
}
+23 -5
View File
@@ -250,12 +250,17 @@ local function encodeName(buf, off, len, text)
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
i, pos = i + 1, pos + clen
end
-- Write exactly ONE $50 terminator and then STOP. The bytes after it are
-- left untouched: when encoding over a template they stay as the original
-- save's post-terminator padding (so an unchanged name round-trips
-- byte-identical), and on a templateless export they stay zero-filled. The
-- game reads a name only up to the first $50, so whatever follows is inert.
-- Write exactly ONE $50 terminator and then $50-pad the rest of the
-- field: every real save the naming screen ever wrote fills the tail
-- with $50, and a zero tail is what PKHeX renders as garbage glyphs
-- after the name ("JOHN{}", #206). Template bytes that are NOT zero
-- stay untouched, so an unchanged name still round-trips
-- byte-identical and stale template data survives.
if i < len then setByte(buf, off + i, 0x50) end
for j = i + 1, len - 1 do
local cur = buf[off + j + 1]
if cur == nil or cur:byte() == 0 then setByte(buf, off + j, 0x50) end
end
end
-- ------------------------------------------------------------------
@@ -734,6 +739,19 @@ function GenSave.encode(save, data, template)
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED")
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE")
setU16be(buf, O.playerId, (save.player and save.player.id) or 0)
-- wOptions (engine/menus/main_menu.asm InitOptions): bit 7 = battle
-- effects OFF, bit 6 = SET style, bits 2-0 = text speed -- the recomp's
-- textSpeed 1/3/5 are pokered's exact FAST/MEDIUM/SLOW values
-- (SaveData.defaultOptions). Templateless exports only: with a
-- template the byte survives untouched (the round-trip invariant), and
-- decode() never reads it back anyway.
if not src then
local opts = save.options or {}
local ob = (tonumber(opts.textSpeed) or 3) % 8
if opts.battleStyle == "set" then ob = bit.bor(ob, 0x40) end
if opts.animations == false then ob = bit.bor(ob, 0x80) end
setByte(buf, O.options, ob)
end
setBcd(buf, O.money, 3, math.min(save.money or 0, 999999))
setBcd(buf, O.coins, 2, math.min(save.coins or 0, 9999))
+96
View File
@@ -493,5 +493,101 @@ else
math.floor(rs.playTime / 3600), math.floor(rs.playTime / 60) % 60))
end
-- ------------------------------------------------------------------
-- #206 export fidelity, found while auditing the reporter's PKHeX shots:
--
-- 1. Name fields on a templateless (engine-origin) export must be
-- $50-PADDED after the terminator, like every real save the naming
-- screen ever wrote -- a zero tail is what PKHeX rendered as "JOHN{}".
-- 2. wOptions (0x2601) must carry the text speed / battle style / battle
-- effects bits; it was never written at all.
-- 3. The party/box catch-rate byte freezes at catch time in Gen1
-- (evolution does NOT update it), so a mon carries its
-- as-caught catchRate and encode() must write that, not blindly
-- re-derive from the current species (PKHeX: "Expected a preevolution
-- catch rate").
-- ------------------------------------------------------------------
-- (1) templateless export pads every name tail with $50
do
local function nameField(bytes, off, label)
local term
for i = 0, 10 do
if bytes:byte(off + i + 1) == 0x50 then term = i break end
end
check(term ~= nil, label .. ": a $50 terminator exists")
if term then
for i = term + 1, 10 do
if bytes:byte(off + i + 1) ~= 0x50 then
check(false, label .. ": tail byte " .. i .. " is $50 padding, got $"
.. ("%02X"):format(bytes:byte(off + i + 1)))
return
end
end
check(true, label .. ": the tail is $50-padded")
end
end
-- bytes2 is templateless (ASH/GARY, MEW in the party, PIKACHU boxed)
nameField(bytes2, OFF.playerName, "player name")
nameField(bytes2, OFF.rivalName, "rival name")
nameField(bytes2, OFF.partyMonOT, "party OT")
nameField(bytes2, OFF.partyMonNicks, "party nickname")
local box3 = OFF.box1 + 2 * GenSave.BOX_REGION_SIZE
nameField(bytes2, box3 + 22 + 20 * 33, "box OT")
nameField(bytes2, box3 + 22 + 20 * (33 + 11), "box nickname")
-- ...while a template's nonzero stale bytes still survive (round-trip):
-- the template-aware block above already proves byte-identical tails.
end
-- (2) wOptions carries text speed (bits 2-0), battle style (bit 6) and
-- battle effects off (bit 7)
do
local o = SaveData.newGame({ playerName = "RED" })
o.options = SaveData.mergeOptions({ textSpeed = 1, battleStyle = "set",
animations = false })
local ob = GenSave.encode(o, data, nil)
check(ob:byte(OFF.options + 1) == 0x80 + 0x40 + 1,
("wOptions packs effects-off + set style + fast text ($%02X)"):format(
ob:byte(OFF.options + 1)))
local o2 = SaveData.newGame({ playerName = "RED" })
o2.options = SaveData.defaultOptions() -- textSpeed 3, shift, animations on
local ob2 = GenSave.encode(o2, data, nil)
check(ob2:byte(OFF.options + 1) == 3,
("wOptions defaults to medium text, shift style, effects on ($%02X)"):format(
ob2:byte(OFF.options + 1)))
-- ...but with a template the cartridge's own byte wins (the round-trip
-- invariant): an imported save's FAST+SET options must not be flattened
-- to the session defaults on re-export
local tpl = {}
for i = 1, GenSave.SAVE_SIZE do tpl[i] = string.char(0) end
tpl[OFF.options + 1] = string.char(0xC1)
local ob3 = GenSave.encode(o2, data, table.concat(tpl))
check(ob3:byte(OFF.options + 1) == 0xC1,
("wOptions survives from the template on re-export ($%02X)"):format(
ob3:byte(OFF.options + 1)))
end
-- (3) catchRate: Pokemon.new stamps the as-caught byte, evolution keeps it,
-- encode prefers the mon's byte over the current species def
do
local Pokemon = require("src.pokemon.Pokemon")
local Evolution = require("src.pokemon.Evolution")
local mon = Pokemon.new(data, "NIDORAN_F", 16)
check(mon.catchRate == data.pokemon.NIDORAN_F.catchRate,
"Pokemon.new stamps the species catchRate (the as-caught byte)")
local game = { data = data, save = { pokedex = { seen = {}, owned = {} } } }
Evolution.apply(game, mon, "NIDORINA")
check(mon.species == "NIDORINA" and mon.catchRate == data.pokemon.NIDORAN_F.catchRate,
"evolution preserves the as-caught catchRate byte (Gen1 quirk)")
local s = SaveData.newGame({ playerName = "RED" })
s.party = { mon }
for i = 1, 12 do s.boxes = s.boxes or {}; s.boxes[i] = {} end
local sb = GenSave.encode(s, data, nil)
check(sb:byte(OFF.partyMons + 7 + 1) == data.pokemon.NIDORAN_F.catchRate,
"encode writes the mon's catchRate, not the evolved species' ("
.. sb:byte(OFF.partyMons + 7 + 1) .. " vs "
.. data.pokemon.NIDORAN_F.catchRate .. ")")
end
print(string.format("save convert: %d/%d checks passed", checks - failures, checks))
if failures > 0 then os.exit(1) end