mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-26 07:21:22 +02:00
fix(mobile): in-process launcher return for Android and iOS
Stop joining ChipAudio on every EXIT GAME, always rebuild the Gen1 Game module on Play-again, and treat iOS like Android for return-to-launcher so release/mobile LOVE restarts no longer wipe Game.load or crash on quit.
This commit is contained in:
@@ -384,12 +384,14 @@ function bootGame(version, cartId)
|
|||||||
Game = require("src.core.Game2").new()
|
Game = require("src.core.Game2").new()
|
||||||
Game:load()
|
Game:load()
|
||||||
else
|
else
|
||||||
-- Gen1 Game is a module singleton. In-process EXIT GAME resets it in
|
-- Gen1 Game is a module singleton. Always re-require after in-process
|
||||||
-- place; if a prior teardown left load missing, rebuild from source.
|
-- EXIT GAME so a prior session cannot leave a table whose rawget(load)
|
||||||
local gameMod = require("src.core.Game")
|
-- is nil (release Android: bootGame then dies with load-a-nil-value).
|
||||||
if type(gameMod.load) ~= "function" then
|
-- rawget: type(mod.load) can lie via __index and skip a rebuild.
|
||||||
package.loaded["src.core.Game"] = nil
|
package.loaded["src.core.Game"] = nil
|
||||||
gameMod = require("src.core.Game")
|
local gameMod = require("src.core.Game")
|
||||||
|
if type(rawget(gameMod, "load")) ~= "function" then
|
||||||
|
error("src.core.Game missing load after reload")
|
||||||
end
|
end
|
||||||
Game = gameMod
|
Game = gameMod
|
||||||
Game:load()
|
Game:load()
|
||||||
@@ -1125,15 +1127,23 @@ function love.quit()
|
|||||||
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
|
-- 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
|
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
||||||
-- claims the hook) is exactly the condition below.
|
-- claims the hook) is exactly the condition below.
|
||||||
local isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android")
|
--
|
||||||
|
-- Android and iOS both tear down LOVE in-process rather than
|
||||||
|
-- love.event.quit("restart"): Android's vendored love.cpp PHYSFS-crashes
|
||||||
|
-- on a second init (#575), and iOS's love.cpp forces DONE_RESTART for
|
||||||
|
-- every quit while warning that leftover threads make that unreliable.
|
||||||
|
-- SessionLifecycle workers (ChipAudio / Fetch / Check) make that warning
|
||||||
|
-- real -- endProcess joins them, then the native restart still blows up.
|
||||||
|
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||||
|
local inProcessReturn = (osName == "Android" or osName == "iOS")
|
||||||
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
||||||
return Game and not Importer and not quitToLauncher and not scripted
|
return Game and not Importer and not quitToLauncher and not scripted
|
||||||
and (isAndroid or not launchedIntoGame)
|
and (inProcessReturn or not launchedIntoGame)
|
||||||
end)
|
end)
|
||||||
if wouldReturnToLauncher then
|
if wouldReturnToLauncher then
|
||||||
if isAndroid then
|
if inProcessReturn then
|
||||||
returnToLauncher()
|
returnToLauncher()
|
||||||
return true -- abort this quit; the restart lands back in the launcher
|
return true -- abort this quit; stay in the same LOVE run
|
||||||
end
|
end
|
||||||
quitToLauncher = true
|
quitToLauncher = true
|
||||||
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
||||||
|
|||||||
@@ -106,6 +106,13 @@ if [ -n "$TEST_APPLICATION_ID" ]; then
|
|||||||
APPLICATION_ID="$TEST_APPLICATION_ID"
|
APPLICATION_ID="$TEST_APPLICATION_ID"
|
||||||
APP_NAME="$APP_NAME (test)"
|
APP_NAME="$APP_NAME (test)"
|
||||||
fi
|
fi
|
||||||
|
# Optional overrides for side-by-side test APKs (never used by CI shipping builds).
|
||||||
|
if [ -n "${GEN1RECOMP_ANDROID_APPLICATION_ID:-}" ]; then
|
||||||
|
APPLICATION_ID="$GEN1RECOMP_ANDROID_APPLICATION_ID"
|
||||||
|
fi
|
||||||
|
if [ -n "${GEN1RECOMP_ANDROID_APP_NAME:-}" ]; then
|
||||||
|
APP_NAME="$GEN1RECOMP_ANDROID_APP_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
# --------------------------------------------------------------- preconditions
|
# --------------------------------------------------------------- preconditions
|
||||||
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
|
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
|
||||||
|
|||||||
+17
-1
@@ -158,7 +158,15 @@ end
|
|||||||
-- and the app dies. There we relaunch through the GameActivity.restartApp
|
-- and the app dies. There we relaunch through the GameActivity.restartApp
|
||||||
-- JNI bridge (love.system.restartApp), which schedules our launch intent
|
-- JNI bridge (love.system.restartApp), which schedules our launch intent
|
||||||
-- and kills the process so no native state can leak into the fresh run.
|
-- and kills the process so no native state can leak into the fresh run.
|
||||||
-- On every other platform the in-process restart works, so keep it.
|
-- iOS is the same class of problem with a sharper edge: love.cpp under
|
||||||
|
-- LOVE_IOS forces DONE_RESTART for *every* quit (Apple forbids programmatic
|
||||||
|
-- exit) and comments that leftover threads make that restart unreliable --
|
||||||
|
-- which our ChipAudio / Fetch / Check workers are. There is no
|
||||||
|
-- restartApp bridge on iOS, so callers that want "back to launcher" must
|
||||||
|
-- use main.lua's in-process returnToLauncher (love.quit aborts the quit);
|
||||||
|
-- HostShell.restart itself refuses quit("restart") and falls back to a
|
||||||
|
-- bare quit() so a mod that still calls restart does not pick the worst
|
||||||
|
-- path on purpose.
|
||||||
function HostShell.restart()
|
function HostShell.restart()
|
||||||
if not (love and love.event and love.event.quit) then return end
|
if not (love and love.event and love.event.quit) then return end
|
||||||
|
|
||||||
@@ -174,6 +182,14 @@ function HostShell.restart()
|
|||||||
love.event.quit()
|
love.event.quit()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if osName == "iOS" then
|
||||||
|
-- No process-kill bridge. A bare quit still becomes DONE_RESTART in
|
||||||
|
-- love.cpp, but quit("restart") is the path that also runs our
|
||||||
|
-- endProcess worker joins first and then re-enters runlove -- the
|
||||||
|
-- combination that crashes EXIT GAME. Prefer the softer quit.
|
||||||
|
love.event.quit()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
local appimage = os.getenv("APPIMAGE")
|
local appimage = os.getenv("APPIMAGE")
|
||||||
if not appimage then
|
if not appimage then
|
||||||
|
|||||||
@@ -64,11 +64,18 @@ end
|
|||||||
|
|
||||||
-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the
|
-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the
|
||||||
-- live game instance so map/GPU holders are gone before endMountedSession.
|
-- live game instance so map/GPU holders are gone before endMountedSession.
|
||||||
|
--
|
||||||
|
-- ChipAudio's worker stays alive across game sessions (see tier comment
|
||||||
|
-- above). shutdown() joins the thread and is process-exit only -- calling
|
||||||
|
-- it here on every Android EXIT GAME has been observed to leave the Gen1
|
||||||
|
-- Game singleton unbootable (Game.load nil) on the next Play of a version
|
||||||
|
-- already opened this process, while a debug APK with a different liblove
|
||||||
|
-- did not reproduce.
|
||||||
function SessionLifecycle.endGameSession(game)
|
function SessionLifecycle.endGameSession(game)
|
||||||
pcall(function() require("src.core.Music").stop() end)
|
pcall(function() require("src.core.Music").stop() end)
|
||||||
pcall(function() require("src.core.Sound").stop() end)
|
pcall(function() require("src.core.Sound").stop() end)
|
||||||
if package.loaded["src.core.ChipAudio"] then
|
if package.loaded["src.core.ChipAudio"] then
|
||||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
pcall(package.loaded["src.core.ChipAudio"].stopMusic)
|
||||||
end
|
end
|
||||||
if package.loaded["src.core.DiscordPresence"] then
|
if package.loaded["src.core.DiscordPresence"] then
|
||||||
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
||||||
@@ -87,11 +94,11 @@ function SessionLifecycle.endGameSession(game)
|
|||||||
if game and game.reset then
|
if game and game.reset then
|
||||||
pcall(function() game:reset() end)
|
pcall(function() game:reset() end)
|
||||||
end
|
end
|
||||||
-- Gen1 Game is the module singleton. If teardown left it without load,
|
-- Gen1 Game is the module singleton. Always drop the cached module after
|
||||||
-- drop the cached module so the next require rebuilds a clean table
|
-- a session that owned it so the next bootGame require rebuilds a clean
|
||||||
-- (bootGame also guards this; doing it here keeps Play-again reliable).
|
-- table. A type(game.load) check is not enough: a wrong table parked in
|
||||||
if game and type(game.load) ~= "function"
|
-- package.loaded (e.g. with __index) can still look like it has load.
|
||||||
and package.loaded["src.core.Game"] == game then
|
if game and package.loaded["src.core.Game"] == game then
|
||||||
package.loaded["src.core.Game"] = nil
|
package.loaded["src.core.Game"] = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -109,9 +109,35 @@ do
|
|||||||
-- Mimic a shared net module parked on the singleton (handle-style release).
|
-- Mimic a shared net module parked on the singleton (handle-style release).
|
||||||
Game.net = { release = function(id) end }
|
Game.net = { release = function(id) end }
|
||||||
SessionLifecycle.endGameSession(Game)
|
SessionLifecycle.endGameSession(Game)
|
||||||
|
check(package.loaded["src.core.Game"] == nil,
|
||||||
|
"endGameSession drops the Gen1 Game module cache")
|
||||||
local again = require("src.core.Game")
|
local again = require("src.core.Game")
|
||||||
check(type(again.load) == "function",
|
check(type(rawget(again, "load")) == "function",
|
||||||
"Play-again can call Game:load after endGameSession")
|
"Play-again can call Game:load after endGameSession")
|
||||||
|
check(again ~= Game, "Play-again gets a fresh Gen1 Game module table")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 7. endGameSession must not join the ChipAudio worker (process-tier only).
|
||||||
|
-- Joining on every EXIT GAME correlates with release-APK Game.load nil
|
||||||
|
-- when reopening a version already played this process.
|
||||||
|
do
|
||||||
|
local SessionLifecycle = require("src.core.SessionLifecycle")
|
||||||
|
local ChipAudio = require("src.core.ChipAudio")
|
||||||
|
local shutdownCalls, stopCalls = 0, 0
|
||||||
|
local origShutdown, origStop = ChipAudio.shutdown, ChipAudio.stopMusic
|
||||||
|
ChipAudio.shutdown = function(...)
|
||||||
|
shutdownCalls = shutdownCalls + 1
|
||||||
|
return origShutdown(...)
|
||||||
|
end
|
||||||
|
ChipAudio.stopMusic = function(...)
|
||||||
|
stopCalls = stopCalls + 1
|
||||||
|
return origStop(...)
|
||||||
|
end
|
||||||
|
local game = { reset = function() end, load = function() end }
|
||||||
|
SessionLifecycle.endGameSession(game)
|
||||||
|
ChipAudio.shutdown, ChipAudio.stopMusic = origShutdown, origStop
|
||||||
|
eq(shutdownCalls, 0, "endGameSession does not ChipAudio.shutdown")
|
||||||
|
check(stopCalls >= 1, "endGameSession stops chip music (Music.stop and/or stopMusic)")
|
||||||
end
|
end
|
||||||
|
|
||||||
T.finish("android_exit_to_launcher_test")
|
T.finish("android_exit_to_launcher_test")
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
-- The fix prefers the love.system.restartApp JNI bridge (which kills the
|
-- The fix prefers the love.system.restartApp JNI bridge (which kills the
|
||||||
-- process, so a true return is never observed live) and, on an old APK
|
-- process, so a true return is never observed live) and, on an old APK
|
||||||
-- whose liblove lacks the bridge, falls back to a CLEAN quit with no
|
-- whose liblove lacks the bridge, falls back to a CLEAN quit with no
|
||||||
-- argument. Desktop keeps the in-process quit("restart").
|
-- argument. iOS has no restartApp bridge and love.cpp forces DONE_RESTART
|
||||||
|
-- for every quit; HostShell.restart must still refuse quit("restart") so a
|
||||||
|
-- leftover caller does not pick the worker-join + native-restart path that
|
||||||
|
-- crashes EXIT GAME. Desktop keeps the in-process quit("restart").
|
||||||
-- luajit tests/engine/host_restart_android_bug575.lua
|
-- luajit tests/engine/host_restart_android_bug575.lua
|
||||||
|
|
||||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
@@ -48,12 +51,18 @@ HostShell.restart()
|
|||||||
eq(#quits, 2, "a bridge-less APK quits cleanly instead of crashing")
|
eq(#quits, 2, "a bridge-less APK quits cleanly instead of crashing")
|
||||||
eq(quits[2].n, 0, "again with no restart argument")
|
eq(quits[2].n, 0, "again with no restart argument")
|
||||||
|
|
||||||
|
-- iOS: no process-kill bridge; never quit("restart")
|
||||||
|
osName = "iOS"
|
||||||
|
HostShell.restart()
|
||||||
|
eq(#quits, 3, "iOS HostShell.restart still quits once")
|
||||||
|
eq(quits[3].n, 0, "iOS uses a bare quit(), never quit(\"restart\")")
|
||||||
|
|
||||||
-- desktop (no AppImage in a test environment) keeps the in-process restart
|
-- desktop (no AppImage in a test environment) keeps the in-process restart
|
||||||
if not os.getenv("APPIMAGE") then
|
if not os.getenv("APPIMAGE") then
|
||||||
osName = "OS X"
|
osName = "OS X"
|
||||||
HostShell.restart()
|
HostShell.restart()
|
||||||
eq(quits[3] and quits[3].arg, "restart",
|
eq(quits[4] and quits[4].arg, "restart",
|
||||||
"non-Android still restarts in-process")
|
"non-mobile still restarts in-process")
|
||||||
end
|
end
|
||||||
|
|
||||||
T.finish("host_restart_android_bug575")
|
T.finish("host_restart_android_bug575")
|
||||||
|
|||||||
@@ -64,12 +64,17 @@ do
|
|||||||
Game.linkFetch = {
|
Game.linkFetch = {
|
||||||
release = function(id) jobs[id] = nil end,
|
release = function(id) jobs[id] = nil end,
|
||||||
}
|
}
|
||||||
|
local before = Game
|
||||||
SessionLifecycle.endGameSession(Game)
|
SessionLifecycle.endGameSession(Game)
|
||||||
check(type(Game.load) == "function",
|
check(type(before.load) == "function",
|
||||||
"endGameSession leaves Game.load intact for the next bootGame")
|
"endGameSession reset leaves methods on the old table")
|
||||||
check(package.loaded["src.core.Game"] == Game
|
check(package.loaded["src.core.Game"] == nil,
|
||||||
or type((package.loaded["src.core.Game"] or {}).load) == "function",
|
"endGameSession drops Gen1 Game from package.loaded")
|
||||||
"Gen1 Game module remains require-able after endGameSession")
|
local again = require("src.core.Game")
|
||||||
|
check(type(rawget(again, "load")) == "function",
|
||||||
|
"require rebuilds a bootable Gen1 Game after endGameSession")
|
||||||
|
check(again ~= before, "Play-again uses a fresh Gen1 Game module table")
|
||||||
|
Game = again
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---- Game2:reset releases world GPU and present canvases ------------------
|
-- ---- Game2:reset releases world GPU and present canvases ------------------
|
||||||
|
|||||||
@@ -197,6 +197,16 @@ check(source("src/update/Check.lua"):find("registerProcessShutdown(Check.shutdow
|
|||||||
check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil,
|
check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil,
|
||||||
"Fetch registers its shutdown hook at load")
|
"Fetch registers its shutdown hook at load")
|
||||||
|
|
||||||
|
-- iOS EXIT GAME must share Android's in-process returnToLauncher: love.cpp
|
||||||
|
-- under LOVE_IOS forces DONE_RESTART for every quit and warns that leftover
|
||||||
|
-- threads make that restart unreliable (ChipAudio / Fetch / Check).
|
||||||
|
check(quitHook:find('osName == "Android" or osName == "iOS"', 1, true) ~= nil,
|
||||||
|
"love.quit treats Android and iOS as in-process return platforms")
|
||||||
|
check(quitHook:find("inProcessReturn", 1, true) ~= nil,
|
||||||
|
"love.quit gates returnToLauncher on inProcessReturn")
|
||||||
|
check(quitHook:find('require("src.core.HostShell").restart()', 1, true) ~= nil,
|
||||||
|
"desktop return-to-launcher still reaches HostShell.restart")
|
||||||
|
|
||||||
-- The Android half: LOVE keeps the JVM process after the native main returns,
|
-- The Android half: LOVE keeps the JVM process after the native main returns,
|
||||||
-- so the quit event exits the process outright. It has to sit after the
|
-- so the quit event exits the process outright. It has to sit after the
|
||||||
-- love.quit() veto test, or the editor's abort-quit path would die on a quit
|
-- love.quit() veto test, or the editor's abort-quit path would die on a quit
|
||||||
|
|||||||
Reference in New Issue
Block a user