mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
e79107c644
A platform-specific launcher wrapper (a native shell embedding this engine, owning its own UI around the game window) needs to pause the simulation while its own UI is on top, live-reload options it wrote outside any Lua UI, and veto main.lua's "closing the window returns to the Lua launcher" behavior when it owns that job itself. Implementing this by hand-patching main.lua's love.update/love.quit directly ties every such integration to editing the one file every other engine change also touches, guaranteeing merge conflicts. No existing hook covers "should the per-frame simulation step run" or "should closing the window return to the Lua launcher." Adds two generic, additive hooks (src/core/PlatformHooks.lua): core.update and core.quit_to_launcher, replacing what would otherwise be inline main.lua special-casing. Also adds Manifest.force_enable_env, letting a mod that cannot function disabled on the one build where its env var is set (a platform-bridge mod bundled only with that build) re-enable itself regardless of a saved disable. RFC 0006 status: Proposed.
954 lines
37 KiB
Lua
954 lines
37 KiB
Lua
-- Native LÖVE2D port of Pokemon Red. A packaged build creates its private
|
|
-- game-data cache from a user-provided ROM on first boot.
|
|
--
|
|
-- The save editor (tools/save-editor/) ships inside every build and is
|
|
-- reachable two ways:
|
|
-- * standalone: POKEPORT_EDITOR=1 or `love . --editor`, its own window
|
|
-- * from the launcher: Edit on a save row, which suspends the launcher,
|
|
-- opens the editor on that slot's file, and restores the launcher when
|
|
-- the editor's Close button is pressed (openEditor / closeEditor below)
|
|
|
|
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
|
|
|
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
|
local LaunchOptions = require("src.core.LaunchOptions")
|
|
local NxDisplay = require("src.core.NxDisplay")
|
|
local PlatformHooks = require("src.core.PlatformHooks")
|
|
|
|
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
|
do
|
|
local defaultErrorHandler = love.errorhandler
|
|
function love.errorhandler(msg)
|
|
local hint = SwitchDiagnostics.logLuaError(msg)
|
|
if hint and type(msg) == "string" then
|
|
msg = msg .. "\n\n" .. hint
|
|
end
|
|
if defaultErrorHandler then
|
|
return defaultErrorHandler(msg)
|
|
end
|
|
end
|
|
end
|
|
|
|
local Game, EditorApp, Importer, TouchEditor
|
|
|
|
-- #887: quit-to-launcher state, shared by love.load and love.quit (both need
|
|
-- it, so it is declared here rather than next to love.quit).
|
|
-- * launchedIntoGame -- a --game / POKEPORT_GAME shortcut booted this
|
|
-- session straight into a game, so there is no launcher behind it and a
|
|
-- window close must exit. Restarting instead re-read the same shortcut
|
|
-- and came right back into the game, and the next close did it again:
|
|
-- the app could not be closed at all (macOS feels this worst, where the
|
|
-- red X, Cmd+Q and the Dock's Quit are all the same quit event).
|
|
-- * RELAUNCH_MARKER -- written in the save dir just before the #785
|
|
-- restart, so the fresh boot ignores any boot-straight-into-a-game
|
|
-- option exactly once and keeps #785's promise of landing in the
|
|
-- launcher, whatever put the game on screen this time.
|
|
local launchedIntoGame = false
|
|
local RELAUNCH_MARKER = "relaunch_to_launcher.txt"
|
|
|
|
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
|
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
|
-- coroutine that receives `Game` and yields once per
|
|
-- frame; used headless (xvfb) for scripted screenshots
|
|
|
|
-- --speed N / POKEPORT_SPEED=N: run the logic clock N times faster without
|
|
-- touching audio (src/core/GameSpeed.lua). Overrides the saved option so a
|
|
-- bot or screenshot run is not at the mercy of the player's last choice.
|
|
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
|
|
|
|
-- POKEPORT_TOUCH=1 forces the mobile on-screen controls on and lets the
|
|
-- mouse stand in for a finger, so the overlay can be exercised on desktop
|
|
-- (see src/core/TouchControls.lua).
|
|
local mouseTouch = os.getenv("POKEPORT_TOUCH") == "1"
|
|
|
|
-- How many times to run a scripted act+step loop per rendered frame. Only
|
|
-- scripted runs use this; interactive play fast-forwards through
|
|
-- Game.speedOverride / the GAME SPEED option instead.
|
|
local function scriptedIterations()
|
|
if not (autopilot or driverCo) then return 1 end
|
|
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
|
|
end
|
|
|
|
-- ------------------------------------------------------------ save editor
|
|
-- The launcher instance parked while the editor is up, plus the version whose
|
|
-- cache the editor mounted (so closing can put the read path back).
|
|
local editorHost, editorVersion, editorWindow
|
|
local closeEditor -- forward declaration: openEditor hands it to the editor
|
|
|
|
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
|
-- their directories have to be on the require path. It must be
|
|
-- love.filesystem's path, not package.path: in a packaged build these files
|
|
-- live inside the .love archive, which the stock Lua searcher cannot open.
|
|
local function addEditorRequirePath()
|
|
local fs = love.filesystem
|
|
if not (fs.setRequirePath and fs.getRequirePath) then
|
|
-- very old LOVE: a source checkout still resolves through package.path
|
|
package.path = fs.getSource() .. "/tools/save-editor/?.lua;"
|
|
.. fs.getSource() .. "/tools/save-editor/panels/?.lua;"
|
|
.. package.path
|
|
return
|
|
end
|
|
local current = fs.getRequirePath()
|
|
if current:find("tools/save%-editor") then return end
|
|
fs.setRequirePath("tools/save-editor/?.lua;tools/save-editor/panels/?.lua;"
|
|
.. current)
|
|
end
|
|
|
|
-- Desktop only: the launcher window (1024x768) is tighter than the editor's
|
|
-- design size, so grow it while editing and put it back on Close. Never
|
|
-- shrinks, never touches a fullscreen or mobile window.
|
|
local function resizeForEditor()
|
|
if not (love.window and love.window.getMode and love.window.setMode) then return end
|
|
local osName = love.system.getOS()
|
|
if osName ~= "OS X" and osName ~= "Windows" and osName ~= "Linux" then return end
|
|
local w, h, flags = love.window.getMode()
|
|
if flags.fullscreen then return end
|
|
local dw, dh = love.window.getDesktopDimensions()
|
|
local wantW = math.max(w, math.min(1360, math.floor((dw or w) * 0.92)))
|
|
local wantH = math.max(h, math.min(860, math.floor((dh or h) * 0.88)))
|
|
if wantW <= w and wantH <= h then return end
|
|
editorWindow = { w = w, h = h }
|
|
love.window.setMode(wantW, wantH, flags)
|
|
end
|
|
|
|
local function restoreWindow()
|
|
if not editorWindow then return end
|
|
local _, _, flags = love.window.getMode()
|
|
love.window.setMode(editorWindow.w, editorWindow.h, flags)
|
|
editorWindow = nil
|
|
end
|
|
|
|
-- Open the editor on a launcher save row. The version's cache has to be
|
|
-- mounted before the editor's Data:load runs, or a Blue save would be edited
|
|
-- against Red's species/item tables.
|
|
local function openEditor(version, slotId)
|
|
local SaveData = require("src.core.SaveData")
|
|
local path = SaveData.slotDiskPath(version, slotId)
|
|
if not path then
|
|
if Importer then
|
|
Importer.saveNotice = Importer.saveNotice or {}
|
|
Importer.saveNotice[version] =
|
|
{ ok = false, text = "Could not resolve that save slot on disk." }
|
|
end
|
|
return
|
|
end
|
|
local GameVersion = require("src.core.GameVersion")
|
|
GameVersion.set(version)
|
|
require("src.import.CacheFs").mountVersion(version)
|
|
editorVersion = version
|
|
editorHost = Importer
|
|
-- Drop launcher pad/FlexLove so the save editor owns input (NX shim +
|
|
-- virtual cursor + system hand cursor). Desktop park is a light no-op.
|
|
if Importer and Importer.prepareOverlayHandoff then
|
|
Importer:prepareOverlayHandoff()
|
|
end
|
|
Importer = nil
|
|
editorMode = true
|
|
resizeForEditor()
|
|
addEditorRequirePath()
|
|
EditorApp = require("App")
|
|
EditorApp.load(path, { version = version, slotId = slotId, embedded = true,
|
|
onClose = function() closeEditor() end })
|
|
end
|
|
|
|
-- Back to the launcher. Everything the editor mounted or cached has to come
|
|
-- back out: the version overlay (CacheFs) and the generated modules require
|
|
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
|
|
-- with this one's data.
|
|
function closeEditor()
|
|
local version = editorVersion
|
|
editorMode = false
|
|
if EditorApp and EditorApp.unload then EditorApp.unload() end
|
|
EditorApp = nil
|
|
if version then
|
|
require("src.import.CacheFs").unmountVersion(version)
|
|
require("src.core.Data"):unloadGenerated()
|
|
end
|
|
editorVersion = nil
|
|
restoreWindow()
|
|
Importer = editorHost
|
|
editorHost = nil
|
|
if Importer and Importer.resumeAfterOverlay then
|
|
Importer:resumeAfterOverlay()
|
|
end
|
|
if Importer and version and Importer.savesChanged then
|
|
Importer:savesChanged(version)
|
|
end
|
|
end
|
|
|
|
-- ------------------------------------------------------------ touch controls editor
|
|
-- Suspends the launcher while the player drags on-screen buttons / toggles
|
|
-- the overlay off (#327). No ROM cache needed -- options.lua only.
|
|
local touchEditorHost
|
|
local closeTouchControlsEditor -- forward declaration
|
|
|
|
local function openTouchControlsEditor()
|
|
touchEditorHost = Importer
|
|
if Importer and Importer.prepareOverlayHandoff then
|
|
Importer:prepareOverlayHandoff()
|
|
end
|
|
Importer = nil
|
|
TouchEditor = require("src.ui.TouchControlsEditor")
|
|
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
|
|
end
|
|
|
|
function closeTouchControlsEditor()
|
|
if TouchEditor and TouchEditor.unload then TouchEditor.unload() end
|
|
TouchEditor = nil
|
|
Importer = touchEditorHost
|
|
touchEditorHost = nil
|
|
if Importer and Importer.resumeAfterOverlay then
|
|
Importer:resumeAfterOverlay()
|
|
end
|
|
end
|
|
|
|
local function bootGame(version)
|
|
-- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and
|
|
-- headless runs fall back to POKEPORT_VERSION, then Red. Set the active
|
|
-- version and overlay its extracted cache BEFORE anything requires generated
|
|
-- data, so data/generated + assets/generated resolve to that version's files.
|
|
local GameVersion = require("src.core.GameVersion")
|
|
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
|
|
local CacheFs = require("src.import.CacheFs")
|
|
-- Keep CacheFs.prefix aligned for any CacheFs.read fallback during Data:load
|
|
-- (Blue/Yellow caches live under blue/ / yellow/).
|
|
CacheFs.prefix = GameVersion.cachePrefix()
|
|
CacheFs.mountVersion(GameVersion.get())
|
|
-- NX: always write nx-asset-probe.log so Yellow/Blue art failures are
|
|
-- diagnosable from the SD without enabling switch-debug.txt.
|
|
pcall(function()
|
|
require("src.debug.SwitchDiagnostics").probeAssets(GameVersion.get())
|
|
end)
|
|
if love.window and love.window.setTitle then
|
|
local Version = require("src.core.Version")
|
|
love.window.setTitle(Version.title(
|
|
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
|
|
end
|
|
Game = require("src.core.Game")
|
|
Game:load()
|
|
if os.getenv("POKEPORT_AUTOPILOT") then
|
|
autopilot = require("tests.autopilot")
|
|
end
|
|
local driverPath = os.getenv("POKEPORT_DRIVER")
|
|
if driverPath then
|
|
local fn = assert(loadfile(driverPath))()
|
|
driverCo = coroutine.create(fn)
|
|
end
|
|
-- After the two above are known: a scripted run drives the multiplier
|
|
-- from love.update's loop, so the in-engine one must stay at 1 or the
|
|
-- two would compound (10x10 = 100 steps per observation).
|
|
Game.speedOverride = (autopilot or driverCo) and 1 or speedOverride
|
|
end
|
|
|
|
function love.load(args)
|
|
-- Before anything can shell out (update check, mod index, ROM picker),
|
|
-- claim one hidden console on Windows so those children inherit it instead
|
|
-- of each flashing their own cmd.exe window (#606). No-op elsewhere.
|
|
require("src.core.HostShell").hideHostConsole()
|
|
|
|
-- NX fused mounts are unreliable for the blue|yellow cache overlay: wrap
|
|
-- the love loaders once so every generated-asset read falls back to the
|
|
-- versioned save-dir copy. Never installed on desktop/Android/iOS.
|
|
if require("src.core.Platform").isNX() then
|
|
require("src.core.NxAssetOverlay").install()
|
|
end
|
|
|
|
-- Self-updater boot shell: a fused build may mount and chainload a newer
|
|
-- downloaded payload here. True means it took over, so we must stop. A
|
|
-- dev / source checkout no-ops (see src/update/Boot.lua).
|
|
local Boot = require("src.update.Boot")
|
|
if Boot.run(args) then return end
|
|
|
|
local savePath
|
|
for i, a in ipairs(args or {}) do
|
|
if a == "--editor" then
|
|
editorMode = true
|
|
elseif a == "--developer" then
|
|
_G.POKEPORT_DEV_MODE = true
|
|
elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then
|
|
savePath = args[i + 1]
|
|
elseif a == "--speed" and tonumber(args[i + 1]) then
|
|
speedOverride = tonumber(args[i + 1])
|
|
end
|
|
end
|
|
love.graphics.setDefaultFilter("nearest", "nearest")
|
|
-- NX: handheld 720p / docked 1080p. Runs for every boot path (launcher,
|
|
-- editor, scripted); no-op on desktop/mobile.
|
|
NxDisplay.sync()
|
|
|
|
-- Apply the persisted Android orientation lock (#592) before the launcher
|
|
-- shows: SDL created the window with no orientation hint, so without this
|
|
-- the launcher would rotate freely until Game:applyOptions runs at boot.
|
|
-- No-op on desktop / iOS / when options.lua does not exist yet.
|
|
require("src.core.Orientation").applyOptions(
|
|
require("src.core.SaveData").loadOptions())
|
|
|
|
-- Standalone editor. A bare `--editor` run has no launcher behind it, so
|
|
-- Close quits; --save points it at a specific file, otherwise it opens the
|
|
-- default save path for POKEPORT_VERSION (Red unless overridden), whose
|
|
-- cache has to be mounted before the editor's Data:load.
|
|
if editorMode then
|
|
local version = os.getenv("POKEPORT_VERSION") or "red"
|
|
require("src.core.GameVersion").set(version)
|
|
require("src.import.CacheFs").mountVersion(version)
|
|
addEditorRequirePath()
|
|
EditorApp = require("App")
|
|
EditorApp.load(savePath, { version = version })
|
|
return
|
|
end
|
|
|
|
local RomImporter = require("src.import.RomImporter")
|
|
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
|
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
|
|
-- Scripted / headless runs pick their game from POKEPORT_VERSION (default
|
|
-- Red); the launcher's per-column choice does not apply to them.
|
|
local scriptedVersion = os.getenv("POKEPORT_VERSION") or "red"
|
|
local ready = RomImporter.isReady(scriptedVersion)
|
|
-- Scripted / headless runs have to reach the game with no human pressing
|
|
-- Play: an autopilot, a frame driver, an import-only build step, or an
|
|
-- explicit ROM path all bypass the interactive launcher and keep today's
|
|
-- import-then-boot (or boot-straight-in) behavior.
|
|
local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER")
|
|
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or importPath ~= nil
|
|
|
|
if scripted then
|
|
if forceImport or not ready then
|
|
-- The importer detects the dropped/loaded ROM's version by SHA-1 and
|
|
-- passes it to onComplete; boot that version.
|
|
Importer = RomImporter.new(function(version)
|
|
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
|
|
love.event.quit()
|
|
return
|
|
end
|
|
Importer = nil
|
|
bootGame(version or scriptedVersion)
|
|
end)
|
|
if importPath then Importer:startPath(importPath) end
|
|
return
|
|
end
|
|
bootGame(scriptedVersion)
|
|
return
|
|
end
|
|
|
|
-- The launcher draws before any game boots, so the mod loader has not run
|
|
-- and Strings has no catalog. Routing the launcher's text through Strings
|
|
-- (#767) only pays off if something fills that catalog this early, and no
|
|
-- restart could: the ordering is the same on every launch. Read the
|
|
-- enabled mods' string catalogs -- data only, no entry chunk -- so a
|
|
-- translation reaches the launcher too. Game:load replaces this with the
|
|
-- real merged catalog once a version boots.
|
|
do
|
|
local preload = require("src.mods.LauncherMods").translationStrings()
|
|
if preload then require("src.core.Strings").load({ strings = preload }) end
|
|
end
|
|
|
|
-- LAUNCH OPTIONS: skip the launcher and boot a game directly.
|
|
-- --game red|blue|yellow (or POKEPORT_GAME / POKEPORT_LAUNCH)
|
|
-- --slot <id> optional; picks the save slot to load
|
|
-- --launcher force the launcher even if a game is set
|
|
-- This is what a desktop shortcut, a Steam entry, or a frontend like
|
|
-- EmulationStation needs: one click into the game the player wants, with no
|
|
-- menu in between. A game that is not imported falls through to the
|
|
-- launcher on its tab rather than booting into nothing.
|
|
-- A window close that restarted us into the launcher (#785) leaves the
|
|
-- marker behind: consume it and stay on the launcher, or the shortcut below
|
|
-- would boot the same game again and that close would restart again,
|
|
-- forever (#887). Consumed on read, so the very next launch is normal.
|
|
local relaunched = love.filesystem.getInfo(RELAUNCH_MARKER) ~= nil
|
|
if relaunched then pcall(love.filesystem.remove, RELAUNCH_MARKER) end
|
|
|
|
local launchGame, launchSlot = LaunchOptions.resolve(arg)
|
|
if launchGame and not relaunched and not LaunchOptions.forceLauncher(arg) then
|
|
if RomImporter.isReady(launchGame) then
|
|
if launchSlot then LaunchOptions.selectSlot(launchGame, launchSlot) end
|
|
-- No launcher behind this session: love.quit must exit, not restart.
|
|
launchedIntoGame = true
|
|
bootGame(launchGame)
|
|
return
|
|
end
|
|
-- Not importable yet: open the launcher already showing that game, so the
|
|
-- shortcut still lands the player where they meant to go.
|
|
LaunchOptions.pendingTab = launchGame
|
|
end
|
|
|
|
-- Interactive: the launcher always runs. Red, Blue, and Yellow are each
|
|
-- live: a column shows Play when that game's ROM is already imported, or
|
|
-- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its
|
|
-- SHA-1 (GameVersion.forSha1); pressing Play boots that game. Edit on a
|
|
-- save row opens the bundled editor on that slot (openEditor).
|
|
Importer = RomImporter.new(function(version)
|
|
Importer = nil
|
|
bootGame(version)
|
|
end, {
|
|
launcher = true,
|
|
forceImport = forceImport,
|
|
onEditSave = openEditor,
|
|
onEditTouchControls = openTouchControlsEditor,
|
|
})
|
|
end
|
|
|
|
function love.update(dt)
|
|
SwitchDiagnostics.maybeFlush(false)
|
|
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
|
|
NxDisplay.sync()
|
|
if editorMode then return EditorApp.update(dt) end
|
|
if TouchEditor then return TouchEditor.update(dt) end
|
|
if Importer then return Importer:update(dt) end
|
|
|
|
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
|
|
-- once per Game:update, so they must keep a 1:1 relationship with the
|
|
-- logic step. Fast-forwarding them by scaling the step inside
|
|
-- Game:update would run N steps per observation: a held direction walks
|
|
-- through all N, the player slides past the waypoint, and the script
|
|
-- re-plans from an overshot cell. So iterate the whole act+step loop
|
|
-- instead -- same script, just more of it per rendered frame.
|
|
local iterations = scriptedIterations()
|
|
|
|
if autopilot then
|
|
for _ = 1, iterations do
|
|
autopilot.update()
|
|
Game:update(1 / 60) -- deterministic stepping for the autopilot
|
|
end
|
|
return
|
|
end
|
|
if driverCo then
|
|
for _ = 1, iterations do
|
|
local ok, err = coroutine.resume(driverCo, Game)
|
|
if not ok then
|
|
print("driver error: " .. tostring(err))
|
|
love.event.quit(1)
|
|
return
|
|
end
|
|
if coroutine.status(driverCo) == "dead" then
|
|
love.event.quit()
|
|
return
|
|
end
|
|
Game:update(1 / 60)
|
|
end
|
|
return
|
|
end
|
|
-- Mods may wrap or veto the per-frame simulation step (pause it, react
|
|
-- to external platform state, etc.) -- see docs/modding.md's core.update
|
|
-- entry. Vanilla behavior (used when no mod claims the hook) is just
|
|
-- Game:update(dt), unconditionally, exactly as before this hook existed.
|
|
PlatformHooks.update(Game, dt)
|
|
end
|
|
|
|
function love.draw()
|
|
if editorMode then return EditorApp.draw() end
|
|
if TouchEditor then return TouchEditor.draw() end
|
|
if Importer then return Importer:draw() end
|
|
|
|
Game:draw()
|
|
-- frame capture requested by a driver
|
|
if Game.capturePath then
|
|
local path = Game.capturePath
|
|
Game.capturePath = nil
|
|
love.graphics.captureScreenshot(function(imagedata)
|
|
local fd = imagedata:encode("png")
|
|
local f = io.open(path, "wb")
|
|
if f then
|
|
f:write(fd:getString())
|
|
f:close()
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
function love.keypressed(key, scancode, isrepeat)
|
|
if editorMode then return EditorApp.keypressed(key) end
|
|
if TouchEditor then return TouchEditor.keypressed(key) end
|
|
if Importer then return Importer:keypressed(key) end
|
|
Game:keypressed(key)
|
|
end
|
|
|
|
function love.keyreleased(key)
|
|
if editorMode or TouchEditor then return end
|
|
if Importer then return end
|
|
Game:keyreleased(key)
|
|
end
|
|
|
|
function love.gamepadpressed(joystick, button)
|
|
SwitchDiagnostics.onJoystickEvent("gamepadpressed", joystick, button)
|
|
if editorMode then
|
|
if EditorApp and EditorApp.gamepadpressed then
|
|
return EditorApp.gamepadpressed(joystick, button)
|
|
end
|
|
return
|
|
end
|
|
if TouchEditor then
|
|
if TouchEditor.gamepadpressed then
|
|
return TouchEditor.gamepadpressed(joystick, button)
|
|
end
|
|
return
|
|
end
|
|
if Importer then return Importer:gamepadpressed(joystick, button) end
|
|
Game:gamepadpressed(joystick, button)
|
|
end
|
|
|
|
function love.gamepadreleased(joystick, button)
|
|
SwitchDiagnostics.onJoystickEvent("gamepadreleased", joystick, button)
|
|
if editorMode then
|
|
if EditorApp and EditorApp.gamepadreleased then
|
|
return EditorApp.gamepadreleased(joystick, button)
|
|
end
|
|
return
|
|
end
|
|
if TouchEditor then
|
|
if TouchEditor.gamepadreleased then
|
|
return TouchEditor.gamepadreleased(joystick, button)
|
|
end
|
|
return
|
|
end
|
|
if Importer then return Importer:gamepadreleased(joystick, button) end
|
|
Game:gamepadreleased(joystick, button)
|
|
end
|
|
|
|
function love.gamepadaxis(joystick, axis, value)
|
|
SwitchDiagnostics.onJoystickEvent("gamepadaxis", joystick, axis, { value = value })
|
|
if editorMode then
|
|
if EditorApp and EditorApp.gamepadaxis then
|
|
return EditorApp.gamepadaxis(joystick, axis, value)
|
|
end
|
|
return
|
|
end
|
|
if TouchEditor then
|
|
if TouchEditor.gamepadaxis then
|
|
return TouchEditor.gamepadaxis(joystick, axis, value)
|
|
end
|
|
return
|
|
end
|
|
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
|
Game:gamepadaxis(joystick, axis, value)
|
|
end
|
|
|
|
function love.joystickpressed(joystick, button)
|
|
SwitchDiagnostics.onJoystickEvent("joystickpressed", joystick, button)
|
|
if editorMode then
|
|
if EditorApp and EditorApp.joystickpressed then
|
|
return EditorApp.joystickpressed(joystick, button)
|
|
end
|
|
return
|
|
end
|
|
if TouchEditor then
|
|
if TouchEditor.joystickpressed then
|
|
return TouchEditor.joystickpressed(joystick, button)
|
|
end
|
|
return
|
|
end
|
|
if Importer then return Importer:joystickpressed(joystick, button) end
|
|
Game:joystickpressed(joystick, button)
|
|
end
|
|
|
|
function love.joystickreleased(joystick, button)
|
|
SwitchDiagnostics.onJoystickEvent("joystickreleased", joystick, button)
|
|
if editorMode then
|
|
if EditorApp and EditorApp.joystickreleased then
|
|
return EditorApp.joystickreleased(joystick, button)
|
|
end
|
|
return
|
|
end
|
|
if TouchEditor then
|
|
if TouchEditor.joystickreleased then
|
|
return TouchEditor.joystickreleased(joystick, button)
|
|
end
|
|
return
|
|
end
|
|
if Importer then return Importer:joystickreleased(joystick, button) end
|
|
Game:joystickreleased(joystick, button)
|
|
end
|
|
|
|
function love.joystickaxis(joystick, axis, value)
|
|
SwitchDiagnostics.onJoystickEvent("joystickaxis", joystick, axis, { value = value })
|
|
if editorMode then
|
|
if EditorApp and EditorApp.joystickaxis then
|
|
return EditorApp.joystickaxis(joystick, axis, value)
|
|
end
|
|
return
|
|
end
|
|
if TouchEditor then
|
|
if TouchEditor.joystickaxis then
|
|
return TouchEditor.joystickaxis(joystick, axis, value)
|
|
end
|
|
return
|
|
end
|
|
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
|
Game:joystickaxis(joystick, axis, value)
|
|
end
|
|
|
|
function love.joystickhat(joystick, hat, direction)
|
|
SwitchDiagnostics.onJoystickEvent("joystickhat", joystick, hat, { direction = direction })
|
|
if editorMode then
|
|
if EditorApp and EditorApp.joystickhat then
|
|
return EditorApp.joystickhat(joystick, hat, direction)
|
|
end
|
|
return
|
|
end
|
|
if TouchEditor then
|
|
if TouchEditor.joystickhat then
|
|
return TouchEditor.joystickhat(joystick, hat, direction)
|
|
end
|
|
return
|
|
end
|
|
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
|
Game:joystickhat(joystick, hat, direction)
|
|
end
|
|
|
|
function love.joystickadded(joystick)
|
|
SwitchDiagnostics.onJoystickEvent("joystickadded", joystick)
|
|
if editorMode or TouchEditor then return end
|
|
if Importer then return end
|
|
Game:joystickadded(joystick)
|
|
end
|
|
|
|
function love.joystickremoved(joystick)
|
|
SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick)
|
|
if editorMode or TouchEditor then return end
|
|
if Importer then return end
|
|
Game:joystickremoved(joystick)
|
|
end
|
|
|
|
-- f is true on focus gained, false on focus lost (e.g. alt-tab). A held
|
|
-- direction's key-up can be delivered to the OS instead of the game while
|
|
-- unfocused, so reset input on either transition rather than trust it.
|
|
function love.focus(f)
|
|
if editorMode or TouchEditor then return end
|
|
if Importer then
|
|
require("src.core.Input"):reset()
|
|
if Importer.focus then Importer:focus(f) end
|
|
return
|
|
end
|
|
Game:focus(f)
|
|
end
|
|
|
|
-- v is true when the window becomes visible again, false on minimize.
|
|
function love.visible(v)
|
|
if editorMode or TouchEditor then return end
|
|
if Importer then
|
|
require("src.core.Input"):reset()
|
|
return
|
|
end
|
|
Game:visible(v)
|
|
end
|
|
|
|
function love.lowmemory()
|
|
if editorMode or TouchEditor or Importer then return end
|
|
if Game then Game:onResume() end
|
|
end
|
|
|
|
function love.touchpressed(id, x, y, dx, dy, pressure)
|
|
if editorMode then
|
|
-- iOS synthesizes mousepressed for the primary touch; forwarding here
|
|
-- would double-fire. Android / NX need the explicit touch → click path
|
|
-- (love-nx does not synthesize mouse for the editor the way desktop does).
|
|
if love.system.getOS() == "iOS" then return end
|
|
if EditorApp and EditorApp.mousepressed then
|
|
return EditorApp.mousepressed(x, y, 1)
|
|
end
|
|
return
|
|
end
|
|
if TouchEditor then
|
|
-- iOS synthesizes mousepressed for the primary touch (same as the
|
|
-- launcher); Android drives the editor through love.touch directly.
|
|
if love.system.getOS() == "iOS" then return end
|
|
return TouchEditor.touchpressed(id, x, y)
|
|
end
|
|
if Importer then
|
|
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
|
|
-- polled inside the view; the istouch filter on mousepressed still drops
|
|
-- Android's synthesized mouse twin so Import cannot double-fire (#553).
|
|
return Importer:touchpressed(id, x, y, dx, dy, pressure)
|
|
end
|
|
Game:touchpressed(id, x, y, dx, dy, pressure)
|
|
end
|
|
|
|
function love.touchmoved(id, x, y, dx, dy, pressure)
|
|
if editorMode then return end
|
|
if TouchEditor then
|
|
if love.system.getOS() == "iOS" then return end
|
|
return TouchEditor.touchmoved(id, x, y)
|
|
end
|
|
if Importer then
|
|
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
|
end
|
|
Game:touchmoved(id, x, y, dx, dy, pressure)
|
|
end
|
|
|
|
function love.touchreleased(id, x, y, dx, dy, pressure)
|
|
if editorMode then return end
|
|
if TouchEditor then
|
|
if love.system.getOS() == "iOS" then return end
|
|
return TouchEditor.touchreleased(id, x, y)
|
|
end
|
|
if Importer then
|
|
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
|
end
|
|
Game:touchreleased(id, x, y, dx, dy, pressure)
|
|
end
|
|
|
|
function love.wheelmoved(x, y)
|
|
if editorMode then
|
|
if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end
|
|
return
|
|
end
|
|
if TouchEditor then return end
|
|
if Importer then return end
|
|
Game:wheelmoved(x, y)
|
|
end
|
|
|
|
-- #781: Linux X11 multi-monitor with the primary display away from desktop
|
|
-- (0,0): SDL's polled mouse state can come back in desktop-virtual
|
|
-- coordinates while the event stream stays window-relative, which strands
|
|
-- every polled consumer (launcher Kit rising-edge clicks, the pad-cursor
|
|
-- motion yield, PadCursor) on coordinates no hit test can match. Sanitize
|
|
-- the poll once here: remember the last window-relative event coordinates
|
|
-- and substitute them whenever the polled value falls outside the window.
|
|
-- Linux only -- macOS / Windows / mobile keep the stock function, and the
|
|
-- NX launcher shim still composes because it captures whatever
|
|
-- love.mouse.getPosition is at bridge time (_ensureNxPointerBridge).
|
|
local eventMouseX, eventMouseY
|
|
if love.system and love.system.getOS() == "Linux"
|
|
and love.mouse and love.mouse.getPosition then
|
|
local polledGetPosition = love.mouse.getPosition
|
|
love.mouse.getPosition = function()
|
|
local x, y = polledGetPosition()
|
|
local w, h = love.graphics.getDimensions()
|
|
if x < 0 or y < 0 or x > w or y > h then
|
|
if eventMouseX then return eventMouseX, eventMouseY end
|
|
return math.max(0, math.min(x, w)), math.max(0, math.min(y, h))
|
|
end
|
|
return x, y
|
|
end
|
|
end
|
|
|
|
function love.mousepressed(x, y, button, istouch)
|
|
if not istouch then eventMouseX, eventMouseY = x, y end
|
|
if TouchEditor then
|
|
-- Android primary touch already arrived via love.touchpressed; a second
|
|
-- mouse path would double-fire Done / begin a second drag.
|
|
if love.system.getOS() == "Android" then return end
|
|
return TouchEditor.mousepressed(x, y, button)
|
|
end
|
|
if Importer then
|
|
-- love.touchpressed already forwards the primary touch into FlexLove for
|
|
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
|
|
-- reached a press handler, one tap ran every launcher button twice and
|
|
-- stacked two SAF pickers (#553). Clicks are polled inside FlexLove from
|
|
-- love.touch / mouse.isDown, so dropping the synthesized istouch press is
|
|
-- safe. A real mouse (DeX, Chromebook, USB) still reaches mousepressed.
|
|
if istouch and (love.system.getOS() == "Android"
|
|
or love.system.getOS() == "iOS") then return end
|
|
return Importer:mousepressed(x, y, button)
|
|
end
|
|
if editorMode and EditorApp.mousepressed then
|
|
-- Same Android double-fire guard: touchpressed already clicked for the
|
|
-- save editor; a synthesized mouse press must not fire again.
|
|
if istouch and love.system.getOS() == "Android" then return end
|
|
return EditorApp.mousepressed(x, y, button)
|
|
end
|
|
if mouseTouch then
|
|
-- the mouse is standing in for a finger: the touch path owns it, and
|
|
-- feeding the same press back in as a mouse pointer would double it
|
|
if Game and button == 1 then Game:touchpressed("mouse", x, y) end
|
|
return
|
|
end
|
|
-- #807: a real mouse reaches gameplay as a pointer event for mods; Game
|
|
-- drops synthesized istouch twins so a mobile touch that already arrived
|
|
-- through love.touchpressed cannot fire twice
|
|
if Game then Game:mousepressed(x, y, button, istouch) end
|
|
end
|
|
|
|
function love.mousereleased(x, y, button, istouch)
|
|
if TouchEditor then
|
|
if love.system.getOS() == "Android" then return end
|
|
return TouchEditor.mousereleased(x, y, button)
|
|
end
|
|
if Importer then return end
|
|
if editorMode and EditorApp.mousereleased then
|
|
return EditorApp.mousereleased(x, y, button)
|
|
end
|
|
if mouseTouch then
|
|
if Game and button == 1 then Game:touchreleased("mouse", x, y) end
|
|
return
|
|
end
|
|
if Game then Game:mousereleased(x, y, button, istouch) end
|
|
end
|
|
|
|
function love.mousemoved(x, y, dx, dy, istouch)
|
|
if not istouch then eventMouseX, eventMouseY = x, y end
|
|
if TouchEditor then
|
|
if love.system.getOS() == "Android" then return end
|
|
return TouchEditor.mousemoved(x, y)
|
|
end
|
|
if editorMode or Importer then return end
|
|
if mouseTouch then
|
|
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
|
return
|
|
end
|
|
if Game then Game:mousemoved(x, y, dx, dy, istouch) end
|
|
end
|
|
|
|
function love.textinput(text)
|
|
if TouchEditor then return end
|
|
if Importer then return Importer:textinput(text) end
|
|
if editorMode and EditorApp.textinput then
|
|
return EditorApp.textinput(text)
|
|
end
|
|
end
|
|
|
|
-- #785: set once love.quit has routed a window close into HostShell.restart,
|
|
-- so the follow-up quit event the restart itself raises (quit("restart") on
|
|
-- desktop; AppImage and Android relaunch the process instead, #575) falls
|
|
-- through to the normal shutdown below instead of restarting forever.
|
|
local quitToLauncher = false
|
|
|
|
function love.quit()
|
|
if editorMode and EditorApp.quit then
|
|
-- true blocks the quit (unsaved-changes prompt). A quit that proceeds
|
|
-- must fall through to the worker shutdowns below instead of returning:
|
|
-- the bundled editor opens from a live launcher whose update-check and
|
|
-- fetch-pool workers are still parked in Channel:demand(), and returning
|
|
-- here skipped their "quit" push, so the process outlived the closed
|
|
-- window and kept the install folder locked on Windows (#727).
|
|
if EditorApp.quit() then return true end
|
|
end
|
|
-- Closing the window of a running game returns to the launcher instead of
|
|
-- exiting the app, so testing a mod does not need a relaunch every time
|
|
-- (#785). Game is only non-nil once bootGame ran; Importer non-nil means
|
|
-- the launcher (or its import) owns the window and its close still quits.
|
|
-- Scripted and headless runs (autopilot, frame driver, import-only, ROM
|
|
-- path import) keep the plain exit so they terminate as before. Nothing
|
|
-- is saved here on purpose: a window close never wrote the save, and the
|
|
-- restart path must be no worse than that, not quietly better.
|
|
local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER")
|
|
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM")
|
|
-- #887: a shortcut session (--game / POKEPORT_GAME) has no launcher to go
|
|
-- back to and the restart would re-read the shortcut, so it exits instead.
|
|
--
|
|
-- A platform launcher that owns "return to launcher" itself (see
|
|
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
|
|
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
|
-- claims the hook) is exactly the condition below.
|
|
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
|
return Game and not Importer and not quitToLauncher and not scripted
|
|
and not launchedIntoGame
|
|
end)
|
|
if wouldReturnToLauncher then
|
|
quitToLauncher = true
|
|
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
|
-- once, so the restart really does land in the launcher (#887). A failed
|
|
-- write only costs that suppression, so it must never block the restart.
|
|
pcall(love.filesystem.write, RELAUNCH_MARKER, "1")
|
|
require("src.core.HostShell").restart()
|
|
return true -- abort this quit; the restart lands back in the launcher
|
|
end
|
|
pcall(function()
|
|
require("src.core.DiscordPresence").shutdown()
|
|
end)
|
|
-- LOVE waits for every live love.thread before the process exits, and both
|
|
-- background workers idle in a loop that only a "quit" command breaks, so
|
|
-- without this the process outlived the window and the next launch re-entered
|
|
-- the dead one instead of starting fresh (#339)
|
|
if package.loaded["src.core.ChipAudio"] then
|
|
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
|
end
|
|
if package.loaded["src.update.Check"] then
|
|
pcall(package.loaded["src.update.Check"].shutdown)
|
|
end
|
|
-- The launcher's fetch pool is the same story: its workers idle in
|
|
-- Channel:demand(), which never returns on its own, so a launcher that ever
|
|
-- touched the network would hang the process on exit (#339's shape again).
|
|
if package.loaded["src.net.Fetch"] then
|
|
pcall(package.loaded["src.net.Fetch"].shutdown)
|
|
end
|
|
end
|
|
|
|
function love.filedropped(file)
|
|
if editorMode and EditorApp and EditorApp.filedropped then
|
|
return EditorApp.filedropped(file)
|
|
end
|
|
if Importer then Importer:filedropped(file) end
|
|
end
|
|
|
|
local function pacingEnabled()
|
|
if os.getenv("POKEPORT_AUTOPILOT") then return false end
|
|
if os.getenv("POKEPORT_DRIVER") then return false end
|
|
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
|
|
return true
|
|
end
|
|
|
|
function love.run()
|
|
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
|
|
|
|
-- don't let love.load's cost land in the first frame's dt
|
|
if love.timer then love.timer.step() end
|
|
|
|
local FrameCap = require("src.core.FrameCap")
|
|
local paced = pacingEnabled()
|
|
-- The deadline the next present() should not beat. Carried forward one
|
|
-- budget per frame so pacing stays even instead of drifting with the
|
|
-- per-frame sleep-granularity jitter.
|
|
local nextFrame = love.timer and love.timer.getTime() or 0
|
|
local dt = 0
|
|
|
|
return function()
|
|
-- process events
|
|
if love.event then
|
|
love.event.pump()
|
|
for name, a, b, c, d, e, f in love.event.poll() do
|
|
if name == "quit" then
|
|
if not love.quit or not love.quit() then
|
|
-- Android keeps the process and its task alive after LOVE's own
|
|
-- teardown, so the relaunched task re-enters an activity whose
|
|
-- native main already returned; end the process outright once the
|
|
-- love.quit hook has run (#339)
|
|
if love.system and love.system.getOS() == "Android" then
|
|
os.exit(a or 0)
|
|
end
|
|
return a or 0
|
|
end
|
|
end
|
|
love.handlers[name](a, b, c, d, e, f)
|
|
end
|
|
end
|
|
|
|
-- update dt
|
|
if love.timer then dt = love.timer.step() end
|
|
|
|
-- call update and draw
|
|
if love.update then love.update(dt) end
|
|
|
|
if love.graphics and love.graphics.isActive() then
|
|
love.graphics.origin()
|
|
love.graphics.clear(love.graphics.getBackgroundColor())
|
|
if love.draw then love.draw() end
|
|
love.graphics.present()
|
|
end
|
|
|
|
if love.timer then
|
|
if paced then
|
|
-- Sleep out the remainder of the frame budget, measured from the
|
|
-- carried deadline, in small chunks so the OS timer stays
|
|
-- responsive. vsync is untouched: when it already paces slower
|
|
-- than the cap the remainder is <= 0 and this rounds to a no-op.
|
|
local budget = 1 / FrameCap.current
|
|
nextFrame = nextFrame + budget
|
|
local now = love.timer.getTime()
|
|
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
|
|
-- deadline more than a full budget in the past; re-anchor to now so
|
|
-- we pace the next frame rather than burst uncapped to catch up.
|
|
if now - nextFrame > budget then
|
|
nextFrame = now
|
|
end
|
|
while true do
|
|
local remaining = nextFrame - love.timer.getTime()
|
|
if remaining <= 0 then break end
|
|
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
|
|
end
|
|
else
|
|
love.timer.sleep(0.001)
|
|
end
|
|
end
|
|
end
|
|
end
|