mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
241 lines
11 KiB
Lua
241 lines
11 KiB
Lua
-- Parity test: imported saves keep their FLY town set (#263). pokered's
|
|
-- wTownVisitedFlag (ram/wram.asm:2057) is a NUM_CITY_MAPS = 11 bit array whose
|
|
-- bit index IS the town's map number, LSB first: town_map.asm
|
|
-- BuildFlyLocationsList does `srl d / rr e` with b counting up from 0, so bit 0
|
|
-- is map 0 = PALLET_TOWN. src/save_convert/GenSave.lua never modeled it, so an
|
|
-- imported .sav arrived with save.visited == nil and FLY offered one town.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
if not _G.love then _G.love = require("tests.love_stub") end
|
|
local Data = require("src.core.Data")
|
|
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
|
|
|
local S = require("tests.harness").suite("parity save fly towns")
|
|
local check, eq = S.check, S.eq
|
|
|
|
local bit = require("bit")
|
|
local GenSave = require("src.save_convert.GenSave")
|
|
local SaveConvert = require("src.save_convert.SaveConvert")
|
|
local SaveData = require("src.core.SaveData")
|
|
local FlyMenu = require("src.ui.FlyMenu")
|
|
|
|
-- loadData() hands back the crosswalk set GenSave wants ({pokemon, moves,
|
|
-- items, maps, eventFlags}) and installs the charmap as a side effect
|
|
local cwData = assert(SaveConvert.loadData(), "save-convert crosswalk data")
|
|
local O = GenSave.OFFSETS
|
|
|
|
-- ------------------------------------------------------------------
|
|
-- The wTownVisitedFlag offset, re-derived here rather than read off
|
|
-- GenSave.OFFSETS: a suite trusting the codec's own constant cannot fail when
|
|
-- that constant is wrong. O.mainData mirrors wMainDataStart, and the flag sits
|
|
-- 1044 bytes in -- 359 past wPlayerCoins (mainData + 685) and 60 before
|
|
-- wEventFlags (mainData + 1104), both of which predate this fix.
|
|
-- ------------------------------------------------------------------
|
|
local TOWN_VISITED = O.mainData + 1044
|
|
local NUM_CITY_MAPS = 11
|
|
|
|
-- Independent re-implementation of CalcCheckSum (complement of the additive
|
|
-- byte sum, engine/menus/save.asm), so a hand-poked image can be re-sealed
|
|
-- without going back through the encoder under test.
|
|
local function rawChecksum(bytes, from, to)
|
|
local sum = 0
|
|
for i = from, to - 1 do sum = bit.band(sum + bytes:byte(i + 1), 0xFF) end
|
|
return bit.band(bit.bnot(sum), 0xFF)
|
|
end
|
|
|
|
local function reseal(bytes)
|
|
local ck = rawChecksum(bytes, O.checksumStart, O.checksumEnd)
|
|
return bytes:sub(1, O.mainChecksum) .. string.char(ck)
|
|
.. bytes:sub(O.mainChecksum + 2)
|
|
end
|
|
|
|
-- write the two wTownVisitedFlag bytes straight into an image, the way a
|
|
-- cartridge would have left them, and re-seal so importSav accepts it
|
|
local function pokeTownBytes(bytes, b0, b1)
|
|
return reseal(bytes:sub(1, TOWN_VISITED) .. string.char(b0, b1)
|
|
.. bytes:sub(TOWN_VISITED + 3))
|
|
end
|
|
|
|
local function townBytesOf(bytes)
|
|
return bytes:byte(TOWN_VISITED + 1), bytes:byte(TOWN_VISITED + 2)
|
|
end
|
|
|
|
-- ------------------------------------------------------------------
|
|
-- 1) the bit-index-is-the-map-index premise, straight off generated data
|
|
-- ------------------------------------------------------------------
|
|
-- data/generated/maps.lua carries pokered's map constant order, so indices 0..10
|
|
-- must be PALLET_TOWN..SAFFRON_CITY, the same 11 the flag_array covers. An
|
|
-- extractor that renumbered them would leave every bit assertion meaningless.
|
|
local TOWN_BY_BIT = {}
|
|
for id, def in pairs(Data.maps) do
|
|
if type(def.index) == "number" and def.index >= 0
|
|
and def.index < NUM_CITY_MAPS then
|
|
TOWN_BY_BIT[def.index] = id
|
|
end
|
|
end
|
|
local EXPECTED_ORDER = {
|
|
[0] = "PALLET_TOWN", "VIRIDIAN_CITY", "PEWTER_CITY", "CERULEAN_CITY",
|
|
"LAVENDER_TOWN", "VERMILION_CITY", "CELADON_CITY", "FUCHSIA_CITY",
|
|
"CINNABAR_ISLAND", "INDIGO_PLATEAU", "SAFFRON_CITY",
|
|
}
|
|
for i = 0, NUM_CITY_MAPS - 1 do
|
|
eq(TOWN_BY_BIT[i], EXPECTED_ORDER[i],
|
|
("map index %d is %s (wTownVisitedFlag bit %d)"):format(i, EXPECTED_ORDER[i], i))
|
|
end
|
|
|
|
local function bitsFor(set)
|
|
local b0, b1 = 0, 0
|
|
for i = 0, NUM_CITY_MAPS - 1 do
|
|
if set[TOWN_BY_BIT[i]] then
|
|
if i < 8 then b0 = bit.bor(b0, bit.lshift(1, i))
|
|
else b1 = bit.bor(b1, bit.lshift(1, i - 8)) end
|
|
end
|
|
end
|
|
return b0, b1
|
|
end
|
|
|
|
-- ------------------------------------------------------------------
|
|
-- 2) decode: the town bits become save.visited
|
|
-- ------------------------------------------------------------------
|
|
|
|
local fresh = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" })
|
|
fresh.flags = { EVENT_GOT_POKEDEX = true, EVENT_GOT_STARTER = true }
|
|
fresh.money = 54321
|
|
local base = GenSave.encode(fresh, cwData, nil)
|
|
eq(#base, GenSave.SAVE_SIZE, "baseline image is 32768 bytes")
|
|
|
|
-- the reported case: a completed cartridge save, all eleven bits set
|
|
local completed = pokeTownBytes(base, 0xFF, 0x07)
|
|
local compSave, compErr = SaveConvert.importSav(completed, 2)
|
|
check(compSave ~= nil, "a completed-cartridge image imports (" .. tostring(compErr) .. ")")
|
|
check(type(compSave and compSave.visited) == "table",
|
|
"an imported save arrives with a visited table, not nil (#263)")
|
|
local compVisited = (compSave and compSave.visited) or {}
|
|
local compCount = 0
|
|
for _ in pairs(compVisited) do compCount = compCount + 1 end
|
|
eq(compCount, NUM_CITY_MAPS,
|
|
"all eleven towns come back visited from a full wTownVisitedFlag")
|
|
for i = 0, NUM_CITY_MAPS - 1 do
|
|
check(compVisited[EXPECTED_ORDER[i]] == true,
|
|
EXPECTED_ORDER[i] .. " is visited on a completed save")
|
|
end
|
|
-- decode must stop at bit 10: ROUTE_1 is map index 12 and can never be a
|
|
-- FLY town, so a loop that ran past NUM_CITY_MAPS would show up here
|
|
check(compVisited.ROUTE_1 == nil,
|
|
"a route never lands in the visited set (the loop stops at NUM_CITY_MAPS)")
|
|
|
|
-- the reported 3DS VC save: Cerulean and Fuchsia visited, Celadon and Saffron
|
|
-- not. Bits 0,1,2,3,7 -> 0x8F 0x00.
|
|
local partialSet = {
|
|
PALLET_TOWN = true, VIRIDIAN_CITY = true, PEWTER_CITY = true,
|
|
CERULEAN_CITY = true, FUCHSIA_CITY = true,
|
|
}
|
|
local pb0, pb1 = bitsFor(partialSet)
|
|
eq(pb0, 0x8F, "bits 0,1,2,3,7 pack LSB-first into byte 0 = 0x8F")
|
|
eq(pb1, 0x00, "no town above bit 7 is set, so byte 1 = 0x00")
|
|
local partial = pokeTownBytes(base, pb0, pb1)
|
|
local partSave = assert(SaveConvert.importSav(partial, 2), "partial image imports")
|
|
local partVisited = partSave.visited or {}
|
|
for id in pairs(partialSet) do
|
|
check(partVisited[id] == true, id .. " decodes as visited")
|
|
end
|
|
check(partVisited.CELADON_CITY == nil,
|
|
"CELADON_CITY was never visited and does not come back visited")
|
|
check(partVisited.SAFFRON_CITY == nil,
|
|
"SAFFRON_CITY was never visited and does not come back visited")
|
|
check(partVisited.INDIGO_PLATEAU == nil,
|
|
"INDIGO_PLATEAU was never visited and does not come back visited")
|
|
|
|
-- ------------------------------------------------------------------
|
|
-- 3) the offset is right: poking the town bytes disturbs nothing else
|
|
-- ------------------------------------------------------------------
|
|
-- An offset a few bytes off still "works" for the checks above while quietly
|
|
-- corrupting a neighbour: wEventFlags starts 60 bytes later, wPlayerCoins 359
|
|
-- earlier, so re-decode the poked image and confirm both survived.
|
|
local baseDec = GenSave.decode(base, cwData)
|
|
local compDec = GenSave.decode(completed, cwData)
|
|
eq(#compDec.warnings, 0, "the re-sealed image passes its own checksum")
|
|
eq(compDec.money, baseDec.money, "poking wTownVisitedFlag leaves money alone")
|
|
check(compDec.flags.EVENT_GOT_POKEDEX and compDec.flags.EVENT_GOT_STARTER,
|
|
"poking wTownVisitedFlag leaves the event flags alone")
|
|
local baseFlagCount, compFlagCount = 0, 0
|
|
for _ in pairs(baseDec.flags) do baseFlagCount = baseFlagCount + 1 end
|
|
for _ in pairs(compDec.flags) do compFlagCount = compFlagCount + 1 end
|
|
eq(compFlagCount, baseFlagCount,
|
|
"setting all eleven town bits sets no event flag (the arrays do not overlap)")
|
|
|
|
-- ------------------------------------------------------------------
|
|
-- 4) encode: the set goes back out in pokered's bit layout
|
|
-- ------------------------------------------------------------------
|
|
-- Hand-picked so an MSB-first writer cannot pass: PALLET is bit 0 and
|
|
-- FUCHSIA bit 7 (byte 0 = 0x81), SAFFRON is bit 10 (byte 1 = 0x04).
|
|
local outSave = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" })
|
|
outSave.visited = {
|
|
PALLET_TOWN = true, FUCHSIA_CITY = true, SAFFRON_CITY = true,
|
|
}
|
|
local outBytes = GenSave.encode(outSave, cwData, nil)
|
|
local ob0, ob1 = townBytesOf(outBytes)
|
|
eq(ob0, 0x81, "PALLET (bit 0) + FUCHSIA (bit 7) write byte 0 = 0x81")
|
|
eq(ob1, 0x04, "SAFFRON (bit 10) writes byte 1 = 0x04")
|
|
|
|
-- and the full loop the reporter cares about: cartridge -> port -> cartridge
|
|
local rtBytes = GenSave.encode(compSave, cwData, nil)
|
|
local r0, r1 = townBytesOf(rtBytes)
|
|
eq(r0, 0xFF, "a completed save exports byte 0 = 0xFF")
|
|
eq(r1, 0x07, "a completed save exports byte 1 = 0x07")
|
|
local rtSave = assert(SaveConvert.importSav(reseal(rtBytes), 2),
|
|
"the exported image re-imports")
|
|
local rtVisited = rtSave.visited or {}
|
|
for i = 0, NUM_CITY_MAPS - 1 do
|
|
check(rtVisited[EXPECTED_ORDER[i]] == true,
|
|
EXPECTED_ORDER[i] .. " survives export and re-import")
|
|
end
|
|
|
|
-- A save with no `visited` key says nothing about the set, so encoding over a
|
|
-- template keeps the template's bits rather than un-flying it on the way back
|
|
-- to hardware.
|
|
local silent = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" })
|
|
silent.visited = nil
|
|
local kept = GenSave.encode(silent, cwData, completed)
|
|
local k0, k1 = townBytesOf(kept)
|
|
eq(k0, 0xFF, "a save with no visited key keeps the template's byte 0")
|
|
eq(k1, 0x07, "a save with no visited key keeps the template's byte 1")
|
|
|
|
-- ------------------------------------------------------------------
|
|
-- 5) the payoff: the real FLY picker lists them
|
|
-- ------------------------------------------------------------------
|
|
-- src/ui/FlyMenu.lua walks data.field.flyOrder and keeps the entries in
|
|
-- save.visited, so this is the list the player sees under FLY. Before #263 an
|
|
-- imported save reached it with visited == nil and the list came back empty.
|
|
local function flyLabels(save)
|
|
local menu = FlyMenu.new({ data = Data, save = save })
|
|
local out = {}
|
|
for _, item in ipairs(menu.items or {}) do out[#out + 1] = item.value end
|
|
return out
|
|
end
|
|
|
|
local compList = flyLabels(compSave)
|
|
eq(#compList, NUM_CITY_MAPS,
|
|
"FLY on a completed imported save lists all eleven destinations")
|
|
-- flyOrder puts the towns in map-constant order after the dungeon escape
|
|
-- spots, which is the order the picker shows
|
|
for i = 0, NUM_CITY_MAPS - 1 do
|
|
eq(compList[i + 1], EXPECTED_ORDER[i],
|
|
("FLY entry %d is %s"):format(i + 1, EXPECTED_ORDER[i]))
|
|
end
|
|
|
|
local partList = flyLabels(partSave)
|
|
eq(#partList, 5, "FLY on the partial save lists exactly the five visited towns")
|
|
local partSeen = {}
|
|
for _, id in ipairs(partList) do partSeen[id] = true end
|
|
check(partSeen.CERULEAN_CITY and partSeen.FUCHSIA_CITY,
|
|
"Cerulean AND Fuchsia are both offered (the reported 3DS VC case)")
|
|
check(not partSeen.CELADON_CITY and not partSeen.SAFFRON_CITY,
|
|
"unvisited towns stay out of the FLY list")
|
|
|
|
-- negative control, the pre-#263 shape: no visited table is an empty picker
|
|
local blank = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" })
|
|
blank.visited = nil
|
|
eq(#flyLabels(blank), 0, "a save with no visited set has no FLY destinations")
|
|
|
|
S.finish()
|