Merge pull request #2842 from bryanthaboi/dev

This commit is contained in:
bryanthaboi
2026-10-10 09:24:41 -04:00
committed by GitHub
16 changed files with 670 additions and 32 deletions
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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
+20 -5
View File
@@ -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
+35 -17
View File
@@ -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
+41 -6
View File
@@ -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)
+8 -1
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
+73
View File
@@ -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
@@ -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
@@ -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()
+67
View File
@@ -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()
@@ -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)")
+8 -1
View File
@@ -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