From c24c7cbcb78d842a9ca3aa95797d7b7f31b3daec Mon Sep 17 00:00:00 2001 From: johnjohto Date: Tue, 28 Jul 2026 09:00:07 -0400 Subject: [PATCH] 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. --- src/pokemon/Pokemon.lua | 4 ++ src/save_convert/GenSave.lua | 28 +++++++++-- tests/save_convert_tests.lua | 96 ++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 8330ed8b..5ba90e22 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -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, } diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index 7387a840..65834cbc 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -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)) diff --git a/tests/save_convert_tests.lua b/tests/save_convert_tests.lua index 94cf92b2..dfa6510a 100644 --- a/tests/save_convert_tests.lua +++ b/tests/save_convert_tests.lua @@ -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