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/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/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/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/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/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/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/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/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/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/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/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..56722092 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. @@ -66,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 @@ -81,7 +129,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 @@ -276,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 @@ -297,6 +359,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 @@ -378,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() @@ -393,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 @@ -430,6 +508,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/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/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/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/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/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..c22733f4 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -605,23 +605,66 @@ 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 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/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) diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index 7ce631d8..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 @@ -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/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 56bbdba9..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 @@ -5618,15 +5631,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 +6207,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 @@ -7277,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 @@ -7327,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/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_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/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/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/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_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/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/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" 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_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") 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() 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/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/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_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") 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_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_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_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_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_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_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_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_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 new file mode 100644 index 00000000..e9b506be --- /dev/null +++ b/tests/parity_rocket3_sight_bug1814.lua @@ -0,0 +1,85 @@ +-- 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 +-- 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, + 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") + +package.loaded["src.render.TextBox"] = realTextBox + +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/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 b778adaf..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 -- ------------------------------------------------------------------ @@ -2927,7 +2950,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") @@ -3223,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() @@ -3615,6 +3640,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",