ChocolateBird/scripts/loaders/snes/music.gd

350 lines
13 KiB
GDScript

const MAX_TRACKS := 8
const MAX_LOOP_DEPTH := 8 # Apparently 4, but eh whatever
const NOTE_IS_TIE := -1
const NOTE_IS_REST := -2
enum EventType {
ADSR_ATTACK_RATE,
ADSR_DECAY_RATE,
ADSR_DEFAULT,
ADSR_SUSTAIN_RATE,
ADSR_SUSTAIN_LEVEL,
CPU_CONTROLLED_JUMP,
ECHO_FEEDBACK_FIR,
ECHO_OFF,
ECHO_ON,
ECHO_VOLUME_SLIDE,
ECHO_VOLUME,
END,
GOTO,
LOOP_BREAK,
LOOP_END,
LOOP_RESTART,
LOOP_START,
MASTER_VOLUME,
NOISE_FREQ,
NOISE_OFF,
NOISE_ON,
NONE,
NOTE,
OCTAVE_DOWN,
OCTAVE_UP,
OCTAVE,
PAN_SLIDE,
PAN_LFO_OFF,
PAN_LFO_ON,
PAN,
PITCH_SLIDE,
PITCHMOD_OFF,
PITCHMOD_ON,
PROGCHANGE,
TEMPO_SLIDE,
TEMPO,
TRANSPOSE_ABS,
TRANSPOSE_REL,
TREMOLO_OFF,
TREMOLO_ON,
TUNING,
VIBRATO_OFF,
VIBRATO_ON,
VOLUME_SLIDE,
VOLUME,
}
enum OperandType {
U8,
U16,
S8,
}
const OPERAND_MAP := {
EventType.ADSR_ATTACK_RATE: [OperandType.U8], # Attack Rate, & 0b00001111
EventType.ADSR_DECAY_RATE: [OperandType.U8], # Decay Rate, & 0b00000111
EventType.ADSR_DEFAULT: [],
EventType.ADSR_SUSTAIN_RATE: [OperandType.U8], # & 0b00001111
EventType.ADSR_SUSTAIN_LEVEL: [OperandType.U8], # & 0b00000111
EventType.CPU_CONTROLLED_JUMP: [OperandType.U16],
EventType.ECHO_FEEDBACK_FIR: [OperandType.U8, OperandType.U8],
EventType.ECHO_OFF: [],
EventType.ECHO_ON: [],
EventType.ECHO_VOLUME_SLIDE: [OperandType.U8, OperandType.U8],
EventType.ECHO_VOLUME: [OperandType.U8],
EventType.END: [],
EventType.GOTO: [OperandType.U16],
EventType.LOOP_BREAK: [OperandType.U8, OperandType.U16],
EventType.LOOP_END: [],
EventType.LOOP_START: [OperandType.U8],
EventType.MASTER_VOLUME: [OperandType.U8],
EventType.NOISE_FREQ: [OperandType.U8],
EventType.NOISE_OFF: [],
EventType.NOISE_ON: [],
EventType.OCTAVE_DOWN: [],
EventType.OCTAVE_UP: [],
EventType.OCTAVE: [OperandType.U8],
EventType.PAN_LFO_OFF: [],
EventType.PAN_LFO_ON: [OperandType.U8, OperandType.U8],
EventType.PAN_SLIDE: [OperandType.U8, OperandType.U8],
EventType.PAN: [OperandType.U8],
EventType.PITCH_SLIDE: [OperandType.U8, OperandType.S8],
EventType.PITCHMOD_OFF: [],
EventType.PITCHMOD_ON: [],
EventType.PROGCHANGE: [OperandType.U8],
EventType.TEMPO_SLIDE: [OperandType.U8, OperandType.U8],
EventType.TEMPO: [OperandType.U8],
EventType.TRANSPOSE_ABS: [OperandType.S8],
EventType.TRANSPOSE_REL: [OperandType.S8],
EventType.TREMOLO_OFF: [],
EventType.TREMOLO_ON: [OperandType.U8, OperandType.U8, OperandType.U8],
EventType.TUNING: [OperandType.U8],
EventType.VIBRATO_OFF: [],
EventType.VIBRATO_ON: [OperandType.U8, OperandType.U8, OperandType.U8],
EventType.VOLUME_SLIDE: [OperandType.U8, OperandType.U8],
EventType.VOLUME: [OperandType.U8],
}
const PPQN := 48 # Pulses Per Quarter Note. All the games this project cares about use 48 (allows fast triplets). Some other SNES games I don't care about use 12/24/32.
# const TIMER0_FREQUENCY := 36 # 0x24
# return 60000000.0 / (125 * PPQN * TIMER0_FREQUENCY)) * (tempo / 256.0);
static func tempo_to_bpm(tempo_byte: int) -> float:
if tempo_byte < 1:
return 1.0
return (tempo_byte / 255.0) * 60000000.0 / 216000.0 # VGMTrans uses /256.0 but I don't trust that
# bpm * ppqn = ppm (pulses per minute)
# ppm / 60 = pps (pulses per second)
# 1/pps = seconds per pulse
static func tempo_to_seconds_per_pulse(tempo_byte: int) -> float:
# 125 * TIMER0_FREQUENCY = 4500
return 4500.0 / (1000000.0 * tempo_byte / 255.0)
static func get_int_array(size: int) -> PoolIntArray:
var array := PoolIntArray()
array.resize(size)
array.fill(0)
return array
# In real OOP these would be static or static const, but GDScript doesn't allow that
# These need to be defined in subclasses for methods to work
var EVENT_MAP: Dictionary
var NOTE_DURATIONS: PoolByteArray # This might need to be made untyped if a future addition uses PoolIntArray instead
var REFERENCE_NOTE: int
var NOTE_TIE: int
var NOTE_REST: int
const LOGGING_LEVEL_INFO: bool = false
func print_info(s: String) -> void:
if LOGGING_LEVEL_INFO:
print(s)
# These would be static methods if NOTE_DURATIONS and EVENT_MAP could be static
func translate_instruction(buffer: StreamPeer) -> Array:
var instruction := buffer.get_u8()
if instruction < 0xD2:
var duration = self.NOTE_DURATIONS[instruction % 15]
var note = instruction / 15
if note >= 12: # 12 and 13 are rests and ties
if note == self.NOTE_REST:
note = NOTE_IS_REST
else:
note = NOTE_IS_TIE
return [EventType.NOTE, note, duration]
else:
# Control codes
var cc = self.EVENT_MAP[instruction]
var operand_list = OPERAND_MAP[cc]
var operands = [cc]
for o in operand_list:
match o:
OperandType.U8:
operands.append(buffer.get_u8())
OperandType.U16:
operands.append(buffer.get_u16())
OperandType.S8:
operands.append(buffer.get_8())
return operands
func disassemble_raw(buffer: StreamPeerBuffer, labels: Dictionary, address_start := 0x3000, address_end := 0x4800) -> PoolStringArray:
var output := PoolStringArray()
var address := address_start
var address_offset: int = address_start - buffer.get_position()
var ins_labels := EventType.keys()
while address < address_end:
if address in labels:
output.append(labels[address])
var instruction := translate_instruction(buffer)
var event_type = instruction[0]
match event_type:
EventType.NOTE:
var pulses = instruction[2]
match instruction[1]:
NOTE_IS_REST:
output.append(' $%04x: NOTE_REST %d pulses'%[address, pulses])
NOTE_IS_TIE:
output.append(' $%04x: NOTE_TIE %d pulses'%[address, pulses])
var pitch:
output.append(' $%04x: NOTE %d %d pulses'%[address, pitch, pulses])
_:
if len(instruction) > 1:
output.append(' $%04x: %s %s'%[address, ins_labels[event_type], instruction.slice(1, -1)])
else:
output.append(' $%04x: %s'%[address, ins_labels[event_type]])
address = address_offset + buffer.get_position()
return output
func disassemble_sfx(buffer: StreamPeerBuffer):
var labels := {}
buffer.seek(0x041F97)
for sfx in 178:
for channel in 2:
var offset: int = buffer.get_u16()
if offset >= 0x3000:
if offset in labels:
labels[offset] = labels[offset] + ', SFX_0x%02X:%d'%[sfx, channel]
labels[offset] = 'SFX_0x%02X:%d'%[sfx, channel]
buffer.seek(0x042397)
var lines := disassemble_raw(buffer, labels)
var file := File.new()
file.open('output/sfx_bank_disassembly.txt', File.WRITE)
for line in lines:
file.store_line(line)
file.close()
func unroll_track(buffer: StreamPeerBuffer, bgm_start_pos: int, track_start_pos: int, bgm_end_pos: int, bgm_id='N/A') -> Array:
# There are three ways a track's data can end:
# 1) Explicit End instruction
# 2) Instruction pointer out of bounds (one FFV track starts out of bounds to indicate an unused channel)
# 3) Infinite loop
# 3.1) unconditional GOTO to a previous address
# 3.2) LOOP_END with a prior [LOOP_START, 0]
# Detecting 1), 2) and 3.1) is trivial and stateless, but 3.2 requires loop tracking
# We may as well unroll the loops here and convert a final infinite loop to a GOTO
# While it is possible that GOTO may point forwards, we'll just treat it as an end for now
var start_bank := bgm_start_pos & 0x3F0000
var events := []
var event_idx := 0
var rom_address_to_event_index := {}
var loop_level: int = -1
var loop_positions := get_int_array(MAX_LOOP_DEPTH)
var loop_counts := get_int_array(MAX_LOOP_DEPTH)
var loop_counters := get_int_array(MAX_LOOP_DEPTH)
var loop_break_positions := get_int_array(MAX_LOOP_DEPTH)
var loop_break_targets := get_int_array(MAX_LOOP_DEPTH)
buffer.seek(track_start_pos)
while true:
var pos = buffer.get_position()
if pos >= bgm_end_pos: # Outside of the chunk that would be copied to the SPC
break
rom_address_to_event_index[pos] = event_idx
var event = translate_instruction(buffer)
match event[0]:
EventType.LOOP_START:
loop_level += 1
var count = event[1]
if count > 0:
count += 1
loop_counts[loop_level] = count
loop_positions[loop_level] = pos
EventType.LOOP_END:
# Check if the loop contains a break
var loop_count := loop_counts[loop_level]
if loop_break_positions[loop_level] > 0:
# Copy the appropriate amount of loops, a partial loop up to the break, then seek to the break's target
var break_pos := loop_break_positions[loop_level]
var jump_pos := loop_break_targets[loop_level]
if jump_pos - pos != 1: # This happens once on track 68:06 FFV SNES BGM
print_debug('LOOP_END at 0x%06X would break instead from 0x%06X to 0x%06X (%d past end)' % [pos, break_pos, jump_pos, jump_pos-pos])
buffer.seek(jump_pos)
var loop_events := events.slice(rom_address_to_event_index[loop_positions[loop_level]], -1)
var partial_loop_events := events.slice(rom_address_to_event_index[loop_positions[loop_level]], rom_address_to_event_index[break_pos]-1)
for loop in loop_count-2: # The first loop is already present, and the last is partial
events.append_array(loop_events)
events.append_array(partial_loop_events)
event_idx += (len(loop_events) * (loop_count-2)) + len(partial_loop_events)
# Clean up loop break vars
loop_break_positions[loop_level] = 0
loop_break_targets[loop_level] = 0
loop_level -= 1
elif loop_count > 0:
# No break within the loop, just copy the loop contents the appropriate number of times and proceed
var loop_events := events.slice(rom_address_to_event_index[loop_positions[loop_level]], -1)
for loop in loop_count-1: # The first loop is already present
events.append_array(loop_events)
event_idx += len(loop_events) * (loop_count-1)
# Clean up loop vars
loop_level -= 1
else:
# Infinite loop, convert it to a GOTO and return
print_info('ended with infinite loop')
events.append([EventType.GOTO, rom_address_to_event_index[loop_break_targets[loop_level]]])
loop_level -= 1
break
EventType.LOOP_BREAK:
# All FFV loop breaks are the same as the loop count
var on_loop: int = event[1]
# Just treat the rest as a GOTO
var target_pos: int = event[2] + start_bank
if target_pos < bgm_start_pos:
target_pos += 0x010000
loop_break_positions[loop_level] = pos
loop_break_targets[loop_level] = target_pos
if on_loop != loop_counts[loop_level]:
print_info('LOOP_BREAK found in track %s at 0x%06X:0x%06X:0x%06X: 0x%06X to 0x%06X on loop %d of %d' % [bgm_id, bgm_start_pos, track_start_pos, bgm_end_pos, pos, target_pos, on_loop, loop_counts[loop_level]])
loop_counts[loop_level] = on_loop
else:
print_info('LOOP_BREAK found in track %s at 0x%06X:0x%06X:0x%06X: 0x%06X to 0x%06X on last loop' % [bgm_id, bgm_start_pos, track_start_pos, bgm_end_pos, pos, target_pos])
if target_pos in rom_address_to_event_index:
print_debug('LOOP_BREAK is a past address, this is either malformed or an infinite loop')
break
EventType.END:
break
EventType.GOTO:
# GOTO pointers reference the ROM position
# BGM 04 (Pirates Ahoy!) crosses a ROM bank so correct bank wrapping is required
var target_pos: int = event[1] + start_bank
if target_pos < bgm_start_pos:
target_pos += 0x010000
var event_num = rom_address_to_event_index.get(target_pos, -1)
if event_num >= 0:
print_info('Infinite GOTO found in track %s at 0x%06X:0x%06X:0x%06X: 0x%06X to 0x%06X (event number %d)' % [bgm_id, bgm_start_pos, track_start_pos, bgm_end_pos, pos, target_pos, event_num])
events.append([EventType.GOTO, rom_address_to_event_index[target_pos]])
break
else: # The GOTO address isn't in our index yet, proceed to it
print_debug('forward GOTO found in track %s at 0x%06X:0x%06X:0x%06X: 0x%06X to 0x%06X' % [bgm_id, bgm_start_pos, track_start_pos, bgm_end_pos, pos, target_pos])
buffer.seek(target_pos)
_:
events.append(event)
event_idx += 1
# DEBUG: Report if track doesn't end all the contained loops
if loop_level >= 0:
print_info('track %s ended on loop_level %d (0x%06X)' % [bgm_id, loop_level, loop_positions[loop_level]])
# # DEBUG: Report total note duration and repeat note duration
# if events:
# print_info('%d events' % len(events))
# var total_note_dur := 0
# var total_notes := 0
# var total_rests := 0
# for e in events:
# if e[0] == EventType.NOTE:
# total_note_dur += e[2]
# if e[1] >= 0:
# total_notes += 1
# else:
# total_rests += 1
# if events[-1][0] == EventType.GOTO:
# var repeat_note_dur := 0
# for e in events.slice(events[-1][1], -1):
# if e[0] == EventType.NOTE:
# repeat_note_dur += e[2]
# print_info('track %s has duration %d and repeat duration %d (intro %d) - %d notes %d rests' % [bgm_id, total_note_dur, repeat_note_dur, total_note_dur-repeat_note_dur, total_notes, total_rests])
# events[-1].append_array([total_note_dur-repeat_note_dur, total_note_dur])
# else:
# print_info('track %s has duration %d - %d notes %d rests' % [bgm_id, total_note_dur, total_notes, total_rests])
return events
func unroll_bgm(buffer: StreamPeerBuffer, bgm_start_address: int, track_start_addresses: PoolIntArray, bgm_end_address: int, bgm_id='N/A') -> Array:
var tracks := []
for track_start_address in track_start_addresses:
tracks.append(self.unroll_track(buffer, bgm_start_address, track_start_address, bgm_end_address, bgm_id))
return tracks