Files
gen1recomp/src/core/Game2.lua
T
1jamie df0be1cba6 Add Gold save-editor support, rearrange the launcher, and ship a patch-notes modal.
The save editor now routes Gold vs RBY through a generation adapter so boxes, items, events, and maps write the right fields. The launcher layout is shuffled a bit, and patch notes can come from the updater, a packed file, or the iOS sidecar.
2026-08-14 18:48:06 -05:00

1999 lines
86 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.isGold(), 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 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")
-- 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")
-- 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
for _, name in ipairs({
"joystickpressed", "joystickreleased", "joystickaxis", "joystickhat",
"joystickadded",
}) do
Game2[name] = noop
end
-- Not a noop, because the overlay has to come back on its own: a player who
-- unplugs the only controller would otherwise have to tap a blind screen to
-- get the pad back (src/core/Game.lua:869 does the same).
function Game2:joystickremoved()
TouchControls:joystickremoved()
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 eight
-- 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)
--
-- 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)
local chunk = love.filesystem.load(path)
if not chunk then return nil end
local ok, data = pcall(chunk)
if ok then return data end
return nil
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
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
-- 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: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
-- 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: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).
-- `meta` stays absent on a Gold save, which stamps no meta block; modsDiff
-- is derived from it and so comes back empty rather than missing.
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,
})
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,
})
end
function Game2:showIntro()
self.stack:clear()
self.phase = "boot"
Screens.push(self, "Gen2GoldSilverIntro", {
onDone = function()
self:showTitle()
end,
})
end
function Game2:showGameFreak()
self.stack:clear()
self.phase = "boot"
Screens.push(self, "Gen2GameFreakPresents", {
title = self.titleData or {},
oakSpeech = self.oakSpeechData or {},
onDone = function()
self:showIntro()
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()
Screens.push(self, "Gen2StartMenu", {
save = self.save,
onClose = function() self.stack:pop() end,
onChoose = function(id) self:openStartMenuItem(id) end,
})
end
function Game2:openStartMenuItem(id)
local function back() self.stack:pop() 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
return self:say(("%s learned\n%s!"):format(name, moveName),
function() finish(true) end)
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(("%s\ndid not learn\v%s."):format(name, moveName),
function() finish(false) end)
end
-- ForgetMove's AskForgetMoveText + YesNoBox (learn.asm:123-127).
askForget = function()
self.stack:push(TextBox.new(self,
("%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?")
:format(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,
("Stop learning\n%s?"):format(moveName), nil,
{ choice = function(yes)
if yes then return decline() end
return askForget()
end }))
end
-- The four-slot list ForgetMove draws under MoveAskForgetText, which is the
-- Blackthorn deleter's own SetUpMoveList box (learn.asm:135-146).
local function pushList()
Screens.push(self, "Gen2MoveDeleter", {
mon = mon,
moves = self.data.moves,
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("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 })
self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!")
:format(name, oldName, name, moveName),
function() finish(true) end)
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, "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(("%s can't learn %s!"):format(
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(("%s already knows %s!"):format(
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
-- 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()
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")
local action = ItemEffects.partyAction(itemId)
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
local function finish(result, mon)
if not result.used then
self:say(result.text)
return
end
if action == "stone" then
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()
end,
})
elseif action == "candy" then
self:consumeItem(itemId)
self:say(result.text, function() self:afterRareCandy(mon, result) end)
else
self:consumeItem(itemId)
self:say(result.text)
end
end
Screens.push(self, "Gen2PartyMenu", {
prompt = "useItem",
onCancel = function() self.stack:pop() end,
onChoose = function(_, mon)
if action ~= "pp" then
self.stack:pop()
finish(ItemEffects.useOnMon(itemId, mon, self.data), mon)
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
self.stack:pop()
finish(ItemEffects.usePpItem(itemId, mon), mon)
return
end
Screens.push(self, "Gen2MoveDeleter", {
mon = mon,
moves = self.data.moves,
onCancel = function() self.stack:pop() end,
onChoose = function(slot)
self.stack:pop() -- the move list
self.stack:pop() -- the party list
finish(ItemEffects.usePpItem(itemId, mon, slot), mon)
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()
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
self:say(Strings(
"There was a trophy\ninside!\fThe trophy was\nsent home."))
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)
local TextBox = require("src.render.TextBox")
self.stack:push(TextBox.new(self, text, onDone))
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()
-- The snapshot is complete, so this payload carries exactly the table the
-- file gets; mods stash runtime state into their own keys now. `meta` is
-- the Gen 1 key, absent rather than renamed: a Gold save stamps no meta
-- block yet (see save.loaded in continueGame).
if ModRuntime.wants("save.writing") then
ModRuntime.emit("save.writing", { save = save, meta = save.meta })
end
return Save.save(save)
end
function Game2:load()
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 {}
-- 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")
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")
-- 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 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)
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)
-- 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.
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 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 1 / 60)
-- 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(1 / 60)
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)
-- START and SELECT are read only at the tail of OWPlayerInput, which
-- PlayerEvents never reaches while a script is running or the player is
-- mid-step (World:acceptsMenuInput transcribes the three gates). A press
-- that arrives under one of them is dropped, not queued -- and the frame
-- still runs, so world:step must not be skipped on the swallowed press.
if self.input:wasPressed("start") and self.world:acceptsMenuInput() then
self:openStartMenu()
return
end
if self.input:wasPressed("select") and self.world:acceptsMenuInput() then
self:useSelectItem()
return
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: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)
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)
-- 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. 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 = math.max(1,
tonumber(self.speedOverride) or tonumber(self.options and self.options.speed)
or 1)
if self.phase == "boot" then
FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05)
FixedStep:update(dt * speed)
return
end
if not self.world or not self.world.map then return end
FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05)
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: GBC 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)
return math.max(1, math.floor(math.min(w / 160, h / 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
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
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 screen-space layer, in the Gen 1 order: render.hud and then the
-- on-screen pad (src/core/Game.lua:521 and :524, either side of
-- Renderer:endFrame). Both are window-space, both sit over the finished
-- frame -- post passes, letterbox and all -- and neither ever enters the game
-- canvas. Every exit path of Game2:draw ends here, which is what makes that
-- true of the composed frame a mod owns as well as of the plain one.
--
-- 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 -- or, now, the pad drawn immediately after it.
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
-- The pad LAST, so a HUD mod cannot draw over the controls the player is
-- pressing. It draws nothing at all off Android/iOS unless POKEPORT_TOUCH=1
-- forces it, and nothing ever while a controller is in use.
TouchControls:draw()
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 sx, sy = w / 160, h / 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 = (z.x or 0) * sx, (z.y or 0) * sy
local x1 = math.floor(math.max(zx, 0))
local y1 = math.floor(math.max(zy, 0))
local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, w))
local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, h))
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
-- 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 GBCFX there.
-- Gold draws straight to the screen instead, which is why its GBC FX row used
-- to change a number and nothing else: 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 GBC 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:draw()
local G = love.graphics
local w, h = G.getDimensions()
local GBCFX = require("src.render.GBCFX")
local GbcPalette = require("src.render.GbcPalette")
local Pipelines = require("src.render.Pipelines")
local fx = GBCFX.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,
-- GBC 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 fx or composing or Pipelines.wantsPresent() or hasOutputHook then
scene = self:presentCanvas(1, w, h)
end
if not scene then
self:drawScene(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)
self:drawScene(w, h)
G.setCanvas(previous)
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: GBC 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 = fx 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)
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 = 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
if fx then
GBCFX.present(source, self:pixelScale(w, h))
else
G.setColor(1, 1, 1, 1)
G.draw(source, 0, 0)
G.setShader()
end
end
end
G.pop()
G.setColor(1, 1, 1, 1)
self:drawHud(w, h)
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: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
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
wide:drawWidescreen(w, h)
self:letterbox(w, h, false)
if wide ~= top then
-- The pushed box blits at the same integer fit the widescreen layer
-- used, or it lands on a different grid than the panel underneath 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()
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)
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()
G.push()
G.translate(math.floor((w - 160 * s) / 2), math.floor((h - 144 * s) / 2))
G.scale(s, s)
self.stack:draw()
G.pop()
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)
-- 5 GBC FX
--
-- `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
self:writeSave()
return true
elseif key == "f2" then
local loaded = Save.load("gold")
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)
persist()
return true
elseif key == "3" then
local Tilt = require("src.render.Tilt")
options.tilt = Tilt.cycle()
persist()
return true
elseif key == "5" then
local GBCFX = require("src.render.GBCFX")
if GBCFX.isSupported() then
options.gbcfx = GBCFX.cycle()
persist()
end
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.world:zoomStep(-1)
options.zoom = require("src.render.Zoom").offset
persist()
return true
elseif key == "=" or key == "kp+" then
self.world:zoomStep(1)
options.zoom = require("src.render.Zoom").offset
persist()
return true
elseif key == "4" then
self.world:zoomCycle()
options.zoom = require("src.render.Zoom").offset
persist()
return true
end
return self:pipelineHotkey(key, options, persist)
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
function Game2:keypressed(key)
-- 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
function Game2:keyreleased(key)
Input:keyreleased(key)
end
function Game2:wheelmoved(_x, dy)
if self.phase == "boot" or self.stack:top() then return end
if not (self.world and self.world.map) then return end
if dy > 0 then
self.world:zoomStep(1)
elseif dy < 0 then
self.world:zoomStep(-1)
end
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)
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
phase = phase, source = source, id = id, x = x, y = y,
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 GBC FX the player left.
function Game2:applyOptions()
local options = self.options or {}
Music.applyOptions(options)
require("src.core.Sound").applyOptions(options)
require("src.render.Zoom").applyOptions(options)
require("src.render.Tilt").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,
})
local GBCFX = require("src.render.GBCFX")
if GBCFX.applyOptions(options) and self.save then
-- applyOptions returns true when it had to clear an unsupported level.
self.save.options = options
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.
function Game2:gamepadpressed(_joystick, button)
-- a controller is being used: the touch overlay steps aside until the next
-- screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad()
-- The shoulders cycle GAME SPEED, as they do in the Gen 1 path.
if button == "rightshoulder" or button == "leftshoulder" then
local GameSpeed = require("src.core.GameSpeed")
local dir = button == "rightshoulder" and 1 or -1
self.options.speed = GameSpeed.cycle(self.options.speed, dir)
if self.save then self.save.options = self.options end
self:persistOptions()
return
end
-- START opens the start menu in the overworld; it used to quit, from before
-- there was a menu to open.
Input:gamepadpressed(_joystick, button)
end
function Game2:gamepadreleased(joystick, button)
Input:gamepadreleased(joystick, button)
end
function Game2:gamepadaxis(joystick, axis, value)
-- past-deadzone only, so resting-stick drift cannot hide the overlay
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
Input:gamepadaxis(joystick, axis, value)
end
return Game2