diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 6f634607..0e9499cd 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 b41ef582..5f4a7ea2 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -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)) diff --git a/tests/save_convert_tests.lua b/tests/save_convert_tests.lua index 87ff9b3f..29c77e18 100644 --- a/tests/save_convert_tests.lua +++ b/tests/save_convert_tests.lua @@ -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