279 lines
10 KiB
GDScript3
279 lines
10 KiB
GDScript3
|
const MAX_TRACKS := 8
|
||
|
const MAX_LOOP_DEPTH := 8 # Apparently 4, but eh whatever
|
||
|
|
||
|
enum EventType {
|
||
|
ADSR_ATTACK,
|
||
|
ADSR_DECAY,
|
||
|
ADSR_DEFAULT,
|
||
|
ADSR_RELEASE,
|
||
|
ADSR_SUSTAIN,
|
||
|
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: [OperandType.U8],
|
||
|
EventType.ADSR_DECAY: [OperandType.U8],
|
||
|
EventType.ADSR_DEFAULT: [],
|
||
|
EventType.ADSR_RELEASE: [OperandType.U8],
|
||
|
EventType.ADSR_SUSTAIN: [OperandType.U8],
|
||
|
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.U8],
|
||
|
EventType.TRANSPOSE_REL: [OperandType.U8],
|
||
|
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
|
||
|
|
||
|
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
|
||
|
|
||
|
# 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
|
||
|
note = -1
|
||
|
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 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('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('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('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('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('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:
|
||
|
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('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('%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('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])
|
||
|
else:
|
||
|
print('track %s has duration %d - %d notes %d rests' % [bgm_id, total_note_dur, total_notes, total_rests])
|
||
|
return events
|