-- 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/.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 six -- 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.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 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 = 1, 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) -- 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( mon.nickname or mon.species or "?", moveName)) return end for _, move in ipairs(mon.moves or {}) do if move.id == moveId then self:say(("%s already knows %s!"):format( mon.nickname or mon.species or "?", 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 ?"), 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 self:consumeItem(itemId) if action == "candy" then self:say(result.text, function() self:afterRareCandy(mon, result) end) else 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) -- Gold composites the WHOLE-FRAME half of a pipeline (`present`) and not the -- world half: its overworld draws straight to the window rather than into a -- canvas the way src/world/OverworldController.lua:4827 hands one to -- Pipelines.drawWorld, so there is nothing here for drawWorld to replace yet. -- A restored level for a world-only pipeline is retired rather than left -- switched on, because on Gold it would render nothing AND hold TILT off -- (Pipelines.setLevel's tilt exclusion). The stored level in -- options.pipelines is left untouched, so the mode comes back the day Gold -- grows a world canvas; Tilt is re-applied from the option the exclusion just -- cleared. local retired = false for _, entry in ipairs(Pipelines.list()) do if entry.def.drawWorld and not entry.def.present and Pipelines.level(entry.id) > 0 then Pipelines.setLevel(entry.id, 0) retired = true end end if retired then require("src.render.Tilt").applyOptions(self.options) end -- 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) if self.phase == "boot" then FixedStep.maxAccum = 0.25 FixedStep:update(dt) return end if not self.world or not self.world.map then return 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. speedOverride is the driver/CLI hook and wins -- over the saved option. local speed = math.max(1, tonumber(self.speedOverride) or tonumber(self.options and self.options.speed) or 1) 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, or a render.compose subscriber about to be -- handed the finished frame. With none of them the frame draws straight to -- the screen exactly as it always did. local composing = ModRuntime.wantsHook("render.compose") local scene = nil if zoned or fx or composing or Pipelines.wantsPresent() 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() 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, _, _, dpi = self:frameFit(w, h) source = Pipelines.present(source, { width = w, height = h, scale = scale, dpi = dpi, dpiX = dpi, dpiY = dpi }) or source 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 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 = self.stack and self.stack.states and self.stack.states[self.stack:visibleBase()] 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 = self.stack.states[self.stack:visibleBase()] -- Title (and friends) paint sky/clouds edge-to-edge; Oak speech and -- name pick paint a paper-white surround via drawWidescreen. local wide = (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 = self.stack.states[self.stack:visibleBase()] local wide = (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) -- options.touchControls (the launcher editor's per-orientation layouts) and -- options.haptics, the same two keys Gen 1 hands over here -- (src/core/Game.lua:1073). One options.lua serves both games, so the pad a -- player laid out for Red is already the pad Gold draws. TouchControls:applyOptions(options) 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