mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
CLOSES #889
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user