Files
gen1recomp/src/core/game3/dataset.lua
T
Shane McGovern 2c356f28ea fix(game3): save, item, map, script and importer bugs found in review
The rest of the Gen 3 review fixes, each with a gated suite in tests/engine/.
ROM semantics were checked against pret/pokefirered.

Scripts
- givemon carried the wrong operand layout, found earlier; four more layout
  desyncs came out of pret asm/macros/event.inc: comparestat is {byte,word},
  and setptr / loadbytefromptr / setptrbyte each carry a leading byte plus a
  word. A wrong size mis-decodes every instruction after the bad one, so
  Versions.CACHE_VERSION moves to 113 and existing caches re-import.
- handlers for previously handler-less verbs: comparestat,
  bufferitemnameplural, setmonmove, setmonmetlocation, the modern
  fateful-encounter pair, the script-locals family (copylocal, setptr,
  loadbytefromptr, setptrbyte, copybyte, compare_local_to_* and
  compare_ptr_to_*), the RAM-script family (setvaddress, vgoto, vcall,
  vgoto_if, vcall_if, vmessage, vbuffermessage, vbufferstring, endram,
  returnram) and the same-map forms of the *at verbs.
- setdooropen / setdoorclosed read their coordinates through VarGet.

Battles
- Knock Off and Thief / Trick persist the item change instead of only
  touching the in-battle copy.
- knocked-off party slots are tracked in a bitmask, so a slot reused later
  does not inherit the flag.

Field and UI
- Player.reset restores facing and clears the surf flags.
- a definition-less Map.load no longer leaves collision unbound.
- an unresolved region-map section no longer reports PALLET TOWN.
- the naming screen splits input from the timer, so update(dt) stops
  indexing a number.
- the hall of fame commits through the engine save path and serializes its
  fields.

Persistence
- gameStats, the link-battle records and the trainer card are serialized.
- the PC deposit refuses at the 999 cap instead of destroying the overflow.

Robustness
- Data.load runs cached modules sandboxed.
- the file browser quotes shell arguments.
- .meta dimensions are bounds-checked and mids.idx validates its header.
2026-09-22 03:28:39 +01:00

400 lines
15 KiB
Lua

-- Hydrate Game3 standalone data from the firered/ GBA extract cache.
-- Uses native mid layouts + warps; does not touch Sevii ferry host maps.
local Versions = require("src.import.gba.versions")
local Extract = require("src.import.gba.extract_island1")
local MapIds = require("src.core.game3.map_ids")
local Dataset = {}
local function diskFallback(rel)
local f = io.open(rel, "rb") or io.open("data/generated/gba/" .. rel, "rb")
if f then
local data = f:read("*a")
f:close()
if type(data) == "string" and #data > 0 then return data end
end
local okG, GameVersion = pcall(require, "src.core.GameVersion")
local prefix = (okG and GameVersion.cachePrefix and GameVersion.cachePrefix()) or "firered/"
local prefixes = { prefix }
-- Always also try firered/ for GBA extract paths (standalone Game3).
if prefix ~= "firered/" then
prefixes[#prefixes + 1] = "firered/"
end
local roots = {}
local identity = os.getenv("POKEPORT_IDENTITY") or ""
local sandboxed = identity ~= ""
local home = os.getenv("HOME")
if home and sandboxed then
roots[#roots + 1] = home .. "/Library/Application Support/LOVE/" .. identity
roots[#roots + 1] = home .. "/.local/share/love/" .. identity
end
if home and not sandboxed then
roots[#roots + 1] = home .. "/.local/share/love/pokemon-love2d"
end
if love and love.filesystem and love.filesystem.getSaveDirectory then
local sd = love.filesystem.getSaveDirectory()
if type(sd) == "string" and sd ~= "" then
roots[#roots + 1] = sd
local parent = sd:match("^(.*)/[^/]+$")
if parent and not sandboxed then roots[#roots + 1] = parent .. "/pokemon-love2d" end
end
end
for _, root in ipairs(roots) do
for _, pfx in ipairs(prefixes) do
for _, path in ipairs({ root .. "/" .. pfx .. rel, root .. "/" .. rel }) do
local f = io.open(path, "rb")
if f then
local data = f:read("*a")
f:close()
if type(data) == "string" and #data > 0 then return data end
end
end
end
end
return nil
end
local function loveCache()
return {
read = function(_, rel)
local ok, CacheFs = pcall(require, "src.import.CacheFs")
if ok and CacheFs and CacheFs.readActive then
local bytes = CacheFs.readActive(rel)
if type(bytes) == "string" then return bytes end
end
if love and love.filesystem then
local bytes = love.filesystem.read(rel)
if type(bytes) == "string" then return bytes end
end
return diskFallback(rel)
end,
write = function(_, rel, bytes)
local ok, CacheFs = pcall(require, "src.import.CacheFs")
if ok and CacheFs and CacheFs.write then
return CacheFs.write(rel, bytes)
end
if not love or not love.filesystem then return false end
return love.filesystem.write(rel, bytes)
end,
exists = function(_, rel)
local cache = loveCache()
return cache:read(rel) ~= nil
end,
}
end
--- Shared firered CacheFs-backed cache for standalone Game3 (mod.cache is nil).
function Dataset.cache()
return loveCache()
end
local function load_lua_rel(rel)
local cache = loveCache()
local src = cache:read(rel)
if not src then return nil end
local chunk = load(src, "@" .. rel, "t", {})
if not chunk then return nil end
local ok, val = pcall(chunk)
if ok then return val end
return nil
end
--- Build map defs for every FR_* (and any other) entry in native manifest / Versions.MAPS.
function Dataset.buildMaps(warps)
warps = warps
or load_lua_rel(Extract.CACHE_ROOT .. "/warps.lua")
or Versions.WARPS
or {}
local connections = load_lua_rel(Extract.CACHE_ROOT .. "/connections.lua") or {}
local manifest = load_lua_rel((Extract.NATIVE_ROOT or Extract.CACHE_ROOT .. "/native") .. "/manifest.lua") or {}
local MapCatalog = require("src.import.gba.map_catalog")
local MapSectionsExtract = require("src.import.gba.map_sections_extract")
local Json = nil
pcall(function() Json = require("src.link.Json") end)
local maps = {}
local function add(mapId, info)
local spec = Versions.MAPS[mapId] or {}
local pair = (info and info.pair) or spec.pair
local tileset = pair and Versions.PAIR_TILESET and Versions.PAIR_TILESET[pair]
-- Load map header metadata if available in map_tree cache
local regionMapSectionId = spec.regionMapSectionId
local showMapName = spec.showMapName
local floorNum = spec.floorNum
local weather = spec.weather
local mapType = spec.mapType
-- pokefirered/include/global.fieldmap.h:191
local cave = spec.cave
local allowEscaping = spec.allowEscaping
local allowRunning = spec.allowRunning
local bikingAllowed = spec.bikingAllowed
local battleType = spec.battleType
local music = spec.music
local borderWidth = spec.borderWidth
local borderHeight = spec.borderHeight
if regionMapSectionId == nil or showMapName == nil or cave == nil
or allowEscaping == nil or allowRunning == nil or bikingAllowed == nil
or battleType == nil or music == nil
or borderWidth == nil or borderHeight == nil then
-- Try loading from data/generated/gba/map_tree/maps/{slot}/header.json
local cache = loveCache()
local candidates = {}
local slot = MapCatalog.slotKeyFor and MapCatalog.slotKeyFor(mapId)
if slot then candidates[#candidates + 1] = slot end
if spec.group ~= nil and spec.num ~= nil then
candidates[#candidates + 1] = string.format("%d_%d", spec.group, spec.num)
end
-- Try lookup in FRLG_MAP_TO_FR reverse
for k, v in pairs(Versions.FRLG_MAP_TO_FR or {}) do
if v == mapId then
candidates[#candidates + 1] = k:gsub(":", "_")
end
end
for k, v in pairs(Versions.FRLG_MAP_TO_SEVII or {}) do
if v == mapId then
candidates[#candidates + 1] = k:gsub(":", "_")
end
end
for _, slot in ipairs(candidates) do
local raw = cache:read("data/generated/gba/map_tree/maps/" .. slot .. "/header.json")
or cache:read(Extract.CACHE_ROOT .. "/map_tree/maps/" .. slot .. "/header.json")
if raw and Json and Json.decode then
local okH, h = pcall(Json.decode, raw)
if okH and type(h) == "table" then
regionMapSectionId = regionMapSectionId or h.regionMapSectionId
showMapName = showMapName or h.showMapName
floorNum = floorNum or h.floorNum
weather = weather or h.weather
mapType = mapType or h.mapType
cave = cave or h.cave
allowEscaping = allowEscaping or h.allowEscaping
allowRunning = allowRunning or h.allowRunning
bikingAllowed = bikingAllowed or h.bikingAllowed
battleType = battleType or h.battleType
music = music or h.music
borderWidth = borderWidth or h.borderWidth
borderHeight = borderHeight or h.borderHeight
break
end
end
end
end
-- Fallback inference if header.json was not loaded
if regionMapSectionId == nil then
local secInfo = MapSectionsExtract.getInfo(nil, mapId, floorNum or 0)
-- getInfo echoes secId 88 (a real section: Pallet Town) with
-- resolved=false for a map it cannot identify. Taking that id would
-- advertise an unknown map as Pallet Town, so only trust a resolved one.
if secInfo and secInfo.resolved then
regionMapSectionId = secInfo.secId
end
end
if showMapName == nil then
showMapName = 0
end
maps[mapId] = {
id = mapId,
name = mapId,
width = (info and info.width) or spec.width or 20,
height = (info and info.height) or spec.height or 18,
kind = spec.kind or "town",
environment = spec.environment or "TOWN",
pair = pair,
tileset = tileset,
warps = warps[mapId] or {},
connections = connections[mapId] or {},
regionMapSectionId = regionMapSectionId,
showMapName = (showMapName == 1 or showMapName == true) and 1 or 0,
floorNum = tonumber(floorNum) or 0,
weather = weather or 0,
mapType = mapType or 0,
cave = tonumber(cave),
allowEscaping = tonumber(allowEscaping),
allowRunning = tonumber(allowRunning),
bikingAllowed = tonumber(bikingAllowed),
battleType = tonumber(battleType),
music = tonumber(music),
borderWidth = tonumber(borderWidth),
borderHeight = tonumber(borderHeight),
native = true,
}
end
if manifest.layouts then
for mapId, info in pairs(manifest.layouts) do
-- Prefer Fire Red maps for standalone boot; keep SEVII_* available but unused.
add(mapId, info)
end
else
for mapId, spec in pairs(Versions.MAPS or {}) do
add(mapId, { width = spec.width, height = spec.height, pair = spec.pair })
end
end
-- Fallback corridor edges if connections.lua missing (pre-v82 caches).
if not next(connections) then
if maps.FR_PALLET_TOWN and maps.FR_ROUTE_1 then
maps.FR_PALLET_TOWN.connections = {
north = { map = "FR_ROUTE_1", offset = 0 },
}
maps.FR_ROUTE_1.connections = {
south = { map = "FR_PALLET_TOWN", offset = 0 },
north = { map = "FR_VIRIDIAN_CITY", offset = -12 },
}
end
if maps.FR_VIRIDIAN_CITY and maps.FR_ROUTE_1 then
maps.FR_VIRIDIAN_CITY.connections = maps.FR_VIRIDIAN_CITY.connections or {}
maps.FR_VIRIDIAN_CITY.connections.south = { map = "FR_ROUTE_1", offset = 12 }
end
if maps.FR_VIRIDIAN_CITY and maps.FR_ROUTE_2 then
maps.FR_VIRIDIAN_CITY.connections = maps.FR_VIRIDIAN_CITY.connections or {}
maps.FR_VIRIDIAN_CITY.connections.north = { map = "FR_ROUTE_2", offset = 12 }
maps.FR_ROUTE_2.connections = {
south = { map = "FR_VIRIDIAN_CITY", offset = -12 },
north = { map = "FR_PEWTER_CITY", offset = -12 },
}
end
if maps.FR_PEWTER_CITY and maps.FR_ROUTE_2 then
maps.FR_PEWTER_CITY.connections = {
south = { map = "FR_ROUTE_2", offset = 12 },
}
end
end
return maps
end
--- Point extract roots at the engine firered cache and install native tilesets.
function Dataset.mountExtractRoots()
local root = Dataset.cacheRootOverride
or os.getenv("POKEPORT_GBA_CACHE")
or "data/generated/gba"
Extract.CACHE_ROOT = root
Extract.NATIVE_ROOT = root .. "/native"
local HealLocations = package.loaded["src.core.game3.heal_locations"]
if HealLocations and HealLocations.invalidate then HealLocations.invalidate() end
end
--- Bind LayoutNative handles onto map defs (FieldView needs midLayout).
function Dataset.attachMidLayouts(maps, cache)
if type(maps) ~= "table" then return 0 end
cache = cache or loveCache()
local LayoutNative = require("src.core.game3.layout_native")
local NativePack = require("src.import.gba.native_pack")
local nativeRoot = Extract.NATIVE_ROOT or (Extract.CACHE_ROOT .. "/native")
local manifest = load_lua_rel(nativeRoot .. "/manifest.lua") or {}
local layouts = manifest.layouts or {}
local attached = 0
for mapId, def in pairs(maps) do
if def and not def.midLayout then
local info = layouts[mapId]
local rel = nativeRoot .. "/"
.. ((info and info.file) or ("layouts/" .. mapId .. ".mid"))
local blob = cache:read(rel)
if blob then
local decoded = NativePack.decodeMidLayout(blob)
if decoded then
local pair = (info and info.pair) or def.pair
def.midLayout = LayoutNative.fromDecoded(decoded, mapId, pair)
if decoded.width and decoded.width > 0 then def.width = decoded.width end
if decoded.height and decoded.height > 0 then def.height = decoded.height end
if pair then def.pair = pair end
attached = attached + 1
end
end
elseif def and def.midLayout then
attached = attached + 1
end
end
return attached
end
--- Populate game.data for standalone Fire Red.
function Dataset.hydrate(game)
Dataset.mountExtractRoots()
local cache = loveCache()
game.data = game.data or {}
game.data.maps = Dataset.buildMaps()
game.data.tilesets = game.data.tilesets or {}
local nLayouts = Dataset.attachMidLayouts(game.data.maps, cache)
local okS, Space = pcall(require, "src.core.game3.scripting.space")
local nEvents = 0
if okS and Space then
Space.ensureBundle(nil)
nEvents = Space.attachEventsToMaps(game.data.maps, Space.bundle) or 0
end
local NativeTileset = require("src.core.game3.tileset_native")
if NativeTileset.install then
NativeTileset.install(cache, nil)
end
local okO, OwSprites = pcall(require, "src.core.game3.ow_sprites")
if okO and OwSprites and OwSprites.install then
OwSprites.install(cache)
end
local okFx, FieldEffects = pcall(require, "src.core.game3.field_effects")
if okFx and FieldEffects and FieldEffects.install then
FieldEffects.install(cache)
end
local okPk, Pokemon = pcall(require, "src.core.game3.pokemon")
if okPk and Pokemon and Pokemon.install then
Pokemon.install(cache)
end
local okPc, PartyChrome = pcall(require, "src.ui.game3.party_chrome")
if okPc and PartyChrome and PartyChrome.install then
PartyChrome.install(cache)
end
local okBc, BagChrome = pcall(require, "src.ui.game3.bag_chrome")
if okBc and BagChrome and BagChrome.install then
BagChrome.install(cache)
end
local okMp, MapPreviewScreen = pcall(require, "src.ui.game3.map_preview_screen")
if okMp and MapPreviewScreen and MapPreviewScreen.install then
MapPreviewScreen.install(cache)
end
local Audio = require("src.core.game3.audio")
-- Explicit firered audio root — never inherit Sevii Extract.CACHE_ROOT default.
local audioRoot = "data/generated/gba/audio"
local okA, errA = Audio.install(cache, { root = audioRoot })
if not okA then
local songs = load_lua_rel(audioRoot .. "/songs.lua")
or load_lua_rel(Extract.CACHE_ROOT .. "/audio/songs.lua")
if songs then
Audio.loadMeta({ songs = songs })
end
print("[game3/dataset] audio install: " .. tostring(errA))
elseif Audio._pack and Audio._pack.index and Audio._pack.index.mapSongs and game.data and game.data.maps then
for mapId, songId in pairs(Audio._pack.index.mapSongs) do
local def = game.data.maps[mapId]
if def and def.music == nil then def.music = songId end
end
end
-- Encounter tables need the mounted firered cache (install may have run earlier).
local okE, Encounters = pcall(require, "src.core.game3.encounters")
if okE and Encounters and Encounters.loadFromMod then
Encounters.loadFromMod(nil)
end
local nMaps = 0
for _ in pairs(game.data.maps) do nMaps = nMaps + 1 end
print(string.format(
"[game3/dataset] hydrated %d maps midLayouts=%d eventMaps=%d (start=%s)",
nMaps, nLayouts, nEvents, tostring(MapIds.NEW_GAME_START.map)))
return true
end
return Dataset