Files
gen1recomp/src/core/Game2.lua
T
2026-09-17 10:51:09 -04:00

2558 lines
106 KiB
Lua

-- Gold's service owner: the Gen 2 peer of src/core/Game.lua. It owns the
-- data tables, input, state stack, world and save state for a Gold boot, and
-- everything under src/*/gen2 reaches shared services through here. Gen 1
-- Game:load cannot consume a Gen 2 cache -- different generated tables, save
-- shape and screen registry -- so main.lua's bootGame picks this owner when
-- GameVersion.generation() == 2, and the two never branch into each other.
--
-- Boot: copyright → GameFreak Presents → GS intro stub → title
-- (tilemap + Ho-Oh flap / clouds / trails) → Oak speech (Marill + shrink)
-- → name pick → New Bark Town via src/world/gen2/World.lua.
--
-- IMPORTANT: do not use a catch-all __index that returns functions. main.lua
-- also reads optional fields like Game.capturePath every frame; a truthy
-- function there is treated as a path and crashes io.open.
local AutoInput = require("src.core.gen2.AutoInput")
local Chrome = require("src.ui.gen2.Chrome")
local Clock = require("src.core.gen2.Clock")
local FixedStep = require("src.core.FixedStep")
local Font = require("src.render.Font")
local GamepadMap = require("src.core.GamepadMap")
local GameVersion = require("src.core.GameVersion")
local Input = require("src.core.Input")
local Music = require("src.core.Music")
local Save = require("src.core.gen2.Save")
local StateStack = require("src.core.StateStack")
local Strings = require("src.core.Strings")
local TextBox = require("src.render.TextBox")
-- The mobile on-screen pad, shared with Gen 1 rather than reimplemented: the
-- same module, the same Xelu CC0 art under assets/touch/, the same
-- options.touchControls layout the launcher's editor writes, and the same
-- Input:overlayPressed source names. A player who lays the pad out in Red
-- finds it in the same place in Gold.
local TouchControls = require("src.core.TouchControls")
local World = require("src.world.gen2.World")
local MapNameSign = require("src.world.gen2.MapNameSign")
-- The mod event/hook buses. Gold reaches them through Runtime like every
-- other engine file, so a call site here is the same call site Gen 1 has.
local ModRuntime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport")
local Playfield = require("src.render.Playfield")
-- Only for the mod-supplied save migrations and the mods-changed report, which
-- are keyed off save.meta and know nothing about a generation; Gold's own save
-- IO is src/core/gen2/Save.lua.
local SaveData = require("src.core.SaveData")
-- Every Gold screen this file opens goes through a src/ui/Screens.lua id
-- rather than a direct require, the same contract the Gen 1 path has: the id
-- is what a mod registers a replacement under, and the boot cinema, the START
-- menu and its submenus are exactly the screens a reskin wants. Screens.push
-- resolves the registry, falls back to src/ui/gen2/<name>.lua when nothing is
-- registered, degrades a broken mod screen back to that builtin, and lands the
-- instance on self.stack -- so these call sites keep the push semantics they
-- had when they required the module by hand.
local Screens = require("src.ui.Screens")
local Game2 = {}
Game2.__index = Game2
local function noop() end
-- THE FRAME AND INPUT SEAMS.
--
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
-- (the FixedStep callback in Game2:load), so none of it goes through
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the hooks
-- below never used to fire here; it is not a reason they should not. A
-- hook is a contract about a MOMENT in the frame, and Gold has every one of
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
-- PAYLOAD, at the Gen 1 point in the order:
--
-- input.step before the pad is read (src/core/Game.lua:191)
-- input.pointer uncaptured pointer events (src/core/Game.lua:887)
-- render.zones the palette pass, pre-blit (src/core/Game.lua:505)
-- render.compose the whole-window composite (Renderer.lua:759)
-- render.output* the normal composed frame (Renderer.lua:1063)
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
-- render.viewport the game's OS-window rectangle (GameViewport.lua:52)
-- render.window final OS-window composition (GameViewport.lua:145)
--
-- Where Gold genuinely cannot tell two Gen 1 things apart -- it composites the
-- world pass and the UI into ONE canvas, not two -- the call site says so and
-- fills both keys with what it does have, rather than inventing a second name.
-- vanilla for input.pointer: nobody consumed the event (src/core/Game.lua:882)
local function pointerUnclaimed() return false end
-- render.zones' identity default: unhooked, the zone list reaches the present
-- pass exactly as the frame computed it (src/core/Game.lua:278)
local function sameZones(_, zones) return zones end
-- Gold runs the engine's own src/core/StateStack.lua, not a private stack.
-- It already draws bottom-up from the topmost opaque state, which is the
-- behavior the boot cinema needs (Oak's pic stays under a TextBox), and going
-- through it is what gives Gold screen.pushed / screen.popped and the
-- screen.render_visible hook for free -- the same three a mod gets in Gen 1.
-- Push semantics are identical; the exit callback is named `exit` there and
-- no Gold screen defines one.
local function makeStack()
StateStack:init()
return StateStack
end
local function visibleBaseState(stack)
if not stack then return nil end
local state = stack.states[stack:visibleBase()]
return stack:renderVisible(state) and state or nil
end
local function loadGenerated(path)
-- CacheFs.loadActive, not love.filesystem.load: Gold's cache lives under
-- gold/ and fused NX often cannot mount that tree onto data/generated/.
local CacheFs = require("src.import.CacheFs")
local data = CacheFs.loadActive(path)
return data
end
-- NewGame (engine/menus/intro_menu.asm) calls OakSpeech, and OakSpeech's first
-- line is `farcall InitClock`: wStartHour / wStartMinute are anchored before
-- InitializeWorld runs, on every new game there is. A run that never reaches
-- that screen -- a driver with the boot cinema skipped -- would leave the base
-- unset, and Clock reads an unanchored save straight off the host clock, so the
-- same new game is MORN on one run and NITE on the next (which mon a patch of
-- grass rolls follows from that). Anchor with InitClock's own 10 AM default so
-- every run mode starts on the same clock the cinema's default would have set.
--
-- POKEPORT_GOLD_HOUR anchors here as well as pinning World:hour, so the
-- Pokegear card and the main menu box agree with the light outside; the day
-- stays wStartDay 0 (the host weekday), because InitDayOfWeek is Mom's wheel
-- and not part of New Game.
local function anchorNewGameClock(save)
if Clock.isSet(save) then return false end
local forced = tonumber(os.getenv("POKEPORT_GOLD_HOUR") or "")
return Clock.setTime(save, forced or Clock.DEFAULT_HOUR,
Clock.DEFAULT_MINUTE)
end
Game2.anchorNewGameClock = anchorNewGameClock
function Game2.new()
local self = setmetatable({
speedOverride = nil,
capturePath = nil,
world = nil,
status = nil,
phase = "boot", -- boot | play | error
input = Input,
-- The automated joypad stream (home/joypad.asm). Owned here rather than
-- by the World so an armed stream survives the map reload a script can do
-- while it is running, and so the boot cinema shares one ring with play.
autoInput = AutoInput.new(),
stack = makeStack(),
-- A real save arrives from CONTINUE or Save.newGame; this skeleton only
-- has to survive the boot cinema, which reads player.name.
save = Save.newGame(),
-- No `tokens` here, deliberately. It is the tokens registry's Data
-- target, and src/mods/Builtins.lua seeds that registry from
-- TextBox.registerInto on both generations -- so a table sitting here
-- before the merge is a BASE the registry folds against, and every one of
-- those seed registrations then collides ("tokens already registered:
-- RIVAL") and takes the whole mod subsystem down with it. Gen 1 has no
-- Data.tokens before the merge either; the merge is what creates it.
-- TextBox.substitute falls back to TextBox.TOKENS while it is absent,
-- which covers the window before mods:load, and nothing draws text in it.
data = { audio = {}, pokemon = {} },
titleData = nil,
oakSpeechData = nil,
fontData = nil,
-- Options live in options.lua under `gold`, not in the save file: they
-- survive New Game, and the launcher's gear edits the same block before
-- the game boots. The save keeps a reference so the OPTION screen and
-- anything holding a save still read one table.
options = Save.loadOptions(),
}, Game2)
self.save.options = self.options
self.sessionStartedAt = os.time()
anchorNewGameClock(self.save)
return self
end
-- Persist the option block. Called from every place that changes it -- the
-- OPTION screen, the hotkey ladder, the pad's speed buttons -- rather than
-- from applyOptions, which also runs on boot and on CONTINUE where there is
-- nothing new to write.
function Game2:persistOptions()
pcall(Save.saveOptions, self.options)
end
Game2.writeOptions = Game2.persistOptions
-- Point the loader's mod.save backing at this save's modData so per-mod state
-- persists with the slot. Same contract and same three call sites as Gen 1
-- (src/core/Game.lua:990): seedBuckets is boot-only and keeps what entry
-- chunks wrote before any save existed, NEW GAME and CONTINUE replace the
-- backing outright. src/core/gen2/Save.lua serializes the whole table and
-- Save.normalize keeps keys it does not know, so modData round-trips.
function Game2:adoptSave(save, seedBuckets)
if not save then return end
save.modData = save.modData or {}
local loader = self.mods
if not loader then return end
if seedBuckets then
for id, bucket in pairs(loader.modSave or {}) do
if save.modData[id] == nil then save.modData[id] = bucket end
end
end
loader.modSave = save.modData
end
function Game2:enterArena(spec)
local version = GameVersion.get()
if spec and spec.slotId then
pcall(SaveData.setActiveSlot, version, spec.slotId)
end
local ok, loaded = pcall(Save.load, version)
if ok and loaded then
local activeMods = self.modStatus and self.modStatus.loaded
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
self.save = loaded
self.save.options = self.options
self:adoptSave(loaded)
require("src.battle.gen2.Mon").syncSaveIdentity(loaded, self.data)
else
require("src.core.Logger").warn("arena2: save slot %s could not be loaded",
tostring(spec and spec.slotId))
end
self.phase = "boot"
self.stack:clear()
self.stack:push(require("src.ui.gen2.ArenaState").new(self, spec))
end
function Game2:startWorld()
if self.world and self.world.map then
self.phase = "play"
return true
end
self.world = World.new(self)
if not self.world:load() then
self.status = self.world.status
self.phase = "error"
return false
end
self.phase = "play"
return true
end
function Game2:showOakSpeech()
self.stack:clear()
self.phase = "boot"
Screens.push(self, "Gen2OakSpeech", {
data = self.oakSpeechData or {},
font = self.fontData,
onDone = function()
self.stack:clear()
self:startWorld()
end,
})
end
-- NEW GAME: a fresh save, then the Oak speech (which collects the name), then
-- SPAWN_HOME. engine/menus/intro_menu.asm NewGame is this same order.
function Game2:newGame()
self.save = Save.newGame({ playerName = self.save.player.name })
self.save.options = self.options
self.sessionStartedAt = os.time()
-- InitClock re-anchors this the moment the player answers Oak; the default
-- only has to hold for a run that skips the screen.
anchorNewGameClock(self.save)
-- Where Gen 1 emits it on NEW GAME (src/core/Game.lua:158): the skeleton is
-- finished and adopted, nothing has been pushed yet. save.new_game already
-- fired inside Save.newGame, so a mod that reshaped the skeleton sees its
-- own work in this payload. Same name, same `save` key as Gen 1.
-- No bucket carry-over, for the reason Gen 1 gives (src/core/Game.lua:155):
-- state from an abandoned session must not leak into a fresh slot.
self:adoptSave(self.save)
ModRuntime.emit("save.created", { save = self.save })
self:showOakSpeech()
end
-- CONTINUE: adopt the loaded save and drop straight into the world at the
-- position it recorded (World:load reads save.position).
function Game2:continueGame(save)
if not save then
self:newGame()
return
end
-- Gen 1 raises this from Game:restoreSave (src/core/Game.lua:1079) at the
-- same point: the file is off disk and migrated but not yet adopted, so a
-- mod still has a window to repair its own data in `raw`. Gold's read
-- happens in the caller (the intro menu's CONTINUE row, the F2 hotkey), the
-- way Gen 1's happens in SaveData.load before restoreSave is reached.
if ModRuntime.wants("save.loading") then
ModRuntime.emit("save.loading", { raw = save })
end
-- Mod chains replay before the world stands, where Gen 1 runs them
-- (src/core/Game.lua:1076): a mod repairs its own data before anything reads
-- it. Generation-blind -- these are mod-supplied functions keyed off
-- save.meta -- so there is no Gen 2 variant to write.
local activeMods = self.modStatus and self.modStatus.loaded
SaveData.runMigrations(save, self.mods and self.mods.migrations, activeMods)
local modsDiff = SaveData.modsDiff(save, activeMods)
self.save = save
self.sessionStartedAt = os.time()
self:adoptSave(save)
-- Editor species swaps used to leave mon.name on the previous species.
-- CONTINUE rewrites party, boxes, and Day-Care copies from the live record.
require("src.battle.gen2.Mon").syncSaveIdentity(save, self.data)
-- options.lua wins over anything a save file carries: options are a display
-- preference that survives New Game and is edited from the launcher, so a
-- save written before they moved out must not drag old values back in.
self.save.options = self.options
self:applyOptions()
self.stack:clear()
self.world = nil
self:startWorld()
-- After the adopt and after the world is standing, which is where Gen 1
-- emits it (src/core/Game.lua:1127, once the stack has been rebuilt).
if modsDiff then
local notice = SaveData.modsDiffNotice(modsDiff, save.meta)
if notice then require("src.core.Logger").warn("%s", notice) end
end
if ModRuntime.wants("save.loaded") then
ModRuntime.emit("save.loaded",
{ save = save, meta = save.meta, modsDiff = modsDiff })
end
end
function Game2:showMainMenu()
self.stack:clear()
self.phase = "boot"
Screens.push(self, "Gen2MainMenu", {
onNewGame = function() self:newGame() end,
onContinue = function(save) self:continueGame(save) end,
onOption = function() self:showOptions(function() self:showMainMenu() end) end,
onExit = self.onExit,
})
end
-- QUIT from the START menu: back to the title like a power-cycle, with
-- everything since the last save discarded. Same contract as Game:returnToTitle
-- in the Gen 1 path, so the two generations' QUIT rows behave identically.
function Game2:returnToTitle()
Music.stop()
self.stack:clear()
self.world = nil
self:showTitle()
end
-- Reset (home/init.asm:1-14) falls into Init -> GameInit -> IntroSequence
-- (engine/menus/intro_menu.asm:1140-1143, :848-849): copyright splash, not title.
function Game2:softReset()
Music.stop()
self.stack:clear()
self.world = nil
self:showCopyright()
end
function Game2:showOptions(onDone)
Screens.push(self, "Gen2OptionsMenu", {
options = self.options,
onDone = function(options)
self.options = options
if self.save then self.save.options = options end
self:applyOptions()
self:persistOptions()
if onDone then onDone() end
end,
})
end
function Game2:showTitle()
self.stack:clear()
self.phase = "boot"
Screens.push(self, "Gen2TitleState", {
title = self.titleData or {},
onContinue = function()
self:showMainMenu()
end,
-- engine/menus/intro_menu.asm:848-889
onTimeout = function()
self:showCopyright()
end,
})
end
-- ../pokegold/engine/movie/intro.asm:1 GoldSilverIntro, and Crystal's own
-- program at ../pokecrystal/engine/movie/intro.asm:1 CrystalIntro.
function Game2:showIntro()
self.stack:clear()
self.phase = "boot"
local id = (GameVersion.engine() == "crystal")
and "Gen2CrystalIntro" or "Gen2GoldSilverIntro"
Screens.push(self, id, {
onDone = function()
self:showTitle()
end,
})
end
-- ../pokegold/engine/menus/intro_menu.asm:848-851 IntroSequence, and Crystal's
-- at ../pokecrystal/engine/menus/intro_menu.asm:964-967: a skip means the title.
function Game2:showGameFreak()
self.stack:clear()
self.phase = "boot"
local id = (GameVersion.engine() == "crystal")
and "Gen2CrystalSplash" or "Gen2GameFreakPresents"
Screens.push(self, id, {
title = self.titleData or {},
oakSpeech = self.oakSpeechData or {},
onDone = function(skipped)
if skipped then
self:showTitle()
else
self:showIntro()
end
end,
})
end
function Game2:showCopyright()
self.stack:clear()
self.phase = "boot"
Screens.push(self, "Gen2CopyrightSplash", {
title = self.titleData or {},
onDone = function()
self:showGameFreak()
end,
})
end
-- START in the overworld. The submenus each push themselves and pop back to
-- the start menu, matching .MenuReturns (most entries reopen it; SAVE and EXIT
-- close it).
function Game2:openStartMenu()
-- ../pokecrystal/engine/overworld/events.asm:284-285
if self.world and self.world.cancelMapNameSign then
self.world:cancelMapNameSign()
end
-- ../pokecrystal/engine/overworld/events.asm:494-510
if self.world and self.world.player then self.world.player:stopForEvent() end
Screens.push(self, "Gen2StartMenu", {
save = self.save,
onClose = function() self.stack:pop() end,
onChoose = function(id) self:openStartMenuItem(id) end,
})
end
-- ../pokecrystal/engine/menus/start_menu.asm:444-518
function Game2:openStartMenuItem(id)
local MenuFade = require("src.ui.gen2.MenuFade")
local party = self.save and self.save.party
local white = MenuFade.openWhite(id, party and #party or 0)
if not white then return self:pushStartMenuItem(id) end
Screens.push(self, "Gen2MenuFade", {
kind = "out", white = white,
onDone = function() self:pushStartMenuItem(id) end,
})
end
-- ../pokecrystal/home/map.asm:1919-1925
function Game2:closeStartMenuItem(id)
local MenuFade = require("src.ui.gen2.MenuFade")
self.stack:pop()
local white = MenuFade.closeWhite(id)
if not white then return end
Screens.push(self, "Gen2MenuFade", { kind = "in", white = white })
end
function Game2:pushStartMenuItem(id)
local function back() self:closeStartMenuItem(id) end
if id == "pokedex" then
Screens.push(self, "Gen2PokedexMenu", { onClose = back })
elseif id == "pokemon" then
-- The field list is the one flavour that opens PokemonActionSubmenu on A
-- (engine/pokemon/mon_menu.asm) rather than answering to a caller.
Screens.push(self, "Gen2PartyMenu", {
prompt = "choose", submenu = true, onCancel = back,
})
elseif id == "pack" then
Screens.push(self, "Gen2PackMenu", {
onClose = back,
onChoose = function(itemId) self:useFieldItem(itemId) end,
})
elseif id == "pokegear" then
Screens.push(self, "Gen2Pokegear", {
onClose = back,
currentLandmark = self:currentLandmark(),
onCall = function(call) return self:runPokegearCall(call) end,
})
elseif id == "status" then
Screens.push(self, "Gen2TrainerCard", { onClose = back })
elseif id == "save" then
Screens.push(self, "Gen2SaveMenu", {
save = self:snapshotSave(),
-- The screen's default writer is Save.save; route it through writeSave
-- so the save.write veto and the save.writing event fire at the moment
-- the cart writes (between the two SAVING messages) rather than when
-- the menu opened. It re-snapshots, which costs nothing and cannot go
-- stale if a script moved the player while the box was up.
writer = function() return self:writeSave() end,
onDone = function()
self.stack:pop() -- the save screen
self.stack:pop() -- and the start menu, like .Exit does
end,
})
elseif id == "option" then
self:showOptions(back)
elseif id == "mods" then
Screens.push(self, "ManagerState")
end
end
-- MakePhoneCallFromPokegear's .DoPhoneCall (engine/phone/phone.asm): the
-- contact's SCRIPT1 runs while the Pokegear keeps the screen -- on the cart
-- through ExecuteCallbackScript, here through the overworld VM, whose text
-- pages are TextBox states pushed OVER the card, exactly the stack they ride
-- over the overworld. Only a connected call ("call" without the wrong-number
-- fallback) has a script to run; the out-of-area / just-talk kinds keep the
-- card's own one-line answer. wCurCaller rides vm.curPhoneCaller so
-- GetCallerLocation's two specials know who picked up, and A/B afterwards is
-- PokegearPhone_FinishPhoneCall's hang-up, unchanged.
function Game2:runPokegearCall(call)
if not (call and call.kind == "call") or call.wrongNumber then return false end
local world = self.world
local vm = world and world.vm
local key = call.scriptKey
if not (vm and key and vm.scripts[key]) then return false end
if vm:running() then return false end
vm.curPhoneCaller = call.contact
local ok = vm:start(key)
if ok then call.ranScript = true end
return ok
end
-- home/hm_moves.asm IsHMMove's .HMMoves.
local HM_MOVES = {
CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true,
WATERFALL = true, WHIRLPOOL = true,
}
-- LearnMove (engine/pokemon/learn.asm): a free slot learns outright, a full
-- set runs ForgetMove's ask / pick / "Stop learning" loop. onDone(true) is
-- the routine's own `ld b, 1`.
function Game2:learnMoveOn(mon, moveId, onDone)
local Mon = require("src.battle.gen2.Mon")
local moveDef = (self.data.moves or {})[moveId]
local moveName = (moveDef and moveDef.name) or moveId
local name = mon.nickname or mon.name or mon.species or "?"
local ok, reason, entry = Mon.learnMove(mon, moveId, self.data)
local function finish(learned)
if onDone then onDone(learned) end
end
if ok then
-- data/text/common_3.asm:119
return self:say(Strings("%s learned\n%s!", name, moveName),
function() finish(true) end,
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
end
if reason ~= "full" then return finish(false) end
local askForget, pickMove, askStop
-- DidNotLearnMoveText, then `ld b, 0` (learn.asm:110-113).
local function decline()
self:say(Strings("%s\ndid not learn\v%s.", name, moveName),
function() finish(false) end)
end
-- ForgetMove's AskForgetMoveText + YesNoBox (learn.asm:123-127).
askForget = function()
self.stack:push(TextBox.new(self,
Strings("%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves.\fDelete an older\nmove to make room\vfor %s?",
name, moveName, name, moveName),
nil, { choice = function(yes)
if yes then return pickMove() end
return askStop()
end }))
end
-- StopLearningMoveText, whose NO is `jp c, .loop` (learn.asm:104-108).
askStop = function()
self.stack:push(TextBox.new(self,
Strings("Stop learning\n%s?", moveName), nil,
{ choice = function(yes)
if yes then return decline() end
return askForget()
end }))
end
-- engine/pokemon/learn.asm:135-166
local function pushList()
Screens.push(self, "Gen2MoveDeleter", {
mon = mon,
moves = self.data.moves,
layout = "forget",
onCancel = function()
self.stack:pop() -- the move list
self.stack:pop() -- the question it stood on
askStop()
end,
onChoose = function(slot)
local old = mon.moves[slot]
self.stack:pop() -- the move list
-- MoveCantForgetHMText, then `jr .loop` (learn.asm:183-197): the
-- question stays up and the list comes back over it.
if old and HM_MOVES[old.id] then
return self:say(Strings("HM moves can't be\nforgotten now."), pushList)
end
self.stack:pop() -- the question the list stood on
local oldDef = (self.data.moves or {})[old and old.id]
local oldName = (oldDef and oldDef.name) or (old and old.id) or "?"
mon.moves[slot] = entry
-- The slot is written here rather than through Mon.learnMove, so
-- pokemon.move_learned is raised here too.
ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
-- engine/pokemon/learn.asm:225-229, data/text/common_3.asm:165-173
self:say(Strings("1, 2 and…\1 Poof!\1\f%s forgot\n%s.\fAnd…\f%s learned\n%s!",
name, oldName, name, moveName),
function() finish(true) end,
TextBox.soundOpts(self, "Sfx_DexFanfare5079",
{ pauseSounds = { "Sfx_SwitchPokemon" } }))
end,
})
end
-- MoveAskForgetText, a `done` text: the box stays while the list stands on
-- it (learn.asm:136-137).
pickMove = function()
self.stack:push(TextBox.new(self, Strings("Which move should\nbe forgotten?"), nil,
{ stay = { onShown = pushList } }))
end
askForget()
end
-- Using an item from the PACK outside a battle: pack.asm UseItem's .Party
-- arm, for the two families it covers. A TM/HM opens the party to teach
-- (ItemAttributes says its ITEMMENU_PARTY opens the list, `teaches` names the
-- move, BASE_TMHM on the species says whether it may learn it -- TeachTMHM);
-- everything else with a ported party effect (src/core/gen2/ItemEffects.lua:
-- heals, status cures, revives, RARE CANDY, the PP family) opens the same
-- list under UseOnWhichPKMNString and runs its item_effects.asm routine on
-- the pick. The world's own .Current / .Field items never reach here --
-- PackMenu hands them to World:useFieldItem first.
function Game2:useFieldItem(itemId)
local items = self.data.items or {}
local def = items[itemId]
local moveId = def and def.teaches
if not moveId then return self:usePartyItem(itemId) end
local moves = self.data.moves or {}
local moveDef = moves[moveId]
local moveName = (moveDef and moveDef.name) or moveId
Screens.push(self, "Gen2PartyMenu", {
prompt = "choose",
onCancel = function() self.stack:pop() end,
onChoose = function(index, mon)
self.stack:pop()
local species = self.data.pokemon and self.data.pokemon[mon.species]
local learnable = species and species.tmhm
local allowed = false
for _, id in ipairs(learnable or {}) do
if id == moveId then allowed = true end
end
if not allowed then
self:say(Strings("%s can't learn %s!",
require("src.battle.gen2.Mon").displayName(mon), moveName))
return
end
for _, move in ipairs(mon.moves or {}) do
if move.id == moveId then
self:say(Strings("%s already knows %s!",
require("src.battle.gen2.Mon").displayName(mon), moveName))
return
end
end
-- TeachTMHM's `predef LearnMove`, then `ld a, b / and a / jr z, .nope`:
-- a refusal spends nothing (engine/items/tmhm.asm:142-153).
self:learnMoveOn(mon, moveId, function(learned)
if not learned then return end
-- IsHM `ret c`: an HM is never consumed and pays no happiness.
if tostring(itemId):sub(1, 3) == "HM_" then return end
require("src.core.gen2.Happiness").change(mon, "LEARNMOVE")
self:consumeItem(itemId)
end)
end,
})
end
-- UseDisposableItem (engine/items/item_effects.asm): one copy leaves the
-- pack, and only on a success -- every refusal above it returns first.
function Game2:consumeItem(itemId)
if not (self.save and self.save.inventory) then return end
local left = (self.save.inventory[itemId] or 1) - 1
self.save.inventory[itemId] = left > 0 and left or nil
end
-- engine/pokemon/evolve.asm:333
function Game2:restartMapMusicAfterEvolution()
local world = self.world
if world and world.restoreMapMusic then world:restoreMapMusic() end
end
-- RareCandyEffect's tail (engine/items/item_effects.asm): LearnLevelMoves at
-- the new level, then EvolvePokemon. LearnLevelMoves' .learn arm is `predef
-- LearnMove` (engine/pokemon/evolve.asm), so a full set gets ForgetMove's ask
-- rather than a refusal; the evolution rides the very screen the battle's own
-- EvolveAfterBattle pass pushes, with wForceEvolution clear --
-- Evolution.checkMon's ordinary condition walk -- so an Everstone or an
-- unmet happiness row still refuses.
function Game2:afterRareCandy(mon, result, onDone)
local data = self.data
local queue = {}
for _, moveId in ipairs(result.learned or {}) do
queue[#queue + 1] = moveId
end
local function evolve()
local Evolution = require("src.core.gen2.Evolution")
local Palettes = require("src.world.gen2.Palettes")
local entry = Evolution.checkMon(data, mon, {
timeOfDay = Palettes.clockDaytime(),
})
if not entry then
if onDone then onDone() end
return
end
local party = (self.save and self.save.party) or {}
local index
for i, member in ipairs(party) do
if member == mon then index = i end
end
Screens.push(self, "Gen2EvolutionAnim", {
mon = mon,
entry = entry,
index = index,
party = party,
save = self.save,
onDone = function()
self.stack:pop()
self:restartMapMusicAfterEvolution()
if onDone then onDone() end
end,
})
end
local nextMove
nextMove = function()
local moveId = table.remove(queue, 1)
if not moveId then return evolve() end
self:learnMoveOn(mon, moveId, function() nextMove() end)
end
nextMove()
end
-- The non-TM half of UseItem's .Party: ChooseMonToUseItemOn over the party
-- ("Use on which <PK><MN>?"), then the item family's own item_effects.asm
-- routine on the pick. The PP family threads one more screen first --
-- MoveSelectionScreen's "Restore the PP of which move?" pick, which the port
-- serves with the move-list screen the Blackthorn deleter already draws
-- (both are SetUpMoveList on the cart). Backing out of either list is the
-- .SelectMon / PPRestoreItem_Cancel carry path: nothing spent.
function Game2:usePartyItem(itemId)
local ItemEffects = require("src.core.gen2.ItemEffects")
-- without the merged dataset this can only ever see RECORDS, the
-- module's own built-ins, so a mod's field item resolves to no action
-- at all and never gets past the .Oak refusal below
local action = ItemEffects.partyAction(itemId, self.data)
if not action then return end
local party = (self.save and self.save.party) or {}
if #party == 0 then
-- UseItem's .NoPokemon arm (_YouDontHaveAMonText).
self:say(Strings("You don't have a\n#MON!"))
return
end
-- engine/items/item_effects.asm:1748
local function openMenu()
local menu = self.stack.top and self.stack:top()
if menu and menu.showItemResult then return menu end
return nil
end
local function finish(result, mon, slot, before)
local menu = openMenu()
if not result.used then
self:say(result.text, menu and function() self.stack:pop() end or nil)
return
end
if action == "stone" then
if menu then self.stack:pop() end
local party = (self.save and self.save.party) or {}
local index
for i, member in ipairs(party) do
if member == mon then index = i break end
end
Screens.push(self, "Gen2EvolutionAnim", {
mon = mon, entry = result.evolution, index = index,
party = party, save = self.save,
force = true,
onDone = function(evolution)
if evolution and evolution.evolved then self:consumeItem(itemId) end
self.stack:pop()
self:restartMapMusicAfterEvolution()
end,
})
return
end
self:consumeItem(itemId)
if not menu then
if action == "candy" then
-- data/text/common_1.asm:86
self:say(result.text, function() self:afterRareCandy(mon, result) end,
result.sfx and TextBox.soundOpts(self, result.sfx) or nil)
else
self:say(result.text)
end
return
end
-- engine/items/item_effects.asm:1663
local climbs = (action == "heal" or action == "revive")
and before and mon.hp and mon.hp ~= before
menu:showItemResult(slot, {
fromHp = climbs and before or nil,
toHp = climbs and mon.hp or nil,
sfx = climbs and "Sfx_Potion" or result.sfx,
text = result.text,
onDone = function()
self.stack:pop()
if action == "candy" then self:afterRareCandy(mon, result) end
end,
})
end
Screens.push(self, "Gen2PartyMenu", {
prompt = "useItem",
onCancel = function() self.stack:pop() end,
onChoose = function(slot, mon)
local before = mon and mon.hp
if action ~= "pp" then
finish(ItemEffects.useOnMon(itemId, mon, self.data), mon, slot, before)
return
end
-- RestorePPEffect: the ELIXER pair needs no move pick; an EGG refuses
-- before the move list ever opens (UseItem_SelectMon's `cp EGG`).
local row = ItemEffects.RESTORE_PP[itemId] or {}
if row.each or mon.isEgg then
finish(ItemEffects.usePpItem(itemId, mon, nil, self.data), mon, slot,
before)
return
end
Screens.push(self, "Gen2MoveDeleter", {
mon = mon,
moves = self.data.moves,
onCancel = function() self.stack:pop() end,
onChoose = function(moveSlot)
self.stack:pop() -- the move list
finish(ItemEffects.usePpItem(itemId, mon, moveSlot, self.data), mon,
slot, before)
end,
})
end,
})
end
-- SelectMenu (engine/overworld/select_menu.asm): the SELECT press in the
-- overworld. World:useSelectItem runs CheckRegisteredItem's re-validation
-- and UseRegisteredItem's dispatch; everything past that is just which of
-- the cart's fixed messages to print, the same way the START handler above
-- is the whole of .MenuReturns for its own button.
function Game2:useSelectItem()
-- ../pokecrystal/engine/overworld/events.asm:494-510
if self.world.player then self.world.player:stopForEvent() end
local outcome, itemId = self.world:useSelectItem()
if outcome == "not_registered" then
-- MayRegisterItemText.
self:say(Strings(
"An item in your\nPACK may be\fregistered for use\non SELECT Button."))
elseif outcome == "cant_use" or outcome == "nowhere" then
-- ItemsOakWarningText, the same "not the time" line CheckItemMenu's
-- .CantUse arm and a busy world both land on.
self:say(Strings("OAK: {PLAYER}!\nThis isn't the\vtime to use that!"))
elseif outcome == "repel_active" then
self:say(Strings("The REPEL used\nearlier is still\vin effect."))
elseif outcome == "repel_used" then
local items = self.data.items or {}
local name = (items[itemId] and items[itemId].name) or itemId
self:say(Strings("{PLAYER} used the\n%s.", name))
elseif outcome == "trophy_sent" then
-- data/text/common_3.asm:372
self:say(Strings(
"There was a trophy\ninside!\fThe trophy was\nsent home."),
nil, TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
end
-- Anything else (a fishing bite, the ITEMFINDER's queued script) already
-- drives its own presentation off World:step -- nothing left to print here.
end
-- A message over whatever is on screen. TextBox pops ITSELF on the final A
-- press before running onDone -- the same contract every other push site in
-- the tree leans on -- so onDone here is only the caller's continuation. An
-- onDone that popped again ate the state UNDER the box: dismissing a message
-- over the PACK closed the PACK with it, and over an empty overworld stack it
-- was a silent extra pop.
function Game2:say(text, onDone, opts)
local TextBox = require("src.render.TextBox")
self.stack:push(TextBox.new(self, text, onDone, opts))
end
-- The landmark the player is standing in, for the Pokegear map's marker.
-- Through src/core/gen2/Nests.lua rather than off landmarks.order, so the
-- `landmarks` registry's own records answer too: order is a flat list the
-- extractor writes and a registered landmark is not in it, while every record
-- carries the map header's own `index` byte.
function Game2:currentLandmark()
local map = self.world and self.world.map and self.world.map.def
return require("src.core.gen2.Nests")
.landmarkId(self.data, map and map.landmark)
end
-- Fold the live world state into the save before writing it, so a reload comes
-- back on the same tile facing the same way.
function Game2:snapshotSave()
local world = self.world
if world and world.map and world.player then
self.save.position = {
map = world.map.id,
x = world.player.cellX,
y = world.player.cellY,
facing = world.player.facing,
}
self.save.events = world.events and world.events:serialize()
or self.save.events
self.save.mapScenes = world.mapScenes or self.save.mapScenes
-- wPlayerState, out of the same sPlayerData block the flags and the scene
-- ids come from: save on the BICYCLE and the reload has to come back on
-- the BICYCLE, save aboard a Lapras and it has to come back afloat.
-- Without this line a save walks the player off the bike and, worse, off
-- the water -- World:loadPlayerData reads it back.
self.save.playerState = world.playerState or self.save.playerState
-- The script VM's sparse WRAM store. These are counters no other field
-- covers -- the Goldenrod underground switch positions and the MooMoo
-- berries -- so leaving them out of the snapshot is the same as never
-- having flicked a switch.
self.save.scriptMem = world.vm and world.vm:serializeMem()
or self.save.scriptMem
-- wVariableSprites. WRAM on the cart and therefore never saved there,
-- which the cart survives because it never rebuilds the world mid-session.
-- This port does, on every CONTINUE -- and an unfilled slot is an object
-- that does not spawn -- so the chosen sprites ride along with the save.
-- Route 36 is why it matters both ways: the slot holds the disguised
-- Sudowoodo before the fight and the TWIN who replaces it after.
self.save.variableSprites = world.variableSprites
or self.save.variableSprites
-- wBackupWarpNumber / wBackupMapGroup / wBackupMapNumber (home/map.asm
-- CopyWarpData), which a -1 warp destination resolves through. Saved
-- WRAM on the cart, so a save made on POKECENTER_2F must still know which
-- centre's stairs lead back down -- World:loadPlayerData reads it back.
self.save.backupWarp = world.backupWarp or self.save.backupWarp
end
self.save.options = self.options
return self.save
end
-- Snapshot the world and persist it. The mirror of Game:writeSave
-- (src/core/Game.lua:1005): same veto hook, same event, same order, so a mod
-- written against the Gen 1 save lifecycle behaves identically on Gold.
--
-- Every write the player can ask for goes through here -- the SAVE row of the
-- start menu (via the writer handed to Gen2SaveMenu) and the F1 hotkey -- so
-- there is one place the veto has to hold.
function Game2:writeSave()
-- Tool sessions can be deliberately ephemeral. Give them one narrow veto
-- before snapshotSave folds the live world in or any progress bytes reach
-- disk. Returning false here is what SaveMenu reads back as "not saved".
if ModRuntime.call("save.write", function() return true end, self) == false then
return false
end
local save = self:snapshotSave()
save.meta = SaveData.buildMeta(
self.modStatus and self.modStatus.loaded, save.meta, self.sessionStartedAt)
if ModRuntime.wants("save.writing") then
ModRuntime.emit("save.writing", { save = save, meta = save.meta })
end
local written, err = Save.save(save)
if written then
local eng = self:syncEngine()
if eng then pcall(eng.noteSaveWritten, eng) end
end
return written, err
end
-- engine/overworld/events.asm:241-244
function Game2:quickSaveAllowed()
local w = self.world
if not w then return true end
if not (w.map and w.player) then return true end
if not w.acceptsMenuInput then return true end
return w:acceptsMenuInput() == true
end
function Game2:syncEngine()
if self._syncOff then return nil end
local eng = self._syncEngineRef
if not eng then
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
if not ok or type(SyncEngine) ~= "table" then
self._syncOff = true
return nil
end
eng = SyncEngine.shared()
if not eng then
self._syncOff = true
return nil
end
self._syncEngineRef = eng
end
return eng
end
function Game2:load(opts)
opts = opts or {}
local arena = opts.arena
Input:init()
-- Before applyOptions, which is what pushes options.touchControls into it:
-- init() decides whether the platform wants the overlay at all and loads the
-- art, applyOptions then lays it out (src/core/Game.lua:59-60 does the pair
-- in the same order).
TouchControls:init()
self.touchControls = TouchControls
self:applyOptions()
self.titleData = loadGenerated("data/generated/title.lua") or {}
self.oakSpeechData = loadGenerated("data/generated/oak_speech.lua") or {}
-- data.font, not a private field: it is the `font` registry's Data target,
-- so a mod that overrides a glyph is merged in before Font.load reads it
-- below. self.fontData stays as the handle the boot screens pass around.
self.data.font = loadGenerated("data/generated/font.lua")
self.fontData = self.data.font
self.data.audio = loadGenerated("data/generated/audio.lua") or {}
self.data.pokemon = loadGenerated("data/generated/pokemon.lua") or {}
self.data.items = loadGenerated("data/generated/items.lua") or {}
self.data.moves = loadGenerated("data/generated/moves.lua") or {}
self.data.type_chart = loadGenerated("data/generated/type_chart.lua") or {}
-- data/types/type_matchups.asm:112-116: the rows after the `db -2` marker
-- apply by default; Foresight is what cuts the table short at it.
local chart = self.data.type_chart
for _, row in ipairs(chart.foresightMatchups or {}) do
chart.matchups[#chart.matchups + 1] = row
end
-- The `held_items` registry's merge target: ItemAttributes' last two columns
-- as their own table, so a mod can give an item a held behaviour without
-- owning the whole item record. Built BEFORE mods:load so the registry
-- folds against the vanilla row (a mod's register collides, a patch stacks),
-- and snapshotted so the write-back below can tell what the merge actually
-- changed from what a mod reached through the shared `items` registry
-- instead. Both halves live in src/core/gen2/ItemEffects.lua.
local ItemEffects = require("src.core.gen2.ItemEffects")
self.data.gen2HeldItems = ItemEffects.heldItemsFrom(self.data.items)
local heldBefore = ItemEffects.heldSnapshot(self.data.gen2HeldItems)
-- Gen 2-only tables the menus read. Namespaced so nothing collides with the
-- Gen 1 keys of the same idea (data.palettes, data.icons).
self.data.gen2Palettes = loadGenerated("data/generated/palettes.lua")
self.data.gen2Icons = loadGenerated("data/generated/icons.lua")
self.data.gen2Pokedex = loadGenerated("data/generated/pokedex.lua")
self.data.gen2Landmarks = loadGenerated("data/generated/landmarks.lua")
self.data.gen2Sprites = loadGenerated("data/generated/sprites.lua")
self.data.gen2MenuGfx = loadGenerated("data/generated/menu_gfx.lua")
self.data.gen2Intro = loadGenerated("data/generated/intro.lua")
self.data.gen2Credits = loadGenerated("data/generated/credits.lua")
self.data.gen2Diploma = loadGenerated("data/generated/diploma.lua")
self.data.gen2Trade = loadGenerated("data/generated/trade.lua")
self.data.gen2Trainers = loadGenerated("data/generated/trainers.lua")
self.data.gen2Encounters = loadGenerated("data/generated/encounters.lua")
-- The battle-animation script pool and the ordered name lists its ids index
-- into (BattleAnimObjects rows, framesets, OAM sets, BG effects). Both are
-- read by src/battle/gen2/AnimRunner.lua.
self.data.gen2BattleAnims = loadGenerated("data/generated/battle_anims.lua")
self.data.gen2Constants = loadGenerated("data/generated/constants.lua")
-- The overworld's own tables. src/world/gen2/World.lua:load used to read
-- these straight off disk into World fields, which meant they never passed
-- through game.data and a registry had no Data path to merge into -- the one
-- cause behind most of the `false` rows in Schemas.GEN2. Loading them here
-- puts them in self.data ahead of the mods:load(self.data) call below, so a
-- merge lands before World ever holds the table; World takes each by
-- reference and never copies, so the table it walks IS the merged one.
self.data.gen2Maps = loadGenerated("data/generated/maps.lua")
self.data.gen2Tilesets = loadGenerated("data/generated/tilesets.lua")
self.data.gen2Roofs = loadGenerated("data/generated/roofs.lua")
-- engine/events/magnet_train.asm:165 DrawMagnetTrain
self.data.gen2Field = loadGenerated("data/generated/field.lua")
self.data.gen2Marts = loadGenerated("data/generated/marts.lua")
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
self.data.gen2Text = loadGenerated("data/generated/text.lua")
-- The engine's own strings, keyed by the disassembly's label. gen2Text
-- above is the script text and is keyed by bank:address for the overworld
-- VM, so the two are different tables and both are loaded. This one is
-- what src/core/RomText.lua reads, which is why it lands on `text`: that
-- helper is shared with Gen 1 and looks up data.text[label].
self.data.text = loadGenerated("data/generated/rom_text.lua") or {}
-- data/generated/events.lua: the side tables a script command NAMES rather
-- than carries -- the phone book, the in-game trades, the elevator's floor
-- labels, the decoration descriptions. Keyed for World's own `eventTables`
-- field rather than "gen2Events" so it is never read as the mod event bus.
self.data.gen2EventTables = loadGenerated("data/generated/events.lua")
-- InitializeEventsScript's seed: the setevent list AND the nine
-- `variablesprite` assignments, both of which World:load applies.
self.data.gen2InitialEvents =
loadGenerated("data/generated/initial_events.lua")
-- Gold's trainer table under the second name the Gen 2 code already reads it
-- by (World:trainerParty, src/core/gen2/BugContest.lua and
-- src/world/gen2/Palettes.lua all say data.trainers). The SAME table as
-- data.gen2Trainers, never a copy, so a merge into either key is seen
-- through both; World:load used to set this from a second disk read, which
-- is what dropped a merge on the floor.
self.data.trainers = self.data.gen2Trainers
-- Mods. Discovery and the manager are generation-agnostic -- they read
-- manifests and the enable/disable state, not game data -- so Gold gets the
-- same MODS row the Gen 1 start menu has. Only a mod whose manifest says
-- gen2compat is actually run here (Loader:_gateGeneration); the rest are
-- listed and skipped rather than half-applied. Registry targets route per
-- generation through Schemas.GEN2, so the ones with a Gen 2 home merge into
-- self.data and the ones without report instead of silently vanishing. The
-- whole thing is behind a pcall so a mod problem can never cost Gold its
-- boot.
local modOpts = arena and {
mode = (arena.profile and arena.profile.kind == "cart")
and "cartOnly" or "disableAll",
cartId = opts.cartId,
} or nil
local ok, loader = pcall(function()
local mods = require("src.mods.Loader").new()
-- the live service owner, before load: mod.world and mod.input resolve
-- through this, and without it the facade would bind to the Gen 1
-- src/core/Game.lua singleton that a Gold boot never loads
mods.game = self
mods:load(self.data, modOpts)
return mods
end)
if ok and loader then
self.mods = loader
self.modStatus = loader:status()
else
-- The pcall above keeps a mod problem from costing Gold its boot, which is
-- right; swallowing it without a word is not. A throw here takes out the
-- WHOLE subsystem -- no discovery, no manager, no registry merge -- and
-- with no line printed the only symptom is that mods quietly do nothing,
-- which is indistinguishable from having none installed.
require("src.core.Logger").error(
"mods failed to load, continuing without them: %s", tostring(loader))
end
-- The Gen 2-only content registries, collected after the merge and before
-- anything reads their tables. Each module holds the merged table by
-- reference (or folds it onto the rows it already keys by, for the two whose
-- callers have a byte and not a dataset), so this is where a registered
-- decoration, phone contact, apricorn or held item becomes the one the game
-- actually uses. `landmarks` and `radio_channels` need no call: their
-- consumers (src/core/gen2/Nests.lua, src/ui/gen2/MapRadio.lua) read
-- game.data at the point of use.
ItemEffects.applyHeldItems(self.data, heldBefore)
require("src.core.gen2.Phone").useRegistry(self.data)
require("src.core.gen2.Decorations").useRegistry(self.data)
require("src.core.gen2.Apricorns").useRegistry(self.data)
-- data.gen2Pokedex is a separate table from the `pokemon` registry's own
-- merge target (data.pokemon): a translation mod's
-- mod.content.pokemon:patch(id, { dexEntry = ... }) would otherwise never
-- reach the #DEX screen. See src/core/gen2/PokedexText.lua.
require("src.core.gen2.PokedexText").apply(self.data)
-- Rendering pipelines: the engine half of the render_pipelines registry
-- (src/render/Pipelines.lua). install() points it at GOLD's merged dataset
-- -- Gen 1 points it at the src/core/Data.lua singleton, which a Gold boot
-- never loads (src/core/Game.lua:47) -- and applyOptions restores the ladder
-- the player left in options.pipelines. Both after the merge, so a mod's
-- pipeline record is already in data.render_pipelines when either reads it.
local Pipelines = require("src.render.Pipelines")
Pipelines.install(self.data)
Pipelines.applyOptions(self.options)
-- Both halves run on Gold now: `present` folds over the composite in
-- Game2:draw, `drawWorld` owns the world pass in World:drawPipeline. So a
-- restored world level stays switched on, as it does for Gen 1.
-- After the merge, so a font override and a translation mod's catalog
-- (#501) are both in Data before the first screen draws a glyph. Gen 1
-- calls these two here for the same reason (src/core/Game.lua:65,70).
if self.data.font then
pcall(Font.load, self.data)
end
Strings.load(self.data)
-- The boot skeleton (Game2.new built it, before any bus existed) announced
-- here rather than at its construction, which is the same spot in the boot
-- order Gen 1 announces its own from: after the merge, before game.ready,
-- stack still empty (src/core/Game.lua:79). A driver that skips the cinema
-- plays on this save, so a mod that seeds through save.created has to be
-- given it exactly once, here.
-- seed=true keeps what entry chunks wrote through mod.save before any save
-- existed, the way Gen 1 seeds its boot skeleton (src/core/Game.lua:78).
self:adoptSave(self.save, true)
ModRuntime.emit("save.created", { save = self.save })
-- The handshake every behavior mod waits on: the one place a mod is handed
-- the live service owner (mod.input needs it, and it is what the docs tell
-- a mod to hold). Emitted where Gen 1 emits it -- every service up, the
-- stack still empty -- so a listener that pushes a state lands underneath
-- the boot cinema rather than being buried by it.
pcall(function() require("src.core.DiscordPresence").init(self) end)
ModRuntime.emit("game.ready", { game = self })
-- Drivers that walk the overworld skip boot cinema so smoke stays stable.
-- POKEPORT_BOOT_CINEMA=1 opts back in, which is how the boot-chain driver
-- exercises copyright -> title -> intro menu -> Oak -> naming.
if arena then
self:enterArena(arena)
elseif os.getenv("POKEPORT_DRIVER")
and os.getenv("POKEPORT_BOOT_CINEMA") ~= "1" then
self:startWorld()
else
self:showCopyright()
end
FixedStep:init(function(dt)
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on the
-- same fixed-step boundary a physical controller does. Raised HERE, ahead
-- of both the AUTO_INPUT arm and Input:step, for the reason Gen 1 raises it
-- ahead of Input:step (src/core/Game.lua:188): a button chosen by a mod has
-- to be visible to THIS logic tick, not the next one, and the cart's own
-- canned stream must be able to overwrite it the way GetJoypad's arm
-- overwrites the mirrors. Payload is Gen 1's exactly: (game, fixed dt).
ModRuntime.call("input.step", noop, self, dt or FixedStep.STEP)
-- GetJoypad's AUTO_INPUT arm runs ahead of everything that reads the pad,
-- and it overwrites the mirrors outright, so a stream frame has to land
-- before Input:step promotes this tick's edges -- otherwise the canned
-- press would be a tick late and the player's own keys would still be in
-- the queue alongside it.
self.autoInput:step(self.input)
self.input:step()
-- UpdateJoypad's soft reset (home/joypad.asm:99-102) is `and PAD_BUTTONS /
-- cp PAD_BUTTONS`, so the d-pad is masked off and the chord fires at once.
if self.input.isDown and self.input:isDown("a") and self.input:isDown("b")
and self.input:isDown("start") and self.input:isDown("select") then
Input:reset()
TouchControls:reset()
self:softReset()
return
end
-- Not the audio tick: _UpdateSound runs once per frame off VBlank
-- (audio/engine.asm:84, home/vblank.asm:141-143), never off the logic clock.
local top = self.stack:top()
if top and top.update then
top:update(FixedStep.STEP)
return
end
if self.phase ~= "play" or not self.world then return end
-- The play clock only runs in the overworld, the way wGameTimerPaused is
-- set while the intro menu is up.
Save.tickPlayTime(self.save)
-- MAPEVENTS_OFF skips GetJoypad for the whole of a step, so the hJoyDown
-- mirror is frozen -- events.asm:190-198, :211-227 (#525, #1718)
local accepts = self.world:acceptsMenuInput()
local latch = self.joyLatch
if accepts then
self.joyLatch = nil
if self.input:wasPressed("start")
or (latch and latch.start and self.input:isDown("start")) then
self:openStartMenu()
return
end
if self.input:wasPressed("select")
or (latch and latch.select and self.input:isDown("select")) then
self:useSelectItem()
return
end
else
if not latch then latch = {}; self.joyLatch = latch end
if self.input:wasPressed("start") then latch.start = true end
if self.input:wasPressed("select") then latch.select = true end
end
self.world:pollInput(self.input)
if self.input:wasPressed("a") then
self.world:interact()
end
self.world:step()
end)
end
function Game2:inFillBoot()
-- Entire pre-world cinema (copyright / title / Oak / name / nested NamingScreen)
-- draws in GB letterbox space.
return self.phase == "boot" and self.stack:top() ~= nil
end
function Game2:logicSpeed()
return math.max(1,
tonumber(self.speedOverride) or tonumber(self.options and self.options.speed)
or 1)
end
function Game2:update(dt)
-- _UpdateSound is a VBlank job, so it runs at 60Hz off real time whatever the
-- logic multiplier is (audio/engine.asm:84, home/vblank.asm:141-143).
local step = FixedStep.STEP
self.audioAccum = math.min((self.audioAccum or 0) + dt, 0.25)
while self.audioAccum >= step do
self.audioAccum = self.audioAccum - step
Music.update(self.data)
-- ../pokecrystal/engine/overworld/events.asm:177-191
if self.world and self.world.map then MapNameSign.frame(self.world) end
end
-- TILT eases toward its new angle in real time, not on the logic clock, so
-- fast-forward does not fling the camera over.
require("src.render.Tilt").update(dt)
-- Mod render pipelines tween on the same real-frame clock, for the same
-- reason and at the same place Gen 1 ticks them (src/core/Game.lua:265):
-- they are presentational, so fast-forward must not speed them up.
require("src.render.Pipelines").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
-- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does:
-- audio runs off its own real-time accumulator, so music and sfx keep their
-- tempo at every multiplier (#1990/#1991/#1997). speedOverride is the
-- driver/CLI hook and wins over the saved option.
-- pokegold engine/menus/intro_menu.asm:848 IntroSequence: boot cinema runs on the same clock as the overworld
local speed = self:logicSpeed()
if self.phase == "boot" then
FixedStep.maxAccum = FixedStep.catchupLimit(speed)
FixedStep:update(dt, speed)
return
end
if not self.world or not self.world.map then return end
FixedStep.maxAccum = FixedStep.catchupLimit(speed)
FixedStep:update(dt, speed)
end
-- The screen-pixels-per-GB-pixel scale the post passes need so their grid and
-- shadow offsets stay window-size independent. Always the plain letterbox
-- fit, never the survey zoom: SHADER FX is simulating the PANEL the picture
-- is being shown on, and the panel does not resize when the player zooms the map
-- -- Gen 1 hands the same pass its `Renderer:fitScale()` for that reason
-- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to
-- one screen pixel a cell out at survey range.
function Game2:pixelScale(w, h)
local _, _, pw, ph = Playfield.rect(w, h)
return math.max(1, math.floor(math.min(pw / 160, ph / 144)))
end
-- A window-sized canvas the whole frame is composed into, so the post passes
-- have something to read. Rebuilt on resize; nil (and a plain draw) when the
-- backend cannot give us one.
function Game2:presentCanvas(index, w, h)
self._canvases = self._canvases or {}
local canvas = self._canvases[index]
if canvas then
local cw, ch = canvas:getDimensions()
if cw ~= w or ch ~= h then canvas = nil end
end
if not canvas then
local ok, made = pcall(love.graphics.newCanvas, w, h)
if not ok or not made then return nil end
made:setFilter("nearest", "nearest")
self._canvases[index] = made
canvas = made
end
return canvas
end
-- The letterbox this frame is being drawn in, in the terms Gen 1's
-- Renderer:endFrame reports it: the integer fit scale, the centred origin that
-- goes with it, the window in LOVE units and in framebuffer pixels, and the DPI
-- scale between the two.
--
-- One difference from Gen 1 has to be named, because two payload fields carry
-- it. Gen 1 fits in FRAMEBUFFER pixels and divides back into units, so its
-- `scale` is framebuffer-pixels-per-GB-pixel; Gold fits in LOVE units
-- throughout (Chrome.fitScale takes love.graphics.getDimensions), so `scale`
-- here is units-per-GB-pixel. They are the same number on every 1x display,
-- which is where Gen 1 mods are written and verified, and on a HiDPI display
-- this is the one that actually describes Gold's picture -- `gameX + x * scale`
-- lands on GB pixel x either way. The rect fields (gameX/gameY/gameWidth/
-- gameHeight, ox/oy/vpw/vph) are LOVE units in both generations.
function Game2:frameFit(w, h)
local scale = Chrome.fitScale(w, h)
local ox, oy = Chrome.fitOrigin(w, h, scale)
local dpi = 1
if love.window and love.window.getDPIScale then
dpi = tonumber(love.window.getDPIScale()) or 1
end
local pw, ph = w * dpi, h * dpi
pw, ph = GameViewport.pixelDimensions()
return scale, ox, oy, dpi, pw, ph
end
-- render.hud's payload (src/core/Game.lua:521), which is what Renderer:endFrame
-- returns on the Gen 1 side: the window and the playfield rect inside it, both
-- in LOVE window units. Built only when someone is subscribed, so a mod-free
-- frame allocates nothing.
function Game2:viewport(w, h)
local scale, ox, oy, dpi = self:frameFit(w, h)
return {
width = w, height = h,
gameX = ox, gameY = oy,
gameWidth = 160 * scale, gameHeight = 144 * scale,
scale = scale, dpiX = dpi, dpiY = dpi,
}
end
-- The render.hud layer, in Gen 1's order over the finished game frame. The
-- on-screen pad is drawn separately after GameViewport.finish, because it is
-- OS-window chrome and must not be captured or scaled with this canvas.
--
-- render.hud: persistent tool status. The call is fenced with
-- push("all")/pop for the reason src/render/Pipelines.lua:guardRender fences a
-- mod render callback: a subscriber that returns cleanly but leaves a shader
-- bound, the canvas redirected or the colour changed must not corrupt the next
-- frame.
function Game2:drawHud(w, h)
if ModRuntime.wantsHook("render.hud") then
local G = love.graphics
G.push("all")
ModRuntime.call("render.hud", noop, self, self:viewport(w, h))
G.pop()
end
end
-- render.letterbox: SGB borders and custom void art in the bars around the
-- 160x144 blit. Gen 1 raises it in Renderer:endFrame after the background
-- clear and before the game canvas, so the playfield sits on top of the border;
-- this is the same instant, and Gold reaches it five different ways -- a title
-- screen's own widescreen sky, a page's paper surround, the white void a nested
-- screen gets, the opaque-page safety net, and the live overworld -- so
-- drawScene calls this at each of them and exactly one fires per frame.
--
-- Payload is Gen 1's table field for field (Renderer.lua:840).
function Game2:letterbox(w, h, worldActive)
if not ModRuntime.wantsHook("render.letterbox") then return end
local scale, ox, oy, dpi, pw, ph = self:frameFit(w, h)
local G = love.graphics
G.push("all")
ModRuntime.call("render.letterbox", noop, {
ww = w, wh = h, pw = pw, ph = ph,
ox = ox, oy = oy, vpw = 160 * scale, vph = 144 * scale,
scale = scale, dpiX = dpi, dpiY = dpi,
worldActive = worldActive and true or false,
})
G.pop()
end
-- The zone pass: one scissored full-frame draw per zone, later zones on top,
-- each through its own palette. This is src/render/Renderer.lua:blitCanvas
-- with Gold's palette shader standing in for PaletteFX's, down to the
-- `colors == false` opt-out that draws its rect with no shader at all.
--
-- Zone rects are 160x144 SCREEN space and map onto the WINDOW, not onto the
-- letterbox: Gold's picture fills the window (the overworld draws edge to edge
-- at World:zoomScale, and every full-screen page paints its own surround), so
-- a whole-screen zone is the whole window -- which is exactly what the CLASSIC
-- present pass has always been.
function Game2:blitZones(canvas, zones, w, h)
local G = love.graphics
local GbcPalette = require("src.render.GbcPalette")
local px, py, pw, ph = Playfield.rect(w, h)
local sx, sy = pw / 160, ph / 144
G.setColor(1, 1, 1, 1)
for _, z in ipairs(zones) do
-- a colors == false zone is the true-colour opt-out; anything the shader
-- refuses (no GPU shader support) also falls back to a plain draw
if z.colors == false or not GbcPalette.useRaw(z.colors) then
G.setShader()
end
-- Clamped to the frame and SKIPPED when it clamps to nothing, which is
-- what src/render/Renderer.lua:scissorClamped does with a zone rect on the
-- Gen 1 side. A zone list is mod input (render.zones), so an empty or
-- backwards rect is reachable -- a weather mod deriving one from a
-- viewport that is momentarily zero-sized, say -- and there it just draws
-- nothing. Here it reached love.graphics.setScissor, which raises "Can't
-- set scissor with negative width and/or height" from inside Game2:draw
-- and takes the whole frame down: the hazard the seam rule names, a hook
-- whose contract differs from Gen 1's. Whole-screen and half-screen zones
-- come out of this at exactly the pixels the plain floor/ceil pair gave
-- them, so the vanilla picture is untouched.
local zx, zy = px + (z.x or 0) * sx, py + (z.y or 0) * sy
local x1 = math.floor(math.max(zx, px))
local y1 = math.floor(math.max(zy, py))
local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, px + pw))
local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, py + ph))
if x2 > x1 and y2 > y1 then
G.setScissor(x1, y1, x2 - x1, y2 - y1)
G.draw(canvas, 0, 0)
end
end
G.setScissor()
G.setShader()
end
-- render.compose: hand a mod the finished frame and the frame metrics and let
-- it lay the picture out however it likes -- two stacked Game Boy screens, one
-- driven onto a second physical display. The mod returns true to take over the
-- whole window; anything else falls through to the normal present below.
-- Returns whether it took over.
--
-- Gold composites its world pass and its UI into ONE canvas rather than the two
-- Gen 1 keeps apart, so `worldCanvas` and `uiCanvas` are the same texture here
-- and `worldZones` is nil -- there is no second zone space for them to be in.
-- Every other key is what Renderer.lua:748 puts there, and `generation` /
-- `sceneCanvas` are ADDITIONS, so a mod that reads uiCanvas plus the metrics
-- works unchanged while one that needs the two passes apart can tell which
-- game it is in.
function Game2:compose(scene, zones, w, h)
local scale, ox, oy, dpi, pw, ph = self:frameFit(w, h)
local ctx = {
renderer = self,
worldCanvas = scene, uiCanvas = scene,
worldOverride = nil,
worldActive = self.frameWorldActive and true or false,
zones = zones, worldZones = nil,
ww = w, wh = h, pw = pw, ph = ph, ox = ox, oy = oy,
vpw = 160 * scale, vph = 144 * scale, uiw = 160, uih = 144,
scale = scale, Sx = scale, Sy = scale, dpiX = dpi, dpiY = dpi,
secondScreen = require("src.render.SecondScreen"),
-- Gen 2 additions: the one canvas both passes landed in, and which game
-- this is, so a compose mod can branch instead of guessing from uiw.
sceneCanvas = scene, generation = 2,
}
local G = love.graphics
G.push("all")
local handled = ModRuntime.call("render.compose",
function() return false end, self, ctx) == true
G.pop()
return handled
end
function Game2:fxWorldOrigin(w, h, scale)
local cam = self.world and self.world.camera
if not cam then return nil end
local fx, fy = Playfield.rect(w, h)
return fx + math.floor(-cam.x * scale), fy + math.floor(-cam.y * scale)
end
function Game2:fxSplitsUi(w, h)
local world = self.world
if not (world and world.map) then return false end
local fs = world:fitScale()
if world:zoomScale() ~= fs then return true end
local wx, wy = self:fxWorldOrigin(w, h, fs)
if not wx then return false end
local ox, oy = Chrome.fitOrigin(w, h, fs)
return (ox - wx) % fs ~= 0 or (oy - wy) % fs ~= 0
end
-- Gold's frame, and then the passes that run over it.
--
-- The Gen 1 path gets these for free because everything it draws goes through
-- src/render/Renderer.lua, which owns a present canvas and calls ShaderFX
-- there. Gold draws straight to the screen instead, which is why its SHADER
-- FX row used to change a number and nothing else (back when it was GBC FX):
-- nothing ever presented a canvas for the shader to read. So compose into
-- one here when a pass wants it, and skip the canvas entirely when none does
-- -- the common case, and one less full-screen blit than the old path would
-- have paid.
--
-- CLASSIC runs first and SHADER FX second, matching the Gen 1 order: the
-- palette IS the picture, and the screen effects are simulating the panel
-- that picture is being shown on. Mod post-processes fold in between the
-- two, where Renderer.lua:1058 folds them -- a blur or a colour grade is
-- what the LCD grid is then drawn over, rather than something that smears
-- the grid itself.
function Game2:drawViewportFrame()
local G = love.graphics
local w, h = GameViewport.dimensions()
local ShaderFX = require("src.render.ShaderFX")
local GbcPalette = require("src.render.GbcPalette")
local Pipelines = require("src.render.Pipelines")
-- Same dispatch src/render/Renderer.lua:1185 already uses for Gen 1
-- (ShaderFX replaced GBCFX's slot; GBCFX.lua itself is removed).
local shaderfx = ShaderFX.active()
-- render.zones, at the instant Gen 1 raises it: the palette list is settled
-- and the blit has not happened yet. Gen 1's list is the SGB packet zones
-- the top state exposed; Gold is a CGB game whose colour is already IN the
-- picture, so the only zone it computes for itself is the whole-screen
-- present palette CLASSIC needs. That is the same case Gen 1 covers with
-- PaletteFX.ensureZones, where a forced mono/CLASSIC mode over a raw DMG
-- canvas gets exactly one whole-screen zone and nothing else -- same rect
-- shape (x/y/w/h in 160x144 screen space, `colors` four 0-255 triples,
-- `colors == false` the opt-out), same identity default -- so a weather or
-- lighting mod written against Gen 1 tints Gold through the same seam.
local zones = nil
local classic = GbcPalette.available() and GbcPalette.presentColors() or nil
if classic then
zones = { { x = 0, y = 0, w = 160, h = 144, colors = classic } }
end
if ModRuntime.wantsHook("render.zones") then
zones = ModRuntime.call("render.zones", sameZones, self, zones)
end
local zoned = type(zones) == "table" and zones[1] ~= nil
-- A present canvas is paid for only when something reads it: the zone pass,
-- SHADER FX, a mod post-process, render.compose, or an enabled render.output
-- subscriber. With none of them the frame draws straight to the screen
-- exactly as it always did.
local composing = ModRuntime.wantsHook("render.compose")
local hasOutputHook = ModRuntime.wantsHook("render.output")
and ModRuntime.call("render.output_enabled", function() return false end) == true
local scene = nil
if zoned or shaderfx or composing or Pipelines.wantsPresent() or hasOutputHook then
scene = self:presentCanvas(1, w, h)
end
if not scene then
self:drawContained(w, h)
self:drawHud(w, h)
return
end
local previous = G.getCanvas()
-- A canvas does not reset the transform, so this needs its own origin.
G.push()
G.origin()
G.setCanvas(scene)
G.clear(0, 0, 0, 1)
-- A zoomed live overworld gets shaded at its own scale, so the stack that
-- sits over it is drawn onto a transparent layer and shaded at FIT instead.
self.fxUiLayer = nil
self.fxUiDrawn = false
if shaderfx and self:fxSplitsUi(w, h) then
self.fxUiLayer = self:presentCanvas(3, w, h)
end
self:drawContained(w, h)
G.setCanvas(previous)
local uiLayer = self.fxUiDrawn and self.fxUiLayer or nil
self.fxUiLayer = nil
if composing and self:compose(scene, zones, w, h) then
-- the mod owns the window this frame; the HUD still draws over it, as it
-- does over Gen 1's composed frame
G.pop()
G.setColor(1, 1, 1, 1)
self:drawHud(w, h)
return
end
-- The zone pass has to land in a texture whenever anything still reads one
-- after it: SHADER FX and a post-process both sample the tinted image, not
-- the untinted one. On its own the tint rides the final blit and no second
-- canvas is paid for.
local source = scene
local reread = shaderfx or Pipelines.wantsPresent() or hasOutputHook
if zoned and reread then
local tinted = self:presentCanvas(2, w, h)
if tinted then
G.setCanvas(tinted)
G.clear(0, 0, 0, 1)
self:blitZones(scene, zones, w, h)
if uiLayer then
local tintedUi = self:presentCanvas(4, w, h)
if tintedUi then
G.setCanvas(tintedUi)
G.clear(0, 0, 0, 0)
self:blitZones(uiLayer, zones, w, h)
uiLayer = tintedUi
end
end
G.setCanvas(previous)
source = tinted
end
-- no second canvas: drop the tint rather than the frame
elseif zoned then
self:blitZones(scene, zones, w, h)
source = nil -- already on the screen
end
if source then
-- Post-process pipelines run over the finished composite and before GBC
-- FX. Each hands back a canvas; with none registered this returns `source`
-- unchanged and the frame is byte-identical (Renderer.lua:1058).
local scale, ox, oy, dpi, pw, ph = self:frameFit(w, h)
source = Pipelines.present(source, { width = w, height = h, scale = scale,
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
local outputHandled = hasOutputHook
and ModRuntime.call("render.output", function() return false end, {
canvas = source, width = w, height = h,
gameX = ox, gameY = oy,
gameWidth = 160 * scale, gameHeight = 144 * scale,
scale = scale, dpiX = dpi, dpiY = dpi,
generation = 2,
}) == true
if not outputHandled then
local cx, cy, cw, ch = Playfield.cutout(w, h)
if cx then G.setScissor(cx, cy, cw, ch) end
if shaderfx then
-- Whole window, matching Renderer.lua: the world at its zoom scale
-- when the UI was split off, otherwise everything at FIT.
local s = scale * dpi
local ws = uiLayer and self.world:zoomScale() * dpi or s
local wox, woy = ox * dpi, oy * dpi
if self.frameWorldActive then
local wx, wy = self:fxWorldOrigin(w, h, ws / dpi)
if wx then wox, woy = wx * dpi, wy * dpi end
end
ShaderFX.render(source, { x = 0, y = 0, w = pw, h = ph, scale = ws },
{ w = pw / ws, h = ph / ws }, dpi, dpi, { originX = wox, originY = woy })
if uiLayer then
ShaderFX.render(uiLayer, { x = 0, y = 0, w = pw, h = ph, scale = s },
{ w = pw / s, h = ph / s }, dpi, dpi,
{ layer = "ui", mask = true, originX = ox * dpi, originY = oy * dpi })
end
else
G.setColor(1, 1, 1, 1)
G.draw(source, 0, 0)
G.setShader()
end
if cx then G.setScissor() end
elseif uiLayer then
G.setColor(1, 1, 1, 1)
G.draw(uiLayer, 0, 0)
end
end
G.pop()
G.setColor(1, 1, 1, 1)
self:drawHud(w, h)
end
function Game2:draw()
GameViewport.begin(2)
GameViewport.setTarget()
self:drawViewportFrame()
GameViewport.finish(self)
-- OS-window chrome: draw after companion composition so viewport layouts
-- neither shrink nor cover the touch pad.
TouchControls:draw()
end
-- The paper a pushed TextBox has to sit on. A textbox is built entirely from
-- font-page tiles ($79-$7e frame, ' ' $7f interior), so it takes BG palette 0
-- colour 0 from the screen UNDER it (pokegold engine/pokegear/pokegear.asm
-- TownMapPals: the attribute map covers $00-$5f and everything >= $60 uses
-- palette 0). White on every screen whose colour 0 is white, which is all of
-- them but the Pokegear, whose paper is RGB 28,31,20. Nil means white, which
-- is what Font.drawBox does by default.
function Game2:textboxPaper()
local base = visibleBaseState(self.stack)
if base and base.paperColor then return base:paperColor() end
return nil
end
function Game2:drawContained(w, h)
local pw, ph = Playfield.push(w, h)
local ok, err = pcall(self.drawScene, self, pw, ph)
Playfield.pop()
if not ok then error(err, 0) end
end
local function panelBlit(stack, w, h)
local states = stack and stack.states or {}
for i = #states, 1, -1 do
local state = states[i]
if state then
if state.battlePanelScale then
local scale = state:battlePanelScale(w, h)
if scale then return scale, Chrome.fitOrigin(w, h, scale) end
end
if state.drawsWidescreen and state:drawsWidescreen() then break end
end
end
local scale = Chrome.fitScale(w, h)
local ox, oy = Chrome.fitOrigin(w, h, scale)
return scale, ox, oy
end
local function battleSurround(stack)
local states = stack and stack.states or {}
for i = #states, 1, -1 do
local state = states[i]
if state and state.bgMode then
return state:bgMode(), state.BG_WORLD_DIM or 0.55, state, i
end
end
end
function Game2:paintBattleSurround(w, h)
local mode, dim, owner, at = battleSurround(self.stack)
if mode ~= "black" and mode ~= "world" then return end
local alpha = mode == "world" and dim or 1
if not alpha or alpha <= 0 then return end
local G = love.graphics
local scale, ox, oy = panelBlit(self.stack, w, h)
local stack = self.stack
if at and stack and stack.visibleBase then
local base = stack:visibleBase()
if base and base > at then owner = stack.states[base] end
end
local sw, sh = 160, 144
if owner and owner.panelSize then sw, sh = owner:panelSize() end
if sw ~= 160 then
if owner.battlePanelScale then
scale = owner:battlePanelScale(w, h) or scale
end
ox, oy = Chrome.fitOriginFor(w, h, scale, sw / 8, sh / 8)
end
local pw, ph = sw * scale, sh * scale
if owner and owner.extendedHUD and owner:extendedHUD()
and stack and stack.top and stack:top() == owner then
if mode == "world" then return end
G.setColor(0, 0, 0, 1)
if ox > 0 then G.rectangle("fill", 0, 0, ox, h) end
if ox + pw < w then G.rectangle("fill", ox + pw, 0, w - ox - pw, h) end
G.setColor(1, 1, 1, 1)
return
end
G.setColor(0, 0, 0, alpha)
if oy > 0 then G.rectangle("fill", 0, 0, w, oy) end
if oy + ph < h then G.rectangle("fill", 0, oy + ph, w, h - oy - ph) end
if ox > 0 then G.rectangle("fill", 0, oy, ox, ph) end
if ox + pw < w then G.rectangle("fill", ox + pw, oy, w - ox - pw, ph) end
G.setColor(1, 1, 1, 1)
end
-- Mirrored menus stay on the input stack. They must not trigger another
-- panel pass over a completed widescreen scene when none is visible.
local function hasVisibleOverlay(stack, base)
for i = #stack.states, 1, -1 do
local screen = stack.states[i]
if screen == base then return false end
if stack:renderVisible(screen) then return true end
end
return false
end
function Game2:drawScene(w, h)
local G = love.graphics
-- render.compose reads this after the scene is drawn; the plain overworld
-- branch below is the only one where Gen 1 would call the world pass live.
self.frameWorldActive = false
Chrome.worldSurround = false
if self:inFillBoot() then
local top = self.stack:top()
local base = visibleBaseState(self.stack)
-- Title (and friends) paint sky/clouds edge-to-edge; Oak speech and
-- name pick paint a paper-white surround via drawWidescreen.
local wide = (self.stack:renderVisible(top)
and top.drawsWidescreen and top:drawsWidescreen()
and top.drawWidescreen) and top
or (base and base.drawsWidescreen and base:drawsWidescreen()
and base.drawWidescreen and base)
if wide then
-- Widescreen layer paints the surround; GB canvas stacks on top so
-- TextBox can overlay Oak's pic without wiping the white field.
wide:drawWidescreen(w, h)
self:letterbox(w, h, false)
if wide ~= top or #self.stack.states > self.stack:visibleBase() then
-- Same integer blit the widescreen layer under it used, or the GB
-- canvas would land on a different grid than the panel it overlays.
local scale = Chrome.fitScale(w, h)
local ox, oy = Chrome.fitOrigin(w, h, scale)
G.push()
G.translate(ox, oy)
G.scale(scale, scale)
self.stack:draw()
G.pop()
end
else
-- Nested NamingScreen etc.: paper void instead of black pillarboxes.
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, w, h)
self:letterbox(w, h, false)
local scale = Chrome.fitScale(w, h)
local ox, oy = Chrome.fitOrigin(w, h, scale)
G.push()
G.translate(ox, oy)
G.scale(scale, scale)
self.stack:draw()
G.pop()
end
return
end
if self.world and self.world.map then
-- A screen that paints its own surround (the battle) covers the window
-- edge to edge instead of sitting in a letterbox over the overworld --
-- the battle background IS white on the cart, so a white field is what
-- "full screen" means here.
-- The widescreen layer is whichever of the stack's TOP or its visible BASE
-- paints one, the same resolution inFillBoot already makes above. Testing
-- only the top loses the surround the moment anything is pushed over such a
-- screen: .DoPhoneCall (engine/phone/phone.asm) runs the caller's script
-- with the POKEGEAR still owning the screen, and the same happens to the
-- PARTY, PACK, #DEX, PC, DAY-CARE, MAILBOX and TRADE screens whenever a
-- TextBox goes up over them.
local top = self.stack:top()
local base = visibleBaseState(self.stack)
local wide = (self.stack:renderVisible(top)
and top.drawsWidescreen and top:drawsWidescreen()
and top.drawWidescreen) and top
or (base and base.drawsWidescreen and base:drawsWidescreen()
and base.drawWidescreen and base)
if wide then
if battleSurround(self.stack) == "world" then
self.frameWorldActive = true
self:letterbox(w, h, true)
self.world:draw()
Chrome.worldSurround = true
end
wide:drawWidescreen(w, h)
self:paintBattleSurround(w, h)
Chrome.worldSurround = false
self:letterbox(w, h, false)
if wide ~= top and hasVisibleOverlay(self.stack, wide) then
local scale, ox, oy = panelBlit(self.stack, w, h)
G.push()
G.translate(ox, oy)
G.scale(scale, scale)
self.stack:draw()
G.pop()
end
return
end
-- CLEARTILEMAP SAFETY NET. Every full-screen Gold page wipes the tilemap
-- on its way in -- ClearBGPalettes / ClearTilemap at engine/games/
-- unown_puzzle.asm:11, engine/events/diploma.asm:13, engine/events/
-- magnet_train.asm:101 (ClearBGPalettes / ClearSprites / DisableLCD),
-- engine/printer/print_party.asm:134 and engine/events/print_unown.asm:17
-- -- so not one map tile can survive underneath one. A screen that
-- declares itself OPAQUE but ships no widescreen layer would otherwise
-- letterbox over a live `world:draw()` and show the overworld all round
-- its edges, which is the one thing the cart cannot do.
--
-- Gated strictly on isOpaque, because the screens that deliberately sit
-- OVER the map (StartMenu, DayCareMenu, MailboxMenu, ScriptMenu,
-- ElevatorMenu, HeldItemMenu, MoveDeleter, TradeMenu, MapRadio,
-- BankOfMom, BattleTransition) leave it false and MUST keep the world
-- behind them -- src/ui/gen2/DayCareMenu.lua draws only its box.
if base and base.isOpaque then
if base.drawWidescreen then
-- Already paints its own surround and only misses the
-- `drawsWidescreen` opt-in the resolution above tests, so use it:
-- the panel's own field colour is what belongs outside the page.
base:drawWidescreen(w, h)
else
-- No surround of its own: the paper-white void the boot path uses,
-- rather than a window full of somebody else's map.
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, w, h)
end
self:letterbox(w, h, false)
-- Same integer blit every widescreen layer uses, or the GB canvas lands
-- on a different grid than the field behind it.
local scale = Chrome.fitScale(w, h)
local ox, oy = Chrome.fitOrigin(w, h, scale)
G.push()
G.translate(ox, oy)
G.scale(scale, scale)
self.stack:draw()
G.pop()
return
end
-- The live overworld IS the background here -- it draws edge to edge at
-- World:zoomScale, with no surround to paint first -- so the border seam
-- sits ahead of it, which is where Gen 1 puts it too: Renderer:endFrame
-- raises render.letterbox before the world blit as well as before the UI
-- one, and `worldActive` in the payload is how a subscriber tells the two
-- frames apart.
self.frameWorldActive = true
self:letterbox(w, h, true)
local layer = self.fxUiLayer
local sceneCanvas
if layer then
sceneCanvas = G.getCanvas()
G.setCanvas(layer)
G.clear(0, 0, 0, 0)
G.setCanvas(sceneCanvas)
self.fxUiDrawn = true
end
self.world:draw()
if self.stack:top() then
-- ZOOM RESIZES THE MAP, NOT THE UI. The world fills the window at
-- `world:zoomScale()`; the stack canvas -- the dialogue box, the START
-- menu, every screen that sits over the overworld -- blits at the plain
-- integer letterbox fit instead, so a zoom step moves the map under a
-- text box that stays exactly the size it is at FIT.
--
-- This is the split src/render/Renderer.lua makes for Gen 1, whose UI
-- LAYOUT defaults to CENTERED: `Renderer:uiScale` returns `fitScale()`
-- and only the world canvas follows `Zoom.scale`. Gold has no DYNAMIC
-- row to opt into the step-down half, so CENTERED is the whole rule
-- here.
local s = self.world:fitScale()
local ox, oy = Chrome.fitOrigin(w, h, s)
if layer then G.setCanvas(layer) end
G.push()
G.translate(ox, oy)
G.scale(s, s)
self.stack:draw()
G.pop()
if layer then G.setCanvas(sceneCanvas) end
end
return
end
G.clear(0.07, 0.05, 0.02, 1)
G.setColor(0.85, 0.57, 0.13, 1)
G.printf("POKEMON GOLD", 0, math.floor(h * 0.38), w, "center")
G.setColor(0.92, 0.90, 0.82, 1)
G.printf(self.status or "Failed to boot Gen 2 world.",
0, math.floor(h * 0.48), w, "center")
G.printf("Press Escape to quit.", 0, math.floor(h * 0.62), w, "center")
G.setColor(1, 1, 1, 1)
end
-- The display/speed hotkey ladder, the same keys and the same order the Gen 1
-- path binds them in (src/core/Game.lua keypressed), driving the same shared
-- modules so a player's muscle memory carries between the two games:
--
-- F1/F2 write / reload the save 1 GAME SPEED
-- - = zoom one step out / in 2 COLOR
-- 4 cycle ZOOM 3 TILT (mnemonic: 3D)
--
-- `2` is COLOR here rather than Gen 1's COLORS. The Gen 1 row cycles SGB
-- palette packs, which a CGB-native game has no use for; what it cycles here
-- is whether the cart's own colour is showing at all (GBC / DMG / CLASSIC).
-- Same key, same place in the ladder, same idea: "change how this looks".
function Game2:hotkey(key)
local options = self.options or {}
local function persist()
if self.save then self.save.options = options end
self:persistOptions()
end
if key == "f1" then
if not self:quickSaveAllowed() then return true end
self:writeSave()
return true
elseif key == "f2" then
if not self:quickSaveAllowed() then return true end
local loaded = Save.load()
if loaded then self:continueGame(loaded) end
return true
elseif key == "1" then
local GameSpeed = require("src.core.GameSpeed")
options.speed = GameSpeed.cycle(options.speed, 1)
persist()
return true
elseif key == "2" then
local GbcPalette = require("src.render.GbcPalette")
GbcPalette.setMode(options.color or "gbc")
options.color = GbcPalette.cycle(1)
options.palette = ""
persist()
return true
elseif key == "3" then
local Tilt = require("src.render.Tilt")
options.tilt = Tilt.cycle()
persist()
return true
end
if not (self.world and self.world.map) then
return self:pipelineHotkey(key, options, persist)
end
if key == "-" or key == "kp-" then
self:zoomStep(-1)
return true
elseif key == "=" or key == "kp+" then
self:zoomStep(1)
return true
elseif key == "4" then
self.world:zoomCycle()
self:storeZoom()
return true
end
return self:pipelineHotkey(key, options, persist)
end
function Game2:storeZoom()
local options = self.options or {}
self.options = options
options.zoom = require("src.render.Zoom").offset
if self.save then self.save.options = options end
self:persistOptions()
end
function Game2:zoomStep(delta)
if not (self.world and self.world.map) then return end
self.world:zoomStep(delta)
self:storeZoom()
end
-- The (top, overworld) pair src/render/Pipelines.lua's free-roam gate reads.
--
-- Gen 1 hands it (stack:top(), overworld), and the default gate (Zoom.gateOK)
-- asks "is the overworld itself the top state, and is it idle". Gold's
-- overworld is not a state at all -- an empty stack IS free roam -- so
-- reporting the world as its own top in that case is what makes a Gen 1-shaped
-- gate answer correctly here. The idle half is World:acceptsMenuInput, which
-- transcribes CheckMenuOW's three gates (engine/overworld/events.asm:802) and
-- is the same test Gold's own START/SELECT presses go through.
function Game2:pipelineGate()
local world = self.world
if not (world and world.map) then return nil, nil end
local top = self.stack:top()
if top then return top, world end
if not world:acceptsMenuInput() then return nil, world end
return world, world
end
-- Mod render pipelines claim their hotkeys LAST, so one can never shadow an
-- engine display key however a mod declares it -- the rule and the order
-- src/core/Game.lua:652 follows. syncOptions writes the whole ladder back,
-- including the tilt exclusion a world pipeline forces.
function Game2:pipelineHotkey(key, options, persist)
local Pipelines = require("src.render.Pipelines")
local top, world = self:pipelineGate()
if not Pipelines.hotkey(key, top, world) then return false end
Pipelines.syncOptions(options)
require("src.render.Tilt").setLevel(options.tilt or 0)
persist()
return true
end
-- RFC 0020: see Game:keypressed's own comment (src/core/Game.lua) for the
-- full precedent this restores -- fires before self:hotkey,
-- before Input:keypressed, before anything else in this method.
function Game2:keypressed(key)
local function vanilla()
-- Escape is NOT a quit key: src/core/Input.lua binds it to START, which is
-- how the start menu opens on a desktop keyboard. Quitting is the start
-- menu's QUIT row and the intro menu's EXIT GAME.
-- A screen that is open owns the keyboard, the same way Game hands the top
-- state first refusal -- except for the display ladder, which is a host
-- control rather than a game button. It runs during the boot cinema too:
-- the title screen and the intro menu are exactly where someone tries the
-- COLOR key, and the ladder's world-only rungs already refuse themselves
-- when there is no map.
if self:hotkey(key) then return end
Input:keypressed(key)
end
if not ModRuntime.wantsHook("input.key") then return vanilla() end
return ModRuntime.call("input.key", vanilla, self, { phase = "pressed", key = key })
end
function Game2:keyreleased(key)
local function vanilla()
Input:keyreleased(key)
end
if not ModRuntime.wantsHook("input.key") then return vanilla() end
return ModRuntime.call("input.key", vanilla, self, { phase = "released", key = key })
end
-- RFC 0020: input.wheel is a plain observer -- see Game:wheelmoved's own
-- comment (src/core/Game.lua).
function Game2:wheelmoved(_x, dy)
local function vanilla()
if self.phase == "boot" or self.stack:top() then return end
if dy > 0 then
self:zoomStep(1)
elseif dy < 0 then
self:zoomStep(-1)
end
end
if not ModRuntime.wantsHook("input.wheel") then return vanilla() end
return ModRuntime.call("input.wheel", vanilla, self, dy)
end
-- ---- the gameplay pointer seam (#807) --------------------------------------
--
-- The same hook, the same payload and the same lifecycle rules
-- src/core/Game.lua:872 documents, including the ownership rule:
-- src/core/TouchControls.lua gets FIRST REFUSAL on every touch, because a
-- pointer that begins on a virtual d-pad belongs to the pad for its whole life
-- and must never reach a mod. Capture is decided at press and rides
-- TouchControls.touches[id]; a pointer that begins outside the controls stays
-- mod-visible even if it later wanders across one.
--
-- Everything a subscriber costs -- the per-pointer records in self.modPointers,
-- the payload tables -- is behind wantsHook, so a mod-free boot allocates
-- nothing here.
-- coordinates are LOVE window units, the same space render.hud's viewport is in
function Game2:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
phase = phase, source = source, id = id, x = x, y = y,
gameX = gameX, gameY = gameY, insideGame = insideGame,
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
})
end
function Game2:touchpressed(id, x, y, dx, dy, pressure)
if TouchControls:touchpressed(id, x, y) then return end
if not ModRuntime.wantsHook("input.pointer") then return end
-- POKEPORT_TOUCH routes the mouse through here as a stand-in finger under the
-- id "mouse" (see main.lua); mods still see its true source
local source = id == "mouse" and "mouse" or "touch"
self.modPointers = self.modPointers or {}
self.modPointers[id] = { source = source, x = x, y = y, pressure = pressure }
self:pointerEvent("pressed", source, id, x, y, dx, dy, pressure)
end
function Game2:touchmoved(id, x, y, dx, dy, pressure)
-- The pad tracks only ids it captured at press, so this is a no-op for a
-- mod-visible pointer; a captured one sliding between d-pad directions swaps
-- the held GB button here.
TouchControls:touchmoved(id, x, y)
local p = self.modPointers and self.modPointers[id]
if not p then return end
-- the POKEPORT_TOUCH mouse path carries no deltas; derive them from the
-- pointer's last seen position so drags read the same either way
if dx == nil then dx, dy = x - p.x, y - p.y end
p.x, p.y = x, y
if pressure ~= nil then p.pressure = pressure end
if ModRuntime.wantsHook("input.pointer") then
self:pointerEvent("moved", p.source, id, x, y, dx, dy, pressure)
end
end
function Game2:touchreleased(id, x, y, dx, dy, pressure)
TouchControls:touchreleased(id, x, y)
local p = self.modPointers and self.modPointers[id]
if not p then return end
self.modPointers[id] = nil
if ModRuntime.wantsHook("input.pointer") then
self:pointerEvent("released", p.source, id, x, y, dx, dy, pressure)
end
end
-- A real mouse without POKEPORT_TOUCH. Gameplay itself has no mouse verbs, so
-- the pointer hook is the only consumer and everything is behind the wantsHook
-- gate. A synthesized istouch twin is dropped unconditionally: the same
-- contact already arrived through touchpressed, and forwarding both would fire
-- a mobile touch twice.
function Game2:mousepressed(x, y, button, istouch)
if istouch then return end
if not ModRuntime.wantsHook("input.pointer") then return end
self.modPointers = self.modPointers or {}
local p = self.modPointers.mouse
if p then
p.held, p.x, p.y = (p.held or 1) + 1, x, y
else
self.modPointers.mouse = { source = "mouse", x = x, y = y,
held = 1, button = button }
end
self:pointerEvent("pressed", "mouse", "mouse", x, y, 0, 0, nil, button)
end
-- hover moves are delivered too (button = nil); only pressed pointers are
-- tracked, because only they owe a released/cancelled later
function Game2:mousemoved(x, y, dx, dy, istouch)
if istouch then return end
local p = self.modPointers and self.modPointers.mouse
if p then p.x, p.y = x, y end
if not ModRuntime.wantsHook("input.pointer") then return end
self:pointerEvent("moved", "mouse", "mouse", x, y, dx, dy, nil, nil)
end
function Game2:mousereleased(x, y, button, istouch)
if istouch then return end
local p = self.modPointers and self.modPointers.mouse
if not p then return end
p.held = (p.held or 1) - 1
if p.held <= 0 then self.modPointers.mouse = nil end
if ModRuntime.wantsHook("input.pointer") then
self:pointerEvent("released", "mouse", "mouse", x, y, 0, 0, nil, button)
end
end
-- Focus/visibility loss swallows pointer releases the same way it swallows
-- key-ups (the hazard Input:reset exists for): every mod-visible pointer gets a
-- "cancelled" instead of leaving subscribers waiting on a "released" that can
-- never arrive. Cleared even when the subscriber is already gone, so no stale
-- record outlives its mod.
function Game2:cancelPointers()
local pointers = self.modPointers
if not pointers then return end
self.modPointers = nil
if not ModRuntime.wantsHook("input.pointer") then return end
for id, p in pairs(pointers) do
self:pointerEvent("cancelled", p.source, id, p.x, p.y, 0, 0,
p.pressure, p.button)
end
end
-- The three window-lifecycle callbacks main.lua forwards, with the bodies
-- src/core/Game.lua:804 gives them: drop every held button the window is about
-- to stop hearing key-ups for, reconcile back whatever is still physically
-- down, cancel live pointers, and clear the touch overlay. LÖVE has no
-- touchcancelled, so a finger the OS takes away (an app switch, a system
-- gesture) never fires touchreleased and would strand its GB button held
-- forever -- TouchControls:reset is the only thing that frees it.
function Game2:focus(f)
Input:reset()
TouchControls:reset()
if f then Input:reconcile() end
self:cancelPointers()
end
function Game2:visible(v)
if v then
self:onResume()
else
Input:reset()
TouchControls:reset()
self:cancelPointers()
end
end
function Game2:onResume()
Input:reset()
TouchControls:reset()
Input:reconcile()
self:cancelPointers()
end
-- Push the saved display options into the modules that own them. Called
-- whenever the options table changes hands (boot, CONTINUE, the OPTION
-- screen), so a reload comes back at the zoom, tilt and SHADER FX the player
-- left.
function Game2:applyOptions()
local options = self.options or {}
require("src.core.FaithfulRes").setNativeSize(160, 144)
Music.applyOptions(options)
require("src.core.Sound").applyOptions(options)
local Zoom = require("src.render.Zoom")
Zoom.applyOptions(options)
local caps = require("src.core.Performance").applyOptions(options)
Zoom.allowSurvey = caps.survey
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
require("src.render.Tilt").applyOptions(options)
require("src.render.Letterbox").applyOptions(options)
require("src.render.GbcPalette").applyOptions(options)
-- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame.
Font.setFrame(options.frame or 1)
-- the mod pipeline ladder rides options.pipelines and restores with the rest
-- of the display block, as it does in src/core/Game.lua:1041
require("src.render.Pipelines").applyOptions(options)
-- src/core/Game.lua:1121 mirrors this call for Gen 1
Input:applyBindings(options.bindings)
TouchControls:applyOptions({
touchControls = options.touchControls,
haptics = options.haptics,
hotbar = options.hotbar,
})
require("src.core.VideoMode").applyOptions(options)
require("src.core.ScreenPosition").applyOptions(options)
require("src.core.VSync").applyOptions(options)
require("src.core.FrameCap").applyOptions(options)
require("src.core.LogicClock").applyOptions(options)
require("src.core.PresentSync").applyFixedStepPeriod()
require("src.world.gen2.BorderFill").applyOptions(options)
-- returns true when a persisted preset name no longer resolves (deleted
-- from the drop-in folder, or failed to (re)translate) and had to be
-- cleared back to OFF -- src\core\Game.lua:1215 mirrors this call for
-- Gen 1 (SHADER FX reaches Gen 2 too)
local shaderfxCleared = require("src.render.ShaderFX").applyOptions(options)
-- Scale the optional presentation extras to the device's performance
-- tier, same clamp src/core/Game.lua:1222-1230 applies for Gen 1 -- see
-- that site's comment for the full rationale.
local caps = require("src.core.Performance").applyOptions(options)
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
if not caps.shaderfx then require("src.render.ShaderFX").deactivate() end
local Zoom = require("src.render.Zoom")
Zoom.allowSurvey = caps.survey
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
if caps.fpsMax then
require("src.core.FrameCap").clampToPerformance(caps.fpsMax)
end
if shaderfxCleared and self.save then
-- applyOptions returns true when it had to clear an unresolved preset.
self.save.options = options
end
end
function Game2:_cycleSpeed(dir)
local GameSpeed = require("src.core.GameSpeed")
self.options.speed = GameSpeed.cycle(self.options.speed, dir)
if self.save then self.save.options = self.options end
self:persistOptions()
end
local function padPressedBody(self, joystick, button)
TouchControls:noteGamepad()
local selectHeld = Input:isDown("select")
if not selectHeld and joystick and joystick.isGamepadDown then
local ok, down = pcall(function()
return joystick:isGamepadDown("back")
end)
selectHeld = ok and down == true
end
local top = self.stack and self.stack:top()
if top and top.onGamepadPressed then
top:onGamepadPressed(button)
return
end
if not selectHeld then
local action = Input:padAction(button)
if action == "speedUp" then
self:_cycleSpeed(1)
return
elseif action == "speedDown" then
self:_cycleSpeed(-1)
return
end
end
if selectHeld then
local digit = GamepadMap.displayChordDigit(button)
if digit then
self:keypressed(digit)
return
end
end
Input:gamepadpressed(joystick, button)
end
local function padReleasedBody(self, joystick, button)
Input:gamepadreleased(joystick, button)
local top = self.stack and self.stack:top()
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
end
-- `back` -- SDL's name for the small left-hand menu button: Xbox VIEW, the PS
-- CREATE/SHARE beside the touchpad, the Switch MINUS -- is SELECT, and has been
-- since src/core/GamepadMap.lua's DEFAULT_GAMEPAD_BINDINGS was written
-- (`back = "select"`). It used to QUIT here, from the same era as the START
-- comment below: before there was a start menu, the menu button was the only
-- way out of a Gold boot. That left a controller with no SELECT at all -- the
-- register/use-item press (UseRegisteredItem, engine/overworld/select_menu.asm),
-- the PACK's move-item, the party menu's reorder and half the soft-reset chord
-- (A+B+SELECT+START) were all unreachable from a pad, and pressing the button
-- to find out killed the process. It reaches Input like every other button now.
-- RFC 0020: input.gamepad covers press/release/axis, see Game:gamepadpressed's
-- own comment (src/core/Game.lua) for why, and for the precedent this
-- restores.
function Game2:gamepadpressed(joystick, button)
local function vanilla()
padPressedBody(self, joystick, button)
end
if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end
return ModRuntime.call("input.gamepad", vanilla, self,
{ phase = "pressed", joystick = joystick, button = button })
end
function Game2:gamepadreleased(joystick, button)
local function vanilla()
padReleasedBody(self, joystick, button)
end
if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end
return ModRuntime.call("input.gamepad", vanilla, self,
{ phase = "released", joystick = joystick, button = button })
end
function Game2:gamepadaxis(joystick, axis, value)
local function vanilla()
-- past-deadzone only, so resting-stick drift cannot hide the overlay
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
local trigger, phase = Input:triggerAxis(axis, value)
if trigger then
if phase == "pressed" then
padPressedBody(self, joystick, trigger)
elseif phase == "released" then
padReleasedBody(self, joystick, trigger)
end
return
end
Input:gamepadaxis(joystick, axis, value)
end
if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end
return ModRuntime.call("input.gamepad", vanilla, self,
{ phase = "axis", joystick = joystick, axis = axis, value = value })
end
-- The raw joystick road, same bodies as src/core/Game.lua:935 (#620, #632, #1570).
local function isRawStick(joystick)
return not (joystick and joystick.isGamepad and joystick:isGamepad())
end
function Game2:joystickpressed(joystick, button)
if GamepadMap.isAccelerometer(joystick) then return end
TouchControls:noteGamepad()
local top = self.stack and self.stack:top()
if isRawStick(joystick) and top and top.onJoystickPressed then
top:onJoystickPressed(button)
return
end
if not Input:isDown("select") then
local action = Input:joyAction(button)
if action == "speedUp" then
self:_cycleSpeed(1)
return
elseif action == "speedDown" then
self:_cycleSpeed(-1)
return
end
end
Input:joystickpressed(joystick, button)
end
function Game2:joystickreleased(joystick, button)
if GamepadMap.isAccelerometer(joystick) then return end
Input:joystickreleased(joystick, button)
local top = self.stack and self.stack:top()
if isRawStick(joystick) and top and top.onJoystickReleased then
top:onJoystickReleased(button)
end
end
function Game2:joystickaxis(joystick, axis, value)
if GamepadMap.isAccelerometer(joystick) then return end
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
Input:joystickaxis(joystick, axis, value)
end
function Game2:joystickhat(joystick, hat, direction)
if GamepadMap.isAccelerometer(joystick) then return end
if direction ~= "c" then TouchControls:noteGamepad() end
Input:joystickhat(joystick, hat, direction)
end
-- src/core/Game.lua:1015 (#799)
function Game2:recoverInput()
Input:reset()
Input:reconcile()
TouchControls:reset()
if self.mods and self.mods.releaseModInput then self.mods:releaseModInput() end
self:cancelPointers()
end
function Game2:joystickadded()
self:recoverInput()
end
-- The overlay comes back on its own when the last pad is unplugged
-- (src/core/Game.lua:1044).
function Game2:joystickremoved()
self:recoverInput()
TouchControls:joystickremoved()
end
-- In-process return-to-launcher (Android / intent_game): drop session fields
-- so a later Game2.new() + load is not sharing a live stack or mod loader.
-- Methods live on the class table; pairs(self) only sees instance state.
-- Same rule as Gen1: only release known GPU owners -- never fan out
-- arbitrary field:release() (shared modules use :release as a handle API).
function Game2:reset()
if self.stack and self.stack.clear then
pcall(function() self.stack:clear() end)
end
if self.world and self.world.release then
pcall(function() self.world:release() end)
end
if self._canvases then
for _, canvas in pairs(self._canvases) do
if canvas and canvas.release then pcall(canvas.release, canvas) end
end
end
if self.renderer then
local release = self.renderer.releaseCanvases or self.renderer.release
if release then pcall(release, self.renderer) end
end
local keys = {}
for key, value in pairs(self) do
if type(value) ~= "function" then
keys[#keys + 1] = key
end
end
for _, key in ipairs(keys) do
self[key] = nil
end
end
return Game2