From 16eefcde683824a6aaf41f11a8c433374fbe2a58 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Sat, 22 Aug 2026 17:08:14 -0500 Subject: [PATCH] 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 --- main.lua | 64 +- src/core/Game.lua | 22 + src/core/Game2.lua | 18 + src/import/CacheFs.lua | 30 +- src/render/PaletteFX.lua | 26 +- src/render/Renderer.lua | 20 + src/render/TileRenderer.lua | 7 + src/ui/TradeAnim.lua | 678 ++++++++++++++---- src/ui/gen2/MagnetTrainRide.lua | 191 ++++- .../engine/android_exit_to_launcher_test.lua | 22 + tests/engine/cache_fs_red_migration_test.lua | 30 + .../engine/launcher_session_teardown_test.lua | 111 +++ tests/gen2_magnet_train_test.lua | 12 + tests/love_stub.lua | 4 +- tests/save_editor_mod_tests.lua | 22 + tests/yellow_title_palette_test.lua | 48 ++ 16 files changed, 1090 insertions(+), 215 deletions(-) create mode 100644 tests/engine/launcher_session_teardown_test.lua create mode 100644 tests/yellow_title_palette_test.lua diff --git a/main.lua b/main.lua index 8cbf4c7d..7b6fba4c 100644 --- a/main.lua +++ b/main.lua @@ -82,6 +82,43 @@ end local editorHost, editorVersion, editorWindow local closeEditor -- forward declaration: openEditor hands it to the editor +-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted +-- version session (save editor or game). closeEditor and returnToLauncher +-- both go through here so neither path can forget a singleton the other resets. +local function teardownMountedSession(version) + if version then + require("src.import.CacheFs").unmountVersion(version) + end + require("src.core.Data"):unloadGenerated() + local Runtime = require("src.mods.Runtime") + if Runtime.reset then Runtime.reset() end + local Assets = require("src.render.Assets") + if Assets.installLoader then Assets.installLoader(nil) end + local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat") + if okCompat and LegacyCompat.reset then LegacyCompat.reset() end +end + +-- Evict every save-editor module from package.loaded without a hardcoded +-- panel whitelist. Flat require names (App, Party, …) resolve under +-- tools/save-editor/; path-style keys may also appear. A key is flushed +-- when it names a save-editor path or when tools/save-editor/{panels/}K.lua +-- exists for a flat name K -- new panels are picked up automatically. +local function flushEditorPackageLoaded() + local fs = love and love.filesystem + local function isEditorFlat(name) + if not (fs and fs.getInfo) then return false end + if name:find("[./]") then return false end + return fs.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil + or fs.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 +end + -- 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 @@ -180,9 +217,9 @@ local function openEditor(version, slotId) if EditorApp.unload then pcall(EditorApp.unload) end EditorApp = nil if version then - require("src.import.CacheFs").unmountVersion(version) - require("src.core.Data"):unloadGenerated() + teardownMountedSession(version) end + flushEditorPackageLoaded() restoreWindow() Importer = editorHost editorHost = nil @@ -197,21 +234,17 @@ 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. +-- with this one's data. Also reset Runtime / Assets / LegacyCompat so the +-- next Edit or Play does not inherit the editor's dead mod loader. 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 - for k in pairs(package.loaded) do - if type(k) == "string" and (k:find("save%-editor") or k == "App" or k == "Kit" or k == "State" or k == "Catalog" or k == "SaveIO" or k == "Ops" or k == "MonOps" or k == "ItemOps" or k == "PadInput" or k == "Gen" or k == "Theme") then - package.loaded[k] = nil - end + teardownMountedSession(version) end + flushEditorPackageLoaded() editorVersion = nil restoreWindow() Importer = editorHost @@ -333,16 +366,11 @@ local function returnToLauncher() local GameVersion = require("src.core.GameVersion") local currentVersion = GameVersion.get() - if currentVersion then - require("src.import.CacheFs").unmountVersion(currentVersion) - end - require("src.core.Data"):unloadGenerated() + teardownMountedSession(currentVersion) - local Runtime = require("src.mods.Runtime") - if Runtime.reset then - Runtime.reset() + if Game.reset then + pcall(function() Game:reset() end) end - Game = nil autopilot = nil driverCo = nil diff --git a/src/core/Game.lua b/src/core/Game.lua index 85bedf24..b1b730b9 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1381,4 +1381,26 @@ function Game:restoreCheckpointBattle(battle) if battle.resumeCheckpoint then battle:resumeCheckpoint() end 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. +function Game:reset() + if self.stack and self.stack.clear then + pcall(function() self.stack:clear() end) + end + if self.renderer and self.renderer.releaseCanvases then + pcall(function() self.renderer:releaseCanvases() end) + end + local keys = {} + for key, value in pairs(self) do + if type(value) ~= "function" then + keys[#keys + 1] = key + end + end + for _, key in ipairs(keys) do + self[key] = nil + end +end + return Game diff --git a/src/core/Game2.lua b/src/core/Game2.lua index e5edaa4f..4ab8a479 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -2123,4 +2123,22 @@ function Game2:joystickremoved() TouchControls:joystickremoved() 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. +function Game2:reset() + if self.stack and self.stack.clear then + pcall(function() self.stack:clear() end) + end + local keys = {} + for key, value in pairs(self) do + if type(value) ~= "function" then + keys[#keys + 1] = key + end + end + for _, key in ipairs(keys) do + self[key] = nil + end +end + return Game2 diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index 00ef8e99..4b86d5cb 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -614,14 +614,18 @@ function CacheFs.mountVersion(version) return true end --- Undo mountVersion. A process normally mounts exactly one version and then --- boots it, but the launcher can open the save editor on one game's save, --- close it, and press Play on another: with the first version's subtree --- still prepended, the other's require("data.generated.*") and generated --- art would silently resolve to the first game's files. Callers must also --- drop the generated modules from package.loaded --- (src.core.Data:unloadGenerated) -- unmounting alone only fixes the read --- path, not what require already cached. +-- Undo mountVersion in LIFO order relative to mountVersion: generated-tree +-- overlays first (assets, then data -- reverse of mountGeneratedTrees), then +-- the version folder. PHYSFS resolves by stack order; peeling the wrong +-- layer first can leave another version's generated files winning a name. +-- +-- A process normally mounts exactly one version and then boots it, but the +-- launcher can open the save editor on one game's save, close it, and press +-- Play on another: with the first version's subtree still prepended, the +-- other's require("data.generated.*") and generated art would silently +-- resolve to the first game's files. Callers must also drop the generated +-- modules from package.loaded (src.core.Data:unloadGenerated) -- unmounting +-- alone only fixes the read path, not what require already cached. -- -- Returns true when nothing was mounted or the unmount took. function CacheFs.unmountVersion(version) @@ -633,6 +637,16 @@ function CacheFs.unmountVersion(version) base = love.filesystem.getSaveDirectory() end local done = false + -- LIFO vs mountGeneratedTrees: assets/generated, then data/generated. + if love.filesystem and love.filesystem.unmount then + local generated = { + prefix .. "assets/generated", + prefix .. "data/generated", + } + for _, src in ipairs(generated) do + done = love.filesystem.unmount(src) or done + end + end local fn = resolveUnmount() if fn and base then done = fn(base .. SEP .. sub) or done diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 7accec59..b00d65c3 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -397,9 +397,12 @@ function PaletteFX.uiSpriteRedraws() end -- Active named-palette table for COLORS: RED++ uses data/palettes_gbc.lua, --- everything else uses the ROM-imported data.palettes. +-- Yellow uses data/palettes_yellow.lua, everything else uses the ROM-imported data.palettes. function PaletteFX.pack(data) - if PaletteFX.usesGbcPack() then + if GameVersion.isYellow() then + local y = PaletteFX.yellowPack() + if y then return y end + elseif PaletteFX.usesGbcPack() then local g = PaletteFX.gbcPack() if g then return g end end @@ -449,24 +452,27 @@ function PaletteFX.pal(data, name) local fromRom = romNamedPal(data, name) if fromRom then return fromRom end end - if PaletteFX.usesYellowCgb() then - local fromCgb = yellowCgbNamedPal(data, name) - if fromCgb then return fromCgb end + if GameVersion.isYellow() then + if PaletteFX.usesYellowCgb() then + local fromCgb = yellowCgbNamedPal(data, name) + if fromCgb then return fromCgb end + end + local y = PaletteFX.yellowPack() + local yc = y and y.palettes and y.palettes[name] + if yc then return yc end + local fromRom = romNamedPal(data, name) + if fromRom then return fromRom end end local p = PaletteFX.pack(data) local c = p and p.palettes and p.palettes[name] if c then return c end - if GameVersion.isYellow() then - local y = PaletteFX.yellowPack() - local yc = y and y.palettes and y.palettes[name] - if yc then return yc end - end if PaletteFX.usesGbcPack() then return romNamedPal(data, name) end return nil end + -- the species' palette (data/pokemon/palettes.asm), MEWMON for unknowns. -- transformed forces PAL_GRAYMON (Ditto's palette) regardless of species -- (engine/gfx/palettes.asm DeterminePaletteID: bit TRANSFORMED, a; a diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 412f793c..5ed14d47 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -100,10 +100,30 @@ local function positionLift(ph, contentPx, dpiY, cut) return ScreenPosition.lift(ph, contentPx, ScreenPosition.safeTop() * dpiY) end +-- Free GPU canvases immediately. Overwriting the Lua reference alone leaves +-- VRAM allocated until LOVE's GC runs, which is too slow when Android loops +-- Play → launcher → Play in one process. +local function releaseCanvas(canvas) + if canvas and canvas.release then pcall(canvas.release, canvas) end +end + +function Renderer:releaseCanvases() + releaseCanvas(self.canvas); self.canvas = nil + releaseCanvas(self.battleHUDCanvas); self.battleHUDCanvas = nil + releaseCanvas(self.worldCanvas); self.worldCanvas = nil + releaseCanvas(self.uprightCanvas); self.uprightCanvas = nil + self.worldActive = false + self.uprightActive = false + self.worldOverride = nil +end + function Renderer:init() -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua -- (#208). Every canvas below is sized in framebuffer pixels for the same -- reason -- worldViewSize() already works in drawable pixels. + -- Release any prior session's surfaces before reallocating (in-process + -- return-to-launcher reuses this Renderer singleton). + self:releaseCanvases() self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest") self.battleHUDCanvas = nil diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index da138779..ad29bce5 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -951,6 +951,13 @@ function TileRenderer.invalidate() frameImages = {} toggleImages = {} stripData = {} + -- RED++ per-map atlases are large Images; clear and release so an + -- in-process Play → launcher → Play loop does not keep every prior + -- session's bake in VRAM / Lua heap. + for key, img in pairs(gbcAtlasCache) do + safeRelease(img) + gbcAtlasCache[key] = nil + end end Assets.register(TileRenderer.invalidate) diff --git a/src/ui/TradeAnim.lua b/src/ui/TradeAnim.lua index 5e926626..35353e18 100644 --- a/src/ui/TradeAnim.lua +++ b/src/ui/TradeAnim.lua @@ -34,10 +34,12 @@ local DEFAULT_ART = { cableBall = "assets/generated/trade/cable_ball.png", cableBallAlt = "assets/generated/trade/cable_ball_alt.png", bubble = "assets/generated/trade/bubble.png", + moveAnim0 = "assets/generated/battle/anims/move_anim_0.png", + balls = "assets/generated/battle/balls.png", } local function tryImage(path) - if not path then return nil end + if not (path and love and love.graphics and love.graphics.newImage) then return nil end local ok, img = pcall(love.graphics.newImage, path) return ok and img or nil end @@ -89,6 +91,92 @@ local SEQ = { "done", } +-- FrameBlocks 03..08 definition for move_anim_0.png (128x40, 16 tiles/row, 8x8 per tile) +local FRAME_BLOCKS = { + -- FrameBlock03: Pokeball normal (16x16) + [3] = { + { tile = 2, dx = 0, dy = 0, xflip = false, yflip = false }, + { tile = 2, dx = 8, dy = 0, xflip = true, yflip = false }, + { tile = 18, dx = 0, dy = 8, xflip = false, yflip = false }, + { tile = 18, dx = 8, dy = 8, xflip = true, yflip = false }, + }, + -- FrameBlock04: Pokeball tilted left (16x16) + [4] = { + { tile = 6, dx = 0, dy = 0, xflip = false, yflip = false }, + { tile = 7, dx = 8, dy = 0, xflip = false, yflip = false }, + { tile = 22, dx = 0, dy = 8, xflip = false, yflip = false }, + { tile = 23, dx = 8, dy = 8, xflip = false, yflip = false }, + }, + -- FrameBlock05: Pokeball tilted right (16x16) + [5] = { + { tile = 7, dx = 0, dy = 0, xflip = true, yflip = false }, + { tile = 6, dx = 8, dy = 0, xflip = true, yflip = false }, + { tile = 23, dx = 0, dy = 8, xflip = true, yflip = false }, + { tile = 22, dx = 8, dy = 8, xflip = true, yflip = false }, + }, + -- FrameBlock06: Small Poof smoke cloud (32x32) + [6] = { + { tile = 0x23, dx = 8, dy = 0, xflip = false, yflip = false }, + { tile = 0x32, dx = 0, dy = 8, xflip = false, yflip = false }, + { tile = 0x33, dx = 8, dy = 8, xflip = false, yflip = false }, + { tile = 0x23, dx = 16, dy = 0, xflip = true, yflip = false }, + { tile = 0x33, dx = 16, dy = 8, xflip = true, yflip = false }, + { tile = 0x32, dx = 24, dy = 8, xflip = true, yflip = false }, + { tile = 0x32, dx = 0, dy = 16, xflip = false, yflip = true }, + { tile = 0x33, dx = 8, dy = 16, xflip = false, yflip = true }, + { tile = 0x23, dx = 8, dy = 24, xflip = false, yflip = true }, + { tile = 0x33, dx = 16, dy = 16, xflip = true, yflip = true }, + { tile = 0x32, dx = 24, dy = 16, xflip = true, yflip = true }, + { tile = 0x23, dx = 16, dy = 24, xflip = true, yflip = true }, + }, + -- FrameBlock07: Medium Poof smoke cloud (32x32) + [7] = { + { tile = 0x20, dx = 0, dy = 0, xflip = false, yflip = false }, + { tile = 0x21, dx = 8, dy = 0, xflip = false, yflip = false }, + { tile = 0x30, dx = 0, dy = 8, xflip = false, yflip = false }, + { tile = 0x31, dx = 8, dy = 8, xflip = false, yflip = false }, + { tile = 0x21, dx = 16, dy = 0, xflip = true, yflip = false }, + { tile = 0x20, dx = 24, dy = 0, xflip = true, yflip = false }, + { tile = 0x31, dx = 16, dy = 8, xflip = true, yflip = false }, + { tile = 0x30, dx = 24, dy = 8, xflip = true, yflip = false }, + { tile = 0x30, dx = 0, dy = 16, xflip = false, yflip = true }, + { tile = 0x31, dx = 8, dy = 16, xflip = false, yflip = true }, + { tile = 0x20, dx = 0, dy = 24, xflip = false, yflip = true }, + { tile = 0x21, dx = 8, dy = 24, xflip = false, yflip = true }, + { tile = 0x31, dx = 16, dy = 16, xflip = true, yflip = true }, + { tile = 0x30, dx = 24, dy = 16, xflip = true, yflip = true }, + { tile = 0x21, dx = 16, dy = 24, xflip = true, yflip = true }, + { tile = 0x20, dx = 24, dy = 24, xflip = true, yflip = true }, + }, + -- FrameBlock08: Large Poof smoke cloud (32x32) + [8] = { + { tile = 0x22, dx = 0, dy = 0, xflip = false, yflip = false }, + { tile = 0x23, dx = 8, dy = 0, xflip = false, yflip = false }, + { tile = 0x32, dx = 0, dy = 8, xflip = false, yflip = false }, + { tile = 0x33, dx = 8, dy = 8, xflip = false, yflip = false }, + { tile = 0x23, dx = 16, dy = 0, xflip = true, yflip = false }, + { tile = 0x22, dx = 24, dy = 0, xflip = true, yflip = false }, + { tile = 0x33, dx = 16, dy = 8, xflip = true, yflip = false }, + { tile = 0x32, dx = 24, dy = 8, xflip = true, yflip = false }, + { tile = 0x32, dx = 0, dy = 16, xflip = false, yflip = true }, + { tile = 0x33, dx = 8, dy = 16, xflip = false, yflip = true }, + { tile = 0x22, dx = 0, dy = 24, xflip = false, yflip = true }, + { tile = 0x23, dx = 8, dy = 24, xflip = false, yflip = true }, + { tile = 0x33, dx = 16, dy = 16, xflip = true, yflip = true }, + { tile = 0x32, dx = 24, dy = 16, xflip = true, yflip = true }, + { tile = 0x23, dx = 16, dy = 24, xflip = true, yflip = true }, + { tile = 0x22, dx = 24, dy = 24, xflip = true, yflip = true }, + }, +} + +-- BallMoveDistances1 (engine/battle/animations.asm:881) +-- Pokéball jumps up into the open link cable nozzle: -12, -12, -8 (3 frames each) +local BALL_SUCTION_DISTANCES = { -12, -12, -8 } + +-- BallMoveDistances2 (engine/battle/animations.asm:922) +-- Incoming Pokéball bounces out of nozzle onto the ground: 11, 12, -12, -7, 7, 12, -8, 8 (5 frames each) +local BALL_BOUNCE_DISTANCES = { 11, 12, -12, -7, 7, 12, -8, 8 } + function TradeAnim.new(game, opts) opts = opts or {} local self = setmetatable({}, TradeAnim) @@ -120,6 +208,8 @@ function TradeAnim.new(game, opts) cableBall = tryImage(art.cableBall or DEFAULT_ART.cableBall), cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt), bubble = tryImage(art.bubble or DEFAULT_ART.bubble), + moveAnim0 = tryImage(DEFAULT_ART.moveAnim0), + balls = tryImage(DEFAULT_ART.balls), } self.sentSprite, self.sentSpriteTrueColor = spriteOf(game, self.sent) self.recvSprite, self.recvSpriteTrueColor = spriteOf(game, self.received) @@ -136,6 +226,14 @@ function TradeAnim.new(game, opts) self.monVisible = true self.waitingText = false self.cableFlash = false + self.activeBallBlock = nil + self.activeBallX = 0 + self.activeBallY = 0 + self.activePoofBlock = nil + self.activePoofX = 0 + self.activePoofY = 0 + self.wx = 0 + self.slidingBox = false return self end @@ -151,52 +249,100 @@ function TradeAnim:advance() self.sub = nil self.flash = false self.cableFlash = false - self.poof = nil + self.activeBallBlock = nil + self.activePoofBlock = nil + self.slidingBox = false + self.wx = 0 + if self.phase == "done" then self.game.stack:pop() if self.onDone then self.onDone() end elseif self.phase == "show_enemy" then self.monVisible = false + self.activeBallBlock = 4 + self.activeBallX, self.activeBallY = 72, 40 + self.activePoofBlock = nil + self.cableSlideOut = 0 elseif self.phase == "transfer_lr" then - -- wBaseCoord $54, $1c OAM -> screen (76, 12) - self.monX, self.monY = 76, 12 + -- BaseCoord $54, $1c -> Screen (92, 28) for 16x16 icon (center 100, 36) + self.monX, self.monY = 92, 28 + self.scx = 0 elseif self.phase == "transfer_rl" then - -- wBaseCoord $64, $44 OAM -> screen (92, 52), right GB on screen - self.monX, self.monY = 92, 52 - self.scx = 160 + -- BaseCoord $64, $44 -> Screen (108, 68) for 16x16 icon (center 116, 76) + self.monX, self.monY = 108, 68 + self.scx = 256 elseif self.phase == "ball_enter" then - -- lb bc, $20, $60: b = Y, c = X (Trade_AnimateBallEnteringLinkCable) + -- Pokéball sits at BaseCoord $48 (Screen 72, 64) before shake and suction + self.activeBallBlock = 3 + self.activeBallX, self.activeBallY = 72, 64 self.ballX, self.ballY = 0x60, 0x20 elseif self.phase == "open_cable" or self.phase == "open_cable2" then - -- SCX $a0 -> $f0: cable slides in from the right, open end rests at x64 - self.scx = 0x50 + -- SCX $a0 (160) -> $f0 (240): cable slides in from right, open end rests at x64 + self.scx = 80 + if self.phase == "open_cable" then + self.activeBallBlock = 3 + self.activeBallX, self.activeBallY = 72, 64 + end Sound.play(self.game.data, "Heal_HP") end end -function TradeAnim:say(text, delay, thenFn) - self.waitingText = true - self.game.stack:push(TextBox.new(self.game, text, function() - self.waitingText = false - if thenFn then thenFn() else self:advance() end - end, { auto = { delay = delay or 80 } })) +function TradeAnim:skipHeld() + return self.game.input:wasPressed("a") or self.game.input:wasPressed("b") end -function TradeAnim:skipHeld() - return self.game.input:wasPressed("a") or self.game.input:isDown("a") +function TradeAnim:drawFrameBlock(blockId, x, y) + local block = FRAME_BLOCKS[blockId] + if not block then return end + if self.img.moveAnim0 then + local iw, ih = self.img.moveAnim0:getDimensions() + for _, tile in ipairs(block) do + local tx = (tile.tile % 16) * 8 + local ty = math.floor(tile.tile / 16) * 8 + local quad = love.graphics.newQuad(tx, ty, 8, 8, iw, ih) + local sx = tile.xflip and -1 or 1 + local sy = tile.yflip and -1 or 1 + local ox = tile.xflip and 8 or 0 + local oy = tile.yflip and 8 or 0 + love.graphics.draw(self.img.moveAnim0, quad, x + tile.dx + ox, y + tile.dy + oy, 0, sx, sy) + end + else + if blockId == 3 or blockId == 4 or blockId == 5 then + love.graphics.setColor(0.9, 0.2, 0.2, 1) + love.graphics.arc("fill", x + 8, y + 8, 7, math.pi, 0) + love.graphics.setColor(0.9, 0.9, 0.9, 1) + love.graphics.arc("fill", x + 8, y + 8, 7, 0, math.pi) + love.graphics.setColor(0, 0, 0, 1) + love.graphics.circle("line", x + 8, y + 8, 7) + love.graphics.line(x + 1, y + 8, x + 15, y + 8) + love.graphics.circle("fill", x + 8, y + 8, 2) + love.graphics.setColor(1, 1, 1, 1) + elseif blockId == 6 or blockId == 7 or blockId == 8 then + local r = (blockId - 5) * 5 + love.graphics.setColor(0.8, 0.8, 0.8, 0.8) + love.graphics.circle("fill", x + 16, y + 16, r) + love.graphics.setColor(1, 1, 1, 1) + end + end end function TradeAnim:update(dt) if self.waitingText or self.phase == "done" then return end - local skip = self.game.input:wasPressed("a") + local skip = self:skipHeld() self.t = self.t + 1 local p = self.phase if p == "show_player" then - -- slide from SCX $86 -> 0, hold 80, poof, cry (Trade_ShowPlayerMon) + -- Trade_ShowPlayerMon: + -- 1. Slide from SCX $7e (126) -> 0 at 2px/frame (63 frames) + -- 2. Hold 80 frames + -- 3. TRADE_BALL_POOF_ANIM at BaseCoord $72 (Screen 64, 48): 3 frames x 6 ticks = 18 ticks + -- 4. TRADE_BALL_DROP_ANIM: mon vanishes, ball drops $41 (72, 56) -> $48 (72, 64) over 36 ticks + -- 5. PlayCry while ball sits on ground at (72, 64) if not self.sub then self.sub = "slide" end + if self.sub == "slide" then - self.scx = math.max(0, 0x86 - self.t * 2) + self.scx = math.max(0, 126 - self.t * 2) if skip or self.scx <= 0 then self.scx = 0 self.sub = "hold" @@ -206,52 +352,131 @@ function TradeAnim:update(dt) if skip or self.t >= 80 then self.sub = "poof" self.t = 0 - self.monVisible = false Sound.play(self.game.data, "Ball_Poof") end elseif self.sub == "poof" then - self.poof = math.max(0, 16 - self.t) - if skip or self.t >= 16 then + local step = math.floor(self.t / 6) + if step >= 3 or skip then + self.sub = "ball_drop" + self.t = 0 + self.activePoofBlock = nil + self.monVisible = false + else + self.activePoofBlock = 6 + step + self.activePoofX, self.activePoofY = 64, 48 + end + elseif self.sub == "ball_drop" then + if skip or self.t >= 36 then + self.sub = "cry" + self.t = 0 + self.activeBallBlock = 3 + self.activeBallX, self.activeBallY = 72, 64 Sound.playCry(self.game.data, self.sent.species) + else + local step = math.floor(self.t / 6) + if step == 0 then + self.activeBallBlock = 3 + self.activeBallX, self.activeBallY = 72, 56 + else + local blocks = { 3, 4, 3, 5, 3 } + self.activeBallBlock = blocks[step] or 3 + self.activeBallX, self.activeBallY = 72, 64 + end + end + elseif self.sub == "cry" then + if skip or self.t >= 40 then self.sub = nil self:advance() end end elseif p == "open_cable" or p == "open_cable2" then - -- 20 steps of 4px, matching the SCX loop - self.scx = math.max(0, 0x50 - self.t * 4) + -- Trade_DrawOpenEndOfLinkCable: SCX $a0 (160) -> $f0 (240) in 20 steps of 4px + self.scx = math.max(0, 80 - self.t * 4) if skip or self.scx <= 0 then self.scx = 0 self:advance() end elseif p == "ball_enter" then - -- TRADE_BALL_SHAKE then ball rides the cable; X from $60 toward $a0 - if self.t < 20 then - if skip then self.t = 20 end - return + -- Trade_AnimateBallEnteringLinkCable: + -- 1. TRADE_BALL_SHAKE_ANIM (16 ticks) + TradeShakePokeball suction jump (-12, -12, -8 over 9 ticks): + -- - Shakes at (72, 64) for 16 ticks (FrameBlock 04, 03, 05, 03) + -- - Jumps up: Y=52 (3 ticks), Y=40 (3 ticks), Y=32 (3 ticks) + -- - Ball cleared, SFX_TRADE_MACHINE plays + -- 2. DelayFrames 10 (chills for 10 frames) + -- 3. Ball inside horizontal cable: 16 steps of +4px every 3 frames (48 frames) with Tink sfx & bulge toggle + -- 4. Delay3 + if not self.sub then self.sub = "shake" end + + if self.sub == "shake" then + if skip or self.t >= 16 then + self.sub = "jump_up" + self.t = 0 + else + local shakeBlocks = { 4, 3, 5, 3 } + local step = math.floor(self.t / 4) + 1 + self.activeBallBlock = shakeBlocks[step] or 3 + self.activeBallX, self.activeBallY = 72, 64 + end + elseif self.sub == "jump_up" then + local step = math.floor(self.t / 3) + 1 + if step > #BALL_SUCTION_DISTANCES or skip then + self.sub = "chill" + self.t = 0 + self.activeBallBlock = nil + Sound.play(self.game.data, "Trade_Machine") + else + local yPos = 64 + for i = 1, step do + yPos = yPos + BALL_SUCTION_DISTANCES[i] + end + self.activeBallBlock = 3 + self.activeBallX, self.activeBallY = 72, yPos + end + elseif self.sub == "chill" then + if skip or self.t >= 10 then + self.sub = "suction" + self.t = 0 + self.ballX = 0x60 + end + elseif self.sub == "suction" then + local step = math.floor(self.t / 3) + if step >= 16 or skip then + self.sub = "exit_pause" + self.t = 0 + self.ballX = 0xA0 + else + self.ballX = 0x60 + step * 4 + self.flash = (step % 2 == 1) + if self.t % 3 == 0 and step < 16 then + Sound.play(self.game.data, "Tink") + end + end + elseif self.sub == "exit_pause" then + if skip or self.t >= 3 then + self.sub = nil + self:advance() + end end - local step = self.t - 20 - if step % 3 == 0 then - self.ballX = 0x60 + math.floor(step / 3) * 4 - self.flash = not self.flash - if self.ballX < 0xA0 then Sound.play(self.game.data, "Tink") end - end - if skip then self.ballX = 0xA0 end - if self.ballX >= 0xA0 then self:advance() end elseif p == "transfer_lr" then - -- scroll left GB off while the mon rides the cable, then the sprite - -- itself moves right and down into the right GB (Trade_AnimMonMoveVertical) - if self.t <= 80 then - self.scx = math.min(160, self.t * 2) - self.monX, self.monY = 76, 12 - elseif self.t <= 80 + 32 then - self.monX = 76 + math.floor((self.t - 80) / 8) * 4 - elseif self.t <= 80 + 64 then - self.monX = 92 - self.monY = 12 + math.floor((self.t - 112) / 8) * 10 + -- 16 units of 16px (256px total scroll) at 2px/frame = 128 frames (SCX 0 -> 256) + if self.t <= 128 then + self.scx = math.min(256, self.t * 2) + self.monX, self.monY = 92, 28 + elseif self.t <= 128 + 32 then + local step = math.floor((self.t - 128) / 8) + 1 + step = math.min(4, step) + self.scx = 256 + self.monX = 92 + step * 4 + self.monY = 28 + elseif self.t <= 128 + 64 then + local step = math.floor((self.t - 160) / 8) + 1 + step = math.min(4, step) + self.scx = 256 + self.monX = 108 + self.monY = 28 + step * 10 else self:advance() return @@ -260,17 +485,22 @@ function TradeAnim:update(dt) if skip then self:advance() end elseif p == "transfer_rl" then - -- mirror: sprite climbs out of the right GB, then the screen scrolls - -- back to the left GB + -- 64 frames vertical ascent + 128 frames horizontal scroll (SCX 256 -> 0) if self.t <= 32 then - self.scx = 160 - self.monX, self.monY = 92, 52 - math.floor(self.t / 8) * 10 + local step = math.floor(self.t / 8) + 1 + step = math.min(4, step) + self.scx = 256 + self.monX = 108 + self.monY = 68 - step * 10 elseif self.t <= 64 then - self.monX = 92 - math.floor((self.t - 32) / 8) * 4 - self.monY = 12 - elseif self.t <= 64 + 80 then - self.scx = math.max(0, 160 - (self.t - 64) * 2) - self.monX, self.monY = 76, 12 + local step = math.floor((self.t - 32) / 8) + 1 + step = math.min(4, step) + self.scx = 256 + self.monX = 108 - step * 4 + self.monY = 28 + elseif self.t <= 64 + 128 then + self.scx = math.max(0, 256 - (self.t - 64) * 2) + self.monX, self.monY = 92, 28 else self:advance() return @@ -282,54 +512,185 @@ function TradeAnim:update(dt) if skip or self.t >= 100 then self:advance() end elseif p == "went_to" then - local text = expand(self.game, "_TradeWentToText", { - ["RAM:wStringBuffer"] = speciesName(self.game, self.sent), - ["RAM:wLinkEnemyTrainerName"] = self.enemyName, - }) - self:say(text, 200) + if not self.sub then + self.sub = "text" + self.t = 0 + local text = expand(self.game, "_TradeWentToText", { + ["RAM:wStringBuffer"] = speciesName(self.game, self.sent), + ["RAM:wLinkEnemyTrainerName"] = self.enemyName, + }) + self.dialogText = text + end + + if self.sub == "text" then + if skip or self.t >= 200 then + self.sub = "slide_hold" + self.t = 0 + end + elseif self.sub == "slide_hold" then + if skip or self.t >= 50 then + self.sub = "slide_off" + self.t = 0 + self.slidingBox = true + end + elseif self.sub == "slide_off" then + self.wx = math.min(160, self.t * 2) + if skip or self.wx >= 160 then + self.wx = 160 + self.sub = "slide_end_pause" + self.t = 0 + end + elseif self.sub == "slide_end_pause" then + if skip or self.t >= 10 then + self.slidingBox = false + self.dialogText = nil + self.sub = nil + self:advance() + end + end elseif p == "for_sends" then - local a = expand(self.game, "_TradeForText", { - ["RAM:wStringBuffer"] = speciesName(self.game, self.sent), - }) - local b = expand(self.game, "_TradeSendsText", { - ["RAM:wLinkEnemyTrainerName"] = self.enemyName, - ["RAM:wNameBuffer"] = nameOf(self.game, self.received), - }) - self.waitingText = true - self.game.stack:push(TextBox.new(self.game, a, function() - self.game.stack:push(TextBox.new(self.game, b, function() - self.waitingText = false + if not self.sub then + self.sub = "for_text" + self.t = 0 + self.dialogText = expand(self.game, "_TradeForText", { + ["RAM:wStringBuffer"] = speciesName(self.game, self.sent), + }) + end + + if self.sub == "for_text" then + if skip or self.t >= 80 then + self.sub = "sends_text" + self.t = 0 + self.dialogText = expand(self.game, "_TradeSendsText", { + ["RAM:wLinkEnemyTrainerName"] = self.enemyName, + ["RAM:wNameBuffer"] = nameOf(self.game, self.received), + }) + end + elseif self.sub == "sends_text" then + if skip or self.t >= 80 then + self.dialogText = nil + self.sub = nil self:advance() - end, { auto = { delay = 80 } })) - end, { auto = { delay = 80 } })) + end + end elseif p == "farewell" then - local a = expand(self.game, "_TradeWavesFarewellText", { - ["RAM:wLinkEnemyTrainerName"] = self.enemyName, - }) - local b = expand(self.game, "_TradeTransferredText", { - ["RAM:wNameBuffer"] = nameOf(self.game, self.received), - }) - self.waitingText = true - self.game.stack:push(TextBox.new(self.game, a, function() - self.game.stack:push(TextBox.new(self.game, b, function() - self.waitingText = false + if not self.sub then + self.sub = "farewell_text" + self.t = 0 + self.dialogText = expand(self.game, "_TradeWavesFarewellText", { + ["RAM:wLinkEnemyTrainerName"] = self.enemyName, + }) + end + + if self.sub == "farewell_text" then + if skip or self.t >= 80 then + self.sub = "transferred_text" + self.t = 0 + self.dialogText = expand(self.game, "_TradeTransferredText", { + ["RAM:wNameBuffer"] = nameOf(self.game, self.received), + }) + end + elseif self.sub == "transferred_text" then + if skip or self.t >= 80 then + self.sub = "slide_hold" + self.t = 0 + end + elseif self.sub == "slide_hold" then + if skip or self.t >= 50 then + self.sub = "slide_off" + self.t = 0 + self.slidingBox = true + end + elseif self.sub == "slide_off" then + self.wx = math.min(160, self.t * 2) + if skip or self.wx >= 160 then + self.wx = 160 + self.sub = "slide_end_pause" + self.t = 0 + end + elseif self.sub == "slide_end_pause" then + if skip or self.t >= 10 then + self.slidingBox = false + self.dialogText = nil + self.sub = nil self:advance() - end, { auto = { delay = 80 } })) - end, { auto = { delay = 80 } })) + end + end elseif p == "show_enemy" then - if self.t == 1 then - Sound.play(self.game.data, "Ball_Poof") - self.monVisible = true - elseif self.t == 20 then - Sound.playCry(self.game.data, self.received.species) - elseif self.t >= 120 or skip then - local text = expand(self.game, "_TradeTakeCareText", { - ["RAM:wNameBuffer"] = nameOf(self.game, self.received), - }) - self:say(text, 80) + -- Trade_ShowEnemyMon (engine/movie/trade.asm:354): + -- 1. TRADE_BALL_TILT_ANIM with TradeJumpPokeball (8 bounce steps, 5 ticks each = 40 ticks): + -- - Starts at BaseCoord $84 (Screen X=72, Y=40) + -- - Moves Y by BallMoveDistances2 (+11, +12, -12, -7, +7, +12, -8, +8) + -- - On each step: Cable slides off-screen right by 8px + -- - Plays SFX_SWAP on impacts + -- 2. ClearScreen, Info box at (4, 10), Mon front sprite at (7, 2) + -- 3. TRADE_BALL_POOF_ANIM: FrameBlock 06, 07, 08 (18 ticks), SFX_BALL_POOF, revealing mon + -- 4. PlayCry + -- 5. Trade_Delay100 + -- 6. Info box cleared, PrintTradeTakeCareText (80 ticks) + -- 7. Trade_Delay100 -> finish + if not self.sub then + self.sub = "ball_bounce" + self.t = 0 + self.cableSlideOut = 0 + self.activeBallBlock = 4 + self.activeBallX, self.activeBallY = 72, 40 + end + + if self.sub == "ball_bounce" then + local step = math.floor(self.t / 5) + 1 + if step > #BALL_BOUNCE_DISTANCES or skip then + self.sub = "poof" + self.t = 0 + self.activeBallBlock = nil + self.monVisible = true + self.cableSlideOut = nil + Sound.play(self.game.data, "Ball_Poof") + else + local yPos = 40 + for i = 1, step do + yPos = yPos + BALL_BOUNCE_DISTANCES[i] + end + self.activeBallBlock = 4 + self.activeBallX, self.activeBallY = 72, yPos + self.cableSlideOut = (step - 1) * 8 + if self.t % 5 == 0 and (BALL_BOUNCE_DISTANCES[step] == 12 or step == #BALL_BOUNCE_DISTANCES) then + Sound.play(self.game.data, "Swap") + end + end + elseif self.sub == "poof" then + local step = math.floor(self.t / 6) + if step >= 3 or skip then + self.sub = "cry" + self.t = 0 + self.activePoofBlock = nil + Sound.playCry(self.game.data, self.received.species) + else + self.activePoofBlock = 6 + step + self.activePoofX, self.activePoofY = 64, 48 + end + elseif self.sub == "cry" then + if skip or self.t >= 100 then + self.sub = "take_care" + self.t = 0 + self.dialogText = expand(self.game, "_TradeTakeCareText", { + ["RAM:wNameBuffer"] = nameOf(self.game, self.received), + }) + end + elseif self.sub == "take_care" then + if skip or self.t >= 80 then + self.sub = "delay_end" + self.t = 0 + self.dialogText = nil + end + elseif self.sub == "delay_end" then + if skip or self.t >= 100 then + self.sub = nil + self:advance() + end end end end @@ -337,10 +698,18 @@ end local function drawCableHoriz(self, y, x0, x1) local w = math.max(0, x1 - x0) if w <= 0 then return end + if self.cableFlash then + love.graphics.setColor(0.65, 0.65, 0.65, 1) + else + love.graphics.setColor(1, 1, 1, 1) + end if self.img.cableHoriz then local iw, ih = self.img.cableHoriz:getDimensions() - local quad = love.graphics.newQuad(0, 0, math.min(w, iw), ih, iw, ih) - love.graphics.draw(self.img.cableHoriz, quad, x0, y) + for x = x0, x1 - 1, iw do + local drawW = math.min(iw, x1 - x) + local quad = love.graphics.newQuad(0, 0, drawW, ih, iw, ih) + love.graphics.draw(self.img.cableHoriz, quad, x, y) + end elseif self.img.cableSeg then for x = x0, x1 - 8, 8 do love.graphics.draw(self.img.cableSeg, x, y) @@ -348,13 +717,11 @@ local function drawCableHoriz(self, y, x0, x1) else love.graphics.setColor(0.2, 0.2, 0.2, 1) love.graphics.rectangle("fill", x0, y + 1, w, 6) - love.graphics.setColor(1, 1, 1, 1) end + love.graphics.setColor(1, 1, 1, 1) end function TradeAnim:drawMonInfo(mon, ot, otId, boxTy) - -- Trade_PrintPlayerMonInfoText: rows +0/+2/+4/+6 from the box top; the - -- No. line replaces part of the top border (hlcoord 5, 0 in trade2.asm) Font.drawBox(4, boxTy, 12, 8) local y0 = boxTy * 8 local no = ("No.%03d"):format(dexOf(self.game, mon)) @@ -368,16 +735,6 @@ function TradeAnim:drawMonInfo(mon, ot, otId, boxTy) love.graphics.setColor(1, 1, 1, 1) end --- Trade_WriteCircledMonOAM: the mon crosses the cable as its party-menu --- sprite (wMonPartySpriteSpecies -> WriteMonPartySpriteOAMBySpecies), not as --- its battle pic, and Trade_AnimCircledMon flips both it and the ring to --- their second frame every step. The ring is four OAM blocks -- --- Trade_CircleOAMBlocks .OAMBlock0-3 at (8,8) (24,8) (8,24) (24,24) with the --- X/Y flips -- so the 16x32 bubble sheet holds one quadrant per frame and the --- circle it makes is 32x32 around the 16x16 icon. The icon rides OAM --- block 0 and the circle blocks 1-4 (Trade_WriteCircleOAMBlock counts a up --- from 1), and the lower OAM index wins overlap on DMG, so the icon draws --- on top of the circle's filled interior. #750 function TradeAnim:drawIconInBubble(mon, x, y) if self.img.bubble then if not self.bubbleQuad then @@ -415,11 +772,14 @@ function TradeAnim:drawGameBoy(x, y) end function TradeAnim:drawLeftGB() - -- cable from GB to the right edge + if self.cableFlash then + love.graphics.setColor(0.65, 0.65, 0.65, 1) + end if self.img.cableConn then love.graphics.draw(self.img.cableConn, 88, 32) end - drawCableHoriz(self, 32, 96, 160) + love.graphics.setColor(1, 1, 1, 1) + drawCableHoriz(self, 32, 96, 256) self:drawGameBoy(40, 24) Font.drawBox(4, 12, 9, 4) love.graphics.setColor(0, 0, 0, 1) @@ -429,6 +789,9 @@ end function TradeAnim:drawRightGB() drawCableHoriz(self, 32, 0, 112) + if self.cableFlash then + love.graphics.setColor(0.65, 0.65, 0.65, 1) + end if self.img.cableCorner then love.graphics.draw(self.img.cableCorner, 112, 32) end if self.img.cableVert then for i = 1, 4 do @@ -437,6 +800,7 @@ function TradeAnim:drawRightGB() end if self.img.cableEnd then love.graphics.draw(self.img.cableEnd, 112, 72) end if self.img.cableConn then love.graphics.draw(self.img.cableConn, 104, 72) end + love.graphics.setColor(1, 1, 1, 1) self:drawGameBoy(56, 64) Font.drawBox(6, 0, 9, 4) love.graphics.setColor(0, 0, 0, 1) @@ -444,6 +808,24 @@ function TradeAnim:drawRightGB() love.graphics.setColor(1, 1, 1, 1) end +function TradeAnim:drawDialog() + if not self.dialogText then return end + love.graphics.push() + if self.slidingBox then + love.graphics.translate(self.wx, 0) + end + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + local lines = {} + for line in self.dialogText:gmatch("[^\n]+") do + lines[#lines + 1] = line + end + if lines[1] then Font.draw(lines[1], 8, 112) end + if lines[2] then Font.draw(lines[2], 8, 128) end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.pop() +end + function TradeAnim:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) @@ -452,10 +834,7 @@ function TradeAnim:draw() if p == "show_player" then love.graphics.push() love.graphics.translate(-self.scx, 0) - -- mon pic on the BG at hlcoord 7, 2; info box on the window at - -- hWY $50, so it sits in the bottom half of the screen if self.monVisible and self.sentSprite then - -- engine/movie/trade.asm:751 love.graphics.draw(self.sentSprite, 56 + self.sentSprite:getWidth(), 16, 0, -1, 1) if self.sentSpriteTrueColor then require("src.render.PaletteFX").markTrueColor( @@ -464,64 +843,79 @@ function TradeAnim:draw() end self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10) love.graphics.pop() - if self.poof and self.poof > 0 then - love.graphics.setColor(0, 0, 0, self.poof / 16) - love.graphics.circle("fill", 80, 40, 20 - (self.poof or 0)) - love.graphics.setColor(1, 1, 1, 1) + + if self.activePoofBlock then + self:drawFrameBlock(self.activePoofBlock, self.activePoofX, self.activePoofY) + end + if self.activeBallBlock then + self:drawFrameBlock(self.activeBallBlock, self.activeBallX, self.activeBallY) end elseif p == "open_cable" or p == "open_cable2" then love.graphics.push() love.graphics.translate(self.scx, 0) if self.img.openCable then - love.graphics.draw(self.img.openCable, 64, 16) -- open end at SCX $f0 + love.graphics.draw(self.img.openCable, 64, 16) end love.graphics.pop() + if p == "open_cable" and self.activeBallBlock then + self:drawFrameBlock(self.activeBallBlock, self.activeBallX, self.activeBallY) + end elseif p == "ball_enter" then if self.img.openCable then love.graphics.draw(self.img.openCable, 64, 16) end - -- OAM coords carry a (+8, +16) hardware offset - local ball = self.flash and (self.img.cableBallAlt or self.img.cableBall) - or self.img.cableBall - if ball then - love.graphics.draw(ball, self.ballX - 8, self.ballY - 16) - else - love.graphics.setColor(0, 0, 0, 1) - love.graphics.circle("fill", self.ballX, self.ballY - 8, 6) - love.graphics.setColor(1, 1, 1, 1) + if self.activeBallBlock then + self:drawFrameBlock(self.activeBallBlock, self.activeBallX, self.activeBallY) + elseif self.sub == "suction" or self.sub == "exit_pause" then + local ball = self.flash and (self.img.cableBallAlt or self.img.cableBall) + or self.img.cableBall + if ball then + love.graphics.draw(ball, self.ballX - 8, self.ballY - 16) + else + love.graphics.setColor(0, 0, 0, 1) + love.graphics.circle("fill", self.ballX, self.ballY - 8, 6) + love.graphics.setColor(1, 1, 1, 1) + end end elseif p == "transfer_lr" or p == "transfer_rl" then - -- two-screen world: left GB scene at x0, right GB scene at x160; - -- the mon icon stays in screen space like the OAM sprite it ports love.graphics.push() love.graphics.translate(-self.scx, 0) self:drawLeftGB() - love.graphics.translate(160, 0) + love.graphics.translate(256, 0) self:drawRightGB() love.graphics.pop() local mon = p == "transfer_lr" and self.sent or self.received self:drawIconInBubble(mon, self.monX, self.monY) - if self.cableFlash then - love.graphics.setColor(1, 1, 1, 0.15) - love.graphics.rectangle("fill", 0, 32, 160, 8) - love.graphics.setColor(1, 1, 1, 1) - end elseif p == "show_enemy" then + if self.cableSlideOut and self.img.openCable then + love.graphics.push() + love.graphics.translate(self.cableSlideOut, 0) + love.graphics.draw(self.img.openCable, 64, 16) + love.graphics.pop() + end if self.monVisible and self.recvSprite then - -- engine/movie/trade.asm:751 love.graphics.draw(self.recvSprite, 56 + self.recvSprite:getWidth(), 16, 0, -1, 1) if self.recvSpriteTrueColor then require("src.render.PaletteFX").markTrueColor( 56, 16, self.recvSprite:getDimensions()) end end - self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10) + if self.monVisible and self.sub ~= "take_care" and self.sub ~= "delay_end" then + self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10) + end + if self.activeBallBlock then + self:drawFrameBlock(self.activeBallBlock, self.activeBallX, self.activeBallY) + end + if self.activePoofBlock then + self:drawFrameBlock(self.activePoofBlock, self.activePoofX, self.activePoofY) + end end - -- went_to / for_sends / farewell / delay: cleared window; TextBox draws + + self:drawDialog() end return TradeAnim diff --git a/src/ui/gen2/MagnetTrainRide.lua b/src/ui/gen2/MagnetTrainRide.lua index dc920687..5a3f6a0f 100644 --- a/src/ui/gen2/MagnetTrainRide.lua +++ b/src/ui/gen2/MagnetTrainRide.lua @@ -22,12 +22,14 @@ -- PAL_BG_YELLOW, out of a TOWN palette set at the current time of day -- (the routine pushes wEnvironment, forces TOWN, and pops it back). -- --- Two hardware details are not reproduced. The four player OBJs all carry --- OAM_PRIO, so on the cart they sit behind background colours 1-3 and only --- show through the window's colour 0; here they are drawn straight over the --- background, which looks the same everywhere the window is transparent and --- differs only if the sprite drifts over solid train tiles. And the ride --- cannot be skipped, exactly as on the cart: the loop reads no input at all. +-- Two hardware details from the cart are faithfully reproduced: +-- 1. Viewport wrapping: the 256x144 background scrolls in 3 LY override bands, +-- wrapped strictly within the 160x144 GB screen so pixels do not bleed into +-- the widescreen surround. +-- 2. OAM Priority (OAM_PRIO): the four player OBJs sit behind background +-- colours 1-3 and only show through the window's colour 0. This is drawn +-- via a base background pass, the player sprite, and an overlay pass where +-- shade 0 is transparent and solid train tiles mask the sprite. local Assets = require("src.render.Assets") local Chrome = require("src.ui.gen2.Chrome") @@ -46,6 +48,30 @@ local BG_W = 256 -- TILEMAP_WIDTH * 8 local TILES_PER_ROW = 16 -- every 2bpp sheet the importer writes local BLACK = { 0, 0, 0 } +-- Overlay shader for OAM_PRIO: shade 0 is transparent; shades 1-3 are solid +-- palette colors, masking any sprites beneath solid background tiles. +local OVERLAY_SHADER_SOURCE = [[ +extern vec3 pal0; +extern vec3 pal1; +extern vec3 pal2; +extern vec3 pal3; + +vec4 effect(vec4 tint, Image tex, vec2 uv, vec2 screen) { + vec4 px = Texel(tex, uv); + float shade = floor((1.0 - px.r) * 3.0 + 0.5); + vec3 rgb = pal0; + if (shade > 2.5) { + rgb = pal3; + } else if (shade > 1.5) { + rgb = pal2; + } else if (shade > 0.5) { + rgb = pal1; + } + float alpha = shade < 0.5 ? 0.0 : px.a; + return vec4(rgb, alpha) * tint; +} +]] + function MagnetTrainRide:wantsFillScale() return true end -- opts: toGoldenrod (the wScriptVar the officer's script set), onDone() @@ -97,7 +123,7 @@ function MagnetTrainRide:palette(slot) end -------------------------------------------------------------------------- --- Sheets +-- Sheets & Shaders -------------------------------------------------------------------------- local function sheetFor(path) @@ -159,22 +185,66 @@ function MagnetTrainRide:playerQuad(vtile) return quad end +function MagnetTrainRide:overlayShader() + if self.ovShader ~= nil then return self.ovShader or nil end + if not (love and love.graphics and love.graphics.newShader) then + self.ovShader = false + return nil + end + local ok, result = pcall(love.graphics.newShader, OVERLAY_SHADER_SOURCE) + self.ovShader = ok and result or false + return self.ovShader or nil +end + +local function palChannel(colors, index) + local c = colors and colors[index] + if not c then return 0, 0, 0 end + return (c[1] or 0) / 255, (c[2] or 0) / 255, (c[3] or 0) / 255 +end + +function MagnetTrainRide:useOverlayShader(colors) + local sh = self:overlayShader() + if not sh then return false end + local resolved = GbcPalette.resolve(colors) + for i = 0, 3 do + local u = "pal" .. i + if sh:hasUniform(u) then + local r, g, b = palChannel(resolved, i + 1) + sh:send(u, { r, g, b }) + end + end + love.graphics.setShader(sh) + return true +end + + -------------------------------------------------------------------------- --- The baked background +-- The baked backgrounds (Base + OAM_PRIO Overlay) -------------------------------------------------------------------------- --- DrawMagnetTrain plus SetMagnetTrainPals, rendered once into a 256x144 --- canvas. Tiles are drawn palette group by palette group so the three --- palettes cost three shader switches rather than one per tile. -function MagnetTrainRide:background() - if self.bgCanvas ~= nil then return self.bgCanvas or nil end +-- DrawMagnetTrain plus SetMagnetTrainPals, rendered once into 256x144 canvases. +-- Base canvas contains the full opaque background; Overlay canvas contains +-- solid shades 1-3 with shade 0 transparent for OAM_PRIO window masking. +function MagnetTrainRide:backgrounds() + if self.bgCanvas ~= nil then + return self.bgCanvas or nil, self.bgOverlayCanvas or nil + end self.bgCanvas = false + self.bgOverlayCanvas = false local rows = self.ride:tilemap() local sheet = self:tileSheet() - if not (rows and sheet) then return nil end - local ok, canvas = pcall(love.graphics.newCanvas, BG_W, SCREEN_H) - if not ok then return nil end - canvas:setFilter("nearest", "nearest") + if not (rows and sheet) then return nil, nil end + + local ok1, baseCanvas = pcall(love.graphics.newCanvas, BG_W, SCREEN_H) + if not ok1 then return nil, nil end + baseCanvas:setFilter("nearest", "nearest") + + local ok2, overlayCanvas = pcall(love.graphics.newCanvas, BG_W, SCREEN_H) + if ok2 and overlayCanvas then + overlayCanvas:setFilter("nearest", "nearest") + else + overlayCanvas = nil + end local groups = {} for row = 1, #rows do @@ -188,11 +258,12 @@ function MagnetTrainRide:background() local G = love.graphics local previous = G.getCanvas() - -- A canvas does not reset the transform: without this the map lands under - -- the renderer's letterbox scale and off the edge. - G.push() + G.push("all") G.origin() - G.setCanvas(canvas) + G.setScissor() + + -- 1. Base background canvas (shades 0-3) + G.setCanvas(baseCanvas) G.clear(0, 0, 0, 1) G.setColor(1, 1, 1, 1) local shader = GbcPalette.available() @@ -205,10 +276,35 @@ function MagnetTrainRide:background() end end if shader then GbcPalette.clear() end + + -- 2. Overlay canvas for OAM_PRIO (shades 1-3 solid, shade 0 transparent) + if overlayCanvas then + G.setCanvas(overlayCanvas) + G.clear(0, 0, 0, 0) + G.setColor(1, 1, 1, 1) + local ovShader = self:overlayShader() + for slot, list in pairs(groups) do + local colors = self:palette(slot) + if ovShader and colors then self:useOverlayShader(colors) end + for _, cell in ipairs(list) do + local quad = sheet.quads[cell[1]] + if quad then G.draw(sheet.image, quad, cell[2], cell[3]) end + end + end + if ovShader then G.setShader() end + end + G.setCanvas(previous) G.pop() - self.bgCanvas = canvas - return canvas + + self.bgCanvas = baseCanvas + self.bgOverlayCanvas = overlayCanvas + return baseCanvas, overlayCanvas +end + +function MagnetTrainRide:background() + local base = self:backgrounds() + return base end -------------------------------------------------------------------------- @@ -240,19 +336,24 @@ function MagnetTrainRide:update(_dt) end end -function MagnetTrainRide:drawBackground() - local canvas = self:background() +-- Draws the 3 LY override bands strictly wrapped inside the 160px GB screen. +function MagnetTrainRide:drawBands(canvas) if not canvas then return end local G = love.graphics G.setColor(1, 1, 1, 1) self.bandQuad = self.bandQuad - or love.graphics.newQuad(0, 0, BG_W, 1, BG_W, SCREEN_H) + or love.graphics.newQuad(0, 0, SCREEN_W, 1, BG_W, SCREEN_H) for _, band in ipairs(self.ride:bands()) do local top, bottom, scx = band[1], band[2], band[3] % BG_W local height = bottom - top + 1 - self.bandQuad:setViewport(0, top, BG_W, height, BG_W, SCREEN_H) - G.draw(canvas, self.bandQuad, -scx, top) - G.draw(canvas, self.bandQuad, -scx + BG_W, top) + local w1 = math.min(SCREEN_W, BG_W - scx) + self.bandQuad:setViewport(scx, top, w1, height, BG_W, SCREEN_H) + G.draw(canvas, self.bandQuad, 0, top) + if w1 < SCREEN_W then + local w2 = SCREEN_W - w1 + self.bandQuad:setViewport(0, top, w2, height, BG_W, SCREEN_H) + G.draw(canvas, self.bandQuad, w1, top) + end end end @@ -277,16 +378,19 @@ function MagnetTrainRide:drawPlayer() if colors then GbcPalette.use(colors) end G.setColor(1, 1, 1, 1) for _, entry in ipairs(oam) do - local quad = self:playerQuad(entry.tile) - if quad then - G.draw(sheet.image, quad, - entry.x + (entry.xflip and 8 or 0), entry.y, 0, - entry.xflip and -1 or 1, 1) + if entry.x >= -8 and entry.x < SCREEN_W and entry.y >= -16 and entry.y < SCREEN_H then + local quad = self:playerQuad(entry.tile) + if quad then + G.draw(sheet.image, quad, + entry.x + (entry.xflip and 8 or 0), entry.y, 0, + entry.xflip and -1 or 1, 1) + end end end if colors then GbcPalette.clear() end end + -- The backdrop is BG colour 0 of the gray palette the train body uses; on the -- cart it is what shows wherever nothing was drawn. function MagnetTrainRide:backdrop() @@ -299,13 +403,24 @@ function MagnetTrainRide:drawPanel() G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) G.setColor(1, 1, 1, 1) - self:drawBackground() + + local base, overlay = self:backgrounds() + -- 1. Base background (bushes, train body, window aperture) + self:drawBands(base) + -- 2. Player sprite (OAM_PRIO: sits behind train body, visible through window) self:drawPlayer() + -- 3. Train body / frame overlay (colors 1-3 mask sprite; color 0 is transparent) + self:drawBands(overlay) + G.setColor(1, 1, 1, 1) end function MagnetTrainRide:draw() + local G = love.graphics + G.push("all") + G.setScissor(0, 0, SCREEN_W, SCREEN_H) self:drawPanel() + G.pop() end -- MagnetTrain_LoadGFX_PlayMusic opens on ClearBGPalettes / ClearSprites @@ -323,7 +438,8 @@ function MagnetTrainRide:drawWidescreen(winW, winH) G.setColor(1, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) - G.push() + G.push("all") + G.setScissor(ox, oy, SCREEN_W * scale, SCREEN_H * scale) G.translate(ox, oy) G.scale(scale, scale) self:drawPanel() @@ -331,3 +447,6 @@ function MagnetTrainRide:drawWidescreen(winW, winH) end return MagnetTrainRide + + + diff --git a/tests/engine/android_exit_to_launcher_test.lua b/tests/engine/android_exit_to_launcher_test.lua index 247366d5..64ae7165 100644 --- a/tests/engine/android_exit_to_launcher_test.lua +++ b/tests/engine/android_exit_to_launcher_test.lua @@ -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") diff --git a/tests/engine/cache_fs_red_migration_test.lua b/tests/engine/cache_fs_red_migration_test.lua index 7ab2cd2b..cea34cca 100644 --- a/tests/engine/cache_fs_red_migration_test.lua +++ b/tests/engine/cache_fs_red_migration_test.lua @@ -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") diff --git a/tests/engine/launcher_session_teardown_test.lua b/tests/engine/launcher_session_teardown_test.lua new file mode 100644 index 00000000..6ceee3e2 --- /dev/null +++ b/tests/engine/launcher_session_teardown_test.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") diff --git a/tests/gen2_magnet_train_test.lua b/tests/gen2_magnet_train_test.lua index 4e70649d..0b3c7ecd 100644 --- a/tests/gen2_magnet_train_test.lua +++ b/tests/gen2_magnet_train_test.lua @@ -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 diff --git a/tests/love_stub.lua b/tests/love_stub.lua index 0e35f8af..28ad9244 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -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 = {} } diff --git a/tests/save_editor_mod_tests.lua b/tests/save_editor_mod_tests.lua index 1fac297f..9fccce8d 100644 --- a/tests/save_editor_mod_tests.lua +++ b/tests/save_editor_mod_tests.lua @@ -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") diff --git a/tests/yellow_title_palette_test.lua b/tests/yellow_title_palette_test.lua new file mode 100644 index 00000000..33c5d0db --- /dev/null +++ b/tests/yellow_title_palette_test.lua @@ -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()