Merge pull request #1726 from bryanthaboi/dev

This commit is contained in:
bryanthaboi
2026-08-22 19:13:03 -04:00
committed by GitHub
25 changed files with 9451 additions and 222 deletions
+46 -18
View File
@@ -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
+22
View File
@@ -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
+24
View File
@@ -943,6 +943,12 @@ function Game2:load()
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
self.data.gen2Text = loadGenerated("data/generated/text.lua")
-- The engine's own strings, keyed by the disassembly's label. gen2Text
-- above is the script text and is keyed by bank:address for the overworld
-- VM, so the two are different tables and both are loaded. This one is
-- what src/core/RomText.lua reads, which is why it lands on `text`: that
-- helper is shared with Gen 1 and looks up data.text[label].
self.data.text = loadGenerated("data/generated/rom_text.lua") or {}
-- data/generated/events.lua: the side tables a script command NAMES rather
-- than carries -- the phone book, the in-game trades, the elevator's floor
-- labels, the decoration descriptions. Keyed for World's own `eventTables`
@@ -2117,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
+22 -8
View File
@@ -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
+47 -1
View File
@@ -27,7 +27,7 @@ RomExtractorGen2.__index = RomExtractorGen2
-- scripts, pokemon, moves, items, marts, encounters, trainers, pokedex,
-- landmarks, intro movie, menu gfx, title, credits, diploma, trade animation,
-- audio, stubs
local STAGE_COUNT = 26
local STAGE_COUNT = 27
local Opcodes = require("src.script.gen2.Opcodes")
-- BG palette slots inside one loaded 8-palette set (constants/tileset_constants.asm
@@ -136,6 +136,18 @@ local TEXT_NO_GLYPH = {
[0x13] = true, [0x15] = true,
}
-- The three runtime name slots. PlaceMoveUsersName, PlaceMoveTargetsName and
-- PlaceEnemysName (home/text.asm:302, :307, :327) swap these for a battler's
-- own name as the line prints, so they are markers rather than glyphs. They
-- decode to the same shape Gen 1 uses, which src/core/RomText.lua already
-- fills in argument order. Dropped, SubTookDamageText read "took damage
-- for" with nothing after it.
local NAME_SLOT = {
["<USER>"] = "{USER}",
["<TARGET>"] = "{TARGET}",
["<ENEMY>"] = "{ENEMY}",
}
local ROOF_TILES = 9
local SPRITEDATA_LENGTH = 6
@@ -2596,6 +2608,8 @@ function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
out[#out + 1] = ch
elseif ch == "<……>" or b == 0x56 then
out[#out + 1] = "……"
elseif NAME_SLOT[ch] then
out[#out + 1] = NAME_SLOT[ch]
elseif not ch then
out[#out + 1] = ("{BYTE:%02X}"):format(b)
end
@@ -3698,6 +3712,37 @@ function RomExtractorGen2:splashGfx()
}
end
-- The engine's own strings, keyed by the label the disassembly gives them.
--
-- This is what extractOakSpeech has always done for _OakText1-7: resolve the
-- label, decode from the cart, key by name. What is new is that the list of
-- labels comes from the manifest instead of being written out here, so all of
-- data/text/ arrives rather than seven strings. Gen 1 has had the same table
-- since RomExtractor:extractText; this is the Gen 2 side of it, and it is
-- what lets src/core/RomText.lua work on Gold and Silver at all.
--
-- Written as `rom_text` rather than `text`: data/generated/text.lua is
-- already the script text, keyed by bank:address for the overworld VM, and
-- these are a different table with different keys.
function RomExtractorGen2:extractText()
self:beginStage("Dialogue")
local charmap = self.manifest.charmap or {}
local labels = (self.manifest.text or {}).labels or {}
local texts = {}
for index, label in ipairs(labels) do
local location = self.symbols[label]
-- A label the manifest names but the symbol table does not carry would
-- be a generator bug, not a cart difference: make_gold_manifest.py
-- resolves every one of these before it writes the list.
if location then
texts[label] = self:decodeGen2Text(location[1], location[2], charmap)
end
self:tick("Dialogue", index, #labels)
end
self:write("rom_text", texts)
return texts
end
-- OakSpeech (engine/menus/intro_menu.asm): named _OakText* strings plus the
-- POKEMON_PROF / CAL trainer pics shown before NamePlayer. Also pulls
-- Shrink1/2 pics and the GameFreak splash sheets for the boot cinema.
@@ -6283,6 +6328,7 @@ function RomExtractorGen2:run()
results.sprites = self:extractSprites()
results.stdScripts = self:extractStdScripts()
results.scripts = self:extractScriptsAndText(results.maps, results.stdScripts)
results.text = self:extractText()
results.pokemon = self:extractPokemon()
results.moves = self:extractMoves()
results.items = self:extractItems()
+6
View File
@@ -104,6 +104,12 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
"data/generated/sprites.lua", -- OW sheets (Chris + NPCs)
"data/generated/scripts.lua", -- disassembled map scripts
"data/generated/text.lua", -- decoded Gen 2 dialogue strings
-- The engine's own strings, keyed by label rather than by address. A
-- cache built before RomExtractorGen2:extractText has none, and every
-- line that reads through src/core/RomText.lua would silently keep
-- printing its Lua fallback, so this re-imports those caches rather than
-- bumping CACHE_FORMAT and dragging Red, Blue and Yellow through it too.
"data/generated/rom_text.lua",
"data/generated/pokemon.lua",
"data/generated/tilesets.lua",
"data/generated/audio.lua",
+12 -6
View File
@@ -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 GameVersion.isYellow() then
if PaletteFX.usesYellowCgb() then
local fromCgb = yellowCgbNamedPal(data, name)
if fromCgb then return fromCgb 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
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 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
+20
View File
@@ -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
+7
View File
@@ -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)
+509 -115
View File
@@ -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
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
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:say(text, 200)
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", {
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),
})
local b = expand(self.game, "_TradeSendsText", {
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),
})
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
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", {
if not self.sub then
self.sub = "farewell_text"
self.t = 0
self.dialogText = expand(self.game, "_TradeWavesFarewellText", {
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
})
local b = expand(self.game, "_TradeTransferredText", {
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),
})
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
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")
-- 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
elseif self.t == 20 then
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)
elseif self.t >= 120 or skip then
local text = expand(self.game, "_TradeTakeCareText", {
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),
})
self:say(text, 80)
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,25 +843,32 @@ 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
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
@@ -492,36 +878,44 @@ function TradeAnim:draw()
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
if self.monVisible and self.sub ~= "take_care" and self.sub ~= "delay_end" then
self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10)
end
-- went_to / for_sends / farewell / delay: cleared window; TextBox draws
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
self:drawDialog()
end
return TradeAnim
+150 -31
View File
@@ -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,6 +378,7 @@ function MagnetTrainRide:drawPlayer()
if colors then GbcPalette.use(colors) end
G.setColor(1, 1, 1, 1)
for _, entry in ipairs(oam) do
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,
@@ -284,9 +386,11 @@ function MagnetTrainRide:drawPlayer()
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
@@ -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")
+1 -1
View File
@@ -42,7 +42,7 @@ check(type(RomExtractorGen2.extractDiploma) == "function",
"RomExtractorGen2:extractDiploma exists")
check(extractorSource:find("results.diploma = self:extractDiploma()", 1, true)
~= nil, "and RomExtractorGen2:run calls it")
check(extractorSource:find("local STAGE_COUNT = 26", 1, true) ~= nil,
check(extractorSource:find("local STAGE_COUNT = 27", 1, true) ~= nil,
"STAGE_COUNT counts the new stage, so the progress bar still ends at 1")
-- The three symbols the stage reads have to be in the curated manifest set or
+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
+139
View File
@@ -0,0 +1,139 @@
-- Gen 2's engine text, the counterpart to Gen 1's data/generated/text.lua.
--
-- Gold and Silver had no label-keyed string table at all: the manifests
-- carried no `text` section, RomExtractorGen2 had no extractText, and
-- game.data.text was never assigned, so every call through
-- src/core/RomText.lua fell back to the literal written beside it. These
-- cover the three halves of closing that: the manifest names the labels and
-- resolves every one, the decoder emits the runtime name slots rather than
-- dropping them, and RomText fills those slots.
--
-- GOLD_CACHE="..." luajit tests/gen2_rom_text_test.lua
--
-- ROM-free apart from the last section, which reads an imported cache's
-- rom_text.lua and skips cleanly when there is none.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 rom text")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local Json = require("src.link.Json")
local romText = require("src.core.RomText")
local function manifest(path)
local file = assert(io.open(path, "r"))
local data = assert(Json.decode(file:read("*a")))
file:close()
return data
end
-- ---- the label list, and that every label resolves ------------------------
-- A label the list names but the symbol table cannot place would fail the
-- import at the Dialogue stage rather than at generation time, so the pairing
-- is asserted here instead.
for _, edition in ipairs({ "gold", "silver" }) do
local data = manifest("tools/rom_manifest_" .. edition .. ".json")
local labels = (data.text or {}).labels or {}
check((data.text or {}).labels ~= nil,
edition .. " carries a text section")
check(#labels > 800,
("%s names %d text labels"):format(edition, #labels))
local unresolved = {}
for _, label in ipairs(labels) do
if not data.symbols[label] then unresolved[#unresolved + 1] = label end
end
eq(#unresolved, 0,
("every %s text label resolves to a symbol (%s)")
:format(edition, table.concat(unresolved, ", "):sub(1, 60)))
local named = {}
for _, label in ipairs(labels) do named[label] = true end
-- data/text/ also holds keyboard layouts and kana tables. Decoded as text
-- they come out as keyboard rows, so make_gold_manifest.TEXT_SOURCES leaves
-- their files out. `BattleText::` is excluded for a different reason: it
-- is a bank anchor sharing an address with the first real label under it,
-- and its own comment in the disassembly says so.
for _, excluded in ipairs({ "NameInputLower", "MailEntry_Uppercase",
"Dakutens", "Gen1TrainerClassNames", "BattleText" }) do
check(not named[excluded],
edition .. " leaves " .. excluded .. " out of the text list")
end
end
-- Both editions describe the same strings; only the addresses move.
do
local gold = (manifest("tools/rom_manifest_gold.json").text or {}).labels or {}
local silver =
(manifest("tools/rom_manifest_silver.json").text or {}).labels or {}
eq(#gold, #silver, "Gold and Silver name the same number of labels")
local mismatch
for index, label in ipairs(gold) do
if silver[index] ~= label then mismatch = label; break end
end
eq(mismatch, nil, "and the same labels in the same order")
end
-- ---- the slots RomText fills ----------------------------------------------
-- decodeGen2Text emits {USER}, {TARGET} and {ENEMY} for the three names
-- PlaceMoveUsersName / PlaceMoveTargetsName / PlaceEnemysName write at
-- runtime (home/text.asm:302, :307, :327). Dropped, the line printed with a
-- hole where the name belongs.
do
local data = { text = {
SubTookDamageText = "The SUBSTITUTE\ntook damage for\v{TARGET}!",
WantsToBattleText = "{ENEMY}\nwants to battle!",
ConfusedNoMoreText = "{USER}'s\nconfused no more!",
SuperEffectiveText = "It's super-\neffective!",
} }
eq(romText(data, "SubTookDamageText", "fallback", "GEODUDE"),
"The SUBSTITUTE\ntook damage for\vGEODUDE!",
"a {TARGET} slot takes the name the caller passes")
eq(romText(data, "WantsToBattleText", "fallback", "FALKNER"),
"FALKNER\nwants to battle!", "and so does {ENEMY}")
eq(romText(data, "ConfusedNoMoreText", "fallback", "CYNDAQUIL"),
"CYNDAQUIL's\nconfused no more!", "and {USER}")
eq(romText(data, "SuperEffectiveText", "It's super effective!"),
"It's super-\neffective!",
"a line with no slot comes back as the cart wrote it")
eq(romText(data, "NoSuchLabel", "the engine's own wording"),
"the engine's own wording",
"and a label the cache does not carry falls back")
end
-- ---- against a real imported cache ----------------------------------------
do
local cache = os.getenv("GOLD_CACHE")
if not cache then
local home = os.getenv("HOME") or ""
cache = home .. "/Library/Application Support/LOVE/gold-dev/gold"
end
local path = cache .. "/data/generated/rom_text.lua"
local file = io.open(path, "r")
if not file then
print(" (skipped: no rom_text.lua at " .. path .. ")")
else
file:close()
local texts = assert(loadfile(path))()
check(next(texts) ~= nil, "the imported cache carries strings")
-- Wording taken from pokegold's data/text/battle.asm, with \n for `line`
-- and \v for `cont`, which is what RomExtractorGen2 decodes those to.
eq(texts.SuperEffectiveText, "It's super-\neffective!",
"SuperEffectiveText comes off the cart hyphenated and broken")
eq(texts.NotVeryEffectiveText, "It's not very\neffective…",
"NotVeryEffectiveText ends on the ellipsis glyph")
eq(texts.StartPerishText, "Both POKéMON will\nfaint in 3 turns!",
"StartPerishText names both sides")
eq(texts.ButItFailedText, "But it failed!", "and a one-row line is one row")
eq(texts.SubTookDamageText, "The SUBSTITUTE\ntook damage for\v{TARGET}!",
"SpikesText's neighbour keeps its cont row and its target slot")
eq(texts.PlayerHitTimesText, "Hit {NUM} times!",
"a text_decimal reads back as {NUM}")
end
end
S.finish()
+1 -1
View File
@@ -45,7 +45,7 @@ check(type(RomExtractorGen2.extractTrade) == "function",
"RomExtractorGen2:extractTrade exists")
check(extractorSource:find("results.trade = self:extractTrade()", 1, true)
~= nil, "and RomExtractorGen2:run calls it")
check(extractorSource:find("local STAGE_COUNT = 26", 1, true) ~= nil,
check(extractorSource:find("local STAGE_COUNT = 27", 1, true) ~= nil,
"STAGE_COUNT counts the new stage, so the progress bar still ends at 1")
-- pokegold.sym, bank $0a. These are also what the cache is checked against
+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 = {} }
+1
View File
@@ -3526,6 +3526,7 @@ runSuites({
"tests/gen2_decorations_test.lua",
"tests/gen2_pokerus_test.lua",
"tests/gen2_common_text_test.lua",
"tests/gen2_rom_text_test.lua",
"tests/gen2_magnet_train_test.lua",
"tests/gen2_bank_of_mom_test.lua",
"tests/gen2_trainerhouse_test.lua",
+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()
+62 -4
View File
@@ -907,9 +907,62 @@ REQUIRED_SYMBOLS = {
}
def embedded_symbols(symbols, pokemon_labels, song_labels=()):
"""Resolve REQUIRED_SYMBOLS + pic labels + Music_* song headers."""
names = set(REQUIRED_SYMBOLS) | set(pokemon_labels) | set(song_labels)
# The engine's own text, the counterpart to make_rom_manifest.text_metadata.
#
# Only these five carry dialogue. data/text/'s other files are character
# tables rather than strings: dakutens.asm and name_input_chars.asm /
# mail_input_chars.asm are keyboard layouts, and unused_gen1_trainer_names.asm
# is a dead Gen 1 leftover. Decoding those as text yields keyboard rows and
# kana runs, so they are left out by name rather than filtered afterwards.
#
# None of the five carries an IF DEF(_GOLD) / IF DEF(_SILVER) arm, so the
# label set is one list for both editions and make_silver_manifest.py inherits
# it with the addresses re-resolved from pokesilver.sym.
TEXT_SOURCES = (
"battle.asm",
"common_1.asm",
"common_2.asm",
"common_3.asm",
"std_text.asm",
)
def text_labels(pokegold):
"""Every text label in TEXT_SOURCES, in sorted order.
Unlike Gen 1 there is no `dynamic` map beside this. pokered's decoder is
told which runtime token each label carries; RomExtractorGen2's reads the
cart's own TX_RAM / TX_DECIMAL command bytes and emits {STRBUF} / {NUM}
itself, so the label alone is enough.
"""
labels = set()
for name in TEXT_SOURCES:
path = os.path.join(pokegold, "data/text", name)
pending = None
for _, line in read_asm(path):
stripped = line.strip()
if not stripped:
continue
match = re.match(r"(\w+)::?\s*$", stripped)
if match:
# A label whose next line is another label owns no string of
# its own. `BattleText::` is the one in this set: its own
# comment says "used only for BANK(BattleText)", and it shares
# an address with the first real label under it, so taking it
# would decode that neighbour's string a second time.
pending = match.group(1)
continue
if pending:
labels.add(pending)
pending = None
return sorted(labels)
def embedded_symbols(symbols, pokemon_labels, song_labels=(),
text_label_names=()):
"""Resolve REQUIRED_SYMBOLS + pic labels + songs + text labels."""
names = (set(REQUIRED_SYMBOLS) | set(pokemon_labels)
| set(song_labels) | set(text_label_names))
for symbol_name in symbols.by_name:
# Pokedex entries are split across four banks and the game derives the
# bank arithmetically from the species id (radio.asm's rlca/maskbits
@@ -1111,6 +1164,7 @@ def generate(pokegold, symbols_path):
pokemon_labels.append(asset["backLabel"])
songs = music_order(pokegold)
text_label_names = text_labels(pokegold)
sfx = sfx_order(pokegold)
# Index 0 is NO_ITEM, so the parsed list is already 1-based on item id.
@@ -1202,6 +1256,9 @@ def generate(pokegold, symbols_path):
"battleAnimBgPaletteOrder": battle_anim_bg_pals,
"battleAnimObPaletteOrder": battle_anim_ob_pals,
},
# Label -> decoded string is built at import time from these, the
# same way Gen 1 builds data/generated/text.lua from its own list.
"text": {"labels": text_label_names},
"charmap": charmap(pokegold),
"fontCharmap": font_extract.parse_charmap(pokegold),
"pokemonAssets": assets,
@@ -1210,7 +1267,8 @@ def generate(pokegold, symbols_path):
"maps": {name: map_groups[name] for name in map_order},
"tilesets": {name: {} for name in tilesets},
}
data["symbols"] = embedded_symbols(symbols, pokemon_labels, songs)
data["symbols"] = embedded_symbols(
symbols, pokemon_labels, songs, text_label_names)
return data
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff