mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 15:51:17 +02:00
2141 lines
86 KiB
Lua
2141 lines
86 KiB
Lua
-- Gen 1 module facades for a Gen 2 boot: the Gen 1 API, backed by Gen 2
|
|
-- internals, handed to a mod's own require by src/mods/Loader.lua's shim.
|
|
-- Contract and the full table: docs/mod-api-gen2-compat.md.
|
|
--
|
|
-- Three rules, because a plausible wrong answer is worse than a missing
|
|
-- module: answered at CALL time (never a snapshot), absent or warned once when
|
|
-- Gold cannot back it, and one stable writable table for the whole run.
|
|
--
|
|
-- A fourth that decides facade-versus-alias: a name a mod MONKEY-PATCHES has
|
|
-- to resolve to the very table Gold runs, or the patch lands on a copy and
|
|
-- silently never fires. Everything else is a translating facade, because an
|
|
-- alias to a Gen 2 module with a different argument list is the plausible
|
|
-- wrong answer in its purest form.
|
|
--
|
|
-- Gen2Compat.coverage() publishes which is which, per member, so the modkit
|
|
-- checker cannot drift from what is actually built here.
|
|
|
|
local Logger = require("src.core.Logger")
|
|
local Runtime = require("src.mods.Runtime")
|
|
|
|
local Gen2Compat = {}
|
|
|
|
-- the genuine require: the Loader replaces _G.require while mods load
|
|
local rawRequire = require
|
|
|
|
-- injected by src/mods/Loader.lua: the live Game2, or nil before one exists
|
|
local resolveGame = nil
|
|
|
|
-- built adapters, one per name for the life of the process
|
|
local built = {}
|
|
-- mod ids that took each facade, for attribution in the warnings below
|
|
local claimants = {}
|
|
local warned = {}
|
|
|
|
local function warnOnce(key, fmt, ...)
|
|
if warned[key] then return end
|
|
warned[key] = true
|
|
Logger.warn(fmt, ...)
|
|
end
|
|
|
|
local function who(name)
|
|
local ids = claimants[name]
|
|
if not ids or #ids == 0 then return "a gen2compat mod" end
|
|
return table.concat(ids, ", ")
|
|
end
|
|
|
|
local function live()
|
|
return resolveGame and resolveGame() or nil
|
|
end
|
|
|
|
-- the live World, or nil plus one warning naming the member that wanted it
|
|
local function liveWorld(module, member)
|
|
local g = live()
|
|
local world = g and g.world
|
|
if world then return world end
|
|
warnOnce(module .. "." .. member .. ".noworld",
|
|
"[%s] %s.%s: no world is up yet; the honest replacement is the "
|
|
.. "game.ready / map.entered event", who(module), module, member)
|
|
return nil
|
|
end
|
|
|
|
-- A member Gold cannot back: the call is answered with nil and named once,
|
|
-- with the mod that took the facade attributed. Never a default.
|
|
local function unbacked(module, member, why)
|
|
return function()
|
|
warnOnce(module .. "." .. member,
|
|
"[%s] %s.%s has no Gen 2 backing: %s",
|
|
who(module), module, member, why)
|
|
return nil
|
|
end
|
|
end
|
|
|
|
-- ------- coverage
|
|
--
|
|
-- One machine-readable answer to "is this module served, and what happened to
|
|
-- each member". Statuses are exactly three and the vocabulary is frozen:
|
|
-- backed the member is present and does the Gen 1 job on Gold
|
|
-- warned the member is present, answers nil (or degrades) and says so once
|
|
-- absent the member is deliberately not on the table; a nil call is the
|
|
-- honest failure and calling it is a mod bug, not an adapter gap
|
|
-- `notes` carries the one-line reason a checker or a doc wants to quote.
|
|
|
|
local COVERAGE = {}
|
|
|
|
Gen2Compat.COVERAGE_VERSION = 1
|
|
Gen2Compat.STATUS = { BACKED = "backed", WARNED = "warned", ABSENT = "absent" }
|
|
|
|
local function words(s)
|
|
local out = {}
|
|
for w in tostring(s or ""):gmatch("%S+") do out[#out + 1] = w end
|
|
return out
|
|
end
|
|
|
|
-- ------- src.core.Game
|
|
--
|
|
-- Gen 1's `Game` IS the running game (src/core/Game.lua:17); Game2 is a class
|
|
-- whose instance is minted per boot, so there is no table to alias. The
|
|
-- proxy resolves every key against the live instance at read time.
|
|
--
|
|
-- Deliberately NOT a blanket forward: a dozen Gen 1 members Game2 simply
|
|
-- lacks would read nil with no word, and two (applyOptions, load) forward to a
|
|
-- Game2 method of the same name with a different contract.
|
|
|
|
-- data.sprites is a rename (gen2Sprites, src/mods/Schemas.lua:475); the rest
|
|
-- of the renames sit beside it. data.constants is the one that must NOT be
|
|
-- routed: Gold's gen2Constants is the cart's ordered name lists, not Gen 1's
|
|
-- rule table, so the same word names a different thing.
|
|
local DATA_RENAMES = {
|
|
sprites = "gen2Sprites", maps = "gen2Maps", tilesets = "gen2Tilesets",
|
|
text = "gen2Text", encounters = "gen2Encounters", palettes = "gen2Palettes",
|
|
icons = "gen2Icons", battle_anims = "gen2BattleAnims",
|
|
}
|
|
|
|
local DATA_UNBACKED = {
|
|
field = "Gold's ledges are src/world/gen2/Permissions.lua and its tile "
|
|
.. "pairs do not exist",
|
|
constants = "data.gen2Constants is the cart's ordered NAME LISTS, not Gen "
|
|
.. "1's rule table; partyMax / boxCount / badges have no Gold equivalent",
|
|
text_pointers = "the extractor's Gen 1 pointer table has no Gold counterpart",
|
|
trainer_headers = "the extractor's Gen 1 header table has no Gold counterpart",
|
|
}
|
|
|
|
local dataProxies = setmetatable({}, { __mode = "kv" })
|
|
|
|
local function dataProxy(data)
|
|
if not data then return nil end
|
|
local hit = dataProxies[data]
|
|
if hit then return hit end
|
|
hit = setmetatable({}, {
|
|
__index = function(_, key)
|
|
local renamed = DATA_RENAMES[key]
|
|
if renamed then return data[renamed] end
|
|
local why = DATA_UNBACKED[key]
|
|
if why then
|
|
warnOnce("data." .. key, "[%s] game.data.%s is Gen 1 only: %s",
|
|
who("src.core.Game"), key, why)
|
|
return nil
|
|
end
|
|
return data[key]
|
|
end,
|
|
__newindex = function(_, key, value) data[DATA_RENAMES[key] or key] = value end,
|
|
})
|
|
dataProxies[data] = hit
|
|
return hit
|
|
end
|
|
|
|
-- Members Gen 1 answers and Gold does NOT, where a nil read would otherwise
|
|
-- pass for "the game says no" instead of "this API is not here". Everything
|
|
-- Game2 genuinely carries forwards; everything else reads nil AND says so.
|
|
local GAME_UNBACKED = {
|
|
renderer = "Gold composites in Game2:draw and never inits the Renderer "
|
|
.. "singleton; Game2:pixelScale / :frameFit / :viewport are the honest "
|
|
.. "replacements, and a Renderer alias would answer fitScale correctly "
|
|
.. "while every state write silently no-opped",
|
|
load = "calling it would re-run Gold's whole boot on top of a running game",
|
|
bootConfig = "Gold's new-game inputs are Save.newGame's opts, not a "
|
|
.. "data.field slice",
|
|
makeTitleState = "Gen 2 pushes the title itself (Game2:showTitle); nothing "
|
|
.. "returns a state to push",
|
|
step = "Gold's logic tick is the FixedStep callback in Game2:load; the "
|
|
.. "input.step hook fires at the identical moment",
|
|
}
|
|
|
|
-- Pure whole-stack walks that are not generation-bound at all; pulled off the
|
|
-- real Gen 1 module rather than copied, so they cannot drift.
|
|
local GAME_STACK_STATICS = {
|
|
"worldBgBattleDim", "worldBgBattleInStack", "fillScaleInStack",
|
|
"wideBattleInStack", "uiAnchorsHeldInStack", "drawBaseInStack", "dynamicUI",
|
|
}
|
|
|
|
local function buildGame()
|
|
local proxy = {}
|
|
local statics = nil
|
|
|
|
local function gen1Static(key)
|
|
if statics == nil then
|
|
local ok, module = pcall(rawRequire, "src.core.Game")
|
|
statics = ok and module or false
|
|
end
|
|
return statics and statics[key] or nil
|
|
end
|
|
|
|
local translate = {}
|
|
|
|
-- `.overworld` is the Gen 1 spelling of Game2.world (src/core/Game2.lua:213)
|
|
function translate.overworld(g) return g.world end
|
|
function translate.data(g) return dataProxy(g.data) end
|
|
function translate.fixedStep() return rawRequire("src.core.FixedStep") end
|
|
|
|
function translate.writeOptions()
|
|
return function()
|
|
local g = live()
|
|
if g and g.persistOptions then return g:persistOptions() end
|
|
end
|
|
end
|
|
|
|
function translate.logicSpeed()
|
|
return function()
|
|
local g = live()
|
|
if not g then return 1 end
|
|
return math.max(1, tonumber(g.speedOverride)
|
|
or tonumber(g.options and g.options.speed) or 1)
|
|
end
|
|
end
|
|
|
|
-- Game2:applyOptions takes NO argument and applies self.options, so a
|
|
-- blanket forward of Game:applyOptions(myTable) succeeds and applies the
|
|
-- wrong table. Adopt the argument first, exactly as Gen 1 does.
|
|
function translate.applyOptions()
|
|
return function(opts)
|
|
local g = live()
|
|
if not g then return end
|
|
if opts and opts ~= proxy and opts ~= g.options then
|
|
g.options = opts
|
|
if g.save then g.save.options = opts end
|
|
end
|
|
return g:applyOptions()
|
|
end
|
|
end
|
|
|
|
function translate.restoreSave()
|
|
return function(loaded, recovered)
|
|
local g = live()
|
|
if not g then return end
|
|
if recovered ~= nil then
|
|
warnOnce("game.restoreSave.recovered",
|
|
"[%s] Game:restoreSave's `recovered` and the quarantine report are "
|
|
.. "unserved: Gold has no SaveData.validate pass",
|
|
who("src.core.Game"))
|
|
end
|
|
return g:continueGame(loaded)
|
|
end
|
|
end
|
|
|
|
function translate.restartWithMods()
|
|
return function() return rawRequire("src.core.HostShell").restart() end
|
|
end
|
|
|
|
function translate.recoverInput()
|
|
return function()
|
|
local g = live()
|
|
if not g then return end
|
|
local Input = rawRequire("src.core.Input")
|
|
local Touch = rawRequire("src.core.TouchControls")
|
|
Input:reset()
|
|
if Input.reconcile then Input:reconcile() end
|
|
Touch:reset()
|
|
if g.mods and g.mods.releaseModInput then g.mods:releaseModInput() end
|
|
if g.cancelPointers then g:cancelPointers() end
|
|
end
|
|
end
|
|
|
|
-- Gen 1's joystickadded runs the whole input recovery; Gold's is a noop, so
|
|
-- a mod relying on hotplug recovery would get none and no signal.
|
|
function translate.joystickadded()
|
|
local recover = translate.recoverInput()
|
|
return function(joystick)
|
|
recover()
|
|
local g = live()
|
|
if g and g.joystickadded then g:joystickadded(joystick) end
|
|
end
|
|
end
|
|
|
|
function translate.zoomStep()
|
|
return function(delta)
|
|
local g = live()
|
|
if not (g and g.world and g.world.map) then return end
|
|
g.world:zoomStep(delta)
|
|
g.options = g.options or {}
|
|
g.options.zoom = rawRequire("src.render.Zoom").offset
|
|
if g.persistOptions then g:persistOptions() end
|
|
end
|
|
end
|
|
|
|
setmetatable(proxy, {
|
|
__index = function(_, key)
|
|
local why = GAME_UNBACKED[key]
|
|
if why then
|
|
warnOnce("game." .. key, "[%s] Game.%s has no Gen 2 backing: %s",
|
|
who("src.core.Game"), key, why)
|
|
return nil
|
|
end
|
|
local made = translate[key]
|
|
if made then
|
|
local g = live()
|
|
if not g and key ~= "writeOptions" and key ~= "logicSpeed"
|
|
and key ~= "fixedStep" and key ~= "restartWithMods" then
|
|
return nil
|
|
end
|
|
return made(g)
|
|
end
|
|
for _, name in ipairs(GAME_STACK_STATICS) do
|
|
if name == key then return gen1Static(key) end
|
|
end
|
|
local g = live()
|
|
if not g then return nil end
|
|
local value = g[key]
|
|
-- Gen 1 has it, Gold does not, and nothing above claimed it: nil is the
|
|
-- answer either way, but a mod deserves to hear which nil this is.
|
|
if value == nil then
|
|
warnOnce("game.unknown." .. tostring(key),
|
|
"[%s] Game.%s is not on the Gen 2 service owner; it reads nil "
|
|
.. "because Game2 has no member of that name",
|
|
who("src.core.Game"), tostring(key))
|
|
return nil
|
|
end
|
|
-- a method reached through the facade gets the live game as self
|
|
if type(value) == "function" then
|
|
return function(first, ...)
|
|
if first == proxy then return value(g, ...) end
|
|
return value(first, ...)
|
|
end
|
|
end
|
|
return value
|
|
end,
|
|
__newindex = function(_, key, value)
|
|
local g = live()
|
|
if not g then return end
|
|
if key == "overworld" then g.world = value return end
|
|
g[key] = value
|
|
end,
|
|
})
|
|
return proxy
|
|
end
|
|
|
|
COVERAGE["src.core.Game"] = {
|
|
kind = "facade",
|
|
backed = "overworld data data.sprites data.maps data.tilesets data.text "
|
|
.. "data.encounters data.trainers data.palettes data.icons "
|
|
.. "data.battle_anims data.pokemon data.moves data.items data.type_chart "
|
|
.. "data.font data.audio save mods modStatus input touchControls stack "
|
|
.. "fixedStep speedOverride modPointers capturePath audioAccum "
|
|
.. "writeOptions logicSpeed applyOptions restoreSave restartWithMods "
|
|
.. "recoverInput zoomStep joystickadded returnToTitle update draw "
|
|
.. "wheelmoved keypressed keyreleased gamepadpressed gamepadreleased "
|
|
.. "gamepadaxis joystickpressed joystickreleased joystickaxis "
|
|
.. "joystickhat joystickremoved focus visible onResume pointerEvent "
|
|
.. "touchpressed touchmoved touchreleased mousepressed mousemoved "
|
|
.. "mousereleased cancelPointers adoptSave writeSave worldBgBattleDim "
|
|
.. "worldBgBattleInStack fillScaleInStack wideBattleInStack "
|
|
.. "uiAnchorsHeldInStack drawBaseInStack dynamicUI",
|
|
warned = "renderer load bootConfig makeTitleState step data.field "
|
|
.. "data.constants data.text_pointers data.trainer_headers",
|
|
absent = "linkNet linkSession saveReport save.money save.player.map "
|
|
.. "save.player.x save.player.y save.player.facing save.player.rival "
|
|
.. "save.defeatedTrainers save.meta save.box",
|
|
notes = {
|
|
identity = "the proxy can never compare equal to the Game2 instance the "
|
|
.. "game.ready payload carries (5.1 fires __eq only when both operands "
|
|
.. "share it)",
|
|
iteration = "pairs/next/rawget see an EMPTY table; enumerate the "
|
|
.. "game.ready payload instead",
|
|
rawset = "rawset lands on the proxy, invisible to the engine, and reads "
|
|
.. "back correctly through the same facade -- which is what hides it",
|
|
["save.money"] = "save.player.money on Gold",
|
|
["save.player.map"] = "save.position.map/x/y/facing on Gold",
|
|
["save.player.rival"] = "save.rival.name on Gold",
|
|
keypressed = "F10, F5, backtick and `4` outside the world are silent "
|
|
.. "no-ops on Gold; `2` cycles GbcPalette, not PaletteFX",
|
|
keyreleased = "a Gold screen never sees onKeyReleased (#589)",
|
|
},
|
|
}
|
|
|
|
-- ------- src.world.NPC
|
|
--
|
|
-- ALIAS, and it has to be: a mod tests getmetatable(npc) == the module it
|
|
-- required, or setmetatable(its own trailer, NPC). A facade table cannot be
|
|
-- the metatable of an object built by src/world/gen2/Npc.lua, so the Gen 1
|
|
-- CONSTRUCTOR arity was added there instead (NPC.new sniffs a table first
|
|
-- argument) and the Gen 1 instance surface (pose, a camera-arity draw,
|
|
-- `marching`) with it.
|
|
|
|
local function buildNpc()
|
|
local Npc2 = rawRequire("src.world.gen2.Npc")
|
|
-- the sheet a Gen 1 objDef naming a Kanto SPRITE_* falls back to
|
|
Npc2.fallbackSpriteDef = function()
|
|
local g = live()
|
|
return g and g.world and g.world.player and g.world.player.spriteDef
|
|
end
|
|
return Npc2
|
|
end
|
|
|
|
COVERAGE["src.world.NPC"] = {
|
|
kind = "alias", target = "src.world.gen2.Npc",
|
|
backed = "new __index facePlayer update walkPhase pose draw def id cellX "
|
|
.. "cellY px py facing moving progress stepFlip frozen timer targetX "
|
|
.. "targetY sprite spriteId passable stepFrames marching MOVE",
|
|
warned = "",
|
|
absent = "hopStep",
|
|
notes = {
|
|
new = "movement translated: WALK+UP_DOWN/LEFT_RIGHT to WALK_*, else "
|
|
.. "WANDER with a {x=3,y=3} radius default (radius 0 freezes the "
|
|
.. "object); STAY to STANDING_*, never STILL, which carries FIXED_FACING",
|
|
facePlayer = "Gen 2 returns early for a fixed-facing object or a sheet "
|
|
.. "with one frame, so a raw MOVE.STILL byte still no-ops",
|
|
draw = "two arguments is read as a Gen 1 camera; three is Gold's "
|
|
.. "(ox, oy, scale)",
|
|
hopStep = "Gen 2 interpolates cell to target, so a two-cell hop is "
|
|
.. "expressed by setting the target two cells out",
|
|
update = "wanders / roamDirs are not read; Gold branches on npc.kind, and "
|
|
.. "for kind == 'turn' roamDirs is a facing->facing MAP, not an array",
|
|
},
|
|
}
|
|
|
|
-- ------- src.world.Map
|
|
--
|
|
-- ALIAS, for the same reason: a mod never builds a Map, it gets world.map, so
|
|
-- anything a facade added would be invisible. The Gen 1 module-level statics
|
|
-- and the missing instance methods went into src/world/gen2/Map.lua.
|
|
|
|
COVERAGE["src.world.Map"] = {
|
|
kind = "alias", target = "src.world.gen2.Map",
|
|
backed = "new inBounds cellTile isWalkableCell isWaterCell isGrassCell "
|
|
.. "isCounterCell warpAtCell blockAt setBlock tileAt isDoorTileCell "
|
|
.. "isWarpTileCell signAtCell connection isOutdoor isOutside inRegion "
|
|
.. "isPushable defCellTile defIsWalkableCell defIsWaterCell defPassable "
|
|
.. "def tileset id widthCells heightCells DELTA warps",
|
|
warned = "cellTile defCellTile",
|
|
absent = "isFlyTown ghostBattles warpPadOrHoleAt walkable doorTiles "
|
|
.. "warpTiles waterTiles renderer signAt",
|
|
notes = {
|
|
warpAt = "NAME COLLISION: Gen 1's map.warpAt is a TABLE keyed by cell, "
|
|
.. "Gold's Map:warpAt is a METHOD of the same name. map.warpAt[k] "
|
|
.. "errors and pairs(map.warpAt) errors -- loud, but pointing at the "
|
|
.. "mod. Enumerate map.warps instead, which Gold carries as an "
|
|
.. "ordered array",
|
|
cellTile = "returns a COLL_* byte, not a Gen 1 tile id; unrelated number "
|
|
.. "spaces, warned once on first call",
|
|
isDoorTileCell = "the narrow arm (Permissions.isImmediateWarp): a door "
|
|
.. "walked INTO, not a mat stood on",
|
|
signAtCell = "coordinate-only, so it reports bg events World:bgEventAt "
|
|
.. "would filter out by facing; the record is a bgEvent, not a sign, "
|
|
.. "so sign.text is nil",
|
|
isOutdoor = "Gold decides both isOutdoor and isOutside from the header's "
|
|
.. "environment byte, so they collapse to one answer",
|
|
isFlyTown = "Gold's fly destinations are landmark SPAWN POINTS, not a "
|
|
.. "property of a def; ask world.landmarks.spawns",
|
|
walkable = "Gold has no per-map tile set to extend; widen movement "
|
|
.. "through the movement.collision hook, which Gold honours for the "
|
|
.. "player and for NPC wandering",
|
|
renderer = "Gold bakes whole-map images on the World; the equivalent "
|
|
.. "operation is mod.world:replaceBlock",
|
|
},
|
|
}
|
|
|
|
-- ------- src.world.Collision
|
|
|
|
local function buildCollision()
|
|
local Map2 = rawRequire("src.world.gen2.Map")
|
|
local Permissions = rawRequire("src.world.gen2.Permissions")
|
|
local Collision = { DELTA = Map2.DELTA }
|
|
|
|
function Collision.target(cx, cy, dir)
|
|
local d = Map2.DELTA[dir]
|
|
-- Gen 1 errors here (it indexes a nil DELTA row); softened deliberately,
|
|
-- so a loop on a bad dir spins rather than crashing.
|
|
if not d then return cx, cy end
|
|
return cx + d[1], cy + d[2]
|
|
end
|
|
|
|
-- src/world/Collision.lua:20's contract, `passable` included
|
|
function Collision.occupied(entities, cx, cy, ignore)
|
|
for _, e in ipairs(entities or {}) do
|
|
if e ~= ignore and not e.passable then
|
|
if (e.cellX == cx and e.cellY == cy)
|
|
or (e.targetX == cx and e.targetY == cy) then
|
|
return e
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Gold has no tile-pair table and no data.field to read one out of; the
|
|
-- capability's Gen 2 home is Permissions, which is not data-driven. A
|
|
-- silent accept would leave the mod's intent unhappened with no trace.
|
|
function Collision.load(_data)
|
|
warnOnce("collision.load",
|
|
"[%s] Collision.load: Gold has no data.field.tilePairs; ledges and side "
|
|
.. "walls come from src/world/gen2/Permissions.lua, which is not "
|
|
.. "data-driven", who("src.world.Collision"))
|
|
return nil
|
|
end
|
|
|
|
local function passthrough(allowed) return allowed end
|
|
|
|
-- The verdict Gold's own player gets one line later, not a walkable test:
|
|
-- the surf exception and GetMovementPermissions' side-wall rule are both
|
|
-- invisible to map:isWalkableCell, and without them the facade says yes
|
|
-- where Gold bumps and no on every water cell a surfer is riding.
|
|
local function verdict(map, entities, mover, dir, tx, ty)
|
|
if not map:inBounds(tx, ty) then return false, "bounds" end
|
|
if not map:isWalkableCell(tx, ty) then
|
|
local surfable = mover.surfing
|
|
and Permissions.surfable(map:cellCollision(tx, ty)) ~= nil
|
|
if not surfable then return false, "tile" end
|
|
end
|
|
-- src/world/gen2/Map.lua:128, the refusal World:movePlayer applies BEFORE
|
|
-- tryMove. Reported as "tile", which is how it arrives at Gold's own
|
|
-- verdict; Gen 1 has no name for it.
|
|
if map.stepPermitted and not map:stepPermitted(mover.cellX, mover.cellY, dir)
|
|
then
|
|
return false, "tile"
|
|
end
|
|
if Collision.occupied(entities, tx, ty, mover) then return false, "entity" end
|
|
return true
|
|
end
|
|
|
|
function Collision.canMove(map, entities, mover, dir)
|
|
local tx, ty = Collision.target(mover.cellX, mover.cellY, dir)
|
|
local allowed, why = verdict(map, entities, mover, dir, tx, ty)
|
|
-- the mod's OWN movement.collision hook has to see its own canMove call,
|
|
-- with the ctx keys both generations already use
|
|
if Runtime.wantsHook("movement.collision") then
|
|
local ctx = { map = map, mover = mover, dir = dir,
|
|
fromX = mover.cellX, fromY = mover.cellY,
|
|
toX = tx, toY = ty, reason = why }
|
|
allowed = Runtime.call("movement.collision", passthrough, allowed, ctx)
|
|
why = ctx.reason
|
|
end
|
|
if allowed then return true end
|
|
return false, why
|
|
end
|
|
|
|
return Collision
|
|
end
|
|
|
|
COVERAGE["src.world.Collision"] = {
|
|
kind = "facade",
|
|
backed = "DELTA target occupied canMove",
|
|
warned = "load",
|
|
absent = "",
|
|
notes = {
|
|
DELTA = "the LIVE table, as under Gen 1: a mod that adds a key mutates "
|
|
.. "Gold's own movement table",
|
|
target = "returns (cx, cy) unchanged for an unknown dir where Gen 1 errors",
|
|
canMove = "adds Gold's side-wall refusal (Map:stepPermitted) as reason "
|
|
.. "'tile'; Gen 1's tile-pair check has no Gen 2 counterpart and drops",
|
|
occupied = "tests targetX/Y unconditionally, as Gen 1 does; Gold's own "
|
|
.. "loops test them only while moving",
|
|
},
|
|
}
|
|
|
|
-- ------- src.world.FieldDefaults
|
|
--
|
|
-- Pure Lua with no love.* and no world. Three members hold values that are
|
|
-- TRUE on Gold and are answered; the FIELD half is Kanto content and stays
|
|
-- refused, because a table with one real key and twelve Kanto keys is worse
|
|
-- than no table.
|
|
|
|
-- Every one of these is the same number on both generations, verified against
|
|
-- the Gen 2 file named beside it. The four Gen 1 world constants NOT here
|
|
-- (poisonStepInterval, poisonDamage, blackoutMoneyDivisor, daycareExpPerStep)
|
|
-- are absent on purpose: Gold's step events do not read a shared constant
|
|
-- table and the Gen 1 numbers would be a guess.
|
|
local WORLD_CONSTANTS = {
|
|
stepFrames = 16, -- src/world/gen2/Npc.lua's STEP_FRAMES
|
|
turnFrames = 4, -- src/world/gen2/Player.lua's TURN_FRAMES
|
|
neighborHops = 2, -- the hop count World.computeNeighbors is called with
|
|
}
|
|
|
|
local function buildFieldDefaults()
|
|
local FieldDefaults = {}
|
|
|
|
local constants = { world = WORLD_CONSTANTS }
|
|
-- bikeStepFrames is derived, not copied: Gold's bike length comes out of
|
|
-- src/world/gen2/Bike.lua rather than a table.
|
|
local okBike, Bike = pcall(rawRequire, "src.world.gen2.Bike")
|
|
if okBike and Bike and Bike.stepFramesFor then
|
|
WORLD_CONSTANTS.bikeStepFrames = Bike.stepFramesFor(WORLD_CONSTANTS.stepFrames)
|
|
end
|
|
-- encounterBuckets and hmBadges are Kanto: absent, so a mod's
|
|
-- `for _, b in ipairs(CONSTANTS.encounterBuckets)` errors instead of
|
|
-- rolling Kanto odds on Gold.
|
|
FieldDefaults.CONSTANTS = constants
|
|
|
|
function FieldDefaults.field(_data, key)
|
|
warnOnce("fieldDefaults.field." .. tostring(key),
|
|
"[%s] src.world.FieldDefaults.field(%s): Gold has no data.field",
|
|
who("src.world.FieldDefaults"), tostring(key))
|
|
return nil
|
|
end
|
|
|
|
-- Gen 1's is VARIADIC (src/world/FieldDefaults.lua:240); a fixed arity drops
|
|
-- every deeper path. Exactly one path resolves on Gold.
|
|
function FieldDefaults.fieldValue(_data, key, ...)
|
|
local first = ...
|
|
if key == "playerSprites" then
|
|
if first == "walk" then
|
|
return rawRequire("src.world.gen2.World").PLAYER_SPRITE
|
|
end
|
|
-- Gen 1 returns the whole TABLE here (walk/surf/bike/fly), so answering
|
|
-- the walk string would make a mod's `.surf` a string-index error.
|
|
if first == nil then
|
|
warnOnce("fieldDefaults.fieldValue.playerSprites",
|
|
"[%s] FieldDefaults.fieldValue(data, 'playerSprites') returns the "
|
|
.. "whole Gen 1 table; Gold can answer only the 'walk' leaf",
|
|
who("src.world.FieldDefaults"))
|
|
return nil
|
|
end
|
|
end
|
|
local path = tostring(key)
|
|
for i = 1, select("#", ...) do path = path .. "." .. tostring((select(i, ...))) end
|
|
warnOnce("fieldDefaults.fieldValue." .. path,
|
|
"[%s] src.world.FieldDefaults: Gold has no data.field %s",
|
|
who("src.world.FieldDefaults"), path)
|
|
return nil
|
|
end
|
|
|
|
-- data.constants is the cache's own; the three Gen 1 default tables are
|
|
-- Kanto (encounterBuckets is the Gen 1 wild-slot spread, hmBadges names
|
|
-- Kanto badges) and must never be the fallback.
|
|
function FieldDefaults.constant(data, key)
|
|
local value = data and data.constants and data.constants[key]
|
|
if value ~= nil then return value end
|
|
if key == "world" then return constants.world end
|
|
warnOnce("fieldDefaults.constant." .. tostring(key),
|
|
"[%s] FieldDefaults.constant(%s): Gold's cache does not carry it and "
|
|
.. "the Gen 1 default is Kanto", who("src.world.FieldDefaults"),
|
|
tostring(key))
|
|
return nil
|
|
end
|
|
|
|
function FieldDefaults.world(data, key)
|
|
local scoped = data and data.constants and data.constants.world
|
|
local value = scoped and scoped[key]
|
|
if value ~= nil then return value end
|
|
value = WORLD_CONSTANTS[key]
|
|
if value ~= nil then return value end
|
|
warnOnce("fieldDefaults.world." .. tostring(key),
|
|
"[%s] FieldDefaults.world(%s): Gold's step events do not read a shared "
|
|
.. "constant table, so the Gen 1 number would be a guess",
|
|
who("src.world.FieldDefaults"), tostring(key))
|
|
return nil
|
|
end
|
|
|
|
-- Never let it write: seeding Kanto's field record into a Gold dataset
|
|
-- would put PALLET_TOWN palettes and SILPH_SCOPE rules where mod patches
|
|
-- and engine reads would find them.
|
|
FieldDefaults.seed = unbacked("src.world.FieldDefaults", "seed",
|
|
"seeding Kanto's FIELD into a Gold dataset would put PALLET_TOWN "
|
|
.. "palettes and SILPH_SCOPE rules into data.field")
|
|
|
|
return FieldDefaults
|
|
end
|
|
|
|
COVERAGE["src.world.FieldDefaults"] = {
|
|
kind = "facade",
|
|
backed = "CONSTANTS CONSTANTS.world constant world fieldValue",
|
|
warned = "field seed",
|
|
absent = "FIELD CONSTANTS.encounterBuckets CONSTANTS.hmBadges",
|
|
notes = {
|
|
["CONSTANTS.world"] = "stepFrames, turnFrames, neighborHops and a derived "
|
|
.. "bikeStepFrames only; the four poison / blackout / daycare keys are "
|
|
.. "warned because Gold's StepEvents do not read a shared table",
|
|
fieldValue = "serves data.field playerSprites.walk and nothing else",
|
|
FIELD = "every leaf is Kanto (map ids, tileset names, SGB palettes); a "
|
|
.. "nil-index error is the honest outcome",
|
|
},
|
|
}
|
|
|
|
-- ------- src.pokemon.Boxes
|
|
|
|
local function buildBoxes()
|
|
local Boxes2 = rawRequire("src.core.gen2.Boxes")
|
|
-- the pass-through is purely additive (name/rename/withdraw/release/move/
|
|
-- canUsePc...); only `deposit` collides, and the override below takes it back
|
|
local adapter = setmetatable({}, { __index = Boxes2 })
|
|
|
|
adapter.COUNT = Boxes2.NUM_BOXES
|
|
adapter.CAPACITY = Boxes2.MONS_PER_BOX
|
|
|
|
-- Gen 1's ensure MATERIALISES save.boxes because its callers then index it;
|
|
-- src/core/gen2/Boxes.lua:48 creates one box on demand instead. The clamp
|
|
-- is src/pokemon/Boxes.lua:22 verbatim and is load bearing: Boxes2.box
|
|
-- hands back a fresh DETACHED table for an index outside 1..NUM_BOXES, so
|
|
-- an unclamped currentBox loses whatever is deposited into it.
|
|
function adapter.ensure(save)
|
|
if not save then return {} end
|
|
save.boxes = save.boxes or {}
|
|
for i = 1, Boxes2.NUM_BOXES do
|
|
save.boxes[i] = save.boxes[i] or {}
|
|
end
|
|
save.currentBox =
|
|
math.max(1, math.min(Boxes2.NUM_BOXES, save.currentBox or 1))
|
|
return save.boxes
|
|
end
|
|
|
|
function adapter.active(save)
|
|
local boxes = adapter.ensure(save)
|
|
return boxes[save and save.currentBox or 1]
|
|
end
|
|
|
|
-- src/pokemon/Boxes.lua:33-43 with COUNT/CAPACITY swapped for Gold's.
|
|
-- NOT the inherited Gen 2 deposit(save, partyIndex, boxIndex): a Gen 1 call
|
|
-- would index save.party with a MON TABLE, get nil, and return false plus
|
|
-- "There is no POKeMON there." -- which reads to the caller exactly like
|
|
-- "every box is full" while the mon is silently dropped.
|
|
function adapter.deposit(save, mon)
|
|
local boxes = adapter.ensure(save)
|
|
for off = 0, Boxes2.NUM_BOXES - 1 do
|
|
local i = ((save.currentBox - 1 + off) % Boxes2.NUM_BOXES) + 1
|
|
if #boxes[i] < Boxes2.MONS_PER_BOX then
|
|
table.insert(boxes[i], mon)
|
|
return i
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
return adapter
|
|
end
|
|
|
|
COVERAGE["src.pokemon.Boxes"] = {
|
|
kind = "facade", target = "src.core.gen2.Boxes",
|
|
backed = "COUNT CAPACITY ensure active deposit defaultName name rename box "
|
|
.. "count isFull setCurrent healthyCount canDeposit canWithdraw withdraw "
|
|
.. "release move canUsePc NUM_BOXES MONS_PER_BOX PARTY_SIZE",
|
|
warned = "",
|
|
absent = "",
|
|
notes = {
|
|
COUNT = "14 on Gold, not 12; data.constants.boxCount reads nil, so a UI "
|
|
.. "mod sizing a grid off constants and one sizing it off COUNT "
|
|
.. "disagree",
|
|
ensure = "the save.box migration arm is dropped; a Gold save is born with "
|
|
.. "boxes and never had one",
|
|
},
|
|
}
|
|
|
|
-- ------- src.world.WorldAPI
|
|
--
|
|
-- ALIAS, and it must stay one: src/mods/Loader.lua:977 builds every mod's
|
|
-- mod.world from src.world.gen2.WorldAPI, so a patch applied to the aliased
|
|
-- table is a patch applied to every live mod.world. A facade copy would
|
|
-- produce two WorldAPIs: one the mod patches, one the engine handed it.
|
|
|
|
COVERAGE["src.world.WorldAPI"] = {
|
|
kind = "alias", target = "src.world.gen2.WorldAPI",
|
|
backed = "new __index overworld current mapOverview warpTo toggleObject replaceBlock "
|
|
.. "spawnNpc removeNpc npc queueScript invalidateMap",
|
|
warned = "setFlag getFlag",
|
|
absent = "",
|
|
notes = {
|
|
overworld = "Gold's World is not a stack state, so nothing can hide it; "
|
|
.. "the returned object is a World, not an OverworldState",
|
|
warpTo = "opts is accepted for parity and nothing in it is read: no "
|
|
.. "arrive FX, no onDone, no keepMusic -- a mod chaining off onDone "
|
|
.. "stops with no error",
|
|
toggleObject = "visibility IS the MAPOBJECT_EVENT_FLAG, so an off-map "
|
|
.. "toggle is refused where Gen 1 accepts it",
|
|
setFlag = "Gen 2 event flags are NUMERIC indices into wEventFlags; a "
|
|
.. "string is refused by name rather than written where nothing reads it",
|
|
npc = "looks up def.index or def.name only, NOT npc.id; the handle's "
|
|
.. "marchInPlace is refused outright and scriptMove refuses a second "
|
|
.. "concurrent movement (World.moveState is one slot)",
|
|
queueScript = "validates the WHOLE row list against a five-verb allow "
|
|
.. "list and refuses by name before the first row runs",
|
|
},
|
|
}
|
|
|
|
-- ------- src.world.OverworldController
|
|
--
|
|
-- FACADE (dispatch table), never an alias. Gen 1's module IS the live state
|
|
-- singleton, so patching a method and calling one are the same table; Gold's
|
|
-- overworld is a World INSTANCE with a separate class table. So this is a
|
|
-- table Gold DISPATCHES THROUGH at named seams (World:step calls worldTick,
|
|
-- World:interact asks interactWrapper, World:interactBody asks talkToWrapper)
|
|
-- plus, where a member is a plain query, a function that resolves the live
|
|
-- world and forwards.
|
|
|
|
local overworld
|
|
|
|
local function defaultUpdate() end
|
|
|
|
local function defaultInteract(world)
|
|
return world:interactBody()
|
|
end
|
|
|
|
local function defaultTalkTo()
|
|
return false
|
|
end
|
|
|
|
local OW = "src.world.OverworldController"
|
|
|
|
-- the compass keys Map:connection is indexed by, from the up/down/left/right
|
|
-- vocabulary everything else uses. Getting this wrong lands the player on
|
|
-- the wrong edge with no error.
|
|
local COMPASS = { up = "north", down = "south", left = "west", right = "east" }
|
|
|
|
-- The one place a Gen 1 ENTITY becomes a Gen 2 objectId: 0 is the player, 1 is
|
|
-- wLastTalked and an extracted object is its index + 1
|
|
-- (src/world/gen2/World.lua:3559). The player has no .def, so the old
|
|
-- `(def.index or 0) + 1` walked the last-talked NPC instead; nil means "not
|
|
-- addressable", never a fallback.
|
|
local function objectIdOf(world, entity)
|
|
if entity == nil then return nil end
|
|
if world and entity == world.player then return 0 end
|
|
local index = entity.def and entity.def.index
|
|
if type(index) == "number" then return index + 1 end
|
|
return nil
|
|
end
|
|
|
|
local function buildOverworld()
|
|
local Map2 = rawRequire("src.world.gen2.Map")
|
|
local Permissions = rawRequire("src.world.gen2.Permissions")
|
|
local World2 = rawRequire("src.world.gen2.World")
|
|
local Movement = rawRequire("src.script.gen2.Movement")
|
|
local HiddenItems = rawRequire("src.world.gen2.HiddenItems")
|
|
local Bike = rawRequire("src.world.gen2.Bike")
|
|
|
|
local api = nil
|
|
-- one WorldAPI instance, so queueScript reuses the five-verb allow list
|
|
-- rather than growing a second one here
|
|
local function worldApi()
|
|
if api then return api end
|
|
local g = live()
|
|
if not g then return nil end
|
|
api = rawRequire("src.world.gen2.WorldAPI").new(g, OW)
|
|
return api
|
|
end
|
|
|
|
local function w(member) return liveWorld(OW, member) end
|
|
|
|
local ow = {
|
|
update = defaultUpdate,
|
|
interact = defaultInteract,
|
|
talkTo = defaultTalkTo,
|
|
}
|
|
|
|
-- ---- entity lookups
|
|
|
|
function ow.npcAtCell(cx, cy)
|
|
local world = w("npcAtCell")
|
|
return world and world:npcAt(cx, cy) or nil
|
|
end
|
|
|
|
function ow.pushableAtCell(cx, cy)
|
|
local world = w("pushableAtCell")
|
|
if not world then return nil end
|
|
for _, npc in ipairs(world.npcs or {}) do
|
|
if World2.isStrengthBoulder(npc) and npc:covers(cx, cy) then return npc end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Gen 2 objectIds are the extracted index PLUS ONE (0 is the player, 1 is
|
|
-- wLastTalked), so passing the Gen 1 index straight through returns the
|
|
-- object before the one asked for and nothing errors. Extracted indices are
|
|
-- 1-based on BOTH generations (src/world/gen2/World.lua:6924), so anything
|
|
-- below 1 names no object at all: World:objectEntity would answer talkNpc.
|
|
function ow.npcByIndex(index)
|
|
local world = w("npcByIndex")
|
|
if not world then return nil end
|
|
if type(index) ~= "number" or index < 1 then
|
|
warnOnce("ow.npcByIndex.range",
|
|
"[%s] OverworldState.npcByIndex(%s): object indices start at 1 on "
|
|
.. "both generations; the player is objectId 0 on Gold and is "
|
|
.. "world.player, not an entry in this list", who(OW), tostring(index))
|
|
return nil
|
|
end
|
|
return world:objectEntity(index + 1)
|
|
end
|
|
|
|
-- pool and data are the World's own; a mod keeping a private pool gets
|
|
-- Gold's objects instead, which is worth one line.
|
|
function ow.pooledNPC(pool, _data, mapId, obj)
|
|
local world = w("pooledNPC")
|
|
if not world then return nil end
|
|
if pool ~= nil and pool ~= world.npcPool then
|
|
warnOnce("ow.pooledNPC.pool",
|
|
"[%s] OverworldState.pooledNPC ignores the pool argument: Gold pools "
|
|
.. "on the World (World:pooledNpc)", who(OW))
|
|
end
|
|
return world:pooledNpc(mapId, obj)
|
|
end
|
|
|
|
ow.computeNeighbors = World2.computeNeighbors
|
|
|
|
-- ---- map lifecycle
|
|
|
|
function ow.setMap(mapId, x, y, facing, opts)
|
|
local world = w("setMap")
|
|
if not world then return nil end
|
|
if opts and opts.keepPikachu ~= nil and opts.keepFollower == nil then
|
|
opts = { via = opts.via, seamless = opts.seamless,
|
|
keepMusic = opts.keepMusic, keepFollower = opts.keepPikachu }
|
|
end
|
|
return world:setMap(mapId, x, y, facing, opts)
|
|
end
|
|
|
|
function ow.rebuildNeighbors()
|
|
local world = w("rebuildNeighbors")
|
|
return world and world:rebuildNeighbors() or nil
|
|
end
|
|
|
|
function ow.reloadMap(mapId, reason)
|
|
local world = w("reloadMap")
|
|
if not world then return nil end
|
|
if mapId ~= nil and world.map and world.map.id ~= mapId then return true end
|
|
return world:reloadMapBadWarp(reason)
|
|
end
|
|
|
|
function ow.takeWarp(warpDef)
|
|
local world = w("takeWarp")
|
|
return world and world:takeWarp(warpDef) or nil
|
|
end
|
|
|
|
function ow.startWarpTo(mapId, x, y, facing, onDone, _opts)
|
|
local world = w("startWarpTo")
|
|
if not world then return nil end
|
|
if onDone then
|
|
warnOnce("ow.startWarpTo.onDone",
|
|
"[%s] OverworldState.startWarpTo drops onDone on Gold "
|
|
.. "(World:warpToMapId has no completion callback); a mod chaining "
|
|
.. "off it stalls", who(OW))
|
|
end
|
|
return world:warpToMapId(mapId, x, y,
|
|
facing or (world.player and world.player.facing))
|
|
end
|
|
|
|
function ow.canCollisionWarp()
|
|
local world = w("canCollisionWarp")
|
|
return world and not world:warpsSuppressed() or false
|
|
end
|
|
|
|
function ow.refreshStandingOnWarp()
|
|
local world = w("refreshStandingOnWarp")
|
|
return world and world:armWarpCheck() or nil
|
|
end
|
|
|
|
function ow.healPoint()
|
|
local world = w("healPoint")
|
|
return world and world:healPoint() or nil
|
|
end
|
|
|
|
function ow.warpToHealPoint(onDone)
|
|
local world = w("warpToHealPoint")
|
|
if not world then return nil end
|
|
if onDone then
|
|
warnOnce("ow.warpToHealPoint.onDone",
|
|
"[%s] OverworldState.warpToHealPoint drops onDone and opts.arrive on "
|
|
.. "Gold (World:warpToSpawn takes neither)", who(OW))
|
|
end
|
|
return world:warpToSpawn()
|
|
end
|
|
|
|
-- ---- movement
|
|
|
|
function ow.dirHeld()
|
|
local world = w("dirHeld")
|
|
return world and world.heldDir or nil
|
|
end
|
|
|
|
function ow.handleInput()
|
|
local world = w("handleInput")
|
|
local g = live()
|
|
return world and world:pollInput(g and g.input) or nil
|
|
end
|
|
|
|
function ow.stepForwardOrCrossEdge(dir)
|
|
local world = w("stepForwardOrCrossEdge")
|
|
return world and world:movePlayer(dir) or nil
|
|
end
|
|
|
|
function ow.checkLedgeHop(dir)
|
|
local world = w("checkLedgeHop")
|
|
return world and world:tryLedgeJump(dir) or false
|
|
end
|
|
|
|
function ow.checkEdgeExit(dir)
|
|
local world = w("checkEdgeExit")
|
|
return world and world:tryConnection(dir) or false
|
|
end
|
|
|
|
-- Gold has no crossConnection/checkEdgeExit split: tryConnection does both.
|
|
ow.crossConnection = function(dir) return ow.checkEdgeExit(dir) end
|
|
|
|
-- Gen 1's five-value shape, in order: the destination map DEF, its tileset
|
|
-- def, x, y, conn (src/world/OverworldController.lua:1404). Returning the
|
|
-- map ID first put a STRING where callers index .width, and dropped the
|
|
-- tileset the very next Map.defPassable call needs.
|
|
function ow.connectionLanding(dir)
|
|
local world = w("connectionLanding")
|
|
if not (world and world.map and world.player) then return nil end
|
|
local conn = world.map:connection(COMPASS[dir] or dir)
|
|
if not conn then return nil end
|
|
local destId = conn.map or conn.mapId
|
|
local destDef = destId and world.maps and world.maps[destId]
|
|
if not destDef then return nil end
|
|
local tileset = world.tilesets and world.tilesets[destDef.tileset]
|
|
if not tileset then return nil end
|
|
local x, y = Map2.connectionLanding(destDef, conn, dir,
|
|
world.player.cellX, world.player.cellY)
|
|
return destDef, tileset, x, y, conn
|
|
end
|
|
|
|
function ow.checkBoulderPush(dir)
|
|
local world = w("checkBoulderPush")
|
|
if not (world and world.player) then return false end
|
|
local d = Map2.DELTA[dir]
|
|
if not d then return false end
|
|
local p = world.player
|
|
return world:tryPushBoulder(dir, p.cellX + d[1], p.cellY + d[2])
|
|
end
|
|
|
|
function ow.checkForcedMovement()
|
|
local world = w("checkForcedMovement")
|
|
return world and world:checkCarpetWhileStanding() or nil
|
|
end
|
|
|
|
-- Gold has ONE movement slot; a second concurrent call is refused with a
|
|
-- reason rather than dropped (src/world/gen2/WorldAPI.lua:171's recipe).
|
|
function ow.scriptMove(entity, dir, tiles, onDone)
|
|
local world = w("scriptMove")
|
|
if not world then return nil, "no overworld" end
|
|
if world.moveState then return nil, "a movement is already running" end
|
|
local step = Movement.stepByte(dir)
|
|
if not step then return nil, "unknown direction: " .. tostring(dir) end
|
|
local bytes = {}
|
|
for _ = 1, math.max(0, tiles or 1) do bytes[#bytes + 1] = step end
|
|
bytes[#bytes + 1] = Movement.STEP_END
|
|
local objectId = objectIdOf(world, entity)
|
|
if not objectId then
|
|
return nil, "no Gen 2 objectId for that entity: only the player and a "
|
|
.. "mapped object (def.index) can be moved"
|
|
end
|
|
world:beginMovement(objectId, bytes, onDone)
|
|
return true
|
|
end
|
|
|
|
function ow.updateScriptMoves()
|
|
local world = w("updateScriptMoves")
|
|
return world and world:updateMovement() or nil
|
|
end
|
|
|
|
-- ---- world state
|
|
|
|
function ow.timeOfDay()
|
|
local world = w("timeOfDay")
|
|
if not world then return nil end
|
|
-- Gen 1 returns the CACHED tod, Gold recomputes from the clock every
|
|
-- call: a mod that set a custom tod reads the clock's answer back unless
|
|
-- its world.tod hook is still installed.
|
|
return world:timeOfDay(world:hour())
|
|
end
|
|
|
|
-- Gold's darkness is a map PROPERTY the palette set reads; the only thing a
|
|
-- mod can move is flashUsed, and only on a DARKNESS palset map.
|
|
function ow.setDark(on)
|
|
local world = w("setDark")
|
|
if not world then return nil end
|
|
warnOnce("ow.setDark",
|
|
"[%s] OverworldState.setDark only has an effect on maps whose palset is "
|
|
.. "DARKNESS; elsewhere it is a no-op (Gold's dimming is a palette set, "
|
|
.. "not a state boolean)", who(OW))
|
|
world.flashUsed = not on
|
|
return world:applyPalettes()
|
|
end
|
|
|
|
function ow.bikeAllowed(mapId)
|
|
local world = w("bikeAllowed")
|
|
if not world then return false end
|
|
local def = world.maps and world.maps[mapId]
|
|
if not def then return false end
|
|
return Bike.environmentAllows(def.environment)
|
|
end
|
|
|
|
function ow.replaceBlock(bx, by, block)
|
|
local world = w("replaceBlock")
|
|
if not world then return nil end
|
|
-- NEVER World:replaceBlock, which exists on Gold under the same name and
|
|
-- takes a FLAT INDEX plus a block id: forwarding there edits an unrelated
|
|
-- block and drops the third argument, without erroring.
|
|
local ok = world:changeBlock(bx, by, block)
|
|
Runtime.emit("world.block_replaced",
|
|
{ mapId = world.map and world.map.id, bx = bx, by = by, block = block })
|
|
return ok
|
|
end
|
|
|
|
ow.addRuntimeObject = function(mapId, objDef, owner)
|
|
local world = w("addRuntimeObject")
|
|
return world and world:addRuntimeObject(mapId, objDef, owner) or nil
|
|
end
|
|
|
|
ow.removeRuntimeObject = function(npcId, owner)
|
|
local world = w("removeRuntimeObject")
|
|
return world and world:removeRuntimeObject(npcId, owner) or nil
|
|
end
|
|
|
|
-- ---- interaction and field moves
|
|
|
|
function ow.tryHiddenObject(fx, fy)
|
|
local world = w("tryHiddenObject")
|
|
if not (world and world.map) then return nil end
|
|
return HiddenItems.at(world.map.def, fx, fy, world.events)
|
|
end
|
|
|
|
function ow.hasHiddenItemLeft()
|
|
local world = w("hasHiddenItemLeft")
|
|
if not (world and world.map) then return false end
|
|
return #HiddenItems.unfound(world.map.def, world.events) > 0
|
|
end
|
|
|
|
function ow.facingIsShoreOrWater()
|
|
local world = w("facingIsShoreOrWater")
|
|
if not (world and world.map and world.player) then return false end
|
|
local p = world.player
|
|
local d = Map2.DELTA[p.facing]
|
|
if not d then return false end
|
|
return Permissions.surfable(
|
|
world.map:cellCollision(p.cellX + d[1], p.cellY + d[2])) ~= nil
|
|
end
|
|
|
|
function ow.facingIsLandDismount()
|
|
local world = w("facingIsLandDismount")
|
|
if not (world and world.map and world.player) then return false end
|
|
local p = world.player
|
|
if not p.surfing then return false end
|
|
local d = Map2.DELTA[p.facing]
|
|
if not d then return false end
|
|
return Permissions.isLand(
|
|
world.map:cellCollision(p.cellX + d[1], p.cellY + d[2]))
|
|
end
|
|
|
|
function ow.trySurf(_fx, _fy, onClose)
|
|
local world = w("trySurf")
|
|
if not world then return nil end
|
|
if onClose then
|
|
warnOnce("ow.trySurf.onClose",
|
|
"[%s] OverworldState.trySurf drops onClose on Gold: World:trySurfOW "
|
|
.. "takes the faced cell from the player and has no callback",
|
|
who(OW))
|
|
end
|
|
return world:trySurfOW()
|
|
end
|
|
|
|
function ow.tryCut()
|
|
local world = w("tryCut")
|
|
return world and world:tryCutOW() or nil
|
|
end
|
|
|
|
function ow.partyKnows(moveId)
|
|
local world = w("partyKnows")
|
|
-- Gold returns the MON; forwarding it raw hands back a table where the mod
|
|
-- expects true/false, which works in a conditional and breaks on `== true`
|
|
return world ~= nil and world:partyMoveUser(moveId) ~= nil
|
|
end
|
|
|
|
function ow.goFishing(rod)
|
|
local world = w("goFishing")
|
|
if not world then return nil end
|
|
if type(rod) == "string" then
|
|
warnOnce("ow.goFishing.rod",
|
|
"[%s] OverworldState.goFishing takes a Gen 2 ITEM ID on Gold, not a "
|
|
.. "Gen 1 rod name like %s", who(OW), rod)
|
|
return nil
|
|
end
|
|
return world:useRod(rod)
|
|
end
|
|
|
|
-- Gen 1 takes a MAP ID, Gold takes a SPAWN ID out of landmarks.spawns.
|
|
-- Forwarding the map id returns false and the player simply does not fly.
|
|
function ow.flyTo(mapId)
|
|
local world = w("flyTo")
|
|
if not world then return nil end
|
|
for key, spawn in pairs(world.landmarks and world.landmarks.spawns or {}) do
|
|
if spawn.map == mapId then return world:flyTo(key) end
|
|
end
|
|
warnOnce("ow.flyTo." .. tostring(mapId),
|
|
"[%s] OverworldState.flyTo(%s): no landmark spawn lands on that map, so "
|
|
.. "Gold has no fly destination for it", who(OW), tostring(mapId))
|
|
return nil
|
|
end
|
|
|
|
function ow.openPC(onDone)
|
|
local world = w("openPC")
|
|
return world and world:openPc({ onDone = onDone }) or nil
|
|
end
|
|
|
|
function ow.nurseHeal(onDone)
|
|
local world = w("nurseHeal")
|
|
if not world then return nil end
|
|
return world:startHealMachineAnim(nil, function()
|
|
world:healParty()
|
|
if onDone then onDone() end
|
|
end)
|
|
end
|
|
|
|
function ow.trainerDefeated(npc)
|
|
local world = w("trainerDefeated")
|
|
if not world then return false end
|
|
local def = npc and npc.def
|
|
return world:trainerBeaten(def and def.trainer or def) and true or false
|
|
end
|
|
|
|
function ow.engageTrainer(npc, onDone, endBattleText, skipBattleText)
|
|
local world = w("engageTrainer")
|
|
if not world then return nil end
|
|
if endBattleText ~= nil or skipBattleText ~= nil then
|
|
warnOnce("ow.engageTrainer.text",
|
|
"[%s] OverworldState.engageTrainer drops endBattleText / "
|
|
.. "skipBattleText on Gold: the trainer flow is an extracted SCRIPT "
|
|
.. "and there is nowhere to put them", who(OW))
|
|
end
|
|
local script = npc and npc.def and npc.def.scriptKey
|
|
local ok = world:startTrainerScript(npc, script, false)
|
|
if onDone then onDone(ok) end
|
|
return ok
|
|
end
|
|
|
|
function ow.checkTrainerSight()
|
|
local world = w("checkTrainerSight")
|
|
return world and world:checkTrainerBattle() or false
|
|
end
|
|
|
|
function ow.startTrainerApproach(npc, _dist)
|
|
local world = w("startTrainerApproach")
|
|
if not world then return nil end
|
|
if npc then
|
|
warnOnce("ow.startTrainerApproach.npc",
|
|
"[%s] OverworldState.startTrainerApproach drops its npc and distance "
|
|
.. "on Gold: World:trainerApproach takes them from the World's own "
|
|
.. "sight scan, so a specific object cannot be approached", who(OW))
|
|
end
|
|
return world:trainerApproach()
|
|
end
|
|
|
|
-- Forwarding a TEXT_* constant to World:showText prints the constant NAME
|
|
-- on screen, which looks like a mod bug forever.
|
|
function ow.showMapText(key, _npc, onDone)
|
|
local world = w("showMapText")
|
|
if not world then return nil end
|
|
local body = world.text and world.text[key]
|
|
if body == nil then
|
|
warnOnce("ow.showMapText." .. tostring(key),
|
|
"[%s] OverworldState.showMapText(%s): Gold's text ids are ROM pointer "
|
|
.. "strings, so a TEXT_* constant resolves to nothing; printing "
|
|
.. "nothing rather than the key", who(OW), tostring(key))
|
|
return nil
|
|
end
|
|
return world:showText(body, onDone)
|
|
end
|
|
|
|
function ow.applyFieldPoison()
|
|
local world = w("applyFieldPoison")
|
|
if not world then return nil end
|
|
local StepEvents = rawRequire("src.world.gen2.StepEvents")
|
|
local g = live()
|
|
return StepEvents.poisonStep(g and g.save and g.save.party)
|
|
end
|
|
|
|
function ow.queueScript(rows, extra)
|
|
local a = worldApi()
|
|
if not a then return nil, "no overworld" end
|
|
return a:queueScript(rows, extra)
|
|
end
|
|
|
|
-- Gen 1's module IS the state, so a mod calls these with a COLON and the
|
|
-- module arrives as self. Strip that leading argument -- and a World, which
|
|
-- is what a mod holding game.overworld would pass -- so one source calls
|
|
-- either way. The three seams below are called BY Gold with the world as
|
|
-- their first argument and must keep it.
|
|
local SEAMS = { update = true, interact = true, talkTo = true,
|
|
computeNeighbors = true }
|
|
for key, fn in pairs(ow) do
|
|
if type(fn) == "function" and not SEAMS[key] then
|
|
ow[key] = function(first, ...)
|
|
if first == overworld
|
|
or (type(first) == "table" and first.stepBody ~= nil) then
|
|
return fn(...)
|
|
end
|
|
return fn(first, ...)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Gen 1's module IS the live state (src/core/Game.lua:87 assigns the module
|
|
-- itself), so `OverworldController.player` off the required module is the
|
|
-- ordinary idiom. These seven names are the World's own fields under the
|
|
-- same spelling and shape, and they read AND write through to it.
|
|
local LIVE_FIELD = { map = true, player = true, npcs = true, entities = true,
|
|
ghosts = true, npcPool = true, camera = true }
|
|
|
|
setmetatable(ow, {
|
|
__index = function(_, key)
|
|
if LIVE_FIELD[key] then
|
|
local world = w(key)
|
|
return world and world[key] or nil
|
|
end
|
|
-- Gen 1's entries are { map = mapDef, ox, oy } and Gold's are
|
|
-- { id, ox, oy, image }: nb.map is nil there, so a scan silently matches
|
|
-- nothing (src/world/OverworldController.lua:529 vs World.lua:8275).
|
|
if key == "neighbors" then
|
|
warnOnce("ow.neighbors",
|
|
"[%s] OverworldState.neighbors has no Gen 2 backing with the Gen 1 "
|
|
.. "shape: Gold's entries carry `id`, not `map`. world.neighbors "
|
|
.. "plus world.maps[nb.id] is the honest route", who(OW))
|
|
return nil
|
|
end
|
|
if key == "isOverworld" then
|
|
warnOnce("ow.isOverworld",
|
|
"[%s] OverworldController.isOverworld exists on this facade so a "
|
|
.. "module compare works, but Gold's world is NOT on the state "
|
|
.. "stack: a scan of game.stack.states finds nothing. Use "
|
|
.. "mod.world:overworld()", who(OW))
|
|
return true
|
|
end
|
|
return nil
|
|
end,
|
|
-- a write to one of the live names has to move the world, the way a write
|
|
-- to the Gen 1 module did; everything else is a seam the mod installs
|
|
__newindex = function(t, key, value)
|
|
if LIVE_FIELD[key] then
|
|
-- never rawset one of these: the key would shadow the world for the
|
|
-- rest of the run and every later read would answer the stale value
|
|
local world = w(key)
|
|
if world then world[key] = value end
|
|
return
|
|
end
|
|
rawset(t, key, value)
|
|
end,
|
|
})
|
|
|
|
overworld = ow
|
|
return ow
|
|
end
|
|
|
|
-- called once per logic frame from World:step's tail
|
|
function Gen2Compat.worldTick(world, dt)
|
|
if not overworld then return end
|
|
if overworld.update == defaultUpdate then return end
|
|
overworld.update(world, dt)
|
|
end
|
|
|
|
-- nil while nothing replaced `interact`: one comparison on a mod-free press
|
|
function Gen2Compat.interactWrapper()
|
|
if not overworld or overworld.interact == defaultInteract then return nil end
|
|
return overworld.interact
|
|
end
|
|
|
|
-- the same shape for the talk dispatch World:interactBody seams in
|
|
function Gen2Compat.talkToWrapper()
|
|
if not overworld or overworld.talkTo == defaultTalkTo then return nil end
|
|
return overworld.talkTo
|
|
end
|
|
|
|
COVERAGE[OW] = {
|
|
kind = "facade", target = "src.world.gen2.World",
|
|
backed = "update interact talkTo npcAtCell pushableAtCell npcByIndex "
|
|
.. "pooledNPC computeNeighbors setMap rebuildNeighbors reloadMap takeWarp "
|
|
.. "startWarpTo canCollisionWarp refreshStandingOnWarp healPoint "
|
|
.. "warpToHealPoint dirHeld handleInput stepForwardOrCrossEdge "
|
|
.. "checkLedgeHop checkEdgeExit crossConnection connectionLanding "
|
|
.. "checkBoulderPush checkForcedMovement scriptMove updateScriptMoves "
|
|
.. "timeOfDay bikeAllowed replaceBlock addRuntimeObject "
|
|
.. "removeRuntimeObject tryHiddenObject hasHiddenItemLeft "
|
|
.. "facingIsShoreOrWater facingIsLandDismount tryCut partyKnows "
|
|
.. "goFishing flyTo openPC nurseHeal trainerDefeated engageTrainer "
|
|
.. "checkTrainerSight startTrainerApproach showMapText applyFieldPoison "
|
|
.. "queueScript map player npcs entities ghosts npcPool camera",
|
|
warned = "isOverworld setDark trySurf neighbors npcByIndex",
|
|
absent = "isOpaque objectVisible enter paletteNameFor sgbPalettes "
|
|
.. "sgbWorldZones isDungeonTransitionMap pushBattle drainPendingScripts "
|
|
.. "startParallel killParallel updateParallel startDustAnim "
|
|
.. "startCutTreeAnim useSurfFieldMove useCutFieldMove beginTeleportOut "
|
|
.. "syncSurfingPikachu tryBookshelf benchGuyText tryCardKeyDoor "
|
|
.. "trashCanSwitch stampClosedDoors billsHousePC billsHousePokemonList "
|
|
.. "billsHouseBillExits tilesetHasWater surfBlockedHere "
|
|
.. "checkSeafoamCurrent seafoamHolesFor boulderIntoHole openOaksPC "
|
|
.. "dexRating cableClubReceptionist finishNurseHeal stepHealAnim "
|
|
.. "checkVictoryRewards offerGymTm runVictoryHook onStepComplete "
|
|
.. "rollEncounter checkSpinner runSpinnerMoves rewrittenLastMap "
|
|
.. "syncLastMapRewrite rememberOutdoor checkBadgeGate inSafariStepZone "
|
|
.. "safariStep safariGameOver afterBattle draw drawWorld drawUI billboard "
|
|
.. "captureSave marchInPlace runner scriptMoves marchers parallelRunners "
|
|
.. "parallelQueue pendingScripts npcMoveLocks engaging lastOutdoor dark "
|
|
.. "tod",
|
|
notes = {
|
|
update = "called as update(world, dt) with dt fixed at 1/60, AFTER "
|
|
.. "World:stepBody -- a Gen 1 wrapper that expects to pre-empt the body "
|
|
.. "is too late; `self` is a World, not an OverworldState",
|
|
interact = "the default is world:interactBody(), so a wrapper receives "
|
|
.. "the World as self and can call through",
|
|
talkTo = "seamed into World:interactBody once the object is resolved; a "
|
|
.. "true return suppresses the built-in path",
|
|
npcAtCell = "Gold does NOT match targetX/targetY, so an NPC walking INTO "
|
|
.. "the cell is missed; it DOES match all four cells of a BIG_OBJECT",
|
|
player = "the LIVE World's field, read and written through: it is a "
|
|
.. "src/world/gen2/Player.lua, so cellX / cellY / facing / surfing "
|
|
.. "carry and the Gen 1 Player's own methods do not",
|
|
map = "the live World's map, a src/world/gen2/Map.lua instance -- which "
|
|
.. "IS what require('src.world.Map') hands the mod back here",
|
|
npcs = "the live lists; a ghost NPC on a neighbouring map is in `ghosts`, "
|
|
.. "not here, on both generations",
|
|
neighbors = "WARNED: Gold's entries are { id, ox, oy, image } where Gen "
|
|
.. "1's are { map = mapDef, ox, oy }, so the field answers nil rather "
|
|
.. "than a list whose nb.map is nil on every row",
|
|
npcByIndex = "1-based extracted indices only; 0 and below answer nil and "
|
|
.. "warn, because Gold's objectId 0 is the PLAYER (world.player) and "
|
|
.. "objectId 1 is wLastTalked, not object zero",
|
|
scriptMove = "the player maps to objectId 0 and a mapped object to "
|
|
.. "def.index + 1; an entity with neither (a mod's own guest) is "
|
|
.. "REFUSED with a reason rather than moving the last-talked NPC",
|
|
connectionLanding = "Gen 1's five values (destDef, tilesetDef, x, y, "
|
|
.. "conn); `conn` is Gold's connection record, keyed map/mapId + offset",
|
|
timeOfDay = "recomputed from the clock every call and never cached, so a "
|
|
.. "custom tod does not read back; world.daytime is the field",
|
|
setDark = "only a DARKNESS palset map is affected",
|
|
rollEncounter = "absent: Gold's takes (kind, terrain, tables, vanilla) "
|
|
.. "and the Gen 1 kind vocabulary cannot be derived from an encDef",
|
|
runner = "absent from the facade; world.runner is the shim on the World "
|
|
.. "itself and answers isRunning()",
|
|
tod = "world.daytime is the Gen 2 spelling",
|
|
onStepComplete = "no seam; the world.stepped event is the supported route",
|
|
afterBattle = "no seam; the battle.ended event is the supported route",
|
|
draw = "no seam; the render_pipelines registry is the supported route on "
|
|
.. "both generations",
|
|
},
|
|
}
|
|
|
|
-- ------- src.world.PikachuFollower
|
|
--
|
|
-- ALIAS, required: followers-ex reaches the module's file-local shouldSpawn
|
|
-- UPVALUE with debug.setupvalue and monkey-patches update / onMapEntered /
|
|
-- starterInParty on the module table. A facade copy would have different
|
|
-- upvalues and the patched function would be one nothing calls.
|
|
|
|
COVERAGE["src.world.PikachuFollower"] = {
|
|
kind = "alias", target = "src.world.gen2.Follower",
|
|
backed = "setShouldSpawn current onMapEntered update rebase "
|
|
.. "talk starterInParty setVisible at SPRITE",
|
|
warned = "",
|
|
absent = "shouldSpawn onStep bumpHappiness modifyHappiness picLift hopToCounter "
|
|
.. "updateHop onBillsHouseEnter onBillWalksAroundPlayer "
|
|
.. "onBillEnteredMachine onBillExitedMachine",
|
|
notes = {
|
|
shouldSpawn = "ABSENT as a MODULE member on both generations: it is a "
|
|
.. "file-local, reachable through setShouldSpawn or the upvalue of that "
|
|
.. "name. The callback is passed (game, world) where Gen 1 passes "
|
|
.. "(game, ow); a predicate reading ow.player / ow.map is unchanged",
|
|
onMapEntered = "accepts opts.keepPikachu as well as opts.keepFollower",
|
|
talk = "the stub returns false and does NOT call done(); a mod that calls "
|
|
.. "it and waits for the callback hangs -- check the return",
|
|
["ow.pikachuTrail"] = "aliased by reference to world.followerTrail, so a "
|
|
.. "reset through either name moves the live trail",
|
|
bumpHappiness = "Gold has per-mon friendship, not one companion byte; "
|
|
.. "mapping it would silently move a real game stat",
|
|
},
|
|
}
|
|
|
|
-- ------- src.ui.PartyMenu
|
|
--
|
|
-- FACADE over the aliased Gen 2 class: __index falls through so a wrap of
|
|
-- .update / .draw resolves, __newindex writes THROUGH so that wrap lands on
|
|
-- Gold's live class, and only the four members whose contract differs are
|
|
-- overridden here. The icon statics and sgbPalettes must stay ABSENT.
|
|
|
|
local UI_ABSENT_ICON = "Gold has per-species two-frame icon sheets, not Gen "
|
|
.. "1's nine shared icon classes, so there is no class name to key on"
|
|
|
|
-- A mod's write WINS on read (rawequal(read, patch) holds, so the patch is
|
|
-- visible) and is written THROUGH so Gold's own class runs it too. The
|
|
-- overrides below must therefore call the member they CAPTURED at build time,
|
|
-- never target[key], or a wrapper that chains to the override re-enters it.
|
|
local PATCHED_NIL = {}
|
|
|
|
local function passThroughProxy(target, overrides, absent, moduleName)
|
|
local patched = {}
|
|
return setmetatable({}, {
|
|
__index = function(_, key)
|
|
local mine = patched[key]
|
|
if mine == PATCHED_NIL then return nil end
|
|
if mine ~= nil then return mine end
|
|
local made = overrides[key]
|
|
if made ~= nil then return made end
|
|
local why = absent[key]
|
|
if why then
|
|
warnOnce(moduleName .. "." .. key,
|
|
"[%s] %s.%s has no Gen 2 backing: %s",
|
|
who(moduleName), moduleName, key, why)
|
|
return nil
|
|
end
|
|
return target[key]
|
|
end,
|
|
-- a monkey-patch has to land on the class Gold actually pushes
|
|
__newindex = function(_, key, value)
|
|
patched[key] = (value == nil) and PATCHED_NIL or value
|
|
target[key] = value
|
|
end,
|
|
})
|
|
end
|
|
|
|
local function buildPartyMenu()
|
|
local Party2 = rawRequire("src.ui.gen2.PartyMenu")
|
|
local overrides, absent = {}, {}
|
|
-- built first so .new can stamp whatever the MOD last wrote to these names
|
|
local proxy = passThroughProxy(Party2, overrides, absent, "src.ui.PartyMenu")
|
|
-- captured before any mod write: a wrapper chaining to overrides.new must
|
|
-- reach Gold's constructor, not itself
|
|
local newOrig = Party2.new
|
|
|
|
absent.drawIcon = "PartyMenu.drawIcon is a STATIC under Gen 1 and a METHOD "
|
|
.. "on Gold, so the same call would bind self = game and die inside "
|
|
.. "iconFor; " .. UI_ABSENT_ICON
|
|
absent.frameFor = UI_ABSENT_ICON
|
|
absent.mirrorsIcon = UI_ABSENT_ICON .. " and Gold draws the whole 16x16 quad"
|
|
absent.iconFrames = UI_ABSENT_ICON .. "; an empty table would accept the "
|
|
.. "mod's writes and never draw from them"
|
|
absent.sgbPalettes = "Gold is a GBC title: colour is GbcPalette plus the "
|
|
.. "map palsets, and there are no SGB zone packets under src/ui/gen2/"
|
|
absent.animateTo = "Gold's party screen has no HP-fill animation; a stub "
|
|
.. "calling onDone immediately would print the mod's message with no fill "
|
|
.. "and leave A live over a menu the caller thinks is busy"
|
|
|
|
-- Gen 1 pops ITSELF on B (src/ui/PartyMenu.lua:540) and Gold never pops.
|
|
function overrides.new(game, opts)
|
|
opts = opts or {}
|
|
-- the LIVE Game2, not the src.core.Game proxy the mod is holding: the Gen
|
|
-- 2 screen reads game.partyMenuCursor, game.data and game.stack off it
|
|
local g = live() or game
|
|
local inst
|
|
local function popSelf()
|
|
if g and g.stack and g.stack:top() == inst then g.stack:pop() end
|
|
end
|
|
if opts.keepOpen then
|
|
warnOnce("party.keepOpen",
|
|
"[%s] PartyMenu opts.keepOpen has no Gen 2 backing and is treated as "
|
|
.. "false: Gold's party list has no stay-open contract",
|
|
who("src.ui.PartyMenu"))
|
|
end
|
|
if opts.tmhm then
|
|
warnOnce("party.tmhm",
|
|
"[%s] PartyMenu opts.tmhm has no Gen 2 backing: Gold has no per-row "
|
|
.. "ABLE / NOT ABLE column, so prompt = 'teach' alone would make the "
|
|
.. "mod read every mon as ineligible", who("src.ui.PartyMenu"))
|
|
end
|
|
local prompt, submenu, battleSubmenu
|
|
if opts.forceSwitch then
|
|
prompt = "which"
|
|
elseif opts.battle and opts.onSwitch then
|
|
prompt, battleSubmenu = "choose", true
|
|
elseif opts.pickOnly then
|
|
prompt = "useItem"
|
|
elseif opts.onSwitch then
|
|
-- src/ui/PartyMenu.lua:569: onSwitch OUTSIDE battle fires on A itself,
|
|
-- so the field submenu must not swallow the press
|
|
prompt = "choose"
|
|
else
|
|
prompt, submenu = "choose", true
|
|
end
|
|
-- Gen 1's saved cursor lives under a different name; copy it over or the
|
|
-- mod's stored slot is ignored.
|
|
if g and g.partyMenuSavedIndex and not g.partyMenuCursor then
|
|
g.partyMenuCursor = g.partyMenuSavedIndex
|
|
end
|
|
inst = newOrig(g, {
|
|
party = opts.party,
|
|
prompt = prompt, submenu = submenu, battleSubmenu = battleSubmenu,
|
|
-- (mon, menu) versus (index, mon): both the order and the arity differ
|
|
onChoose = function(_i, mon)
|
|
popSelf()
|
|
if opts.onSwitch then opts.onSwitch(mon, inst) end
|
|
end,
|
|
onCancel = function()
|
|
popSelf()
|
|
if opts.onCancel then opts.onCancel() end
|
|
end,
|
|
})
|
|
-- Stamped per INSTANCE, never onto the Gen 2 class: the object's
|
|
-- metatable is Gold's PartyMenu, so a facade-only member would be
|
|
-- unreachable as inst:close(), and writing it onto the class would change
|
|
-- what Gold's own screen answers.
|
|
inst.close = proxy.close
|
|
inst.bottomMessage = proxy.bottomMessage
|
|
return inst
|
|
end
|
|
|
|
-- src/ui/PartyMenu.lua:306, which Gold has no counterpart for and needs
|
|
-- nothing from Gen 2 to write.
|
|
function overrides.close(self)
|
|
if self.game and self.game.stack and self.game.stack:top() == self then
|
|
self.game.stack:pop()
|
|
end
|
|
end
|
|
|
|
-- src/ui/gen2/PartyMenu.lua:734 verbatim
|
|
function overrides.bottomMessage(self)
|
|
if self.switchFrom then return Party2.PROMPTS.moveTo end
|
|
return self.prompt
|
|
end
|
|
|
|
-- Gold's list starts at tile row 1, so Gen 1's (i-1)*16 sits 8px high on
|
|
-- every row.
|
|
function overrides.entryY(i)
|
|
return 8 + (i - 1) * 16
|
|
end
|
|
|
|
return proxy
|
|
end
|
|
|
|
COVERAGE["src.ui.PartyMenu"] = {
|
|
kind = "facade", target = "src.ui.gen2.PartyMenu",
|
|
backed = "new close bottomMessage entryY update draw isOpaque __index "
|
|
.. "PROMPTS index party submenu switchFrom clock onCancel",
|
|
warned = "keepOpen tmhm",
|
|
absent = "drawIcon frameFor mirrorsIcon iconFrames sgbPalettes animateTo "
|
|
.. "heal softboiledFrom battle subItems subIndex swapFrom blink onSwitch "
|
|
.. "pickOnly forceSwitch",
|
|
notes = {
|
|
new = "onSwitch(mon, menu) is wrapped onto onChoose(index, mon); opts."
|
|
.. "battle carries only its BOOLEAN sense and self.battle is left nil "
|
|
.. "so a `menu.battle:say(...)` fails loudly",
|
|
index = "1..#party+1 on Gold because CANCEL is one past the last mon; "
|
|
.. "party[self.index] is nil there",
|
|
party = "always materialised, so `if not menu.party then` takes the wrong "
|
|
.. "branch",
|
|
swapFrom = "renamed switchFrom; a WRITE of menu.swapFrom is inert",
|
|
subItems = "self.submenu is a TABLE on Gold: subItems is submenu.items "
|
|
.. "and subIndex is submenu.index",
|
|
blink = "renamed clock, and Gold never wraps it (Gen 1 wraps at 320)",
|
|
onSwitch = "replacing menu.onSwitch on a LIVE instance writes a field "
|
|
.. "Gen 2 never reads; pass it to .new instead",
|
|
bottomMessage = "returns Gold's strings with <PK>/<MN> charmap glyphs, so "
|
|
.. "a compare against \"Use on which one?\" will not match",
|
|
["hook ui.party.submenu"] = "same name and arity; rows carry `id` on Gold "
|
|
.. "where Gen 1 carries `action`, and ctx.battle is a BOOLEAN, not a "
|
|
.. "BattleState",
|
|
},
|
|
}
|
|
|
|
-- ------- src.ui.BoxMenu
|
|
--
|
|
-- ALIAS, but to src.ui.gen2.PcMenu, not src.ui.gen2.BoxMenu. Gen 1's
|
|
-- src.ui.BoxMenu is Bill's PC TOP MENU; Gold's BoxMenu is the withdraw /
|
|
-- deposit LIST that Gen 1 builds inline with ListMenu. The alias has to be
|
|
-- bare, because PcMenu is the table src/ui/Screens.lua caches for
|
|
-- "Gen2PcMenu" and a monkey-patch of .new must land there.
|
|
|
|
COVERAGE["src.ui.BoxMenu"] = {
|
|
kind = "alias", target = "src.ui.gen2.PcMenu",
|
|
backed = "new",
|
|
warned = "",
|
|
absent = "",
|
|
notes = {
|
|
new = "PcMenu.new(game) with opts omitted yields the five-row folded PC; "
|
|
.. "the RETURNED object is a PcMenu, not a src.ui.Menu",
|
|
items = "renamed .entries; rows are { id, label } dispatched by id, and a "
|
|
.. "row carrying label + onSelect is now answered by PcMenu:choose's "
|
|
.. "onSelect arm so an injected row does something",
|
|
th = "PcMenu owns the whole screen (0,0,19,17); .th / .tx / .tw / "
|
|
.. ".scroll / :clampScroll do not exist and a write to them is inert",
|
|
["hook ui.pc.items"] = "same NAME, DIFFERENT MENU: Gen 1 raises it over "
|
|
.. "the WHICH-PC list, Gold over Bill's PC's own rows",
|
|
},
|
|
}
|
|
|
|
-- ------- src.ui.StartMenu
|
|
|
|
local function buildStartMenu()
|
|
local Start2 = rawRequire("src.ui.gen2.StartMenu")
|
|
local overrides, absent = {}, {}
|
|
-- captured before any mod write, so a wrapper of .new cannot re-enter here
|
|
local newOrig = Start2.new
|
|
|
|
-- Both callbacks MUST be synthesised: Gen 2's :close() only calls
|
|
-- self.onClose and :choose() only calls self.onChoose, so without them B
|
|
-- does nothing and A on POKeMON does nothing -- a menu that opens and
|
|
-- cannot be exited. The wiring is src/core/Game2.lua:390-396.
|
|
function overrides.new(game)
|
|
local g = live() or game
|
|
local save = g and g.save
|
|
if save and save.startMenuIndex then
|
|
Start2.lastIndex = save.startMenuIndex
|
|
end
|
|
return newOrig(g, {
|
|
save = save,
|
|
onClose = function()
|
|
local owner = live()
|
|
if owner and owner.stack then owner.stack:pop() end
|
|
end,
|
|
onChoose = function(id)
|
|
local owner = live()
|
|
if owner and owner.openStartMenuItem then
|
|
owner:openStartMenuItem(id)
|
|
end
|
|
end,
|
|
})
|
|
end
|
|
|
|
return passThroughProxy(Start2, overrides, absent, "src.ui.StartMenu")
|
|
end
|
|
|
|
COVERAGE["src.ui.StartMenu"] = {
|
|
kind = "facade", target = "src.ui.gen2.StartMenu",
|
|
backed = "new items ITEMS lastIndex",
|
|
warned = "",
|
|
absent = "",
|
|
notes = {
|
|
new = "synthesises onClose (stack:pop) and onChoose "
|
|
.. "(Game2:openStartMenuItem); without them the menu cannot be left",
|
|
index = "the cursor is menu.list.index on Gold (a Chrome.List)",
|
|
["save.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct "
|
|
.. "and never copied back: Gold's cursor is a class field, not save data",
|
|
tx = "the box is fixed at Chrome.box(10, 0, 10, h); tx/ty/tw/th/anchor/"
|
|
.. "maxVisible/startCloses/noSound do not exist and writes are inert",
|
|
["hook ui.start_menu.items"] = "full parity; a row carrying { label, "
|
|
.. "onSelect } and no value is answered on both generations",
|
|
},
|
|
}
|
|
|
|
-- ------- src.ui.OptionsMenu
|
|
|
|
local function buildOptionsMenu()
|
|
local Options2 = rawRequire("src.ui.gen2.OptionsMenu")
|
|
local overrides, absent = {}, {}
|
|
-- captured before any mod write, so a wrapper of .new cannot re-enter here
|
|
local newOrig = Options2.new
|
|
|
|
absent.sgbPalettes = "Gold is a GBC title; a mod wrapping this to append a "
|
|
.. "zone would get nil from the original and register a zone nothing reads"
|
|
|
|
-- Gen 2's :leave_ is the only exit and it calls self.onDone or nothing, so
|
|
-- without a synthesised one the screen cannot be left. Body is
|
|
-- src/core/Game2.lua:330-341 plus the pop Gen 1 does for itself.
|
|
function overrides.new(game, opts)
|
|
opts = opts or {}
|
|
local g = live() or game
|
|
local inst
|
|
inst = newOrig(g, {
|
|
options = g and g.options,
|
|
onDone = function(options)
|
|
local owner = live()
|
|
if owner then
|
|
owner.options = options
|
|
if owner.save then owner.save.options = options end
|
|
if owner.applyOptions then owner:applyOptions() end
|
|
if owner.persistOptions then owner:persistOptions() end
|
|
if owner.stack and owner.stack:top() == inst then owner.stack:pop() end
|
|
end
|
|
if opts.onCancel then opts.onCancel() end
|
|
end,
|
|
})
|
|
return inst
|
|
end
|
|
|
|
return passThroughProxy(Options2, overrides, absent, "src.ui.OptionsMenu")
|
|
end
|
|
|
|
COVERAGE["src.ui.OptionsMenu"] = {
|
|
kind = "facade", target = "src.ui.gen2.OptionsMenu",
|
|
backed = "new update draw isOpaque rows index scroll options",
|
|
warned = "",
|
|
absent = "sgbPalettes",
|
|
notes = {
|
|
new = "synthesises onDone out of opts.onCancel plus the pop Gen 1 does "
|
|
.. "for itself",
|
|
index = "1..#rows on Gold: there is no CANCEL row, so `index == #rows+1` "
|
|
.. "never fires. A row carrying cancel = true is the Gen 2 equivalent",
|
|
rows = "row.step's RETURN VALUE is ignored on Gold; the write happens "
|
|
.. "once, on close, through onDone",
|
|
options = "Game2 sets save.options = self.options, so a Gen 1 row's "
|
|
.. "g.save.options write edits the same table the screen edits",
|
|
["hook ui.options.rows"] = "full parity: Gen 2 answers row.step, "
|
|
.. "row.activate and row.value first",
|
|
["src.ui.OptionRows"] = "NOT gated by the shim and loads fine under Gold, "
|
|
.. "so a mod calling OptionRows.draw paints Red's chrome over Gold's",
|
|
},
|
|
}
|
|
|
|
-- ------- src.battle.BattleState
|
|
--
|
|
-- FACADE, and mostly an absent one. Gen 1's BattleState is engine and screen
|
|
-- on one table; Gold splits them (src/battle/gen2/Battle.lua is the model,
|
|
-- src/ui/gen2/BattleState.lua the screen). Nine of ~163 members share a name
|
|
-- and two of those differ in signature. Everything else is named once,
|
|
-- grouped, and left absent rather than answered with a near miss.
|
|
|
|
local function buildBattleState()
|
|
local Battle2 = rawRequire("src.ui.gen2.BattleState")
|
|
local overrides, absent = {}, {}
|
|
|
|
local MOVED = "turn resolution, damage, catching and the AI live on "
|
|
.. "src/battle/gen2/Battle.lua, over a mon rather than a Gen 1 battler "
|
|
.. "wrapper, so no argument shape survives"
|
|
|
|
for _, name in ipairs({
|
|
"makeSafari", "makeGhost", "makeUnveiledGhost", "queueScopeReveal",
|
|
"sayChoice", "act", "ui", "animNext", "actNext", "sayNext", "sayNextAuto",
|
|
"uiNext", "drainNext", "waitNext", "buildScreen", "makeBattler",
|
|
"resolveTurn", "executeAction", "performMove", "enemyAction",
|
|
"vanillaEnemyAction", "resolveSwitch", "endOfTurn", "residualFor",
|
|
"queueResidual", "tickTokens", "applyDamage", "onFaint", "awardExp",
|
|
"learnMove", "clearVolatiles", "computeDamage", "accuracyRoll",
|
|
"catchAttempt", "runRoll", "runRollVanilla", "moveDef", "effectRecord",
|
|
"ballDef", "statusLabel", "aiUsesFor", "battleKind", "sideOf",
|
|
"syncSides", "playerHasPP", "lockedAction", "computeMusicKind",
|
|
"throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage",
|
|
"storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow",
|
|
"drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize",
|
|
"sgbPalettes", "trainerPalette", "trainerPicPath", "invalidate",
|
|
"imageBattleScale", "resolveBattleScale", "backPlacement",
|
|
"frontPlacement", "StatBox", "enter", "exit",
|
|
}) do
|
|
absent[name] = MOVED
|
|
end
|
|
absent.newWild = "Gold has no factory that returns an UNPUSHED battle: "
|
|
.. "World:startBattle constructs a src/battle/gen2/Battle.lua and pushes "
|
|
.. "the screen in one call. A mod rewriting the species belongs on the "
|
|
.. "encounter.species hook, which Gold raises with the same name and shape"
|
|
absent.newTrainer = absent.newWild
|
|
absent.makeSafari = "Gold has no Safari Zone; the Bug-Catching Contest is a "
|
|
.. "different rule set (PARK BALL, a held-mon rule, no BAIT or ROCK), and "
|
|
.. "answering this would make a mod take its native safari path"
|
|
absent.enter = "Gold's battle sets up in .new and tears down in "
|
|
.. "finishBattle; StateStack calls enter/exit when callable, so INSTALLING "
|
|
.. "one where Gold has none makes the original a nil call"
|
|
absent.exit = absent.enter
|
|
absent.sgbPalettes = "Gold is a GBC title; there are no SGB zone packets"
|
|
|
|
-- say/sayAuto: exact, because the generic text arm honours event.text on
|
|
-- any event. Gold's messages always auto-advance, so sayAuto's delay is
|
|
-- meaningless there rather than wrong.
|
|
function overrides.say(self, text)
|
|
return self:push({ kind = "message", text = text })
|
|
end
|
|
|
|
function overrides.sayAuto(self, text)
|
|
return self:push({ kind = "message", text = text })
|
|
end
|
|
|
|
function overrides.openItems(self) return self:openPack() end
|
|
function overrides.openReplacementMenu(self) return self:openParty(true) end
|
|
function overrides.finish(self) return self:finishBattle() end
|
|
|
|
function overrides.askNicknameUI(self, mon, displayName)
|
|
if displayName then
|
|
warnOnce("battle.askNicknameUI",
|
|
"[%s] BattleState:askNicknameUI drops displayName on Gold: the "
|
|
.. "nickname prompt names the species itself",
|
|
who("src.battle.BattleState"))
|
|
end
|
|
return self:askNickname(mon)
|
|
end
|
|
|
|
function overrides.playEntranceCry(self, battler)
|
|
return self:playCry(battler and battler.mon or battler)
|
|
end
|
|
|
|
function overrides.stampOT(save, mon)
|
|
return rawRequire("src.battle.gen2.Mon").stampOT(save, mon)
|
|
end
|
|
|
|
-- Gen 1's tryRun PREDICTS; Gold's model method RESOLVES the escape, which
|
|
-- is the difference between asking and doing.
|
|
function overrides.tryRun(self)
|
|
warnOnce("battle.tryRun",
|
|
"[%s] BattleState:tryRun RESOLVES the escape on Gold "
|
|
.. "(src/battle/gen2/Battle.lua:3618); Gen 1's queued a message and "
|
|
.. "returned a roll", who("src.battle.BattleState"))
|
|
return self.battle and self.battle:tryRun() or nil
|
|
end
|
|
|
|
-- These seven are INSTANCE methods a mod calls as battle:say(...), and the
|
|
-- instance's metatable is Gold's own class -- a facade-only member would be
|
|
-- unreachable from it. Every one is a name Gold does not use, so the write
|
|
-- is purely additive and changes nothing Gold's screen does; a name the
|
|
-- class already answers is left exactly as it is.
|
|
for _, name in ipairs({ "say", "sayAuto", "openItems", "openReplacementMenu",
|
|
"finish", "askNicknameUI", "playEntranceCry",
|
|
"tryRun" }) do
|
|
if Battle2[name] == nil then Battle2[name] = overrides[name] end
|
|
end
|
|
|
|
return passThroughProxy(Battle2, overrides, absent, "src.battle.BattleState")
|
|
end
|
|
|
|
COVERAGE["src.battle.BattleState"] = {
|
|
kind = "facade", target = "src.ui.gen2.BattleState",
|
|
backed = "update draw __index isOpaque openParty swapMoves "
|
|
.. "lowHealthAlarmActive playVictoryMusic say sayAuto openItems "
|
|
.. "openReplacementMenu finish askNicknameUI playEntranceCry stampOT "
|
|
.. "tryRun wantsFillScale",
|
|
warned = "tryRun askNicknameUI",
|
|
absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn "
|
|
.. "computeDamage catchAttempt runRoll enter exit sgbPalettes "
|
|
.. "isWideBattleLayout wideLayout bgMode uiSize letterboxWhite "
|
|
.. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath invalidate "
|
|
.. "backPlacement frontPlacement StatBox drawClassic drawBallRow "
|
|
.. "safariAction safariEnemyTurn throwBall storeCaughtMon field ruleset "
|
|
.. "rng oppClass partyIndex aiUses introText dead",
|
|
notes = {
|
|
newWild = "ABSENT: Gold has no factory that returns an unpushed battle, "
|
|
.. "and World:startBattle constructs and pushes in one call. A mod "
|
|
.. "that wraps newWild to rewrite the species must be pointed at the "
|
|
.. "encounter.species hook, which Gold raises with the same name and "
|
|
.. "shape (World:rollEncounter)",
|
|
makeSafari = "must stay absent: the wilds mod probes for it by name and "
|
|
.. "takes its native safari path when it finds one",
|
|
openParty = "Gold's takes a `forced` argument Gen 1's does not; a wrap "
|
|
.. "must forward ... faithfully rather than normalising it away",
|
|
wantsFillScale = "returns TRUE unconditionally on Gold, which reads as "
|
|
.. "\"the player chose FILL\" and is not a choice at all",
|
|
swapMoves = "no disabled-slot migration and no sfx on Gold",
|
|
sides = "self.battle.sides is the same { index, battlers, screens, "
|
|
.. "hazards, tokens } shape, with the same index-1-is-player rule",
|
|
say = "Gold's messages always auto-advance, so sayAuto's delay is ignored",
|
|
},
|
|
}
|
|
|
|
-- ------- src.script.ScriptRunner
|
|
--
|
|
-- NARROW FACADE, never an alias: ScriptRunner.new(game, overworld) landing on
|
|
-- Vm.new(scripts, text, events, hooks) would build a Vm whose `scripts` is
|
|
-- the Game and whose `text` is the World, and every later call would fail
|
|
-- deep inside the VM naming neither the mod nor the mismatch.
|
|
--
|
|
-- Two halves. scanLabels and validate are PURE over the mod's own row list
|
|
-- and forward verbatim; the lifecycle half has a different execution model
|
|
-- and is a thin handle onto the one world.vm, with the two double-drive
|
|
-- members refused.
|
|
|
|
local SR = "src.script.ScriptRunner"
|
|
|
|
local function buildScriptRunner()
|
|
local Gen1 = rawRequire("src.script.ScriptRunner")
|
|
local adapter = {}
|
|
|
|
-- pure over the caller's table; Gold's VM has no label concept, so a
|
|
-- scanned label is meaningful only to the mod's own tooling
|
|
adapter.scanLabels = Gen1.scanLabels
|
|
|
|
-- The default lookup MUST be replaced: with lookup nil, Gen 1's validate
|
|
-- resolves against src/script/Commands.lua, so a script of show_text / wait
|
|
-- / warp validates CLEAN on Gold and then every row is skipped at run time.
|
|
-- A validator that passes a script the game cannot run is worse than none.
|
|
function adapter.validate(script, lookup)
|
|
return Gen1.validate(script, lookup or function(verb)
|
|
local g = live()
|
|
local commands = g and g.data and g.data.commands
|
|
return commands ~= nil and commands[verb] ~= nil
|
|
end)
|
|
end
|
|
|
|
local Handle = {}
|
|
Handle.__index = function(self, key)
|
|
if key == "game" then return live() end
|
|
if key == "overworld" then return rawget(self, "world") end
|
|
local vm = rawget(self, "vm")
|
|
if key == "co" then return vm and vm.co end
|
|
if key == "ctx" then return vm and vm.ctx end
|
|
if key == "waitingFrames" then return vm and vm.waitLeft end
|
|
if key == "parallel" then
|
|
warnOnce("runner.parallel",
|
|
"[%s] runner.parallel has no Gen 2 backing: Gold has one script "
|
|
.. "frame, and src/script/gen2/Vm.lua unpacks the `foreground` and "
|
|
.. "`blocking` flags and ignores them", who(SR))
|
|
return nil
|
|
end
|
|
if key == "waitingCheck" then
|
|
warnOnce("runner.waitingCheck",
|
|
"[%s] runner.waitingCheck has no Gen 2 backing: the Gen 2 VM parks on "
|
|
.. "a TYPED REQUEST the World resumes, with no polling model", who(SR))
|
|
return nil
|
|
end
|
|
return rawget(Handle, key)
|
|
end
|
|
|
|
function Handle:isRunning()
|
|
return self.vm ~= nil and self.vm:running() or false
|
|
end
|
|
|
|
function Handle:run(script, extra)
|
|
if extra ~= nil then
|
|
warnOnce("runner.run.extra",
|
|
"[%s] runner:run's `extra` is unserved on Gold: the VM builds its own "
|
|
.. "ctx and there is no afterScript / onDone drain", who(SR))
|
|
end
|
|
if not self.vm then return false end
|
|
return self.vm:start(script)
|
|
end
|
|
|
|
-- Same name and calling convention as Gen 1's fire-and-forget resume, and
|
|
-- Gold's re-raises AND double-drives a VM the World is already driving, so
|
|
-- a pending request is dispatched twice. Refused rather than forwarded.
|
|
Handle.resume = unbacked(SR, "runner:resume",
|
|
"Gold's Vm:resume re-raises and dispatches the pending request, and the "
|
|
.. "World already drives it; a second call double-drives the VM")
|
|
|
|
Handle.update = unbacked(SR, "runner:update",
|
|
"World already calls Vm:update every frame; a second call "
|
|
.. "double-decrements waitLeft, so every `pause` in a running script "
|
|
.. "finishes early")
|
|
|
|
Handle.exec = unbacked(SR, "runner:exec",
|
|
"the patch point a mod wants is the script.command hook, which both "
|
|
.. "generations raise around the one-row dispatch with (ctx, name, args)")
|
|
|
|
Handle.yield = unbacked(SR, "runner:yield",
|
|
"a mod verb blocks with ctx.vm:waitFrames(n) or ctx.vm:showText(key)")
|
|
|
|
Handle.makeContext = unbacked(SR, "runner:makeContext",
|
|
"Vm:scriptCtx builds its own ctx and deliberately carries no game / save "
|
|
.. "/ overworld / runner")
|
|
|
|
function adapter.new(game, overworldArg)
|
|
local world = overworldArg
|
|
if type(world) ~= "table" or world.vm == nil then
|
|
local g = live() or game
|
|
world = g and g.world
|
|
end
|
|
if not world or not world.vm then
|
|
error("src.script.ScriptRunner: Gold has one script frame and it does "
|
|
.. "not exist yet; take it after the game.ready event", 0)
|
|
end
|
|
return setmetatable({ vm = world.vm, world = world }, Handle)
|
|
end
|
|
|
|
return adapter
|
|
end
|
|
|
|
COVERAGE[SR] = {
|
|
kind = "facade", target = "src.script.gen2.Vm",
|
|
backed = "scanLabels validate new isRunning run game overworld co ctx "
|
|
.. "waitingFrames",
|
|
warned = "resume update exec yield makeContext parallel waitingCheck",
|
|
absent = "__index",
|
|
notes = {
|
|
validate = "the default verb lookup is game.data.commands (MOD verbs "
|
|
.. "only on Gold), not src/script/Commands.lua, or a script of Gen 1 "
|
|
.. "built-ins would validate clean and then run as nothing",
|
|
new = "a thin handle onto the ONE world.vm, never a second runner: a "
|
|
.. "mod-built Vm would carry none of World's ~70 hooks, so every "
|
|
.. "blocking opcode falls through",
|
|
run = "Vm:start returns FALSE when busy where Gen 1 asserts; only rows "
|
|
.. "whose verb is a REGISTERED mod command run, and a verb that raises "
|
|
.. "is pcall'd and skipped",
|
|
scanLabels = "Gold's VM has no label concept at all",
|
|
["src.script.Commands"] = "NOT gated by the shim: a mod requiring it on "
|
|
.. "Gold gets the real Gen 1 table of ~100 verbs, none of which Gold "
|
|
.. "can run",
|
|
events = "script.started / script.ended / script.command already work on "
|
|
.. "Gold with the same payload and are the supported route",
|
|
},
|
|
}
|
|
|
|
-- ------- the table
|
|
|
|
-- name -> how to build it. A string means the Gen 2 arm IS the adapter, so a
|
|
-- mod's patch lands on the table Gold runs rather than on a copy.
|
|
local ADAPTERS = {
|
|
["src.core.Game"] = buildGame,
|
|
["src.world.NPC"] = buildNpc,
|
|
["src.world.Collision"] = buildCollision,
|
|
["src.world.FieldDefaults"] = buildFieldDefaults,
|
|
["src.pokemon.Boxes"] = buildBoxes,
|
|
["src.world.OverworldController"] = buildOverworld,
|
|
["src.ui.PartyMenu"] = buildPartyMenu,
|
|
["src.ui.StartMenu"] = buildStartMenu,
|
|
["src.ui.OptionsMenu"] = buildOptionsMenu,
|
|
["src.battle.BattleState"] = buildBattleState,
|
|
["src.script.ScriptRunner"] = buildScriptRunner,
|
|
["src.world.PikachuFollower"] = "src.world.gen2.Follower",
|
|
["src.world.Map"] = "src.world.gen2.Map",
|
|
["src.world.WorldAPI"] = "src.world.gen2.WorldAPI",
|
|
-- Gen 1's BoxMenu is Bill's PC TOP MENU, which is PcMenu on Gold;
|
|
-- src/ui/gen2/BoxMenu.lua is the withdraw/deposit LIST Gen 1 builds inline.
|
|
["src.ui.BoxMenu"] = "src.ui.gen2.PcMenu",
|
|
}
|
|
|
|
Gen2Compat.ADAPTERS = ADAPTERS
|
|
|
|
function Gen2Compat.bind(fn)
|
|
resolveGame = fn
|
|
end
|
|
|
|
function Gen2Compat.serves(name)
|
|
return ADAPTERS[name] ~= nil
|
|
end
|
|
|
|
-- ------- the coverage API
|
|
--
|
|
-- Stable contract, versioned by Gen2Compat.COVERAGE_VERSION:
|
|
-- Gen2Compat.modules() -> sorted array of served module names
|
|
-- Gen2Compat.coverage(name) -> { module, kind, target, members, notes }
|
|
-- members[member] = "backed"|"warned"|
|
|
-- "absent"; a fresh table per call
|
|
-- Gen2Compat.memberStatus(n, m) -> that status string, or nil when the
|
|
-- module is unserved or the member is
|
|
-- not recorded
|
|
|
|
function Gen2Compat.modules()
|
|
local out = {}
|
|
for name in pairs(ADAPTERS) do out[#out + 1] = name end
|
|
table.sort(out)
|
|
return out
|
|
end
|
|
|
|
function Gen2Compat.coverage(name)
|
|
local row = COVERAGE[name]
|
|
if not row then return nil end
|
|
local members = {}
|
|
-- warned last on purpose: a name listed as backed AND warned is present and
|
|
-- degraded, and the weaker claim is the safe one to publish
|
|
for _, status in ipairs({ "backed", "absent", "warned" }) do
|
|
for _, member in ipairs(words(row[status])) do
|
|
members[member] = status
|
|
end
|
|
end
|
|
local notes = {}
|
|
for key, value in pairs(row.notes or {}) do notes[key] = value end
|
|
return { module = name, kind = row.kind, target = row.target,
|
|
members = members, notes = notes }
|
|
end
|
|
|
|
function Gen2Compat.memberStatus(name, member)
|
|
local row = Gen2Compat.coverage(name)
|
|
return row and row.members[member] or nil
|
|
end
|
|
|
|
-- The one entry point the Loader calls. `modId` is attribution only: no
|
|
-- adapter branches on it.
|
|
function Gen2Compat.resolve(name, modId)
|
|
local spec = ADAPTERS[name]
|
|
if not spec then return nil end
|
|
local module = built[name]
|
|
if not module then
|
|
module = type(spec) == "string" and rawRequire(spec) or spec()
|
|
built[name] = module
|
|
end
|
|
if modId then
|
|
local ids = claimants[name]
|
|
if not ids then ids = {} claimants[name] = ids end
|
|
local seen = false
|
|
for _, id in ipairs(ids) do if id == modId then seen = true break end end
|
|
if not seen then ids[#ids + 1] = modId end
|
|
end
|
|
return module
|
|
end
|
|
|
|
return Gen2Compat
|