diff --git a/main.lua b/main.lua index d5343f2f..f02e078b 100644 --- a/main.lua +++ b/main.lua @@ -230,6 +230,26 @@ end local launchedIntoGame = false local RELAUNCH_MARKER = "relaunch_to_launcher.txt" local launchOptionsSuppressed = false +local quitToLauncher = false +local processEnded = false + +local function endProcessOnce() + if processEnded then return end + processEnded = true + pcall(function() + require("src.core.DiscordPresence").shutdown() + end) + require("src.core.SessionLifecycle").endProcess() +end + +local function readRelaunchHandoff() + if not love.filesystem.getInfo(RELAUNCH_MARKER) then return nil end + local raw = love.filesystem.read(RELAUNCH_MARKER) + pcall(love.filesystem.remove, RELAUNCH_MARKER) + local data = require("src.core.SaveSerializer").decode(raw or "", + { maxBytes = 65536 }) + return type(data) == "table" and data or {} +end local onlineClient, onlineClientResolved local function onlineClientModule() @@ -506,58 +526,25 @@ local function makeLauncher(launcherOpts) end local function returnToLauncher(opts) - if not Game then return end + if not Game or quitToLauncher then return end + quitToLauncher = true + opts = opts or {} + local handoff = { tab = opts.tab, invite = opts.invite, request = opts.request } + local okEncode, body = pcall(require("src.core.SaveSerializer").encode, handoff) + pcall(love.filesystem.write, RELAUNCH_MARKER, okEncode and body or "return {}\n") - if require("src.core.RequireGuard").repair() then - print("boot: restored love.filesystem searcher (see #2001)") - end - - local GameVersion = require("src.core.GameVersion") - local currentVersion = GameVersion.get() SessionLifecycle.endGameSession(Game) Game = nil - pcall(function() require("src.online.Trade").hostIsLive = nil end) - local syncEngine = package.loaded["src.sync.SyncEngine"] - if type(syncEngine) == "table" and type(syncEngine._shared) == "table" then - pcall(syncEngine._shared.protectPlaythrough, syncEngine._shared, nil, nil) - end autopilot = nil driverCo = nil - -- Leave the cart's scope behind: the launcher's own settings and slots are - -- the base game's, not the cart's. The speed ladder is cart state too, so - -- a 1x/2x cart must not pin the launcher or the next game. - local SaveData = require("src.core.SaveData") - local cartId = SaveData.getCart() - SaveData.setCart(nil) - require("src.core.GameSpeed").setAllowed(nil) - - SessionLifecycle.endMountedSession(currentVersion) - - -- Slot lists are resolved once per process. Invalidate only the game - -- (and cart, if any) we just left so the new launcher can migrate a flat - -- in-game SAVE into a visible slot -- nothing else is rewritten. - SaveData.refreshSlotResolution(currentVersion) - if cartId then SaveData.refreshSlotResolution("cart_" .. cartId) end - - applySavedOrientation() - - local preload = require("src.mods.LauncherMods").translationStrings() - if preload then require("src.core.Strings").load({ strings = preload }) end - - if love.window and love.window.setTitle then - local Version = require("src.core.Version") - love.window.setTitle(Version.title("Gen 1 Recompilation Project")) - end - - Importer = makeLauncher({ initialTab = opts and opts.tab or nil, - invite = opts and opts.invite or nil }) - -- Finger that confirmed EXIT GAME is often still down over Import Save. - if Importer.ignoreReturningPointer then - Importer:ignoreReturningPointer() - end + endProcessOnce() + require("src.core.HostShell").restart() end local pendingLauncherReturn +local driverHost = { + returnToLauncher = function(o) pendingLauncherReturn = o or {} end, +} function bootGame(version, cartId, opts) opts = opts or {} @@ -701,19 +688,19 @@ local function startLaunchRequest(request) return true end + if Game then + returnToLauncher({ request = request }) + return true + end + local version = request.game if request.launcher or not version then - if Game then - returnToLauncher({ tab = version }) - else - showLauncher(version) - end + showLauncher(version) autoUpdateMods(request, not version and "mods" or nil) return true end local RomImporter = require("src.import.RomImporter") - if Game then returnToLauncher() end Importer = nil if Prelaunch then return true end @@ -873,6 +860,10 @@ function love.load(args) scriptedOpts = { arena = spec } end + local handoff = readRelaunchHandoff() + local relaunched = handoff ~= nil + if relaunched and os.getenv("POKEPORT_DRIVER") then scripted = nil end + if scripted then if forceImport or not ready then -- The importer detects the dropped/loaded ROM's version by SHA-1 and @@ -917,11 +908,7 @@ function love.load(args) -- 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 - launchOptionsSuppressed = true - pcall(love.filesystem.remove, RELAUNCH_MARKER) - end + if relaunched then launchOptionsSuppressed = true end if not relaunched and resolvedLaunch.game and not resolvedLaunch.launcher and startLaunchRequest(resolvedLaunch) then @@ -937,12 +924,23 @@ function love.load(args) -- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold -- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md). -- Edit on a save row opens the bundled editor on that slot (openEditor). - Importer = makeLauncher() if not relaunched then + Importer = makeLauncher() launcherSplash = require("src.import.LauncherSplash").new() autoUpdateMods(resolvedLaunch, not resolvedLaunch.game and "mods" or nil) + return end + Importer = makeLauncher({ + initialTab = type(handoff.tab) == "string" and handoff.tab or nil, + invite = type(handoff.invite) == "string" and handoff.invite or nil, + }) + if Importer.ignoreReturningPointer then Importer:ignoreReturningPointer() end + local driverPath = os.getenv("POKEPORT_DRIVER") + if driverPath then + driverCo = coroutine.create(assert(loadfile(driverPath))()) + end + if type(handoff.request) == "table" then startLaunchRequest(handoff.request) end end function love.update(dt) @@ -986,6 +984,18 @@ function love.update(dt) if Importer then require("src.import.LauncherWindow").observe(dt) Importer._inputBlocked = splashBlocksInput() + if driverCo and not Game then + local ok, err = coroutine.resume(driverCo, Importer) + 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 + end return Importer:update(dt) end if not Game then return end @@ -1010,7 +1020,7 @@ function love.update(dt) local i = 0 while i < iterations do i = i + 1 - local ok, err = coroutine.resume(driverCo, Game) + local ok, err = coroutine.resume(driverCo, Game, driverHost) if not ok then print("driver error: " .. tostring(err)) love.event.quit(1) @@ -1563,12 +1573,6 @@ function love.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 launcherSplash then launcherSplash:release(); launcherSplash = nil end if Importer and Importer._themeVideo then @@ -1605,36 +1609,17 @@ function love.quit() -- 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. - -- - -- 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 mobile = (osName == "Android" or osName == "iOS") local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function() return Game and not Importer and not quitToLauncher and not scripted - and (inProcessReturn or not launchedIntoGame) + and (mobile or not launchedIntoGame) end) if wouldReturnToLauncher then - if inProcessReturn then - returnToLauncher() - return true -- abort this quit; stay in the same LOVE run - end - 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 + returnToLauncher() + return true end - pcall(function() - require("src.core.DiscordPresence").shutdown() - end) - SessionLifecycle.endProcess() + endProcessOnce() end function love.filedropped(file) @@ -1725,7 +1710,7 @@ function love.run() -- 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 + if a ~= "restart" and love.system and love.system.getOS() == "Android" then os.exit(a or 0) end return a or 0 diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 56ec8f40..90f41061 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -248,42 +248,18 @@ end -- ("Failed to initialize filesystem: already initialized") and the relaunch -- crashes. So on an AppImage we relaunch the executable; the fresh process's -- Boot step mounts any downloaded update exactly as a manual relaunch would. --- Android hits the same wall (#575): the vendored love.cpp loops runlove() --- in-process on "restart", and PHYSFS_deinit in the old Filesystem module's --- destructor fails ("files still open") whenever any physfs handle survives --- lua_close, so the second PHYSFS_init throws the same "already initialized" --- and the app dies. There we relaunch through the GameActivity.restartApp --- JNI bridge (love.system.restartApp), which schedules our launch intent --- and kills the process so no native state can leak into the fresh run. --- 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. +-- Android (#575) and iOS restart in-process: love.quit joins every worker +-- first, so no physfs handle outlives lua_close. iOS turns every quit into +-- DONE_RESTART in love.cpp, so a bare quit() is the restart there. function HostShell.restart() if not (love and love.event and love.event.quit) then return end local osName = love.system and love.system.getOS and love.system.getOS() if osName == "Android" then - -- restartApp kills the process on success, so a true return is never - -- observed; false means the bridge could not schedule the relaunch. - -- An older APK whose liblove predates the bridge (love.system.restartApp - -- is nil) has no crash-free in-process restart, so quit to the OS - -- cleanly and let the player relaunch by hand -- worse than restarting, - -- but better than the guaranteed crash of quit("restart") (#575). - if love.system.restartApp and love.system.restartApp() then return end - love.event.quit() + love.event.quit("restart") return 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 diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index e7ab2d2e..d1898a25 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -420,12 +420,10 @@ local function discoverModSchemas(opts) local flag = require("src.core.SaveData").modEnabled(opts, m.id) local enabled = flag == true or (flag == nil and not m.experimental) if enabled and not SaveData.isSafeMode(opts) then - local chunk = fs.load(path .. "/" .. m.options_schema) - if chunk then - local okR, schema = pcall(chunk) - if okR and type(schema) == "table" then - out[#out + 1] = { id = m.id, name = m.name or m.id, schema = schema } - end + local rel = path .. "/" .. m.options_schema + local schema = require("src.mods.Sandbox").evalData(fs.read(rel), "@" .. rel) + if schema then + out[#out + 1] = { id = m.id, name = m.name or m.id, schema = schema } end end end diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index e627edaa..af8fc963 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -589,15 +589,7 @@ local function readStringsCatalog(path) local fs = love and love.filesystem if not (fs and fs.read) then return nil end local rel = path .. "/" .. STRINGS_CATALOG - local raw = fs.read(rel) - if type(raw) ~= "string" or raw == "" then return nil end - local chunk = loadstring(raw, "@" .. rel) - if not chunk then return nil end - -- Lua 5.1/LuaJIT: no _ENV, so setfenv is the sandbox. - if setfenv then setfenv(chunk, {}) end - local ok, result = pcall(chunk) - if not ok or type(result) ~= "table" then return nil end - return result + return require("src.mods.Sandbox").evalData(fs.read(rel), "@" .. rel) end -- deriveStrings(rows, byId, read) -> the merged catalog, pure. diff --git a/src/mods/Sandbox.lua b/src/mods/Sandbox.lua index d6f8b701..6cde6419 100644 --- a/src/mods/Sandbox.lua +++ b/src/mods/Sandbox.lua @@ -293,6 +293,15 @@ function Sandbox.loadFile(fs, path, env) return Sandbox.compile(source, "@" .. path, env) end +function Sandbox.evalData(source, chunkname) + if type(source) ~= "string" or source == "" then return nil end + local chunk = Sandbox.compile(source, chunkname, {}) + if not chunk then return nil end + local ok, result = pcall(chunk) + if ok and type(result) == "table" then return result end + return nil +end + Sandbox.safePath = SafePath.safe Sandbox.requirePath = SafePath.require diff --git a/tests/engine/host_restart_android_bug575.lua b/tests/engine/host_restart_android_bug575.lua index 3d5c7dc0..8eba0487 100644 --- a/tests/engine/host_restart_android_bug575.lua +++ b/tests/engine/host_restart_android_bug575.lua @@ -1,13 +1,8 @@ --- #575: HostShell.restart on Android must never reach love.event.quit --- ("restart") -- the vendored love.cpp loops runlove() in-process on --- "restart" and the second PHYSFS_init crashes ("already initialized"). --- 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 --- whose liblove lacks the bridge, falls back to a CLEAN quit with no --- 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"). +-- #575: Android restarts in-process. The restartApp alarm relaunch is a +-- background activity start that Android 14 blocks, so the app closed to the +-- home screen instead of coming back; love.quit joins every worker before +-- the restart, so the second PHYSFS_init no longer finds open handles. +-- iOS turns every quit into DONE_RESTART, so it keeps a bare quit(). -- luajit tests/engine/host_restart_android_bug575.lua package.path = "./?.lua;./?/init.lua;" .. package.path @@ -20,8 +15,6 @@ local HostShell = require("src.core.HostShell") local quits = {} love.event = { - -- record the argument distinctly from "called with none": quit() and - -- quit("restart") are the whole difference this test pins quit = function(...) quits[#quits + 1] = { n = select("#", ...), arg = (...) } end, @@ -31,38 +24,28 @@ local osName = "Android" local restartCalls = 0 love.system = love.system or {} love.system.getOS = function() return osName end - --- bridge present and schedulable: restart goes through it, quit untouched love.system.restartApp = function() restartCalls = restartCalls + 1 return true end -HostShell.restart() -eq(restartCalls, 1, "Android restart prefers the restartApp bridge (#575)") -eq(#quits, 0, "a scheduled relaunch never touches love.event.quit") --- bridge present but could not schedule: clean quit, never quit("restart") -love.system.restartApp = function() restartCalls = restartCalls + 1 return false end HostShell.restart() -eq(restartCalls, 2, "the bridge is still tried first") -eq(#quits, 1, "a failed schedule falls back to one quit") -eq(quits[1].n, 0, "and it is a bare quit(), not quit(\"restart\")") +eq(restartCalls, 0, "Android never schedules the blocked restartApp relaunch") +eq(#quits, 1, "Android restart quits once") +eq(quits[1].arg, "restart", "and it is the in-process quit(\"restart\")") --- old APK, no bridge compiled in: same clean quit fallback -love.system.restartApp = nil -HostShell.restart() -eq(#quits, 2, "a bridge-less APK quits cleanly instead of crashing") -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\")") +eq(#quits, 2, "iOS HostShell.restart quits once") +eq(quits[2].n, 0, "iOS uses a bare quit(), which love.cpp turns into a restart") --- desktop (no AppImage in a test environment) keeps the in-process restart if not os.getenv("APPIMAGE") then osName = "OS X" HostShell.restart() - eq(quits[4] and quits[4].arg, "restart", - "non-mobile still restarts in-process") + eq(quits[3] and quits[3].arg, "restart", "desktop restarts in-process") end +local f = assert(io.open("main.lua", "rb")) +local mainSrc = f:read("*a") +f:close() +check(mainSrc:find('a ~= "restart" and love.system and love.system.getOS() == "Android"', 1, true) ~= nil, + "love.run lets an Android quit(\"restart\") reach LOVE's boot loop instead of os.exit") + T.finish("host_restart_android_bug575") diff --git a/tests/engine/launcher_return_pointer_bug2079.lua b/tests/engine/launcher_return_pointer_bug2079.lua index 2a865248..c9dbdfcd 100644 --- a/tests/engine/launcher_return_pointer_bug2079.lua +++ b/tests/engine/launcher_return_pointer_bug2079.lua @@ -116,9 +116,9 @@ end do local main = assert(io.open("main.lua")):read("*a") - local body = main:match("local function returnToLauncher%(opts%)(.-)\nend\n") - check(body ~= nil, "main.lua still has returnToLauncher") - check(body:find("ignoreReturningPointer", 1, true) ~= nil, + local body = main:match("Importer = makeLauncher%(%{%s*initialTab = type%(handoff%.tab%)(.-)\nend\n") + check(body ~= nil, "main.lua rebuilds the launcher from the relaunch handoff") + check(body and body:find("ignoreReturningPointer", 1, true) ~= nil, "EXIT GAME asks the new launcher to ignore the leftover pointer (#2079)") end diff --git a/tests/engine/quit_thread_shutdown.lua b/tests/engine/quit_thread_shutdown.lua index 8f3fe744..3f0a2bf6 100644 --- a/tests/engine/quit_thread_shutdown.lua +++ b/tests/engine/quit_thread_shutdown.lua @@ -196,8 +196,10 @@ check(checkSrc:match('cmd%.cmd == "quit"%s*then%s*\n%s*break') ~= nil, local mainSrc = source("main.lua") local quitHook = mainSrc:match("\nfunction love%.quit%(%).-\nend\n") check(quitHook ~= nil, "love.quit is still a single top-level function") -check(mainSrc:find("SessionLifecycle.endProcess()", 1, true) ~= nil, - "love.quit shuts workers down via SessionLifecycle.endProcess") +check(mainSrc:find('require("src.core.SessionLifecycle").endProcess()', 1, true) ~= nil, + "endProcessOnce shuts workers down via SessionLifecycle.endProcess") +check(quitHook and quitHook:find("endProcessOnce()", 1, true) ~= nil, + "love.quit shuts workers down via endProcessOnce") local lifecycleSrc = source("src/core/SessionLifecycle.lua") check(lifecycleSrc:find("registerProcessShutdown", 1, true) ~= nil, @@ -213,15 +215,15 @@ check(source("src/update/Check.lua"):find("registerProcessShutdown(Check.shutdow check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil, "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") +local returnBody = mainSrc:match("local function returnToLauncher%(opts%)(.-)\nend\n") or "" +check(quitHook:find("returnToLauncher()", 1, true) ~= nil, + "love.quit routes a game window close to returnToLauncher on every platform") +check(quitHook:find("inProcessReturn", 1, true) == nil, + "no platform keeps the old in-process launcher swap") +local joinAt = returnBody:find("endProcessOnce()", 1, true) +local restartAt = returnBody:find('require("src.core.HostShell").restart()', 1, true) +check(joinAt ~= nil and restartAt ~= nil and joinAt < restartAt, + "returnToLauncher joins every worker before HostShell.restart") -- 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 diff --git a/tests/engine/require_guard_test.lua b/tests/engine/require_guard_test.lua index e01b84ec..9611658a 100644 --- a/tests/engine/require_guard_test.lua +++ b/tests/engine/require_guard_test.lua @@ -90,8 +90,8 @@ check(bootBody:find('require("src.core.RequireGuard").repair()', 1, true) ~= nil local returnBody = functionBody("local function returnToLauncher%(") check(returnBody ~= "", "main.lua still defines returnToLauncher") -check(returnBody:find('require("src.core.RequireGuard").repair()', 1, true) ~= nil, - "returnToLauncher repairs the loader chain") +check(returnBody:find('require("src.core.HostShell").restart()', 1, true) ~= nil, + "returnToLauncher restarts into a fresh Lua state, whose love.load captures the loader chain again") local issueBody = readFile("src/core/IssueReport.lua") or "" check(issueBody:find("RequireGuard", 1, true) ~= nil diff --git a/tests/engine/save_slots.lua b/tests/engine/save_slots.lua index 00054006..2a70ffc8 100644 --- a/tests/engine/save_slots.lua +++ b/tests/engine/save_slots.lua @@ -335,8 +335,8 @@ do local main = assert(io.open("main.lua")):read("*a") local body = main:match("local function returnToLauncher%(opts%)(.-)\nend\n") T.check(body ~= nil, "main.lua still has returnToLauncher") - T.check(body:find("refreshSlotResolution%(currentVersion%)", 1, false) ~= nil, - "EXIT GAME refreshes only the version just left") + T.check(body and body:find('require("src.core.HostShell").restart()', 1, true) ~= nil, + "EXIT GAME restarts, so the launcher resolves every slot list fresh") T.check(body:find("resetSlotState", 1, true) == nil, "and does not call resetSlotState (that would clear cart/seal state)") end diff --git a/tests/engine/sync_engine_test.lua b/tests/engine/sync_engine_test.lua index e97d6885..a8b822c1 100644 --- a/tests/engine/sync_engine_test.lua +++ b/tests/engine/sync_engine_test.lua @@ -231,8 +231,12 @@ do local main = assert(io.open("main.lua")):read("*a") local body = main:match("local function returnToLauncher%(opts%)(.-)\nend\n") T.check(body ~= nil, "main.lua still has returnToLauncher") - T.check(body and body:find("protectPlaythrough", 1, true) ~= nil, - "and returning to the launcher clears the played key on the shared engine") + T.check(body and body:find('require("src.core.HostShell").restart()', 1, true) ~= nil, + "and returning to the launcher restarts, so no played key survives on the shared engine") + local importer = assert(io.open("src/import/RomImporter.lua")):read("*a") + local pump = importer:match("function RomImporter:_pumpSync%(dt%)(.-)\nend\n") + T.check(pump and pump:find("booted.syncNow", 1, true) ~= nil, + "and the restarted launcher syncs on its first frame") end do