From 532972de59b21da593c94ce95ea7bee57aa9988d Mon Sep 17 00:00:00 2001 From: Colson Rice Date: Fri, 28 Aug 2026 16:18:55 -0400 Subject: [PATCH] Carry the map's object state when a Gen 2 export moves maps --- src/import/CacheContract.lua | 8 +- src/import/RomExtractorGen2.lua | 7 + src/save_convert/Gen2MapContext.lua | 222 ++++++++++++++++++ src/save_convert/Gen2Save.lua | 23 ++ tests/engine/gen2_save_export_map_objects.lua | 169 +++++++++++++ 5 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 src/save_convert/Gen2MapContext.lua create mode 100644 tests/engine/gen2_save_export_map_objects.lua diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index f6c75b9e..8089aa4b 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -10,7 +10,13 @@ local CacheContract = {} CacheContract.FORMAT = "rom-cache-v10:" CacheContract.VERSION_FORMAT = { - crystal = "rom-cache-v10-crystal4:", + -- v11: Gen 2 maps carry their object list's ROM address, which a .sav + -- export re-anchoring a save onto another map writes back into + -- wCurMapObjectEventsPointer. A v10 cache has no address to write, and + -- such an export is refused until the ROM re-imports. + gold = "rom-cache-v11:", + silver = "rom-cache-v11:", + crystal = "rom-cache-v11-crystal4:", } CacheContract.MARKER_PATH = "rom-cache.complete" diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index a4ea6c3c..eeb4fc8a 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -1208,6 +1208,11 @@ function RomExtractorGen2:readMapEvents(bank, address, spriteOrder) local objectCount = self.rom:byte(bank, cursor) cursor = cursor + 1 + -- The bus address of the first object_event, which is exactly the pointer + -- ReadObjectEvents (home/map.asm) leaves in wCurMapObjectEventsPointer: DE + -- after the count byte. A .sav export re-anchoring a save onto this map + -- writes it back so a later ReloadMapEvents reads the right list. + local objectEventsAddr = cursor local objects = {} for i = 1, objectCount do local spriteId = self.rom:byte(bank, cursor) @@ -1241,6 +1246,7 @@ function RomExtractorGen2:readMapEvents(bank, address, spriteOrder) return { warps = warps, coordEvents = coordEvents, bgEvents = bgEvents, objects = objects, + objectEventsAddr = objectEventsAddr, } end @@ -1359,6 +1365,7 @@ function RomExtractorGen2:extractMaps() coordEvents = events.coordEvents, bgEvents = events.bgEvents, objects = events.objects, + objectEventsAddr = events.objectEventsAddr, sceneScripts = sceneScripts, callbacks = callbacks, scripts = { bank = eventsBank, address = scriptsAddr }, diff --git a/src/save_convert/Gen2MapContext.lua b/src/save_convert/Gen2MapContext.lua new file mode 100644 index 00000000..1b21a1ae --- /dev/null +++ b/src/save_convert/Gen2MapContext.lua @@ -0,0 +1,222 @@ +-- Gen2MapContext -- the saved object state a Gen 2 cart save has to carry +-- for the map it stands on. +-- +-- Continuing a real Gen 2 save re-derives the map itself but not its people. +-- MapSetupScript_Continue (data/maps/setup_scripts.asm) runs +-- LoadMapAttributes_SkipObjects: attributes, blocks, graphics and palettes +-- all come back out of the ROM by wMapGroup/wMapNumber, but ReadObjectEvents +-- is deliberately skipped, so wMapObjects, the object structs, the object +-- masks and wCurMapObjectEventCount/Pointer are trusted straight out of the +-- save file. A cartridge save always has them because the game wrote them. +-- +-- An export from this port did not, whenever the save had MOVED since the +-- cartridge image it writes into: encode updated wMapGroup, wMapNumber and +-- the coordinates and left the whole object window describing the old map. +-- The real game then continues onto the new map with the old map's NPCs, +-- object scripts and event flags walking around in it, which is the garbled +-- overworld users see after exporting a save that progressed past the map it +-- was imported on. Gen 1 had the same class of bug and closed it in +-- MapContext.lua (#889, finished by #1691); this is that module for Gen 2. +-- +-- The rebuild replays ReadObjectEvents (home/map.asm) exactly: +-- +-- * each object_event's thirteen ROM bytes are copied verbatim behind a +-- MAPOBJECT_OBJECT_STRUCT_ID of -1, "no struct spawned yet"; +-- * the remaining map-object slots get 0 / -1, the empty pattern; +-- * every NPC object struct is cleared (ClearObjectStructs), and the +-- engine respawns them from wMapObjects the same way it does after any +-- ordinary warp; +-- * wObjectMasks is zeroed and wObjectFollow_Leader/Follower reset to -1; +-- * wCurMapObjectEventCount and wCurMapObjectEventsPointer are set to the +-- new map's count and its object list's ROM address, which is what a +-- later ReloadMapEvents reads. +-- +-- The player keeps the template's own struct and map object, standing and +-- idle exactly as an in-game SAVE leaves them, with only the four map +-- coordinate bytes re-anchored to the new position. +-- +-- Offsets are .sav file offsets. The anchor is the same one +-- tools/gen2_sram_offsets.py uses (wMoney against Gen2Layout), and every +-- value below was summed from the pret .sym files and cross-checked against +-- Gen2Layout's own rows: pokecrystal wMapGroup $DCB5 lands at 0x2843 and +-- pokegold's at 0x2868, which are the numbers Gen2Layout already ships. +-- +-- Known limitation, deliberate: wObjectMasks starts at zero, so an object a +-- scene script would hide is visible until the first re-entry runs that +-- scene. Objects hidden by their own event flag, which is nearly all of +-- them, hide correctly, because the flag lives in wEventFlags and travels +-- with the save. +-- +-- Pure Lua, no love.*: shared by the runtime exporter, the CLI and the tests. + +local Gen2MapContext = {} + +local NUM_OBJECTS = 16 -- wMapObjects slots, the player plus fifteen +local MAPOBJECT_LENGTH = 16 +local OBJECT_EVENT_SIZE = 13 -- the ROM bytes CopyMapObjectEvents copies +local NUM_OBJECT_STRUCTS = 13 -- the player plus twelve +local OBJECT_LENGTH = 40 +-- object_struct's map coordinate bytes (macros/wram.asm): OBJECT_MAP_X at +-- +16, OBJECT_MAP_Y at +17, then the LAST_MAP pair. Same in pokegold +-- (wPlayerMapX $D20D against wPlayerStruct $D1FD) and pokecrystal +-- (wPlayerMapX $D4E6 against wPlayerStruct $D4D6). +local STRUCT_MAP_X = 16 +local STRUCT_MAP_Y = 17 +local STRUCT_LAST_MAP_X = 18 +local STRUCT_LAST_MAP_Y = 19 + +Gen2MapContext.OFFSETS = { + goldSilver = { + objectFollow = 0x205C, -- wObjectFollow_Leader, then _Follower + objectStructs = 0x2065, -- wObjectStructs / wPlayerStruct + mapObjects = 0x22AD, -- wMapObjects, slot 0 is the player's + objectMasks = 0x23AD, -- wObjectMasks + objectEventCount = 0x27B6, -- wCurMapObjectEventCount + objectEventsPointer = 0x27B7, + }, + crystal = { + objectFollow = 0x205B, + objectStructs = 0x2064, + mapObjects = 0x22AC, + objectMasks = 0x23AC, + objectEventCount = 0x2792, + objectEventsPointer = 0x2793, + }, +} + +function Gen2MapContext.offsetsFor(gameVersion) + if gameVersion == "crystal" then return Gen2MapContext.OFFSETS.crystal end + if gameVersion == "gold" or gameVersion == "silver" then + return Gen2MapContext.OFFSETS.goldSilver + end + return nil +end + +local function findMap(data, group, number) + for id, def in pairs((data and data.maps) or {}) do + if type(def) == "table" and def.group == group and def.map == number then + return id, def + end + end + return nil +end + +local function u8(v) return math.floor(tonumber(v) or 0) % 256 end +local function signedU8(v) + v = math.floor(tonumber(v) or 0) + if v < 0 then v = v + 256 end + return v % 256 +end + +-- One wMapObjects slot from the extractor's decoded object_event, laid out +-- exactly as CopyMapObjectEvents leaves it: -1 for the struct id, then the +-- thirteen ROM bytes verbatim, then the two bytes the struct pads to +-- sixteen with. The extractor stores coordinates biased back by the game's +-- +4 and the radius split into nibbles; both transforms are undone here so +-- the slot is byte-for-byte what the cartridge would hold. +local function mapObjectSlot(obj) + local radius = obj.radius or {} + local hours = obj.hours or {} + local eventFlag = obj.eventFlag or 0xFFFF + local script = obj.script or 0 + return { + 0xFF, + u8(obj.spriteId), + u8((obj.y or 0) + 4), + u8((obj.x or 0) + 4), + u8(obj.movement), + (u8(radius.y or 0) % 16) * 16 + u8(radius.x or 0) % 16, + signedU8(hours[1]), + signedU8(hours[2]), + (u8(obj.palette or 0) % 16) * 16 + u8(obj.type or 0) % 16, + u8(obj.sight), + script % 256, math.floor(script / 256) % 256, + eventFlag % 256, math.floor(eventFlag / 256) % 256, + 0, 0, + } +end + +-- build(data, gameVersion, group, number, x, y) -> ctx, err +-- +-- ctx.writes [.sav offset] = array of bytes +-- +-- Returns nil plus a reason when the map is unknown to this data set or the +-- cache predates the extractor field this needs, so callers refuse the +-- export rather than write one that continues wrong. +function Gen2MapContext.build(data, gameVersion, group, number, x, y) + local O = Gen2MapContext.offsetsFor(gameVersion) + if not O then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end + local id, def = findMap(data, group, number) + if not def then + return nil, ("unknown map %d/%d"):format(tonumber(group) or -1, tonumber(number) or -1) + end + local objects = def.objects or {} + if #objects > NUM_OBJECTS - 1 then + return nil, ("%s declares %d objects and a save holds %d") + :format(tostring(id), #objects, NUM_OBJECTS - 1) + end + if type(def.objectEventsAddr) ~= "number" then + return nil, "map cache has no object-table address (re-import the ROM)" + end + x, y = math.floor(tonumber(x) or 0), math.floor(tonumber(y) or 0) + + local writes = {} + + -- The NPC map objects, then the empty pattern ReadObjectEvents pads with. + local slots = {} + for _, obj in ipairs(objects) do + local slot = mapObjectSlot(obj) + for i = 1, MAPOBJECT_LENGTH do slots[#slots + 1] = slot[i] end + end + for _ = #objects + 1, NUM_OBJECTS - 1 do + slots[#slots + 1] = 0 + slots[#slots + 1] = 0xFF + for _ = 3, MAPOBJECT_LENGTH do slots[#slots + 1] = 0 end + end + writes[O.mapObjects + MAPOBJECT_LENGTH] = slots + + -- The player's map object keeps the template's sprite and movement; only + -- its coordinates move. Byte 0 is its struct id, byte 1 its sprite, and + -- the coordinates sit where CopyMapObjectEvents put them, +2 and +3. + writes[O.mapObjects + 2] = { u8(y + 4), u8(x + 4) } + + -- ClearObjectStructs, for the twelve NPC structs. The engine spawns fresh + -- ones from wMapObjects the same way it does after a warp. + local cleared = {} + for _ = 1, (NUM_OBJECT_STRUCTS - 1) * OBJECT_LENGTH do cleared[#cleared + 1] = 0 end + writes[O.objectStructs + OBJECT_LENGTH] = cleared + + -- The player's struct stays as the in-game SAVE left it, standing and + -- idle, re-anchored to the new tile. + writes[O.objectStructs + STRUCT_MAP_X] = { u8(x + 4) } + writes[O.objectStructs + STRUCT_MAP_Y] = { u8(y + 4) } + writes[O.objectStructs + STRUCT_LAST_MAP_X] = { u8(x + 4) } + writes[O.objectStructs + STRUCT_LAST_MAP_Y] = { u8(y + 4) } + + -- Nobody is following anybody across an export. + writes[O.objectFollow] = { 0xFF, 0xFF } + + local masks = {} + for _ = 1, NUM_OBJECTS do masks[#masks + 1] = 0 end + writes[O.objectMasks] = masks + + writes[O.objectEventCount] = { u8(#objects) } + writes[O.objectEventsPointer] = { + def.objectEventsAddr % 256, + math.floor(def.objectEventsAddr / 256) % 256, + } + + return { writes = writes, mapId = id } +end + +-- STRUCT_MAP_Y and STRUCT_LAST_MAP_Y are documented above and pinned by the +-- tests; exported so the tests read the same constants the writes use. +Gen2MapContext.STRUCT_MAP_X = STRUCT_MAP_X +Gen2MapContext.STRUCT_MAP_Y = STRUCT_MAP_Y +Gen2MapContext.MAPOBJECT_LENGTH = MAPOBJECT_LENGTH +Gen2MapContext.OBJECT_LENGTH = OBJECT_LENGTH +Gen2MapContext.NUM_OBJECTS = NUM_OBJECTS +Gen2MapContext.NUM_OBJECT_STRUCTS = NUM_OBJECT_STRUCTS +Gen2MapContext.OBJECT_EVENT_SIZE = OBJECT_EVENT_SIZE + +return Gen2MapContext diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index 276077b9..e499d5e6 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -803,6 +803,29 @@ function Gen2Save.encode(save, gameVersion, template, data) putU8(t, L.wXCoord, pos.x or 0) putU8(t, L.wYCoord, pos.y or 0) end + + -- The save may now stand on a different map than the cartridge image it + -- was written into. Everything the template carried for ITS map is right + -- only for that map: the real game's CONTINUE re-derives attributes and + -- blocks from the ROM but keeps the saved object window + -- (MapSetupScript_Continue runs LoadMapAttributes_SkipObjects), so a + -- moved save needs that window rebuilt for where it stands, or the new + -- map continues with the old map's people in it. Same-map exports leave + -- the template's window untouched, byte for byte. + local templateGroup = template:byte(L.wMapGroup + 1) + local templateNumber = template:byte(L.wMapNumber + 1) + if t[L.wMapGroup] ~= templateGroup or t[L.wMapNumber] ~= templateNumber then + local Gen2MapContext = require("src.save_convert.Gen2MapContext") + local ctx, why = Gen2MapContext.build(data, gameVersion, + t[L.wMapGroup], t[L.wMapNumber], t[L.wXCoord], t[L.wYCoord]) + if not ctx then + return nil, ("this save cannot be exported onto map %d/%d: %s") + :format(t[L.wMapGroup], t[L.wMapNumber], tostring(why)) + end + for offset, values in pairs(ctx.writes) do + for i, value in ipairs(values) do t[offset + i - 1] = value % 256 end + end + end local pt = save.playTime if pt then putBE(t, L.wGameTimeHours, pt.hours or 0, 2) diff --git a/tests/engine/gen2_save_export_map_objects.lua b/tests/engine/gen2_save_export_map_objects.lua new file mode 100644 index 00000000..a9623a68 --- /dev/null +++ b/tests/engine/gen2_save_export_map_objects.lua @@ -0,0 +1,169 @@ +-- A Gen 2 export that moved maps carries the new map's object window +-- (data/maps/setup_scripts.asm MapSetupScript_Continue, home/map.asm +-- ReadObjectEvents). Same-map exports leave the template's window alone, +-- byte for byte, which is the #1852 guarantee these tests also pin. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Gen2Save = require("src.save_convert.Gen2Save") +local Gen2Layout = require("src.save_convert.Gen2Layout") +local Gen2MapContext = require("src.save_convert.Gen2MapContext") + +local SIZE = Gen2Save.SAVE_SIZE +local MAPOBJECT = Gen2MapContext.MAPOBJECT_LENGTH +local STRUCT = Gen2MapContext.OBJECT_LENGTH + +-- A sealed template standing on a chosen map, with a recognizable pattern +-- across its whole object window so both preservation and rebuild are +-- visible in the bytes. Synthesized, not checked in: a real .sav is +-- personal data, same rule tests/engine/gen2_save_import.lua follows. +local function template(L, O, group, number) + local b = {} + for i = 0, SIZE - 1 do b[i] = 0 end + b[L.wMapGroup], b[L.wMapNumber] = group, number + for i = 0, Gen2MapContext.NUM_OBJECTS * MAPOBJECT - 1 do + b[O.mapObjects + i] = 0xA0 + i % 16 + end + for i = 0, Gen2MapContext.NUM_OBJECT_STRUCTS * STRUCT - 1 do + b[O.objectStructs + i] = 0xB0 + i % 16 + end + for i = 0, Gen2MapContext.NUM_OBJECTS - 1 do b[O.objectMasks + i] = 0xC0 end + b[O.objectFollow], b[O.objectFollow + 1] = 3, 4 + b[O.objectEventCount] = 9 + b[L.sCheckValue1] = 0x63 + b[L.sCheckValue2] = 0x7F + local sum = 0 + for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + b[i]) % 65536 end + b[L.sChecksum] = sum % 256 + b[L.sChecksum + 1] = math.floor(sum / 256) % 256 + local out = {} + for i = 0, SIZE - 1 do out[i + 1] = string.char(b[i]) end + return table.concat(out) +end + +-- The map the moved save lands on: two objects, one carrying every field a +-- real object_event can, one minimal. +local function fixtureData(group, number) + return { + pokemon = {}, moves = {}, items = {}, + maps = { + FIX_HOUSE = { + group = group, map = number, + objectEventsAddr = 0x5A17, + objects = { + { + index = 1, spriteId = 0x2F, x = 3, y = 5, movement = 0x07, + radius = { y = 2, x = 1 }, hours = { -1, 20 }, + palette = 4, type = 2, sight = 0, + script = 0x6BCD, eventFlag = 0x02A5, + }, + { + index = 2, spriteId = 0x11, x = 0, y = 0, movement = 0x01, + radius = { y = 0, x = 0 }, hours = { -1, -1 }, + palette = 0, type = 5, sight = 3, + script = 0x6BE0, + }, + }, + }, + }, + } +end + +local function save(group, number, x, y) + return { + player = { name = "TESTER" }, + position = { mapGroup = group, mapNumber = number, x = x, y = y }, + } +end + +local function u8(bytes, at) return bytes:byte(at + 1) end + +for _, spec in ipairs({ + { version = "crystal", L = Gen2Layout.crystal, O = Gen2MapContext.OFFSETS.crystal }, + { version = "gold", L = Gen2Layout.goldSilver, O = Gen2MapContext.OFFSETS.goldSilver }, +}) do + local L, O = spec.L, spec.O + local tpl = template(L, O, 21, 14) + + -- Same map: the template's whole object window survives untouched. + local bytes, err = Gen2Save.encode(save(21, 14, 6, 1), spec.version, tpl, + fixtureData(21, 14)) + T.check(bytes, spec.version .. " same-map export succeeds: " .. tostring(err)) + local windowSame = true + for i = 0, Gen2MapContext.NUM_OBJECTS * MAPOBJECT - 1 do + if u8(bytes, O.mapObjects + i) ~= u8(tpl, O.mapObjects + i) then + windowSame = false + break + end + end + T.check(windowSame, spec.version .. ": same-map export leaves wMapObjects byte-identical") + T.eq(u8(bytes, O.objectEventCount), 9, + spec.version .. ": same-map export leaves the object count alone") + + -- Moved: the window is the NEW map's, exactly as ReadObjectEvents lays + -- it out. + bytes, err = Gen2Save.encode(save(21, 15, 6, 1), spec.version, tpl, + fixtureData(21, 15)) + T.check(bytes, spec.version .. " moved export succeeds: " .. tostring(err)) + + local slot1 = O.mapObjects + MAPOBJECT + local expected = { + 0xFF, 0x2F, 5 + 4, 3 + 4, 0x07, + 2 * 16 + 1, 0xFF, 20, 4 * 16 + 2, 0x00, + 0xCD, 0x6B, 0xA5, 0x02, 0, 0, + } + for i, want in ipairs(expected) do + T.eq(u8(bytes, slot1 + i - 1), want, + ("%s: object 1 byte %d"):format(spec.version, i)) + end + T.eq(u8(bytes, slot1 + MAPOBJECT + 1), 0x11, spec.version .. ": object 2 sprite") + -- An object with no event flag writes the -1 the game uses for "none". + T.eq(u8(bytes, slot1 + MAPOBJECT + 12), 0xFF, spec.version .. ": no-flag object writes $FFFF") + T.eq(u8(bytes, slot1 + MAPOBJECT + 13), 0xFF, spec.version .. ": no-flag object writes $FFFF hi") + -- The first empty slot carries ReadObjectEvents' 0 / -1 pattern. + local empty = slot1 + 2 * MAPOBJECT + T.eq(u8(bytes, empty), 0, spec.version .. ": empty slot struct id") + T.eq(u8(bytes, empty + 1), 0xFF, spec.version .. ": empty slot sprite") + + -- The player: map object coordinates re-anchored, sprite untouched. + T.eq(u8(bytes, O.mapObjects + 2), 1 + 4, spec.version .. ": player map object y") + T.eq(u8(bytes, O.mapObjects + 3), 6 + 4, spec.version .. ": player map object x") + T.eq(u8(bytes, O.mapObjects + 1), u8(tpl, O.mapObjects + 1), + spec.version .. ": player map object sprite keeps the template's byte") + + -- The player struct moves with them; the NPC structs are cleared. + T.eq(u8(bytes, O.objectStructs + Gen2MapContext.STRUCT_MAP_X), 6 + 4, + spec.version .. ": player struct map x") + T.eq(u8(bytes, O.objectStructs + Gen2MapContext.STRUCT_MAP_Y), 1 + 4, + spec.version .. ": player struct map y") + T.eq(u8(bytes, O.objectStructs + STRUCT), 0, spec.version .. ": NPC struct 1 cleared") + T.eq(u8(bytes, O.objectStructs + 12 * STRUCT + STRUCT - 1), 0, + spec.version .. ": NPC struct 12 cleared to its last byte") + + -- Masks, follow, count and pointer. + T.eq(u8(bytes, O.objectMasks), 0, spec.version .. ": object masks cleared") + T.eq(u8(bytes, O.objectMasks + 15), 0, spec.version .. ": all sixteen of them") + T.eq(u8(bytes, O.objectFollow), 0xFF, spec.version .. ": follow leader reset") + T.eq(u8(bytes, O.objectFollow + 1), 0xFF, spec.version .. ": follow follower reset") + T.eq(u8(bytes, O.objectEventCount), 2, spec.version .. ": object count") + T.eq(u8(bytes, O.objectEventsPointer), 0x17, spec.version .. ": events pointer lo") + T.eq(u8(bytes, O.objectEventsPointer + 1), 0x5A, spec.version .. ": events pointer hi") + + -- And the checksum still seals the block the game verifies. + T.check(Gen2Save.checksumValid(bytes, L), spec.version .. ": moved export checksums") + + -- Refusals: an unknown map, and a cache from before the extractor kept + -- the object-table address. + local refused, why = Gen2Save.encode(save(9, 9, 0, 0), spec.version, tpl, + fixtureData(21, 15)) + T.check(refused == nil and why:find("unknown map 9/9", 1, true), + spec.version .. ": unknown map refuses: " .. tostring(why)) + + local stale = fixtureData(21, 15) + stale.maps.FIX_HOUSE.objectEventsAddr = nil + refused, why = Gen2Save.encode(save(21, 15, 0, 0), spec.version, tpl, stale) + T.check(refused == nil and why:find("re%-import the ROM"), + spec.version .. ": stale cache refuses with the re-import hint: " .. tostring(why)) +end + +T.finish("gen2 save export map objects")