diff --git a/main.lua b/main.lua index 8220c9c2..7b92e902 100644 --- a/main.lua +++ b/main.lua @@ -384,7 +384,14 @@ function bootGame(version, cartId) Game = require("src.core.Game2").new() Game:load() else - Game = require("src.core.Game") + -- Gen1 Game is a module singleton. In-process EXIT GAME resets it in + -- place; if a prior teardown left load missing, rebuild from source. + local gameMod = require("src.core.Game") + if type(gameMod.load) ~= "function" then + package.loaded["src.core.Game"] = nil + gameMod = require("src.core.Game") + end + Game = gameMod Game:load() if os.getenv("POKEPORT_AUTOPILOT") then autopilot = require("tests.autopilot") diff --git a/src/core/Game.lua b/src/core/Game.lua index 2cc890d7..c5b6937e 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1389,8 +1389,13 @@ end -- Drop every session-owned field so the next Game:load() starts clean when -- the process returns to the launcher in-place (Android / intent_game). --- main.lua must not guess field names: new systems (Game.network, …) are --- cleared automatically because only functions (methods) are kept. +-- +-- Gen1 Game is a MODULE SINGLETON (methods live on this table). Never fan +-- out arbitrary field:release() here: session fields can hold shared modules +-- (Fetch, SyncClient, …) whose :release is a job-handle API, not instance +-- teardown -- calling them as value:release() corrupts process state and has +-- been observed to leave Game.load nil after EXIT GAME on Android. +-- Explicit GPU owners are released below; everything else is just dropped. function Game:reset() if self.stack and self.stack.clear then pcall(function() self.stack:clear() end) @@ -1403,23 +1408,23 @@ function Game:reset() if canvas and canvas.release then pcall(canvas.release, canvas) end end end - if self.renderer and self.renderer.releaseCanvases then - pcall(function() self.renderer:releaseCanvases() end) + if self.renderer then + local release = self.renderer.releaseCanvases or self.renderer.release + if release then pcall(release, self.renderer) end end + -- Keep methods; clear every other field (including session scalars like + -- speedOverride). Re-seed module constants afterward. + local skinFast = self.SKIN_FAST_FORWARD local keys = {} for key, value in pairs(self) do if type(value) ~= "function" then - if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then - if type(value) == "table" and value.release then - pcall(value.release, value) - end - end keys[#keys + 1] = key end end for _, key in ipairs(keys) do self[key] = nil end + self.SKIN_FAST_FORWARD = skinFast or 4 end return Game diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 22d914ff..342fd5f8 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -2204,6 +2204,8 @@ end -- In-process return-to-launcher (Android / intent_game): drop session fields -- so a later Game2.new() + load is not sharing a live stack or mod loader. -- Methods live on the class table; pairs(self) only sees instance state. +-- Same rule as Gen1: only release known GPU owners -- never fan out +-- arbitrary field:release() (shared modules use :release as a handle API). function Game2:reset() if self.stack and self.stack.clear then pcall(function() self.stack:clear() end) @@ -2216,17 +2218,13 @@ function Game2:reset() if canvas and canvas.release then pcall(canvas.release, canvas) end end end - if self.renderer and self.renderer.releaseCanvases then - pcall(function() self.renderer:releaseCanvases() end) + if self.renderer then + local release = self.renderer.releaseCanvases or self.renderer.release + if release then pcall(release, self.renderer) end end local keys = {} for key, value in pairs(self) do if type(value) ~= "function" then - if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then - if type(value) == "table" and value.release then - pcall(value.release, value) - end - end keys[#keys + 1] = key end end diff --git a/src/core/SessionLifecycle.lua b/src/core/SessionLifecycle.lua index 84508a67..815a00d4 100644 --- a/src/core/SessionLifecycle.lua +++ b/src/core/SessionLifecycle.lua @@ -87,6 +87,13 @@ function SessionLifecycle.endGameSession(game) if game and game.reset then pcall(function() game:reset() end) end + -- Gen1 Game is the module singleton. If teardown left it without load, + -- drop the cached module so the next require rebuilds a clean table + -- (bootGame also guards this; doing it here keeps Play-again reliable). + if game and type(game.load) ~= "function" + and package.loaded["src.core.Game"] == game then + package.loaded["src.core.Game"] = nil + end local Input = require("src.core.Input") local TouchControls = require("src.core.TouchControls") diff --git a/tests/engine/android_exit_to_launcher_test.lua b/tests/engine/android_exit_to_launcher_test.lua index 64ae7165..04e69531 100644 --- a/tests/engine/android_exit_to_launcher_test.lua +++ b/tests/engine/android_exit_to_launcher_test.lua @@ -99,4 +99,19 @@ do check(type(Game.load) == "function", "Game:reset keeps methods") end +-- 6. Play-again after endGameSession: Game.load must still be callable +-- (Android crash: main.lua bootGame → Game:load with load == nil) +do + local Game = require("src.core.Game") + local SessionLifecycle = require("src.core.SessionLifecycle") + Game.save = {} + Game.stack = { clear = function() end } + -- Mimic a shared net module parked on the singleton (handle-style release). + Game.net = { release = function(id) end } + SessionLifecycle.endGameSession(Game) + local again = require("src.core.Game") + check(type(again.load) == "function", + "Play-again can call Game:load after endGameSession") +end + T.finish("android_exit_to_launcher_test") diff --git a/tests/engine/launcher_session_teardown_test.lua b/tests/engine/launcher_session_teardown_test.lua index e63d3fd7..5e3b4b66 100644 --- a/tests/engine/launcher_session_teardown_test.lua +++ b/tests/engine/launcher_session_teardown_test.lua @@ -28,8 +28,15 @@ do Game.network = { live = true } -- future field: must not need a whitelist Game.stack = StateStack Game.renderer = Renderer + Game.SKIN_FAST_FORWARD = 4 Renderer.canvas = love.graphics.newCanvas(8, 8) + -- Handle-style :release (Fetch/SyncClient) must not run as instance teardown. + local shared = { + release = function(self) self.killed = true end, + } + Game.sharedNet = shared + Game:reset() check(type(Game.load) == "function", "Game:reset keeps methods") @@ -40,6 +47,29 @@ do check(Game.stack == nil, "Game:reset clears stack reference") check(Game.renderer == nil, "Game:reset clears renderer reference") check(StateStack:top() == nil, "Game:reset cleared the shared StateStack") + check(shared.killed ~= true, + "Game:reset does not call handle-style :release on session fields") + check(Game.SKIN_FAST_FORWARD == 4, + "Game:reset preserves module scalars like SKIN_FAST_FORWARD") +end + +-- ---- endGameSession must leave Gen1 Game.load callable for Play-again ------ +do + Game.save = { money = 1 } + Game.stack = { clear = function() end } + -- Poison pattern from the Android crash: a field whose :release is a + -- job-handle API. Old reset called it as value:release() and could leave + -- the singleton unbootable (Game.load nil → main.lua bootGame crash). + local jobs = {} + Game.linkFetch = { + release = function(id) jobs[id] = nil end, + } + SessionLifecycle.endGameSession(Game) + check(type(Game.load) == "function", + "endGameSession leaves Game.load intact for the next bootGame") + check(package.loaded["src.core.Game"] == Game + or type((package.loaded["src.core.Game"] or {}).load) == "function", + "Gen1 Game module remains require-able after endGameSession") end -- ---- Game2:reset releases world GPU and present canvases ------------------