mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-23 14:07:34 +02:00
264 lines
11 KiB
Lua
264 lines
11 KiB
Lua
-- 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
|