This commit is contained in:
bryanthaboi
2026-08-18 04:52:00 -04:00
parent 7b1e796c48
commit cfa8406306
7 changed files with 313 additions and 46 deletions
+2 -1
View File
@@ -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
+12 -6
View File
@@ -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
+107 -36
View File
@@ -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
+2 -1
View File
@@ -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
+4 -2
View File
@@ -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())
@@ -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
+94
View File
@@ -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