From e97def0c4f4463caaf7849822ad20ef1536322b2 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Sun, 30 Aug 2026 16:51:18 -0500 Subject: [PATCH] revereted attaching game spe3ed to sound.setRate so that audio plays the same reguardless of speed --- src/core/Game.lua | 8 +++-- src/core/Game2.lua | 6 ++-- src/core/GameSpeed.lua | 5 ++- src/core/Sound.lua | 11 +++++-- .../battle_fanfare_speed_bug1952_test.lua | 18 ++++++----- .../gold_fanfare_speed_bug1952_test.lua | 29 +++++++++-------- tests/engine/fanfare_speed_bug1952_test.lua | 31 +++++++++++++------ 7 files changed, 71 insertions(+), 37 deletions(-) diff --git a/src/core/Game.lua b/src/core/Game.lua index 40f37326..afacc42e 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -394,13 +394,17 @@ function Game:logicSpeed() end function Game:update(dt) + -- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua). -- Give the accumulator room for one full frame at the current speed, -- or the anti-spiral clamp quietly caps every level above ~15X. local speed = self:logicSpeed() - local Sound = require("src.core.Sound") - if Sound.setRate then Sound.setRate(speed) end FixedStep.maxAccum = FixedStep.catchupLimit(speed) FixedStep:update(dt, speed) + -- 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. 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/Game2.lua b/src/core/Game2.lua index 3d34d646..c1e400f0 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -1261,12 +1261,14 @@ function Game2:update(dt) -- they are presentational, so fast-forward must not speed them up. require("src.render.Pipelines").update(dt) pcall(function() require("src.core.DiscordPresence").update(dt) end) + -- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does: + -- audio runs off its own real-time accumulator, so music and sfx keep their + -- tempo at every multiplier (#1990/#1991/#1997). speedOverride is the + -- driver/CLI hook and wins over the saved option. -- pokegold engine/menus/intro_menu.asm:848 IntroSequence: boot cinema runs on the same clock as the overworld local speed = math.max(1, tonumber(self.speedOverride) or tonumber(self.options and self.options.speed) or 1) - local Sound = require("src.core.Sound") - if Sound.setRate then Sound.setRate(speed) end if self.phase == "boot" then FixedStep.maxAccum = FixedStep.catchupLimit(speed) FixedStep:update(dt, speed) diff --git a/src/core/GameSpeed.lua b/src/core/GameSpeed.lua index a8681e71..74e8a531 100644 --- a/src/core/GameSpeed.lua +++ b/src/core/GameSpeed.lua @@ -3,7 +3,10 @@ -- Speeding up means running the 1/60 fixed step N times per real frame -- (Game:update), so everything driven by the step -- movement, text, -- battle timing, scripts -- advances N times faster while staying --- deterministic. +-- deterministic. Audio deliberately does NOT scale: Music.update drives +-- fade counters and ChipAudio synthesis off its own real-time 60Hz +-- accumulator in Game:update, so music and sfx play at normal pitch and +-- tempo at every speed (#1990/#1991/#1997). -- -- Vsync still caps how much work a real frame can do, so 10X is a target -- rather than a promise on a slow machine -- the logic simply runs as many diff --git a/src/core/Sound.lua b/src/core/Sound.lua index da5c5a99..b7777f6e 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -159,7 +159,11 @@ local function deviceSuspended() return ChipAudio ~= nil and ChipAudio.isSuspended() end --- home/vblank.asm:58-72 +-- Optional one-shot playback rate (Source:setPitch multiplier), capped the +-- way the cart's audio engine clamps under frame-skip (home/vblank.asm:58-72). +-- GAME SPEED does NOT drive this: the port keeps SFX at natural pitch at +-- every multiplier (#1990/#1991/#1997). Callers may still set it for tests +-- or a future opt-in; SessionLifecycle resets it on teardown. local FF_PITCH_MAX = 4 local rate = 1 @@ -180,7 +184,10 @@ local function applyRate(src, base) return src end --- home/delay.asm:14 +-- 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). function Sound.waitFrames(src, fallback) if not src then return 0 end local okd, dur = pcall(src.getDuration, src) diff --git a/tests/drivers/battle_fanfare_speed_bug1952_test.lua b/tests/drivers/battle_fanfare_speed_bug1952_test.lua index b0490966..c18dc244 100644 --- a/tests/drivers/battle_fanfare_speed_bug1952_test.lua +++ b/tests/drivers/battle_fanfare_speed_bug1952_test.lua @@ -1,5 +1,5 @@ --- home/vblank.asm:58-72 --- home/delay.asm:14 +-- Wait gates must release early at high GAME SPEED (#1952) 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" @@ -26,6 +26,10 @@ return function(game) ow:pushBattle(battle) U.log("logic speed", game:logicSpeed(), "sfx rate", Sound.rate()) + if Sound.rate() ~= 1 then + error(("bug1952: Game:update pitched SFX off GAME SPEED (rate %s at 4X)") + :format(tostring(Sound.rate()))) + end for _ = 1, 240 do if battle.phase == "menu" then break end @@ -53,6 +57,10 @@ return function(game) local okp, p = pcall(src.getPitch, src) dur = okd and d or nil pitch = okp and p or nil + if pitch and pitch ~= 1 then + error(("bug1952: the level-up fanfare was pitched with GAME SPEED (%s)") + :format(tostring(pitch))) + end if not shot then shot = U.shot(game, DIR .. "/bug1952_fanfare.png") end @@ -74,10 +82,6 @@ return function(game) error(("bug1952: the 4X battle still held the full fanfare (%.3fs of %.3fs)") :format(held, dur)) end - if dur and pitch and pitch > 0 and held < (dur / pitch) * 0.8 then - error(("bug1952: the fanfare was cut short (%.3fs of a %.3fs pitched jingle)") - :format(held, dur / pitch)) - end - U.log("PASS the fanfare hold scaled with the logic clock") + U.log("PASS the fanfare kept natural pitch and the hold released early") love.event.quit() end diff --git a/tests/drivers/gold_fanfare_speed_bug1952_test.lua b/tests/drivers/gold_fanfare_speed_bug1952_test.lua index 93948288..962ae358 100644 --- a/tests/drivers/gold_fanfare_speed_bug1952_test.lua +++ b/tests/drivers/gold_fanfare_speed_bug1952_test.lua @@ -1,5 +1,6 @@ --- ../pokecrystal/home/vblank.asm:58-72 --- ../pokecrystal/home/delay.asm:14 +-- GAME SPEED must not pitch one-shot SFX (#1990/#1991/#1997). Wait gates +-- still release early via their logic-frame budget (#1952); that path is +-- covered by the engine + Gen 1 battle drivers. 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" @@ -22,8 +23,8 @@ return function(game) game.speedOverride = 4 U.wait(2) U.log("gen2 speed override", 4, "sfx rate", Sound.rate()) - if Sound.rate() ~= 4 then - error(("bug1952: Game2:update never drove Sound.setRate (rate %s at 4X)") + if Sound.rate() ~= 1 then + error(("bug1952: Game2:update pitched SFX off GAME SPEED (rate %s at 4X)") :format(tostring(Sound.rate()))) end @@ -34,8 +35,8 @@ return function(game) local okp, pitch = pcall(src.getPitch, src) if not (okd and dur) then error("bug1952: no duration for " .. name) end U.log(("%s duration %.3fs pitch %s"):format(name, dur, tostring(pitch))) - if not (okp and pitch == 4) then - error(("bug1952: the gen 2 jingle played at natural pitch (%s)") + if not (okp and pitch == 1) then + error(("bug1952: the gen 2 jingle was pitched with GAME SPEED (%s)") :format(tostring(pitch))) end @@ -58,14 +59,16 @@ return function(game) end end if not held then error("bug1952: the gen 2 jingle never ended") end - U.log(("held %.3fs of a %.3fs jingle pitched to %s"):format(held, dur, pitch)) - if held > dur * 0.75 then - error(("bug1952: the 4X jingle still ran the full length (%.3fs of %.3fs)") + U.log(("held %.3fs of a %.3fs natural-pitch jingle"):format(held, dur)) + -- Freely playing (no wait gate cutting it) must take roughly the full + -- duration -- proof the 4X logic clock did not chipmunk the source. + if held < dur * 0.7 then + error(("bug1952: the jingle finished early under 4X (%.3fs of %.3fs)") :format(held, dur)) end - if held < (dur / pitch) * 0.7 then - error(("bug1952: the jingle was cut short (%.3fs of a %.3fs pitched jingle)") - :format(held, dur / pitch)) + if held > dur * 1.4 then + error(("bug1952: the jingle dragged past its length (%.3fs of %.3fs)") + :format(held, dur)) end game.speedOverride = nil @@ -76,6 +79,6 @@ return function(game) end U.shot(game, DIR .. "/bug1952_gold_after.png") - U.log("PASS the gen 2 jingle scaled with the logic clock") + U.log("PASS the gen 2 jingle kept natural pitch under GAME SPEED") love.event.quit() end diff --git a/tests/engine/fanfare_speed_bug1952_test.lua b/tests/engine/fanfare_speed_bug1952_test.lua index a49b9247..2709b65b 100644 --- a/tests/engine/fanfare_speed_bug1952_test.lua +++ b/tests/engine/fanfare_speed_bug1952_test.lua @@ -1,5 +1,5 @@ --- home/vblank.asm:58-72 --- home/delay.asm:14 +-- WaitForSoundToFinish must not stall logic at high GAME SPEED (#1952), +-- but one-shot SFX stay at natural pitch (#1990/#1991/#1997). package.path = "./?.lua;./?/init.lua;" .. package.path @@ -45,9 +45,9 @@ eq(Sound.waitFrames(stub(2, 4)), 32, "and a quarter at the 4X clamp") Sound.setRate(4) eq(Sound.waitFrames(stub(2, 4)), 122, - "at 4X a 4X-pitched jingle still spans the same 120 logic frames") + "at rate 4 a 4X-pitched jingle still spans the same 120 logic frames") eq(Sound.waitFrames(stub(2, 1)), 482, - "a source fast-forward could not pitch costs proportionally more frames") + "a source the rate could not pitch costs proportionally more frames") Sound.setRate(1) local broken = { getDuration = function() error("no duration") end } @@ -85,9 +85,22 @@ check(st2:updateQueue() == false, "and releases the frame the sfx goes quiet") eq(st2.waitSoundLeft, nil, "the budget is cleared with the source") +local Game = require("src.core.Game") local Game2 = require("src.core.Game2") require("src.core.FixedStep"):init(function() end) +local function gen1(speed) + return setmetatable({ + speedOverride = speed, + audioAccum = 0, + stack = { top = function() return nil end }, + logicSpeed = function(self) + return require("src.core.GameSpeed").clamp(self.speedOverride or 1) + end, + updateSync = function() end, + }, { __index = Game }) +end + local function gen2(speed) return setmetatable({ phase = "boot", @@ -99,14 +112,12 @@ local function gen2(speed) end Sound.setRate(1) +gen1(4):update(0) +eq(Sound.rate(), 1, "a Gen 1 frame does not pitch SFX from GAME SPEED") gen2(4):update(0) -eq(Sound.rate(), 4, "a Gen 2 frame drives the one-shot rate off its own speed") -gen2(2):update(0) -eq(Sound.rate(), 2, "and follows it down") +eq(Sound.rate(), 1, "and neither does a Gen 2 frame") gen2(100):update(0) -eq(Sound.rate(), 4, "the Gen 2 path clamps through the same setRate") -gen2(1):update(0) -eq(Sound.rate(), 1, "1X is rate 1 in Gen 2 too") +eq(Sound.rate(), 1, "even at a clamped 200X logic multiplier") Sound.setRate(4) require("src.core.SessionLifecycle").endGameSession(nil)