diff options
-rw-r--r-- | pokemontools/audio.py | 286 | ||||
-rw-r--r-- | pokemontools/crystal.py | 79 | ||||
-rw-r--r-- | pokemontools/sfx_names.py | 211 | ||||
-rw-r--r-- | pokemontools/song_names.py | 107 |
4 files changed, 661 insertions, 22 deletions
diff --git a/pokemontools/audio.py b/pokemontools/audio.py new file mode 100644 index 0000000..5318ebe --- /dev/null +++ b/pokemontools/audio.py @@ -0,0 +1,286 @@ +# coding: utf-8 + +import os + +from math import ceil + +from gbz80disasm import get_global_address, get_local_address + +import crystal +from crystal import music_classes as sound_classes +from crystal import Command + +from crystal import load_rom +rom = load_rom() +rom = bytearray(rom) + +import config +conf = config.Config() + + +class NybbleParam: + size = 0.5 + byte_type = 'dn' + which = None + + def __init__(self, address, name): + if self.which == None: + self.which = {0.0: 'lo', 0.5: 'hi'}[address % 1] + self.address = int(address) + self.name = name + self.parse() + + def parse(self): + self.nybble = (rom[self.address] >> {'lo': 0, 'hi': 4}[self.which]) & 0xf + +class HiNybbleParam(NybbleParam): + which = 'hi' + def to_asm(self): + return '%d' % self.nybble + +class LoNybbleParam(NybbleParam): + which = 'lo' + def to_asm(self): + return '%d' % self.nybble + +class PitchParam(HiNybbleParam): + def to_asm(self): + if self.nybble == 0: + pitch = 'Rst' + else: + pitch = 'CDEFGAB'[(self.nybble - 1) / 2] + if not self.nybble & 1: + pitch += '#' + return pitch + + +class Note(Command): + macro_name = "note" + size = 0 + end = False + param_types = { + 0: {"name": "pitch", "class": PitchParam}, + 1: {"name": "duration", "class": LoNybbleParam}, + } + allowed_lengths = [2] + + def parse(self): + self.params = [] + byte = rom[self.address] + current_address = self.address + for (key, param_type) in self.param_types.items(): + name = param_type["name"] + class_ = param_type["class"] + + # by making an instance, obj.parse() is called + obj = class_(address=int(current_address), name=name) + self.params += [obj] + + current_address += obj.size + self.size += obj.size + + self.params = dict(enumerate(self.params)) + + # obj sizes were 0.5, but were working with ints + current_address = int(ceil(current_address)) + self.size = int(ceil(self.size)) + + self.last_address = current_address + return True + + + +class Channel: + """A sound channel data parser.""" + + def __init__(self, address, channel=1, base_label='Sound'): + self.start_address = address + self.address = address + self.channel = channel + self.base_label = base_label + self.output = [] + self.parse() + + def parse(self): + noise = False + done = False + while not done: + cmd = rom[self.address] + + class_ = self.get_sound_class(cmd)(address=self.address) + + # notetype loses the intensity param on channel 4 + if class_.macro_name == 'notetype': + if self.channel in [4, 8]: + class_.size -= 1 + del class_.params[class_.size - 1] + + # togglenoise only has a param when toggled on + elif class_.macro_name in ['togglenoise', 'sfxtogglenoise']: + if noise: + class_.size -= 1 + del class_.params[class_.size - 1] + noise = not noise + + asm = class_.to_asm() + + # label any jumps or calls + for key, param in class_.param_types.items(): + if param['class'] == crystal.PointerLabelParam: + label_address = class_.params[key].parsed_address + label = '%s_branch_%x' % ( + self.base_label, + label_address + ) + label_output = ( + label_address, + '%s: ; %x' % (label, label_address) + ) + if label_output not in self.output: + self.output += [label_output] + asm = asm.replace( + '$%x' % (get_local_address(label_address)), + label + ) + + self.output += [(self.address, '\t' + asm)] + self.address += class_.size + + done = class_.end + # infinite loops are enders + if class_.macro_name == 'loopchannel': + if class_.params[0].byte == 0: + done = True + + # keep going past enders if theres more to parse + if any(self.address <= address for address, asm in self.output): + if done: + self.output += [(self.address, '; %x' % self.address)] + done = False + + # dumb safety checks + if ( + self.address >= len(rom) or + self.address / 0x4000 != self.start_address / 0x4000 + ) and not done: + done = True + raise Exception, 'reached the end of the bank without finishing!' + + def to_asm(self): + self.output = sorted( + self.output, + # comment then label then asm + key=lambda (x, y):(x, not y.startswith(';'), ':' not in y) + ) + text = '' + for i, (address, asm) in enumerate(self.output): + if ':' in asm: + # dont print labels for empty chunks + for (x, y) in self.output[i:]: + if ':' not in y: + text += '\n' + asm + '\n' + break + else: + text += asm + '\n' + text += '; %x' % (address + 1) + '\n' + return text + + def get_sound_class(self, i): + for class_ in sound_classes: + if class_.id == i: + return class_ + return Note + + +class Sound: + """Interprets a sound data header.""" + + def __init__(self, address, name=''): + self.start_address = address + self.bank = address / 0x4000 + self.address = address + + self.name = name + self.base_label = 'Sound_%x' % self.start_address + if self.name != '': + self.base_label = self.name + + self.output = [] + self.parse() + + def parse(self): + self.num_channels = (rom[self.address] >> 6) + 1 + self.channels = [] + for ch in xrange(self.num_channels): + current_channel = (rom[self.address] & 0xf) + 1 + self.address += 1 + address = rom[self.address] + rom[self.address + 1] * 0x100 + address = self.bank * 0x4000 + address % 0x4000 + self.address += 2 + channel = Channel(address, current_channel, self.base_label) + self.channels += [(current_channel, channel)] + + def to_asm(self): + asms = {} + + text = '' + text += '%s: ; %x' % (self.base_label, self.start_address) + '\n' + for num, channel in self.channels: + text += '\tchannel %d, %s_Ch%d' % (num, self.base_label, num) + '\n' + text += '; %x' % self.address + '\n' + asms[self.start_address] = text + + text = '' + for ch, (num, channel) in enumerate(self.channels): + text += '%s_Ch%d: ; %x' % ( + self.base_label, + num, + channel.start_address + ) + '\n' + # stack labels at the same address + if ch < len(self.channels) - 1: + next_channel = self.channels[ch + 1][1] + if next_channel.start_address == channel.start_address: + continue + text += channel.to_asm() + asms[channel.start_address] = text + text = '' + + return '\n'.join(asm for address, asm in sorted(asms.items())) + + +def dump_sounds(origin, names, path, base_label='Sound_'): + """Dump sound data from a pointer table.""" + for i, name in enumerate(names): + addr = origin + i * 3 + bank, address = rom[addr], rom[addr+1] + rom[addr+2] * 0x100 + sound_at = get_global_address(address, bank) + + sound = Sound(sound_at, base_label + name) + output = sound.to_asm() + + filename = name.lower() + '.asm' + with open(os.path.join(path, filename), 'w') as out: + out.write(output) + +def dump_crystal_music(): + from song_names import song_names + dump_sounds(0xe906e, song_names, os.path.join(conf.path, 'audio', 'music'), 'Music_') + +def generate_crystal_music_pointers(): + from song_names import song_names + return '\n'.join('\tdbw BANK({0}), {0}'.format('Music_' + label) for label in song_names) + +def dump_crystal_sfx(): + from sfx_names import sfx_names + dump_sounds(0xe927c, sfx_names, os.path.join(conf.path, 'audio', 'sfx'), 'Sfx_') + +def generate_crystal_sfx_pointers(): + from sfx_names import sfx_names + return '\n'.join('\tdbw BANK({0}), {0}'.format('Sfx_' + label) for label in sfx_names) + + +if __name__ == '__main__': + dump_crystal_music() + dump_crystal_sfx() + diff --git a/pokemontools/crystal.py b/pokemontools/crystal.py index 5d602c9..dd2461a 100644 --- a/pokemontools/crystal.py +++ b/pokemontools/crystal.py @@ -2372,41 +2372,65 @@ def create_command_classes(debug=False): command_classes = create_command_classes() +class BigEndianParam: + """big-endian word""" + size = 2 + should_be_decimal = False + + def __init__(self, *args, **kwargs): + self.prefix = '$' + for (key, value) in kwargs.items(): + setattr(self, key, value) + self.parse() -music_commands_new = { - 0xD0: ["octave8"], - 0xD1: ["octave7"], - 0xD2: ["octave6"], - 0xD3: ["octave5"], - 0xD4: ["octave4"], - 0xD5: ["octave3"], - 0xD6: ["octave2"], - 0xD7: ["octave1"], - 0xD8: ["notetype", ["note_length", SingleByteParam], ["intensity", SingleByteParam]], # only 1 param on ch3 + def parse(self): + self.bytes = rom_interval(self.address, 2, strings=False) + self.parsed_number = self.bytes[0] * 0x100 + self.bytes[1] + + def to_asm(self): + if not self.should_be_decimal: + return self.prefix+"".join([("%.2x")%x for x in self.bytes]) + elif self.should_be_decimal: + decimal = int("0x"+"".join([("%.2x")%x for x in self.bytes]), 16) + return str(decimal) + +class DecimalBigEndianParam(BigEndianParam): + should_be_decimal = True + +music_commands = { + 0xD0: ["octave 8"], + 0xD1: ["octave 7"], + 0xD2: ["octave 6"], + 0xD3: ["octave 5"], + 0xD4: ["octave 4"], + 0xD5: ["octave 3"], + 0xD6: ["octave 2"], + 0xD7: ["octave 1"], + 0xD8: ["notetype", ["note_length", SingleByteParam], ["intensity", SingleByteParam]], # no intensity on channel 4/8 0xD9: ["forceoctave", ["octave", SingleByteParam]], - 0xDA: ["tempo", ["tempo", MultiByteParam]], + 0xDA: ["tempo", ["tempo", DecimalBigEndianParam]], 0xDB: ["dutycycle", ["duty_cycle", SingleByteParam]], 0xDC: ["intensity", ["intensity", SingleByteParam]], 0xDD: ["soundinput", ["input", SingleByteParam]], - 0xDE: ["unknownmusic0xde", ["unknown", SingleByteParam]], # also updates duty cycle + 0xDE: ["unknownmusic0xde", ["unknown", SingleByteParam]], 0xDF: ["unknownmusic0xdf"], 0xE0: ["unknownmusic0xe0", ["unknown", SingleByteParam], ["unknown", SingleByteParam]], 0xE1: ["vibrato", ["delay", SingleByteParam], ["extent", SingleByteParam]], 0xE2: ["unknownmusic0xe2", ["unknown", SingleByteParam]], - 0xE3: ["togglenoise", ["id", SingleByteParam]], # this can have 0-1 params! + 0xE3: ["togglenoise", ["id", SingleByteParam]], # no parameters on toggle off 0xE4: ["panning", ["tracks", SingleByteParam]], 0xE5: ["volume", ["volume", SingleByteParam]], - 0xE6: ["tone", ["tone", MultiByteParam]], # big endian + 0xE6: ["tone", ["tone", BigEndianParam]], 0xE7: ["unknownmusic0xe7", ["unknown", SingleByteParam]], 0xE8: ["unknownmusic0xe8", ["unknown", SingleByteParam]], - 0xE9: ["globaltempo", ["value", MultiByteParam]], + 0xE9: ["globaltempo", ["value", DecimalBigEndianParam]], 0xEA: ["restartchannel", ["address", PointerLabelParam]], - 0xEB: ["newsong", ["id", MultiByteParam]], + 0xEB: ["newsong", ["id", DecimalBigEndianParam]], 0xEC: ["sfxpriorityon"], 0xED: ["sfxpriorityoff"], 0xEE: ["unknownmusic0xee", ["address", PointerLabelParam]], 0xEF: ["stereopanning", ["tracks", SingleByteParam]], - 0xF0: ["sfxtogglenoise", ["id", SingleByteParam]], # 0-1 params + 0xF0: ["sfxtogglenoise", ["id", SingleByteParam]], # no parameters on toggle off 0xF1: ["music0xf1"], # nothing 0xF2: ["music0xf2"], # nothing 0xF3: ["music0xf3"], # nothing @@ -2419,19 +2443,29 @@ music_commands_new = { 0xFA: ["setcondition", ["condition", SingleByteParam]], 0xFB: ["jumpif", ["condition", SingleByteParam], ["address", PointerLabelParam]], 0xFC: ["jumpchannel", ["address", PointerLabelParam]], - 0xFD: ["loopchannel", ["count", SingleByteParam], ["address", PointerLabelParam]], + 0xFD: ["loopchannel", ["count", DecimalParam], ["address", PointerLabelParam]], 0xFE: ["callchannel", ["address", PointerLabelParam]], 0xFF: ["endchannel"], } -music_command_enders = [0xEA, 0xEB, 0xEE, 0xFC, 0xFF,] -# special case for 0xFD (if loopchannel.count = 0, break) +music_command_enders = [ + "restartchannel", + "newsong", + "unknownmusic0xee", + "jumpchannel", + "endchannel", +] def create_music_command_classes(debug=False): klasses = [] - for (byte, cmd) in music_commands_new.items(): + for (byte, cmd) in music_commands.items(): cmd_name = cmd[0].replace(" ", "_") - params = {"id": byte, "size": 1, "end": byte in music_command_enders, "macro_name": cmd_name} + params = { + "id": byte, + "size": 1, + "end": cmd[0] in music_command_enders, + "macro_name": cmd[0] + } params["param_types"] = {} if len(cmd) > 1: param_types = cmd[1:] @@ -2451,6 +2485,7 @@ def create_music_command_classes(debug=False): klasses.append(klass) # later an individual klass will be instantiated to handle something return klasses + music_classes = create_music_command_classes() class callchannel(Command): diff --git a/pokemontools/sfx_names.py b/pokemontools/sfx_names.py new file mode 100644 index 0000000..f2d6408 --- /dev/null +++ b/pokemontools/sfx_names.py @@ -0,0 +1,211 @@ +# coding: utf-8 + +sfx_names = [ + 'DexFanfare5079', + 'Item', + 'CaughtMon', + 'PokeballsPlacedOnTable', + 'Potion', + 'FullHeal', + 'Menu', + 'ReadText', + 'ReadText2', + 'DexFanfare2049', + 'DexFanfare80109', + 'Poison', + 'GotSafariBalls', + 'BootPc', + 'ShutDownPc', + 'ChoosePcOption', + 'EscapeRope', + 'PushButton', + 'SecondPartOfItemfinder', + 'WarpTo', + 'WarpFrom', + 'ChangeDexMode', + 'JumpOverLedge', + 'GrassRustle', + 'Fly', + 'Wrong', + 'Squeak', + 'Strength', + 'Boat', + 'WallOpen', + 'PlacePuzzlePieceDown', + 'EnterDoor', + 'SwitchPokemon', + 'Tally', + 'Transaction', + 'ExitBuilding', + 'Bump', + 'Save', + 'Pokeflute', + 'ElevatorEnd', + 'ThrowBall', + 'BallPoof', + 'Unknown3A', + 'Run', + 'SlotMachineStart', + 'Fanfare', + 'Peck', + 'Kinesis', + 'Lick', + 'Pound', + 'MovePuzzlePiece', + 'CometPunch', + 'MegaPunch', + 'Scratch', + 'Vicegrip', + 'RazorWind', + 'Cut', + 'WingAttack', + 'Whirlwind', + 'Bind', + 'VineWhip', + 'DoubleKick', + 'MegaKick', + 'Headbutt', + 'HornAttack', + 'Tackle', + 'PoisonSting', + 'Powder', + 'Doubleslap', + 'Bite', + 'JumpKick', + 'Stomp', + 'TailWhip', + 'KarateChop', + 'Submission', + 'WaterGun', + 'SwordsDance', + 'Thunder', + 'Supersonic', + 'Leer', + 'Ember', + 'Bubblebeam', + 'HydroPump', + 'Surf', + 'Psybeam', + 'Charge', + 'Thundershock', + 'Psychic', + 'Screech', + 'BoneClub', + 'Sharpen', + 'EggBomb', + 'Sing', + 'HyperBeam', + 'Shine', + 'Unknown5F', + 'Unknown60', + 'Unknown61', + 'Unknown62', + 'Unknown63', + 'Burn', + 'TitleScreenEntrance', + 'Unknown66', + 'GetCoinFromSlots', + 'PayDay', + 'Metronome', + 'Call', + 'HangUp', + 'NoSignal', + 'Sandstorm', + 'Elevator', + 'Protect', + 'Sketch', + 'RainDance', + 'Aeroblast', + 'Spark', + 'Curse', + 'Rage', + 'Thief', + 'Thief2', + 'SpiderWeb', + 'MindReader', + 'Nightmare', + 'Snore', + 'SweetKiss', + 'SweetKiss2', + 'BellyDrum', + 'Unknown7F', + 'SludgeBomb', + 'Foresight', + 'Spite', + 'Outrage', + 'PerishSong', + 'GigaDrain', + 'Attract', + 'Kinesis2', + 'ZapCannon', + 'MeanLook', + 'HealBell', + 'Return', + 'ExpBar', + 'MilkDrink', + 'Present', + 'MorningSun', + 'LevelUp', + 'KeyItem', + 'Fanfare2', + 'RegisterPhone#', + '3RdPlace', + 'GetEggFromDaycareMan', + 'GetEggFromDaycareLady', + 'MoveDeleted', + '2NdPlace', + '1StPlace', + 'ChooseACard', + 'GetTm', + 'GetBadge', + 'QuitSlots', + 'EggCrack', + 'DexFanfareLessThan20', + 'DexFanfare140169', + 'DexFanfare170199', + 'DexFanfare200229', + 'DexFanfare230Plus', + 'Evolved', + 'MasterBall', + 'EggHatch', + 'GsIntroCharizardFireball', + 'GsIntroPokemonAppears', + 'Flash', + 'GameFreakLogoGs', + 'NotVeryEffective', + 'Damage', + 'SuperEffective', + 'BallBounce', + 'Moonlight', + 'Encore', + 'BeatUp', + 'BatonPass', + 'BallWiggle', + 'SweetScent', + 'SweetScent2', + 'HitEndOfExpBar', + 'GiveTrademon', + 'GetTrademon', + 'TrainArrived', + 'StopSlot', + '2Boops', + 'GlassTing', + 'GlassTing2', + 'IntroUnown1', + 'IntroUnown2', + 'IntroUnown3', + 'DittoPopUp', + 'DittoTransform', + 'IntroSuicune1', + 'IntroPichu', + 'IntroSuicune2', + 'IntroSuicune3', + 'DittoBounce', + 'IntroSuicune4', + 'GameFreakPresents', + 'Tingle', + 'UnknownCb', + 'TwoPcBeeps', + '4NoteDitty', + 'Twinkle', +] diff --git a/pokemontools/song_names.py b/pokemontools/song_names.py new file mode 100644 index 0000000..1e48ca7 --- /dev/null +++ b/pokemontools/song_names.py @@ -0,0 +1,107 @@ +# coding: utf-8 + +song_names = [ + 'Nothing', + 'TitleScreen', + 'Route1', + 'Route3', + 'Route12', + 'MagnetTrain', + 'KantoGymBattle', + 'KantoTrainerBattle', + 'KantoWildBattle', + 'PokemonCenter', + 'LookHiker', + 'LookLass', + 'LookOfficer', + 'HealPokemon', + 'LavenderTown', + 'Route2', + 'MtMoon', + 'ShowMeAround', + 'GameCorner', + 'Bicycle', + 'HallOfFame', + 'ViridianCity', + 'CeladonCity', + 'TrainerVictory', + 'WildPokemonVictory', + 'GymLeaderVictory', + 'MtMoonSquare', + 'Gym', + 'PalletTown', + 'ProfOaksPokemonTalk', + 'ProfOak', + 'LookRival', + 'AfterTheRivalFight', + 'Surf', + 'Evolution', + 'NationalPark', + 'Credits', + 'AzaleaTown', + 'CherrygroveCity', + 'LookKimonoGirl', + 'UnionCave', + 'JohtoWildBattle', + 'JohtoTrainerBattle', + 'Route30', + 'EcruteakCity', + 'VioletCity', + 'JohtoGymBattle', + 'ChampionBattle', + 'RivalBattle', + 'RocketBattle', + 'ElmsLab', + 'DarkCave', + 'Route29', + 'Route36', + 'SSAqua', + 'LookYoungster', + 'LookBeauty', + 'LookRocket', + 'LookPokemaniac', + 'LookSage', + 'NewBarkTown', + 'GoldenrodCity', + 'VermilionCity', + 'PokemonChannel', + 'PokeFluteChannel', + 'TinTower', + 'SproutTower', + 'BurnedTower', + 'Lighthouse', + 'LakeOfRage', + 'IndigoPlateau', + 'Route37', + 'RocketHideout', + 'DragonsDen', + 'JohtoWildBattleNight', + 'RuinsOfAlphRadio', + 'SuccessfulCapture', + 'Route26', + 'Mom', + 'VictoryRoad', + 'PokemonLullaby', + 'PokemonMarch', + 'GoldSilverOpening', + 'GoldSilverOpening2', + 'MainMenu', + 'RuinsOfAlphInterior', + 'RocketTheme', + 'DancingHall', + 'ContestResults', + 'BugCatchingContest', + 'LakeOfRageRocketRadio', + 'Printer', + 'PostCredits', + 'Clair', + 'MobileAdapterMenu', + 'MobileAdapter', + 'BuenasPassword', + 'LookMysticalMan', + 'CrystalOpening', + 'BattleTowerTheme', + 'SuicuneBattle', + 'BattleTowerLobby', + 'MobileCenter', +] |