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