From 2504a36a8433d2c4eee249b577453ae2e350b932 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 2 Sep 2026 12:06:56 -0400 Subject: [PATCH] CLOSES #2087, CLOSES #2107, CLOSES #2108, CLOSES #2109 --- data/scripts/story2.lua | 42 ++++- src/battle/BattleState.lua | 5 +- src/core/ChipSynth.lua | 3 +- src/core/Game.lua | 3 +- src/core/Sound.lua | 5 +- src/render/TextBox.lua | 10 +- .../drivers/battle_cry_speed_bug2087_test.lua | 115 +++++++++++++ .../battle_fanfare_speed_bug1952_test.lua | 15 +- .../drivers/daycare_sfx_bug2107_2108_test.lua | 134 +++++++++++++++ tests/drivers/snorlax_flute_bug2109_test.lua | 106 ++++++++++++ tests/engine/chip_sfx_long_render_bug2109.lua | 65 ++++++++ tests/engine/fanfare_speed_bug1952_test.lua | 48 +++++- .../engine/textbox_sfx_speed_bug2087_test.lua | 154 ++++++++++++++++++ tests/parity_daycare.lua | 107 ++++++++++++ 14 files changed, 791 insertions(+), 21 deletions(-) create mode 100644 tests/drivers/battle_cry_speed_bug2087_test.lua create mode 100644 tests/drivers/daycare_sfx_bug2107_2108_test.lua create mode 100644 tests/drivers/snorlax_flute_bug2109_test.lua create mode 100644 tests/engine/chip_sfx_long_render_bug2109.lua create mode 100644 tests/engine/textbox_sfx_speed_bug2087_test.lua diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index db05c9dd..be567161 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -1012,12 +1012,21 @@ M.DAYCARE = { local t = game.data.text local dc = game.save.daycare local playerName = game.save.player and game.save.player.name or "RED" + local Sound = require("src.core.Sound") local function monName(mon) local def = game.data.pokemon[mon.species] return mon.nickname or (def and def.name) or mon.species end + local function showMoney() return game.save.money end + + -- pokeyellow scripts/Daycare.asm:54 + local function isStarterPika(mon) + return require("src.core.GameVersion").isYellow() + and require("src.world.PikachuFollower").isStarterPikachu(game.save, mon) + end + if dc and dc.mon then local Growth = require("src.pokemon.Growth") local Stats = require("src.pokemon.Stats") @@ -1068,7 +1077,10 @@ M.DAYCARE = { t._DaycareGentlemanOweMoneyText or "You owe me ¥{NUM:wDayCareTotalCost, 2 | LEADING_ZEROES | LEFT_ALIGN}\nfor the return\nof this POKéMON.", subs), - nil, { choice = function(yes) + nil, { + -- scripts/Daycare.asm:133 + money = showMoney, moneyWithChoice = true, + choice = function(yes) if not yes then -- .leaveMonInDayCare: revert any transient level bump mon.level = startLevel @@ -1104,8 +1116,22 @@ M.DAYCARE = { fillDaycareText( t._DaycareGentlemanGotMonBackText or "{PLAYER} got\n{RAM:wDayCareMonName} back!", - subs), done)) - end)) + subs), done, { + money = showMoney, + -- scripts/Daycare.asm:202 + preSound = function() + -- pokeyellow scripts/Daycare.asm:229 + if isStarterPika(mon) then + return Sound.playPikaCry(game.data, 35) + end + return Sound.playCry(game.data, mon.species) + end })) + end, { + -- scripts/Daycare.asm:161 + preSound = function() + return Sound.play(game.data, "Purchase") + end, + money = showMoney })) end })) end)) return @@ -1149,7 +1175,15 @@ M.DAYCARE = { function() game.stack:push(TextBox.new(game, t._DaycareGentlemanComeSeeMeInAWhileText - or "Come see me in\na while.", done)) + or "Come see me in\na while.", done, { + -- scripts/Daycare.asm:58 + preSound = function() + -- pokeyellow scripts/Daycare.asm:66 + if isStarterPika(mon) then + return Sound.playPikaCry(game.data, 28) + end + return Sound.playCry(game.data, mon.species) + end })) end)) end, })) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 900aa296..735493b8 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1375,7 +1375,10 @@ function BattleState:updateQueue() local Sound = require("src.core.Sound") self.waitSoundLeft = Sound.waitFrames and Sound.waitFrames(src) or 180 end - self.waitSoundLeft = self.waitSoundLeft - 1 + local game = self.game + local speed = game and game.logicSpeed and game:logicSpeed() or 1 + if type(speed) ~= "number" or speed ~= speed or speed < 1 then speed = 1 end + self.waitSoundLeft = self.waitSoundLeft - 1 / speed local playing = src and src.isPlaying and src:isPlaying() if playing and self.waitSoundLeft > 0 then return true end if playing then pcall(src.stop, src) end diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index 2f843746..41b1310f 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -1440,7 +1440,8 @@ local function renderEffectData(data, header, options) options.sfx = true options.allowLoops = false local engine = Engine.new(data, header, options) - local maximum = SAMPLE_RATE * 5 + -- audio/sfx/pokeflute.asm:20 + local maximum = SAMPLE_RATE * (options.maxSeconds or 12) local values = {} local count = 0 while count < maximum and not engine:finished() do diff --git a/src/core/Game.lua b/src/core/Game.lua index c78cee4a..3f55bbd5 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -409,8 +409,7 @@ function Game:update(dt) -- Audio runs off real time at a fixed 60Hz regardless of game speed or -- display refresh, so fades and chip synthesis keep their intended tempo -- whether we are at 1X, 10X, or running with vsync disabled. One-shot - -- SFX stay at natural pitch too (#1990/#1991/#1997); WaitForSoundToFinish - -- gates still release early at high speed via their logic-frame budget. + -- SFX stay at natural pitch too (#1990/#1991/#1997). local step = FixedStep.STEP self.audioAccum = math.min((self.audioAccum or 0) + dt, 0.25) while self.audioAccum >= step do diff --git a/src/core/Sound.lua b/src/core/Sound.lua index ae820fcc..e4a3189b 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -184,10 +184,7 @@ local function applyRate(src, base) return src end --- WaitForSoundToFinish budget in logic frames (home/delay.asm:14). --- At N× GAME SPEED the same budget passes N× sooner in wall time, so a --- gate releases early instead of stalling the battle/script on a full-length --- jingle -- without pitching the SFX (#1952 vs #1990). +-- WaitForSoundToFinish budget in 60 Hz frames (home/delay.asm:14). function Sound.waitFrames(src, fallback) if not src then return 0 end local okd, dur = pcall(src.getDuration, src) diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 006eb2ed..74cdaa42 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -28,6 +28,12 @@ local function sfxWaitFrames(src) return Sound.waitFrames(src) end +local function sfxWaitStep(game) + local speed = game and game.logicSpeed and game:logicSpeed() or 1 + if type(speed) ~= "number" or speed ~= speed or speed < 1 then speed = 1 end + return 1 / speed +end + -- theme-free fallbacks; geometry resolves against Theme.textBox at -- construction time, so an unthemed boot stays byte-identical local BOX_TX, BOX_TY, BOX_TW, BOX_TH = 0, 12, 20, 6 @@ -396,7 +402,7 @@ function TextBox:update(dt) self.preSrc = self.preSound() self.preSrcLeft = sfxWaitFrames(self.preSrc) end - self.preSrcLeft = (self.preSrcLeft or 0) - 1 + self.preSrcLeft = (self.preSrcLeft or 0) - sfxWaitStep(self.game) local playing = self.preSrc and self.preSrc.isPlaying and self.preSrc:isPlaying() if playing and self.preSrcLeft > 0 then return end if playing then pcall(self.preSrc.stop, self.preSrc) end @@ -466,7 +472,7 @@ function TextBox:update(dt) if self.auto.tick then self.auto.tick() end -- home/delay.asm:14 if self.autoSrc then - self.autoSrcLeft = (self.autoSrcLeft or 0) - 1 + self.autoSrcLeft = (self.autoSrcLeft or 0) - sfxWaitStep(self.game) if self.autoSrc.isPlaying and self.autoSrc:isPlaying() then if self.autoSrcLeft > 0 then return end pcall(self.autoSrc.stop, self.autoSrc) diff --git a/tests/drivers/battle_cry_speed_bug2087_test.lua b/tests/drivers/battle_cry_speed_bug2087_test.lua new file mode 100644 index 00000000..598e6492 --- /dev/null +++ b/tests/drivers/battle_cry_speed_bug2087_test.lua @@ -0,0 +1,115 @@ +-- home/pokemon.asm:145, home/delay.asm:15, home/text.asm:506 +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Growth = require("src.pokemon.Growth") + local BattleState = require("src.battle.BattleState") + local Sound = require("src.core.Sound") + + game.speedOverride = 4 + + local pikachu = Pokemon.new(game.data, "PIKACHU", 5, function(_, b) return b end) + local def = game.data.pokemon.PIKACHU + pikachu.exp = Growth.expForLevel(def.growthRate, 6, game.data.growth_rates) - 1 + game.save.party = { pikachu } + + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local battle = BattleState.newWild(game, "RATTATA", 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) + + U.log("logic speed", game:logicSpeed(), "sfx rate", Sound.rate()) + if Sound.rate() ~= 1 then + error(("bug2087: Game:update pitched SFX off battle speed (rate %s at 4X)") + :format(tostring(Sound.rate()))) + end + + local starts = setmetatable({}, { __mode = "k" }) + local function stamp(fn) + return function(...) + local src = fn(...) + if src and not starts[src] then starts[src] = love.timer.getTime() end + return src + end + end + Sound.play, Sound.playCry = stamp(Sound.play), stamp(Sound.playCry) + if Sound.playPikaCry then Sound.playPikaCry = stamp(Sound.playPikaCry) end + + local gates = {} + local cur + local function poll(label) + local src = battle.waitingSound + if src and not cur then + local okd, d = pcall(src.getDuration, src) + local okp, p = pcall(src.getPitch, src) + cur = { label = label, src = src, t0 = starts[src] or love.timer.getTime(), + dur = okd and d or nil, pitch = okp and p or nil } + elseif cur and src ~= cur.src then + cur.held = love.timer.getTime() - cur.t0 + table.insert(gates, cur) + cur = nil + if src then poll(label) end + end + end + + local shot = false + for _ = 1, 2400 do + poll("entrance cry") + if battle.phase == "menu" then break end + if battle.waitingSound and not shot then + shot = U.shot(game, DIR .. "/bug2087_cry.png") + end + U.tap(game, "a") + U.wait(2) + end + if battle.phase ~= "menu" then error("bug2087: never reached the FIGHT menu") end + if #gates < 2 then + error(("bug2087: expected the enemy and player cries to arm the gate, saw %d") + :format(#gates)) + 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("bug2087: never reached move select") end + U.tap(game, "a") + + local before = #gates + local shotLevel = false + for _ = 1, 2400 do + poll("post-move sfx") + if game.stack:top() ~= battle then break end + if #gates > before and cur and not shotLevel then + shotLevel = U.shot(game, DIR .. "/bug2087_levelup.png") + end + U.tap(game, "a") + U.wait(1) + end + poll("post-move sfx") + if #gates == before then error("bug2087: the level-up jingle never armed the gate") end + U.shot(game, DIR .. "/bug2087_after.png") + + local fail + for i, g in ipairs(gates) do + U.log(("%s #%d duration %.3fs pitch %s held %.3fs"):format( + g.label, i, g.dur or -1, tostring(g.pitch), g.held or -1)) + if g.pitch and g.pitch ~= 1 then + fail = ("bug2087: %s was pitched with battle speed (%s)"):format( + g.label, tostring(g.pitch)) + elseif g.dur and g.held < g.dur * 0.9 then + fail = ("bug2087: %s cut short at 4X (%.3fs of %.3fs)"):format( + g.label, g.held, g.dur) + end + end + if fail then error(fail) end + U.log("PASS every battle sfx gate held for its real length at 4X") + U.log("input is yours now") +end diff --git a/tests/drivers/battle_fanfare_speed_bug1952_test.lua b/tests/drivers/battle_fanfare_speed_bug1952_test.lua index c18dc244..578abc83 100644 --- a/tests/drivers/battle_fanfare_speed_bug1952_test.lua +++ b/tests/drivers/battle_fanfare_speed_bug1952_test.lua @@ -1,5 +1,5 @@ --- Wait gates must release early at high GAME SPEED (#1952) without --- pitching one-shot SFX (#1990/#1991/#1997). +-- Wait gates hold for the sound's real length at high GAME SPEED +-- (#1952/#2087) without pitching one-shot SFX (#1990/#1991/#1997). return function(game) local U = dofile("tests/drivers/util.lua") local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" @@ -78,10 +78,15 @@ return function(game) dur or -1, tostring(pitch), held)) U.shot(game, DIR .. "/bug1952_after.png") - if dur and held > dur * 0.75 then - error(("bug1952: the 4X battle still held the full fanfare (%.3fs of %.3fs)") + if not dur then error("bug1952: no duration for the level-up fanfare") end + if held < dur * 0.9 then + error(("bug2087: the 4X battle cut the fanfare short (%.3fs of %.3fs)") :format(held, dur)) end - U.log("PASS the fanfare kept natural pitch and the hold released early") + if held > dur + 1 then + error(("bug1952: the fanfare dragged past its length (%.3fs of %.3fs)") + :format(held, dur)) + end + U.log("PASS the fanfare kept natural pitch and played to completion at 4X") love.event.quit() end diff --git a/tests/drivers/daycare_sfx_bug2107_2108_test.lua b/tests/drivers/daycare_sfx_bug2107_2108_test.lua new file mode 100644 index 00000000..2948264d --- /dev/null +++ b/tests/drivers/daycare_sfx_bug2107_2108_test.lua @@ -0,0 +1,134 @@ +-- scripts/Daycare.asm:58,133,161,202 +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 Growth = require("src.pokemon.Growth") + local Sound = require("src.core.Sound") + local TextBox = require("src.render.TextBox") + + local calls = {} + local realPlay, realPlayCry, realPlayPika = + Sound.play, Sound.playCry, Sound.playPikaCry + Sound.play = function(data, name, ...) + calls[#calls + 1] = "sfx:" .. tostring(name) + return realPlay(data, name, ...) + end + Sound.playCry = function(data, species, ...) + calls[#calls + 1] = "cry:" .. tostring(species) + return realPlayCry(data, species, ...) + end + Sound.playPikaCry = function(data, n, ...) + calls[#calls + 1] = "pika:" .. tostring(n) + return realPlayPika(data, n, ...) + end + + local function has(prefix) + for _, c in ipairs(calls) do + if c:sub(1, #prefix) == prefix then return true end + end + return false + end + + local function report(ok, what) + U.log((ok and "PASS " or "FAIL ") .. what) + end + + local function findBox(pred) + for _, st in ipairs(game.stack.states) do + if getmetatable(st) == TextBox and pred(st) then return st end + end + end + + local function inOverworld() + return game.stack:top() == game.overworld + end + + local function advanceUntil(pred, budget) + for _ = 1, (budget or 300) do + if pred() then return true end + U.tap(game, "a") + U.wait(4) + end + return pred() + end + + local function idleUntil(pred, budget) + for _ = 1, (budget or 600) do + if pred() then return true end + U.wait(1) + end + return pred() + end + + game.save.party = { + Pokemon.new(game.data, "RATTATA", 5), + Pokemon.new(game.data, "PIDGEY", 5), + } + game.save.money = 10000 + game.save.player.name = "RED" + game.save.daycare = nil + + U.teleport(game, "DAYCARE", 2, 4, "up") + U.wait(30) + + -- scripts/Daycare.asm:47 + U.tap(game, "a") + U.wait(10) + advanceUntil(function() + local b = findBox(function(st) return st.choicePushed end) + return b ~= nil + end, 120) + U.tap(game, "a") -- YES + U.wait(10) + advanceUntil(function() + return game.stack:top() ~= nil + and game.stack:top().pickOnly ~= nil + end, 120) + U.tap(game, "a") -- slot 1 + U.wait(10) + advanceUntil(function() + return findBox(function(st) return st.preSound ~= nil end) ~= nil + or game.save.daycare ~= nil and has("cry:") + end, 200) + idleUntil(function() return findBox(function(st) return st.preSound ~= nil end) == nil end, 300) + report(game.save.daycare ~= nil and game.save.daycare.mon ~= nil, "mon left at the daycare") + report(has("cry:") or has("pika:"), "cry requested on leave (Daycare.asm:58)") + advanceUntil(inOverworld, 200) + U.wait(20) + + -- scripts/Daycare.asm:131 + local dc = game.save.daycare + if dc and dc.mon then + local def = game.data.pokemon[dc.mon.species] + dc.steps = Growth.expForLevel(def.growthRate, dc.mon.level + 2) + - Growth.expForLevel(def.growthRate, dc.mon.level) + end + calls = {} + U.tap(game, "a") + U.wait(10) + local oweBox + advanceUntil(function() + oweBox = findBox(function(st) return st.choicePushed end) + return oweBox ~= nil + end, 200) + U.wait(10) + report(oweBox ~= nil and oweBox:moneyVisible(), + "OweMoney YES/NO box shows the MONEY box (Daycare.asm:133)") + U.shot(game, DIR .. "/daycare_2107_00_owe_money_box.png") + U.tap(game, "a") -- YES + U.wait(10) + idleUntil(function() return has("sfx:Purchase") end, 120) + report(has("sfx:Purchase"), "SFX_PURCHASE requested on YES (Daycare.asm:161)") + local heres = findBox(function(st) return st.money ~= nil and not st.choice end) + report(heres ~= nil and heres:moneyVisible(), "MONEY box stays up on HeresYourMon") + U.shot(game, DIR .. "/daycare_2107_01_heres_your_mon.png") + advanceUntil(function() return has("cry:") or has("pika:") end, 200) + report(has("cry:") or has("pika:"), "cry requested on take-back (Daycare.asm:202)") + report(game.save.daycare == nil and #game.save.party == 2, "mon back in the party") + U.shot(game, DIR .. "/daycare_2107_02_got_back.png") + U.log("sound calls:", table.concat(calls, ",")) + U.log("shots under", DIR) + + U.log("input is yours now") +end diff --git a/tests/drivers/snorlax_flute_bug2109_test.lua b/tests/drivers/snorlax_flute_bug2109_test.lua new file mode 100644 index 00000000..fdecea9f --- /dev/null +++ b/tests/drivers/snorlax_flute_bug2109_test.lua @@ -0,0 +1,106 @@ +-- pokered audio/sfx/pokeflute.asm:1, engine/items/item_effects.asm:1794 (#2109) +-- POKEPORT_DRIVER=tests/drivers/snorlax_flute_bug2109_test.lua \ +-- POKEPORT_IDENTITY=bug2109 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Screens = require("src.ui.Screens") + local Bag = require("src.inventory.Bag") + local Pokemon = require("src.pokemon.Pokemon") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1; U.log("PASS", label) + else fail = fail + 1; U.log("FAIL", label) end + return ok + end + local function finish() + U.log(("RESULT pass=%d fail=%d"):format(pass, fail)) + U.log("input is yours now") + while true do coroutine.yield() end + end + + local vol = game.save.options and game.save.options.sfxVol + if vol == 0 then + U.log("sfxVol is 0; turn it up in OPTION or the ear half of this check is moot") + end + + game.save.party = { Pokemon.new(game.data, "PIDGEOTTO", 30) } + Bag.add(game.save, "POKE_FLUTE", 1) + + U.teleport(game, "ROUTE_12", 10, 30, "down") + U.wait(20) + local ow = game.overworld + local snorlax + for _, npc in ipairs(ow.npcs or {}) do + if npc.def and tostring(npc.def.name or ""):find("SNORLAX") then snorlax = npc end + end + if not check("ROUTE_12 has its SNORLAX", snorlax ~= nil) then finish() end + U.teleport(game, "ROUTE_12", snorlax.cellX, snorlax.cellY - 1, "down") + U.wait(20) + ow = game.overworld + + local bag = Screens.push(game, "BagMenu") + U.wait(20) + local row + for i, item in ipairs(bag.items) do + if item.value == "POKE_FLUTE" then row = i end + end + if not check("the POKé FLUTE is in the bag", row ~= nil) then finish() end + bag.index = row + U.tap(game, "a") + U.wait(20) + if game.stack:top() ~= bag and not game.stack:top().isTextBox then + U.tap(game, "a") + U.wait(20) + end + + local box + for _ = 1, 240 do + local top = game.stack:top() + if top and top.isTextBox then box = top break end + U.wait(1) + end + if not check("the played-flute box opened", box ~= nil) then finish() end + + for _ = 1, 240 do + if box.done then break end + U.tap(game, "a") + end + check("the line typed out", box.done == true) + check("nothing has played yet", box.autoStarted ~= true and box.autoSrc == nil) + U.log("the box should read ' played the POKé FLUTE.' with a blinking") + U.log("arrow and NO tune yet; the map music is still going") + U.shot(game, "/tmp/shots/bug2109_prompt.png") + + U.tap(game, "a") + U.wait(2) + check("the prompt was answered", box.autoPrompted == true) + if not check("the tune started", box.autoSrc ~= nil) then finish() end + local duration = box.autoSrc:getDuration() + check(("the whole tune rendered (%.2f s, want >= 9.5)"):format(duration), + duration >= 9.5) + U.log("the tune plays now, with the same box still on screen; listen for all") + U.log("13 notes and then about 2.4 s of silence before the box goes away") + + local heldFrames, wokeEarly = 0, false + for _ = 1, 700 do + local playing = box.autoSrc and box.autoSrc.isPlaying and box.autoSrc:isPlaying() + if not playing then break end + heldFrames = heldFrames + 1 + if game.stack:top() ~= box then wokeEarly = true break end + U.tap(game, "a") + end + check("A never cut the tune short", not wokeEarly) + check(("the tune held the box through the rest (%d frames, want >= 570)") + :format(heldFrames), heldFrames >= 570) + + local woke + for _ = 1, 120 do + local top = game.stack:top() + if top and top.isTextBox and top ~= box then woke = top break end + U.wait(1) + end + check("SNORLAX woke up! follows only after the tune and its rest", woke ~= nil) + U.shot(game, "/tmp/shots/bug2109_after.png") + finish() +end diff --git a/tests/engine/chip_sfx_long_render_bug2109.lua b/tests/engine/chip_sfx_long_render_bug2109.lua new file mode 100644 index 00000000..60a0691e --- /dev/null +++ b/tests/engine/chip_sfx_long_render_bug2109.lua @@ -0,0 +1,65 @@ +-- ../pokered/audio/sfx/pokeflute.asm:1 +-- luajit tests/engine/chip_sfx_long_render_bug2109.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check + +love = require("tests.love_stub") + +local ChipAsm = require("src.audio.ChipAsm") +local ChipSynth = require("src.core.ChipSynth") + +local flute = ChipAsm.song{ + tempo = 0x100, + channels = { { hw = 3, program = { + { notetype = { speed = 12, waveLevel = 1, waveInstrument = 0 } }, + { octave = 5 }, + { note = "E", len = 2 }, + { note = "F", len = 2 }, + { note = "G", len = 4 }, + { note = "A", len = 2 }, + { note = "G", len = 2 }, + { octave = 6 }, + { note = "C", len = 4 }, + { note = "C", len = 2 }, + { note = "D", len = 2 }, + { note = "C", len = 2 }, + { octave = 5 }, + { note = "G", len = 2 }, + { note = "A", len = 2 }, + { note = "F", len = 2 }, + { note = "G", len = 8 }, + { rest = 12 }, + } } }, +} +local blobData = { audio = { sfx = {}, cries = {} } } + +local RATE = ChipSynth.SAMPLE_RATE +local EXPECTED = 48 * 12 / 60 + +local engine = ChipSynth.newEngine(blobData, flute, + { sfx = true, allowLoops = false }) +local ran = 0 +while ran < RATE * 12 and not engine:finished() do + ran = ran + 1 + engine:sample() +end +check(engine:finished(), "the flute program reaches sound_ret on its own") +check(math.abs(ran / RATE - EXPECTED) < 0.05, + ("the program runs %.3f s (expected %.1f)"):format(ran / RATE, EXPECTED)) + +local sd = ChipSynth.renderEffectData(blobData, flute, {}) +check(sd ~= nil, "the flute renders") +local seconds = sd and sd:getSampleCount() / RATE or 0 +check(seconds >= 9.5, + ("the rendered effect keeps the whole program: %.3f s"):format(seconds)) +check(math.abs(seconds - EXPECTED) < 0.05, + ("the rendered length matches the program: %.3f s"):format(seconds)) + +local capped = ChipSynth.renderEffectData(blobData, flute, { maxSeconds = 5 }) +check(capped and capped:getSampleCount() == RATE * 5, + "maxSeconds is the only thing that cuts a program short") + +T.finish("long SFX render (#2109)") diff --git a/tests/engine/fanfare_speed_bug1952_test.lua b/tests/engine/fanfare_speed_bug1952_test.lua index 2709b65b..3406bc61 100644 --- a/tests/engine/fanfare_speed_bug1952_test.lua +++ b/tests/engine/fanfare_speed_bug1952_test.lua @@ -1,5 +1,4 @@ --- WaitForSoundToFinish must not stall logic at high GAME SPEED (#1952), --- but one-shot SFX stay at natural pitch (#1990/#1991/#1997). +-- home/delay.asm:15 package.path = "./?.lua;./?/init.lua;" .. package.path @@ -84,6 +83,51 @@ short.playing = false check(st2:updateQueue() == false, "and releases the frame the sfx goes quiet") eq(st2.waitSoundLeft, nil, "the budget is cleared with the source") +local function gate4(src) + local st4 = gate(src) + st4.game.logicSpeed = function() return 4 end + return st4 +end + +local natural = stub(1, 1) +local stopped = false +natural.stop = function(self) stopped = true; self.playing = false end +local steps = 0 +natural.isPlaying = function(self) + return self.playing and steps < 240 +end +local st3 = gate4(natural) +local held3 = 0 +for _ = 1, 1000 do + if not st3:updateQueue() then break end + steps = steps + 1 + held3 = held3 + 1 +end +check(not stopped, "at 4X a source that plays its full length is never stopped early") +eq(held3, 240, "the gate holds every logic step of the sound's real length") +eq(st3.waitingSound, nil, "and releases when the sound goes quiet on its own") + +local stuck4 = stub(1, 1) +local st4 = gate4(stuck4) +local held4 = 0 +for _ = 1, budget * 4 + 200 do + if not st4:updateQueue() then break end + held4 = held4 + 1 +end +check(stuck4.playing == false, "at 4X the safety stop still lands on a stuck source") +eq(held4, budget * 4 - 1, "after the same wall time, four times the logic steps") + +local quiet4 = stub(1, 1) +local st5 = gate4(quiet4) +check(st5:updateQueue(), "at 4X the gate holds while the sfx sounds") +quiet4.playing = false +check(st5:updateQueue() == false, "and releases the step the sfx goes quiet") + +local nanSpeed = gate(stub(1, 1)) +nanSpeed.game.logicSpeed = function() return 0 / 0 end +check(nanSpeed:updateQueue(), "a NaN speed counts as 1X") +eq(nanSpeed.waitSoundLeft, budget - 1, "and decrements by a whole frame") + local Game = require("src.core.Game") local Game2 = require("src.core.Game2") diff --git a/tests/engine/textbox_sfx_speed_bug2087_test.lua b/tests/engine/textbox_sfx_speed_bug2087_test.lua new file mode 100644 index 00000000..272364fa --- /dev/null +++ b/tests/engine/textbox_sfx_speed_bug2087_test.lua @@ -0,0 +1,154 @@ +-- home/delay.asm:15 + +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 Sound = require("src.core.Sound") +local TextBox = require("src.render.TextBox") + +Sound.setRate(1) + +local function stub(dur, playFor) + local src = { playing = true, steps = 0, stopped = false } + src.getDuration = function() return dur end + src.getPitch = function() return 1 end + src.isPlaying = function(self) + return self.playing and (not playFor or self.steps < playFor) + end + src.stop = function(self) self.stopped = true; self.playing = false end + return src +end + +local function newGame(speed) + local game = { + save = { player = {}, options = { textSpeed = "FAST" } }, + data = { audio = { fanfares = {}, sfx = {} }, text = {} }, + logicSpeed = function() return speed end, + } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function(self, btn) return self.queue[btn] or false end, + } + return game +end + +local budget = Sound.waitFrames(stub(1)) +eq(budget, 62, "a 1 s sfx is 60 logic frames plus margin at 1X") + +do + local game = newGame(4) + local src = stub(1, 240) + local box = TextBox.new(game, "Hi.", nil, { preSound = function() return src end }) + game.stack:push(box) + local held = 0 + for _ = 1, 1000 do + if not box.preSound then break end + box:update(1 / 60) + if not box.preSound then break end + src.steps = src.steps + 1 + held = held + 1 + end + check(not src.stopped, "at 4X the preSound gate never stops a source still playing") + eq(held, 240, "the gate holds every logic step of the sound's real length") + eq(box.preSrc, nil, "and releases once the source goes quiet") +end + +do + local game = newGame(4) + local src = stub(1) + local box = TextBox.new(game, "Hi.", nil, { preSound = function() return src end }) + game.stack:push(box) + local held = 0 + for _ = 1, budget * 4 + 200 do + box:update(1 / 60) + if not box.preSound then break end + held = held + 1 + end + check(src.stopped, "at 4X the safety stop still lands on a stuck source") + eq(held, budget * 4 - 1, "after the same wall time, four times the logic steps") +end + +do + local game = newGame(1) + local src = stub(1) + local box = TextBox.new(game, "Hi.", nil, { preSound = function() return src end }) + game.stack:push(box) + local held = 0 + for _ = 1, budget + 200 do + box:update(1 / 60) + if not box.preSound then break end + held = held + 1 + end + check(src.stopped, "at 1X a stuck source is stopped") + eq(held, budget - 1, "after exactly the frame budget") +end + +do + local game = newGame(0 / 0) + local src = stub(1) + local box = TextBox.new(game, "Hi.", nil, { preSound = function() return src end }) + game.stack:push(box) + box:update(1 / 60) + eq(box.preSrcLeft, budget - 1, "a NaN speed decrements by a whole frame") +end + +-- engine/items/item_effects.asm:1807 +do + local game = newGame(4) + local src = stub(1, 240) + local box = TextBox.new(game, "Hi.", nil, { + auto = { wait = false, delay = 0, sound = function() return src end }, + }) + game.stack:push(box) + local frames = 0 + while not box.done and frames < 600 do + box:update(1 / 60) + frames = frames + 1 + end + check(box.done, "the box finishes typing") + local held = 0 + for _ = 1, 1000 do + if #game.stack.states == 0 then break end + box:update(1 / 60) + if #game.stack.states == 0 then break end + src.steps = src.steps + 1 + held = held + 1 + end + check(not src.stopped, "at 4X the auto gate never stops a source still playing") + eq(held, 240, "the auto gate holds every logic step of the sound's real length") + eq(#game.stack.states, 0, "and the box pops once the source goes quiet") +end + +do + local game = newGame(4) + local src = stub(1) + local box = TextBox.new(game, "Hi.", nil, { + auto = { wait = false, delay = 0, sound = function() return src end }, + }) + game.stack:push(box) + local frames = 0 + while not box.done and frames < 600 do + box:update(1 / 60) + frames = frames + 1 + end + local held = 0 + for _ = 1, budget * 4 + 200 do + box:update(1 / 60) + if #game.stack.states == 0 then break end + held = held + 1 + end + check(src.stopped, "at 4X a stuck auto source is still stopped") + eq(held, budget * 4 - 1, "after four times the logic steps") +end + +T.finish("textbox_sfx_speed_bug2087") diff --git a/tests/parity_daycare.lua b/tests/parity_daycare.lua index c11d70fb..b01a8c02 100644 --- a/tests/parity_daycare.lua +++ b/tests/parity_daycare.lua @@ -16,10 +16,12 @@ local check, eq = S.check, S.eq local realTextBox = package.loaded["src.render.TextBox"] local shownTexts = {} +local shownOpts = {} local choiceAnswer = true package.loaded["src.render.TextBox"] = { new = function(game, text, onDone, opts) table.insert(shownTexts, text) + table.insert(shownOpts, opts or {}) if opts and opts.choice then opts.choice(choiceAnswer) elseif onDone then @@ -49,6 +51,7 @@ end local function talk(game) shownTexts = {} + shownOpts = {} local doneCalled = false talkGentleman(game, {}, nil, function() doneCalled = true end) return doneCalled @@ -218,5 +221,109 @@ do check(not hasTackle, "decline leaves moves unchanged (no Tackle yet)") end +local function optsFor(needle) + for i, s in ipairs(shownTexts) do + if s:find(needle, 1, true) then return shownOpts[i] end + end +end + +local Sound = require("src.core.Sound") +local realPlay, realPlayCry, realPlayPika = + Sound.play, Sound.playCry, Sound.playPikaCry +local sounds = {} +Sound.play = function(_, name) sounds[#sounds + 1] = "sfx:" .. tostring(name) end +Sound.playCry = function(_, species) sounds[#sounds + 1] = "cry:" .. tostring(species) end +Sound.playPikaCry = function(_, n) sounds[#sounds + 1] = "pika:" .. tostring(n) end + +-- === 8) take-back: money box on OweMoney/HeresYourMon/GotMonBack, SFX_PURCHASE, cry (#2107 #2108) === +do + local game = newGame() + boardAtLevel(game, "RATTATA", 5, "SCRAPPY") + game.save.daycare.steps = stepsForLevels("RATTATA", 5, 2) + choiceAnswer = true + check(talk(game), "take-back sfx talk completes") + local owe = optsFor("owe me") + check(owe and type(owe.money) == "function", "OweMoney box carries money box") + eq(owe and owe.moneyWithChoice, true, "OweMoney money box waits for YES/NO") + local heres = optsFor("Here's") + check(heres and type(heres.money) == "function", "HeresYourMon keeps money box") + check(heres and type(heres.preSound) == "function", "HeresYourMon has preSound") + eq(heres and heres.money and heres.money(), 10000 - 300, + "money box reads debited wallet") + local got = optsFor("back!") + check(got and type(got.money) == "function", "GotMonBack keeps money box") + check(got and type(got.preSound) == "function", "GotMonBack has preSound") + sounds = {} + if heres and heres.preSound then heres.preSound() end + if got and got.preSound then got.preSound() end + eq(table.concat(sounds, ","), "sfx:Purchase,cry:RATTATA", + "take-back plays SFX_PURCHASE then the mon's cry") +end + +-- === 9) deposit: cry between WillLookAfterMon and ComeSeeMeInAWhile (#2108) === +do + local realPartyMenu = package.loaded["src.ui.PartyMenu"] + package.loaded["src.ui.PartyMenu"] = { + new = function(game, o) + o.onSwitch(game.save.party[1]) + return {} + end, + } + local game = newGame() + game.save.party = { + Pokemon.new(Data, "PIDGEY", 5), Pokemon.new(Data, "RATTATA", 5), + } + game.save.daycare = nil + choiceAnswer = true + check(talk(game), "deposit talk completes") + check(game.save.daycare and game.save.daycare.mon.species == "PIDGEY", + "PIDGEY deposited") + local come = optsFor("Come see me") + check(come and type(come.preSound) == "function", "ComeSeeMeInAWhile has preSound") + sounds = {} + if come and come.preSound then come.preSound() end + eq(table.concat(sounds, ","), "cry:PIDGEY", "deposit plays the mon's cry") + package.loaded["src.ui.PartyMenu"] = realPartyMenu +end + +-- === 10) Yellow starter Pikachu: clip 28 on deposit, clip 35 on take-back (#2108) === +do + local GameVersion = require("src.core.GameVersion") + local realVersion = GameVersion.current + GameVersion.current = "yellow" + local realPartyMenu = package.loaded["src.ui.PartyMenu"] + package.loaded["src.ui.PartyMenu"] = { + new = function(game, o) + o.onSwitch(game.save.party[1]) + return {} + end, + } + local game = newGame() + local pika = Pokemon.new(Data, "PIKACHU", 5) + pika.otId, pika.ot = game.save.player.id, game.save.player.name + game.save.party = { pika, Pokemon.new(Data, "RATTATA", 5) } + game.save.daycare = nil + choiceAnswer = true + check(talk(game), "Yellow deposit talk completes") + local come = optsFor("Come see me") + sounds = {} + if come and come.preSound then come.preSound() end + eq(table.concat(sounds, ","), "pika:28", + "starter PIKACHU deposit plays PikachuCry28") + package.loaded["src.ui.PartyMenu"] = realPartyMenu + + check(game.save.daycare and game.save.daycare.mon == pika, "PIKACHU boarded") + game.save.daycare.steps = 0 + choiceAnswer = true + check(talk(game), "Yellow take-back talk completes") + local got = optsFor("back!") + sounds = {} + if got and got.preSound then got.preSound() end + eq(table.concat(sounds, ","), "pika:35", + "starter PIKACHU take-back plays PikachuCry35") + GameVersion.current = realVersion +end + +Sound.play, Sound.playCry, Sound.playPikaCry = realPlay, realPlayCry, realPlayPika package.loaded["src.render.TextBox"] = realTextBox S.finish()