CLOSES #788, CLOSES #795, CLOSES #796, CLOSES #797, CLOSES #805, CLOSES #826, CLOSES #833, CLOSES #835, CLOSES #837, CLOSES #844, CLOSES #845, CLOSES #846, CLOSES #847

This commit is contained in:
bryanthaboi
2026-08-05 11:09:05 -04:00
parent d18824cfae
commit f6392e8932
32 changed files with 2776 additions and 114 deletions
+48 -2
View File
@@ -202,6 +202,30 @@ local function headerChannels(banks, header)
return channels
end
-- Which software channels (CHAN5-8) an sfx occupies: its header carries one
-- 3-byte descriptor per channel. Audio2_PlaySound walks exactly this list to
-- decide whether a new sfx may start at all (audio/engine_2.asm
-- .sfxChannelLoop), so Sound.playMove needs the set to reproduce that gate.
-- nil = not knowable here (a file def, or the banks are not readable yet),
-- which callers read as "no conflict".
function ChipSynth.effectChannels(data, def)
if type(def) ~= "table" then return nil end
local chip = def.chip
local specs = chip and chip.channels
if not specs then
if not def.address then return nil end
local ok, banks = pcall(engineBanks, data, chip)
if not ok then return nil end
local read
ok, read = pcall(headerChannels, banks, def)
if not ok then return nil end
specs = read
end
local channels = {}
for _, spec in ipairs(specs) do channels[#channels + 1] = spec.number end
return channels
end
local function fadeValue(nibble)
if bit.band(nibble, 8) ~= 0 then return -bit.band(nibble, 7) end
return nibble
@@ -406,7 +430,15 @@ function Channel:nextEvent()
elseif command == 0xEC then
self.duty = bit.band(self:byte(), 3)
elseif command == 0xED then
self.engine.tempo = self:byte() * 0x100 + self:byte()
local high = self:byte()
local low = self:byte()
-- a header carrying its own tempo is one of audio/alternate_tempo.asm's
-- Music_*AlternateTempo entry points, which re-point channel 1 at a
-- stub that sets the tempo and jumps into the normal body -- the body's
-- own tempo command never runs there, so ignore it here (#847)
if not self.engine.tempoLocked then
self.engine.tempo = high * 0x100 + low
end
elseif command == 0xEE then
self.engine.pan = self:byte()
elseif command == 0xEF or command == 0xF0 then
@@ -458,7 +490,15 @@ function Channel:nextEvent()
local volume = bit.rshift(packed, 4)
local fade = fadeValue(bit.band(packed, 0x0F))
if self.noise then
local parameter = self:byte()
-- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the
-- frequency low byte for every channel at or past CHAN5, the noise
-- channel included (audio/engine_2.asm Audio2_ApplyFrequencyModifier).
-- On CHAN8 that byte is the polynomial counter, so the modifier moves
-- the noise pitch; it wraps at 8 bits, the carry landing in the high
-- 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)
return self:noiseEvent(
self:durationTicks(length), volume, fade, parameter)
end
@@ -753,6 +793,12 @@ function Engine.new(data, header, options)
noiseInstruments = {},
channels = {},
}, Engine)
-- header.tempo: the Music_*AlternateTempo override Music.play stamps onto
-- a copy of the song def (audio/alternate_tempo.asm) (#847)
if header.tempo then
engine.tempo = header.tempo
engine.tempoLocked = true
end
for _, spec in ipairs(chip and chip.channels
or headerChannels(banks, header)) do
local frameTicks = options.frameTicks
+16 -1
View File
@@ -82,6 +82,7 @@ state = {
fanfare = nil, -- fanfare SFX source; the song pauses while it plays
fanfareResume = false, -- start/resume state.source when the fanfare ends
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
tempo = nil, -- alternate-tempo override in force for `current`
failed = {}, -- labels whose def could not be started; logged once
}
@@ -234,11 +235,23 @@ function Music.play(data, song, loop, ctx)
if not song then return end
if not love.audio then return end -- headless test stub
song = selectSong(song, ctx)
-- ctx.tempo is a Music_*AlternateTempo cue (audio/alternate_tempo.asm):
-- the same song restarted with channel 1 re-pointed at a stub whose only
-- difference is its `tempo`, so the same label at a different tempo is a
-- different cue and must not be deduped away (#847)
local tempo = ctx and ctx.tempo or nil
-- a hook may silence the cue outright, or swap in a label the dedupe
-- below has to compare against
if not song or song == state.current then return end
if not song or (song == state.current and tempo == state.tempo) then return end
local def = songDef(data, song)
if not def or state.failed[song] then return end
if tempo then
-- shallow copy: the registry def is shared, only this playback is slowed
local slowed = {}
for key, value in pairs(def) do slowed[key] = value end
slowed.tempo = tempo
def = slowed
end
local wantLoop = loop ~= false
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
if not src then
@@ -274,6 +287,7 @@ function Music.play(data, song, loop, ctx)
local previous = state.current
state.source, state.loopSource, state.chip = src, loopSrc, isChip
state.current = song
state.tempo = tempo
if Runtime.wants("music.started") then
Runtime.emit("music.started", {
song = song, previous = previous, chip = isChip,
@@ -288,6 +302,7 @@ function Music.stop()
stopSource(state.loopSource)
require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
state.tempo = nil
state.chip = false
state.pendingRestore = nil
if previous and Runtime.wants("music.stopped") then
+17 -2
View File
@@ -368,11 +368,26 @@ function SaveData.saveOptions(opts, fs)
end
opts.modOptions = merged
end
local ok, err = fs.write(OPTIONS_FILENAME, SaveSerializer.encode(opts))
local encoded = SaveSerializer.encode(opts)
local ok, err = fs.write(OPTIONS_FILENAME, encoded)
if not ok then
Logger.error("options save failed: %s", tostring(err))
return nil
end
return ok and opts or nil
-- #828: settings "reset" on Android and Steam Deck with nothing in the log.
-- Every options write is a WHOLE-FILE rewrite, so a write that reports
-- success without the bytes landing (an external-storage volume that went
-- away mid-session, a read-only or full save dir) is indistinguishable from
-- "the launcher never saved". Read the file back and fail loudly instead:
-- callers already treat nil as a failed write, and the log line is what the
-- next report from those platforms needs to carry.
local wrote = fs.getInfo(OPTIONS_FILENAME) and fs.read(OPTIONS_FILENAME)
if wrote ~= encoded then
Logger.error("options save did not land (%d bytes written, %s on disk)",
#encoded, type(wrote) == "string" and tostring(#wrote) or "nothing")
return nil
end
return opts
end
function SaveData.loadOptions(fs)
+84 -15
View File
@@ -208,29 +208,91 @@ end
-- sfx table; older audio.lua builds without the variants fall back to
-- the unmodified sound.
-- anim: a moves.lua anim table { sound, pitch, tempo }.
--
-- Whether a row sound is heard at all is Audio2_PlaySound's channel gate
-- (audio/engine_2.asm .playSfx/.sfxChannelLoop): for every channel the new
-- sfx wants, a channel still busy with a LOWER sound id aborts the whole
-- request (`cp [hl] / jr z,.playChannel / jr c,.playChannel / ret`), while
-- an equal or lower id takes those channels over. A sound id is
-- (header address - SFX_Headers_1) / 3 (constants/music_constants.asm
-- music_const), so a def's header address orders ids inside one engine
-- bank. Blizzard's animation is two rows, BLIZZARD then HYDRO_PUMP
-- (data/moves/animations.asm BlizzardAnim), and SFX_BATTLE_29 (CHAN5+8) is
-- 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 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
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
-- 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
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
end
local function noteMoveSfx(data, def, src)
if not src or type(def) ~= "table" or not def.address then
lastMoveSfx = nil
return
end
lastMoveSfx = {
src = src, rank = def.address, engine = def.engine,
channels = require("src.core.ChipSynth").effectChannels(data, def),
}
end
function Sound.playMove(data, anim)
if not anim or not anim.sound then return end
local sfx = data.audio and data.audio.sfx
if not sfx then return end
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 src
-- a chip program synthesizes the modified variant on demand; a file def
-- can only reach for a pre-rendered one
if isChipDef(sfx[name]) then
if playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
sfx[name], pitch, tempo) then
played("move", name)
end
return
end
if pitch ~= 0 or tempo ~= 0x80 then
if isChipDef(def) then
src = playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
def, pitch, tempo)
else
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
if sfx[key] then
if playPath(data, key, sfx[key]) then played("move", name) end
return
if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then
src = playPath(data, key, sfx[key])
else
src = playPath(data, name, def)
end
end
if playPath(data, name, sfx[name]) then played("move", name) end
if src then
played("move", name)
noteMoveSfx(data, def, src)
end
end
-- A derived cry ({ base = "RHYDON", pitch, length }) borrows another
@@ -304,12 +366,18 @@ end
-- returns the source (nil headless) so callers that block on the cry
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
function Sound.playCry(data, species)
function Sound.playCry(data, species, pikaClip)
if not love.audio then return nil end
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
-- never used for the species there); clip 1 is the everyday "Pika!"
-- never used for the species there). Which clip is a property of the
-- call site in the original -- every caller of PlayPikachuSoundClip sets
-- its own `ldpikacry e, PikachuCryN` -- so pikaClip carries that choice
-- in; it is ignored for every other species. Clip 1 is the LONG
-- title-screen "Pikachuuu" (engine/movie/title.asm:146), kept as the
-- default only for the sites that have not been given their own clip
-- yet; battle entrances pass 11/37 (#837).
if species == "PIKACHU" then
local src = Sound.playPikaCry(data, 1)
local src = Sound.playPikaCry(data, pikaClip or 1)
if src then return src end
end
local cries = data.audio and data.audio.cries
@@ -451,6 +519,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
local function evict(store, key)
local src = store[key]
if src then pcall(src.stop, src) end