This commit is contained in:
bryanthaboi
2026-08-06 14:36:01 -04:00
parent 984cefdc7b
commit 00f6e3a7b4
8 changed files with 600 additions and 4 deletions
+39
View File
@@ -27,6 +27,7 @@
-- game regenerates all of it from wCurMap on the next map load anyway.
local bit = require("bit")
local MapContext = require("src.save_convert.MapContext")
local GenSave = {}
@@ -1023,6 +1024,44 @@ function GenSave.encode(save, data, template)
setByte(buf, O.lastMap, cw.mapsIndex[save.lastOutdoor.id] or 0)
end
-- Current-map engine state (see src/save_convert/MapContext.lua). A
-- Continue restores this window from the save and never rebuilds it, so a
-- zero-filled one boots into a garbled map on a silent hang (#889).
--
-- Rebuilt when there is no template at all (a save that began as a New Game
-- in this port), and when the template was saved on a DIFFERENT map than the
-- one the player is standing on now -- an imported save that has since been
-- played carries the old map's header, which is just as unbootable. A
-- template still on its own map keeps its bytes untouched: they are the
-- game's own, including live NPC positions, and preserving them is what
-- makes import -> export byte-identical.
local mapId = save.player and save.player.map
if mapId then
local rebuild = true
if src then
-- compare the way the byte was written (masked), and rebuild when the
-- map has no index at all rather than trusting a stale template
local index = cw.mapsIndex[mapId]
rebuild = index == nil or u8(src, O.curMap) ~= bit.band(index, 0xFF)
end
if rebuild then
local ctx = MapContext.build(data, mapId,
(save.player and save.player.x) or 0, (save.player and save.player.y) or 0)
if ctx then
for offset, values in pairs(ctx.writes) do
for i, value in ipairs(values) do
setByte(buf, O.mainData + offset + i - 1, value)
end
end
for i, value in ipairs(ctx.spriteData) do
setByte(buf, O.spriteData + i - 1, value)
end
-- sTileAnimations, the byte between sCurBoxData and the checksum
setByte(buf, O.checksumEnd - 1, ctx.tileAnimations)
end
end
end
-- play time: split save.playTime (seconds) back into H/M/S/F. The real
-- game freezes the clock at 255h and sets wPlayTimeMaxed once past it, so
-- mirror that cap rather than letting hours overflow a single byte.
+263
View File
@@ -0,0 +1,263 @@
-- MapContext -- the current map's engine state, as a vanilla Gen1 save has to
-- carry it (#889).
--
-- Continuing a real save never re-loads the map header. LoadMainData
-- (engine/menus/save.asm) copies sMainData back into WRAM and then sets
-- BIT_NO_PREVIOUS_MAP in wCurMapTileset; LoadMapHeader (home/overworld.asm)
-- clears that bit and RETURNS IMMEDIATELY when it was set, so every byte it
-- would otherwise have written -- the map header, connections, warps, signs,
-- sprites, the tileset header, the map's music -- comes straight out of the
-- save file. A cartridge save always has them because the game saved them.
--
-- An export from this port did not: GenSave models the player's progress, not
-- the engine scratch around it, and left that whole window zero-filled unless
-- it had an imported SRAM image to copy from. The game then continued into
-- tileset 0 with a $0000 map-data pointer and sound id 0, which is the
-- garbled overworld / silent hang users reported after exporting a save that
-- began as a New Game in the port.
--
-- This module rebuilds the window from the extracted ROM data, replaying the
-- same writes LoadMapHeader makes, so a port-origin save boots on hardware.
-- The offsets are all relative to wMainDataStart (= sMainData in a .sav) and
-- were summed from ram/wram.asm, then checked byte-for-byte against a real
-- cartridge save: rebuilding its current map reproduces the bytes it already
-- held, everywhere except the stale tails of disabled connection slots (which
-- the engine never reads once the connected-map byte is $FF).
--
-- Pure Lua, no love.*: shared by the runtime exporter, the CLI and the tests.
local MapContext = {}
-- wMainDataStart-relative offsets. Anchors: wCurMap is +103 (the offset
-- GenSave already decodes the player's map from), and the run from there is
-- wCurMap, wCurrentTileBlockMapViewPointer(2), wYCoord, wXCoord, wYBlockCoord,
-- wXBlockCoord, wLastMap, wUnusedLastMapWidth, wCurMapHeader -- so the header
-- lands at +112, and every later label follows from the declaration sizes in
-- ram/wram.asm (MAX_WARP_EVENTS 32, MAX_BG_EVENTS 16, MAX_OBJECT_EVENTS 16,
-- SPRITE_SET_LENGTH 11).
local O = {
mapMusicSoundID = 100, -- wMapMusicSoundID
mapMusicROMBank = 101, -- wMapMusicROMBank
viewPointer = 104, -- wCurrentTileBlockMapViewPointer (2)
yCoord = 106,
xCoord = 107,
yBlockCoord = 108,
xBlockCoord = 109,
curMapHeader = 112, -- wCurMapHeader (10)
connectionHeaders = 122, -- wNorth/South/West/EastConnectionHeader (4 x 11)
mapBackgroundTile = 182,
numberOfWarps = 183,
warpEntries = 184, -- MAX_WARP_EVENTS x 4
numSigns = 441,
signCoords = 442, -- MAX_BG_EVENTS x 2
signTextIDs = 474, -- MAX_BG_EVENTS
numSprites = 490,
mapSpriteData = 493, -- MAX_OBJECT_EVENTS x 2
mapSpriteExtra = 525, -- MAX_OBJECT_EVENTS x 2
currentMapHeight2 = 557,
currentMapWidth2 = 558,
tilesetHeader = 564, -- wTilesetBank .. wGrassTile (11)
}
MapContext.OFFSETS = O
local MAX_WARP_EVENTS = 32
local MAX_BG_EVENTS = 16
local MAX_OBJECT_EVENTS = 16
local CONNECTION_STRUCT = 11 -- map_connection_struct (macros/ram.asm)
local SPRITE_STRUCT = 16 -- one wSpriteStateData1/2 entry
local NUM_SPRITE_STRUCTS = 16
-- wOverworldMap, the tile-block map the view pointer indexes. Recovered from
-- the event_displacement formula below applied to a real save's coordinates,
-- and it is the same $C6E8 every Gen1 reference quotes.
local OVERWORLD_MAP = 0xC6E8
-- Audio header tables start at $4000 in each of the three audio banks, and
-- constants/music_constants.asm derives every song id arithmetically from
-- that base ("Song ids are calculated by address", music_const:
-- (Music_X - SFX_Headers_1) / 3). So the extracted header address IS the id,
-- and no separate MapSongBanks extraction is needed.
local SFX_HEADERS_BASE = 0x4000
local function soundId(address)
if type(address) ~= "number" then return nil end
local delta = address - SFX_HEADERS_BASE
if delta < 0 or delta % 3 ~= 0 then return nil end
local id = delta / 3
if id > 0xFF then return nil end
return id
end
-- LoadMapHeader parses the map's object data (data/maps/objects/*.asm) into
-- five separate WRAM arrays. Walk it exactly as the engine does; `bytes` is
-- the 1-based array of raw object-data bytes the extractor captured.
local function parseObjects(bytes)
local pos = 1
local function take()
local b = bytes[pos]
pos = pos + 1
return b
end
local out = { warps = {}, signCoords = {}, signTexts = {},
spriteData = {}, spriteExtra = {}, sprites = {} }
out.backgroundTile = take()
local warpCount = take()
if warpCount == nil then return nil end
out.warpCount = warpCount
for _ = 1, math.min(warpCount, MAX_WARP_EVENTS) * 4 do
out.warps[#out.warps + 1] = take()
end
local signCount = take()
if signCount == nil then return nil end
out.signCount = signCount
for _ = 1, math.min(signCount, MAX_BG_EVENTS) do
out.signCoords[#out.signCoords + 1] = take() -- Y
out.signCoords[#out.signCoords + 1] = take() -- X
out.signTexts[#out.signTexts + 1] = take()
end
local spriteCount = take()
if spriteCount == nil then return nil end
out.spriteCount = spriteCount
for _ = 1, math.min(spriteCount, MAX_OBJECT_EVENTS) do
local picture, mapY, mapX = take(), take(), take()
local movement1, movement2, textId = take(), take(), take()
if textId == nil then return nil end
out.sprites[#out.sprites + 1] = {
picture = picture, mapY = mapY, mapX = mapX, movement1 = movement1,
}
-- wMapSpriteData: movement byte 2, then the text id with its trainer/item
-- flag bits masked off (LoadMapHeader's `and $3f`).
out.spriteData[#out.spriteData + 1] = movement2
out.spriteData[#out.spriteData + 1] = textId % 0x40
-- BIT_TRAINER ($40) is tested before BIT_ITEM ($80), as LoadMapHeader does
if textId % 0x80 >= 0x40 then -- trainer: class, then party id
out.spriteExtra[#out.spriteExtra + 1] = take()
out.spriteExtra[#out.spriteExtra + 1] = take()
elseif textId >= 0x80 then -- item ball: item id, second byte unused
out.spriteExtra[#out.spriteExtra + 1] = take()
out.spriteExtra[#out.spriteExtra + 1] = 0
else
out.spriteExtra[#out.spriteExtra + 1] = 0
out.spriteExtra[#out.spriteExtra + 1] = 0
end
end
return out
end
-- build(data, mapId, x, y) -> ctx, err
--
-- ctx.writes [wMainDataStart-relative offset] = array of bytes
-- ctx.spriteData 512 bytes for sSpriteData (wSpriteStateData1 then 2)
-- ctx.tileAnimations the byte sTileAnimations holds
--
-- Returns nil plus a reason when the data set predates the extractor fields
-- this needs (an older ROM cache), so callers can fall back rather than fail.
function MapContext.build(data, mapId, x, y)
local map = data and data.maps and data.maps[mapId]
if not map then return nil, "unknown map " .. tostring(mapId) end
local sram = map.sram
if not (sram and sram.header and sram.objects) then
return nil, "map cache has no saved-map bytes (re-import the ROM)"
end
x, y = math.floor(tonumber(x) or 0), math.floor(tonumber(y) or 0)
local writes = {}
local header = sram.header
writes[O.curMapHeader] = header
local height, width = header[2], header[3]
local connectionFlags = header[10]
-- Connections: LoadMapHeader writes $FF over all four connected-map bytes
-- and then copies 11 bytes per present direction, north/south/west/east.
-- The disabled slots' remaining bytes are never read.
local conn = {}
for i = 1, 4 * CONNECTION_STRUCT do conn[i] = 0 end
for slot = 0, 3 do conn[slot * CONNECTION_STRUCT + 1] = 0xFF end
local pos = 1
for slot, flag in ipairs({ 0x08, 0x04, 0x02, 0x01 }) do
if math.floor(connectionFlags / flag) % 2 == 1 then
for i = 0, CONNECTION_STRUCT - 1 do
conn[(slot - 1) * CONNECTION_STRUCT + 1 + i] = (sram.connections or {})[pos + i] or 0
end
pos = pos + CONNECTION_STRUCT
end
end
writes[O.connectionHeaders] = conn
local parsed = parseObjects(sram.objects)
if not parsed then return nil, "malformed object data for " .. tostring(mapId) end
writes[O.mapBackgroundTile] = { parsed.backgroundTile }
writes[O.numberOfWarps] = { parsed.warpCount }
writes[O.warpEntries] = parsed.warps
writes[O.numSigns] = { parsed.signCount }
writes[O.signCoords] = parsed.signCoords
writes[O.signTextIDs] = parsed.signTexts
writes[O.numSprites] = { parsed.spriteCount }
writes[O.mapSpriteData] = parsed.spriteData
writes[O.mapSpriteExtra] = parsed.spriteExtra
-- "map height/width in 2x2 tile blocks", doubled at the end of LoadMapHeader
writes[O.currentMapHeight2] = { (height * 2) % 256 }
writes[O.currentMapWidth2] = { (width * 2) % 256 }
-- The tileset header, as predef LoadTilesetHeader would have copied it.
local tilesets = data.tilesets or {}
local tilesetDef = tilesets[map.tileset]
local tileAnimations = 0
if tilesetDef and tilesetDef.header then
local row = {}
for i = 1, 11 do row[i] = tilesetDef.header[i] end
writes[O.tilesetHeader] = row
tileAnimations = tilesetDef.header[12] or 0
end
-- MapSongBanks: without it the game continues with sound id 0 and audio
-- bank 0, which is what actually hangs a Continue on a white screen.
local audio = data.audio
local songLabel = audio and audio.mapSongs and audio.mapSongs[mapId]
local song = songLabel and audio.songs and audio.songs[songLabel]
local id = song and soundId(song.address)
if id and song.bank then
writes[O.mapMusicSoundID] = { id }
writes[O.mapMusicROMBank] = { song.bank % 256 }
end
-- Player position within its block, and the upper-left corner of the view.
-- The pointer is the same expression the warp_to tables are assembled with
-- (macros/coords.asm event_displacement).
writes[O.yBlockCoord] = { y % 2 }
writes[O.xBlockCoord] = { x % 2 }
local view = OVERWORLD_MAP + 7 + width
+ (width + 6) * math.floor(y / 2) + math.floor(x / 2)
writes[O.viewPointer] = { view % 256, math.floor(view / 256) % 256 }
-- sSpriteData. LoadMapHeader zeroes structs 1-15, sets their image index to
-- $ff (off screen until the engine places them), then fills in each map
-- object's picture id and its map Y/X plus movement byte 1. Struct 0 is the
-- player, which SpecialEnterMap rebuilds through ResetPlayerSpriteData.
local spriteData = {}
for i = 1, NUM_SPRITE_STRUCTS * SPRITE_STRUCT * 2 do spriteData[i] = 0 end
local data2 = NUM_SPRITE_STRUCTS * SPRITE_STRUCT
for slot = 1, NUM_SPRITE_STRUCTS - 1 do
spriteData[slot * SPRITE_STRUCT + 2 + 1] = 0xFF
end
for index, sprite in ipairs(parsed.sprites) do
local base = index * SPRITE_STRUCT
spriteData[base + 1] = sprite.picture
spriteData[data2 + base + 4 + 1] = sprite.mapY
spriteData[data2 + base + 5 + 1] = sprite.mapX
spriteData[data2 + base + 6 + 1] = sprite.movement1
end
return {
writes = writes,
spriteData = spriteData,
tileAnimations = tileAnimations,
}
end
return MapContext
+10 -1
View File
@@ -42,11 +42,17 @@ local DATA_MODULES = {
moves = { "data.generated.moves", "data/generated/moves.lua" },
items = { "data.generated.items", "data/generated/items.lua" },
maps = { "data.generated.maps", "data/generated/maps.lua" },
-- tilesets/audio are only read by src/save_convert/MapContext.lua, to
-- rebuild the current map's engine state on export (#889)
tilesets = { "data.generated.tilesets", "data/generated/tilesets.lua" },
audio = { "data.generated.audio", "data/generated/audio.lua" },
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" },
eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" },
toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" },
}
local OPTIONAL_MODULES = { tilesets = true, audio = true }
-- Yellow renumbers wEventFlags bits: pokeyellow's constants/event_constants.asm
-- inserts events pokered does not have (the Jessie & James fights, catch
-- training, the Officer Jenny Squirtle) and shifts the Mt Moon 3 / Silph Co
@@ -132,7 +138,10 @@ local function ensureData(gameVersion)
if not mod then
local e
mod, e = loadTable(spec[1], spec[2])
if not mod then return nil, e end
-- tilesets/audio only sharpen the export (MapContext); a cache
-- without them still imports and exports, just without the
-- rebuilt map window, so they must not fail the whole load
if not mod and not OPTIONAL_MODULES[name] then return nil, e end
end
data[name] = mod
end