From 998cb03d4c335ea69f573752485a99cb7510c27a Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 31 Aug 2026 12:05:42 -0400 Subject: [PATCH] CLOSES #1947, CLOSES #1987, CLOSES #1989, CLOSES #1994, CLOSES #1996, CLOSES #1998, CLOSES #2010, CLOSES #2011, CLOSES #2012, CLOSES #2013, CLOSES #2014, CLOSES #2015, CLOSES #2017, CLOSES #2018, CLOSES #2019, CLOSES #2020, CLOSES #2021, CLOSES #2022 --- data/scripts/oaks_lab.lua | 35 ++- data/scripts/oaks_lab_yellow.lua | 37 ++- docs/new-features.md | 2 +- main.lua | 13 + src/battle/BattleSafety.lua | 2 +- src/battle/BattleState.lua | 100 +++++-- src/battle/Experience.lua | 54 ++-- src/battle/gen2/Battle.lua | 65 +++++ src/core/BattleCheckpoint.lua | 1 + src/core/ChipAudio.lua | 62 ++++- src/core/Game.lua | 13 +- src/core/Game2.lua | 10 +- src/core/IssueReport.lua | 6 + src/core/RequireGuard.lua | 41 +++ src/core/SaveData.lua | 68 +++-- src/core/Sound.lua | 26 ++ src/import/LauncherView.lua | 169 +++++++++--- src/import/RomExtractor.lua | 16 ++ src/import/RomExtractorGen2.lua | 8 + src/import/RomImporter.lua | 199 +++++++++++--- src/import/SaveFileIO.lua | 7 +- src/mods/Gen2Compat.lua | 5 +- src/save_convert/GenSave.lua | 29 +- src/script/gen2/Specials.lua | 39 +-- src/sync/SyncEngine.lua | 29 +- src/ui/NamingScreen.lua | 23 +- src/ui/Screens.lua | 5 +- src/ui/gen2/BattleHud.lua | 17 ++ src/ui/gen2/BattleState.lua | 60 ++++- src/ui/gen2/BattleTransition.lua | 35 ++- src/ui/gen2/BlankScreen.lua | 52 ++++ src/ui/gen2/CenterPcMenu.lua | 42 ++- src/ui/gen2/ItemPcMenu.lua | 46 +++- src/ui/gen2/PackMenu.lua | 43 ++- src/ui/gen2/PartyMenu.lua | 21 +- src/ui/gen2/SummaryMenu.lua | 30 ++- src/ui/gen2/WaitPlaySFX.lua | 28 ++ src/ui/kit/Kit.lua | 4 + src/world/gen2/MapNameSign.lua | 15 ++ src/world/gen2/Player.lua | 5 +- src/world/gen2/World.lua | 141 +++++++--- .../battle_levelup_order_bug1996_test.lua | 110 ++++++++ tests/drivers/bind_sfx_wait_bug1998_test.lua | 236 ++++++++++++++++ .../drivers/crystal_fly_bird_bug2015_test.lua | 208 +++++++++++++++ tests/drivers/crystal_fly_bug1960_test.lua | 6 +- .../crystal_fly_flash_bug2020_test.lua | 233 ++++++++++++++++ .../crystal_map_name_sign_speed_bug1994.lua | 84 ++++++ .../gen2_belly_drum_attract_bug2018_2010.lua | 146 ++++++++++ .../gen2_bump_walk_in_place_bug1978_test.lua | 2 +- tests/drivers/gen2_pack_pc_menus_2012.lua | 68 +++++ tests/drivers/gold_exp_bar.lua | 31 ++- tests/drivers/gold_fly_anim_bug1711_test.lua | 6 +- tests/drivers/gold_oak_rating_2017.lua | 98 +++++++ tests/drivers/gold_pack_sfx_bug1893_test.lua | 13 +- tests/drivers/launcher_cart_outline_shot.lua | 66 +++++ tests/drivers/launcher_sync_shot.lua | 11 + tests/drivers/loader_chain_2001.lua | 31 +++ tests/drivers/oak_rival_path_bug1989_test.lua | 120 +++++++++ .../applying_attack_sfx_wait_bug1998.lua | 137 ++++++++++ tests/engine/audio_device_reset.lua | 22 +- tests/engine/battle_levelup_order_bug1996.lua | 225 ++++++++++++++++ tests/engine/chip_audio_preroll_test.lua | 216 +++++++++++++++ .../crystal_map_name_sign_speed_bug1994.lua | 137 ++++++++++ tests/engine/ed_tile_import_1947.lua | 146 ++++++++++ tests/engine/gen1_save_identity_roundtrip.lua | 171 ++++++++++++ .../gen2_bump_walk_in_place_bug1978.lua | 39 ++- .../gen2_fly_bird_offscreen_bug2015.lua | 108 ++++++++ tests/engine/gen2_logic_speed_bug1994.lua | 39 +++ .../gen2_trainer_ball_lifetime_bug2021.lua | 120 +++++++++ tests/engine/launcher_cart_ring_bug2003.lua | 191 +++++++++++++ tests/engine/launcher_sync_modal.lua | 44 +++ tests/engine/launcher_update_all.lua | 164 +++++++++++- tests/engine/naming_screen_layout_1947.lua | 45 ++++ .../oaks_lab_rival_paths_bug1989_bug1987.lua | 128 +++++++++ tests/engine/require_guard_test.lua | 101 +++++++ tests/engine/sync_engine_test.lua | 53 ++++ tests/engine/sync_save_identity_test.lua | 252 ++++++++++++++++++ tests/engine/sync_session_meta_test.lua | 98 +++++++ tests/gen2_battle_pack_test.lua | 40 ++- tests/gen2_battle_test.lua | 183 +++++++++++++ tests/gen2_battle_ui_test.lua | 107 ++++++++ tests/gen2_fly_map_test.lua | 125 +++++++++ tests/gen2_menus_test.lua | 65 +++++ tests/gen2_pack_rows_test.lua | 39 +++ tests/gen2_pc_screens_test.lua | 143 +++++++++- tests/gen2_summary_test.lua | 36 +++ tests/mod_world_tests.lua | 8 + tools/make_gold_manifest.py | 3 + tools/make_rom_manifest.py | 1 + tools/rom_manifest.json | 4 + tools/rom_manifest_blue.json | 4 + tools/rom_manifest_crystal.json | 4 + tools/rom_manifest_gold.json | 4 + tools/rom_manifest_silver.json | 4 + tools/rom_manifest_yellow.json | 4 + 95 files changed, 5955 insertions(+), 327 deletions(-) create mode 100644 src/core/RequireGuard.lua create mode 100644 src/ui/gen2/BlankScreen.lua create mode 100644 src/ui/gen2/WaitPlaySFX.lua create mode 100644 tests/drivers/battle_levelup_order_bug1996_test.lua create mode 100644 tests/drivers/bind_sfx_wait_bug1998_test.lua create mode 100644 tests/drivers/crystal_fly_bird_bug2015_test.lua create mode 100644 tests/drivers/crystal_fly_flash_bug2020_test.lua create mode 100644 tests/drivers/crystal_map_name_sign_speed_bug1994.lua create mode 100644 tests/drivers/gen2_belly_drum_attract_bug2018_2010.lua create mode 100644 tests/drivers/gen2_pack_pc_menus_2012.lua create mode 100644 tests/drivers/gold_oak_rating_2017.lua create mode 100644 tests/drivers/launcher_cart_outline_shot.lua create mode 100644 tests/drivers/loader_chain_2001.lua create mode 100644 tests/drivers/oak_rival_path_bug1989_test.lua create mode 100644 tests/engine/applying_attack_sfx_wait_bug1998.lua create mode 100644 tests/engine/battle_levelup_order_bug1996.lua create mode 100644 tests/engine/chip_audio_preroll_test.lua create mode 100644 tests/engine/crystal_map_name_sign_speed_bug1994.lua create mode 100644 tests/engine/ed_tile_import_1947.lua create mode 100644 tests/engine/gen1_save_identity_roundtrip.lua create mode 100644 tests/engine/gen2_fly_bird_offscreen_bug2015.lua create mode 100644 tests/engine/gen2_logic_speed_bug1994.lua create mode 100644 tests/engine/gen2_trainer_ball_lifetime_bug2021.lua create mode 100644 tests/engine/launcher_cart_ring_bug2003.lua create mode 100644 tests/engine/oaks_lab_rival_paths_bug1989_bug1987.lua create mode 100644 tests/engine/require_guard_test.lua create mode 100644 tests/engine/sync_save_identity_test.lua diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index 9cb8e18b..ed88f8af 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -69,6 +69,27 @@ local function starterBall(askText, species, choseFlag, ownBall, } end +-- engine/overworld/pathfinding.asm:36-70 (FindPathToPlayer): step whichever +local function findPathRows(rows, objIndex, sx, sy, tx, ty) + local rx, ry = math.abs(tx - sx), math.abs(ty - sy) + local xdir = tx < sx and "left" or "right" + local ydir = ty < sy and "up" or "down" + local last, count = nil, 0 + local function flush() + if count > 0 then + rows[#rows + 1] = { "move_npc", objIndex, last, count } + end + last, count = nil, 0 + end + while rx > 0 or ry > 0 do + local dir + if rx >= ry then dir, rx = xdir, rx - 1 else dir, ry = ydir, ry - 1 end + if dir ~= last then flush() end + last, count = dir, count + 1 + end + flush() +end + return { talk = { -- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_ @@ -305,7 +326,11 @@ return { end end if target then - table.insert(rows, { "move_npc_to", 1, target[1], target[2] }) + if type(rival.cellX) == "number" and type(rival.cellY) == "number" then + findPathRows(rows, 1, rival.cellX, rival.cellY, target[1], target[2]) + else + table.insert(rows, { "move_npc_to", 1, target[1], target[2] }) + end end table.insert(rows, { "face_object", 1, target and target[2] < y and "down" @@ -327,7 +352,13 @@ return { -- departure (lines 144-146), and this exit should match (#596). table.insert(rows, { "stop_music" }) table.insert(rows, { "play_music", "Music_MeetRival", { start = "rival" } }) - table.insert(rows, { "move_npc_to", 1, 4, 11 }) + -- scripts/OaksLab.asm:448-472 sidestep out of the player's column then + local side = x == 4 and "right" or "left" + table.insert(rows, { "move_npc", 1, side, 1 }) + table.insert(rows, { "face_player_dir", side }) + table.insert(rows, { "move_npc", 1, "down", 1 }) + table.insert(rows, { "face_player_dir", "down" }) + table.insert(rows, { "move_npc", 1, "down", 4 }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) table.insert(rows, { "play_music", "Music_OaksLab" }) ow.runner:run(rows, { npc = rival }) diff --git a/data/scripts/oaks_lab_yellow.lua b/data/scripts/oaks_lab_yellow.lua index 729923d1..429d4229 100644 --- a/data/scripts/oaks_lab_yellow.lua +++ b/data/scripts/oaks_lab_yellow.lua @@ -14,6 +14,27 @@ local OAK1 = 3 local RIVAL = 1 +-- engine/overworld/pathfinding.asm:36-70 (FindPathToPlayer): step whichever +local function findPathRows(rows, objIndex, sx, sy, tx, ty) + local rx, ry = math.abs(tx - sx), math.abs(ty - sy) + local xdir = tx < sx and "left" or "right" + local ydir = ty < sy and "up" or "down" + local last, count = nil, 0 + local function flush() + if count > 0 then + rows[#rows + 1] = { "move_npc", objIndex, last, count } + end + last, count = nil, 0 + end + while rx > 0 or ry > 0 do + local dir + if rx >= ry then dir, rx = xdir, rx - 1 else dir, ry = ydir, ry - 1 end + if dir ~= last then flush() end + last, count = dir, count + 1 + end + flush() +end + return { talk = { TEXT_OAKSLAB_OAK1 = { @@ -280,7 +301,12 @@ return { end end if target then - table.insert(rows, { "move_npc_to", RIVAL, target[1], target[2] }) + if type(rival.cellX) == "number" and type(rival.cellY) == "number" then + findPathRows(rows, RIVAL, rival.cellX, rival.cellY, + target[1], target[2]) + else + table.insert(rows, { "move_npc_to", RIVAL, target[1], target[2] }) + end end table.insert(rows, { "face_object", RIVAL, target and target[2] < y and "down" @@ -304,7 +330,14 @@ return { table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" }) table.insert(rows, { "stop_music" }) table.insert(rows, { "play_music", "Music_MeetRival", { start = "rival" } }) - table.insert(rows, { "move_npc_to", RIVAL, 4, 11 }) + -- pokeyellow scripts/OaksLab.asm:411-436 sidestep out of the player's + local side = x == 4 and "right" or "left" + table.insert(rows, { "move_npc", RIVAL, side, 1 }) + table.insert(rows, { "move_npc", RIVAL, "down", 1 }) + table.insert(rows, { "face_player_dir", side }) + table.insert(rows, { "move_npc", RIVAL, "down", 1 }) + table.insert(rows, { "face_player_dir", "down" }) + table.insert(rows, { "move_npc", RIVAL, "down", 4 }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) table.insert(rows, { "play_music", "Music_OaksLab" }) -- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009) diff --git a/docs/new-features.md b/docs/new-features.md index 61212a53..2dbaa767 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -21,7 +21,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Install required mods**, one press on a cart that will not start, fetching every pinned mod at the pinned version and refusing any archive whose hash is not the one the cart recorded * **Browse carts in Find mods**, a Mods / Carts switch on the same community index, searched and filtered by base game, installing the cart file straight into that game's cart list * **Filter Find mods by game**, a generation or single-game filter of its own, with every listing showing the games and tags it declares -* **Update all mods** in one press from the MODS tab, installing every outdated mod in turn with a summary of what failed +* **Update all** in one press from the MODS tab, installing every outdated mod and every installed cart the index lists ahead of it, with a summary of what failed * **Rebindable GAME SPEED shortcuts**, SPEED - / SPEED + rows in CONTROLS that move the shoulder hotkeys to any pad button or switch them off * **Key bar on the touch pad**, a corner toggle that slides out SAVE, LOAD, SPEED, COLOR, TILT and ZOOM for phones with no keyboard * **Save editor item verbs**, sorting the bag and PC by item number or name, filling one stack or every stack to x99, and a coin editor on every game diff --git a/main.lua b/main.lua index 9e0f24c5..cd0d7386 100644 --- a/main.lua +++ b/main.lua @@ -410,11 +410,19 @@ end local function returnToLauncher(opts) if not Game then return end + if require("src.core.RequireGuard").repair() then + print("boot: restored love.filesystem searcher (see #2001)") + end + local GameVersion = require("src.core.GameVersion") local currentVersion = GameVersion.get() SessionLifecycle.endGameSession(Game) Game = nil pcall(function() require("src.online.Trade").hostIsLive = nil end) + local syncEngine = package.loaded["src.sync.SyncEngine"] + if type(syncEngine) == "table" and type(syncEngine._shared) == "table" then + pcall(syncEngine._shared.protectPlaythrough, syncEngine._shared, nil, nil) + end autopilot = nil driverCo = nil -- Leave the cart's scope behind: the launcher's own settings and slots are @@ -442,6 +450,9 @@ local pendingLauncherReturn function bootGame(version, cartId, opts) opts = opts or {} + if require("src.core.RequireGuard").repair() then + print("boot: restored love.filesystem searcher (see #2001)") + end pcall(function() require("src.online.Trade").hostIsLive = function() return true end end) @@ -600,6 +611,8 @@ function love.load(args) -- of each flashing their own cmd.exe window (#606). No-op elsewhere. require("src.core.HostShell").hideHostConsole() + require("src.core.RequireGuard").capture() + -- Hang gen1tls on love.system before mods boot. Android already has tls* -- from JNI; this is the desktop half. No DLL / no FFI is fine -- ws:// -- rooms still work, wss:// just won't. diff --git a/src/battle/BattleSafety.lua b/src/battle/BattleSafety.lua index 89cf6836..47f77c2e 100644 --- a/src/battle/BattleSafety.lua +++ b/src/battle/BattleSafety.lua @@ -7,7 +7,7 @@ local BattleSafety = {} local BATTLE_BUSY_FIELDS = { "current", "afterQueue", "nextInsert", "pendingHit", "waitingUI", - "waitingSound", "waitFrames", "draining", "animPlaying", "growIn", + "waitingSound", "waitFrames", "hitSfxWait", "draining", "animPlaying", "growIn", "shrinkOut", "introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result", } diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 752f4be0..aa00a460 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1126,6 +1126,15 @@ function BattleState:sayNextAuto(text, delay) { text = text, auto = true, autoDelay = delay or 0 }) end +-- sound_level_up then text_end, no prompt -- home/text.asm:506-531 (the sfx +-- wait) then home/text.asm:328-334 (TX_END returns out of PrintText) +function BattleState:sayNextAutoWaitSfx(text, sfx) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, + { text = text, auto = true, autoDelay = 0, + waitForLearningSfx = sfx }) +end + -- insert a UI push right after the current queue item (dex page, the -- level-up stat box -- anything that must keep queue order) function BattleState:uiNext(factory) @@ -1377,6 +1386,8 @@ function BattleState:updateQueue() end end if self.animPlayer:isDone() then + -- engine/battle/animations.asm:2639 PlayApplyingAttackSound + if self:holdForMoveSfx() then return true end self.animPlaying = false -- the target's hit blink + damage sound follow the animation -- (pokered plays them after PlayMoveAnimation returns) @@ -1571,6 +1582,12 @@ function BattleState:updateQueue() end else local item = self.current + -- TextCommand_SOUND precedes the page's tail command -- home/text.asm: + if item and item.waitForLearningSfx and not item.soundStarted then + item.soundStarted = true + self.waitingSound = item.waitForLearningSfx() + return true + end -- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO -- overlays the still-visible "Will … change POKéMON?" page. if item and item.choice and not item.choiceOpen then @@ -1604,11 +1621,6 @@ function BattleState:updateQueue() self.current = nil end elseif not (item and item.choice) then - if item and item.waitForLearningSfx and not item.soundStarted then - item.soundStarted = true - self.waitingSound = item.waitForLearningSfx() - return true - end -- The page is typed out and waiting on the player: PromptText -- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll -- blinks it until A/B, so the arrow belongs on a finished page and not @@ -3401,6 +3413,36 @@ function BattleState:applyAnimEffect(ev) -- battler.substituteHP is set (MoveEffects raises it with the move) end +-- engine/battle/animations.asm:2639 PlayApplyingAttackSound +-- -> home/delay.asm:15 WaitForSoundToFinish +function BattleState:holdForMoveSfx() + local hit = self.pendingHit + local t = hit and hit.animType + if hit and not t and hit.blink then t = hit.blink.isPlayer and 1 or 4 end + -- engine/battle/animations.asm:492-499 + if not (t == 1 or t == 2 or t == 4 or t == 5) + or self:lowHealthAlarmActive() then + self.hitSfxWait = nil + return false + end + local Sound = require("src.core.Sound") + if not (Sound.moveSfxBusy and Sound.moveSfxBusy()) then + self.hitSfxWait = nil + return false + end + if not self.hitSfxWait then + local budget = Sound.moveSfxWaitFrames and Sound.moveSfxWaitFrames() or 0 + local cap = 180 * (Sound.rate and Sound.rate() or 1) + self.hitSfxWait = math.max(0, math.min(budget, cap)) + end + if self.hitSfxWait <= 0 then + self.hitSfxWait = nil + return false + end + self.hitSfxWait = self.hitSfxWait - 1 + return true +end + -- The post-animation applying-attack feedback (PlayApplyingAttackAnimation -- -> AnimationTypePointerTable, engine/battle/animations.asm:475-524). -- hit.animType is wAnimationType, 1..6: @@ -4473,6 +4515,7 @@ function BattleState:awardExp() if participants == 0 and self.player.mon.hp > 0 then participants, alive = 1, { self.player.mon } end + local pendingStep = {} local function applyShare(mon, split, announce) local playerId = self.game.save.player and self.game.save.player.id -- GainExperience (engine/battle/experience.asm:69-88) compares the @@ -4480,9 +4523,12 @@ function BattleState:awardExp() -- mon.traded covers otId-less mons (repairTradedOtIds, old link peers) #1488 local traded = playerId ~= nil and ((mon.otId ~= nil and mon.otId ~= playerId) or (mon.otId == nil and mon.traded == true)) - local levels, gained = Experience.apply(self.data, mon, self.enemy.def, + -- experience.asm:158-163: the level is recomputed only after GainedText + local levels, gained, steps = Experience.apply(self.data, mon, self.enemy.def, self.enemy.mon.level, self.kind == "trainer", - split, traded) + split, traded, + { defer = true, from = pendingStep[mon] }) + if #steps > 0 then pendingStep[mon] = steps[#steps] end -- Track level-ups for EvolveAfterBattle (BattleState:finish -> -- Evolution.checkParty). B-cancel leaves the mon at/above threshold; -- without this gate it re-triggers after every later fight (#213). @@ -4514,32 +4560,30 @@ function BattleState:awardExp() -- per level: GrewLevelText -> the stats window (PrintStatsBox) -> -- the move-learn checks (experience.asm:245-256) local game = self.game - for _, lv in ipairs(levels) do - -- experience.asm:248 fires per grew-level text - require("src.world.PikachuFollower") - .modifyHappiness(game.save, "LEVELUP", mon) + for i, lv in ipairs(levels) do + -- experience.asm:168-239 -- the level write, CalcStats, the max-HP + self:actNext(function() + Experience.commit(self.data, mon, steps[i]) + -- experience.asm:248 fires per grew-level text + require("src.world.PikachuFollower") + .modifyHappiness(game.save, "LEVELUP", mon) + -- engine/battle/experience.asm:209 (cp wPlayerMonNumber) + if mon == self.player.mon then + -- engine/battle/experience.asm:236-239 + self.player.badgeExtraBoosts = nil + self.player.shownHP = mon.hp + self.player.shownPx = Timing.hpBarPixels(mon.hp, + math.max(1, mon.stats.hp)) + self.player.drainFloor = nil + end + end) -- GrewLevelText: text_far, sound_level_up, text_end (experience.asm: - -- 369-372); PrintStatsBox only runs once PrintText has returned - self:sayNextWaitSfx(Strings("%s grew\nto level %d!", name, lv), + -- WaitForSoundToFinish releases (experience.asm:243-249) + self:sayNextAutoWaitSfx(Strings("%s grew\nto level %d!", name, lv), function() return require("src.core.Sound").play(game.data, "Level_Up") end) self:uiNext(function() return StatBox.new(game, mon) end) - -- After PrintStatsBox, experience.asm reloads the active battler's - -- wBattleMon and runs DrawHUDsAndHPBars, so its HP bar reflects the - -- higher current HP. Experience.lua:84 already raised mon.hp by - -- (newMaxHP - oldMaxHP); the party mon and the battler share one table - -- (makeBattler), so mon.stats.hp (the bar's denominator) jumps to the - -- new max instantly while the battler's shownHP numerator lags at the - -- old current HP -- the bar SHRINKS (#224). Animate shownHP up to the - -- new current HP (house convention: potions drain the bar too, see - -- itemUsed) so the bar grows instead. Only the active player battler - -- shares its table with the HUD; other party mons (EXP.ALL) have no bar. - if mon == self.player.mon then - -- engine/battle/experience.asm:236 - self.player.badgeExtraBoosts = nil - self:drainNext() - end for _, moveId in ipairs(Experience.movesLearnedAt( self.data.pokemon[mon.species], lv)) do self:learnMove(mon, moveId) diff --git a/src/battle/Experience.lua b/src/battle/Experience.lua index 78ca0fdf..cc942cb0 100644 --- a/src/battle/Experience.lua +++ b/src/battle/Experience.lua @@ -48,9 +48,10 @@ end -- Applies exp/stat exp; returns the list of levels gained plus the raw -- exp delta (wExpAmountGained, printed by _ExpPointsText -- captured --- before the max-level cap, experience.asm:92-100). +-- before the max-level cap, experience.asm:92-100), and the per-level +-- Experience.commit -- engine/battle/experience.asm:158-200 function Experience.apply(data, mon, defeatedDef, level, isTrainer, - numParticipants, traded) + numParticipants, traded, opts) local speciesDef = data.pokemon[mon.species] -- stat exp is divided among participants too -- (DivideExpDataByNumMonsGainingExp divides wEnemyMonBaseStats) @@ -74,23 +75,46 @@ function Experience.apply(data, mon, defeatedDef, level, isTrainer, mon.exp = mon.exp + gained local cap = consts and consts.levelCap or 100 - local levels = {} + local levels, steps = {}, {} + local defer = opts and opts.defer local newLevel = Growth.levelForExp(speciesDef.growthRate, mon.exp, cap, data.growth_rates) - while mon.level < math.min(newLevel, cap) do - mon.level = mon.level + 1 - local old = mon.stats - mon.stats = Stats.calc(speciesDef, mon.level, mon.dvs, mon.statExp) - mon.hp = math.min(mon.stats.hp, mon.hp + (mon.stats.hp - old.hp)) - table.insert(levels, mon.level) - if Runtime.wants("pokemon.level_up") then - Runtime.emit("pokemon.level_up", { - mon = mon, level = mon.level, prevLevel = mon.level - 1, - learnable = Experience.movesLearnedAt(speciesDef, mon.level), - }) + local from = opts and opts.from + local lv, stats, hp = mon.level, mon.stats, mon.hp + if from then + lv, stats, hp = from.level, from.stats, from.hp + end + while lv < math.min(newLevel, cap) do + lv = lv + 1 + local old = stats + stats = Stats.calc(speciesDef, lv, mon.dvs, mon.statExp) + hp = math.min(stats.hp, hp + (stats.hp - old.hp)) + table.insert(levels, lv) + table.insert(steps, { level = lv, stats = stats, hp = hp }) + if not defer then + mon.level, mon.stats, mon.hp = lv, stats, hp + if Runtime.wants("pokemon.level_up") then + Runtime.emit("pokemon.level_up", { + mon = mon, level = lv, prevLevel = lv - 1, + learnable = Experience.movesLearnedAt(speciesDef, lv), + }) + end end end - return levels, gained + return levels, gained, steps +end + +-- engine/battle/experience.asm:168-200 +function Experience.commit(data, mon, step) + mon.level, mon.stats = step.level, step.stats + mon.hp = math.min(step.stats.hp, step.hp) + if Runtime.wants("pokemon.level_up") then + Runtime.emit("pokemon.level_up", { + mon = mon, level = step.level, prevLevel = step.level - 1, + learnable = Experience.movesLearnedAt(data.pokemon[mon.species], + step.level), + }) + end end -- Moves learned when reaching exactly `level`. diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 6a05626d..ab57aac4 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -988,6 +988,23 @@ local function checkTurn(self, mon, moveId) end end end + -- engine/battle/effect_commands.asm:291-310, enemy twin :539-558 + if vol.attract then + local partner = (mon == self.player) and self.enemy or self.player + -- data/text/battle.asm:484 + self:emit({ kind = "message", + text = Strings("%s\nis in love with", name) }) + self:emit({ kind = "message", + text = Strings("is in love with\n%s!", self:monName(partner)) }) + if rand(self.random, 256) >= 128 then + -- data/text/battle.asm:490 + self:emit({ kind = "message", + text = Strings("%s's\ninfatuation kept", name) }) + self:emit({ kind = "message", + text = Strings("infatuation kept\nit from attacking!") }) + return false + end + end if beforeMove then return beforeMove(self, mon, name) and true or false end @@ -1054,6 +1071,15 @@ function Battle:clearVolatile(mon) if not mon then return end self:untransform(mon) mon.volatile = nil + -- engine/battle/core.asm:3871 + if mon == self.player or mon == self.enemy then + if self.player and self.player.volatile then + self.player.volatile.attract = nil + end + if self.enemy and self.enemy.volatile then + self.enemy.volatile.attract = nil + end + end end -- The cart keeps every substatus in battle RAM (wPlayerSubStatus1-5), which @@ -2668,6 +2694,24 @@ Battle.MOVE_EFFECTS.EFFECT_CURSE = function(self, attacker, defender) .. self:monName(defender) .. "!" }) end +-- engine/battle/move_effects/belly_drum.asm:1, data/moves/effects.asm:1835 +Battle.MOVE_EFFECTS.EFFECT_BELLY_DRUM = function(self, attacker) + local stages = self.stages[self:sideOf(attacker)] + if not Effects.applyStage(stages, "attack", 2) then return fail(self) end + local maxHp = attacker.maxHp or (attacker.stats and attacker.stats.hp) or 1 + -- engine/battle/core.asm:1821, CheckUserHasEnoughHP :1874 + local half = math.max(1, math.floor(maxHp / 2)) + if (attacker.hp or 0) <= half then return fail(self) end + attacker.hp = attacker.hp - half + self:emit({ kind = "damage", side = self:sideOf(attacker), amount = half, + hp = attacker.hp, anim = false }) + stages.attack = Effects.MAX_STAGE + -- data/text/battle.asm:1049 + self:emit({ kind = "message", + text = Strings("%s\ncut its HP and", self:monName(attacker)) }) + self:emit({ kind = "message", text = Strings("maximized ATTACK!") }) +end + -- BattleCommand_LeechSeed (engine/battle/move_effects/leech_seed.asm): the -- flag sits on the SEEDED mon and ResidualDamage drains an eighth every turn -- into whoever stands on the other side by then. A Grass target does not @@ -2762,6 +2806,26 @@ Battle.MOVE_EFFECTS.EFFECT_MEAN_LOOK = function(self, attacker, defender) text = self:monName(defender) .. " can't escape now!" }) end +-- engine/battle/move_effects/attract.asm:1, CheckOppositeGender :24 +-- data/moves/effects.asm:1599 +Battle.MOVE_EFFECTS.EFFECT_ATTRACT = function(self, attacker, defender, + def, moveId, sureHit) + if not sureHit and not self:accuracyRoll(def, attacker, defender) then + return fail(self) + end + local mine = attacker.gender or "unknown" + local theirs = defender.gender or "unknown" + if mine == "unknown" or theirs == "unknown" or mine == theirs then + return fail(self) + end + local target = self:volatile(defender) + if target.vanished or target.attract then return fail(self) end + target.attract = true + -- data/text/battle.asm:1001 + self:emit({ kind = "message", + text = Strings("%s\nfell in love!", self:monName(defender)) }) +end + -- BattleCommand_ForceSwitch (effect_commands.asm:4913). Fails outright for -- BATTLETYPE_FORCESHINY and BATTLETYPE_TRAP. Against a WILD mon the battle -- simply ENDS -- either direction writes DRAW into wBattleResult, which is @@ -5083,6 +5147,7 @@ Battle.LINK_STAGES = { "attack", "defense", "speed", "specialAttack", "specialDefense", "accuracy", "evasion" } Battle.LINK_VOLATILE = { + "attract", "bideStored", "bideTurns", "chargeMove", "confuseCount", "curled", "cursed", "disabled", "disabledTurns", "encore", "encoreTurns", "endure", "flinched", "focusEnergy", "futureSight", "futureSightDamage", "futureSightSide", diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua index 39e6a3b6..f14228fb 100644 --- a/src/core/BattleCheckpoint.lua +++ b/src/core/BattleCheckpoint.lua @@ -362,6 +362,7 @@ function BattleCheckpoint.restore(game, checkpoint, copy) battle.current, battle.afterQueue, battle.nextInsert = nil, nil, nil battle.pendingHit, battle.waitingUI, battle.waitingSound = nil, nil, nil battle.waitFrames, battle.draining, battle.animPlaying = nil, nil, nil + battle.hitSfxWait = nil battle.introText, battle.introBalls, battle.introSlide = nil, nil, nil battle.showPlayerBack, battle.showEnemyTrainer, battle.showEnemyBalls = nil, nil, nil battle.player.shownHP, battle.player.shownStatus = diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index ef07d40e..4caaef17 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -68,6 +68,9 @@ local musicHeld = false local suspended = false +local STATS = os.getenv("POKEPORT_AUDIO_STATS") == "1" +local statFrames, statUnderruns, statRestarts = 0, 0, 0 + -- --------------------------------------------------------------------------- -- worker management -- --------------------------------------------------------------------------- @@ -192,6 +195,21 @@ local musicGen = 0 -- than playing out the ~6s stall-tolerance queue (#1471) local stereoEpoch = 0 +local MUSIC_PREROLL = 4 +ChipAudio.MUSIC_PREROLL = MUSIC_PREROLL + +local function queuedBuffers(source) + local ok, free = pcall(source.getFreeBufferCount, source) + if not ok or type(free) ~= "number" then return nil end + return MUSIC_BUFFER_COUNT - free +end + +local function readyToStart(m) + local queued = queuedBuffers(m.source) + if not queued then return false end + return queued >= (m.preroll or 1) or (m.finished and queued > 0) +end + function ChipAudio.playMusic(data, header, allowLoops) if not ensureWorker() then return playMusicSync(data, header, allowLoops) @@ -216,8 +234,8 @@ function ChipAudio.playMusic(data, header, allowLoops) stereoEpoch = stereoEpoch }) currentMusic = { source = source, gen = gen, threaded = true, started = false, finished = false, - stereoEpoch = stereoEpoch } - -- playback starts in update() once the first buffer arrives (~1 frame) + stereoEpoch = stereoEpoch, + preroll = (allowLoops ~= false) and MUSIC_PREROLL or 1 } return source end @@ -264,13 +282,22 @@ local function updateThreaded() end end end - if not m.started and not musicHeld then - local okFree, free = pcall(m.source.getFreeBufferCount, m.source) - if okFree and type(free) == "number" - and (MUSIC_BUFFER_COUNT - free) > 0 then - pcall(function() m.source:play() end) - m.started = true - end + if not m.started and not musicHeld and readyToStart(m) then + pcall(function() m.source:play() end) + m.started = true + end +end + +local function noteStats(m) + statFrames = statFrames + 1 + local depth = m.source and queuedBuffers(m.source) or nil + if depth == 0 then statUnderruns = statUnderruns + 1 end + if statFrames % 60 == 0 then + require("src.core.Logger").info( + "chipaudio: depth=%d/%d out=%d underruns=%d restarts=%d rate=%d", + depth or -1, MUSIC_BUFFER_COUNT, + (m.threaded and outCh) and outCh:getCount() or -1, + statUnderruns, statRestarts, SAMPLE_RATE) end end @@ -283,6 +310,7 @@ function ChipAudio.update() else fillSync() end + if STATS then noteStats(m) end end -- Recover from a queue underrun caused by a long render stall. Called after @@ -296,10 +324,9 @@ function ChipAudio.ensureMusicPlaying() if not m.started then return end local ok, playing = pcall(function() return m.source:isPlaying() end) if not ok or playing then return end - local okFree, free = pcall(m.source.getFreeBufferCount, m.source) - if okFree and type(free) == "number" - and (MUSIC_BUFFER_COUNT - free) > 0 then + if readyToStart(m) then pcall(function() m.source:play() end) + statRestarts = statRestarts + 1 end else if not m.engine or m.engine:finished() then return end @@ -307,6 +334,7 @@ function ChipAudio.ensureMusicPlaying() if ok and not playing then fillSync(MUSIC_FILL_INITIAL) pcall(m.source.play, m.source) + statRestarts = statRestarts + 1 end end end @@ -560,6 +588,16 @@ end -- test hooks (headless): synchronous synthesis straight through ChipSynth -- --------------------------------------------------------------------------- +function ChipAudio._setAudioStatsForTest(flag) + STATS = not not flag + statFrames, statUnderruns, statRestarts = 0, 0, 0 +end + +function ChipAudio._audioStatsForTest() + return { frames = statFrames, underruns = statUnderruns, + restarts = statRestarts } +end + -- Force the "threaded, first buffer not yet queued" window so Music's -- playOnce / pendingRestore race can be asserted without love.thread. -- Returns a clear() that drops the override (call after the assertion). diff --git a/src/core/Game.lua b/src/core/Game.lua index 42294ee1..84fe42cf 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1285,14 +1285,25 @@ function Game:updateSync(dt) local eng = self:syncEngine() if not eng then return end if not (eng.state.enabled and eng:linked()) and not eng:busy() then return end + local wasBusy = eng:busy() pcall(eng.update, eng, dt) + if wasBusy and not eng:busy() then self:adoptPlaythroughId() end +end + +function Game:adoptPlaythroughId() + local save = self.save + local meta = type(save) == "table" and save.meta + if type(meta) ~= "table" or meta.savedAt == nil then return end + if type(meta.playthroughId) == "string" and meta.playthroughId ~= "" then return end + local id = SaveData.selectedPlaythroughId(save) + if type(id) == "string" and id ~= "" then meta.playthroughId = id end end -- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings -- across New Game without touching the progress save. function Game:writeOptions() if not (self.save and self.save.options) then return end - SaveData.saveOptions(self.save.options) + SaveData.saveLiveOptions(self.save) end -- Push the live options table into audio + display subsystems. diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 0043a3bd..51da5b60 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -1244,6 +1244,12 @@ function Game2:inFillBoot() return self.phase == "boot" and self.stack:top() ~= nil end +function Game2:logicSpeed() + return math.max(1, + tonumber(self.speedOverride) or tonumber(self.options and self.options.speed) + or 1) +end + function Game2:update(dt) -- _UpdateSound is a VBlank job, so it runs at 60Hz off real time whatever the -- logic multiplier is (audio/engine.asm:84, home/vblank.asm:141-143). @@ -1266,9 +1272,7 @@ function Game2:update(dt) -- tempo at every multiplier (#1990/#1991/#1997). speedOverride is the -- driver/CLI hook and wins over the saved option. -- pokegold engine/menus/intro_menu.asm:848 IntroSequence: boot cinema runs on the same clock as the overworld - local speed = math.max(1, - tonumber(self.speedOverride) or tonumber(self.options and self.options.speed) - or 1) + local speed = self:logicSpeed() if self.phase == "boot" then FixedStep.maxAccum = FixedStep.catchupLimit(speed) FixedStep:update(dt, speed) diff --git a/src/core/IssueReport.lua b/src/core/IssueReport.lua index cbd432c4..1b6b5979 100644 --- a/src/core/IssueReport.lua +++ b/src/core/IssueReport.lua @@ -234,6 +234,12 @@ local function metadata(options, context) add("App", version ~= "" and ("gen1recomp v" .. version) or "gen1recomp") add("LÖVE", loveVersion()) if safeMode then add("Safe mode", "on") end + local guardOk, guardPresent = pcall(function() + return require("src.core.RequireGuard").present() + end) + if guardOk and guardPresent == false then + add("Loader chain", "love searcher missing") + end return { rawOS = rawOS, os = formOS(rawOS), diff --git a/src/core/RequireGuard.lua b/src/core/RequireGuard.lua new file mode 100644 index 00000000..55761499 --- /dev/null +++ b/src/core/RequireGuard.lua @@ -0,0 +1,41 @@ +local RequireGuard = {} + +local searcher + +local function chain() + if not package then return nil end + return package.loaders or package.searchers +end + +function RequireGuard.capture() + if searcher then return false end + local loaders = chain() + if not loaders then return false end + if not (love and love.filesystem) then return false end + if type(loaders[2]) ~= "function" then return false end + searcher = loaders[2] + return true +end + +function RequireGuard.captured() + return searcher ~= nil +end + +function RequireGuard.present() + local loaders = chain() + if not searcher or not loaders then return true end + for i = 1, #loaders do + if loaders[i] == searcher then return true end + end + return false +end + +function RequireGuard.repair() + if RequireGuard.present() then return false end + local loaders = chain() + if not loaders then return false end + table.insert(loaders, math.min(2, #loaders + 1), searcher) + return true +end + +return RequireGuard diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 6a32f5ea..5dcdbbf0 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1383,6 +1383,12 @@ local function deleteSlotIn(key, slotId) reg.active = reg.list[1] -- may be nil when the list is now empty end putRegistry(opts, key, reg) + local ids = type(opts.playthroughIds) == "table" and opts.playthroughIds[key] + if type(ids) == "table" then + ids[slotId] = nil + if next(ids) == nil then opts.playthroughIds[key] = nil end + if next(opts.playthroughIds) == nil then opts.playthroughIds = nil end + end SaveData.saveOptions(opts, fs) slotsChecked[key] = true activeSlotCache[key] = reg.active or false @@ -1665,10 +1671,7 @@ local function playthroughScope(version, injectedFs) end local function rememberPlaythroughId(save, opts, injectedFs) - local meta = type(save) == "table" and save.meta - local id = type(meta) == "table" and meta.playthroughId - if type(id) ~= "string" or id == "" then return opts, false end - local version = save.version or GameVersion.get() + local version = type(save) == "table" and save.version or GameVersion.get() local scope, key = playthroughScope(version, injectedFs) local persisted = SaveData.loadOptions(injectedFs) if opts then @@ -1682,6 +1685,16 @@ local function rememberPlaythroughId(save, opts, injectedFs) else opts = persisted end + local meta = type(save) == "table" and save.meta + local id = type(meta) == "table" and meta.playthroughId + if type(id) ~= "string" or id == "" then + local byVersion = opts.playthroughIds and opts.playthroughIds[key] + local mapped = byVersion and byVersion[scope] + if type(meta) == "table" and type(mapped) == "string" and mapped ~= "" then + meta.playthroughId = mapped + end + return opts, false + end opts.playthroughIds = opts.playthroughIds or {} opts.playthroughIds[key] = opts.playthroughIds[key] or {} local changed = opts.playthroughIds[key][scope] ~= id @@ -1729,6 +1742,26 @@ function SaveData.ensurePlaythroughId(save, injectedFs) return id end +function SaveData.slotPlaythroughId(version, slotId, save, injectedFs) + version = version or GameVersion.get() + if not knownVersion(version) then return nil end + if type(slotId) ~= "string" or slotId == "" then return nil end + local meta = type(save) == "table" and save.meta + local stamped = type(meta) == "table" and meta.playthroughId + local opts = SaveData.loadOptions(injectedFs) + local byVersion = opts.playthroughIds and opts.playthroughIds[version] + local mapped = byVersion and byVersion[slotId] + local id = (type(stamped) == "string" and stamped ~= "") and stamped or mapped + if type(id) ~= "string" or id == "" then id = SaveData.newPlaythroughId() end + if mapped ~= id then + opts.playthroughIds = opts.playthroughIds or {} + opts.playthroughIds[version] = opts.playthroughIds[version] or {} + opts.playthroughIds[version][slotId] = id + SaveData.saveOptions(opts, injectedFs) + end + return id +end + -- Resolve the already-selected playthrough without changing the supplied save -- or allocating a replacement id. Title tools use this before a normal SAVE: -- Game:load intentionally owns a fresh skeleton there, while the selected @@ -2026,6 +2059,12 @@ end) -- ------- write +function SaveData.saveLiveOptions(data) + if type(data) ~= "table" or type(data.options) ~= "table" then return nil end + data.options = rememberPlaythroughId(data, data.options) + return SaveData.saveOptions(data.options) +end + -- Game progress only; options are written separately via saveOptions. -- If `data.options` is present it is also flushed to options.lua so an -- F1 / in-game save keeps the live settings in sync, then stripped from @@ -2036,20 +2075,15 @@ function SaveData.save(data, mods) -- write to the file matching this save's own version, not just the active -- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version) - if data.options then - local opts = data.options - if data.meta and data.meta.playthroughId then - opts = rememberPlaythroughId(data, data.options) - end - data.options = opts - SaveData.saveOptions(opts) - elseif data.meta and data.meta.playthroughId then - local opts, changed = rememberPlaythroughId(data) - if changed then SaveData.saveOptions(opts) end - end if mods ~= nil or data.meta == nil then data.meta = SaveData.buildMeta(mods, data.meta) end + if data.options then + SaveData.saveLiveOptions(data) + else + local opts, changed = rememberPlaythroughId(data) + if changed then SaveData.saveOptions(opts) end + end if activeCart and activeCartHash and type(data.meta) == "table" then data.meta.cartHash = activeCartHash end @@ -2122,6 +2156,10 @@ function SaveData.load(version) end SaveData.runMigrations(data) data.options = SaveData.loadOptions() + local mapped = SaveData.selectedPlaythroughId(data) + if type(mapped) == "string" and mapped ~= "" then + data.meta.playthroughId = mapped + end Logger.info("loaded save") return data, recovered end diff --git a/src/core/Sound.lua b/src/core/Sound.lua index b7777f6e..ae820fcc 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -574,6 +574,32 @@ local function remainingFrames(src) return math.max(0, math.ceil((dur - pos) * 60)) end +-- home/delay.asm:15 WaitForSoundToFinish polls CHAN5/CHAN6/CHAN8 +function Sound.moveSfxBusy() + pruneMoveSfx() + for _ in pairs(moveSfxChannels) do return true end + return false +end + +function Sound.moveSfxWaitFrames() + pruneMoveSfx() + local worst = 0 + for _, cur in pairs(moveSfxChannels) do + local n = remainingFrames(cur.src) + if n then + local okp, pitch = pcall(cur.src.getPitch, cur.src) + if okp and type(pitch) == "number" and pitch > 0 then + n = math.ceil(n / pitch * rate) + end + n = n + 2 + else + n = Sound.waitFrames(cur.src, 0) + end + if n > worst then worst = n end + end + return worst +end + -- audio/engine_2.asm:1077-1096, :991-1013, :1015-1033 local function plainMoveFrames(data, def, channels) local id = sfxHeaderId(def) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 5a2c324c..092fb732 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -586,6 +586,65 @@ local function cartQuad(project, x, y, w, h, z) } end +local OUTLINE_DIRS = { + { 1, 0 }, { 0.7071, 0.7071 }, { 0, 1 }, { -0.7071, 0.7071 }, + { -1, 0 }, { -0.7071, -0.7071 }, { 0, -1 }, { 0.7071, -0.7071 }, +} + +local function hullCross(o, a, b) + return (a[1] - o[1]) * (b[2] - o[2]) - (a[2] - o[2]) * (b[1] - o[1]) +end + +local function cartHull(quads) + local pts = {} + for i = 1, #quads do + local q = quads[i] + for j = 1, #q do pts[#pts + 1] = q[j] end + end + if #pts < 3 then return nil end + table.sort(pts, function(a, b) + if a[1] == b[1] then return a[2] < b[2] end + return a[1] < b[1] + end) + local hull, k = {}, 0 + for i = 1, #pts do + while k >= 2 and hullCross(hull[k - 1], hull[k], pts[i]) <= 0 do k = k - 1 end + k = k + 1 + hull[k] = pts[i] + end + local lower = k + 1 + for i = #pts - 1, 1, -1 do + while k >= lower and hullCross(hull[k - 1], hull[k], pts[i]) <= 0 do + k = k - 1 + end + k = k + 1 + hull[k] = pts[i] + end + if k < 4 then return nil end + local flat = {} + for i = 1, k - 1 do + flat[#flat + 1], flat[#flat + 2] = hull[i][1], hull[i][2] + end + return flat +end + +local function cartOutline(groups, thickness) + if not (love.graphics.polygon and love.graphics.push) then return end + local hulls = {} + for i = 1, #groups do + hulls[#hulls + 1] = cartHull(groups[i]) + end + if #hulls == 0 then return end + Theme.col(PAL.lineStrong, 1) + for i = 1, #OUTLINE_DIRS do + local d = OUTLINE_DIRS[i] + love.graphics.push() + love.graphics.translate(d[1] * thickness, d[2] * thickness) + for j = 1, #hulls do love.graphics.polygon("fill", hulls[j]) end + love.graphics.pop() + end +end + local function cartFacing(points) local area = 0 for i = 1, #points do @@ -653,31 +712,41 @@ vec4 position(mat4 transform_projection, vec4 vertex_position) { #endif #ifdef PIXEL +#if defined(GL_ES) && defined(GL_FRAGMENT_PRECISION_HIGH) +precision highp float; +#endif + // finish: 0 plain, 1 sparkle (glitter suspended in the shell), 2 holographic // label sweep. Both are ours, not ported from anywhere. extern float finish; -extern float finish_time; -extern float finish_spin; +extern float finish_twinkle; +extern float finish_band; +extern float finish_wave; + +const float CART_TAU = 6.2831853; float sparkHash(vec2 p) { - return fract(sin(dot(p, vec2(41.7321, 289.113))) * 43758.5453); + float h = fract(p.x * 0.1031 + p.y * 0.3711 + 0.137); + h = fract(h * (h + 47.13)); + h = fract(h * (h + 19.77)); + return h; } // Flecks live on a jittered lattice so they read as suspended grains rather // than a regular grid, and each one twinkles on its own phase. vec3 sparkle(vec2 screen_coords) { vec2 cell = screen_coords / 19.0; - vec2 id = floor(cell); + vec2 id = mod(floor(cell), 512.0); vec2 f = fract(cell); float peak = 0.0; for (int oy = -1; oy <= 1; oy++) { for (int ox = -1; ox <= 1; ox++) { vec2 n = vec2(float(ox), float(oy)); vec2 h = vec2(sparkHash(id + n), sparkHash(id + n + 17.0)); - float phase = sparkHash(id + n + 71.0) * 6.2831; - float tw = sin(finish_time * 2.6 + phase + finish_spin * 3.0) * 0.5 + 0.5; + float phase = sparkHash(id + n + 71.0) * CART_TAU; + float tw = sin(finish_twinkle + phase) * 0.5 + 0.5; float d = length(f - (n + h)); - peak = max(peak, smoothstep(0.13, 0.0, d) * pow(tw, 16.0)); + peak = max(peak, (1.0 - smoothstep(0.0, 0.13, d)) * pow(max(tw, 0.0), 16.0)); } } return vec3(peak); @@ -686,9 +755,9 @@ vec3 sparkle(vec2 screen_coords) { // Angle-dependent spectral sweep: a diagonal band whose hue walks with view // angle, so tilting the cart moves the rainbow the way real foil does. vec3 holo(vec2 uv) { - float band = uv.x * 0.9 + uv.y * 0.6 + finish_spin * 0.55 + finish_time * 0.06; - vec3 hue = 0.5 + 0.5 * cos(6.2831 * (band + vec3(0.0, 0.33, 0.67))); - float interference = 0.5 + 0.5 * sin((uv.x - uv.y) * 62.0 + finish_time * 0.9); + float band = uv.x * 0.9 + uv.y * 0.6 + finish_band; + vec3 hue = 0.5 + 0.5 * cos(CART_TAU * (band + vec3(0.0, 0.33, 0.67))); + float interference = 0.5 + 0.5 * sin((uv.x - uv.y) * 62.0 + finish_wave); return hue * (0.55 + 0.45 * interference); } @@ -722,26 +791,36 @@ end local function cartSendHover(shader, mx, my, hovering, screenScale) if not shader or not shader.send then return false end - local ok = pcall(function() - shader:send("mouse_screen_pos", { mx, my }) - shader:send("hovering", hovering) - shader:send("screen_scale", screenScale) - end) + local ok = pcall(shader.send, shader, "mouse_screen_pos", { mx, my }) + ok = pcall(shader.send, shader, "hovering", hovering) and ok + ok = pcall(shader.send, shader, "screen_scale", screenScale) and ok return ok end -- FINISH_* match the shader's `finish` uniform. local FINISH_NONE, FINISH_SPARKLE, FINISH_HOLO = 0, 1, 2 +local function cartFinishPhases(spin) + local t = Kit.time or 0 + spin = spin or 0 + return (t * 2.6 + spin * 3.0) % TAU, + (t * 0.06 + spin * 0.55) % 1.0, + (t * 0.9) % TAU +end + local function cartSendFinish(shader, mode, spin) if not shader or not shader.send then return end - pcall(function() - shader:send("finish", mode) - shader:send("finish_time", Kit.time or 0) - shader:send("finish_spin", spin or 0) - end) + local twinkle, band, wave = cartFinishPhases(spin) + pcall(shader.send, shader, "finish", mode) + pcall(shader.send, shader, "finish_twinkle", twinkle) + pcall(shader.send, shader, "finish_band", band) + pcall(shader.send, shader, "finish_wave", wave) end +LauncherView.CART_HOVER_SHADER = CART_HOVER_SHADER +LauncherView.cartFinishPhases = cartFinishPhases +LauncherView.cartHull = cartHull + local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action, version) local state = cartridgeState(imp, skin.cacheKey) markNoDrag(imp, x, y, w, h) @@ -842,10 +921,6 @@ local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action, ver local pressedScale = (active and 0.965 or 1) * state.visScale Kit._audit("control", x, y, w, h, key) - if focused then - Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6, PAL.lineStrong, - Theme.A.focus, 2, Theme.cardRadius() + 2) - end local halfW, halfH = w / 2, h / 2 local depth = math.max(6, w * 0.10) @@ -865,11 +940,6 @@ local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action, ver love.graphics.translate(cx, cy) love.graphics.rotate(juiceR * 2) love.graphics.translate(-cx, -cy) - if useHover then love.graphics.setShader(shader) end - if useHover then - cartSendFinish(shader, skin.sparkle and FINISH_SPARKLE or FINISH_NONE, - state.spin) - end local capH = h * 3 / 65 local mainTop = -halfH + capH @@ -880,6 +950,14 @@ local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action, ver capRight + halfW, capH, depth) local capBack = cartQuad(project, -halfW, -halfH, capRight + halfW, capH, -depth) + if shader then love.graphics.setShader(shader) end + if focused then + cartSendFinish(shader, FINISH_NONE, state.spin) + cartOutline({ { mainFront, mainBack }, { capFront, capBack } }, + math.max(2, math.min(w, h) * 0.012)) + end + cartSendFinish(shader, skin.sparkle and FINISH_SPARKLE or FINISH_NONE, + state.spin) local shell = skin.color local side = { math.floor(shell[1] * 0.54), math.floor(shell[2] * 0.54), math.floor(shell[3] * 0.54) } @@ -984,7 +1062,7 @@ local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action, ver local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2) local label = cartridgeLabel(imp, skin.cacheKey, skin.labelPath, skin.cartId) local mesh = label and cartLabelMesh(imp, skin.cacheKey, label, labelPoints) - if useHover and skin.holo then + if skin.holo then cartSendFinish(shader, FINISH_HOLO, state.spin) end if mesh then @@ -995,7 +1073,7 @@ local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action, ver love.graphics.draw(label.image, labelPoints[1][1], labelPoints[1][2], 0, artScale, artScale) end - if useHover and skin.holo then + if skin.holo then cartSendFinish(shader, skin.sparkle and FINISH_SPARKLE or FINISH_NONE, state.spin) end @@ -2432,6 +2510,12 @@ local function modGameCheckbox(x, y, size, checked, game, id, enabled) return enabled and (Kit.press(x, y, size, size) or Kit._activateId == id) end +local function cartsWithUpdates(imp) + if not imp._updateAllCartRows then return {} end + local ok, rows = pcall(imp._updateAllCartRows, imp) + return (ok and type(rows) == "table") and rows or {} +end + local function modsWithUpdates(imp) local names = {} for _, m in ipairs(imp.mods or {}) do @@ -2440,15 +2524,19 @@ local function modsWithUpdates(imp) names[#names + 1] = m.name or m.id end end + for _, row in ipairs(cartsWithUpdates(imp)) do + names[#names + 1] = row.name or row.id + end return names end local function modsWithUpdatesCount(imp) local mods = imp.mods or {} local rev = imp._modUpdateRev or 0 + local carts = #cartsWithUpdates(imp) local cache = imp._modUpdateCountCache if cache and cache.src == mods and cache.n == #mods and cache.rev == rev then - return cache.count + return cache.count + carts end local n = 0 for _, m in ipairs(mods) do @@ -2456,7 +2544,7 @@ local function modsWithUpdatesCount(imp) if info and info.status == "available" and info.best then n = n + 1 end end imp._modUpdateCountCache = { src = mods, n = #mods, rev = rev, count = n } - return n + return n + carts end local function askUpdateAllMods(imp) @@ -2465,12 +2553,12 @@ local function askUpdateAllMods(imp) imp:pressUpdateAllMods() return end - local lines = { Strings("Update %d mods?", #names) } + local lines = { Strings("Update %d items?", #names) } for i = 1, math.min(3, #names) do lines[#lines + 1] = names[i] end if #names > 3 then lines[#lines + 1] = Strings("and %d more", #names - 3) end - imp._modConfirm = { kind = "updateAll", title = Strings("Update all mods"), + imp._modConfirm = { kind = "updateAll", title = Strings("Update all"), yesLabel = Strings("Update all"), lines = lines } end @@ -2568,6 +2656,15 @@ local function buildModsPanel(imp, x, y, w, availH, m) btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, { kind = "accent", font = "small", action = function() imp:chooseMod() end }) + if #cartsWithUpdates(imp) > 0 then + local updateAllW = Kit.textWidth("small", Strings("Update all")) + + math.floor(20 * m.s) + btn(imp, place(updateAllW), cy, updateAllW, bh, "mods-update-all", + Strings("Update all"), { + kind = "warn", font = "small", + enabled = bulkOk and imp._updateAll == nil, + action = function() askUpdateAllMods(imp) end }) + end end cy = cy + bh + math.floor(8 * m.s) @@ -4085,7 +4182,7 @@ local function buildModHeaderActionsModal(imp, m) local btns = { { label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end }, { label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end }, - { label = Strings("Update all mods"), kind = "warn", + { label = Strings("Update all"), kind = "warn", enabled = bulkOk and imp._updateAll == nil, action = function() askUpdateAllMods(imp) end }, { label = Strings("Enable all mods"), kind = "good", enabled = bulkOk, diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 492826d6..5f9ed96b 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -456,6 +456,22 @@ function RomExtractor:extractFont() mainBase = 0x80, extraBase = 0x60, glyphsPerRow = 16, charmap = self.manifest.fontCharmap, } + -- engine/menus/naming_screen.asm:326 + local edSymbol = self.symbols["ED_Tile"] + if edSymbol then + local edRaw = self.rom:bytes(edSymbol[1], edSymbol[2], 8) + local ed = ImageWriter.blank(8, 8, 0, 0, 0, 0) + for y = 0, 7 do + local row = edRaw[y + 1] + for x = 0, 7 do + if bit.band(row, 2 ^ (7 - x)) ~= 0 then + ed:setPixel(x, y, 0, 0, 0, 1) + end + end + end + self:save(ed, "fonts/ed.png") + data.imageEd = "assets/generated/fonts/ed.png" + end self:write("font", data) self:tick("Fonts", 2, 2) return data diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 93eb40c8..d560d28c 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -5797,6 +5797,14 @@ function RomExtractorGen2:extractMenuGfx() hud.expBarFirstTile = 0x55 hud.expBarCells = 9 + -- (../pokecrystal/engine/sprite_anims/core.asm:547-557). + if self.symbols["EndOfExpBarGFX"] then + local expBarEnd = self:symbol("EndOfExpBarGFX") + self:write2bpp(self.rom:bytes(expBarEnd.bank, expBarEnd.address, 16), + 8, 8, "battle/hud/exp_bar_end.png", true) + hud.expBarEnd = "assets/generated/battle/hud/exp_bar_end.png" + end + -- Four OAM tiles at $31 -- normal, statused, fainted, empty -- and OBJ -- colour 0 is transparent (engine/battle/trainer_huds.asm:47-99, :225-232). local balls = self:symbol("LoadBallIconGFX.gfx") diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 6a736f35..f012533a 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -3804,11 +3804,15 @@ end -- FlexLove.touch* or scroll containers never drag on phones. function RomImporter:mousepressed(x, y, button) self._padCursorActive = false + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit then Kit.pointerUsed() end if button ~= 1 or not self._flex then return end require("src.import.LauncherView").mousepressed(self, x, y) end function RomImporter:touchpressed(id, x, y, dx, dy, pressure) + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit then Kit.pointerUsed() end if not self._flex then return end require("src.import.LauncherView").touchpressed( self, id, x, y, dx, dy, pressure) @@ -4179,6 +4183,14 @@ function RomImporter:_pumpSync(dt) local eng = self._sync if not eng then return end pcall(eng.update, eng, dt) + if eng.changed then + eng.changed = nil + local rows = eng.lastDownloads + eng.lastDownloads = nil + for _, row in ipairs(type(rows) == "table" and rows or {}) do + self:_syncNoteDownload(row) + end + end if eng.phase == "conflict" and eng.conflicts and #eng.conflicts > 0 then if not self._syncModal and not self._syncConflictShown then self._syncConflictShown = true @@ -4189,6 +4201,16 @@ function RomImporter:_pumpSync(dt) end end +function RomImporter:_syncNoteDownload(row) + local version = type(row) == "table" and row.version + if type(version) ~= "string" or not GameVersion.VERSIONS[version] then return end + self:_refreshSlots(version) + if row.created then self.slotScroll[version] = math.huge end + local from = row.device and (" from " .. tostring(row.device)) or "" + self.saveNotice[version] = { ok = true, + text = ("Downloaded a save%s into %s."):format(from, tostring(row.slot)) } +end + function RomImporter:_openSync() self:_syncEngine() self._syncModal = self._syncModal @@ -6220,14 +6242,18 @@ end -- A cart is one .g1rcart file, so the bytes go whole to CartStore.install and -- never through the mod installer. Shares the mod job's single-in-flight -- rule: either entry point refuses while the other is running. -function RomImporter:_beginCartInstall(entry) - if self._modInstall or self._cartInstall or self._updateAll then return end +function RomImporter:_beginCartInstall(entry, spec) + if self._modInstall or self._cartInstall then return end + if self._updateAll and not (spec and spec.fromUpdateAll) then return end local ModIndex = require("src.mods.ModIndex") local release, why = ModIndex.releaseFor(entry) if type(release) ~= "table" or not (release.zip and release.zip.url) then - self.findNotice = { ok = false, - text = ("%s: %s"):format(tostring(entry.title or entry.id), - tostring(why or "this cart has no downloadable release")) } + local text = ("%s: %s"):format(tostring(entry.title or entry.id), + tostring(why or "this cart has no downloadable release")) + if not (spec and spec.quiet) then + self.findNotice = { ok = false, text = text } + end + if spec and spec.done then spec.done(false, text) end return end local ModUpdate = require("src.mods.ModUpdate") @@ -6237,16 +6263,21 @@ function RomImporter:_beginCartInstall(entry) require("src.carts.CartStore").EXT) self._cartInstall = { entry = entry, version = release.version or entry.version, + quiet = spec and spec.quiet or nil, done = spec and spec.done or nil, h = ModUpdate.beginDownloadZip(release.zip.url, tmpName, release.zip.size), } self:_setBusy(Strings("Downloading %s", tostring(entry.title or entry.id)), "v" .. tostring(release.version or entry.version or "?")) end -function RomImporter:_cartInstallFailed(msg) +function RomImporter:_cartInstallFailed(msg, job) + job = job or self._cartInstall self._cartInstall = nil self:_clearBusy() - self.findNotice = { ok = false, text = tostring(msg) } + if not (job and job.quiet) then + self.findNotice = { ok = false, text = tostring(msg) } + end + if job and job.done then job.done(false, tostring(msg)) end end function RomImporter:_pumpCartInstall() @@ -6270,17 +6301,19 @@ function RomImporter:_pumpCartInstall() local read, bytes = pcall(love.filesystem.read, path) pcall(love.filesystem.remove, path) if not read or type(bytes) ~= "string" or bytes == "" then - return self:_cartInstallFailed(name .. ": the download could not be read back") + return self:_cartInstallFailed(name .. ": the download could not be read back", + job) end self:_setBusy(Strings("Installing %s", name)) local CartStore = require("src.carts.CartStore") local ran, cart, installErr = pcall(CartStore.install, bytes) self:_clearBusy() if not ran then - return self:_cartInstallFailed(name .. ": install failed: " .. tostring(cart)) + return self:_cartInstallFailed(name .. ": install failed: " .. tostring(cart), + job) end if not cart then - return self:_cartInstallFailed(name .. ": " .. tostring(installErr)) + return self:_cartInstallFailed(name .. ": " .. tostring(installErr), job) end -- _refreshCarts caches per version, so the Custom Carts list for the game -- this cart plays as would keep its pre-install copy until a relaunch. @@ -6288,10 +6321,13 @@ function RomImporter:_pumpCartInstall() self:_refreshCarts(cart.base) self._cartPlan = nil self._cartridgeLabels = nil - self.findNotice = { ok = true, - text = Strings("Installed %s v%s. It is in this game's cart list now.", - tostring(cart.title or cart.id), tostring(cart.version or "?")) } - self:_offerCartPins(cart) + if not job.quiet then + self.findNotice = { ok = true, + text = Strings("Installed %s v%s. It is in this game's cart list now.", + tostring(cart.title or cart.id), tostring(cart.version or "?")) } + self:_offerCartPins(cart) + end + if job.done then job.done(true, tostring(cart.version or "?")) end end -- The pins a cart is missing, answered without disturbing whichever cart the @@ -6506,17 +6542,68 @@ function RomImporter:_installModVersion(modId, release) end -function RomImporter:_updateAllRows() - if self.modCartPlan and self:modCartPlan() then return {} end - local rows = {} - for _, m in ipairs(self.mods or {}) do - local info = self:_modUpdateInfo(m.id) - if info and info.status == "available" and info.best - and info.best.zip and info.best.zip.url then - rows[#rows + 1] = { id = m.id, name = m.name, release = info.best, - from = m.version } +local function repoKey(...) + local Manifest = require("src.mods.Manifest") + for i = 1, select("#", ...) do + local value = select(i, ...) + if type(value) == "string" and value ~= "" then + local ok, key = pcall(Manifest.parseGithub, value) + if ok and type(key) == "string" then return key:lower() end end end + return nil +end + +function RomImporter:_updateAllCartRows() + local feed = (self.findIndex and self.findIndex.carts) or nil + local seen = self:_findInstalledCarts() + local cache = self._cartUpdateCache + if cache and cache.feed == feed and cache.seen == seen then + return cache.rows + end + local rows = {} + self._cartUpdateCache = { feed = feed, seen = seen, rows = rows } + if type(feed) ~= "table" or #feed == 0 then return rows end + local listed = {} + for _, entry in ipairs(feed) do + if type(entry) == "table" and entry.id and seen[entry.id] then + listed[entry.id] = entry + end + end + if next(listed) == nil then return rows end + local ok, installed = pcall(function() + return require("src.carts.CartStore").list() + end) + if not ok or type(installed) ~= "table" then return rows end + local ModIndex = require("src.mods.ModIndex") + local ModUpdate = require("src.mods.ModUpdate") + for _, row in ipairs(installed) do + local entry = listed[row.id] + local mine = row.cart and repoKey(row.cart.repo) + if entry and mine and mine == repoKey(entry.github, entry.repo) + and ModIndex.canInstall(entry) + and ModUpdate.isNewer(row.version, ModIndex.displayVersion(entry)) then + rows[#rows + 1] = { kind = "cart", id = row.id, entry = entry, + name = row.title or row.id, from = row.version, + to = ModIndex.displayVersion(entry) } + end + end + return rows +end + +function RomImporter:_updateAllRows() + local rows = {} + if not (self.modCartPlan and self:modCartPlan()) then + for _, m in ipairs(self.mods or {}) do + local info = self:_modUpdateInfo(m.id) + if info and info.status == "available" and info.best + and info.best.zip and info.best.zip.url then + rows[#rows + 1] = { kind = "mod", id = m.id, name = m.name, + release = info.best, from = m.version } + end + end + end + for _, row in ipairs(self:_updateAllCartRows()) do rows[#rows + 1] = row end return rows end @@ -6529,9 +6616,10 @@ function RomImporter:pressUpdateAllMods() return false end self._updateAll = { stage = "check", index = 0, updated = 0, - updatedIds = {}, failures = {} } + updatedIds = {}, updatedCarts = {}, failures = {} } self.modNotice = nil self:_syncModUpdateInfo(true) + self:_ensureFind() self:_setBusy(Strings("Checking for updates"), nil, function() self:_cancelUpdateAll() end) self:_pumpUpdateAll() @@ -6542,8 +6630,8 @@ function RomImporter:_cancelUpdateAll() local job = self._updateAll if not job or job.cancelled then return end job.cancelled = true - if self._modInstall then - self:_setBusy(Strings("Finishing the last mod")) + if self._modInstall or self._cartInstall then + self:_setBusy(Strings("Finishing the last download")) else self:_finishUpdateAll(true) end @@ -6554,7 +6642,7 @@ function RomImporter:_pumpUpdateAll() if not job then return end if job.stage == "check" then - if self._modInfoFetch then return end + if self._modInfoFetch or self._findFetch then return end job.rows = self:_updateAllRows() job.total = #job.rows if job.cancelled or job.total == 0 then @@ -6571,24 +6659,35 @@ function RomImporter:_pumpUpdateAll() local row = job.rows[job.index] if not row then return self:_finishUpdateAll() end job.stage = "installing" - self:_beginModInstall({ - modId = row.id, name = row.name, release = row.release, - verb = "Updated", notice = "mod", quiet = true, - done = function(ok, text) - if ok then - job.updated = job.updated + 1 - job.updatedIds[#job.updatedIds + 1] = row.id + local function finished(ok, text) + if ok then + job.updated = job.updated + 1 + if row.kind == "cart" then + job.updatedCarts[#job.updatedCarts + 1] = + { id = row.id, base = row.entry and row.entry.base, + name = row.name or row.id } else - job.failures[#job.failures + 1] = - ("%s: %s"):format(tostring(row.name or row.id), tostring(text)) + job.updatedIds[#job.updatedIds + 1] = row.id end - job.stage = "next" - end, - }) - if self._modInstall then + else + job.failures[#job.failures + 1] = + ("%s: %s"):format(tostring(row.name or row.id), tostring(text)) + end + job.stage = "next" + end + if row.kind == "cart" then + self:_beginCartInstall(row.entry, + { quiet = true, fromUpdateAll = true, done = finished }) + else + self:_beginModInstall({ + modId = row.id, name = row.name, release = row.release, + verb = "Updated", notice = "mod", quiet = true, done = finished, + }) + end + if self._modInstall or self._cartInstall then self:_setBusy(Strings("Updating %s (%d of %d)", tostring(row.name or row.id), job.index, job.total), - "v" .. tostring(row.release.version or "?"), + "v" .. tostring(row.to or (row.release and row.release.version) or "?"), function() self:_cancelUpdateAll() end) elseif job.stage == "installing" then job.stage = "next" @@ -6603,17 +6702,29 @@ function RomImporter:_finishUpdateAll(cancelled) if not job then return end if cancelled then self.modNotice = { ok = true, failures = job.failures, - text = Strings("Stopped after updating %d mods.", job.updated) } + text = Strings("Stopped after updating %d items.", job.updated) } elseif (job.total or 0) == 0 then - self.modNotice = { ok = true, text = Strings("All mods are up to date.") } + self.modNotice = { ok = true, text = Strings("Everything is up to date.") } elseif #job.failures == 0 then self.modNotice = { ok = true, - text = Strings("Updated %d mods.", job.updated) } + text = Strings("Updated %d items.", job.updated) } else self.modNotice = { ok = false, failures = job.failures, text = Strings("Updated %d of %d. %d failed:", job.updated, job.total, #job.failures) } end + for _, cart in ipairs(job.updatedCarts or {}) do + if cart.base then + local missing = #self:_cartPinsMissing(cart.base, cart.id) + if missing > 0 then + local lines = self.modNotice.failures or {} + lines[#lines + 1] = Strings( + "%s now pins %d mod(s) you do not have. Open it in Custom Carts to install them.", + tostring(cart.name), missing) + self.modNotice.failures = lines + end + end + end local LauncherMods = require("src.mods.LauncherMods") for _, id in ipairs(job.updatedIds or {}) do local ok, depCheck = pcall(LauncherMods.checkDependencies, { id = id }) diff --git a/src/import/SaveFileIO.lua b/src/import/SaveFileIO.lua index a11f41b4..a5d137bf 100644 --- a/src/import/SaveFileIO.lua +++ b/src/import/SaveFileIO.lua @@ -171,7 +171,12 @@ function SaveFileIO.exportActiveSlot(version) version = version or GameVersion.get() local save = SaveData.load(version) if not save then return false, "this game has no save to export yet" end - local slotId = SaveData.activeSlot(version) or "save" + local activeSlot = SaveData.activeSlot(version) + local slotId = activeSlot or "save" + if activeSlot and type(save.meta) == "table" then + local minted, id = pcall(SaveData.slotPlaythroughId, version, activeSlot, save) + if minted and type(id) == "string" then save.meta.playthroughId = id end + end local bytes, exportErr = SaveConvert.exportSav(save, version, readCart(version, slotId)) if not bytes then return false, exportErr end diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua index ed185d4e..a5ed0145 100644 --- a/src/mods/Gen2Compat.lua +++ b/src/mods/Gen2Compat.lua @@ -197,9 +197,8 @@ local function buildGame() function translate.logicSpeed() return function() local g = live() - if not g then return 1 end - return math.max(1, tonumber(g.speedOverride) - or tonumber(g.options and g.options.speed) or 1) + if not g or not g.logicSpeed then return 1 end + return g:logicSpeed() end end diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index 48e948db..f4f44c6c 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -152,6 +152,10 @@ O.checksumStart = O.playerName O.checksumEnd = O.curBoxData + BOX_REGION_SIZE + 1 -- + sTileAnimations (1B) O.mainChecksum = O.checksumEnd -- 1B +O.identityTag = 8192 -- ram/sram.asm:14 +GenSave.IDENTITY_MAGIC = "G1RC" +GenSave.IDENTITY_ID_LENGTH = 32 + O.box1 = 16384 -- bank 2 start O.boxBank2Checksum = O.box1 + 6 * BOX_REGION_SIZE O.boxBank2IndividualChecksums = O.boxBank2Checksum + 1 -- 6B @@ -673,7 +677,7 @@ function GenSave.decode(bytes, data, opts) end local save = { - meta = { format = "gen1_import" }, + meta = { format = "gen1_import", playthroughId = GenSave.readIdentity(bytes) }, player = { name = decodeName(bytes, O.playerName, NAME_LENGTH), rival = decodeName(bytes, O.rivalName, NAME_LENGTH), @@ -874,6 +878,23 @@ end -- encode: save.lua-shaped table -> raw 32768-byte SRAM string -- ------------------------------------------------------------------ +local function identityOf(save) + local meta = type(save) == "table" and save.meta + local id = type(meta) == "table" and meta.playthroughId + if type(id) ~= "string" or #id ~= GenSave.IDENTITY_ID_LENGTH then return nil end + if not id:match("^%x+$") then return nil end + return id +end + +function GenSave.readIdentity(bytes) + if type(bytes) ~= "string" or #bytes < GenSave.SAVE_SIZE then return nil end + local off = O.identityTag + local magic = GenSave.IDENTITY_MAGIC + if bytes:sub(off + 1, off + #magic) ~= magic then return nil end + return identityOf({ meta = { playthroughId = + bytes:sub(off + #magic + 1, off + #magic + GenSave.IDENTITY_ID_LENGTH) } }) +end + function GenSave.encode(save, data, template) local cw = GenSave.crosswalks(data) local src = template or save.rawImport @@ -885,6 +906,12 @@ function GenSave.encode(save, data, template) for i = 1, GenSave.SAVE_SIZE do buf[i] = zero end end + local identity = identityOf(save) + if identity then + local tag = GenSave.IDENTITY_MAGIC .. identity + for i = 1, #tag do buf[O.identityTag + i] = tag:sub(i, i) end + end + local padTail = not src encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED", padTail) encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE", padTail) diff --git a/src/script/gen2/Specials.lua b/src/script/gen2/Specials.lua index 25c3e6ea..00c12118 100644 --- a/src/script/gen2/Specials.lua +++ b/src/script/gen2/Specials.lua @@ -1951,10 +1951,11 @@ H.DaisysGrooming = function(vm) haircut(vm, "daisy") end -- the cart puts them in two DIFFERENT buffers (wStringBuffer3, -- wStringBuffer4) in the one textbox, and the VM's {STRBUF} substitution -- only ever carries one value. +-- `para` is `\f` (home/text.asm:403), `cont` is `\v` (home/text.asm:442), the local OAK_PC_TEXT = { completion = Strings.source("Current #DEX\ncompletion level:"), counts = Strings.source( - "%d #MON seen\n%d #MON owned\n\nPROF.OAK's\nRating:"), + "%d #MON seen\n%d #MON owned\fPROF.OAK's\nRating:"), } -- OakRatings (data/events/pokedex_ratings.asm). Each row is (cap, sfx, @@ -1967,39 +1968,39 @@ local OAK_RATINGS = { { max = 9, sfx = "Sfx_DexFanfareLessThan20", text = Strings.source( "Look for #MON\nin grassy areas!") }, { max = 19, sfx = "Sfx_DexFanfareLessThan20", text = Strings.source( - "Good. I see you\nunderstand how to\nuse # BALLS.") }, + "Good. I see you\nunderstand how to\vuse # BALLS.") }, { max = 34, sfx = "Sfx_DexFanfare2049", text = Strings.source( - "You're getting\ngood at this.\n\nBut you have a\nlong way to go.") }, + "You're getting\ngood at this.\fBut you have a\nlong way to go.") }, { max = 49, sfx = "Sfx_DexFanfare2049", text = Strings.source( - "You need to fill\nup the #DEX.\n\nCatch different\nkinds of #MON!") }, + "You need to fill\nup the #DEX.\fCatch different\nkinds of #MON!") }, { max = 64, sfx = "Sfx_DexFanfare5079", text = Strings.source( - "You're trying--I\ncan see that.\n\nYour #DEX is\ncoming together.") }, + "You're trying--I\ncan see that.\fYour #DEX is\ncoming together.") }, { max = 79, sfx = "Sfx_DexFanfare5079", text = Strings.source( - "To evolve, some\n#MON grow,\n\nothers use the\neffects of STONES.") }, + "To evolve, some\n#MON grow,\fothers use the\neffects of STONES.") }, { max = 94, sfx = "Sfx_DexFanfare80109", text = Strings.source( - "Have you gotten a\nfishing ROD? You\n\ncan catch #MON\nby fishing.") }, + "Have you gotten a\nfishing ROD? You\fcan catch #MON\nby fishing.") }, { max = 109, sfx = "Sfx_DexFanfare80109", text = Strings.source( - "Excellent! You\nseem to like col-\nlecting things!") }, + "Excellent! You\nseem to like col-\vlecting things!") }, { max = 124, sfx = "Sfx_CaughtMon", text = Strings.source( - "Some #MON only\nappear during\n\ncertain times of\nthe day.") }, + "Some #MON only\nappear during\fcertain times of\nthe day.") }, { max = 139, sfx = "Sfx_CaughtMon", text = Strings.source( - "Your #DEX is\nfilling up. Keep\nup the good work!") }, + "Your #DEX is\nfilling up. Keep\vup the good work!") }, { max = 154, sfx = "Sfx_DexFanfare140169", text = Strings.source( - "I'm impressed.\nYou're evolving\n\n#MON, not just\ncatching them.") }, + "I'm impressed.\nYou're evolving\f#MON, not just\ncatching them.") }, { max = 169, sfx = "Sfx_DexFanfare140169", text = Strings.source( - "Have you met KURT?\nHis custom #\nBALLS should help.") }, + "Have you met KURT?\nHis custom #\vBALLS should help.") }, { max = 184, sfx = "Sfx_DexFanfare170199", text = Strings.source( - "Wow. You've found\nmore #MON than\n\nthe last #DEX\nresearch project.") }, + "Wow. You've found\nmore #MON than\fthe last #DEX\nresearch project.") }, { max = 199, sfx = "Sfx_DexFanfare170199", text = Strings.source( - "Are you trading\nyour #MON?\n\nIt's tough to do\nthis alone!") }, + "Are you trading\nyour #MON?\fIt's tough to do\nthis alone!") }, { max = 214, sfx = "Sfx_DexFanfare200229", text = Strings.source( - "Wow! You've hit\n200! Your #DEX\nis looking great!") }, + "Wow! You've hit\n200! Your #DEX\vis looking great!") }, { max = 229, sfx = "Sfx_DexFanfare200229", text = Strings.source( - "You've found so\nmany #MON!\n\nYou've really\nhelped my studies!") }, + "You've found so\nmany #MON!\fYou've really\nhelped my studies!") }, { max = 239, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( - "Magnificent! You\ncould become a\n\n#MON professor\nright now!") }, + "Magnificent! You\ncould become a\f#MON professor\nright now!") }, { max = 248, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( - "Your #DEX is\namazing! You're\n\nready to turn\nprofessional!") }, + "Your #DEX is\namazing! You're\fready to turn\nprofessional!") }, -- The top band (251 real species, the table's cap of 255 covers it): this -- is the ONLY place the ROM checks "has the player finished the #DEX", and -- it does it here rather than handing off to anything. The actual diploma @@ -2011,7 +2012,7 @@ local OAK_RATINGS = { -- A finished #DEX here just means every rating after this one is this -- same line. { max = 255, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( - "Whoa! A perfect\n#DEX! I've\n\ndreamt about this!\nCongratulations!") }, + "Whoa! A perfect\n#DEX! I've\fdreamt about this!\nCongratulations!") }, } -- CountSetBits over wPokedexSeen/wPokedexCaught. The port's dex is a diff --git a/src/sync/SyncEngine.lua b/src/sync/SyncEngine.lua index bc7142a5..c8b25db5 100644 --- a/src/sync/SyncEngine.lua +++ b/src/sync/SyncEngine.lua @@ -43,7 +43,6 @@ function SyncEngine.defaultSaves() return { list = function() local SaveData = saveApi() - local options = SaveData.loadOptions() local out = {} for _, version in ipairs(gameVersions()) do for _, slot in ipairs(SaveData.listSlots(version)) do @@ -52,12 +51,7 @@ function SyncEngine.defaultSaves() local save = source and SaveData.decode(source) if type(save) == "table" then local meta = type(save.meta) == "table" and save.meta or {} - local id = meta.playthroughId - if type(id) ~= "string" or id == "" then - local byVersion = options.playthroughIds - and options.playthroughIds[version] - id = byVersion and byVersion[slot.id] or nil - end + local id = SaveData.slotPlaythroughId(version, slot.id, save) if id then local name, summary = SaveData.slotSummary(save) out[#out + 1] = { @@ -95,7 +89,7 @@ function SyncEngine.defaultSaves() if type(save) ~= "table" then return nil, "the downloaded save is unreadable" end save.version = save.version or version local options = SaveData.loadOptions() - local slotId + local slotId, created if mode == "new" then save.meta = type(save.meta) == "table" and save.meta or {} save.meta.playthroughId = SaveData.newPlaythroughId() @@ -105,6 +99,7 @@ function SyncEngine.defaultSaves() if not slotId then slotId = SaveData.createSlot(version) if not slotId then return nil, "could not make a save slot" end + created = true end local ok, err = SaveData.writeSlot(version, slotId, save) if not ok then return nil, err or "could not write the save" end @@ -114,7 +109,7 @@ function SyncEngine.defaultSaves() options.playthroughIds[version][slotId] = save.meta and save.meta.playthroughId or playthroughId SaveData.saveOptions(options) - return slotId + return slotId, created == true end, } end @@ -621,16 +616,26 @@ function SyncEngine:_queueDownload(key, version, playthroughId, mode, knownRev) e:_fail("the server sent no save data") return end - local slotId, writeErr = e.saves.write(version, playthroughId, data.blob, mode) + local slotId, detail = e.saves.write(version, playthroughId, data.blob, mode) if not slotId then - e:_fail(writeErr or "could not write the downloaded save") + e:_fail(detail or "could not write the downloaded save") return end + local created = detail == true + local meta = type(data.meta) == "table" and data.meta or {} if mode ~= "new" then - local meta = type(data.meta) == "table" and data.meta or {} SyncState.setRev(e.state, key, tonumber(data.rev) or knownRev, unixSeconds(meta.savedAt)) end + e.lastDownloads = e.lastDownloads or {} + e.lastDownloads[#e.lastDownloads + 1] = { + version = version, + slot = slotId, + created = created, + device = type(meta.device) == "string" and meta.device ~= "" + and meta.device or nil, + } + e.changed = true e:_persist() if not e:busy() then e:_finish() end end) diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index a54a42ba..4ef017a6 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -9,6 +9,7 @@ -- ui.naming.grid may replace either page; keep an "ED" cell and a -- single-cell case-switch row so confirm / case-flip keep working. +local Assets = require("src.render.Assets") local Font = require("src.render.Font") local HudTiles = require("src.render.HudTiles") local Runtime = require("src.mods.Runtime") @@ -25,6 +26,23 @@ local UNDERSCORE, RAISED = 0x76, 0x77 -- engine/gfx/mon_icons.asm:88 local ICON_SPEED = 16 +-- engine/menus/naming_screen.asm:326 +local ED_IMAGE = "assets/generated/fonts/ed.png" +local edTile + +local function drawEd(x, y) + if edTile == nil then + local ok, image = pcall(Assets.image, ED_IMAGE) + edTile = ok and image or false + end + if not edTile then return false end + love.graphics.draw(edTile, x, y) + return true +end + +function NamingScreen.invalidate() edTile = nil end +Assets.register(NamingScreen.invalidate) + -- SGB: generic whole-screen palette (SET_PAL_GENERIC) function NamingScreen:sgbPalettes(game) return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") @@ -260,7 +278,10 @@ function NamingScreen:draw() -- engine/menus/naming_screen.asm:346 for r, row in ipairs(self:grid()) do for c, cell in ipairs(row) do - Font.draw(Strings(cell), c * 16, 24 + r * 16) + local x, y = c * 16, 24 + r * 16 + if cell ~= "ED" or not drawEd(x, y) then + Font.draw(Strings(cell), x, y) + end end end Font.drawCode(Theme.cursor, self.col * 16 - 8, 24 + self.row * 16) diff --git a/src/ui/Screens.lua b/src/ui/Screens.lua index 032441a3..2fb382b0 100644 --- a/src/ui/Screens.lua +++ b/src/ui/Screens.lua @@ -43,7 +43,10 @@ local GEN2 = { -- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1 the CHALLENGE -- / EXPLANATION / CANCEL desk menu, Crystal only. "BattleTowerMenu", - "BattleTransition", "BoxMenu", + "BattleTransition", + -- home/tilemap.asm:1 ClearBGPalettes, the white a page is built behind. + "BlankScreen", + "BoxMenu", -- ../pokecrystal/engine/events/buena.asm:1 the radio-password prompt behind -- `special BuenasPassword`, Crystal only. "BuenaPassword", diff --git a/src/ui/gen2/BattleHud.lua b/src/ui/gen2/BattleHud.lua index 7822a1ae..2faab48e 100644 --- a/src/ui/gen2/BattleHud.lua +++ b/src/ui/gen2/BattleHud.lua @@ -237,6 +237,23 @@ function BattleHud:drawExpBar(fraction, tx, ty, zero) return true end +-- than on the tile grid (../pokecrystal/engine/sprite_anims/core.asm:547-608). +function BattleHud:drawExpBarEnd(x, y, colors) + local image = self:image("expBarEnd") + if not image then return false end + local G = love.graphics + G.setColor(1, 1, 1, 1) + local function body() G.draw(image, x, y) end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + G.setColor(0, 0, 0, 1) + body() + G.setColor(1, 1, 1, 1) + end + return true +end + -- The mark sits inside the enemy HP block, which the battle attrmap fills with -- PAL_BATTLE_BG_ENEMY_HP (engine/gfx/cgb_layouts.asm:123-128). function BattleHud:drawCaughtIcon(tx, ty, hp, maxHp) diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 2147d05f..8ac07a24 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -38,6 +38,8 @@ local Prize = require("src.battle.gen2.Prize") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local Sound = require("src.core.Sound") +-- Only for Sprites_Sine / Sprites_Cosine: ../pokecrystal/engine/math/sine.asm +local SpriteAnims = require("src.ui.gen2.SpriteAnims") -- Only for playerPic: the player.sprite raiser both generations share. local Sprites = require("src.pokemon.Sprites") local Strings = require("src.core.Strings") @@ -1085,6 +1087,19 @@ local SFX_EXP_BAR = "Sfx_ExpBar" local SFX_END_OF_EXP_BAR = "Sfx_HitEndOfExpBar" local EXP_SOUND_FRAMES = 10 +-- (../pokecrystal/engine/sprite_anims/core.asm:547-608). The `call WaitSFX` +-- behind it (../pokecrystal/engine/battle/core.asm:7538) is what holds the +local EXP_BURST_FRAMES = 8 +local EXP_BURST_SPRITES = 8 +local EXP_BURST_X = 10 * 8 + 4 - 8 +local EXP_BURST_Y = 13 * 8 - 16 + +-- ../pokecrystal/engine/sprite_anims/core.asm:543 leaves a as a signed byte. +local function signedByte(value) + if value >= 128 then return value - 256 end + return value +end + -- One tick of the exp bar crawl. AnimateExpBar walks the bar one PIXEL at a -- time out of 64 (.LoopBarAnimation, engine/battle/core.asm:7325-7362): the -- gap starts at three frames a pixel and drops by one after every SECOND @@ -1097,7 +1112,11 @@ local EXP_SOUND_FRAMES = 10 -- way the cart's loop holds the game. function BattleState:stepExpAnim() local anim = self.expAnim - if not anim then return false end + if not anim then + self.expBurst = nil + return false + end + if self:stepExpBurst(anim) then return true end local mon = anim.mon local toLevel = (mon and mon.level) or self.shownLevel or 1 local target = BattleHud.EXP_LENGTH_PX @@ -1131,15 +1150,47 @@ function BattleState:stepExpAnim() Sound.stop(SFX_EXP_BAR) if (self.shownLevel or 1) < toLevel then self.shownLevel = (self.shownLevel or 1) + 1 - self.shownExp = 0 self:playSfx(SFX_END_OF_EXP_BAR) - anim.started = false + self.expBurst = { frame = 1 } return true end self.expAnim = nil return true end +-- (../pokecrystal/engine/battle/core.asm:7536-7538). The bar stays at $40 +function BattleState:stepExpBurst(anim) + local burst = self.expBurst + if not burst then return false end + burst.frame = burst.frame + 1 + if burst.frame <= EXP_BURST_FRAMES then return true end + if not burst.left then + burst.left = (Sound.waitFramesFor and Sound.waitFramesFor(SFX_END_OF_EXP_BAR)) + or 0 + end + burst.left = burst.left - 1 + if burst.left > 0 and Sound.isPlaying(SFX_END_OF_EXP_BAR) then return true end + self.expBurst = nil + self.shownExp = 0 + anim.started = false + return true +end + +-- (../pokecrystal/engine/sprite_anims/core.asm:558-608). +function BattleState:drawExpBurst() + local burst = self.expBurst + if not burst or burst.frame > EXP_BURST_FRAMES then return end + local radius = (burst.frame - 1) * 2 + local set = self.palettes and self.palettes.battleObjects + local colors = set and set.PAL_BATTLE_OB_BLUE or nil + for c = EXP_BURST_SPRITES - 1, 0, -1 do + local angle = c * 8 + local y = EXP_BURST_Y + signedByte(SpriteAnims.sine(angle, radius)) + local x = EXP_BURST_X + signedByte(SpriteAnims.cosine(angle, radius)) + self.hud:drawExpBarEnd(x, y, colors) + end +end + -- The HP a side's HUD prints and fills its bar from: the chased value, not -- the engine's, which ran a whole turn ahead. function BattleState:hudHp(mon, side) @@ -1635,6 +1686,7 @@ function BattleState:advanceQueue() self.shownExp = self:expPixels(event.mon, level, event.experience or event.mon.experience) self.expAnim = nil + self.expBurst = nil end end -- ResetEnemyBattleVars' SlideBattlePicOut (engine/battle/core.asm:3027): @@ -3809,6 +3861,8 @@ function BattleState:drawHud() self:drawFrame(9, 11, 10, true) HpBar.drawExp(self.palettes, expFraction, 10 * 8, 11 * 8 + 4) end + -- The OBJ layer over the bar (../pokecrystal/engine/battle/core.asm:7537). + if self.expBurst then self:drawExpBurst() end Font.useBattleExtra(wasBattle) end diff --git a/src/ui/gen2/BattleTransition.lua b/src/ui/gen2/BattleTransition.lua index 6be58bc0..0d2ba9ed 100644 --- a/src/ui/gen2/BattleTransition.lua +++ b/src/ui/gen2/BattleTransition.lua @@ -159,6 +159,11 @@ function BattleTransition.pokeballCells() return cells end +-- ../pokecrystal/engine/battle/battle_transition.asm:266-290 +function BattleTransition.ballShade(byte) + return GbcPalette.bgpShades(byte)[4] + 1 +end + -------------------------------------------------------------------------- -- SpinToBlack -------------------------------------------------------------------------- @@ -682,8 +687,10 @@ function BattleTransition:drawWidescreen(w, h) G.rectangle("fill", 0, 0, w, h) end - if self.phase == "pokeball" then - self:drawCells(w, h, BattleTransition.pokeballCells()) + -- ../pokecrystal/engine/battle/battle_transition.asm:584 + if self.trainer then + self:drawCells(w, h, BattleTransition.pokeballCells(), + (not pal) and byte or nil, ly) end if pal then @@ -735,20 +742,38 @@ end -- BATTLETRANSITION_SQUARE, the Poke Ball's own tile: a filled block in the -- text palette rather than the black the wipe uses, so the ball reads against -- the map behind it. -function BattleTransition:drawCells(w, h, cells) +function BattleTransition:drawCells(w, h, cells, byte, ly) local G = love.graphics local size, ox, oy = self:grid(w, h) -- Shade 3 of the text palette, through the COLOR mode like every other -- direct colour read. local ramp = self:trainerRamp() - local color = ramp and GbcPalette.color(ramp, 4) or GbcPalette.color(nil, 4) + local shade = BattleTransition.ballShade(byte) + local color = GbcPalette.color(ramp, shade) if color then G.setColor(color[1] / 255, color[2] / 255, color[3] / 255, 1) else G.setColor(0, 0, 0, 1) end + local scale = math.max(1, size / 8) for _, cell in ipairs(cells) do - G.rectangle("fill", ox + cell[1] * size, oy + cell[2] * size, size, size) + local x, y = ox + cell[1] * size, oy + cell[2] * size + if not ly then + G.rectangle("fill", x, y, size, size) + else + local base = math.floor(y / scale) + for line = 0, 7 do + local row = math.max(0, math.min(143, base + line)) + local shift = (ly[row] or 0) * scale + local top = y + line * scale + G.rectangle("fill", x - shift, top, size, scale) + if shift > 0 then + G.rectangle("fill", x - shift + w, top, size, scale) + elseif shift < 0 then + G.rectangle("fill", x - shift - w, top, size, scale) + end + end + end end end diff --git a/src/ui/gen2/BlankScreen.lua b/src/ui/gen2/BlankScreen.lua new file mode 100644 index 00000000..43285b35 --- /dev/null +++ b/src/ui/gen2/BlankScreen.lua @@ -0,0 +1,52 @@ +-- ClearBGPalettes (home/tilemap.asm:1), the white hold every full-screen page +-- opens and closes with: _FlyMap (engine/pokegear/pokegear.asm:1979) and +-- ExitAllMenus (home/map.asm:2282). + +local Chrome = require("src.ui.gen2.Chrome") + +local BlankScreen = {} +BlankScreen.__index = BlankScreen +BlankScreen.isOpaque = true + +-- WaitBGMap's `ld c, 4 / call DelayFrames` (home/tilemap.asm:3) +BlankScreen.FRAMES = 4 + +function BlankScreen.new(game, opts) + opts = opts or {} + return setmetatable({ + game = game, + left = opts.frames or BlankScreen.FRAMES, + onDone = opts.onDone, + }, BlankScreen) +end + +function BlankScreen:drawsWidescreen() return true end + +function BlankScreen:draw() + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) +end + +function BlankScreen:drawWidescreen(winW, winH) + local G = love.graphics + Chrome.letterbox(winW, winH, 1, 1, 1) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(Chrome.fitOrigin(winW, winH, scale)) + G.scale(scale, scale) + self:draw() + G.pop() +end + +function BlankScreen:update() + if self.left > 0 then + self.left = self.left - 1 + return + end + local done = self.onDone + self.onDone = nil + if done then done() end +end + +return BlankScreen diff --git a/src/ui/gen2/CenterPcMenu.lua b/src/ui/gen2/CenterPcMenu.lua index cddae6ee..ca665f63 100644 --- a/src/ui/gen2/CenterPcMenu.lua +++ b/src/ui/gen2/CenterPcMenu.lua @@ -34,16 +34,35 @@ local ENGINE_POKEDEX = 11 function CenterPcMenu:wantsFillScale() return true end function CenterPcMenu:drawsWidescreen() return true end --- A multi-page body: `para` (a blank line in the transcription) is a --- screenful of its own, two lines to a page. +-- `\f` = para (home/text.asm:403 Paragraph), `\v` = cont (home/text.asm:442 +-- _ContTextNoPause); two rows to a page (constants/text_constants.asm:32). local function pagesOf(body) local pages = {} - for chunk in (tostring(body) .. "\n\n"):gmatch("(.-)\n\n") do - local lines = {} - for line in (chunk .. "\n"):gmatch("(.-)\n") do - if line ~= "" then lines[#lines + 1] = line end + for chunk in (tostring(body) .. "\f"):gmatch("(.-)\f") do + local flat, pos, scrolled = {}, 1, false + while true do + local brk = chunk:find("[\n\v]", pos) + local line = brk and chunk:sub(pos, brk - 1) or chunk:sub(pos) + if line ~= "" then flat[#flat + 1] = { line, scrolled } end + if not brk then break end + scrolled = chunk:sub(brk, brk) == "\v" + pos = brk + 1 + end + local page + for _, entry in ipairs(flat) do + if not page then + page = { entry[1] } + pages[#pages + 1] = page + elseif entry[2] then + page = { page[#page], entry[1] } + pages[#pages + 1] = page + elseif #page >= 2 then + page = { entry[1] } + pages[#pages + 1] = page + else + page[#page + 1] = entry[1] + end end - if #lines > 0 then pages[#pages + 1] = lines end end return pages end @@ -67,8 +86,9 @@ function CenterPcMenu.new(game, opts) if not (party and #party > 0) then -- PC_CheckPartyForPokemon: SFX_CHOOSE_PC_OPTION, the refusal, and the PC -- never boots (`ret c` before PC_PlayBootSound). + -- data/text/common_2.asm:725 _PokecenterPCCantUseText ends in cont. self:playSfx("Sfx_ChoosePcOption") - self:say({ { "Bzzzzt! You must", "have a #MON to", "use this!" } }, + self:say(pagesOf(Strings("Bzzzzt! You must\nhave a #MON to\vuse this!")), function() self:close() end) else -- PC_PlayBootSound + _PokecenterPCTurnOnText. @@ -267,9 +287,9 @@ function CenterPcMenu:drawBottomLines(lines) Chrome.box(0, 12, 20, 6) if not lines then return end local name = self:playerName() - local startY = #lines >= 3 and 13 or 14 - for i, line in ipairs(lines) do - Chrome.print((line:gsub("{PLAYER}", name)), 1, startY + (i - 1) * 2) + -- constants/text_constants.asm:32 TEXTBOX_INNERY: rows 14 and 16 only. + for i = 1, math.min(#lines, 2) do + Chrome.print((lines[i]:gsub("{PLAYER}", name)), 1, 14 + (i - 1) * 2) end end diff --git a/src/ui/gen2/ItemPcMenu.lua b/src/ui/gen2/ItemPcMenu.lua index db3afc64..5a294628 100644 --- a/src/ui/gen2/ItemPcMenu.lua +++ b/src/ui/gen2/ItemPcMenu.lua @@ -32,6 +32,7 @@ local PcItems = require("src.core.gen2.PcItems") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local Sound = require("src.core.Sound") +local WaitPlaySFX = require("src.ui.gen2.WaitPlaySFX") local ItemPcMenu = {} ItemPcMenu.__index = ItemPcMenu @@ -63,6 +64,10 @@ local function sameItems(_, items) return items end -- PCItemsJoypad's ScrollingMenu is `db 4, 8 ; rows, columns`. local VISIBLE_ROWS = 4 +-- .PCItemsMenuData is menu_coords 4, 1 (engine/events/pokecenter_pc.asm:641), +-- and ScrollingMenu_UpdateDisplay (engine/menus/scrolling_menu.asm:359) adds +local LIST_X = 5 + -- charmap.asm: the quantity glyph. local TIMES = "\xc3\x97" @@ -135,6 +140,21 @@ function ItemPcMenu:playPcSfx(name) self:playSfx(name) end +-- engine/events/pokecenter_pc.asm:195 +function ItemPcMenu:playPcSfxTwice(name) + self:playPcSfx(name) + self.repeatSfx = WaitPlaySFX.arm(name) +end + +function ItemPcMenu:tickRepeatSfx() + local pending = self.repeatSfx + if not pending then return false end + if WaitPlaySFX.waiting(pending) then return true end + self.repeatSfx = nil + self:playPcSfx(pending.name) + return false +end + function ItemPcMenu:playerName() local player = self.save and self.save.player return (player and player.name) or "GOLD" @@ -445,7 +465,7 @@ end function ItemPcMenu:placeSwitch() local held = self.rows[self.switching] local target = self.rows[self.listIndex] - self:playPcSfx("Sfx_SwitchPokemon") + self:playPcSfxTwice("Sfx_SwitchPokemon") if not target then return end if held and self.save and target.slot ~= held.slot then PcItems.move(self.save, held.id, target.slot, self.items) @@ -460,6 +480,9 @@ function ItemPcMenu:update(_dt) local input = self.game and self.game.input if not input then return end + -- engine/events/pokecenter_pc.asm:195 + if self:tickRepeatSfx() then return end + if self.message then if input:wasPressed("a") or input:wasPressed("b") then local m = self.message @@ -582,6 +605,8 @@ end function ItemPcMenu:drawList() Chrome.box(0, 0, 20, 12) + -- engine/events/pokecenter_pc.asm:628 .a_1 -> home/menu.asm:50 + local picked = (self.message or self.qtyState or self.confirm) and true or false for row = 1, VISIBLE_ROWS do local i = row + self.scroll local ty = row * 2 @@ -589,18 +614,18 @@ function ItemPcMenu:drawList() local entry = self.rows[i] -- home/menu.asm:50 if i == self.listIndex then - Chrome.cursor(5, ty) + Chrome.cursor(LIST_X - 1, ty, picked) elseif i == self.switching then - Chrome.cursor(5, ty, true) + Chrome.cursor(LIST_X - 1, ty, true) end - Chrome.print(entry.name, 6, ty) + Chrome.print(entry.name, LIST_X, ty) -- PlaceMenuItemQuantity (engine/menus/menu_2.asm:18, :24) if not self:cantToss(entry.id) then - Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1) + Chrome.print(TIMES .. Chrome.number(entry.count, 2), LIST_X + 9, ty + 1) end elseif i == self:listTotal() then - if i == self.listIndex then Chrome.cursor(5, ty) end - Chrome.print("CANCEL", 6, ty) + if i == self.listIndex then Chrome.cursor(LIST_X - 1, ty, picked) end + Chrome.print("CANCEL", LIST_X, ty) end end -- UpdateItemDescription under the list. @@ -643,9 +668,10 @@ function ItemPcMenu:drawPanel() if self.qtyState then local q = self.qtyState self:drawBottomLines(q.prompt) - Chrome.box(7, 15, 13, 3) - Chrome.print(TIMES, 8, 16) - Chrome.print(Chrome.number(q.qty, 2, true), 9, 16) + -- engine/items/buy_sell_toss.asm:205 TossItem_MenuHeader, :133 + Chrome.box(15, 9, 5, 3) + Chrome.print(TIMES, 16, 10) + Chrome.print(Chrome.number(q.qty, 2, true), 17, 10) elseif self.confirm then self:drawBottomLines(self.confirm.prompt) Chrome.box(14, 7, 6, 5) diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index 8e6d9960..abd9c217 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -18,6 +18,7 @@ local PackGfx = require("src.ui.gen2.PackGfx") local Screens = require("src.ui.Screens") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") +local WaitPlaySFX = require("src.ui.gen2.WaitPlaySFX") local MenuRepeat = require("src.ui.MenuRepeat") -- constants/sfx_constants.asm:3, :28 @@ -335,6 +336,21 @@ function PackMenu:playSfx(name) if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end end +-- engine/items/pack.asm:1312, home/audio.asm:220 +function PackMenu:playSfxTwice(name) + self:playSfx(name) + self.repeatSfx = WaitPlaySFX.arm(name) +end + +function PackMenu:tickRepeatSfx() + local pending = self.repeatSfx + if not pending then return false end + if WaitPlaySFX.waiting(pending) then return true end + self.repeatSfx = nil + self:playSfx(pending.name) + return false +end + function PackMenu:showMessage(lines) self.message, self.messagePage = lines, 1 self.pagesSource, self.pages = lines, messagePages(lines) @@ -778,6 +794,8 @@ function PackMenu:update(_dt) self.staleRows = nil self:rebuild() end + -- engine/items/pack.asm:1312, home/audio.asm:225 + if self:tickRepeatSfx() then return end -- Pack_PrintTextNoScroll ends on a `prompt`, so the message holds the PACK -- until a button clears it and the list is untouchable underneath. The -- quantity selector is the one thing drawn OVER a message rather than under @@ -897,8 +915,8 @@ function PackMenu:placeSwitch() self:rebuild() self:storeCursor() end - -- engine/items/pack.asm:1309 - self:playSfx("Sfx_SwitchPokemon") + -- engine/items/pack.asm:1309, :1311 + self:playSfxTwice("Sfx_SwitchPokemon") self:endSwitch() end @@ -1043,6 +1061,9 @@ end -- ScrollingMenu_PlaceCursor (engine/menus/scrolling_menu.asm:438) marks the -- row SELECT armed with the hollow ▷ while the solid ▶ goes on looking. function PackMenu:drawList(listX, listY) + -- engine/menus/scrolling_menu.asm:86 .a_button -> home/menu.asm:50 + local picked = (self.submenu or self.qtyState or self.confirm or self.message) + and true or false for row = 1, VISIBLE_ROWS do local i = row + self.scroll local ty = listY + (row - 1) * LIST_SPACING @@ -1053,7 +1074,7 @@ function PackMenu:drawList(listX, listY) Chrome.printThrough(entry.tmhmLabel, listX - 3, ty, Chrome.DEFAULT_BOX_PALETTE) end if i == self.index then - self:cursorAt(listX - 1, ty) + self:cursorAt(listX - 1, ty, picked) elseif i == self.switching then self:cursorAt(listX - 1, ty, true) end @@ -1064,7 +1085,7 @@ function PackMenu:drawList(listX, listY) listX + 9, ty + 1, Chrome.DEFAULT_BOX_PALETTE) end elseif i == self:total() then - if i == self.index then self:cursorAt(listX - 1, ty) end + if i == self.index then self:cursorAt(listX - 1, ty, picked) end Chrome.printThrough("CANCEL", listX, ty, Chrome.DEFAULT_BOX_PALETTE) end end @@ -1099,16 +1120,9 @@ function PackMenu:drawDescription(ty) if second then Chrome.printThrough(second, 1, ty + 2, Chrome.DEFAULT_BOX_PALETTE) end end --- The submenu box. Every one of the seven headers is `menu_coords 0, top, --- SCREEN_WIDTH - 14, TEXTBOX_Y - 1` -- the left six columns, growing UPWARD --- from the description box so its bottom edge never moves. The five-row --- header is the one exception, reaching one row further down (TEXTBOX_Y), so --- the bottom is 12 there and 11 otherwise; either way the first label sits one --- row inside (STATICMENU_NO_TOP_SPACING) with the cursor a column left of it. --- ../pokecrystal/engine/items/pack.asm:810, :826 open at column 13; --- ../pokegold/engine/items/pack.asm:810, :826 open at column 0. +-- ../pokecrystal/engine/items/pack.asm:162, :178, :313, :335, :355, :371, +-- TEXTBOX_Y - 1`; ../pokegold/engine/items/pack.asm has the same ten at function PackMenu:submenuColumn() - if not self:inBattle() then return 0 end local version = (self.save and self.save.version) or GameVersion.get() return GameVersion.engine(version) == "crystal" and 13 or 0 end @@ -1117,7 +1131,8 @@ function PackMenu:drawSubmenu() local menu = self.submenu local count = #menu.rows local x = self:submenuColumn() - local bottom = count >= 5 and 12 or 11 + -- ../pokegold/engine/items/pack.asm:313 ends on TEXTBOX_Y, pokecrystal:313 + local bottom = (count >= 5 and x == 0) and 12 or 11 local top = bottom - count * 2 Chrome.box(x, top, 7, bottom - top + 1) for i, id in ipairs(menu.rows) do diff --git a/src/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua index 2ae1610f..64bb9a94 100644 --- a/src/ui/gen2/PartyMenu.lua +++ b/src/ui/gen2/PartyMenu.lua @@ -365,11 +365,30 @@ end -- (src/world/gen2/FieldMoves.lua and World:useFieldMove), because a field move -- is a question about the map, not about the menu. All that is left here is -- the $2 / $3 branch. +-- once a spawn is picked -- engine/pokemon/mon_menu.asm:609-626 over +-- engine/events/overworld.asm:556-568. B answers -1 and takes .Error's $0, +-- which is StartMenu_Pokemon's .choosemenu (engine/menus/start_menu.asm:503). function PartyMenu:useFieldMove(moveId, mon) local world = self.game and self.game.world if not (world and world.useFieldMove) then return end local result = world:useFieldMove(moveId, mon) - if result and result.ok then self:exitToField() end + if not (result and result.ok) then return end + if result.action == "fly" and world.openFlyMap then + world.queuedFieldMove = nil + local opened = world:openFlyMap(mon, { + onChosen = function(spawnId) + result.flySpawn = spawnId + world.queuedFieldMove = result + -- home/map.asm:2281 + if world.exitMenusFade then world:exitMenusFade() end + self:exitToField() + end, + onCancel = function() end, + }) + if opened then return end + world.queuedFieldMove = result + end + self:exitToField() end -- ManagePokemonMoves (engine/pokemon/mon_menu.asm:858-873), the MOVE row: an diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index 6d95e648..280a245c 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -788,6 +788,30 @@ function SummaryMenu:playSwapSfx() end end +local function waitPlaySfx() + local ok, mod = pcall(require, "src.ui.gen2.WaitPlaySFX") + return ok and mod or nil +end + +-- mon_menu.asm:1040 +function SummaryMenu:playSwapSfxTwice() + self:playSwapSfx() + local WaitPlaySFX = waitPlaySfx() + if WaitPlaySFX then + self.repeatSfx = WaitPlaySFX.arm("Sfx_SwitchPokemon") + end +end + +function SummaryMenu:tickRepeatSfx() + local pending = self.repeatSfx + if not pending then return false end + local WaitPlaySFX = waitPlaySfx() + if WaitPlaySFX and WaitPlaySFX.waiting(pending) then return true end + self.repeatSfx = nil + self:playSwapSfx() + return false +end + -- MoveScreenLoop's .joy_loop. A picks a move up (.a_button stores wMenuCursorY -- in wSwappingMove and draws the hollow cursor) and puts it down (.place_move); -- B drops it back on the row it came from and only then exits. @@ -800,7 +824,9 @@ function SummaryMenu:updateMoveDetail(input) self.moveIndex = self.moveIndex < count and self.moveIndex + 1 or 1 elseif input:wasPressed("a") then if self.swapFrom then - if self:swapMoves(self.swapFrom, self.moveIndex) then self:playSwapSfx() end + if self:swapMoves(self.swapFrom, self.moveIndex) then + self:playSwapSfxTwice() + end self.swapFrom = nil elseif moves[self.moveIndex] then self.swapFrom = self.moveIndex @@ -828,6 +854,8 @@ function SummaryMenu:update(_dt) self:stepPicAnim() local input = self.game and self.game.input if not input then return end + -- mon_menu.asm:1040 + if self:tickRepeatSfx() then return end if self.moveDetail then self:updateMoveDetail(input) return diff --git a/src/ui/gen2/WaitPlaySFX.lua b/src/ui/gen2/WaitPlaySFX.lua new file mode 100644 index 00000000..0065d2a8 --- /dev/null +++ b/src/ui/gen2/WaitPlaySFX.lua @@ -0,0 +1,28 @@ +local WaitPlaySFX = {} + +local function sound() + local ok, mod = pcall(require, "src.core.Sound") + return ok and type(mod) == "table" and mod or nil +end + +-- home/audio.asm:220 WaitPlaySFX +-- home/delay.asm:15 +function WaitPlaySFX.arm(name, fallback) + local Sound = sound() + local frames = Sound and Sound.waitFramesFor + and Sound.waitFramesFor(name, fallback or 30) + return { name = name, left = tonumber(frames) or 0 } +end + +-- home/audio.asm:225 WaitSFX +function WaitPlaySFX.waiting(pending) + if not pending then return false end + pending.left = (pending.left or 0) - 1 + if pending.left <= 0 then return false end + local Sound = sound() + if not Sound then return false end + if Sound.sfxBusy and Sound.sfxBusy() then return true end + return (Sound.isPlaying and Sound.isPlaying(pending.name)) or false +end + +return WaitPlaySFX diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index 406bdc43..90c25d26 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -447,6 +447,10 @@ function Kit.setFocus(id) Kit._ringShown = id ~= nil end +function Kit.pointerUsed() + Kit._ringShown = false +end + function Kit._resolveNav() local dir = Kit._navQueue Kit._navQueue = nil diff --git a/src/world/gen2/MapNameSign.lua b/src/world/gen2/MapNameSign.lua index 71c8720d..8a9626c5 100644 --- a/src/world/gen2/MapNameSign.lua +++ b/src/world/gen2/MapNameSign.lua @@ -113,6 +113,17 @@ function MapNameSign.cancel(world) if world then world.mapSign = nil end end +local function logicSpeed(world) + local game = world and world.game + if not game or not game.logicSpeed then return 1 end + local ok, n = pcall(game.logicSpeed, game) + if ok then + n = tonumber(n) + if n and n > 1 then return n end + end + return 1 +end + -- ../pokecrystal/engine/events/map_name_sign.asm:99-117 -- ../pokecrystal/home/window.asm:42 function MapNameSign.tick(world) @@ -122,6 +133,10 @@ function MapNameSign.tick(world) MapNameSign.cancel(world) return end + local speed = logicSpeed(world) + s.accum = (s.accum or 0) + 1 + if s.accum < speed then return end + s.accum = s.accum - speed s.timer = s.timer - 1 if s.timer <= 0 then world.mapSign = nil end end diff --git a/src/world/gen2/Player.lua b/src/world/gen2/Player.lua index eb62ae49..08e38a3d 100644 --- a/src/world/gen2/Player.lua +++ b/src/world/gen2/Player.lua @@ -184,7 +184,8 @@ function Player:walkPhase() if not self.moving then -- map_object_action.asm:45-69 if (self.bumpFrames or 0) <= 0 then return 0 end - return (math.floor(self.animClock / 8) % 2 == 1) and 1 or 0 + -- map_object_action.asm:98-119 + return (math.floor(self.animClock / STEP_FRAMES) % 2 == 1) and 1 or 0 end local p = self.animClock % STEP_FRAMES return (p >= 4 and p < 12) and 1 or 0 @@ -193,7 +194,7 @@ end -- map_object_action.asm:71-94 function Player:drawFlip() if self.moving or (self.bumpFrames or 0) <= 0 then return self.stepFlip end - local mirrored = math.floor(self.animClock / 16) % 2 == 1 + local mirrored = math.floor(self.animClock / (STEP_FRAMES * 2)) % 2 == 1 return self.stepFlip ~= mirrored end diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index b70e7b85..e495d4c2 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -231,6 +231,9 @@ local MAPSETUP_ROAM_JUMP = { [MAPSETUP.TELEPORT] = true } local FADE_STEPS = 4 local FADE_STEP_FRAMES = 2 +-- ExitAllMenus opens on ClearBGPalettes (home/map.asm:2282), whose WaitBGMap +local MENU_BLANK_FRAMES = 4 + -- A New Game starts in the bedroom, not outside: engine/menus/intro_menu.asm -- NewGame sets wDefaultSpawnpoint = SPAWN_HOME and warps there, and -- data/maps/spawn_points.asm puts SPAWN_HOME at PLAYERS_HOUSE_2F (3,3). @@ -3514,6 +3517,14 @@ function World:runMapSetup(method, load, fly) return true end +-- ramp with no load between the halves (home/map.asm:2281-2292). +function World:exitMenusFade() + self.fade, self.fadeLevel = "white", 1 + self.mapSetup = { + phase = "in", step = FADE_STEPS, wait = MENU_BLANK_FRAMES, + } +end + -- --------------------------------------------------------------------------- -- The three roaming beasts (engine/overworld/wildmons.asm). -- --------------------------------------------------------------------------- @@ -5918,7 +5929,12 @@ function World:runFieldMove(result) elseif action == "waterfall" then self:runWaterfall(result) elseif action == "fly" then - self:openFlyMap(result.mon) + -- (engine/events/overworld.asm:565, :584); a picker that never opened + if result.flySpawn then + self:flyTo(result.flySpawn, result.mon) + else + self:openFlyMap(result.mon) + end elseif action == "headbutt" then self:runHeadbutt(result.facingX, result.facingY, result.mon) elseif action == "sweetscent" then @@ -6122,6 +6138,10 @@ end local FLY = { FROM_FRAMES = 128, TO_FRAMES = 64, HOVER = 0x40, AMP_MAX = 0x40, TO_AMP = 11 * 8, RISE = 84, + -- engine/events/field_moves.asm:341 (depixel 31, 10, 4, 0 up to 10 * 8 + 4) + TO_RISE = 88, + -- engine/events/field_moves.asm:307 over data/sprite_anims/oam.asm:314 + BIRD_OX = 80 - 8 - 8, BIRD_OY = 84 - 8 - 16, -- engine/sprite_anims/functions.asm:1389-1416 LEAF_DEATH_X = 184, LEAF_AMP = 0x40, -- constants/sprite_anim_constants.asm:20 @@ -6135,6 +6155,16 @@ function World.leafScreenPos(leaf) return leaf.x + (leaf.xoff or 0) + FLY.LEAF_OX, leaf.y + FLY.LEAF_OY end +-- engine/sprite_anims/core.asm:216 (UpdateAnimFrame) +function World.birdScreenPos(fa) + return FLY.BIRD_OX + (fa.xoff or 0), FLY.BIRD_OY + (fa.y or 0) +end + +-- engine/sprite_anims/core.asm:229 (UpdateAnimFrame) +function World.offGbScreen(x, y, w, h) + return x + w <= 0 or y + h <= 0 or x >= 160 or y >= 144 +end + -- FlyFunction_InitGFX's GetSpeciesIcon (engine/events/field_moves.asm:390): -- the icon of the mon in wCurPartyMon, on PAL_OW_RED like every other OW OBJ. function World:flyIconFor(mon) @@ -6171,12 +6201,12 @@ function World:startFlyAnim(phase, mon, onDone) local landing = phase == "to" self.flyAnim = { phase = phase, icon = icon, onDone = onDone, t = 0, - px = p.px, py = p.py, xoff = 0, wave = 0, + xoff = 0, wave = 0, leaves = {}, left = landing and FLY.TO_FRAMES or FLY.FROM_FRAMES, hover = landing and 0 or FLY.HOVER, amp = landing and FLY.TO_AMP or 0, - y = landing and -FLY.RISE or 0, + y = landing and -FLY.TO_RISE or 0, } return true end @@ -6272,15 +6302,17 @@ function World:drawFlyLeaves(s, billboard) G.setColor(1, 1, 1, 1) for _, leaf in ipairs(fa.leaves) do local lx, ly = World.leafScreenPos(leaf) - lx, ly = lx + sox, ly + soy - local function one() - G.draw(sheet, self.cutGrassQuad, - math.floor(lx * s), math.floor(ly * s), 0, s, s) - end - if billboard then - billboard((lx + 4) * s, (ly + 4) * s, one) - else - one() + if not World.offGbScreen(lx, ly, 8, 8) then + lx, ly = lx + sox, ly + soy + local function one() + G.draw(sheet, self.cutGrassQuad, + math.floor(lx * s), math.floor(ly * s), 0, s, s) + end + if billboard then + billboard((lx + 4) * s, (ly + 4) * s, one) + else + one() + end end end end @@ -6297,24 +6329,23 @@ function World:drawFlyAnim(s, billboard) local fa = self.flyAnim if not (fa and fa.icon) then return end local G = love.graphics - local cam = self.camera - local px = fa.px + fa.xoff - local py = fa.py + fa.y - local ox = math.floor((0 - cam.x) * s) - local oy = math.floor((0 - cam.y) * s) - local beat = math.floor(fa.t / 8) % 4 - local function body() - G.setColor(1, 1, 1, 1) - G.push() - G.translate(ox, oy) - G.scale(s, s) - fa.icon:draw(px, py, 0, 0, "down", 0, false, false, beat == 3, beat % 2) - G.pop() - end - if billboard then - billboard(ox + (px + 8) * s, oy + (py + 16) * s, body) - else - body() + local bx, by = World.birdScreenPos(fa) + if not World.offGbScreen(bx, by, 16, 16) then + local sox, soy = self:gbScreenOrigin() + local px, py = bx + sox, by + soy + local beat = math.floor(fa.t / 8) % 4 + local function body() + G.setColor(1, 1, 1, 1) + G.push() + G.scale(s, s) + fa.icon:draw(px, py, 0, 0, "down", 0, false, false, beat == 3, beat % 2) + G.pop() + end + if billboard then + billboard((px + 8) * s, (py + 16) * s, body) + else + body() + end end self:drawFlyLeaves(s, billboard) end @@ -6356,28 +6387,50 @@ end -- "Where?" plate over it instead of the card strip. A run with no love at all -- (a headless probe) has no screen to push, so the destinations are offered -- one at a time through the same yesorno box every other field move uses. -function World:openFlyMap(mon) +-- list standing -- engine/events/overworld.asm:556, :578 over +-- engine/pokemon/mon_menu.asm:624. +function World:openFlyMap(mon, opts) local points = self:flyPoints() if #points == 0 then return false end -- Loaded on demand and through pcall: a headless run has no love, and this -- is the only place in the world that reaches for a screen module by hand. local okGear, Pokegear = pcall(require, "src.ui.gen2.Pokegear") if okGear and Pokegear.FLY_MAP and self.game and self.game.stack then - Screens.push(self.game, "Gen2Pokegear", { - save = self.game.save, - currentLandmark = self:currentLandmarkId(), - fly = points, - -- TownMapMon draws wCurPartyMon's icon as the cursor - -- (../pokecrystal/engine/pokegear/pokegear.asm:2708-2721). - flyMon = mon, - onFly = function(spawnId) - self.game.stack:pop() - self:flyTo(spawnId, mon) - end, - onClose = function() self.game.stack:pop() end, - }) + local function pushGear() + Screens.push(self.game, "Gen2Pokegear", { + save = self.game.save, + currentLandmark = self:currentLandmarkId(), + fly = points, + -- (../pokecrystal/engine/pokegear/pokegear.asm:2708-2721). + flyMon = mon, + onFly = function(spawnId) + self.game.stack:pop() + if opts and opts.onChosen then + opts.onChosen(spawnId) + else + self:flyTo(spawnId, mon) + end + end, + onClose = function() + self.game.stack:pop() + if opts and opts.onCancel then opts.onCancel() end + end, + }) + end + if opts then + -- engine/pokegear/pokegear.asm:1979 + Screens.push(self.game, "Gen2BlankScreen", { + onDone = function() + self.game.stack:pop() + pushGear() + end, + }) + else + pushGear() + end return true end + if opts then return false end self:askFlyPoint(points, 1, mon) return true end diff --git a/tests/drivers/battle_levelup_order_bug1996_test.lua b/tests/drivers/battle_levelup_order_bug1996_test.lua new file mode 100644 index 00000000..d3c3f2dc --- /dev/null +++ b/tests/drivers/battle_levelup_order_bug1996_test.lua @@ -0,0 +1,110 @@ +-- engine/battle/experience.asm:149-256 prints GainedText (prompt), and only +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Growth = require("src.pokemon.Growth") + local BattleState = require("src.battle.BattleState") + + local squirtle = Pokemon.new(game.data, "SQUIRTLE", 5, function(_, b) return b end) + local def = game.data.pokemon.SQUIRTLE + local oldHP = 8 + squirtle.hp = oldHP + squirtle.exp = Growth.expForLevel(def.growthRate, 6, game.data.growth_rates) - 1 + game.save.party = { squirtle } + local oldMax = squirtle.stats.hp + + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local battle = BattleState.newWild(game, "SLOWPOKE", 2) + battle.onFinish = function() end + battle.rng = function(a, _) return a end + battle.enemy.mon.hp = 1 + battle.enemy.mon.stats.speed = 1 + ow:pushBattle(battle) + + for _ = 1, 240 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(3) + end + if battle.phase ~= "menu" then error("bug1996: never reached the FIGHT menu") end + + U.tap(game, "a") + for _ = 1, 60 do + if battle.phase == "moveSelect" then break end + U.wait(1) + end + if battle.phase ~= "moveSelect" then error("bug1996: never reached move select") end + U.tap(game, "a") + + local sawExpPage = false + for i = 1, 1200 do + local cur = battle.current + if cur and cur.text and cur.text:find("EXP", 1, true) then + sawExpPage = true + if squirtle.level ~= 5 then + error("bug1996: level moved to " .. tostring(squirtle.level) .. + " while the exp page was still up") + end + if squirtle.stats.hp ~= oldMax then + error("bug1996: max HP moved to " .. tostring(squirtle.stats.hp) .. + " while the exp page was still up") + end + U.shot(game, DIR .. "/bug1996_exp_page.png") + break + end + if i % 6 == 0 then U.tap(game, "a") else U.wait(1) end + if game.stack:top() ~= battle then break end + end + if not sawExpPage then error("bug1996: never saw the gained-EXP page") end + + local landed = false + for _ = 1, 400 do + U.tap(game, "a") + U.wait(1) + if squirtle.level >= 6 then landed = true break end + end + if not landed then error("bug1996: the level never landed after the press") end + local newMax = squirtle.stats.hp + if not (newMax > oldMax) then + error("bug1996: max HP did not grow; repro is meaningless") + end + local expectHP = math.min(newMax, oldHP + (newMax - oldMax)) + if squirtle.hp ~= expectHP then + error("bug1996: current HP wrong: got " .. tostring(squirtle.hp) .. + ", expected " .. tostring(expectHP)) + end + local shown = battle.player.shownHP + if shown ~= squirtle.hp then + error("bug1996: the bar did not snap with the level: shownHP=" .. + tostring(shown) .. ", mon.hp=" .. tostring(squirtle.hp)) + end + U.shot(game, DIR .. "/bug1996_hud_snapped.png") + + local box + for _ = 1, 400 do + U.wait(1) + local top = game.stack:top() + if top and top ~= battle and top.mon == squirtle then box = top break end + end + if not box then + error("bug1996: the stat box never opened without a button press") + end + U.shot(game, DIR .. "/bug1996_stat_box.png") + + -- and one press dismisses it (experience.asm:250) + U.tap(game, "a") + for _ = 1, 60 do + U.wait(1) + if game.stack:top() ~= box then break end + end + if game.stack:top() == box then + error("bug1996: the stat box did not close on A") + end + + U.log("bug1996 OK: L6, HP " .. tostring(squirtle.hp) .. "/" .. tostring(newMax) .. + ", bar snapped from " .. tostring(oldHP) .. " with the level") +end diff --git a/tests/drivers/bind_sfx_wait_bug1998_test.lua b/tests/drivers/bind_sfx_wait_bug1998_test.lua new file mode 100644 index 00000000..3ca585ad --- /dev/null +++ b/tests/drivers/bind_sfx_wait_bug1998_test.lua @@ -0,0 +1,236 @@ +-- PlayAnimation (engine/battle/animations.asm:437), and every type that +-- (home/delay.asm:15), which blocks while CHAN5/CHAN6/CHAN8 still sound. +-- POKEPORT_DRIVER=tests/drivers/bind_sfx_wait_bug1998_test.lua POKEPORT_VERSION=red POKEPORT_TOUCH=0 POKEPORT_IDENTITY=bug1998 SHOT_DIR=/tmp/bind1998 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local AnimPlayer = require("src.battle.AnimPlayer") + local Sound = require("src.core.Sound") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + U.log("#1998 applying-attack shake waits out the move sfx: machine checks") + + check("Sound exposes the CHAN5/6/8 busy predicate", + type(Sound.moveSfxBusy) == "function") + check("Sound exposes the WaitForSoundToFinish frame budget", + type(Sound.moveSfxWaitFrames) == "function") + check("nothing is sounding before a battle starts", Sound.moveSfxBusy() == false) + + -- data/moves/animations.asm:326 BindAnim is SUBANIM_0_BIND twice at delay 4, + -- (delay + 1) per frame block (:346, home/copy2.asm:62). + local ba = game.data.battle_anims + check("battle_anims is in the cache", ba ~= nil) + + local function compile(move) + if not (ba and (ba.moveAnims or {})[move]) then return nil end + local p = AnimPlayer.new(ba) + local ok = pcall(p.start, p, move, false) + if not ok then return nil end + return p + end + + local function shapeOf(p) + local out = {} + for _, s in ipairs(p.steps) do out[#out + 1] = tostring(s.dur) end + return table.concat(out, " ") + end + + local function total(p) + local n = 0 + for _, s in ipairs(p.steps) do n = n + s.dur end + return n + end + + local FENCE = { + { move = "BIND", frames = 40, shape = "10 5 5 10 5 5" }, + { move = "WRAP", frames = 60, shape = "10 5 5 10 5 5 10 5 5" }, + { move = "CONSTRICT", frames = 72, shape = "10 7 7 10 7 7 10 7 7" }, + } + for _, f in ipairs(FENCE) do + local p = compile(f.move) + check(f.move .. " compiles into a timeline", p ~= nil) + if p then + U.log((" %s steps: %s"):format(f.move, shapeOf(p))) + check(("%s is %d frames (want %d): the subanimation is unchanged") + :format(f.move, total(p), f.frames), total(p) == f.frames) + check(("%s keeps its %s shape"):format(f.move, f.shape), + shapeOf(p) == f.shape) + end + end + + U.log(("machine checks: %d passed, %d failed"):format(pass, fail)) + + if not (game.data.moves.BIND and ba) then + U.log("no BIND animation in this cache, so there is nothing to watch") + while true do coroutine.yield() end + end + + local MAP, SX, SY = "ROUTE_1", 5, 5 + local ekans = Pokemon.new(game.data, "EKANS", 30) + ekans.moves = { { id = "BIND", pp = game.data.moves.BIND.pp } } + game.save.party = { ekans } + game.save.player.name = "RED" + + U.teleport(game, MAP, SX, SY, "down") + U.wait(12) + local ow = game.overworld + if ow and not ow.map:isWalkableCell(SX, SY) then + for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do + local cx, cy = SX + d[1], SY + d[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.teleport(game, MAP, cx, cy, "down") + U.wait(12) + ow = game.overworld + break + end + end + end + check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP) + + local battle = BattleState.newWild(game, "PIDGEY", 20) + battle.onFinish = function() end + ow:pushBattle(battle) + for _ = 1, 400 do + if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end + U.wait(1) + end + check("the battle reached the screen", game.stack:top() == battle) + + battle.enemy.mon.stats.hp = 500 + battle.enemy.mon.hp = 500 + battle.enemy.shownHP = 500 + for i = #battle.enemy.mon.moves, 1, -1 do + local id = battle.enemy.mon.moves[i].id + if id == "WHIRLWIND" or id == "ROAR" or id == "TELEPORT" then + table.remove(battle.enemy.mon.moves, i) + end + end + + local held, sawBusy, shakeAt, animEnd = 0, false, nil, nil + local budgetSeen, tailSeen = 0, 0 + local frame = 0 + local curMove + if battle.animPlayer then + local realStart = battle.animPlayer.start + battle.animPlayer.start = function(self, moveId, isPlayer, o) + local r = realStart(self, moveId, isPlayer, o) + curMove = moveId + return r + end + end + local function sample() + frame = frame + 1 + if curMove ~= "BIND" then return end + if battle.animPlaying then + local n = Sound.moveSfxWaitFrames() + if n > tailSeen then tailSeen = n end + end + if battle.hitSfxWait then + held = held + 1 + sawBusy = true + animEnd = animEnd or frame + if battle.hitSfxWait + 1 > budgetSeen then budgetSeen = battle.hitSfxWait + 1 end + end + if not shakeAt and battle.fx and (battle.fx.shakeProg or battle.fx.blink) then + shakeAt = frame + end + end + + local function pump(n, mash, stop) + for i = 1, n do + if mash and i % mash == 0 and battle.phase == "messages" then + table.insert(game.input.pressQueue, "a") + end + U.wait(1) + game.input.state.a = false + sample() + if stop and stop() then return end + end + end + + local function toMenu() + pump(1200, 6, function() return battle.phase == "menu" and #battle.queue == 0 end) + return battle.phase == "menu" + end + + local function useBind() + for _ = 1, 80 do + if battle.phase == "moveSelect" then break end + if battle.phase == "menu" then + if battle.menuIndex ~= 1 then + U.tap(game, battle.menuIndex > 2 and "up" or "left") + else + U.tap(game, "a") + end + else + U.tap(game, "a") + end + for _ = 1, 3 do U.wait(1) sample() end + end + if battle.phase ~= "moveSelect" then return false end + for _ = 1, 20 do + if battle.phase ~= "moveSelect" then return true end + U.tap(game, "a") + for _ = 1, 3 do U.wait(1) sample() end + end + return false + end + + check("the battle reached its FIGHT menu", toMenu()) + + local sent = false + for _ = 1, 6 do + battle.player.stages.accuracy = 0 + shakeAt, animEnd, held, curMove = nil, nil, 0, nil + budgetSeen, tailSeen = 0, 0 + sent = useBind() + if not sent then break end + pump(900, 8, function() return shakeAt ~= nil and not battle.animPlaying end) + if shakeAt then break end + U.log(" BIND did not connect that turn; using it again") + toMenu() + end + check("chose BIND from the move menu", sent) + check("BIND's applying-attack fx eventually ran", shakeAt ~= nil) + + U.log((" the shake was held for %d frames after the animation finished"):format(held)) + U.log((" budget taken at the gate: %d frames; longest BIND sfx tail seen " + .. "during the animation: %d frames"):format(budgetSeen, tailSeen)) + if sawBusy then + check("the shake waited on the BIND sfx instead of starting the same frame", + held > 0) + else + U.log(" the BIND sfx had already finished when the animation ended, so") + U.log(" there was nothing to wait for on this turn (short sfx, or SFX is") + U.log(" muted in OPTION). raise SFX and try the pad below before judging.") + end + U.shot(game, DIR .. "/bug1998_bind_after.png") + + local opts = game.save.options or {} + if opts.animations == false then + U.log(" ANIMATION is off in OPTION, which skips the whole row: turn it on") + end + if opts.sfxVol == 0 then + U.log(" sfxVol is 0, so there is no sfx tail to wait on: raise SFX") + end + + U.log("the pad is yours in a battle where the EKANS knows only BIND (#1998).") + U.log("pick FIGHT then BIND and watch the END of the animation: the two coil") + U.log("frames should finish, the screen should sit completely still for about") + U.log("half a second while the squeeze sfx rings out, and only then should the") + U.log("screen shake and the HP bar drain.") + U.log("the bug was the shake starting on the very next frame, which wiped the") + U.log("second coil off the screen mid-sound and read as a cut-off animation.") + U.log("BIND traps the foe, so the next few turns replay it as \"attack") + U.log("continues!\" and you can watch the beat more than once.") + U.log("screenshots: " .. DIR .. "/bug1998_*.png") + while true do coroutine.yield() end +end diff --git a/tests/drivers/crystal_fly_bird_bug2015_test.lua b/tests/drivers/crystal_fly_bird_bug2015_test.lua new file mode 100644 index 00000000..de1eea5c --- /dev/null +++ b/tests/drivers/crystal_fly_bird_bug2015_test.lua @@ -0,0 +1,208 @@ +-- ../pokecrystal/engine/events/field_moves.asm:300 +-- ../pokecrystal/engine/sprite_anims/functions.asm:642 +-- ../pokecrystal/engine/sprite_anims/core.asm:229 +-- POKEPORT_IDENTITY=crystal-aug31 POKEPORT_GAME=crystal POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/crystal_fly_bird_bug2015_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/crystal-fly2015 +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Mon = require("src.battle.gen2.Mon") +local World = require("src.world.gen2.World") +local Zoom = require("src.render.Zoom") + +local FLY_SPECIES = "PIDGEOTTO" + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-fly2015" + local fails = 0 + + local function ok(cond, msg) + if cond then + print("[fly2015] ok " .. msg) + else + fails = fails + 1 + print("[fly2015] FAIL " .. msg) + end + return cond + end + + local function tap(btn) U.tap(game, btn) U.wait(3) end + local function top() return game.stack:top() end + + U.wait(45) + local world = game.world + local save, data = game.save, game.data + if not ok(world and world.map, "booted into the overworld") then + error("fly2015: no world") + end + + save.player = save.player or {} + if type(save.player.badges) ~= "table" then save.player.badges = {} end + save.player.badges.STORM = true + save.engineFlags = save.engineFlags or {} + for _, row in ipairs(FieldMoves.FLYPOINTS) do + save.engineFlags[row.flag] = true + end + + local flyer = Mon.new(data, FLY_SPECIES, 24) + table.remove(flyer.moves, 1) + Mon.learnMove(flyer, "FLY", data) + save.party = { flyer } + + world.mapScenes = world.mapScenes or {} + world.mapScenes.NEW_BARK_TOWN = 1 + world:setMap("NEW_BARK_TOWN", 9, 8, "down") + U.wait(15) + ok(world.map.id == "NEW_BARK_TOWN", "standing in New Bark Town") + + Zoom.step(-2, world:fitScale()) + U.wait(6) + ok(world.viewH and world.viewH > 152, + ("zoomed out to a %dx%d world view (the margin the bird used to park in)") + :format(world.viewW or 0, world.viewH or 0)) + + for _ = 1, 240 do + if not world:busy() then break end + U.wait(2) + end + + local menu + for _ = 1, 10 do + tap("start") + menu = top() + if menu and menu.screenId == "Gen2StartMenu" then break end + U.wait(6) + end + if not ok(menu and menu.screenId == "Gen2StartMenu", + "START opened the menu") then + error("fly2015: no start menu") + end + for _ = 1, 10 do + if menu.list:current().value == "pokemon" then break end + tap("down") + end + tap("a") + + local party = top() + if not ok(party and party.screenId == "Gen2PartyMenu", + "POKeMON opened the party list") then + error("fly2015: no party list") + end + tap("a") + local sub = party.submenu + if not ok(sub ~= nil, "A opened the action submenu") then + error("fly2015: no submenu") + end + local flyRow + for index, item in ipairs(sub.items) do + if item.id == "FLY" then flyRow = index end + end + if not ok(flyRow ~= nil, "which lists FLY") then + error("fly2015: the submenu has no FLY row") + end + for _ = 1, #sub.items do + if sub.index == flyRow then break end + tap("down") + end + tap("a") + U.wait(10) + + local picker = top() + if not ok(picker and picker.screenId == "Gen2Pokegear" and picker.fly, + "FLY opened the destination map") then + error("fly2015: no picker") + end + tap("up") + U.wait(6) + + U.tap(game, "a") + for _ = 1, 180 do + U.wait(1) + if world.flyAnim and world.flyAnim.phase == "from" then break end + end + if not ok(world.flyAnim ~= nil and world.flyAnim.phase == "from", + "A started FlyFromAnim") then + error("fly2015: the flight collapsed into a warp") + end + + local shots = { hover = false, rising = false, gone = false, land = false } + local tail, tailDrawn, tailInWindow = 0, 0, 0 + local landStart, sawTo = nil, false + local risenY = nil + + for _ = 1, 1800 do + local fa = world.flyAnim + if fa then + local bx, by = World.birdScreenPos(fa) + local _, soy = world:gbScreenOrigin() + local gone = World.offGbScreen(bx, by, 16, 16) + if fa.phase == "from" then + if not shots.hover and fa.hover and fa.hover > 0 and fa.t >= 20 then + shots.hover = true + U.shot(game, out .. "/01-bird-hover.png") + end + if not shots.rising and fa.y <= -40 and fa.y > -70 then + shots.rising = true + U.shot(game, out .. "/02-bird-rising.png") + end + if fa.y <= -84 then + risenY = by + tail = tail + 1 + if not gone then tailDrawn = tailDrawn + 1 end + if soy + by + 16 > 0 then tailInWindow = tailInWindow + 1 end + if not shots.gone and fa.left <= 20 then + shots.gone = true + U.shot(game, out .. "/03-bird-gone.png") + end + end + else + sawTo = true + landStart = landStart or by + if not shots.land and fa.y >= -20 then + shots.land = true + U.shot(game, out .. "/04-landing.png") + end + end + end + if sawTo and not world.flyAnim then break end + U.wait(1) + end + + ok(tail > 0, ("the rise finished (%d tail frames)"):format(tail)) + ok(risenY == -24, + ("and left the icon at GB y %s, above the LCD"):format(tostring(risenY))) + ok(tailDrawn == 0, + ("nothing was drawn for the whole tail (%d/%d frames drawn)") + :format(tailDrawn, tail)) + ok(tailInWindow > 0, + ("and the world view was tall enough to have shown it (%d frames)") + :format(tailInWindow)) + ok(sawTo, "FlyToAnim ran on the far side") + ok(landStart == -28, + ("which starts off the top of the LCD at GB y %s") + :format(tostring(landStart))) + + U.wait(30) + ok(world.flyAnim == nil, "the animation is over") + ok(world.flyHidden == nil, ".ReturnFromFly respawned the player") + U.shot(game, out .. "/05-fly-landed.png") + + U.log("flew out of New Bark Town zoomed out (#2015). judge these shots:") + U.log("03-bird-gone.png -- the window must be EMPTY of the flying icon:") + U.log("no bird, no sliver of one, parked at the top of the frame. this is") + U.log("the shot the bug was about.") + U.log("01/02 -- the bird hovers over the player and rises straight up out") + U.log("of the GB screen; 04/05 -- it swoops back down onto the new map.") + U.log("shots are in " .. out .. ".") + if fails > 0 then + U.log(("%d assertion(s) failed above -- read those before the pictures.") + :format(fails)) + end + U.log("the party still holds a flyer and the badge, so FLY again whenever") + U.log("you want another look. the controls are yours.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/crystal_fly_bug1960_test.lua b/tests/drivers/crystal_fly_bug1960_test.lua index d78121c7..165ddb1b 100644 --- a/tests/drivers/crystal_fly_bug1960_test.lua +++ b/tests/drivers/crystal_fly_bug1960_test.lua @@ -113,7 +113,11 @@ return function(game) U.wait(6) U.tap(game, "a") - U.wait(3) + -- ExitAllMenus runs before .FlyScript (../pokecrystal/home/map.asm:1927) + for _ = 1, 120 do + if world.flyAnim then break end + U.wait(1) + end if not ok(world.flyAnim ~= nil and world.flyAnim.phase == "from", "A started FlyFromAnim") then error("fly1960: the flight collapsed into a warp") diff --git a/tests/drivers/crystal_fly_flash_bug2020_test.lua b/tests/drivers/crystal_fly_flash_bug2020_test.lua new file mode 100644 index 00000000..690fa42a --- /dev/null +++ b/tests/drivers/crystal_fly_flash_bug2020_test.lua @@ -0,0 +1,233 @@ +-- ../pokecrystal/engine/pokemon/mon_menu.asm:609 +-- ../pokecrystal/engine/events/overworld.asm:556 +-- ../pokecrystal/engine/pokegear/pokegear.asm:2026 +-- ../pokecrystal/home/map.asm:1927 +-- POKEPORT_IDENTITY=crystal-aug31 POKEPORT_GAME=crystal POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/crystal_fly_flash_bug2020_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/crystal-fly2020 +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Mon = require("src.battle.gen2.Mon") + +local FLY_SPECIES = "PIDGEOTTO" + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-fly2020" + local fails = 0 + + local function ok(cond, msg) + if cond then + print("[fly2020] ok " .. msg) + else + fails = fails + 1 + print("[fly2020] FAIL " .. msg) + end + return cond + end + + local function tap(btn) U.tap(game, btn) U.wait(3) end + local function top() return game.stack:top() end + local function topId() + local state = top() + return state and state.screenId or nil + end + + U.wait(45) + local world = game.world + local save, data = game.save, game.data + if not ok(world and world.map, "booted into the overworld") then + error("fly2020: no world") + end + + save.player = save.player or {} + if type(save.player.badges) ~= "table" then save.player.badges = {} end + save.player.badges.STORM = true + save.engineFlags = save.engineFlags or {} + for _, row in ipairs(FieldMoves.FLYPOINTS) do + save.engineFlags[row.flag] = true + end + + local flyer = Mon.new(data, FLY_SPECIES, 24) + table.remove(flyer.moves, 1) + Mon.learnMove(flyer, "FLY", data) + save.party = { flyer } + + world.mapScenes = world.mapScenes or {} + world.mapScenes.NEW_BARK_TOWN = 1 + world:setMap("NEW_BARK_TOWN", 9, 8, "down") + U.wait(15) + ok(world.map.id == "NEW_BARK_TOWN", "standing in New Bark Town") + + for _ = 1, 240 do + if not world:busy() then break end + U.wait(2) + end + + local function openFlyRow() + local menu + for _ = 1, 10 do + tap("start") + menu = top() + if menu and menu.screenId == "Gen2StartMenu" then break end + U.wait(6) + end + if not ok(menu and menu.screenId == "Gen2StartMenu", + "START opened the menu") then + error("fly2020: no start menu") + end + for _ = 1, 10 do + if menu.list:current().value == "pokemon" then break end + tap("down") + end + tap("a") + local party = top() + if not ok(party and party.screenId == "Gen2PartyMenu", + "POKeMON opened the party list") then + error("fly2020: no party list") + end + tap("a") + local sub = party.submenu + if not ok(sub ~= nil, "A opened the action submenu") then + error("fly2020: no submenu") + end + local flyRow + for index, item in ipairs(sub.items) do + if item.id == "FLY" then flyRow = index end + end + if not ok(flyRow ~= nil, "which lists FLY") then + error("fly2020: the submenu has no FLY row") + end + for _ = 1, #sub.items do + if sub.index == flyRow then break end + tap("down") + end + return party + end + + local party = openFlyRow() + U.tap(game, "a") + + local empties, blankFrames, seenBlank, shotBlank = 0, 0, false, false + local underneath = true + for _ = 1, 90 do + U.wait(1) + local id = topId() + if id == nil then empties = empties + 1 end + if id == "Gen2BlankScreen" then + seenBlank = true + blankFrames = blankFrames + 1 + local states = game.stack.states or {} + if states[#states - 1] ~= party then underneath = false end + if not shotBlank then + shotBlank = true + U.shot(game, out .. "/01-blank-in.png") + end + end + if id == "Gen2Pokegear" then break end + end + + ok(seenBlank, ("_FlyMap blanked to white first (%d frame(s))") + :format(blankFrames)) + ok(empties == 0, + ("no live overworld frame leaked on the way in (%d empty stack frame(s))") + :format(empties)) + ok(underneath, "and the party list stayed underneath the blank") + + local picker = top() + if not ok(picker and picker.screenId == "Gen2Pokegear" and picker.fly, + "then the destination map took over") then + error("fly2020: no picker") + end + U.wait(6) + U.shot(game, out .. "/02-fly-map.png") + + tap("b") + U.wait(6) + ok(topId() == "Gen2PartyMenu", ("B returned to the party list (top is %s)") + :format(tostring(topId()))) + ok(world.queuedFieldMove == nil, "with no fly queued behind it") + U.shot(game, out .. "/03-b-back-to-party.png") + + tap("b") + tap("b") + U.wait(10) + party = openFlyRow() + U.tap(game, "a") + for _ = 1, 90 do + U.wait(1) + if topId() == "Gen2Pokegear" then break end + end + if not ok(topId() == "Gen2Pokegear", "the picker is back up") then + error("fly2020: no picker on the second pass") + end + tap("up") + U.wait(6) + + U.tap(game, "a") + local held, whiteHeld, sawRamp, shotWhite, shotRamp = 0, 0, false, false, false + local leaked, startedEarly, sawHold = 0, false, false + for _ = 1, 240 do + U.wait(1) + if topId() ~= nil then leaked = leaked + 1 end + local ms = world.mapSetup + if ms and ms.phase == "in" and (ms.wait or 0) > 2 then sawHold = true end + if world.fade == "white" and (world.fadeLevel or 0) >= 1 then + whiteHeld = whiteHeld + 1 + if world.flyAnim then startedEarly = true end + if not shotWhite then + shotWhite = true + U.shot(game, out .. "/04-exit-white.png") + end + end + if ms and ms.phase == "in" and (ms.step or 9) < 4 then + sawRamp = true + if not shotRamp and (ms.step or 9) <= 2 then + shotRamp = true + U.shot(game, out .. "/05-fade-in.png") + end + end + held = held + 1 + if world.flyAnim then break end + end + + ok(leaked == 0, + ("the menus were gone the moment the spawn was taken (%d menu frame(s))") + :format(leaked)) + ok(whiteHeld > 0 and sawHold, + ("ExitAllMenus put the white up and held it (%d frame(s))") + :format(whiteHeld)) + ok(not startedEarly, "and FlyFromAnim waited for it") + ok(sawRamp, "FadeInFromWhite ramped back in") + ok(world.flyAnim ~= nil and world.flyAnim.phase == "from", + ("then the take-off ran, %d frame(s) after the press"):format(held)) + U.shot(game, out .. "/06-takeoff.png") + + for _ = 1, 1800 do + U.wait(1) + if world.flyAnim == nil and world.map.id ~= "NEW_BARK_TOWN" then break end + end + U.wait(20) + ok(world.map.id ~= "NEW_BARK_TOWN", + ("landed on %s"):format(tostring(world.map.id))) + U.shot(game, out .. "/07-landed.png") + + U.log("flew out of New Bark Town from the party list (#2020). judge these:") + U.log("01-blank-in.png -- a WHITE screen, edge to edge, on the way into the") + U.log("town map. no overworld tiles, no sliver of the party list.") + U.log("03-b-back-to-party.png -- B on the town map must land back on the") + U.log("party list, not in the overworld.") + U.log("04-exit-white.png and 05-fade-in.png -- the exit is white again and") + U.log("then fades in; the bird only lifts off after that (06-takeoff.png).") + U.log("shots are in " .. out .. ".") + if fails > 0 then + U.log(("%d assertion(s) failed above -- read those before the pictures.") + :format(fails)) + end + U.log("the party still holds a flyer and the badge, so FLY again whenever") + U.log("you want another look. the controls are yours.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/crystal_map_name_sign_speed_bug1994.lua b/tests/drivers/crystal_map_name_sign_speed_bug1994.lua new file mode 100644 index 00000000..2c08f2f4 --- /dev/null +++ b/tests/drivers/crystal_map_name_sign_speed_bug1994.lua @@ -0,0 +1,84 @@ +local U = require("tests.drivers.util") + +-- ../pokecrystal/engine/events/map_name_sign.asm:41-44 +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +return function(game) + local fails = 0 + + local function say(line) print("[mapsign1994] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + if not (world and world.map) then + say("FAIL the crystal world did not boot") + love.event.quit(1) + return + end + + local DESTS = { + { "ROUTE_29", 10, 8, "left" }, + { "NEW_BARK_TOWN", 5, 6, "down" }, + } + local nextDest = 1 + + local function raise() + local d = DESTS[nextDest] + nextDest = nextDest % #DESTS + 1 + world:warpToMapId(d[1], d[2], d[3], d[4]) + for _ = 1, 240 do + if world.mapSign then return true end + U.wait(1) + end + return false + end + + local function measure(speed) + game.speedOverride = speed + if not raise() then + ok(false, speed .. "X: no sign came up to time") + return nil + end + say(("%dX: game:logicSpeed() reports %s") + :format(speed, tostring(game:logicSpeed()))) + local frames, t0 = 0, love.timer.getTime() + for _ = 1, 60 * 60 do + if not world.mapSign then break end + U.wait(1) + frames = frames + 1 + end + local elapsed = love.timer.getTime() - t0 + say(("%dX: %d rendered frames (%d logic steps), %.3f real seconds") + :format(speed, frames, frames * speed, elapsed)) + ok(world.mapSign == nil, speed .. "X: the sign expired") + return frames, elapsed + end + + for _, speed in ipairs({ 1, 4, 10 }) do + local frames, elapsed = measure(speed) + if frames then + ok(math.abs(frames - 60) <= 15, + ("%dX: %d rendered frames is within 15 of 60"):format(speed, frames)) + ok(elapsed >= 0.75 and elapsed <= 1.5, + ("%dX: %.3fs of wall clock is the one-second hold"):format(speed, elapsed)) + end + end + + game.speedOverride = 10 + if raise() then + U.wait(30) + ok(world.mapSign ~= nil, + "10X: the banner is still up half a real second in") + U.shot(game, SHOT_DIR .. "/mapsign1994_10x_halfsecond.png") + else + ok(false, "10X: no sign came up for the screenshot") + end + game.speedOverride = nil + + say(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gen2_belly_drum_attract_bug2018_2010.lua b/tests/drivers/gen2_belly_drum_attract_bug2018_2010.lua new file mode 100644 index 00000000..67cc28c2 --- /dev/null +++ b/tests/drivers/gen2_belly_drum_attract_bug2018_2010.lua @@ -0,0 +1,146 @@ +-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-aug30 \ +-- POKEPORT_DRIVER=tests/drivers/gen2_belly_drum_attract_bug2018_2010.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gen2-drum-attract love . +-- Block 1 is engine/battle/move_effects/belly_drum.asm: half the max HP for a +-- Block 2 is engine/battle/move_effects/attract.asm plus the SUBSTATUS_IN_LOVE +-- arm of CheckPlayerTurn (engine/battle/effect_commands.asm:279-300) and +-- BreakAttraction (engine/battle/core.asm:4110). +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +local function battleScreen(game) + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +local function drain(game, screen, frames) + for _ = 1, (frames or 400) do + if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then + return true + end + U.tap(game, "a") + U.wait(3) + end + return false +end + +local function giveMoves(mon, game, moves) + mon.moves = {} + for i, id in ipairs(moves) do + local def = assert(game.data.moves[id], id .. " is not in moves.lua") + mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp } + end + return mon +end + +local function dvsWithAttack(attack) + local dvs = { attack = attack, defense = 15, speed = 15, special = 15 } + dvs.hp = Mon.hpDV(dvs) + return dvs +end + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gen2-drum-attract" + U.wait(45) + local world = game.world + assert(world and world.map, "gen 2 world did not boot") + local failures = {} + local function check(ok, what) + print((ok and "[ok] " or "[FAIL] ") .. what) + if not ok then failures[#failures + 1] = what end + end + local function leaveBattle(game_) + for _ = 1, 400 do + if not game_.stack:top() or not game_.stack:top().battle then break end + U.tap(game_, "a") + U.wait(3) + end + U.wait(60) + end + + local player = Mon.new(game.data, "SNORLAX", 40, { dvs = dvsWithAttack(15) }) + giveMoves(player, game, { "BELLY_DRUM", "TACKLE" }) + game.save.party = { player } + local foe = Mon.new(game.data, "RATTATA", 5, { dvs = dvsWithAttack(15) }) + giveMoves(foe, game, { "TACKLE" }) + assert(world:startBattle({ wild = foe }), "startBattle failed") + local screen = battleScreen(game) + drain(game, screen, 200) + + local maxHp = player.maxHp + local cost = math.max(1, math.floor(maxHp / 2)) + screen:submit({ kind = "move", move = "BELLY_DRUM" }) + U.wait(40) + U.shot(game, out .. "/01-belly-drum.png") + drain(game, screen, 400) + print(("[driver] BELLY DRUM: %d/%d HP, attack stage %d") + :format(player.hp, maxHp, screen.battle.stages.player.attack)) + check(player.hp <= maxHp - cost, "the user paid half its max HP") + check(screen.battle.stages.player.attack == 6, "and ATTACK is maxed") + + screen:submit({ kind = "move", move = "BELLY_DRUM" }) + U.wait(40) + U.shot(game, out .. "/02-belly-drum-failed.png") + local hpBefore = player.hp + drain(game, screen, 400) + check(player.hp >= hpBefore, "a second BELLY DRUM under half costs no HP") + leaveBattle(game) + + local lover = Mon.new(game.data, "SNORLAX", 40, { dvs = dvsWithAttack(15) }) + giveMoves(lover, game, { "ATTRACT", "TACKLE" }) + local bench = Mon.new(game.data, "PIDGEY", 20, { dvs = dvsWithAttack(15) }) + giveMoves(bench, game, { "TACKLE" }) + game.save.party = { lover, bench } + local miltank = Mon.new(game.data, "MILTANK", 20, { dvs = dvsWithAttack(0) }) + giveMoves(miltank, game, { "TACKLE" }) + assert(world:startBattle({ wild = miltank }), "startBattle failed") + screen = battleScreen(game) + drain(game, screen, 200) + print(("[driver] genders: user %s, foe %s") + :format(tostring(screen.battle.player.gender), + tostring(screen.battle.enemy.gender))) + check(screen.battle.player.gender == "male" + and screen.battle.enemy.gender == "female", + "the pair is opposite-sex, so CheckOppositeGender lets ATTRACT through") + + screen:submit({ kind = "move", move = "ATTRACT" }) + U.wait(40) + U.shot(game, out .. "/03-fell-in-love.png") + drain(game, screen, 400) + check(screen.battle:volatile(screen.battle.enemy).attract == true, + "the foe is infatuated") + + local lines = 0 + for _ = 1, 6 do + if (screen.battle.enemy.hp or 0) <= 0 then break end + screen:submit({ kind = "move", move = "TACKLE" }) + U.wait(40) + if screen.message and screen.message:find("in love", 1, true) then + lines = lines + 1 + U.shot(game, out .. "/04-in-love-turn.png") + end + drain(game, screen, 400) + end + print(("[driver] the in-love line came up on %d of the turns watched") + :format(lines)) + + if (screen.battle.enemy.hp or 0) > 0 then + screen:submit({ kind = "switch", index = 2 }) + drain(game, screen, 400) + check(screen.battle:volatile(screen.battle.enemy).attract == nil, + "and the switch broke the attraction on the mon that stayed") + end + leaveBattle(game) + + if #failures > 0 then + for _, what in ipairs(failures) do print("[FAIL] " .. what) end + error(#failures .. " checks failed") + end + print("[driver] all belly drum / attract checks passed") + print("[driver] shots in " .. out) +end diff --git a/tests/drivers/gen2_bump_walk_in_place_bug1978_test.lua b/tests/drivers/gen2_bump_walk_in_place_bug1978_test.lua index 003fb64e..c64bc32e 100644 --- a/tests/drivers/gen2_bump_walk_in_place_bug1978_test.lua +++ b/tests/drivers/gen2_bump_walk_in_place_bug1978_test.lua @@ -54,7 +54,7 @@ return function(game) coroutine.yield() if p.moving then moved = true end phases[p:walkPhase()] = true - if f == 4 or f == 11 then + if f == 4 or f == 20 then shots[#shots + 1] = DIR .. "/1978-frame" .. f .. ".png" U.shot(game, shots[#shots]) end diff --git a/tests/drivers/gen2_pack_pc_menus_2012.lua b/tests/drivers/gen2_pack_pc_menus_2012.lua new file mode 100644 index 00000000..8d67176d --- /dev/null +++ b/tests/drivers/gen2_pack_pc_menus_2012.lua @@ -0,0 +1,68 @@ +-- ../pokecrystal/engine/items/pack.asm:313, ../pokegold/engine/items/pack.asm:313 +-- ../pokecrystal/engine/events/pokecenter_pc.asm:628, :641 +-- ../pokecrystal/engine/items/buy_sell_toss.asm:205 +-- POKEPORT_IDENTITY=crystal-aug31 POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/pack2012 POKEPORT_DRIVER=tests/drivers/gen2_pack_pc_menus_2012.lua love . +-- POKEPORT_IDENTITY=gold-aug30 POKEPORT_VERSION=gold POKEPORT_SHOT_DIR=/tmp/pack2012 POKEPORT_DRIVER=tests/drivers/gen2_pack_pc_menus_2012.lua love . +local U = require("tests.drivers.util") + +local Bag = require("src.inventory.Bag") +local Mon = require("src.battle.gen2.Mon") + +local SHOTS = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" +local TAG = os.getenv("POKEPORT_VERSION") or "gen2" + +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gen2 world did not boot") + local save, data = game.save, game.data + + save.party = { Mon.new(data, "CYNDAQUIL", 12) } + save.inventory, save.bagOrder = {}, nil + Bag.add(save, "POTION", 5, data) + Bag.add(save, "ANTIDOTE", 3, data) + Bag.add(save, "SUPER_POTION", 2, data) + save.pcItems = { POTION = 4, ANTIDOTE = 2, SUPER_POTION = 3 } + save.pcOrder = nil + + assert(game.world:openPc(), "openPc failed") + U.wait(30) + U.tap(game, "a") + U.wait(12) + U.tap(game, "down") + U.wait(8) + U.tap(game, "a") + U.wait(12) + U.tap(game, "a") + U.wait(12) + U.tap(game, "a") + U.wait(20) + U.tap(game, "a") + U.wait(30) + U.shot(game, SHOTS .. "/2012_" .. TAG .. "_pc_list.png") + + U.tap(game, "a") + U.wait(30) + U.shot(game, SHOTS .. "/2012_" .. TAG .. "_pc_qty.png") + + U.tap(game, "b") + U.wait(20) + U.tap(game, "b") + U.wait(20) + for _ = 1, 6 do + U.tap(game, "b") + U.wait(20) + end + + game:openStartMenuItem("pack") + U.wait(30) + U.shot(game, SHOTS .. "/2012_" .. TAG .. "_pack_list.png") + U.tap(game, "a") + U.wait(30) + U.shot(game, SHOTS .. "/2012_" .. TAG .. "_pack_submenu.png") + + U.log("done --", + "pc_list: cursor col 4, names col 5, xNN flush right at col 14;", + "pc_qty: the x01 box bottom-right at (15,9), list arrow hollow;", + "pack_submenu: crystal draws USE/GIVE/TOSS/QUIT on the RIGHT (col 13),", + "gold on the LEFT (col 0), list arrow hollow in both.") +end diff --git a/tests/drivers/gold_exp_bar.lua b/tests/drivers/gold_exp_bar.lua index f4355594..b3f15e3e 100644 --- a/tests/drivers/gold_exp_bar.lua +++ b/tests/drivers/gold_exp_bar.lua @@ -13,7 +13,8 @@ -- mid-crawl, and only changes on the frame the bar tops out -- (wBattleMonLevel is written inside the level loop, :7267-7274), -- with the end-of-bar hit playing there. --- 06 " grew to level N!", which comes AFTER all of that. +-- ../pokecrystal/engine/sprite_anims/core.asm:547-608). +-- sparks (../pokecrystal/engine/battle/core.asm:7538). -- And by ear: the low-HP siren is loud on the way in (the player is left on 3 -- HP on purpose), and is cut dead the moment the wild mon faints -- it must -- not blare on under the victory jingle and the exp lines @@ -86,12 +87,31 @@ return function(game) -- Four stills across the crawl. A bar that is already full in the first is -- the bug this driver exists for. - local seen = {} + local seen, burst, spread = {}, nil, false + local function watchBurst() + local frame = screen.expBurst and screen.expBurst.frame + if not frame then return end + if not burst then + burst = { screen.shownExp, screen.shownLevel, screen.message } + U.shot(game, out .. "/05b-end-of-bar-burst.png") + elseif not spread and frame >= 5 then + spread = true + U.shot(game, out .. "/05c-burst-spread.png") + end + end while screen.expAnim and shots < 4 do shots = shots + 1 seen[shots] = { screen.shownExp, screen.shownLevel } U.shot(game, out .. ("/%02d-crawl.png"):format(shots + 1)) - U.wait(18) + for _ = 1, 18 do + watchBurst() + U.wait(1) + end + end + for _ = 1, 900 do + if burst or not screen.expAnim then break end + watchBurst() + U.wait(1) end for i = 1, shots do print(("[driver] shot %d: bar %s/64, :L%s") @@ -99,6 +119,11 @@ return function(game) end assert(shots >= 2, "the crawl was over before two frames could be shot") assert(seen[1][1] < 64, "the bar was already full on the first crawl frame") + assert(burst, "the end-of-bar burst never armed on the level crossing") + print(("[driver] burst: bar %s/64, :L%s, message %q") + :format(tostring(burst[1]), tostring(burst[2]), tostring(burst[3]))) + assert(not tostring(burst[3] or ""):find("grew to level"), + "the grew-to-level line was already up while the sparks were on screen") -- The rest of the queue: the grew-to-level line and the way out. for _ = 1, 900 do diff --git a/tests/drivers/gold_fly_anim_bug1711_test.lua b/tests/drivers/gold_fly_anim_bug1711_test.lua index d8cb70d7..0d103a08 100644 --- a/tests/drivers/gold_fly_anim_bug1711_test.lua +++ b/tests/drivers/gold_fly_anim_bug1711_test.lua @@ -214,7 +214,11 @@ return function(game) -- A commits, and .FlyScript starts on this map, not the far one. U.tap(game, "a") - U.wait(3) + -- ExitAllMenus runs before .FlyScript (home/map.asm:2281) + for _ = 1, 120 do + if world.flyAnim then break end + U.wait(1) + end if not ok(world.flyAnim ~= nil and world.flyAnim.phase == "from", "A started FlyFromAnim over the map being left") then error("gold fly: the flight collapsed into a warp") diff --git a/tests/drivers/gold_oak_rating_2017.lua b/tests/drivers/gold_oak_rating_2017.lua new file mode 100644 index 00000000..8cfba34d --- /dev/null +++ b/tests/drivers/gold_oak_rating_2017.lua @@ -0,0 +1,98 @@ +-- POKEPORT_IDENTITY=gold-aug30 POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_oak_rating_2017.lua \ +-- POKEPORT_SHOT_DIR=/tmp/oak-rating-2017 \ +-- (data/text/common_2.asm:942), whose third line is a `cont` +-- (home/text.asm:442 _ContTextNoPause) -- shots 04 and 05 are the two halves +local U = require("tests.drivers.util") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/oak-rating-2017" + local fails = 0 + + local function ok(cond, msg) + if cond then print("[oakrating] ok " .. msg) + else fails = fails + 1 print("[oakrating] FAIL " .. msg) end + return cond + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + U.wait(45) + local w = game.world + assert(w and w.map, "gold world did not boot") + local save = game.save + + local Mon = require("src.battle.gen2.Mon") + save.party = { Mon.new(game.data, "CYNDAQUIL", 10) } + save.pokedex = { seen = {}, caught = {} } + for i = 1, 205 do + save.pokedex.seen["DEX" .. i] = true + save.pokedex.caught["DEX" .. i] = true + end + w:setEngineFlag(require("src.ui.gen2.CenterPcMenu").ENGINE_POKEDEX, true) + + local function topId() + local top = game.stack:top() + return top and top.screenId or nil + end + + assert(w:setMap("CHERRYGROVE_POKECENTER_1F", 4, 4, "up"), + "setMap CHERRYGROVE_POKECENTER_1F failed") + U.wait(5) + local pcX, pcY + for cy = 0, w.map.heightCells - 1 do + for cx = 0, w.map.widthCells - 1 do + if w.map:cellCollision(cx, cy) == 0x93 then pcX, pcY = cx, cy end + end + end + assert(pcX, "no COLL_PC tile in the Pokecenter") + assert(w:setMap("CHERRYGROVE_POKECENTER_1F", pcX, pcY + 1, "up"), + "setMap onto the PC tile failed") + U.wait(5) + + tap("a", 8) + ok(topId() == "Gen2CenterPcMenu", + "A at the Pokecenter PC opens the whose-PC menu (top: " + .. tostring(topId()) .. ")") + tap("a", 4) + local pc = game.stack:top() + ok(pc and pc.entries and pc.entries[3] and pc.entries[3].id == "oaks", + "the #DEX flag puts PROF.OAK's PC on the list") + + tap("down", 4) + tap("down", 4) + tap("a", 4) + tap("a", 4) + tap("a", 6) + ok(pc and pc.confirm ~= nil, "_OakPCText1 asks for the yes/no") + U.shot(game, out .. "/01-rated-prompt.png") + tap("a", 6) + + local shot = 1 + local seen = {} + for _ = 1, 10 do + if not (pc and pc.message) then break end + local page = pc.message.pages[pc.message.page] + ok(#page <= 2, "page " .. pc.message.page .. " is at most two lines (" + .. table.concat(page, " / ") .. ")") + seen[#seen + 1] = table.concat(page, " / ") + shot = shot + 1 + U.shot(game, out .. ("/%02d-page%d.png"):format(shot, pc.message.page)) + tap("a", 6) + end + + ok(seen[4] == "Wow! You've hit / 200! Your #DEX", + "205 caught reaches _OakRating15 (" .. tostring(seen[4]) .. ")") + ok(seen[5] == "200! Your #DEX / is looking great!", + "and its cont scrolls rather than adding a third line (" + .. tostring(seen[5]) .. ")") + + print(("[oakrating] %d failures"):format(fails)) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_pack_sfx_bug1893_test.lua b/tests/drivers/gold_pack_sfx_bug1893_test.lua index aa0599ff..6e00f87b 100644 --- a/tests/drivers/gold_pack_sfx_bug1893_test.lua +++ b/tests/drivers/gold_pack_sfx_bug1893_test.lua @@ -107,10 +107,15 @@ return function(game) pass(#heard == mark, "SELECT arming the move is silent on the cart too") tap("down") tap("a") - pass(since(mark) == "Sfx_SwitchPokemon", - ("placing the item played %s"):format(since(mark))) - U.log("5. the party-swap chirp as POTION lands on the second row. the cart") - U.log(" plays it twice, one after the other; this port plays it once.") + local beeps = 0 + for i = mark + 1, #heard do + if heard[i] == "Sfx_SwitchPokemon" then beeps = beeps + 1 end + end + -- engine/items/pack.asm:1309, :1311 + pass(beeps == 2, + ("placing the item played the swap chirp %d time(s)"):format(beeps)) + U.log("5. the party-swap chirp as POTION lands on the second row, TWICE --") + U.log(" the second only after the first has finished sounding.") Sound.play = realPlay U.log(("%d cue(s) missing"):format(failed)) diff --git a/tests/drivers/launcher_cart_outline_shot.lua b/tests/drivers/launcher_cart_outline_shot.lua new file mode 100644 index 00000000..d3234811 --- /dev/null +++ b/tests/drivers/launcher_cart_outline_shot.lua @@ -0,0 +1,66 @@ +-- POKEPORT_IDENTITY=crystal-aug31 SHOT_DIR=/tmp/cartoutline \ +-- POKEPORT_DRIVER=tests/drivers/launcher_cart_outline_shot.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local RomImporter = require("src.import.RomImporter") + local Kit = require("src.ui.kit.Kit") + + local dir = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") + or "/tmp/cartoutline" + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + love.window.setMode(1024, 768, { resizable = true, highdpi = true }) + U.wait(2) + + local imp = RomImporter.new(function() end, { launcher = true }) + + local hold = nil + local pending = nil + love.draw = function() + if hold then + Kit.focusId = hold + Kit._ringShown = true + end + imp:draw() + if pending then + local path = pending + pending = nil + love.graphics.captureScreenshot(function(imagedata) + local f = io.open(path, "wb") + if f then f:write(imagedata:encode("png"):getString()) f:close() end + end) + end + end + local function shot(name) + pending = dir .. "/" .. name + for _ = 1, 90 do + if not pending then break end + imp:update(1 / 60) + coroutine.yield() + end + U.wait(3) + local f = io.open(dir .. "/" .. name, "rb") + U.log(f and "shot" or "FAIL shot", name) + if f then f:close() end + end + + for _, version in ipairs({ "crystal", "red" }) do + imp:_switchTab(version) + for _ = 1, 60 do imp:update(1 / 60); coroutine.yield() end + U.log("tab:", imp.tab, "ready:", tostring(imp.ready and imp.ready[version]), + "blockClicks:", tostring(Kit.blockClicks)) + + hold = "play-" .. version + U.wait(20) + shot(version .. "_highlighted.png") + + hold = nil + imp:touchpressed(1, 40, 40, 0, 0, 1) + U.wait(20) + U.log("ring after touch:", tostring(Kit._ringShown)) + shot(version .. "_plain.png") + end + + U.log("done") + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/drivers/launcher_sync_shot.lua b/tests/drivers/launcher_sync_shot.lua index b1faa9d9..38e235b6 100644 --- a/tests/drivers/launcher_sync_shot.lua +++ b/tests/drivers/launcher_sync_shot.lua @@ -144,6 +144,17 @@ return function(game) U.log("closed:", tostring(imp._syncModal == nil)) shot("sync_closed.png") + love.window.setMode(1024, 768, { resizable = true, highdpi = true }) + U.wait(3) + imp:_switchTab("red") + eng.changed = true + eng.lastDownloads = { { version = "red", slot = "slot2", created = true, + device = "Android" } } + imp:update(1 / 60) + U.log("download notice:", tostring(imp.saveNotice.red and imp.saveNotice.red.text)) + U.wait(2) + shot("sync_downloaded.png") + U.log("done") love.event.quit() while true do coroutine.yield() end diff --git a/tests/drivers/loader_chain_2001.lua b/tests/drivers/loader_chain_2001.lua new file mode 100644 index 00000000..82ffd8bb --- /dev/null +++ b/tests/drivers/loader_chain_2001.lua @@ -0,0 +1,31 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local Guard = require("src.core.RequireGuard") + local loaders = package.loaders or package.searchers + + U.wait(30) + U.log("loader_chain_2001: captured=", tostring(Guard.captured()), + "present=", tostring(Guard.present())) + + local searcher = loaders[2] + table.remove(loaders, 2) + U.log("loader_chain_2001: removed package.loaders[2], present=", + tostring(Guard.present())) + + U.wait(5) + local repaired = Guard.repair() + U.log("loader_chain_2001: repair=", tostring(repaired), + "present=", tostring(Guard.present()), + "slot2_restored=", tostring(loaders[2] == searcher)) + + U.wait(5) + local ok = pcall(require, "src.core.GameSpeed") + U.log("loader_chain_2001: cold require after repair ok=", tostring(ok)) + + U.wait(30) + love.event.quit() + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/oak_rival_path_bug1989_test.lua b/tests/drivers/oak_rival_path_bug1989_test.lua new file mode 100644 index 00000000..afc6a753 --- /dev/null +++ b/tests/drivers/oak_rival_path_bug1989_test.lua @@ -0,0 +1,120 @@ +-- scripts/OaksLab.asm:343-377 + engine/overworld/pathfinding.asm:36-70 +-- (approach), scripts/OaksLab.asm:448-472 and :488-508 (exit + watch facing). + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/shots" + + local Commands = require("src.script.Commands") + local origStartBattle = Commands.start_battle + Commands.start_battle = function(ctx) + ctx.lastBattleResult = "win" + ctx.lastCheck = true + return nil + end + + local function resetSave() + local flags = game.save.flags or {} + game.save.flags = flags + flags.EVENT_FOLLOWED_OAK_INTO_LAB = true + flags.EVENT_GOT_STARTER = true + flags.EVENT_CHOSE_SQUIRTLE = true + flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil + if game.save.objectToggles and game.save.objectToggles.OAKS_LAB then + game.save.objectToggles.OAKS_LAB.OAKSLAB_RIVAL = nil + end + end + + -- scripts/OaksLab.asm OaksLabRivalTakePokeBallScript leaves him at (ballX,4) + local function placeRival(cx, cy) + local r = game.overworld:npcByIndex(1) + if not r then return nil end + r.cellX, r.cellY = cx, cy + r.px, r.py = cx * 16, cy * 16 + r.moving = false + r.targetX, r.targetY = nil, nil + return r + end + + local function scenario(px, side, tag) + resetSave() + U.teleport(game, "OAKS_LAB", px, 5, "down") + U.wait(6) + local rival = placeRival(7, 4) + if not rival then U.log(tag, "FAIL no rival object") return false end + + local cells, facings = {}, {} + local function sample() + local r = game.overworld:npcByIndex(1) + if r then + local last = cells[#cells] + if not last or last[1] ~= r.cellX or last[2] ~= r.cellY then + cells[#cells + 1] = { r.cellX, r.cellY } + end + end + local f = game.overworld.player.facing + if facings[#facings] ~= f then facings[#facings + 1] = f end + end + + U.shot(game, DIR .. "/" .. tag .. "_before.png") + local shotApproach, shotSide = false, false + for _ = 1, 900 do + sample() + if (not shotApproach) and #cells >= 2 then + U.shot(game, DIR .. "/" .. tag .. "_approach.png") + shotApproach = true + end + if (not shotSide) and facings[#facings] == side then + U.shot(game, DIR .. "/" .. tag .. "_watch.png") + shotSide = true + end + if game.overworld:npcByIndex(1) == nil then break end + local p = game.overworld.player + if p and (p.cellY or 0) < 6 then U.hold(game, "down", 8) end + U.tap(game, "a") + U.wait(2) + sample() + end + U.wait(6) + U.shot(game, DIR .. "/" .. tag .. "_end.png") + + local trail = {} + for _, c in ipairs(cells) do trail[#trail + 1] = c[1] .. "," .. c[2] end + U.log(tag, "rival trail:", table.concat(trail, " -> ")) + U.log(tag, "player facing:", table.concat(facings, " -> ")) + + -- engine/overworld/pathfinding.asm:36-40 + local horizontalFirst = cells[1] and cells[2] + and cells[2][2] == cells[1][2] + and cells[2][1] < cells[1][1] + -- scripts/OaksLab.asm:449-462 + local clearedColumn = true + for _, c in ipairs(cells) do + if c[2] >= 6 and c[1] == px then clearedColumn = false end + end + local watched, wentDown = false, false + for i, f in ipairs(facings) do + if f == side then watched = true end + if watched and f == "down" and i > 1 then wentDown = true end + end + local pass = horizontalFirst and clearedColumn and watched and wentDown + U.log(tag, "horizontalFirst:", tostring(horizontalFirst), + "clearedColumn:", tostring(clearedColumn), + "watched(" .. side .. "):", tostring(watched), + "thenDown:", tostring(wentDown)) + U.log(tag, pass and "PASS" or "FAIL") + return pass + end + + local a = scenario(4, "right", "playerx4") + local b = scenario(5, "left", "playerx5") + + Commands.start_battle = origStartBattle + + U.log("RESULT bug1989_bug1987", (a and b) and "PASS" or "FAIL") + assert(a, "player at x=4: rival must approach left-first, sidestep RIGHT " + .. "out of column 4, and the player must turn right then down") + assert(b, "player at x=5: rival must approach left-first, sidestep LEFT " + .. "out of column 5, and the player must turn left then down") +end diff --git a/tests/engine/applying_attack_sfx_wait_bug1998.lua b/tests/engine/applying_attack_sfx_wait_bug1998.lua new file mode 100644 index 00000000..16f5c331 --- /dev/null +++ b/tests/engine/applying_attack_sfx_wait_bug1998.lua @@ -0,0 +1,137 @@ +-- engine/battle/animations.asm:2639, home/delay.asm:15 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") +local Sound = require("src.core.Sound") +TypeChart.load(Data) + +local busy, budget = false, 0 +Sound.moveSfxBusy = function() return busy end +Sound.moveSfxWaitFrames = function() return budget end + +local function newBattle(hit, alarm) + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 40) } + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + local battle = BattleState.newWild(game, "FIXMON_C", 40) + battle.queue, battle.nextInsert = {}, 0 + battle.fx = {} + battle.animPlayer = { update = function() end, + isDone = function() return true end, + pollEffects = function() return {} end } + battle.animPlaying = true + battle.pendingHit = hit + battle.lowHealthAlarmActive = function() return alarm == true end + return battle +end + +local function fxArmed(battle) + return (battle.fx.shakeProg ~= nil) or (battle.fx.blink ~= nil) +end + +for _, t in ipairs({ 1, 2, 5 }) do + busy, budget = true, 5 + local battle = newBattle({ animType = t, sfx = "Damage" }) + for i = 1, 5 do + T.eq(battle:updateQueue(), true, + ("type %d holds the queue on frame %d"):format(t, i)) + T.check(not fxArmed(battle), + ("type %d arms no applying-attack fx while the sfx sounds"):format(t)) + T.check(battle.pendingHit ~= nil, + ("type %d keeps its hit row pending"):format(t)) + T.eq(battle.animPlaying, true, + ("type %d stays in the animation branch"):format(t)) + end + battle:updateQueue() + T.check(fxArmed(battle), + ("type %d arms its fx once the budget expires"):format(t)) + T.eq(battle.pendingHit, nil, ("type %d consumes its hit row"):format(t)) + T.eq(battle.hitSfxWait, nil, ("type %d clears the hold counter"):format(t)) +end + +do + busy, budget = true, 3 + local battle = newBattle(nil) + battle.pendingHit = { animType = 4, sfx = "Damage", blink = battle.enemy } + battle:updateQueue() + T.eq(battle.fx.blink, nil, "type 4 does not blink while the sfx sounds") + for _ = 1, 3 do battle:updateQueue() end + T.check(battle.fx.blink ~= nil, "type 4 blinks once the sfx has finished") +end + +do + busy, budget = true, 60 + local battle = newBattle({ animType = 1, sfx = "Damage" }) + battle:updateQueue() + battle:updateQueue() + T.check(not fxArmed(battle), "still held while CHAN5/6/8 are busy") + busy = false + battle:updateQueue() + T.check(fxArmed(battle), "released the frame the sfx channels go idle") + T.eq(battle.hitSfxWait, nil, "and the hold counter is cleared") +end + +-- animations.asm:495 / :498 ShakeScreenHorizontallySlow, ...Slow2 +for _, t in ipairs({ 3, 6 }) do + busy, budget = true, 60 + local battle = newBattle({ animType = t }) + battle:updateQueue() + T.check(battle.fx.shakeProg ~= nil, + ("type %d shakes without waiting for the sfx"):format(t)) + T.eq(battle.hitSfxWait, nil, ("type %d sets no hold counter"):format(t)) +end + +do + busy, budget = true, 60 + local battle = newBattle({ animType = 1, sfx = "Damage" }, true) + battle:updateQueue() + T.check(fxArmed(battle), "the low-health alarm bypasses the wait") + T.eq(battle.hitSfxWait, nil, "and leaves no hold counter behind") +end + +do + busy, budget = true, 60 + local battle = newBattle(nil) + battle:updateQueue() + T.eq(battle.animPlaying, false, "a hitless animation row ends on its own") + T.eq(battle.hitSfxWait, nil, "and sets no hold counter") +end + +do + busy, budget = true, 0 + local battle = newBattle({ animType = 1, sfx = "Damage" }) + battle:updateQueue() + T.check(fxArmed(battle), "a zero budget releases immediately") +end + +do + busy, budget = true, 100000 + local battle = newBattle({ animType = 1, sfx = "Damage" }) + battle:updateQueue() + T.eq(battle.hitSfxWait, 179, "the budget is capped at 180 logic frames") + for _ = 1, 200 do + if fxArmed(battle) then break end + battle:updateQueue() + end + T.check(fxArmed(battle), "and a stuck source cannot wedge the battle") +end + +do + Sound.moveSfxBusy, Sound.moveSfxWaitFrames = nil, nil + package.loaded["src.core.Sound"] = nil + local Fresh = require("src.core.Sound") + T.eq(type(Fresh.moveSfxBusy), "function", "Sound exposes moveSfxBusy") + T.eq(type(Fresh.moveSfxWaitFrames), "function", + "Sound exposes moveSfxWaitFrames") + T.eq(Fresh.moveSfxBusy(), false, "no move sfx sounding, no wait") + T.eq(Fresh.moveSfxWaitFrames(), 0, "and a zero frame budget") +end + +T.finish() diff --git a/tests/engine/audio_device_reset.lua b/tests/engine/audio_device_reset.lua index 687d677c..e313f475 100644 --- a/tests/engine/audio_device_reset.lua +++ b/tests/engine/audio_device_reset.lua @@ -130,6 +130,12 @@ local function audioReset() end local ChipAudio = freshChipAudio(true) +local PREROLL = ChipAudio.MUSIC_PREROLL + +local function deliverPreroll(epoch) + for _ = 1, PREROLL do deliverBuffer(epoch) end +end + local data = fixtureData() Sound.invalidate() Music.reload() @@ -142,12 +148,12 @@ check(src and src.queueable, "the map theme streams through ChipAudio") src.playing = false audioSuspend() -deliverBuffer() +deliverPreroll() ChipAudio.update() eq(src.free, BUFFERS, "a suspended update queues nothing onto the dead source") check(not src.playing, "a suspended update does not start playback") -eq(channel("chipaudio_out"):getCount(), 1, - "the worker buffer waits in the channel instead of being dropped") +eq(channel("chipaudio_out"):getCount(), PREROLL, + "the worker buffers wait in the channel instead of being dropped") ChipAudio.setSuspended(true) ChipAudio.setSuspended(false) @@ -155,7 +161,7 @@ ChipAudio.setSuspended(false) check(not ChipAudio.isSuspended(), "the suspend flag is idempotent both ways") ChipAudio.update() -eq(src.free, BUFFERS - 1, "the resumed update queues the waiting buffer") +eq(src.free, BUFFERS - PREROLL, "the resumed update queues the waiting buffers") check(src.playing, "the resumed update starts playback") src.playing = false @@ -177,15 +183,15 @@ check(fresh ~= nil and fresh ~= src, "rebuildPlayback swapped in a fresh source" check(not src.playing, "the source from the dead device was stopped") eq(fresh.free, BUFFERS, "the replacement source starts empty") -deliverBuffer() +deliverPreroll() ChipAudio.update() -eq(fresh.free, BUFFERS - 1, +eq(fresh.free, BUFFERS - PREROLL, "worker PCM tagged with the pre-reset gen and stereo epoch still queues") -check(fresh.playing, "playback resumes on the first requeued buffer") +check(fresh.playing, "playback resumes once the rebuilt queue is pre-rolled") deliverBuffer(lastEpoch + 1) ChipAudio.update() -eq(fresh.free, BUFFERS - 1, +eq(fresh.free, BUFFERS - PREROLL, "a buffer from another stereo epoch is still dropped after the reset") check(fresh.volume ~= nil, "Music re-applied volume to the replacement source") diff --git a/tests/engine/battle_levelup_order_bug1996.lua b/tests/engine/battle_levelup_order_bug1996.lua new file mode 100644 index 00000000..9e262c15 --- /dev/null +++ b/tests/engine/battle_levelup_order_bug1996.lua @@ -0,0 +1,225 @@ +-- before GrewLevelText (engine/battle/experience.asm:149-256). GrewLevelText +-- is sound_level_up + text_end with no prompt (data/text/text_2.asm:1228-1234), +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) + +local BattleState = require("src.battle.BattleState") +local Experience = require("src.battle.Experience") +local Growth = require("src.pokemon.Growth") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Timing = require("src.core.Timing") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local function newGame(mon) + local save = SaveData.newGame() + save.player.name = "RED" + save.party = { mon } + return { + data = Data, save = save, + stack = { top = function() return nil end, push = function() end }, + input = { wasPressed = function() return false end, + isDown = function() return false end }, + } +end + +local function monAtBrink(level) + local mon = Pokemon.new(Data, "FIXMON_A", level, function(_, b) return b end) + mon.exp = Growth.expForLevel(Data.pokemon.FIXMON_A.growthRate, level + 1, + Data.growth_rates) - 1 + return mon +end + + +do + local eager, deferred = monAtBrink(10), monAtBrink(10) + local baseLevel, baseMax = eager.level, eager.stats.hp + eager.hp, deferred.hp = 4, 4 + + local eagerLevels, eagerGained = Experience.apply(Data, eager, + Data.pokemon.FIXMON_C, 10, false, 1, false) + local levels, gained, steps = Experience.apply(Data, deferred, + Data.pokemon.FIXMON_C, 10, false, 1, false, { defer = true }) + + T.eq(#levels, #eagerLevels, "defer reports the same level count") + T.eq(levels[1], eagerLevels[1], "defer reports the same first level") + T.eq(gained, eagerGained, "defer reports the same exp award") + T.eq(deferred.exp, eager.exp, "exp is credited either way (experience.asm:129)") + T.eq(deferred.level, baseLevel, "defer leaves mon.level uncommitted") + T.eq(deferred.stats.hp, baseMax, "defer leaves mon.stats uncommitted") + T.eq(deferred.hp, 4, "defer leaves mon.hp uncommitted") + T.eq(#steps, #levels, "one step per level gained") + T.eq(steps[1].level, levels[1], "the step carries its level") + T.check(steps[1].stats.hp > baseMax, "the step carries the grown stats") + T.eq(steps[1].hp, 4 + (steps[1].stats.hp - baseMax), + "the step carries the max-HP delta (experience.asm:194-200)") + + Experience.commit(Data, deferred, steps[1]) + T.eq(deferred.level, eager.level, "commit lands the same level") + T.eq(deferred.stats.hp, eager.stats.hp, "commit lands the same max HP") + T.eq(deferred.hp, eager.hp, "commit lands the same current HP") +end + +do + local mon = Pokemon.new(Data, "FIXMON_A", 5, function(_, b) return b end) + mon.exp = Growth.expForLevel(Data.pokemon.FIXMON_A.growthRate, 9, + Data.growth_rates) + local levels, _, steps = Experience.apply(Data, mon, Data.pokemon.FIXMON_C, + 5, false, 1, false, { defer = true }) + T.check(#levels >= 2, "a multi-level jump reports every level") + for i = 2, #steps do + T.check(steps[i].level == steps[i - 1].level + 1, + "step " .. i .. " advances one level") + T.check(steps[i].stats.hp >= steps[i - 1].stats.hp, + "step " .. i .. " never loses max HP") + end + T.eq(mon.level, 5, "and none of them is committed") +end + + +local function awardOnce() + local mon = monAtBrink(10) + mon.hp = 4 + local game = newGame(mon) + local battle = BattleState.newWild(game, "FIXMON_C", 10) + battle.queue, battle.nextInsert = {}, 0 + battle.participants = { [mon] = true } + battle:awardExp() + return battle, mon +end + +do + local battle = awardOnce() + local q = battle.queue + T.check(q[1] ~= nil and q[1].text ~= nil, + "row 1 is the gained-EXP page (experience.asm:149)") + T.eq(q[1].auto, nil, "the gained-EXP page keeps its prompt (_ExpPointsText)") + T.check(q[2] ~= nil and type(q[2].fn) == "function", + "row 2 writes the level (experience.asm:158-239)") + T.check(q[3] ~= nil and q[3].text ~= nil, "row 3 is the grew-level page") + T.eq(q[3].auto, true, "GrewLevelText ends in text_end, not prompt") + T.eq(type(q[3].waitForLearningSfx), "function", + "GrewLevelText still waits out sound_level_up") + T.check(q[4] ~= nil and type(q[4].ui) == "function", + "row 4 is PrintStatsBox (experience.asm:249)") + for i, row in ipairs(q) do + T.eq(row.drain, nil, "row " .. i .. " does not animate the HP bar") + end +end + +do + local battle, mon = awardOnce() + local oldLevel, oldMax = 10, mon.stats.hp + T.eq(mon.level, oldLevel, "the level has not moved while the pages queue") + T.eq(mon.stats.hp, oldMax, "nor has the HUD's max-HP denominator") + T.eq(battle.player.shownHP, 4, "nor the bar") + + battle.queue[2].fn() + T.eq(mon.level, oldLevel + 1, "the queued row writes the level") + T.check(mon.stats.hp > oldMax, "and the new max HP") + T.eq(mon.hp, 4 + (mon.stats.hp - oldMax), "and the max-HP delta") + T.eq(battle.player.shownHP, mon.hp, + "DrawPlayerHUDAndHPBar snaps the bar (experience.asm:239)") + T.eq(battle.player.shownPx, + Timing.hpBarPixels(mon.hp, math.max(1, mon.stats.hp)), + "including its pixel length") + T.eq(battle.player.badgeExtraBoosts, nil, + "ApplyBadgeStatBoosts is re-run (experience.asm:238)") +end + + +do + local mon = monAtBrink(10) + local game = newGame(mon) + local battle = BattleState.newWild(game, "FIXMON_C", 10) + battle.queue, battle.nextInsert = {}, 0 + + local Sound = require("src.core.Sound") + local origWait = Sound.waitFrames + Sound.waitFrames = function() return 3 end + local playing, sfxCalls = true, 0 + battle:sayNextAutoWaitSfx("FIXMON A GREW\nTO LEVEL 11!", function() + sfxCalls = sfxCalls + 1 + return { isPlaying = function() return playing end, + stop = function() end } + end) + + local frames = 0 + while battle.queue[1] or battle.current or battle.waitingSound do + battle:updateQueue() + frames = frames + 1 + if frames > 2000 then break end + if battle.waitingSound and frames > 400 then playing = false end + end + Sound.waitFrames = origWait + + T.eq(sfxCalls, 1, "the auto page still plays sound_level_up") + T.eq(battle.current, nil, "and clears with no A/B press") + T.eq(battle.msgPrompt, nil, "no prompt arrow was ever raised") + T.check(frames <= 2000, "the queue did not wedge") +end + + +-- engine/battle/core.asm:829-858 +local function awardWithExpAll(startLevel) + local mon = monAtBrink(startLevel) + mon.hp = 4 + local game = newGame(mon) + game.save.inventory.EXP_ALL = 1 + local battle = BattleState.newWild(game, "FIXMON_C", 10) + battle.queue, battle.nextInsert = {}, 0 + battle.participants = { [mon] = true } + battle:awardExp() + local gainedPages, grewLevels, fnRows, uiRows = {}, {}, {}, {} + for _, row in ipairs(battle.queue) do + if type(row.text) == "string" then + local lv = row.text:match("to level (%d+)!") + if lv then + table.insert(grewLevels, tonumber(lv)) + elseif row.text:match("EXP%. Points!") then + table.insert(gainedPages, row.text) + end + elseif type(row.fn) == "function" then + table.insert(fnRows, row.fn) + elseif type(row.ui) == "function" then + table.insert(uiRows, row.ui) + end + end + return battle, mon, gainedPages, grewLevels, fnRows, uiRows +end + +do + local _, mon, gained, grew, fnRows, uiRows = awardWithExpAll(10) + T.eq(#gained, 2, "both shares print their own gained-EXP page") + T.check(gained[2]:match("with EXP%.ALL,") ~= nil, + "the party pass carries _WithExpAllText") + T.eq(#grew, 1, "one grew-level page for the one level crossed") + T.eq(grew[1], 11, "and it names level 11") + T.eq(#fnRows, 1, "one commit row") + T.eq(#uiRows, 1, "one PrintStatsBox (experience.asm:249)") + T.eq(mon.level, 10, "the level is still uncommitted while the pages queue") + fnRows[1]() + T.eq(mon.level, 11, "the commit row lands the level once") +end + +do + local _, mon, _, grew, fnRows, uiRows = awardWithExpAll(5) + local final = Growth.levelForExp(Data.pokemon.FIXMON_A.growthRate, mon.exp, + 100, Data.growth_rates) + T.check(final > 6, "both shares together cross more than one level") + T.eq(#grew, final - 5, "one grew-level page per level crossed") + for i = 2, #grew do + T.eq(grew[i], grew[i - 1] + 1, + "grew page " .. i .. " names the next level, never a repeat") + end + T.eq(#fnRows, #grew, "one commit row per grew page") + T.eq(#uiRows, #grew, "one stat box per grew page") + for _, fn in ipairs(fnRows) do fn() end + T.eq(mon.level, final, "the commit rows walk to the final level") +end + +T.finish() diff --git a/tests/engine/chip_audio_preroll_test.lua b/tests/engine/chip_audio_preroll_test.lua new file mode 100644 index 00000000..4b88afaf --- /dev/null +++ b/tests/engine/chip_audio_preroll_test.lua @@ -0,0 +1,216 @@ + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +love = require("tests.love_stub") + +local sources = {} + +local Source = {} +Source.__index = Source +function Source:play() self.playing = true end +function Source:stop() self.playing = false end +function Source:pause() self.playing = false end +function Source:isPlaying() return self.playing end +function Source:setLooping(v) self.looping = v end +function Source:setVolume(v) self.volume = v end +function Source:setPitch(v) self.pitch = v end +function Source:setFilter() end +function Source:getDuration() return 1 end +function Source:getFreeBufferCount() return self.free end +function Source:queue() self.free = math.max(0, self.free - 1) end + +local ChipSynth = require("src.core.ChipSynth") +local BUFFERS = ChipSynth.MUSIC_BUFFER_COUNT + +function Source:drain(n) + self.free = math.min(BUFFERS, self.free + n) +end + +local function track(src) + sources[#sources + 1] = src + return src +end + +love.audio = { + newSource = function(what, mode) + return track(setmetatable({ file = what, mode = mode, free = 0 }, Source)) + end, + newQueueableSource = function() + return track(setmetatable({ queueable = true, free = BUFFERS }, Source)) + end, +} + +local channels = {} +local lastGen, lastEpoch = 0, 0 + +local Channel = {} +Channel.__index = Channel +function Channel:push(msg) + if type(msg) == "table" and msg.cmd == "play" then + lastGen, lastEpoch = msg.gen, msg.stereoEpoch or 0 + end + self.queue[#self.queue + 1] = msg +end +function Channel:pop() return table.remove(self.queue, 1) end +function Channel:clear() self.queue = {} end +function Channel:getCount() return #self.queue end + +local function channel(name) + channels[name] = channels[name] or setmetatable({ queue = {} }, Channel) + return channels[name] +end + +love.thread = { + newThread = function() + return { + start = function() end, + getError = function() return nil end, + wait = function() end, + } + end, + getChannel = channel, +} + +local ChipAudio = require("src.core.ChipAudio") +local Logger = require("src.core.Logger") +local PREROLL = ChipAudio.MUSIC_PREROLL + +check(type(PREROLL) == "number" and PREROLL > 1, + "ChipAudio publishes a pre-roll floor deeper than one buffer") + +local function deliver(n) + for _ = 1, (n or 1) do + channel("chipaudio_out"):push({ + gen = lastGen, sd = true, stereoEpoch = lastEpoch }) + end +end + +local function deliverDone() + channel("chipaudio_out"):push({ gen = lastGen, done = true }) +end + +local ChipAsm = require("src.audio.ChipAsm") + +local song = ChipAsm.song{ + channels = { { hw = 1, program = { + { notetype = { speed = 12, volume = 12, fade = 0 } }, + { octave = 4 }, + { note = "C", len = 8 }, + { loop = { count = 0, to = 1 } }, + } } }, +} +local data = { audio = { songs = { Music_PalletTown = song } } } + +local function lastSource() return sources[#sources] end + +local function newSong(allowLoops) + for i = #sources, 1, -1 do sources[i] = nil end + channel("chipaudio_out"):clear() + check(ChipAudio.playMusic(data, song, allowLoops) ~= nil, + "threaded playMusic built a queueable source") + return lastSource() +end + + +local src = newSong(true) +check(not src.playing, "a fresh song is silent before any buffer lands") + +for queued = 1, PREROLL - 1 do + deliver(1) + ChipAudio.update() + eq(BUFFERS - src.free, queued, + ("update queued buffer %d"):format(queued)) + check(not src.playing, + ("a looping song is still silent on %d queued buffer(s)"):format(queued)) + check(ChipAudio.awaitingFirstBuffer(), + "the song still reads as awaiting its first buffer") +end + +deliver(1) +ChipAudio.update() +eq(BUFFERS - src.free, PREROLL, "the pre-roll is fully queued") +check(src.playing, "the looping song starts once the pre-roll is queued") +check(not ChipAudio.awaitingFirstBuffer(), "and it is no longer awaiting") + + +local jingle = newSong(false) +check(ChipAudio.awaitingFirstBuffer(), + "the jingle awaits its first buffer like it always did") +deliver(1) +ChipAudio.update() +check(jingle.playing, "a playOnce jingle still starts on its first buffer") +check(not ChipAudio.awaitingFirstBuffer(), + "and awaitingFirstBuffer clears on that same buffer") + + +local short = newSong(true) +deliver(1) +deliverDone() +ChipAudio.update() +check(short.playing, + "a song the worker finished short of the pre-roll starts on what it has") + + +local recover = newSong(true) +deliver(PREROLL) +ChipAudio.update() +check(recover.playing, "the song is sounding before the stall") + +recover:drain(PREROLL) +recover.playing = false +eq(BUFFERS - recover.free, 0, "the stall drained the queue dry") +ChipAudio.ensureMusicPlaying() +check(not recover.playing, "an empty queue is not restarted") + +deliver(1) +ChipAudio.update() +ChipAudio.ensureMusicPlaying() +check(not recover.playing, + "recovery does not restart on one buffer straight back into an underrun") + +deliver(PREROLL - 1) +ChipAudio.update() +ChipAudio.ensureMusicPlaying() +check(recover.playing, "recovery restarts once the queue is pre-rolled again") + + +check(type(ChipAudio._setAudioStatsForTest) == "function", + "ChipAudio exposes the audio stats counters") +ChipAudio._setAudioStatsForTest(true) + +local stats = newSong(true) +local logBase = #Logger.history +deliver(PREROLL) +for _ = 1, 59 do ChipAudio.update() end +eq(#Logger.history, logBase, "the stats line does not fire before a full second") +ChipAudio.update() +local line = Logger.history[#Logger.history] +check(line ~= nil and line:find("chipaudio: depth=", 1, true) ~= nil, + "one stats line per 60 updates: " .. tostring(line)) +check(line:find(("depth=%d/%d"):format(PREROLL, BUFFERS), 1, true) ~= nil, + "the line reports the live queue depth") +check(line:find("rate=" .. ChipSynth.SAMPLE_RATE, 1, true) ~= nil, + "and the sample rate the source was built at") + +stats:drain(PREROLL) +stats.playing = false +for _ = 1, 60 do ChipAudio.update() end +local counters = ChipAudio._audioStatsForTest() +eq(counters.underruns, 60, "a dry queue is counted as an underrun every frame") +eq(counters.restarts, 0, "nothing restarted while the queue was dry") + +deliver(PREROLL) +ChipAudio.update() +ChipAudio.ensureMusicPlaying() +eq(ChipAudio._audioStatsForTest().restarts, 1, + "the restart the reporter hears as a click is counted") + +ChipAudio._setAudioStatsForTest(false) +for _ = 1, 120 do ChipAudio.update() end +eq(ChipAudio._audioStatsForTest().frames, 0, + "the counters cost an ungated session nothing") + +T.finish("chip audio pre-roll") diff --git a/tests/engine/crystal_map_name_sign_speed_bug1994.lua b/tests/engine/crystal_map_name_sign_speed_bug1994.lua new file mode 100644 index 00000000..d008847b --- /dev/null +++ b/tests/engine/crystal_map_name_sign_speed_bug1994.lua @@ -0,0 +1,137 @@ +-- ../pokecrystal/engine/events/map_name_sign.asm:41-44 +-- ../pokecrystal/engine/events/map_name_sign.asm:99-107 + +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") + +package.loaded["src.core.GameVersion"] = { + engine = function() return "crystal" end, +} +package.loaded["src.render.Assets"] = { + register = function() end, + resolve = function(path) return path end, + exists = function() return false end, + image = function() error("no sheet in the fixture cache") end, +} +package.loaded["src.render.GbcPalette"] = { + available = function() return false end, + with = function(_, body) body() end, +} +package.loaded["src.render.Font"] = { + ttfActive = function() return false end, + encode = function(text) + local out = {} + for i = 1, #tostring(text) do out[i] = string.byte(text, i) end + return out + end, + width = function(text) return #tostring(text) * 8 end, + draw = function() end, +} +package.loaded["src.world.gen2.MapNameSign"] = nil +local Sign = require("src.world.gen2.MapNameSign") + +local World = {} +World.__index = World + +function World.new(game) + return setmetatable({ + game = game, + map = { id = "ROUTE_29", + def = { landmark = "LANDMARK_ROUTE_29", environment = "ROUTE" } }, + mapSignState = { prev = "LANDMARK_NEW_BARK_TOWN" }, + }, World) +end + +function World:currentLandmarkId() return "LANDMARK_ROUTE_29" end +function World:landmarkName() return "ROUTE 29" end + +local function raise(game) + local w = World.new(game) + Sign.init(w) + return w +end + +local function ticksToClear(w, limit) + for i = 1, limit do + Sign.tick(w) + if w.mapSign == nil then return i end + end + return nil +end + +local function gameAt(speed) + return { logicSpeed = function() return speed end } +end + +local one = raise(gameAt(1)) +if check(one.mapSign ~= nil, "the sign comes up at 1X") then + eq(one.mapSign.timer, 60, "wLandmarkSignTimer starts at 60") + for _ = 1, 10 do Sign.tick(one) end + eq(one.mapSign.timer, 50, "ten frames burn ten frames of the timer at 1X") +end +eq(ticksToClear(raise(gameAt(1)), 200), 60, "60 logic frames clear it at 1X") + +local four = raise(gameAt(4)) +for _ = 1, 60 do Sign.tick(four) end +if check(four.mapSign ~= nil, "60 logic frames at 4X is a quarter second, so " + .. "the sign is still up") then + eq(four.mapSign.timer, 45, "and 60 logic frames at 4X have burned 15 of them") +end + +local four2 = raise(gameAt(4)) +for _ = 1, 3 do Sign.tick(four2) end +if check(four2.mapSign ~= nil, "the sign is up three logic frames into 4X") then + eq(four2.mapSign.timer, 60, "three logic frames at 4X burn none of the timer") + Sign.tick(four2) + eq(four2.mapSign.timer, 59, "the fourth burns exactly one") +end +eq(ticksToClear(raise(gameAt(4)), 400), 240, + "4 x 60 logic frames is one real second at 4X") + +eq(ticksToClear(raise(gameAt(10)), 900), 600, "and 600 of them at 10X") + +local half = raise({ logicSpeed = function() return 1.5 end }) +for _ = 1, 3 do Sign.tick(half) end +if check(half.mapSign ~= nil, "1.5X keeps the sign up past three ticks") then + eq(half.mapSign.timer, 58, "1.5X burns two frames in three ticks") +end + +local bare = raise(nil) +eq(ticksToClear(bare, 200), 60, "no game object means the plain 60-frame life") + +local broken = raise({ logicSpeed = function() error("no speed for you") end }) +eq(ticksToClear(broken, 200), 60, "a throwing logicSpeed falls back to 1X") + +local numberless = raise({ logicSpeed = function() return "fast" end }) +eq(ticksToClear(numberless, 200), 60, "a non-numeric logicSpeed falls back too") + +local zero = raise({ logicSpeed = function() return 0 end }) +eq(ticksToClear(zero, 200), 60, "and so does a zero, rather than freezing it") + +-- ../pokecrystal/home/window.asm:42 +for _, speed in ipairs({ 1, 4, 10 }) do + local talked = raise(gameAt(speed)) + talked.textbox = true + Sign.tick(talked) + eq(talked.mapSign, nil, + "a text box takes the sign down on the first tick at " .. speed .. "X") +end + +-- ../pokecrystal/engine/overworld/events.asm:284-285 +local pushed = raise(gameAt(10)) +for _ = 1, 30 do Sign.tick(pushed) end +check(pushed.mapSign ~= nil, "the sign survives 30 logic frames at 10X") +Sign.cancel(pushed) +eq(pushed.mapSign, nil, "and a player event still clears it outright") + +local stateful = raise(gameAt(4)) +Sign.tick(stateful) +eq(Sign.state(stateful).accum, nil, + "the accumulator does not reach the saved crystal state") +check(stateful.mapSign and stateful.mapSign.accum ~= nil, + "it lives on world.mapSign") + +T.finish("crystal map name sign speed bug 1994") diff --git a/tests/engine/ed_tile_import_1947.lua b/tests/engine/ed_tile_import_1947.lua new file mode 100644 index 00000000..1fce78df --- /dev/null +++ b/tests/engine/ed_tile_import_1947.lua @@ -0,0 +1,146 @@ +-- engine/menus/naming_screen.asm:326, gfx/font/ED.1bpp (#1947) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +if not _G.love then _G.love = require("tests.love_stub") end + +love.image.newImageData = function(width, height) + local px = {} + local img = { w = width or 8, h = height or 8 } + function img:getWidth() return self.w end + function img:getHeight() return self.h end + function img:getDimensions() return self.w, self.h end + function img:setPixel(x, y, r, g, b, a) + px[y * self.w + x] = { r, g, b, a } + end + function img:getPixel(x, y) + local p = px[y * self.w + x] + if not p then return 0, 0, 0, 0 end + return p[1], p[2], p[3], p[4] + end + function img:mapPixel(fn) + for y = 0, self.h - 1 do + for x = 0, self.w - 1 do + px[y * self.w + x] = { fn(x, y, self:getPixel(x, y)) } + end + end + end + function img:encode() return { getString = function() return "" end } end + return img +end + +local RomExtractor = require("src.import.RomExtractor") + +local function readFile(path) + local handle = io.open(path, "r") + if not handle then return nil end + local text = handle:read("*a") + handle:close() + return text +end + +local MANIFESTS = { + { "tools/rom_manifest.json", 1, 26471 }, + { "tools/rom_manifest_blue.json", 1, 26471 }, + { "tools/rom_manifest_yellow.json", 1, 25829 }, +} + +for _, spec in ipairs(MANIFESTS) do + local path, expectedBank, expectedAddress = spec[1], spec[2], spec[3] + local text = readFile(path) + T.check(text ~= nil, path .. " is readable") + if text then + local bank, address = text:match( + '"ED_Tile"%s*:%s*%[%s*(%d+)%s*,%s*(%d+)%s*%]') + T.eq(bank, tostring(expectedBank), path .. ": ED_Tile bank") + T.eq(address, tostring(expectedAddress), path .. ": ED_Tile address") + end +end + +local gen = readFile("tools/make_rom_manifest.py") +T.check(gen ~= nil, "tools/make_rom_manifest.py is readable") +T.check(gen ~= nil and gen:find('"ED_Tile"', 1, true) ~= nil, + "a regenerated manifest keeps ED_Tile") + +local ED_BYTES = { 0xFF, 0x81, 0xA5, 0x81, 0xBD, 0x81, 0x81, 0xFF } +local BASE_SYMBOLS = { + FontGraphics = { 4, 0 }, + TextBoxGraphics = { 4, 4096 }, + PokedexTileGraphics = { 4, 8192 }, +} + +local function fakeExtractor(withEd) + local symbols = {} + for name, location in pairs(BASE_SYMBOLS) do symbols[name] = location end + if withEd then symbols.ED_Tile = { 1, 26471 } end + local reads, saved, written = {}, {}, {} + local ex = setmetatable({ + symbols = symbols, + manifest = { fontCharmap = {} }, + stage = 0, + rom = { + bytes = function(_, bank, address, length) + table.insert(reads, + { bank = bank, address = address, length = length }) + local out = {} + if symbols.ED_Tile and bank == symbols.ED_Tile[1] + and address == symbols.ED_Tile[2] and length == 8 then + for i = 1, 8 do out[i] = ED_BYTES[i] end + return out + end + for i = 1, length do out[i] = 0 end + return out + end, + }, + }, RomExtractor) + ex.save = function(_, image, relative) saved[relative] = image end + ex.write = function(_, name, value) written[name] = value end + return ex, reads, saved, written +end + +do + local ex, reads, saved, written = fakeExtractor(true) + local ok, data = pcall(ex.extractFont, ex) + T.check(ok, "extractFont runs against the fake ROM: " .. tostring(data)) + local edRead + for _, read in ipairs(reads) do + if read.length == 8 then edRead = read end + end + T.check(edRead ~= nil, "the Fonts stage reads an 8-byte tile") + if edRead then + T.eq(edRead.bank, 1, "from the manifest's ED_Tile bank") + T.eq(edRead.address, 26471, "and its address") + end + local ed = saved["fonts/ed.png"] + T.check(ed ~= nil, "and saves fonts/ed.png") + if ed then + T.eq(ed:getWidth(), 8, "the tile is 8 wide") + T.eq(ed:getHeight(), 8, "and 8 tall") + local wrong = 0 + for y = 0, 7 do + for x = 0, 7 do + local _, _, _, a = ed:getPixel(x, y) + local on = math.floor(ED_BYTES[y + 1] / 2 ^ (7 - x)) % 2 == 1 + if (a == 1) ~= on then wrong = wrong + 1 end + end + end + T.eq(wrong, 0, "1bpp decodes MSB-left, a set bit is opaque black") + end + T.eq(type(data) == "table" and data.imageEd or nil, + "assets/generated/fonts/ed.png", "font.lua publishes the path") + T.eq(written.font and written.font.imageEd or nil, + "assets/generated/fonts/ed.png", "and the written font table carries it") +end + +do + local ex, _, saved, written = fakeExtractor(false) + local ok, data = pcall(ex.extractFont, ex) + T.check(ok, "a manifest without ED_Tile still imports: " .. tostring(data)) + T.eq(saved["fonts/ed.png"], nil, "no ed.png is written") + T.eq(type(data) == "table" and data.imageEd or nil, nil, + "and font.lua publishes no path") + T.check(written.font ~= nil, "the rest of the font stage still runs") +end + +T.finish("ED tile import") diff --git a/tests/engine/gen1_save_identity_roundtrip.lua b/tests/engine/gen1_save_identity_roundtrip.lua new file mode 100644 index 00000000..f41497e8 --- /dev/null +++ b/tests/engine/gen1_save_identity_roundtrip.lua @@ -0,0 +1,171 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local GenSave = require("src.save_convert.GenSave") +local SaveConvert = require("src.save_convert.SaveConvert") +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local GameVersion = require("src.core.GameVersion") +local SaveFileIO = require("src.import.SaveFileIO") +local SyncEngine = require("src.sync.SyncEngine") + +if not loadfile("data/generated/pokemon.lua") then + print("gen1_save_identity_roundtrip skipped (needs data/generated/ for the Gen1 save codec)") + os.exit(0) +end + +local data = assert(SaveConvert.loadData("red")) +local stampMapWindow = loadfile("tests/fixture_data/map_window.lua")() +for mapId in pairs(data.maps) do stampMapWindow(data, mapId) end + +local realFS = love.filesystem + +local function memfs(files) + return { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + createDirectory = function() return true end, + getSaveDirectory = function() return "/fake/save" end, + } +end + +local function fresh() + local files = {} + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set("red") + return files +end + +local bit = require("bit") +local O = GenSave.OFFSETS +local function rawChecksum(bytes, from, to) + local sum = 0 + for i = from, to - 1 do sum = bit.band(sum + bytes:byte(i + 1), 0xFF) end + return bit.band(bit.bnot(sum), 0xFF) +end +local function mainChecksumValid(bytes) + return rawChecksum(bytes, O.checksumStart, O.checksumEnd) + == bytes:byte(O.mainChecksum + 1) +end + +local TAG = O.identityTag +local TAG_LEN = #GenSave.IDENTITY_MAGIC + GenSave.IDENTITY_ID_LENGTH +local ID = "0123456789abcdef0123456789abcdef" + +do + T.eq(TAG, 0x2000, "the tag sits at the start of SRAM bank 1") + T.check(TAG + TAG_LEN <= O.checksumStart, + "and ends before the checksummed sGameData window opens") + + local save = SaveData.newGame({ playerName = "ASH", rivalName = "GARY" }) + save.money = 4321 + save.meta = SaveData.buildMeta(nil, { mods = {} }) + local plain = GenSave.encode(save, data, nil) + save.meta.playthroughId = ID + local tagged = GenSave.encode(save, data, nil) + T.eq(#tagged, GenSave.SAVE_SIZE, "a tagged export is still 32768 bytes") + T.check(tagged:sub(1, TAG) == plain:sub(1, TAG), + "bank 0 and the bytes before the tag match a tagless export") + T.check(tagged:sub(TAG + TAG_LEN + 1) == plain:sub(TAG + TAG_LEN + 1), + "and so does every byte after it") + T.eq(tagged:sub(TAG + 1, TAG + TAG_LEN), GenSave.IDENTITY_MAGIC .. ID, + "the tag is the magic followed by the 32 hex id") + T.eq(plain:sub(TAG + 1, TAG + TAG_LEN), string.rep("\0", TAG_LEN), + "a tagless export leaves the bank 1 padding zero") + T.check(mainChecksumValid(tagged), "the main-data checksum still verifies") + T.eq(tagged:byte(O.mainChecksum + 1), plain:byte(O.mainChecksum + 1), + "and is the same byte as the tagless export's") + T.check(GenSave.mainChecksumValid(tagged), "the codec's own check agrees") + + T.eq(GenSave.readIdentity(tagged), ID, "the tag reads back") + T.eq(GenSave.readIdentity(plain), nil, "a tagless image has no identity") + + local decoded = GenSave.decode(tagged, data) + T.eq(decoded.meta.playthroughId, ID, "decode carries the id into the meta") + T.eq(decoded.player.name, "ASH", "beside the progress it always read") + T.eq(GenSave.decode(plain, data).meta.playthroughId, nil, + "and a tagless image decodes with none") + + T.check(GenSave.encode(decoded, data, nil) == tagged, + "a template round trip is byte-identical, tag included") + decoded.meta.playthroughId = nil + T.check(GenSave.encode(decoded, data, nil) == tagged, + "a save that lost its id leaves the template's bytes verbatim") + + save.meta.playthroughId = "abc" + T.check(GenSave.encode(save, data, nil) == plain, + "an id that is not 32 hex characters is never written") + + local junk = tagged:sub(1, TAG) .. GenSave.IDENTITY_MAGIC .. ("zz"):rep(16) + .. tagged:sub(TAG + TAG_LEN + 1) + T.eq(GenSave.readIdentity(junk), nil, "a non-hex id after the magic is ignored") + local wrongMagic = tagged:sub(1, TAG) .. "G1RX" .. tagged:sub(TAG + 5) + T.eq(GenSave.readIdentity(wrongMagic), nil, "and so is the wrong magic") + T.eq(GenSave.decode(wrongMagic, data).meta.playthroughId, nil, + "leaving the decoded meta identity-free") +end + +do + local files = fresh() + local slot = SaveData.createSlot("red") + SaveData.setActiveSlot("red", slot) + local save = SaveData.newGame({ playerName = "ASH", rivalName = "GARY" }) + save.money = 4321 + save.meta = SaveData.buildMeta({}, save.meta, os.time() - 60) + T.check(SaveData.save(save), "device one plays and saves") + T.eq(SaveData.load("red").meta.playthroughId, nil, + "with no identity minted yet") + + local ok, path = SaveFileIO.exportActiveSlot("red") + T.eq(ok, true, "device one exports: " .. tostring(path)) + local out = files["exports/red/gen1recomp-red-" .. slot .. ".sav"] + T.check(type(out) == "string" and #out == GenSave.SAVE_SIZE, + "the export is a 32 KB image") + local id = GenSave.readIdentity(out) + T.check(type(id) == "string" and #id == 32 and id:match("^%x+$") ~= nil, + "an export mints and tags the slot's identity") + T.eq(SaveData.loadOptions().playthroughIds.red[slot], id, + "the same identity device one syncs under") + local mine = SyncEngine.defaultSaves().list() + T.eq(mine[1] and mine[1].playthroughId, id, "as its sync listing confirms") + T.check(mainChecksumValid(out), "and the image still verifies") + local onDisk = SaveSerializer.decode(files["saves/red/" .. slot .. ".lua"]) + T.eq(onDisk.meta.playthroughId, nil, "exporting rewrote no progress bytes") + + local files2 = fresh() + local imported, slot2 = SaveFileIO.importToSlot(out, "red") + T.eq(imported, true, "device two imports the export: " .. tostring(slot2)) + local loaded = SaveData.load("red") + T.eq(loaded and loaded.meta.playthroughId, id, + "and the imported slot carries the same identity") + T.eq(loaded and loaded.player.name, "ASH", "with the progress") + local theirs = SyncEngine.defaultSaves().list() + T.eq(#theirs, 1, "device two lists the import for sync") + T.eq(theirs[1] and theirs[1].playthroughId, id, + "under the same key, so both devices meet on the server") + T.eq(SaveData.loadOptions().playthroughIds.red[slot2], id, + "and the slot is mapped so a download replaces it in place") + + T.eq(SaveFileIO.exportActiveSlot("red"), true, "device two exports again") + T.eq(GenSave.readIdentity(files2["exports/red/gen1recomp-red-" .. slot2 .. ".sav"]), + id, "keeping the identity through the second hop") +end + +love.filesystem = realFS +SaveData.resetSlotState() +GameVersion.set("red") + +T.finish("gen1_save_identity_roundtrip") diff --git a/tests/engine/gen2_bump_walk_in_place_bug1978.lua b/tests/engine/gen2_bump_walk_in_place_bug1978.lua index 5986ce8c..660566ed 100644 --- a/tests/engine/gen2_bump_walk_in_place_bug1978.lua +++ b/tests/engine/gen2_bump_walk_in_place_bug1978.lua @@ -45,22 +45,47 @@ eq(p.cellX, 5, "and the player never left the cell") p = newPlayer() p.animClock = 0 local phases, flips = {}, {} -for i = 0, 31 do +for i = 0, 63 do p:tryMove("right", MAP, nil) phases[i] = p:walkPhase() flips[i] = p:drawFlip() p:update() end +-- map_object_action.asm:98-119 eq(phases[0], 0, "the first pose is the standing one") eq(phases[6], 0, "still standing seven frames in") -eq(phases[8], 1, "the walking pose lands on the eighth frame") -eq(phases[14], 1, "and holds for eight frames") -eq(phases[16], 0, "back to standing") -eq(phases[24], 1, "the second walking pose is the mirrored one") -check(flips[24] ~= flips[8], "which draws with the other leg forward") +eq(phases[14], 0, "and still standing at fifteen frames") +eq(phases[16], 1, "the walking pose lands on the sixteenth frame") +eq(phases[30], 1, "and holds for sixteen frames") +eq(phases[32], 0, "back to standing") +eq(phases[48], 1, "the second walking pose is the mirrored one") +check(flips[48] ~= flips[16], "which draws with the other leg forward") + +local function firstRunOfWalk(seq) + local start + for i = 0, #seq do + if seq[i] == 1 then + if not start then start = i end + elseif start then + return i - start + end + end +end + +local walkPhases = {} +local w = newPlayer() +for i = 0, 63 do + if not w.moving then w:tryMove("right", OPEN, nil) end + walkPhases[i] = w:walkPhase() + w:update() +end +local bumpRun = firstRunOfWalk(phases) +local walkRun = firstRunOfWalk(walkPhases) +eq(walkRun, 8, "a walked step holds each pose eight frames") +eq(bumpRun, walkRun * 2, "a bump holds each pose twice as long as a step") p = newPlayer() -p.animClock = 8 +p.animClock = 16 p:tryMove("right", MAP, nil) -- engine/overworld/movement.asm:315 eq(p.bumpFrames, 1, "the bump lasts one OBJECT_STEP_DURATION frame") diff --git a/tests/engine/gen2_fly_bird_offscreen_bug2015.lua b/tests/engine/gen2_fly_bird_offscreen_bug2015.lua new file mode 100644 index 00000000..7f0bedca --- /dev/null +++ b/tests/engine/gen2_fly_bird_offscreen_bug2015.lua @@ -0,0 +1,108 @@ +-- engine/events/field_moves.asm:300, engine/sprite_anims/functions.asm:642 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +love = require("tests.love_stub") + +local World = require("src.world.gen2.World") + +local function flyWorld() + local world = World.new({ data = {}, save = { player = {} } }) + world.player = { px = 64, py = 64, facing = "down" } + world.flyIconFor = function() return { draw = function() end } end + world.playSfxNamed = function() end + return world +end + +-- engine/events/field_moves.asm:304 (depixel 10, 10, 4, 0) +do + local world = flyWorld() + check(world:startFlyAnim("from", { species = "PIDGEY" }), "FlyFromAnim runs") + local fa = world.flyAnim + local bx, by = World.birdScreenPos(fa) + eq(bx, 64, "the bird starts on the OAM column the cart uses") + eq(by, 60, "and four pixels above the player, not on his own row") + check(not World.offGbScreen(bx, by, 16, 16), "so it is on the LCD") +end + +-- engine/sprite_anims/functions.asm:642 (SpriteAnimFunc_FlyFrom) +do + local world = flyWorld() + check(world:startFlyAnim("from", { species = "PIDGEY" }), "FlyFromAnim runs") + local fa = world.flyAnim + local steps, culled = 0, nil + while world.flyAnim and steps < 400 do + world:stepFlyAnim() + steps = steps + 1 + local x, y = World.birdScreenPos(fa) + if steps == 64 then + eq(fa.hover, 0, "the hover burns 64 frames") + eq(fa.y, 0, "and moves nothing") + end + if World.offGbScreen(x, y, 16, 16) and not culled then culled = steps end + end + eq(steps, 129, "the 128-frame counter runs out") + eq(fa.y, -84, "YCOORD walked 84 pixels up") + eq(select(2, World.birdScreenPos(fa)), -24, + "which puts the whole icon above the LCD") + check(culled and culled <= 106, + "and it is gone by the end of the rise, not parked for the tail") + check(steps - culled >= 22, "for the last 22 frames of the counter") +end + +-- engine/sprite_anims/core.asm:229 (the OAM byte wraps; there is no margin) +do + local world = flyWorld() + check(world:startFlyAnim("from", { species = "PIDGEY" }), "FlyFromAnim runs") + local fa = world.flyAnim + fa.y = -84 + local bx, by = World.birdScreenPos(fa) + for _, viewH in ipairs({ 144, 156, 180, 216 }) do + world.viewW, world.viewH = 160, viewH + local _, soy = world:gbScreenOrigin() + check(World.offGbScreen(bx, by, 16, 16), + "the cull drops the parked bird at viewH " .. viewH) + if viewH >= 180 then + check(soy + by + 16 > 0, + "which is the only thing keeping it out of the letterbox margin") + end + end +end + +-- engine/events/field_moves.asm:338 (depixel 31, 10, 4, 0) +do + local world = flyWorld() + check(world:startFlyAnim("to", { species = "PIDGEY" }), "FlyToAnim runs") + local fa = world.flyAnim + eq(fa.y, -88, "the landing starts 88 pixels up, at YCOORD -4") + local sx, sy = World.birdScreenPos(fa) + eq(sy, -28, "fully off the top of the LCD") + check(World.offGbScreen(sx, sy, 16, 16), "so nothing is drawn on frame one") + local steps, moved = 0, 0 + while world.flyAnim and steps < 400 do + local before = fa.y + world:stepFlyAnim() + steps = steps + 1 + if fa.y ~= before then moved = moved + 1 end + end + eq(steps, 65, "the 64-frame counter runs out") + eq(moved, 44, "after 44 frames of descent") + eq(fa.y, 0, "landing on the player's row") + eq(fa.amp, 0, "with the wobble fully decayed") +end + +-- engine/sprite_anims/functions.asm:1389 (SpriteAnimFunc_FlyLeaf) +do + local dying = { x = 182, y = 0x40, xoff = 0 } + local lx, ly = World.leafScreenPos(dying) + check(World.offGbScreen(lx, ly, 8, 8), + "a leaf past the right edge is not drawn in the margin") + local mid = { x = 90, y = 0x40, xoff = 0 } + local mx, my = World.leafScreenPos(mid) + check(not World.offGbScreen(mx, my, 8, 8), "a mid-sweep leaf still draws") +end + +T.finish("gen2 fly bird leaves the LCD bug2015") diff --git a/tests/engine/gen2_logic_speed_bug1994.lua b/tests/engine/gen2_logic_speed_bug1994.lua new file mode 100644 index 00000000..d2f0305e --- /dev/null +++ b/tests/engine/gen2_logic_speed_bug1994.lua @@ -0,0 +1,39 @@ + +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 Game2 = require("src.core.Game2") + +local function game(fields) + return setmetatable(fields or {}, { __index = Game2 }) +end + +eq(game():logicSpeed(), 1, "a bare game with no options at all is 1X") +eq(game({ options = {} }):logicSpeed(), 1, "options with no speed is 1X") +eq(game({ options = { speed = 4 } }):logicSpeed(), 4, + "the saved GAME SPEED option is the ordinary source") +eq(game({ options = { speed = 4 }, speedOverride = 10 }):logicSpeed(), 10, + "speedOverride (--speed/POKEPORT_SPEED) wins over the saved option") +eq(game({ options = { speed = "8" } }):logicSpeed(), 8, + "a string from the options file is coerced") +eq(game({ options = { speed = 0 } }):logicSpeed(), 1, "0X is floored to 1X") +eq(game({ options = { speed = -5 } }):logicSpeed(), 1, + "and so is a negative") +eq(game({ options = { speed = "fast" } }):logicSpeed(), 1, + "an unparseable option falls back rather than erroring") + +local Gen2Compat = require("src.mods.Gen2Compat") +local live = game({ options = { speed = 4 } }) +Gen2Compat.bind(function() return live end) +local Facade = Gen2Compat.resolve("src.core.Game", "bug1994") +eq(Facade.logicSpeed(), 4, "the Gen 1 facade reports the engine's own speed") +live.speedOverride = 10 +eq(Facade.logicSpeed(), 10, "and follows it when it changes") +Gen2Compat.bind(function() return nil end) +eq(Facade.logicSpeed(), 1, "with no live game the facade still answers 1X") +check(Facade.logicSpeed() >= 1, "and never below 1") + +T.finish("gen2 logic speed bug 1994") diff --git a/tests/engine/gen2_trainer_ball_lifetime_bug2021.lua b/tests/engine/gen2_trainer_ball_lifetime_bug2021.lua new file mode 100644 index 00000000..747200fb --- /dev/null +++ b/tests/engine/gen2_trainer_ball_lifetime_bug2021.lua @@ -0,0 +1,120 @@ +-- ../pokecrystal/engine/battle/battle_transition.asm:584 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local Transition = require("src.ui.gen2.BattleTransition") + +local function make(opts) + opts.environment = opts.environment or "TOWN" + local state = Transition.new(nil, opts) + state.drawCells = function(self, _w, _h, cells, byte, ly) + self.drawn = { cells = cells, byte = byte, ly = ly } + end + return state +end + +local function frame(state) + state.drawn = nil + state:drawWidescreen(160, 144) + return state.drawn +end + +local function pastBall(state) + state:update() + state:update() +end + +do + local cells = Transition.pokeballCells() + T.eq(#cells, 112, ".PokeBallTransition sets 112 bits") + local minX, minY, maxY = 20, 18, -1 + for _, cell in ipairs(cells) do + minX = math.min(minX, cell[1]) + minY = math.min(minY, cell[2]) + maxY = math.max(maxY, cell[2]) + end + T.eq(minX, Transition.POKEBALL_X, "the stamp starts at hlcoord 2, 1 (x)") + T.eq(minY, Transition.POKEBALL_Y, "the stamp starts at hlcoord 2, 1 (y)") + T.eq(maxY - minY + 1, 16, "and it is sixteen rows tall") +end + +do + T.eq(Transition.ballShade(nil), 4, "colour 3 shows shade 3 at the identity") + T.eq(Transition.ballShade(Transition.flashByte({ 3, 2, 1, 0 })), 4, + "...which is what the identity row packs to") + T.eq(Transition.ballShade(Transition.flashByte({ 0, 0, 0, 0 })), 1, + "the white-out row takes the ball with it") + T.eq(Transition.ballShade(Transition.flashByte({ 1, 0, 0, 0 })), 2, + "and a mid row moves it one step along the ramp") +end + +do + local state = make({ trainer = true }) + T.eq(state.phase, "pokeball", "a trainer battle stamps the ball first") + T.check(frame(state) ~= nil, "and draws it before the flash") + + pastBall(state) + T.eq(state.phase, "flash", "then the flash starts") + local seen = 0 + for _ = 1, Transition.FLASH_FRAMES do + if frame(state) then seen = seen + 1 end + state:update() + end + T.eq(seen, Transition.FLASH_FRAMES, + "the ball is still in the tilemap for every flash frame") + T.eq(state.phase, "outro", "the flash hands off to the outro") + + local drawn, missed, guard = 0, 0, 0 + while state.phase == "outro" and guard < 400 do + if frame(state) then drawn = drawn + 1 else missed = missed + 1 end + state:update() + guard = guard + 1 + end + T.check(drawn > 0, "and the outro paints over a ball that is still there") + T.eq(missed, 0, "no outro frame drops it early") + T.eq(state.phase, "black", "the outro ends on the black hold") + T.eq(frame(state), nil, "which is solid black, ball included") +end + +do + local state = make({ trainer = false }) + T.eq(state.phase, "flash", "a wild battle has no ball to stamp") + local drew, guard = false, 0 + while not state.finished and guard < 400 do + if frame(state) then drew = true end + state:update() + guard = guard + 1 + end + T.eq(drew, false, "and never draws one") +end + +do + local state = make({ trainer = true, dark = true }) + T.eq(state.phase, "pokeball", "a DARKNESS trainer battle still stamps it") + pastBall(state) + T.eq(state.phase, "outro", "...and skips straight to the outro") + T.check(frame(state) ~= nil, "...with the ball still on screen") +end + +do + local state = make({ + trainer = true, environment = "CAVE", playerLevel = 10, enemyLevel = 5, + }) + T.eq(state.style, "sine", "a cave trainer battle wobbles") + pastBall(state) + local guard = 0 + while state.phase == "flash" and guard < 400 do + state:update() + guard = guard + 1 + end + state:update() + local drawn = frame(state) + T.check(drawn ~= nil, "the ball rides the sine outro") + T.check(drawn and drawn.ly ~= nil, + "...and takes the frame's rSCX overrides with it") +end + +T.finish("gen2 trainer ball lifetime bug 2021") diff --git a/tests/engine/launcher_cart_ring_bug2003.lua b/tests/engine/launcher_cart_ring_bug2003.lua new file mode 100644 index 00000000..7c2dfc89 --- /dev/null +++ b/tests/engine/launcher_cart_ring_bug2003.lua @@ -0,0 +1,191 @@ + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local Kit = require("src.ui.kit.Kit") +local RomImporter = require("src.import.RomImporter") +local View = require("src.import.LauncherView") + +local function freshImporter() + Kit.focusId = nil + Kit._navQueue = nil + return setmetatable({ + isNX = false, + _padCursor = { x = 100, y = 100 }, + _padCursorActive = false, + _padAxis = { leftx = 0, lefty = 0, righty = 0 }, + _padDir = {}, + _rawHatDirs = {}, + _padInited = true, + _flex = false, + tab = "red", + }, RomImporter) +end + + +do + local imp = freshImporter() + Kit._ringShown = true + Kit.focusId = "play-red" + imp:touchpressed(1, 10, 10, 0, 0, 1) + check(Kit._ringShown == false, "a touch clears the focus ring") + eq(Kit.focusId, "play-red", "but the ring keeps its parked id for the pad") + + Kit.navigate("down") + Kit._navQueue = nil + check(Kit._ringShown == true, "a d-pad step brings the ring back") + + imp:mousepressed(10, 10, 1) + check(Kit._ringShown == false, "a left click clears the focus ring") + + Kit.navigate("up") + Kit._navQueue = nil + imp:mousepressed(10, 10, 2) + check(Kit._ringShown == false, + "a non-primary click is still a pointer, so it clears the ring too") + + Kit.setFocus("play-red") + check(Kit._ringShown == true, "an explicit setFocus re-arms the ring") +end + +do + Kit.scale = 1 + Kit.blockClicks = false + Kit.focusId = nil + Kit.beginFrame(0, 0, false, 0) + Kit.focusable("tab-red", 10, 60, 40, 24) + Kit.focusable("play-red", 20, 200, 120, 200) + Kit.focusable("manage-red", 150, 200, 34, 34) + Kit.endFrame() + eq(Kit.focusId, "play-red", + "with no rom- button on the panel the ring parks on the cart") +end + + +local function hullContains(flat, px, py) + local n = #flat / 2 + local pos, neg = false, false + for i = 1, n do + local ax, ay = flat[i * 2 - 1], flat[i * 2] + local j = i % n + 1 + local bx, by = flat[j * 2 - 1], flat[j * 2] + local cross = (bx - ax) * (py - ay) - (by - ay) * (px - ax) + if cross > 1e-9 then pos = true elseif cross < -1e-9 then neg = true end + end + return not (pos and neg) +end + +do + local front = { { 0, 0 }, { 100, 0 }, { 100, 200 }, { 0, 200 } } + local back = { { 14, -9 }, { 114, -9 }, { 114, 191 }, { 14, 191 } } + local flat = View.cartHull({ front, back }) + check(type(flat) == "table" and #flat >= 8, + "the silhouette of an extruded box is a polygon") + local worst = nil + for _, q in ipairs({ front, back }) do + for _, p in ipairs(q) do + if not hullContains(flat, p[1], p[2]) then worst = p end + end + end + eq(worst, nil, "every projected corner sits inside the silhouette") + check(not hullContains(flat, 130, 100), + "and the silhouette does not swallow the whole panel") +end + + +do + local realTime = Kit.time + local TAU = 6.2831853 + Kit.time = 987654.321 + local tw, band, wave = View.cartFinishPhases(4096 * math.pi) + check(tw >= 0 and tw < TAU + 1e-9, + "a day of uptime still reaches the shader as one turn of twinkle phase") + check(band >= 0 and band < 1 + 1e-9, + "and as one cycle of hue band phase") + check(wave >= 0 and wave < TAU + 1e-9, + "and as one turn of interference phase") + + Kit.time = 9876.54321 + local spin = 48 * math.pi + tw, band, wave = View.cartFinishPhases(spin) + local rawTw = Kit.time * 2.6 + spin * 3.0 + check(math.abs(math.sin(tw) - math.sin(rawTw)) < 1e-6, + "the twinkle wrap is a whole period, so the flecks never jump") + local rawBand = Kit.time * 0.06 + spin * 0.55 + check(math.abs(math.cos(TAU * band) - math.cos(TAU * rawBand)) < 1e-3, + "the hue wrap is a whole cycle, so the sweep never jumps") + local rawWave = Kit.time * 0.9 + check(math.abs(math.sin(wave) - math.sin(rawWave)) < 1e-6, + "the interference wrap is a whole period") + Kit.time = realTime +end + + +do + local src = View.CART_HOVER_SHADER + check(not src:find("sin(dot(", 1, true), + "no screen-space dot product is fed to sin(): GLSL ES leaves that undefined") + check(src:find("mod(floor(cell), 512.0)", 1, true) ~= nil, + "the fleck cell id is bounded before it reaches the hash") + check(src:find("precision highp float;", 1, true) ~= nil, + "the pixel stage claims highp where the driver offers it") + check(src:find("GL_FRAGMENT_PRECISION_HIGH", 1, true) ~= nil, + "and only where it offers it") + check(not src:find("smoothstep(0.13, 0.0,", 1, true), + "smoothstep is never called with edge0 > edge1") + check(src:find("pow(max(tw, 0.0), 16.0)", 1, true) ~= nil, + "pow() never sees a negative base") + check(not src:find("finish_time", 1, true), + "the raw wall clock never reaches the shader") + check(src:find("h = fract(h * (h + 47.13));", 1, true) ~= nil + and src:find("h = fract(h * (h + 19.77));", 1, true) ~= nil, + "the fleck hash is the one mirrored below") +end + + +local function fract(x) return x - math.floor(x) end + +local function mediump(x) + if x == 0 then return 0 end + local m, e = math.frexp(x) + return math.ldexp(math.floor(m * 1024 + 0.5) / 1024, e) +end + +local function sparkHash(x, y, q) + local h = q(fract(q(q(x * 0.1031) + q(y * 0.3711)) + 0.137)) + h = q(fract(q(h * q(h + 47.13)))) + h = q(fract(q(h * q(h + 19.77)))) + return h +end + +local function oldHash(x, y, q) + return q(fract(q(math.sin(q(x * 41.7321 + y * 289.113)) * 43758.5453))) +end + +local function spread(fn, q) + local seen = {} + for iy = 0, 129 do + for ix = 0, 57 do + seen[math.floor(fn(ix, iy, q) * 64)] = true + end + end + local n = 0 + for _ in pairs(seen) do n = n + 1 end + return n +end + +local function exact(x) return x end + +do + check(spread(sparkHash, exact) >= 60, + "the fleck hash fills its range at full precision") + check(spread(sparkHash, mediump) > 32, + "and still fills it at mediump, so the flecks stay scattered") + check(spread(oldHash, mediump) < 10, + "the sin() hash it replaced collapses at mediump into the reported grid") +end + +T.finish("launcher_cart_ring_bug2003") diff --git a/tests/engine/launcher_sync_modal.lua b/tests/engine/launcher_sync_modal.lua index df6818b5..c8cee71c 100644 --- a/tests/engine/launcher_sync_modal.lua +++ b/tests/engine/launcher_sync_modal.lua @@ -308,6 +308,48 @@ raised:_pumpSync(0.016) eq(raised._syncModal, nil, "and a prompt the player dismissed does not reopen every frame") +do + local SaveData = require("src.core.SaveData") + local realList, realOpts = SaveData.listSlots, SaveData.loadOptions + local dl = fakeEngine({ isLinked = true }) + local got = launcher(dl) + got.slots.red = { { id = "slot1", exists = true } } + got.activeSlot.red = "slot1" + SaveData.listSlots = function(version) + if version ~= "red" then return {} end + return { { id = "slot1", exists = true }, { id = "slot2", exists = true } } + end + SaveData.loadOptions = function() + return { saveSlots = { red = { list = { "slot1", "slot2" }, active = "slot1" } } } + end + dl.changed = true + dl.lastDownloads = { { version = "red", slot = "slot2", created = true, + device = "Android" } } + got:_pumpSync(0.016) + eq(dl.changed, nil, "the launcher consumes the change flag") + eq(dl.lastDownloads, nil, "and the download list") + eq(#got.slots.red, 2, "the slot list is re-read so the downloaded slot shows") + eq(got.activeSlot.red, "slot1", "without hijacking CONTINUE") + check(got.saveNotice.red and got.saveNotice.red.ok == true, + "a downloaded save is announced on the game's save card") + check(got.saveNotice.red and got.saveNotice.red.text:find( + "Downloaded a save from Android into slot2", 1, true) ~= nil, + "naming the device and the slot") + eq(got.slotScroll.red, math.huge, "and the list scrolls to the new row") + + got.slotScroll.red = nil + dl.changed = true + dl.lastDownloads = { { version = "red", slot = "slot1", created = false } } + got:_pumpSync(0.016) + eq(got.slotScroll.red, nil, "a replace in place leaves the scroll alone") + check(got.saveNotice.red.text:find("Downloaded a save into slot1", 1, true) ~= nil, + "and says which slot changed") + got:_pumpSync(0.016) + eq(got.saveNotice.red.text:find("slot1", 1, true) ~= nil, true, + "a quiet frame does not disturb the notice") + SaveData.listSlots, SaveData.loadOptions = realList, realOpts +end + local view = read("src/import/LauncherView.lua") local impSrc = read("src/import/RomImporter.lua") @@ -333,6 +375,8 @@ check(impSrc:find("_pumpSync(dt)", 1, true) ~= nil, local pump = impSrc:match("function RomImporter:_pumpSync%(dt%)(.-)\nend\n") check(pump and pump:find("self.launcher", 1, true) ~= nil, "only the interactive launcher boots an engine of its own") +check(pump and pump:find("eng.changed", 1, true) ~= nil, + "and the pump relists slots when the engine wrote one") check(impSrc:find("_syncTypeInto", 1, true) ~= nil, "text input is routed through the code filter") diff --git a/tests/engine/launcher_update_all.lua b/tests/engine/launcher_update_all.lua index f34488b4..8f681567 100644 --- a/tests/engine/launcher_update_all.lua +++ b/tests/engine/launcher_update_all.lua @@ -7,6 +7,7 @@ local check, eq = T.check, T.eq local ModUpdate = require("src.mods.ModUpdate") local LauncherMods = require("src.mods.LauncherMods") local Platform = require("src.core.Platform") +local CartStore = require("src.carts.CartStore") local RomImporter = require("src.import.RomImporter") local RELEASES = { @@ -14,19 +15,39 @@ local RELEASES = { { version = "1.0.0", zip = { url = "https://example.invalid/b.zip" } }, } +local FEED_CART = { + kind = "cart", id = "wild_green", title = "Wild Green", author = "Ren", + version = "0.29.1", base = "red", seal = "sealed", + repo = "https://github.com/ren/wild-green", github = "ren/wild-green", + update_check = "ok", + latest = { version = "0.29.1", tag = "v0.29.1", + zip = { name = "wild_green-0.29.1.g1rcart", + url = "https://example.invalid/wild_green.g1rcart" } }, +} + local oldBeginFetch = ModUpdate.beginFetchReleases local oldBeginZip = ModUpdate.beginDownloadZip local oldPumpZip = ModUpdate.pumpDownloadZip local oldInstall = LauncherMods.installDownloadedZip local oldDeps = LauncherMods.checkDependencies local oldRemote = Platform.canFetchRemote +local oldCartList = CartStore.list +local oldCartIndex = CartStore.index +local oldCartListFor = CartStore.listFor +local oldCartInstall = CartStore.install local failId = nil local installs = {} +local cartRows = {} +local cartInstalls = {} +local cartInstallErr = nil ModUpdate.beginFetchReleases = function() return {} end ModUpdate.beginDownloadZip = function() return {} end -ModUpdate.pumpDownloadZip = function() return true, "install.zip" end +ModUpdate.pumpDownloadZip = function() + love.filesystem.write("install.zip", "CART BYTES") + return true, "install.zip" +end LauncherMods.installDownloadedZip = function(id, _, version) installs[#installs + 1] = id if id == failId then return nil, "the archive had no manifest" end @@ -34,13 +55,32 @@ LauncherMods.installDownloadedZip = function(id, _, version) end LauncherMods.checkDependencies = function() return { hasIssues = false } end Platform.canFetchRemote = function() return true end +CartStore.list = function() return cartRows end +CartStore.listFor = function() return {} end +CartStore.index = function() + local out = {} + for _, row in ipairs(cartRows) do + out[#out + 1] = { id = row.id, title = row.title, version = row.version } + end + return out +end +CartStore.install = function(bytes) + cartInstalls[#cartInstalls + 1] = tostring(bytes) + if cartInstallErr then return nil, cartInstallErr end + return { id = "wild_green", title = "Wild Green", version = "0.29.1", + base = "red" } +end +local function installedCart(version, repo) + return { { id = "wild_green", title = "Wild Green", version = version, + cart = { id = "wild_green", version = version, repo = repo } } } +end local function info(status, best) return { status = status, latest = best and best.version or nil, best = best, releases = RELEASES } end -local function launcher(secondStatus) +local function launcher(secondStatus, feedCarts) local ri = setmetatable({ mods = { { id = "one", name = "One", version = "1.0.0", github = "a/one" }, @@ -53,6 +93,9 @@ local function launcher(secondStatus) secondStatus == "available" and RELEASES[1] or nil), }, activeCart = {}, + carts = {}, + findLoaded = true, + findIndex = { mods = {}, carts = feedCarts or {} }, }, RomImporter) ri._refreshMods = function() end return ri @@ -63,6 +106,7 @@ local function run(ri, frames) if not ri._updateAll then break end ri:_pumpUpdateAll() ri:_pumpModInstall() + ri:_pumpCartInstall() end end @@ -78,6 +122,98 @@ do end +do + cartRows = installedCart("0.2.0", "ren/wild-green") + local ri = launcher(nil, { FEED_CART }) + local rows = ri:_updateAllRows() + eq(#rows, 2, "an installed cart the index lists ahead of it is swept too") + eq(rows[1].kind, "mod", "mods keep their place at the front of the queue") + eq(rows[2].kind, "cart", "and the cart rows follow") + eq(rows[2].id, "wild_green", "named by the installed cart") + eq(rows[2].from, "0.2.0", "from what is on disk") + eq(rows[2].to, "0.29.1", "to what the feed lists") + + cartRows = installedCart("0.2.0", "someoneelse/wild-green") + eq(#launcher(nil, { FEED_CART }):_updateAllCartRows(), 0, + "a cart of the same id from another repo is never overwritten") + + cartRows = { { id = "wild_green", title = "Wild Green", version = "0.2.0" } } + eq(#launcher(nil, { FEED_CART }):_updateAllCartRows(), 0, + "nor one whose manifest names no repo at all") + + cartRows = installedCart("0.29.1", "ren/wild-green") + eq(#launcher(nil, { FEED_CART }):_updateAllCartRows(), 0, + "a cart already at the listed version is not queued") + + cartRows = installedCart("1.0.0", "ren/wild-green") + eq(#launcher(nil, { FEED_CART }):_updateAllCartRows(), 0, + "and one ahead of it is never downgraded") + cartRows = {} +end + + +do + installs, cartInstalls = {}, {} + cartRows = installedCart("0.2.0", "ren/wild-green") + local ri = launcher(nil, { FEED_CART }) + check(ri:pressUpdateAllMods(), "the press starts the queue") + + ri._modInfoFetch = nil + ri._findFetch = {} + ri:_pumpUpdateAll() + eq(ri._updateAll.stage, "check", "the queue waits for the index feed too") + eq(#cartInstalls, 0, "and installs nothing meanwhile") + + ri._findFetch = nil + run(ri) + eq(ri._updateAll, nil, "the queue finishes") + eq(#installs, 1, "the mod went through the mod installer") + eq(installs[1], "one", "by id") + eq(#cartInstalls, 1, "and the cart through CartStore.install, not that one") + eq(ri.findNotice, nil, "with no FIND notice raised mid-sweep") + eq(ri._modConfirm, nil, "and no pin modal to click through") + check(ri.modNotice and ri.modNotice.ok, "the run is reported") + eq(ri.modNotice.text, "Updated 2 items.", "counting both kinds") +end + + +do + installs, cartInstalls = {}, {} + cartInstallErr = "the cart file was truncated" + cartRows = installedCart("0.2.0", "ren/wild-green") + local ri = launcher(nil, { FEED_CART }) + ri:pressUpdateAllMods() + ri._modInfoFetch = nil + run(ri) + cartInstallErr = nil + eq(#installs, 1, "a failed cart does not stop the mod half") + eq(ri.modNotice.text, "Updated 1 of 2. 1 failed:", "and is counted") + eq(#ri.modNotice.failures, 1, "with one line") + check(ri.modNotice.failures[1]:find("Wild Green", 1, true) ~= nil, + "naming the cart") + cartRows = {} +end + + +do + cartInstalls = {} + cartRows = installedCart("0.2.0", "ren/wild-green") + local ri = launcher(nil, { FEED_CART }) + local seen = {} + ri._updateAll = { stage = "installing" } + ri:_beginCartInstall(FEED_CART, { quiet = true, fromUpdateAll = true, + done = function(ok) seen[#seen + 1] = ok end }) + check(ri._cartInstall ~= nil, "the queue's own cart install is let through") + ri:_pumpCartInstall() + eq(#cartInstalls, 1, "the bytes reach CartStore.install") + eq(ri.findNotice, nil, "a quiet cart install writes no FIND notice") + eq(ri._modConfirm, nil, "and raises no pin modal mid-queue") + eq(#seen, 1, "the queue is told the row is done") + eq(seen[1], true, "and that it worked") + cartRows = {} +end + + do installs = {} local ri = launcher("available") @@ -106,7 +242,7 @@ do eq(installs[1], "one", "in feed order") eq(installs[2], "two", "one after the other") check(ri.modNotice and ri.modNotice.ok, "and reports the run") - eq(ri.modNotice.text, "Updated 2 mods.", "as one summary") + eq(ri.modNotice.text, "Updated 2 items.", "as one summary") eq(ri._busy, nil, "with the overlay down") end @@ -134,8 +270,8 @@ do ri:pressUpdateAllMods() ri._modInfoFetch = nil run(ri) - eq(ri.modNotice.text, "All mods are up to date.", - "a run with no outdated mod says so rather than going quiet") + eq(ri.modNotice.text, "Everything is up to date.", + "a run with no outdated mod or cart says so rather than going quiet") end do @@ -268,8 +404,20 @@ do imp._modHeaderActionsPopup = true local popup = draw(imp) imp._modHeaderActionsPopup = nil - check(popup:find("Update all mods", 1, true) ~= nil, + check(popup:find("Update all", 1, true) ~= nil, "and More... is where a phone reaches Update all") + + window(1400, 900) + cartRows = installedCart("0.2.0", "ren/wild-green") + imp.mods = {} + imp._modUpdateCountCache = nil + imp._cartUpdateCache = nil + imp._findCartMap = nil + imp.findIndex = { mods = {}, carts = { FEED_CART } } + local cartsOnly = draw(imp) + check(cartsOnly:find("Update all", 1, true) ~= nil, + "a launcher with carts but no mods still reaches the sweep") + cartRows = {} end ModUpdate.beginFetchReleases = oldBeginFetch @@ -278,5 +426,9 @@ ModUpdate.pumpDownloadZip = oldPumpZip LauncherMods.installDownloadedZip = oldInstall LauncherMods.checkDependencies = oldDeps Platform.canFetchRemote = oldRemote +CartStore.list = oldCartList +CartStore.index = oldCartIndex +CartStore.listFor = oldCartListFor +CartStore.install = oldCartInstall T.finish("launcher update all") diff --git a/tests/engine/naming_screen_layout_1947.lua b/tests/engine/naming_screen_layout_1947.lua index cb784c42..a447cdd5 100644 --- a/tests/engine/naming_screen_layout_1947.lua +++ b/tests/engine/naming_screen_layout_1947.lua @@ -147,6 +147,51 @@ ns.anim, icons = 16, {} ns:draw() check(icons[1] and icons[1].alt == true, "frame 2 for the next 16") +-- naming_screen.asm:326 +local Assets = require("src.render.Assets") +local realImage, realDraw = Assets.image, love.graphics.draw +local edDraws = {} +love.graphics.draw = function(image, x, y) + local r, g, b, a = love.graphics.getColor() + edDraws[#edDraws + 1] = { + image = image, x = x, y = y, color = { r, g, b, a }, + } +end + +local edImage = { getDimensions = function() return 8, 8 end } +Assets.image = function(path) + if path == "assets/generated/fonts/ed.png" then return edImage end + error("no such asset: " .. tostring(path)) +end +NamingScreen.invalidate() +ns = render({ title = "YOUR NAME?", maxLen = 7 }) +edDraws = {} +ns:draw() + +eq(#edDraws, 1, "the confirm cell draws one ED tile") +check(edDraws[1] and edDraws[1].image == edImage, "the tile is fonts/ed.png") +eq(edDraws[1] and edDraws[1].x, 144, "ED at hlcoord 18 -> x 144") +eq(edDraws[1] and edDraws[1].y, 104, "ED on the last letter row -> y 104") +local ec = edDraws[1] and edDraws[1].color +check(ec and ec[1] == 0 and ec[2] == 0 and ec[3] == 0, + "drawn in the grid's black, like every other cell") +check(drawn("ED") == nil, "and never as the two ASCII glyphs E and D") + +Assets.image = function(path) error("no such asset: " .. tostring(path)) end +NamingScreen.invalidate() +ns = render({ title = "YOUR NAME?", maxLen = 7 }) +edDraws = {} +ns:draw() + +eq(#edDraws, 0, "a cache without fonts/ed.png draws no tile") +local edText = drawn("ED") +check(edText ~= nil, "and falls back to the text cell instead of a blank") +eq(edText and edText.x, 144, "at the same column") +eq(edText and edText.y, 104, "and the same row") + +love.graphics.draw, Assets.image = realDraw, realImage +NamingScreen.invalidate() + -- naming_screen.asm:487 local OakSpeech = require("src.ui.OakSpeech") local rival diff --git a/tests/engine/oaks_lab_rival_paths_bug1989_bug1987.lua b/tests/engine/oaks_lab_rival_paths_bug1989_bug1987.lua new file mode 100644 index 00000000..06ce46db --- /dev/null +++ b/tests/engine/oaks_lab_rival_paths_bug1989_bug1987.lua @@ -0,0 +1,128 @@ +-- scripts/OaksLab.asm:448-472/488-508, engine/overworld/pathfinding.asm:36-70; +-- pokeyellow scripts/OaksLab.asm:411-436/452-469 (one extra down step). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +local function fakeOw(rival) + return { + npcByIndex = function(_, i) return i == 1 and rival or nil end, + map = { + inBounds = function() return true end, + isWalkableCell = function() return true end, + }, + runner = { run = function() return true end }, + } +end + +local function captureRun(ow) + local captured + ow.runner.run = function(_, rows) captured = rows return true end + return function() return captured end +end + +local function baseGame() + return { + save = { + flags = { + EVENT_GOT_STARTER = true, + EVENT_BATTLED_RIVAL_IN_OAKS_LAB = false, + EVENT_CHOSE_BULBASAUR = true, + }, + }, + } +end + +local function runOnStep(path, rival, x, y) + local M = assert(loadfile(path))() + local ow = fakeOw(rival) + local getRows = captureRun(ow) + local ok = M.onStep(baseGame(), ow, x, y) + T.check(ok == true, path .. ": onStep claims the rival-challenge step") + local rows = getRows() + T.check(rows ~= nil, path .. ": the challenge rows reached the runner") + return rows or {} +end + +local function indexOf(rows, pred, from) + for i = from or 1, #rows do + if pred(rows[i]) then return i end + end + return nil +end + +local function isMove(row, dir) + return row[1] == "move_npc" and row[3] == dir +end + +local function checkApproach(rows, expectX, expectXCount) + T.check(indexOf(rows, function(r) return r[1] == "move_npc_to" end) == nil, + "no move_npc_to row survives on the approach (#1989)") + local i = indexOf(rows, function(r) return r[1] == "move_npc" end) + T.check(i ~= nil, "the rival walks the approach with move_npc rows") + if not i then return #rows + 1 end + T.same(rows[i], { "move_npc", 1, expectX, expectXCount }, + "the rival closes the X distance first (FindPathToPlayer ties to X)") + T.same(rows[i + 1], { "move_npc", 1, "down", 1 }, + "then the single Y step down to the tile above the player") + return i + 2 +end + +local function checkExit(rows, from, side, totalDowns, turnAfterDowns) + local i = indexOf(rows, function(r) return isMove(r, side) end, from) + T.check(i ~= nil, "the rival sidesteps " .. side .. " out of the column") + if not i then return end + T.same(rows[i], { "move_npc", 1, side, 1 }, "the sidestep is a single tile") + local downs, sideTurnAt, downTurnAt = 0, nil, nil + local j = i + 1 + while rows[j] and rows[j][1] ~= "hide_object" do + local r = rows[j] + if isMove(r, "down") then downs = downs + r[4] end + if r[1] == "face_player_dir" and r[2] == side then sideTurnAt = downs end + if r[1] == "face_player_dir" and r[2] == "down" then downTurnAt = downs end + j = j + 1 + end + T.eq(downs, totalDowns, "the rival walks straight down to his hide cell") + T.eq(sideTurnAt, turnAfterDowns, + "the player turns " .. side .. " to watch him go (#1987)") + T.eq(downTurnAt, turnAfterDowns + 1, + "and turns down one step later, as the counter branch does") + T.same(rows[j], { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, + "the rival is hidden where the movement list runs out") +end + +local RED = "data/scripts/oaks_lab.lua" +local YELLOW = "data/scripts/oaks_lab_yellow.lua" + +do + local rows = runOnStep(RED, { cellX = 7, cellY = 4 }, 4, 6) + local after = checkApproach(rows, "left", 3) + checkExit(rows, after, "right", 5, 0) +end + +do + local rows = runOnStep(RED, { cellX = 7, cellY = 4 }, 5, 6) + local after = checkApproach(rows, "left", 2) + checkExit(rows, after, "left", 5, 0) +end + +do + local rows = runOnStep(RED, { id = "rival" }, 4, 6) + T.check(indexOf(rows, function(r) + return r[1] == "move_npc_to" and r[3] == 4 and r[4] == 5 + end) ~= nil, "a coordinate-less rival falls back to move_npc_to") +end + +do + local rows = runOnStep(YELLOW, { cellX = 7, cellY = 4 }, 4, 6) + local after = checkApproach(rows, "left", 3) + checkExit(rows, after, "right", 6, 1) +end + +do + local rows = runOnStep(YELLOW, { cellX = 7, cellY = 4 }, 5, 6) + local after = checkApproach(rows, "left", 2) + checkExit(rows, after, "left", 6, 1) +end + +T.finish("oaks_lab_rival_paths_bug1989_bug1987") diff --git a/tests/engine/require_guard_test.lua b/tests/engine/require_guard_test.lua new file mode 100644 index 00000000..e01b84ec --- /dev/null +++ b/tests/engine/require_guard_test.lua @@ -0,0 +1,101 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local RG = require("src.core.RequireGuard") + +local loaders = package.loaders or package.searchers +check(type(loaders) == "table", "package.loaders is a table on this interpreter") + +local original = {} +for i = 1, #loaders do original[i] = loaders[i] end + +local function restore() + for i = #loaders, 1, -1 do loaders[i] = nil end + for i = 1, #original do loaders[i] = original[i] end +end + +local function indexOf(fn) + for i = 1, #loaders do + if loaders[i] == fn then return i end + end + return nil +end + +local fakeA = function() end +local fakeB = function() end + +local savedLove = _G.love +_G.love = { filesystem = {} } + +table.insert(loaders, 2, fakeA) +check(RG.capture() == true, "capture() snapshots package.loaders[2] under love") +check(RG.captured() == true, "captured() reports the snapshot") +check(RG.present() == true, "present() is true on an intact chain") +check(RG.repair() == false, "repair() on an intact chain reports no repair") +eq(#loaders, #original + 1, "repair() on an intact chain adds no entry") + +table.insert(loaders, 2, fakeB) +check(RG.capture() == false, "capture() is idempotent") + +table.remove(loaders, indexOf(fakeA)) +check(RG.present() == false, "present() detects the dropped love searcher") +check(RG.repair() == true, "repair() puts the love searcher back") +eq(indexOf(fakeA), 2, "repair() reinserts the searcher at index 2") +check(RG.present() == true, "present() is true again after repair") +check(RG.repair() == false, "a second repair() adds nothing") +eq(#loaders, #original + 2, "chain length is stable across repairs") + +restore() + +_G.love = nil +package.loaded["src.core.RequireGuard"] = nil +local Fresh = require("src.core.RequireGuard") +check(Fresh.capture() == false, "capture() without love.filesystem is a no-op") +check(Fresh.captured() == false, "no snapshot is taken without love.filesystem") +check(Fresh.present() == true, "present() stays true when nothing was captured") +check(Fresh.repair() == false, "repair() without a snapshot changes nothing") +eq(#loaders, #original, "an uncaptured guard leaves package.loaders alone") + +package.loaded["src.core.RequireGuard"] = nil +_G.love = savedLove + +local function readFile(path) + local f = io.open(path, "rb") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end + +local mainBody = readFile("main.lua") +check(mainBody ~= nil, "main.lua is readable from the repo root") +mainBody = mainBody or "" + +check(mainBody:find('require("src.core.RequireGuard").capture()', 1, true) ~= nil, + "love.load captures love's filesystem searcher") + +local function functionBody(header) + local s = mainBody:find(header) + if not s then return "" end + local e = mainBody:find("\nend\n", s, true) or #mainBody + return mainBody:sub(s, e) +end + +local bootBody = functionBody("function bootGame%(") +check(bootBody ~= "", "main.lua still defines bootGame") +check(bootBody:find('require("src.core.RequireGuard").repair()', 1, true) ~= nil, + "bootGame repairs the loader chain before its first cold require") + +local returnBody = functionBody("local function returnToLauncher%(") +check(returnBody ~= "", "main.lua still defines returnToLauncher") +check(returnBody:find('require("src.core.RequireGuard").repair()', 1, true) ~= nil, + "returnToLauncher repairs the loader chain") + +local issueBody = readFile("src/core/IssueReport.lua") or "" +check(issueBody:find("RequireGuard", 1, true) ~= nil + and issueBody:find("Loader chain", 1, true) ~= nil, + "diagnostics report a missing love searcher") + +T.finish() diff --git a/tests/engine/sync_engine_test.lua b/tests/engine/sync_engine_test.lua index 09fd6524..c9df5db3 100644 --- a/tests/engine/sync_engine_test.lua +++ b/tests/engine/sync_engine_test.lua @@ -164,6 +164,59 @@ do T.eq(eng.phase, "idle", "the engine settles") T.eq(transport.sent[2].url, "http://sync.test/sync/save?id=xyz&version=gold", "the download names the playthrough, not the slot") + T.eq(eng.changed, true, "and the launcher is told a slot changed") + T.eq(eng.lastDownloads and eng.lastDownloads[1].version, "gold", + "naming the game") + T.eq(eng.lastDownloads and eng.lastDownloads[1].slot, "slot1", "and the slot") +end + +do + local eng, _, saves = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"red/xyz":{"rev":2,"meta":{"savedAt":900}}}}' }, + ["GET /sync/save"] = { code = 200, + body = '{"rev":2,"meta":{"savedAt":900,"device":"Android"},' .. + '"blob":"return { player = {} }"}' }, + }, {}) + saves.write = function() return "slot3", true end + eng:syncNow() + pump(eng) + T.eq(eng.changed, true, "a download into a fresh slot flags the launcher") + T.eq(eng.lastDownloads[1].slot, "slot3", "naming the slot") + T.eq(eng.lastDownloads[1].created, true, "as one it created") + T.eq(eng.lastDownloads[1].device, "Android", + "and the device whose save it holds") +end + +do + local state = linkedState() + SyncState.setRev(state, "red/abc", 1, 500) + local eng, _, saves = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"red/abc":{"rev":5,"meta":{"savedAt":900}}}}' }, + ["GET /sync/save"] = { code = 200, + body = '{"rev":5,"meta":{"savedAt":900},"blob":"return { player = {} }"}' }, + }, { saveEntry("red", "abc", 500, 400) }, state) + eng:protectPlaythrough("red", "abc") + eng:syncNow() + pump(eng) + T.eq(#saves.writes, 0, + "while a playthrough is being played its moved rev is left alone") + T.eq(eng.phase, "idle", "and that sync still settles") + eng:protectPlaythrough(nil, nil) + T.eq(eng.protectedKey, nil, "leaving the game drops the protection") + eng:syncNow() + pump(eng) + T.eq(#saves.writes, 1, + "so the launcher's next sync fetches the other device's progress") + T.eq(saves.writes[1].mode, "replace", "into this playthrough's slot") + T.eq(SyncState.rev(eng.state, "red/abc"), 5, "and adopts the served rev") + + local main = assert(io.open("main.lua")):read("*a") + local body = main:match("local function returnToLauncher%(opts%)(.-)\nend\n") + T.check(body ~= nil, "main.lua still has returnToLauncher") + T.check(body and body:find("protectPlaythrough", 1, true) ~= nil, + "and returning to the launcher clears the played key on the shared engine") end do diff --git a/tests/engine/sync_save_identity_test.lua b/tests/engine/sync_save_identity_test.lua new file mode 100644 index 00000000..9448f5da --- /dev/null +++ b/tests/engine/sync_save_identity_test.lua @@ -0,0 +1,252 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local GameVersion = require("src.core.GameVersion") +local SyncEngine = require("src.sync.SyncEngine") + +local realFS = love.filesystem + +local function memfs(files) + return { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + createDirectory = function() return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + return nil + end, + } +end + +local function fresh() + local files = {} + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set("red") + return files +end + +local function slotOnDisk(files, slotId) + local body = files["saves/red/" .. slotId .. ".lua"] + return body and SaveSerializer.decode(body) or nil +end + +local function opaque(id) + return type(id) == "string" and #id == 32 and id:match("^%x+$") ~= nil +end + +do + local files = fresh() + local provider = SyncEngine.defaultSaves() + T.eq(#provider.list(), 0, "a fresh install has nothing to sync") + + local slotId = SaveData.createSlot("red") + SaveData.setActiveSlot("red", slotId) + local save = SaveData.newGame() + save.player.name = "ASH" + save.meta = SaveData.buildMeta({}, save.meta, os.time() - 60) + T.check(SaveData.save(save), "a vanilla in-game save lands") + T.eq(slotOnDisk(files, slotId).meta.playthroughId, nil, + "a save nobody has synced or tooled carries no identity yet") + + local entries = provider.list() + T.eq(#entries, 1, "a vanilla slot is synced without a mod") + local id = entries[1] and entries[1].playthroughId + T.check(opaque(id), "under an opaque 32-hex identity") + T.eq(entries[1] and entries[1].slot, slotId, "from the slot the game saved into") + T.eq(SaveData.loadOptions().playthroughIds.red[slotId], id, + "the identity is persisted as that slot's mapping") + T.eq(provider.list()[1].playthroughId, id, + "and a second listing answers the same id") + T.eq(slotOnDisk(files, slotId).meta.playthroughId, nil, + "listing never rewrites the progress bytes") + + save.money = 777 + T.check(SaveData.save(save), "the next in-game save") + T.eq(slotOnDisk(files, slotId).meta.playthroughId, id, + "stamps the mapped identity into the progress meta") + T.eq(save.meta.playthroughId, id, "and onto the live save") + T.eq(SaveData.loadOptions().playthroughIds.red[slotId], id, + "while the stale in-game options snapshot leaves the mapping alone") + T.eq(provider.list()[1].playthroughId, id, "so the sync key never moves") +end + +do + fresh() + local provider = SyncEngine.defaultSaves() + local save = SaveData.newGame() + save.player.name = "ASH" + save.meta = SaveData.buildMeta(nil, { format = "gen1_import", mods = {} }) + local slot = SaveData.createSlot("red") + T.check(SaveData.writeSlot("red", slot, save), "an imported .sav slot writes") + SaveData.setActiveSlot("red", slot) + + local entries = provider.list() + T.eq(#entries, 1, "an imported .sav is uploaded") + T.eq(entries[1] and entries[1].slot, slot, "from its own slot") + T.check(opaque(entries[1] and entries[1].playthroughId), + "under an identity minted for it") +end + +do + fresh() + local provider = SyncEngine.defaultSaves() + local shared = ("ab"):rep(16) + local save = SaveData.newGame() + save.player.name = "ASH" + save.meta = SaveData.buildMeta(nil, + { format = "gen1_import", mods = {}, playthroughId = shared }) + local slot = SaveData.createSlot("red") + SaveData.writeSlot("red", slot, save) + SaveData.setActiveSlot("red", slot) + + local entries = provider.list() + T.eq(entries[1] and entries[1].playthroughId, shared, + "an identity stamped by the import wins over minting a new one") + T.eq(SaveData.loadOptions().playthroughIds.red[slot], shared, + "and is mapped to its slot so a replace can find it") + + local theirs = SaveData.newGame() + theirs.player.name = "ASH" + theirs.money = 5 + theirs.meta = SaveData.buildMeta({}, { playthroughId = shared }, os.time() - 10) + local wrote, created = provider.write("red", shared, SaveData.encode(theirs), "replace") + T.eq(wrote, slot, "the other device's copy replaces the imported slot in place") + T.eq(created, false, "and reports that no slot was created") + T.eq(#SaveData.listSlots("red"), 1, "leaving one slot") + T.eq(SaveData.load("red").money, 5, "holding the downloaded progress") + + local stranger = SaveData.newGame() + stranger.player.name = "BLUE" + stranger.meta = SaveData.buildMeta({}, { playthroughId = ("cd"):rep(16) }) + local newSlot, made = provider.write("red", ("cd"):rep(16), + SaveData.encode(stranger), "replace") + T.check(newSlot ~= nil and newSlot ~= slot, "an unknown playthrough gets its own slot") + T.eq(made, true, "and reports the slot as created") + T.eq(SaveData.activeSlot("red"), slot, "without becoming the slot CONTINUE loads") +end + +do + fresh() + local slot1 = SaveData.createSlot("red") + SaveData.setActiveSlot("red", slot1) + local save = SaveData.newGame() + save.player.name = "ASH" + save.meta = SaveData.buildMeta({}, save.meta, os.time() - 60) + T.check(SaveData.save(save), "first in-game save") + + local slot2 = SyncEngine.defaultSaves().write("red", "idA", SaveData.encode(save), "replace") + T.check(slot2 ~= nil and slot2 ~= slot1, + "sync lands the other playthrough in a second slot mid-session") + local opts = SaveData.loadOptions() + T.eq(#opts.saveSlots.red.list, 2, "the registry holds both slots after the download") + T.eq(opts.playthroughIds.red[slot2], "idA", "and the mapping for slot2") + + save.money = 999 + T.check(SaveData.save(save), "second in-game save") + opts = SaveData.loadOptions() + T.eq(#opts.saveSlots.red.list, 2, "slot2 registration survives an in-game save") + T.eq(opts.playthroughIds.red[slot2], "idA", + "slot2 identity mapping survives an in-game save") + T.eq(opts.saveSlots.red.active, slot1, "without moving the active slot") + T.eq(SaveData.load("red").money, 999, "and the progress itself was written") +end + +do + fresh() + local slot1 = SaveData.createSlot("red") + SaveData.setActiveSlot("red", slot1) + local save = SaveData.newGame() + save.player.name = "ASH" + save.meta = SaveData.buildMeta({}, save.meta, os.time() - 60) + T.check(SaveData.save(save), "in-game save before the download") + + local slot2 = SyncEngine.defaultSaves().write("red", "idA", SaveData.encode(save), "replace") + T.check(slot2 ~= nil and slot2 ~= slot1, "sync lands a second slot mid-session") + + local Game = require("src.core.Game") + local game = setmetatable({ save = save }, { __index = Game }) + save.options.textSpeed = 1 + Game.writeOptions(game) + local opts = SaveData.loadOptions() + T.eq(opts.textSpeed, 1, "an Options menu write lands the live setting") + T.eq(#opts.saveSlots.red.list, 2, + "slot2 registration survives an Options menu write") + T.eq(opts.playthroughIds.red[slot2], "idA", + "slot2 identity mapping survives an Options menu write") +end + +do + fresh() + local slot = SaveData.createSlot("red") + SaveData.setActiveSlot("red", slot) + local save = SaveData.newGame() + save.player.name = "ASH" + save.meta = SaveData.buildMeta({}, save.meta, os.time() - 60) + save.options.textSpeed = 1 + T.check(SaveData.save(save), "an in-game save with a live options change") + T.eq(SaveData.loadOptions().textSpeed, 1, + "still writes the player's live settings") + T.eq(SaveData.loadOptions().playthroughIds, nil, + "and mints no identity of its own") +end + +do + local files = fresh() + local provider = SyncEngine.defaultSaves() + local keep = SaveData.createSlot("red") + local slot = SaveData.createSlot("red") + SaveData.setActiveSlot("red", keep) + local save = SaveData.newGame() + save.player.name = "ASH" + save.meta = SaveData.buildMeta({}, save.meta, os.time() - 60) + T.check(SaveData.save(save), "the kept slot saves") + SaveData.setActiveSlot("red", slot) + save = SaveData.newGame() + save.player.name = "GONE" + save.meta = SaveData.buildMeta({}, save.meta, os.time() - 30) + T.check(SaveData.save(save), "the doomed slot saves") + T.eq(#provider.list(), 2, "both slots sync") + local ids = SaveData.loadOptions().playthroughIds.red + local keptId, goneId = ids[keep], ids[slot] + T.check(opaque(keptId) and opaque(goneId) and keptId ~= goneId, + "under two identities") + + T.check(SaveData.deleteSlot("red", slot), "the doomed slot is deleted") + ids = SaveData.loadOptions().playthroughIds.red + T.eq(ids[slot], nil, "deleting a slot forgets its playthrough mapping") + T.eq(ids[keep], keptId, "and leaves the other slot's mapping alone") + + local again = SaveData.createSlot("red") + T.eq(again, slot, "the next new slot reuses the id") + SaveData.setActiveSlot("red", again) + local fresh = SaveData.newGame() + fresh.player.name = "NEW" + fresh.meta = SaveData.buildMeta({}, fresh.meta, os.time() - 10) + T.check(SaveData.save(fresh), "a New Game in the recreated slot saves") + T.eq(slotOnDisk(files, again).meta.playthroughId, nil, + "without inheriting the deleted playthrough's identity") + local entries = provider.list() + T.eq(#entries, 2, "and syncs") + for _, entry in ipairs(entries) do + T.neq(entry.playthroughId, goneId, + "under a key that is not the deleted playthrough's (" .. entry.slot .. ")") + end + + T.check(SaveData.deleteSlot("red", again), "deleting the recreated slot") + T.check(SaveData.deleteSlot("red", keep), "and the kept slot") + T.eq(SaveData.loadOptions().playthroughIds, nil, + "leaves no playthrough mappings behind") +end + +love.filesystem = realFS +SaveData.resetSlotState() +GameVersion.set("red") + +T.finish("sync_save_identity") diff --git a/tests/engine/sync_session_meta_test.lua b/tests/engine/sync_session_meta_test.lua index b1b558a6..36bf7a65 100644 --- a/tests/engine/sync_session_meta_test.lua +++ b/tests/engine/sync_session_meta_test.lua @@ -166,6 +166,104 @@ do SyncEngine.forgetShared() end +local function onDisk(files, slot) + local body = files["saves/red/" .. slot .. ".lua"] + return body and SaveData.decode(body) or nil +end + +do + local files = fresh() + local Game = require("src.core.Game") + local slot = SaveData.createSlot("red") + SaveData.setActiveSlot("red", slot) + local save = SaveData.newGame() + save.player.name = "ASH" + save.meta = SaveData.buildMeta({}, save.meta, os.time() - 60) + T.check(SaveData.writeSlot("red", slot, save), "a slot is written without an identity stamp") + local mapped = SaveData.slotPlaythroughId("red", slot, save) + T.check(type(mapped) == "string" and #mapped == 32, + "a launcher sync listing mints the slot's identity into the mapping") + T.eq(onDisk(files, slot).meta.playthroughId, nil, + "while the progress file still carries none") + + local protected = {} + SyncEngine._shared = { + state = { enabled = true }, + linked = function() return true end, + busy = function() return false end, + noteSaveWritten = function() end, + update = function() end, + protectPlaythrough = function(_, version, id) + protected.version, protected.id = version, id + end, + } + local loaded = SaveData.load("red") + T.eq(loaded and loaded.player.name, "ASH", "CONTINUE loads that slot") + local game = setmetatable({ save = loaded }, { __index = Game }) + T.check(Game.syncEngine(game) ~= nil, "the running game reaches the engine") + T.eq(protected.version, "red", "and protects its own game") + T.eq(protected.id, mapped, + "under the mapped identity, so an in-game sync cannot replace the slot it is playing") + T.eq(onDisk(files, slot).meta.playthroughId, nil, + "loading never rewrites the progress bytes") + SyncEngine.forgetShared() +end + +do + local files = fresh() + local Game = require("src.core.Game") + local slot = SaveData.createSlot("red") + SaveData.setActiveSlot("red", slot) + local provider = SyncEngine.defaultSaves() + local protected, busy, listed = {}, false, 0 + SyncEngine._shared = { + state = { enabled = true }, + linked = function() return true end, + busy = function() return busy end, + noteSaveWritten = function() busy = true end, + update = function() + if busy then + listed = listed + #provider.list() + busy = false + end + end, + protectPlaythrough = function(_, version, id) + protected.version, protected.id = version, id + end, + } + local game = setmetatable({ save = SaveData.newGame(), + sessionStartedAt = os.time() - 60 }, { __index = Game }) + game.save.player.name = "ASH" + Game.updateSync(game, 0.1) + T.eq(protected.id, nil, "an unsaved New Game protects no key") + + T.eq(Game.writeSave(game), true, "the first in-game save lands") + T.eq(game.save.meta.playthroughId, nil, "with no identity yet") + T.eq(onDisk(files, slot).meta.playthroughId, nil, "on disk either") + Game.updateSync(game, 0.1) + T.eq(listed, 1, "the debounced upload lists the new slot") + local mapped = SaveData.loadOptions().playthroughIds.red[slot] + T.check(type(mapped) == "string" and #mapped == 32, + "and mints its identity into the mapping") + T.eq(game.save.meta.playthroughId, mapped, + "the running save adopts the identity the sync minted for its slot") + Game.syncEngine(game) + T.eq(protected.id, mapped, + "so the next sync cannot replace the slot under the running game") + T.eq(onDisk(files, slot).meta.playthroughId, nil, + "adopting rewrites nothing on disk until the next SAVE") + + local title = setmetatable({ save = SaveData.newGame() }, { __index = Game }) + busy = true + Game.updateSync(title, 0.1) + T.eq(title.save.meta.playthroughId, nil, + "an unsaved skeleton never takes the slot's identity") + Game.syncEngine(title) + T.eq(protected.id, nil, + "and leaves the slot open for a download at the title") + SyncEngine.forgetShared() +end + love.filesystem = realFS T.finish("sync_session_meta") diff --git a/tests/gen2_battle_pack_test.lua b/tests/gen2_battle_pack_test.lua index 2595a408..ee77897e 100644 --- a/tests/gen2_battle_pack_test.lua +++ b/tests/gen2_battle_pack_test.lua @@ -354,12 +354,40 @@ do local silver = battlePack("silver") eq(silver:submenuColumn(), 0, "Silver runs Gold's engine and its column") - local field = PackMenu.new({ data = DATA, input = Input, options = {}, - save = { player = { name = "GOLD" }, version = "crystal", - inventory = { POTION = 1 } }, - stack = { push = function() end, pop = function() end, - top = function() return nil end } }, { items = ITEMS }) - eq(field:submenuColumn(), 0, "the field pack keeps the column it drew in") + -- ../pokecrystal/engine/items/pack.asm:313 is menu_coords 13, 1, ..., + -- TEXTBOX_Y - 1 and ../pokegold/engine/items/pack.asm:313 is + local function fieldPack(version) + local save = { player = { name = "GOLD" }, version = version, + inventory = { POTION = 1 } } + local game = { data = DATA, save = save, input = Input, options = {}, + stack = { push = function() end, pop = function() end, + top = function() return nil end } } + return PackMenu.new(game, { save = save, items = ITEMS }) + end + + local function fiveRowBox(pack) + pack.submenu = { rows = { "use", "give", "toss", "sel", "quit" }, index = 1 } + boxes = {} + pack:drawSubmenu() + return boxes[1] + end + + local crystalField = fieldPack("crystal") + eq(crystalField:submenuColumn(), 13, "Crystal's field submenu is menu_coords 13") + local crystalFieldBox = fiveRowBox(crystalField) + eq(crystalFieldBox and crystalFieldBox[1], 13, "off the pack picture, on the right") + eq(crystalFieldBox and crystalFieldBox[2], 1, "MenuHeader_UsableKeyItem's row 1") + eq(crystalFieldBox and crystalFieldBox[4], 11, "eleven rows to TEXTBOX_Y - 1") + + local goldField = fieldPack("gold") + eq(goldField:submenuColumn(), 0, "Gold's field submenu stays at menu_coords 0") + local goldFieldBox = fiveRowBox(goldField) + eq(goldFieldBox and goldFieldBox[1], 0, "over the pack picture, on the left") + eq(goldFieldBox and goldFieldBox[2], 2, "one row lower: menu_coords 0, 2") + eq(goldFieldBox and goldFieldBox[4], 11, "the same eleven rows, to TEXTBOX_Y") + + local silverField = fieldPack("silver") + eq(silverField:submenuColumn(), 0, "Silver runs Gold's engine in the field too") Chrome.box = realBox Chrome.cursorThrough = realCursor diff --git a/tests/gen2_battle_test.lua b/tests/gen2_battle_test.lua index c401f3c7..53a9d482 100644 --- a/tests/gen2_battle_test.lua +++ b/tests/gen2_battle_test.lua @@ -164,6 +164,16 @@ local POKEMON = { levelMoves = { { level = 1, move = "LICK" } }, evolutions = {}, }, + -- GENDER_UNKNOWN (data/pokemon/base_stats/magnemite.asm). + MAGNEMITE = { + id = "MAGNEMITE", index = 81, name = "MAGNEMITE", + baseStats = { hp = 25, attack = 35, defense = 70, speed = 45, + specialAttack = 95, specialDefense = 55 }, + types = { "ELECTRIC", "STEEL" }, catchRate = 190, baseExp = 89, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 0xff, + levelMoves = { { level = 1, move = "TACKLE" } }, + evolutions = {}, + }, } local DATA = { @@ -1050,6 +1060,12 @@ local EFFECT_MOVES = { accuracy = 100, pp = 20, effect = "EFFECT_HEAL" }, PROTECT = { id = "PROTECT", name = "PROTECT", power = 0, type = "NORMAL", accuracy = 100, pp = 10, effect = "EFFECT_PROTECT" }, + -- data/moves/moves.asm:203 and the ATTRACT row. + BELLY_DRUM = { id = "BELLY_DRUM", name = "BELLY DRUM", power = 0, + type = "NORMAL", accuracy = 100, pp = 10, + effect = "EFFECT_BELLY_DRUM" }, + ATTRACT = { id = "ATTRACT", name = "ATTRACT", power = 0, type = "NORMAL", + accuracy = 100, pp = 15, effect = "EFFECT_ATTRACT" }, } for id, def in pairs(EFFECT_MOVES) do MOVES[id] = def end @@ -3096,6 +3112,173 @@ end)() check("and the share is a real number", shared > 0, true) end)() +-- BattleCommand_BellyDrum (engine/battle/move_effects/belly_drum.asm): the +;(function() + local function said(events, text) + for _, e in ipairs(events) do + if e.kind == "message" and e.text == text then return true end + end + return false + end + local function drumBattle() + local player = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }) + player.moves = { { id = "BELLY_DRUM", pp = 10, maxPp = 10 } } + local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local b = Battle.new({ data = DATA, party = { player }, wild = wild, + random = zeroRandom }) + b:takeEvents() + return b, player, wild + end + + local b, player, wild = drumBattle() + player.maxHp, player.hp = 40, 40 + b:useMove(player, wild, "BELLY_DRUM") + local events = b:takeEvents() + check("Belly Drum pays half the max HP", player.hp, 20) + check("and maximizes ATTACK", b.stages.player.attack, 6) + check("with BellyDrumText's second box", + said(events, "maximized ATTACK!"), true) + + -- CheckUserHasEnoughHP (engine/battle/core.asm:1930) returns the borrow, so + local half, halfPlayer, halfWild = drumBattle() + halfPlayer.maxHp, halfPlayer.hp = 40, 20 + half:useMove(halfPlayer, halfWild, "BELLY_DRUM") + check("exactly half HP refuses the move", halfPlayer.hp, 20) + check("...and says so", said(half:takeEvents(), "But it failed!"), true) + check("but the opening attackup2 is kept", half.stages.player.attack, 2) + + local low, lowPlayer, lowWild = drumBattle() + lowPlayer.maxHp, lowPlayer.hp = 40, 19 + low:useMove(lowPlayer, lowWild, "BELLY_DRUM") + check("below half refuses it too", lowPlayer.hp, 19) + + local capped, cappedPlayer, cappedWild = drumBattle() + cappedPlayer.maxHp, cappedPlayer.hp = 40, 40 + capped.stages.player.attack = 6 + capped:useMove(cappedPlayer, cappedWild, "BELLY_DRUM") + check("a maxed ATTACK costs no HP", cappedPlayer.hp, 40) + check("and leaves the stage where it was", capped.stages.player.attack, 6) + check("with the failure line", + said(capped:takeEvents(), "But it failed!"), true) + + -- GetHalfMaxHP floors (engine/battle/core.asm:1821). + local odd, oddPlayer, oddWild = drumBattle() + oddPlayer.maxHp, oddPlayer.hp = 21, 21 + odd:useMove(oddPlayer, oddWild, "BELLY_DRUM") + check("an odd max HP floors the cost", oddPlayer.hp, 11) +end)() + +-- BattleCommand_Attract (engine/battle/move_effects/attract.asm), the +-- SUBSTATUS_IN_LOVE arm of CheckPlayerTurn (effect_commands.asm:291-310) and +-- BreakAttraction (engine/battle/core.asm:3871). +;(function() + local function said(events, text) + for _, e in ipairs(events) do + if e.kind == "message" and e.text == text then return true end + end + return false + end + local femaleDvs = { attack = 0, defense = 15, speed = 15, special = 15 } + femaleDvs.hp = Mon.hpDV(femaleDvs) + check("a 0 Attack DV PIDGEY is female", + Mon.new(DATA, "PIDGEY", 20, { dvs = femaleDvs }).gender, "female") + check("and the perfect one is male", + Mon.new(DATA, "PIDGEY", 20, { dvs = perfect }).gender, "male") + check("MAGNEMITE has no gender at all", + Mon.new(DATA, "MAGNEMITE", 20, { dvs = perfect }).gender, "unknown") + + local function attractBattle(species, dvs, userSpecies) + local player = Mon.new(DATA, userSpecies or "CYNDAQUIL", 20, + { dvs = perfect }) + player.moves = { { id = "ATTRACT", pp = 15, maxPp = 15 }, + { id = "TACKLE", pp = 35, maxPp = 35 } } + local bench = Mon.new(DATA, "TOTODILE", 20, { dvs = perfect }) + bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local wild = Mon.new(DATA, species, 20, { dvs = dvs }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local b = Battle.new({ data = DATA, party = { player, bench }, + wild = wild, random = zeroRandom }) + b:takeEvents() + return b, player, wild + end + + local b, player, wild = attractBattle("PIDGEY", femaleDvs) + b:useMove(player, wild, "ATTRACT") + check("ATTRACT infatuates the opposite sex", b:volatile(wild).attract, true) + check("with FellInLoveText", + said(b:takeEvents(), b:monName(wild) .. "\nfell in love!"), true) + b:useMove(player, wild, "ATTRACT") + check("a second ATTRACT on the same target fails", + said(b:takeEvents(), "But it failed!"), true) + + local same, samePlayer, sameWild = attractBattle("PIDGEY", perfect) + same:useMove(samePlayer, sameWild, "ATTRACT") + check("same sex sets nothing", same:volatile(sameWild).attract, nil) + check("and fails outright", + said(same:takeEvents(), "But it failed!"), true) + + local none, nonePlayer, noneWild = attractBattle("MAGNEMITE", perfect) + none:useMove(nonePlayer, noneWild, "ATTRACT") + check("a genderless target sets nothing", + none:volatile(noneWild).attract, nil) + + local selfless, selflessPlayer, selflessWild = + attractBattle("PIDGEY", femaleDvs, "MAGNEMITE") + selfless:useMove(selflessPlayer, selflessWild, "ATTRACT") + check("a genderless user sets nothing", + selfless:volatile(selflessWild).attract, nil) + + local turn, turnPlayer, turnWild = attractBattle("PIDGEY", femaleDvs) + turn:volatile(turnPlayer).attract = true + turn.random = maxRandom + check("a high byte keeps the mon from attacking", + turn:canAct(turnPlayer, "TACKLE"), false) + local lostEvents = turn:takeEvents() + check("the in-love line opens on the user", said(lostEvents, + turn:monName(turnPlayer) .. "\nis in love with"), true) + check("and scrolls onto the target", said(lostEvents, + "is in love with\n" .. turn:monName(turnWild) .. "!"), true) + check("InfatuationText opens on the user", said(lostEvents, + turn:monName(turnPlayer) .. "'s\ninfatuation kept"), true) + check("and scrolls onto the refusal", said(lostEvents, + "infatuation kept\nit from attacking!"), true) + -- constants/text_constants.asm:32 + local widest = 0 + for _, e in ipairs(lostEvents) do + if e.kind == "message" then + local rows = 0 + for row in (e.text .. "\n"):gmatch("([^\n]*)\n") do + rows = rows + 1 + widest = math.max(widest, #row) + end + check("every infatuated box is at most two rows", rows <= 2, true) + end + end + check("and no row overflows the message box", widest <= 18, true) + turn.random = zeroRandom + check("a low byte lets the move go", + turn:canAct(turnPlayer, "TACKLE"), true) + local keptEvents = turn:takeEvents() + check("but the in-love pair still prints", said(keptEvents, + turn:monName(turnPlayer) .. "\nis in love with"), true) + check("both halves of it", said(keptEvents, + "is in love with\n" .. turn:monName(turnWild) .. "!"), true) + check("and nothing was kept from attacking", said(keptEvents, + "infatuation kept\nit from attacking!"), false) + + local sw, swPlayer, swWild = attractBattle("PIDGEY", femaleDvs) + sw:useMove(swPlayer, swWild, "ATTRACT") + sw:volatile(swPlayer).attract = true + sw:volatile(swWild).attract = true + sw:takeEvents() + sw:switch(2) + check("the mon that left drops its own", (swPlayer.volatile or {}).attract, + nil) + check("and the one that stayed loses it too", + sw:volatile(swWild).attract, nil) +end)() + print(("gen2 battle: %d checks, %d failures"):format(checks, failures)) -- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an diff --git a/tests/gen2_battle_ui_test.lua b/tests/gen2_battle_ui_test.lua index 37434776..a17e1be7 100644 --- a/tests/gen2_battle_ui_test.lua +++ b/tests/gen2_battle_ui_test.lua @@ -418,6 +418,76 @@ do "and the bar landed on the mon's real place in level 6") end +do + -- sparks have gone (../pokecrystal/engine/battle/core.asm:7529-7541). + local EXP_FULL = require("src.ui.gen2.BattleHud").EXP_LENGTH_PX + local player = Mon.new(DATA, "CYNDAQUIL", 5, { dvs = perfect }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local growth = DATA.pokemon.growthRates.GROWTH_MEDIUM_SLOW + player.experience = Mon.experienceForLevel(growth, 6) - 1 + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + wild.hp = 1 + local screen = newScreen({ player = player, wild = wild }) + check(runToMenu(screen), "reached the menu") + screen:submit({ kind = "move", move = "TACKLE" }) + + local frames, full, quiet, level = {}, true, true, nil + for _ = 1, 3000 do + drainStep(screen) + local burst = screen.expBurst + if burst then + frames[#frames + 1] = burst.frame + level = level or screen.shownLevel + if screen.shownExp ~= EXP_FULL then full = false end + if tostring(screen.message or ""):find("grew to level") then + quiet = false + end + end + if screen.phase == "done" then break end + end + eq(#frames, 8, "the burst holds the queue for the eight frames of .loop") + eq(frames[1], 1, "opening on the frame the bar tops out") + eq(frames[#frames], 8, "and ending on the eighth") + check(full, "with the bar left full under it, PlaceExpBar not called again") + eq(level, 6, "the level number has already ticked over") + check(quiet, "and the grew-to-level line held off until the sparks clear") +end + +do + -- OAM bias (../pokecrystal/engine/sprite_anims/core.asm:558-608). + local screen = newScreen() + local drawn = {} + screen.hud = { drawExpBarEnd = function(_, x, y) + drawn[#drawn + 1] = ("%d,%d"):format(x, y) + end } + + screen.expBurst = { frame = 1 } + screen:drawExpBurst() + eq(#drawn, 8, "eight objects a frame") + local collapsed = true + for _, at in ipairs(drawn) do + if at ~= "76,88" then collapsed = false end + end + check(collapsed, "all on the bar's left end while the radius is still 0") + + drawn = {} + screen.expBurst = { frame = 8 } + screen:drawExpBurst() + local seen = {} + for _, at in ipairs(drawn) do seen[at] = (seen[at] or 0) + 1 end + local ring = { "90,88", "85,97", "76,102", "67,97", + "62,88", "67,79", "76,74", "85,79" } + for _, at in ipairs(ring) do + eq(seen[at], 1, "a spark at " .. at .. " on the last frame") + end + + drawn = {} + screen.expBurst = { frame = 9 } + screen:drawExpBurst() + eq(#drawn, 0, "and ClearSprites past the loop leaves nothing behind") +end + -- ---- a level-up REDRAWS the HP bar, it does not animate it ---------------- do -- GiveExperiencePoints' `.skip_active_mon_update` guard @@ -1513,4 +1583,41 @@ do check(text:match("R:%d+/%d+@19,1%d") == nil, "no PP is printed per row") end +-- data/text/battle.asm:484 InLoveWithText, :490 InfatuationText: three rows +do + local Chrome = require("src.ui.gen2.Chrome") + local screen, battle = newScreen() + check(runToMenu(screen), "reached the menu") + battle:volatile(battle.player).attract = true + battle.random = function(n) return (n or 1) - 1 end + screen:submit({ kind = "move", move = "TACKLE" }) + local seen, printed, widest, tallest = {}, {}, 0, 0 + for _ = 1, 3000 do + local msg = screen.message + if msg and not seen[msg] then + seen[msg] = true + local rows = Chrome.wrap(msg, 18) + tallest = math.max(tallest, #rows) + for i = 1, math.min(#rows, 2) do + widest = math.max(widest, #rows[i]) + printed[#printed + 1] = rows[i] + end + printed[#printed + 1] = "|" + end + drainStep(screen) + if screen.phase == "menu" then break end + end + local shown = table.concat(printed, "\n") + check(shown:find(battle:monName(battle.player) .. "\nis in love with", + 1, true) ~= nil, "the in-love line opens on the user") + check(shown:find("is in love with\n" .. battle:monName(battle.enemy) .. "!", + 1, true) ~= nil, "and scrolls onto the target it never used to reach") + check(shown:find("infatuation kept", 1, true) ~= nil, + "InfatuationText prints") + check(shown:find("it from attacking!", 1, true) ~= nil, + "and so does the row the two-row box used to cut") + eq(tallest, 2, "no drawn message runs past the box's two rows") + check(widest <= 18, "and no row past its 18 tiles") +end + S.finish() diff --git a/tests/gen2_fly_map_test.lua b/tests/gen2_fly_map_test.lua index e19d9a28..2a489788 100644 --- a/tests/gen2_fly_map_test.lua +++ b/tests/gen2_fly_map_test.lua @@ -211,4 +211,129 @@ do "kanto", "with no landmark table the Gold constants stand in") end +-- (engine/pokemon/mon_menu.asm:609-626, engine/events/overworld.asm:556-568). +-- the list, and the exit is ExitAllMenus' white (home/map.asm:2281). +do + local World = require("src.world.gen2.World") + local PartyMenu = require("src.ui.gen2.PartyMenu") + + local function fakeStack() + local stack = { states = {}, low = math.huge } + function stack:push(state) + self.states[#self.states + 1] = state + self.low = math.min(self.low, #self.states) + end + function stack:pop() + local state = table.remove(self.states) + self.low = math.min(self.low, #self.states) + return state + end + function stack:top() return self.states[#self.states] end + function stack:clear() + self.states = {} + self.low = math.min(self.low, 0) + end + return stack + end + + local function flyFromParty() + local save = visited("SPAWN_NEW_BARK", "SPAWN_VIOLET", "SPAWN_GOLDENROD") + save.player = { badges = { STORM = true } } + local stack = fakeStack() + local game = { save = save, stack = stack, input = fakeInput() } + local mon = { species = 17, nickname = "PIDGEOTTO" } + local world = setmetatable({ + game = game, + landmarks = LANDMARKS, + map = { def = { landmark = "LANDMARK_NEW_BARK_TOWN" } }, + }, World) + game.world = world + local flown + world.flyTo = function(_self, spawnId, who) + flown = { spawn = spawnId, mon = who } + return true + end + world.useFieldMove = function(_self, moveId, who) + local result = FieldMoves.fromMenu(moveId, + { save = save, environment = "TOWN", mon = who }) + result.mon = result.mon or who + if result.ok then _self.queuedFieldMove = result end + return result + end + local party = setmetatable({ game = game }, PartyMenu) + stack:push(party) + party:useFieldMove("FLY", mon) + return world, party, stack, mon, function() return flown end, game + end + + do + local world, party, stack = flyFromParty() + eq(#stack.states, 2, "FLY opens a screen OVER the party list") + eq(stack.states[1], party, "which is still underneath") + eq(stack:top().screenId, "Gen2BlankScreen", + "_FlyMap's ClearBGPalettes blanks first") + eq(world.queuedFieldMove, nil, "and nothing is queued for the overworld yet") + + local blank = stack:top() + for _ = 1, blank.left do blank:update(0) end + eq(stack:top(), blank, "the blank holds WaitBGMap's four frames") + blank:update(0) + eq(stack:top().screenId, "Gen2Pokegear", "then the town map takes over") + eq(#stack.states, 2, "still over the list") + eq(stack.low, 1, "and the stack never emptied on the way in") + end + + do + local world, party, stack, _mon, _flown, game = flyFromParty() + local blank = stack:top() + for _ = 1, blank.left + 1 do blank:update(0) end + local gear = stack:top() + game.input:press("b") + gear:update(0) + eq(stack:top(), party, "B on the fly map returns to the party list") + eq(#stack.states, 1, "with nothing else left standing") + eq(world.queuedFieldMove, nil, "and no fly queued behind it") + eq(world.fade, nil, "nothing fades out for a cancel") + end + + do + local world, _party, stack, mon, flown, game = flyFromParty() + local blank = stack:top() + for _ = 1, blank.left + 1 do blank:update(0) end + local gear = stack:top() + gear.flyIndex = 1 + game.input:press("a") + gear:update(0) + eq(#stack.states, 0, "A takes the spawn and exits the menus") + eq(world.queuedFieldMove and world.queuedFieldMove.flySpawn, "SPAWN_NEW_BARK", + "with the chosen spawn on the queued script") + eq(world.fade, "white", "ExitAllMenus' ClearBGPalettes is already up") + eq(world.fadeLevel, 1, "at full white") + eq(world.mapSetup and world.mapSetup.phase, "in", + "and FadeInFromWhite is armed behind it") + check(world.mapSetup.wait > 2, "held for WaitBGMap's frames first") + + world.mapSetup = nil + local queued = world.queuedFieldMove + world.queuedFieldMove = nil + world:runFieldMove(queued) + eq(flown() and flown().spawn, "SPAWN_NEW_BARK", + "the queued fly takes the spawn the picker chose") + eq(flown().mon, mon, "on the mon the list picked") + end + + do + -- engine/events/overworld.asm:568 + local world, _party, _stack, mon, _flown, game = flyFromParty() + game.stack = nil + local asked = false + world.showText = function() asked = true end + eq(world:openFlyMap(mon, { onChosen = function() end }), false, + "a picker with no screen refuses the menu-side open") + eq(asked, false, "and does not ask from under the list") + check(world:openFlyMap(mon) == true, "while the queued path still asks") + eq(asked, true, "through the same yesorno box") + end +end + S.finish() diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index 6b45d448..02d94a7a 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -2502,6 +2502,71 @@ local function saveSfxChecks() end saveSfxChecks() +-- engine/items/pack.asm:1307 .place_insert, home/audio.asm:220 WaitPlaySFX +local function packSwitchSfxChecks() + local Sound = require("src.core.Sound") + local realPlay, realBusy, realFrames = + Sound.play, Sound.sfxBusy, Sound.waitFramesFor + local rang = {} + Sound.play = function(_, name) rang[#rang + 1] = name end + + local save = Save.newGame() + save.inventory = { POTION = 1, ANTIDOTE = 1 } + local game, input = newGame(save) + game.data.items = { + POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 1 }, + ANTIDOTE = { id = "ANTIDOTE", name = "ANTIDOTE", pocket = "ITEM", + index = 2 }, + } + game.data.audio = { sfx = { Sfx_SwitchPokemon = {} } } + local menu = PackMenu.new(game, { pocket = "ITEM" }) + check("two rows to shuffle", #menu.rows, 2) + + menu.index = 1 + input:press("select") + menu:update(0) + check("SELECT picks the row up", menu.switching, 1) + input:press("down") + menu:update(0) + check("the cursor moves under it", menu.index, 2) + input:press("a") + menu:update(0) + check("the place ends the switch", menu.switching, nil) + check("and beeps once in that frame", #rang, 1) + menu:update(0) + check("the second beep follows on the next tick", #rang, 2) + check("both are the switch cue", + rang[1] == "Sfx_SwitchPokemon" and rang[2] == "Sfx_SwitchPokemon", true) + menu:update(0) + check("and there is no third", #rang, 2) + + -- home/audio.asm:225 WaitSFX, home/delay.asm:14 + Sound.sfxBusy = function() return true end + Sound.waitFramesFor = function() return 3 end + rang = {} + menu.index = 1 + input:press("select") + menu:update(0) + input:press("down") + menu:update(0) + input:press("a") + menu:update(0) + check("the place beeps", #rang, 1) + local held = menu.index + input:press("down") + menu:update(0) + check("the pending beep holds the list", menu.index, held) + check("with no second beep yet", #rang, 1) + menu:update(0) + check("still waiting", #rang, 1) + menu:update(0) + check("the budget releases the second beep", #rang, 2) + + Sound.play, Sound.sfxBusy, Sound.waitFramesFor = realPlay, realBusy, + realFrames +end +packSwitchSfxChecks() + -- ------------------------------------------------- the mod row contract -- -- Gold's screens raise the same three hooks the Gen 1 ones do, which is only diff --git a/tests/gen2_pack_rows_test.lua b/tests/gen2_pack_rows_test.lua index d70ecca8..e087bdd4 100644 --- a/tests/gen2_pack_rows_test.lua +++ b/tests/gen2_pack_rows_test.lua @@ -423,6 +423,45 @@ do check("and the same gap", at("POTION(S)?"), "1,16") end +-- engine/menus/scrolling_menu.asm:86, home/menu.asm:50 +do + local pack = openPack({ POTION = 3 }, { "POTION" }, "ITEM") + + local function listCursor() + install() + pack:drawPanel() + restore() + return cursors[1] + end + + pack.index = 1 + check("an idle list keeps the solid arrow", listCursor().hollow, false) + + pack.submenu = { rows = { "use", "give", "toss", "quit" }, index = 1 } + check("the submenu hollows the row A picked", listCursor().hollow, true) + check("without moving it", listCursor().x, 7) + + pack.submenu = nil + pack.qtyState = { qty = 1, max = 3 } + check("so does the toss quantity prompt", listCursor().hollow, true) + + pack.qtyState = nil + pack.confirm = { prompt = { "Throw away 1", "POTION(S)?" }, choice = 1 } + check("so does the yes/no over it", listCursor().hollow, true) + + pack.confirm = nil + pack.message, pack.messagePage = { "Threw away", "POTION." }, 1 + check("and so does the text box it ends on", listCursor().hollow, true) + + pack.message, pack.messagePage = nil, nil + check("the arrow fills back in once the list has the joypad again", + listCursor().hollow, false) + + pack.index = pack:total() + pack.submenu = { rows = { "quit" }, index = 1 } + check("CANCEL's arrow hollows too", listCursor().hollow, true) +end + print(("gen2 pack rows: %d checks, %d failures"):format(checks, failures)) -- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an -- exit here takes the whole tier down with it and silently skips every diff --git a/tests/gen2_pc_screens_test.lua b/tests/gen2_pc_screens_test.lua index 6ff8aac3..1c342c03 100644 --- a/tests/gen2_pc_screens_test.lua +++ b/tests/gen2_pc_screens_test.lua @@ -188,6 +188,15 @@ do local pc = CenterPcMenu.new(game, { save = save, onClose = function() closed = true end }) check(pc.message ~= nil, "an empty party gets the Bzzzzt! refusal") + -- data/text/common_2.asm:725 _PokecenterPCCantUseText ends in `cont`. + local refusal = pc.message.pages + eq(#refusal, 2, "the refusal is two pages, not one three-line page") + eq(#refusal[1], 2, "page 1 fills the two-row box") + eq(#refusal[2], 2, "page 2 too") + eq(refusal[2][1], refusal[1][2], "the scroll keeps 'have a #MON to' on top") + eq(refusal[2][2], "use this!", "and lands the cont line under it") + press(pc, input, "a") + check(pc.message ~= nil, "the cont waits for its own A press") press(pc, input, "a") check(closed, "and the PC never opens") eq(#game.stack._items, 0, "nothing was pushed") @@ -254,6 +263,66 @@ do check(pc.closed == false, "the OAK flow drops back to the menu, not out") end +-- Two rows to the box (constants/text_constants.asm:32 TEXTBOX_INNERY); the +-- ratings' third lines are `cont` (data/text/common_2.asm:841). +do + local function ratingPages(caught) + local save = newSave(1) + save.engineFlags = { [CenterPcMenu.ENGINE_POKEDEX] = true } + local dex = {} + for i = 1, caught do dex["SPECIES" .. i] = true end + save.pokedex = { seen = dex, caught = dex } + local game, input = newGame(save) + local pc = CenterPcMenu.new(game, { save = save }) + press(pc, input, "a") + pc:oakRate() + return pc.message.pages + end + + local function indexOf(pages, first) + for i, page in ipairs(pages) do + if page[1] == first then return i end + end + return nil + end + + -- One caught count per band (data/events/pokedex_ratings.asm). + local tall = {} + for _, caught in ipairs({ 5, 15, 25, 40, 55, 70, 85, 100, 115, 130, 145, + 160, 175, 190, 205, 220, 235, 245, 251 }) do + for _, page in ipairs(ratingPages(caught)) do + if #page ~= 2 then + tall[#tall + 1] = caught .. ":" .. table.concat(page, "/") + end + end + end + eq(table.concat(tall, " "), "", + "every band pages exactly two lines, the box's two rows") + + local pages = ratingPages(205) + eq(table.concat(pages[1], "/"), "Current #DEX/completion level:", + "_OakPCText2 opens the flow") + eq(table.concat(pages[2], "/"), "205 #MON seen/205 #MON owned", + "then the two counts") + eq(table.concat(pages[3], "/"), "PROF.OAK's/Rating:", + "and the para clears the box for the rating header") + + local at = indexOf(pages, "Wow! You've hit") + check(at ~= nil, "205 owned lands on _OakRating15") + eq(pages[at][2], "200! Your #DEX", "page 1 is text + line") + eq(pages[at + 1][1], "200! Your #DEX", "the cont scroll keeps line 2 on top") + eq(pages[at + 1][2], "is looking great!", "with the cont line under it") + eq(pages[at + 2], nil, "and that is the last page") + + pages = ratingPages(25) + at = indexOf(pages, "You're getting") + check(at ~= nil, "25 owned lands on _OakRating03") + eq(table.concat(pages[at], "/"), "You're getting/good at this.", + "page 1 is text + line") + eq(table.concat(pages[at + 1], "/"), "But you have a/long way to go.", + "and the para page shares no line with it") +end + -- TURN OFF: the Link closed line, then the shutdown. do local save = newSave(1) @@ -373,11 +442,83 @@ do for i, row in ipairs(pc.rows) do rowY[row.id] = i * 2 end local counts = {} for _, p in ipairs(printed) do - if p.x == 7 then counts[p.y] = p.text end + if p.x == 14 then counts[p.y] = p.text end end eq(counts[rowY.POTION + 1], TIMES .. " 3", "the POTION stack keeps its xNN") eq(counts[rowY.HM_CUT + 1], nil, "and the HM row draws none (PlaceMenuItemQuantity .done)") + local names = {} + for _, p in ipairs(printed) do names[p.text] = p.x .. "," .. p.y end + eq(names.POTION, "5," .. rowY.POTION, "the name column is menu_coords 4 + 1") + eq(names.CANCEL, "5," .. (#pc.rows + 1) * 2, "and CANCEL shares it") +end + +-- (engine/events/pokecenter_pc.asm:641) and .a_1 (:628) leaves the row A +-- picked hollow, the way engine/menus/scrolling_menu.asm:86 does everywhere +-- (engine/items/buy_sell_toss.asm:205), menu_coords 15, 9. +do + local save = newSave(1) + local game = newGame(save) + save.pcItems = { POTION = 3 } + local pc = ItemPcMenu.new(game, { save = save, items = ITEMS }) + local Chrome = require("src.ui.gen2.Chrome") + local saved = { print = Chrome.print, box = Chrome.box, + cursor = Chrome.cursor, clear = Chrome.clear } + local boxes, cursors + Chrome.print = function() end + Chrome.box = function(x, y, w, h) boxes[#boxes + 1] = { x, y, w, h } end + Chrome.cursor = function(x, y, hollow) + cursors[#cursors + 1] = { x = x, y = y, hollow = hollow or false } + end + Chrome.clear = function() end + pc.phase = "withdraw" + pc:rebuild() + + local function shot() + boxes, cursors = {}, {} + pc:drawPanel() + return boxes, cursors + end + + local _, idle = shot() + eq(idle[1] and idle[1].x, 4, "the PC list cursor sits in menu_coords 4") + eq(idle[1] and idle[1].hollow, false, "solid while the list has the joypad") + + pc.qtyState = { qty = 1, max = 3, prompt = { "How many?" } } + local qtyBoxes, qtyCursors = shot() + eq(qtyCursors[1] and qtyCursors[1].hollow, true, + "hollow once A opened the quantity prompt") + local qtyBox + for _, b in ipairs(qtyBoxes) do + if b[1] == 15 and b[2] == 9 then qtyBox = b end + end + eq(qtyBox and qtyBox[3], 5, "TossItem_MenuHeader is five columns wide") + eq(qtyBox and qtyBox[4], 3, "and three rows tall, bottom-right of the list") + local strayBox = false + for _, b in ipairs(qtyBoxes) do + if b[1] == 7 and b[2] == 15 then strayBox = true end + end + eq(strayBox, false, "and nothing sits at BuyItem_MenuHeader's 7, 15") + + pc.qtyState = nil + pc.confirm = { prompt = { "Throw away?" }, choice = 1 } + local _, confirmCursors = shot() + eq(confirmCursors[1] and confirmCursors[1].hollow, true, + "hollow under the yes/no too") + + pc.confirm = nil + pc.message = { pages = { { "Withdrew POTION." } }, page = 1 } + local _, messageCursors = shot() + eq(messageCursors[1] and messageCursors[1].hollow, true, + "and under the text box A ends on") + + pc.message = nil + local _, backCursors = shot() + eq(backCursors[1] and backCursors[1].hollow, false, + "filling back in when the list gets the joypad back") + + Chrome.print, Chrome.box = saved.print, saved.box + Chrome.cursor, Chrome.clear = saved.cursor, saved.clear end -- An empty bag never opens the PACK (.CheckItemsInBag). diff --git a/tests/gen2_summary_test.lua b/tests/gen2_summary_test.lua index 64138f97..08b33c51 100644 --- a/tests/gen2_summary_test.lua +++ b/tests/gen2_summary_test.lua @@ -655,6 +655,42 @@ do love.graphics.draw = realDraw end +-- (engine/pokemon/mon_menu.asm:1036-1042). With no blocking WaitSFX the +do + local Sound = require("src.core.Sound") + local realPlay, realBusy, realFrames = + Sound.play, Sound.sfxBusy, Sound.waitFramesFor + local rang = {} + Sound.play = function(_, name) rang[#rang + 1] = name end + DATA.audio = { sfx = { Sfx_SwitchPokemon = {} } } + + local swap, swapInput = newSummary() + swap.page = SummaryMenu.GREEN_PAGE + swapInput:press("select") + swap:update(0) + check("the move screen is up", swap.moveDetail, true) + swapInput:press("a") + swap:update(0) + check("A picks a move up", swap.swapFrom, 1) + swapInput:press("down") + swap:update(0) + swapInput:press("a") + swap:update(0) + check("the moves swapped", swap.mon.moves[1].id, "EMBER") + check("and it beeps once in that frame", #rang, 1) + swap:update(0) + check("the second beep follows on the next tick", #rang, 2) + check("both are the switch cue", + rang[1] == "Sfx_SwitchPokemon" and rang[2] == "Sfx_SwitchPokemon", true) + swap:update(0) + check("and there is no third", #rang, 2) + + swap:swapMoves(1, 2) + DATA.audio = nil + Sound.play, Sound.sfxBusy, Sound.waitFramesFor = + realPlay, realBusy, realFrames +end + print(("gen2 summary: %d checks, %d failures"):format(checks, failures)) -- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an -- exit here takes the whole tier down with it and silently skips every diff --git a/tests/mod_world_tests.lua b/tests/mod_world_tests.lua index f1ea223c..92bf2247 100644 --- a/tests/mod_world_tests.lua +++ b/tests/mod_world_tests.lua @@ -971,7 +971,15 @@ do check(battle ~= nil, "the transition hands off to the battle") battle.participants = { [caterpie] = true } + local preAward = {} + for _, row in ipairs(battle.queue) do preAward[row] = true end battle:awardExp() + -- experience.asm:158-200: the level write rides a queued row, so run the + local awarded = {} + for _, row in ipairs(battle.queue) do + if row.fn and not preAward[row] then awarded[#awarded + 1] = row.fn end + end + for _, fn in ipairs(awarded) do battle.nextInsert = 0 fn() end check(caterpie.level >= 7, "the mon levels past its evolution threshold") check(battle.leveledUp and battle.leveledUp[caterpie], "awardExp records the level-up for EvolveAfterBattle") diff --git a/tools/make_gold_manifest.py b/tools/make_gold_manifest.py index 3a56cc88..eb75b28f 100644 --- a/tools/make_gold_manifest.py +++ b/tools/make_gold_manifest.py @@ -778,6 +778,9 @@ REQUIRED_SYMBOLS = { # nine fill cells. "HP:" and the ten HP-bar cells come from # FontBattleExtra, which is already extracted. "EnemyHPBarBorderGFX", "HPExpBarBorderGFX", "ExpBarGFX", + # gfx/battle/expbarend.2bpp, the single OBJ tile AnimateEndOfExpBar bursts + # out of the bar (engine/sprite_anims/core.asm:547-608). + "EndOfExpBarGFX", # gfx/battle/balls.2bpp, the four party-ball OAM tiles # (engine/battle/trainer_huds.asm LoadBallIconGFX). "LoadBallIconGFX.gfx", diff --git a/tools/make_rom_manifest.py b/tools/make_rom_manifest.py index 3e29bb6d..a69bf6ed 100755 --- a/tools/make_rom_manifest.py +++ b/tools/make_rom_manifest.py @@ -538,6 +538,7 @@ DIRECT_SYMBOLS = { "CircleTile", "CryData", "DoorTileIDPointers", + "ED_Tile", "EvosMovesPointerTable", "FlowerTile1", "FlowerTile2", diff --git a/tools/rom_manifest.json b/tools/rom_manifest.json index 20132e26..46f4e842 100644 --- a/tools/rom_manifest.json +++ b/tools/rom_manifest.json @@ -20266,6 +20266,10 @@ 12, 17536 ], + "ED_Tile": [ + 1, + 26471 + ], "EeveePicBack": [ 11, 26149 diff --git a/tools/rom_manifest_blue.json b/tools/rom_manifest_blue.json index 083e4ae2..210f43f4 100644 --- a/tools/rom_manifest_blue.json +++ b/tools/rom_manifest_blue.json @@ -20243,6 +20243,10 @@ 12, 17536 ], + "ED_Tile": [ + 1, + 26471 + ], "EeveePicBack": [ 11, 26149 diff --git a/tools/rom_manifest_crystal.json b/tools/rom_manifest_crystal.json index 82b53a95..e6bf0a0a 100644 --- a/tools/rom_manifest_crystal.json +++ b/tools/rom_manifest_crystal.json @@ -15672,6 +15672,10 @@ 32, 20144 ], + "EndOfExpBarGFX": [ + 35, + 26612 + ], "EnemyHPBarBorderGFX": [ 62, 19136 diff --git a/tools/rom_manifest_gold.json b/tools/rom_manifest_gold.json index 493227bd..bd71dd21 100644 --- a/tools/rom_manifest_gold.json +++ b/tools/rom_manifest_gold.json @@ -13944,6 +13944,10 @@ 64, 22629 ], + "EndOfExpBarGFX": [ + 35, + 26452 + ], "EnemyHPBarBorderGFX": [ 62, 19378 diff --git a/tools/rom_manifest_silver.json b/tools/rom_manifest_silver.json index 1e8bfc5c..d4bda79e 100644 --- a/tools/rom_manifest_silver.json +++ b/tools/rom_manifest_silver.json @@ -13944,6 +13944,10 @@ 64, 22629 ], + "EndOfExpBarGFX": [ + 35, + 26426 + ], "EnemyHPBarBorderGFX": [ 62, 19378 diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index d98a56d7..6444ea23 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -21401,6 +21401,10 @@ 12, 17610 ], + "ED_Tile": [ + 1, + 25829 + ], "EeveePicBack": [ 11, 26243