diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index 1a57d155..2b16e6b6 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -424,11 +424,12 @@ local function renderEffect(data, header, options) return love.audio.newSource(sd, "static") end -function ChipAudio.newSfx(data, name, pitch, tempo, header) +function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames) header = header or data.audio.sfx[name] return renderEffect(data, header, { frequencyOffset = pitch or 0, frameTicks = 0x80 + (tempo or 0x80), + plainFrames = plainFrames, }) end diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index e21500e5..f870ddb5 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -280,6 +280,7 @@ function Channel.new(engine, spec, options) allowLoops = options.allowLoops ~= false, frequencyOffset = options.frequencyOffset or 0, frameTicks = options.frameTicks or FRAME_TICKS, + plainTicks = (options.plainFrames or 0) * FRAME_TICKS, speed = 12, noteLength = 1, -- Gen 2 CHANNEL_NOTE_LENGTH (note_type) durationModifier = 0, -- Gen 2 fractional-frame carry @@ -349,8 +350,9 @@ function Channel:frequencyGen2(note, octave) return bit.band(register + self.frequencyOffset, 0x7FF) end -function Channel:durationTicks(length) - local tempo = self.sfx and self.frameTicks or self.engine.tempo +function Channel:durationTicks(length, plain) + local tempo = self.sfx and (plain and FRAME_TICKS or self.frameTicks) + or self.engine.tempo local speed = self.sfx and (self.executeMusic and self.speed or 1) or self.speed return length * speed * tempo @@ -586,6 +588,9 @@ function Channel:nextEvent() local packed = self:byte() local volume = bit.rshift(packed, 4) local fade = fadeValue(bit.band(packed, 0x0F)) + -- audio/engine_2.asm:991-1013, :1015-1033, :1077-1096 + local plain = self.timeTicks < self.plainTicks + local offset = plain and 0 or self.frequencyOffset if self.noise then -- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the -- frequency low byte for every channel at or past CHAN5, the noise @@ -595,12 +600,12 @@ function Channel:nextEvent() -- byte that noise does not use for frequency. Dropping it left the -- battle hit sounds at their unmodified pitches, where super effective -- reads as the duller of the two (#826). - local parameter = bit.band(self:byte() + self.frequencyOffset, 0xFF) + local parameter = bit.band(self:byte() + offset, 0xFF) return self:noiseEvent( - self:durationTicks(length), volume, fade, parameter) + self:durationTicks(length, plain), volume, fade, parameter) end - local register = bit.band(self:word() + self.frequencyOffset, 0x7FF) - return self:tone(self:durationTicks(length), register, volume, fade) + local register = bit.band(self:word() + offset, 0x7FF) + return self:tone(self:durationTicks(length, plain), register, volume, fade) elseif command == 0x10 then local packed = self:byte() self.sweep = { @@ -1260,6 +1265,7 @@ function Engine.new(data, header, options) allowLoops = options.allowLoops, frequencyOffset = options.frequencyOffset, frameTicks = frameTicks, + plainFrames = options.plainFrames, }) end return engine diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 08888cf4..26433019 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -8,6 +8,7 @@ local Assets = require("src.render.Assets") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") +local bit = require("bit") local Sound = {} @@ -142,10 +143,10 @@ local function newFileSource(def) return s end -local function newSfxSource(data, key, def, pitch, tempo) +local function newSfxSource(data, key, def, pitch, tempo, plain) if isChipDef(def) then local ok, s = pcall(require("src.core.ChipAudio").newSfx, - data, key:match("^([^@]+)") or key, pitch, tempo, def) + data, key:match("^([^@]+)") or key, pitch, tempo, def, plain) if not ok then return nil, tostring(s) end if not s then return nil, "no source" end return s @@ -153,12 +154,12 @@ local function newSfxSource(data, key, def, pitch, tempo) return newFileSource(def) end -local function playPath(data, key, def, pitch, tempo) +local function playPath(data, key, def, pitch, tempo, plain) if not love.audio or not def then return nil end local src = cache[key] if src == false then return nil end -- known bad, already logged if not src then - local s, err = newSfxSource(data, key, def, pitch, tempo) + local s, err = newSfxSource(data, key, def, pitch, tempo, plain) if not s then cache[key] = false reportBadDef("sfx", key, owner(data, "sfx", key), err) @@ -413,50 +414,109 @@ end -- still sounding when the second row starts, so the original never plays -- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running -- past the end of the animation (#844). -local lastMoveSfx -- { src, rank, engine, channels } of the last row sound +local moveSfxChannels = {} -- software channel (5-8) -> { src, address, engine } -local function channelsOverlap(a, b) - if not (a and b) then return false end - for _, x in ipairs(a) do - for _, y in ipairs(b) do - if x == y then return true end - end +local function sourceAlive(entry) + local ok, playing = pcall(entry.src.isPlaying, entry.src) + return ok and playing +end + +local function pruneMoveSfx() + for ch, cur in pairs(moveSfxChannels) do + if not sourceAlive(cur) then moveSfxChannels[ch] = nil end end - return false end -- would PlaySound start this def now? Taking a channel over also stops the -- sound that held it, the way .playChannel resets the channel. local function sfxChannelGate(data, def) - local cur = lastMoveSfx - if not cur then return true end - local ok, playing = pcall(cur.src.isPlaying, cur.src) - if not (ok and playing) then - lastMoveSfx = nil - return true - end + pruneMoveSfx() -- an unrankable def (file asset, or another engine's bank) has no -- comparable sound id: leave it to the mixer, as before - if type(def) ~= "table" or not def.address or def.engine ~= cur.engine then - return true - end + if type(def) ~= "table" or not def.address then return true end local channels = require("src.core.ChipSynth").effectChannels(data, def) - if not channelsOverlap(channels, cur.channels) then return true end - if def.address > cur.rank then return false end - pcall(cur.src.stop, cur.src) - lastMoveSfx = nil - return true + if not channels then return true end + local takeover + for _, ch in ipairs(channels) do + local cur = moveSfxChannels[ch] + if cur and cur.engine == def.engine then + if def.address > cur.address then return false end + takeover = takeover or {} + takeover[cur.src] = true + end + end + if takeover then + for ch, cur in pairs(moveSfxChannels) do + if takeover[cur.src] then moveSfxChannels[ch] = nil end + end + end + return true, takeover end local function noteMoveSfx(data, def, src) if not src or type(def) ~= "table" or not def.address then - lastMoveSfx = nil + moveSfxChannels = {} return end - lastMoveSfx = { - src = src, rank = def.address, engine = def.engine, - channels = require("src.core.ChipSynth").effectChannels(data, def), - } + local channels = require("src.core.ChipSynth").effectChannels(data, def) + if not channels then + moveSfxChannels = {} + return + end + local entry = { src = src, address = def.address, engine = def.engine } + for _, ch in ipairs(channels) do moveSfxChannels[ch] = entry end +end + +-- constants/music_constants.asm:4 +local function sfxHeaderId(def) + if type(def) ~= "table" or not def.address then return nil end + local rel = def.address - 0x4000 + if rel <= 0 or rel % 3 ~= 0 then return nil end + return rel / 3 +end + +local function remainingFrames(src) + local ok, dur = pcall(src.getDuration, src, "seconds") + if not ok or type(dur) ~= "number" then return nil end + local pos + ok, pos = pcall(src.tell, src, "seconds") + if not ok or type(pos) ~= "number" then return nil end + return math.max(0, math.ceil((dur - pos) * 60)) +end + +-- audio/engine_2.asm:1077-1096, :991-1013, :1015-1033 +local function plainMoveFrames(data, def, channels) + local id = sfxHeaderId(def) + local sfx = data.audio and data.audio.sfx + if not (id and sfx and channels) then return 0 end + local first = sfxHeaderId(sfx.Peck) -- constants/music_constants.asm:178 + local last = sfxHeaderId(sfx.Trainer_Appeared) -- constants/music_constants.asm:228 + if not (first and last) then return 0 end + local claimed = {} + for _, ch in ipairs(channels) do claimed[ch] = true end + local base = (claimed[5] or claimed[8]) and id or 0 + local others = {} + for _, ch in ipairs({ 5, 8 }) do + local cur = (not claimed[ch]) and moveSfxChannels[ch] or nil + if cur and cur.engine == def.engine and sourceAlive(cur) then + local otherId = sfxHeaderId(cur) + local rem = remainingFrames(cur.src) + if otherId and rem and rem > 0 then + others[#others + 1] = { id = otherId, rem = rem } + end + end + end + table.sort(others, function(a, b) return a.rem < b.rem end) + local plain = 0 + for drop = 0, #others do + local combined = base + for i = drop + 1, #others do + combined = bit.bor(combined, others[i].id) + end + if combined >= first and combined <= last then return plain end + if drop < #others then plain = others[drop + 1].rem end + end + return plain end function Sound.playMove(data, anim) @@ -466,13 +526,20 @@ function Sound.playMove(data, anim) local name = anim.sound local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80 local def = sfx[name] - if not sfxChannelGate(data, def) then return end + local allowed, superseded = sfxChannelGate(data, def) + if not allowed then return end local src -- a chip program synthesizes the modified variant on demand; a file def -- can only reach for a pre-rendered one if isChipDef(def) then - src = playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo), - def, pitch, tempo) + local plain = 0 + if pitch ~= 0 or tempo ~= 0x80 then + local channels = require("src.core.ChipSynth").effectChannels(data, def) + plain = plainMoveFrames(data, def, channels) + end + local key = ("%s@%02x%02x"):format(name, pitch, tempo) + if plain > 0 then key = ("%s~%d"):format(key, plain) end + src = playPath(data, key, def, pitch, tempo, plain) else local key = ("%s@%02x%02x"):format(name, pitch, tempo) if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then @@ -481,6 +548,10 @@ function Sound.playMove(data, anim) src = playPath(data, name, def) end end + -- audio/engine_2.asm:1537 + if superseded then + for old in pairs(superseded) do pcall(old.stop, old) end + end if src then played("move", name) noteMoveSfx(data, def, src) @@ -711,7 +782,7 @@ end -- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo -- variants included) or all of them, so the next play re-resolves the def function Sound.invalidate(name) - lastMoveSfx = nil -- its source is about to be dropped or stopped + moveSfxChannels = {} -- their sources are about to be dropped or stopped -- Same for wCurSFX, and a reloaded table can repoint the id order. curSfx = nil sfxIds = nil diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index d704a3e7..972ec407 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -166,7 +166,8 @@ function EvolutionState:draw() if sprite then local x = math.floor((160 - sprite:getWidth()) / 2) local y = math.max(8, 64 - sprite:getHeight()) - love.graphics.draw(sprite, x, y) + -- engine/movie/evolution.asm:103 + love.graphics.draw(sprite, x + sprite:getWidth(), y, 0, -1, 1) if spriteTrueColor then require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions()) end diff --git a/src/ui/TradeAnim.lua b/src/ui/TradeAnim.lua index 791f7ebe..bb693588 100644 --- a/src/ui/TradeAnim.lua +++ b/src/ui/TradeAnim.lua @@ -445,7 +445,8 @@ function TradeAnim:draw() -- mon pic on the BG at hlcoord 7, 2; info box on the window at -- hWY $50, so it sits in the bottom half of the screen if self.monVisible and self.sentSprite then - love.graphics.draw(self.sentSprite, 56, 16) + -- engine/movie/trade.asm:751 + love.graphics.draw(self.sentSprite, 56 + self.sentSprite:getWidth(), 16, 0, -1, 1) if self.sentSpriteTrueColor then require("src.render.PaletteFX").markTrueColor( 56 - self.scx, 16, self.sentSprite:getDimensions()) @@ -501,7 +502,8 @@ function TradeAnim:draw() elseif p == "show_enemy" then if self.monVisible and self.recvSprite then - love.graphics.draw(self.recvSprite, 56, 16) + -- engine/movie/trade.asm:751 + love.graphics.draw(self.recvSprite, 56 + self.recvSprite:getWidth(), 16, 0, -1, 1) if self.recvSpriteTrueColor then require("src.render.PaletteFX").markTrueColor( 56, 16, self.recvSprite:getDimensions()) diff --git a/tests/drivers/evolution_flip_bug1412_test.lua b/tests/drivers/evolution_flip_bug1412_test.lua new file mode 100644 index 00000000..7d49a6cd --- /dev/null +++ b/tests/drivers/evolution_flip_bug1412_test.lua @@ -0,0 +1,92 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/bug1412" + local Pokemon = require("src.pokemon.Pokemon") + local Evolution = require("src.pokemon.Evolution") + local Screens = require("src.ui.Screens") + local TradeAnim = require("src.ui.TradeAnim") + local TextBox = require("src.render.TextBox") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + U.log("Issue #1412: mon pics must be mirrored on the evolution and trade") + U.log("screens, and only there; battle keeps the raw orientation.") + + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(10) + check("the overworld fixture is ready", game.overworld ~= nil) + + local mon = Pokemon.new(game.data, "PIKACHU", 20) + game.save.party = { mon } + -- engine/movie/evolution.asm:103 + Evolution.evolve(game, mon, "RAICHU", nil, "ITEM") + U.wait(12) + local top = game.stack:top() + check("the evolution screen opened", + top and top.screenId == "EvolutionState") + U.shot(game, DIR .. "/bug1412_evo_old_pikachu.png") + + for _ = 1, 600 do + if top.done then break end + U.wait(1) + end + check("the evolution finished", top.done and not top.canceled) + U.wait(2) + U.shot(game, DIR .. "/bug1412_evo_new_raichu.png") + for _ = 1, 60 do + if game.stack:top() == game.overworld then break end + U.tap(game, "a") + U.wait(4) + end + + -- engine/movie/trade.asm:751 + local sent = Pokemon.new(game.data, "SPEAROW", 10) + local recv = Pokemon.new(game.data, "FARFETCHD", 10) + recv.nickname = "DUX" + recv.traded = true + recv.ot = "TRAINER" + U.teleport(game, "ROUTE_1", 5, 5, "down") + local anim = Screens.push(game, "TradeAnim", { + sent = sent, received = recv, enemyName = "TRAINER", + }) + check("the trade cinematic opened", getmetatable(anim) == TradeAnim) + for _ = 1, 200 do + if anim.sub == "hold" then break end + U.wait(1) + end + check("the sent SPEAROW pic is on screen", + anim.phase == "show_player" and anim.sub == "hold" and anim.monVisible) + U.shot(game, DIR .. "/bug1412_trade_sent_spearow.png") + + local function topIsText() + return getmetatable(game.stack:top()) == TextBox + end + for _ = 1, 8000 do + if anim.phase == "show_enemy" and anim.monVisible then break end + if anim.phase == "done" then break end + if anim.waitingText or topIsText() then + U.wait(1) + else + anim:update(1 / 60) + end + end + U.wait(2) + check("the received FARFETCH'D pic is on screen", + anim.phase == "show_enemy" and anim.monVisible) + U.shot(game, DIR .. "/bug1412_trade_recv_farfetchd.png") + + U.log("shots in", DIR) + U.log("Right: all four pics are mirrored left-right compared to the same") + U.log("mon's battle front sprite (compare the hardware capture on #1412:") + U.log("PIKACHU's face points the other way than it does in battle).") + U.log("Wrong: any of the four matches the battle orientation.") + U.log("Battle, pokedex, title, Hall of Fame, League PC, credits, and the") + U.log("museum fossils are untouched and must still match the cart.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/leer_sound_bug1414_test.lua b/tests/drivers/leer_sound_bug1414_test.lua new file mode 100644 index 00000000..df046a92 --- /dev/null +++ b/tests/drivers/leer_sound_bug1414_test.lua @@ -0,0 +1,94 @@ +-- data/moves/sfx.asm:46, data/moves/animations.asm:448, audio/engine_2.asm:1077 +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local opts = game.save.options or {} + check(("sfx volume %d (needs > 0 to hear anything)"):format(opts.sfxVol or 7), + (opts.sfxVol or 7) > 0) + check("battle animations are on", opts.animations ~= false) + + local mdef = game.data.moves.LEER + check("LEER is in the move table", mdef ~= nil) + local anim = mdef and mdef.anim + check(("LEER maps to %s pitch %s tempo %s (wants Battle_31 255 64)"):format( + anim and tostring(anim.sound) or "?", + anim and tostring(anim.pitch) or "?", + anim and tostring(anim.tempo) or "?"), + anim ~= nil and anim.sound == "Battle_31" + and anim.pitch == 255 and anim.tempo == 64) + + local lead = Pokemon.new(game.data, "CHARMANDER", 20) + lead.moves = { { id = "LEER", pp = 30 } } + game.save.party = { lead } + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(15) + local ow = game.overworld + check("standing on ROUTE_1", ow.map.id == "ROUTE_1") + + local ChipAudio = require("src.core.ChipAudio") + local renders = {} + local origNewSfx = ChipAudio.newSfx + ChipAudio.newSfx = function(data, name, pitch, tempo, header, plain) + renders[#renders + 1] = { name = name, pitch = pitch, tempo = tempo, + plain = plain } + return origNewSfx(data, name, pitch, tempo, header, plain) + end + + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + ow:pushBattle(battle) + + local function waitPhase(phase, tries) + for _ = 1, tries do + if battle.phase == phase then return true end + U.tap(game, "a") + U.wait(6) + end + return battle.phase == phase + end + + U.log("") + U.log("LISTEN: the cart's LEER opens with a short PIERCING high tone") + U.log(" (about a third of a second, while the seed-drop noise is still") + U.log(" running) and only then falls to the slow low buzz. Before the") + U.log(" fix the recomp skipped the piercing part and played the whole") + U.log(" sound as the low buzz.") + U.log("") + + for round = 1, 3 do + check(("round %d: menu is up"):format(round), waitPhase("menu", 150)) + U.tap(game, "a") + check(("round %d: move list is up"):format(round), + waitPhase("moveSelect", 12)) + U.tap(game, "a") + U.wait(150) + if round == 1 then U.shot(game, DIR .. "/bug1414_leer.png") end + end + + ChipAudio.newSfx = origNewSfx + local sawPlain, sawSeed = false, false + for _, r in ipairs(renders) do + if r.name == "Battle_31" and r.pitch == 255 and r.tempo == 64 + and (r.plain or 0) > 0 then + sawPlain = true + end + if r.name == "Battle_1B" then sawSeed = true end + end + check("the seed-drop noise (Battle_1B) rendered", sawSeed) + check("LEER's Battle_31 rendered with an unmodified opening" + .. " while the noise still ran", sawPlain) + for _, r in ipairs(renders) do + U.log((" rendered %s pitch=%s tempo=%s plainFrames=%s"):format( + r.name, tostring(r.pitch), tostring(r.tempo), tostring(r.plain))) + end + + U.log("done; the window stays open, pick LEER again to re-listen") +end