Export fidelity: name padding, wOptions, as-caught catchRate (#206)

Auditing the reporter's PKHeX screenshots against the emitted bytes
turned up three fidelity bugs in the .sav export:

- Name fields on a templateless export stayed zero-filled after the
  $50 terminator; every real save the naming screen wrote $50-pads
  the tail, and the zero tail is what PKHeX rendered as "JOHN{}".
  encodeName now pads zero tail bytes with $50 (nonzero template
  bytes still survive untouched).
- wOptions was never written, dropping text speed / battle style /
  battle effects on export.  Templateless exports now pack it from
  save.options (the recomp's textSpeed 1/3/5 are pokered's exact
  values); with a template the cartridge's own byte still wins.
- The party/box catch-rate byte was always re-derived from the
  current species, but Gen1 freezes it at catch time (evolution does
  not update it), which is why PKHeX demanded "a preevolution catch
  rate".  Pokemon.new now stamps the as-caught catchRate and
  Evolution.apply's in-place mutation preserves it.

The reported emulator crash itself is not addressed here: the encoder
is byte-identical to the reporter's version, and a byte-level audit
(offsets vs Bulbapedia's save map, all three checksums, a real
gameplay save round-trip, pokered's LoadMapData regenerating every
zeroed cache) shows the current export is structurally valid.
This commit is contained in:
johnjohto
2026-07-28 09:00:07 -04:00
parent 72f83760b7
commit c24c7cbcb7
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
@@ -232,12 +232,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
-- ------------------------------------------------------------------
@@ -656,6 +661,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
@@ -472,5 +472,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