summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pokemontools/audio.py286
-rw-r--r--pokemontools/crystal.py79
-rw-r--r--pokemontools/sfx_names.py211
-rw-r--r--pokemontools/song_names.py107
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',
+]