From 273350d11ec6bd36d3434be7bddf9875d9259da7 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:43:53 +0200 Subject: [PATCH 1/8] Route the Gen2 #DEX screen's kind/text through the pokemon registry src/ui/gen2/PokedexMenu.lua reads its KIND label and both description pages from data.gen2Pokedex.entries, loaded straight from disk before mods:load runs -- a separate table from data.pokemon, the `pokemon` registry's own merge target. mod.content.pokemon:patch(id, { dexEntry = ... }) therefore validated but never reached the screen. Adds src/core/gen2/PokedexText.lua to project a patched dexEntry onto the #DEX table after the merge (Game2:load, alongside the other Gen 2 post-merge registries), and a text2 field to the dexEntry schema for the entry's second description page, which the screen already reads but the registry had no field for. Also routes the OPTION/SEARCH panel titles (PokedexMenu.lua) through Strings(), the same literal-wrapping pattern already used elsewhere in this screen and its siblings. --- docs/modding/reference/registries.md | 2 +- src/core/Game2.lua | 5 ++++ src/core/gen2/PokedexText.lua | 39 ++++++++++++++++++++++++++++ src/mods/Schemas.lua | 6 ++++- src/ui/gen2/PokedexMenu.lua | 12 ++++++--- 5 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 src/core/gen2/PokedexText.lua diff --git a/docs/modding/reference/registries.md b/docs/modding/reference/registries.md index 96693e63..fcd96383 100644 --- a/docs/modding/reference/registries.md +++ b/docs/modding/reference/registries.md @@ -821,7 +821,7 @@ mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", { map = "ROUTE_31" }) | `catchRate` | integer 0..255 | yes | | `cry` | cries id | no | | `dex` | integer >= 1 | yes | -| `dexEntry` | {heightFt, heightIn, heightM?, kind, text, weight, weightKg?} | no | +| `dexEntry` | {heightFt, heightIn, heightM?, kind, text, text2?, weight, weightKg?} | no | | `evolutions` | list of {item?, level?, method, species} | yes | | `frontSize` | integer 1..7 | yes | | `growthRate` | growth_rates id | yes | diff --git a/src/core/Game2.lua b/src/core/Game2.lua index dbc6a0fc..4e274e86 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -1024,6 +1024,11 @@ function Game2:load() require("src.core.gen2.Phone").useRegistry(self.data) require("src.core.gen2.Decorations").useRegistry(self.data) require("src.core.gen2.Apricorns").useRegistry(self.data) + -- data.gen2Pokedex is a separate table from the `pokemon` registry's own + -- merge target (data.pokemon): a translation mod's + -- mod.content.pokemon:patch(id, { dexEntry = ... }) would otherwise never + -- reach the #DEX screen. See src/core/gen2/PokedexText.lua. + require("src.core.gen2.PokedexText").apply(self.data) -- Rendering pipelines: the engine half of the render_pipelines registry -- (src/render/Pipelines.lua). install() points it at GOLD's merged dataset diff --git a/src/core/gen2/PokedexText.lua b/src/core/gen2/PokedexText.lua new file mode 100644 index 00000000..f25379ae --- /dev/null +++ b/src/core/gen2/PokedexText.lua @@ -0,0 +1,39 @@ +-- Projects a mod's `pokemon` registry dexEntry onto the #DEX screen's own +-- data table. +-- +-- data.gen2Pokedex.entries[species] (data/generated/pokedex.lua, +-- RomExtractorGen2:extractPokedex) is what src/ui/gen2/PokedexMenu.lua +-- actually reads for the KIND label and the two description pages +-- (entry.kind, entry.text, entry.text2) -- a table loaded straight from +-- disk in Game2:load, BEFORE mods:load runs, and never routed through a +-- registry. The `pokemon` registry's own merge target is the separate +-- data.pokemon table (src/mods/Schemas.lua, R.pokemon: `target = "pokemon"`), +-- which mod.content.pokemon:patch(id, { dexEntry = { kind = ..., text = ..., +-- text2 = ... } }) already reaches -- so a translation mod's #DEX text was +-- silently invisible in-game despite validating against the registry. +-- +-- This is the missing link: called once after the merge (src/core/Game2.lua), +-- the same way ItemEffects.applyHeldItems projects held_items onto +-- data.gen2HeldItems. height/weight/dex stay untouched -- game state, not +-- text a translation carries. +local PokedexText = {} + +function PokedexText.apply(data) + local dex = data and data.gen2Pokedex + local pokemon = data and data.pokemon + if not (dex and dex.entries and pokemon) then return 0 end + local count = 0 + for species, entry in pairs(dex.entries) do + local def = pokemon[species] + local override = def and def.dexEntry + if override and (override.kind or override.text or override.text2) then + if override.kind then entry.kind = override.kind end + if override.text then entry.text = override.text end + if override.text2 then entry.text2 = override.text2 end + count = count + 1 + end + end + return count +end + +return PokedexText diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 2b6e8d38..1861aae7 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -826,10 +826,14 @@ R.pokemon = { item = f.opt(f.id("items")), species = f.id("pokemon") }), spriteFront = f.path, spriteBack = f.path, frontSize = f.int(1, 7), + -- text2 is the #DEX entry's second description page (Pokedex_asm's bare + -- `page` macro, engine/pokedex/pokedex.asm): PokedexMenu:drawEntryBody + -- shows `entry.text` on page 1 and `entry.text2` on page 2, so a + -- translation needs both to cover the whole entry. dexEntry = f.opt(f.rec{ kind = f.str, heightFt = f.int(0), heightIn = f.int(0, 11), weight = f.num, heightM = f.opt(f.num), weightKg = f.opt(f.num), - text = f.str }), + text = f.str, text2 = f.opt(f.str) }), icon = f.opt(f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } }), cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")), diff --git a/src/ui/gen2/PokedexMenu.lua b/src/ui/gen2/PokedexMenu.lua index 3b023465..9a1b3b70 100644 --- a/src/ui/gen2/PokedexMenu.lua +++ b/src/ui/gen2/PokedexMenu.lua @@ -37,6 +37,13 @@ local TileSheet = require("src.ui.gen2.TileSheet") local Nests = require("src.core.gen2.Nests") local Sound = require("src.core.Sound") local Unown = require("src.core.gen2.Unown") +local Strings = require("src.core.Strings") + +-- `db $3b, " OPTION ", $3c` / `db $3b, " SEARCH ", $3c"`: the panel titles +-- drawn by drawOption/drawSearch below, declared here (rather than inline) +-- so Strings.source puts them in the catalog harvest. +local OPTION_LABEL = Strings.source(" OPTION ") +local SEARCH_LABEL = Strings.source(" SEARCH ") local PokedexMenu = {} PokedexMenu.__index = PokedexMenu @@ -776,7 +783,6 @@ function PokedexMenu:printEntry() local row = self:current() if not row then return end local Printer = require("src.core.Printer") - local Strings = require("src.core.Strings") local TextBox = require("src.render.TextBox") local name = (self.pokemon and self.pokemon[row.species] and self.pokemon[row.species].name) or tostring(row.species) @@ -1385,7 +1391,7 @@ function PokedexMenu:drawOption() -- `db $3b, " OPTION ", $3c`: the two end-cap tiles are the dex sheet's, not -- font glyphs. self:tile(0x3b, 0, 1) - self:text(" OPTION ", 1, 1) + self:text(Strings(OPTION_LABEL), 1, 1) self:tile(0x3c, 9, 1) local rows = self:optionRows() for i, row in ipairs(rows) do @@ -1405,7 +1411,7 @@ function PokedexMenu:drawSearch() self:fill(TILE_BG, 0, 0, Chrome.SCREEN_W, Chrome.SCREEN_H) self:border(0, 2, 14, 18) self:tile(0x3b, 0, 1) - self:text(" SEARCH ", 1, 1) + self:text(Strings(SEARCH_LABEL), 1, 1) self:tile(0x3c, 9, 1) self:text("TYPE1", 3, 4) self:text("TYPE2", 3, 6) From c8eeeaa1f6657c42326ad4e022068707ccfe3dc7 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:44:00 +0200 Subject: [PATCH 2/8] Cover the #DEX registry projection fix with a targeted test --- tests/gen2_pokedex_text_registry_test.lua | 91 +++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/gen2_pokedex_text_registry_test.lua diff --git a/tests/gen2_pokedex_text_registry_test.lua b/tests/gen2_pokedex_text_registry_test.lua new file mode 100644 index 00000000..604b5322 --- /dev/null +++ b/tests/gen2_pokedex_text_registry_test.lua @@ -0,0 +1,91 @@ +-- The #DEX screen (src/ui/gen2/PokedexMenu.lua) reads its KIND label and +-- description pages from data.gen2Pokedex.entries, a table loaded straight +-- from disk before mods:load runs -- a separate table from data.pokemon, +-- the `pokemon` registry's own merge target +-- (mod.content.pokemon:patch(id, { dexEntry = ... })). Without a projection +-- step, a translation mod's #DEX text validates against the registry and +-- never reaches the screen. src/core/gen2/PokedexText.lua is that +-- projection, called once after the merge (src/core/Game2.lua). ROM-free: +-- luajit tests/gen2_pokedex_text_registry_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 pokedex text registry") +local check, eq = S.check, S.eq + +local PokedexText = require("src.core.gen2.PokedexText") + +-- A patched species (CYNDAQUIL, kind/text/text2 all overridden), one left +-- alone (TOTODILE, no dexEntry at all in data.pokemon -- a mod that never +-- touched it), and one gen2Pokedex has no matching data.pokemon row for +-- (UNOWN, e.g. a form gen2Pokedex tracks that the pokemon registry does not). +do + local data = { + pokemon = { + CYNDAQUIL = { name = "CYNDAQUIL", dexEntry = { + kind = "SOURIS DE FEU", text = "Page un traduite.", + text2 = "Page deux traduite.", + } }, + TOTODILE = { name = "TOTODILE" }, + }, + gen2Pokedex = { entries = { + CYNDAQUIL = { id = "CYNDAQUIL", dex = 155, kind = "FIRE MOUSE", + height = 108, weight = 170, + text = "It is timid, and always curls itself up in a ball.", + text2 = "If attacked, it flares up its back for protection." }, + TOTODILE = { id = "TOTODILE", dex = 158, kind = "BIG JAW", + height = 200, weight = 200, text = "English page one.", + text2 = "English page two." }, + UNOWN = { id = "UNOWN", dex = 201, kind = "SYMBOL", + height = 50, weight = 50, text = "English only." }, + } }, + } + + local touched = PokedexText.apply(data) + eq(touched, 1, "only the species a mod actually patched are touched") + + local cyndaquil = data.gen2Pokedex.entries.CYNDAQUIL + eq(cyndaquil.kind, "SOURIS DE FEU", "kind reaches the #DEX entry") + eq(cyndaquil.text, "Page un traduite.", "page one reaches the #DEX entry") + eq(cyndaquil.text2, "Page deux traduite.", "page two reaches the #DEX entry") + eq(cyndaquil.dex, 155, "dex number is untouched -- it is not translatable text") + eq(cyndaquil.height, 108, "height is untouched -- game state, not prose") + eq(cyndaquil.weight, 170, "weight is untouched -- game state, not prose") + + local totodile = data.gen2Pokedex.entries.TOTODILE + eq(totodile.kind, "BIG JAW", "a species with no dexEntry patch stays in English") + eq(totodile.text, "English page one.", "unpatched page one is untouched") + + local unown = data.gen2Pokedex.entries.UNOWN + eq(unown.kind, "SYMBOL", + "a #DEX entry with no data.pokemon counterpart at all is left alone") +end + +-- A patch that only sets `kind` (species_kinds without species_dex_text +-- shipped, or vice versa) must not blank out the fields it did not touch. +do + local data = { + pokemon = { ABRA = { name = "ABRA", dexEntry = { kind = "PSY" } } }, + gen2Pokedex = { entries = { + ABRA = { id = "ABRA", dex = 63, kind = "PSI", + text = "English text.", text2 = "English text two." }, + } }, + } + PokedexText.apply(data) + local abra = data.gen2Pokedex.entries.ABRA + eq(abra.kind, "PSY", "kind alone is applied") + eq(abra.text, "English text.", "text is left alone when the patch omits it") + eq(abra.text2, "English text two.", "text2 is left alone when the patch omits it") +end + +-- Missing tables at every level answer 0 rather than raising -- a Gen 1 boot, +-- or a Gold boot with no mods loaded, never calls this with a shaped table. +do + eq(PokedexText.apply(nil), 0, "nil data does not raise") + eq(PokedexText.apply({}), 0, "empty data does not raise") + eq(PokedexText.apply({ gen2Pokedex = { entries = {} } }), 0, + "no data.pokemon at all does not raise") +end + +check(true, "PokedexText.apply covers the projection this fix adds") + +S.finish() From fe31293566decb2459946dbf48ea892c93cf5750 Mon Sep 17 00:00:00 2001 From: Colson Rice Date: Wed, 26 Aug 2026 14:52:44 -0400 Subject: [PATCH 3/8] Refuse a Gen 2 cart save as a Gen 2 save, not as a broken Gen 1 one #1832 reports a Crystal .sav failing to import with "save data checksum invalid (main data checksum mismatch)". The save is fine. It is being measured with the wrong generation's ruler. SaveFileIO.importToSlot sends anything that is not exactly SAVE_SIZE into mainChecksumValid, which is pokered's main-data checksum. Gen 2 carts are MBC3+TIMER, so a real Gold, Silver or Crystal battery save carries an RTC footer: 32786 bytes, never 32768. It misses the size test, falls into the checksum branch, and is told it is damaged by a rule written for a different game. Every real Gen 2 cart save takes that path, every time. The generation check already exists, it just lives too late, inside importSav and behind the size gate that has already rejected the save. This lifts it into SaveConvert.importSupported and exportSupported and asks before the bytes are measured. Both existing guards route through the same predicates now, so the early caller and the late one cannot describe the same game two different ways. Nothing changes for Gen 1, and Gen 2 is still refused, for the real reason and in a sentence a player can do something with. tests/gen2_save_import_message_test.lua covers it. Against the code as it stands it is 20/26, and the failures are the report itself. Co-Authored-By: Claude Opus 5 --- src/import/SaveFileIO.lua | 6 ++ src/save_convert/SaveConvert.lua | 41 ++++++++--- tests/gen2_save_import_message_test.lua | 96 +++++++++++++++++++++++++ tests/run_tests.lua | 1 + 4 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 tests/gen2_save_import_message_test.lua diff --git a/src/import/SaveFileIO.lua b/src/import/SaveFileIO.lua index 0a36fa1a..8723fe0a 100644 --- a/src/import/SaveFileIO.lua +++ b/src/import/SaveFileIO.lua @@ -78,6 +78,12 @@ function SaveFileIO.importToSlot(source, version, force) version = version or GameVersion.get() local bytes, readErr = readSource(source) if not bytes then return false, readErr end + -- The GAME decides before the BYTES do. Everything below this line judges a + -- save by Gen 1's rules -- the size test, and mainChecksumValid, which is + -- pokered's checksum -- so a Gen 2 cart save reaching it is measured against + -- a rule that cannot match and comes back "checksum invalid" (#1832). + local supported, unsupportedWhy = SaveConvert.importSupported(version) + if not supported then return false, unsupportedWhy end if #bytes ~= SAVE_SIZE then local check = SaveConvert.mainChecksumValid(bytes) if check == nil then diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index e9d11bd0..449cab82 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -235,6 +235,35 @@ local function gen2CartName(gameVersion) return GameVersion.info(gameVersion).displayName end +-- Can a cart save for this game cross in or out at all? Public because the +-- launcher has to ask about the GAME before it measures the BYTES. +-- +-- SaveFileIO.importToSlot judges anything that is not exactly SAVE_SIZE with +-- mainChecksumValid, which is pokered's main-data checksum. A Gen 2 cart is +-- MBC3+TIMER, so a real Gold/Silver/Crystal .sav carries an RTC footer and is +-- 32786 bytes: it misses the size test and is then measured against a rule +-- that was never going to match it. The player is told "save data checksum +-- invalid" about a save that is perfectly good (#1832). +-- +-- Returns true, or false plus the same sentence importSav/exportSav would have +-- answered with, so a caller that asks early and a caller that does not cannot +-- describe the same game two different ways. +function SaveConvert.importSupported(gameVersion) + local gen2Name = gen2CartName(gameVersion) + if gen2Name then + return false, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet." + end + return true +end + +function SaveConvert.exportSupported(gameVersion) + local gen2Name = gen2CartName(gameVersion) + if gen2Name then + return false, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet." + end + return true +end + -- importSav(bytes, version, gameVersion) -> saveTable, err -- bytes: the raw 32768-byte SRAM string. Validates size and the main-data -- checksum, decodes through GenSave, and returns a save table fully merged @@ -247,10 +276,8 @@ function SaveConvert.importSav(bytes, version, gameVersion) if type(bytes) ~= "string" then return nil, "expected raw save bytes as a string" end - local gen2Name = gen2CartName(gameVersion) - if gen2Name then - return nil, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet." - end + local supported, unsupportedWhy = SaveConvert.importSupported(gameVersion) + if not supported then return nil, unsupportedWhy end if #bytes ~= GenSave.SAVE_SIZE then return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes) end @@ -283,10 +310,8 @@ function SaveConvert.exportSav(saveTable, gameVersion) if type(saveTable) ~= "table" then return nil, "expected a save table" end - local gen2Name = gen2CartName(gameVersion) - if gen2Name then - return nil, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet." - end + local supported, unsupportedWhy = SaveConvert.exportSupported(gameVersion) + if not supported then return nil, unsupportedWhy end local data, derr = ensureData(gameVersion) if not data then return nil, derr end local ok, bytes = pcall(GenSave.encode, saveTable, data, nil) diff --git a/tests/gen2_save_import_message_test.lua b/tests/gen2_save_import_message_test.lua new file mode 100644 index 00000000..652479f2 --- /dev/null +++ b/tests/gen2_save_import_message_test.lua @@ -0,0 +1,96 @@ +-- A Gen 2 cart save must be refused as a Gen 2 cart save, not as a corrupt +-- Gen 1 one (#1832). +-- luajit tests/gen2_save_import_message_test.lua +-- Also dofile'd by tests/run_tests.lua. +-- +-- SaveFileIO.importToSlot judges anything that is not exactly SAVE_SIZE with +-- SaveConvert.mainChecksumValid, which is pokered's main-data checksum. Gen 2 +-- carts are MBC3+TIMER, so a real Gold/Silver/Crystal battery save carries an +-- RTC footer and is 32786 bytes: it misses the size test, is then measured +-- against a checksum rule written for a different generation, and the launcher +-- tells the player their save is corrupt. It is not -- there is simply no Gen +-- 2 codec yet, which is a different sentence and an actionable one. +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = love or require("tests.love_stub") + +local S = require("tests.harness").suite("gen2 save import message") +local check = S.check + +local SaveConvert = require("src.save_convert.SaveConvert") +local SaveFileIO = require("src.import.SaveFileIO") + +-- The size a real Gen 2 cart save actually is: 32768 bytes of SRAM plus the +-- 18-byte RTC footer an MBC3+TIMER cart writes. +local GEN2_CART_SAVE_SIZE = 32786 + +local function blob(n) return string.rep("\0", n) end + +-- readSource only takes a raw string when it is EXACTLY 32768 bytes; anything +-- else is treated as a picker path (its own comment says so). A real Gen 2 +-- cart save is 32786, so it can only ever reach importToSlot as a FILE -- which +-- is exactly how the player in #1832 supplied theirs. Write one and hand over +-- the path, so this exercises the route the report came from. +local function savFile(n) + local path = os.tmpname() + local f = assert(io.open(path, "wb")) + f:write(blob(n)) + f:close() + return path +end + +-- ------------------------------------------------------------------ +-- The report: a real Gen 2 save is not "checksum invalid" +-- ------------------------------------------------------------------ + +for _, version in ipairs({ "gold", "silver", "crystal" }) do + local ok, err = SaveFileIO.importToSlot(savFile(GEN2_CART_SAVE_SIZE), version, true) + check(ok == false, version .. ": a Gen 2 cart save is still refused") + check(type(err) == "string" and err:find("Gen 2 cart save", 1, true) ~= nil, + version .. ": refused AS a Gen 2 save -- got: " .. tostring(err)) + check(type(err) == "string" and err:find("checksum", 1, true) == nil, + version .. ": never blamed on a checksum it was never measured by -- got: " + .. tostring(err)) +end + +-- ------------------------------------------------------------------ +-- The predicate both callers share +-- ------------------------------------------------------------------ + +for _, version in ipairs({ "red", "blue", "yellow" }) do + check(SaveConvert.importSupported(version) == true, + version .. ": Gen 1 import is unaffected") + check(SaveConvert.exportSupported(version) == true, + version .. ": Gen 1 export is unaffected") +end + +for _, version in ipairs({ "gold", "silver", "crystal" }) do + local impOk, impWhy = SaveConvert.importSupported(version) + local expOk, expWhy = SaveConvert.exportSupported(version) + check(impOk == false and expOk == false, version .. ": both directions say no") + -- One sentence per direction, wherever it is asked from: the early gate in + -- SaveFileIO and the late one inside importSav must not describe the same + -- game two different ways. + local _, lateWhy = SaveConvert.importSav(blob(32768), version, version) + check(impWhy == lateWhy, + version .. ": the early gate and importSav answer identically") + check(expWhy:find("exporting", 1, true) ~= nil, + version .. ": the export sentence is about exporting") +end + +-- ------------------------------------------------------------------ +-- Gen 1 keeps its own diagnosis +-- ------------------------------------------------------------------ +-- +-- A Gen 1 save that really is the wrong size AND fails pokered's checksum must +-- still say so: this fix moves the generation check in front of that test, it +-- does not remove it. + +do + local ok, err = SaveFileIO.importToSlot(savFile(GEN2_CART_SAVE_SIZE), "red", true) + check(ok == false, "red: a corrupt oversize save is still refused") + check(type(err) == "string" and err:find("checksum", 1, true) ~= nil, + "red: still diagnosed by pokered's checksum -- got: " .. tostring(err)) +end + +S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index b778adaf..8a39a350 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -3615,6 +3615,7 @@ runSuites(orderedGlob( -- name resolution, and the .sav converter refusing a Gen 2 save table. "tests/gen2_sound_alias_test.lua", "tests/gen2_save_convert_cli_test.lua", + "tests/gen2_save_import_message_test.lua", -- The wall radios (`special MapRadio`). gen2_save_export_test cannot share a -- process (LEAKS_SAVE_SLOT_STATE above); tests/run_gen2.lua runs it alone. "tests/gen2_map_radio_test.lua", From 3a0a72102fb9ab6b6dc375c3745604bb0240bca1 Mon Sep 17 00:00:00 2001 From: Colson Rice Date: Wed, 26 Aug 2026 15:47:08 -0400 Subject: [PATCH 4/8] Teach the bug420 SaveConvert double the method importToSlot now calls CI caught this: tests/engine/save_import_retry_bug420.lua replaces SaveConvert with a minimal double, and importToSlot now asks it importSupported before it measures the bytes, so the double answered nil and the call died. The double stands in for the real module, so it grows with it. Answering true keeps that case about the thing it is testing, which is that importToSlot names the game whose cache to read. ./scripts/test.sh passes end to end locally now, every ROM-free tier. Co-Authored-By: Claude Opus 5 --- tests/engine/save_import_retry_bug420.lua | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/engine/save_import_retry_bug420.lua b/tests/engine/save_import_retry_bug420.lua index 46b955e3..bf14507c 100644 --- a/tests/engine/save_import_retry_bug420.lua +++ b/tests/engine/save_import_retry_bug420.lua @@ -221,6 +221,11 @@ do local seen = {} package.loaded["src.save_convert.SaveConvert"] = { SAVE_SIZE = 32768, + -- importToSlot asks this before it measures the bytes, so a save for a + -- game with no codec is refused as that rather than as a bad checksum. + -- The double has to answer it; "yes" is what keeps this case about the + -- cache-name contract below and nothing else. + importSupported = function() return true end, importSav = function(_, version, gameVersion) seen.import = { version = version, gameVersion = gameVersion } return nil, "stub" From 48d8a4e9221184ab05591a9da1474fe3d8586966 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 27 Aug 2026 05:09:10 -0400 Subject: [PATCH 5/8] CLOSES #1562, CLOSES #1804, CLOSES #1805, CLOSES #1810, CLOSES #1811, CLOSES #1814, CLOSES #1817, CLOSES #1818, CLOSES #1819, CLOSES #1821, CLOSES #1826, CLOSES #1827, CLOSES #1829, CLOSES #1833, CLOSES #1840, CLOSES #1842, CLOSES #1845, CLOSES #1846, CLOSES #1847, CLOSES #1848, CLOSES #1849, CLOSES #1853, CLOSES #1858, CLOSES #1862 --- data/scripts/story.lua | 31 +- data/scripts/story3.lua | 41 +- src/battle/AnimPlayer.lua | 20 +- src/battle/BattleState.lua | 53 ++- src/battle/gen2/AnimRunner.lua | 1 + src/core/Game.lua | 10 +- src/inventory/ItemEffects.lua | 7 +- src/link/LinkBattle.lua | 30 +- src/mods/Gen2Compat.lua | 6 +- src/render/Renderer.lua | 13 + src/render/TextBox.lua | 75 ++++ src/script/gen2/CallAsm.lua | 11 +- src/ui/BagMenu.lua | 4 +- src/ui/BoxMenu.lua | 126 +++++-- src/ui/DexEntryMenu.lua | 42 ++- src/ui/ListMenu.lua | 67 ++-- src/ui/MoveLearnMenu.lua | 7 +- src/ui/PartyMenu.lua | 26 +- src/ui/PlayerPC.lua | 49 ++- src/ui/PokedexMenu.lua | 355 ++++++++++++++---- src/ui/SlotMachine.lua | 37 +- src/ui/StartMenu.lua | 4 +- src/ui/TownMap.lua | 70 ++-- src/ui/gen2/BattleState.lua | 49 ++- src/ui/gen2/SummaryMenu.lua | 15 +- src/world/OverworldController.lua | 56 ++- src/world/gen2/World.lua | 13 +- tests/drivers/fly_bird_bug1840_test.lua | 101 +++++ tests/drivers/ghost_marowak_bug1849_test.lua | 129 +++++++ .../gold_stats_text_paper_bug1858_test.lua | 156 ++++++++ .../gold_whirlpool_block_bug1862_test.lua | 141 +++++++ .../drivers/nightshade_anim_bug1848_test.lua | 122 ++++++ .../party_submenu_box_bug1819_test.lua | 118 ++++++ tests/drivers/pc_deposit_test.lua | 6 +- .../drivers/pc_lists_bug1845_bug1847_test.lua | 206 ++++++++++ .../drivers/pokedex_contents_bug1829_test.lua | 119 ++++++ tests/drivers/rare_candy_bug1846_test.lua | 71 ++++ tests/drivers/rocket3_sight_bug1814_test.lua | 100 +++++ tests/drivers/slots_prompt_bug1811_test.lua | 113 ++++++ tests/drivers/title_seam_bug1866_test.lua | 155 ++++++++ tests/drivers/vitamin_bug1805_test.lua | 78 ++++ tests/engine/gen2_ball_anim_id_bug1804.lua | 41 ++ .../gen2_front_anim_mod_sheet_bug1827.lua | 79 ++++ tests/engine/gen2_stats_text_tint_bug1858.lua | 72 ++++ ...gen2_whirlpool_block_after_sfx_bug1862.lua | 56 +++ tests/engine/link_switch_withdraw_bug1562.lua | 105 ++++++ tests/engine/party_fieldmove_order_bug792.lua | 12 +- tests/engine/party_submenu_cancel_bug1833.lua | 98 +++++ tests/engine/pokedex_contents_bug1829.lua | 127 +++++++ tests/engine/rare_candy_bag_open_bug796.lua | 14 +- tests/engine/slot_machine_boxes_bug1811.lua | 100 +++++ tests/engine/start_menu_cursor_bug1821.lua | 52 +++ tests/engine/textbox_pause_bug1818.lua | 113 ++++++ tests/engine/warp_sprite_hidden_bug916.lua | 12 +- tests/gen2_world_test.lua | 6 +- tests/mod_ui_tests.lua | 11 +- tests/parity_ai_switch_exp.lua | 82 ++++ tests/parity_ball_shake_anim.lua | 47 +++ tests/parity_box_mon_stats.lua | 5 +- tests/parity_ghost_marowak.lua | 112 ++++++ tests/parity_giovanni_end_battle.lua | 76 ++++ tests/parity_hidden_coins_bcd_bug1810.lua | 38 ++ tests/parity_marowak.lua | 7 +- tests/parity_pokemon_tower_rival_bug1842.lua | 79 ++++ tests/parity_rocket3_sight_bug1814.lua | 80 ++++ tests/parity_tower_rival.lua | 12 +- tests/parity_wavy_screen.lua | 35 ++ tests/run_tests.lua | 3 +- 68 files changed, 3979 insertions(+), 328 deletions(-) create mode 100644 tests/drivers/fly_bird_bug1840_test.lua create mode 100644 tests/drivers/ghost_marowak_bug1849_test.lua create mode 100644 tests/drivers/gold_stats_text_paper_bug1858_test.lua create mode 100644 tests/drivers/gold_whirlpool_block_bug1862_test.lua create mode 100644 tests/drivers/nightshade_anim_bug1848_test.lua create mode 100644 tests/drivers/party_submenu_box_bug1819_test.lua create mode 100644 tests/drivers/pc_lists_bug1845_bug1847_test.lua create mode 100644 tests/drivers/pokedex_contents_bug1829_test.lua create mode 100644 tests/drivers/rare_candy_bug1846_test.lua create mode 100644 tests/drivers/rocket3_sight_bug1814_test.lua create mode 100644 tests/drivers/slots_prompt_bug1811_test.lua create mode 100644 tests/drivers/title_seam_bug1866_test.lua create mode 100644 tests/drivers/vitamin_bug1805_test.lua create mode 100644 tests/engine/gen2_ball_anim_id_bug1804.lua create mode 100644 tests/engine/gen2_front_anim_mod_sheet_bug1827.lua create mode 100644 tests/engine/gen2_stats_text_tint_bug1858.lua create mode 100644 tests/engine/gen2_whirlpool_block_after_sfx_bug1862.lua create mode 100644 tests/engine/link_switch_withdraw_bug1562.lua create mode 100644 tests/engine/party_submenu_cancel_bug1833.lua create mode 100644 tests/engine/pokedex_contents_bug1829.lua create mode 100644 tests/engine/slot_machine_boxes_bug1811.lua create mode 100644 tests/engine/start_menu_cursor_bug1821.lua create mode 100644 tests/engine/textbox_pause_bug1818.lua create mode 100644 tests/parity_ai_switch_exp.lua create mode 100644 tests/parity_ball_shake_anim.lua create mode 100644 tests/parity_ghost_marowak.lua create mode 100644 tests/parity_giovanni_end_battle.lua create mode 100644 tests/parity_hidden_coins_bcd_bug1810.lua create mode 100644 tests/parity_pokemon_tower_rival_bug1842.lua create mode 100644 tests/parity_rocket3_sight_bug1814.lua create mode 100644 tests/parity_wavy_screen.lua diff --git a/data/scripts/story.lua b/data/scripts/story.lua index ec5435a2..0e4b8fbc 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -1251,19 +1251,26 @@ local function pokemonTower2FRivalScript(playerX) and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN return { - { "face_player" }, -- 1 - { "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2 - { "jump_if_true", 12 }, -- 3 - { "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4 - { "rival_battle", "OPP_RIVAL2", 4 }, -- 5 - { "jump_if_false", "end" }, -- 6 loss: stay - { "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7 - { "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8 + { "face_player" }, + { "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, + { "jump_if_true", "beaten" }, + { "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, + -- .DefeatedText rides BIT_PRINT_END_BATTLE_TEXT, so it prints on the + -- battle screen -- scripts/PokemonTower2F.asm:145-150 + { "save_end_battle_text", "_PokemonTower2FRivalDefeatedText" }, + { "rival_battle", "OPP_RIVAL2", 4 }, + { "jump_if_false", "end" }, -- loss: stay + { "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, + -- the win re-runs DisplayTextID on the beaten branch + -- scripts/PokemonTower2F.asm:72-75, :137-140 + { "show_text", "_PokemonTower2FRivalHowsYourDexText" }, { "play_music", "Music_MeetRival", { start = "rival" } }, - { "walk_npc", 1, exitDirs }, -- 9 - { "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10 - { "jump", "end" }, -- 11 - { "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12 + { "walk_npc", 1, exitDirs }, + { "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, + { "play_default_music" }, -- scripts/PokemonTower2F.asm:124 + { "jump", "end" }, + { "label", "beaten" }, + { "show_text", "_PokemonTower2FRivalHowsYourDexText" }, } end diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 9c6ae321..c47a1ad4 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -167,13 +167,11 @@ M.POKEMON_TOWER_6F = { -- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants -- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the -- calmed line; the port dropped the first text and the cry - -- (#867). play_cry arms the next show_text, so the cry rides - -- the calmed box's open with the button prompt kept, and the - -- wait row stands in for the asm's 30-frame gap. + -- (#867). text/PokemonTower6F.asm:1-5 (#1849) local rows = { + { "play_cry", "MAROWAK" }, { "show_text", t._PokemonTower6FGhostWasCubonesMotherText or "The GHOST was the\nrestless soul of\vCUBONE's mother!" }, - { "play_cry", "MAROWAK", true }, { "wait", 30 }, { "show_text", t._PokemonTower6FSoulWasCalmedText or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" }, @@ -191,7 +189,8 @@ M.POKEMON_TOWER_6F = { end ow:afterBattle(result, battle) end - game.stack:push(battle) + -- InitWildBattle runs the wipe before the disguise (core.asm:6695-6702) + ow:pushBattle(battle) end)) return true end, @@ -443,6 +442,9 @@ M.ROCKET_HIDEOUT_B4F = { or "I hope we meet\nagain..." game.stack:push(TextBox.new(game, impressed, function() local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1) + -- SaveEndBattleTextPointers (scripts/RocketHideoutB4F.asm:99-120): + -- PrintEndBattleText prints it on the battle screen (#1817) + battle.endBattleText = TextBox.substitute(game, cannotBe) battle.onFinish = function(result) if result ~= "win" then ow:afterBattle(result, battle) @@ -451,22 +453,19 @@ M.ROCKET_HIDEOUT_B4F = { end game.save.defeatedTrainers[npc.id] = true game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true - -- End-battle "WHAT!" then BeatGiovanniScript's hope text, - -- fade, HideObject Giovanni, ShowObject Silph Scope. - game.stack:push(TextBox.new(game, cannotBe, function() - game.stack:push(TextBox.new(game, hope, function() - local Transition = require("src.render.Transition") - game.stack:push(Transition.new(game, function() - local Commands = require("src.script.Commands") - local ctx = { game = game, save = game.save, overworld = ow } - Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F", - "ROCKETHIDEOUTB4F_GIOVANNI") - Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F", - "ROCKETHIDEOUTB4F_SILPH_SCOPE") - end, function() - ow:afterBattle(result, battle) - done() - end)) + -- BeatGiovanniScript (scripts/RocketHideoutB4F.asm) + game.stack:push(TextBox.new(game, hope, function() + local Transition = require("src.render.Transition") + game.stack:push(Transition.new(game, function() + local Commands = require("src.script.Commands") + local ctx = { game = game, save = game.save, overworld = ow } + Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F", + "ROCKETHIDEOUTB4F_GIOVANNI") + Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F", + "ROCKETHIDEOUTB4F_SILPH_SCOPE") + end, function() + ow:afterBattle(result, battle) + done() end)) end)) end diff --git a/src/battle/AnimPlayer.lua b/src/battle/AnimPlayer.lua index 7f305432..051da07f 100644 --- a/src/battle/AnimPlayer.lua +++ b/src/battle/AnimPlayer.lua @@ -104,7 +104,9 @@ local SE_FRAMES = { SE_FLASH_MON_PIC = 4, SE_FLASH_ENEMY_MON_PIC = 4, SE_TRANSFORM_MON = 4, SE_SUBSTITUTE_MON = 3, - SE_WAVY_SCREEN = 255, -- AnimationWavyScreen: ld c, $ff frames + -- AnimationWavyScreen: `ld c, $ff` counts outer passes, and the inner + -- loop exits twice per displayed frame (animations.asm:1884-1903) + SE_WAVY_SCREEN = 128, } -- data/battle_anims/special_effects.asm AnimationIdSpecialEffects: @@ -502,15 +504,10 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts) local transform = resolveTransform(sub.type, attackerIsPlayer) local first, last, dir = 1, #sub.blocks, 1 if transform == "REVERSE" then first, last, dir = last, first, -1 end - -- DoBallShakeSpecialEffects: each ball shake opens with SFX_TINK - -- and a 40-frame pause, then rewinds the same subanimation; the - -- mode-4 frame blocks persist, so the resting ball stays visible - -- through the pauses between wobbles + -- DoBallShakeSpecialEffects: animations.asm:739-747, :623-627 + local pendingTink = false for _ = 1, (opts and opts.shakes) or 1 do - if opts and opts.shakes then - events[#events + 1] = { effect = "SFX_TINK", frame = frame } - emit(40) - end + pendingTink = opts and opts.shakes ~= nil local dest = 1 -- PlaySubanimation resets the OAM cursor per row local nblocks = math.abs(last - first) + 1 local played = 0 @@ -568,6 +565,11 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts) -- DoSpecialEffectByAnimationId runs after every frame -- block with wSubAnimCounter = blocks remaining played = played + 1 + if pendingTink then + pendingTink = false + events[#events + 1] = { effect = "SFX_TINK", frame = frame } + emit(40) + end if ballFlicker then obp0Flip = not obp0Flip end if idFx then local counter = nblocks - played + 1 diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index c6b41847..53984c2e 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -340,6 +340,22 @@ local function monPalette(data, species) return { name = name, colors = colors } end +-- MarowakAnim OBJ pics under OAM_PAL1: +-- engine/battle/ghost_marowak_anim.asm:3-5,77 +local function objPicPalette() + local PaletteFX = require("src.render.PaletteFX") + if not PaletteFX.usesSpriteObp() then return nil end + local colors, group = PaletteFX.ogObj() + if not colors then return nil end + return { name = "obp1:" .. tostring(group), colors = colors } +end + +local function objPic(path, trueColor) + local pal = objPicPalette() + if not pal then return nil end + return getImage(path, pal, trueColor) +end + -- a named palette from the active COLORS pack as a getImage pal local function namedPalette(data, name) local PaletteFX = require("src.render.PaletteFX") @@ -897,7 +913,8 @@ local function disguiseAsGhost(self) self.enemy.name = "GHOST" self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png", monPalette(self.data, self.enemy.mon.species)) - self.introText = Strings("The GHOST\nappeared!") + -- _EnemyAppearedText (data/text/text_2.asm:1251-1255) has no article + self.introText = self:romText("_EnemyAppearedText", "%s\nappeared!", self.enemy.name) end -- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope @@ -941,7 +958,11 @@ function BattleState:queueScopeReveal() local unveiled = self.data.text and self.data.text._UnveiledGhostText self:say(unveiled or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!")) - self:act(function() self.ghostReveal = { t = 0 } end) + self:act(function() + self.ghostReveal = { t = 0 } + local ghostObj = objPic("assets/generated/battle/front/ghost.png") + if ghostObj then self.enemy.sprite = ghostObj end + end) table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES }) self:say(self:romText("_WildMonAppearedText", "Wild %s\nappeared!", self.ghostReal and self.ghostReal.name or self.enemy.name)) @@ -3264,9 +3285,9 @@ function BattleState:applyAnimEffect(ev) end fx.hudShakeProg = prog elseif e == "SE_WAVY_SCREEN" then - -- AnimationWavyScreen: 255 frames of per-scanline SCX offsets - -- walking WavyScreenLineOffsets - fx.wavy = { left = 255, phase = 0 } + -- AnimationWavyScreen: 255 outer passes, two per displayed frame + -- (animations.asm:1884-1903), walking WavyScreenLineOffsets + fx.wavy = { left = 128, phase = 0 } -- ---------------------------------------------- mon pic effects elseif e == "SE_SLIDE_MON_OFF" then @@ -3650,7 +3671,7 @@ function BattleState:updateFx() end if fx.wavy then fx.wavy.left = fx.wavy.left - 1 - fx.wavy.phase = fx.wavy.phase + 1 + fx.wavy.phase = fx.wavy.phase + 2 if fx.wavy.left <= 0 then fx.wavy = nil end end end @@ -3704,12 +3725,22 @@ function BattleState:updateFx() local real = self.ghostReal if real then self.enemy.name = real.name or self.enemy.name - self.enemy.sprite = real.sprite or self.enemy.sprite + gr.bgSprite = real.sprite or self.enemy.sprite + local objReal + if objPicPalette() then + local Sprites = require("src.pokemon.Sprites") + local path, tc = Sprites.path(self.data, self.enemy.mon.species, + "front", { mon = self.enemy.mon, kind = "battle" }) + objReal = path and objPic(path, tc) + end + self.enemy.sprite = objReal or gr.bgSprite end end pf.fade = math.min(1, math.ceil((gr.t - outEnd) / 10) / 4) end if gr.t >= BattleState.GHOST_REVEAL_FRAMES then + -- home/clear_sprites.asm:1 + if gr.bgSprite then self.enemy.sprite = gr.bgSprite end self.ghostReveal, self.scopeReveal, pf.fade = nil, nil, nil end end @@ -3825,6 +3856,9 @@ function BattleState:executeAction(user, target, action) -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap clearTrapping(self.player) self:syncSides() + -- EnemySendOut (core.asm:1276-1289): only the mon on the field stays flagged + self.participants = {} + self:markParticipant() Runtime.emit("battle.battler_switched", { battle = self, side = self.sides[2], battler = self.enemy, previous = previous, @@ -5950,8 +5984,7 @@ local WAVY_OFFSETS = { 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1, 0, 0, 0, 0, 0, -1, -1, -1, -2, -2, -2, -2, -2, -1, -1, -1 } --- wave the BG canvas one scanline at a time; the offset table walks --- one entry per frame like the asm's advancing pointer +-- WavyScreen_SetSCX: animations.asm:1916-1927 function BattleState:applyWavy(src) local wavy = self.fx and self.fx.wavy if not wavy then return src end @@ -5964,7 +5997,7 @@ function BattleState:applyWavy(src) for line = 0, 143 do self.waveQuad:setViewport(0, line, 160, 1) g.draw(src, self.waveQuad, - WAVY_OFFSETS[(line + wavy.phase) % 32 + 1], line) + WAVY_OFFSETS[(line * 2 + wavy.phase) % 32 + 1], line) end g.setCanvas(prev) return self.waveCanvas diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua index e795b6c7..599eccd5 100644 --- a/src/battle/gen2/AnimRunner.lua +++ b/src/battle/gen2/AnimRunner.lua @@ -88,6 +88,7 @@ function AnimRunner.new(opts) } self.objects = AnimObjects.new(self.data, self.constants, self.env) self.bg = BgEffects.new(self.constants, self.env) + self.animId = opts.animId -- wFXAnimID self.gfxOrder = self.constants.battleAnimGfxOrder or {} self.sfxOrder = opts.sfxOrder or {} diff --git a/src/core/Game.lua b/src/core/Game.lua index c505f456..97a6d117 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -186,7 +186,8 @@ function Game:makeTitleState() onContinue = function() local loaded, recovered = SaveData.load() if loaded then - self:restoreSave(loaded, recovered, { freshBoot = true }) + self:restoreSave(loaded, recovered, + { freshBoot = true, continued = true }) end end, onExit = self.onExit, @@ -1322,6 +1323,9 @@ function Game:restoreSave(loaded, recovered, opts) report.recovered = recovered report.modsDiff = modsDiff self.save = loaded + -- the START cursor lives outside sGameData (ram/sram.asm:17-21) + loaded.startMenuIndex = nil + self.startMenuIndex = nil self:adoptSave(loaded) -- SaveData.load already attached the standalone options.lua table self:applyOptions(loaded.options) @@ -1336,8 +1340,10 @@ function Game:restoreSave(loaded, recovered, opts) -- freshBoot threads through from the caller (onContinue and F2 both set -- it); a future caller that doesn't ask for it keeps the ordinary -- crossfade by default. + -- engine/menus/main_menu.asm:110 (CONTINUE forces PLAYER_DIR_DOWN) + local facing = (opts and opts.continued) and "down" or loaded.player.facing self.stack:push(self.overworld, loaded.player.map, - loaded.player.x, loaded.player.y, loaded.player.facing, + loaded.player.x, loaded.player.y, facing, { via = "boot", freshBoot = opts and opts.freshBoot }) self.saveReport = report if not SaveData.emptyReport(report) then diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 6fa8ab07..8fd8dbb2 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -94,9 +94,11 @@ function ItemEffects.healsHP(id) end -- .useRareCandy prints over the still-drawn party menu --- (engine/items/item_effects.asm:1392-1418) +-- (engine/items/item_effects.asm:1392-1418); .useVitamin ends at +-- RemoveUsedItem the same way (engine/items/item_effects.asm:1315-1322) function ItemEffects.keepsPartyMenuOpen(id) return ItemEffects.healsHP(id) or id == "RARE_CANDY" + or VITAMINS[id] ~= nil end function ItemEffects.isBattleMedicine(id) @@ -526,8 +528,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- _VitaminStatRoseText's slot order is localization-dependent (the -- Spanish ROM puts the stat before the name), so the extracted line -- cannot be filled positionally; the engine wording stands + -- engine/items/item_effects.asm:1313 return "consumed", { Strings("%s's %s\nrose!", monName(data, target), - Strings(STAT_LABEL[vitaminStat])) } + Strings(STAT_LABEL[vitaminStat])) }, { useJingle = true } end -- PP UP boosts the move the player picked (ItemUsePPUp's move menu) diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index fa27805d..abb66c3b 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -23,6 +23,7 @@ local Protocol = require("src.link.Protocol") local Runtime = require("src.mods.Runtime") local TurnOrder = require("src.battle.TurnOrder") local Strings = require("src.core.Strings") +local Timing = require("src.core.Timing") local LinkBattle = {} @@ -473,11 +474,22 @@ function LinkBattle.new(game, net, opts) -- switches happen before attacks (both may switch) if myMsg.kind == "switch" then local idx = myMsg.index - s:act(function() sendOutPlayer(s, myParty[idx]) end) + -- SwitchPlayerMon (engine/battle/core.asm:2419-2423); a post-faint + -- replacement shares sendOutPlayer and prints neither line + s:act(function() + s:sayNextAuto(s:withdrawText(s.player.name), Timing.SWITCH_PLAYER_MON) + s:queueRetreatAnim() + s:actNext(function() sendOutPlayer(s, myParty[idx]) end) + end) myAction = nil end if theirSwitch then - s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end) + -- SwitchEnemyMon (engine/battle/trainer_ai.asm:596-599) + s:act(function() + s:sayNext(s:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!", + theirName, s.enemy.name)) + s:actNext(function() sendOutEnemy(s, theirParty[theirSwitch]) end) + end) end s:act(function() @@ -885,11 +897,21 @@ function LinkBattle.newSpectator(game, net, opts) if hostMsg.kind == "switch" then local idx = hostMsg.index - s:act(function() sendOutHost(s, hostParty[idx]) end) + -- SwitchPlayerMon (engine/battle/core.asm:2419-2423) + s:act(function() + s:sayNextAuto(s:withdrawText(s.player.name), Timing.SWITCH_PLAYER_MON) + s:queueRetreatAnim() + s:actNext(function() sendOutHost(s, hostParty[idx]) end) + end) end if guestMsg.kind == "switch" then local idx = guestMsg.index - s:act(function() sendOutGuest(s, guestParty[idx]) end) + -- SwitchEnemyMon (engine/battle/trainer_ai.asm:596-599) + s:act(function() + s:sayNext(s:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!", + guestName, s.enemy.name)) + s:actNext(function() sendOutGuest(s, guestParty[idx]) end) + end) end s:act(function() diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua index b2a6c789..7bc4f150 100644 --- a/src/mods/Gen2Compat.lua +++ b/src/mods/Gen2Compat.lua @@ -1711,8 +1711,8 @@ local function buildStartMenu() function overrides.new(game) local g = live() or game local save = g and g.save - if save and save.startMenuIndex then - Start2.lastIndex = save.startMenuIndex + if g and g.startMenuIndex then + Start2.lastIndex = g.startMenuIndex end return newOrig(g, { save = save, @@ -1741,7 +1741,7 @@ COVERAGE["src.ui.StartMenu"] = { new = "synthesises onClose (stack:pop) and onChoose " .. "(Game2:openStartMenuItem); without them the menu cannot be left", index = "the cursor is menu.list.index on Gold (a Chrome.List)", - ["save.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct " + ["game.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct " .. "and never copied back: Gold's cursor is a class field, not save data", tx = "the box is fixed at Chrome.box(10, 0, 10, h); tx/ty/tw/th/anchor/" .. "maxVisible/startCloses/noSound do not exist and writes are inert", diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 7dc88eb4..96aaae51 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -332,6 +332,7 @@ end -- transparent: the world pass shows through (UI pass draws overlays only) function Renderer:beginFrame(transparent) + self.uiOpaque = not transparent self.worldActive = false self.uprightActive = false self.worldOverride = nil @@ -699,6 +700,18 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, return end love.graphics.setShader(shader) + -- data/sgb/sgb_packets.asm:123-127: zone 1 is the whole-screen ATTR_BLK, + -- so the underpaint is only drawn when it leaves part of the box bare (#1866) + local first = zoneList[1] + if canvas == self.canvas and self.uiOpaque and first.colors + and not (bx + first.x * zoneSx <= boxX + and by + first.y * zoneSy <= boxY + and bx + (first.x + first.w) * zoneSx >= boxX + boxW + and by + (first.y + first.h) * zoneSy >= boxY + boxH) then + PaletteFX.sendColors(shader, first.colors) + love.graphics.setScissor(boxX, boxY, boxW, boxH) + love.graphics.draw(canvas, bx, by, 0, sx, sy) + end -- a colors == false zone is the trueColor opt-out: its rect draws with -- no shader at all. Nothing sets one without a mod, so a vanilla zone -- list never toggles and issues exactly the calls it always did. diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index c8ce982a..f8091a4a 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -28,6 +28,52 @@ local MAX_COLS = 18 -- pokegold constants/ram_constants.asm: TEXT_DELAY_FAST/MED/SLOW = 1/3/5 local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 } +-- TextCommand_PAUSE (home/text.asm:492-504): a mid-string wait callers +-- embed as TextBox.PAUSE, stripped here and re-anchored after pagination. +TextBox.PAUSE = "\1" +local PAUSE_FRAMES = 30 + +local function glyphs(str) + return #Font.split((str:gsub("[\n\v\f]", ""))) +end + +local function stripPauses(text) + if not text:find(TextBox.PAUSE, 1, true) then return text, nil end + local out, marks, count, pos = {}, {}, 0, 1 + while true do + local i = text:find(TextBox.PAUSE, pos, true) + if not i then + out[#out + 1] = text:sub(pos) + break + end + local chunk = text:sub(pos, i - 1) + out[#out + 1] = chunk + count = count + glyphs(chunk) + marks[#marks + 1] = count + pos = i + 1 + end + return table.concat(out), marks +end + +-- glyph offsets into the whole text -> [page][line][char] +local function mapPauses(pages, marks) + local at, acc, mi = {}, 0, 1 + for pi, page in ipairs(pages) do + for li, line in ipairs(page) do + local n = #Font.split(line) + while mi <= #marks and marks[mi] <= acc + n do + local ci = marks[mi] - acc + at[pi] = at[pi] or {} + at[pi][li] = at[pi][li] or {} + at[pi][li][ci] = mi + mi = mi + 1 + end + acc = acc + n + end + end + return at +end + -- opts.choice: when the last page has typed out, a YES/NO ChoiceBox pops -- up over the still-visible text (YesNoChoicePokeCenter and friends); -- the box then closes and choice(yes) runs instead of onDone. @@ -81,7 +127,13 @@ function TextBox.new(game, text, onDone, opts) self.line1Y = (self.boxTy + 2) * 8 self.line2Y = (self.boxTy + 4) * 8 text = TextBox.substitute(game, text) + local marks + text, marks = stripPauses(text) self.pages = TextBox.paginate(text, self.maxCols) + -- opts.pauseSounds[i] is the sfx the i-th marker fires once its wait is + -- over (text_asm SFX_SWAP, engine/pokemon/learn_move.asm:210-213) + self.pauseSounds = opts and opts.pauseSounds + self.pauseAt = marks and mapPauses(self.pages, marks) or nil self.pageIndex = 1 self.lineIndex = 1 self.charIndex = 0 @@ -297,6 +349,20 @@ function TextBox:update(dt) self.holdFrames = self.holdFrames - 1 return end + -- home/text.asm:492 + if self.pauseFrames then + if self.pauseFrames > 0 then + self.pauseFrames = self.pauseFrames - 1 + return + end + self.pauseFrames = nil + local snd = self.pauseSounds and self.pauseSounds[self.pauseMark] + if type(snd) == "function" then + snd() + elseif snd then + require("src.core.Sound").play(self.game.data, snd) + end + end if self.done then -- opts.stay: the box is finished but stays up under whatever the caller -- pushed over it; StateStack updates the top state only, so this runs @@ -430,6 +496,15 @@ function TextBox:update(dt) self.charIndex = self.charIndex + 1 local line = self.shown[#self.shown] line[#line + 1] = self.codes[self.charIndex] + local marks = self.pauseAt and self.pauseAt[self.pageIndex] + marks = marks and marks[self.lineIndex] + if marks and marks[self.charIndex] then + self.pauseMark = marks[self.charIndex] + -- TextCommand_PAUSE reads hJoyHeld, so a held A/B skips the wait + self.pauseFrames = (input:isDown("a") or input:isDown("b")) + and 0 or PAUSE_FRAMES + break + end else -- line finished local page = self.pages[self.pageIndex] diff --git a/src/script/gen2/CallAsm.lua b/src/script/gen2/CallAsm.lua index 57472ba2..2d50238d 100644 --- a/src/script/gen2/CallAsm.lua +++ b/src/script/gen2/CallAsm.lua @@ -451,12 +451,13 @@ function H.CutDownTreeOrGrass(ctx) end -- DisappearWhirlpool is CutDownTreeOrGrass with PlayWhirlpoolSound in place of --- OWCutAnimation: the same block write, the same redraw, then the surf wash the --- port owns as World:playWhirlpoolSound. #1717 +-- OWCutAnimation, and the block only reaches the screen after the sound +-- -- engine/events/overworld.asm:1157-1164 (#1717, #1862) function H.DisappearWhirlpool(ctx) - local ret = H.CutDownTreeOrGrass(ctx) - call(ctx, "playWhirlpoolSound") - return ret + local index = ctx and ctx.cutWhirlpoolBlockIndex + local blockId = ctx and ctx.cutWhirlpoolReplacement + call(ctx, "playWhirlpoolSound", index, blockId) + return nil end -- BlindingFlash sets STATUSFLAGS_FLASH_F and reloads the palettes. Setting diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 8b5a6c1b..d810ce0c 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -360,6 +360,8 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) -- .useItem_partyMenu jumps back to StartMenu_Item once UseItem -- returns, cursor still on the candy (start_sub_menus.asm) -- so -- mashing A burns through a stack of them (#796) + -- RareCandyText carries sound_get_item_1 + -- (engine/menus/party_menu.asm:289-293) showMessages(game, payload, function() local StatBox = require("src.battle.BattleState").StatBox game.stack:push(StatBox.new(game, target, function() @@ -400,7 +402,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) end nextStep() end)) - end) + end, TextBox.soundOpts(game, "Get_Item1")) return end -- HP medicine: fill the bar in the still-open picker first, then print diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index 5c39c7cc..7dc08d9b 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -13,9 +13,21 @@ local Strings = require("src.core.Strings") local BoxMenu = {} -local function monLabel(game, mon) +-- PrintListMenuEntries prints the nickname at hlcoord 6,4 and PrintLevel +-- one row down, 8 columns right (home/list_menu.asm:364-365, 459-461) +local function monRow(game, mon, value) local def = game.data.pokemon[mon.species] - return Strings("%s :L%d", mon.nickname or def.name, mon.level) + return { + label = mon.nickname or def.name, + sub = Strings(":L%d", mon.level), + value = value, + } +end + +-- the $ff terminator's row (home/list_menu.asm:371-372, 523-528) +local function withCancel(items) + items[#items + 1] = { cancel = true, label = Strings("CANCEL") } + return items end local function monName(game, mon) @@ -61,14 +73,15 @@ local function withdraw(game) return end local items = {} - for i, mon in ipairs(box) do - table.insert(items, { label = monLabel(game, mon), value = i }) - end - game.stack:push(ListMenu.new(game, - Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, { + for i, mon in ipairs(box) do table.insert(items, monRow(game, mon, i)) end + game.stack:push(ListMenu.new(game, nil, withCancel(items), { noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) kind = "pc_box_withdraw", + -- DisplayMonListMenu draws LIST_MENU_BOX over the PC menu, which stays + -- visible around it (home/list_menu.asm:29-31) + itemBox = true, onChoose = function(item, list) + if item.cancel then list:close() return end local mon = box[item.value] if not mon then return end monSubmenu(game, "WITHDRAW", mon, function() @@ -111,12 +124,14 @@ local function deposit(game) end local items = {} for i, mon in ipairs(game.save.party) do - table.insert(items, { label = monLabel(game, mon), value = i }) + table.insert(items, monRow(game, mon, i)) end - game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, { + game.stack:push(ListMenu.new(game, nil, withCancel(items), { noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) kind = "pc_box_deposit", + itemBox = true, onChoose = function(item, list) + if item.cancel then list:close() return end local mon = game.save.party[item.value] if not mon then return end local Follower = require("src.world.PikachuFollower") @@ -163,14 +178,13 @@ local function release(game) return end local items = {} - for i, mon in ipairs(box) do - table.insert(items, { label = monLabel(game, mon), value = i }) - end - game.stack:push(ListMenu.new(game, - Strings("BOX %d (RELEASE)", game.save.currentBox), items, { + for i, mon in ipairs(box) do table.insert(items, monRow(game, mon, i)) end + game.stack:push(ListMenu.new(game, nil, withCancel(items), { noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) kind = "pc_box_release", - onChoose = function(_, list) + itemBox = true, + onChoose = function(item, list) + if item.cancel then list:close() return end local mon = box[list.index] if not mon then return end local name = monName(game, mon) @@ -202,37 +216,67 @@ local function release(game) })) end -local function changeBox(game) +-- DisplayChangeBoxMenu: engine/menus/save.asm:437-506 +local function changeBoxMenu(game) local boxes = Boxes.ensure(game.save) local items = {} for i = 1, Boxes.COUNT do - local mark = i == game.save.currentBox and "*" or " " - table.insert(items, { - label = Strings("%sBOX %2d", mark, i), - right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY), - value = i, - }) + items[i] = { + label = Strings("BOX%2d", i), + onSelect = function() + game.save.currentBox = i + if game.writeSave then game:writeSave() end + -- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044) + require("src.core.Sound").play(game.data, "Save") + end, + } end - game.stack:push(ListMenu.new(game, "CHANGE BOX", items, { - noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) - kind = "pc_box_change", - onChoose = function(item, list) - -- the original asks BEFORE switching ("When you change a #MON - -- BOX, data will be saved. OK?"); declining aborts the change - game.stack:push(TextBox.new(game, - Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), nil, { - noSound = true, - choice = function(yes) - if not yes then return end - game.save.currentBox = item.value - if game.writeSave then game:writeSave() end - -- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044) - require("src.core.Sound").play(game.data, "Save") - list:close() - end, - })) + local menu = Menu.new(game, items, { + tx = 11, ty = 0, tw = 9, th = 14, rowStep = 1, itemY = 1, noSound = true, + }) + menu.kind = "pc_box_change" -- screen.render_visible identity + menu.index = math.max(1, math.min(Boxes.COUNT, game.save.currentBox or 1)) + local t = game.data.text + local baseDraw = menu.draw + function menu:draw() + -- ChooseABoxText goes to the standard box, over BillsPCMenu's "What?" + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + local y = 112 + for line in ((t._ChooseABoxText or Strings("Choose a\nPOKéMON BOX.")) + .. "\n"):gmatch("([^\n]*)\n") do + Font.draw(line, 8, y) + y = y + 16 + end + Font.drawBox(0, 0, 11, 4) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(Strings("BOX No."), 8, 16) + local n = game.save.currentBox or 1 + Font.draw(tostring(n), n >= 10 and 64 or 72, 16) + baseDraw(self) + love.graphics.setColor(0, 0, 0, 1) + for i = 1, Boxes.COUNT do + if #boxes[i] > 0 then ListMenu.drawBall(148, i * 8 + 4) end + end + love.graphics.setColor(1, 1, 1, 1) + end + game.stack:push(menu) +end + +-- ChangeBox (engine/menus/save.asm:358-368) asks before showing the menu +-- and returns to the PC menu on No. +local function changeBox(game) + local t = game.data.text + local ask = TextBox.new(game, t._WhenYouChangeBoxText + or Strings("When you change a\nPOKéMON BOX, data\vwill be saved.\fIs that okay?"), + nil, { + noSound = true, + choice = function(yes) + if yes then changeBoxMenu(game) end end, - })) + }) + ask.kind = "pc_box_change" + game.stack:push(ask) end -- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua index 8114203c..88e6c518 100644 --- a/src/ui/DexEntryMenu.lua +++ b/src/ui/DexEntryMenu.lua @@ -87,6 +87,15 @@ local function frameSheet(game) return frameCache[path] end +-- one tile of the sheet, for the CONTENTS screen's divider column +-- (engine/menus/pokedex.asm:350, DrawPokedexVerticalLine) +function DexEntryMenu.tile(game, code, tx, ty) + local frame = frameSheet(game) + if not (frame and code) then return end + local quad = frame.quads[code - 0x60] + if quad then love.graphics.draw(frame.img, quad, tx * 8, ty * 8) end +end + -- engine/menus/pokedex.asm:601 local DIVIDER = { 0x68, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x6B, @@ -106,14 +115,28 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone) self.sprite = ok and img or nil self.spriteTrueColor = self.sprite and trueColor or false self.page = 1 + self.blink = 0 local pages = descPages(game, self.def, forceOwned) self.pageCount = pages and #pages or 1 - require("src.core.Sound").playCry(game.data, species) + -- engine/menus/pokedex.asm:500-506, home/pokemon.asm:145-148 + self.crySrc = require("src.core.Sound").playCry(game.data, species) return self end +-- home/pokemon.asm:148 (jp WaitForSoundToFinish) +function DexEntryMenu:crying() + local src = self.crySrc + if not src then return false end + local ok, playing = pcall(src.isPlaying, src) + if ok and playing then return true end + self.crySrc = nil + return false +end + function DexEntryMenu:update(dt) local input = self.game.input + self.blink = ((self.blink or 0) + 1) % 60 + if self:crying() then return end if input:wasPressed("a") or input:wasPressed("b") then -- home/text.asm:245 if self.page < (self.pageCount or 1) then @@ -127,15 +150,18 @@ end function DexEntryMenu:draw() DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned, - self.spriteTrueColor, self.page) + self.spriteTrueColor, self.page, + { crying = self:crying(), + arrow = (self.blink or 0) < 30 }) end -- Static entry-page renderer, shared with the printer stand-in -- (src/core/Printer.lua renders the same page into a PNG the way -- PrintPokedexEntry rendered it to the Game Boy Printer). -- engine/menus/pokedex.asm:399 -function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page) +function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, state) page = page or 1 + state = state or {} love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) local frame = frameSheet(game) @@ -185,6 +211,12 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page) Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), 16, 64) local owned = ownedFor(game, def, forceOwned) + -- engine/menus/pokedex.asm:516: everything below the divider waits on the + -- cry the line above it started + if state.crying then + love.graphics.setColor(1, 1, 1, 1) + return + end -- engine/menus/pokedex.asm:449, numbers only once owned if owned and e.heightFt then if e.heightM then @@ -202,8 +234,8 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page) for i, line in ipairs(lines) do Font.draw(line, 8, 72 + i * 16) end - -- home/text.asm:245 - if page < #pages then + -- home/text.asm:245 places the '▼' and home/joypad2.asm:55-83 blinks it + if page < #pages and state.arrow ~= false then Font.drawCode(Theme.moreArrow, 144, 128) end else diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index dad1bda9..41f16fd6 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -120,8 +120,9 @@ function ListMenu.new(game, title, items, opts) -- so their lists opt out of the A/B beep the same way Menu's noSound does self.noSound = opts.noSound or false -- the bag's item list: a partial box the map stays visible around, not a - -- screen of its own (home/list_menu.asm:29-31) - self.itemBox = opts.itemBox or false + -- screen of its own (home/list_menu.asm:29-31). Every DisplayListMenuID + -- caller gets the same box, the PC item lists included (#1845). + self.itemBox = opts.itemBox or opts.messageBox or false if self.itemBox then self.isOpaque = false -- keep RunDefaultPaletteCommand's last palette: ItemMenuLoop never sets @@ -256,6 +257,34 @@ function ListMenu:close() if top == self then self.game.stack:pop() end end +-- standard bottom text box (PrintText); long prompts wrap and keep +-- their last two lines, like the GB's scrolled box (#115/#174) +local function drawMessageBox(self) + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + if not self.footer then return end + local flat = {} + for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do + for _, line in ipairs(page) do flat[#flat + 1] = line end + end + local y = 112 + for i = math.max(1, #flat - 1), #flat do + Font.draw(flat[i], 8, y) + y = y + 16 + end +end + +-- the Pokédex owned-ball marker tile, also the CHANGE BOX screen's +-- PokeballTileGraphics marker (engine/menus/save.asm:495-499) +function ListMenu.drawBall(x, y) + local r, g, b, a = love.graphics.getColor() + love.graphics.circle("fill", x, y, 3.5) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", x - 3.5, y - 0.5, 7, 1) + love.graphics.circle("fill", x, y, 1.2) + love.graphics.setColor(r, g, b, a) +end + -- PrintListMenuEntries, minus the price column StartMenu_Item never asks for -- (wPrintItemPrices = 0, engine/menus/start_sub_menus.asm) function ListMenu:drawItemBox() @@ -274,7 +303,10 @@ function ListMenu:drawItemBox() if item.cancel then sawCancel = true end local y = ITEM_TOP_Y + (row - 1) * 16 Font.draw(item.label, ITEM_NAME_X, y) - if item.right then + if item.sub then + -- PrintLevel, one row down and 8 columns right (home/list_menu.asm:459-461) + Font.draw(item.sub, ITEM_QTY_X, y + 8) + elseif item.right then -- '×' at column 14, PrintNumber's two right-aligned digits after it -- (home/list_menu.asm:479-490) local count = item.right:sub(2) @@ -294,6 +326,9 @@ function ListMenu:drawItemBox() if shown == self.rows and not sawCancel then Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y) end + -- players_pc.asm:97/151/205 PrintText the prompt before DisplayListMenuID, + -- so the bottom box sits under the list from the first frame + if self.messageBox or self.footer then drawMessageBox(self) end love.graphics.setColor(1, 1, 1, 1) end @@ -326,13 +361,7 @@ function ListMenu:draw() -- one blank glyph after the name, measured in glyph advances rather -- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so -- `#item.label` overcounted by 2 and pushed their ball 16px right (#285) - local bx = 16 + Font.width(label) + 8 + 3 - local by = y + 3 - love.graphics.circle("fill", bx, by, 3.5) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", bx - 3.5, by - 0.5, 7, 1) - love.graphics.circle("fill", bx, by, 1.2) - love.graphics.setColor(unpack(textColor)) + ListMenu.drawBall(16 + Font.width(label) + 8 + 3, y + 3) end if item.right then Font.draw(item.right, 160 - 8 - Font.width(item.right), y) @@ -360,22 +389,8 @@ function ListMenu:draw() local money = ("¥%d"):format(self.money and self.money() or 0) Font.draw(money, 152 - Font.width(money), 8) end - if self.dialogue or (self.messageBox and self.footer) then - -- standard bottom text box (PrintText); long prompts wrap and keep - -- their last two lines, like the GB's scrolled box (#115/#174) - Font.drawBox(0, 12, 20, 6) - love.graphics.setColor(0, 0, 0, 1) - if self.footer then - local flat = {} - for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do - for _, line in ipairs(page) do flat[#flat + 1] = line end - end - local y = 112 - for i = math.max(1, #flat - 1), #flat do - Font.draw(flat[i], 8, y) - y = y + 16 - end - end + if self.dialogue then + drawMessageBox(self) elseif self.footer then -- bare footer (bag money line, etc.) local flat = {} diff --git a/src/ui/MoveLearnMenu.lua b/src/ui/MoveLearnMenu.lua index 5ceb00ce..d2578f66 100644 --- a/src/ui/MoveLearnMenu.lua +++ b/src/ui/MoveLearnMenu.lua @@ -120,14 +120,17 @@ function MoveLearnMenu:finish(learned) local opts if learned then -- pokered pages this as four texts in a row; _ForgotAndText carries - -- the "And..." tail + -- the "And..." tail. Each text_pause holds the box, and the first one + -- is followed by SFX_SWAP (engine/pokemon/learn_move.asm:208-222). msg = romText(game.data, "_OneTwoAndText", "1, 2 and...") + .. TextBox.PAUSE .. romText(game.data, "_PoofText", " Poof!") + .. TextBox.PAUSE .. romText(game.data, "_ForgotAndText", "\f%s forgot\n%s!\fAnd...", name, self.forgot) .. "\f" .. romText(game.data, "_LearnedMove1Text", "%s learned\n%s!", name, mdef.name) - opts = { auto = { sound = function() + opts = { pauseSounds = { "Swap" }, auto = { sound = function() return require("src.core.Sound").play(game.data, self.learnedSound) end, wait = true } } else diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 718c5ba3..5995ae13 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -71,6 +71,12 @@ function PartyMenu:sgbPalettes(game) return zones end +-- data/moves/field_moves.asm: leftmost tile per field move name +local FIELD_MOVE_X = { + cut = 12, fly = 12, surf = 12, flash = 12, DIG = 12, + strength = 10, TELEPORT = 10, softboiled = 8, +} + local function sameItems(_, items) return items end local function followerUnavailable(game, mon) @@ -706,6 +712,9 @@ function PartyMenu:update(dt) -- (text_box.asm .donePrintingNames). #768 items[#items + 1] = { label = Strings("STATS"), action = "stats" } items[#items + 1] = { label = Strings("SWITCH"), action = "switch" } + -- CANCEL closes the whole party menu (start_sub_menus.asm:71-75 + -- .exitMenu), where B returns to the party list. #1833 + items[#items + 1] = { label = Strings("CANCEL"), action = "cancel" } end local ctx = { battle = self.battle, overworld = ow } local hooked = Runtime.call("ui.party.submenu", sameItems, @@ -873,12 +882,19 @@ function PartyMenu:draw() end if self.submenu then local n = #self.subItems - Font.drawBox(9, 17 - n * 2 - 1, 11, n * 2 + 1) - local y0 = (17 - n * 2) * 8 - for si, entry in ipairs(self.subItems) do - Font.draw(entry.label, 88, y0 + (si - 1) * 16) + -- engine/menus/text_box.asm:397-440, data/text_boxes.asm:33 #1819 + local lx = 12 + for _, entry in ipairs(self.subItems) do + local mx = FIELD_MOVE_X[entry.move or entry.action] + if mx and mx < lx then lx = mx end end - Font.drawCode(Theme.cursor, 80, y0 + (self.subIndex - 1) * 16) + local top = n > 3 and math.max(0, 16 - n * 2) or 11 + Font.drawBox(lx - 1, top, 21 - lx, 18 - top) + local y0 = (18 - n * 2) * 8 + for si, entry in ipairs(self.subItems) do + Font.draw(entry.label, (lx + 1) * 8, y0 + (si - 1) * 16) + end + Font.drawCode(Theme.cursor, lx * 8, y0 + (self.subIndex - 1) * 16) end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 207d4aaf..01a17baf 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -11,6 +11,8 @@ local ListMenu = require("src.ui.ListMenu") local Menu = require("src.ui.Menu") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") +local TextBox = require("src.render.TextBox") +local romText = require("src.core.RomText") local PlayerPC = {} @@ -36,9 +38,20 @@ local function buildItems(game, store, order) }) end end + -- the $ff terminator's row (home/list_menu.asm:371-372, 523-528) + items[#items + 1] = { cancel = true, label = Strings("CANCEL") } return items end +-- A on CANCEL leaves the list exactly like B (home/list_menu.asm:105-110) +local function leftOnCancel(item, list) + if item and item.cancel then + list:close() + return true + end + return false +end + -- Ask "How many?" (DepositHowManyText/WithdrawHowManyText → -- DisplayChooseQuantityMenu) capped at the stack count. Key items and -- HMs always move one, with no prompt (IsKeyItem in players_pc.asm). @@ -76,11 +89,21 @@ end local function withdraw(game) local pc = game.save.pcItems - game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), { + -- players_pc.asm:144-149: an empty list is never opened + if next(pc) == nil then + game.stack:push(TextBox.new(game, romText(game.data, "_NothingStoredText", + "There is nothing\nstored."), nil, { noSound = true })) + return + end + game.stack:push(ListMenu.new(game, nil, buildItems(game, pc), { kind = "pc_item_withdraw", messageBox = true, + -- players_pc.asm:151-152 WhatToWithdrawText, printed before the list + footer = romText(game.data, "_WhatToWithdrawText", + "What do you want\nto withdraw?"), noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) onChoose = function(item, list) + if leftOnCancel(item, list) then return end askQuantity(game, list, pc[item.value] or 1, item.value, function(qty) local Bag = require("src.inventory.Bag") if not Bag.add(game.save, item.value, qty, game.data) then @@ -112,11 +135,21 @@ local function deposit(game) local Bag = require("src.inventory.Bag") -- engine/menus/players_pc.asm:99 wListPointer = wNumBagItems, so deposit order == bag order local order = Bag.order(game.save, game.data) - game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv, order), { + -- players_pc.asm:90-95: an empty bag never reaches the list + if #order == 0 then + game.stack:push(TextBox.new(game, romText(game.data, "_NothingToDepositText", + "You have nothing\nto deposit."), nil, { noSound = true })) + return + end + game.stack:push(ListMenu.new(game, nil, buildItems(game, inv, order), { kind = "pc_item_deposit", messageBox = true, + -- players_pc.asm:97-98 WhatToDepositText, printed before the list + footer = romText(game.data, "_WhatToDepositText", + "What do you want\nto deposit?"), noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) onChoose = function(item, list) + if leftOnCancel(item, list) then return end askQuantity(game, list, inv[item.value] or 1, item.value, function(qty) if pcFull(game, pc, item.value) then list.footer = Strings("No room left to\nstore items.") @@ -134,11 +167,21 @@ end local function toss(game) local pc = game.save.pcItems - game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), { + -- players_pc.asm:196-201: an empty list is never opened + if next(pc) == nil then + game.stack:push(TextBox.new(game, romText(game.data, "_NothingStoredText", + "There is nothing\nstored."), nil, { noSound = true })) + return + end + game.stack:push(ListMenu.new(game, nil, buildItems(game, pc), { kind = "pc_item_toss", messageBox = true, + -- players_pc.asm:205-206 WhatToTossText, printed before the list + footer = romText(game.data, "_WhatToTossText", + "What do you want\nto toss away?"), noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) onChoose = function(item, list) + if leftOnCancel(item, list) then return end local def = game.data.items[item.value] if (def and def.keyItem) or item.value:find("^HM_") then list.footer = Strings("That's too impor-\ntant to toss!") diff --git a/src/ui/PokedexMenu.lua b/src/ui/PokedexMenu.lua index e25a7854..821eb49e 100644 --- a/src/ui/PokedexMenu.lua +++ b/src/ui/PokedexMenu.lua @@ -1,9 +1,38 @@ --- Minimal Pokédex: dex-ordered list with seen/owned markers. +-- Pokédex CONTENTS screen: engine/menus/pokedex.asm:157-348 +-- (Yellow: pokeyellow engine/menus/pokedex.asm:263-336). -local ListMenu = require("src.ui.ListMenu") +local Font = require("src.render.Font") local Strings = require("src.core.Strings") +local Theme = require("src.ui.Theme") local PokedexMenu = {} +PokedexMenu.__index = PokedexMenu +PokedexMenu.isOpaque = true + +-- engine/menus/pokedex.asm:226 (`ld d, 7`), :21 (top menu item Y 3, X 0) +local ROWS = 7 +local FIRST_ROW_Y = 24 +local NUM_X, BALL_X, NAME_X, CURSOR_X = 8, 24, 32, 0 + +-- engine/menus/pokedex.asm:163-199 +-- (Yellow: pokeyellow engine/menus/pokedex.asm:266-306) +local RED_ROWS = { seen = 2, own = 5, rule = 8, items = 10 } +local YELLOW_ROWS = { seen = 1, own = 4, rule = 6, items = 8 } + +-- engine/menus/pokedex.asm:350 (DrawPokedexVerticalLine): column 14, $71 +-- toggling to $70 down each nine-tile run, both runs restarting on $71 +local function dividerCodes() + local codes = { [0] = 0x71 } + for _, top in ipairs({ 1, 9 }) do + local code = 0x71 + for i = 0, 8 do + codes[top + i] = code + code = code == 0x71 and 0x70 or 0x71 + end + end + return codes +end +local DIVIDER = dividerCodes() -- SGB: PalPacket_Pokedex, whole screen function PokedexMenu:sgbPalettes(game) @@ -12,108 +41,270 @@ end function PokedexMenu.new(game, opts) opts = opts or {} + local self = setmetatable({}, PokedexMenu) + self.game = game + self.onCancel = opts.onCancel -- B returns to the start menu when opened from it + self.index = 1 + self.scroll = 0 + self.rowsAt = require("src.core.GameVersion").isYellow() + and YELLOW_ROWS or RED_ROWS local dex = game.save.pokedex or { seen = {}, owned = {} } local byDex = {} for species, def in pairs(game.data.pokemon) do if def.dex then byDex[def.dex] = def end end - local items = {} - local seen, owned = 0, 0 -- dex bound and number width come from constants; the fallbacks keep a -- cache imported before those keys existed on the Kanto numbering local constants = game.data.constants or {} local numFmt = ("%%0%dd"):format(constants.dexDigits or 3) - for n = 1, constants.dexSize or 151 do + local dexSize = constants.dexSize or 151 + -- engine/menus/pokedex.asm:200-216: the list stops at wDexMaxSeenMon, the + -- highest number the player has seen, not at the end of the roster + local maxSeen = 0 + local seen, owned = 0, 0 + for n = 1, dexSize do local def = byDex[n] if def then - local label if dex.owned[def.id] then - label = (numFmt .. " %s"):format(n, def.name) owned = owned + 1 seen = seen + 1 + maxSeen = n elseif dex.seen[def.id] then - label = (numFmt .. " %s"):format(n, def.name) seen = seen + 1 - else - label = (numFmt .. " -----"):format(n) + maxSeen = n end - table.insert(items, { - label = label, + end + end + local items = {} + for n = 1, math.min(dexSize, maxSeen) do + local def = byDex[n] + if def then + local known = dex.owned[def.id] or dex.seen[def.id] + -- engine/menus/pokedex.asm:265 (.dashedLine) for anything unseen + local name = known and def.name or "----------" + items[#items + 1] = { + num = numFmt:format(n), + name = name, + label = (numFmt .. " %s"):format(n, name), -- owned entries carry the pokéball marker like the original -- list; seen-only entries are just the name ball = dex.owned[def.id] or nil, - value = (dex.owned[def.id] or dex.seen[def.id]) and def.id or nil, - }) + value = known and def.id or nil, + } end end - local list = ListMenu.new(game, "POKéDEX", items, { - -- SEEN / OWN in the original's fixed three-digit field: engine/menus/ - -- pokedex.asm HandlePokedexListMenu prints both counts with - -- `lb bc, 1, 3` (hlcoord 16,3 and 16,6), labelled by PokedexSeenText - -- and PokedexOwnText ("OWN", not "OWNED"). The width is load bearing - -- here: a bare ListMenu footer goes through the 18-column text wrap, - -- so the old 19-glyph "SEEN 100 OWNED 100" split in two and its first - -- half landed on the list's last row at y=120 (#639). - footer = Strings("SEEN %3d OWN %3d", seen, owned), - pageJump = true, -- Left/Right page jumps like the original - onCancel = opts.onCancel, -- B returns to the start menu when opened from it - onChoose = function(item, dexList) - if not item.value then return end - -- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm - -- PokedexMenuItemsText); CRY keeps the side menu open like the - -- original. B is what returns to the list: HandlePokedexSideMenu - -- hands back b=2 for B and b=1 for QUIT, and ShowPokedexMenu sends - -- b=1 to .exitPokedex, so QUIT closes the whole Pokédex (#571) - local Menu = require("src.ui.Menu") - local Screens = require("src.ui.Screens") - local entries = { - { label = Strings("DATA"), onSelect = function() - Screens.push(game, "DexEntryMenu", item.value) - end }, - { label = Strings("CRY"), keepOpen = true, onSelect = function() - require("src.core.Sound").playCry(game.data, item.value) - end }, - { label = Strings("AREA"), onSelect = function() - Screens.push(game, "TownMap", { nestSpecies = item.value }) - end }, - } - -- Yellow's PRNT item (engine/menus/pokedex.asm PokedexMenuItemsText - -- _YELLOW branch -> PrintPokedexEntry): the Game Boy Printer job is - -- stood in for by a PNG of the entry page saved under prints/. - if require("src.core.GameVersion").isYellow() then - entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function() - local DexEntryMenu = require("src.ui.DexEntryMenu") - local Printer = require("src.core.Printer") - local TextBox = require("src.render.TextBox") - local def = game.data.pokemon[item.value] - local path = require("src.pokemon.Sprites").path( - game.data, item.value, "front", { kind = "dex" }) - local ok, sprite = false, nil - if path then ok, sprite = pcall(love.graphics.newImage, path) end - local saved, err = Printer.save("dex_" .. item.value, 160, 144, - function() - DexEntryMenu.render(game, def, ok and sprite or nil, false) - end) - game.stack:push(TextBox.new(game, saved - and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.", - def.name, saved) - or Strings("Printer error!\n%s", tostring(err)))) - end } - end - entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function() - -- .exitPokedex: drop the dex list too and hand back to whoever - -- opened it (the start menu, whose saved cursor is still on - -- POKéDEX -- wBattleAndStartSavedMenuItem) - dexList:close() - if opts.onCancel then opts.onCancel() end - end } - game.stack:push(Menu.new(game, entries, - { tx = 12, ty = 8, tw = 8, - th = #entries * 2 + 2 })) - end, - }) - list.sgbPalettes = PokedexMenu.sgbPalettes - return list + self.items = items + -- SEEN / OWN, `lb bc, 1, 3` at hlcoord 16,3 and 16,6: engine/menus/ + -- pokedex.asm HandlePokedexListMenu (#639) + self.seenCount, self.ownedCount = seen, owned + self.footer = Strings("SEEN %3d OWN %3d", seen, owned) + return self +end + +-- HandleMenuInput_ replays SFX_PRESS_AB for A and B (home/window.asm) +local function beep(self) + if not (self.game and self.game.data) then return end + require("src.core.Sound").play(self.game.data, "Press_AB") +end + +function PokedexMenu:rows() + return math.min(ROWS, #self.items) +end + +-- engine/menus/pokedex.asm:290-313 +function PokedexMenu:syncScroll() + local rows = self:rows() + if rows == 0 then + self.index, self.scroll = 1, 0 + return + end + self.index = math.max(1, math.min(#self.items, self.index)) + if self.index - self.scroll > rows then self.scroll = self.index - rows end + if self.index - self.scroll < 1 then self.scroll = self.index - 1 end +end + +-- engine/menus/pokedex.asm:314-342: Left and Right move wListScrollOffset +-- by seven and never touch wCurrentMenuItem, so the cursor keeps its row +function PokedexMenu:pageScroll(dir) + local n = #self.items + if n < ROWS then return end + local row = self.index - self.scroll + local scroll = self.scroll + dir * ROWS + scroll = math.max(0, math.min(n - ROWS, scroll)) + self.scroll = scroll + self.index = math.min(n, scroll + row) +end + +function PokedexMenu:close() + local top = self.game.stack:top() + if top == self then self.game.stack:pop() end +end + +function PokedexMenu:update(dt) + -- back on top of the stack, so the side menu's hollow '▷' is gone + -- (engine/menus/pokedex.asm:61, PlaceUnfilledArrowMenuCursor) + self.hollowIndex = nil + local input = self.game.input + if #self.items == 0 then + if input:wasPressed("a") or input:wasPressed("b") then + beep(self) + self.game.stack:pop() + if self.onCancel then self.onCancel() end + end + return + end + if input:wasPressed("up") then + self.index = self.index - 1 + elseif input:wasPressed("down") then + self.index = self.index + 1 + elseif input:wasPressed("left") then + self:pageScroll(-1) + elseif input:wasPressed("right") then + self:pageScroll(1) + elseif input:wasPressed("b") then + beep(self) + self.game.stack:pop() + if self.onCancel then self.onCancel() end + return + elseif input:wasPressed("a") then + beep(self) + self.onChoose(self.items[self.index], self) + return + end + self:syncScroll() +end + +-- engine/menus/pokedex.asm:371-375, :81-85 +function PokedexMenu:sideItems() + local labels = { Strings("DATA"), Strings("CRY"), Strings("AREA") } + if require("src.core.GameVersion").isYellow() then + labels[#labels + 1] = Strings("PRNT") + end + labels[#labels + 1] = Strings("QUIT") + return labels +end + +local function sideRowY(menu, row) + return (menu.ty + menu.th - 2 - (#menu.items - row) * 2) * 8 +end + +local function drawSideMenu(self) + love.graphics.setColor(0, 0, 0, 1) + for row = 1, #self.items do + Font.draw(self.items[row].label, (self.tx + 2) * 8, sideRowY(self, row)) + end + Font.drawCode(Theme.cursor, (self.tx + 1) * 8, sideRowY(self, self.index)) + love.graphics.setColor(1, 1, 1, 1) +end + +-- engine/menus/pokedex.asm HandlePokedexSideMenu, ShowPokedexMenu +-- .exitPokedex (#571) +local function chooseEntry(item, dexList) + local game = dexList.game + -- engine/menus/pokedex.asm:77-80: an unseen row hands back b=2 at once + if not item.value then return end + local Menu = require("src.ui.Menu") + local Screens = require("src.ui.Screens") + local entries = { + { label = Strings("DATA"), onSelect = function() + Screens.push(game, "DexEntryMenu", item.value) + end }, + { label = Strings("CRY"), keepOpen = true, onSelect = function() + require("src.core.Sound").playCry(game.data, item.value) + end }, + { label = Strings("AREA"), onSelect = function() + Screens.push(game, "TownMap", { nestSpecies = item.value }) + end }, + } + -- pokeyellow engine/menus/pokedex.asm PokedexMenuItemsText, + -- PrintPokedexEntry + if require("src.core.GameVersion").isYellow() then + entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function() + local DexEntryMenu = require("src.ui.DexEntryMenu") + local Printer = require("src.core.Printer") + local TextBox = require("src.render.TextBox") + local def = game.data.pokemon[item.value] + local path = require("src.pokemon.Sprites").path( + game.data, item.value, "front", { kind = "dex" }) + local ok, sprite = false, nil + if path then ok, sprite = pcall(love.graphics.newImage, path) end + local saved, err = Printer.save("dex_" .. item.value, 160, 144, + function() + DexEntryMenu.render(game, def, ok and sprite or nil, false) + end) + game.stack:push(TextBox.new(game, saved + and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.", + def.name, saved) + or Strings("Printer error!\n%s", tostring(err)))) + end } + end + entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function() + -- engine/menus/pokedex.asm .exitPokedex + dexList:close() + if dexList.onCancel then dexList.onCancel() end + end } + local ty = dexList.rowsAt.items - 2 + local side = Menu.new(game, entries, { tx = 14, ty = ty, tw = 6 }) + -- the block is already on screen, so the side menu is the cursor alone: + -- text at column 16, cursor at 15 (engine/menus/pokedex.asm:82-86) + side.tx, side.tw, side.ty, side.th = 14, 6, ty, #entries * 2 + 2 + side.draw = drawSideMenu + dexList.hollowIndex = dexList.index + game.stack:push(side) +end +PokedexMenu.onChoose = chooseEntry + +-- engine/menus/pokedex.asm:256 writes tile $72 in the column left of the +-- name; the port paints the same marker rather than carry a one-tile sheet +local function drawBall(x, y) + love.graphics.circle("fill", x + 4, y + 4, 3.5) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", x + 0.5, y + 3.5, 7, 1) + love.graphics.circle("fill", x + 4, y + 4, 1.2) + love.graphics.setColor(0, 0, 0, 1) +end + +function PokedexMenu:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + local at = self.rowsAt + local DexEntryMenu = require("src.ui.DexEntryMenu") + -- engine/menus/pokedex.asm:159-176: the divider column and the rule that + -- fences SEEN / OWN off from the side-menu block + for ty = 0, 17 do + DexEntryMenu.tile(self.game, DIVIDER[ty], 14, ty) + end + love.graphics.setColor(0, 0, 0, 1) + Font.draw(("─"):rep(5), 120, at.rule * 8) + Font.draw(Strings("CONTENTS"), 8, 8) + Font.draw(Strings("SEEN"), 128, at.seen * 8) + Font.draw(Strings("OWN"), 128, at.own * 8) + -- PrintNumber's fixed three-digit field ends on column 19 + local function count(n, ty) + local text = tostring(n) + Font.draw(text, 152 - Font.width(text), ty * 8) + end + count(self.seenCount, at.seen + 1) + count(self.ownedCount, at.own + 1) + local labels = self:sideItems() + for i, label in ipairs(labels) do + Font.draw(label, 128, (at.items + (i - 1) * 2) * 8) + end + for row = 1, self:rows() do + local i = self.scroll + row + local item = self.items[i] + if not item then break end + local y = FIRST_ROW_Y + (row - 1) * 16 + -- the number sits one row above its name (engine/menus/pokedex.asm:242-246) + Font.draw(item.num, NUM_X, y - 8) + if item.ball then drawBall(BALL_X, y) end + Font.draw(item.name, NAME_X, y) + if i == self.index then + Font.drawCode(self.hollowIndex == i + and Theme.cursorHollow or Theme.cursor, CURSOR_X, y) + end + end + love.graphics.setColor(1, 1, 1, 1) end return PokedexMenu diff --git a/src/ui/SlotMachine.lua b/src/ui/SlotMachine.lua index a0c04734..524d1d31 100644 --- a/src/ui/SlotMachine.lua +++ b/src/ui/SlotMachine.lua @@ -192,9 +192,9 @@ function SlotMachine.new(game, lucky) local self = setmetatable({}, SlotMachine) self.game = game self.wheels = game.data.field.slotWheels - -- intro | bet | spinup | spin | reroll | flash | message | payout | onemore - -- PromptUserToPlaySlots asks "Want to play?" before the session starts. - self.stage = "intro" + -- bet | spinup | spin | reroll | flash | message | payout | onemore + -- PromptUserToPlaySlots: engine/slots/slot_machine.asm:9-23 + self.stage = "bet" self.yesno = 1 -- YES/NO cursor (1 = YES); wCurrentMenuItem -- CoinMultiplierSlotMachineText lists ×3/×2/×1 with the cursor defaulting to -- the top (wCurrentMenuItem 0), i.e. bet = 3 - menuItem. @@ -417,7 +417,7 @@ function SlotMachine:startPayout() self.flash = false end --- YES/NO prompt shared by the intro ("Want to play?") and "One more go?". +-- the "One more go?" YES/NO prompt. function SlotMachine:updateYesNo(onYes) local input = self.game.input if input:wasPressed("up") or input:wasPressed("down") then @@ -435,12 +435,6 @@ function SlotMachine:update(dt) local input = self.game.input local save = self.game.save - if self.stage == "intro" then - -- PromptUserToPlaySlots: "A slot machine! Want to play?" - self:updateYesNo(function() self:enterBet() end) - return - end - if self.stage == "message" then if self.exitTimer then -- OutOfCoinsSlotMachineText: DelayFrames 60, then leave @@ -638,9 +632,7 @@ end -- menu on the right (like MainSlotMachineLoop's TextBoxBorder + menus). function SlotMachine:drawBottom() local lines - if self.stage == "intro" then - lines = { "A slot machine!", "Want to play?" } - elseif self.stage == "bet" then + if self.stage == "bet" then lines = { "Bet how many", "coins?" } elseif self.stage == "onemore" then lines = { "One more", "go?" } @@ -661,22 +653,21 @@ function SlotMachine:drawBottom() Font.draw(lines[1] or "", 8, 14 * 8) Font.draw(lines[2] or "", 8, 16 * 8) if self.stage == "bet" then - Font.drawBox(14, 11, 6, 5) + -- hlcoord 14,11 with b=5 c=4 -- engine/slots/slot_machine.asm:83-86 + Font.drawBox(14, 11, 6, 7) love.graphics.setColor(0, 0, 0, 1) Font.draw("×3", 16 * 8, 12 * 8) Font.draw("×2", 16 * 8, 13 * 8) Font.draw("×1", 16 * 8, 14 * 8) Font.drawCode(0xED, 15 * 8, (12 + self.betIndex) * 8) - elseif self.stage == "intro" or self.stage == "onemore" then - -- "One more go?" sits at the right of the box (hlcoord 14,12); the longer - -- "A slot machine!" prompt would clip against it, so the intro's YES/NO - -- floats above the reels instead. - local by = self.stage == "intro" and 6 or 11 - Font.drawBox(13, by, 6, 5) + elseif self.stage == "onemore" then + -- hlcoord 14,12 with the YES_NO_MENU 4x3 body + -- engine/slots/slot_machine.asm:136-138, data/yes_no_menu_strings.asm:10 + Font.drawBox(14, 12, 6, 5) love.graphics.setColor(0, 0, 0, 1) - Font.draw(Strings("YES"), 15 * 8, (by + 1) * 8) - Font.draw(Strings("NO"), 15 * 8, (by + 2) * 8) - Font.drawCode(0xED, 14 * 8, (by + 1 + (self.yesno == 1 and 0 or 1)) * 8) + Font.draw(Strings("YES"), 16 * 8, 13 * 8) + Font.draw(Strings("NO"), 16 * 8, 14 * 8) + Font.drawCode(0xED, 15 * 8, (13 + (self.yesno == 1 and 0 or 1)) * 8) end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index 7df11e4c..2537bf28 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -200,12 +200,12 @@ function StartMenu.new(game) anchor = "topright" }) -- the cursor position survives closing the menu -- (wBattleAndStartSavedMenuItem, home/start_menu.asm) - menu.index = math.min(game.save.startMenuIndex or 1, #items) + menu.index = math.min(game.startMenuIndex or 1, #items) menu:clampScroll() local baseUpdate = menu.update menu.update = function(self, dt) baseUpdate(self, dt) - game.save.startMenuIndex = self.index + game.startMenuIndex = self.index end -- inside the Safari Zone the start menu also shows remaining steps and diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 38157f41..a0a57460 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -130,6 +130,25 @@ local function markerXY(loc) return loc.x * 8 + 16, loc.y * 8 + 8 end +-- the marker wears its own sheet's OBJ palette and shade-0 keying +-- engine/items/town_map.asm:342 +local function markerSheet(def, seed) + local colors, group + if PaletteFX.usesGbcPack() then + colors, group = PaletteFX.spriteObp(def, seed) + end + if not colors then + if PaletteFX.usesSpriteObp() then + colors, group = PaletteFX.ogObj() + else + colors, group = PaletteFX.dmgObj() + end + end + local ok, img = pcall(SpriteRenderer.obpImage, def and def.image, colors, group) + if not (ok and img) then return nil, nil end + return img, love.graphics.newQuad(0, 0, 16, 16, img:getDimensions()) +end + -- the row-0 name banner; fly mode prefixes "To " like LoadTownMap_Fly -- (engine/menus/town_map.asm prints the destination as "To ") function TownMap:bannerText(loc) @@ -224,32 +243,18 @@ function TownMap.new(game, opts) local mapId = game.overworld and game.overworld.map and game.overworld.map.id self.playerLoc = mapId and self.byMap[mapId] or nil -- engine/items/town_map.asm:347 - do - local playerSprites = (game.data.field and game.data.field.playerSprites) - or {} - local sprites = game.data.sprites or {} - local red = sprites[playerSprites.walk or "SPRITE_RED"] - or sprites.SPRITE_RED - -- the marker is the overworld walking sheet, so it wears that sheet's OBJ - -- palette and shade-0 keying -- engine/items/town_map.asm:342 - local colors, group - if PaletteFX.usesGbcPack() then - colors, group = PaletteFX.spriteObp(red, "player") - end - if not colors then - if PaletteFX.usesSpriteObp() then - colors, group = PaletteFX.ogObj() - else - colors, group = PaletteFX.dmgObj() - end - end - local ok, img = pcall(SpriteRenderer.obpImage, - red and red.image, colors, group) - if ok and img then - self.playerSheet = img - self.playerQuad = love.graphics.newQuad(0, 0, 16, 16, - img:getDimensions()) - end + local playerSprites = (game.data.field and game.data.field.playerSprites) + or {} + local sprites = game.data.sprites or {} + self.playerSheet, self.playerQuad = + markerSheet(sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED, + "player") + -- LoadTownMap_Fly overwrites the cursor tiles with BirdSprite and marks + -- the destination with it -- engine/items/town_map.asm:146-149, 177-179 + if self.fly then + self.birdSheet, self.birdQuad = + markerSheet(sprites[playerSprites.fly or "SPRITE_BIRD"] + or sprites.SPRITE_BIRD, "bird") end self.sel = 1 -- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST @@ -402,8 +407,19 @@ function TownMap:draw() end end -- WriteTownMapSpriteOAM carry quirk: -4 X, -3 Y for cursor and player alike -- engine/items/town_map.asm:454 + -- LoadTownMap_Fly's .inputLoop has no blinking animation + -- engine/items/town_map.asm:190-198 local showCursor = true - if GameVersion.generation() == 1 then + if self.fly and self.birdSheet then + if selected then + local x, y = markerXY(selected) + love.graphics.draw(self.birdSheet, self.birdQuad, x - 4, y - 3) + if PaletteFX.usesSpriteObp() or PaletteFX.usesGbcPack() then + PaletteFX.markUiSpriteRedraw(self.birdSheet, self.birdQuad, x - 4, y - 3) + end + end + showCursor = false + elseif GameVersion.generation() == 1 then showCursor = self.blink < 25 else showCursor = self.blink % 16 < 10 diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 4cb11ee5..31e4fc23 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -611,17 +611,60 @@ function BattleState:animData(mon) return def.anim end +-- ../pokecrystal/engine/gfx/load_pics.asm:105-131 +function BattleState:frontPicReplaced(mon) + local def = self.pokemon and mon and self.pokemon[mon.species] + local vanilla = def and def.spriteFront + if mon and mon.species == Unown.SPECIES then + vanilla = Unown.formSprite(self.pokemon, Unown.monLetter(mon), false) + or vanilla + end + if type(vanilla) ~= "string" then return false end + local _, _, path = self:pic(mon, false) + if type(path) == "string" and path ~= vanilla then return true end + return Assets.resolve(vanilla) ~= vanilla +end + +-- one cart asset, two files on the pokemon.sprite seam (#1827) +-- ../pokecrystal/engine/gfx/load_pics.asm:132-158 +function BattleState:animSheetPath(mon, data) + local path = data and data.sheet + if type(path) ~= "string" then return nil, false end + if not Runtime.wantsHook("pokemon.sprite") then + return path, Assets.resolve(path) ~= path + end + local letter + if mon and mon.species == Unown.SPECIES then letter = Unown.monLetter(mon) end + local hooked = Runtime.call("pokemon.sprite", + function(value) return value end, path, { + species = mon and mon.species, + side = "front", + kind = "battle_anim", + mon = mon, + data = (self.game and self.game.data) or nil, + letter = letter, + shiny = mon and mon.shiny and true or false, + }) + if type(hooked) == "string" and hooked ~= "" and hooked ~= path then + return hooked, true + end + return path, Assets.resolve(path) ~= path +end + -- ANIM_MON_NORMAL, the scene BattleStartMessage runs on the enemy's frontpic. -- ../pokecrystal/engine/battle/core.asm:9112-9113 function BattleState:startFrontAnim(mon) self.frontAnim = nil local data = self:animData(mon) if not data then return end - local cached = self.picCache[data.sheet] + local sheet, sheetReplaced = self:animSheetPath(mon, data) + if not sheet then return end + if not sheetReplaced and self:frontPicReplaced(mon) then return end + local cached = self.picCache[sheet] if cached == nil then - local ok, image = pcall(Assets.image, data.sheet) + local ok, image = pcall(Assets.image, sheet) cached = ok and image or false - self.picCache[data.sheet] = cached + self.picCache[sheet] = cached end if not cached then return end local runner = MonAnim.new(data, "battle") diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index 7ce631d8..fea55cc0 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -1105,9 +1105,14 @@ function SummaryMenu:drawEggIconFallback(colors) G.setColor(1, 1, 1, 1) end -function SummaryMenu:drawPlacements(list) +-- engine/gfx/color.asm:342-359 +function SummaryMenu:drawPlacements(list, palette) for _, entry in ipairs(list) do - Chrome.print(entry.text, entry.x, entry.y) + if palette then + Chrome.printThrough(entry.text, entry.x, entry.y, palette) + else + Chrome.print(entry.text, entry.x, entry.y) + end end end @@ -1168,7 +1173,7 @@ function SummaryMenu:drawPinkPage() HpBar.drawWithLabel(self.palettes, mon.hp, maxHp, 0, 9, Font) end self:drawVerticalDivider(9) - self:drawPlacements(self:pinkPlacements()) + self:drawPlacements(self:pinkPlacements(), self:lowerColors()) -- FillInExpBar is handed (11,16), adds 7 to reach the rightmost cell and -- fills eight of them walking left, with the $40/$41 caps outside at (10,16) @@ -1188,12 +1193,12 @@ function SummaryMenu:drawPinkPage() end function SummaryMenu:drawGreenPage() - self:drawPlacements(self:greenPlacements()) + self:drawPlacements(self:greenPlacements(), self:lowerColors()) end function SummaryMenu:drawBluePage() self:drawVerticalDivider(10) - self:drawPlacements(self:bluePlacements()) + self:drawPlacements(self:bluePlacements(), self:lowerColors()) end function SummaryMenu:drawMoveDetail() diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 6616c0bb..6bd33408 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -48,6 +48,10 @@ local WILD_ENCOUNTER_GRACE_STEPS = 3 -- of the GB screen center: FLY_ANCHOR is the pair where the original has -- the player's sprite, so path1 starts exactly on the player. local FLY_ANCHOR = { 0x3C, 0x48 } +-- StopMusic's fade-out control byte and the 7 volume steps it waits out +-- engine/overworld/player_animations.asm:123, home/fade_audio.asm:36 +local FLY_FADE_CONTROL = 4 +local FLY_FADE_FRAMES = FLY_FADE_CONTROL * 7 local FLY_PATH1 = { -- FlyAnimationScreenCoords1: up and off to the right { 0x3C, 0x48 }, { 0x3C, 0x50 }, { 0x3B, 0x58 }, { 0x3A, 0x60 }, { 0x39, 0x68 }, { 0x37, 0x70 }, { 0x37, 0x78 }, { 0x33, 0x80 }, @@ -1189,6 +1193,17 @@ function OverworldState:update(dt) end return end + -- StopMusic busy-waits on the fade before LoadBirdSpriteGraphics, so the + -- world holds and the bird is not on screen yet -- home/overworld.asm:772 + if self.flyFade then + self.flyFade = self.flyFade - 1 + if self.flyFade <= 0 then + self.flyFade = nil + self.flyAnim = { phase = "flap", t = 0 } + end + self.player:update() + return + end if self.flyAnim then -- DoFlyAnimation runs one coord pair every Delay3 (3 frames); the -- in-place flap is 8 pairs, then the two paths with a 40-frame beat @@ -2070,7 +2085,9 @@ function OverworldState:flyTo(mapId) -- then SFX_FLY and the up-right path, a 40-frame beat off screen, and -- the exit over the top-left -- the warp fades only once the bird is -- gone (#702). fxBird draws it; the player hides for the whole flight. - self.flyAnim = { phase = "flap", t = 0 } + -- engine/overworld/player_animations.asm:123, home/overworld.asm:772 + require("src.core.Music").fadeOut(FLY_FADE_CONTROL) + self.flyFade = FLY_FADE_FRAMES self.player.inputLocked = true self.flyDest = { map = mapId, x = spot.x, y = spot.y } end @@ -2093,6 +2110,9 @@ function OverworldState:beginTeleportOut(onDone) if onDone then onDone() end return end + -- StopMusic sits above both _LeaveMapAnim branches + -- engine/overworld/player_animations.asm:123 + require("src.core.Music").fadeOut(FLY_FADE_CONTROL) require("src.core.Sound").play(Game.data, "Teleport_Exit1") self.player.surfing = false self:syncSurfingPikachu() @@ -2315,6 +2335,15 @@ function OverworldState.benchGuyText(data, save, label) return data.text["_" .. label] or data.text[BENCH_GUY_TEXT[label] or ""] end +-- HiddenCoins pays a BCD constant chosen by the argument, and the 40 case +-- falls into .bcd20 -- engine/events/hidden_items.asm:79 +function OverworldState.hiddenCoinPayout(amount) + amount = tonumber(amount) or 0 + if amount == 10 then return 10 end + if amount == 20 or amount == 40 then return 20 end + return 100 +end + -- Hidden events at the faced cell (data/events/hidden_events.asm): -- HiddenItems give their item once, HiddenCoins fill the COIN CASE, -- StartSlotMachine seats open the minigame. Taken spots persist in @@ -2359,9 +2388,10 @@ function OverworldState:tryHiddenObject(fx, fy) if save.hiddenTaken[key] then return false end if not save.inventory.COIN_CASE then return false end save.hiddenTaken[key] = true - save.coins = math.min(9999, (save.coins or 0) + h.coins) + local paid = OverworldState.hiddenCoinPayout(h.coins) + save.coins = math.min(9999, (save.coins or 0) + paid) Game.stack:push(TextBox.new(Game, - Strings("%s found\n%d coins!", save.player.name, h.coins), + Strings("%s found\n%d coins!", save.player.name, paid), nil, TextBox.soundOpts(Game, "Get_Item2"))) return true end @@ -2391,7 +2421,19 @@ function OverworldState:tryHiddenObject(fx, fy) else -- one machine per visit is secretly lucky -- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots.asm) - Screens.push(Game, "SlotMachine", seatIndex == self.luckySlot) + local lucky = seatIndex == self.luckySlot + -- PromptUserToPlaySlots: engine/slots/slot_machine.asm:9-23 + Game.stack:push(TextBox.new(Game, txt._PlaySlotMachineText + or romText(Game.data, "_PlaySlotMachineText", + "A slot machine!\nWant to play?"), nil, { + choice = function(yes) + if not yes then return end + self.emote = { + npc = self.player, frames = 60, bubble = 3, + onDone = function() Screens.push(Game, "SlotMachine", lucky) end, + } + end, + })) end return true end @@ -3829,6 +3871,10 @@ function OverworldState:checkTrainerSight() if self.player.moving or self.engaging then return end if Game.stack:top() ~= self then return end local p = self.player + -- a map contribution opts a trainer out of CheckFightingMapTrainers with + -- `noSight = { TEXT_... = true }` -- home/trainers.asm:129 + local view = mapScripts.get and mapScripts.get(self.map.id) + local noSight = view and view.noSight local cancelled = self.cancelledTrainerSight if cancelled and (cancelled.playerX ~= p.cellX or cancelled.playerY ~= p.cellY) then @@ -3842,7 +3888,7 @@ function OverworldState:checkTrainerSight() if d.trainerClass and not npc.moving and not (cancelled and cancelled.npcId == npc.id) and not self:trainerDefeated(npc) - and not mapScripts.talkScript(self.map.id, d.text) + and not (noSight and d.text and noSight[d.text]) and trainerSpriteOnScreen(npc, p) then local header = Game.data:trainerHeader(self.map.def.label, d.index) local range = header and header.range or 0 diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 56bbdba9..155b38db 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -5618,15 +5618,16 @@ end function World:runWhirlpool(result) self:setNickname(result.mon) self:showText(Strings(result.text), function() - self:replaceBlock(result.blockIndex, result.replacement) - self:playWhirlpoolSound() + self:playWhirlpoolSound(result.blockIndex, result.replacement) end) end -- PlayWhirlpoolSound is WaitSFX, SFX_SURF, WaitSFX, never a bare PlaySFX --- -- engine/events/field_moves.asm:5-10 (#1717) -function World:playWhirlpoolSound() - self.fieldMove = { phase = "whirlpoolsfx", waiting = true, left = 180 } +-- -- engine/events/field_moves.asm:5-10 (#1717). The block swap lands after +-- it -- engine/events/overworld.asm:1157-1164 +function World:playWhirlpoolSound(blockIndex, replacement) + self.fieldMove = { phase = "whirlpoolsfx", waiting = true, left = 180, + blockIndex = blockIndex, replacement = replacement } end -- PlayerMovementPointers' .force_turn arm and the Script_ForcedMovement it @@ -6193,6 +6194,8 @@ function World:updateFieldMove() end if Sound.sfxBusy() and st.left > 0 then return end self.fieldMove = nil + -- engine/events/overworld.asm:1163-1164 + if st.blockIndex then self:replaceBlock(st.blockIndex, st.replacement) end return end if st.phase == "strength" then diff --git a/tests/drivers/fly_bird_bug1840_test.lua b/tests/drivers/fly_bird_bug1840_test.lua new file mode 100644 index 00000000..2a35efef --- /dev/null +++ b/tests/drivers/fly_bird_bug1840_test.lua @@ -0,0 +1,101 @@ +-- Manual check of the FLY picker's bird marker and the departure fade (#1840). +-- LoadTownMap_Fly overwrites the cursor tiles with BirdSprite and marks the +-- destination with it, with no blink (engine/items/town_map.asm:146-149, +-- 177-179, 190-198); _LeaveMapAnim stops the music with fade control $4 before +-- the bird appears (engine/overworld/player_animations.asm:123, home/overworld.asm:772). +-- POKEPORT_DRIVER=tests/drivers/fly_bird_bug1840_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Screens = require("src.ui.Screens") + local Music = require("src.core.Music") + local TownMap = require("src.ui.TownMap") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local vol = game.save.options and game.save.options.musicVol + if vol == 0 then + U.log("music volume is 0 in options; raise it or the fade is inaudible") + end + if (game.save.options and game.save.options.sfxVol) == 0 then + U.log("sfx volume is 0 in options; the bird's SFX_FLY will be silent too") + end + + -- BuildFlyLocationsList only offers visited towns + game.save.visited = game.save.visited or {} + for _, town in ipairs({ "PALLET_TOWN", "VIRIDIAN_CITY", "PEWTER_CITY", + "CERULEAN_CITY", "CELADON_CITY" }) do + game.save.visited[town] = true + end + + U.teleport(game, "PALLET_TOWN", 5, 6, "down") + U.wait(30) + + local flown = nil + Screens.push(game, "TownMap", { fly = true, + onFly = function(mapId) flown = mapId end }) + U.wait(20) + local map = game.stack:top() + check("the FLY picker opened", getmetatable(map) == TownMap) + check("it is in fly mode", map.fly == true) + check("the destination marker is the bird sheet", map.birdSheet ~= nil) + check("the blinking cursor art is still loaded for the plain viewer", + map.bg == nil or map.bg.cursor ~= nil) + U.log("fly destinations:", #(map.locs or {})) + U.shot(game, SHOT_DIR .. "/bug1840_flypicker.png") + + -- the marker must not blink: sample the same screen a few frames apart + map.blink = 0 + U.wait(2) + U.shot(game, SHOT_DIR .. "/bug1840_flypicker_a.png") + map.blink = 30 + U.wait(2) + U.shot(game, SHOT_DIR .. "/bug1840_flypicker_b.png") + U.log("compare", SHOT_DIR .. "/bug1840_flypicker_a.png", + "with _b.png: they must be identical") + + U.tap(game, "up") + U.wait(20) + U.tap(game, "a") + U.wait(20) + check("A picks a destination", flown ~= nil) + U.log("flying to", tostring(flown)) + + local faded = false + local realFade = Music.fadeOut + Music.fadeOut = function(control, pending) + faded = control + return realFade(control, pending) + end + local ow = game.overworld + if flown then ow:flyTo(flown) end + U.wait(1) + check("departure asks the music to fade", faded ~= false) + check("with StopMusic's control byte $4", faded == 4) + check("the world holds during the fade", ow.flyFade ~= nil) + check("and the bird is not on screen yet", ow.flyAnim == nil) + U.shot(game, SHOT_DIR .. "/bug1840_fading.png") + + local flapping = false + for _ = 1, 120 do + if ow.flyAnim then flapping = true break end + U.wait(1) + end + check("the bird appears once the fade is done", flapping) + Music.fadeOut = realFade + U.wait(6) + U.shot(game, SHOT_DIR .. "/bug1840_bird.png") + U.log("captured", SHOT_DIR .. "/bug1840_{flypicker,fading,bird}.png") + + U.log("Right: the picker marks the town with a small bird that holds still,") + U.log("and pressing A drops the map theme to silence before the bird flaps.") + U.log("The near-miss is a square bracket cursor blinking on the town, or the") + U.log("theme still playing under the flap and cutting off at the warp.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/ghost_marowak_bug1849_test.lua b/tests/drivers/ghost_marowak_bug1849_test.lua new file mode 100644 index 00000000..eb5bcd6f --- /dev/null +++ b/tests/drivers/ghost_marowak_bug1849_test.lua @@ -0,0 +1,129 @@ +-- Eye/ear check on the Pokemon Tower 6F ghost scene (#1849): entry wipe, +-- the "GHOST appeared!" wording, the SILPH SCOPE unveil colours, and the +-- cry landing on the CUBONE's-mother line. +-- pokered engine/battle/core.asm:6695-6702, data/text/text_2.asm:1251-1255, +-- engine/battle/ghost_marowak_anim.asm:3-5,77, scripts/PokemonTower6F.asm:137-148. +-- POKEPORT_DRIVER=tests/drivers/ghost_marowak_bug1849_test.lua POKEPORT_IDENTITY=bug1849 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- PokemonTower6FMarowakCoords (scripts/PokemonTower6F.asm:43-45) is + -- (10, 16); the 7F stairs at (9, 16) sit right beside it, so the cell + -- north of the trigger is open floor. + local MAP = "POKEMON_TOWER_6F" + local TRIGGER = { x = 10, y = 16 } + local STAND = { x = 10, y = 15, facing = "down" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local opts = game.save.options or {} + local sfxVol = opts.sfxVol or 7 + if sfxVol == 0 then + U.log("FAIL sfx volume is 0: the MAROWAK cry is the audible half of this") + U.log(" check and will not be heard. Set SFX to 7 in OPTION first.") + end + check(("sfx volume %d"):format(sfxVol), sfxVol > 0) + + game.save.party = { Pokemon.new(game.data, "CHARMANDER", 40) } + game.save.inventory = game.save.inventory or {} + game.save.inventory.SILPH_SCOPE = 1 + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_BEAT_GHOST_MAROWAK = nil + check("the SILPH SCOPE is in the bag (this is the unveil path)", + (game.save.inventory.SILPH_SCOPE or 0) > 0) + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(20) + local ow = game.overworld + check("standing on " .. MAP, ow.map.id == MAP) + if not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit moved the floor: take any free neighbour of the trigger + for _, d in ipairs({ { 0, -1 }, { -1, 0 }, { 1, 0 }, { 0, 1 } }) do + local cx, cy = TRIGGER.x + d[1], TRIGGER.y + d[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + local facing = (d[2] == -1 and "down") or (d[2] == 1 and "up") + or (d[1] == -1 and "right") or "left" + U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), + cx, cy, "facing", facing) + U.teleport(game, MAP, cx, cy, facing) + U.wait(20) + ow = game.overworld + break + end + end + end + + -- walk onto the trigger cell ourselves; the Be gone... box opens on arrival + local BattleTransition = require("src.render.BattleTransition") + local sawWipe = false + local battle + for _ = 1, 240 do + U.hold(game, "down", 2) + U.tap(game, "a") + U.wait(4) + local top = game.stack:top() + if getmetatable(top) == BattleTransition then + sawWipe = true + U.shot(game, DIR .. "/bug1849_entry_wipe.png") + end + if getmetatable(top) == BattleState then + battle = top + break + end + end + check("the ghost battle started", battle ~= nil) + check("an entry wipe ran before it (pushBattle, not a bare stack push)", + sawWipe) + + if battle then + check("the foe is disguised as the GHOST", battle.enemy.name == "GHOST") + U.log("intro line reads:", (tostring(battle.introText):gsub("\n", " / "))) + check("the intro line is \"GHOST appeared!\", with no article", + battle.introText == "GHOST\nappeared!") + check("the scope unveil is queued", battle.scopeReveal == true) + + -- ride the unveil: mash A through the boxes and shoot the anim + local shotFlash, shotFade, shotDone = false, false, false + for _ = 1, 900 do + U.tap(game, "a") + U.wait(2) + local gr = battle.ghostReveal + if gr then + if not shotFlash and gr.t >= 20 then + shotFlash = true + U.shot(game, DIR .. "/bug1849_unveil_flash.png") + end + if not shotFade and gr.t >= 100 then + shotFade = true + U.shot(game, DIR .. "/bug1849_unveil_fadein.png") + end + elseif shotFade and not shotDone then + shotDone = true + U.shot(game, DIR .. "/bug1849_unveiled.png") + break + end + end + check("the unveil animation played", shotFlash and shotFade) + check("the real name came back: " .. tostring(battle.enemy.name), + battle.enemy.name ~= "GHOST") + end + + U.log("Shots are in " .. DIR .. ": bug1849_entry_wipe, _unveil_flash,") + U.log("_unveil_fadein, _unveiled.png. In OG RED mode MarowakAnim draws the") + U.log("pic as a sprite under OBJ palette 1, so the flashing ghost and the") + U.log("fading-in MAROWAK are green (pink in Blue) and only settle back to") + U.log("the red background palette once the animation ends. The near-miss is") + U.log("a pic that stays red the whole way through.") + U.log("Beat the MAROWAK and listen: the cry belongs on the CUBONE's-mother") + U.log("box, not on the one after it.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/gold_stats_text_paper_bug1858_test.lua b/tests/drivers/gold_stats_text_paper_bug1858_test.lua new file mode 100644 index 00000000..5f9e7e51 --- /dev/null +++ b/tests/drivers/gold_stats_text_paper_bug1858_test.lua @@ -0,0 +1,156 @@ +-- #1858: stats-screen strings stamped white paper over the tinted lower half. +-- Rows 8-17 stay on BG palette 0, whose colour 0 LoadStatsScreenPals sets to +-- the page tint, so a glyph's blank pixels show that tint -- engine/gfx/ +-- color.asm:342-359, engine/gfx/cgb_layouts.asm:167-192. Never POKEPORT_SPEED +-- here: the page redraw and its cry are being judged in order. +-- POKEPORT_IDENTITY=c10-gold POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/gold_stats_text_paper_bug1858_test.lua POKEPORT_SHOT_DIR=/tmp/gold-bug1858 love . +local U = require("tests.drivers.util") + +local GbcPalette = require("src.render.GbcPalette") +local Mon = require("src.battle.gen2.Mon") +local SummaryMenu = require("src.ui.gen2.SummaryMenu") + +-- The rows the lower half prints on: EXP POINTS / LEVEL UP / TO on the pink +-- page, ITEM and the move list on green, №. and OT/ on blue. +local TEXT_ROWS = { + [SummaryMenu.PINK_PAGE] = { + { "the EXP POINTS row", 80, 72, 159, 79 }, + { "the STATUS/ row", 0, 96, 55, 103 }, + { "the LEVEL UP row", 80, 96, 159, 103 }, + }, + [SummaryMenu.GREEN_PAGE] = { + { "the ITEM row", 0, 64, 159, 71 }, + { "the first move name", 64, 80, 159, 87 }, + { "the first PP row", 96, 88, 159, 95 }, + }, + [SummaryMenu.BLUE_PAGE] = { + { "the ID No. row", 0, 72, 79, 79 }, + { "the OT/ row", 0, 96, 79, 103 }, + { "the ATTACK row", 88, 64, 159, 71 }, + }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1858" + local fails, lines = 0, {} + local function claim(ok, text) + if not ok then fails = fails + 1 end + lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text + return ok + end + local function tap(button) + U.tap(game, button) + U.wait(4) + end + local function top() return game.stack:top() end + + U.wait(45) + if not (game.world and game.world.map) then + U.log("FAIL the gold world never booted, nothing to open a summary over") + while true do coroutine.yield() end + end + + local save = game.save + save.player.name = "GOLD" + save.party = { Mon.new(game.data, "CYNDAQUIL", 22), + Mon.new(game.data, "TOTODILE", 18) } + local lead = save.party[1] + lead.item = "BERRY" + lead.hp = math.max(1, math.floor((lead.maxHp or 1) * 0.45)) + claim(GbcPalette.available(), "the GBC shade-remap shader compiled") + game.options = game.options or {} + game.options.color = "gbc" + save.options = game.options + GbcPalette.applyOptions(game.options) + claim(GbcPalette.mode == "gbc", "COLOR is GBC for the run") + + -- The summary draws at GB coordinates, so a 160x144 canvas at scale 1 makes + -- a GB pixel a pixel and the paper countable. + local function frameOf(screen) + local G = love.graphics + local canvas = G.newCanvas(160, 144) + G.setCanvas(canvas) + G.clear(0, 0, 0, 1) + G.setColor(1, 1, 1, 1) + screen:draw() + G.setCanvas() + G.setColor(1, 1, 1, 1) + return canvas:newImageData() + end + + local function whitePct(img, rect) + local white, total = 0, 0 + for y = rect[3], rect[5] do + for x = rect[2], rect[4] do + local r, g, b = img:getPixel(x, y) + total = total + 1 + if r * 255 > 250 and g * 255 > 250 and b * 255 > 250 then + white = white + 1 + end + end + end + return white / total * 100 + end + + tap("start") + local menu = top() + if not claim(menu and menu.screenId == "Gen2StartMenu", + "START opened the menu") then + for _, line in ipairs(lines) do U.log(line) end + while true do coroutine.yield() end + end + for _ = 1, 10 do + if menu.list:current().value == "pokemon" then break end + tap("down") + end + tap("a") + local party = top() + if not claim(party and party.screenId == "Gen2PartyMenu", + "that opened the party list") then + for _, line in ipairs(lines) do U.log(line) end + while true do coroutine.yield() end + end + tap("a") + tap("a") + local summary = top() + if not claim(summary and summary.screenId == "Gen2SummaryMenu", + "STATS opened the summary") then + for _, line in ipairs(lines) do U.log(line) end + while true do coroutine.yield() end + end + + local NAMES = { "pink", "green", "blue" } + local function inspect(shot) + local img = frameOf(summary) + local page = summary.page + for _, rect in ipairs(TEXT_ROWS[page] or {}) do + local pct = whitePct(img, rect) + U.log((" %-22s %5.1f%% white"):format(rect[1], pct)) + claim(pct <= 1, ("%s (%s page) has no white paper behind it"):format( + rect[1], NAMES[page] or "?")) + end + U.shot(game, ("%s/%s.png"):format(out, shot)) + end + + inspect("01-pink-page") + tap("right") + claim(summary.page == SummaryMenu.GREEN_PAGE, "right reached the green page") + inspect("02-green-page") + tap("right") + claim(summary.page == SummaryMenu.BLUE_PAGE, "right reached the blue page") + inspect("03-blue-page") + tap("right") + + for _, line in ipairs(lines) do U.log(line) end + U.log(("%d checks, %d failed"):format(#lines, fails)) + if fails > 0 then + U.log("a FAIL above means the run is not showing what it claims to") + end + U.log("what right looks like: everything under the rule is one flat wash of") + U.log("the page colour, with the letters and numbers sitting straight on it.") + U.log("a white box the width of each string is the bug; a white box behind") + U.log("only the numbers, or only the upper half tinted, is a half fix.") + U.log("left and right turn the page; the pad is yours.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_whirlpool_block_bug1862_test.lua b/tests/drivers/gold_whirlpool_block_bug1862_test.lua new file mode 100644 index 00000000..88785929 --- /dev/null +++ b/tests/drivers/gold_whirlpool_block_bug1862_test.lua @@ -0,0 +1,141 @@ +-- #1862: the whirlpool must stay on screen for the whole surf wash. +-- DisappearWhirlpool blanks hBGMapMode before the redraw, plays the sound and +-- only then buffers the screen -- engine/events/overworld.asm:1150-1165, +-- engine/events/field_moves.asm:5-10. No POKEPORT_SPEED: audio runs on its +-- own real-time accumulator and the ordering is the whole point. +-- POKEPORT_IDENTITY=c10-gold POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/gold_whirlpool_block_bug1862_test.lua POKEPORT_SHOT_DIR=/tmp/gold-bug1862 love . +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Mon = require("src.battle.gen2.Mon") +local Permissions = require("src.world.gen2.Permissions") + +-- data/generated/maps.lua, ROUTE_41: whirlpools at (22,12), (42,24), (6,30) +-- and (28,48). Stand one cell north of the first and face it. +local MAP = "ROUTE_41" +local WHIRL = { x = 22, y = 12 } + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1862" + local fails, lines = 0, {} + local function claim(ok, text) + if not ok then fails = fails + 1 end + lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text + return ok + end + local function report() + for _, line in ipairs(lines) do U.log(line) end + U.log(("%d checks, %d failed"):format(#lines, fails)) + end + + U.wait(45) + local world = game.world + if not (world and world.map) then + U.log("FAIL the gold world never booted, nothing to drain") + while true do coroutine.yield() end + end + + local vol = game.save.options and game.save.options.sfxVol + claim(vol ~= 0, ("SFX VOL is %s"):format(tostring(vol))) + if vol == 0 then + U.log("SFX VOL is ZERO, so the wash this is timed against is inaudible;") + U.log("turn it up in OPTION before judging anything by ear.") + end + + local badges = game.save.player.badges or {} + game.save.player.badges = badges + for _, badge in pairs(FieldMoves.BADGE) do badges[badge] = true end + local swimmer = Mon.new(game.data, "LAPRAS", 30, + { moves = { { id = "SURF" }, { id = "WHIRLPOOL" } } }) + claim(swimmer ~= nil, "a LAPRAS that knows SURF and WHIRLPOOL") + game.save.party = { swimmer } + + world:applyPlayerState(FieldMoves.PLAYER_SURF) + world:setMap(MAP, WHIRL.x, WHIRL.y - 1, "down") + U.wait(15) + world.noWildEncounters = true + + local ctx = world:fieldContext() + local facing = Permissions.isWhirlpool(ctx.facingColl) + if not facing then + -- A re-import moved it: take any whirlpool with open water above it. + local def = world.maps[MAP] + for y = 1, (def.height or 0) * 2 - 1 do + for x = 0, (def.width or 0) * 2 - 1 do + if not facing and Permissions.isWhirlpool(world.map:cellCollision(x, y)) + and Permissions.isWater(world.map:cellCollision(x, y - 1)) then + WHIRL.x, WHIRL.y = x, y + world:setMap(MAP, x, y - 1, "down") + world:applyPlayerState(FieldMoves.PLAYER_SURF) + U.wait(10) + ctx = world:fieldContext() + facing = Permissions.isWhirlpool(ctx.facingColl) + U.log(("note: using the whirlpool at (%d,%d)"):format(x, y)) + end + end + end + end + claim(facing, ("facing the whirlpool at (%d,%d) on %s"):format( + WHIRL.x, WHIRL.y, MAP)) + local blocks = world.maps[MAP] and world.maps[MAP].blocks + local index = ctx.facingBlockIndex + local before = blocks and index and blocks[index] + claim(before ~= nil, "and the block behind it is readable") + if not (facing and before) then + report() + U.log("nothing to drive; stopping rather than faking the moment") + while true do coroutine.yield() end + end + U.shot(game, out .. "/01-facing.png") + + local function tap(button, gap) + table.insert(game.input.pressQueue, button) + game.input.state[button] = true + coroutine.yield() + game.input.state[button] = false + for _ = 1, (gap or 8) do coroutine.yield() end + end + + -- A into the whirlpool, then A through the ask box (YES is the default) and + -- the USED WHIRLPOOL line. + for _ = 1, 24 do + if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then break end + tap("a") + end + claim(world.fieldMove and world.fieldMove.phase == "whirlpoolsfx", + "the drain reached PlayWhirlpoolSound") + claim(blocks[index] == before, + "the whirlpool is still in the block buffer as the wash starts") + U.shot(game, out .. "/02-wash-starts.png") + + local swapped, phaseFrames = nil, 0 + for _ = 1, 300 do + if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then + phaseFrames = phaseFrames + 1 + if blocks[index] ~= before and not swapped then swapped = phaseFrames end + if phaseFrames == 6 then U.shot(game, out .. "/03-mid-wash.png") end + end + if not world:busy() then break end + coroutine.yield() + end + claim(swapped == nil, + "and it stayed there for every frame of the wash") + claim(blocks[index] ~= before, + "the block was swapped once the wash ended") + claim(phaseFrames > 0 and phaseFrames < 180, + ("the wash ended on its own after %d frames"):format(phaseFrames)) + U.wait(4) + U.shot(game, out .. "/04-drained.png") + + report() + if fails > 0 then + U.log("a FAIL above means the run is not showing what it claims to") + end + U.log("what right looks like: the whirlpool keeps spinning through the whole") + U.log("surf wash and only pops out of the water when the sound has finished.") + U.log("water where the whirlpool was while the wash is still playing is the") + U.log("bug; a whirlpool still there after control comes back is the overshoot.") + U.log("the pad is yours -- the other whirlpools are further south.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/nightshade_anim_bug1848_test.lua b/tests/drivers/nightshade_anim_bug1848_test.lua new file mode 100644 index 00000000..455ba304 --- /dev/null +++ b/tests/drivers/nightshade_anim_bug1848_test.lua @@ -0,0 +1,122 @@ +-- Eye check on NIGHT_SHADE's wavy screen (#1848). AnimationWavyScreen's +-- `ld c, $ff` counts outer passes and the inner loop exits twice a frame +-- (pokered engine/battle/animations.asm:1884-1903, 1916-1927), so the +-- wobble runs ~128 frames with a 16-line wave, not 255 with a 32-line one. +-- POKEPORT_DRIVER=tests/drivers/nightshade_anim_bug1848_test.lua POKEPORT_IDENTITY=bug1848 POKEPORT_TOUCH=0 love . +-- No POKEPORT_SPEED: fast-forward scales the logic clock only. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local AnimPlayer = require("src.battle.AnimPlayer") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- data/generated/maps.lua ROUTE_1: open path south of the first grass + -- patch; pokered data/maps/objects/Route1.asm parks its youngsters at + -- (5, 24) and (15, 13), so nobody is standing here or watching. + local MAP = "ROUTE_1" + local STAND = { x = 5, y = 5, facing = "down" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local opts = game.save.options or {} + local sfxVol = opts.sfxVol or 7 + if sfxVol == 0 then + U.log("FAIL sfx volume is 0, so the flash that opens the animation is") + U.log(" silent. Set SFX to 7 in OPTION first.") + end + check(("sfx volume %d"):format(sfxVol), sfxVol > 0) + if opts.animations == false then + U.log("FAIL OPTION has animations off, which skips every queued anim row.") + end + check("battle animations are on", opts.animations ~= false) + + local anims = (game.data.battle_anims or {}).moveAnims or {} + check("NIGHT_SHADE has an animation", anims.NIGHT_SHADE ~= nil) + + -- what the animation player budgets for the effect, ahead of the battle + local probe = AnimPlayer.new(game.data.battle_anims) + probe:start("NIGHT_SHADE", true) + local budget + for _, e in ipairs(probe.events) do + if e.effect == "SE_WAVY_SCREEN" then budget = e.dur end + end + check(("SE_WAVY_SCREEN is budgeted %s frames (want 128)"):format(tostring(budget)), + budget == 128) + + local lead = Pokemon.new(game.data, "GASTLY", 25) + lead.moves = { { id = "NIGHT_SHADE", pp = 15 } } + game.save.party = { lead } + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(15) + local ow = game.overworld + if not ow.map:isWalkableCell(STAND.x, STAND.y) then + for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do + local cx, cy = STAND.x + d[1], STAND.y + d[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy) + U.teleport(game, MAP, cx, cy, STAND.facing) + U.wait(15) + ow = game.overworld + break + end + end + end + check("standing on " .. MAP, ow.map.id == MAP) + + local battle = BattleState.newWild(game, "PIDGEY", 6) + battle.onFinish = function() end + ow:pushBattle(battle) + + local function waitPhase(phase, tries) + for _ = 1, tries do + if battle.phase == phase then return true end + U.tap(game, "a") + U.wait(6) + end + return battle.phase == phase + end + check("the battle reached the menu", waitPhase("menu", 60)) + U.tap(game, "a") + check("NIGHT_SHADE is the move on the list", waitPhase("moveSelect", 20)) + U.tap(game, "a") + + -- ride the animation and time the wave from the fx layer itself + local started, frames, phases = false, 0, {} + local shots = { [4] = "start", [64] = "middle", [124] = "end" } + for _ = 1, 400 do + local wavy = battle.fx and battle.fx.wavy + if wavy then + started = true + frames = frames + 1 + phases[#phases + 1] = wavy.phase + local tag = shots[frames] + if tag then + U.shot(game, ("%s/bug1848_wavy_%s.png"):format(DIR, tag)) + end + elseif started then + break + end + U.wait(1) + end + + check("the wave actually ran", started) + check(("the wave lasted %d frames (want 128)"):format(frames), + frames >= 120 and frames <= 136) + local step = (#phases >= 2) and (phases[2] - phases[1]) or 0 + check(("the offset pointer advances %d entries a frame (want 2)"):format(step), + step == 2) + + U.log("Three shots are in " .. DIR .. ": bug1848_wavy_start/middle/end.png.") + U.log("The screen should ripple with about nine full waves top to bottom,") + U.log("scrolling upward, and settle after roughly two seconds. The near-miss") + U.log("to watch for is half that many waves crawling for twice as long.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/party_submenu_box_bug1819_test.lua b/tests/drivers/party_submenu_box_bug1819_test.lua new file mode 100644 index 00000000..70cb36ee --- /dev/null +++ b/tests/drivers/party_submenu_box_bug1819_test.lua @@ -0,0 +1,118 @@ +-- Party submenu box geometry (#1819) with PokemonMenuEntries in full (#1833). +-- pokered anchors this box to the bottom row of the screen and grows it +-- upward two rows per field move, indenting it by the widest field move name +-- (engine/menus/text_box.asm:397-440, data/moves/field_moves.asm, +-- data/text_boxes.asm:33). +-- POKEPORT_DRIVER=tests/drivers/party_submenu_box_bug1819_test.lua POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local Font = require("src.render.Font") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local mon = Pokemon.new(game.data, "CHANSEY", 40) + game.save.party = { mon } + game.save.player.name = "bryan" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + -- record the boxes one real draw pass puts on screen; the submenu is the + -- only one that does not start at column 0 + local function boxThisFrame() + local seen = {} + local real = Font.drawBox + Font.drawBox = function(tx, ty, tw, th, ...) + seen[#seen + 1] = { tx, ty, tw, th } + return real(tx, ty, tw, th, ...) + end + U.wait(2) + Font.drawBox = real + for i = #seen, 1, -1 do + if seen[i][1] > 0 then return seen[i] end + end + return nil + end + + local function openSubmenu(moves) + mon.moves = moves + Screens.push(game, "PartyMenu", {}) + U.wait(12) + U.tap(game, "a") + U.wait(12) + return game.stack:top() + end + + local function closeSubmenu() + U.tap(game, "b") + U.wait(8) + U.tap(game, "b") + U.wait(8) + end + + local function move(id) + local def = game.data.moves[id] + return { id = id, pp = def and def.pp or 10 } + end + + -- tx, ty, tw, th the port should hand Font.drawBox, per case + local cases = { + { name = "no field moves", shot = "bug1819_submenu_plain.png", + moves = { move("POUND") }, rows = 3, box = { 11, 11, 9, 7 } }, + { name = "one field move (STRENGTH indents to tile 10)", + shot = "bug1819_submenu_strength.png", + moves = { move("STRENGTH") }, rows = 4, box = { 9, 8, 11, 10 } }, + { name = "SOFTBOILED, the widest name (tile 8)", + shot = "bug1819_submenu_softboiled.png", + moves = { move("SOFTBOILED") }, rows = 4, box = { 7, 8, 13, 10 } }, + } + + for _, c in ipairs(cases) do + local pm = openSubmenu(c.moves) + local ok = pm and pm.submenu + check(c.name .. ": the submenu opened", ok == true) + if ok then + local labels = {} + for i, e in ipairs(pm.subItems) do labels[i] = e.label end + U.log(c.name .. " lists:", table.concat(labels, " / ")) + check(c.name .. ": " .. c.rows .. " rows ending in CANCEL", + #pm.subItems == c.rows + and pm.subItems[#pm.subItems].action == "cancel") + local b = boxThisFrame() + if not b then + check(c.name .. ": a submenu box was drawn", false) + else + U.log(c.name .. " box tx,ty,tw,th:", b[1], b[2], b[3], b[4]) + check(c.name .. ": box is " .. table.concat(c.box, ","), + b[1] == c.box[1] and b[2] == c.box[2] + and b[3] == c.box[3] and b[4] == c.box[4]) + check(c.name .. ": its bottom border is on tile row 17", + b[2] + b[4] - 1 == 17) + check(c.name .. ": its right edge is on tile column 19", + b[1] + b[3] - 1 == 19) + end + U.shot(game, SHOT_DIR .. "/" .. c.shot) + U.log("captured", SHOT_DIR .. "/" .. c.shot) + end + closeSubmenu() + end + + -- leave the last one up for the eye + openSubmenu({ move("SOFTBOILED") }) + + U.log("The submenu on screen should sit flush against the bottom of the") + U.log("screen, its lower border sharing the screen's last pixel row, with") + U.log("SOFTBOILED / STATS / SWITCH / CANCEL inside it and one blank row") + U.log("above SOFTBOILED. The near-miss to watch for is an 8px gap under") + U.log("the box that shows the message box's own bottom line, so the two") + U.log("borders read as a doubled or detached line.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/pc_deposit_test.lua b/tests/drivers/pc_deposit_test.lua index 0de8e0c1..f9a1d814 100644 --- a/tests/drivers/pc_deposit_test.lua +++ b/tests/drivers/pc_deposit_test.lua @@ -72,7 +72,11 @@ return function(game) check(howY == 112, "How many? sits on text-box first line (y=112)") check(textBox ~= nil, "bottom PrintText box drawn") check(qtyBox ~= nil, "quantity box at pokered row 9") - check(maxItemY and maxItemY <= 72, "last visible item row above qty/text (y<=72)") + -- LIST_MENU_BOX 4,2 - 19,12: the fourth row's quantity sits at y=88 + -- (data/text_boxes.asm:13, engine/menus/players_pc.asm) + check(maxItemY == 88, "last visible item row at y=88") + check(textBox and maxItemY and maxItemY + 8 <= textBox.ty * 8, + "last visible item row clears the bottom text box (#174)") if maxItemY and howY then check(maxItemY + 8 <= howY, "item glyphs do not overlap How many?") end diff --git a/tests/drivers/pc_lists_bug1845_bug1847_test.lua b/tests/drivers/pc_lists_bug1845_bug1847_test.lua new file mode 100644 index 00000000..1f757d16 --- /dev/null +++ b/tests/drivers/pc_lists_bug1845_bug1847_test.lua @@ -0,0 +1,206 @@ +-- The four PC list screens: the player's PC item lists (#1845) and Bill's +-- PC mon lists plus CHANGE BOX (#1847). Both are LIST_MENU_BOX screens in +-- the original (home/list_menu.asm:29-31, engine/menus/save.asm:437-506), +-- not full-screen lists of their own. +-- SHOT_DIR=/tmp/pc_lists POKEPORT_DRIVER=tests/drivers/pc_lists_bug1845_bug1847_test.lua POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Boxes = require("src.pokemon.Boxes") + local BoxMenu = require("src.ui.BoxMenu") + local Font = require("src.render.Font") + local ListMenu = require("src.ui.ListMenu") + local Menu = require("src.ui.Menu") + local PlayerPC = require("src.ui.PlayerPC") + local Pokemon = require("src.pokemon.Pokemon") + local TextBox = require("src.render.TextBox") + + local pass = true + local function check(label, ok) + if ok then U.log("PASS", label) else pass = false; U.log("FAIL", label) end + return ok + end + + -- record every Font.drawBox/Font.draw of one frame so the geometry can be + -- asserted instead of eyeballed + local function capture() + local out = {} + local savedDraw, savedBox = Font.draw, Font.drawBox + Font.draw = function(text, x, y) + out[#out + 1] = { kind = "text", text = tostring(text), x = x, y = y } + return savedDraw(text, x, y) + end + Font.drawBox = function(tx, ty, tw, th) + out[#out + 1] = { kind = "box", tx = tx, ty = ty, tw = tw, th = th } + return savedBox(tx, ty, tw, th) + end + game.stack:draw() + Font.draw, Font.drawBox = savedDraw, savedBox + return out + end + + local function hasBox(drawn, tx, ty, tw, th) + for _, d in ipairs(drawn) do + if d.kind == "box" and d.tx == tx and d.ty == ty + and d.tw == tw and d.th == th then return true end + end + return false + end + + local function textOnRow(drawn, y) + for _, d in ipairs(drawn) do + if d.kind == "text" and d.y == y and d.text ~= "" then return d end + end + return nil + end + + local function hasText(drawn, want) + for _, d in ipairs(drawn) do + if d.kind == "text" and d.text == want then return d end + end + return nil + end + + -- pokered data/maps/objects/ViridianPokecenter.asm keeps the floor east of + -- the counter free; the PC itself is a hidden object, so the screens are + -- pushed directly rather than bumped into + local MAP, STAND = "VIRIDIAN_POKECENTER", { x = 13, y = 4 } + U.teleport(game, MAP, STAND.x, STAND.y, "up") + U.wait(6) + local function freeNeighbour(ow) + for dy = -2, 2 do + for dx = -2, 2 do + local cx, cy = STAND.x + dx, STAND.y + dy + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + return cx, cy + end + end + end + end + local ow = game.overworld + if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then + local cx, cy = freeNeighbour(ow) + if cx then + U.log("stand cell blocked, standing on", cx, cy) + U.teleport(game, MAP, cx, cy, "up") + U.wait(6) + end + end + + -- #1845: the player's PC item lists + game.save.pcItems = { POTION = 3, ANTIDOTE = 1, REPEL = 5, ESCAPE_ROPE = 2 } + game.stack:push(PlayerPC.new(game)) + U.wait(4) + U.tap(game, "a") -- WITHDRAW ITEM + U.wait(6) + local list = game.stack:top() + check("WITHDRAW ITEM opened a list", getmetatable(list) == ListMenu) + if getmetatable(list) == ListMenu then + check("the list is drawn as LIST_MENU_BOX, not a screen", list.itemBox == true) + check("the PC menu underneath stays visible", list.isOpaque == false) + check("PrintListMenuEntries' four rows", list.rows == 4) + check("the list ends on the CANCEL terminator", + list.items[#list.items].cancel == true) + check("the withdraw prompt is up before anything is chosen", + type(list.footer) == "string" and list.footer:find("withdraw", 1, true) ~= nil) + local drawn = capture() + check("LIST_MENU_BOX at tile 4,2 (16x11)", hasBox(drawn, 4, 2, 16, 11)) + check("the prompt's text box at tile 0,12 (20x6)", hasBox(drawn, 0, 12, 20, 6)) + check("nothing on the invented title row (y=4)", textOnRow(drawn, 4) == nil) + local name = hasText(drawn, list.items[1].label) + check("first name at hlcoord 6,4 (48,32)", + name ~= nil and name.x == 48 and name.y == 32) + end + U.shot(game, DIR .. "/pc_1845_withdraw_item.png") + U.tap(game, "b") -- back to the PC menu + U.wait(4) + U.tap(game, "down"); U.tap(game, "down"); U.tap(game, "down") + U.wait(2) + U.tap(game, "a") -- LOG OFF + U.wait(6) + + -- #1847: Bill's PC mon lists and CHANGE BOX + local boxes = Boxes.ensure(game.save) + boxes[1] = { + Pokemon.new(game.data, "PIDGEY", 12), + Pokemon.new(game.data, "RATTATA", 7), + Pokemon.new(game.data, "NIDORAN_M", 15), + Pokemon.new(game.data, "ZUBAT", 9), + Pokemon.new(game.data, "ONIX", 22), + } + boxes[3] = { Pokemon.new(game.data, "MAGIKARP", 5) } + game.save.currentBox = 1 + game.save.party = { Pokemon.new(game.data, "CHARMANDER", 14) } + game.stack:push(BoxMenu.new(game)) + U.wait(4) + U.tap(game, "a") -- WITHDRAW POKéMON + U.wait(6) + local mons = game.stack:top() + check("WITHDRAW POKéMON opened a list", getmetatable(mons) == ListMenu) + if getmetatable(mons) == ListMenu then + check("the mon list is drawn as LIST_MENU_BOX", mons.itemBox == true) + check("the mon list ends on CANCEL", + mons.items[#mons.items].cancel == true) + check("the level is a row of its own, not part of the name", + mons.items[1].sub == ":L12" + and mons.items[1].label:find(":L") == nil) + local drawn = capture() + check("LIST_MENU_BOX at tile 4,2 (16x11)", hasBox(drawn, 4, 2, 16, 11)) + check("no invented BOX 1 (WITHDRAW) title", + hasText(drawn, "BOX 1 (WITHDRAW)") == nil) + local lvl = hasText(drawn, ":L12") + check("PrintLevel one row down, 8 columns right (112,40)", + lvl ~= nil and lvl.x == 112 and lvl.y == 40) + end + U.shot(game, DIR .. "/pc_1847_withdraw_mon.png") + U.tap(game, "b") + U.wait(4) + + U.tap(game, "down"); U.tap(game, "down"); U.tap(game, "down") + U.wait(2) + U.tap(game, "a") -- CHANGE BOX + U.wait(30) + local ask = game.stack:top() + check("CHANGE BOX asks before it shows the box list", + getmetatable(ask) == TextBox) + U.shot(game, DIR .. "/pc_1847_change_box_prompt.png") + -- page through "When you change a POKéMON BOX..." and take the YES + local function isPicker(s) + return getmetatable(s) == Menu and s.kind == "pc_box_change" + end + for _ = 1, 12 do + if isPicker(game.stack:top()) then break end + U.tap(game, "a") + U.wait(12) + end + local picker = game.stack:top() + check("YES opens the box picker", isPicker(picker)) + if isPicker(picker) then + check("twelve boxes", #picker.items == 12) + check("BoxNames spelling", picker.items[1].label == "BOX 1" + and picker.items[12].label == "BOX12") + check("the cursor starts on the current box", + picker.index == game.save.currentBox) + local drawn = capture() + check("box list at tile 11,0 (9x14)", hasBox(drawn, 11, 0, 9, 14)) + check("BOX No. panel at tile 0,0 (11x4)", hasBox(drawn, 0, 0, 11, 4)) + local first = hasText(drawn, "BOX 1") + check("BoxNames at hlcoord 13,1 (104,8)", + first ~= nil and first.x == 104 and first.y == 8) + end + U.shot(game, DIR .. "/pc_1847_change_box.png") + + U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE") + U.log("On screen now: the CHANGE BOX picker. BOX 1..BOX12 sit in a tall") + U.log("box on the right, BOX No. 1 in a small panel top-left, and a") + U.log("pokeball marks BOX 1 and BOX 3 only, the two that hold POKéMON.") + U.log("The near-miss to watch for is a marker on all twelve boxes, or on") + U.log("none, and BOX10-12 spelled with the space of BOX 1 to BOX 9.") + U.log("Shots are in " .. DIR .. "; the two list shots must show the") + U.log("PC menu and the What? box around a bordered list, never a white") + U.log("page with a title row.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/pokedex_contents_bug1829_test.lua b/tests/drivers/pokedex_contents_bug1829_test.lua new file mode 100644 index 00000000..4e73f4e1 --- /dev/null +++ b/tests/drivers/pokedex_contents_bug1829_test.lua @@ -0,0 +1,119 @@ +-- Eye/ear check for the Pokédex CONTENTS screen and entry page (#1829): +-- screen furniture, the seven rows, the side-menu cursor, the cry the entry +-- page waits on and the blinking page arrow. +-- engine/menus/pokedex.asm:161-199, :242-273, :500-506; home/joypad2.asm:55-83. +-- No POKEPORT_SPEED: the cry runs on the real-time audio clock. +-- POKEPORT_DRIVER=tests/drivers/pokedex_contents_bug1829_test.lua POKEPORT_IDENTITY=bug1829 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Screens = require("src.ui.Screens") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local opts = game.save.options or {} + if (opts.sfxVol or 1) == 0 then + U.log("WARNING sfxVol is 0, so the entry page's cry will be silent and") + U.log("WARNING the pause before the height/weight lines will look like a hang") + end + + -- dex numbers -> species, so the driver never hardcodes an id + local byDex = {} + for _, def in pairs(game.data.pokemon) do + if def.dex then byDex[def.dex] = def end + end + check("the cache carries a Kanto numbering", byDex[1] ~= nil and byDex[20] ~= nil) + + game.save.pokedex = { seen = {}, owned = {} } + for n = 1, 20 do + local def = byDex[n] + if def and n ~= 5 then game.save.pokedex.seen[def.id] = true end + end + for _, n in ipairs({ 1, 2, 3, 7, 8 }) do + local def = byDex[n] + if def then + game.save.pokedex.seen[def.id] = true + game.save.pokedex.owned[def.id] = true + end + end + game.save.player.name = "bryan" + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(20) + + local dex = Screens.push(game, "PokedexMenu") + U.wait(20) + check("the dex list is on the stack", game.stack:top() == dex) + check("the list stops at the highest seen number (20)", #dex.items == 20) + check("dex 005 was left unseen, so its row is a dashed line", + dex.items[5] and dex.items[5].name == "----------") + check("dex 001 is owned, so its row carries the ball marker", + dex.items[1] and dex.items[1].ball == true) + U.shot(game, SHOT_DIR .. "/bug1829_contents.png") + + -- Right pages the list without moving the cursor off its screen row + local rowBefore = dex.index - dex.scroll + U.tap(game, "right") + U.wait(10) + check("Right scrolled the list", dex.scroll == 7) + check("the cursor kept its screen row", dex.index - dex.scroll == rowBefore) + U.shot(game, SHOT_DIR .. "/bug1829_contents_paged.png") + U.tap(game, "left") + U.wait(10) + check("Left paged back to the top", dex.scroll == 0) + + -- A on an owned row opens the side menu: the cursor moves onto the + -- DATA/CRY/AREA/QUIT block that was already on screen + U.tap(game, "a") + U.wait(20) + local side = game.stack:top() + check("A opened the side menu", side ~= dex) + check("the list row kept its hollow cursor", dex.hollowIndex == dex.index) + U.shot(game, SHOT_DIR .. "/bug1829_side_menu.png") + + -- DATA is the first row, so A again opens the entry page + U.tap(game, "a") + U.wait(2) + local page = game.stack:top() + check("DATA opened the entry page", page ~= side and page.def ~= nil) + U.shot(game, SHOT_DIR .. "/bug1829_entry_cry.png") + + local waited = 0 + while page.crying and page:crying() and waited < 300 do + waited = waited + 1 + coroutine.yield() + end + U.log("the cry held the page for", waited, "frames") + U.wait(10) + U.shot(game, SHOT_DIR .. "/bug1829_entry_full.png") + check("the entry has more than one page, so the arrow is drawn", + (page.pageCount or 1) > 1) + + local function shotAtBlink(target, path) + for _ = 1, 120 do + if (page.blink or 0) == target then break end + coroutine.yield() + end + U.shot(game, path) + end + shotAtBlink(8, SHOT_DIR .. "/bug1829_arrow_on.png") + shotAtBlink(38, SHOT_DIR .. "/bug1829_arrow_off.png") + + U.log("Shots are in " .. SHOT_DIR .. ". The CONTENTS screen should read") + U.log("CONTENTS top left, a vertical rule down column 14 with SEEN and OWN") + U.log("counts beside it and DATA/CRY/AREA/QUIT under them, and seven rows") + U.log("of number-above-name with the ball left of the owned names.") + U.log("bug1829_entry_cry should show the frame, name, kind, No. and pic") + U.log("with nothing below the divider while the cry sounds; the height,") + U.log("weight and description arrive in bug1829_entry_full once it ends.") + U.log("The near-miss to watch for: all of it painted at once with the cry") + U.log("playing over the top, and a page arrow that never blinks off") + U.log("(bug1829_arrow_on has it, bug1829_arrow_off should not).") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/rare_candy_bug1846_test.lua b/tests/drivers/rare_candy_bug1846_test.lua new file mode 100644 index 00000000..0a46bfe6 --- /dev/null +++ b/tests/drivers/rare_candy_bug1846_test.lua @@ -0,0 +1,71 @@ +-- Rare Candy's "grew to level N!" line carries a jingle (#1846): pokered's +-- RareCandyText is text_far _RareCandyText / sound_get_item_1 / +-- text_promptbutton (engine/menus/party_menu.asm:289-293), and outside +-- battle that command plays SFX_GET_ITEM_1 (home/text.asm:546). +-- POKEPORT_DRIVER=tests/drivers/rare_candy_bug1846_test.lua POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local Bag = require("src.inventory.Bag") + local Sound = require("src.core.Sound") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + if (game.save.options and game.save.options.sfxVol or 1) == 0 then + U.log("WARNING: sfxVol is 0, so nothing here can be heard.") + U.log("WARNING: set SFX volume in OPTION before judging this driver.") + end + + local mon = Pokemon.new(game.data, "NIDORINO", 20) + game.save.party = { mon } + game.save.player.name = "bryan" + game.save.inventory = {} + Bag.add(game.save, "RARE_CANDY", 3, game.data) + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + local heard = {} + local realPlay = Sound.play + Sound.play = function(data, name, ...) + heard[#heard + 1] = name + return realPlay(data, name, ...) + end + + -- bag -> RARE CANDY -> USE -> the only party member + Screens.push(game, "BagMenu", {}) + U.wait(12) + U.tap(game, "a") + U.wait(12) + U.tap(game, "a") + U.wait(12) + local level = mon.level + U.tap(game, "a") + U.wait(40) + + Sound.play = realPlay + check("the candy raised the level", mon.level == level + 1) + U.log("sounds heard:", table.concat(heard, " ")) + local jingle = false + for _, name in ipairs(heard) do + if name == "Get_Item1" then jingle = true end + end + check("the level line carried SFX_GET_ITEM_1", jingle) + + U.shot(game, SHOT_DIR .. "/bug1846_rare_candy.png") + U.log("captured", SHOT_DIR .. "/bug1846_rare_candy.png") + + U.log("NIDORINO grew to level 21 just now: the short item fanfare should") + U.log("sound as that line finishes typing, and the box waits for A after") + U.log("it. Silence there is the bug. The other near-miss is the longer") + U.log("level-up fanfare, which only belongs to a candy used in battle.") + U.log("Two candies are left in the bag to hear it again.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/rocket3_sight_bug1814_test.lua b/tests/drivers/rocket3_sight_bug1814_test.lua new file mode 100644 index 00000000..08419cd8 --- /dev/null +++ b/tests/drivers/rocket3_sight_bug1814_test.lua @@ -0,0 +1,100 @@ +-- Manual check that Hideout B4F's Rocket3 engages on sight (#1814). +-- RocketHideout4TrainerHeader2 is `trainer EVENT_..., 1, ...` +-- (scripts/RocketHideoutB4F.asm:95-96), so CheckSpriteCanSeePlayer engages him +-- from the tile below. The LIFT KEY half (his after-battle talk still reveals +-- the ball) is asserted in tests/parity_rocket3_sight_bug1814.lua. +-- POKEPORT_DRIVER=tests/drivers/rocket3_sight_bug1814_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- pokered data/maps/objects/RocketHideoutB4F.asm: ROCKET3 stands at (11,2) + -- facing DOWN, so the sight tile is the one below him + local MAP = "ROCKET_HIDEOUT_B4F" + local TEXT = "TEXT_ROCKETHIDEOUTB4F_ROCKET3" + + local function rocketIn(ow) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.text == TEXT then return n end + end + end + + -- a party that can take a hit, so the battle the sight starts is playable + if #(game.save.party or {}) == 0 then + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { Pokemon.new(game.data, "NIDOKING", 45) } + end + + U.teleport(game, MAP, 13, 3, "left") + U.wait(10) + local ow = game.overworld + local rocket = rocketIn(ow) + check("Rocket3 is on the map", rocket ~= nil) + if rocket then + U.log(("Rocket3 at (%d, %d) facing %s") + :format(rocket.cellX, rocket.cellY, tostring(rocket.facing))) + end + + local header = rocket + and game.data:trainerHeader(ow.map.def.label, rocket.def.index) + check("his extracted view range is 1 tile", header and header.range == 1) + + -- walk north into his line of sight; a map edit that moved him is a + -- different failure than a sight check that never fires, so aim at the + -- cell below wherever he actually stands + local target = rocket and { rocket.cellX, rocket.cellY + 1 } or { 11, 3 } + local steps = 0 + for _ = 1, 8 do + local p = game.overworld.player + if (p.cellX == target[1] and p.cellY == target[2]) + or game.overworld.engaging then break end + if p.cellX > target[1] then U.hold(game, "left", 16) + elseif p.cellX < target[1] then U.hold(game, "right", 16) + elseif p.cellY > target[2] then U.hold(game, "up", 16) + else U.hold(game, "down", 16) end + steps = steps + 1 + U.wait(4) + end + U.log("walked", steps, "steps toward", target[1], target[2]) + + local sighted = false + for _ = 1, 90 do + ow = game.overworld + if ow.engaging or ow.emote then sighted = true break end + U.wait(1) + end + check("stepping into his line of sight engages him", sighted) + if game.overworld.emote then + U.shot(game, SHOT_DIR .. "/bug1814_sighted.png") + U.log("captured", SHOT_DIR .. "/bug1814_sighted.png") + end + + local BattleState = require("src.battle.BattleState") + -- his line prints first and holds on a button wait (home/text_script.asm:96) + local fought = false + for _ = 1, 60 do + if getmetatable(game.stack:top()) == BattleState then fought = true break end + U.tap(game, "a") + U.wait(8) + end + check("the walk-up runs into the battle", fought) + if fought then + U.wait(60) + U.shot(game, SHOT_DIR .. "/bug1814_battle.png") + U.log("captured", SHOT_DIR .. "/bug1814_battle.png") + end + + U.log("Right: the grunt notices the player one tile below him, the bubble") + U.log("pops, he marches down and the fight starts on its own. The near-miss") + U.log("is nothing happening until the player presses A at him, which is the") + U.log("bug. After winning, talk to him again: he drops the LIFT KEY ball.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/slots_prompt_bug1811_test.lua b/tests/drivers/slots_prompt_bug1811_test.lua new file mode 100644 index 00000000..95b1d33a --- /dev/null +++ b/tests/drivers/slots_prompt_bug1811_test.lua @@ -0,0 +1,113 @@ +-- Manual check of the Game Corner slot prompt, emote and menus (#1811). +-- PromptUserToPlaySlots prints "Want to play?" and floats SMILE_BUBBLE over +-- the player on the map, and only then loads the slot screen, whose bet menu +-- is hlcoord 14,11 b=5 c=4 (engine/slots/slot_machine.asm:9-23, :83-86). +-- Box geometry is asserted in tests/engine/slot_machine_boxes_bug1811.lua. +-- POKEPORT_DRIVER=tests/drivers/slots_prompt_bug1811_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") + local SlotMachine = require("src.ui.SlotMachine") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local vol = game.save.options and game.save.options.sfxVol + if vol == 0 then + U.log("sfx volume is 0 in options; raise it or the menu beeps are silent") + end + + -- AbleToPlaySlotsCheck wants a COIN CASE with coins in it + game.save.inventory = game.save.inventory or {} + game.save.inventory.COIN_CASE = 1 + game.save.coins = math.max(game.save.coins or 0, 50) + + -- the seats are hidden events on the machine tiles (data/events/ + -- hidden_events.asm StartSlotMachine); stand on any walkable neighbour + local seats = (game.data.field.slotMachines or {}).GAME_CORNER or {} + local placed = nil + U.teleport(game, "GAME_CORNER", 9, 15, "down") + local ow = game.overworld + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, seat in ipairs(seats) do + if seat.state == "ok" then + for _, s in ipairs(sides) do + local cx, cy = seat.x + s[1], seat.y + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.teleport(game, "GAME_CORNER", cx, cy, s[3]) + U.wait(10) + ow = game.overworld + local fx, fy = ow.player:facingCell() + if fx == seat.x and fy == seat.y then + placed = { seat = seat, x = cx, y = cy, facing = s[3] } + end + break + end + end + end + if placed then break end + end + check("standing at a working slot machine", placed ~= nil) + if placed then + U.log(("machine at (%d, %d), player at (%d, %d) facing %s") + :format(placed.seat.x, placed.seat.y, placed.x, placed.y, + placed.facing)) + end + + U.tap(game, "a") + U.wait(30) + local top = game.stack:top() + local isBox = getmetatable(top) == TextBox + check("A opens a text box, not the slot screen", isBox) + check("the slot screen is not on the stack yet", + getmetatable(top) ~= SlotMachine) + if isBox then + local shown = {} + for _, page in ipairs(top.pages or {}) do + for _, line in ipairs(page) do shown[#shown + 1] = line end + end + U.log("box reads:", table.concat(shown, " / ")) + end + U.shot(game, SHOT_DIR .. "/bug1811_prompt.png") + + -- through the text, then YES + for _ = 1, 8 do + if game.overworld.emote then break end + U.tap(game, "a") + U.wait(12) + end + U.wait(4) + local emote = game.overworld.emote + check("YES floats an emotion bubble over the player", emote ~= nil) + check("it is SMILE_BUBBLE, the third crop", emote and emote.bubble == 3) + check("and it is over the player, not an NPC", + emote and emote.npc == game.overworld.player) + U.shot(game, SHOT_DIR .. "/bug1811_smile.png") + + for _ = 1, 120 do + if getmetatable(game.stack:top()) == SlotMachine then break end + U.wait(2) + end + local slots = game.stack:top() + check("the slot screen opens after the bubble", + getmetatable(slots) == SlotMachine) + check("and it opens on the bet menu", slots.stage == "bet") + U.wait(10) + U.shot(game, SHOT_DIR .. "/bug1811_bet.png") + U.log("captured", SHOT_DIR .. "/bug1811_{prompt,smile,bet}.png") + + U.log("Right: the prompt box sits over the still-drawn Game Corner floor,") + U.log("then a smiley pops over the player, then the machine appears with a") + U.log("bet menu whose bottom edge is the last row of the screen. The") + U.log("near-miss is the machine already drawn behind the prompt, or a bet") + U.log("box two rows short of the bottom.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/title_seam_bug1866_test.lua b/tests/drivers/title_seam_bug1866_test.lua new file mode 100644 index 00000000..ff52722c --- /dev/null +++ b/tests/drivers/title_seam_bug1866_test.lua @@ -0,0 +1,155 @@ +-- Manual check for #1866: black hairlines at the title's SGB zone boundaries +-- (canvas rows 64 and 80, BlkPacket_Titlescreen, data/sgb/sgb_packets.asm: +-- 123-127) at window heights that are not a whole multiple of 144. +-- POKEPORT_DRIVER=tests/drivers/title_seam_bug1866_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music. +return function(game) + local U = dofile("tests/drivers/util.lua") + local PaletteFX = require("src.render.PaletteFX") + local Renderer = require("src.render.Renderer") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and top.screenId == "TitleState" and top.sgbPalettes then + title = top + break + end + U.tap(game, "start") + U.wait(9) + end + check("the title screen is on top", title ~= nil) + if not (title and title.sgbPalettes) then + U.log("No title state to look at; nothing below can run.") + while true do coroutine.yield() end + end + + local opts = game.save.options + local mode = opts and opts.colors or PaletteFX.mode + if PaletteFX.mode ~= "gbc" then + U.log("COLORS is", tostring(mode) .. "; switching the view to SGB for the check") + PaletteFX.setMode("gbc") + U.wait(20) + end + local zones = PaletteFX.ensureZones(title:sgbPalettes(game)) + check("the title builds three SGB zones", zones ~= nil and #zones == 3) + + -- Every height below is odd and none is a multiple of 144, so the picture + -- never lands on a whole number of screen pixels per GB pixel. "fill" + -- additionally drops the integer fit scale, which is the shape the report's + -- screenshot was taken in (about 5.98 screen pixels per GB pixel). + local HEIGHTS = { 721, 823, 907, 1013 } + local WIDTH = 1157 + local worstRows, worstTag = 0, "none" + + -- the control: a zone list with a real hole in it, drawn the way the + -- per-zone pass alone would draw it. It proves the sampler below can see + -- a seam at all, and that the underpaint is what closes this one. + local gapped = {} + for i, z in ipairs(zones) do + gapped[i] = { x = z.x, y = z.y, w = z.w, h = z.h, colors = z.colors } + end + if gapped[2] then + gapped[2].y = gapped[2].y + 1 + gapped[2].h = gapped[2].h - 2 + end + + local function drawZonesOnly(zoneList, Ux, Uy, uox, uoy) + local shader = PaletteFX.shader() + love.graphics.setShader(shader) + for _, z in ipairs(zoneList) do + PaletteFX.sendColors(shader, z.colors) + love.graphics.setScissor(uox + z.x * Ux, uoy + z.y * Uy, + z.w * Ux, z.h * Uy) + love.graphics.draw(Renderer.canvas, uox, uoy, 0, Ux, Uy) + end + love.graphics.setScissor() + love.graphics.setShader() + end + + local function probeAt(ph, fill, zoneList, zonesOnly) + local dpi = 1 + local probe = love.graphics.newCanvas(WIDTH, ph, { dpiscale = dpi }) + local Up + if fill then + Up = math.min(ph / 144, WIDTH / 160) + else + Up = math.max(1, math.floor(math.min(WIDTH / 160, ph / 144))) + end + local Ux, Uy = Up / dpi, Up / dpi + local uox = math.floor((WIDTH - 160 * Up) / 2) / dpi + local uoy = math.floor((ph - 144 * Up) / 2) / dpi + local uvpw, uvph = 160 * Ux, 144 * Uy + love.graphics.setCanvas(probe) + love.graphics.clear(0, 0, 0, 1) + love.graphics.setColor(1, 1, 1, 1) + if zonesOnly then + drawZonesOnly(zoneList, Ux, Uy, uox, uoy) + else + Renderer:blitCanvas(Renderer.canvas, Ux, Uy, zoneList, Ux, Uy, + uox, uoy, uox, uoy, uvpw, uvph, dpi, dpi) + end + love.graphics.setCanvas() + local data = probe:newImageData() + local top = math.floor(uoy * dpi) + 1 + local bottom = math.min(math.floor((uoy + uvph) * dpi) - 1, data:getHeight() - 1) + local col = math.floor(uox * dpi) + 2 + local rows = {} + for py = top, bottom do + local r, g, b = data:getPixel(col, py) + if r < 0.02 and g < 0.02 and b < 0.02 then rows[#rows + 1] = py end + end + return rows, ("%dx%d %s"):format(WIDTH, ph, fill and "fill" or "fit") + end + + for _, ph in ipairs(HEIGHTS) do + for _, fill in ipairs({ false, true }) do + local rows, tag = probeAt(ph, fill, zones, false) + if #rows > worstRows then worstRows, worstTag = #rows, tag end + check(tag .. ": no letterbox row shows through a zone boundary", + #rows == 0) + if #rows > 0 then + U.log(" black rows:", table.concat(rows, ",")) + end + end + end + U.log("worst case:", worstRows, "black rows at", worstTag) + + local holeRows = probeAt(HEIGHTS[3], true, gapped, true) + check("a zone list with a hole in it does show the letterbox clear", + #holeRows > 0) + local filledRows = probeAt(HEIGHTS[3], true, gapped, false) + check("and the same hole drawn through the renderer shows none", + #filledRows == 0) + + -- and the live window at one of those heights, for the eye + local ok = pcall(love.window.setMode, WIDTH, HEIGHTS[3], + { resizable = true, vsync = 0 }) + check("the window resized to " .. WIDTH .. "x" .. HEIGHTS[3], ok) + U.wait(20) + check("wrote " .. SHOT_DIR .. "/bug1866_title_907.png", + U.shot(game, SHOT_DIR .. "/bug1866_title_907.png")) + pcall(love.window.setMode, WIDTH, HEIGHTS[4], { resizable = true, vsync = 0 }) + U.wait(20) + check("wrote " .. SHOT_DIR .. "/bug1866_title_1013.png", + U.shot(game, SHOT_DIR .. "/bug1866_title_1013.png")) + + PaletteFX.setMode(mode) + U.wait(10) + + U.log("Look along the title picture where the POKeMON logo meets the red") + U.log("VERSION ribbon, and where the ribbon meets RED and the copyright: the") + U.log("off-white background must run through both boundaries unbroken.") + U.log("The near-miss is a hairline one or two pixels tall that is dark grey") + U.log("or slightly off-white rather than black; that is still the seam.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/vitamin_bug1805_test.lua b/tests/drivers/vitamin_bug1805_test.lua new file mode 100644 index 00000000..f9481ab0 --- /dev/null +++ b/tests/drivers/vitamin_bug1805_test.lua @@ -0,0 +1,78 @@ +-- Vitamins (#1805): pokered's .useVitamin plays SFX_HEAL_AILMENT, then +-- prints VitaminStatRoseText over the still-drawn party menu and ends at +-- RemoveUsedItem, which only strips the item from the bag +-- (engine/items/item_effects.asm:1313-1322, :2274). +-- POKEPORT_DRIVER=tests/drivers/vitamin_bug1805_test.lua POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local Bag = require("src.inventory.Bag") + local Sound = require("src.core.Sound") + local PartyMenu = require("src.ui.PartyMenu") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + if (game.save.options and game.save.options.sfxVol or 1) == 0 then + U.log("WARNING: sfxVol is 0, so nothing here can be heard.") + U.log("WARNING: set SFX volume in OPTION before judging this driver.") + end + + local mon = Pokemon.new(game.data, "NIDORINO", 20) + game.save.party = { mon } + game.save.player.name = "bryan" + game.save.inventory = {} + Bag.add(game.save, "HP_UP", 3, game.data) + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + local heard = {} + local realPlay = Sound.play + Sound.play = function(data, name, ...) + heard[#heard + 1] = name + return realPlay(data, name, ...) + end + + -- bag -> HP UP -> USE -> the only party member + Screens.push(game, "BagMenu", {}) + U.wait(12) + U.tap(game, "a") + U.wait(12) + U.tap(game, "a") + U.wait(12) + U.tap(game, "a") + U.wait(30) + + Sound.play = realPlay + check("the vitamin was consumed", (game.save.inventory.HP_UP or 0) == 2) + check("HP stat exp went up", (mon.statExp and mon.statExp.hp or 0) == 2560) + U.log("sounds heard:", table.concat(heard, " ")) + local jingle = false + for _, name in ipairs(heard) do + if name == "Heal_Ailment" then jingle = true end + end + check("the stat line carried SFX_HEAL_AILMENT", jingle) + + local partyUp = false + for _, s in ipairs(game.stack.states or {}) do + if getmetatable(s) == PartyMenu then partyUp = true end + end + check("the party menu is still drawn under the message", partyUp) + + U.shot(game, SHOT_DIR .. "/bug1805_vitamin.png") + U.log("captured", SHOT_DIR .. "/bug1805_vitamin.png") + + U.log("NIDORINO's HEALTH rose just now: the short cure jingle should sound") + U.log("as that line types, with the party list still on screen behind the") + U.log("box, and only then does the menu come down. The bug was silence") + U.log("plus the list vanishing to the map the instant A was pressed.") + U.log("Two HP UPs are left in the bag to try it again.") + + while true do + coroutine.yield() + end +end diff --git a/tests/engine/gen2_ball_anim_id_bug1804.lua b/tests/engine/gen2_ball_anim_id_bug1804.lua new file mode 100644 index 00000000..6a124fe2 --- /dev/null +++ b/tests/engine/gen2_ball_anim_id_bug1804.lua @@ -0,0 +1,41 @@ +-- data/moves/animations.asm:401-403 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local UI = require("src.ui.gen2.BattleState") +local AnimRunner = require("src.battle.gen2.AnimRunner") + +local runner = AnimRunner.new({ animId = "ANIM_THROW_POKE_BALL", battleTurn = 0 }) +T.eq(runner.animId, "ANIM_THROW_POKE_BALL", + "the runner exposes the anim id BattleState latches on") +T.eq(runner.env.animId, "ANIM_THROW_POKE_BALL", + "the object/bg env still sees the same id") + +-- A real runner skipped with B must still hide the caught mon. +do + local s = setmetatable({ + anim = runner, + ballThrow = { caught = true }, + picHidden = { player = false, enemy = false }, + }, { __index = UI }) + local input = { wasPressed = function(_, key) return key == "b" end } + s:stepAnim(input) + T.eq(s.picHidden.enemy, true, "a B-skipped catch latches with a real runner") +end + +do + local free = AnimRunner.new({ animId = "ANIM_THROW_POKE_BALL", battleTurn = 0 }) + local s = setmetatable({ + anim = free, + ballThrow = { caught = false }, + picHidden = { player = false, enemy = false }, + }, { __index = UI }) + local input = { wasPressed = function(_, key) return key == "b" end } + s:stepAnim(input) + T.eq(s.picHidden.enemy, false, "a B-skipped break-free still does not latch") +end + +T.finish("gen2 ball anim id bug 1804") diff --git a/tests/engine/gen2_front_anim_mod_sheet_bug1827.lua b/tests/engine/gen2_front_anim_mod_sheet_bug1827.lua new file mode 100644 index 00000000..85c020c8 --- /dev/null +++ b/tests/engine/gen2_front_anim_mod_sheet_bug1827.lua @@ -0,0 +1,79 @@ +-- ../pokecrystal/engine/gfx/load_pics.asm:105-158 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local Assets = require("src.render.Assets") +local UI = require("src.ui.gen2.BattleState") + +local FRONT = "assets/generated/battle/front/cyndaquil.png" +local SHEET = "assets/generated/battle/anim/cyndaquil.png" +local MOD_FRONT = "mods/skin/overrides/battle/front/cyndaquil.png" +local MOD_SHEET = "mods/skin/overrides/battle/anim/cyndaquil.png" + +local image = { getDimensions = function() return 40, 200 end } +Assets.image = function() return image end + +local overrides = {} +Assets.resolve = function(path) return overrides[path] or path end + +local mon = { species = "CYNDAQUIL" } +local anim = { sheet = SHEET, tiles = 5, play = { { 1 } } } + +local function newSelf(picPath) + local self = setmetatable({ + pokemon = { CYNDAQUIL = { spriteFront = FRONT, anim = anim } }, + picCache = {}, + }, { __index = UI }) + if picPath then + self.pic = function() return image, false, picPath end + end + return self +end + +-- Stock cache, no mod: the sheet still animates. +do + overrides = {} + local s = newSelf() + s:startFrontAnim(mon) + T.check(s.frontAnim ~= nil, "an unmodded Crystal front pic still animates") + T.eq(s.frontAnim and s.frontAnim.sheet, image, "off the extracted sheet") +end + +-- Gold and Silver have no anim row at all and never reach any of this. +do + overrides = {} + local s = newSelf() + s.pokemon.CYNDAQUIL.anim = nil + s:startFrontAnim(mon) + T.eq(s.frontAnim, nil, "a cache with no anim row is untouched") + s.pokemon.CYNDAQUIL.anim = anim +end + +-- The bug: the static pic replaced, the sheet left stock. Holding the static +-- pic for the whole scene is the only way not to flicker stock art over it. +do + overrides = { [FRONT] = MOD_FRONT } + local s = newSelf() + s:startFrontAnim(mon) + T.eq(s.frontAnim, nil, "an overridden front pic suppresses the stock frames") +end + +do + overrides = {} + local s = newSelf(MOD_FRONT) + s:startFrontAnim(mon) + T.eq(s.frontAnim, nil, "a pokemon.sprite path also suppresses them") +end + +-- A mod that ships both halves keeps its animation. +do + overrides = { [FRONT] = MOD_FRONT, [SHEET] = MOD_SHEET } + local s = newSelf() + s:startFrontAnim(mon) + T.check(s.frontAnim ~= nil, "a mod that ships a sheet as well still animates") +end + +T.finish("gen2 front anim mod sheet bug 1827") diff --git a/tests/engine/gen2_stats_text_tint_bug1858.lua b/tests/engine/gen2_stats_text_tint_bug1858.lua new file mode 100644 index 00000000..92879813 --- /dev/null +++ b/tests/engine/gen2_stats_text_tint_bug1858.lua @@ -0,0 +1,72 @@ +-- engine/gfx/color.asm:342-359 / engine/gfx/cgb_layouts.asm:167-192 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +local Chrome = require("src.ui.gen2.Chrome") +local SummaryMenu = require("src.ui.gen2.SummaryMenu") + +local printed, through +local realPrint, realThrough = Chrome.print, Chrome.printThrough +Chrome.print = function(text, tx, ty) + printed[#printed + 1] = { text = text, x = tx, y = ty } + return 0 +end +Chrome.printThrough = function(text, tx, ty, palette) + through[#through + 1] = { text = text, x = tx, y = ty, palette = palette } + return 0 +end + +local function fake(page, placements) + local self = setmetatable({ page = page, mon = {}, party = {} }, + { __index = SummaryMenu }) + self.greenPlacements = function() return placements end + self.bluePlacements = function() return placements end + self.pinkPlacements = function() return placements end + self.drawVerticalDivider = function() end + return self +end + +local rows = { + { text = "ITEM", x = 0, y = 8 }, + { text = "MOVE", x = 0, y = 10 }, +} + +for _, page in ipairs({ SummaryMenu.PINK_PAGE, SummaryMenu.GREEN_PAGE, + SummaryMenu.BLUE_PAGE }) do + local self = fake(page, rows) + printed, through = {}, {} + if page == SummaryMenu.GREEN_PAGE then + self:drawGreenPage() + elseif page == SummaryMenu.BLUE_PAGE then + self:drawBluePage() + else + self:drawPlacements(self:pinkPlacements(), self:lowerColors()) + end + T.eq(#printed, 0, "no lower-half string prints on the white default paper") + T.eq(#through, #rows, "every lower-half string prints through a palette") + local tint = SummaryMenu.PAGE_TINTS[page] + for _, call in ipairs(through) do + T.eq(call.palette and call.palette[1][1], tint[1], "paper red is the page tint") + T.eq(call.palette and call.palette[1][2], tint[2], "paper green is the page tint") + T.eq(call.palette and call.palette[1][3], tint[3], "paper blue is the page tint") + T.eq(call.palette and call.palette[4][1], 0, "ink stays black") + end +end + +-- Rows 0-7 are filled with palette $1, whose colour 0 is white, so the upper +-- half keeps the default paper. +do + local self = fake(SummaryMenu.PINK_PAGE, rows) + printed, through = {}, {} + self:drawPlacements(rows) + T.eq(#printed, #rows, "upper-half strings still take the default paper") + T.eq(#through, 0, "upper-half strings do not force a page tint") +end + +Chrome.print, Chrome.printThrough = realPrint, realThrough + +T.finish("gen2 stats text tint bug 1858") diff --git a/tests/engine/gen2_whirlpool_block_after_sfx_bug1862.lua b/tests/engine/gen2_whirlpool_block_after_sfx_bug1862.lua new file mode 100644 index 00000000..ac470270 --- /dev/null +++ b/tests/engine/gen2_whirlpool_block_after_sfx_bug1862.lua @@ -0,0 +1,56 @@ +-- engine/events/overworld.asm:1150-1165, engine/events/field_moves.asm:5-10 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local Sound = require("src.core.Sound") +local World = require("src.world.gen2.World") + +local busy = false +Sound.sfxBusy = function() return busy end + +local log = {} +local world = setmetatable({}, { __index = World }) +world.setNickname = function() end +world.showText = function(_, _, done) if done then done() end end +world.playSfxNamed = function() log[#log + 1] = "sfx" end +world.replaceBlock = function(_, index, blockId) + log[#log + 1] = "block:" .. tostring(index) .. ":" .. tostring(blockId) + return true +end + +world:runWhirlpool({ text = "USED WHIRLPOOL", blockIndex = 7, replacement = 3 }) +T.eq(#log, 0, "the box closing does not swap the block yet") +T.eq(world.fieldMove and world.fieldMove.phase, "whirlpoolsfx", + "closing the box parks the WaitSFX phase") + +busy = false +world:updateFieldMove() +T.same(log, { "sfx" }, "the first WaitSFX passes and SFX_SURF starts") + +busy = true +world:updateFieldMove() +world:updateFieldMove() +T.same(log, { "sfx" }, "the whirlpool is still on screen while the sfx plays") + +busy = false +world:updateFieldMove() +T.same(log, { "sfx", "block:7:3" }, + "the block is swapped once the second WaitSFX clears") +T.eq(world.fieldMove, nil, "and the phase is done") + +-- The callback path (DisappearWhirlpool via callasm) carries no block, and +-- must not invent one. +do + log = {} + world.fieldMove = nil + world:playWhirlpoolSound() + busy = false + world:updateFieldMove() + world:updateFieldMove() + T.same(log, { "sfx" }, "a bare sound call swaps nothing") +end + +T.finish("gen2 whirlpool block after sfx bug 1862") diff --git a/tests/engine/link_switch_withdraw_bug1562.lua b/tests/engine/link_switch_withdraw_bug1562.lua new file mode 100644 index 00000000..690c6194 --- /dev/null +++ b/tests/engine/link_switch_withdraw_bug1562.lua @@ -0,0 +1,105 @@ +-- A link switch runs SwitchPlayerMon on the switcher and SwitchEnemyMon on +-- the peer (core.asm:2418-2422, trainer_ai.asm:596-599) (#1562). +-- luajit tests/run_engine.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Link = require("tests.modkit.link") +local Net = require("src.link.Net") +local Protocol = require("src.link.Protocol") +local LinkBattle = require("src.link.LinkBattle") + +math.randomseed(4242) +local Input = Link.prepare(Data) + +local gameA = Link.fakeGame(Data, { "FIXMON_A", "FIXMON_B" }, { name = "RED" }) +local gameB = Link.fakeGame(Data, { "FIXMON_A", "FIXMON_B" }, { name = "BLUE" }) + +local netA, netB = Net.loopbackPair() +local packedA = Protocol.packParty(gameA.save.party) +local packedB = Protocol.packParty(gameB.save.party) +local battleA = LinkBattle.newHost(gameA, netA, { + myParty = packedA, theirParty = packedB, + theirName = "BLUE", seed = 987654321, +}) +local battleB = LinkBattle.newGuest(gameB, netB, { + myParty = packedB, theirParty = packedA, + theirName = "RED", seed = 987654321, +}) + +local function watch(battle) + local seen = {} + local sayNext, sayNextAuto = battle.sayNext, battle.sayNextAuto + battle.sayNext = function(s, text) seen[#seen + 1] = text; return sayNext(s, text) end + battle.sayNextAuto = function(s, text, d) + seen[#seen + 1] = text + return sayNextAuto(s, text, d) + end + return seen +end +local seenA, seenB = watch(battleA), watch(battleB) + +local function count(list, needle) + local n = 0 + for _, text in ipairs(list) do + if text and text:find(needle, 1, true) then n = n + 1 end + end + return n +end +local function firstIndex(list, needle) + for i, text in ipairs(list) do + if text and text:find(needle, 1, true) then return i end + end +end + +local resA, resB +battleA.onFinish = function(r) resA = r end +battleB.onFinish = function(r) resB = r end +gameA.stack:push(battleA) +gameB.stack:push(battleB) + +local switched, retreatSeen = false, false +for _ = 1, 60000 do + if resA and resB then break end + Input.pressed = { a = true } + if not switched and battleA.phase == "menu" and battleA.playerParty[2] then + battleA:resolveSwitch(battleA.playerParty[2]) + switched = true + end + for _, g in ipairs({ gameA, gameB }) do + local top = g.stack:top() + if top and top.forceSwitch and top.party then + for i, mon in ipairs(top.party) do + if mon.hp > 0 then top.index = i break end + end + end + g.stack:update(1 / 60) + end + if battleA.shrinkOut then retreatSeen = true end +end + +T.check(switched, "the host got a menu phase to switch from") +T.eq(count(seenA, "Come back!"), 1, + "RetreatMon prints once, for the voluntary switch only") +T.check(retreatSeen, "AnimateRetreatingPlayerMon runs on the switcher") +T.eq(count(seenB, "drew"), 1, "the peer prints AIBattleWithdrawText once") + +local withdrawA = firstIndex(seenA, "Come back!") +local sendA = firstIndex(seenA, "Go! ") or firstIndex(seenA, "Do it! ") + or firstIndex(seenA, "Get'm! ") +T.check(sendA ~= nil and withdrawA < sendA, "the recall line comes before the send-out") +local withdrawB = firstIndex(seenB, "drew") +local sendB = firstIndex(seenB, "sent\nout") +T.check(sendB ~= nil and withdrawB < sendB, "and so does the peer's withdraw line") + +T.check(battleA.localHashes ~= nil and battleB.localHashes ~= nil, + "both sides still record per-turn hashes") +local mismatch +for turn, hash in pairs(battleA.localHashes) do + local other = battleB.localHashes[turn] + if other and other ~= hash then mismatch = mismatch or turn end +end +T.check(mismatch == nil, "the added lines leave the lockstep state hash alone") + +T.finish("link switch prints the recall lines (#1562)") diff --git a/tests/engine/party_fieldmove_order_bug792.lua b/tests/engine/party_fieldmove_order_bug792.lua index a134df38..747fda02 100644 --- a/tests/engine/party_fieldmove_order_bug792.lua +++ b/tests/engine/party_fieldmove_order_bug792.lua @@ -5,7 +5,7 @@ -- GetMonFieldMoves (engine/menus/text_box.asm, called from -- start_sub_menus.asm) walks wPartyMon1Moves in slot order -- so a mon -- with STRENGTH in slot 3 and SURF in slot 4 shows STRENGTH then SURF on --- top, with STATS/SWITCH closing the list. The port used to build the +-- top, with STATS/SWITCH/CANCEL closing the list. The port used to build the -- submenu as STATS/SWITCH first and tack the field moves on the bottom. -- ROM-free: drives the real PartyMenu over stub game state. -- luajit tests/engine/party_fieldmove_order_bug792.lua @@ -74,7 +74,8 @@ local pm = PartyMenu.new(game, {}) game.stack:push(pm) openSubmenu(pm) check(pm.submenu, "A on a party mon opens the submenu") -same(actions(pm.subItems), { "strength", "surf", "stats", "switch" }, +same(actions(pm.subItems), + { "strength", "surf", "stats", "switch", "cancel" }, "field moves sit above STATS/SWITCH in move-list order (#792)") eq(pm.subItems[1].label, "STRENGTH", "slot 3's STRENGTH leads the submenu") eq(pm.subItems[2].label, "SURF", "slot 4's SURF follows it") @@ -84,8 +85,8 @@ local plain = newGame({ { id = "TACKLE", pp = 35 } }) local pm2 = PartyMenu.new(plain, {}) plain.stack:push(pm2) openSubmenu(pm2) -same(actions(pm2.subItems), { "stats", "switch" }, - "no field moves: the submenu is just STATS/SWITCH") +same(actions(pm2.subItems), { "stats", "switch", "cancel" }, + "no field moves: the submenu is just PokemonMenuEntries") -- GetMonFieldMoves is badge-blind: the same Lapras without the badges -- still lists both HMs, and .outOfBattleMovePointers refuses on @@ -95,7 +96,8 @@ local noBadges = newGame( local pm3 = PartyMenu.new(noBadges, {}) noBadges.stack:push(pm3) openSubmenu(pm3) -same(actions(pm3.subItems), { "strength", "surf", "stats", "switch" }, +same(actions(pm3.subItems), + { "strength", "surf", "stats", "switch", "cancel" }, "the HM moves are listed without the badges (#1022)") T.finish("party_fieldmove_order_bug792") diff --git a/tests/engine/party_submenu_cancel_bug1833.lua b/tests/engine/party_submenu_cancel_bug1833.lua new file mode 100644 index 00000000..b4b24f1b --- /dev/null +++ b/tests/engine/party_submenu_cancel_bug1833.lua @@ -0,0 +1,98 @@ +-- Out of battle the per-mon submenu ends STATS/SWITCH/CANCEL: pokered +-- prints one string, PokemonMenuEntries (engine/menus/text_box.asm:504-507), +-- under whatever field moves the mon has, and start_sub_menus.asm:71-75 +-- treats the last row as a real selection that leaves the party menu +-- (.exitMenu, start_sub_menus.asm:26-30) where B only returns to the list. +-- The port stopped at SWITCH (#1833). ROM-free: drives the real PartyMenu +-- over stub game state. +-- luajit tests/engine/party_submenu_cancel_bug1833.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq, same = T.check, T.eq, T.same +love = love or require("tests.love_stub") + +local PartyMenu = require("src.ui.PartyMenu") + +local function newGame(moves) + local game = { + data = { pokemon = { LAPRAS = { name = "LAPRAS" } } }, + save = { + party = { { species = "LAPRAS", hp = 50, stats = { hp = 50 }, + level = 30, moves = moves } }, + inventory = {}, options = {}, flags = {}, + }, + overworld = { map = { def = { tileset = "OVERWORLD" }, + id = "PALLET_TOWN" }, + dark = false }, + } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function() return false end, + } + return game +end + +local function press(pm, btn) + pm.game.input.queue = { [btn] = true } + pm:update(1 / 60) + pm.game.input.queue = {} +end + +local function actions(items) + local out = {} + for i, item in ipairs(items or {}) do out[i] = item.action end + return out +end + +local game = newGame({ { id = "TACKLE", pp = 35 } }) +local cancelled = false +local pm = PartyMenu.new(game, { onCancel = function() cancelled = true end }) +game.stack:push(pm) +press(pm, "a") +check(pm.submenu, "A on a party mon opens the submenu") +same(actions(pm.subItems), { "stats", "switch", "cancel" }, + "the plain list is PokemonMenuEntries in full (#1833)") +eq(pm.subItems[3].label, "CANCEL", "CANCEL closes the list") + +-- CANCEL sits at the bottom, so one wrap upward reaches it +press(pm, "up") +eq(pm.subIndex, 3, "up from STATS wraps onto CANCEL") +press(pm, "a") +eq(#game.stack.states, 0, "choosing CANCEL leaves the party menu (.exitMenu)") +check(cancelled, "and reports the cancel to the caller") + +-- B out of the submenu is the other path: back to the party list, menu up +local game2 = newGame({ { id = "TACKLE", pp = 35 } }) +local pm2 = PartyMenu.new(game2, {}) +game2.stack:push(pm2) +press(pm2, "a") +press(pm2, "b") +check(not pm2.submenu, "B closes the submenu") +eq(#game2.stack.states, 1, "B keeps the party menu up (start_sub_menus.asm:68-69)") + +-- field moves still lead, with the three fixed rows under them +local game3 = newGame({ { id = "STRENGTH", pp = 15 }, { id = "SURF", pp = 15 } }) +local pm3 = PartyMenu.new(game3, {}) +game3.stack:push(pm3) +press(pm3, "a") +same(actions(pm3.subItems), { "strength", "surf", "stats", "switch", "cancel" }, + "field moves sit above the whole of PokemonMenuEntries") + +-- the in-battle list is its own three-row template and is unchanged +local game4 = newGame({ { id = "SURF", pp = 15 } }) +local pm4 = PartyMenu.new(game4, { battle = {}, onSwitch = function() end }) +game4.stack:push(pm4) +press(pm4, "a") +same(actions(pm4.subItems), { "battle_switch", "stats", "cancel" }, + "battle keeps SwitchStatsCancelText's order (data/text_boxes.asm:33)") + +T.finish() diff --git a/tests/engine/pokedex_contents_bug1829.lua b/tests/engine/pokedex_contents_bug1829.lua new file mode 100644 index 00000000..05c7b5bb --- /dev/null +++ b/tests/engine/pokedex_contents_bug1829.lua @@ -0,0 +1,127 @@ +-- The Pokédex list was a generic ListMenu with a title and a footer instead +-- of the cart's CONTENTS screen, and Left/Right dragged the cursor with the +-- page (#1829). +-- engine/menus/pokedex.asm:161-199 (furniture), :242-273 (rows), :314-342 +-- (Left/Right move wListScrollOffset only), :208-222 (wDexMaxSeenMon). +-- luajit tests/engine/pokedex_contents_bug1829.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +-- stub Font: the screen draws through Font.draw/Font.drawCode only, and the +-- real Font needs loaded page images this suite has no reason to touch. +local calls = {} +package.loaded["src.render.Font"] = { + draw = function(text, x, y) calls[#calls + 1] = { text = text, x = x, y = y } end, + drawCode = function(code, x, y) calls[#calls + 1] = { code = code, x = x, y = y } end, + width = function(text) return #text * 8 end, + split = function(text) + local out = {} + for i = 1, #text do out[i] = { from = i, to = i } end + return out + end, +} + +local PokedexMenu = require("src.ui.PokedexMenu") +local Theme = require("src.ui.Theme") + +local function hasText(text, x, y) + for _, c in ipairs(calls) do + if c.text == text and c.x == x and c.y == y then return true end + end + return false +end +local function hasCode(code, x, y) + for _, c in ipairs(calls) do + if c.code == code and c.x == x and c.y == y then return true end + end + return false +end + +local DEX_SIZE = 151 +local data = { + pokemon = {}, + constants = { dexSize = DEX_SIZE, dexDigits = 3 }, +} +for n = 1, DEX_SIZE do + local id = ("DEXMON_%03d"):format(n) + data.pokemon[id] = { id = id, name = ("MON%03d"):format(n), dex = n } +end + +-- seen 1..20, owned 1..3, so the list stops at 20 and row 2 carries a ball +local function newGame() + local save = { pokedex = { seen = {}, owned = {} } } + for n = 1, 20 do save.pokedex.seen[("DEXMON_%03d"):format(n)] = true end + for n = 1, 3 do save.pokedex.owned[("DEXMON_%03d"):format(n)] = true end + return { data = data, save = save, + stack = { push = function() end, pop = function() end, + top = function() end } } +end + +local dex = PokedexMenu.new(newGame(), {}) + +-- ------------------------------------------------ wDexMaxSeenMon caps it +eq(#dex.items, 20, "the list stops at the highest seen number, not at 151") +eq(dex.items[20].name, "MON020", "the last row is the highest seen species") + +-- ------------------------------------------------ Left / Right scroll only +dex.index, dex.scroll = 3, 0 +dex:pageScroll(1) +eq(dex.scroll, 7, "Right pages the scroll offset down seven rows") +eq(dex.index - dex.scroll, 3, "the cursor keeps the screen row it was on") +dex:pageScroll(1) +eq(dex.scroll, 13, "the second page clamps to wDexMaxSeenMon - 7") +eq(dex.index - dex.scroll, 3, "the clamped page still keeps the cursor row") +dex:pageScroll(-1) +eq(dex.scroll, 6, "Left pages back seven rows") +eq(dex.index - dex.scroll, 3, "Left keeps the cursor row too") +dex:pageScroll(-1) +eq(dex.scroll, 0, "Left stops at the top of the list") +eq(dex.index, 3, "the cursor is back where it started") + +-- the witness for the bug: a whole-page jump used to move the cursor to the +-- bottom row, which is what a page jump on any other list does +check(dex.index - dex.scroll ~= 7, + "the cursor did not slide to the last screen row (the ListMenu bug)") + +-- ------------------------------------------------------------- the screen +dex.index, dex.scroll = 1, 0 +calls = {} +dex:draw() +check(hasText("CONTENTS", 8, 8), "CONTENTS at hlcoord 1,1, not a POKéDEX title") +check(hasText("SEEN", 128, 16), "SEEN label at hlcoord 16,2") +check(hasText("20", 136, 24), "the seen count right-aligned in its 3-tile field") +check(hasText("OWN", 128, 40), "OWN label at hlcoord 16,5") +check(hasText("3", 144, 48), "the owned count right-aligned under it") +check(not hasText("SEEN 20 OWN 3", 8, 136), + "the counts are not a bottom footer line any more") +check(hasText("DATA", 128, 80) and hasText("CRY", 128, 96) + and hasText("AREA", 128, 112) and hasText("QUIT", 128, 128), + "DATA/CRY/AREA/QUIT are permanent furniture at hlcoord 16,10") + +check(hasText("001", 8, 16), "row 1's number sits one row above its name") +check(hasText("MON001", 32, 24), "row 1's name is at column 4") +check(hasCode(Theme.cursor, 0, 24), "the cursor is at column 0 on row 3") +check(hasText("007", 8, 112) and hasText("MON007", 32, 120), + "seven rows, two tiles apart, end on row 15") +check(not hasText("MON008", 32, 136), "there is no eighth row") + +-- the hollow ▷ pokered leaves under the side menu (PlaceUnfilledArrowMenuCursor) +dex.hollowIndex = 1 +calls = {} +dex:draw() +check(hasCode(Theme.cursorHollow, 0, 24), + "the chosen row keeps a hollow cursor while the side menu is open") + +-- ------------------------------------------------------ unseen dashed rows +local partial = newGame() +partial.save.pokedex.seen.DEXMON_005 = nil +local gapped = PokedexMenu.new(partial, {}) +eq(gapped.items[5].name, "----------", + "an unseen number inside the range prints the dashed line") +check(gapped.items[5].value == nil, "and cannot be chosen") + +T.finish("pokedex contents bug 1829") diff --git a/tests/engine/rare_candy_bag_open_bug796.lua b/tests/engine/rare_candy_bag_open_bug796.lua index 37646a11..f0963325 100644 --- a/tests/engine/rare_candy_bag_open_bug796.lua +++ b/tests/engine/rare_candy_bag_open_bug796.lua @@ -23,7 +23,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.harness") -local check, eq = T.check, T.eq +local check, eq, same = T.check, T.eq, T.same love = love or require("tests.love_stub") -- Lazily-required inside the use branches, so seeding package.loaded before @@ -34,8 +34,15 @@ package.loaded["src.core.Sound"] = { } -- Real TextBoxes want a Font atlas. This flow only cares that a message -- opened, what it says, and what its onDone does. +local jingles = {} package.loaded["src.render.TextBox"] = { new = function(_, text, done) return { textBox = true, text = text, done = done } end, + soundOpts = function(_, sound, opts) + jingles[#jingles + 1] = sound + opts = opts or {} + opts.auto = { sound = sound, wait = true } + return opts + end, } -- BagMenu and PartyMenu bind TextBox at require time, so they load against -- the stub; Screens caches its factory per id and must be told to forget. @@ -133,6 +140,7 @@ end -- The bug: three candies in the bag, use one in the field. do local game, mon = freshGame(3) + jingles = {} local list, why = useFromBag(game, nil, "RARE_CANDY") if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then eq(mon.level, 6, "the candy leveled the mon 5 -> 6") @@ -156,6 +164,10 @@ do check(box.text:find("level 6", 1, true) ~= nil, "and it names the new level: " .. tostring(box.text)) end + -- RareCandyText: text_far, sound_get_item_1, text_promptbutton + -- (engine/menus/party_menu.asm:289-293) + same(jingles, { "Get_Item1" }, + "the level-up line carries sound_get_item_1 and nothing else") end end diff --git a/tests/engine/slot_machine_boxes_bug1811.lua b/tests/engine/slot_machine_boxes_bug1811.lua new file mode 100644 index 00000000..4fbb7503 --- /dev/null +++ b/tests/engine/slot_machine_boxes_bug1811.lua @@ -0,0 +1,100 @@ +-- The slot machine's menus sit where the original draws them (#1811). +-- +-- The bet menu is `hlcoord 14,11 / b=5 / c=4 / TextBoxBorder` +-- (engine/slots/slot_machine.asm:83-86), i.e. a 6x7 box whose bottom edge is +-- the last row of the screen, with the multipliers at hlcoord 16,12 and the +-- cursor at wTopMenuItemX 15 (asm:77-78, :87). "One more go?" is a +-- TWO_OPTION_MENU at hlcoord 14,12 (asm:136-138) over the YES_NO_MENU body +-- (width 4, height 3 -> a 6x5 box; data/yes_no_menu_strings.asm:10). +-- The "Want to play?" prompt is not this screen's at all: PromptUserToPlaySlots +-- asks it before LoadSlotMachineTiles (asm:9-23), so the screen opens on the +-- bet menu. +-- luajit tests/engine/slot_machine_boxes_bug1811.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +-- Font wants a real atlas and this suite is about which box lands on which +-- row, so it records the calls instead (tests/engine/bag_item_box_bug1521.lua's +-- shape) +local calls = {} +package.loaded["src.render.Font"] = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end, + drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end, + drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end, + width = function(text) return #tostring(text) * 8 end, +} +package.loaded["src.core.Sound"] = { play = function() end } +package.loaded["src.ui.SlotMachine"] = nil +local SlotMachine = require("src.ui.SlotMachine") + +local game = { + data = { field = { slotWheels = {} }, text = {} }, + save = { coins = 100 }, +} + +local function record(fn) + calls = {} + fn() + return calls +end + +local function box(list, tx, ty) + for _, c in ipairs(list) do + if c[1] == "box" and c[2] == tx and c[3] == ty then return c end + end +end + +local function drawn(list, text) + for _, c in ipairs(list) do + if c[1] == "draw" and c[2] == text then return c end + end +end + +local function cursor(list) + for _, c in ipairs(list) do + if c[1] == "code" and c[2] == 0xED then return c end + end +end + +local slots = SlotMachine.new(game, false) +eq(slots.stage, "bet", "the screen opens on the bet menu, not a prompt") + +local bet = record(function() slots:drawBottom() end) +local betBox = box(bet, 14, 11) +check(betBox ~= nil, "the bet menu box starts at (14,11)") +eq(betBox and betBox[4], 6, "it is 6 tiles wide (c = 4)") +eq(betBox and betBox[5], 7, "and 7 tall (b = 5), down to the last row") +eq(betBox and (betBox[3] + betBox[5]), 18, "so its bottom edge is the screen bottom") +local x3 = drawn(bet, "×3") +eq(x3 and x3[3], 16 * 8, "the multipliers print at column 16") +eq(x3 and x3[4], 12 * 8, "on row 12") +local betCursor = cursor(bet) +eq(betCursor and betCursor[3], 15 * 8, "the cursor sits at column 15") +eq(betCursor and betCursor[4], 12 * 8, "on the ×3 row by default") + +slots.stage = "onemore" +slots.yesno = 1 +local more = record(function() slots:drawBottom() end) +local moreBox = box(more, 14, 12) +check(moreBox ~= nil, "the One more go? menu box starts at (14,12)") +eq(moreBox and moreBox[4], 6, "it is 6 tiles wide") +eq(moreBox and moreBox[5], 5, "and 5 tall (YES_NO_MENU 4x3)") +check(box(more, 13, 12) == nil, "and not one column left of that") +local yes, no = drawn(more, "YES"), drawn(more, "NO") +eq(yes and yes[3], 16 * 8, "YES prints at column 16") +eq(yes and yes[4], 13 * 8, "on row 13") +eq(no and no[4], 14 * 8, "NO one row below it") +local moreCursor = cursor(more) +eq(moreCursor and moreCursor[3], 15 * 8, "its cursor sits at column 15") +eq(moreCursor and moreCursor[4], 13 * 8, "starting on YES") + +slots.yesno = 2 +local onNo = record(function() slots:drawBottom() end) +eq(cursor(onNo) and cursor(onNo)[4], 14 * 8, "and moves to the NO row") + +T.finish() diff --git a/tests/engine/start_menu_cursor_bug1821.lua b/tests/engine/start_menu_cursor_bug1821.lua new file mode 100644 index 00000000..88c97e84 --- /dev/null +++ b/tests/engine/start_menu_cursor_bug1821.lua @@ -0,0 +1,52 @@ +-- The START cursor is unsaved WRAM (ram/wram.asm:238-242) and lies outside +-- sGameData (ram/sram.asm:17-21), so it must not ride the save file (#1821). +-- luajit tests/run_engine.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) +local SaveData = require("src.core.SaveData") +local StartMenu = require("src.ui.StartMenu") + +local function stubGame(save) + local stack = { states = {} } + function stack:push(s) self.states[#self.states + 1] = s end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return { + data = Data, save = save, stack = stack, + input = { wasPressed = function() return false end, + isDown = function() return false end }, + } +end + +do + local save = SaveData.newGame() + local game = stubGame(save) + local menu = StartMenu.new(game) + menu.index = math.min(3, #menu.items) + menu:update(1 / 60) + T.eq(game.startMenuIndex, menu.index, "the cursor slot lands on the game") + T.check(save.startMenuIndex == nil, "and never on the save table") + + local reopened = StartMenu.new(stubGame(save)) + T.eq(reopened.index, 1, "a fresh session opens on the top row") + + local same = StartMenu.new(game) + T.eq(same.index, menu.index, "the same session reopens where it was") +end + +do + local save = SaveData.newGame() + save.startMenuIndex = 5 + local encoded = SaveData.encodeForTest and SaveData.encodeForTest(save) or nil + T.check(encoded == nil or encoded:find("startMenuIndex") == nil, + "no serializer writes the cursor back out") + local game = stubGame(save) + local menu = StartMenu.new(game) + T.eq(menu.index, 1, + "a legacy save file's stored slot no longer reopens the menu on it") +end + +T.finish("START menu cursor is session state (#1821)") diff --git a/tests/engine/textbox_pause_bug1818.lua b/tests/engine/textbox_pause_bug1818.lua new file mode 100644 index 00000000..060d291e --- /dev/null +++ b/tests/engine/textbox_pause_bug1818.lua @@ -0,0 +1,113 @@ +-- The forget-a-move text is not one uninterrupted string: pokered's +-- OneTwoAndText holds the box with text_pause, plays SFX_SWAP, prints +-- " Poof!" and holds it again before the paragraph break +-- (engine/pokemon/learn_move.asm:208-222, home/text.asm:492-504). The port +-- typed all of it straight through in silence (#1818). TextBox.PAUSE now +-- carries those waits inside the string, with opts.pauseSounds naming the +-- sfx each one fires. ROM-free. +-- luajit tests/engine/textbox_pause_bug1818.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local TextBox = require("src.render.TextBox") +local MoveLearnMenu = require("src.ui.MoveLearnMenu") +local Sound = require("src.core.Sound") + +local played = {} +local realPlay = Sound.play +Sound.play = function(data, name) + played[#played + 1] = name + return nil +end + +local function newGame() + local game = { + save = { player = {}, options = { textSpeed = "FAST" } }, + data = { text = {}, moves = { MEGA_PUNCH = { name = "MEGA PUNCH" } }, + pokemon = { NIDORINO = { name = "NIDORINO" } } }, + } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function(self, btn) return self.queue[btn] or false end, + } + return game +end + +local game = newGame() +local box = TextBox.new(game, "1, 2 and..." .. TextBox.PAUSE .. " Poof!", + nil, { pauseSounds = { "Swap" } }) + +eq(box.pages[1][1], "1, 2 and... Poof!", + "the marker is stripped out of the line the font ever sees") +check(box.pauseAt and box.pauseAt[1] and box.pauseAt[1][1], + "the pause is re-anchored onto page 1, line 1") +eq(box.pauseAt[1][1][11], 1, "it sits on the 11th glyph, the last of \"1, 2 and...\"") + +-- type up to the marker +local frames = 0 +while not box.pauseFrames and frames < 600 do + box:update(1 / 60) + frames = frames + 1 +end +check(box.pauseFrames, "typing stops at the marker") +eq(box.charIndex, 11, "it stops with \"1, 2 and...\" printed and nothing after it") +eq(#played, 0, "SFX_SWAP has not sounded yet -- the wait comes first") + +local frozen = box.charIndex +for _ = 1, 31 do + box:update(1 / 60) + if #played > 0 then break end + eq(box.charIndex, frozen, "nothing types while the pause runs") +end +eq(played[1], "Swap", "the wait ends with SFX_SWAP (learn_move.asm:210-213)") + +for _ = 1, 600 do + if box.done then break end + box:update(1 / 60) +end +check(box.done, "\" Poof!\" types out after the sound") +eq(box.charIndex, 17, "the whole line printed, marker included in nothing") + +-- a held A/B skips the wait: TextCommand_PAUSE reads hJoyHeld +local held = newGame() +held.input.queue = { a = true } +local box2 = TextBox.new(held, "1, 2 and..." .. TextBox.PAUSE .. " Poof!", + nil, { pauseSounds = { "Swap" } }) +local before = #played +for _ = 1, 40 do + if box2.done then break end + box2:update(1 / 60) +end +check(box2.done, "with A held the box runs straight through the pause") +check(#played > before, "the sound still plays on the skipped wait") + +-- and the real caller wires both waits with the swap on the first +local mlm = MoveLearnMenu.new(game, { species = "NIDORINO", level = 20, + moves = {} }, "MEGA_PUNCH") +mlm.forgot = "HORN ATTACK" +game.stack:push(mlm) +mlm:finish(true) +local learned = game.stack:top() +check(learned.pauseAt, "the learned-move box carries text_pause waits") +eq(learned.pauseSounds[1], "Swap", "the first wait carries SFX_SWAP") +local count = 0 +for _, lines in pairs(learned.pauseAt) do + for _, chars in pairs(lines) do + for _ in pairs(chars) do count = count + 1 end + end +end +eq(count, 2, "both of learn_move.asm's text_pause commands survive") + +Sound.play = realPlay +T.finish() diff --git a/tests/engine/warp_sprite_hidden_bug916.lua b/tests/engine/warp_sprite_hidden_bug916.lua index 0376350b..442d9c0b 100644 --- a/tests/engine/warp_sprite_hidden_bug916.lua +++ b/tests/engine/warp_sprite_hidden_bug916.lua @@ -133,16 +133,18 @@ check(ow.player.spinDrop ~= true and ow.player.spinning == false, check(not playerHidden(ow), "player drawable again after the dig landing") -- ------------------------------------------------------------------ fly --- flap (24) + path1 (36) + hold (40) + path2 (33) = 133 frames of flyAnim, --- then the fade, then the bird swoops in (flyArrive). Same invariant. +-- the StopMusic fade hold (28) then flap (24) + path1 (36) + hold (40) + +-- path2 (33) = 161 frames of flyAnim, then the fade, then the bird swoops +-- in (flyArrive). Same invariant. Data.field.flyWarps.FIX_ROUTE = { x = 4, y = 6 } ow = newOW() ow:flyTo("FIX_ROUTE") st = drive(ow, 260) check(st.warpFrame ~= nil, "fly departure ends and the warp fade begins") --- flap (8*3) + path1 (12*3) + hold (40) + path2 (11*3) = 133 frames; the --- warp fires on frame 133's update, so the fade is on top from loop frame 134 -eq(st.warpFrame, 134, "fly fade begins right after the bird''s exit path") +-- fade hold (4*7) + flap (8*3) + path1 (12*3) + hold (40) + path2 (11*3) +-- = 161 frames; the warp fires on frame 161's update, so the fade is on top +-- from loop frame 162 (player_animations.asm:123 StopMusic) #1840 +eq(st.warpFrame, 162, "fly fade begins right after the bird''s exit path") check(st.fadeFrames > 0, "fly warp fade ran (" .. st.fadeFrames .. " frames)") eq(st.gapFrames, 0, "no fly fade frame leaves the player standing bare (#916)") diff --git a/tests/gen2_world_test.lua b/tests/gen2_world_test.lua index 7de0e454..f009e401 100644 --- a/tests/gen2_world_test.lua +++ b/tests/gen2_world_test.lua @@ -922,8 +922,12 @@ check(poolWorld:useFieldMove("WHIRLPOOL", SPINNER[1]).ok, "whirlpool cleared") runFrames(poolWorld, 1) eq(poolWorld.log[1], T.USE_WHIRLPOOL, "UseWhirlpoolText") advanceText(poolWorld) +-- engine/events/overworld.asm:1142-1164 +eq(poolWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x07, + "the whirlpool block is still on screen while the sfx plays (#1862)") +runFrames(poolWorld, 4) eq(poolWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x36, - "DisappearWhirlpool swaps block $07 for $36") + "DisappearWhirlpool swaps block $07 for $36 once the sfx ends") local wrongBlock = fieldWorld({ [4 * 100 + 5] = COLL_WHIRLPOOL }, SPINNER) check(not wrongBlock:useFieldMove("WHIRLPOOL", SPINNER[1]).ok, "a whirlpool collision over the wrong block is refused") diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 2afd28d1..44e62756 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -558,8 +558,9 @@ local pm = PartyMenu.new(pgame) pm.game = pgame pgame.stack:push(pm) press(pm, "a") -check(pm.submenu and #pm.subItems == 2 - and pm.subItems[1].label == "STATS" and pm.subItems[2].label == "SWITCH", +check(pm.submenu and #pm.subItems == 3 + and pm.subItems[1].label == "STATS" and pm.subItems[2].label == "SWITCH" + and pm.subItems[3].label == "CANCEL", "vanilla party submenu unchanged with no hooks") pm.submenu = nil @@ -570,9 +571,9 @@ hooks:wrap("ui.party.submenu", function(nextFn, game, items, mon, ctx) return nextFn(game, items, mon, ctx) end, 0, "fixture") press(pm, "a") -check(#pm.subItems == 3 and pm.subItems[3].label == "QUESTS", +check(#pm.subItems == 4 and pm.subItems[4].label == "QUESTS", "hook appends a party submenu entry") -pm.subIndex = 3 +pm.subIndex = 4 press(pm, "a") check(ranWith == pgame.save.party[1], "an injected entry's onSelect runs with the focused mon") @@ -581,7 +582,7 @@ hooks:removeOwner("fixture") hooks:wrap("ui.party.submenu", function() return nil end, 0, "bad") press(pm, "a") -check(#pm.subItems == 2, "a non-table submenu result keeps the vanilla list") +check(#pm.subItems == 3, "a non-table submenu result keeps the vanilla list") hooks:removeOwner("bad") pm.submenu = nil diff --git a/tests/parity_ai_switch_exp.lua b/tests/parity_ai_switch_exp.lua new file mode 100644 index 00000000..b3f2ca71 --- /dev/null +++ b/tests/parity_ai_switch_exp.lua @@ -0,0 +1,82 @@ +-- Parity test: an AI trainer switch clears the exp participant flags. +-- +-- pokered's AI switch path calls EnemySendOut, which zeroes +-- wPartyGainExpFlags and re-flags only wPlayerMonNumber before falling +-- into EnemySendOutFirstMon (engine/battle/core.asm:1276-1292). The port +-- kept the old participants, so a mon that had fought the withdrawn foe +-- still halved the exp for the newly sent-out one (#1826). +-- +-- Self-contained; run via `luajit tests/parity_ai_switch_exp.lua`. +-- Also picked up by tests/run_tests.lua's parity_* glob. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local Pokemon = require("src.pokemon.Pokemon") +local BattleState = require("src.battle.BattleState") +local S = require("tests.harness").suite("parity ai switch exp") +local check, eq = S.check, S.eq + +local function freshGame() + return { + data = Data, + save = { + party = { Pokemon.new(Data, "BULBASAUR", 50), Pokemon.new(Data, "CHARMANDER", 50) }, + player = { name = "RED", id = 1234 }, + inventory = {}, + options = { battleStyle = "set" }, + pokedex = { seen = {}, owned = {} }, + flags = {}, + money = 0, + }, + stack = { push = function() end, pop = function() end, top = function() end }, + } +end + +-- Both party mons fought the first foe; the AI then withdraws it. +local function battleAfterAiSwitch() + local Game = freshGame() + local b = BattleState.newTrainer(Game, "OPP_AGATHA", 1) + check(#b.enemyParty >= 2, "the trainer has a reserve to switch to") + b.participants = { [Game.save.party[1]] = true, [Game.save.party[2]] = true } + b:executeAction(b.enemy, b.player, { special = "aiSwitch", index = 2 }) + return Game, b +end + +do + local Game, b = battleAfterAiSwitch() + eq(b.participants[Game.save.party[1]], true, + "the mon on the field is the one participant after the switch") + eq(b.participants[Game.save.party[2]], nil, + "the benched mon lost its gain-exp flag (core.asm:1276-1289)") +end + +-- The divisor follows: the on-field mon takes the whole share, and the +-- benched one earns nothing off the new foe's KO. +do + local Game, b = battleAfterAiSwitch() + b.enemy.mon.hp = 0 + local before1 = Game.save.party[1].exp + local before2 = Game.save.party[2].exp + b:awardExp() + local gainedSwitched = Game.save.party[1].exp - before1 + check(gainedSwitched > 0, "the mon on the field is paid for the KO") + eq(Game.save.party[2].exp - before2, 0, "the benched mon is paid nothing") + + -- Reference: the same KO with a single flagged participant from the start. + local Game2 = freshGame() + local b2 = BattleState.newTrainer(Game2, "OPP_AGATHA", 1) + b2:executeAction(b2.enemy, b2.player, { special = "aiSwitch", index = 2 }) + b2.participants = { [Game2.save.party[1]] = true } + b2.enemy.mon.hp = 0 + local ref = Game2.save.party[1].exp + b2:awardExp() + eq(gainedSwitched, Game2.save.party[1].exp - ref, + "the share is undivided, not halved by the stale participant") +end + +S.finish() diff --git a/tests/parity_ball_shake_anim.lua b/tests/parity_ball_shake_anim.lua new file mode 100644 index 00000000..5683f59d --- /dev/null +++ b/tests/parity_ball_shake_anim.lua @@ -0,0 +1,47 @@ +-- Parity test: the ball-shake pause keeps the resting ball on screen. +-- +-- PlaySubanimation draws the frame block and only then runs +-- DoSpecialEffectByAnimationId (engine/battle/animations.asm:623-627), so +-- DoBallShakeSpecialEffects' SFX_TINK + DelayFrames 40 (animations.asm: +-- 739-747) lands with the ball already drawn. The port paused first and +-- showed 40 blank frames per wobble (#1853). +-- +-- Self-contained; run via `luajit tests/parity_ball_shake_anim.lua`. +-- Also picked up by tests/run_tests.lua's parity_* glob. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("parity ball shake anim") +local check, eq = S.check, S.eq + +local AnimPlayer = require("src.battle.AnimPlayer") +local player = AnimPlayer.new(require("data.generated.battle_anims")) +player:start("SHAKE_ANIM", true, { shakes = 3 }) + +local tinks = {} +for _, e in ipairs(player.events) do + if e.effect == "SFX_TINK" then tinks[#tinks + 1] = e.frame end +end +eq(#tinks, 3, "one SFX_TINK per requested shake") + +-- the step a given frame falls in +local function stepAt(frame) + local at = 0 + for _, st in ipairs(player.steps) do + if frame >= at and frame < at + st.dur then return st end + at = at + st.dur + end +end + +for i, frame in ipairs(tinks) do + local st = stepAt(frame) + check(st ~= nil, "shake " .. i .. " has a step at its tink frame") + eq(st.dur, 40, "shake " .. i .. " pauses 40 frames on the tink") + check(#st.sprites > 0, + "shake " .. i .. " shows the resting ball through that pause") +end + +check(tinks[1] > 0, + "the first tink is not the very first thing the animation does") + +S.finish() diff --git a/tests/parity_box_mon_stats.lua b/tests/parity_box_mon_stats.lua index d0bf98ae..6bed01e5 100644 --- a/tests/parity_box_mon_stats.lua +++ b/tests/parity_box_mon_stats.lua @@ -259,7 +259,10 @@ do withdraw(game) check(captured ~= nil, "withdraw opened its box list") if captured then - eq(#captured.items, 1, "the list has the one box mon in it") + -- engine/menus/players_pc.asm: the list ends in CANCEL (#1847) + eq(#captured.items, 2, "the list has the one box mon in it, plus CANCEL") + check(captured.items[2] and captured.items[2].cancel, + "the last row is the CANCEL terminator") captured.opts.onChoose(captured.items[1], { index = 1, close = function() end }) eq(#save.boxes[1], 0, "the mon left the box") diff --git a/tests/parity_ghost_marowak.lua b/tests/parity_ghost_marowak.lua new file mode 100644 index 00000000..76def148 --- /dev/null +++ b/tests/parity_ghost_marowak.lua @@ -0,0 +1,112 @@ +-- Parity test: the Pokemon Tower 6F Marowak ghost scene. +-- +-- InitWildBattle runs the battle transition before the disguise +-- (engine/battle/core.asm:6695-6702), _EnemyAppearedText carries no +-- article (data/text/text_2.asm:1251-1255), and the CUBONE's-mother text +-- ends in `done` so PlayCry lands on it (scripts/PokemonTower6F.asm: +-- 137-148, text/PokemonTower6F.asm:1-5). See #1849. +-- +-- Self-contained; run via `luajit tests/parity_ghost_marowak.lua`. +-- Also picked up by tests/run_tests.lua's parity_* glob. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local Pokemon = require("src.pokemon.Pokemon") +local S = require("tests.harness").suite("parity marowak scene") +local check, eq = S.check, S.eq + +do + local RealBattleState = require("src.battle.BattleState") + local game = { + data = Data, + save = { + party = { Pokemon.new(Data, "BULBASAUR", 30) }, + player = { name = "RED" }, + inventory = {}, + options = {}, + pokedex = { seen = {}, owned = {} }, + flags = {}, + money = 0, + }, + stack = { push = function() end, pop = function() end, top = function() end }, + } + local b = RealBattleState.newWild(game, "MAROWAK", 30) + b:makeGhost() + eq(b.enemy.name, "GHOST", "the disguise renames the foe") + eq(b.introText, "GHOST\nappeared!", + "_EnemyAppearedText has no article in front of the nick") +end + +-- The script side: entry wipe and cry ordering. +local realTextBox = package.loaded["src.render.TextBox"] +local realBattleState = package.loaded["src.battle.BattleState"] + +local pushed = {} +package.loaded["src.render.TextBox"] = { + new = function(_, text, cb) + local box = { text = text, cb = cb } + table.insert(pushed, box) + return box + end, + substitute = function(_, text) return text end, +} +local battles = {} +local stubBattle = {} +stubBattle.__index = stubBattle +function stubBattle:makeGhost() self.ghost = true end +function stubBattle:makeUnveiledGhost() self.scopeReveal = true end +package.loaded["src.battle.BattleState"] = { + newWild = function(_, species, level) + local b = setmetatable({ species = species, level = level }, stubBattle) + table.insert(battles, b) + return b + end, +} + +local scripts = dofile("data/scripts/story3.lua") +local onStep = scripts.POKEMON_TOWER_6F.onStep +check(onStep ~= nil, "the tower 6F step trigger is registered") + +local game = { + data = { text = {} }, + save = { flags = {}, inventory = {} }, + stack = { push = function() end }, +} +local rows +local pushedBattles, stackBattles = 0, 0 +local ow = { + pushBattle = function() pushedBattles = pushedBattles + 1 end, + afterBattle = function() end, + scriptMove = function() end, + runner = { run = function(_, r) rows = r end }, +} +game.stack.push = function(_, thing) + if getmetatable(thing) == stubBattle then stackBattles = stackBattles + 1 end +end + +eq(onStep(game, ow, 10, 16), true, "stepping on the trigger cell fires it") +eq(#pushed, 1, "the Be gone... line opens the scene") +pushed[1].cb() +eq(#battles, 1, "the ghost battle is built") +eq(pushedBattles, 1, "the battle goes through the entry wipe (pushBattle)") +eq(stackBattles, 0, "and is never pushed straight onto the stack") + +battles[1].onFinish("win") +check(rows ~= nil, "victory queues the departed script") +eq(rows[1][1], "play_cry", "PlayCry is armed before the CUBONE's-mother box") +eq(rows[1][2], "MAROWAK", "and it is the RESTLESS SOUL's cry") +eq(rows[1][3], nil, "with no button wait -- that text ends in `done`") +eq(rows[2][1], "show_text", "the CUBONE's-mother line follows") +check(rows[2][2]:find("CUBONE") ~= nil, "and it is that line") +eq(rows[3][1], "wait", "then the DelayFrames 30 gap") +eq(rows[4][1], "show_text", "then the soul-was-calmed line") + +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.battle.BattleState"] = realBattleState + +S.finish() diff --git a/tests/parity_giovanni_end_battle.lua b/tests/parity_giovanni_end_battle.lua new file mode 100644 index 00000000..1f62d24c --- /dev/null +++ b/tests/parity_giovanni_end_battle.lua @@ -0,0 +1,76 @@ +-- Parity test: Giovanni's "WHAT! This cannot be!" is an end-battle line. +-- +-- RocketHideoutB4FGiovanniText arms it with SaveEndBattleTextPointers +-- before EngageMapTrainer (scripts/RocketHideoutB4F.asm:99-120), so +-- PrintEndBattleText prints it on the battle screen between +-- TrainerDefeatedText and MoneyForWinningText. The port pushed it as an +-- overworld TextBox from onFinish, i.e. after the map was back (#1817). +-- +-- Self-contained; run via `luajit tests/parity_giovanni_end_battle.lua`. +-- Also picked up by tests/run_tests.lua's parity_* glob. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("parity giovanni end battle") +local check, eq = S.check, S.eq + +local realTextBox = package.loaded["src.render.TextBox"] +local realBattleState = package.loaded["src.battle.BattleState"] + +local pushed = {} +package.loaded["src.render.TextBox"] = { + new = function(_, text, cb) + local box = { text = text, cb = cb } + table.insert(pushed, box) + return box + end, + substitute = function(_, text) return text end, +} +local battles = {} +package.loaded["src.battle.BattleState"] = { + newTrainer = function(_, class, index) + local b = { trainerClass = class, partyIndex = index } + table.insert(battles, b) + return b + end, +} + +local scripts = dofile("data/scripts/story3.lua") +local handler = scripts.ROCKET_HIDEOUT_B4F.talk.TEXT_ROCKETHIDEOUTB4F_GIOVANNI +check(handler ~= nil, "the Giovanni talk handler is registered") + +local game = { + data = { text = {} }, + save = { defeatedTrainers = {}, flags = {}, player = { name = "RED" } }, + stack = { push = function() end }, +} +local ow = { + trainerDefeated = function() return false end, + pushBattle = function() end, + afterBattle = function() end, +} + +handler(game, ow, { id = 7 }, function() end) +eq(#pushed, 1, "the impressed line opens the scene") +pushed[1].cb() +eq(#battles, 1, "talking starts the Giovanni battle") +local battle = battles[1] +check(battle.endBattleText ~= nil, + "the loss line is handed to the battle (SaveEndBattleTextPointers)") +check(battle.endBattleText:find("cannot be") ~= nil, + "and it is the WHAT!/This cannot be! text") + +pushed = {} +battle.onFinish("win") +eq(#pushed, 1, "victory pushes exactly one overworld box") +check(pushed[1].text:find("cannot be") == nil, + "the cannot-be line no longer reprints on the map") +check(pushed[1].text:find("meet") ~= nil, + "BeatGiovanniScript's hope-we-meet-again text follows instead") +eq(game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI, true, + "the beat-Giovanni event flag is set") + +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.battle.BattleState"] = realBattleState + +S.finish() diff --git a/tests/parity_hidden_coins_bcd_bug1810.lua b/tests/parity_hidden_coins_bcd_bug1810.lua new file mode 100644 index 00000000..b19590e5 --- /dev/null +++ b/tests/parity_hidden_coins_bcd_bug1810.lua @@ -0,0 +1,38 @@ +-- Parity: HiddenCoins pays BCD constants, and the COIN+40 tile pays 20 (#1810). +-- +-- engine/events/hidden_items.asm:79-96 branches the hidden-event argument +-- into .bcd10 ($10), .bcd20 ($20) or .bcd100 ($0100); `cp 40 / jr z, .bcd20` +-- is pokered's own typo, so .bcd40 at :90 ("due to a typo, this is never +-- used") never runs and the Game Corner's COIN+40 tile (data/events/ +-- hidden_events.asm:294, GAME_CORNER 11,7) credits 20 coins on hardware. +-- +-- Self-contained; run via `luajit tests/parity_hidden_coins_bcd_bug1810.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local S = require("tests.harness").suite("parity hidden coins bcd") +local check, eq = S.check, S.eq + +local OverworldState = require("src.world.OverworldController") +local pay = OverworldState.hiddenCoinPayout + +eq(pay(10), 10, "COIN + 10 pays BCD $10") +eq(pay(20), 20, "COIN + 20 pays BCD $20") +eq(pay(40), 20, "COIN + 40 falls into .bcd20 and pays 20") +eq(pay(100), 100, "COIN + 100 pays BCD $0100") +eq(pay(0), 100, "anything the branch chain misses pays 100") + +-- the generated data still carries the raw argument: the runtime mapping, +-- not the extractor, is what turns the 40 tile into 20 +local field = dofile("data/generated/field.lua") +local corner = field.hiddenCoins and field.hiddenCoins.GAME_CORNER +check(corner ~= nil, "GAME_CORNER has hidden coin tiles") + +local forty +for _, h in ipairs(corner or {}) do + if h.x == 11 and h.y == 7 then forty = h end +end +check(forty ~= nil, "the (11,7) hidden coin tile is present") +eq(forty and forty.coins, 40, "it is extracted as the COIN + 40 argument") +eq(pay(forty and forty.coins), 20, "and the award path credits 20 for it") + +S.finish() diff --git a/tests/parity_marowak.lua b/tests/parity_marowak.lua index d0488dd6..0f30f771 100644 --- a/tests/parity_marowak.lua +++ b/tests/parity_marowak.lua @@ -62,12 +62,15 @@ local function gameWith(inventory, flags) end local function owWith() - local moved, after = {}, {} + local moved, after, wiped = {}, {}, {} return { player = { facing = "up" }, scriptMove = function(_, _, dir, n) moved[#moved + 1] = { dir, n } end, afterBattle = function(_, result) after[#after + 1] = result end, - }, moved, after + -- InitWildBattle runs the transition before the disguise + -- (engine/battle/core.asm:6695-6702), so the script uses pushBattle + pushBattle = function(_, battle) wiped[#wiped + 1] = battle end, + }, moved, after, wiped end -- Walk the trigger: run onStep, then the Be-gone text's done() to push diff --git a/tests/parity_pokemon_tower_rival_bug1842.lua b/tests/parity_pokemon_tower_rival_bug1842.lua new file mode 100644 index 00000000..c86cbd60 --- /dev/null +++ b/tests/parity_pokemon_tower_rival_bug1842.lua @@ -0,0 +1,79 @@ +-- Parity: the Tower 2F rival scene arms its in-battle line, prints +-- HowsYourDex on the win, and restores the map music (#1842). +-- +-- PokemonTower2FRivalText arms SaveEndBattleTextPointers with .DefeatedText +-- before the fight (scripts/PokemonTower2F.asm:145-150), the win re-runs +-- DisplayTextID so the beaten branch prints .HowsYourDexText (asm:72-75, +-- :137-140), and PokemonTower2FRivalExitsScript hides him and calls +-- PlayDefaultMusic (asm:115-124). +-- +-- Self-contained; run via `luajit tests/parity_pokemon_tower_rival_bug1842.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local S = require("tests.harness").suite("parity pokemon tower rival") +local check, eq = S.check, S.eq + +local story = dofile("data/scripts/story.lua") +local tower = story.POKEMON_TOWER_2F +check(tower ~= nil and tower.rivalScript ~= nil, "POKEMON_TOWER_2F rivalScript exists") + +local rows = tower.rivalScript(15) + +local function find(verb, arg) + for i, row in ipairs(rows) do + if row[1] == verb and (arg == nil or row[2] == arg) then return i, row end + end +end + +local iBattle = find("rival_battle") +local iArm = find("save_end_battle_text", "_PokemonTower2FRivalDefeatedText") +check(iArm ~= nil, "_PokemonTower2FRivalDefeatedText is armed for the battle") +check(iBattle and iArm == iBattle - 1, + "SaveEndBattleTextPointers runs just before the battle") +check(find("show_text", "_PokemonTower2FRivalDefeatedText") == nil, + "and it is not also printed on the map afterwards") + +local iSet = find("set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL") +local iDex = find("show_text", "_PokemonTower2FRivalHowsYourDexText") +check(iSet and iDex and iDex > iSet, + "the win prints HowsYourDex after setting the event") + +local iHide = find("hide_object") +local iDefault = find("play_default_music") +check(iDefault ~= nil, "the exit calls play_default_music") +check(iHide and iDefault and iDefault > iHide, + "PlayDefaultMusic follows HideObject") +local iWalk = find("walk_npc") +check(iWalk and iHide and iHide > iWalk, "the walk-out runs before the hide") + +-- the beaten branch: every jump target resolves, and talking again after the +-- win reaches HowsYourDex instead of falling off the end +local ScriptRunner = require("src.script.ScriptRunner") +local problems = ScriptRunner.validate(rows) +eq(#problems, 0, "rival script validates: " .. table.concat(problems, "; ")) + +local iJumpIfTrue, jumpRow = find("jump_if_true") +check(iJumpIfTrue ~= nil, "the beaten check jumps") +local labelRow +for i, row in ipairs(rows) do + if row[1] == "label" and row[2] == (jumpRow and jumpRow[2]) then labelRow = i end +end +check(labelRow ~= nil, "it jumps to a real label, not a stale row number") +local tail +for i = labelRow or 0, #rows do + if rows[i] and rows[i][1] == "show_text" then tail = rows[i][2] break end +end +eq(tail, "_PokemonTower2FRivalHowsYourDexText", + "the beaten branch prints HowsYourDex") + +-- both exit paths are still distinct (ON_LEFT vs not, asm:76-82) +local left = tower.rivalScript(15) +local right = tower.rivalScript(14) +local _, lWalk = find("walk_npc") +local rWalk +for _, row in ipairs(right) do if row[1] == "walk_npc" then rWalk = row end end +check(lWalk and rWalk and lWalk[3][1] ~= rWalk[3][1], + "the two exit movements still differ") +eq(#left, #right, "both variants are the same script shape") + +S.finish() diff --git a/tests/parity_rocket3_sight_bug1814.lua b/tests/parity_rocket3_sight_bug1814.lua new file mode 100644 index 00000000..6145cd0c --- /dev/null +++ b/tests/parity_rocket3_sight_bug1814.lua @@ -0,0 +1,80 @@ +-- Parity: Rocket3 in the Hideout B4F engages on sight, and the LIFT KEY +-- still drops after a sight-triggered battle (#1814). +-- +-- RocketHideout4TrainerHeader2 is `trainer EVENT_..., 1, ...` +-- (scripts/RocketHideoutB4F.asm:95-96) and the `trainer` macro stores arg 2 +-- as `db \2 << 4` (macros/scripts/maps.asm:107-127), so CheckSpriteCanSeePlayer +-- (engine/overworld/trainer_sight.asm:257-262) engages him from one tile away +-- through SCRIPT_ROCKETHIDEOUTB4F_DEFAULT (asm:42, home/trainers.asm:129). +-- The port used to skip any trainer with a hand-ported talk script; only an +-- explicit `noSight` opt-out may do that now. His LIFT KEY lives in +-- Rocket3AfterBattleText (asm:189-199), i.e. the next talk, so a sight battle +-- does not lose it. +-- +-- Self-contained; run via `luajit tests/parity_rocket3_sight_bug1814.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local S = require("tests.harness").suite("parity rocket3 sight") +local check, eq = S.check, S.eq + +local MAP = "ROCKET_HIDEOUT_B4F" +local TEXT = "TEXT_ROCKETHIDEOUTB4F_ROCKET3" + +-- the sight range the engine reads +local headers = dofile("data/generated/trainer_headers.lua") +local h = headers.RocketHideoutB4F and headers.RocketHideoutB4F[4] +check(h ~= nil, "RocketHideoutB4F header 4 is extracted") +eq(h and h.range, 1, "Rocket3's view range is one tile") + +local maps = dofile("data/generated/maps.lua") +local rocket3 +for _, o in ipairs(maps[MAP].objects or {}) do + if o.text == TEXT then rocket3 = o end +end +check(rocket3 ~= nil, "Rocket3 is object " .. TEXT) +eq(rocket3 and rocket3.index, 4, "he is object index 4, the header's row") +check(rocket3 and rocket3.trainerClass ~= nil, "and he is a trainer sprite") +eq(rocket3 and rocket3.movement, "STAY", "he holds his tile") +eq(rocket3 and rocket3.range, "DOWN", "facing down, so the sight line is below him") +eq(rocket3 and rocket3.x, 11, "standing at x 11") +eq(rocket3 and rocket3.y, 2, "standing at y 2") + +-- the gate the fix uses: having a talk script is NOT an opt-out; only an +-- explicit noSight set is +local story3 = dofile("data/scripts/story3.lua") +local hideout = story3[MAP] +check(hideout ~= nil and hideout.talk[TEXT] ~= nil, + "Rocket3 still has his hand-ported talk script") +check(hideout.noSight == nil or hideout.noSight[TEXT] ~= true, + "and he is not opted out of CheckFightingMapTrainers") + +-- the LIFT KEY half: after the battle -- however it started -- the next talk +-- reveals the ball +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { text = text, done = done } end, + soundOpts = function() return nil end, + substitute = function(_, t) return t end, +} +local pushed +local game = { + data = { text = { _RocketHideoutB4FRocket3AfterBattleText = "Oh no!" } }, + save = { flags = {} }, + stack = { push = function(_, box) pushed = box end }, +} +local ow = { + map = { id = MAP, def = { objects = {} } }, + npcs = {}, entities = {}, + trainerDefeated = function() return true end, +} +local finished = false +hideout.talk[TEXT](game, ow, { def = rocket3 }, function() finished = true end) +check(pushed ~= nil, "talking after the win prints the after-battle text") +if pushed and pushed.done then pushed.done() end +check(game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY == true, + "EVENT_ROCKET_DROPPED_LIFT_KEY is set") +local toggles = game.save.objectToggles and game.save.objectToggles[MAP] +check(toggles and toggles.ROCKETHIDEOUTB4F_LIFT_KEY == true, + "the LIFT KEY ball is shown") +check(finished, "and the talk hands control back") + +S.finish() diff --git a/tests/parity_tower_rival.lua b/tests/parity_tower_rival.lua index a55cedca..b1a34a2c 100644 --- a/tests/parity_tower_rival.lua +++ b/tests/parity_tower_rival.lua @@ -64,16 +64,22 @@ do "player at x=15 uses DownThenRightMovement") end --- (2) win path: flag, defeat text, walk, hide -- in order +-- (2) win path: flag, post-win text, walk, hide -- in order. +-- DefeatedText rides SaveEndBattleTextPointers, so it prints in battle and the +-- map-side box after the win is HowsYourDex (#1842). +-- scripts/PokemonTower2F.asm:71-75, :137-140 do local rows = tower.rivalScript(14) local preds = { + { "save_end_battle_text DefeatedText", + function(r) return r[1] == "save_end_battle_text" + and r[2] == "_PokemonTower2FRivalDefeatedText" end }, { "set_flag EVENT_BEAT_POKEMON_TOWER_RIVAL", function(r) return r[1] == "set_flag" and r[2] == "EVENT_BEAT_POKEMON_TOWER_RIVAL" end }, - { "show_text DefeatedText", + { "show_text HowsYourDexText", function(r) return r[1] == "show_text" - and r[2] == "_PokemonTower2FRivalDefeatedText" end }, + and r[2] == "_PokemonTower2FRivalHowsYourDexText" end }, { "walk_npc exit", function(r) return r[1] == "walk_npc" end }, { "hide_object POKEMONTOWER2F_RIVAL", diff --git a/tests/parity_wavy_screen.lua b/tests/parity_wavy_screen.lua new file mode 100644 index 00000000..5f7f4b8f --- /dev/null +++ b/tests/parity_wavy_screen.lua @@ -0,0 +1,35 @@ +-- Parity test: SE_WAVY_SCREEN is timed in displayed frames. +-- +-- AnimationWavyScreen's `ld c, $ff` counts outer passes, and the inner +-- loop only exits when rLY reaches 143, which happens twice per displayed +-- frame (engine/battle/animations.asm:1884-1903). The port ran it for +-- 255 frames, roughly twice as long as the original (#1848). +-- +-- Self-contained; run via `luajit tests/parity_wavy_screen.lua`. +-- Also picked up by tests/run_tests.lua's parity_* glob. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("parity wavy screen") +local check, eq = S.check, S.eq + +local AnimPlayer = require("src.battle.AnimPlayer") +local player = AnimPlayer.new(require("data.generated.battle_anims")) +player:start("NIGHT_SHADE", true) + +local wavy +for _, e in ipairs(player.events) do + if e.effect == "SE_WAVY_SCREEN" then wavy = e end +end +check(wavy ~= nil, "NIGHT_SHADE plays SE_WAVY_SCREEN") +eq(wavy.dur, 128, "255 outer passes at two a frame is 128 displayed frames") + +local BattleState = require("src.battle.BattleState") +local side = setmetatable({}, { __index = BattleState }) +BattleState.applyAnimEffect(side, { effect = "SE_WAVY_SCREEN" }) +check(side.fx and side.fx.wavy ~= nil, "the effect arms the wave") +eq(side.fx.wavy.left, wavy.dur, + "the fx layer and the animation player agree on the length") +eq(side.fx.wavy.phase, 0, "the offset pointer starts at the table head") + +S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index b778adaf..275e4474 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -2927,7 +2927,8 @@ do qpressed = { a = true } qmenu:update(1 / 60) qpressed = {} - eq(qsave.startMenuIndex, quitIdx, "QUIT selection persists the cursor slot") + eq(qg.startMenuIndex, quitIdx, "QUIT selection persists the cursor slot") + check(qsave.startMenuIndex == nil, "and keeps it off the saved data") local qbox = qstack:top() check(qbox ~= qmenu and qbox ~= nil and qbox.pages ~= nil, "QUIT pushes a confirmation textbox") From eed246f6453f57d0f3b9d804fa6c7603ffb287cb Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 27 Aug 2026 06:09:50 -0400 Subject: [PATCH 6/8] CLOSES #1831, CLOSES #1834, CLOSES #1836, CLOSES #1860 --- src/import/RomExtractorGen2.lua | 3 +- src/render/TextBox.lua | 12 ++ src/render/TileRenderer.lua | 9 +- src/script/gen2/Specials.lua | 18 ++- src/script/gen2/Vm.lua | 16 ++- src/ui/gen2/BattleState.lua | 2 +- src/ui/gen2/SummaryMenu.lua | 2 +- src/world/Player.lua | 14 +- src/world/gen2/FieldMoves.lua | 55 ++++---- src/world/gen2/World.lua | 25 +++- tests/drivers/gold_jingle_bug1860_test.lua | 131 ++++++++++++++++++ tests/drivers/spinner_rate_bug1831_test.lua | 99 +++++++++++++ tests/engine/gen2_flypoint_rebind_bug1836.lua | 67 +++++++++ tests/gen2_unown_test.lua | 87 ++++++++++++ tests/parity_G.lua | 7 +- 15 files changed, 485 insertions(+), 62 deletions(-) create mode 100644 tests/drivers/gold_jingle_bug1860_test.lua create mode 100644 tests/drivers/spinner_rate_bug1831_test.lua create mode 100644 tests/engine/gen2_flypoint_rebind_bug1836.lua diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 2719dc76..4ab3ba84 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -2753,7 +2753,8 @@ function RomExtractorGen2:extractAudio(maps) local speciesOrder = self.manifest.constants.speciesOrder or {} local cries = {} for index, species in ipairs(speciesOrder) do - if species ~= "UNOWN" and not tostring(species):match("^UNUSED") then + -- data/pokemon/cries.asm:209 gives UNOWN a real cry row + if not tostring(species):match("^UNUSED") then local row = self.rom:bytes( pokeCryPtr.bank, pokeCryPtr.address + (index - 1) * 6, 6) local cryIndex = row[1] + row[2] * 256 diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index f8091a4a..56722092 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -112,6 +112,8 @@ function TextBox.new(game, text, onDone, opts) self.stay = opts and opts.stay -- engine/events/hidden_events/cinnabar_gym_quiz.asm:119 self.preSound = opts and opts.preSound + -- pokegold engine/overworld/scripting.asm:485 WaitSFX + self.sfxWait = opts and opts.sfxWait -- opts.instant: put the LAST page up already typed, with no typewriter and -- no page waits. A `yesorno` follows a `writetext` that has already been -- read, so re-typing the line under the YES/NO box would be wrong -- the @@ -328,6 +330,14 @@ function TextBox:visibleText() return #out > 0 and out or nil end +-- pokegold engine/overworld/scripting.asm:484-485 PlaySFX / WaitSFX +function TextBox:sfxHeld() + if not self.sfxWait then return false end + if require("src.core.Sound").sfxBusy() then return true end + self.sfxWait = nil + return false +end + function TextBox:update(dt) local input = self.game.input self.blink = (self.blink + 1) % 60 @@ -444,6 +454,7 @@ function TextBox:update(dt) end return end + if self:sfxHeld() then return end if input:wasPressed("a") or input:wasPressed("b") then require("src.core.Sound").play(self.game.data, "Press_AB") self.game.stack:pop() @@ -459,6 +470,7 @@ function TextBox:update(dt) self.preWait = self.preWait - 1 return end + if self:sfxHeld() then return end if input:wasPressed("a") or input:wasPressed("b") then require("src.core.Sound").play(self.game.data, "Press_AB") self.waiting = false diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index ad29bce5..02b937ab 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -150,13 +150,10 @@ end -- true while the spinner arrow tiles should show the 'blur' graphic; false -- means draw nothing extra (the static window tile shows through, --- matching the asm's restore-to-original behavior). The 8-tick --- half-period approximates one GB movement step (2px/frame); this is a --- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not --- a cycle-accurate replication -- the port's tweened scriptMove has no --- direct equivalent discrete step counter. +-- matching the asm's restore-to-original behavior). +-- spinners.asm:17-22, home/overworld.asm:1844-1846, :49-52 function TileRenderer.spinBlurActive() - return spinning and (math.floor(animFrame / 8) % 2 == 0) + return spinning and (math.floor(animFrame / 16) % 2 == 0) end -- ------------------------------------------------------------------ diff --git a/src/script/gen2/Specials.lua b/src/script/gen2/Specials.lua index c0240a48..f786932a 100644 --- a/src/script/gen2/Specials.lua +++ b/src/script/gen2/Specials.lua @@ -152,6 +152,12 @@ local function answer(vm, value) vm.scriptVar = value or 0 end +-- WaitSFX (pokegold home/audio.asm); a test stub that calls a handler off +-- the coroutine has no sfx to drain. +local function drainSfx() + if coroutine.running() then coroutine.yield({ kind = "waitsfx" }) end +end + -- Every routine that ends `call GetPokemonName / jp -- CopyPokemonName_Buffer1_Buffer3` puts a name where the next writetext's -- {STRBUF} will find it. @@ -452,8 +458,10 @@ H.BugContestJudging = function(vm) local what = (h.monName and h.monName(entry.species)) or entry.species or "" vm:showRaw(Strings(row.page, who, what)) + -- pokegold engine/events/bug_contest/judging.asm:29-32 + drainSfx() if h.playSfxNamed then h.playSfxNamed(row.sfx) end - vm:showRaw(Strings(row.score, entry.score or 0)) + vm:showRaw(Strings(row.score, entry.score or 0), nil, nil, true) end end answer(vm, place) @@ -2033,12 +2041,10 @@ H.ProfOaksPCBoot = function(vm) vm:showRaw(Strings(OAK_PC_TEXT.counts, seen, caught)) local rating = findOakRating(caught) local h = hooks(vm) + -- pokegold engine/events/prof_oaks_pc.asm:18-20 PlaySFX / JoyWaitAorB / WaitSFX + drainSfx() if h.playSfxNamed then h.playSfxNamed(rating.sfx) end - vm:showRaw(Strings(rating.text)) - -- `call PlaySFX / call JoyWaitAorB / call WaitSFX`: the fanfare is left - -- playing under the rating text, and the caller (OaksLab's own script) - -- waits it out before closing the box. - coroutine.yield({ kind = "waitsfx" }) + vm:showRaw(Strings(rating.text), nil, nil, true) end -- The whose-PC menu's PROF.OAK's PC row (src/ui/gen2/CenterPcMenu.lua) runs diff --git a/src/script/gen2/Vm.lua b/src/script/gen2/Vm.lua index 6652153c..19ffcfba 100644 --- a/src/script/gen2/Vm.lua +++ b/src/script/gen2/Vm.lua @@ -700,8 +700,10 @@ local function runCmd(self, cmd, op) -- GetPocketName fills from ItemPocketNames: KEY ITEMs, BALLs and TMs -- name their own pocket, not the ITEM one (data/text/common_2.asm -- :1351, data/items/pocket_names.asm:10-13). + -- Script_specialsound's WaitSFX (scripting.asm:485): the box holds + -- its press until the jingle ends. self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.", - name, self:pocketName(item))) + name, self:pocketName(item)), nil, nil, true) else self:showRaw(Strings("The %s\nis full…", self:pocketName(item))) end @@ -1510,8 +1512,8 @@ local function runCmd(self, cmd, op) self.playSoundFn(SFX_ITEM) end -- FruitTreeScript's tail is `specialsound / itemnotify` with NOTHING - -- between them (engine/events/fruit_trees.asm:23-24), and Script_specialsound - -- is a bare PlaySFX -- it does not wait either (scripting.asm:476-483). + -- between them (engine/events/fruit_trees.asm:23-24); + -- Script_specialsound ends PlaySFX / WaitSFX (scripting.asm:484-485) -- The port used to park here on a `waitsfx`, which is the same seam -- GiveItemScript's did: this port's box takes its own button and pops on -- it, so the park ran with an EMPTY state stack and the bare overworld @@ -1523,7 +1525,7 @@ local function runCmd(self, cmd, op) -- noun still comes from ItemPocketNames rather than from a third copy of -- the literal (data/items/pocket_names.asm:10-13). self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.", - name, self:pocketName(item))) + name, self:pocketName(item)), nil, nil, true) return "end" elseif op == "describedecoration" then -- `describedecoration byte` picks one of five DECODESC_* arms @@ -2427,7 +2429,7 @@ end -- the port's world does not tick while a box is on the stack (Game2:update -- stops at the top state), so the only clock that can count it is the box's; -- World:showText is where it lands. -function Vm:showRaw(body, stay, hold) +function Vm:showRaw(body, stay, hold, sfxWait) if not body or body == "" then body = "..." end if self.stringBuffer and self.stringBuffer ~= "" then body = body:gsub("{STRBUF}", self.stringBuffer) @@ -2438,6 +2440,8 @@ function Vm:showRaw(body, stay, hold) text = body, stay = (stay or self:textStays()) and true or false, hold = hold, + -- pokegold engine/overworld/scripting.asm:485 WaitSFX + sfxWait = sfxWait and true or nil, }) end end @@ -2646,7 +2650,7 @@ function Vm:resume(resumeValue) if req and req.kind == "text" and self.showTextFn then self.showTextFn(req.text, function() self:resume() - end, req.stay, req.hold) + end, req.stay, req.hold, req.sfxWait) elseif req and req.kind == "wait" then self.waitLeft = req.frames or 0 elseif req and req.kind == "waitbutton" then diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 31e4fc23..c22733f4 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -605,7 +605,7 @@ function BattleState:animData(mon) local def = self.pokemon and mon and self.pokemon[mon.species] if not def then return nil end if mon.species == Unown.SPECIES and def.letters then - local entry = def.letters[Unown.monLetter(mon)] + local entry = def.letters[Unown.name(Unown.monLetter(mon))] if entry and entry.anim then return entry.anim end end return def.anim diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index fea55cc0..6d95e648 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -336,7 +336,7 @@ function SummaryMenu:startPicAnim() if not def then return end local data = def.anim if mon.species == Unown.SPECIES and def.letters then - local entry = def.letters[Unown.monLetter(mon)] + local entry = def.letters[Unown.name(Unown.monLetter(mon))] if entry and entry.anim then data = entry.anim end end if not data then return end diff --git a/src/world/Player.lua b/src/world/Player.lua index 5fd33c47..71ad7e9b 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -182,6 +182,9 @@ function Player:update() if self.turnTimer > 0 then self.turnTimer = self.turnTimer - 1 end + if self.spinning then + self.spinTimer = (self.spinTimer or 0) + 1 + end if self.spinFrames then self.spinFrames = self.spinFrames - 1 if self.spinFrames <= 0 then @@ -261,11 +264,6 @@ local SPIN_ORDER = { "down", "left", "up", "right" } -- -- The last return says the player is mid-ledge-hop, which is what the 2D -- path draws the ground shadow from and a 3D path turns into vertical lift. --- --- This ADVANCES the surf-bob and spinner timers, so exactly one of pose() --- and draw() may run per frame -- and draw() is written in terms of pose() --- to keep that true by construction. (hopFrames counts down in --- Player:update, on the fixed step, so it is safe to read here.) function Player:pose() local py = self.py local hopping = false @@ -288,10 +286,8 @@ function Player:pose() -- the leg cadence local flip = math.floor((self.animClock or 0) / 16) % 2 == 1 if self.spinning then - -- spinner tiles whirl the sprite on its standing pose, one facing - -- per frame (LoadSpinnerArrowTiles runs every OverworldLoop frame) - self.spinTimer = (self.spinTimer or 0) + 1 - facing = SPIN_ORDER[self.spinTimer % 4 + 1] + -- spinners.asm:1-11, home/overworld.asm:41-44, :268-272 + facing = SPIN_ORDER[math.floor((self.spinTimer or 0) / 2) % 4 + 1] phase, flip = 0, false -- teleport arrivals spin the sprite down into place -- (EnterMapAnim PlayerSpinWhileMovingDown) diff --git a/src/world/gen2/FieldMoves.lua b/src/world/gen2/FieldMoves.lua index 97cb0cc9..b45734ca 100644 --- a/src/world/gen2/FieldMoves.lua +++ b/src/world/gen2/FieldMoves.lua @@ -187,6 +187,11 @@ function FieldMoves.bindEngineFlags(order) -- row, so every id from BUG_CONTEST_TIMER up shifts one. FieldMoves.BUG_CONTEST_FLAG = byName["ENGINE_BUG_CONTEST_TIMER"] or 16 FieldMoves.BIKE_SHOP_CALL_FLAG = byName["ENGINE_BIKE_SHOP_CALL_ENABLED"] or 19 + -- pokecrystal constants/engine_flags.asm:66-92 vs pokegold :65-91 + for _, row in ipairs(FieldMoves.FLYPOINTS or {}) do + row.goldFlag = row.goldFlag or row.flag + row.flag = byName[row.name] or row.goldFlag + end return flags end @@ -475,33 +480,35 @@ end -- const_def count (0-based, ENGINE_RADIO_CARD is 0): the byte a town's own -- MAPCALLBACK_NEWMAP callback sets with `setflag` the first time you walk in, -- and what FieldMoves.hasVisitedSpawn below actually reads. +-- Ids are pokegold's; bindEngineFlags rebinds by name (pokecrystal +-- constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM shifts them +1). FieldMoves.FLYPOINTS = { -- Johto - { landmark = "LANDMARK_NEW_BARK_TOWN", spawn = "SPAWN_NEW_BARK", flag = 64 }, - { landmark = "LANDMARK_CHERRYGROVE_CITY", spawn = "SPAWN_CHERRYGROVE", flag = 65 }, - { landmark = "LANDMARK_VIOLET_CITY", spawn = "SPAWN_VIOLET", flag = 66 }, - { landmark = "LANDMARK_AZALEA_TOWN", spawn = "SPAWN_AZALEA", flag = 67 }, - { landmark = "LANDMARK_GOLDENROD_CITY", spawn = "SPAWN_GOLDENROD", flag = 69 }, - { landmark = "LANDMARK_ECRUTEAK_CITY", spawn = "SPAWN_ECRUTEAK", flag = 71 }, - { landmark = "LANDMARK_OLIVINE_CITY", spawn = "SPAWN_OLIVINE", flag = 70 }, - { landmark = "LANDMARK_CIANWOOD_CITY", spawn = "SPAWN_CIANWOOD", flag = 68 }, - { landmark = "LANDMARK_MAHOGANY_TOWN", spawn = "SPAWN_MAHOGANY", flag = 72 }, - { landmark = "LANDMARK_LAKE_OF_RAGE", spawn = "SPAWN_LAKE_OF_RAGE", flag = 73 }, - { landmark = "LANDMARK_BLACKTHORN_CITY", spawn = "SPAWN_BLACKTHORN", flag = 74 }, - { landmark = "LANDMARK_SILVER_CAVE", spawn = "SPAWN_MT_SILVER", flag = 75 }, + { landmark = "LANDMARK_NEW_BARK_TOWN", spawn = "SPAWN_NEW_BARK", flag = 64, name = "ENGINE_FLYPOINT_NEW_BARK" }, + { landmark = "LANDMARK_CHERRYGROVE_CITY", spawn = "SPAWN_CHERRYGROVE", flag = 65, name = "ENGINE_FLYPOINT_CHERRYGROVE" }, + { landmark = "LANDMARK_VIOLET_CITY", spawn = "SPAWN_VIOLET", flag = 66, name = "ENGINE_FLYPOINT_VIOLET" }, + { landmark = "LANDMARK_AZALEA_TOWN", spawn = "SPAWN_AZALEA", flag = 67, name = "ENGINE_FLYPOINT_AZALEA" }, + { landmark = "LANDMARK_GOLDENROD_CITY", spawn = "SPAWN_GOLDENROD", flag = 69, name = "ENGINE_FLYPOINT_GOLDENROD" }, + { landmark = "LANDMARK_ECRUTEAK_CITY", spawn = "SPAWN_ECRUTEAK", flag = 71, name = "ENGINE_FLYPOINT_ECRUTEAK" }, + { landmark = "LANDMARK_OLIVINE_CITY", spawn = "SPAWN_OLIVINE", flag = 70, name = "ENGINE_FLYPOINT_OLIVINE" }, + { landmark = "LANDMARK_CIANWOOD_CITY", spawn = "SPAWN_CIANWOOD", flag = 68, name = "ENGINE_FLYPOINT_CIANWOOD" }, + { landmark = "LANDMARK_MAHOGANY_TOWN", spawn = "SPAWN_MAHOGANY", flag = 72, name = "ENGINE_FLYPOINT_MAHOGANY" }, + { landmark = "LANDMARK_LAKE_OF_RAGE", spawn = "SPAWN_LAKE_OF_RAGE", flag = 73, name = "ENGINE_FLYPOINT_LAKE_OF_RAGE" }, + { landmark = "LANDMARK_BLACKTHORN_CITY", spawn = "SPAWN_BLACKTHORN", flag = 74, name = "ENGINE_FLYPOINT_BLACKTHORN" }, + { landmark = "LANDMARK_SILVER_CAVE", spawn = "SPAWN_MT_SILVER", flag = 75, name = "ENGINE_FLYPOINT_SILVER_CAVE" }, -- Kanto - { landmark = "LANDMARK_PALLET_TOWN", spawn = "SPAWN_PALLET", flag = 52 }, - { landmark = "LANDMARK_VIRIDIAN_CITY", spawn = "SPAWN_VIRIDIAN", flag = 53 }, - { landmark = "LANDMARK_PEWTER_CITY", spawn = "SPAWN_PEWTER", flag = 54 }, - { landmark = "LANDMARK_CERULEAN_CITY", spawn = "SPAWN_CERULEAN", flag = 55 }, - { landmark = "LANDMARK_VERMILION_CITY", spawn = "SPAWN_VERMILION", flag = 57 }, - { landmark = "LANDMARK_ROCK_TUNNEL", spawn = "SPAWN_ROCK_TUNNEL", flag = 56 }, - { landmark = "LANDMARK_LAVENDER_TOWN", spawn = "SPAWN_LAVENDER", flag = 58 }, - { landmark = "LANDMARK_CELADON_CITY", spawn = "SPAWN_CELADON", flag = 60 }, - { landmark = "LANDMARK_SAFFRON_CITY", spawn = "SPAWN_SAFFRON", flag = 59 }, - { landmark = "LANDMARK_FUCHSIA_CITY", spawn = "SPAWN_FUCHSIA", flag = 61 }, - { landmark = "LANDMARK_CINNABAR_ISLAND", spawn = "SPAWN_CINNABAR", flag = 62 }, - { landmark = "LANDMARK_INDIGO_PLATEAU", spawn = "SPAWN_INDIGO", flag = 63 }, + { landmark = "LANDMARK_PALLET_TOWN", spawn = "SPAWN_PALLET", flag = 52, name = "ENGINE_FLYPOINT_PALLET" }, + { landmark = "LANDMARK_VIRIDIAN_CITY", spawn = "SPAWN_VIRIDIAN", flag = 53, name = "ENGINE_FLYPOINT_VIRIDIAN" }, + { landmark = "LANDMARK_PEWTER_CITY", spawn = "SPAWN_PEWTER", flag = 54, name = "ENGINE_FLYPOINT_PEWTER" }, + { landmark = "LANDMARK_CERULEAN_CITY", spawn = "SPAWN_CERULEAN", flag = 55, name = "ENGINE_FLYPOINT_CERULEAN" }, + { landmark = "LANDMARK_VERMILION_CITY", spawn = "SPAWN_VERMILION", flag = 57, name = "ENGINE_FLYPOINT_VERMILION" }, + { landmark = "LANDMARK_ROCK_TUNNEL", spawn = "SPAWN_ROCK_TUNNEL", flag = 56, name = "ENGINE_FLYPOINT_ROCK_TUNNEL" }, + { landmark = "LANDMARK_LAVENDER_TOWN", spawn = "SPAWN_LAVENDER", flag = 58, name = "ENGINE_FLYPOINT_LAVENDER" }, + { landmark = "LANDMARK_CELADON_CITY", spawn = "SPAWN_CELADON", flag = 60, name = "ENGINE_FLYPOINT_CELADON" }, + { landmark = "LANDMARK_SAFFRON_CITY", spawn = "SPAWN_SAFFRON", flag = 59, name = "ENGINE_FLYPOINT_SAFFRON" }, + { landmark = "LANDMARK_FUCHSIA_CITY", spawn = "SPAWN_FUCHSIA", flag = 61, name = "ENGINE_FLYPOINT_FUCHSIA" }, + { landmark = "LANDMARK_CINNABAR_ISLAND", spawn = "SPAWN_CINNABAR", flag = 62, name = "ENGINE_FLYPOINT_CINNABAR" }, + { landmark = "LANDMARK_INDIGO_PLATEAU", spawn = "SPAWN_INDIGO", flag = 63, name = "ENGINE_FLYPOINT_INDIGO_PLATEAU" }, } -- spawn -> row, built once, so hasVisitedSpawn below does not walk the whole diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 155b38db..a20a321a 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -929,8 +929,8 @@ function World:load() -- `hold` is the same story one argument along: the cart `pause` a held box -- stands through (FindItemInBallScript's `pause 60`). Dropping it made the -- box hand back the instant it finished typing. - showText = function(body, onDone, stay, hold) - self:showText(body, onDone, stay, hold) + showText = function(body, onDone, stay, hold, sfxWait) + self:showText(body, onDone, stay, hold, sfxWait) end, facePlayer = function() if self.talkNpc and self.player then @@ -4224,7 +4224,7 @@ function World:tryWildEncounter() -- That is what keeps the Ruins chambers empty until a wall has been solved. local monOpts = nil if roll.species == Unown.SPECIES then - local flags = self:engineFlags() + local flags = self:unownUnlockFlags() if not Unown.anyUnlocked(flags) then return false end -- LoadEnemyMon's .GenerateDVs loop rerolls until CheckUnownLetter clears -- the form, so a chamber only ever produces letters its own puzzle @@ -4846,6 +4846,19 @@ function World:engineFlagId(name, goldId) return ids[name] or goldId end +-- Unown.UNLOCK_SETS keys the flags by pokegold's ids; Crystal's are one +-- higher (../pokecrystal/constants/engine_flags.asm:57-60 vs ../pokegold:56-59). +function World:unownUnlockFlags() + local engine = self:engineFlags() + local out = {} + for _, set in ipairs(Unown.UNLOCK_SETS) do + if engine[self:engineFlagId(set.name, set.flag)] then + out[set.flag] = true + end + end + return out +end + -- wBikeFlags' three bits are ENGINE_* ids like any other flag, so the map -- callbacks that set them (Route16AlwaysOnBikeCallback, -- Route17AlwaysOnBikeCallback) already land on save.engineFlags. @@ -5338,7 +5351,7 @@ function World:sweetScentEncounter() -- stays empty for SWEET SCENT too. local monOpts = nil if roll.species == Unown.SPECIES then - local flags = self:engineFlags() + local flags = self:unownUnlockFlags() if not Unown.anyUnlocked(flags) then return false end monOpts = { dvs = Unown.wildDVs(flags, Mon.randomDVs) } end @@ -7280,7 +7293,7 @@ end -- stack the overworld and the VM under it do not tick at all -- so the wait has -- to be counted by the box itself. Frames, already doubled by Vm:pauseFrames -- the way Script_pause's `ld c, 2 / call DelayFrames` doubles the operand. -function World:showText(body, onDone, stay, hold) +function World:showText(body, onDone, stay, hold, sfxWait) local game = self.game -- The box a PREVIOUS `stay` left standing (TextBox's contract is "whoever -- pushed it owns the pop", src/render/TextBox.lua:40). `yesorno` consumes it @@ -7330,7 +7343,7 @@ function World:showText(body, onDone, stay, hold) game.stack:push(TextBox.new(game, body, function() self.textbox = nil if onDone then onDone() end - end)) + end, sfxWait and { sfxWait = true } or nil)) end function World:pooledNpc(mapId, obj) diff --git a/tests/drivers/gold_jingle_bug1860_test.lua b/tests/drivers/gold_jingle_bug1860_test.lua new file mode 100644 index 00000000..fcf254a4 --- /dev/null +++ b/tests/drivers/gold_jingle_bug1860_test.lua @@ -0,0 +1,131 @@ +-- #1860: jingles cut or dropped by the text blip. GiveItemScript +-- (pokegold engine/overworld/scripting.asm:441-449), Oak's rating +-- (engine/events/prof_oaks_pc.asm:14-21), bug contest judging +-- (engine/events/bug_contest/judging.asm:29-32). +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_jingle_bug1860_test.lua love . +local U = require("tests.drivers.util") +local Sound = require("src.core.Sound") + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map and world.vm, "gold world did not boot") + + local opts = game.save.options or {} + if (opts.sfxVol or 7) == 0 then + U.log("WARNING: options.sfxVol is 0 -- every jingle below will be") + U.log("WARNING: silent; raise SFX volume before judging this by ear") + end + + local results = {} + local function check(label, ok) + results[#results + 1] = (ok and "PASS " or "FAIL ") .. label + end + + local function specialId(name) + for index, entry in ipairs(world.vm.specialOrder or {}) do + if entry == name then return index - 1 end + end + return nil + end + + -- World:specialSound resolves the numeric index (itemIdByIndex), and only + -- a TM_HM-pocket item rings Sfx_GetTm. + local tm + for _, def in pairs(game.data.items or {}) do + if type(def) == "table" and def.pocket == "TM_HM" and def.index then + tm = def.index + break + end + end + check("cache names a TM_HM-pocket item", tm ~= nil) + + -- Runs one scripted moment while mashing A every 4th frame the whole way. + -- everBusy: a gated sfx started at all (the drop half of the bug). + -- maxRun: longest unbroken stretch it kept sounding (the cut half). + -- popEarly: a box with the sfx hold popped while the jingle still rang. + local function runMoment(label, script, minRun) + U.wait(30) + world.vm:start(script) + U.wait(2) + local everBusy, maxRun, run = false, 0, 0 + local sawHeld, popEarly = false, false + local pressIn = 4 + for _ = 1, 1500 do + local busy = Sound.sfxBusy() + if busy then + everBusy = true + run = run + 1 + if run > maxRun then maxRun = run end + else + run = 0 + end + local top = game.stack:top() + if top and top.sfxWait and busy then sawHeld = true end + pressIn = pressIn - 1 + if pressIn <= 0 then + pressIn = 4 + game.input.pressQueue[#game.input.pressQueue + 1] = "a" + game.input.state.a = true + U.wait(1) + game.input.state.a = false + if top and top.sfxWait and busy and game.stack:top() ~= top then + popEarly = true + end + else + U.wait(1) + end + if not world:busy() and not game.stack:top() then break end + end + check(label .. ": a jingle started (not dropped by the blip)", everBusy) + check(("%s: it survived mashed A for %d frames (want >= %d)") + :format(label, maxRun, minRun), maxRun >= minRun) + check(label .. ": the held box refused A while it rang", + sawHeld and not popEarly) + end + + if tm then + runMoment("verbosegiveitem TM", { + { op = "opentext" }, + { op = "verbosegiveitem", args = { tm, 1 } }, + { op = "closetext" }, + { op = "end" }, + }, 60) + end + + local oak = specialId("ProfOaksPCBoot") + check("specialOrder names ProfOaksPCBoot", oak ~= nil) + if oak then + game.save.pokedex = game.save.pokedex or { seen = {}, caught = {} } + game.save.pokedex.caught = {} + for i = 1, 150 do game.save.pokedex.caught["DEX_SEED_" .. i] = true end + runMoment("Oak's dex rating fanfare", { + { op = "opentext" }, + { op = "special", id = oak }, + { op = "closetext" }, + { op = "end" }, + }, 40) + end + + local judging = specialId("BugContestJudging") + check("specialOrder names BugContestJudging", judging ~= nil) + if judging then + runMoment("bug contest place fanfares", { + { op = "opentext" }, + { op = "special", id = judging }, + { op = "closetext" }, + { op = "end" }, + }, 30) + end + + for _, line in ipairs(results) do U.log(line) end + + U.log("Right sounds like: the TM jingle, the dex-rating fanfare and each") + U.log("place fanfare play out in full even while A is mashed; the box under") + U.log("each one only closes once its jingle has finished ringing.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/spinner_rate_bug1831_test.lua b/tests/drivers/spinner_rate_bug1831_test.lua new file mode 100644 index 00000000..96020c96 --- /dev/null +++ b/tests/drivers/spinner_rate_bug1831_test.lua @@ -0,0 +1,99 @@ +-- #1831: spinner tiles whirled the sprite per DISPLAY frame and flickered +-- the arrows at twice the cart's rate (engine/overworld/spinners.asm:1-22, +-- home/overworld.asm:41-44,268-272). +-- POKEPORT_DRIVER=tests/drivers/spinner_rate_bug1831_test.lua \ +-- POKEPORT_IDENTITY=bug1831 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local TileRenderer = require("src.render.TileRenderer") + + local results = {} + local function check(label, ok) + results[#results + 1] = (ok and "PASS " or "FAIL ") .. label + end + + -- data/generated/field.lua ROCKET_HIDEOUT_B2F: the arrow at (12, 9) + -- slides 10 cells left, the longest straight ride on the floor. + U.teleport(game, "ROCKET_HIDEOUT_B2F", 13, 9, "left") + U.wait(10) + local ow = game.overworld + check("overworld is up on ROCKET_HIDEOUT_B2F", ow ~= nil and ow.player ~= nil) + + -- one step left onto the arrow starts the forced slide + U.hold(game, "left", 20) + local spinning = false + for _ = 1, 30 do + if ow.player.spinning then spinning = true break end + U.wait(1) + end + check("stepping on the arrow starts the spin", spinning) + + -- sample per fixed step while the slide runs + local facings, timers, blurs = {}, {}, {} + for _ = 1, 200 do + if not ow.player.spinning then break end + local _, _, _, facing = ow.player:pose() + facings[#facings + 1] = facing + timers[#timers + 1] = ow.player.spinTimer or 0 + blurs[#blurs + 1] = TileRenderer.spinBlurActive() + U.wait(1) + end + check(("the slide gave %d samples (want >= 32)"):format(#facings), + #facings >= 32) + + -- spinTimer ticks once per fixed step, not per rendered frame + local ticks = true + for i = 2, #timers do + if timers[i] - timers[i - 1] ~= 1 then ticks = false end + end + check("spinTimer advances exactly once per fixed step", ticks) + + -- each facing holds for 2 fixed steps: one quarter-turn per OverworldLoop + -- iteration, two frames each (home/overworld.asm:41-44) + local runs, run = {}, 1 + for i = 2, #facings do + if facings[i] == facings[i - 1] then + run = run + 1 + else + runs[#runs + 1] = run + run = 1 + end + end + local twos, others = 0, 0 + for i = 2, #runs do -- the first run starts mid-phase, skip it + if runs[i] == 2 then twos = twos + 1 else others = others + 1 end + end + check(("facing holds 2 steps (%d runs of 2, %d other)"):format(twos, others), + twos >= 8 and others == 0) + + -- arrow blur half-period is 16 frames: one whole 16-frame walked tile + -- per wSimulatedJoypadStatesIndex parity (spinners.asm:18-22). The clock + -- advances on the draw path, so allow one frame of sampling skew; the old + -- bug read 8 here. + local span, spans = 1, {} + for i = 2, #blurs do + if blurs[i] == blurs[i - 1] then + span = span + 1 + else + spans[#spans + 1] = span + span = 1 + end + end + local good, bad = 0, 0 + for i = 2, #spans do -- first span starts mid-phase, skip it + if spans[i] >= 15 and spans[i] <= 17 then good = good + 1 + else bad = bad + 1 end + end + check(("blur toggles every ~16 frames (%d good, %d off)"):format(good, bad), + good >= 1 and bad == 0) + + for _, line in ipairs(results) do U.log(line) end + + U.log("Right looks like: while the player is swept along the arrow the") + U.log("sprite makes roughly one full turn per 8 frames, a lazy whirl, and") + U.log("the arrow tiles swap between blur and static about twice a second.") + + while true do + coroutine.yield() + end +end diff --git a/tests/engine/gen2_flypoint_rebind_bug1836.lua b/tests/engine/gen2_flypoint_rebind_bug1836.lua new file mode 100644 index 00000000..94686586 --- /dev/null +++ b/tests/engine/gen2_flypoint_rebind_bug1836.lua @@ -0,0 +1,67 @@ +-- pokecrystal constants/engine_flags.asm:66-92 vs pokegold :65-91 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local FieldMoves = require("src.world.gen2.FieldMoves") + +-- pokegold constants/engine_flags.asm:65-91, in const_def order from +-- ENGINE_RADIO_CARD = 0: PALLET 52 .. INDIGO_PLATEAU 63, NEW_BARK 64 .. +-- SILVER_CAVE 75. +local GOLD_IDS = { + SPAWN_PALLET = 52, SPAWN_VIRIDIAN = 53, SPAWN_PEWTER = 54, + SPAWN_CERULEAN = 55, SPAWN_ROCK_TUNNEL = 56, SPAWN_VERMILION = 57, + SPAWN_LAVENDER = 58, SPAWN_SAFFRON = 59, SPAWN_CELADON = 60, + SPAWN_FUCHSIA = 61, SPAWN_CINNABAR = 62, SPAWN_INDIGO = 63, + SPAWN_NEW_BARK = 64, SPAWN_CHERRYGROVE = 65, SPAWN_VIOLET = 66, + SPAWN_AZALEA = 67, SPAWN_CIANWOOD = 68, SPAWN_GOLDENROD = 69, + SPAWN_OLIVINE = 70, SPAWN_ECRUTEAK = 71, SPAWN_MAHOGANY = 72, + SPAWN_LAKE_OF_RAGE = 73, SPAWN_BLACKTHORN = 74, SPAWN_MT_SILVER = 75, +} + +local function byFlag() + local out = {} + for _, row in ipairs(FieldMoves.FLYPOINTS) do out[row.spawn] = row.flag end + return out +end + +-- A Gold cache carries no engineFlagOrder; the table keeps its literal ids. +FieldMoves.bindEngineFlags(nil) +local gold = byFlag() +for spawn, id in pairs(GOLD_IDS) do + T.eq(gold[spawn], id, spawn .. " keeps the pokegold id with no order") +end + +-- pokecrystal constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM at index 16 +-- shifts every later id up exactly one. +local crystalOrder = {} +for _, row in ipairs(FieldMoves.FLYPOINTS) do + -- order[i] names id i-1, so the Crystal id (gold + 1) sits at gold + 2 + crystalOrder[GOLD_IDS[row.spawn] + 2] = row.name +end +FieldMoves.bindEngineFlags(crystalOrder) +local crystal = byFlag() +for spawn, id in pairs(GOLD_IDS) do + T.eq(crystal[spawn], id + 1, spawn .. " rebinds one higher under Crystal") +end + +-- The Kanto gate reads SPAWN_INDIGO through the rebound id: Crystal's 64, +-- which the old table misread as Gold's SPAWN_NEW_BARK. +local save = { engineFlags = { [64] = true } } +T.check(FieldMoves.hasVisitedSpawn(save, "SPAWN_INDIGO"), + "Crystal flag 64 is the INDIGO_PLATEAU flypoint") +local kanto = FieldMoves.flyPoints(save, nil, "kanto") +T.eq(#kanto, 1, "the Kanto half opens on it") +T.eq(kanto[1].spawn, "SPAWN_INDIGO", "with the plateau row itself") + +-- And a rebind back to a Gold cache restores the literal ids. +FieldMoves.bindEngineFlags(nil) +local again = byFlag() +for spawn, id in pairs(GOLD_IDS) do + T.eq(again[spawn], id, spawn .. " returns to the pokegold id") +end +local goldSave = { engineFlags = { [63] = true } } +T.check(FieldMoves.hasVisitedSpawn(goldSave, "SPAWN_INDIGO"), + "Gold flag 63 is INDIGO_PLATEAU again") + +T.finish("gen2 flypoint rebind bug 1836") diff --git a/tests/gen2_unown_test.lua b/tests/gen2_unown_test.lua index a5dc9e74..dee63d4b 100644 --- a/tests/gen2_unown_test.lua +++ b/tests/gen2_unown_test.lua @@ -526,6 +526,93 @@ for _, id in ipairs(Screens.GEN2_IDS) do end check("Gen2UnownPuzzle is a screen id", registered, true) +-- ================================================= Crystal's flag skew (#1834) +-- +-- pokecrystal constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM shifts the +-- unlock flags to 43-46 (:57-60); pokegold keeps 42-45 (:56-59). +do + local World = require("src.world.gen2.World") + local crystalOrder = {} + for _, set in ipairs(Unown.UNLOCK_SETS) do + crystalOrder[set.flag + 2] = set.name + end + local function stubWorld(engineFlags, order) + return setmetatable({ + constants = { engineFlagOrder = order }, + game = { save = { engineFlags = engineFlags } }, + }, { __index = World }) + end + + -- what a Crystal save's raw flags used to feed Unown directly: L-Z only + local rawCrystal = { [43] = true, [44] = true, [45] = true, [46] = true } + local leaked = Unown.unlockedLetters(rawCrystal) + check("raw Crystal flags leak only 15 letters", #leaked, 15) + check("and the first survivor is L", leaked[1], 12) + + local view = stubWorld(rawCrystal, crystalOrder):unownUnlockFlags() + local resolved = Unown.unlockedLetters(view) + check("the resolved view unlocks all 26", #resolved, 26) + check("A included", resolved[1], 1) + + -- the same distribution through wildDVs itself + math.randomseed(1) + local function roll() + return { attack = math.random(0, 15), defense = math.random(0, 15), + speed = math.random(0, 15), special = math.random(0, 15) } + end + local seenOld, seenNew = {}, {} + for _ = 1, 4000 do + seenOld[Unown.letterFromDVs(Unown.wildDVs(rawCrystal, roll))] = true + seenNew[Unown.letterFromDVs(Unown.wildDVs(view, roll))] = true + end + local countOld, countNew = 0, 0 + for _ in pairs(seenOld) do countOld = countOld + 1 end + for _ in pairs(seenNew) do countNew = countNew + 1 end + check("raw flags roll only 15 letters", countOld, 15) + check("raw flags never roll A", seenOld[1], nil) + check("the resolved view rolls all 26", countNew, 26) + + -- one chamber: Crystal's setflag 43 is Kabuto's A-K, not L-R + local kabuto = Unown.unlockedLetters( + stubWorld({ [43] = true }, crystalOrder):unownUnlockFlags()) + check("Crystal flag 43 is A-K", #kabuto, 11) + check("starting at A", kabuto[1], 1) + + -- Crystal's 42 is ENGINE_EARTHBADGE and must not unlock a chamber + check("Crystal flag 42 unlocks nothing", Unown.anyUnlocked( + stubWorld({ [42] = true }, crystalOrder):unownUnlockFlags()), false) + + -- a Gold cache has no engineFlagOrder and keeps its own ids + local gold = Unown.unlockedLetters( + stubWorld({ [42] = true }, nil):unownUnlockFlags()) + check("Gold flag 42 stays A-K", #gold, 11) +end + +-- ============================================= the letters table's keys (#1834) +-- +-- RomExtractorGen2 keys `letters` by "A".."Z"; monLetter answers a number, so +-- the anim lookup must convert or every letter animates as A. +do + local BattleState = require("src.ui.gen2.BattleState") + local SummaryMenu = require("src.ui.gen2.SummaryMenu") + local animA, animI = { sheet = "sheet-a" }, { sheet = "sheet-i" } + data.pokemon.UNOWN.anim = animA + data.pokemon.UNOWN.letters.I.anim = animI + local screen = { pokemon = data.pokemon } + check("animData picks the mon's own letter", + BattleState.animData(screen, unown), animI) + local plain = Mon.new(data, "UNOWN", 5, { dvs = Unown.dvsForLetter(2) }) + check("a letter with no anim row falls back to the species'", + BattleState.animData(screen, plain), animA) + -- StatsScreen_PlaceFrontpic reads the same letter row + -- (../pokecrystal/engine/pokemon/stats_screen.asm:889-901) + local asked + local summary = { mon = unown, pokemon = data.pokemon, + picImage = function(_, sheet) asked = sheet return nil end } + SummaryMenu.startPicAnim(summary) + check("the summary menu reads the same letter row", asked, "sheet-i") +end + print(("gen2 unown: %d checks, %d failures"):format(checks, failures)) -- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit -- here would take the whole tier down and silently skip every suite after it. diff --git a/tests/parity_G.lua b/tests/parity_G.lua index 1cea8b88..88e96695 100644 --- a/tests/parity_G.lua +++ b/tests/parity_G.lua @@ -44,11 +44,14 @@ end TileRenderer.setSpinning(false) check(not TileRenderer.spinBlurActive(), "no arrow blur frame outside a spin") +-- one toggle per 16-frame walked tile: wSimulatedJoypadStatesIndex drops +-- once per JoypadOverworld call (home/overworld.asm:1844-1846) and +-- spinners.asm:18-22 reads its bit 0 (#1831) TileRenderer.setSpinning(true) local a = TileRenderer.spinBlurActive() -for i = 1, 8 do TileRenderer.tick() end +for i = 1, 16 do TileRenderer.tick() end local b = TileRenderer.spinBlurActive() -check(a ~= b, "arrow blur frame toggles every ~8 ticks while spinning") +check(a ~= b, "arrow blur frame toggles every ~16 ticks while spinning") TileRenderer.setSpinning(false) check(not TileRenderer.spinBlurActive(), "blur frame turns off once the spin ends") From bd3e02986d210e5a91e30f65017130ff2afe48e6 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 27 Aug 2026 07:11:43 -0400 Subject: [PATCH 7/8] test fix and iOS close hide --- src/import/LauncherView.lua | 10 ++- tests/parity_I_M.lua | 4 +- tests/parity_applying_attack_anim.lua | 13 +-- tests/parity_celadon_roof_readables.lua | 46 +++++++---- tests/parity_lance.lua | 2 +- tests/parity_rare_candy_menu.lua | 3 + tests/parity_rocket3_sight_bug1814.lua | 5 ++ tests/rom_importer_choose_version_test.lua | 26 ++++++ tests/run_tests.lua | 92 ++++++++++++++-------- 9 files changed, 141 insertions(+), 60 deletions(-) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 796868f3..d23badb0 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1394,7 +1394,9 @@ local function buildHeader(imp, m) -- under the gear and the quit X -- "the settings is covering the logo". -- Reserving the space on both sides costs a little width and cannot -- overlap at any window size. - local clusterW = 2 * gear + math.floor(6 * m.s) + m.pad + -- iOS has no quit button (the OS owns app exit), so the cluster is the + -- gear alone and the wordmark gets that width back + local clusterW = (imp.ios and gear or 2 * gear + math.floor(6 * m.s)) + m.pad local boxX = m.x + clusterW local boxW = math.max(0, m.w - 2 * clusterW) if imp.logo and boxW > 0 then @@ -1428,8 +1430,8 @@ local function buildHeader(imp, m) -- inboard of it -- but the two are REGISTERED gear first, because the first -- focusable of the first frame adopts the keyboard ring and that must not be -- the button that exits the app. - local quitX = rx - gear - rx = quitX - math.floor(6 * m.s) + local quitX = not imp.ios and rx - gear or nil + if quitX then rx = quitX - math.floor(6 * m.s) end -- Settings gear. It now also owns the CONTROL settings (touch overlay -- editor, reset rebinds), which used to be buttons stacked in the game @@ -1441,7 +1443,7 @@ local function buildHeader(imp, m) chrome.gear.image = imp._gearIcon btn(imp, rx, by, gear, gear, "gear", "", chrome.gear) - btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit) + if quitX then btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit) end -- The self-update control lives in the FOOTER next to the BCG mark (small, -- out of the wordmark's way -- it used to overlap the logo on a phone). It diff --git a/tests/parity_I_M.lua b/tests/parity_I_M.lua index d40e1a64..84400482 100644 --- a/tests/parity_I_M.lua +++ b/tests/parity_I_M.lua @@ -303,7 +303,9 @@ Game.save.forcedBike = true ow = pushOW("ROUTE_17", 4, 10, "down") ow:flyTo("PALLET_TOWN") eq(Game.save.forcedBike, nil, "Fly clears BIT_ALWAYS_ON_BIKE") -ow.flyAnim, ow.flyDest, ow.player.inputLocked = nil, nil, false -- undo flyTo +-- undo flyTo, flyFade included: it re-arms flyAnim when it drains +ow.flyAnim, ow.flyDest, ow.flyFade = nil, nil, nil +ow.player.inputLocked = false Game.save.forcedBike = true ow:warpToHealPoint() eq(Game.save.forcedBike, nil, "blackout/escape warps clear BIT_ALWAYS_ON_BIKE") diff --git a/tests/parity_applying_attack_anim.lua b/tests/parity_applying_attack_anim.lua index 9b7e5dea..14256557 100644 --- a/tests/parity_applying_attack_anim.lua +++ b/tests/parity_applying_attack_anim.lua @@ -35,12 +35,12 @@ local check, eq = S.check, S.eq -- rng floor: accuracyRoll compares rng(0, 255) against the scaled accuracy, -- so the lowest roll lands HYPNOSIS (60%) every run -local function freshBattle() +local function freshBattle(rng) Game.save.options.animations = true Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 30) } local tb = BattleState.newWild(Game, "PIDGEY", 10) tb.queue, tb.nextInsert = {}, 0 - tb.rng = function(a) return a end + tb.rng = rng or function(a) return a end return tb end @@ -53,8 +53,8 @@ local function hitRows(tb) return out end -local function typeOf(moveId, isPlayer) - local tb = freshBattle() +local function typeOf(moveId, isPlayer, rng) + local tb = freshBattle(rng) local user = isPlayer and tb.player or tb.enemy local target = isPlayer and tb.enemy or tb.player tb:performMove(user, target, { id = moveId, pp = 10 }, false) @@ -111,7 +111,10 @@ do eq(typeOf("HYPNOSIS", false), 3, "and type 3 from the foe") eq(typeOf("GROWL", true), 6, "GROWL is a primary stat drop: type 6") eq(typeOf("TAIL_WHIP", true), 6, "so is TAIL_WHIP") - eq(typeOf("SAND_ATTACK", false), 3, "the foe's SAND-ATTACK is type 3") + -- StatModifierDownEffect misses on a roll under $40 on the enemy's + -- turn -- engine/battle/effects.asm:552 + eq(typeOf("SAND_ATTACK", false, function() return 0x40 end), 3, + "the foe's SAND-ATTACK is type 3") eq(typeOf("POISONPOWDER", true), 6, "POISONPOWDER is type 6") eq(typeOf("CONFUSE_RAY", true), 6, "CONFUSE RAY is type 6") eq(typeOf("DISABLE", true), 6, "DISABLE is type 6") diff --git a/tests/parity_celadon_roof_readables.lua b/tests/parity_celadon_roof_readables.lua index a8e922b2..bf02d104 100644 --- a/tests/parity_celadon_roof_readables.lua +++ b/tests/parity_celadon_roof_readables.lua @@ -90,7 +90,7 @@ end eq(hooks.onInteract(game, ow, 2, 0), false, "the wall left of the blackboard stays silent") --- walk the loop: intro -> prompt -> heading box -> blurb -> prompt again +-- walk the loop: intro -> held prompt -> heading menu over it stack = {} hooks.onInteract(game, ow, 3, 0) local intro = stack[#stack] @@ -100,36 +100,52 @@ local prompt = stack[#stack] check(getmetatable(prompt) == TextBox and pages(prompt):find("heading", 1, true) ~= nil, "the intro leads into the which-heading prompt") -prompt.onDone() +-- #591: LinkCableHelpText2 ends in `text_end`, so .linkHelpLoop leaves it on +-- screen and runs HandleMenuInput under it +check(prompt.onDone == nil and prompt.stay ~= nil, + "the prompt is held open instead of waiting for A and popping") +prompt.stay.onShown() local menu = stack[#stack] -check(getmetatable(menu) == Menu, "the prompt opens the heading menu") +check(getmetatable(menu) == Menu, "the held prompt opens the heading menu") +eq(stack[#stack - 1], prompt, "the menu sits on the still-visible prompt box") eq(#menu.items, 4, "four headings, as in HowToLinkText") local labels = {} for i, item in ipairs(menu.items) do labels[i] = item.label end eq(table.concat(labels, "/"), "HOW TO LINK/COLOSSEUM/TRADE CENTER/STOP READING", "the headings read in HowToLinkText order") -eq(menu.items[4].onSelect, nil, "STOP READING just closes the menu") check(menu.tw == 15 and menu.th == 10 and menu.tx == 0 and menu.ty == 0, "the box is the asm's 15x10 at the top left") for i = 1, 3 do - stack = {} + stack = { prompt, menu } + menu.index = i + eq(menu.items[i].keepOpen, true, + labels[i] .. " leaves the menu up, as `jp .linkHelpLoop` does") menu.items[i].onSelect() local blurb = stack[#stack] - check(getmetatable(blurb) == TextBox, - labels[i] .. " prints a text box") + check(getmetatable(blurb) == TextBox, labels[i] .. " prints a text box") local want = Data.text["_LinkCableInfoText" .. i]:match("^[^\n\011\012]+") check(pages(blurb):find(want, 1, true) ~= nil, labels[i] .. " prints _LinkCableInfoText" .. i) - check(type(blurb.onDone) == "function", - labels[i] .. " returns to the prompt instead of dropping out") - blurb.onDone() - check(getmetatable(stack[#stack]) == TextBox, - "the prompt comes back after " .. labels[i]) - stack[#stack].onDone() - check(getmetatable(stack[#stack]) == Menu, - "the menu comes back after " .. labels[i]) + eq(blurb.onDone, nil, + labels[i] .. " pops itself back onto the menu instead of dropping out") + table.remove(stack) -- the blurb pops itself once it has been read + eq(stack[#stack], menu, "the same menu comes back after " .. labels[i]) + eq(menu.index, i, "the cursor stays on the heading that was just read") + eq(stack[#stack - 1], prompt, "the held prompt is still under it") end +-- STOP READING and B share .exit: both close the menu and the prompt box +eq(menu.items[4].keepOpen, nil, "STOP READING closes the menu") +stack = { prompt, menu } +table.remove(stack) -- Menu pops itself before a non-keepOpen onSelect runs +menu.items[4].onSelect() +eq(#stack, 0, "STOP READING closes the menu and the held prompt together") +check(type(menu.onCancel) == "function", "B is watched (PAD_A | PAD_B)") +stack = { prompt, menu } +table.remove(stack) +menu.onCancel() +eq(#stack, 0, "B closes the menu and the held prompt together") + S.finish() diff --git a/tests/parity_lance.lua b/tests/parity_lance.lua index c22a4b76..960a0a5e 100644 --- a/tests/parity_lance.lua +++ b/tests/parity_lance.lua @@ -138,4 +138,4 @@ do eq(#pushed, 0, "loss does not push after-battle text") end -print("parity_lance: ok") +S.finish() diff --git a/tests/parity_rare_candy_menu.lua b/tests/parity_rare_candy_menu.lua index d9e188da..4268ce31 100644 --- a/tests/parity_rare_candy_menu.lua +++ b/tests/parity_rare_candy_menu.lua @@ -29,8 +29,11 @@ local Bag = require("src.inventory.Bag") local realTextBox = package.loaded["src.render.TextBox"] local realBag = package.loaded["src.ui.BagMenu"] local realParty = package.loaded["src.ui.PartyMenu"] +-- soundOpts only builds an opts table, so the real one runs against the stub. +local soundOpts = require("src.render.TextBox").soundOpts package.loaded["src.render.TextBox"] = { new = function(_, text, done) return { textBox = true, text = text, done = done } end, + soundOpts = soundOpts, } package.loaded["src.ui.BagMenu"] = nil package.loaded["src.ui.PartyMenu"] = nil diff --git a/tests/parity_rocket3_sight_bug1814.lua b/tests/parity_rocket3_sight_bug1814.lua index 6145cd0c..e9b506be 100644 --- a/tests/parity_rocket3_sight_bug1814.lua +++ b/tests/parity_rocket3_sight_bug1814.lua @@ -50,6 +50,9 @@ check(hideout.noSight == nil or hideout.noSight[TEXT] ~= true, -- the LIFT KEY half: after the battle -- however it started -- the next talk -- reveals the ball +-- put the real module back at the bottom: the tier dofiles every parity file +-- into one process, so a stub left behind here breaks every later suite +local realTextBox = package.loaded["src.render.TextBox"] package.loaded["src.render.TextBox"] = { new = function(_, text, done) return { text = text, done = done } end, soundOpts = function() return nil end, @@ -77,4 +80,6 @@ check(toggles and toggles.ROCKETHIDEOUTB4F_LIFT_KEY == true, "the LIFT KEY ball is shown") check(finished, "and the talk hands control back") +package.loaded["src.render.TextBox"] = realTextBox + S.finish() diff --git a/tests/rom_importer_choose_version_test.lua b/tests/rom_importer_choose_version_test.lua index 35b5bacc..1f8402e2 100644 --- a/tests/rom_importer_choose_version_test.lua +++ b/tests/rom_importer_choose_version_test.lua @@ -8,6 +8,11 @@ -- findPendingRom, answered with the first dump whose SHA-1 mapped to any -- not-yet-ready version, so the selection was dropped on the floor. -- +-- Since 592daafa the in-launcher Kit.FileBrowser is tried ahead of that scan, +-- so the save-dir scan is now what a build without the Kit browser takes. +-- Kit is stubbed away below to reach it; the last block covers the browser +-- being present. +-- -- Self-contained: `luajit tests/rom_importer_choose_version_test.lua` package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end @@ -51,6 +56,9 @@ local saved = { -- to a dialog that may or may not exist on the machine running the suite. -- The fallback under test is the same one every pickerless device takes. love.system.getOS = function() return "Unknown" end +-- No Kit browser in this build, so Choose falls through to the save-dir scan. +local savedKit = package.loaded["src.ui.kit.Kit"] +package.loaded["src.ui.kit.Kit"] = { FileBrowser = nil } love.filesystem.getSaveDirectory = function() return "/tmp/pokemon-love2d" end love.filesystem.getDirectoryItems = function() return LISTING end love.filesystem.getInfo = function(name, filter) @@ -116,6 +124,24 @@ ri:choose("red") check(ri._started == nil, "and a version already imported is not extracted a second time") +-- ------- with the Kit browser present it opens instead of scanning + +local opened = nil +package.loaded["src.ui.kit.Kit"] = { + FileBrowser = { + open = function(opts) opened = opts end, + }, +} +ri = freshImporter() +ri:choose("blue") +check(ri._started == nil and opened ~= nil, + "a build with the Kit browser opens it rather than scanning the save dir") +eq(opened and opened.title, "Select " + .. (GameVersion.info("blue").displayName or "ROM"), + "titled for the version that was chosen") + +package.loaded["src.ui.kit.Kit"] = savedKit + for name, fn in pairs(saved) do if name == "getOS" then love.system.getOS = fn elseif name == "hash" then love.data.hash = fn diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 64adac34..716bf5bf 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -12,6 +12,9 @@ love = require("tests.love_stub") -- assertions and the RNG seed come off the shared harness; this file keeps -- its own tail because the verdict line ("N FAILURES") is what CI greps local T = require("tests.harness") +-- every suite below is dofile'd in this process, so a suite that reaches for +-- T.finish must raise into runSuites' pcall instead of os.exit(0)-ing the tier +_G.POKEPORT_TEST_CHILD = true -- this suite has always streamed a line per check, and it is the one a -- developer watches for progress through ~1600 assertions T.verbose = true @@ -437,11 +440,13 @@ check(Damage.compute(ruleset, confused, confused, confMove, -- (StatModifierDownEffect's side-effect branch skips MoveHitTest) local MoveEffects = require("src.battle.MoveEffects") local sideRng = { rng = function() return 0 end } +-- player attacker: effects.asm:552's 25 percent miss is the enemy's branch +local sideUser = { isPlayer = true, stages = {}, mon = {} } local misted = { stages = {}, mist = true, name = "MISTY", mon = {} } -MoveEffects.secondary.ATTACK_DOWN_SIDE_EFFECT(sideRng, nil, misted) +MoveEffects.secondary.ATTACK_DOWN_SIDE_EFFECT(sideRng, sideUser, misted) eq(misted.stages.attack, -1, "secondary stat drop pierces MIST") local misted2 = { stages = {}, mist = true, name = "MISTY", mon = {} } -local mistMsgs = MoveEffects.primary.ATTACK_DOWN1_EFFECT(sideRng, nil, misted2) +local mistMsgs = MoveEffects.primary.ATTACK_DOWN1_EFFECT(sideRng, sideUser, misted2) check(misted2.stages.attack == nil and mistMsgs[1]:find("MIST", 1, true) ~= nil, "primary stat drop still blocked by MIST") @@ -2644,7 +2649,8 @@ do -- the text-speed cursor clamps at its ends (.pressedLeftInTextSpeed), -- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / VIDEO MODE -- cycle their display modes (SHADER FX activates a pushed screen instead). -do +-- Nested function so LuaJIT's 200-local main-chunk limit is not hit. +(function() local OptionsMenu = require("src.ui.OptionsMenu") local OInput = require("src.core.Input") local PaletteFX = require("src.render.PaletteFX") @@ -2656,8 +2662,19 @@ do -- Isolate from earlier save/options writes in this suite SD.saveOptions(SD.defaultOptions()) local popped = false + local om + -- the rows sit on group pages now, so the stub needs a real push/pop/top + -- stack for a group opener to have anywhere to go + local ostack = { states = {} } + function ostack:push(s) self.states[#self.states + 1] = s end + function ostack:pop() + local s = table.remove(self.states) + if s == om then popped = true end + return s + end + function ostack:top() return self.states[#self.states] end local og = { data = Data, save = SD.newGame(), - input = OInput, stack = { pop = function() popped = true end }, + input = OInput, stack = ostack, writeOptions = function(self) SD.saveOptions(self.save.options) end, -- the PERFORMANCE row routes through Game:applyOptions; the -- stub carries the headless slice of it (the tier record), @@ -2665,24 +2682,29 @@ do applyOptions = function(self, o) require("src.core.Performance").applyOptions(o) end } - local om = OptionsMenu.new(og) + om = OptionsMenu.new(og) + ostack:push(om) + local OptionRows = require("src.ui.OptionRows") + -- cur is whichever screen the cursor is on: the top level, or the group + -- page seek() walked into + local cur = om local function press(btn) OInput.pressed = { [btn] = true } - om:update(1 / 60) + cur:update(1 / 60) OInput.pressed = {} end - -- walk the cursor down to a row by id, so a row added to OptionsMenu - -- shifts these blocks instead of silently retargeting them + local function rowAt(s) + local rows = s.view or s.rows + return rows[s.index] + end + -- put the cursor on a row by id, opening its group page first, so a row + -- added to OptionsMenu shifts these blocks instead of silently + -- retargeting them local function seek(id) - local want = -1 - for i, row in ipairs(om.rows) do - if row.id == id then want = i end - end - for _ = 1, #om.rows do - if om.index == want then break end - press("down") - end - return om.index == want + while ostack:top() ~= om do ostack:pop() end + cur = om:focusRow(id) or om + local row = rowAt(cur) + return row ~= nil and row.id == id end eq(og.save.options.textSpeed, 3, "new saves default to MEDIUM text (InitOptions TEXT_DELAY_MEDIUM)") @@ -2699,9 +2721,11 @@ do "A switches the battle screen to the WIDE layout") press("a") eq(og.save.options.battleLayout, "og", "BATTLE LAYOUT wraps back to OG") + -- the BATTLE page's sixth row sits past the 4-box viewport + check(seek("battleBg"), "cursor reaches BATTLE BG") + eq(cur.scroll, cur.index - OptionRows.VISIBLE, + "viewport scrolls to keep a deep group row on screen") check(seek("musicVol"), "cursor reaches MUSIC VOL") - eq(om.scroll, om.index - require("src.ui.OptionRows").VISIBLE, - "viewport scrolls to keep MUSIC VOL on screen") press("left") eq(og.save.options.musicVol, 6, "left lowers MUSIC VOL") press("right") @@ -2733,19 +2757,18 @@ do eq(og.save.options.tilt, 0, "TILT wraps back to OFF") check(seek("shaderfx"), "cursor reaches SHADER FX") -- this row activates a pushed ShaderFXScreen rather than cycling in - -- place like the rest of this suite's rows; `og.stack` above only - -- stubs `pop`, not a real push/top stack, so activate() is not - -- called here -- tests/mod_ui_tests.lua exercises it end to end against - -- a real stack. - check(om.rows[om.index].step == nil, "SHADER FX row has no step()") - check(type(om.rows[om.index].activate) == "function", + -- place like the rest of this suite's rows, so activate() is not called + -- here -- tests/mod_ui_tests.lua exercises it end to end against a real + -- game. + check(rowAt(cur).step == nil, "SHADER FX row has no step()") + check(type(rowAt(cur).activate) == "function", "SHADER FX row has an activate()") check(seek("shaderfx2"), "cursor reaches SHADER FX 2") -- the dual-shader secondary slot: same shared ShaderFXScreen, opened on -- "secondary" instead -- see the SHADER FX row above for why activate() - -- isn't exercised against this stub stack either. - check(om.rows[om.index].step == nil, "SHADER FX 2 row has no step() either") - check(type(om.rows[om.index].activate) == "function", + -- isn't exercised here either. + check(rowAt(cur).step == nil, "SHADER FX 2 row has no step() either") + check(type(rowAt(cur).activate) == "function", "SHADER FX 2 row has an activate()") check(seek("zoom"), "cursor reaches ZOOM") local ZoomOpt = require("src.render.Zoom") @@ -2804,10 +2827,10 @@ do check(seek("dateFormat"), "cursor reaches DATE FORMAT") check(seek("timeFormat"), "cursor reaches TIME FORMAT") press("down") - -- CANCEL is appended after the descriptor list rather than living in it, so - -- it lands one past #rows and the window holds the last six boxes. Counted - -- off #rows so the next row added here is not read as a wrap bug. - local cancelRow = #om.rows + 1 + -- CANCEL is appended after the top-level view rather than living in it, so + -- it lands one past #view and the window holds the last six boxes. Counted + -- off #view so the next row added here is not read as a wrap bug. + local cancelRow = #om.view + 1 eq(om.index, cancelRow, "CANCEL stays the fixed final row") eq(om.scroll, cancelRow - 5, "CANCEL keeps the last option boxes on screen") om:draw() -- smoke: scrolled layout draws under the headless stub @@ -2826,7 +2849,7 @@ do require("src.render.Zoom").applyOptions(og.save.options) require("src.render.TileRenderer").applyOptions(og.save.options) require("src.core.VideoMode").applyOptions(og.save.options) -end +end)() end -- ------------------------------------------------------------------ @@ -3224,7 +3247,8 @@ do local TileRenderer = require("src.render.TileRenderer") TileRenderer.setSpinning(true) local before = TileRenderer.spinBlurActive() - for _ = 1, 8 do TileRenderer.tick(1 / 60) end + -- the blur alternates once per simulated-joypad step, 16 frames (#1831) + for _ = 1, 16 do TileRenderer.tick(1 / 60) end check(before ~= TileRenderer.spinBlurActive(), "tick(1/60) advances water/spinner clock at fixed 60Hz") local mid = TileRenderer.spinBlurActive() From 8b56ec2d7781e56f2c39a1c2bc45fcaf79e17743 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 27 Aug 2026 07:39:58 -0400 Subject: [PATCH 8/8] big egg energy --- src/core/gen2/Breeding.lua | 6 ++++-- tests/gen2_breeding_test.lua | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/core/gen2/Breeding.lua b/src/core/gen2/Breeding.lua index 57db6832..f7d03e16 100644 --- a/src/core/gen2/Breeding.lua +++ b/src/core/gen2/Breeding.lua @@ -1101,8 +1101,10 @@ function Breeding.hatch(data, save, index, nickname, where) hatched.experience = egg.experience -- `ld a, [de] / ld [hli], a` twice: HP := MaxHP. hatched.hp = hatched.maxHp - hatched.ot = egg.ot or (save.player and save.player.name) - hatched.otId = egg.otId or (save.player and save.player.id) + -- HatchEggs overwrites OT unconditionally, so the ODD_EGG's "ODD" and its + -- table id do not survive -- engine/pokemon/breeding.asm:299-309 + hatched.ot = (save.player and save.player.name) or egg.ot + hatched.otId = (save.player and save.player.id) or egg.otId hatched.caughtLevel = egg.level or Breeding.EGG_LEVEL -- SetEggMonCaughtData swaps wCurPartyLevel for CAUGHT_EGG_LEVEL around the -- shared setter -- engine/pokemon/caught_data.asm:235-246. diff --git a/tests/gen2_breeding_test.lua b/tests/gen2_breeding_test.lua index 532512bb..f14c9507 100644 --- a/tests/gen2_breeding_test.lua +++ b/tests/gen2_breeding_test.lua @@ -645,6 +645,16 @@ save = newSave({ eggWith(0) }) hatched = Breeding.hatch(DATA, save, 1, "SPROUT") eq(hatched.nickname, "SPROUT", "a nickname taken from the naming screen sticks") +-- HatchEggs writes wPlayerID / wPlayerName over whatever the egg carried, so +-- the ODD_EGG's "ODD" and its table id do not survive the hatch. +-- engine/pokemon/breeding.asm:299-309 +save = newSave({ eggWith(0) }) +save.party[1].ot = "ODD" +save.party[1].otId = 2048 +hatched = Breeding.hatch(DATA, save, 1) +eq(hatched.ot, "GOLD", "the hatchling's OT becomes the player") +eq(hatched.otId, 1234, "and MON_OT_ID becomes wPlayerID") + eq(#Breeding.readyToHatch(newSave({ eggWith(0), eggWith(2), eggWith(0) })), 2, "readyToHatch names every spent counter")