mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
220 lines
8.9 KiB
Lua
220 lines
8.9 KiB
Lua
-- Animation-row SFX must obey PlaySound's channel-occupancy gate (#844).
|
|
--
|
|
-- Blizzard's animation is two rows -- `battle_anim BLIZZARD, ...` then
|
|
-- `battle_anim HYDRO_PUMP, ...` (data/moves/animations.asm, BlizzardAnim) --
|
|
-- and PlaySubanimation issues a PlaySound for every row
|
|
-- (engine/battle/animations.asm, PlaySubanimation). The extracted data and
|
|
-- the row timing are both faithful; what the port was missing is that the
|
|
-- original never actually starts that second sound. Audio2_PlaySound's
|
|
-- .playSfx/.sfxChannelLoop (audio/engine_2.asm) walks the channels the new
|
|
-- sfx declares and, for each one already busy, does
|
|
-- `ld a,[wSoundID] / cp [hl] / jr z,.playChannel / jr c,.playChannel / ret`:
|
|
-- a channel held by a LOWER sound id aborts the whole request, while an
|
|
-- equal or lower id takes the channel over (and .playChannel resets the
|
|
-- channel, cutting the old sound off). SFX_BATTLE_29 (BLIZZARD, CHAN5+8) is
|
|
-- still sounding when the HYDRO_PUMP row starts, and SFX_BATTLE_2A wants
|
|
-- CHAN5+6+8, so on hardware it is dropped outright. Unguarded, the port
|
|
-- layered it and its watery tail outlived the animation.
|
|
--
|
|
-- Sound ids order by header address: `DEF \1 EQUS "((\2 - SFX_Headers_1) / 3)"`
|
|
-- (constants/music_constants.asm, music_const), so a def's `address` is the
|
|
-- comparable rank inside one engine bank -- which is what Sound.playMove
|
|
-- compares and what ChipSynth.effectChannels supplies the channel set for.
|
|
--
|
|
-- ROM-free: ChipAsm blobs stand in for the sfx headers, so nothing here
|
|
-- reads data/generated/.
|
|
-- luajit tests/engine/move_sfx_channel_gate_bug844.lua
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local T = require("tests.harness")
|
|
local check, eq = T.check, T.eq
|
|
|
|
love = require("tests.love_stub")
|
|
|
|
-- ------- love.audio stub
|
|
-- tests/love_stub carries no love.audio (headless suites never play), and
|
|
-- the gate reads Source:isPlaying on the previously accepted row sound.
|
|
-- These stub sources never finish on their own, which is exactly the
|
|
-- "previous sfx is still sounding" state a mid-animation row sees.
|
|
local sources = {}
|
|
|
|
local Source = {}
|
|
Source.__index = Source
|
|
function Source:play() self.playing = true; self.plays = self.plays + 1 end
|
|
function Source:stop() self.playing = false end
|
|
function Source:isPlaying() return self.playing end
|
|
function Source:pause() self.playing = false end
|
|
function Source:setLooping(value) self.looping = value end
|
|
function Source:setVolume(value) self.volume = value end
|
|
function Source:setPitch(value) self.pitch = value end
|
|
function Source:setFilter() end
|
|
function Source:getDuration() return 1 end
|
|
|
|
love.audio = {
|
|
newSource = function(what, mode)
|
|
local src = setmetatable({
|
|
file = what, mode = mode, plays = 0, playing = false,
|
|
}, Source)
|
|
sources[#sources + 1] = src
|
|
return src
|
|
end,
|
|
}
|
|
|
|
local ChipAsm = require("src.audio.ChipAsm")
|
|
local ChipSynth = require("src.core.ChipSynth")
|
|
local Sound = require("src.core.Sound")
|
|
local Runtime = require("src.mods.Runtime")
|
|
|
|
-- ------- sfx fixtures
|
|
-- One audible note per channel. ChipAsm.sfx numbers effect channels hw+4,
|
|
-- so hw 1/2/4 assemble as CHAN5/CHAN6/CHAN8 -- the same software channels
|
|
-- the real sfx headers claim. `address` is the sound-id rank and `engine`
|
|
-- names the bank the rank is comparable within.
|
|
local function sfxDef(address, hws)
|
|
local channels = {}
|
|
for _, hw in ipairs(hws) do
|
|
local program
|
|
if hw == 4 then
|
|
program = { { noiseNote = { len = 8, volume = 15, fade = 1,
|
|
parameter = 0x11 } } }
|
|
else
|
|
program = { { squareNote = { len = 8, volume = 15, fade = 1,
|
|
frequency = 0x600 } } }
|
|
end
|
|
channels[#channels + 1] = { hw = hw, program = program }
|
|
end
|
|
local def = ChipAsm.sfx{ channels = channels }
|
|
def.address, def.engine = address, 2
|
|
return def
|
|
end
|
|
|
|
-- Battle_29 = CHAN5,8 at rank 16975; Battle_2A = CHAN5,6,8 at 16981. The
|
|
-- ranks below are the same ordering, scaled small for readability.
|
|
local defs = {
|
|
Blizzard_Sfx = sfxDef(100, { 1, 4 }), -- SFX_BATTLE_29 shape
|
|
HydroPump_Sfx = sfxDef(106, { 1, 2, 4 }), -- SFX_BATTLE_2A shape
|
|
Disable_Sfx = sfxDef(100, { 4 }), -- SFX_BATTLE_1B shape: CHAN8
|
|
Leer_Sfx = sfxDef(106, { 1, 2 }), -- SFX_BATTLE_31 shape: CHAN5,6
|
|
Loud_Sfx = sfxDef(106, { 1, 4 }), -- a high-ranked incumbent
|
|
Quiet_Sfx = sfxDef(100, { 1 }), -- a lower id that takes over
|
|
Unranked_Sfx = ChipAsm.sfx{ channels = { { hw = 1, program = {
|
|
{ squareNote = { len = 8, volume = 15, fade = 1, frequency = 0x600 } },
|
|
} } } }, -- a mod def: no header address
|
|
}
|
|
|
|
local data = { audio = { sfx = defs, cries = {}, songs = {} } }
|
|
|
|
-- the gate is only observable through what actually started, so watch the
|
|
-- Runtime event the mod SDK exposes for exactly that
|
|
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
|
local events = require("src.mods.Events").new()
|
|
Runtime.install(events, require("src.mods.Hooks").new())
|
|
|
|
local played = {}
|
|
events:on("sound.played", function(p) played[#played + 1] = p end, nil, "test")
|
|
|
|
local function reset()
|
|
Sound.invalidate() -- also clears the tracked row sound
|
|
for index = #sources, 1, -1 do sources[index] = nil end
|
|
for index = #played, 1, -1 do played[index] = nil end
|
|
end
|
|
|
|
local function playMove(name)
|
|
Sound.playMove(data, { sound = name, pitch = 0, tempo = 0x80 })
|
|
end
|
|
|
|
local function names()
|
|
local out = {}
|
|
for _, p in ipairs(played) do out[#out + 1] = p.name end
|
|
return table.concat(out, ",")
|
|
end
|
|
|
|
-- ------- the channel sets the gate reads
|
|
eq(table.concat(ChipSynth.effectChannels(data, defs.Blizzard_Sfx), ","),
|
|
"5,8", "effectChannels reads CHAN5+8 off the Blizzard-shaped header")
|
|
eq(table.concat(ChipSynth.effectChannels(data, defs.HydroPump_Sfx), ","),
|
|
"5,6,8", "effectChannels reads CHAN5+6+8 off the Hydro Pump-shaped header")
|
|
check(ChipSynth.effectChannels(data, "assets/beep.wav") == nil,
|
|
"a file def has no knowable channel set")
|
|
|
|
-- ------- 1. higher id + overlapping channels is dropped (the Blizzard case)
|
|
reset()
|
|
playMove("Blizzard_Sfx")
|
|
check(#sources == 1 and sources[1].playing, "the Blizzard row sound starts")
|
|
playMove("HydroPump_Sfx")
|
|
eq(#played, 1, "the second Blizzard row is dropped, not layered (" .. names() .. ")")
|
|
eq(played[1] and played[1].name, "Blizzard_Sfx",
|
|
"the sound that survives is the Blizzard row")
|
|
eq(#sources, 1, "the dropped row never even builds a source")
|
|
check(sources[1].playing, "the incumbent keeps sounding through the drop")
|
|
|
|
-- ------- 2. higher id + disjoint channels still plays (Disable/Leer)
|
|
-- The regression guard: SFX_BATTLE_1B is CHAN8 and SFX_BATTLE_31 is
|
|
-- CHAN5+6, so nothing is busy and both sounds are heard.
|
|
reset()
|
|
playMove("Disable_Sfx")
|
|
playMove("Leer_Sfx")
|
|
eq(#played, 2, "a higher id on disjoint channels is not gated (" .. names() .. ")")
|
|
eq(played[2] and played[2].name, "Leer_Sfx", "the second sound is the later row")
|
|
check(sources[1].playing and sources[2].playing,
|
|
"neither disjoint sound cuts the other off")
|
|
|
|
-- ------- 3. a lower id takes the channels over
|
|
-- .playChannel zeroes the channel state, which stops whatever held it.
|
|
reset()
|
|
playMove("Loud_Sfx")
|
|
local incumbent = sources[1]
|
|
playMove("Quiet_Sfx")
|
|
eq(#played, 2, "a lower id is allowed to start (" .. names() .. ")")
|
|
check(not incumbent.playing,
|
|
"taking CHAN5 over stops the sound that held it")
|
|
check(sources[2] and sources[2].playing, "the taking-over sound is playing")
|
|
|
|
-- ------- 4. an equal id restarts the sound
|
|
-- Repeated rows of one sound (Wrap, Metronome) must not be swallowed.
|
|
reset()
|
|
playMove("Blizzard_Sfx")
|
|
playMove("Blizzard_Sfx")
|
|
eq(#played, 2, "the same sound replayed is not gated against itself")
|
|
eq(#sources, 1, "the replay reuses the cached source")
|
|
eq(sources[1].plays, 2, "the cached source is restarted")
|
|
check(sources[1].playing, "and is sounding afterwards")
|
|
|
|
-- ------- 5. an unrankable def is left exactly as it was
|
|
-- A mod's chip sfx has no header address, so there is no comparable sound
|
|
-- id and the gate must not invent one.
|
|
reset()
|
|
playMove("Blizzard_Sfx")
|
|
playMove("Unranked_Sfx")
|
|
playMove("Unranked_Sfx")
|
|
eq(#played, 3, "unrankable defs play regardless of what is sounding ("
|
|
.. names() .. ")")
|
|
|
|
-- an unrankable def must also not become an incumbent that gates the next
|
|
-- ranked row
|
|
reset()
|
|
playMove("Unranked_Sfx")
|
|
playMove("HydroPump_Sfx")
|
|
eq(#played, 2, "an unrankable def gates nothing after it (" .. names() .. ")")
|
|
|
|
-- ------- 6. a finished sound gates nothing
|
|
reset()
|
|
playMove("Blizzard_Sfx")
|
|
sources[1].playing = false -- the incumbent ran out
|
|
playMove("HydroPump_Sfx")
|
|
eq(#played, 2, "a row sound that already ended blocks nothing")
|
|
|
|
-- ------- 7. an invalidate (hot reload / cache flush) drops the tracking
|
|
-- Sound.invalidate stops and forgets the cached sources; a stale reference
|
|
-- would gate the next row against a dead source.
|
|
reset()
|
|
playMove("Blizzard_Sfx")
|
|
Sound.invalidate()
|
|
playMove("HydroPump_Sfx")
|
|
eq(#played, 2, "invalidate clears the tracked row sound")
|
|
|
|
Runtime.install(savedEvents, savedHooks)
|
|
|
|
T.finish("move sfx channel gate (#844)")
|