From eed246f6453f57d0f3b9d804fa6c7603ffb287cb Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 27 Aug 2026 06:09:50 -0400 Subject: [PATCH] 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")