mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 00:02:23 +02:00
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:
+48
-2
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user