mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
875 lines
33 KiB
Lua
875 lines
33 KiB
Lua
-- Audio modding (M9): ChipAsm assembler and its def-local blob mode in
|
|
-- ChipAudio, chip/wav shape dispatch in Music/Sound, failure isolation, the
|
|
-- granular sfx/cries/map_songs merge, song-literal tables and their
|
|
-- fallbacks, and the music.select hook plus the audio events.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local S = require("tests.harness").suite("mod audio")
|
|
local check = S.check
|
|
|
|
-- ------- love audio stub
|
|
-- Records every source so the tests can assert on which branch built it;
|
|
-- string paths only resolve when listed in `assets`, which is how a broken
|
|
-- definition is simulated.
|
|
|
|
local love = _G.love or {}
|
|
_G.love = love
|
|
local savedAudio, savedSound = love.audio, love.sound
|
|
|
|
local assets = {
|
|
["assets/beep.wav"] = true,
|
|
["assets/chime.wav"] = true,
|
|
}
|
|
|
|
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
|
|
function Source:getFreeBufferCount() return self.free end
|
|
function Source:queue() self.free = math.max(0, self.free - 1) end
|
|
|
|
local function newSource(what, mode)
|
|
if type(what) == "string" and not assets[what] then
|
|
error("could not open file " .. what, 0)
|
|
end
|
|
local src = setmetatable({
|
|
file = what, mode = mode, plays = 0, free = 0, queueable = false,
|
|
}, Source)
|
|
sources[#sources + 1] = src
|
|
return src
|
|
end
|
|
|
|
local SoundData = {}
|
|
SoundData.__index = SoundData
|
|
function SoundData:setSample(index, a, b)
|
|
self.samples[index] = b or a
|
|
end
|
|
function SoundData:getSample(index) return self.samples[index] or 0 end
|
|
function SoundData:getSampleCount() return self.count end
|
|
|
|
love.audio = {
|
|
newSource = newSource,
|
|
newQueueableSource = function()
|
|
local src = setmetatable({
|
|
plays = 0, free = 32, queueable = true,
|
|
}, Source)
|
|
sources[#sources + 1] = src
|
|
return src
|
|
end,
|
|
}
|
|
love.sound = {
|
|
newSoundData = function(count, rate, bits, channels)
|
|
return setmetatable({
|
|
samples = {}, count = count, rate = rate, bits = bits,
|
|
channels = channels,
|
|
}, SoundData)
|
|
end,
|
|
}
|
|
|
|
local function lastSource()
|
|
return sources[#sources]
|
|
end
|
|
|
|
local function resetSources()
|
|
for index = #sources, 1, -1 do sources[index] = nil end
|
|
end
|
|
|
|
local ChipAsm = require("src.audio.ChipAsm")
|
|
local ChipAudio = require("src.core.ChipAudio")
|
|
local Music = require("src.core.Music")
|
|
local Sound = require("src.core.Sound")
|
|
local Logger = require("src.core.Logger")
|
|
local Loader = require("src.mods.Loader")
|
|
local Runtime = require("src.mods.Runtime")
|
|
|
|
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
|
local savedErrors = Runtime.errors
|
|
|
|
local function loggedCount(fragment)
|
|
local count = 0
|
|
for _, line in ipairs(Logger.history) do
|
|
if line:find(fragment, 1, true) then count = count + 1 end
|
|
end
|
|
return count
|
|
end
|
|
|
|
-- ------- ChipAsm: the encoding oracle
|
|
-- Byte-for-byte expectations read off the command table Channel:nextEvent
|
|
-- decodes, so a change in either one breaks this.
|
|
|
|
local dslSong = ChipAsm.song{
|
|
tempo = 0x100,
|
|
channels = {
|
|
{ hw = 1,
|
|
program = {
|
|
{ duty = 2 },
|
|
{ notetype = { speed = 12, volume = 12, fade = 1 } },
|
|
{ octave = 4 },
|
|
{ label = "body" },
|
|
{ note = "C#", len = 4 },
|
|
{ rest = 2 },
|
|
{ vibrato = { delay = 6, depth = 3, rate = 4 } },
|
|
{ slide = { len = 2, octave = 4, note = "E" } },
|
|
{ call = "riff" },
|
|
{ loop = { count = 0, to = "body" } },
|
|
},
|
|
subroutines = { riff = { { note = "G", len = 2 }, { ret = true } } } },
|
|
{ hw = 4, program = { { drum = 3, len = 8 }, { loop = { count = 0, to = 1 } } } },
|
|
},
|
|
}
|
|
|
|
local function hex(blob)
|
|
local out = {}
|
|
for index = 1, #blob do out[#out + 1] = ("%02X"):format(blob:byte(index)) end
|
|
return table.concat(out, " ")
|
|
end
|
|
|
|
check(hex(dslSong.chip.blob) ==
|
|
"ED 01 00 EC 02 DC C1 E4 13 C1 EA 06 34 EB 02 44 FD 17 40 FE 00 08 40 " ..
|
|
"71 FF 37 FE 00 19 40",
|
|
"ChipAsm encodes the documented program byte for byte")
|
|
check(dslSong.chip.channels[1].number == 1
|
|
and dslSong.chip.channels[1].address == 0x4000,
|
|
"first channel is based at the 0x4000 window")
|
|
check(dslSong.chip.channels[2].number == 4
|
|
and dslSong.chip.channels[2].address == 0x4019,
|
|
"the second channel starts after the first one's bytes")
|
|
check(dslSong.chip.engine == 1, "engine defaults to 1")
|
|
|
|
-- an sfx program lives on channels 5-8 so the interpreter reads the effect
|
|
-- command set
|
|
local dslSfx = ChipAsm.sfx{
|
|
channels = {
|
|
{ hw = 1, program = {
|
|
{ pitchSweep = { pace = 5, subtract = true, shift = 2 } },
|
|
{ squareNote = { len = 4, volume = 15, fade = 1, frequency = 0x123 } },
|
|
} },
|
|
{ hw = 4, program = {
|
|
{ noiseNote = { len = 2, volume = 15, fade = -1, parameter = 0x44 } },
|
|
} },
|
|
},
|
|
}
|
|
check(hex(dslSfx.chip.blob) == "10 5A 23 F1 23 01 FF 21 F9 44 FF",
|
|
"ChipAsm.sfx encodes sweep, square and noise notes")
|
|
check(dslSfx.chip.channels[1].number == 5
|
|
and dslSfx.chip.channels[2].number == 8,
|
|
"sfx channels are numbered 5-8")
|
|
|
|
-- terminator: a stream that can fall off its end gets the endchannel byte,
|
|
-- one that loops forever does not
|
|
check(ChipAsm.song{ channels = { { hw = 1, program = { { note = "C" } } } } }
|
|
.chip.blob == string.char(0x00, 0xFF),
|
|
"a finite stream is terminated with endchannel")
|
|
|
|
-- errors name the channel and the event index
|
|
local function failsWith(fragment, fn)
|
|
local ok, err = pcall(fn)
|
|
check(not ok, "expected a ChipAsm error: " .. fragment)
|
|
check(tostring(err):find(fragment, 1, true),
|
|
("error %q does not mention %q"):format(tostring(err), fragment))
|
|
end
|
|
|
|
failsWith("channel 1 event 2: unknown note \"H\"", function()
|
|
ChipAsm.song{ channels = { { hw = 1,
|
|
program = { { note = "C" }, { note = "H" } } } } }
|
|
end)
|
|
failsWith("channel 2 event 1: len out of range 1-16", function()
|
|
ChipAsm.song{ channels = {
|
|
{ hw = 1, program = { { note = "C" } } },
|
|
{ hw = 2, program = { { note = "C", len = 40 } } } } }
|
|
end)
|
|
failsWith("channel 1 event 1: drums only play on channel 4", function()
|
|
ChipAsm.song{ channels = { { hw = 1, program = { { drum = 1 } } } } }
|
|
end)
|
|
failsWith("channel 1 event 3: unknown label \"nope\"", function()
|
|
ChipAsm.song{ channels = { { hw = 1, program = {
|
|
{ note = "C" }, { note = "D" }, { call = "nope" } } } } }
|
|
end)
|
|
failsWith("channel 1 subroutine \"riff\" event 1: octave out of range", function()
|
|
ChipAsm.song{ channels = { { hw = 1, program = { { call = "riff" } },
|
|
subroutines = { riff = { { octave = 12 } } } } } }
|
|
end)
|
|
|
|
-- friendly drum rows become the segment shape Engine:noiseInstrument caches
|
|
local drumDef = ChipAsm.song{
|
|
channels = { { hw = 4, program = { { drum = 3, len = 4 } } } },
|
|
drums = { [3] = { { len = 4, volume = 13, fade = 2, parameter = 0x42 } } },
|
|
}
|
|
local segment = drumDef.chip.drums[3][1]
|
|
check(segment.startSample == 0 and segment.volume == 13
|
|
and segment.fade == 2 and segment.parameter == 0x42,
|
|
"drum rows assemble into cached noise segments")
|
|
check(segment.endSample > 0, "drum segment spans samples")
|
|
|
|
-- ------- ChipAudio: def-local blobs render without touching programs.bin
|
|
-- Force unity mix so amplitude/pitch checks are independent of the
|
|
-- CHANNEL_VOLUME / CHANNEL_PITCH knobs in ChipAudio.lua.
|
|
ChipAudio.setChannelVolumes({ 1, 1, 1, 1 })
|
|
ChipAudio.setChannelPitches({ 1, 1, 1, 1 })
|
|
|
|
local blobData = { audio = {} }
|
|
|
|
local blobSong = ChipAsm.song{
|
|
tempo = 0x100,
|
|
channels = { { hw = 1, program = {
|
|
{ duty = 2 },
|
|
{ notetype = { speed = 12, volume = 12, fade = 0 } },
|
|
{ octave = 4 },
|
|
{ label = "body" },
|
|
{ note = "C", len = 8 },
|
|
{ note = "E", len = 8 },
|
|
{ loop = { count = 0, to = "body" } },
|
|
} } },
|
|
}
|
|
local rendered = ChipAudio._renderMusicForTest(blobData, blobSong, 0.1)
|
|
local nonzero = 0
|
|
for index = 0, rendered:getSampleCount() - 1 do
|
|
if rendered:getSample(index) ~= 0 then nonzero = nonzero + 1 end
|
|
end
|
|
check(nonzero > 0, "a blob def renders nonzero audio with no ROM banks")
|
|
|
|
local blobTrace = ChipAudio._traceFirstMusicSampleForTest(blobData, blobSong)
|
|
check(blobTrace[1].register == 1797 and blobTrace[1].volume == 12,
|
|
"the blob's first note decodes to the C4 register")
|
|
|
|
-- def-local waves are honored over the ROM's wave banks
|
|
local flatWave = {}
|
|
for index = 1, 32 do flatWave[index] = 1 end
|
|
local waveSong = ChipAsm.song{
|
|
channels = { { hw = 3, program = {
|
|
{ notetype = { speed = 12, waveLevel = 1, waveInstrument = 0 } },
|
|
{ octave = 4 },
|
|
{ note = "C", len = 8 },
|
|
} } },
|
|
waves = { flatWave },
|
|
}
|
|
local waveTrace = ChipAudio._traceFirstMusicSampleForTest(blobData, waveSong)
|
|
check(math.abs(waveTrace[1].value - 1) < 1e-9,
|
|
"def-local waves drive the wave channel")
|
|
|
|
-- def-local drums are honored over the ROM's noise headers
|
|
local drumTrace = ChipAudio._traceFirstMusicSampleForTest(blobData, drumDef)
|
|
check(drumTrace[1].drumSegments == 1, "def-local drums reach the noise channel")
|
|
|
|
-- per-hardware-channel gains scale only that layer; restore to 1x after
|
|
local fullPulse = ChipAudio._traceFirstMusicSampleForTest(blobData, blobSong)
|
|
local fullDrum = ChipAudio._traceFirstMusicSampleForTest(blobData, drumDef)
|
|
local fullWave = ChipAudio._traceFirstMusicSampleForTest(blobData, waveSong)
|
|
ChipAudio.setChannelVolume(1, 0.5)
|
|
local halfPulse = ChipAudio._traceFirstMusicSampleForTest(blobData, blobSong)
|
|
ChipAudio.setChannelVolumes({ 1, 1, 0, 0.5 })
|
|
local muteWave = ChipAudio._traceFirstMusicSampleForTest(blobData, waveSong)
|
|
local halfDrum = ChipAudio._traceFirstMusicSampleForTest(blobData, drumDef)
|
|
local pulseWhileOthers = ChipAudio._traceFirstMusicSampleForTest(blobData, blobSong)
|
|
ChipAudio.setChannelVolumes({ 1, 1, 1, 1 })
|
|
check(math.abs(halfPulse[1].value - fullPulse[1].value * 0.5) < 1e-9,
|
|
"channel 1 volume 0.5 halves the pulse sample")
|
|
check(muteWave[1].value == 0, "channel 3 volume 0 mutes the wave sample")
|
|
check(math.abs(halfDrum[1].value - fullDrum[1].value * 0.5) < 1e-9,
|
|
"channel 4 volume 0.5 halves the drum sample")
|
|
check(math.abs(pulseWhileOthers[1].value - fullPulse[1].value) < 1e-9,
|
|
"muting wave/noise does not change pulse amplitude")
|
|
check(math.abs(fullWave[1].value) > 0, "wave sample is nonzero at unity gain")
|
|
local vols = ChipAudio.getChannelVolumes()
|
|
check(vols[1] == 1 and vols[2] == 1 and vols[3] == 1 and vols[4] == 1,
|
|
"channel volumes restore to 1")
|
|
check(ChipAudio.getNoiseVolume() == 1, "noise-volume alias tracks channel 4")
|
|
|
|
-- per-channel pitch scales oscillator rate (zero-crossings ~double at 2x)
|
|
local function zeroCrossings(sd)
|
|
local count, prev = 0, sd:getSample(0)
|
|
for index = 1, sd:getSampleCount() - 1 do
|
|
local sample = sd:getSample(index)
|
|
if prev * sample < 0 then count = count + 1 end
|
|
prev = sample
|
|
end
|
|
return count
|
|
end
|
|
ChipAudio.setChannelVolumes({ 1, 1, 1, 1 })
|
|
ChipAudio.setChannelPitches({ 1, 1, 1, 1 })
|
|
local pitchBase = ChipAudio._renderMusicChannelForTest(blobData, blobSong, 0.25, 1)
|
|
ChipAudio.setChannelPitch(1, 2)
|
|
local pitchOctave = ChipAudio._renderMusicChannelForTest(blobData, blobSong, 0.25, 1)
|
|
ChipAudio.setChannelPitch(1, 0)
|
|
local pitchFrozen = ChipAudio._renderMusicChannelForTest(blobData, blobSong, 0.25, 1)
|
|
ChipAudio.setChannelPitches({ 1, 1, 1, 1 })
|
|
local baseX = zeroCrossings(pitchBase)
|
|
local octaveX = zeroCrossings(pitchOctave)
|
|
check(baseX > 10, "unity pitch produces a tone with zero crossings")
|
|
check(octaveX > baseX * 1.7 and octaveX < baseX * 2.3,
|
|
"channel 1 pitch 2 roughly doubles zero crossings")
|
|
check(zeroCrossings(pitchFrozen) == 0,
|
|
"channel 1 pitch 0 freezes the oscillator")
|
|
local pitches = ChipAudio.getChannelPitches()
|
|
check(pitches[1] == 1 and pitches[2] == 1 and pitches[3] == 1 and pitches[4] == 1,
|
|
"channel pitches restore to 1")
|
|
|
|
-- ------- data fixtures (chip + wav only)
|
|
|
|
local chipSong = ChipAsm.song{
|
|
channels = { { hw = 1, program = {
|
|
{ notetype = { speed = 12, volume = 12, fade = 0 } },
|
|
{ octave = 4 },
|
|
{ note = "C", len = 8 },
|
|
{ loop = { count = 0, to = 1 } },
|
|
} } },
|
|
}
|
|
|
|
local chipSongB = ChipAsm.song{
|
|
channels = { { hw = 1, program = {
|
|
{ notetype = { speed = 12, volume = 10, fade = 0 } },
|
|
{ octave = 5 },
|
|
{ note = "G", len = 8 },
|
|
{ loop = { count = 0, to = 1 } },
|
|
} } },
|
|
}
|
|
|
|
local chipSfx = ChipAsm.sfx{ channels = { { hw = 1, program = {
|
|
{ squareNote = { len = 8, volume = 15, fade = 1, frequency = 0x600 } },
|
|
} } } }
|
|
|
|
local function fixtureData()
|
|
return {
|
|
audio = {
|
|
songs = {
|
|
Music_Chip = chipSong,
|
|
Music_Other = chipSongB,
|
|
Music_Broken = { file = "assets/missing.wav" },
|
|
Music_BikeRiding = chipSongB,
|
|
Music_PalletTown = chipSong,
|
|
},
|
|
sfx = {
|
|
Beep = "assets/beep.wav",
|
|
Chime = { file = "assets/chime.wav", fanfare = true },
|
|
Level_Up = "assets/beep.wav",
|
|
Broken = { file = "assets/missing.wav" },
|
|
Chip_Sfx = chipSfx,
|
|
},
|
|
cries = {},
|
|
mapSongs = { PALLET_TOWN = "Music_PalletTown" },
|
|
battle = { wild = "Music_Chip", wildWin = "Music_Other" },
|
|
},
|
|
}
|
|
end
|
|
|
|
local function reset(data)
|
|
Sound.invalidate()
|
|
Music.reload()
|
|
resetSources()
|
|
return data
|
|
end
|
|
|
|
-- ------- dispatch: chip and wav branches
|
|
|
|
local data = reset(fixtureData())
|
|
|
|
Music.play(data, "Music_Chip")
|
|
check(lastSource() and lastSource().queueable,
|
|
"a chip def streams through ChipAudio")
|
|
check(lastSource().playing, "the chip song started")
|
|
local chipSource = lastSource()
|
|
|
|
Music.play(data, "Music_Other")
|
|
check(lastSource() and lastSource().queueable and lastSource().playing,
|
|
"a second chip song streams through ChipAudio")
|
|
check(not chipSource.playing, "the outgoing chip song was stopped")
|
|
|
|
-- playOnce must survive the threaded "empty QueueableSource" window:
|
|
-- Source:isPlaying is false until the first worker buffer lands, and that
|
|
-- gap must not look like the jingle already ended (Poké Center heal).
|
|
data = reset(fixtureData())
|
|
Music.playMap(data, "PALLET_TOWN", false, false)
|
|
check(Music.playOnce(data, "Music_Other"), "playOnce starts a chip jingle")
|
|
local jingle = lastSource()
|
|
local clearAwait = ChipAudio._simulateAwaitingFirstBufferForTest()
|
|
check(clearAwait ~= nil, "test can force the awaiting-first-buffer window")
|
|
check(Music.oneShotPlaying(),
|
|
"oneShotPlaying stays true while the first buffer is still in flight")
|
|
Music.update(data)
|
|
check(jingle == lastSource() and jingle.queueable,
|
|
"pendingRestore does not swap the map theme over a pending chip jingle")
|
|
check(ChipAudio.awaitingFirstBuffer(),
|
|
"awaitingFirstBuffer reports the forced window")
|
|
clearAwait()
|
|
|
|
-- sfx shape dispatch
|
|
check(Sound.play(data, "Beep") ~= nil, "Sound.play returns the source it started")
|
|
check(lastSource().file == "assets/beep.wav", "a bare string sfx is a static source")
|
|
resetSources()
|
|
Sound.play(data, "Chip_Sfx")
|
|
check(lastSource() and lastSource().mode == "static"
|
|
and type(lastSource().file) == "table",
|
|
"a chip sfx renders to a static source")
|
|
|
|
-- ------- failure isolation: a bad def costs one log line, nothing else
|
|
|
|
data = reset(fixtureData())
|
|
Music.play(data, "Music_Chip")
|
|
local playing = lastSource()
|
|
local before = loggedCount("bad song def")
|
|
Music.play(data, "Music_Broken")
|
|
Music.play(data, "Music_Chip")
|
|
Music.play(data, "Music_Broken")
|
|
check(loggedCount("bad song def") == before + 1,
|
|
"a broken song def is logged exactly once")
|
|
check(playing.playing, "the previous song keeps playing through a bad def")
|
|
Music.play(data, "Music_Other")
|
|
check(lastSource().queueable and lastSource().playing,
|
|
"a bad def does not disable the rest of the music")
|
|
|
|
local sfxBefore = loggedCount("bad sfx def")
|
|
Sound.play(data, "Broken")
|
|
Sound.play(data, "Broken")
|
|
check(loggedCount("bad sfx def") == sfxBefore + 1,
|
|
"a broken sfx def is logged exactly once")
|
|
resetSources()
|
|
Sound.play(data, "Beep")
|
|
check(lastSource() and lastSource().file == "assets/beep.wav",
|
|
"a bad sfx does not disable the rest of the effects")
|
|
|
|
-- ------- cries: chip and derived variants
|
|
|
|
data = reset(fixtureData())
|
|
data.audio.cries.RHYDON = {
|
|
header = { address = 0x4000, bank = 2, engine = 1 }, pitch = 0, length = 0,
|
|
}
|
|
data.audio.cries.CHIPMON = { chip = chipSfx.chip, pitch = 0, length = 0 }
|
|
data.audio.cries.SHELLORD = { base = "CHIPMON", pitch = 0x2A, length = 0x50 }
|
|
data.audio.cries.CHAINMON = { base = "SHELLORD" }
|
|
|
|
check(Sound.playCry(data, "CHIPMON"), "a chip cry plays")
|
|
check(Sound.playCry(data, "SHELLORD"), "a derived cry plays")
|
|
check(Sound.playCry(data, "CHAINMON"), "a derived cry chain resolves")
|
|
check(Sound.playCry(data, "NOBODY") == nil, "an unregistered species is silent")
|
|
|
|
-- GROWL/ROAR layer their own tempo shift on top of any cry shape
|
|
local chipCry = Sound.playCry(data, "CHIPMON")
|
|
Sound.playMoveCry(data, "CHIPMON", 0xC0)
|
|
check(math.abs(chipCry.pitch - 256 / (128 + 0xC0)) < 1e-9,
|
|
"playMoveCry layers the move's tempo shift onto a chip cry")
|
|
|
|
data.audio.cries.ORPHAN = { base = "MISSING" }
|
|
local cryBefore = loggedCount("bad cry def")
|
|
check(Sound.playCry(data, "ORPHAN") == nil, "a dangling base cry is silent")
|
|
check(loggedCount("bad cry def") == cryBefore + 1,
|
|
"a dangling base cry is logged once")
|
|
|
|
-- ------- song literals: data tables win, module fallbacks preserve vanilla
|
|
|
|
data = reset(fixtureData())
|
|
check(Music.special(data, "title") == "Music_TitleScreen",
|
|
"special song roles fall back to the vanilla labels")
|
|
check(Music.special(data, "bike") == "Music_BikeRiding",
|
|
"the bike role falls back to Music_BikeRiding")
|
|
Music.playMap(data, "PALLET_TOWN", true, false)
|
|
check(lastSource().queueable,
|
|
"the fallback outdoor set engages the bike theme")
|
|
|
|
data = reset(fixtureData())
|
|
data.audio.special = { bike = "Music_Other" }
|
|
data.audio.outdoorSongs = { Music_PalletTown = true }
|
|
Music.playMap(data, "PALLET_TOWN", true, false)
|
|
check(lastSource().queueable,
|
|
"a renamed bike theme engages on outdoor maps")
|
|
check(Music.special(data, "title") == "Music_TitleScreen",
|
|
"roles the data table omits still fall back")
|
|
|
|
data = reset(fixtureData())
|
|
data.audio.outdoorSongs = {}
|
|
Music.playMap(data, "PALLET_TOWN", true, false)
|
|
check(lastSource().queueable,
|
|
"a map outside the outdoor set keeps its own theme on the bike")
|
|
|
|
-- fanfare ducking: the shared table or the definition's own flag
|
|
data = reset(fixtureData())
|
|
local ducked = {}
|
|
local realDuck = Music.duckForFanfare
|
|
Music.duckForFanfare = function(src) ducked[#ducked + 1] = src end
|
|
Sound.play(data, "Level_Up")
|
|
check(#ducked == 1, "a vanilla fanfare ducks the music")
|
|
Sound.play(data, "Chime")
|
|
check(#ducked == 2, "a def with fanfare = true ducks without a table edit")
|
|
Sound.play(data, "Beep")
|
|
check(#ducked == 2, "an ordinary sfx does not duck")
|
|
data.audio.fanfares = { Beep = true }
|
|
Sound.invalidate()
|
|
Sound.play(data, "Beep")
|
|
check(#ducked == 3, "data.audio.fanfares supersedes the fallback table")
|
|
Music.duckForFanfare = realDuck
|
|
|
|
-- ------- cache invalidation
|
|
|
|
data = reset(fixtureData())
|
|
Sound.play(data, "Beep")
|
|
local firstBeep = lastSource()
|
|
Sound.play(data, "Beep")
|
|
check(lastSource() == firstBeep, "sources are cached across plays")
|
|
Sound.invalidate("Beep")
|
|
Sound.play(data, "Beep")
|
|
check(lastSource() ~= firstBeep, "Sound.invalidate drops the cached source")
|
|
|
|
data = reset(fixtureData())
|
|
Music.play(data, "Music_Broken")
|
|
check(#sources == 0, "a broken def creates no source")
|
|
data.audio.songs.Music_Broken = chipSongB
|
|
Music.play(data, "Music_Broken")
|
|
check(#sources == 0, "a failed label stays negatively cached")
|
|
Music.reload()
|
|
Music.play(data, "Music_Broken")
|
|
check(lastSource() and lastSource().queueable,
|
|
"Music.reload re-resolves a repaired def")
|
|
ChipAudio.invalidate()
|
|
|
|
-- ------- the music.select hook and the audio events
|
|
|
|
local events = require("src.mods.Events").new()
|
|
local hooks = require("src.mods.Hooks").new()
|
|
Runtime.install(events, hooks)
|
|
|
|
data = reset(fixtureData())
|
|
check(not Runtime.wantsHook("music.select"),
|
|
"with no wrapper the hook builds no context")
|
|
|
|
local seen = {}
|
|
hooks:wrap("music.select", function(nextLink, song, ctx)
|
|
seen[#seen + 1] = { song = song, reason = ctx.reason, mapId = ctx.mapId,
|
|
kind = ctx.kind, trainerId = ctx.trainerId,
|
|
onBike = ctx.onBike }
|
|
return nextLink(song, ctx)
|
|
end, nil, "test")
|
|
|
|
Music.playMap(data, "PALLET_TOWN", false, false)
|
|
check(seen[1].reason == "map" and seen[1].mapId == "PALLET_TOWN"
|
|
and seen[1].song == "Music_PalletTown",
|
|
"playMap reaches the hook with the map context")
|
|
Music.playBattle(data, "wild", "OPP_RIVAL3")
|
|
check(seen[2].reason == "battle" and seen[2].kind == "wild"
|
|
and seen[2].trainerId == "OPP_RIVAL3",
|
|
"playBattle threads the battle kind and trainer")
|
|
Music.playVictory(data, "wild")
|
|
check(seen[3].reason == "victory" and seen[3].kind == "wild",
|
|
"playVictory reaches the hook")
|
|
Music.playOnce(data, "Music_Other")
|
|
check(seen[4].reason == "once", "playOnce reaches the hook")
|
|
Music.play(data, "Music_Chip")
|
|
check(seen[5].reason == "direct", "a direct play defaults to the direct reason")
|
|
|
|
-- returning nil silences the cue; returning a label plays that label
|
|
Music.reload()
|
|
resetSources()
|
|
hooks:removeOwner("test")
|
|
hooks:wrap("music.select", function() return nil end, nil, "silencer")
|
|
Music.play(data, "Music_Chip")
|
|
check(#sources == 0, "a hook returning nil silences the cue")
|
|
hooks:removeOwner("silencer")
|
|
|
|
hooks:wrap("music.select", function(nextLink, song, ctx)
|
|
if song == "Music_Chip" then return nextLink("Music_Other", ctx) end
|
|
return nextLink(song, ctx)
|
|
end, nil, "swap")
|
|
Music.play(data, "Music_Chip")
|
|
check(lastSource().queueable, "a hook may swap the label")
|
|
Music.play(data, "Music_Other")
|
|
check(lastSource().queueable, "an unswapped label still plays")
|
|
|
|
-- a throwing wrapper is skipped and the chain continues
|
|
hooks:wrap("music.select", function() error("boom", 0) end, nil, "thrower")
|
|
Music.reload()
|
|
resetSources()
|
|
Music.play(data, "Music_Chip")
|
|
check(lastSource() and lastSource().queueable,
|
|
"a throwing wrapper is skipped and the surviving chain still runs")
|
|
hooks:removeOwner("thrower")
|
|
hooks:removeOwner("swap")
|
|
|
|
-- events
|
|
Music.reload()
|
|
resetSources()
|
|
local started, stopped, played = {}, {}, {}
|
|
events:on("music.started", function(p) started[#started + 1] = p end, nil, "test")
|
|
events:on("music.stopped", function(p) stopped[#stopped + 1] = p end, nil, "test")
|
|
events:on("sound.played", function(p) played[#played + 1] = p end, nil, "test")
|
|
|
|
Music.playMap(data, "PALLET_TOWN", false, false)
|
|
check(started[1] and started[1].song == "Music_PalletTown"
|
|
and started[1].reason == "map" and started[1].chip == true,
|
|
"music.started carries the song, reason and chip flag")
|
|
Music.play(data, "Music_Other")
|
|
check(started[2].previous == "Music_PalletTown" and started[2].chip == true,
|
|
"music.started names the song it replaced")
|
|
Music.stop()
|
|
check(#stopped == 1 and stopped[1].song == "Music_Other",
|
|
"music.stopped names the song that was playing")
|
|
Music.stop()
|
|
check(#stopped == 1, "stopping silence emits nothing")
|
|
|
|
Sound.play(data, "Beep")
|
|
check(played[1] and played[1].kind == "sfx" and played[1].name == "Beep",
|
|
"sound.played fires for an sfx")
|
|
Sound.playMove(data, { sound = "Chip_Sfx", pitch = 0x10, tempo = 0x90 })
|
|
check(played[2].kind == "move" and played[2].name == "Chip_Sfx",
|
|
"sound.played fires for a move sound")
|
|
data.audio.cries.CHIPMON = { chip = chipSfx.chip, pitch = 0, length = 0 }
|
|
Sound.playCry(data, "CHIPMON")
|
|
check(played[3].kind == "cry" and played[3].species == "CHIPMON",
|
|
"sound.played fires for a cry")
|
|
|
|
Runtime.install(savedEvents, savedHooks)
|
|
check(not Runtime.wants("music.started"),
|
|
"with no listener the event site allocates no payload")
|
|
|
|
-- ------- registries: the granular merge into data.audio
|
|
|
|
local function memfs(files)
|
|
return {
|
|
read = function(path) return files[path] end,
|
|
getInfo = function(path)
|
|
if files[path] then return { type = "file" } end
|
|
local prefix = path .. "/"
|
|
for key in pairs(files) do
|
|
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
|
end
|
|
return nil
|
|
end,
|
|
load = function(path)
|
|
if not files[path] then return nil, "no file: " .. path end
|
|
return load(files[path], path)
|
|
end,
|
|
getDirectoryItems = function(path)
|
|
local seen, items = {}, {}
|
|
local prefix = path .. "/"
|
|
for key in pairs(files) do
|
|
if key:sub(1, #prefix) == prefix then
|
|
local child = key:sub(#prefix + 1):match("^[^/]+")
|
|
if child and not seen[child] then
|
|
seen[child] = true
|
|
items[#items + 1] = child
|
|
end
|
|
end
|
|
end
|
|
table.sort(items)
|
|
return items
|
|
end,
|
|
}
|
|
end
|
|
|
|
local function manifestJson(id, api, deps)
|
|
return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua","dependencies":%s,"api":%d}]])
|
|
:format(id, id, deps or "[]", api)
|
|
end
|
|
|
|
local granularFiles = {
|
|
["mods/coast/manifest.json"] = manifestJson("coast", 2),
|
|
["mods/coast/main.lua"] = [[
|
|
local ChipAsm = require("src.audio.ChipAsm")
|
|
return function(mod)
|
|
mod.content.music:register("Music_CoastTown", ChipAsm.song{
|
|
channels = { { hw = 1, program = {
|
|
{ notetype = { speed = 12, volume = 12, fade = 0 } },
|
|
{ octave = 4 }, { note = "C", len = 8 },
|
|
{ loop = { count = 0, to = 1 } },
|
|
} } },
|
|
})
|
|
mod.content.sfx:register("Shell_Found", {
|
|
file = "assets/chime.wav", fanfare = true })
|
|
mod.content.cries:register("SHELLORD", {
|
|
header = { address = 16696, bank = 2, engine = 1 }, pitch = 42, length = 80 })
|
|
mod.content.cries:register("REEFMON", ChipAsm.sfx{ channels = { { hw = 1,
|
|
program = { { squareNote = { len = 4, volume = 15, fade = 1,
|
|
frequency = 0x500 } } } } } })
|
|
mod.content.map_songs:override("PALLET_TOWN", "Music_CoastTown")
|
|
mod.content.cries:patch("RHYDON", { pitch = 200 })
|
|
mod.content.sfx:remove("Beep")
|
|
end
|
|
]],
|
|
}
|
|
local merged = fixtureData()
|
|
merged.audio.cries.RHYDON = {
|
|
header = { address = 1, bank = 2, engine = 1 }, pitch = 0, length = 9,
|
|
}
|
|
local granular = Loader.new({ fs = memfs(granularFiles) })
|
|
check(granular:load(merged) == true,
|
|
"the granular audio mod loads: " .. table.concat(granular.errors, "; "))
|
|
check(merged.audio.songs.Music_CoastTown
|
|
and merged.audio.songs.Music_CoastTown.chip,
|
|
"music merges into data.audio.songs")
|
|
check(merged.audio.sfx.Shell_Found.fanfare == true,
|
|
"sfx merges into data.audio.sfx")
|
|
check(merged.audio.cries.SHELLORD.pitch == 42,
|
|
"cries merge into data.audio.cries")
|
|
check(merged.audio.mapSongs.PALLET_TOWN == "Music_CoastTown",
|
|
"map_songs merge into data.audio.mapSongs")
|
|
check(merged.audio.cries.RHYDON.pitch == 200
|
|
and merged.audio.cries.RHYDON.length == 9,
|
|
"patch is field-precise on a cry record")
|
|
check(merged.audio.sfx.Beep == nil, "remove tombstones an sfx")
|
|
|
|
-- the merged map song plays through the ordinary map path
|
|
reset(merged)
|
|
Music.playMap(merged, "PALLET_TOWN", false, false)
|
|
check(sources[1] and sources[1].queueable and sources[1].playing,
|
|
"a mod's map song plays on the map it claims")
|
|
|
|
-- a brand-new species sounds everywhere a vanilla one does
|
|
local reefCry = Sound.playCry(merged, "REEFMON")
|
|
check(reefCry and reefCry.playing,
|
|
"a species the mod invented plays its registered cry")
|
|
|
|
-- and the hook can still take the map theme away from it
|
|
local mapHooks = require("src.mods.Hooks").new()
|
|
Runtime.install(require("src.mods.Events").new(), mapHooks)
|
|
mapHooks:wrap("music.select", function(nextLink, song, ctx)
|
|
if ctx.reason == "map" then return nextLink("Music_Other", ctx) end
|
|
return nextLink(song, ctx)
|
|
end, nil, "night")
|
|
reset(merged)
|
|
Music.playMap(merged, "PALLET_TOWN", false, false)
|
|
check(lastSource().queueable,
|
|
"music.select overrides the track for a map")
|
|
Runtime.install(savedEvents, savedHooks)
|
|
|
|
-- bootstrap: a dataset with no audio namespace at all
|
|
local bootstrapFiles = {
|
|
["mods/tc/manifest.json"] = manifestJson("tc", 2),
|
|
["mods/tc/main.lua"] = [[
|
|
local ChipAsm = require("src.audio.ChipAsm")
|
|
return function(mod)
|
|
mod.content.music:register("Music_TC", ChipAsm.song{
|
|
channels = { { hw = 1, program = {
|
|
{ notetype = { speed = 12, volume = 12, fade = 0 } },
|
|
{ octave = 4 }, { note = "C", len = 8 },
|
|
{ loop = { count = 0, to = 1 } },
|
|
} } },
|
|
})
|
|
mod.content.map_songs:register("TC_TOWN", "Music_TC")
|
|
end
|
|
]],
|
|
}
|
|
local bare = {}
|
|
local bootstrap = Loader.new({ fs = memfs(bootstrapFiles) })
|
|
check(bootstrap:load(bare) == true,
|
|
"an audio-only conversion loads against a dataset with no audio: "
|
|
.. table.concat(bootstrap.errors, "; "))
|
|
check(bare.audio and bare.audio.songs.Music_TC
|
|
and bare.audio.mapSongs.TC_TOWN == "Music_TC",
|
|
"the audio namespace is created when the base cache never shipped one")
|
|
|
|
-- the v1 whole-table registry still works and loses to a granular
|
|
-- registration of the same id
|
|
local v1Files = {
|
|
["mods/legacy/manifest.json"] = manifestJson("legacy", 1),
|
|
["mods/legacy/main.lua"] = [[
|
|
return function(mod)
|
|
mod.content.audio:override("sfx", { Beep = "assets/chime.wav",
|
|
Legacy_Only = "assets/beep.wav" })
|
|
end
|
|
]],
|
|
["mods/modern/manifest.json"] = manifestJson("modern", 2, '["legacy"]'),
|
|
["mods/modern/main.lua"] = [[
|
|
return function(mod)
|
|
mod.content.sfx:override("Beep", "assets/beep.wav")
|
|
end
|
|
]],
|
|
}
|
|
local v1Data = fixtureData()
|
|
local v1Loader = Loader.new({ fs = memfs(v1Files) })
|
|
check(v1Loader:load(v1Data) == true,
|
|
"the v1 audio registry still loads: " .. table.concat(v1Loader.errors, "; "))
|
|
check(v1Data.audio.sfx.Legacy_Only == "assets/beep.wav",
|
|
"the v1 whole-table replacement still applies")
|
|
check(v1Data.audio.sfx.Beep == "assets/beep.wav",
|
|
"a granular registration beats a v1 whole-table replacement")
|
|
check(v1Data.audio._owners.sfx.Beep == "modern",
|
|
"the granular writer owns the id even when a v1 table landed on it first")
|
|
|
|
-- attribution: the owner map the merge stamps names the mod in the log and
|
|
-- in Loader.errors, which is the only feed the mod manager's errors screen
|
|
-- and its errored-mod glyph read
|
|
local function errorsMentioning(loader, fragment)
|
|
local count = 0
|
|
for _, line in ipairs(loader.errors) do
|
|
if line:find(fragment, 1, true) then count = count + 1 end
|
|
end
|
|
return count
|
|
end
|
|
|
|
-- the defs are registered through a real load, not planted in the data, so
|
|
-- the provenance under test is the one the merge produced
|
|
local badFiles = {
|
|
["mods/coast/manifest.json"] = manifestJson("coast", 2),
|
|
["mods/coast/main.lua"] = [[
|
|
return function(mod)
|
|
mod.content.music:register("Music_Bad", { file = "assets/missing.wav" })
|
|
mod.content.sfx:register("Sfx_Bad", { file = "assets/missing.wav" })
|
|
mod.content.sfx:register("Loop_Bad", { file = "assets/missing.wav" })
|
|
mod.content.cries:register("BADMON", { file = "assets/missing.wav" })
|
|
mod.content.sfx:register("Gone", { file = "assets/missing.wav" })
|
|
mod.content.sfx:remove("Gone")
|
|
end
|
|
]],
|
|
}
|
|
local badData = fixtureData()
|
|
local badLoader = Loader.new({ fs = memfs(badFiles) })
|
|
check(badLoader:load(badData) == true,
|
|
"the mod shipping the broken defs loads: "
|
|
.. table.concat(badLoader.errors, "; "))
|
|
check(badData.audio._owners.songs.Music_Bad == "coast"
|
|
and badData.audio._owners.sfx.Sfx_Bad == "coast"
|
|
and badData.audio._owners.sfx.Loop_Bad == "coast"
|
|
and badData.audio._owners.cries.BADMON == "coast",
|
|
"the merge stamps every def a mod registered with its owner")
|
|
check(badData.audio._owners.sfx.Gone == nil,
|
|
"a tombstoned id keeps no provenance behind it")
|
|
check(badData.audio._owners.sfx.Beep == nil,
|
|
"an id no mod touched stays unattributed")
|
|
|
|
reset(badData)
|
|
local attributedBefore = loggedCount("(mod coast)")
|
|
local errorsBefore = #badLoader.errors
|
|
Music.play(badData, "Music_Bad")
|
|
Sound.play(badData, "Sfx_Bad")
|
|
Sound.playCry(badData, "BADMON")
|
|
Sound.startLoop(badData, "Loop_Bad")
|
|
check(loggedCount("(mod coast)") == attributedBefore + 4,
|
|
"a bad def is logged against the mod that registered it")
|
|
check(#badLoader.errors == errorsBefore + 4,
|
|
"every play-time audio failure reaches Loader.errors")
|
|
check(errorsMentioning(badLoader, 'coast: audio: bad song def "Music_Bad"') == 1
|
|
and errorsMentioning(badLoader, 'coast: audio: bad sfx def "Sfx_Bad"') == 1
|
|
and errorsMentioning(badLoader, 'coast: audio: bad cry def "BADMON"') == 1
|
|
and errorsMentioning(badLoader, 'coast: audio: bad sfx def "Loop_Bad"') == 1,
|
|
"Loader.errors names the owning mod and the def that failed")
|
|
|
|
-- replaying a known-bad def is silent: the negative cache keeps the errors
|
|
-- screen from filling up with one broken def
|
|
Music.play(badData, "Music_Bad")
|
|
Sound.play(badData, "Sfx_Bad")
|
|
Sound.playCry(badData, "BADMON")
|
|
Sound.startLoop(badData, "Loop_Bad")
|
|
check(#badLoader.errors == errorsBefore + 4,
|
|
"a known-bad def reports to Loader.errors once, not per play")
|
|
|
|
-- an engine-owned def has no mod to blame, so it stays a console line
|
|
badData.audio.songs.Music_BaseBad = { file = "assets/missing.wav" }
|
|
Music.play(badData, "Music_BaseBad")
|
|
check(loggedCount("bad song def") > 0 and #badLoader.errors == errorsBefore + 4,
|
|
"a base-owned failure never lands in Loader.errors")
|
|
|
|
-- ------- restore the ambient stubs for the suites that follow
|
|
|
|
Sound.invalidate()
|
|
Music.reload()
|
|
Runtime.install(savedEvents, savedHooks, savedErrors)
|
|
love.audio, love.sound = savedAudio, savedSound
|
|
|
|
S.finish()
|