mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
CLOSES #889
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
-- Driver (#889): export a .sav from a save that never came from a ROM import,
|
||||
-- the case that used to write a save with no map context at all -- no map
|
||||
-- header, no tileset header, sound id 0 -- so a real Game Boy continued into a
|
||||
-- garbled map and hung on a white screen.
|
||||
--
|
||||
-- Saves the game in two places (an interior and an outdoor map with
|
||||
-- connections), exports each, and prints the bytes the engine reads back on
|
||||
-- Continue so a human can eyeball them, plus the export path to load in an
|
||||
-- emulator.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local SaveFileIO = require("src.import.SaveFileIO")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local version = GameVersion.get()
|
||||
local spots = {
|
||||
{ "REDS_HOUSE_2F", 3, 6 },
|
||||
{ "PALLET_TOWN", 5, 6 },
|
||||
}
|
||||
|
||||
for _, spot in ipairs(spots) do
|
||||
local map, x, y = spot[1], spot[2], spot[3]
|
||||
U.teleport(game, map, x, y, "down")
|
||||
U.wait(30)
|
||||
-- the same sync the in-game SAVE does before writing (OverworldState:
|
||||
-- captureSave), so the slot on disk holds the position we just walked to
|
||||
local top = game.stack:top()
|
||||
if top and top.captureSave then top:captureSave(game.save) end
|
||||
SaveData.save(game.save)
|
||||
local ok, pathOrErr = SaveFileIO.exportActiveSlot(version)
|
||||
if not ok then
|
||||
U.log(("export_sav_bug889: %s FAILED: %s"):format(map, tostring(pathOrErr)))
|
||||
else
|
||||
local bytes = love.filesystem.read(
|
||||
("exports/%s/gen1recomp-%s-%s.sav"):format(
|
||||
version, version, SaveData.activeSlot(version) or "save"))
|
||||
local main = 0x2598 + 11
|
||||
local function u8(off) return bytes:byte(main + off + 1) end
|
||||
local hdr = {}
|
||||
for i = 0, 9 do hdr[#hdr + 1] = ("%02X"):format(u8(112 + i)) end
|
||||
U.log(("export_sav_bug889: %s -> %s"):format(map, pathOrErr))
|
||||
U.log((" wCurMap=%02X wCurMapHeader=%s music=%02X/%02X tilesetBank=%02X"):
|
||||
format(u8(103), table.concat(hdr, " "), u8(100), u8(101), u8(564)))
|
||||
end
|
||||
end
|
||||
|
||||
U.log("export_sav_bug889: done")
|
||||
love.event.quit()
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -131,7 +131,12 @@ package.loaded["src.import.CacheFs"] = fakeCache
|
||||
|
||||
local SaveConvert = require("src.save_convert.SaveConvert")
|
||||
|
||||
local GENERATED = { "pokemon", "moves", "items", "maps" }
|
||||
-- tilesets/audio joined the set with the #889 map-context rebuild, which
|
||||
-- reads the current map's tileset row and song out of the same cache.
|
||||
-- The audio entry is single-quoted on purpose: gate_meta_coverage.lua treats a
|
||||
-- double-quoted registry name anywhere in the test corpus as that registry's
|
||||
-- unit test, and this suite is not the mod audio registry's.
|
||||
local GENERATED = { "pokemon", "moves", "items", "maps", "tilesets", 'audio' }
|
||||
|
||||
local function prefixes()
|
||||
local seen = {}
|
||||
@@ -148,7 +153,7 @@ do
|
||||
name .. " comes out of Blue's cache, not the un-prefixed read path")
|
||||
end
|
||||
eq(prefixes()[GameVersion.VERSIONS.blue.cachePrefix], #GENERATED,
|
||||
"all four generated tables are read under Blue's cache prefix")
|
||||
"every generated table is read under Blue's cache prefix")
|
||||
eq(fakeCache.prefix, SENTINEL,
|
||||
"CacheFs.prefix is launcher-owned state and is put back after the read")
|
||||
check(data and data.eventFlags ~= nil,
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
-- #889: a .sav exported from a save that never came from a ROM import used to
|
||||
-- carry no current-map state at all. A Continue restores that window from the
|
||||
-- save and never rebuilds it (LoadMainData sets BIT_NO_PREVIOUS_MAP and
|
||||
-- LoadMapHeader returns early on it), so the game continued into tileset 0,
|
||||
-- a $0000 map-data pointer and sound id 0 -- a garbled map and a silent hang
|
||||
-- on real hardware.
|
||||
--
|
||||
-- src/save_convert/MapContext.lua replays LoadMapHeader's WRAM writes from the
|
||||
-- extracted ROM bytes instead. This suite pins the layout it writes, on a
|
||||
-- synthetic map whose header/object bytes are chosen so every field is
|
||||
-- distinguishable, and then checks the encoder's three cases: no template
|
||||
-- (rebuild), a template saved on the same map (leave the game's own bytes
|
||||
-- alone, which is what keeps import -> export byte-identical), and a template
|
||||
-- saved on a different map (rebuild, or the export carries the wrong map's
|
||||
-- header).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local MapContext = require("src.save_convert.MapContext")
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
|
||||
local O = MapContext.OFFSETS
|
||||
local SAVE = GenSave.OFFSETS
|
||||
|
||||
-- ---------------------------------------------------------------- fixtures
|
||||
|
||||
-- tileset 0, 4 blocks tall, 5 wide, data/text/script pointers, north|west
|
||||
local HEADER = { 0x00, 0x04, 0x05, 0x21, 0x43, 0x78, 0x56, 0x89, 0x67, 0x0A }
|
||||
-- two 11-byte map_connection_structs, north first then west (the order
|
||||
-- LoadMapHeader copies them in), each tagged with its own filler
|
||||
local CONNECTIONS = {
|
||||
0x11, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA,
|
||||
0x22, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA,
|
||||
}
|
||||
local OBJECTS = {
|
||||
0x0E, -- wMapBackgroundTile
|
||||
2, -- warps
|
||||
0x04, 0x05, 0x00, 0x26,
|
||||
0x06, 0x07, 0x01, 0x27,
|
||||
2, -- signs
|
||||
0x08, 0x09, 0x03, -- Y, X, text id
|
||||
0x0A, 0x0B, 0x04,
|
||||
3, -- sprites
|
||||
0x01, 0x14, 0x15, 0xFF, 0xD0, 0x05, -- plain NPC
|
||||
0x02, 0x16, 0x17, 0xFE, 0x01, 0x47, -- trainer ($40): class, party
|
||||
0x33, 0x44,
|
||||
0x03, 0x18, 0x19, 0xFF, 0xD3, 0x85, -- item ball ($80): item id
|
||||
0x14,
|
||||
}
|
||||
local TILESET_HEADER = { 0x0C, 0x11, 0x40, 0x22, 0x40, 0x33, 0x40,
|
||||
0x44, 0x55, 0x66, 0x77, 0x02 }
|
||||
|
||||
local function fixtureData()
|
||||
local maps = {}
|
||||
for id, map in pairs(dofile("tests/fixture_data/maps.lua")) do
|
||||
local copy = {}
|
||||
for k, v in pairs(map) do copy[k] = v end
|
||||
maps[id] = copy
|
||||
end
|
||||
maps.FIX_TOWN.sram = {
|
||||
header = HEADER, connections = CONNECTIONS, objects = OBJECTS,
|
||||
}
|
||||
return {
|
||||
maps = maps,
|
||||
pokemon = dofile("tests/fixture_data/pokemon.lua"),
|
||||
moves = dofile("tests/fixture_data/moves.lua"),
|
||||
items = dofile("tests/fixture_data/items.lua"),
|
||||
tilesets = { [maps.FIX_TOWN.tileset] = { header = TILESET_HEADER } },
|
||||
audio = {
|
||||
-- Song ids are computed from the header address
|
||||
-- (constants/music_constants.asm): (address - $4000) / 3.
|
||||
mapSongs = { FIX_TOWN = "Music_Fixture" },
|
||||
songs = { Music_Fixture = { address = 0x4000 + 3 * 0xBD, bank = 2 } },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local data = fixtureData()
|
||||
|
||||
-- ------------------------------------------------------------ the window
|
||||
|
||||
local ctx = assert(MapContext.build(data, "FIX_TOWN", 7, 4))
|
||||
local w = ctx.writes
|
||||
|
||||
local function eqBytes(got, want, msg)
|
||||
got = got or {}
|
||||
T.eq(#got, #want, msg .. " (length)")
|
||||
for i = 1, #want do
|
||||
T.eq(got[i], want[i], ("%s (byte %d)"):format(msg, i))
|
||||
end
|
||||
end
|
||||
|
||||
eqBytes(w[O.curMapHeader], HEADER, "wCurMapHeader is the ROM header verbatim")
|
||||
|
||||
-- north and west are present; south and east must read $FF, or LoadTileBlockMap
|
||||
-- walks a connection that is not there
|
||||
local conn = w[O.connectionHeaders]
|
||||
T.eq(#conn, 44, "all four connection structs are written")
|
||||
T.eq(conn[1], 0x11, "north takes the first connection struct")
|
||||
T.eq(conn[11], 0xAA, "north keeps all 11 of its bytes")
|
||||
T.eq(conn[12], 0xFF, "south is disabled with $FF")
|
||||
T.eq(conn[23], 0x22, "west takes the second connection struct")
|
||||
T.eq(conn[34], 0xFF, "east is disabled with $FF")
|
||||
|
||||
eqBytes(w[O.mapBackgroundTile], { 0x0E }, "wMapBackgroundTile")
|
||||
eqBytes(w[O.numberOfWarps], { 2 }, "wNumberOfWarps")
|
||||
eqBytes(w[O.warpEntries], { 0x04, 0x05, 0x00, 0x26, 0x06, 0x07, 0x01, 0x27 },
|
||||
"wWarpEntries are the raw 4-byte rows")
|
||||
eqBytes(w[O.numSigns], { 2 }, "wNumSigns")
|
||||
eqBytes(w[O.signCoords], { 0x08, 0x09, 0x0A, 0x0B },
|
||||
"wSignCoords are split out of the 3-byte sign rows")
|
||||
eqBytes(w[O.signTextIDs], { 0x03, 0x04 }, "wSignTextIDs")
|
||||
eqBytes(w[O.numSprites], { 3 }, "wNumSprites")
|
||||
-- movement byte 2 and the text id with its flag bits masked ("and $3f")
|
||||
eqBytes(w[O.mapSpriteData], { 0xD0, 0x05, 0x01, 0x07, 0xD3, 0x05 },
|
||||
"wMapSpriteData is (movement byte 2, text id & $3f) per sprite")
|
||||
eqBytes(w[O.mapSpriteExtra], { 0, 0, 0x33, 0x44, 0x14, 0 },
|
||||
"wMapSpriteExtraData holds trainer class/party and item id")
|
||||
eqBytes(w[O.currentMapHeight2], { 8 }, "map height doubled into 2x2 blocks")
|
||||
eqBytes(w[O.currentMapWidth2], { 10 }, "map width doubled into 2x2 blocks")
|
||||
|
||||
local tilesetRow = {}
|
||||
for i = 1, 11 do tilesetRow[i] = TILESET_HEADER[i] end
|
||||
eqBytes(w[O.tilesetHeader], tilesetRow,
|
||||
"wTilesetBank..wGrassTile is the Tilesets row (11 bytes)")
|
||||
T.eq(ctx.tileAnimations, 0x02, "the 12th tileset byte rides in sTileAnimations")
|
||||
|
||||
eqBytes(w[O.mapMusicSoundID], { 0xBD }, "the song id is derived from its header address")
|
||||
eqBytes(w[O.mapMusicROMBank], { 2 }, "the song's audio bank comes with it")
|
||||
|
||||
-- x=7,y=4: block coords are the odd/even halves, and the view pointer is
|
||||
-- macros/coords.asm event_displacement over the map width.
|
||||
eqBytes(w[O.yBlockCoord], { 0 }, "wYBlockCoord is y & 1")
|
||||
eqBytes(w[O.xBlockCoord], { 1 }, "wXBlockCoord is x & 1")
|
||||
local view = 0xC6E8 + 7 + 5 + (5 + 6) * 2 + 3
|
||||
eqBytes(w[O.viewPointer], { view % 256, math.floor(view / 256) },
|
||||
"wCurrentTileBlockMapViewPointer")
|
||||
|
||||
-- sSpriteData: picture ids in structs 1..3 of page 1, positions in page 2,
|
||||
-- and every non-player struct's image index disabled at $ff
|
||||
T.eq(#ctx.spriteData, 512, "sSpriteData is the full 512-byte window")
|
||||
T.eq(ctx.spriteData[16 + 1], 0x01, "sprite 1 picture id")
|
||||
T.eq(ctx.spriteData[16 + 3], 0xFF, "sprite 1 image index starts disabled")
|
||||
T.eq(ctx.spriteData[256 + 16 + 5], 0x14, "sprite 1 map Y")
|
||||
T.eq(ctx.spriteData[256 + 16 + 6], 0x15, "sprite 1 map X")
|
||||
T.eq(ctx.spriteData[256 + 16 + 7], 0xFF, "sprite 1 movement byte 1")
|
||||
T.eq(ctx.spriteData[3 * 16 + 1], 0x03, "sprite 3 picture id")
|
||||
T.eq(ctx.spriteData[1], 0, "the player's struct is left to ResetPlayerSpriteData")
|
||||
|
||||
-- a map the cache has no bytes for degrades instead of raising
|
||||
T.eq(MapContext.build(data, "FIX_ROUTE", 0, 0), nil,
|
||||
"a map with no extracted bytes returns nil, not an error")
|
||||
|
||||
-- ------------------------------------------------------- through the codec
|
||||
|
||||
GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")())
|
||||
|
||||
local save = {
|
||||
player = { name = "RED", rival = "BLUE", map = "FIX_TOWN", x = 7, y = 4 },
|
||||
money = 3000, inventory = {}, pokedex = { seen = {}, owned = {} },
|
||||
flags = {}, party = {}, boxes = {},
|
||||
}
|
||||
|
||||
local function byteAt(bytes, mainOffset)
|
||||
return bytes:byte(SAVE.mainData + mainOffset + 1)
|
||||
end
|
||||
|
||||
local fresh = GenSave.encode(save, data, nil)
|
||||
T.eq(byteAt(fresh, O.curMapHeader + 1), 0x04,
|
||||
"a templateless export carries the map header")
|
||||
T.eq(byteAt(fresh, O.tilesetHeader), 0x0C,
|
||||
"a templateless export carries the tileset header")
|
||||
T.eq(byteAt(fresh, O.mapMusicSoundID), 0xBD,
|
||||
"a templateless export carries the map's music, not sound id 0")
|
||||
T.eq(fresh:byte(SAVE.checksumEnd - 1 + 1), 0x02,
|
||||
"sTileAnimations is written before the checksum covers it")
|
||||
T.eq(GenSave.mainChecksumValid(fresh), true,
|
||||
"the rebuilt window is inside a valid main-data checksum")
|
||||
|
||||
-- a template still on its own map keeps the game's own bytes: those include
|
||||
-- live NPC positions, and preserving them is the round-trip invariant
|
||||
local template = {}
|
||||
for i = 1, #fresh do template[i] = fresh:sub(i, i) end
|
||||
template[SAVE.mainData + O.curMapHeader + 1] = string.char(0x99)
|
||||
template[SAVE.spriteData + 17] = string.char(0x77)
|
||||
local sameMap = GenSave.encode(save, data, table.concat(template))
|
||||
T.eq(byteAt(sameMap, O.curMapHeader), 0x99,
|
||||
"a template saved on this map keeps its map header untouched")
|
||||
T.eq(sameMap:byte(SAVE.spriteData + 17), 0x77,
|
||||
"a template saved on this map keeps its live sprite data")
|
||||
|
||||
-- a template saved somewhere else is stale: it holds the OTHER map's header,
|
||||
-- which is exactly as unbootable as an empty one
|
||||
template[SAVE.mainData + SAVE.curMap - SAVE.mainData + 1] = nil
|
||||
local other = {}
|
||||
for i = 1, #fresh do other[i] = fresh:sub(i, i) end
|
||||
other[SAVE.curMap + 1] = string.char(0xFE) -- some map this save is not on
|
||||
other[SAVE.mainData + O.curMapHeader + 1] = string.char(0x99)
|
||||
local moved = GenSave.encode(save, data, table.concat(other))
|
||||
T.eq(byteAt(moved, O.curMapHeader), HEADER[1],
|
||||
"a template saved on another map is rebuilt for the map the save is on")
|
||||
Reference in New Issue
Block a user