-- Central game object: owns the data, renderer, input, state stack, world -- and save state. Everything else reaches shared services through here. local Data = require("src.core.Data") local FixedStep = require("src.core.FixedStep") local Input = require("src.core.Input") local Logger = require("src.core.Logger") local Renderer = require("src.render.Renderer") local SaveData = require("src.core.SaveData") local StateStack = require("src.core.StateStack") local TouchControls = require("src.core.TouchControls") local GamepadMap = require("src.core.GamepadMap") local ModLoader = require("src.mods.Loader") local ModRuntime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local Game = {} local function renderVisible(stack, state) return state and (not stack.renderVisible or stack:renderVisible(state)) end -- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev -- module unloaded, so a player boot never touches a byte of dev code local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true -- the boot screen ids (field.boot.screens); a plain function so the -- headless harness can borrow makeTitleState onto a stub game local function bootScreens(game) local boot = game.data and game.data.field and game.data.field.boot return (boot and boot.screens) or {} end function Game:load() self.data = Data Data:load() -- Mods are a native engine subsystem. They load after the verified ROM -- data exists, so mods can register or override the same definitions that -- the rest of the game consumes. A broken mod is reported and skipped by -- the loader without preventing the base game from booting. self.mods = ModLoader.new() self.mods:load(Data) self.modStatus = self.mods:status() -- render pipelines dispatch off the merged dataset; point them at the -- one the mods just merged into before anything can draw a frame require("src.render.Pipelines").install(Data) -- Same reason, same moment: TypeChart caches the merged type records in an -- upvalue, and until now only BattleState loaded it, on entering a battle. -- Every non-battle reader of a type -- the summary screen's TYPE1/TYPE2 -- rows, the move-select TYPE/ box -- ran against an unloaded module and got -- the raw id back instead of the display name, so a translation could not -- reach them. Loading here means a type reads the same whoever asks first. require("src.battle.TypeChart").load(Data) self.input = Input Input:init() self.touchControls = TouchControls TouchControls:init() self.renderer = Renderer Renderer:init() require("src.render.Font").load(Data) -- menu cursor/border/geometry constants; field.theme restyles them require("src.ui.Theme").load(Data) -- the engine's own text, after the merge so a translation mod's catalog -- is already in Data.strings; empty on a mod-free boot and skipped require("src.core.Strings").load(Data) self.stack = StateStack StateStack:init() self.save = SaveData.newGame(self:bootConfig()) -- seed=true keeps what entry chunks wrote through mod.save before any -- save existed; the skeleton fires save.created exactly once self:adoptSave(self.save, true) ModRuntime.emit("save.created", { save = self.save }) -- apply the persisted audio + display options before anything plays self:applyOptions(self.save.options) FixedStep:init(function(step) self:step(step) end) self.fixedStep = FixedStep local OverworldState = require("src.world.OverworldController") self.overworld = OverworldState -- Discord Rich Presence: map name / battle status on the player's profile. -- Soft-fail: missing Discord / IPC errors must never block boot. pcall(function() require("src.core.DiscordPresence").init(self) end) -- every service is up but nothing is on the stack yet; this payload is -- the sanctioned way for a mod to obtain the Game object ModRuntime.emit("game.ready", { game = self }) -- boot into the title screen (engine/movie/title.asm); NEW GAME runs -- the Oak speech + naming, CONTINUE restores the save. The headless -- autopilot skips straight into the overworld. if os.getenv("POKEPORT_AUTOPILOT") then StateStack:push(OverworldState, self.save.player.map, self.save.player.x, self.save.player.y, self.save.player.facing) else local titleState = self:makeTitleState() -- the copyright splash + attract movie plays before the title -- (engine/movie/splash.asm + intro.asm; Yellow swaps in its own -- 18-scene movie, engine/movie/intro_yellow.asm); the ids come from -- field.boot.screens so a total conversion owns the whole boot local splash = require("src.core.GameVersion").isYellow() and "YellowIntro" or "IntroMovie" Screens.push(self, bootScreens(self).splash or splash, function() StateStack:push(titleState) end) end Logger.info("game loaded") -- Scaling bug reports (#87, #208) are unanswerable without these three -- numbers: LOVE units, drawable pixels, and the integer physical pixels -- per GB pixel the renderer settled on. Cheap, once, and it turns "it -- looks stretched" into something reproducible. if love.graphics and love.graphics.getDimensions then local ww, wh = love.graphics.getDimensions() local pw, ph = ww, wh if love.graphics.getPixelDimensions then pw, ph = love.graphics.getPixelDimensions() end Logger.info(string.format( "display: %dx%d units, %dx%d px, fit scale %d px/GB px", ww, wh, pw, ph, Renderer:fitScale())) end end -- the merged field.boot: spawn, names, money and the naming presets a -- total conversion overrides. Threaded into SaveData so persistence stays -- free of a Data dependency. function Game:bootConfig() local boot = self.data and self.data.field and self.data.field.boot -- stamp the running game version onto the boot config so a New Game records -- it (SaveData.newGame reads boot.version); this is what routes a Blue -- playthrough to save_blue.lua and Blue's version-gated content if boot then boot.version = require("src.core.GameVersion").get() end return boot end -- the title screen with its NEW GAME / CONTINUE wiring; used at boot -- and by the START-menu QUIT confirmation function Game:makeTitleState() local OverworldState = require("src.world.OverworldController") local factory = Screens.get(self, bootScreens(self).title or "TitleState") local title = factory.new(self, { onNewGame = function() while self.stack:top() do self.stack:pop() end -- New Game keeps the standalone options.lua preferences self.save = SaveData.newGame(self:bootConfig()) -- no bucket carry-over: mod state from an abandoned session must -- not leak into a fresh slot; mods seed via save.created instead self:adoptSave(self.save) ModRuntime.emit("save.created", { save = self.save }) self:applyOptions(self.save.options) self.stack:push(OverworldState, self.save.player.map, self.save.player.x, self.save.player.y, self.save.player.facing, { via = "boot", freshBoot = true }) Screens.push(self, bootScreens(self).newGame or "OakSpeech", function() end) end, onContinue = function() local loaded, recovered = SaveData.load() if loaded then self:restoreSave(loaded, recovered, { freshBoot = true }) end end, }) title.screenId = title.screenId or "TitleState" return title end -- QUIT from the START menu: back to the title like a power-cycle, -- unsaved progress discarded. TitleState:enter restarts the title -- theme; stop() keeps the map song from bleeding over in the meantime. function Game:returnToTitle() require("src.core.Music").stop() while self.stack:top() do self.stack:pop() end self.stack:push(self:makeTitleState()) end function Game:step(dt) -- Tool mods (autoplay, accessibility drivers, input visualizers) act on -- the same fixed-step boundary as a physical controller. Run them before -- Input:step promotes queued edges so a button chosen here is visible to -- this logic tick, not one tick later. With no wrapper this is a no-op. ModRuntime.call("input.step", function() end, self, dt) self.input:step() -- A+B+SELECT+START held for 16 steps: SoftReset (home/init.asm) stops the -- audio, whites the palettes out and falls through into Init, i.e. the -- power-on boot, so everything unsaved is gone and the title sequence -- comes back -- exactly what returnToTitle does for the START menu's QUIT. -- The check lives here, above stack:update, so it fires from any state: -- overworld, battle, a menu or a cutscene, the way _Joypad's does (#563). -- Input:reset clears the four still-physically-held buttons so the title -- screen does not read A as a menu choice on its first frame. -- a tool mod (or a harness) may hand Game a stand-in input with no -- chord bookkeeping; those simply never soft reset if self.input.softResetStep and self.input:softResetStep() then Input:reset() TouchControls:reset() self:returnToTitle() return end -- serviced unconditionally: a link battle's ENet transport must not -- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily -- on top of BattleState (see LinkBattle.new) if self.linkNet and not self.linkNet.closed then self.linkNet:update() end self.stack:update(dt) -- play time for the trainer card / save screen self.save.playTime = (self.save.playTime or 0) + dt -- Music.update is NOT serviced here: it decrements fade counters and -- drives ChipAudio once per call, so running it inside the logic step -- would pitch music and sfx up under fast-forward. Game:update advances -- it on its own real-time 60Hz accumulator instead. end -- The per-category (RFC 0007) save.options multiplier for whichever of -- "battle"/"overworld"/"menu" Game.speedCategoryInStack says is active -- right now. This is the "vanilla" the core.logic_speed hook wraps below -- -- Game:logicSpeed calls it AFTER the link and speedOverride checks, so -- neither a mod nor the category resolution ever has a seam to defeat them. function Game:_resolveLogicSpeed() local GameSpeed = require("src.core.GameSpeed") local category = Game.speedCategoryInStack(self.stack) local key = GameSpeed.optionKey(category) local opts = self.save and self.save.options return GameSpeed.clamp(opts and opts[key] or GameSpeed.DEFAULT) end -- The logic multiplier for this frame. Read live rather than cached so the -- Options rows take effect immediately; speedOverride is the --speed / -- POKEPORT_SPEED run argument, which wins over the saved option so a bot -- or screenshot run does not depend on whatever the player last chose. function Game:logicSpeed() local GameSpeed = require("src.core.GameSpeed") -- Link play is always 1X on both machines, and this wins over every other -- source including POKEPORT_SPEED and every per-category option. -- Fast-forward multiplies the logic clock, so a peer at 10X burned a -- tournament shot clock ten times faster than the opponent it is racing, -- and drove its own animation/message queue at a different rate than the -- peer it is locked to. Nothing about a match should depend on what -- either player set this to -- checked here, before the core.logic_speed -- hook ever runs, so a mod cannot defeat it either. if self.linkSession or (self.linkNet and not self.linkNet.closed) then return 1 end if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end -- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's -- core.logic_speed hook can return anything (0, negative, nil, NaN) and -- Hooks:call only guards against a hook that throws, not one that -- returns a bad value, so an unclamped result would flow straight into -- the FixedStep accumulator math below and freeze or destabilize logic. return GameSpeed.clamp(ModRuntime.call("core.logic_speed", function(g) return g:_resolveLogicSpeed() end, self)) end function Game:update(dt) -- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua). -- Give the accumulator room for one full frame at the current speed, -- or the anti-spiral clamp quietly caps every level above ~15X. local speed = self:logicSpeed() FixedStep.maxAccum = math.max(0.25, speed * FixedStep.STEP * 1.5) FixedStep:update(dt * speed) -- Audio runs off real time at a fixed 60Hz regardless of game speed or -- display refresh, so fades and chip synthesis keep their intended tempo -- whether we are at 1X, 10X, or running with vsync disabled. 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 require("src.core.Music").update(Data) end -- Overworld tilt toggle tween: presentational, so it runs on the real -- frame dt (not the fixed logic step) for a smooth ~0.25s glide. require("src.render.Tilt").update(dt) -- mod render pipelines tween on the same real-frame clock, for the same -- reason: they are presentational, so fast-forward must not speed them up require("src.render.Pipelines").update(dt) pcall(function() require("src.core.DiscordPresence").update(dt) end) -- Steady-state memory backstop: advance the incremental collector one -- small step every rendered frame. The heavy GPU objects are now freed -- explicitly (map eviction, battle exit, canvas/renderer swaps), so this -- only has to keep ordinary Lua-heap garbage (per-frame tables/closures) -- from drifting upward over a long session, and to spread collection out -- so the default lazy schedule never batches it into a visible pause. if collectgarbage then collectgarbage("step", 1) end end -- render.zones' identity default: unhooked, the zone list reaches the blit -- exactly as the owning state computed it local function sameZones(_, zones) return zones end -- Dim alpha for a BATTLE BG "world" battle anywhere in the stack, or nil. -- Same whole-stack rule as fillScaleInStack: a party menu or text box opened -- during the battle must not drop the dim for a frame. function Game.worldBgBattleDim(stack) for i = #(stack and stack.states or {}), 1, -1 do local state = stack.states[i] if state and state.bgMode and state:bgMode() == "world" then return state.BG_WORLD_DIM or 0.55 end end return nil end -- Is a BATTLE BG "world" battle composing itself over the live map right now? -- Same whole-stack walk as worldBgBattleDim, asked for a different reason: the -- dark-cave shade shift (wMapPalOffset) must not reach a frame a battle is -- drawing in. InitBattleCommon (engine/battle/core.asm) pushes wMapPalOffset, -- InitBattleVariables (engine/battle/init_battle_variables.asm) writes 0 over -- it and core.asm pops it back when the battle ends, so a battle in an -- un-flashed Rock Tunnel is lit on hardware. Every other BATTLE BG gets that -- for free -- no map draws beneath an opaque battle, so nothing re-arms the -- per-frame shade map -- but "world" keeps the overworld drawing underneath, -- and its arming then darkened the battle's own pics, HUD and text at colorize -- time (#773). function Game.worldBgBattleInStack(stack) return Game.worldBgBattleDim(stack) ~= nil end -- Does anything on the stack want the surface scaled to FILL the window -- (aspect preserved, bars on the long axis) rather than sit at the fixed -- integer scale? -- -- Asked of the WHOLE stack, not just the top. For BATTLE SIZE "fill" that is -- because the party menu, bag and text boxes a battle opens must not snap the -- surface back to the fixed scale for a frame; the title screen and intro want -- it unconditionally, since neither has a world behind it and neither has any -- reason to sit in a small box in the middle of a large window. function Game.fillScaleInStack(stack) for i = #(stack and stack.states or {}), 1, -1 do local state = stack.states[i] if state and state.wantsFillScale and state:wantsFillScale() then return true end end return false end -- A wide battle owns the surface until it leaves the stack. The party, -- bag, choice and text states it opens still draw their original 160px UI, -- but the canvas must not snap to 160px between those states. function Game.wideBattleInStack(stack) for i = #(stack and stack.states or {}), 1, -1 do local state = stack.states[i] if state and state.isWideBattleLayout and state:isWideBattleLayout() then return state end end return nil end -- Which of "battle"/"overworld"/"menu" per-category GAME SPEED (RFC 0007) -- applies right now. Whole-stack, the same idiom as fillScaleInStack/ -- wideBattleInStack above: an overlay with neither marker (PartyMenu, -- ChoiceBox, a NamingScreen, a text box) is transparent to the walk and -- inherits whatever is under it, making the category a property of the -- STACK POSITION the overlay sits over, not of the overlay itself. A -- scripted sequence (script.started/ended) never pushes a state of its -- own either -- it runs through the owning overworld/battle state's own -- script runner or message queue -- so it inherits the same way. Nothing -- identifying as either (the title screen, credits, an intro cutscene -- with nothing under it) falls to "menu", the bucket every non-gameplay -- screen gets; see the RFC's Decisions section for the full reasoning. function Game.speedCategoryInStack(stack) local states = stack and stack.states for i = #(states or {}), 1, -1 do local state = states[i] if state and state.isBattle then return "battle" end if state and state.isOverworld then return "overworld" end end return "menu" end -- Whether a state on the stack composes its own screen and so wants the -- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like -- everything else here: the text box and YES/NO a battle puts up are states -- of their own sitting above it, and they are exactly the elements that must -- stay inside the battle's composition rather than dock to the window. -- UI LAYOUT: is edge docking switched on? Only the explicit "dynamic" turns -- it on, so a save written before the option existed -- and any caller with no -- save at all, which is most of the headless suites -- gets CENTERED, the -- behaviour the port shipped with. function Game.dynamicUI(save) local options = save and save.options return options ~= nil and options.uiLayout == "dynamic" end function Game.uiAnchorsHeldInStack(stack) for i = #(stack and stack.states or {}), 1, -1 do local state = stack.states[i] if state and state.holdsUIAnchors then return true end end return false end -- Where Game:draw starts drawing this frame. Normally the topmost opaque -- state (StateStack:visibleBase) -- but BATTLE BG "world" composes the battle -- over the LIVE map, and an opaque state pushed on top of it (the party menu, -- the bag) becomes that base, cutting the overworld -- and with it the world -- pass -- out of the frame entirely. The backdrop the battle established -- then collapses to endFrame's flat black clear for as long as the menu is -- up. So a world-bg battle keeps the frame starting from underneath itself -- until it leaves the stack, the same hold uiFill and the dim already use. -- -- Only the START of the draw moves. The clear stays keyed to the real -- visibleBase, so the menu still gets its opaque canvas and draws exactly as -- before; what changes is the window AROUND its letterbox, which keeps -- showing the map instead of going black. Both menus fill their own -- 160x144 field first, so nothing beneath them shows through it. function Game.drawBaseInStack(stack, visibleBase) local states = stack and stack.states or {} for i = visibleBase - 1, 1, -1 do local state = states[i] if state and state.bgMode and state:bgMode() == "world" then -- restart the search from under the battle: the highest opaque state at -- or below it (the overworld), not the menu sitting over it for j = i, 1, -1 do if states[j].isOpaque then return j end end return 1 end end return visibleBase end -- Shift classic SGB zones to the centred UI. A full-width base zone extends -- into both margins, keeping the canvas' paper color continuous; narrower -- sprite and status zones move with the classic UI content. local function centerClassicZones(zones, offset) if not zones or offset == 0 then return zones end local shifted = {} for i, zone in ipairs(zones) do local copy = {} for key, value in pairs(zone) do copy[key] = value end if copy.x == 0 and copy.w == Renderer.WIDTH then copy.w = copy.w + offset * 2 else copy.x = (copy.x or 0) + offset end shifted[i] = copy end return shifted end function Game:draw() -- the UI canvas clears transparent when the overworld's world pass -- shows through beneath it; opaque full-screen states get the classic -- white clear local base = self.stack:visibleBase() local worldBelow = self.stack.states[base] == self.overworld -- a world-bg battle keeps the map drawing under whatever it opened, so the -- world pass can run for a frame whose CLEAR is still an opaque menu's local drawFrom = Game.drawBaseInStack(self.stack, base) local worldDrawn = self.stack.states[drawFrom] == self.overworld -- A wide battle holds its 304px surface through every menu or prompt it -- opens. States that do not draw the wide battle composition are centred -- in that surface below, so their classic coordinates and hit testing stay -- unchanged. Outside a battle, including the title screen, the option is -- intentionally inactive because it is a battle-layout setting. local top = self.stack:top() local wideBattle = Game.wideBattleInStack(self.stack) local classicOffset = 0 if wideBattle and wideBattle.uiSize then Renderer:setUISize(wideBattle:uiSize()) classicOffset = math.floor((select(1, Renderer:uiSize()) - Renderer.WIDTH) / 2) elseif top and top.uiSize then Renderer:setUISize(top:uiSize()) else Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT) end -- BATTLE SIZE: scale the battle surface to the window instead of the -- classic integer letterbox. Read from the whole stack, not just the top, -- so a party menu or text box opened mid-battle keeps the same surface. Renderer.uiFill = Game.fillScaleInStack(self.stack) -- BATTLE BG "world": dim the overworld the battle is drawn over. Read off -- the stack for the same reason as uiFill above -- a prompt opened during -- the battle must not drop the dim for a frame. Renderer.battleDim = Game.worldBgBattleDim(self.stack) -- ...and for the same reason the UI's own scale has to know the world is -- still the backdrop while an opaque menu covers it. Renderer:uiScale -- steps the UI down with the survey zoom only while a world is behind it, -- gated on this frame's world pass -- which the party menu and the bag end -- by being opaque. Without this hold they lose the step-down and blit at -- full fit scale over a battle drawn at the zoomed-out one. Renderer.uiWorldHold = Renderer.battleDim ~= nil -- ...and a battle keeps its dialogue box and YES/NO inside its own screen -- instead of letting them dock to the window edge. -- UI LAYOUT: CENTERED (the default) is a fixed letterbox -- every element -- stays inside the 160x144 canvas and the UI does not follow the survey -- zoom, so the screen furniture never moves or resizes under the player. -- That is the composition the port shipped with. DYNAMIC opts into both -- halves: the dialogue box docks to the window's bottom edge, the START -- menu to its top right, and the whole UI steps down with the zoom. Renderer.uiCentered = not Game.dynamicUI(self.save) Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack) Renderer:beginFrame(worldBelow) for i = drawFrom, #self.stack.states do local state = self.stack.states[i] local wideState = state and state.isWideBattleLayout and state:isWideBattleLayout() if renderVisible(self.stack, state) and state.draw then if classicOffset ~= 0 and not wideState then love.graphics.push() love.graphics.translate(classicOffset, 0) -- a classic state reports its trueColor rects in its own 160x144 -- coordinates, so they take the same shift its pixels just got -- -- centerClassicZones already does exactly this to its zone list, -- and without the pair the unshaded re-blit misses the pic (#637) local P = require("src.render.PaletteFX") P.setMarkOffset(classicOffset) state:draw() P.setMarkOffset(0) love.graphics.pop() else state:draw() end end end -- SGB colorization: the topmost state that knows its palette owns the -- screen (overlays like text boxes inherit from what's beneath them); -- the overworld's world pass colors each visible map area separately local zones, worldZones, zoneOwner for i = #self.stack.states, 1, -1 do local s = self.stack.states[i] if renderVisible(self.stack, s) and s.sgbPalettes then zones = s:sgbPalettes(self) zoneOwner = s break end end if classicOffset ~= 0 and zoneOwner and not (zoneOwner.isWideBattleLayout and zoneOwner:isWideBattleLayout()) then zones = centerClassicZones(zones, classicOffset) end -- 14's render.zones: weather/lighting overlays and custom colorization -- recolor or add zones before the blit if ModRuntime.wantsHook("render.zones") then zones = ModRuntime.call("render.zones", sameZones, self, zones) end -- Keyed to whether the map actually DREW, not to whether it is the clear's -- base: an opaque menu over a world-bg battle still renders the world pass -- (drawBaseInStack), and leaving worldZones nil there drops endFrame's -- world blit onto the UI zone list instead -- the party menu's own HP-bar -- palettes, in 160x144 space, smeared across a world-canvas-sized image. -- That is the offset, red-for-green map behind the menu. if worldDrawn and self.overworld.sgbWorldZones then worldZones = self.overworld:sgbWorldZones() end local viewport = Renderer:endFrame(zones, worldZones) -- Persistent tool status is screen-space UI: draw it over the completed -- render pipeline with exact playfield/margin geometry, but below mobile -- controls. It never becomes an updating game state. if ModRuntime.wantsHook("render.hud") then ModRuntime.call("render.hud", function() end, self, viewport) end -- on-screen mobile controls: pure screen-space, over the finished frame TouchControls:draw() end -- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out function Game:zoomStep(delta) local Zoom = require("src.render.Zoom") if not Zoom.gateOK(self.stack:top(), self.overworld) then return end local offset = Zoom.step(delta, Renderer:fitScale()) if self.save and self.save.options then self.save.options.zoom = offset self:writeOptions() end end function Game:wheelmoved(_, dy) if dy > 0 then self:zoomStep(1) elseif dy < 0 then self:zoomStep(-1) end end function Game:_cycleSpeed(dir) if not (self.save and self.save.options) then return end local busy local ow = self.overworld if ow then local top = self.stack:top() busy = ow.transitioning or (top == ow and ( (ow.runner and ow.runner.isRunning and ow.runner:isRunning()) or (ow.scriptMoves and #ow.scriptMoves > 0) or ow.engaging or ow.emote)) end if busy then return end -- Cycles whichever category Game.speedCategoryInStack says is active -- right now (RFC 0007) -- pressing the hotkey during a battle speeds up -- just the battle, on the overworld just the walk, in a menu just the -- menu. A single physical control that means "speed up whatever I'm -- looking at right now" needs no new UI and matches what a player -- pressing it mid-battle almost certainly wants. local GameSpeed = require("src.core.GameSpeed") local key = GameSpeed.optionKey(Game.speedCategoryInStack(self.stack)) self.save.options[key] = GameSpeed.cycle(self.save.options[key], dir) self:writeOptions() end function Game:keypressed(key) if self.stack and self.stack:top() and self.stack:top().onKeyPressed then self.stack:top():onKeyPressed(key) return end if devMode and key == "f5" then require("src.dev.HotReload").run(self) return end if devMode and key == "`" then self.stack:push(require("src.dev.Console").new(self)) return end if key == "f10" then -- toggle: the manager no longer swallows the keyboard, so a second -- press reaches this branch and closes it instead of stacking another local top = self.stack:top() if top and top.screenId == "ManagerState" then self.stack:pop() else Screens.push(self, "ManagerState") end return end if key == "f1" then self:writeSave() return elseif key == "f2" then local loaded, recovered = SaveData.load() if loaded then -- F2 jumps straight to the loaded save's map/position, with no -- walking transition -- a hard state teleport like Continue, not a -- smooth warp -- whether pressed at the title screen or mid-session. self:restoreSave(loaded, recovered, { freshBoot = true }) end return elseif key == "-" then self:zoomStep(-1) return elseif key == "=" then self:zoomStep(1) return elseif key == "1" then -- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected); -- shoulders/triggers on gamepad do the same (see gamepadpressed) self:_cycleSpeed(1) return elseif key == "2" then -- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change -- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so -- hold it while a warp/transition or an on-screen scripted cutscene is -- driving the overworld rather than tear the escort's NPCs out mid-move local ow = self.overworld local top = self.stack:top() local busy = ow and (ow.transitioning or (top == ow and ( (ow.runner and ow.runner.isRunning and ow.runner:isRunning()) or (ow.scriptMoves and #ow.scriptMoves > 0) or ow.engaging or ow.emote))) if not busy then local PaletteFX = require("src.render.PaletteFX") self.save.options.colors = PaletteFX.cycleMode() self:writeOptions() end return elseif key == "3" then -- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only local Tilt = require("src.render.Tilt") if Tilt.gateOK(self.stack:top(), self.overworld) then self.save.options.tilt = Tilt.cycle() self:writeOptions() end return elseif key == "4" then -- cycle ZOOM through every integer level (survey → FIT → close-up → wrap) local Zoom = require("src.render.Zoom") if Zoom.gateOK(self.stack:top(), self.overworld) then self.save.options.zoom = Zoom.cycle(Renderer:fitScale()) self:writeOptions() end return elseif key == "5" then -- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on -- desktop. Mobile refuses the present shader (issue #136). local GBCFX = require("src.render.GBCFX") if not GBCFX.isSupported() then return end self.save.options.gbcfx = GBCFX.cycle() self:writeOptions() return end -- Mod render pipelines claim their hotkeys last, so one can never shadow -- an engine display key however a mod declares it (12 §rendering -- pipelines). syncOptions writes the whole ladder back, including the -- tilt exclusion a world pipeline forces. local Pipelines = require("src.render.Pipelines") if Pipelines.hotkey(key, self.stack:top(), self.overworld) then Pipelines.syncOptions(self.save.options) require("src.render.Tilt").setLevel(self.save.options.tilt or 0) self:writeOptions() return end Input:keypressed(key) end -- Mod enablement is stored with persistent options. Restarting the actual -- LÖVE process ensures scripts, registries, and assets are all rebuilt from -- the newly selected mod state. function Game:restartWithMods() require("src.core.HostShell").restart() end -- Releases reach Input even while a top state captures raw input: a -- swallowed key-up would strand a held-state flag for a key Input saw go -- down before the capture armed (the stuck-flag hazard Input:reset -- exists for). The top state only OBSERVES the release afterwards, -- unlike onKeyPressed above which owns the press, so BindingsMenu can -- commit a capture on the key-up (#589). function Game:keyreleased(key) Input:keyreleased(key) local top = self.stack and self.stack:top() if top and top.onKeyReleased then top:onKeyReleased(key) end end function Game: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() -- Select held? Needed both to suppress shoulder speed hotkeys (Select+L -- is a display chord on NX) and for the chord path below. local selectHeld = Input:isDown("select") if not selectHeld and joystick and joystick.isGamepadDown then local ok, down = pcall(function() return joystick:isGamepadDown("back") end) selectHeld = ok and down == true end -- shoulder buttons and analog triggers cycle GAME SPEED (R1/RB or -- R2/RT = faster, L1/LB or L2/LT = slower; same as keyboard hotkey -- 1). LÖVE reports an analog trigger as gamepadpressed once it -- crosses the press threshold, so a trigger pull lands here like any -- other pad button. Skip while Select is held so Select+L can reach -- displayChordDigit ("7"). if not selectHeld then if button == "rightshoulder" or button == "righttrigger" then self:_cycleSpeed(1) return elseif button == "leftshoulder" or button == "lefttrigger" then self:_cycleSpeed(-1) return end end -- BindingsMenu's pad capture rides the same top-state routing as keys local top = self.stack and self.stack:top() if top and top.onGamepadPressed then top:onGamepadPressed(button) return end -- Select+face display chords → same digit path as Game:keypressed -- (COLORS/TILT/pipelines). Intercept before Input so face does not -- also fire GB A/B. Dual-path: raw already ignored when isGamepad(). if selectHeld then local digit = GamepadMap.displayChordDigit(button) if digit then self:keypressed(digit) return end end Input:gamepadpressed(joystick, button) end function Game:gamepadreleased(joystick, button) -- same observe-after-Input contract as Game:keyreleased (#589) Input:gamepadreleased(joystick, button) local top = self.stack and self.stack:top() if top and top.onGamepadReleased then top:onGamepadReleased(button) end end function Game:gamepadaxis(joystick, axis, value) -- past-deadzone only, so resting-stick drift can't hide the overlay if math.abs(value) > 0.5 then TouchControls:noteGamepad() end Input:gamepadaxis(joystick, axis, value) end -- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the -- generic joystick path anyway: any sensor-style device that still reaches us -- has gravity pinning an axis past the deadzone, which would hide the touch -- overlay every instant and steer the player by tilt through the axis-1/2 -- mapping (#459). Real controllers arrive as SDL gamepads or named sticks, -- never as "* Accelerometer". local function isAccelerometer(joystick) local name = joystick and joystick.getName and joystick:getName() return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil end -- BindingsMenu's raw-stick capture rides the same top-state routing as the -- keyboard and gamepad paths (#632). Only a stick SDL does not recognize -- as a gamepad reaches the capture: a recognized pad raises BOTH -- joystickpressed and gamepadpressed for one press, and the joystick half -- would otherwise beat its own gamepadpressed to the armed row and record -- "JOY1" for a button the player can plainly see is A. Same predicate as -- Input's, kept local here so Game never reaches into Input's internals. local function isRawStick(joystick) return not (joystick and joystick.isGamepad and joystick:isGamepad()) end function Game:joystickpressed(joystick, button) if isAccelerometer(joystick) then return end TouchControls:noteGamepad() local top = self.stack and self.stack:top() if isRawStick(joystick) and top and top.onJoystickPressed then top:onJoystickPressed(button) return end Input:joystickpressed(joystick, button) end function Game:joystickreleased(joystick, button) if isAccelerometer(joystick) then return end -- same observe-after-Input contract as Game:keyreleased (#589): the -- capture watches the release, it never owns it, so a held-state flag -- Input saw go down before the capture armed cannot be stranded Input:joystickreleased(joystick, button) local top = self.stack and self.stack:top() if isRawStick(joystick) and top and top.onJoystickReleased then top:onJoystickReleased(button) end end function Game:joystickaxis(joystick, axis, value) if isAccelerometer(joystick) then return end if math.abs(value) > 0.5 then TouchControls:noteGamepad() end Input:joystickaxis(joystick, axis, value) end function Game:joystickhat(joystick, hat, direction) if isAccelerometer(joystick) then return end if direction ~= "c" then TouchControls:noteGamepad() end Input:joystickhat(joystick, hat, direction) end -- Window focus/visibility flips: a release due while unfocused/hidden can -- be swallowed by the OS. Reset on both edges; on the regain, reconcile -- re-arms only what is still physically held -- a held key won't re-fire -- keypressed by itself, and without the rebuild a spurious lifecycle event -- parked the player until every direction was re-pressed (#799). function Game:focus(f) Input:reset() if f then Input:reconcile() end TouchControls:reset() self:cancelPointers() end function Game:visible(v) if v then self:onResume() else Input:reset() TouchControls:reset() self:cancelPointers() end end function Game:onResume() Input:reset() Input:reconcile() TouchControls:reset() self:cancelPointers() -- Chip music may survive NX suspend as a duplicate stream; stop it and let -- the active screen re-cue on the next frame (hardware audio check: T19). -- Desktop/mobile window-visible flips must not kill overworld music. if require("src.core.Platform").isNX() then require("src.core.ChipAudio").stopMusic() end local SwitchDiagnostics = require("src.debug.SwitchDiagnostics") if SwitchDiagnostics.isEnabled() then SwitchDiagnostics.onEvent("lifecycle", { event = "resume" }) end end function Game:recoverInput(event, joystick) Input:reset() -- A hotplug can arrive with no hotplug (macOS Bluetooth re-enumeration), -- and the blanket reset above also drops unrelated keyboard holds; put -- back whatever is still physically down (#799). Input:reconcile() TouchControls:reset() -- reset just dropped every source, mod holds included: retire the mods' -- outstanding press tokens so nothing stale can be released later, and -- tell subscribers their live pointers died (#807) if self.mods and self.mods.releaseModInput then self.mods:releaseModInput() end self:cancelPointers() local SwitchDiagnostics = require("src.debug.SwitchDiagnostics") if SwitchDiagnostics.isEnabled() then if joystick then SwitchDiagnostics.onJoystickEvent(event, joystick) else SwitchDiagnostics.onEvent("lifecycle", { event = event }) end end end function Game:joystickadded(joystick) self:recoverInput("joystickadded", joystick) end -- A disconnected/dropped controller can't send the button-up for whatever -- it was holding, so drop all input state rather than try to guess which -- flags it owned. function Game:joystickremoved(joystick) self:recoverInput("joystickremoved", joystick) TouchControls:joystickremoved() end -- Gameplay pointer seam (#807). TouchControls keeps first refusal: a -- pointer that begins on a virtual control belongs to the pad for its -- whole lifecycle and never reaches mods, while one that begins outside -- stays mod-visible even if it later wanders across a control -- (TouchControls only tracks ids it captured at press). Everything a -- subscriber costs -- the per-pointer records in self.modPointers, the -- payload tables -- sits behind wantsHook, so a mod-free boot allocates -- nothing here. -- vanilla for input.pointer: nobody consumed the event local function pointerUnclaimed() return false end -- coordinates are LOVE window units, the same space render.hud's viewport -- and the touch overlay lay out in function Game: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 Game: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 Game:touchmoved(id, x, y, dx, dy, pressure) 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 Game: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 (#807). 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 -- Game:touchpressed, and forwarding both would fire a mobile touch twice. function Game: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 Game: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 Game: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 and input recovery swallow pointer releases the -- same way they swallow 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 (#807). -- Cleared even when the subscriber is already gone, so no stale record -- outlives its mod. function Game: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 -- Point the loader's mod.save backing at this save's modData so per-mod -- state persists with the slot. seedBuckets is boot-only: it keeps what -- entry chunks wrote before any save existed, while NEW GAME and -- CONTINUE replace the backing outright. function Game:adoptSave(save, seedBuckets) 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 -- Capture the live world state into the save table and persist it. -- Options are flushed to options.lua as part of SaveData.save. function Game:writeSave() -- Tool sessions can be deliberately ephemeral. Give them one narrow veto -- before captureSave mutates the snapshot or any progress bytes reach disk. if ModRuntime.call("save.write", function() return true end, self) == false then return false end if self.overworld and self.overworld.captureSave then self.overworld:captureSave(self.save) end -- stamp here so the save.writing payload carries the exact meta the -- file gets; mods snapshot runtime state into their namespace now self.save.meta = SaveData.buildMeta( self.modStatus and self.modStatus.loaded, self.save.meta) if ModRuntime.wants("save.writing") then ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta }) end return SaveData.save(self.save) end -- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings -- across New Game without touching the progress save. function Game:writeOptions() if not (self.save and self.save.options) then return end SaveData.saveOptions(self.save.options) end -- Push the live options table into audio + display subsystems. function Game:applyOptions(opts) opts = opts or (self.save and self.save.options) or {} local Music = require("src.core.Music") local Sound = require("src.core.Sound") if Music.applyOptions then Music.applyOptions(opts) end if Sound.applyOptions then Sound.applyOptions(opts) end require("src.render.PaletteFX").applyOptions(opts) require("src.render.Tilt").applyOptions(opts) -- after Tilt, so a persisted world pipeline can switch the tilt level it -- just restored back off (the two are mutually exclusive) require("src.render.Pipelines").applyOptions(opts) require("src.render.Zoom").applyOptions(opts) require("src.render.TileRenderer").applyOptions(opts) -- returns true when a persisted GBC FX level was cleared on mobile local gbcCleared = require("src.render.GBCFX").applyOptions(opts) require("src.core.VideoMode").applyOptions(opts) -- Android orientation lock (#592); no-op everywhere else require("src.core.Orientation").applyOptions(opts) -- after VideoMode: a faithful-resolution lock is an exact window size, so -- it has to be the last word on the window (it drops fullscreen to hold) require("src.core.FaithfulRes").applyOptions(opts) -- normalizes a nil/garbage cap to the 60 default, so old saves with no -- fpsCap key pace at the standard rate (issue #88) require("src.core.FrameCap").applyOptions(opts) -- Scale the optional presentation extras to the device's performance -- tier. Every heavy feature was just applied from the stored options -- above; here we clamp the *live* state down for a weaker device without -- rewriting what the player saved, so raising the tier later restores -- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the -- default on a normal desktop, and every options.lua predating this -- option) clamps nothing, so it is a no-op for the common case. local caps = require("src.core.Performance").applyOptions(opts) if not caps.tilt then require("src.render.Tilt").setLevel(0) end if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end local Zoom = require("src.render.Zoom") Zoom.allowSurvey = caps.survey if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end if caps.fpsMax then local FrameCap = require("src.core.FrameCap") if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end end Input:applyBindings(opts.bindings) TouchControls:applyOptions(opts) -- heal soft-bricked APK installs that already saved gbcfx > 0 (#136) if gbcCleared then self:writeOptions() end end function Game:restoreSave(loaded, recovered, opts) if ModRuntime.wants("save.loading") then ModRuntime.emit("save.loading", { raw = loaded }) end -- mod chains replay before validation so a mod repairs its own data -- instead of watching it get quarantined; core steps already ran in -- SaveData.load and skip on the format guard local activeMods = self.modStatus and self.modStatus.loaded SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods) -- Issue #103: 0.1.11 softlocks left CONTINUE in HALL_OF_FAME with -- lastOutdoor on Indigo. One-shot relocate + heal before validate. if SaveData.needsPostGameRescue(loaded) then SaveData.applyPostGameHome(loaded, self:bootConfig()) local Pokemon = require("src.pokemon.Pokemon") for _, mon in ipairs(loaded.party or {}) do Pokemon.heal(mon) end end local modsDiff = SaveData.modsDiff(loaded, activeMods) local report = SaveData.validate(loaded, self.data) report.recovered = recovered report.modsDiff = modsDiff self.save = loaded self:adoptSave(loaded) -- SaveData.load already attached the standalone options.lua table self:applyOptions(loaded.options) -- saves from before OT/ID stamping: backfill with the player's (after -- the scrub, so every mon the stamp loop sees is known) local stamp = require("src.battle.BattleState").stampOT for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end for _, box in ipairs(loaded.boxes or {}) do for _, mon in ipairs(box) do stamp(loaded, mon) end end -- rebuild the state stack from the save while self.stack:top() do self.stack:pop() end -- freshBoot threads through from the caller (onContinue and F2 both set -- it); a future caller that doesn't ask for it keeps the ordinary -- crossfade by default. self.stack:push(self.overworld, loaded.player.map, loaded.player.x, loaded.player.y, loaded.player.facing, { via = "boot", freshBoot = opts and opts.freshBoot }) self.saveReport = report if not SaveData.emptyReport(report) then -- the report screen is a Screens id so mods (or the ui milestone) own -- its looks; until one exists the log keeps a quarantine from being -- silent local ok = pcall(Screens.push, self, "QuarantineReport", report) if not ok then Logger.warn("load report: %d mons quarantined, %d items removed, %d maps remapped%s", #report.lostMons, #report.lostItems, #report.remappedMaps, recovered and (", recovered from " .. recovered) or "") local notice = SaveData.modsDiffNotice(modsDiff, loaded.meta) if notice then Logger.warn("%s", notice) end end end if ModRuntime.wants("save.loaded") then ModRuntime.emit("save.loaded", { save = loaded, meta = loaded.meta, modsDiff = modsDiff }) end end -- Reconstruct a previously validated runtime checkpoint without replaying the -- ordinary CONTINUE lifecycle. In particular, map onEnter scripts and -- save.loading/save.loaded events must not run a second time. Validation, -- identity checks and transactional rollback live in Checkpoint.lua. function Game:restoreCheckpointSave(loaded) self.save = loaded self:adoptSave(loaded) while self.stack:top() do self.stack:pop() end -- freshBoot unconditionally: Checkpoint.resume (src/core/Checkpoint.lua) -- is this method's only caller, and it is itself gated to the title -- session (isTitleSession). self.stack:push(self.overworld, loaded.player.map, loaded.player.x, loaded.player.y, loaded.player.facing, { via = "checkpoint", checkpoint = true, freshBoot = true }) end -- Install a reconstructed battle without calling BattleState:enter(), whose -- transition, intro queues and battle-start side effects already happened in -- the checkpointed timeline. function Game:restoreCheckpointBattle(battle) if self.stack:top() ~= self.overworld then error("battle checkpoint requires a reconstructed overworld base", 0) end self.stack.states[#self.stack.states + 1] = battle if battle.resumeCheckpoint then battle:resumeCheckpoint() end end return Game