diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 38839c28..6b5baf3d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5927,7 +5927,10 @@ function BattleState:finish() -- the battle screen closes; leaving battle brings back the map theme, -- like the overworld reload's PlayDefaultMusicFadeOutCurrent -- (home/overworld.asm:2343-2348) - require("src.core.Music").restoreMap(self.data) + -- engine/battle/core.asm:928 + if not (self.result == "win" and self.musicKind == "final") then + require("src.core.Music").restoreMap(self.data) + end self.game.stack:pop() Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) -- Coming back from the battle screen is a fade, not a cut: EnterMap sees diff --git a/src/core/game3/fldeff_misc.lua b/src/core/game3/fldeff_misc.lua index 7698c6d1..2d15b452 100644 --- a/src/core/game3/fldeff_misc.lua +++ b/src/core/game3/fldeff_misc.lua @@ -355,7 +355,7 @@ function FldeffMisc.hallOfFameRecord() -- pokeemerald/src/field_effect.c:1156 playSe("SE_BALL") local o = OFFS[i] or OFFS[1] - balls[i] = Rse().spawn("pokeball_glow", { layer = "front", x = ox + 117 + o[1] + 4, y = oy + 52 + o[2] + 4, + balls[i] = Rse().spawn("pokeball_glow", { layer = "front", x = ox + 117 + o[1], y = oy + 52 + o[2], stopOnEnd = false, keep = function() return FldeffMisc._active[name] == token end }) end if t == n * 25 + 32 then diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 93921696..2f5f971f 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -2944,7 +2944,7 @@ local function buildModsPanel(imp, x, y, w, availH, m) segLine("small", segs, px, ly, textW) elseif orderSort and mod.orderNote then Kit.text("small", Kit.ellipsize("small", Strings(mod.orderNote), textW), - px, ly, PAL.yellow) + px, ly, mod.orderNoteOptional and PAL.detail or PAL.yellow) elseif (mod.description or "") ~= "" then Kit.text("small", Kit.ellipsize("small", mod.description, textW), px, ly, PAL.detail) @@ -5279,7 +5279,8 @@ local function buildModActionsModal(imp, m) if not mod then imp._modActions = nil return end local hasGit = mod.github and mod.github ~= "" local depSpecs = mod.dependencySpecs or (mod.manifest and mod.manifest.dependencySpecs) - local hasDeps = depSpecs and #depSpecs > 0 + local optSpecs = mod.manifest and mod.manifest.optionalSpecs + local hasDeps = (depSpecs and #depSpecs > 0) or (optSpecs and #optSpecs > 0) local imports = mod.imports or mod.requiredImports local hasImports = imports and #imports > 0 local info = hasGit and imp:_modUpdateInfo(mod.id) @@ -5924,7 +5925,7 @@ local function buildDepResolverModal(imp, m) local n = #(res.deps or {}) local anyUnsatisfied = false for _, d in ipairs(res.deps or {}) do - if d.status ~= "satisfied" and d.status ~= "disabled" then anyUnsatisfied = true; break end + if not d.optional and d.status ~= "satisfied" and d.status ~= "disabled" then anyUnsatisfied = true; break end end local totalContentH = n > 0 and (n * rowH + (n - 1) * gap) or 0 @@ -5948,7 +5949,9 @@ local function buildDepResolverModal(imp, m) cy = cy + Kit.textHeight("button") + math.floor(4 * m.s) -- Subtitle / intro - local subText = Strings("This mod requires additional dependencies or has conflicts:") + local subText = res.hasIssues + and Strings("This mod requires additional dependencies or has conflicts:") + or Strings("Dependencies for this mod:") Kit.text("small", subText, px + pad, cy, PAL.muted) cy = cy + Kit.textHeight("small") + math.floor(10 * m.s) @@ -6020,6 +6023,18 @@ local function buildDepResolverModal(imp, m) statusText = Strings("Missing") statusCol = PAL.red end + if dep.kind == "optional" then + if dep.status == "satisfied" then + statusText = Strings("Optional: installed (v%s)", tostring(dep.installedVersion or "?")) + elseif dep.status == "incompatible" then + statusText = Strings("Optional: installed v%s, works with %s", tostring(dep.installedVersion or "?"), tostring(dep.range or "")) + else + statusText = Strings("Optional: not installed") + end + statusCol = dep.status == "satisfied" and PAL.green or PAL.muted + elseif dep.kind == "dependency" then + statusText = Strings("Required: ") .. statusText + end Kit.text("micro", statusText, ix, ry + math.floor(8 * m.s) + Kit.textHeight("small") + math.floor(2 * m.s), statusCol) -- Repo source line or Conflict reason @@ -6120,7 +6135,7 @@ local function buildDepResolverModal(imp, m) kind = "accent", font = "small", action = function() for _, dep in ipairs(res.deps or {}) do - if dep.github and dep.status ~= "satisfied" and dep.status ~= "disabled" and imp._startDepPull then + if not dep.optional and dep.github and dep.status ~= "satisfied" and dep.status ~= "disabled" and imp._startDepPull then imp:_startDepPull(dep) end end diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index af8fc963..0944a47d 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -114,9 +114,9 @@ end -- Resolves the best-known GitHub owner/repo string for a dependency spec, if any. function LauncherMods.resolveDependencyRepo(depId, parentManifest, installedDep) if not depId or depId == "" then return nil end - -- 1. Check spec hint if parentManifest dependencySpecs carries it - if parentManifest and parentManifest.dependencySpecs then - for _, spec in ipairs(parentManifest.dependencySpecs) do + for _, specs in ipairs({ parentManifest and parentManifest.dependencySpecs or {}, + parentManifest and parentManifest.optionalSpecs or {} }) do + for _, spec in ipairs(specs) do if spec.id == depId and spec.github then return spec.github end @@ -169,10 +169,16 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan local depsResult = {} local hasIssues = false - -- 1. Hard Dependencies (dependencySpecs) - if type(manifest.dependencySpecs) == "table" then - for _, spec in ipairs(manifest.dependencySpecs) do - if not version or ModTargets.specApplies(spec, version) then + local depIdsSeen = {} + for _, group in ipairs({ + { specs = manifest.dependencySpecs, kind = "dependency" }, + { specs = manifest.optionalSpecs, kind = "optional" }, + }) do + for _, spec in ipairs(type(group.specs) == "table" and group.specs or {}) do + if not depIdsSeen[spec.id] + and (not version or ModTargets.specApplies(spec, version)) then + depIdsSeen[spec.id] = true + local required = group.kind == "dependency" local depId = spec.id local range = spec.range local installedDep = installedMap[depId] @@ -181,10 +187,10 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan if not installedDep then status = "missing" - hasIssues = true + if required then hasIssues = true end elseif range and not Semver.satisfies(installedDep.version, range) then status = "incompatible" - hasIssues = true + if required then hasIssues = true end end local ghRepo = LauncherMods.resolveDependencyRepo(depId, manifest, installedDep) @@ -195,7 +201,8 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan name = (installedDep and installedDep.name) or depId, range = range, status = status, - kind = "dependency", + kind = group.kind, + optional = not required, installedVersion = installedVersion, github = ghRepo, safeUrl = safeUrl, @@ -366,20 +373,31 @@ function LauncherMods.deriveList(manifests, options, version) row.loadRank = rank[row.id] or (#full + 1) local m = byId[row.id] local after - local function consider(spec) + local seen, names = {}, { required = {}, optional = {} } + local function consider(spec, group) local dep = spec and spec.id - if dep and dep ~= row.id and rank[dep] and rank[dep] > row.loadRank + if dep and dep ~= row.id and not seen[dep] and rank[dep] + and rank[dep] > row.loadRank and ModTargets.specApplies(spec, version, generation) then + seen[dep] = true + local list = names[group] + list[#list + 1] = tostring(byId[dep] and byId[dep].name or dep) if not after or rank[dep] > rank[after] then after = dep end end end - for _, spec in ipairs(m.dependencySpecs or {}) do consider(spec) end - for _, spec in ipairs(m.optionalSpecs or {}) do consider(spec) end + for _, spec in ipairs(m.dependencySpecs or {}) do consider(spec, "required") end + for _, spec in ipairs(m.optionalSpecs or {}) do consider(spec, "optional") end if after then - local dep = byId[after] + local parts = {} + if #names.required > 0 then + parts[#parts + 1] = table.concat(names.required, ", ") .. " (required)" + end + if #names.optional > 0 then + parts[#parts + 1] = table.concat(names.optional, ", ") .. " (optional)" + end row.orderAfter = after - row.orderNote = "Loads after " .. tostring(dep and dep.name or after) - .. " (dependency)" + row.orderNote = "Loads after " .. table.concat(parts, "; ") + row.orderNoteOptional = #names.required == 0 end end return out diff --git a/src/ui/HallOfFame.lua b/src/ui/HallOfFame.lua index c350a57c..7d489e95 100644 --- a/src/ui/HallOfFame.lua +++ b/src/ui/HallOfFame.lua @@ -23,6 +23,11 @@ HallOfFame.isOpaque = true -- SGB: SetPal_PokemonWholeScreen for the mon on display function HallOfFame:sgbPalettes(game) local P = require("src.render.PaletteFX") + if self.phase == "intro" then + local ow = game.overworld + if ow and ow.sgbPalettes then return ow:sgbPalettes(game) end + return nil + end if self.phase == "player" or self.phase == "player_dex" or self.phase == "player_rating" then return P.wholeNamed(game.data, "MEWMON") @@ -67,6 +72,13 @@ local INFO_HOLD = 80 local HOF_HOLD = 180 local FADE_FRAMES = 20 +-- scripts/HallOfFame.asm:24 +local INTRO_DELAY = 3 +-- home/fade.asm:26 +local INTRO_FADE = 24 +-- engine/movie/hall_of_fame.asm:4 +local INTRO_HOLD = 100 + -- HoFPrintTextAndDelay after each dex line local DEX_HOLD = 120 @@ -91,7 +103,10 @@ function HallOfFame.new(game, onDone) self.onDone = onDone self.index = 0 self.timer = 0 - self.phase = "mons" + self.phase = "intro" + self.introT = 0 + self.isOpaque = false + self.introFade = require("src.render.Transition").whiteOut(game) self.sprites = {} -- species -> image or false self.spriteTrueColor = {} -- species -> full-color art flag (#637) self.backs = {} -- species (or "@player") -> back image or false (#847) @@ -107,12 +122,21 @@ function HallOfFame.new(game, onDone) return self end -function HallOfFame:enter() - local data = self.game.data - if data.audio and data.audio.songs and data.audio.songs.Music_HallOfFame then - pcall(Music.play, data, "Music_HallOfFame") +function HallOfFame:updateIntro() + self.introT = self.introT + 1 + if self.introT == INTRO_DELAY + 1 then + -- engine/movie/hall_of_fame.asm:283 + Music.fadeOut(10) + end + if self.introT > INTRO_DELAY + INTRO_FADE then self.isOpaque = true end + if self.introT >= INTRO_DELAY + INTRO_FADE + INTRO_HOLD then + -- engine/movie/hall_of_fame.asm:38 + local data = self.game.data + if data.audio and data.audio.songs and data.audio.songs.Music_HallOfFame then + pcall(Music.play, data, "Music_HallOfFame") + end + self:nextMon() end - self:nextMon() end function HallOfFame:nextMon() @@ -194,6 +218,10 @@ function HallOfFame:update(dt) -- .ScrollPic with d = $a0, e = 4: the back pic crosses the screen right to -- left and is gone before the front pic starts. Both scrolls are plain -- DelayFrame loops in the ROM, so neither takes a button (#847). + if self.phase == "intro" then + self:updateIntro() + return + end if self.phase == "back" then self.scrollX = self.scrollX - SCROLL_SPEED if self.scrollX <= BACK_END_X then @@ -370,6 +398,13 @@ function HallOfFame:drawPlayerStats() end function HallOfFame:draw() + if self.phase == "intro" and not self.isOpaque then + if self.introT > INTRO_DELAY then + self.introFade.t = self.introT - INTRO_DELAY + self.introFade:draw() + end + return + end love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) diff --git a/src/world/Player.lua b/src/world/Player.lua index a7c9ca78..e91baecb 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -272,7 +272,14 @@ local function textBoxUp() end function Player:walkPhase() - if textBoxUp() then return 0 end + if textBoxUp() then + -- engine/overworld/movement.asm:49 + if not self.moving then + self.bumpFrames = nil + self.animClock = 0 + end + return 0 + end -- moving, the land-frame after a completed step, or an active wall-bonk -- (issue #230) animate; a standing sprite otherwise if not self.moving and not self.stepLanded diff --git a/tests/drivers/champion_alt_tempo_bug847_test.lua b/tests/drivers/champion_alt_tempo_bug847_test.lua index bf22c76b..825b865f 100644 --- a/tests/drivers/champion_alt_tempo_bug847_test.lua +++ b/tests/drivers/champion_alt_tempo_bug847_test.lua @@ -242,6 +242,10 @@ return function(game) -- --------------------------------------------------------------- -- the back-pic sweep ahead of the first front pic -- --------------------------------------------------------------- + for _ = 1, 300 do + if hof.phase ~= "intro" then break end + U.wait(1) + end check("the induction opens on the back pic sweep, at the right edge", hof.phase == "back" and (hof.scrollX or 0) > 96) local sweepShot = false diff --git a/tests/drivers/champion_victory_music_bug2835.lua b/tests/drivers/champion_victory_music_bug2835.lua new file mode 100644 index 00000000..813e6dc4 --- /dev/null +++ b/tests/drivers/champion_victory_music_bug2835.lua @@ -0,0 +1,93 @@ +-- engine/battle/core.asm:928 +-- scripts/ChampionsRoom.asm:112 +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 Music = require("src.core.Music") + local BattleState = require("src.battle.BattleState") + os.execute('mkdir -p "' .. DIR .. '" 2>/dev/null') + + local failed = false + local function check(label, ok) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failed = true end + return ok + end + + U.newGame(game) + local mon = Pokemon.new(game.data, "MEWTWO", 100) + mon.moves = { { id = "SWIFT", pp = 20, maxPp = 20, ppUps = 0 } } + game.save.party = { mon } + game.save.player.name = "RED" + + local realNewTrainer = BattleState.newTrainer + local battle + BattleState.newTrainer = function(...) + local b = realNewTrainer(...) + for _, m in ipairs(b.enemyParty or {}) do m.hp = 1 end + battle = b + return b + end + + U.teleport(game, "CHAMPIONS_ROOM", 4, 3, "up") + U.wait(20) + local ow = game.overworld + check("overworld up in CHAMPIONS_ROOM", ow ~= nil and ow.map.id == "CHAMPIONS_ROOM") + if not ow then love.event.quit(1) return end + local rival + for _, npc in ipairs(ow.npcs or {}) do + if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then rival = npc end + end + local rows = require("data.scripts.init").get("CHAMPIONS_ROOM").talk.TEXT_CHAMPIONSROOM_RIVAL + ow:queueScript(rows, { npc = rival }) + + for _ = 1, 3000 do + if battle and game.stack:top() == battle then break end + U.tap(game, "a") + U.wait(4) + end + check("rival battle on screen", battle ~= nil and game.stack:top() == battle) + check("rival battle is the final battle", battle and battle.musicKind == "final") + + local b = game.data.audio and game.data.audio.battle or {} + local gymWin = b.gymWin + for _ = 1, 4000 do + if battle and game.stack:top() ~= battle and battle.result then break end + U.tap(game, "a") + U.wait(6) + end + BattleState.newTrainer = realNewTrainer + check("rival defeated", battle and battle.result == "win") + + local cues = {} + local realPlay = Music.play + Music.play = function(data, song, loop, ctx) + cues[#cues + 1] = song + return realPlay(data, song, loop, ctx) + end + + U.wait(10) + check("battle screen closed", game.stack:top() ~= battle) + check("MUSIC_DEFEATED_GYM_LEADER still playing after the battle screen closes", + gymWin ~= nil and Music.current() == gymWin) + U.still(game, DIR .. "/2835_01_after_battle_victory_theme.png") + + local sawCities, between = false, {} + for _ = 1, 3000 do + if Music.current() == "Music_Cities1" then sawCities = true break end + U.tap(game, "a") + U.wait(4) + end + Music.play = realPlay + for _, s in ipairs(cues) do + if s == "Music_Cities1" then break end + between[#between + 1] = s + end + check("Oak's arrival switches to Cities1", sawCities) + check("no map theme cue between the battle and Oak's arrival (got " + .. table.concat(between, ",") .. ")", #between == 0) + U.still(game, DIR .. "/2835_02_oak_arrives_cities1.png") + + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/em_hof_record_balls_2838.lua b/tests/drivers/em_hof_record_balls_2838.lua new file mode 100644 index 00000000..b5daff8d --- /dev/null +++ b/tests/drivers/em_hof_record_balls_2838.lua @@ -0,0 +1,86 @@ +-- pokeemerald/src/field_effect.c:1066 FldEff_HallOfFameRecord +-- pokeemerald/src/field_effect.c:597 sPokeballCoordOffsets +local U = require("tests.drivers.util") +local F = require("tests.drivers.em_fa_util") +local ST = require("tests.drivers.em_story_util") +local S = F.new("em_hof_record_balls_2838", os.getenv("POKEPORT_SHOT_DIR") or "/tmp/em_hof_record_balls_2838") + +local EXPECT = { { 117, 52 }, { 123, 52 }, { 117, 56 }, { 123, 56 }, { 117, 60 }, { 123, 60 } } + +return function(game) + if not F.boot(game, S) then return S.finish() end + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Message = require("src.ui.game3.message") + local Misc = require("src.core.game3.fldeff_misc") + local Rse = require("src.core.game3.field_effects_rse") + local session = Runtime.getSession() + if not S.check(session.version == "emerald", "driver runs Emerald") then return S.finish() end + + for _, sp in ipairs({ "SPECIES_SCEPTILE", "SPECIES_BLAZIKEN", "SPECIES_SWAMPERT", + "SPECIES_GARDEVOIR", "SPECIES_AGGRON", "SPECIES_SALAMENCE" }) do + ST.giveMon(sp, 50) + end + local n = 0 + for _, m in pairs(session.party or {}) do if m then n = n + 1 end end + S.check(n == 6, "party of six (" .. n .. ")") + + F.settle(game, 300) + local ok, err = pcall(function() + Map.load(nil, game, "EM_EVER_GRANDE_CITY_HALL_OF_FAME", { x = 7, y = 16, facing = "up" }) + end) + if not ok then S.note("Map.load error: " .. tostring(err)) end + session = Runtime.getSession() + S.check(ok and session.map == "EM_EVER_GRANDE_CITY_HALL_OF_FAME", "warped into the Hall of Fame") + + local started + for _ = 1, 3000 do + if Misc._active and Misc._active.FLDEFF_HALL_OF_FAME_RECORD then started = true break end + if Message.isOpen and Message.isOpen() then U.tap(game, "a") end + U.wait(1) + end + if not S.check(started == true, "FLDEFF_HALL_OF_FAME_RECORD started from the map script") then return S.finish() end + S.check(Player.cellX == 7 and Player.cellY == 5 and Player.facing == "up", + ("player in front of the machine at (%s,%s) facing %s"):format(tostring(Player.cellX), + tostring(Player.cellY), tostring(Player.facing))) + + local function balls() + local out = {} + for _, e in ipairs(Rse._list) do + if e.name == "pokeball_glow" then out[#out + 1] = e end + end + return out + end + U.wait(8) + S.still(game, "2838_01_first_ball.png") + for _ = 1, 200 do + if #balls() >= 6 then break end + U.wait(1) + end + U.wait(4) + local list = balls() + S.check(#list == 6, "six glow balls placed (" .. #list .. ")") + S.still(game, "2838_02_six_balls_on_machine.png") + + local ox, oy = Player.px - 112, Player.py - 72 + for i, want in ipairs(EXPECT) do + local e = list[i] + local sx, sy = e and (e.x - ox), e and (e.y - oy) + S.check(e ~= nil and sx == want[1] and sy == want[2], + ("ball %d screen center (%s,%s) == pret (%d,%d)"):format(i, tostring(sx), tostring(sy), want[1], want[2])) + if e then + local tl = math.floor(e.x - e.sheet.fw / 2) - ox + local tt = math.floor(e.y - e.sheet.fh / 2) - oy + S.check(tl == want[1] - 4 and tt == want[2] - 4, + ("ball %d OAM top-left (%d,%d) == (%d,%d)"):format(i, tl, tt, want[1] - 4, want[2] - 4)) + end + end + + for _ = 1, 200 do + if not (Misc._active and Misc._active.FLDEFF_HALL_OF_FAME_RECORD) then break end + U.wait(1) + end + S.check(not (Misc._active and Misc._active.FLDEFF_HALL_OF_FAME_RECORD), "record effect ends") + return S.finish() +end diff --git a/tests/drivers/hall_of_fame_bug704_test.lua b/tests/drivers/hall_of_fame_bug704_test.lua index 6796cbcf..ff5885ff 100644 --- a/tests/drivers/hall_of_fame_bug704_test.lua +++ b/tests/drivers/hall_of_fame_bug704_test.lua @@ -231,6 +231,10 @@ return function(game) -- #847: HoFShowMonOrPlayer sweeps the BACK pic across the screen (low, at -- y=88) before the front pic scrolls in. Catch it mid-sweep, wait the -- sweep out, then catch the front pic partway through its own scroll. + for _ = 1, 300 do + if hof.phase ~= "intro" then break end + U.wait(1) + end for _ = 1, 300 do if hof.phase ~= "back" or (hof.scrollX or 0) <= 56 then break end U.wait(1) diff --git a/tests/drivers/hof_intro_fade_bug2836.lua b/tests/drivers/hof_intro_fade_bug2836.lua new file mode 100644 index 00000000..5dc8ff82 --- /dev/null +++ b/tests/drivers/hof_intro_fade_bug2836.lua @@ -0,0 +1,73 @@ +-- engine/movie/hall_of_fame.asm:1 +-- scripts/HallOfFame.asm:24 +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 Music = require("src.core.Music") + local HallOfFame = require("src.ui.HallOfFame") + os.execute('mkdir -p "' .. DIR .. '" 2>/dev/null') + + local failed = false + local function check(label, ok) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failed = true end + return ok + end + + U.newGame(game) + game.save.party = { Pokemon.new(game.data, "NIDOKING", 63) } + game.save.player.name = "RED" + game.save.pendingHallOfFame = true + U.teleport(game, "HALL_OF_FAME", 4, 7, "up") + + local hof + for _ = 1, 3000 do + local top = game.stack:top() + if getmetatable(top) == HallOfFame then hof = top break end + U.tap(game, "a") + U.wait(3) + end + check("the induction started", hof ~= nil) + if not hof then love.event.quit(1) return end + + check("it opens on the fade, not the back pic sweep", hof.phase == "intro") + check("the room is still drawn under the fade", hof.isOpaque == false) + + local opaqueAt, lastIntro + local midFadeShot, pauseShot, heardHof = false, false, false + for _ = 1, 400 do + if hof.phase ~= "intro" then break end + lastIntro = hof.introT + if Music.current() == "Music_HallOfFame" then heardHof = true end + if hof.isOpaque then + opaqueAt = opaqueAt or hof.introT + if not pauseShot and hof.introT >= 80 then + pauseShot = U.still(game, DIR .. "/2836_02_white_pause.png") + end + elseif not midFadeShot and hof.introT >= 3 + 12 then + midFadeShot = U.still(game, DIR .. "/2836_01_fade_to_white.png") + end + U.wait(1) + end + local pause = (lastIntro or 0) + 1 - ((opaqueAt or 0) - 1) + check("GBFadeOutToWhite runs after Delay3 (opaque white from intro frame " + .. tostring(opaqueAt) .. ")", opaqueAt == 3 + 24 + 1) + check("100 frame white pause before a mon comes in (got " .. pause .. ")", + pause == 100) + check("Music_HallOfFame waits for the pause", not heardHof) + check("mid-fade screenshot", midFadeShot) + check("white pause screenshot", pauseShot) + check("the back pic sweep follows, entering from the right", + hof.phase == "back" and (hof.scrollX or 0) >= 150) + check("Music_HallOfFame starts with the sweep", + Music.current() == "Music_HallOfFame") + for _ = 1, 60 do + if hof.phase ~= "back" or (hof.scrollX or 0) <= 120 then break end + U.wait(1) + end + check("back pic entering screenshot", + U.still(game, DIR .. "/2836_03_back_pic_enters.png")) + + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/parcel_cutscene_stand_2839.lua b/tests/drivers/parcel_cutscene_stand_2839.lua new file mode 100644 index 00000000..19264f7a --- /dev/null +++ b/tests/drivers/parcel_cutscene_stand_2839.lua @@ -0,0 +1,93 @@ +-- engine/overworld/movement.asm:49 +-- home/overworld.asm:108 +-- scripts/OaksLab.asm:1011 +return function(game) + local U = dofile("tests/drivers/util.lua") + local Bag = require("src.inventory.Bag") + local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") + or "/tmp/2839" + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print("[2839] " .. (cond and "PASS " or "FAIL ") .. line) + end + local function finish() + print("[2839] " .. (fails == 0 and "ALL PASS" or (fails .. " FAILURES"))) + love.event.quit(fails == 0 and 0 or 1) + end + + 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 = true + Bag.add(game.save, "OAKS_PARCEL", 1) + game.save.objectToggles = game.save.objectToggles or {} + game.save.objectToggles.OAKS_LAB = game.save.objectToggles.OAKS_LAB or {} + game.save.objectToggles.OAKS_LAB.OAKSLAB_OAK1 = true + game.save.objectToggles.OAKS_LAB.OAKSLAB_RIVAL = false + + U.teleport(game, "OAKS_LAB", 5, 5, "down") + U.wait(10) + local ow = game.overworld + local oak = ow and ow:npcByIndex(5) + if not oak then + ok(false, "Oak is in the lab") + return finish() + end + local ox, oy = oak.cellX, oak.cellY + U.teleport(game, "OAKS_LAB", ox - 1, oy, "right") + U.wait(10) + ow = game.overworld + local p = ow.player + ok(p.cellX == ox - 1 and p.cellY == oy, "player stands left of Oak at " + .. (ox - 1) .. "," .. oy) + + local sawWalk = false + for _ = 1, 22 do + table.insert(game.input.pressQueue, "right") + game.input.state.right = true + coroutine.yield() + if p:walkPhase() == 1 then sawWalk = true end + end + ok(not p.moving, "Oak blocks the step") + ok(sawWalk, "the walk-in-place cycle runs while pushing into Oak") + local clock = p.animClock or 0 + print(string.format("[2839] bump state at talk: bumpFrames=%s animClock=%d", + tostring(p.bumpFrames), clock)) + + U.tap(game, "a") + game.input.state.right = false + U.wait(4) + ok(game.stack:top() ~= ow, "the A press opened Oak's parcel text") + U.still(game, DIR .. "/2839_01_parcel_text.png") + + local badFrames, worldFrames, shotWalk, shotStand = 0, 0, false, false + for _ = 1, 3000 do + if not ow.runner:isRunning() and game.stack:top() == ow then break end + if game.stack:top() == ow then + worldFrames = worldFrames + 1 + if not p.moving and p:walkPhase() ~= 0 then + badFrames = badFrames + 1 + if not shotWalk then + U.still(game, DIR .. "/2839_02_walk_pose_between_boxes.png") + shotWalk = true + end + elseif not shotStand and worldFrames >= 3 then + U.still(game, DIR .. "/2839_03_standing_between_boxes.png") + shotStand = true + end + coroutine.yield() + else + U.tap(game, "a") + U.wait(2) + end + end + print(string.format("[2839] overworld frames inside the cutscene: %d, walk-pose frames: %d", + worldFrames, badFrames)) + ok(worldFrames > 0, "the cutscene ran overworld frames between its text boxes") + ok(badFrames == 0, "the player holds the standing frame through the parcel cutscene") + ok(flags.EVENT_GOT_POKEDEX == true, "the cutscene ran to the Pokedex hand-off") + finish() +end diff --git a/tests/engine/champion_victory_music_bug2835.lua b/tests/engine/champion_victory_music_bug2835.lua new file mode 100644 index 00000000..94490b43 --- /dev/null +++ b/tests/engine/champion_victory_music_bug2835.lua @@ -0,0 +1,59 @@ +-- engine/battle/core.asm:928 +-- home/overworld.asm:2344 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Sound = require("src.core.Sound") +local Music = require("src.core.Music") +local GameVersion = require("src.core.GameVersion") + +local realStopLoop, realRestore = Sound.stopLoop, Music.restoreMap +local restores = 0 +Sound.stopLoop = function() end +Music.restoreMap = function() restores = restores + 1 end + +local function makeGame() + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 20) } + local stack = { states = {} } + function stack:push(s) self.states[#self.states + 1] = s end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return { data = Data, save = save, stack = stack, + input = { wasPressed = function() return false end, + isDown = function() return false end } } +end + +local function finishWith(kind, result) + local game = makeGame() + local b = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + game.stack:push(b) + b.musicKind = kind + b.result = result + b.evolutionsChecked = true + b.pikachuMoodChecked = true + b.onFinish = function() end + restores = 0 + local ok, err = pcall(b.finish, b) + T.check(ok, kind .. "/" .. result .. ": finish runs" .. (ok and "" or (": " .. tostring(err)))) + return restores +end + +GameVersion.set("red") + +T.eq(finishWith("final", "win"), 0, + "beating RIVAL3 keeps the victory theme playing into the overworld") +T.eq(finishWith("trainer", "win"), 1, + "an ordinary trainer win restores the map theme") +T.eq(finishWith("gym", "win"), 1, + "a gym leader win restores the map theme") +T.eq(finishWith("final", "lose"), 1, + "losing to RIVAL3 restores the map theme") + +Sound.stopLoop, Music.restoreMap = realStopLoop, realRestore + +T.finish() diff --git a/tests/engine/hof_intro_fade_bug2836.lua b/tests/engine/hof_intro_fade_bug2836.lua new file mode 100644 index 00000000..df1bfa6d --- /dev/null +++ b/tests/engine/hof_intro_fade_bug2836.lua @@ -0,0 +1,67 @@ +-- engine/movie/hall_of_fame.asm:1 +-- scripts/HallOfFame.asm:24 +-- home/fade.asm:26 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Music = require("src.core.Music") +local PaletteFX = require("src.render.PaletteFX") +local Transition = require("src.render.Transition") +local GameVersion = require("src.core.GameVersion") +local HallOfFame = require("src.ui.HallOfFame") + +GameVersion.set("red") +Data.audio = Data.audio or {} +Data.audio.songs = Data.audio.songs or {} +Data.audio.songs.Music_HallOfFame = Data.audio.songs.Music_HallOfFame or {} + +local realPlay, realFade = Music.play, Music.fadeOut +local plays, fadeAt = {}, nil +local frame = 0 +Music.play = function(_, song) plays[#plays + 1] = { song = song, frame = frame } end +Music.fadeOut = function() fadeAt = fadeAt or frame end + +local save = SaveData.newGame() +save.party = { Pokemon.new(Data, "FIXMON_A", 20) } +local game = { data = Data, save = save } + +local hof = HallOfFame.new(game, function() end) +T.eq(hof.phase, "intro", "induction opens on the fade, not the back pic sweep") +T.check(not hof.isOpaque, "the map stays drawn under the fade") +T.eq(#plays, 0, "Music_HallOfFame waits for the fade and the pause") + +local shades = {} +local backAt +for f = 1, 200 do + frame = f + hof:update(1 / 60) + if hof.phase == "intro" then + PaletteFX.setShadeMap(nil) + hof:draw() + shades[f] = PaletteFX.shadeMap() + elseif not backAt then + backAt = f + break + end +end + +T.eq(fadeAt, 4, "HoFFadeOutScreenAndMusic fades the music after Delay3") +T.eq(shades[3], nil, "Delay3 leaves the palette alone") +T.eq(shades[4], Transition.shadeMapFor(0x90), "fade step 1 on frame 4") +T.eq(shades[11], Transition.shadeMapFor(0x90), "fade step 1 holds 8 frames") +T.eq(shades[12], Transition.shadeMapFor(0x40), "fade step 2 on frame 12") +T.eq(shades[20], Transition.shadeMapFor(0x00), "fade step 3 on frame 20") +T.check(hof.isOpaque, "the screen is cleared to white after the fade") +T.eq(backAt, 3 + 24 + 100, "100 DelayFrames of white before the first mon") +T.eq(hof.phase, "back", "the back pic sweep starts after the pause") +T.eq(hof.scrollX, 160, "the back pic enters from the right edge") +T.eq(plays[1] and plays[1].song, "Music_HallOfFame", "hall of fame theme starts") +T.eq(plays[1] and plays[1].frame, backAt, "theme starts with the sweep, after the pause") + +Music.play, Music.fadeOut = realPlay, realFade +PaletteFX.setShadeMap(nil) + +T.finish() diff --git a/tests/engine/mod_optional_deps_bug2833.lua b/tests/engine/mod_optional_deps_bug2833.lua new file mode 100644 index 00000000..050fa254 --- /dev/null +++ b/tests/engine/mod_optional_deps_bug2833.lua @@ -0,0 +1,74 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local Manifest = require("src.mods.Manifest") +local LauncherMods = require("src.mods.LauncherMods") + +local function mf(raw) return Manifest.validate(raw) end + +local engine = mf({ id = "g9-battle-engine", name = "G9 Battle Engine", + version = "1.0.0", entry = "m.lua" }) +local scene = mf({ id = "g9-Battle-Scene", name = "G9 Battle Scene", + version = "1.0.0", entry = "m.lua", dependencies = { "g9-battle-engine" } }) +local sprites = mf({ id = "g9-battle-sprites", name = "G9 Battle Sprites", + version = "1.0.0", entry = "m.lua", + optional_dependencies = { "national_dex", "g9-battle-engine", "g9-Battle-Scene" } }) +local dex = mf({ id = "national_dex", name = "National Dex", + version = "1.0.0", entry = "m.lua" }) + +do + local res = LauncherMods.checkDependencies(sprites, nil, nil, { sprites }) + T.eq(res.hasIssues, false, "optional deps alone never raise the resolver") + T.eq(#res.deps, 3, "resolver lists every optional dependency") + for _, d in ipairs(res.deps) do + T.eq(d.kind, "optional", d.id .. " is marked optional") + T.eq(d.status, "missing", d.id .. " reports not installed") + end + + local partial = LauncherMods.checkDependencies(sprites, nil, nil, { sprites, scene }) + T.eq(partial.hasIssues, false, "installed optional dep does not flag the rest") + local status = {} + for _, d in ipairs(partial.deps) do status[d.id] = d.status end + T.eq(status["g9-Battle-Scene"], "satisfied", "installed optional shows satisfied") + T.eq(status["national_dex"], "missing", "absent optional still listed") + + local hard = LauncherMods.checkDependencies(scene, nil, nil, { scene }) + T.eq(hard.hasIssues, true, "a missing hard dependency still raises the resolver") + T.eq(hard.deps[1].kind, "dependency", "hard dependency kind kept") +end + +do + local manifests = { engine, scene, sprites, dex } + local function noteFor(order) + local rows = LauncherMods.deriveList(manifests, { mods = {}, modOrder = order }, "crystal") + for _, r in ipairs(rows) do + if r.id == "g9-battle-sprites" then return r end + end + end + local a = noteFor({ "g9-battle-sprites", "national_dex", "g9-battle-engine", "g9-Battle-Scene" }) + T.check(a.orderNote and a.orderNote:find("(optional)", 1, true) ~= nil, + "optional ordering note says optional: " .. tostring(a.orderNote)) + T.check(a.orderNote and not a.orderNote:find("dependency", 1, true), + "optional ordering note never calls it a dependency") + T.eq(a.orderNoteOptional, true, "optional-only note is not a warning") + for _, name in ipairs({ "National Dex", "G9 Battle Engine", "G9 Battle Scene" }) do + T.check(a.orderNote:find(name, 1, true) ~= nil, "note names " .. name) + end + + local b = noteFor({ "g9-battle-sprites", "g9-Battle-Scene", "g9-battle-engine", "national_dex" }) + T.eq(b.orderNote, a.orderNote, "note text is stable when the later deps reorder") + + local scenes = LauncherMods.deriveList(manifests, + { mods = {}, modOrder = { "g9-Battle-Scene", "g9-battle-engine" } }, "crystal") + for _, r in ipairs(scenes) do + if r.id == "g9-Battle-Scene" then + T.check(r.orderNote and r.orderNote:find("(required)", 1, true) ~= nil, + "hard dependency note says required") + T.check(not r.orderNoteOptional, "hard dependency note stays a warning") + end + end +end + +T.finish("mod optional dependencies (#2833)") diff --git a/tests/parity_hof.lua b/tests/parity_hof.lua index c49ab0ef..773b61fb 100644 --- a/tests/parity_hof.lua +++ b/tests/parity_hof.lua @@ -114,7 +114,14 @@ check(getmetatable(stack2:top()) == HallOfFame, "induction showcase pushed") -- $c0 -> $a0) and only then scrolls the front pic in (#847), so the -- induction opens on the back pass. local hofUi = stack2:top() -eq(hofUi.phase, "back", "induction opens on the back pic sweep (#847)") +eq(hofUi.phase, "intro", "induction opens on the fade to white") +local introGuard = 0 +while hofUi.phase == "intro" and introGuard < 400 do + introGuard = introGuard + 1 + hofUi:update(1 / 60) +end +eq(introGuard, 3 + 24 + 100, "Delay3 + GBFadeOutToWhite + 100 DelayFrames before the sweep") +eq(hofUi.phase, "back", "the back pic sweep follows the fade (#847)") eq(hofUi.scrollX, 160, "back pic enters at the right edge (hSCX = $c0)") -- drive the back sweep and the front scroll so the info box is armed local scrollGuard = 0