mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user