Implement session teardown and resource management for in-process transitions

- Introduced `teardownMountedSession` to handle cleanup of mounted versions, generated data, and mod states during transitions between the editor and launcher.
- Added `flushEditorPackageLoaded` to evict save-editor modules from `package.loaded` dynamically, ensuring a clean state for subsequent sessions.
- Implemented `Game:reset` and `Game2:reset` methods to clear session-specific fields, allowing for a fresh start when returning to the launcher.
- Enhanced `Renderer` and `TileRenderer` to release GPU resources immediately, preventing memory leaks during rapid transitions.
- Updated `MagnetTrainRide` to support OAM priority overlays and manage background rendering with new shader functionality.

This commit improves the stability and performance of the application during in-process transitions, particularly on Android.

fixes Yellow color palette for title screen

fixes #1662 #1643 #1536 and finishes fixing #1597
This commit is contained in:
1jamie
2026-08-22 17:08:14 -05:00
parent d54f9a02e0
commit 16eefcde68
16 changed files with 1090 additions and 215 deletions
@@ -77,4 +77,26 @@ do
check(Runtime.modRequire == nil, "Runtime.reset clears modRequire")
end
-- 4. returnToLauncher / closeEditor also clear Assets.loader (orphaned Loader)
do
local Assets = require("src.render.Assets")
Assets.installLoader({
overrideOrder = function() return { { id = "x", path = "mods/x" } } end,
derivedPath = function() return nil end,
})
check(Assets.loader ~= nil, "Assets.loader installed for the session")
Assets.installLoader(nil)
check(Assets.loader == nil, "session teardown clears Assets.loader")
end
-- 5. Gen1 Game:reset exists so main.lua need not guess field names
do
local Game = require("src.core.Game")
check(type(Game.reset) == "function", "Game:reset is defined for in-process return")
Game.mods = { stale = true }
Game:reset()
check(Game.mods == nil, "Game:reset clears session fields")
check(type(Game.load) == "function", "Game:reset keeps methods")
end
T.finish("android_exit_to_launcher_test")
@@ -57,6 +57,36 @@ eq(love.filesystem.read("assets/generated/fonts/font.png"), "font-bytes",
eq(love.filesystem.read("data/generated/constants.lua"), "return {}",
"post-mount probe reads red data at the un-prefixed path")
-- unmountVersion peels generated overlays (LIFO) then the version folder, so
-- a later Play/Edit on another game cannot resolve this version's files.
do
local mounts = love.filesystem._mounts
local archives = {}
for _, m in ipairs(mounts) do archives[#archives + 1] = m.archive end
local function indexOf(name)
for i, a in ipairs(archives) do if a == name then return i end end
end
local dataIdx = indexOf("red/data/generated")
local assetsIdx = indexOf("red/assets/generated")
check(dataIdx and assetsIdx, "mountVersion recorded generated-tree overlays")
check(assetsIdx > dataIdx,
"assets/generated was mounted after data/generated (LIFO unmount peels it first)")
end
check(CacheFs.unmountVersion("red") == true, "unmountVersion(red) returns true")
check(love.filesystem.read("assets/generated/fonts/font.png") == nil,
"post-unmount probe no longer sees red assets at the un-prefixed path")
check(love.filesystem.read("data/generated/constants.lua") == nil,
"post-unmount probe no longer sees red data at the un-prefixed path")
do
local left = {}
for _, m in ipairs(love.filesystem._mounts) do
if tostring(m.archive):find("red", 1, true) then
left[#left + 1] = m.archive
end
end
eq(#left, 0, "no red/ mounts remain after unmountVersion")
end
-- no legacy cache at all: migration is a no-op, not an error
love.filesystem.remove("red/rom-cache.complete")
love.filesystem.remove("red/data/generated/maps.lua")
@@ -0,0 +1,111 @@
-- In-process launcher session teardown: Game:reset, Renderer canvas release,
-- Runtime/Assets/LegacyCompat cleanup, and editor package.loaded discovery flush.
-- luajit tests/engine/launcher_session_teardown_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Runtime = require("src.mods.Runtime")
local Assets = require("src.render.Assets")
local LegacyCompat = require("src.mods.LegacyCompat")
local Game = require("src.core.Game")
local Renderer = require("src.render.Renderer")
local StateStack = require("src.core.StateStack")
-- ---- Game:reset drops instance state, keeps methods ----------------------
do
StateStack:init()
StateStack:push({ name = "stale" })
Game.mods = { id = "orphan" }
Game.save = { money = 1 }
Game.network = { live = true } -- future field: must not need a whitelist
Game.stack = StateStack
Game.renderer = Renderer
Renderer.canvas = love.graphics.newCanvas(8, 8)
Game:reset()
check(type(Game.load) == "function", "Game:reset keeps methods")
check(type(Game.reset) == "function", "Game:reset keeps itself")
check(Game.mods == nil, "Game:reset clears mods")
check(Game.save == nil, "Game:reset clears save")
check(Game.network == nil, "Game:reset clears arbitrary future fields")
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")
end
-- ---- Renderer:init releases prior canvases before realloc ----------------
do
local first = love.graphics.newCanvas(16, 16)
Renderer.canvas = first
Renderer.battleHUDCanvas = love.graphics.newCanvas(16, 16)
Renderer.worldCanvas = love.graphics.newCanvas(16, 16)
Renderer.uprightCanvas = love.graphics.newCanvas(16, 16)
Renderer:init()
check(first.released == true,
"Renderer:init Object:release()s the previous primary canvas")
check(Renderer.canvas ~= nil and Renderer.canvas ~= first,
"Renderer:init allocates a fresh primary canvas")
check(Renderer.canvas.released ~= true,
"the new primary canvas is not released")
-- second init also releases the one just created
local second = Renderer.canvas
Renderer:init()
check(second.released == true,
"a second Renderer:init releases the canvas from the prior init")
end
-- ---- Shared singleton teardown contract (closeEditor / returnToLauncher)
do
Runtime.install({ emit = function() end }, { call = function() end }, { "e" })
Assets.installLoader({
overrideOrder = function() return {} end,
derivedPath = function() return nil end,
})
LegacyCompat.reports = { some_mod = { order = {} } }
-- Mirrors main.lua teardownMountedSession without mounting CacheFs.
require("src.core.Data"):unloadGenerated()
Runtime.reset()
Assets.installLoader(nil)
LegacyCompat.reset()
check(Runtime.errors == nil, "teardown clears Runtime.errors")
check(Assets.loader == nil, "teardown clears Assets.loader")
eq(next(LegacyCompat.reports), nil, "teardown clears LegacyCompat.reports")
end
-- ---- Editor package.loaded discovery flush (no panel whitelist) ---------
do
love.filesystem.write("tools/save-editor/App.lua", "return {}")
love.filesystem.write("tools/save-editor/panels/NewPanel.lua", "return {}")
package.loaded["App"] = { stale = true }
package.loaded["NewPanel"] = { stale = true }
package.loaded["src.core.Data"] = package.loaded["src.core.Data"] -- keep
local function isEditorFlat(name)
if name:find("[./]") then return false end
return love.filesystem.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil
or love.filesystem.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil
end
for k in pairs(package.loaded) do
if type(k) == "string"
and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then
package.loaded[k] = nil
end
end
check(package.loaded["App"] == nil, "discovery flush drops flat App")
check(package.loaded["NewPanel"] == nil,
"discovery flush drops a new panel without a hardcoded list")
check(package.loaded["src.core.Data"] ~= nil,
"discovery flush leaves engine modules alone")
love.filesystem.remove("tools/save-editor/App.lua")
love.filesystem.remove("tools/save-editor/panels/NewPanel.lua")
end
T.finish("launcher_session_teardown_test")
+12
View File
@@ -266,6 +266,18 @@ do
"and with no hook at all the special is a no-op, not a clobber")
end
-- ---- MagnetTrainRide UI screen --------------------------------------------
do
local MagnetTrainRide = require("src.ui.gen2.MagnetTrainRide")
check(MagnetTrainRide.isOpaque, "MagnetTrainRide is opaque")
local ride = MagnetTrainRide.new(nil, { toGoldenrod = false })
check(ride:wantsFillScale(), "wantsFillScale returns true")
check(ride:drawsWidescreen(), "drawsWidescreen returns true")
check(type(ride.drawBands) == "function", "drawBands exists")
check(type(ride.backgrounds) == "function", "backgrounds exists for OAM_PRIO")
end
-- ---- the arrival, from `warpcheck` to the officer's line -------------------
--
-- The ride is only half the feature: the two station scripts end
+3 -1
View File
@@ -56,7 +56,9 @@ stub.graphics = {
end,
newQuad = function(x, y, w, h) return { x = x, y = y, w = w, h = h } end,
newCanvas = function(w, h)
return setmetatable({ w = w, h = h, setFilter = noop }, Image)
local canvas = setmetatable({ w = w, h = h, setFilter = noop, released = false }, Image)
function canvas:release() self.released = true end
return canvas
end,
newSpriteBatch = function(image, size)
local batch = { image = image, sprites = {} }
+22
View File
@@ -143,6 +143,28 @@ local ok, err = pcall(function()
check(#report.lostMons == 0 and probe.party[1].species == "EDITMON",
"validate keeps the modded mon while the mod is enabled")
-- Second editor session in the same process (launcher Close → Edit again):
-- host teardown must leave Runtime/Assets clean and Data unloaded so the
-- next App.load remounts mods instead of serving vanilla catalogs.
App.unload()
if Data._pristineKeys or Data.pokemon then Data:unloadGenerated() end
Runtime.reset()
Assets.installLoader(nil)
package.loaded["App"] = nil
package.loaded["Catalog"] = nil
App = require("App")
App.load(tmpPath)
S = App.getState()
loaded = S.mods:status().loaded
check(#loaded == 1 and loaded[1].id == "zz_editor_fixture",
"fixture mod loads again after host-style teardown")
hasSpecies = false
for _, id in ipairs(S.cat.species) do
if id == "EDITMON" then hasSpecies = true end
end
check(hasSpecies, "second App.load catalog still carries the mod's mon")
check(Data.pokemon.EDITMON ~= nil, "second merge lands EDITMON in Data again")
-- Gold-targeted mods: spa/spd records are usable, and a Gen 1-only
-- manifest stays out of Gold (no ROM cache / App.load("gold") required).
local ModTargets = require("src.mods.ModTargets")
+48
View File
@@ -0,0 +1,48 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("yellow title palette")
local check, eq = S.check, S.eq
local GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX")
local TitleState = require("src.ui.TitleState")
GameVersion.set("yellow")
local prevMode = PaletteFX.mode
-- Test 1: SGB mode
PaletteFX.setMode("gbc")
local mewmonSgb = PaletteFX.pal(nil, "MEWMON")
check(mewmonSgb ~= nil, "MEWMON palette exists in SGB mode on Yellow")
-- In Yellow SGB SuperPalettes: MEWMON color 1 is Yellow {255, 247, 181}, color 2 is Red {222, 132, 132}
eq(mewmonSgb[1][1], 255, "SGB: Color 0 is white R=255")
eq(mewmonSgb[2][1], 255, "SGB: Color 1 is yellow R=255")
eq(mewmonSgb[3][1], 222, "SGB: Color 2 is red R=222")
-- Test 2: ADVANCED mode (redpp)
PaletteFX.setMode("redpp")
local mewmonAdv = PaletteFX.pal(nil, "MEWMON")
check(mewmonAdv ~= nil, "MEWMON palette exists in ADVANCED mode on Yellow")
-- Must NOT be Red++'s Mew purple {115, 33, 165}! It must be Yellow's Pikachu palette!
eq(mewmonAdv[1][1], 255, "ADVANCED: Color 0 is white R=255 (Pikachu eye sclera)")
eq(mewmonAdv[2][1], 255, "ADVANCED: Color 1 is yellow R=255 (Pikachu body)")
check(mewmonAdv[3][3] < 150, "ADVANCED: Color 2 is not purple (B < 150, red cheeks)")
-- Test 3: OG YELLOW mode (ogred on Yellow)
PaletteFX.setMode("ogred")
local mewmonOg = PaletteFX.pal(nil, "MEWMON")
check(mewmonOg ~= nil, "MEWMON palette exists in OG YELLOW mode")
eq(mewmonOg[1][1], 255, "OG YELLOW: Color 0 is white")
eq(mewmonOg[2][1], 255, "OG YELLOW: Color 1 is yellow")
-- Test 4: TitleState sgbPalettes
local fakeGame = { data = { palettes = PaletteFX.yellowPack() } }
local ts = { yellowLayout = true }
local zones = TitleState.sgbPalettes(ts, fakeGame)
check(zones ~= nil and #zones >= 3, "TitleState produces zones for Yellow")
eq(zones[1].colors, PaletteFX.pal(fakeGame.data, "LOGO2"), "Zone 1 uses LOGO2")
eq(zones[2].colors, PaletteFX.pal(fakeGame.data, "MEWMON"), "Zone 2 uses MEWMON (Pikachu yellow/red)")
PaletteFX.setMode(prevMode)
GameVersion.set("red")
S.finish()