Files
gen1recomp/src/mods/Gen2Compat.lua
T
2026-08-13 22:08:00 +02:00

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