G2 support

This commit is contained in:
bryanthaboi
2026-08-11 11:52:56 -04:00
parent 79ed37699e
commit ae6cac89e1
489 changed files with 226677 additions and 1798 deletions
+367 -3
View File
@@ -98,6 +98,15 @@ local PITCHES = {
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA,
}
-- Gen 2 FrequencyTable (audio/notes.asm): index 0 = rest, then C_..B_ twice
-- so transpose can walk into the next octave without an octave command.
local GEN2_FREQUENCY = {
0x0000,
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA,
0xFC16, 0xFC4E, 0xFC83, 0xFCB5, 0xFCE5, 0xFD11,
0xFD3B, 0xFD63, 0xFD89, 0xFDAC, 0xFDCD, 0xFDED,
}
-- LuaGB / DMG 8-step duty tables (index 0-3); stored on channels as that index
local WAVE_PATTERN_TABLES = {
[0] = {0, 0, 0, 0, 0, 0, 0, 1},
@@ -243,6 +252,9 @@ function Channel.new(engine, spec, options)
options = options or {}
local hardware = (spec.number - 1) % 4 + 1
local isSfxChannel = spec.number > 4
-- Default LR tracks match pokegold MonoTracks / StereoTracks ($11/$22/…).
local trackBit = bit.lshift(1, hardware - 1)
local tracks = bit.bor(bit.lshift(trackBit, 4), trackBit)
return setmetatable({
engine = engine,
bank = options.bank,
@@ -257,10 +269,18 @@ function Channel.new(engine, spec, options)
frequencyOffset = options.frequencyOffset or 0,
frameTicks = options.frameTicks or FRAME_TICKS,
speed = 12,
noteLength = 1, -- Gen 2 CHANNEL_NOTE_LENGTH (note_type)
durationModifier = 0, -- Gen 2 fractional-frame carry
volume = 12,
fade = 0,
duty = 2,
octave = 4,
transposition = 0, -- Gen 2: hi=octaves, lo=pitches
pitchOffset = 0, -- Gen 2 pitch_offset (signed word add to freq)
noiseKit = 0,
noiseSampling = false, -- Gen 2 toggle_noise
condition = 0, -- Gen 2 set_condition / sound_jump_if
tracks = tracks, -- Gen 2 CHANNEL_TRACKS (NR51 bits for this channel)
waveInstrument = 0,
waveLevel = 1,
perfectPitch = false,
@@ -299,6 +319,24 @@ function Channel:frequency(note, octave)
return bit.band(register + self.frequencyOffset, 0x7FF)
end
-- pokegold GetFrequency: FrequencyTable[pitch+transpose] with asr while
-- CHANNEL_OCTAVE (+ transpose hi) < 7, then optional pitch_offset.
function Channel:frequencyGen2(note, octave)
local trans = self.transposition or 0
local pitch = note + bit.band(trans, 0x0F)
local oct = (octave or self.octave) + bit.rshift(trans, 4)
local tableVal = GEN2_FREQUENCY[pitch + 1] or 0
local signed = tableVal - 0x10000
local shifts = 0
while oct < 7 do
shifts = shifts + 1
oct = oct + 1
end
local register = bit.band(bit.arshift(signed, shifts), 0x7FF)
register = bit.band(register + (self.pitchOffset or 0), 0x7FF)
return bit.band(register + self.frequencyOffset, 0x7FF)
end
function Channel:durationTicks(length)
local tempo = self.sfx and self.frameTicks or self.engine.tempo
local speed = self.sfx and (self.executeMusic and self.speed or 1)
@@ -306,6 +344,33 @@ function Channel:durationTicks(length)
return length * speed * tempo
end
-- Gen 2 SetNoteDuration (audio/engine.asm). Two eight-bit multiplies, and
-- BOTH of them throw the overflow away -- which is the whole character of the
-- routine and the reason it cannot be written as one product:
--
-- low = LOW((length + 1) * NoteLength) `ld a, l` after .Multiply
-- product = tempo * low + DurationModifier 16-bit, wraps
-- frames = HIGH(product) `ld [hl], d`, one byte
-- modifier= LOW(product) carries into the next note
--
-- Keeping the full product instead is what made a cry run for seconds: a cry
-- sets CHANNEL_TEMPO to its length word (up to 576), so tempo * low routinely
-- runs past 16 bits and the truncation is load bearing rather than incidental.
--
-- NoteLength defaults to 1 and tempo to $100 -- LoadChannel's own defaults --
-- so a channel that never issues note_type or tempo still times correctly.
-- After toggle_sfx (executeMusic), fanfares like Sfx_CaughtMon use the
-- channel's tempo command, not the SFX frameTicks seed.
function Channel:durationTicksGen2(length)
local tempo = (self.sfx and not self.executeMusic)
and self.frameTicks or self.engine.tempo
local low = bit.band((length + 1) * (self.noteLength or 1), 0xFF)
local product = bit.band(tempo * low + (self.durationModifier or 0), 0xFFFF)
self.durationModifier = bit.band(product, 0xFF)
local frames = math.floor(product / 256)
return frames * FRAME_TICKS
end
function Channel:timedEvent(event, ticks)
local first = snapTicks(self.timeTicks)
self.timeTicks = self.timeTicks + ticks
@@ -318,6 +383,11 @@ end
function Channel:pan()
local mask = bit.lshift(1, self.hardware - 1)
if self.engine.generation == 2 then
local tracks = self.tracks or 0xFF
return bit.band(bit.rshift(tracks, 4), mask) ~= 0,
bit.band(tracks, mask) ~= 0
end
return bit.band(bit.rshift(self.engine.pan, 4), mask) ~= 0,
bit.band(self.engine.pan, mask) ~= 0
end
@@ -365,9 +435,15 @@ end
function Channel:drumEvent(ticks, instrument)
local panLeft, panRight = self:pan()
local drum
if self.engine.generation == 2 then
drum = self.engine:drumInstrumentGen2(self.noiseKit or 0, instrument)
else
drum = self.engine:noiseInstrument(instrument)
end
return self:timedEvent({
noise = true,
drum = self.engine:noiseInstrument(instrument),
drum = drum,
panLeft = panLeft,
panRight = panRight,
}, ticks)
@@ -378,6 +454,9 @@ function Channel:silenceEvent(ticks)
end
function Channel:nextEvent()
if self.engine.generation == 2 then
return self:nextEventGen2()
end
if self.ended then return nil end
for _ = 1, 100000 do
local commandAddress = self.address
@@ -526,6 +605,222 @@ function Channel:nextEvent()
return nil
end
-- Gen 2 music bytecode (pokegold macros/scripts/audio.asm, FIRST_MUSIC_CMD=$d0).
-- Notes share the Gen 1 packing; rest is pitch 0. Call/loop opcodes are
-- swapped vs Gen 1 ($fe call, $fd loop) and $fc is sound_jump.
function Channel:nextEventGen2()
if self.ended then return nil end
for _ = 1, 100000 do
local commandAddress = self.address
local command = self:byte()
if command < 0xD0 and self.sfx and not self.executeMusic then
-- ParseSFXOrCry. On a channel carrying SOUND_SFX or SOUND_CRY a byte
-- under $d0 is not a packed note at all: it is a `square_note` /
-- `noise_note` row, and SetNoteDuration is handed the WHOLE byte rather
-- than its low nibble. What follows is the volume envelope and then
-- the raw frequency register -- two bytes on a tone channel, one on
-- noise, where it is the polynomial counter instead.
--
-- Parsing these as music notes is what made every Gold cry and sound
-- effect wrong: the envelope byte was read as a second note and the
-- frequency low byte ($d8 for 1752, say) as a note_type command that
-- then ate the next two bytes.
local ticks = self:durationTicksGen2(command)
local packed = self:byte()
local volume = bit.rshift(packed, 4)
local fade = fadeValue(bit.band(packed, 0x0F))
if self.noise then
local parameter = bit.band(self:byte() + self.frequencyOffset, 0xFF)
return self:noiseEvent(ticks, volume, fade, parameter)
end
-- CHANNEL_PITCH_OFFSET is wCryPitch for a cry and the SFX pitch
-- modifier otherwise; both land in frequencyOffset. The add is 16-bit
-- on hardware and only 11 bits reach the register, so a negative pitch
-- stored as its unsigned word still comes out right.
local register = bit.band(self:word() + self.frequencyOffset, 0x7FF)
return self:tone(ticks, register, volume, fade)
elseif command < 0xD0 then
local note = bit.rshift(command, 4)
local length = bit.band(command, 0x0F)
local ticks = self:durationTicksGen2(length)
if note == 0 then
return self:silenceEvent(ticks)
end
if self.noise and self.noiseSampling then
return self:drumEvent(ticks, note)
end
if self.noise then
return self:silenceEvent(ticks)
end
return self:tone(ticks, self:frequencyGen2(note))
elseif command >= 0xD0 and command <= 0xD7 then
-- octave 8 → $d0 (stored 0); octave 1 → $d7 (stored 7)
self.octave = bit.band(command, 7)
elseif command == 0xD8 then -- note_type / drum_speed
self.noteLength = self:byte()
if not self.noise then
local packed = self:byte()
if self.wave then
self.waveLevel = WAVE_LEVEL[bit.band(bit.rshift(packed, 4), 3)]
self.waveInstrument = bit.band(packed, 0x0F)
else
self.volume = bit.rshift(packed, 4)
self.fade = fadeValue(bit.band(packed, 0x0F))
end
end
elseif command == 0xD9 then -- transpose
self.transposition = self:byte()
elseif command == 0xDA then -- tempo (big-endian)
local high, low = self:byte(), self:byte()
if not self.engine.tempoLocked then
self.engine.tempo = high * 0x100 + low
end
self.durationModifier = 0
elseif command == 0xDB then -- duty_cycle
self.duty = bit.band(self:byte(), 3)
elseif command == 0xDC then -- volume_envelope
local packed = self:byte()
if self.wave then
self.waveLevel = WAVE_LEVEL[bit.band(bit.rshift(packed, 4), 3)]
self.waveInstrument = bit.band(packed, 0x0F)
else
self.volume = bit.rshift(packed, 4)
self.fade = fadeValue(bit.band(packed, 0x0F))
end
elseif command == 0xDD then -- pitch_sweep (SFX; keep for completeness)
local packed = self:byte()
self.sweep = {
pace = bit.band(bit.rshift(packed, 4), 7),
subtract = bit.band(packed, 8) ~= 0,
shift = bit.band(packed, 7),
}
elseif command == 0xDE then -- duty_cycle_pattern
local packed = self:byte()
self.duty = {
bit.band(bit.rshift(packed, 6), 3),
bit.band(bit.rshift(packed, 4), 3),
bit.band(bit.rshift(packed, 2), 3),
bit.band(packed, 3),
}
elseif command == 0xDF then -- toggle_sfx
self.executeMusic = not self.executeMusic
elseif command == 0xE0 then -- pitch_slide
local length, packed = self:byte(), self:byte()
local octave = bit.rshift(packed, 4)
self.pendingSlide = {
length = length,
target = self:frequencyGen2(bit.band(packed, 0x0F), octave),
}
elseif command == 0xE1 then -- vibrato
local delay, packed = self:byte(), self:byte()
local depth = bit.rshift(packed, 4)
if depth == 0 then
self.vibrato = nil
else
self.vibrato = {
delay = delay,
above = bit.rshift(depth, 1) + bit.band(depth, 1),
below = bit.rshift(depth, 1),
rate = bit.band(packed, 0x0F),
}
end
elseif command == 0xE2 then -- unknownmusic0xe2
self:byte()
elseif command == 0xE3 then -- toggle_noise
if self.noiseSampling then
self.noiseSampling = false
else
self.noiseSampling = true
self.noiseKit = self:byte()
end
elseif command == 0xE4 then -- force_stereo_panning
local packed = self:byte()
local mask = bit.lshift(1, self.hardware - 1)
local default = bit.bor(bit.lshift(mask, 4), mask)
self.tracks = bit.band(packed, default)
elseif command == 0xE5 then -- volume (global master; ignored for mix)
self:byte()
elseif command == 0xE6 then -- pitch_offset (big-endian)
local high, low = self:byte(), self:byte()
local value = high * 0x100 + low
if value >= 0x8000 then value = value - 0x10000 end
self.pitchOffset = value
elseif command == 0xE7 or command == 0xE8 then -- unused
self:byte()
elseif command == 0xE9 then -- tempo_relative
local adj = self:byte()
if adj >= 0x80 then adj = adj - 0x100 end
self.engine.tempo = bit.band(self.engine.tempo + adj, 0xFFFF)
elseif command == 0xEA then -- restart_channel
self.address = self:word()
elseif command == 0xEB then -- new_song (unused in music streams)
self:word()
elseif command == 0xEC or command == 0xED then -- sfx priority on/off
-- no-op for the PCM renderer
elseif command == 0xEE then -- unknownmusic0xee
self:word()
elseif command == 0xEF then -- stereo_panning (honor always; options.stereo)
local packed = self:byte()
local mask = bit.lshift(1, self.hardware - 1)
local default = bit.bor(bit.lshift(mask, 4), mask)
self.tracks = bit.band(packed, default)
elseif command == 0xF0 then -- sfx_toggle_noise
if self.noiseSampling then
self.noiseSampling = false
else
self.noiseSampling = true
self.noiseKit = self:byte()
end
elseif command >= 0xF1 and command <= 0xF9 then
-- music0xf1-f9 / unused: no params
elseif command == 0xFA then -- set_condition
self.condition = self:byte()
elseif command == 0xFB then -- sound_jump_if
local want, target = self:byte(), self:word()
if self.condition == want then self.address = target end
elseif command == 0xFC then -- sound_jump
self.address = self:word()
elseif command == 0xFD then -- sound_loop (Gen 2; Gen 1 used $fe)
local count, target = self:byte(), self:word()
if count == 0 then
if self.allowLoops then
self.address = target
else
self.ended = true
return nil
end
else
local remaining = self.loopCounts[commandAddress]
if remaining == nil then remaining = count end
remaining = remaining - 1
if remaining > 0 then
self.loopCounts[commandAddress] = remaining
self.address = target
else
self.loopCounts[commandAddress] = nil
end
end
elseif command == 0xFE then -- sound_call
self.callStack[#self.callStack + 1] = self.address + 2
self.address = self:word()
elseif command == 0xFF then -- sound_ret
local returnAddress = table.remove(self.callStack)
if returnAddress then
self.address = returnAddress
else
self.ended = true
return nil
end
else
self.ended = true
return nil
end
end
self.ended = true
return nil
end
local function envelopeVolume(volume, fade, elapsed)
if fade == 0 then return volume end
local steps = math.floor(elapsed / (math.abs(fade) / 64))
@@ -778,6 +1073,41 @@ function Engine:noiseInstrument(number)
return segments
end
-- Gen 2 Drumkits → kit pointer → instrument noise_note script (ReadNoiseSample).
function Engine:drumInstrumentGen2(kit, pitch)
local key = kit * 256 + pitch
local cached = self.noiseInstruments[key]
if cached then return cached end
local segments = {}
local spec = self.drumkits
if spec and pitch and pitch > 0 then
local kitAddr = romWord(self.banks, spec.bank, spec.address + kit * 2)
local instrAddr = romWord(self.banks, spec.bank, kitAddr + pitch * 2)
local address = instrAddr
local ticks = 0
for _ = 1, 64 do
local command = romByte(self.banks, spec.bank, address)
address = address + 1
if command == 0xFF then break end
local packed = romByte(self.banks, spec.bank, address)
local parameter = romByte(self.banks, spec.bank, address + 1)
address = address + 2
-- ReadNoiseSample: delay = (length & $f) + 1 frames
local duration = (bit.band(command, 0x0F) + 1) * FRAME_TICKS
segments[#segments + 1] = {
startSample = snapTicks(ticks),
endSample = snapTicks(ticks + duration),
volume = bit.rshift(packed, 4),
fade = fadeValue(bit.band(packed, 0x0F)),
parameter = parameter,
}
ticks = ticks + duration
end
end
self.noiseInstruments[key] = segments
return segments
end
local function readWaves(banks, audio, engineNumber)
local spec = audio.waveBanks[tostring(engineNumber)]
local waves = {}
@@ -802,6 +1132,24 @@ local function readWaves(banks, audio, engineNumber)
return waves
end
-- Gen 2 WaveSamples: 10 patterns × 16 bytes (instruments 0-9).
local function readWavesGen2(banks, audio)
local spec = audio.waveBanks and audio.waveBanks["1"]
if not spec then return {} end
local waves = {}
for wave = 0, 9 do
local values = {}
for byteIndex = 0, 15 do
local packed = romByte(
banks, spec.bank, spec.address + wave * 16 + byteIndex)
values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8
values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8
end
waves[#waves + 1] = values
end
return waves
end
-- def-local waves are authored either as raw 0-15 nibbles (the ROM's own
-- units) or as the -1..1 samples readWaves produces; the synth wants the
-- latter (LuaGB: (nibble - 8) / 8)
@@ -828,10 +1176,18 @@ function Engine.new(data, header, options)
-- may supply its own waves/drums, falling back to a ROM engine's tables
local chip = header.chip
local banks = engineBanks(data, chip)
local engineNumber = chip and (chip.engine or 1) or header.engine
local generation = header.generation or audio.generation or 1
local engineNumber = chip and (chip.engine or 1) or header.engine or 1
local waves
if chip and chip.waves then
waves = normalizeWaves(chip.waves)
elseif generation == 2 then
if chip then
local ok, romWaves = pcall(readWavesGen2, banks, audio)
waves = ok and romWaves or {}
else
waves = readWavesGen2(banks, audio)
end
elseif chip then
local ok, romWaves = pcall(readWaves, banks, audio, engineNumber)
waves = ok and romWaves or {}
@@ -840,11 +1196,13 @@ function Engine.new(data, header, options)
end
local engine = setmetatable({
banks = banks,
generation = generation,
tempo = 0x100,
pan = 0xFF,
waves = waves,
noiseHeaders = audio.noiseHeaders
and audio.noiseHeaders[tostring(engineNumber)] or {},
drumkits = audio.drumkits,
customDrums = chip and chip.drums or nil,
noiseInstruments = {},
channels = {},
@@ -864,7 +1222,13 @@ function Engine.new(data, header, options)
if hardware == 4 then
frameTicks = FRAME_TICKS
elseif options.cryLength then
frameTicks = 0x80 + options.cryLength
-- Gen 1: Audio_SetSfxTempo builds a 9-bit tempo out of $80 plus the
-- cry's length BYTE. Gen 2: _PlayCry writes wCryLength -- a full word,
-- and its own comment says "Tempo is effectively length" -- straight
-- into CHANNEL_TEMPO, with no $80 base. Adding one anyway stretched
-- every Gold cry by a third on top of the parse bug above.
frameTicks = generation == 2 and options.cryLength
or (0x80 + options.cryLength)
end
engine.channels[#engine.channels + 1] = Channel.new(engine, spec, {
bank = chip and 0 or header.bank,