diff --git a/args/arguments.py b/args/arguments.py index 155e985a..0f78b26c 100644 --- a/args/arguments.py +++ b/args/arguments.py @@ -4,9 +4,9 @@ def __init__(self): self.groups = [ "settings", "objectives", - "starting_party", "characters", "swdtechs", "blitzes", "lores", "rages", "dances", "steal", "commands", + "starting_party", "characters", "swdtechs", "blitzes", "lores", "rages", "dances", "steal", "sketch_control", "commands", "xpmpgp", "scaling", "bosses", "encounters", "boss_ai", - "espers", "natural_magic", + "espers", "natural_magic", "misc_magic", "starting_gold_items", "items", "shops", "chests", "graphics", "coliseum", "auction_house", "challenges", "bug_fixes", "misc", @@ -25,6 +25,7 @@ def __init__(self): self.parser.add_argument("-nro", dest = "no_rom_output", action = "store_true", help = "Do not output a modified rom file") self.parser.add_argument("-slog", dest = "stdout_log", action = "store_true", help = "Write log to stdout instead of file") + self.parser.add_argument("-hf", dest = "hide_flags", action = "store_true", help = "Hide Flags (no log, no flags menu)") for group in self.group_modules.values(): group.parse(self.parser) diff --git a/args/bosses.py b/args/bosses.py index 29bffd56..7fe1b107 100644 --- a/args/bosses.py +++ b/args/bosses.py @@ -1,3 +1,8 @@ +from data.bosses import BossLocations + + +DEFAULT_DRAGON_PROTOCOL = BossLocations.SHUFFLE +DEFAULT_STATUE_PROTOCOL = BossLocations.MIX def name(): return "Bosses" @@ -9,8 +14,17 @@ def parse(parser): help = "Boss battles shuffled") bosses_battles.add_argument("-bbr", "--boss-battles-random", action = "store_true", help = "Boss battles randomized") - bosses.add_argument("-bmbd", "--mix-bosses-dragons", action = "store_true", + + dragons = bosses.add_mutually_exclusive_group() + dragons.add_argument("-drloc", "--dragon-boss-location", default = DEFAULT_DRAGON_PROTOCOL, type = str.lower, choices = BossLocations.ALL, + help = "Decides which locations the eight dragon encounters can be fought") + dragons.add_argument("-bmbd", "--mix-bosses-dragons", action = "store_true", help = "Shuffle/randomize bosses and dragons together") + + statues = bosses.add_mutually_exclusive_group() + statues.add_argument("-stloc", "--statue-boss-location", default = DEFAULT_STATUE_PROTOCOL, type = str.lower, choices = BossLocations.ALL, + help = "Decides which locations the three statue encounters can be fought") + bosses.add_argument("-srp3", "--shuffle-random-phunbaba3", action = "store_true", help = "Apply Shuffle/Random to Phunbaba 3 (otherwise he will only appear in Mobliz WOR)") bosses.add_argument("-bnds", "--boss-normalize-distort-stats", action = "store_true", @@ -21,7 +35,15 @@ def parse(parser): help = "Undead status removed from bosses") def process(args): - pass + if args.mix_bosses_dragons: + args.dragon_boss_location = BossLocations.MIX + args.mix_bosses_dragons = None + # if neither shuffling or randomizing bosses, and we try to mix the dragons/statues, simply shuffle them instead + vanilla_locations = not (args.boss_battles_shuffle or args.boss_battles_random) + if vanilla_locations and args.dragon_boss_location == BossLocations.MIX: + args.dragon_boss_location = BossLocations.SHUFFLE + if vanilla_locations and args.statue_boss_location == BossLocations.MIX: + args.statue_boss_location = BossLocations.SHUFFLE def flags(args): flags = "" @@ -31,8 +53,14 @@ def flags(args): elif args.boss_battles_random: flags += " -bbr" - if args.mix_bosses_dragons: - flags += " -bmbd" + if args.dragon_boss_location: + flags += f" -drloc {args.dragon_boss_location}" + elif args.mix_bosses_dragons: + flags += f" -drloc {BossLocations.MIX}" + + if args.statue_boss_location: + flags += f" -stloc {args.statue_boss_location}" + if args.shuffle_random_phunbaba3: flags += " -srp3" if args.boss_normalize_distort_stats: @@ -51,9 +79,18 @@ def options(args): elif args.boss_battles_random: boss_battles = "Random" + dragon_battles = DEFAULT_DRAGON_PROTOCOL + if args.dragon_boss_location: + dragon_battles = args.dragon_boss_location.capitalize() + + statue_battles = DEFAULT_DRAGON_PROTOCOL + if args.statue_boss_location: + statue_battles = args.statue_boss_location.capitalize() + return [ ("Boss Battles", boss_battles), - ("Mix Bosses & Dragons", args.mix_bosses_dragons), + ("Dragons", dragon_battles), + ("Statues", statue_battles), ("Shuffle/Random Phunbaba 3", args.shuffle_random_phunbaba3), ("Normalize & Distort Stats", args.boss_normalize_distort_stats), ("Boss Experience", args.boss_experience), diff --git a/args/bug_fixes.py b/args/bug_fixes.py index a9c8518b..3fcccc28 100644 --- a/args/bug_fixes.py +++ b/args/bug_fixes.py @@ -17,6 +17,8 @@ def parse(parser): help = "Poltergeist and Inferno in Kefka's Tower cannot be skipped") bug_fixes.add_argument("-fedc", "--fix-enemy-damage-counter", action = "store_true", help = "Enemy damage counters only trigger if HP is reduced") + bug_fixes.add_argument("-fc", "--fix-capture", action = "store_true", + help = "Fix Capture such that Weapon Special Effects are applied and Multi-Steals work") def process(args): pass @@ -38,6 +40,8 @@ def flags(args): flags += " -fbs" if args.fix_enemy_damage_counter: flags += " -fedc" + if args.fix_capture: + flags += " -fc" return flags @@ -50,6 +54,7 @@ def options(args): ("Jump", args.fix_jump), ("Boss Skip", args.fix_boss_skip), ("Enemy Damage Counter", args.fix_enemy_damage_counter), + ("Capture", args.fix_capture), ] def menu(args): diff --git a/args/challenges.py b/args/challenges.py index 8ebbbc9f..44fd76f3 100644 --- a/args/challenges.py +++ b/args/challenges.py @@ -9,17 +9,57 @@ def parse(parser): help = "Exp. Eggs will not appear in coliseum/auction/shops/chests/events") challenges.add_argument("-nil", "--no-illuminas", action = "store_true", help = "Illuminas will not appear in coliseum/auction/shops/chests/events") - challenges.add_argument("-nu", "--no-ultima", action = "store_true", + ultima = challenges.add_mutually_exclusive_group() + challenges.add_argument("-noshoes", "--no-sprint-shoes", action = "store_true", + help = "Sprint Shoes will not appear in coliseum/auction/shops/chests") + ultima.add_argument("-nu", "--no-ultima", action = "store_true", help = "Ultima cannot be learned from espers/items/natural magic") + ultima.add_argument("-u254", "--ultima-254-mp", action = "store_true", + help = "Ultima costs 254 MP") challenges.add_argument("-nfps", "--no-free-paladin-shields", action = "store_true", help = "Paladin/Cursed Shields will not appear in coliseum/auction/shops/chests/events (Narshe WOR exclusive)") challenges.add_argument("-nfce", "--no-free-characters-espers", action = "store_true", help = "Remove character/esper rewards from: Auction House, Collapsing House, Figaro Castle Throne, Gau's Father's House, Kohlingen Inn, Narshe Weapon Shop, Sealed Gate, South Figaro Basement") challenges.add_argument("-pd", "--permadeath", action = "store_true", help = "Life spells cannot be learned. Fenix Downs unavailable (except from starting items). Buckets/inns/tents/events do not revive characters. Phoenix casts Life 3 on party instead of Life") + challenges.add_argument("-rls", "--remove-learnable-spells", type = str, + help = "Remove spells from learnable sources: Items, Espers, Natural Magic, and Objectives") def process(args): - pass + from constants.spells import black_magic_ids, white_magic_ids, gray_magic_ids, spell_id + + # If no_ultima is on, add it to our exclude list for downstream use + # If permadeath is on, add it to our exclude list for downstream use + args.remove_learnable_spell_ids = [] + if args.no_ultima: + args.remove_learnable_spell_ids.append(spell_id["Ultima"]) + if args.permadeath: + args.remove_learnable_spell_ids.append(spell_id["Life"]) + args.remove_learnable_spell_ids.append(spell_id["Life 2"]) + + if args.remove_learnable_spells: + # Split the comma-separated string + for a_spell_id in args.remove_learnable_spells.split(','): + # look for strings first + a_spell_id = a_spell_id.lower().strip() + if a_spell_id == 'all': + args.remove_learnable_spell_ids.extend(range(len(spell_id))) + elif a_spell_id == 'white': + args.remove_learnable_spell_ids.extend(white_magic_ids) + elif a_spell_id == 'black': + args.remove_learnable_spell_ids.extend(black_magic_ids) + elif a_spell_id == 'gray' or a_spell_id == 'grey': + args.remove_learnable_spell_ids.extend(gray_magic_ids) + else: + spell_ids_lower = {k.lower():v for k,v in spell_id.items()} + if a_spell_id in spell_ids_lower: + args.remove_learnable_spell_ids.append(spell_ids_lower[a_spell_id]) + else: + # assuming it's a number... it'll error out if not + args.remove_learnable_spell_ids.append(int(a_spell_id)) + # remove duplicates and sort + args.remove_learnable_spell_ids = list(set(args.remove_learnable_spell_ids)) + args.remove_learnable_spell_ids.sort() def flags(args): flags = "" @@ -30,29 +70,54 @@ def flags(args): flags += " -nee" if args.no_illuminas: flags += " -nil" + if args.no_sprint_shoes: + flags += " -noshoes" + if args.no_ultima: flags += " -nu" + elif args.ultima_254_mp: + flags += " -u254" + if args.no_free_paladin_shields: flags += " -nfps" if args.no_free_characters_espers: flags += " -nfce" if args.permadeath: flags += " -pd" + if args.remove_learnable_spells: + flags += f" -rls {args.remove_learnable_spells}" return flags def options(args): + ultima = "Original" + if args.no_ultima: + ultima = "No" + elif args.ultima_254_mp: + ultima = "254 MP" + return [ ("No Moogle Charms", args.no_moogle_charms), ("No Exp Eggs", args.no_exp_eggs), ("No Illuminas", args.no_illuminas), - ("No Ultima", args.no_ultima), + ("Ultima", ultima), + ("No Sprint Shoes", args.no_sprint_shoes), ("No Free Paladin Shields", args.no_free_paladin_shields), ("No Free Characters/Espers", args.no_free_characters_espers), ("Permadeath", args.permadeath), + ("Remove Learnable Spells", args.remove_learnable_spell_ids), ] +def _format_spells_log_entries(spell_ids): + from constants.spells import id_spell + spell_entries = [] + for spell_id in spell_ids: + spell_entries.append(("", id_spell[spell_id])) + return spell_entries + def menu(args): + from menus.flags_remove_learnable_spells import FlagsRemoveLearnableSpells + entries = options(args) for index, entry in enumerate(entries): key, value = entry @@ -60,6 +125,9 @@ def menu(args): entries[index] = ("No Free Paladin Shlds", entry[1]) elif key == "No Free Characters/Espers": entries[index] = ("No Free Chars/Espers", entry[1]) + elif key == "Remove Learnable Spells": + entries[index] = ("Remove L. Spells", FlagsRemoveLearnableSpells(value)) # flags sub-menu + return (name(), entries) def log(args): @@ -68,6 +136,16 @@ def log(args): entries = options(args) for entry in entries: - log.append(format_option(*entry)) + key, value = entry + if key == "Remove Learnable Spells": + if len(value) == 0: + entry = (key, "None") + else: + entry = (key, "") # The entries will show up on subsequent lines + log.append(format_option(*entry)) + for spell_entry in _format_spells_log_entries(value): + log.append(format_option(*spell_entry)) + else: + log.append(format_option(*entry)) return log diff --git a/args/characters.py b/args/characters.py index f80634c1..4cb482fa 100644 --- a/args/characters.py +++ b/args/characters.py @@ -6,6 +6,8 @@ def parse(parser): characters.add_argument("-sal", "--start-average-level", action = "store_true", help = "Recruited characters start at the average character level") + characters.add_argument("-stl", "--start-level", default = 3, type = int, choices = range(3, 100), metavar = "COUNT", + help = "Start game at level %(metavar)s.") characters.add_argument("-sn", "--start-naked", action = "store_true", help = "Recruited characters start with no equipment") characters.add_argument("-eu", "--equipable-umaro", action = "store_true", @@ -22,6 +24,8 @@ def flags(args): if args.start_average_level: flags += " -sal" + if args.start_level != 3: + flags += f" -stl {args.start_level}" if args.start_naked: flags += " -sn" if args.equipable_umaro: @@ -36,6 +40,7 @@ def options(args): return [ ("Start Average Level", args.start_average_level), + ("Start Level", args.start_level), ("Start Naked", args.start_naked), ("Equipable Umaro", args.equipable_umaro), ("Character Stats", character_stats), diff --git a/args/encounters.py b/args/encounters.py index 8fb3fb9c..c8d3a91d 100644 --- a/args/encounters.py +++ b/args/encounters.py @@ -10,6 +10,8 @@ def parse(parser): random.add_argument("-rer", "--random-encounters-random", default = None, type = int, metavar = "PERCENT", choices = range(101), help = "Random encounters are randomized") + random.add_argument("-rechu", "--random-encounters-chupon", action = "store_true", + help = "All Random Encounters are replaced with Chupon (Coliseum)") fixed = encounters.add_mutually_exclusive_group() fixed.add_argument("-fer", "--fixed-encounters-random", @@ -33,6 +35,8 @@ def flags(args): flags += " -res" elif args.random_encounters_random is not None: flags += f" -rer {args.random_encounters_random}" + elif args.random_encounters_chupon: + flags += " -rechu" if args.fixed_encounters_random is not None: flags += f" -fer {args.fixed_encounters_random}" @@ -50,6 +54,8 @@ def options(args): random_encounters = "Shuffle" elif args.random_encounters_random is not None: random_encounters = "Random" + elif args.random_encounters_chupon: + random_encounters = "Chupon" result.append(("Random Encounters", random_encounters)) if args.random_encounters_random is not None: diff --git a/args/espers.py b/args/espers.py index 6cfbb466..b35c5994 100644 --- a/args/espers.py +++ b/args/espers.py @@ -1,3 +1,12 @@ +from data.espers import Espers +from event.event_reward import CHARACTER_ESPER_ONLY_REWARDS + +# If all 27 espers are allocated at start, there will be logic errors when it comes to +# assigning characters to character/esper only checks. +# We would have to ensure that a character is assigned to the {6} char/esper only rewards. +# We could account for this in the logic, but it would gentrify the routing logic a bit much. +MAX_STARTING_ESPERS = Espers.ESPER_COUNT - CHARACTER_ESPER_ONLY_REWARDS + def name(): return "Espers" @@ -6,7 +15,13 @@ def parse(parser): from data.characters import Characters espers = parser.add_argument_group("Espers") + esper_start = espers.add_mutually_exclusive_group() + esper_start.add_argument("-stesp", "--starting-espers", default = [0, 0], type = int, + nargs = 2, metavar = ("MIN", "MAX"), choices = range(MAX_STARTING_ESPERS + 1), + help = "Party starts with %(metavar) random espers") + esper_spells = espers.add_mutually_exclusive_group() + esper_spells.add_argument("-esrr", "--esper-spells-random-rates", action = "store_true", help = "Original esper spells with random learn rates") esper_spells.add_argument("-ess", "--esper-spells-shuffle", action = "store_true", @@ -30,7 +45,7 @@ def parse(parser): esper_mp.add_argument("-emps", "--esper-mp-shuffle", action = "store_true", help = "Esper MP costs shuffled") esper_mp.add_argument("-emprv", "--esper-mp-random-value", default = None, type = int, - nargs = 2, metavar = ("MIN", "MAX"), choices = range(129), + nargs = 2, metavar = ("MIN", "MAX"), choices = range(255), help = "Each esper's MP cost set to random value within given range") esper_mp.add_argument("-emprp", "--esper-mp-random-percent", default = None, type = int, nargs = 2, metavar = ("MIN", "MAX"), choices = range(201), @@ -50,6 +65,7 @@ def parse(parser): help = "Espers can be summoned multiple times in battle") def process(args): + args._process_min_max("starting_espers") args._process_min_max("esper_spells_random") args._process_min_max("esper_mp_random_value") args._process_min_max("esper_mp_random_percent") @@ -66,6 +82,9 @@ def process(args): def flags(args): flags = "" + if args.starting_espers_min or args.starting_espers_max: + flags += f" -stesp {args.starting_espers_min} {args.starting_espers_max}" + if args.esper_spells_random_rates: flags += " -esrr" elif args.esper_spells_shuffle: @@ -133,6 +152,7 @@ def options(args): equipable = f"Balanced Random {args.esper_equipable_balanced_random_value}" result = [] + result.append(("Starting Espers", f"{args.starting_espers_min}-{args.starting_espers_max}")) result.append(("Spells", spells)) result.append(("Bonuses", bonuses)) if args.esper_bonuses_random: diff --git a/args/graphics.py b/args/graphics.py index 91425980..36d56262 100644 --- a/args/graphics.py +++ b/args/graphics.py @@ -10,6 +10,15 @@ def parse(parser): graphics.add_argument("-cspr", "--character-sprites", type = str, help = "Character sprite indices") graphics.add_argument("-cspp", "--character-sprite-palettes", type = str, help = "Character sprite palette indices") + remove_flashes = graphics.add_mutually_exclusive_group() + remove_flashes.add_argument("-frw", "--flashes-remove-worst", action = "store_true", + help = "Removes only the worst flashes from animations. Ex: Learning Bum Rush, Bum Rush, Quadra Slam/Slice, Flash, etc.") + remove_flashes.add_argument("-frm", "--flashes-remove-most", action = "store_true", + help = "Removes most flashes from animations. Includes Kefka Death.") + + graphics.add_argument("-wmhc", "--world-minimap-high-contrast", action = "store_true", + help = "World Minimap made Opaque with Minimap icon changed to higher contrast to improve visibility.") + def process(args): import graphics.palettes.palettes as palettes import graphics.portraits.portraits as portraits @@ -98,6 +107,12 @@ def flags(args): if args.character_sprite_palettes: flags += " -cspp " + args.character_sprite_palettes + if args.flashes_remove_worst: + flags += " -frw" + if args.flashes_remove_most: + flags += " -frm" + if args.world_minimap_high_contrast: + flags += " -wmhc" return flags def _truncated_name(name): @@ -148,6 +163,30 @@ def _character_customization_log(args): return log +def _other_options_log(args): + from log import format_option + log = ["Other Graphics"] + + remove_flashes = "Original" + if args.flashes_remove_worst: + remove_flashes = "Worst" + elif args.flashes_remove_most: + remove_flashes = "Most" + + world_minimap = "Original" + if args.world_minimap_high_contrast: + world_minimap = "High Contrast" + + entries = [ + ("Remove Flashes", remove_flashes), + ("World Minimap", world_minimap), + ] + + for entry in entries: + log.append(format_option(*entry)) + + return log + def log(args): lcolumn = [""] lcolumn.extend(_sprite_palettes_log(args)) @@ -155,6 +194,9 @@ def log(args): lcolumn.append("") lcolumn.extend(_other_portraits_sprites_log(args)) + lcolumn.append("") + lcolumn.extend(_other_options_log(args)) + rcolumn = [""] rcolumn.extend(_character_customization_log(args)) diff --git a/args/log.py b/args/log.py index 0160ae56..504be767 100644 --- a/args/log.py +++ b/args/log.py @@ -17,9 +17,9 @@ def _log_tab(tab_name, left_groups, right_groups): def log(): _log_tab("Game", ["settings"], []) args.group_modules["objectives"].log(args) - _log_tab("Party", ["starting_party", "swdtechs", "blitzes", "lores", "rages", "dances", "steal"], ["characters", "commands"]) + _log_tab("Party", ["starting_party", "swdtechs", "blitzes", "lores", "rages", "dances", "steal", "sketch_control"], ["characters", "commands"]) _log_tab("Battle", ["xpmpgp", "bosses", "boss_ai"], ["scaling", "encounters"]) - _log_tab("Magic", ["espers"], ["natural_magic"]) + _log_tab("Magic", ["espers", "misc_magic"], ["natural_magic"]) _log_tab("Items", ["starting_gold_items", "items"], ["shops", "chests"]) args.group_modules["graphics"].log(args) _log_tab("Other", ["coliseum", "auction_house", "misc"], ["challenges", "bug_fixes"]) diff --git a/args/lores.py b/args/lores.py index 04ab7c8a..c4acb9f2 100644 --- a/args/lores.py +++ b/args/lores.py @@ -14,7 +14,7 @@ def parse(parser): lores_mp.add_argument("-lmps", "--lores-mp-shuffle", action = "store_true", help = "Lore MP costs shuffled") lores_mp.add_argument("-lmprv", "--lores-mp-random-value", default = None, type = int, - nargs = 2, metavar = ("MIN", "MAX"), choices = range(100), + nargs = 2, metavar = ("MIN", "MAX"), choices = range(255), help = "Lore MP costs randomized") lores_mp.add_argument("-lmprp", "--lores-mp-random-percent", default = None, type = int, nargs = 2, metavar = ("MIN", "MAX"), choices = range(201), @@ -23,6 +23,9 @@ def parse(parser): lores.add_argument("-lel", "--lores-everyone-learns", action = "store_true", help = "Lores learnable by characters without the Lore command") + lores.add_argument("-llr", "--lores-level-randomize", action = "store_true", + help = "Level based lores will have the level randomized (L?, L1-L5)") + def process(args): args._process_min_max("start_lores_random") args._process_min_max("lores_mp_random_value") @@ -44,6 +47,8 @@ def flags(args): if args.lores_everyone_learns: flags += " -lel" + if args.lores_level_randomize: + flags += " -llr" return flags def options(args): @@ -63,6 +68,7 @@ def options(args): ("Start Lores", start_lores), ("MP", mp), ("Everyone Learns", args.lores_everyone_learns), + ("Lx Level Random", args.lores_level_randomize) ] def menu(args): diff --git a/args/misc.py b/args/misc.py index 7d55fc10..43c32153 100644 --- a/args/misc.py +++ b/args/misc.py @@ -1,10 +1,11 @@ + + def name(): return "Misc." def parse(parser): misc = parser.add_argument_group("Misc.") - misc.add_argument("-as", "--auto-sprint", action = "store_true", - help = "Player always sprints. Sprint Shoes have no effect") + misc.add_argument("-ond", "--original-name-display", action = "store_true", help = "Display original character names in party and party select menus") misc.add_argument("-rr", "--random-rng", action = "store_true", @@ -13,6 +14,17 @@ def parse(parser): help = "Randomize clock's correct time and NPC clues in Zozo") misc.add_argument("-scan", "--scan-all", action = "store_true", help = "All enemies scannable. All characters start with scan learned. Scan costs 0 MP. Useful for testing/debugging") + misc.add_argument("-warp", "--warp-all", action = "store_true", + help = "All characters start with Warp learned. Warp costs 0 MP. Useful for seeds that limit Warp Stone access") + + from data.movement import ALL + movement = misc.add_mutually_exclusive_group() + movement.name = "Movement" + movement.add_argument("-move", "--movement", type = str.lower, choices = ALL, + help = "Player movement options") + # Completely ignore this argument, and default to auto sprint when -move is not defined + misc.add_argument("-as", "--auto-sprint", action = "store_true", + help = "DEPRECATED - Use `-move as` instead. Player always sprints. Sprint Shoes have no effect") event_timers = misc.add_mutually_exclusive_group() event_timers.add_argument("-etr", "--event-timers-random", action = "store_true", @@ -43,12 +55,6 @@ def parse(parser): help = "Remove NPC") parser.y_npc_group = y_npc - remove_flashes = misc.add_mutually_exclusive_group() - remove_flashes.add_argument("-frw", "--flashes-remove-worst", action = "store_true", - help = "Removes only the worst flashes from animations. Ex: Learning Bum Rush, Bum Rush, Quadra Slam/Slice, Flash, etc.") - remove_flashes.add_argument("-frm", "--flashes-remove-most", action = "store_true", - help = "Removes most flashes from animations. Includes Kefka Death.") - def process(args): args.y_npc = False # are any y_npc flags enabled? @@ -61,8 +67,8 @@ def process(args): def flags(args): flags = "" - if args.auto_sprint: - flags += " -as" + if args.movement: + flags += f" -move {args.movement}" if args.original_name_display: flags += " -ond" if args.random_rng: @@ -71,6 +77,8 @@ def flags(args): flags += " -rc" if args.scan_all: flags += " -scan" + if args.warp_all: + flags += " -warp" if args.event_timers_random: flags += " -etr" @@ -98,11 +106,6 @@ def flags(args): elif args.y_npc_remove: flags += " -yremove" - if args.flashes_remove_worst: - flags += " -frw" - if args.flashes_remove_most: - flags += " -frm" - return flags def options(args): @@ -134,21 +137,22 @@ def options(args): elif args.y_npc_remove: y_npc = "Remove" - remove_flashes = "Original" - if args.flashes_remove_worst: - remove_flashes = "Worst" - elif args.flashes_remove_most: - remove_flashes = "Most" + from data.movement import key_name, AUTO_SPRINT + # Similar logic is present in the init fn of settings/movement.py + if args.movement: + movement = key_name[args.movement] + else: + movement = key_name[AUTO_SPRINT] return [ - ("Auto Sprint", args.auto_sprint), + ("Movement", movement), ("Original Name Display", args.original_name_display), ("Random RNG", args.random_rng), ("Random Clock", args.random_clock), ("Scan All", args.scan_all), + ("Warp All", args.warp_all), ("Event Timers", event_timers), ("Y NPC", y_npc), - ("Remove Flashes", remove_flashes) ] def menu(args): diff --git a/args/misc_magic.py b/args/misc_magic.py new file mode 100644 index 00000000..e4250d57 --- /dev/null +++ b/args/misc_magic.py @@ -0,0 +1,68 @@ +def name(): + return "Misc. Magic" + +def parse(parser): + magic = parser.add_argument_group("Misc. Magic") + + magic_mp = magic.add_mutually_exclusive_group() + magic_mp.add_argument("-mmps", "--magic-mp-shuffle", action = "store_true", + help = "Magic spells' MP costs shuffled") + magic_mp.add_argument("-mmprv", "--magic-mp-random-value", default = None, type = int, + nargs = 2, metavar = ("MIN", "MAX"), choices = range(255), + help = "Magic spells' MP costs randomized") + magic_mp.add_argument("-mmprp", "--magic-mp-random-percent", default = None, type = int, + nargs = 2, metavar = ("MIN", "MAX"), choices = range(201), + help = "Each Magic spell's MP cost set to random percent of original within given range") + +def process(args): + args._process_min_max("magic_mp_random_value") + args._process_min_max("magic_mp_random_percent") + +def flags(args): + flags = "" + + if args.magic_mp_shuffle: + flags += " -mmps" + elif args.magic_mp_random_value: + flags += f" -mmprv {args.magic_mp_random_value_min} {args.magic_mp_random_value_max}" + elif args.magic_mp_random_percent: + flags += f" -mmprp {args.magic_mp_random_percent_min} {args.magic_mp_random_percent_max}" + + return flags + +def options(args): + + mp = "Original" + if args.magic_mp_shuffle: + mp = "Shuffle" + elif args.magic_mp_random_value: + mp = f"Random Value {args.magic_mp_random_value_min}-{args.magic_mp_random_value_max}" + elif args.magic_mp_random_percent: + mp = f"Random Percent {args.magic_mp_random_percent_min}-{args.magic_mp_random_percent_max}%" + + return [ + ("MP", mp), + ] + +def menu(args): + entries = options(args) + for index, entry in enumerate(entries): + key, value = entry + try: + if key == "MP": + value = value.replace("Random Value ", "") + value = value.replace("Random Percent ", "") + entries[index] = (key, value) + except: + pass + return (name(), entries) + +def log(args): + from log import format_option + log = [name()] + + entries = options(args) + for entry in entries: + log.append(format_option(*entry)) + + return log diff --git a/args/shops.py b/args/shops.py index 05a87188..012eb7df 100644 --- a/args/shops.py +++ b/args/shops.py @@ -33,12 +33,22 @@ def parse(parser): help = "%(metavar)s shops will contain dried meat") shops.add_argument("-npi", "--no-priceless-items", action = "store_true", help = "Assign values to items which normally sell for 1 gold. Recommended with random inventory") - shops.add_argument("-snbr", "--shops-no-breakable-rods", action = "store_true", + breakable_rods = shops.add_mutually_exclusive_group() + breakable_rods.add_argument("-snbr", "--shops-no-breakable-rods", action = "store_true", help = "Poison, Fire, Ice, Thunder, Gravity, and Pearl Rods not sold in shops") + breakable_rods.add_argument("-sebr", "--shops-expensive-breakable-rods", action = "store_true", + help = "Poison, Fire, Ice, Thunder, Gravity, and Pearl Rods base price increased") + shops.add_argument("-snes", "--shops-no-elemental-shields", action = "store_true", help = "Flame, Ice, and Thunder Shields not sold in shops") - shops.add_argument("-snsb", "--shops-no-super-balls", action = "store_true", + + super_balls = shops.add_mutually_exclusive_group() + super_balls.add_argument("-snsb", "--shops-no-super-balls", action = "store_true", help = "Super Balls not sold in shops") + super_balls.add_argument("-sesb", "--shops-expensive-super-balls", action = "store_true", + help = "Super Balls base price increase") + + shops.add_argument("-snee", "--shops-no-exp-eggs", action = "store_true", help = "Exp. Eggs not sold in shops") shops.add_argument("-snil", "--shops-no-illuminas", action = "store_true", @@ -78,12 +88,20 @@ def flags(args): flags += f" -sdm {args.shop_dried_meat}" if args.no_priceless_items: flags += " -npi" + if args.shops_no_breakable_rods: flags += " -snbr" + elif args.shops_expensive_breakable_rods: + flags += " -sebr" + if args.shops_no_elemental_shields: flags += " -snes" + if args.shops_no_super_balls: flags += " -snsb" + elif args.shops_expensive_super_balls: + flags += " -sesb" + if args.shops_no_exp_eggs: flags += " -snee" if args.shops_no_illuminas: @@ -114,6 +132,18 @@ def options(args): elif args.shop_sell_fraction0: sell_fraction = "0" + breakable_rods = "Available" + if args.shops_no_breakable_rods: + breakable_rods = "No" + elif args.shops_expensive_breakable_rods: + breakable_rods = "Expensive" + + super_balls = "Available" + if args.shops_no_super_balls: + super_balls = "No" + elif args.shops_expensive_super_balls: + super_balls = "Expensive" + result = [("Inventory", inventory)] if args.shop_inventory_shuffle_random: result.append(("Random Percent", f"{args.shop_inventory_shuffle_random_percent}%")) @@ -123,9 +153,9 @@ def options(args): ("Sell Fraction", sell_fraction), ("Dried Meat", args.shop_dried_meat), ("No Priceless Items", args.no_priceless_items), - ("No Breakable Rods", args.shops_no_breakable_rods), + ("Breakable Rods", breakable_rods), ("No Elemental Shields", args.shops_no_elemental_shields), - ("No Super Balls", args.shops_no_super_balls), + ("Super Balls", super_balls), ("No Exp. Eggs", args.shops_no_exp_eggs), ("No Illuminas", args.shops_no_illuminas), ]) diff --git a/args/sketch_control.py b/args/sketch_control.py new file mode 100644 index 00000000..401ba584 --- /dev/null +++ b/args/sketch_control.py @@ -0,0 +1,43 @@ +def name(): + return "Sketch/Control" + +def parse(parser): + sketch_control = parser.add_argument_group("Sketch/Control") + + sketch_control.add_argument("-scis", "--sketch-control-improved-stats", action = "store_true", + help = "Sketch & Control 100%% accurate and use Sketcher/Controller's stats") + sketch_control.add_argument("-scia", "--sketch-control-improved-abilities", action = "store_true", + help = "Improves Sketch & Control abilities. Removes Battle from Sketch. Adds Rage as a Sketch/Control possibility for most monsters. Gives Sketch abilities to most bosses.") + +def process(args): + pass + +def flags(args): + flags = "" + + if args.sketch_control_improved_stats: + flags += " -scis" + if args.sketch_control_improved_abilities: + flags += " -scia" + + return flags + +def options(args): + + return [ + ("Improved Stats", args.sketch_control_improved_stats), + ("Improved Abilities", args.sketch_control_improved_abilities), + ] + +def menu(args): + return (name(), options(args)) + +def log(args): + from log import format_option + log = [name()] + + entries = options(args) + for entry in entries: + log.append(format_option(*entry)) + + return log diff --git a/args/starting_gold_items.py b/args/starting_gold_items.py index 79da17e3..b769e8a3 100644 --- a/args/starting_gold_items.py +++ b/args/starting_gold_items.py @@ -9,12 +9,17 @@ def parse(parser): starting_gold_items.add_argument("-smc", "--start-moogle-charms", default = 0, type = int, choices = range(4), metavar = "COUNT", help = "Start game with %(metavar)s Moogle Charms. Overrides No Moogle Charms option") + starting_gold_items.add_argument("-sshoes", "--start-sprint-shoes", default = 0, type = int, choices = range(4), metavar = "COUNT", + help = "Start game with %(metavar)s Sprint Shoes. Overrides No Sprint Shoes option") + starting_gold_items.add_argument("-sws", "--start-warp-stones", default = 0, type = int, choices = range(11), metavar = "COUNT", help = "Start game with %(metavar)s Warp Stones") starting_gold_items.add_argument("-sfd", "--start-fenix-downs", default = 0, type = int, choices = range(11), metavar = "COUNT", help = "Start game with %(metavar)s Fenix Downs") starting_gold_items.add_argument("-sto", "--start-tools", default = 0, type = int, choices = range(9), metavar = "COUNT", - help = "Start game with %(metavar)s different random tools") + help = "Start game with %(metavar)s different random tools"), + starting_gold_items.add_argument("-sj", "--start-junk", default = 0, type = int, choices = range(25), metavar = "COUNT", + help = "Start game with %(metavar)s unique low tier items. Includes weapons, armors, helmets, shields, and relics"), def process(args): pass @@ -26,12 +31,16 @@ def flags(args): flags += f" -gp {args.gold}" if args.start_moogle_charms != 0: flags += f" -smc {args.start_moogle_charms}" + if args.start_sprint_shoes != 0: + flags += f" -sshoes {args.start_sprint_shoes}" if args.start_warp_stones != 0: flags += f" -sws {args.start_warp_stones}" if args.start_fenix_downs != 0: flags += f" -sfd {args.start_fenix_downs}" if args.start_tools != 0: flags += f" -sto {args.start_tools}" + if args.start_junk != 0: + flags += f" -sj {args.start_junk}" return flags @@ -39,9 +48,11 @@ def options(args): return [ ("Start Gold", args.gold), ("Start Moogle Charms", args.start_moogle_charms), + ("Start Sprint Shoes", args.start_sprint_shoes), ("Start Warp Stones", args.start_warp_stones), ("Start Fenix Downs", args.start_fenix_downs), ("Start Tools", args.start_tools), + ("Start Junk", args.start_junk), ] def menu(args): diff --git a/battle/__init__.py b/battle/__init__.py index 5734ed52..1bafc283 100644 --- a/battle/__init__.py +++ b/battle/__init__.py @@ -5,6 +5,7 @@ import battle.suplex_train_check import battle.auto_status import battle.end_checks +import battle.magitek_upgrade from battle.animations import Animations __all__ = ["Battle"] diff --git a/battle/animations.py b/battle/animations.py index 34e5c834..d6de4e52 100644 --- a/battle/animations.py +++ b/battle/animations.py @@ -6,24 +6,56 @@ class Animations: def __init__(self): self.health_animation_reflect_mod() + self.stray_flash_mod() + + # Flash removal + replace_flash_animation = [] # The background flash to replace with monster flashes + remove_flash_animation = [] # The background flash addresses to remove if args.flashes_remove_most: - flash_address_arrays = battle_animation_scripts.BATTLE_ANIMATION_FLASHES.values() - self.remove_battle_flashes_mod(flash_address_arrays) + # Replace Boss Death and Final Kefka + replace_flash_animation.extend(["Boss Death", "Final KEFKA Death"]) + # And remove the rest + remove_flash_animation.extend(battle_animation_scripts.BATTLE_ANIMATION_FLASHES.keys()) + # Also removing critical flash self.remove_critical_flash() - - if args.flashes_remove_worst: - flash_address_arrays = [] - animation_names = ["Boss Death", "Ice 3", "Fire 3", "Bolt 3", "Schiller", "R.Polarity", "X-Zone", + elif args.flashes_remove_worst: + replace_flash_animation.extend(["Boss Death"]) + remove_flash_animation.extend(["Ice 3", "Fire 3", "Bolt 3", "Schiller", "R.Polarity", "X-Zone", "Muddle", "Dispel", "Shock", "Bum Rush", "Quadra Slam", "Slash", "Flash", - "Step Mine", "Rippler", "WallChange", "Ultima", "ForceField"] - for name in animation_names: - flash_address_arrays.append(battle_animation_scripts.BATTLE_ANIMATION_FLASHES[name]) + "Step Mine", "Rippler", "WallChange", "Ultima", "ForceField"]) + + # Replace any specified above + flash_address_arrays = [battle_animation_scripts.BATTLE_ANIMATION_FLASHES[name] for name in replace_flash_animation] + if flash_address_arrays: + self.replace_bg_flash_with_monster_flash_mod(flash_address_arrays) + + # Remove any remainder specified above + flash_address_arrays = [battle_animation_scripts.BATTLE_ANIMATION_FLASHES[name] for name in remove_flash_animation if name not in replace_flash_animation] + if flash_address_arrays: self.remove_battle_flashes_mod(flash_address_arrays) def remove_critical_flash(self): space = Reserve(0x23410, 0x23413, "Critical hit screen flash", asm.NOP()) + def replace_bg_flash_with_monster_flash_mod(self, flash_address_arrays): + REPLACEMENTS = { + 0xAF: 0xB9, # Set background palette color subtraction (absolute) -> Set monster palettes color subtraction (absolute) + 0xB0: 0xBA, # Set background palette color addition (absolute) -> Set monster palettes color addition (absolute) + 0xB5: 0xBB, # Add color to background palette (relative) -> Add color to monster palettes (relative) + 0xB6: 0xBC, # Subtract color from background palette (relative) -> Subtract color from monster palettes (relative) + } + for flash_addresses in flash_address_arrays: + # For each address in its array + for flash_address in flash_addresses: + # Read the current animation command at the address + animation_cmd = Read(flash_address, flash_address+1) + if(animation_cmd[0] in REPLACEMENTS.keys()): + Write(flash_address, REPLACEMENTS[animation_cmd[0]], "BG flash to monster flash") + else: + # This is an error, reflecting a difference between the disassembly used to generate BATTLE_ANIMATION_FLASHES and the ROM + raise ValueError(f"Battle Animation Script Command at 0x{flash_address:x} (0x{animation_cmd[0]:x}) did not match an expected value.") + def remove_battle_flashes_mod(self, flash_address_arrays): ABSOLUTE_CHANGES = [0xb0, 0xaf] RELATIVE_CHANGES = [0xb5, 0xb6] @@ -42,7 +74,11 @@ def remove_battle_flashes_mod(self, flash_address_arrays): else: # This is an error, reflecting a difference between the disassembly used to generate BATTLE_ANIMATION_FLASHES and the ROM raise ValueError(f"Battle Animation Script Command at 0x{flash_address:x} (0x{animation_cmd[0]:x}) did not match an expected value.") - + + def stray_flash_mod(self): + # port of https://www.romhacking.net/hacks/6740/ + Write(0x10784b, 0xa7, "Flash tool position") #default: 0xaf + def health_animation_reflect_mod(self): # Ref: https://www.ff6hacking.com/forums/thread-4145.html # Banon's Health command casts Cure 2 on the party with a unique animation. diff --git a/battle/auto_status.py b/battle/auto_status.py index e374fb20..09108dc7 100644 --- a/battle/auto_status.py +++ b/battle/auto_status.py @@ -9,12 +9,15 @@ class _AutoStatus: def __init__(self): auto_b_status_effects = ["Condemned", "Image", "Mute", "Berserk", "Muddle", "Seizure", "Sleep"] auto_c_status_effects = ["Float", "Regen", "Slow", "Haste", "Shell", "Safe", "Reflect"] + auto_d_status_effects = ["Life 3", "Dog Block"] auto_addresses = [] for status in auto_b_status_effects: auto_addresses.append(self.auto_status(status, status_effects.B)) for status in auto_c_status_effects: auto_addresses.append(self.auto_status(status, status_effects.C)) + for status in auto_d_status_effects: + auto_addresses.append(self.auto_status(status, status_effects.D)) src = [ # original replaced code @@ -40,8 +43,11 @@ def __init__(self): asm.JSL(START_ADDRESS_SNES + auto_status_effects), ) + # Ensure that Life 3 can also be applied at the start of battle + space = Reserve(0x22823, 0x22823, "Only keep Dog Block and Float") + space.write(0xC4) # Original: 0xC0; also keeping Life 3 + def auto_status(self, status_name, status_effects_group): - status_name = status_name.capitalize() auto_status_name = "Auto " + status_name auto_status_name_upper = auto_status_name.upper() @@ -51,8 +57,13 @@ def auto_status(self, status_name, status_effects_group): status_bit = 1 << status_effects_group.name_id[status_name] if status_effects_group == status_effects.B: status_address = 0x3c6c + opcode = asm.ABS_X elif status_effects_group == status_effects.C: status_address = 0x3c6d + opcode = asm.ABS_X + elif status_effects_group == status_effects.D: + status_address = 0x1615 + opcode = asm.ABS_Y src = [] if auto_status_name in objectives.results: @@ -71,9 +82,26 @@ def auto_status(self, status_name, status_effects_group): asm.RTS(), auto_status_name_upper, - asm.LDA(status_address, asm.ABS_X), + ] + + # if the opcode is Y, that means we're accessing the SRAM offset, for which Y is multiples of 37 + if(opcode == asm.ABS_Y): + src += [ + asm.PHY(), # push current Y + asm.XY16(), # 16-bit X & Y + asm.LDY(0x3010, asm.ABS_X) # get the pointer to the character + ] + src += [ + asm.LDA(status_address, opcode), asm.ORA(status_bit, asm.IMM8), - asm.STA(status_address, asm.ABS_X), + asm.STA(status_address, opcode), + ] + if(opcode == asm.ABS_Y): + src += [ + asm.XY8(), # revert back to 8-bit X&Y + asm.PLY(), # pull original Y + ] + src += [ asm.RTS(), ] space = Write(Bank.F0, src, auto_status_name) diff --git a/battle/magitek_upgrade.py b/battle/magitek_upgrade.py new file mode 100644 index 00000000..928063b0 --- /dev/null +++ b/battle/magitek_upgrade.py @@ -0,0 +1,101 @@ +from memory.space import Bank, START_ADDRESS_SNES, Reserve, Write, Read +import instruction.asm as asm + +import data.event_bit as event_bit +import objectives + +class _MagitekUpgrade: + '''Set the Magitek menu in battle to match the Magitek Upgrade objective result.''' + def __init__(self): + # Write our 2 magitek tables + # We're moving them from C1/910C - C1/911B + # Default: Match Regular character's default + src = [ + 0x00, 0x01, #FIRE_BEAM, BOLT_BEAM, + 0x02, 0xFF, #ICE_BEAM, , + 0x04, 0xFF, #HEAL_FORCE, , + 0xFF, 0xFF #, + ] + space = Write(Bank.F0, src, "magitek default table") + magitek_default_table_addr = space.start_address + + # Upgraded: Match Terra's options + src = [ + 0x00, 0x01, #FIRE_BEAM, BOLT_BEAM, + 0x02, 0x03, #ICE_BEAM, BIO_BLAST, + 0x04, 0x05, #HEAL_FORCE, CONFUSER, + 0x06, 0x07 #X_FER, TEKMISSILE + ] + space = Write(Bank.F0, src, "magitek upgraded table") + magitek_upgraded_table_addr = space.start_address + + # Write our modifications to the C1 routines that use the + # magitek tables. + # There are 2 that use the magitek tables in C1: + # 1) C1/4D42 - C1/4D6D builds the magitek menu by writing to $575A & $5760 + # 2) C1/866A - C1/8683 used when selecting from the menu - + # stores the menu option in A. + self.magitek_upgrade_name = "Magitek Upgrade" + magitek_upgrade_name_upper = self.magitek_upgrade_name.upper() + + # 1) Build the magitek menu + branch_name = f"{magitek_upgrade_name_upper}_MENU" + src = self.get_branch_if_objective_complete_src(branch_name) + src += [ + f"NO_{branch_name}", + asm.LDA(START_ADDRESS_SNES + magitek_default_table_addr, asm.LNG_X), + asm.STA(0x575A, asm.ABS), + asm.LDA(START_ADDRESS_SNES + magitek_default_table_addr + 1, asm.LNG_X), + asm.STA(0x5760, asm.ABS), + asm.RTL(), + branch_name, + asm.LDA(START_ADDRESS_SNES + magitek_upgraded_table_addr, asm.LNG_X), + asm.STA(0x575A, asm.ABS), + asm.LDA(START_ADDRESS_SNES + magitek_upgraded_table_addr + 1, asm.LNG_X), + asm.STA(0x5760, asm.ABS), + asm.RTL(), + ] + space = Write(Bank.F0, src, "build magitek menu") + build_magitek_menu_addr = space.start_address + + space = Reserve(0x14d42, 0x14d6d, "build magitek menu jsl", asm.NOP()) + space.write( + asm.JSL(START_ADDRESS_SNES + build_magitek_menu_addr), + ) + + # 2) Select from the magitek menu + branch_name = f"{magitek_upgrade_name_upper}_SELECT" + src = self.get_branch_if_objective_complete_src(branch_name) + src += [ + f"NO_{branch_name}", + asm.LDA(START_ADDRESS_SNES + magitek_default_table_addr, asm.LNG_X), + asm.RTL(), + branch_name, + asm.LDA(START_ADDRESS_SNES + magitek_upgraded_table_addr, asm.LNG_X), + asm.RTL(), + ] + space = Write(Bank.F0, src, "select from magitek menu") + select_from_magitek_menu_addr_snes = space.start_address_snes + + space = Reserve(0x1866a, 0x18683, "select from magitek menu jsl", asm.NOP()) + space.write( + asm.JSL(select_from_magitek_menu_addr_snes), + ) + + def get_branch_if_objective_complete_src(self, branch_name): + src = [] + if self.magitek_upgrade_name in objectives.results: + for objective in objectives.results[self.magitek_upgrade_name]: + objective_event_bit = event_bit.objective(objective.id) + bit = event_bit.bit(objective_event_bit) + address = event_bit.address(objective_event_bit) + + src += [ + asm.LDA(address, asm.ABS), + asm.AND(2 ** bit, asm.IMM8), + asm.BNE(branch_name), + ] + return src + +magitek_upgrade = _MagitekUpgrade() + diff --git a/bug_fixes/__init__.py b/bug_fixes/__init__.py index fc1eb5fe..98f63b8e 100644 --- a/bug_fixes/__init__.py +++ b/bug_fixes/__init__.py @@ -4,6 +4,7 @@ from bug_fixes.jump import Jump from bug_fixes.retort import Retort from bug_fixes.enemy_damage_counter import EnemyDamageCounter +from bug_fixes.capture import Capture __all__ = ["BugFixes"] class BugFixes: @@ -14,3 +15,4 @@ def __init__(self): self.jump = Jump() self.retort = Retort() self.enemy_damage_counter = EnemyDamageCounter() + self.capture = Capture() diff --git a/bug_fixes/capture.py b/bug_fixes/capture.py new file mode 100644 index 00000000..603308bb --- /dev/null +++ b/bug_fixes/capture.py @@ -0,0 +1,202 @@ +from memory.space import Bank, Reserve, Write +import instruction.asm as asm +import args + +class Capture: + def __init__(self): + if args.fix_capture: + self.weapon_special_mod() + self.multisteal_mod() + + def multisteal_mod(self): + # Fixes issue with multiple steals caused by Genji Glove and/or Offering Capture. + # Issues resolved: + # 1) the stolen items are not all added to your inventory (only the last successful steal is actually added) + # 2) the message display window does not clear in between steal animations, + # meaning that the first item name is the one that is displayed for all subsequent successful steals. + # Based in part on https://www.angelfire.com/al2/imzogelmo/patches.html#patches's Multi-Steal Fix + # and Bropedio's Multi-Steal fix (https://www.ff6hacking.com/forums/thread-4124-post-40232.html#pid40232) + + # Custom variable locations + STOLEN_ITEM_ARRAY_START = 0x2f35 + STOLEN_ITEM_ARRAY_INDEX = 0x2f3b + + # Make the "Steal " text go through the array + src = [ + asm.REP(0x20), #Set A to 16 bits + asm.LDA(0x76, asm.DIR_16), #Load first two bytes of current animation entry + asm.CMP(0x0302, asm.IMM16), #Check for animation opcode 2 (upper text box) and text message 3 (Steal ) + asm.SEP(0x20), #Set A back to 8 bits + asm.BEQ("GET_STEAL_ITEM"), #If the above condition was true, branch + asm.LDA(0x2f35, asm.ABS), #Else, perform the displaced command (Note: it's unclear if this will ever get called) + asm.RTS(), # and return + "GET_STEAL_ITEM", + asm.SEP(0x10), #Set X to 8 bits + asm.LDX(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # Load the index to the stolen item array + asm.LDA(STOLEN_ITEM_ARRAY_START, asm.ABS_X), # Put the item from index into A + asm.REP(0x10), # Set X back to 16 bits + asm.INC(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # Increment the array index + asm.RTS() + ] + space = Write(Bank.C1, src, "Multisteal Fix: steal text") + c1_steal_print_addr = space.start_address + + # Call our new subroutine + space = Reserve(0x15f06, 0x15f08, "Multisteal Fix: call new C1 subroutine to load stolen item into A", asm.NOP()) + space.write( + asm.JSR(c1_steal_print_addr, asm.ABS) + ) + + #These two subroutines reset the stolen item index + src = [ + asm.JSR(0x1429, asm.ABS), # displaced instruction + asm.STZ(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # zero the index + asm.RTS() + ] + space = Write(Bank.C2, src, "Multisteal fix: reset stolen item array index routine") + stolen_item_index_reset = space.start_address + + space = Reserve(0x2140f, 0x21411, "Multisteal Fix: reset stolen item index") + space.write( + asm.JSR(stolen_item_index_reset, asm.ABS) + ) + + src = [ + asm.LDA(0xb5, asm.DIR), # displaced instruction + asm.ASL(), # displaced instruction + asm.STZ(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # zero the index + asm.RTS() + ] + space = Write(Bank.C2, src, "Multisteal fix: reset stolen item array index routine") + stolen_item_index_reset = space.start_address + + space = Reserve(0x213fa, 0x213fc, "Multisteal Fix: reset stolen item index") + space.write( + asm.JSR(stolen_item_index_reset, asm.ABS) + ) + + + + # New subroutine for storing acquired item + src = [ + asm.TSB(0x3a8c, asm.ABS), # set character's reserve item to be added + asm.LDA(0x32f4, asm.ABS_X), # load current reserve item + asm.PHA(), # save reserve item on stack + asm.XBA(), # get new item in A + asm.STA(0x32f4, asm.ABS_X), # store new item in reserve byte + # Store item in array for textbox + asm.PHX(), # save X + asm.LDX(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # Load the index for the array + asm.STA(STOLEN_ITEM_ARRAY_START, asm.ABS_X), # Store the item number into the array + asm.INC(STOLEN_ITEM_ARRAY_INDEX, asm.ABS), # Increment the index for highest variable stored + asm.PLX(), # restore X + # Done storing item in array for textbox + asm.PHX(), # save X + asm.JSR(0x62C7, asm.ABS), # add reserve to obtained-items buffer + asm.PLX(), # restore X + asm.PLA(), # restore previous reserve item + asm.STA(0x32f4, asm.ABS_X), # store in reserve item byte again + asm.RTS() + ] + space = Write(Bank.C2, src, "Multisteal Fix: store acquired item") + store_acquired_addr = space.start_address + + # Update steal formula where it stores the acquired item + space = Reserve(0x239e9, 0x239f4, "Multisteal Fix: call new subroutine", asm.NOP()) + space.write( + asm.XBA(), # store acquired item in B + asm.LDA(0x3018, asm.ABS_X), # character's unique bit + asm.JSR(store_acquired_addr, asm.ABS), # save new item to buffer + ) + + # Fix Item Return Buffer + space = Reserve(0x112d5, 0x112d7, "Multisteal Fix: avoid item return buffer overrun") + space.write( + asm.CPX(0x50, asm.IMM16) # the game only clears #$40 for item buffer, but it expects #$50 + ) + + def weapon_special_mod(self): + # http://assassin17.brinkster.net/patches.htm#anchor18 + NEW_SPECIAL_EFFECT_VAR = 0x2f3d + + ##### + # New subroutines + ##### + # Null the dog block [displaced Square code], and clear my custom special effect byte. + src = [ + asm.STA(0x3a83, asm.ABS), #Null Dog block + asm.STZ(NEW_SPECIAL_EFFECT_VAR, asm.ABS), #Clear new special effect variable + asm.RTS() + ] + space = Write(Bank.C2, src, "Capture Fix: null dog block") + null_dog_block_addr = space.start_address + + #Call Square's per-target special effect function as normal. Then call it again with + # a secondary variable so the Capture command can steal, unless the first function call + # already handled stealing. + src = [ + asm.PHP(), + asm.A8(), # Set 8 bit accumulator + asm.LDA(0x11a9, asm.ABS), # Load A with the current attack special effect -- based on table at c2/3dcd + asm.PHA(), + asm.JSR(0x387e, asm.ABS), # Call special effect function once for value in 11a9 + asm.LDA(NEW_SPECIAL_EFFECT_VAR, asm.ABS), + asm.CMP(0x1, asm.S), # does the custom match the original? + asm.BEQ("SKIP_IT"), # branch if so + asm.STA(0x11a9, asm.ABS), + asm.JSR(0x387e, asm.ABS), # Call special effect function again for our special effect var + "SKIP_IT", + asm.PLA(), + asm.STA(0x11a9, asm.ABS), + asm.PLP(), + asm.RTS() + ] + space = Write(Bank.C2, src, "Capture Fix: new special effect function") + new_special_effect_addr = space.start_address + + ##### + # Modify data in "Character Executes One Hit" function to use new subroutines and variable + ##### + space = Reserve(0x23185, 0x23187, "Capture Fix: call new null dog block subroutine")#, asm.NOP()) + space.write( + asm.JSR(null_dog_block_addr, asm.ABS) #(Null Dog block, then clear my custom special effect + # variable for Capture) + ) + space = Reserve(0x231b0, 0x231b2, "Capture Fix: Save Special Effect to new byte") + space.write( + asm.STA(NEW_SPECIAL_EFFECT_VAR, asm.ABS) #save special effect in our fancy new byte, so we won't + # overwrite the weapon's special effect. + ) + space = Reserve(0x2345c, 0x2345e, "Capture Fix: call new special effect function") + space.write( + asm.JSR(new_special_effect_addr, asm.ABS) #Special effect code for target .. customized + ) + + #### + # Dice Effect + #### + # FF6WC note: Rather than transfering Assassin's extensive changes made to the Dice Effect subroutine (C2/4168 - C2/41E5), + # which were seemingly made just to save space, I'm just transfering the main change as a subroutine: + # replacing the Capture animation with Dice with that of Fight starting at C2/41D9 + src = [ + asm.A8(), # Set 8 bit accumulator + asm.LDA(0xb5, asm.DIR), # Load Command Index + asm.CMP(0x00, asm.IMM8), # Maybe unnecessary? Compare Command with Fight + asm.BEQ("SET_ANIMATION"), # Branch if Fight command + asm.CMP(0x06, asm.IMM8), # Compare Command with Capture + asm.BNE("NO_CHANGE"), # Branch if not Capture command + "SET_ANIMATION", + asm.LDA(0x26, asm.IMM8), + asm.STA(0xb5, asm.DIR), # Store a dice toss animation + "NO_CHANGE", + asm.RTS() + ] + space = Write(Bank.C2, src, "Capture Fix: new dice toss animation") + dice_toss_animation_addr = space.start_address + + space = Reserve(0x241d9, 0x241e5, "Capture Fix: replace dice toss animation", asm.NOP()) + space.write( + asm.JSR(dice_toss_animation_addr, asm.ABS), #Jump to our new routine + asm.RTS() #Done + ) + diff --git a/constants/objectives/condition_bits.py b/constants/objectives/condition_bits.py index bc46f131..8e45eebe 100644 --- a/constants/objectives/condition_bits.py +++ b/constants/objectives/condition_bits.py @@ -68,6 +68,14 @@ NameBit("Zozo Tower", event_bit.GOT_ZOZO_REWARD), ] +check_bit += [ # Index + NameBit("Kefka's Tower Ambush", event_bit.DEFEATED_INFERNO), # 59 + NameBit("Kefka's Tower Guardian", event_bit.DEFEATED_GUARDIAN), # 60 + NameBit("KT Left Triad Statue", event_bit.DEFEATED_DOOM), # 61 + NameBit("KT Mid Triad Statue", event_bit.DEFEATED_POLTERGEIST), # 62 + NameBit("KT Right Triad Statue", event_bit.DEFEATED_GODDESS), # 63 +] + quest_bit = [ NameBit("Defeat Sealed Cave Ninja", event_bit.DEFEATED_NINJA_CAVE_TO_SEALED_GATE), NameBit("Help Injured Lad", event_bit.HELPED_INJURED_LAD), diff --git a/constants/objectives/results.py b/constants/objectives/results.py index 2606d9b2..445bb88d 100644 --- a/constants/objectives/results.py +++ b/constants/objectives/results.py @@ -85,6 +85,12 @@ ], } +#Additional results +category_types["Command"].append(ResultType(59, "Magitek Upgrade", "Magitek Upgrade", None)) +category_types["Item"].append(ResultType(60, "Sprint Shoes", "Sprint Shoes", None)) +category_types["Auto"].append(ResultType(61, "Auto Dog Block", "Auto Dog Block", None)) +category_types["Auto"].append(ResultType(62, "Auto Life 3", "Auto Life 3", None)) + categories = list(category_types.keys()) id_type = {} diff --git a/constants/spells.py b/constants/spells.py index 6c245f44..a1c0a4e0 100644 --- a/constants/spells.py +++ b/constants/spells.py @@ -55,3 +55,7 @@ 53 : "Life 3", } spell_id = {v: k for k, v in id_spell.items()} + +black_magic_ids = range(0, 24) +gray_magic_ids = range(24, 45) +white_magic_ids = range(45, 54) \ No newline at end of file diff --git a/data/bosses.py b/data/bosses.py index 612fd968..fa1910e3 100644 --- a/data/bosses.py +++ b/data/bosses.py @@ -92,6 +92,25 @@ 396 : "Guardian", # defeatable guardian in kefka's tower 401 : "MagiMaster", } + +# These ids are repeated in normal_pack_name as well +# This is intentional as they are used to iterate over ALL bosses for things like objective conditions +statue_pack_name = { + 354 : "Doom", + 355 : "Goddess", + 356 : "Poltrgeist", +} +statue_formation_name = { + 468 : "Doom", + 469 : "Goddess", + 470 : "Poltrgeist", +} +statue_enemy_name = { + 295 : "Doom", + 296 : "Goddess", + 297 : "Poltrgeist", +} + normal_formation_name = { 79 : "Rizopas", 354 : "MagiMaster", @@ -142,6 +161,9 @@ 364 : "Phunbaba 3", 365 : "Phunbaba 4", 256 : "Whelk", + 257 : "Presenter", + 361 : "Naughty", + 292 : "KatanaSoul", 308 : "Head", 259 : "Vargas", 333 : "Ipooh", @@ -255,3 +277,10 @@ enemy_name.update(removed_enemy_name) name_enemy = {v: k for k, v in enemy_name.items()} + +class BossLocations: + MIX = "mix" + ORIGINAL = "original" + SHUFFLE = "shuffle" + + ALL = [MIX, ORIGINAL, SHUFFLE] diff --git a/data/characters.py b/data/characters.py index 5476e9b3..31990fa5 100644 --- a/data/characters.py +++ b/data/characters.py @@ -78,10 +78,11 @@ def get_character_path(self, character): return self.character_paths[character] def mod_init_levels(self): - if self.args.start_average_level: - # characters recruited at average level, set everyone's initial level to 3 - for character in self.characters: - character.init_level_factor = 0 + # remove all variation in leveling, since we're controlling level directly + for character in self.characters: + character.init_level_factor = 0 + + characters_asm.set_starting_level(self.args.start_level) def stats_random_percent(self): import random diff --git a/data/characters_asm.py b/data/characters_asm.py index 0d501bc3..3698a3b1 100644 --- a/data/characters_asm.py +++ b/data/characters_asm.py @@ -7,6 +7,10 @@ def equipable_umaro(character_count): space = Reserve(0x39ef4, 0x39ef7, "Reequip Umaro if genji glove/gauntlet/merit award equipped/removed", asm.NOP()) +def set_starting_level(start_level): + space = Reserve(0x09fc6, 0x09fc6, "Starting level") + space.write(start_level.to_bytes(1, 'little')) + def update_morph_character(characters): from constants.commands import id_name diff --git a/data/chests.py b/data/chests.py index 8d9715b4..d80f8234 100644 --- a/data/chests.py +++ b/data/chests.py @@ -50,10 +50,14 @@ def __init__(self, rom, args, items): from data.chest_item_tiers import tiers, tier_s_distribution self.item_tiers = tiers - # remove excluded items from tier s - # remaining item weights raised proportionally to their original weight - # e.g. if original weights were [0.10, 0.50, 0.40] and 0.50 removed, the remaining ones become [0.20, 0.80] + # remove excluded items from tiers excluded_items = self.items.get_excluded() + for idx, tier in enumerate(self.item_tiers): + tier = [(item) for item in tier if item not in excluded_items] + self.item_tiers[idx] = tier + + # for S-tier, remaining item weights raised proportionally to their original weight + # e.g. if original weights were [0.10, 0.50, 0.40] and 0.50 removed, the remaining ones become [0.20, 0.80] self.item_tier_s_distribution = [(item_weight[0], item_weight[1]) for item_weight in tier_s_distribution if item_weight[0] not in excluded_items] diff --git a/data/control.py b/data/control.py new file mode 100644 index 00000000..95e971f8 --- /dev/null +++ b/data/control.py @@ -0,0 +1,20 @@ +class Control(): + def __init__(self, id, attack_data): + self.id = id + + self.attack_data_array = attack_data + + def attack_data(self): + from data.controls import Controls + data = [0x00] * Controls.ATTACKS_DATA_SIZE + + data = self.attack_data_array + + return data + + def print(self): + attack_str = "" + for attack in self.attack_data: + attack_str += f"{attack} " + + print(f"{self.id} {attack_str}") diff --git a/data/controls.py b/data/controls.py new file mode 100644 index 00000000..9012eac8 --- /dev/null +++ b/data/controls.py @@ -0,0 +1,154 @@ +from data.control import Control +from data.structures import DataArray +from memory.space import Reserve, Allocate, Bank, Write +import instruction.asm as asm + +class Controls(): + ATTACKS_DATA_START = 0xf3d00 + ATTACKS_DATA_END = 0xf42ff + ATTACKS_DATA_SIZE = 4 + ATTACKS_DATA_TOTAL_BYTES = (ATTACKS_DATA_END - ATTACKS_DATA_START) + 1 + + def __init__(self, rom, args, enemies, rages): + self.rom = rom + self.args = args + self.enemies = enemies + self.rages = rages + + # Copy the vanilla table to a new location, so that any modifications do not affect Coliseum/Muddle behavior + self.new_attack_data_space = Allocate(Bank.F0, self.ATTACKS_DATA_TOTAL_BYTES, "new Controls table") + self.new_attack_data_space.copy_from(self.ATTACKS_DATA_START, self.ATTACKS_DATA_END) + + self.attack_data = DataArray(self.rom, self.new_attack_data_space.start_address, self.new_attack_data_space.end_address, self.ATTACKS_DATA_SIZE) + + self.controls = [] + for control_index in range(len(self.attack_data)): + control = Control(control_index, self.attack_data[control_index]) + self.controls.append(control) + + def split_control_table(self): + # Update the vanilla lookup of the table for Control commands + # Default: LDA $CF3D00,X + space = Reserve(0x23758, 0x2375B, "get Control command table") + space.write( + asm.LDA(self.new_attack_data_space.start_address_snes, asm.LNG_X) + ) + + def ignore_randomize_target(self): + # Ignoring Randomize Target bit when Control is used, to ensure that those commands respect the selected targetting + # This is a bug-fix for a vanilla bug, in which Controlled Dance abilities (ex: Sandstorm) swap targetting. + src = [ + asm.LDA(0x3A7A, asm.ABS), # load the command + asm.CMP(0x0E, asm.IMM8), # is it Control? + asm.BEQ("exit"), # if so, skip over displaced code + # displaced code from C2/276A - C2/2771 to read the "Randomize target bit" and set the equivalent in $BA + asm.LDA(0x01, asm.S), + asm.AND(0x10, asm.IMM8), + asm.ASL(), + asm.ASL(), + asm.TSB(0xBA, asm.DIR), + "exit", + asm.RTS(), + ] + space = Write(Bank.C2, src, "Control: ignore Randomize Target bit") + ignore_randomize_target_addr = space.start_address + + # Call our new subroutine + space = Reserve(0x2276A, 0x22771, "control: call ignore randomize target bit subroutine", asm.NOP()) + space.write( + asm.JSR(ignore_randomize_target_addr, asm.ABS) + ) + + def enable_control_casters_stats(self): + src = [ + # X = entity using command (in Control case, this is the monster being controlled) + asm.LDA(0x32B9,asm.ABS_X), # who's Controlling this entity? + asm.CMP(0xFF, asm.IMM8), + asm.BEQ("exit"), # branch if nobody controls them + asm.TAX(), # if there's a valid Controller, use their stats (vigor/magic/level) + asm.LDA(0x11A2, asm.ABS), #Spell Properties + asm.LSR(), #Check if Physical/Magical + asm.LDA(0x3B41, asm.ABS_X), #Controller's Mag.Pwr + asm.BCC("magical"), #Branch if not physical damage + asm.LDA(0x3B2C, asm.ABS_X), #Controller's Vigor * 2 + "magical", + asm.STA(0x11AE, asm.ABS), #Set Controller's Magic or Vigor + "exit", + asm.LDA(0x3B18, asm.ABS_X), # displaced code: get Level + asm.RTS(), + ] + space = Write(Bank.C2, src, "Controller Caster Stats") + use_controller_stats_addr = space.start_address + + # Call our new subroutine + space = Reserve(0x22c28, 0x22c2A, "jump to new routine") + space.write( + asm.JSR(use_controller_stats_addr, asm.ABS) + ) + + def enable_control_chances_always(self): + # Always Control if the target is valid + # NOPing the JSR and BCS that can prevent Control from working + space = Reserve(0x023ae8, 0x023aec, "control always", asm.NOP()) + + def enable_control_improved_abilities(self): + from data.spell_names import name_id + # Ensure that Rage & Special are available (if there are open Controls) + for control in self.controls: + # Search for blanks, rages, and specials + index_of_blank = self.ATTACKS_DATA_SIZE # default to end + control_has_rage = False + control_has_special = False + for attack_index, attack in enumerate(control.attack_data()): + # Look for the first blank entry + if index_of_blank == self.ATTACKS_DATA_SIZE and attack == name_id["??????????"]: + index_of_blank = attack_index + # Look for a rage + if control.id < self.rages.RAGE_COUNT: # Enemy has a rage + if attack == self.rages.rages[control.id].attack2 and not control_has_rage: + control_has_rage = True + else: + control_has_rage = True # no rages to have + # Look for a special + if attack == name_id["Special"] and not control_has_special: + control_has_special = True + + # If we found that it doesn't have a rage and there's room, add the rage + if not control_has_rage and index_of_blank < self.ATTACKS_DATA_SIZE: + control.attack_data_array[index_of_blank] = self.rages.rages[control.id].attack2 + # Avoid duplicate Specials if Rage == Special + if control.attack_data_array[index_of_blank] == name_id["Special"]: + control_has_special = True + index_of_blank = index_of_blank + 1 + + # If we found that it doesn't have a Special and there's room, add the Special + if not control_has_special and index_of_blank < self.ATTACKS_DATA_SIZE: + control.attack_data_array[index_of_blank] = name_id["Special"] + index_of_blank = index_of_blank + 1 + + def mod(self): + + self.ignore_randomize_target() + + if self.args.sketch_control_improved_stats: + self.enable_control_chances_always() + self.enable_control_casters_stats() + if self.args.sketch_control_improved_abilities: + self.split_control_table() + self.enable_control_improved_abilities() + + def write(self): + if self.args.spoiler_log: + self.log() + + for control_index, control in enumerate(self.controls): + self.attack_data[control_index] = control.attack_data() + + self.attack_data.write() + + def log(self): + pass + + def print(self): + for control in self.controls: + control.print() diff --git a/data/data.py b/data/data.py index 90baa85d..1d22f42e 100644 --- a/data/data.py +++ b/data/data.py @@ -11,6 +11,8 @@ import data.rages as rages import data.dances as dances import data.steal as steal +import data.sketches as sketches +import data.controls as controls import data.magiteks as magiteks import data.espers as espers import data.shops as shops @@ -46,7 +48,7 @@ def __init__(self, rom, args): self.blitzes.mod() self.lores = lores.Lores(rom, args, self.characters) - self.lores.mod() + self.lores.mod(self.dialogs) self.rages = rages.Rages(rom, args, self.enemies) self.rages.mod() @@ -56,6 +58,13 @@ def __init__(self, rom, args): self.steal = steal.Steal(rom, args) self.steal.mod() + + self.sketches = sketches.Sketches(rom, args, self.enemies, self.rages) + self.sketches.mod() + + self.controls = controls.Controls(rom, args, self.enemies, self.rages) + self.controls.mod() + self.magiteks = magiteks.Magiteks(rom, args) self.magiteks.mod() @@ -85,6 +94,8 @@ def write(self): self.rages.write() self.dances.write() self.steal.write() + self.sketches.write() + self.controls.write() self.magiteks.write() self.espers.write() self.shops.write() diff --git a/data/enemies.py b/data/enemies.py index ff5de70c..e2d25833 100644 --- a/data/enemies.py +++ b/data/enemies.py @@ -20,6 +20,10 @@ class Enemies(): ITEMS_END = 0xf35ff ITEMS_SIZE = 4 + SPECIAL_NAMES_START = 0xfd0d0 + SPECIAL_NAMES_END = 0xfdfdf + SPECIAL_NAMES_SIZE = 10 + DRAGON_COUNT = 8 SRBEHEMOTH2_ID = 127 @@ -32,11 +36,12 @@ def __init__(self, rom, args): self.enemy_data = DataArray(self.rom, self.DATA_START, self.DATA_END, self.DATA_SIZE) self.enemy_name_data = DataArray(self.rom, self.NAMES_START, self.NAMES_END, self.NAME_SIZE) self.enemy_item_data = DataArray(self.rom, self.ITEMS_START, self.ITEMS_END, self.ITEMS_SIZE) + self.enemy_special_name_data = DataArray(self.rom, self.SPECIAL_NAMES_START, self.SPECIAL_NAMES_END, self.SPECIAL_NAMES_SIZE) self.enemies = [] self.bosses = [] for enemy_index in range(len(self.enemy_data)): - enemy = Enemy(enemy_index, self.enemy_data[enemy_index], self.enemy_name_data[enemy_index], self.enemy_item_data[enemy_index]) + enemy = Enemy(enemy_index, self.enemy_data[enemy_index], self.enemy_name_data[enemy_index], self.enemy_item_data[enemy_index], self.enemy_special_name_data[enemy_index]) self.enemies.append(enemy) if enemy_index in bosses.enemy_name and enemy_index not in bosses.removed_enemy_name: @@ -269,6 +274,21 @@ def shuffle_encounters(self, maps): # NOTE: any remaining formations (due to extra_formations) are lost + def chupon_encounters(self, maps): + # find all packs that are randomly encountered in zones + packs = [] + for zone in self.zones.zones: + if self.skip_shuffling_zone(maps, zone): + continue + + for x in range(zone.PACK_COUNT): + if self.skip_shuffling_pack(zone.packs[x], zone.encounter_rates[x]): + continue + + packs.append(zone.packs[x]) + + self.packs.chupon_packs(packs) + def randomize_encounters(self, maps): # find all packs that are randomly encountered in zones packs = [] @@ -326,6 +346,8 @@ def mod(self, maps): if self.args.random_encounters_shuffle: self.shuffle_encounters(maps) + elif self.args.random_encounters_chupon: + self.chupon_encounters(maps) elif not self.args.random_encounters_original: self.randomize_encounters(maps) @@ -353,10 +375,12 @@ def write(self): self.enemy_data[enemy_index] = self.enemies[enemy_index].data() self.enemy_name_data[enemy_index] = self.enemies[enemy_index].name_data() self.enemy_item_data[enemy_index] = self.enemies[enemy_index].item_data() + self.enemy_special_name_data[enemy_index] = self.enemies[enemy_index].special_name_data() self.enemy_data.write() self.enemy_name_data.write() self.enemy_item_data.write() + self.enemy_special_name_data.write() self.formations.write() self.packs.write() diff --git a/data/enemy.py b/data/enemy.py index 9bc68deb..c7c2491c 100644 --- a/data/enemy.py +++ b/data/enemy.py @@ -2,7 +2,7 @@ from data.status_effects import StatusEffects class Enemy: - def __init__(self, id, data, name_data, item_data): + def __init__(self, id, data, name_data, item_data, special_name_data): self.id = id self.name = text.get_string(name_data, text.TEXT2).rstrip('\0') @@ -70,6 +70,8 @@ def __init__(self, id, data, name_data, item_data): self.drop_rare = item_data[2] self.drop_common = item_data[3] + self.special_name = text.get_string(special_name_data, text.TEXT2).rstrip('\0') + # copy stats for reference after modifications self.original_speed = self.speed self.original_vigor = self.vigor @@ -181,5 +183,11 @@ def item_data(self): return item_data + def special_name_data(self): + from data.enemies import Enemies + data = text.get_bytes(self.special_name, text.TEXT2) + data.extend([0xff] * (Enemies.SPECIAL_NAMES_SIZE - len(data))) + return data + def print(self): print(f"{self.id} {self.name}") diff --git a/data/enemy_formations.py b/data/enemy_formations.py index 22765e34..24e6fa64 100644 --- a/data/enemy_formations.py +++ b/data/enemy_formations.py @@ -11,10 +11,13 @@ class EnemyFormations(): ENEMIES_END = 0xf83bf ENEMIES_SIZE = 15 - PHUNBABA3 = 422 - DOOM_GAZE = 463 + PHUNBABA3 = bosses.name_formation["Phunbaba 3"] + DOOM_GAZE = bosses.name_formation["Doom Gaze"] + ALL_STATUES = list(bosses.statue_formation_name) + ALL_DRAGONS = list(bosses.dragon_formation_name) PRESENTER = 433 COLISEUM = 575 + CHUPON = 563 # Otherwise unused formation -- we'll use it for the random_encounters_chupon flag. def __init__(self, rom, args, enemies): self.rom = rom @@ -105,6 +108,18 @@ def set_chadarnook_position_left_screen(self): self.formations[456].enemy_x_positions[0] = 1 # painting self.formations[456].enemy_x_positions[1] = 1 # demon + def add_chupon(self): + # Add Chupon (Coliseum) to an unused formation for use with random_encounters_chupon + self.formations[self.CHUPON].enemy_ids[0] = 64 # Chupon (Coliseum) + self.formations[self.CHUPON].enemy_slots = 1 + self.formations[self.CHUPON].not_on_veldt = 1 + self.formations[self.CHUPON].disable_back_attack = 1 + self.formations[self.CHUPON].disable_pincer_attack = 1 + self.formations[self.CHUPON].disable_side_attack = 1 + self.formations[self.CHUPON].enemy_y_positions[0] = 5 + self.formations[self.CHUPON].enemy_x_positions[0] = 6 + self.formations[self.CHUPON].mold = 6 << 4 + def write(self): for formation_index in range(len(self.formations)): self.flags_data[formation_index] = self.formations[formation_index].flags_data() @@ -131,6 +146,9 @@ def mod(self): # but it looks better than having chadarnook's left edge showing on all the other battle backgrounds self.set_chadarnook_position_left_screen() + if self.args.random_encounters_chupon: + self.add_chupon() + def print_scripts(self): for formation_index, formation in enumerate(self.formations): if formation.enable_event_script: diff --git a/data/enemy_packs.py b/data/enemy_packs.py index 5aa64871..ba17953d 100644 --- a/data/enemy_packs.py +++ b/data/enemy_packs.py @@ -15,8 +15,9 @@ class EnemyPacks(): ZONE_EATER = 32 VELDT = 255 # placeholder for veldt in wob/wor - PHUNBABA3 = 386 - DOOM_GAZE = 349 + + PHUNBABA3 = bosses.name_pack["Phunbaba 3"] + DOOM_GAZE = bosses.name_pack["Doom Gaze"] def __init__(self, rom, args, formations): self.rom = rom @@ -35,18 +36,80 @@ def __init__(self, rom, args, formations): pack = EnemyPack2(pack2_index, self.pack2_data[pack2_index]) self.packs.append(pack) + # Returns the list of all boss packs that can be used during randomization def _replaceable_bosses(self): - replaceable = list(bosses.normal_pack_name) + dragon_packs = list(bosses.dragon_pack_name) + statue_packs = list(bosses.statue_pack_name) + boss_packs = list(bosses.normal_pack_name) + replaceable = [boss for boss in boss_packs if boss not in statue_packs and boss not in dragon_packs] + if not self.args.shuffle_random_phunbaba3: - replaceable.remove(self.PHUNBABA3) self.event_boss_replacements[self.PHUNBABA3] = self.PHUNBABA3 + if self.PHUNBABA3 in replaceable: + replaceable.remove(self.PHUNBABA3) + if not self.args.doom_gaze_no_escape: # if doom gaze can escape, don't shuffle/randomize him # possibly having multiple doom gazes while trying to keep track of hp is awkward # how would that work with him being in his original spot and the others? How to know when to get bahamut esper? - replaceable.remove(self.DOOM_GAZE) self.event_boss_replacements[self.DOOM_GAZE] = self.DOOM_GAZE - return replaceable + + if self.DOOM_GAZE in replaceable: + replaceable.remove(self.DOOM_GAZE) + + return replaceable + self._replaceable_dragons() + self._replaceable_statues() + + # Statue locations that become available for the general boss pool + def _replaceable_statues(self): + import random + statues = list(bosses.statue_pack_name) + random.shuffle(statues) + return statues if self.args.statue_boss_location == bosses.BossLocations.MIX else [] + + # Dragon locations that become available for the general boss pool + def _replaceable_dragons(self): + import random + statues = list(bosses.dragon_pack_name) + random.shuffle(statues) + return statues if self.args.dragon_boss_location == bosses.BossLocations.MIX else [] + + # As MIX is handled in the shuffle/random functions, this is for handling the other options + def _handle_original_shuffle_statues(self): + statues = list(bosses.statue_pack_name) + + if self.args.statue_boss_location == bosses.BossLocations.ORIGINAL: + for statue in statues: + self.event_boss_replacements[statue] = statue + elif self.args.statue_boss_location == bosses.BossLocations.SHUFFLE: + import random + replacements = statues.copy() + random.shuffle(statues) + random.shuffle(replacements) + + for statue in statues: + self.event_boss_replacements[replacements.pop()] = statue + else: + # boss assignment is handled in the shuffle/random functions + pass + + + # As MIX is handled in the shuffle/random functions, this is for handling the other options + def _handle_original_shuffle_dragons(self): + dragons = list(bosses.dragon_pack_name) + if self.args.dragon_boss_location == bosses.BossLocations.ORIGINAL: + for dragon in dragons: + self.event_boss_replacements[dragon] = dragon + elif self.args.dragon_boss_location == bosses.BossLocations.SHUFFLE: + import random + replacements = dragons.copy() + random.shuffle(dragons) + random.shuffle(replacements) + + for dragon in dragons: + self.event_boss_replacements[replacements.pop()] = dragon + else: + # boss assignment is handled in the shuffle/random functions + pass def phunbaba3_safety_check(self, bosses_possible): import random @@ -72,40 +135,20 @@ def phunbaba3_safety_check(self, bosses_possible): def shuffle_event_bosses(self): import random - self.event_boss_replacements = {} - - if self.args.mix_bosses_dragons: - bosses_dragons_to_replace = self._replaceable_bosses() + list(bosses.dragon_pack_name) - bosses_dragons_possible = bosses_dragons_to_replace.copy() - - random.shuffle(bosses_dragons_possible) - for index, boss in enumerate(bosses_dragons_to_replace): - self.event_boss_replacements[boss] = bosses_dragons_possible[index] - self.phunbaba3_safety_check(bosses_dragons_to_replace) - else: - bosses_to_replace = self._replaceable_bosses() - bosses_possible = bosses_to_replace.copy() - - random.shuffle(bosses_possible) - for index, boss in enumerate(bosses_to_replace): - self.event_boss_replacements[boss] = bosses_possible[index] + bosses_to_replace = self._replaceable_bosses() + bosses_possible = bosses_to_replace.copy() - dragons_to_replace = list(bosses.dragon_pack_name) - dragons_possible = dragons_to_replace.copy() + random.shuffle(bosses_possible) + for index, boss in enumerate(bosses_to_replace): + self.event_boss_replacements[boss] = bosses_possible[index] - random.shuffle(dragons_possible) - for index, dragon in enumerate(dragons_to_replace): - self.event_boss_replacements[dragon] = dragons_possible[index] - - self.phunbaba3_safety_check(bosses_to_replace) + self.phunbaba3_safety_check(bosses_to_replace) def randomize_event_bosses(self): import args, random, objectives from constants.objectives.conditions import names as possible_condition_names - self.event_boss_replacements = {} - boss_condition_name = "Boss" dragon_condition_name = "Dragon" dragons_condition_name = "Dragons" @@ -115,13 +158,19 @@ def randomize_event_bosses(self): required_boss_formations = set() required_dragon_formations = set() + required_statue_formations = set() + min_dragon_formations = 0 for objective in objectives: for condition in objective.conditions: if condition.NAME == boss_condition_name: - required_boss_formations.add(bosses.name_formation[condition.boss_name()]) + formation = condition.boss_formation + if formation in list(bosses.statue_formation_name): + required_statue_formations.add(formation) + else: + required_boss_formations.add(formation) elif condition.NAME == dragon_condition_name: - required_dragon_formations.add(bosses.name_formation[condition.dragon_name()]) + required_dragon_formations.add(condition.dragon_formation) elif condition.NAME == dragons_condition_name and condition.count > min_dragon_formations: min_dragon_formations = condition.count @@ -133,11 +182,14 @@ def randomize_event_bosses(self): required_dragon_formations |= set(random_dragon_formations) required_boss_packs = set() + required_statue_packs = set() for pack_id, pack_name in bosses.normal_pack_name.items(): formations = self.get_formations(pack_id) for formation_id in formations: if formation_id in required_boss_formations: required_boss_packs.add(pack_id) + elif formation_id in required_statue_formations: + required_statue_packs.add(pack_id) required_dragon_packs = set() for pack_id, pack_name in bosses.dragon_pack_name.items(): @@ -146,56 +198,47 @@ def randomize_event_bosses(self): if formation_id in required_dragon_formations: required_dragon_packs.add(pack_id) - if self.args.mix_bosses_dragons: - bosses_dragons_to_replace = self._replaceable_bosses() + list(bosses.dragon_pack_name) - random.shuffle(bosses_dragons_to_replace) - - for pack in required_boss_packs: - self.event_boss_replacements[bosses_dragons_to_replace.pop()] = pack - - for pack in required_dragon_packs: - self.event_boss_replacements[bosses_dragons_to_replace.pop()] = pack - - # guarantee 8 dragons - dragons_possible = list(bosses.dragon_pack_name) - for index in range(len(required_dragon_packs), len(bosses.dragon_pack_name)): - self.event_boss_replacements[bosses_dragons_to_replace.pop()] = random.choice(dragons_possible) - bosses_possible = self._replaceable_bosses() - for boss in bosses_dragons_to_replace: - self.event_boss_replacements[boss] = random.choice(bosses_possible) + # randomizing and shuffling + bosses_to_replace = self._replaceable_bosses() + random.shuffle(bosses_to_replace) + for pack in required_boss_packs: + self.event_boss_replacements[bosses_to_replace.pop()] = pack - self.phunbaba3_safety_check(bosses_possible + dragons_possible) - else: - bosses_to_replace = self._replaceable_bosses() - random.shuffle(bosses_to_replace) - for pack in required_boss_packs: + # If statue locations are not mixed, they will always + if self.args.statue_boss_location == bosses.BossLocations.MIX: + for pack in required_statue_packs: self.event_boss_replacements[bosses_to_replace.pop()] = pack - bosses_possible = self._replaceable_bosses() - for boss in bosses_to_replace: - self.event_boss_replacements[boss] = random.choice(bosses_possible) - - dragons_to_replace = list(bosses.dragon_pack_name) - random.shuffle(dragons_to_replace) + if self.args.dragon_boss_location == bosses.BossLocations.MIX: for pack in required_dragon_packs: - self.event_boss_replacements[dragons_to_replace.pop()] = pack + self.event_boss_replacements[bosses_to_replace.pop()] = pack - dragons_possible = list(bosses.dragon_pack_name) - for dragon in dragons_to_replace: - self.event_boss_replacements[dragon] = random.choice(dragons_possible) + random.shuffle(bosses_to_replace) + bosses_possible = self._replaceable_bosses() + for boss in bosses_to_replace: + self.event_boss_replacements[boss] = random.choice(bosses_possible) - self.phunbaba3_safety_check(bosses_possible) + self.phunbaba3_safety_check(bosses_possible) def randomize_packs(self, packs, boss_percent, no_phunbaba3 = False): - exclude_bosses = None + exclude_bosses = [] if no_phunbaba3 or not self.args.shuffle_random_phunbaba3: - exclude_bosses = [self.formations.PHUNBABA3] + exclude_bosses += [self.formations.PHUNBABA3] if not self.args.doom_gaze_no_escape: - if exclude_bosses is None: - exclude_bosses = [self.formations.DOOM_GAZE] - else: - exclude_bosses.append(self.formations.DOOM_GAZE) + exclude_bosses += [self.formations.DOOM_GAZE] + + # We only want statues and dragons to show up when they are intentionally + # mixed into the general boss pool + # Statues are currently seen as normal bosses in regards to scaling, + # but the long-term goal is to add their own scaling option so it + # makes most sense to begin treating these similarly to dragons. + if self.args.statue_boss_location != bosses.BossLocations.MIX: + exclude_bosses += self.formations.ALL_STATUES + + # This is more futureproofing in the event we consolidate dragons in the future + if self.args.dragon_boss_location != bosses.BossLocations.MIX: + exclude_bosses += self.formations.ALL_DRAGONS import random for pack_id in packs: @@ -206,6 +249,12 @@ def randomize_packs(self, packs, boss_percent, no_phunbaba3 = False): for formation_index in range(self.packs[pack_id].FORMATION_COUNT): self.packs[pack_id].formations[formation_index] = self.formations.get_random_normal() + def chupon_packs(self, packs): + # Replace all packs with the CHUPON formation + for pack_id in packs: + for formation_index in range(self.packs[pack_id].FORMATION_COUNT): + self.packs[pack_id].formations[formation_index] = self.formations.CHUPON + def randomize_fixed(self): lete_river = [263, 264] # nautiloid, exocite, pterodon imperial_camp = [272, 298, 300, 269, 270] # soldier, dogs, templar/soldier, final 3 battles @@ -290,7 +339,7 @@ def has_enemy(self, pack_id, enemy_id): def get_event_boss_replacement(self, original_boss_name): original_boss_id = self.get_id(original_boss_name) - if self.event_boss_replacements is None: + if not original_boss_id in self.event_boss_replacements: return original_boss_id return self.event_boss_replacements[original_boss_id] @@ -302,12 +351,18 @@ def remove_extra_formations(self): pack.extra_formations[formation_index] = False def mod(self): + self.event_boss_replacements = { + self.DOOM_GAZE: self.DOOM_GAZE, + self.PHUNBABA3: self.PHUNBABA3 + } + if self.args.boss_battles_shuffle: self.shuffle_event_bosses() elif self.args.boss_battles_random: self.randomize_event_bosses() - else: - self.event_boss_replacements = None + + self._handle_original_shuffle_dragons() + self._handle_original_shuffle_statues() if not self.args.fixed_encounters_original: self.randomize_fixed() diff --git a/data/enemy_scripts.py b/data/enemy_scripts.py index ad802020..0655e70a 100644 --- a/data/enemy_scripts.py +++ b/data/enemy_scripts.py @@ -257,6 +257,12 @@ def magic_urn_no_life(self): ] magic_urn_script.remove(life) + def chupon_sneeze_all(self): + # Make Chupon 64 (Coliseum) target all allies with initial sneeze + chupon_id = 64 + chupon_script = self.scripts[chupon_id] + chupon_script.insert(0, ai_instr.SetTarget(0x43)) # Target: Allies + def mod(self): # first free up some space for other mods self.cleanup_mod() @@ -298,6 +304,9 @@ def mod(self): self.hidon_no_chokesmoke() self.magic_urn_no_life() + if self.args.random_encounters_chupon: + self.chupon_sneeze_all() + if self.args.ability_scaling: self.enemy_script_abilities.scale_abilities_mod() diff --git a/data/esper.py b/data/esper.py index 401db6d4..96d9f62a 100644 --- a/data/esper.py +++ b/data/esper.py @@ -92,6 +92,23 @@ def remove_spell(self, spell): for spell_index in range(spells_removed): self.spells.append(self.SpellEntry(self.NO_SPELL, 0)) + def get_spell_ids(self): + return [spell.id for spell in self.spells if spell.id != self.NO_SPELL] + + def replace_spell(self, old_spell, new_spell): + if(old_spell == self.NO_SPELL): + return + + # get the old learn rate to reuse it + learn_rate = self.LEARN_RATES[0] + for spell_index in range(self.SPELL_COUNT): + if self.spells[spell_index].id == old_spell: + learn_rate = self.spells[spell_index].rate + + self.remove_spell(old_spell) + if new_spell is not None: + self.add_spell(new_spell, learn_rate) + def clear_spells(self): for spell_index in range(self.SPELL_COUNT): self.spells[spell_index].id = self.NO_SPELL diff --git a/data/espers.py b/data/espers.py index 8d41a950..0b595398 100644 --- a/data/espers.py +++ b/data/espers.py @@ -45,6 +45,11 @@ def __init__(self, rom, args, spells, characters): self.espers.append(esper) self.available_espers = set(range(self.ESPER_COUNT)) + self.starting_espers = [] + + if args.starting_espers_min > 0: + count = random.randint(args.starting_espers_min, args.starting_espers_max) + self.starting_espers = [self.get_random_esper() for _esp in range(count)] def receive_dialogs_mod(self, dialogs): self.receive_dialogs = [1133, 1380, 1381, 1134, 1535, 1082, 1091, 1092, 1136, 1534, 2618, 1093, 1087,\ @@ -141,23 +146,23 @@ def get_spell(): learn_rate = Esper.LEARN_RATES[learn_rate_index] esper.add_spell(get_spell(), learn_rate) - def remove_all_ultima(self): - ultima_id = self.spells.get_id("Ultima") - for esper in self.espers: - if esper.has_spell(ultima_id): - esper.remove_spell(ultima_id) + def remove_flagged_learnables(self): + for a_spell_id in self.args.remove_learnable_spell_ids: + for esper in self.espers: + if(esper.has_spell(a_spell_id)): + esper.remove_spell(a_spell_id) - def remove_all_life(self): - life_id = self.spells.get_id("Life") - life2_id = self.spells.get_id("Life 2") - life3_id = self.spells.get_id("Life 3") + def replace_flagged_learnables(self): for esper in self.espers: - if esper.has_spell(life_id): - esper.remove_spell(life_id) - if esper.has_spell(life2_id): - esper.remove_spell(life2_id) - if esper.has_spell(life3_id): - esper.remove_spell(life3_id) + for a_spell_id in self.args.remove_learnable_spell_ids: + if(esper.has_spell(a_spell_id)): + # Also exclude spells this Esper already knows, to avoid duplicates + exclude_spell_ids = self.args.remove_learnable_spell_ids.copy() + exclude_spell_ids.extend(esper.get_spell_ids()) + + new_spell_id = self.spells.get_replacement(a_spell_id, exclude = exclude_spell_ids) + esper.replace_spell(a_spell_id, new_spell_id) + def clear_spells(self): for esper in self.espers: @@ -202,7 +207,7 @@ def random_mp_percent(self): mp_percent = random.randint(self.args.esper_mp_random_percent_min, self.args.esper_mp_random_percent_max) / 100.0 value = int(esper.mp * mp_percent) - esper.mp = max(min(value, 255), 1) + esper.mp = max(min(value, 254), 1) def equipable_random(self): from data.characters import Characters @@ -271,6 +276,9 @@ def mod(self, dialogs): if self.args.esper_spells_random_rates or self.args.esper_spells_shuffle_random_rates: self.randomize_rates() + if len(self.starting_espers): + self.randomize_rates() + if self.args.esper_spells_shuffle or self.args.esper_spells_shuffle_random_rates: self.shuffle_spells() elif self.args.esper_spells_random: @@ -278,9 +286,6 @@ def mod(self, dialogs): elif self.args.esper_spells_random_tiered: self.randomize_spells_tiered() - if self.args.no_ultima: - self.remove_all_ultima() - if self.args.esper_bonuses_shuffle: self.shuffle_bonuses() elif self.args.esper_bonuses_random: @@ -300,9 +305,15 @@ def mod(self, dialogs): espers_asm.equipable_mod(self) if self.args.permadeath: - self.remove_all_life() self.phoenix_life3() + if self.args.esper_spells_random or self.args.esper_spells_random_tiered: + # if random, replace the spells + self.replace_flagged_learnables() + else: + # otherwise (original or shuffled), remove them + self.remove_flagged_learnables() + if self.args.esper_multi_summon: self.multi_summon() @@ -345,8 +356,10 @@ def log(self): for entry_index in range(self.ESPER_COUNT): esper_index = self.esper_menu_order[entry_index] esper = self.espers[esper_index] + prefix = "*" if esper.id in self.starting_espers else "" + + entry = [f"{prefix}{esper.get_name():<{self.NAME_SIZE}} {esper.mp:>3} MP"] - entry = [f"{esper.get_name():<{self.NAME_SIZE}} {esper.mp:>3} MP"] for spell_index in range(esper.spell_count): spell_name = self.spells.get_name(esper.spells[spell_index].id) learn_rate = esper.spells[spell_index].rate @@ -376,6 +389,9 @@ def log(self): else: lentries.append(entry) + lentries.append("") + lentries.append("* = Starting Esper") + section_entries("Espers", lentries, rentries) def print(self): diff --git a/data/event_bit.py b/data/event_bit.py index b8775c9d..3fab5490 100644 --- a/data/event_bit.py +++ b/data/event_bit.py @@ -1,5 +1,5 @@ # NOTE: (address - 1e80) * 0x8 + bit -# e.g. (1eb7 - 1e80) * 0x8 + 0x1 = 1b9 (airship visible) +# e.g. (1eb7 - 1e80) * 0x8 + 0x1 = 1b9 (airship visible) # (1f43 - 1e80) * 0x8 + 0x3 = 61b (characters on narshe battlefield) DISABLE_SAVE_POINT_TUTORIAL = 0x133 @@ -182,6 +182,13 @@ DEFEATED_PHOENIX_CAVE_DRAGON = 0x120 # custom DEFEATED_FANATICS_TOWER_DRAGON = 0x121 # custom +# KT Battles +DEFEATED_GUARDIAN = 0x0bc +DEFEATED_INFERNO = 0x0bd +DEFEATED_DOOM = 0x072 +DEFEATED_GODDESS = 0x073 +DEFEATED_POLTERGEIST = 0x074 + LEFT_WEIGHT_PUSHED_KEFKA_TOWER = 0x063 RIGHT_WEIGHT_PUSHED_KEFKA_TOWER = 0x064 WEST_PATH_BLOCKED_KEFKA_TOWER = 0x065 # path to doom in switch room diff --git a/data/item.py b/data/item.py index d17db58e..578f4048 100644 --- a/data/item.py +++ b/data/item.py @@ -42,6 +42,10 @@ def remove_learnable_spell(self): self.learnable_spell = 0 self.learnable_spell_rate = 0 + def scale_price(self, factor): + self.price = int(self.price * factor) + self.price = max(min(self.price, 2**16 - 1), 0) + def read(self): name_bytes = self.rom.get_bytes(self.name_addr, self.NAME_LENGTH) self.icon = value_text[name_bytes[0]] diff --git a/data/items.py b/data/items.py index d3e0f5db..a03afa2f 100644 --- a/data/items.py +++ b/data/items.py @@ -155,6 +155,17 @@ def random_prices_percent(self): value = int(item.price * price_percent) item.price = max(min(value, 2**16 - 1), 0) + def expensive_breakable_rods(self): + self.items[name_id["Poison Rod"]].scale_price(3) + self.items[name_id["Fire Rod"]].scale_price(4) + self.items[name_id["Ice Rod"]].scale_price(4) + self.items[name_id["Thunder Rod"]].scale_price(4) + self.items[name_id["Gravity Rod"]].scale_price(1.2) + self.items[name_id["Pearl Rod"]].scale_price(1.2) + + def expensive_super_balls(self): + self.items[name_id["Super Ball"]].scale_price(2) + def assign_values(self): from data.item_custom_values import custom_values for item in self.items: @@ -198,14 +209,19 @@ def mod(self): if self.args.no_priceless_items: self.assign_values() + if self.args.shops_expensive_breakable_rods: + self.expensive_breakable_rods() + + if self.args.shops_expensive_super_balls: + self.expensive_super_balls() + if self.args.shop_prices_random_value: self.random_prices_value() elif self.args.shop_prices_random_percent: self.random_prices_percent() - if self.args.no_ultima: - from data.spell_names import name_id as spell_name_id - self.remove_learnable_spell(spell_name_id["Ultima"]) + for a_spell_id in self.args.remove_learnable_spell_ids: + self.remove_learnable_spell(a_spell_id) if self.args.cursed_shield_battles_original: self.cursed_shield_battles = 256 @@ -281,6 +297,11 @@ def get_excluded(self): exclude.append(name_id["Exp. Egg"]) if self.args.no_illuminas: exclude.append(name_id["Illumina"]) + + from data.movement import AUTO_SPRINT, B_DASH + # Sprint Shoes are a literal dead item if any of these options + if self.args.no_sprint_shoes or self.args.movement in [AUTO_SPRINT, B_DASH]: + exclude.append(name_id["Sprint Shoes"]) if self.args.no_free_paladin_shields: exclude.append(name_id["Paladin Shld"]) exclude.append(name_id["Cursed Shld"]) diff --git a/data/lore.py b/data/lore.py index 6bd02c36..7167f626 100644 --- a/data/lore.py +++ b/data/lore.py @@ -2,11 +2,12 @@ import data.text as text class Lore(AbilityData): - def __init__(self, id, name_data, ability_data): + def __init__(self, id, name_data, ability_data, desc_data): super().__init__(id, ability_data) self.id = id self.name = text.get_string(name_data, text.TEXT2).rstrip('\0') + self.desc = text.get_string(desc_data, text.TEXT2).rstrip('\0') def name_data(self): from data.lores import Lores @@ -14,8 +15,16 @@ def name_data(self): data.extend([0xff] * (Lores.NAME_SIZE - len(data))) return data + def desc_data(self): + from data.lores import Lores + data = text.get_bytes(self.desc, text.TEXT2) + return data + def get_name(self): return self.name.strip('\0') + def get_desc(self): + return self.desc.strip('\0') + def print(self): print(f"{self.id} {self.get_name()}") diff --git a/data/lores.py b/data/lores.py index 842bbc7c..a9054f49 100644 --- a/data/lores.py +++ b/data/lores.py @@ -1,14 +1,16 @@ from data.lore import Lore from data.ability_data import AbilityData -from data.structures import DataBits, DataArray +from data.structures import DataBits, DataArray, DataList -from memory.space import Bank, Reserve, Allocate, Write +from memory.space import Bank, Reserve, Allocate, Write, Space import instruction.asm as asm class Lores: LORE_COUNT = 24 CONDEMNED, ROULETTE, CLEAN_SWEEP, AQUA_RAKE, AERO, BLOW_FISH, BIG_GUARD, REVENGE, PEARL_WIND, L_5_DOOM, L_4_FLARE, L_3_MUDDLE, REFLECT, L_PEARL, STEP_MINE, FORCE_FIELD, DISCHORD, SOUR_MOUTH, PEP_UP, RIPPLER, STONE, QUASAR, GRAND_TRAIN, EXPLODER = range(LORE_COUNT) + DIALOG_OFFSET = 139 # starting offset for battle dialog + INITIAL_LORES_START = 0x26f564 INITIAL_LORES_END = 0x26f566 @@ -16,6 +18,12 @@ class Lores: NAMES_END = 0x26fb65 NAME_SIZE = 10 + DESC_PTRS_START = 0x2d7a70 + DESC_PTRS_END = 0x2d7a9f + + DESC_START = 0x2d77a0 + DESC_END = 0x2d7a6f + ABILITY_DATA_START = 0x04725a ABILITY_DATA_END = 0x0473a9 @@ -28,9 +36,13 @@ def __init__(self, rom, args, characters): self.name_data = DataArray(self.rom, self.NAMES_START, self.NAMES_END, self.NAME_SIZE) self.ability_data = DataArray(self.rom, self.ABILITY_DATA_START, self.ABILITY_DATA_END, AbilityData.DATA_SIZE) + self.desc_data = DataList(Space.rom, self.DESC_PTRS_START, self.DESC_PTRS_END, + Space.rom.SHORT_PTR_SIZE, self.DESC_START, + self.DESC_START, self.DESC_END) + self.lores = [] for lore_index in range(len(self.ability_data)): - lore = Lore(lore_index, self.name_data[lore_index], self.ability_data[lore_index]) + lore = Lore(lore_index, self.name_data[lore_index], self.ability_data[lore_index], self.desc_data[lore_index]) self.lores.append(lore) def write_learners_table(self): @@ -158,9 +170,50 @@ def random_mp_percent(self): mp_percent = random.randint(self.args.lores_mp_random_percent_min, self.args.lores_mp_random_percent_max) / 100.0 value = int(lore.mp * mp_percent) - lore.mp = max(min(value, 255), 0) + lore.mp = max(min(value, 254), 0) + + def _get_new_level_desc(lore_index, level_divisor): + level_string = f'LV{level_divisor}' # keeping it simple to not use extra space + new_desc = '' + if(lore_index == Lores.L_5_DOOM): + new_desc = f'Casts Doom" on {level_string} enemy' + elif(lore_index == Lores.L_4_FLARE): + new_desc = f'Casts Flare" on {level_string} enemy' + elif(lore_index == Lores.L_3_MUDDLE): + new_desc = f'Casts Muddle" on {level_string} enemy' + elif(lore_index == Lores.L_PEARL): + new_desc = f'Pearl attack on {level_string} enemy' + else: + raise ValueError(f'Unexpected lore index: {lore_index}') + return new_desc - def mod(self): + def random_lx_levels(self, dialogs): + import random, re + LX_LORE_IDX = [Lores.L_5_DOOM, Lores.L_4_FLARE, Lores.L_3_MUDDLE, Lores.L_PEARL] + LQ_EFFECT = 29 # the AbilityData.effect setting for L? + NO_EFFECT = 255 # The AbilityData.effect setting for no effect + MAX_DIVISOR = 5 + + for lore_index in LX_LORE_IDX: + lore = self.lores[lore_index] + + level_divisor = random.randint(0, MAX_DIVISOR) + if lore_index == Lores.L_5_DOOM: + # prevent soft-locks with bosses by removing unmissable doom + level_divisor = random.randint(2, MAX_DIVISOR) + + lore.accuracy = level_divisor + if level_divisor: # non-zero + lore.effect = NO_EFFECT + else: # zero - use for L? + level_divisor = '?' + lore.effect = LQ_EFFECT + lore.name = re.sub('L.*[?1-9]', f'L.{level_divisor}', lore.name) + lore.desc = Lores._get_new_level_desc(lore_index, level_divisor) + battle_message = re.sub('', '“', lore.desc) + dialogs.set_battle_message_text(self.DIALOG_OFFSET + lore_index, battle_message) + + def mod(self, dialogs): self.write_learners_table() self.write_is_learner() self.after_battle_check_mod() @@ -175,6 +228,9 @@ def mod(self): elif self.args.lores_mp_random_percent: self.random_mp_percent() + if self.args.lores_level_randomize: + self.random_lx_levels(dialogs) + def write(self): if self.args.spoiler_log: self.log() @@ -182,10 +238,12 @@ def write(self): for lore_index, lore in enumerate(self.lores): self.name_data[lore_index] = lore.name_data() self.ability_data[lore_index] = lore.ability_data() + self.desc_data[lore_index] = lore.desc_data() self.init_data.write() self.name_data.write() self.ability_data.write() + self.desc_data.write() def log(self): from log import section diff --git a/data/magiteks.py b/data/magiteks.py index 6fcbfcd8..1dadccec 100644 --- a/data/magiteks.py +++ b/data/magiteks.py @@ -1,13 +1,12 @@ from data.magitek import Magitek from data.ability_data import AbilityData -from data.structures import DataBits, DataArray +from data.structures import DataArray from memory.space import Bank, Reserve, Allocate, Write class Magiteks: MAGITEK_COUNT = 8 FIRE_BEAM, BOLT_BEAM, ICE_BEAM, BIO_BLAST, HEAL_FORCE, CONFUSER, X_FER, TEKMISSILE = range(MAGITEK_COUNT) - DISABLED_MAGITEK = 0xFF NAMES_START = 0x26f9ad NAMES_END = 0x26f9fc @@ -16,21 +15,12 @@ class Magiteks: ABILITY_DATA_START = 0x0471ea ABILITY_DATA_END = 0x047259 - # These 8 bytes control the Magitek that Terra can use - TERRA_MAGITEK_ATTACKS_START = 0x1910C - TERRA_MAGITEK_ATTACKS_END = 0x19113 - # These 8 bytes control the Magitek that other characters can use - OTHER_CHAR_MAGITEK_ATTACKS_START = 0x19114 - OTHER_CHAR_MAGITEK_ATTACKS_END = 0x1911B - MAGITEK_ATTACKS_SIZE = 1 - def __init__(self, rom, args): self.rom = rom self.args = args self.name_data = DataArray(self.rom, self.NAMES_START, self.NAMES_END, self.NAME_SIZE) self.ability_data = DataArray(self.rom, self.ABILITY_DATA_START, self.ABILITY_DATA_END, AbilityData.DATA_SIZE) - self.other_char_magitek_attacks = DataArray(self.rom, self.OTHER_CHAR_MAGITEK_ATTACKS_START, self.OTHER_CHAR_MAGITEK_ATTACKS_END, self.MAGITEK_ATTACKS_SIZE) self.magiteks = [] for magitek_index in range(len(self.ability_data)): @@ -43,18 +33,8 @@ def fix_reflectable_beams(self): self.magiteks[self.BOLT_BEAM].flags2 = 0x22 self.magiteks[self.ICE_BEAM].flags2 = 0x22 - def give_all_magiteks(self): - # Give all magitek abilities to every character, not just Terra - # FF = disabled - # 0 = Fire Beam, ... 7 = TekMissile - self.other_char_magitek_attacks[self.BIO_BLAST] = self.BIO_BLAST.to_bytes(1, 'little') - self.other_char_magitek_attacks[self.CONFUSER] = self.CONFUSER.to_bytes(1, 'little') - self.other_char_magitek_attacks[self.X_FER] = self.X_FER.to_bytes(1, 'little') - self.other_char_magitek_attacks[self.TEKMISSILE] = self.TEKMISSILE.to_bytes(1, 'little') - def mod(self): self.fix_reflectable_beams() - self.give_all_magiteks() pass def write(self): @@ -67,7 +47,6 @@ def write(self): self.name_data.write() self.ability_data.write() - self.other_char_magitek_attacks.write() def log(self): pass diff --git a/data/maps.py b/data/maps.py index 8c63f69f..064797a1 100644 --- a/data/maps.py +++ b/data/maps.py @@ -12,6 +12,7 @@ from data.map_exit import ShortMapExit, LongMapExit import data.world_map_event_modifications as world_map_event_modifications +from data.world_map import WorldMap class Maps(): MAP_COUNT = 416 @@ -33,6 +34,7 @@ def __init__(self, rom, args, items): self.events = events.MapEvents(rom) self.exits = exits.MapExits(rom) self.world_map_event_modifications = world_map_event_modifications.WorldMapEventModifications(rom) + self.world_map = WorldMap(rom, args) self.read() def read(self): @@ -189,6 +191,7 @@ def _fix_imperial_camp_boxes(self): def mod(self, characters): self.npcs.mod(characters) self.chests.mod() + self.world_map.mod() self._fix_imperial_camp_boxes() diff --git a/data/movement.py b/data/movement.py new file mode 100644 index 00000000..b8c52601 --- /dev/null +++ b/data/movement.py @@ -0,0 +1,34 @@ + +from enum import IntEnum + + +class MovementSpeed(IntEnum): + WALK = 2 # Original FF6 walk speed + SPRINT = 3 # Original FF6 sprint speed + DASH = 4 # Custom, move twice as fast as sprint. + +ORIGINAL = 'og' +AUTO_SPRINT = 'as' +B_DASH = 'bd' +SPRINT_SHOES_B_DASH = 'ssbd' + +name_key = { + # WALK by default + # SPRINT with sprint shoes equipped + 'ORIGINAL' : ORIGINAL, + # SPRINT by default + # WALK when holding B + 'AUTO_SPRINT' : AUTO_SPRINT, + # SPRINT by default + # DASH when holding B + 'B-DASH' : B_DASH, + # SPRINT by default + # DASH when holding B with sprint shoes equipped + # WALK when holding B without sprint shoes equipped + 'SPR SHOE B-DASH' : SPRINT_SHOES_B_DASH, +} + +key_name = {v: k for k, v in name_key.items()} + +ALL = [ORIGINAL, AUTO_SPRINT, B_DASH, SPRINT_SHOES_B_DASH] + diff --git a/data/natural_magic.py b/data/natural_magic.py index 15d65007..17a2a48f 100644 --- a/data/natural_magic.py +++ b/data/natural_magic.py @@ -146,43 +146,29 @@ def mod_learners(self): self.after_battle_check_mod() def randomize_spells1(self): - exclude = [] - if self.args.no_ultima: - exclude.append(self.spells.get_id("Ultima")) - if self.args.permadeath: - exclude.append(self.spells.get_id("Life")) - exclude.append(self.spells.get_id("Life 2")) - exclude.append(self.spells.get_id("Life 3")) - - random_spells = self.spells.get_random(count = len(self.terra_spells), exclude = exclude) + random_spells = self.spells.get_random(count = len(self.terra_spells), exclude = self.args.remove_learnable_spell_ids) for index, spell in enumerate(random_spells): self.terra_spells[index].spell = spell def randomize_spells2(self): - exclude = [] - if self.args.no_ultima: - exclude.append(self.spells.get_id("Ultima")) - if self.args.permadeath: - exclude.append(self.spells.get_id("Life")) - exclude.append(self.spells.get_id("Life 2")) - exclude.append(self.spells.get_id("Life 3")) - - random_spells = self.spells.get_random(count = len(self.celes_spells), exclude = exclude) + random_spells = self.spells.get_random(count = len(self.celes_spells), exclude = self.args.remove_learnable_spell_ids) for index, spell in enumerate(random_spells): self.celes_spells[index].spell = spell - def remove_natural_life(self): - life1_index = 4 - life2_index = 10 - self.terra_spells[life1_index].spell = 0 - self.terra_spells[life1_index].level = 0 + def remove_excluded(self): + for a_spell_id in self.args.remove_learnable_spell_ids: - self.terra_spells[life2_index].spell = 0 - self.terra_spells[life2_index].level = 0 + #linear search through Terra's spells + for terra_spell in self.terra_spells: + if terra_spell.spell == a_spell_id: + terra_spell.spell = 0xFF + terra_spell.level = 0 - def remove_natural_ultima(self): - self.terra_spells[-1].spell = 0 - self.terra_spells[-1].level = 0 + #linear search through Celes' spells + for celes_spell in self.celes_spells: + if celes_spell.spell == a_spell_id: + celes_spell.spell = 0xFF + celes_spell.level = 0 def randomize_levels1(self): import random @@ -208,13 +194,6 @@ def mod(self): self.randomize_levels1() if self.args.random_natural_spells1: self.randomize_spells1() - else: - if self.args.no_ultima: - # not randomizing spells terra normally learns but no ultima chosen so remove it - self.remove_natural_ultima() - if self.args.permadeath: - # not randomizing spells terra normally learns but permadeath chosen so remove life spells - self.remove_natural_life() if self.args.natural_magic2: if self.args.random_natural_levels2: @@ -222,6 +201,10 @@ def mod(self): if self.args.random_natural_spells2: self.randomize_spells2() + # Remove any excluded spells remaining. + # As randomize_spells respects the exclusion list, this should only have the effect of removing any excluded non-random spells. + self.remove_excluded() + def log(self): from log import section, format_option diff --git a/data/rages.py b/data/rages.py index 71795ac9..504e97a7 100644 --- a/data/rages.py +++ b/data/rages.py @@ -1,5 +1,6 @@ from data.rage import Rage from data.structures import DataBits, DataArray +from data.ability_data import AbilityData class Rages(): RAGE_COUNT = 256 # 255 available @@ -15,6 +16,9 @@ class Rages(): ATTACKS_DATA_END = 0xf47ff ATTACKS_DATA_SIZE = 2 + ABILITY_DATA_START = 0x046ac0 + ABILITY_DATA_END = 0x0478bf + def __init__(self, rom, args, enemies): self.rom = rom self.args = args @@ -22,12 +26,18 @@ def __init__(self, rom, args, enemies): self.init_data = DataBits(self.rom, self.INITIAL_RAGES_START, self.INITIAL_RAGES_END) self.attack_data = DataArray(self.rom, self.ATTACKS_DATA_START, self.ATTACKS_DATA_END, self.ATTACKS_DATA_SIZE) + self.ability_data = DataArray(self.rom, self.ABILITY_DATA_START, self.ABILITY_DATA_END, AbilityData.DATA_SIZE) self.rages = [] for rage_index in range(len(self.attack_data)): rage = Rage(rage_index, self.attack_data[rage_index]) self.rages.append(rage) + self.abilities = [] + for ability_index in range(len(self.ability_data)): + ability = AbilityData(ability_index, self.ability_data[ability_index]) + self.abilities.append(ability) + def start_random_rages(self): import random diff --git a/data/sketch.py b/data/sketch.py new file mode 100644 index 00000000..0f90018c --- /dev/null +++ b/data/sketch.py @@ -0,0 +1,18 @@ +class Sketch(): + def __init__(self, id, attack_data): + self.id = id + + self.rare = attack_data[0] + self.common = attack_data[1] + + def attack_data(self): + from data.sketches import Sketches + data = [0x00] * Sketches.ATTACKS_DATA_SIZE + + data[0] = self.rare + data[1] = self.common + + return data + + def print(self): + print(f"{self.id} {self.rare} {self.common}") diff --git a/data/sketch_custom_commands.py b/data/sketch_custom_commands.py new file mode 100644 index 00000000..ec2762ef --- /dev/null +++ b/data/sketch_custom_commands.py @@ -0,0 +1,54 @@ +from data.bosses import name_enemy +from data.spell_names import name_id + +# This dictionary contains sketch command overrides for specific enemies +# Each array is in the order of [Rare (25%), Common (75%)] +custom_commands = { + name_enemy["Vargas"] : [name_id["Gale Cut"] , name_id["Special"]], + name_enemy["TunnelArmr"] : [name_id["Tek Laser"] , name_id["Special"]], + name_enemy["GhostTrain"] : [name_id["Scar Beam"] , name_id["Special"]], + name_enemy["Dadaluma"] : [name_id["Shock Wave"], name_id["Special"]], + name_enemy["Shiva"] : [name_id["Rflect"] , name_id["Special"]], + name_enemy["Number 024"] : [name_id["Cure 2"] , name_id["Scan"]], + name_enemy["Number 128"] : [name_id["Net"] , name_id["Special"]], + name_enemy["Inferno"] : [name_id["Bolt 3"] , name_id["TekBarrier"]], + name_enemy["Left Crane"] : [name_id["TekBarrier"], name_id["Special"]], + name_enemy["Right Crane"] : [name_id["TekBarrier"], name_id["Special"]], + name_enemy["AtmaWeapon"] : [name_id["Bio"] , name_id["Special"]], + name_enemy["KatanaSoul"] : [name_id["Special"] , name_id["Shock Wave"]], + name_enemy["Red Dragon"] : [name_id["Flare"] , name_id["L? Pearl"]], # L? Pearl is a vanilla sketch + name_enemy["Blue Drgn"] : [name_id["Slow"] , name_id["Ice 3"]], # Ice 3 is a vanilla sketch + name_enemy["Skull Drgn"] : [name_id["Elf Fire"] , name_id["Rasp"]], # Rasp is a vanilla sketch + name_enemy["Gold Drgn"] : [name_id["Ice 3"] , name_id["Rflect"]], # Ice 3 is a vanilla sketch + name_enemy["Storm Drgn"] : [name_id["Aero"] , name_id["Pearl Wind"]], # vanilla sketches; just reversed. Useful for Lore. + name_enemy["Whelk"] : [name_id["Mega Volt"] , name_id["Mega Volt"]], + name_enemy["Presenter"] : [name_id["Magnitude8"], name_id["Blow Fish"]], + name_enemy["Air Force"] : [name_id["WaveCannon"], name_id["Tek Laser"]], + name_enemy["Laser Gun"] : [name_id["Diffuser"] , name_id["Tek Laser"]], + name_enemy["FlameEater"] : [name_id["Flare"] , name_id["Rflect"]], + name_enemy["Nerapa"] : [name_id["Condemned"] , name_id["Condemned"]], + name_enemy["SrBehemoth"] : [name_id["Meteo"] , name_id["Pearl"]], + name_enemy["Dullahan"] : [name_id["Pearl"] , name_id["Cure 2"]], + name_enemy["Doom Gaze"] : [name_id["Aero"] , name_id["Aero"]], + name_enemy["Curley"] : [name_id["Fire 3"] , name_id["Pearl Wind"]], + name_enemy["Larry"] : [name_id["Ice 3"] , name_id["Rflect"]], + name_enemy["Moe"] : [name_id["Bolt 3"] , name_id["Shell"]], + name_enemy["Wrexsoul"] : [name_id["Bolt 3"] , name_id["Bolt 3"]], + name_enemy["Hidon"] : [name_id["GrandTrain"], name_id["Poison"]], # Poison will be more common, and heal. May be worth it to learn GrandTrain + name_enemy["Doom"] : [name_id["Special"] , name_id["ForceField"]], + name_enemy["Goddess"] : [name_id["Quasar"] , name_id["Bolt 3"]], # Bolt 3 will be more common and heal. May be worth it to learn Quasar + name_enemy["Poltrgeist"] : [name_id["Meteo"] , name_id["Shrapnel"]], + name_enemy["Ultros 1"] : [name_id["Special"] , name_id["Tentacle"]], + name_enemy["Ultros 2"] : [name_id["Special"] , name_id["Tentacle"]], + name_enemy["Ultros 4"] : [name_id["Special"] , name_id["Tentacle"]], + name_enemy["Striker"] : [name_id["Shrapnel"] , name_id["Special"]], + name_enemy["Tritoch"] : [name_id["Rasp"] , name_id["Rasp"]], + name_enemy["Chadarnook (Demon)"] : [name_id["Flash Rain"], name_id["Flash Rain"]], + name_enemy["Kefka (Narshe)"] : [name_id["Ice 2"] , name_id["Ice 2"]], + name_enemy["Rizopas"] : [name_id["Mega Volt"] , name_id["Special"]], + name_enemy["MagiMaster"] : [name_id["Fire 3"] , name_id["Ice 3"]], # Powerful abilities, but may heal + name_enemy["Naughty"] : [name_id["Cold Dust"] , name_id["Mute"]], + name_enemy["Phunbaba 3"] : [name_id["Special"] , name_id["Blow Fish"]], + name_enemy["Phunbaba 4"] : [name_id["Special"] , name_id["Blow Fish"]], + name_enemy["Atma"] : [name_id["Flare Star"], name_id["S. Cross"]], +} \ No newline at end of file diff --git a/data/sketches.py b/data/sketches.py new file mode 100644 index 00000000..233956f2 --- /dev/null +++ b/data/sketches.py @@ -0,0 +1,102 @@ +from data.sketch import Sketch +from data.structures import DataArray +from memory.space import Reserve, Bank, Write +import instruction.asm as asm + +class Sketches(): + ATTACKS_DATA_START = 0xf4300 + ATTACKS_DATA_END = 0xf45ff + ATTACKS_DATA_SIZE = 2 + + def __init__(self, rom, args, enemies, rages): + self.rom = rom + self.args = args + self.enemies = enemies + self.rages = rages + + self.attack_data = DataArray(self.rom, self.ATTACKS_DATA_START, self.ATTACKS_DATA_END, self.ATTACKS_DATA_SIZE) + + self.sketches = [] + for sketch_index in range(len(self.attack_data)): + sketch = Sketch(sketch_index, self.attack_data[sketch_index]) + self.sketches.append(sketch) + + def enable_sketch_chances_always(self): + # Always Sketch if the target is valid + # NOPing the JSR and BCS that can prevent Sketch from working + space = Reserve(0x023b3d, 0x023b41, "sketch always", asm.NOP()) + + def enable_sketch_casters_stats(self): + # Based on https://www.ff6hacking.com/forums/thread-3478.html + + # New subroutine. Note that most of this logic is the same as vanilla logic at C2/2954. + src = [ + # A = Character using sketch (from $3417) + asm.BMI("exit"), #Branch if no Sketcher + asm.TAX(), #if there's a valid Sketcher, use their Level for attack by making X = character offset + asm.LDA(0x11A2, asm.ABS), #Spell Properties + asm.LSR(), #Check if Physical/Magical + asm.LDA(0x3B41, asm.ABS_X), #Sketcher's Mag.Pwr + asm.BCC("magical"), #Branch if not physical damage + asm.LDA(0x3B2C, asm.ABS_X), #Sketcher's Vigor * 2 + "magical", + asm.STA(0x11AE, asm.ABS), #Set Sketcher's Magic or Vigor + "exit", + asm.RTS(), + ] + space = Write(Bank.C2, src, "Sketch Caster Stats") + use_sketcher_stats_addr = space.start_address + + # Call our new subroutine + space = Reserve(0x22c25, 0x22c27, "jump to new routine") + space.write( + asm.JSR(use_sketcher_stats_addr, asm.ABS) + ) + + def enable_sketch_improved_abilities(self): + from data.spell_names import name_id + from data.sketch_custom_commands import custom_commands + + for sketch in self.sketches: + # if either is Battle, replace with opposite + if sketch.rare == name_id["Battle"]: + sketch.rare = sketch.common + if sketch.common == name_id["Battle"]: + sketch.common = sketch.rare + # If both are Battle, replace both with Rage (if it exists) + if sketch.rare == name_id["Battle"] and sketch.common == name_id["Battle"]: + if sketch.id < self.rages.RAGE_COUNT: + rage = self.rages.rages[sketch.id] + sketch.rare = rage.attack2 + sketch.common = rage.attack2 + # If both are identical, replace rare with Rage (if it exists) + if sketch.rare == sketch.common: + if sketch.id < self.rages.RAGE_COUNT: + sketch.rare = self.rages.rages[sketch.id].attack2 + # Override with custom commands + if sketch.id in custom_commands: + sketch.rare = custom_commands[sketch.id][0] + sketch.common = custom_commands[sketch.id][1] + + def mod(self): + if self.args.sketch_control_improved_stats: + self.enable_sketch_chances_always() + self.enable_sketch_casters_stats() + if self.args.sketch_control_improved_abilities: + self.enable_sketch_improved_abilities() + + def write(self): + if self.args.spoiler_log: + self.log() + + for sketch_index, sketch in enumerate(self.sketches): + self.attack_data[sketch_index] = sketch.attack_data() + + self.attack_data.write() + + def log(self): + pass + + def print(self): + for sketch in self.sketches: + sketch.print() diff --git a/data/spells.py b/data/spells.py index 281ef00a..b6071edf 100644 --- a/data/spells.py +++ b/data/spells.py @@ -47,17 +47,76 @@ def get_random(self, count = 1, exclude = None): import random possible_spell_ids = [spell.id for spell in self.spells if spell.id not in exclude] + count = min(len(possible_spell_ids), count) return random.sample(possible_spell_ids, count) + def get_replacement(self, spell_id, exclude): + ''' get a random spell from the same tier as the given spell_id ''' + import random + from data.esper_spell_tiers import tiers + + same_tier = next((tier for tier in tiers if spell_id in tier), []) + replacements = [i for i in same_tier if i not in exclude] + replacement = random.choice(replacements) if len(replacements) else None + return replacement + def no_mp_scan(self): scan_id = name_id["Scan"] self.spells[scan_id].mp = 0 + def no_mp_warp(self): + warp_id = name_id["Warp"] + self.spells[warp_id].mp = 0 + + def ultima_254_mp(self): + ultima_id = name_id["Ultima"] + self.spells[ultima_id].mp = 254 + + def shuffle_mp(self): + mp = [] + for spell in self.spells: + mp.append(spell.mp) + + import random + random.shuffle(mp) + for spell in self.spells: + spell.mp = mp.pop() + + def random_mp_value(self): + import random + for spell in self.spells: + spell.mp = random.randint(self.args.magic_mp_random_value_min, self.args.magic_mp_random_value_max) + + def random_mp_percent(self): + import random + for spell in self.spells: + mp_percent = random.randint(self.args.magic_mp_random_percent_min, + self.args.magic_mp_random_percent_max) / 100.0 + value = int(spell.mp * mp_percent) + spell.mp = max(min(value, 254), 0) + def mod(self): + if self.args.magic_mp_shuffle: + self.shuffle_mp() + elif self.args.magic_mp_random_value: + self.random_mp_value() + elif self.args.magic_mp_random_percent: + self.random_mp_percent() + + # Apply No MP Scan after any MP shuffle/rando if self.args.scan_all: self.no_mp_scan() + if self.args.warp_all: + self.no_mp_warp() + + # Apply Ultima 254 MP after any MP shuffle/rando + if self.args.ultima_254_mp: + self.ultima_254_mp() def write(self): + if self.args.spoiler_log: + self.log() + for spell_index, spell in enumerate(self.spells): self.name_data[spell_index] = spell.name_data() self.ability_data[spell_index] = spell.ability_data() @@ -65,6 +124,17 @@ def write(self): self.name_data.write() self.ability_data.write() + def log(self): + from log import section + + lcolumn = [] + for spell in self.spells: + spell_name = spell.get_name() + + lcolumn.append(f"{spell_name:<{self.NAME_SIZE}} {spell.mp:>3} MP") + + section("Spells", lcolumn, []) + def print(self): for spell in self.spells: spell.print() diff --git a/data/world_map.py b/data/world_map.py new file mode 100644 index 00000000..2107ca53 --- /dev/null +++ b/data/world_map.py @@ -0,0 +1,56 @@ +from memory.space import Reserve + +class WorldMap: + def __init__(self, rom, args): + self.rom = rom + self.args = args + + def world_minimap_high_contrast_mod(self): + # Thanks to Osteoclave for identifying these changes + + # Increases the sprite priority for the minimap sprites + # So it gets drawn on top of the overworld instead of being translucent + #ee4146=1b + space = Reserve(0x2e4146, 0x2e4146, "minimap sprite priority") + space.write(0x1b) # default: 0x0b + + # Colors bytes: gggrrrrr, xbbbbbgg + # High contrast location indicator on minimaps + # d2eeb8=ff + d2eeb9=7f + # d2efb8=ff + d2efb9=7f + location_indicator_addr = [0x12eeb8, # WoB default: 1100 + 0x12efb8] # WoR default: 1100 + for loc_addr in location_indicator_addr: + space = Reserve(loc_addr, loc_addr+1, "high contrast minimap indicator") + space.write(0xff, 0x7f) + + # d2eeba=ff + d2eebb=7f + # d2efba=ff + d2efbb=7f + location_indicator_addr = [0x12eeba, # WoB default: 1f00 + 0x12efba] # WoR default: 1f00 + for loc_addr in location_indicator_addr: + space = Reserve(loc_addr, loc_addr+1, "high contrast minimap indicator") + space.write(0xff, 0x7f) + + # Additional minimap palette mods + # default: 84 10 e7 1c 4a 29 10 42 ff 7f + # WoB: d2eea2=00 + d2eea3=14 + d2eea4=82 + d2eea5=28 + d2eea6=e4 + d2eea7=38 + d2eea8=67 + d2eea9=51 + d2eeaa=9c + d2eeab=02 + # WoR: d2efa2=00 + d2efa3=14 + d2efa4=82 + d2efa5=28 + d2efa6=e4 + d2efa7=38 + d2efa8=67 + d2efa9=51 + d2efaa=9c + d2efab=02 + minimap_palette_bytes = [0x00, 0x14, 0x82, 0x28, 0xe4, 0x38, 0x67, 0x51, 0x9c, 0x02] + minimap_palette_addr = [0x12eea2, # WoB + 0x12efa2] # WoR + for addr in minimap_palette_addr: + space = Reserve(addr, addr+len(minimap_palette_bytes)-1, "minimap palette") + space.write(minimap_palette_bytes) + + # This changes the color of the Floating Continent (pre-floating) on WoB + # default: e7 1c 4a 29 10 42 + # d2eeac=82 + d2eead=28 + d2eeae=e4 + d2eeaf=38 + d2eeb0=67 + d2eeb1=51 + addr = 0x12eeac + minimap_palette_bytes = [0x82, 0x28, 0xe4, 0x38, 0x67, 0x51] + space = Reserve(addr, addr+len(minimap_palette_bytes)-1, "floating continent palette") + space.write(minimap_palette_bytes) + + def mod(self): + if self.args.world_minimap_high_contrast: + self.world_minimap_high_contrast_mod() \ No newline at end of file diff --git a/event/airship.py b/event/airship.py index 89b74097..1d114243 100644 --- a/event/airship.py +++ b/event/airship.py @@ -19,6 +19,7 @@ def mod(self): self.unequip_party_members_npc_mod() self.inside_blackjack() self.return_to_airship() + self.fix_fly_offscreen_bug() def controls_mod(self): fly_wor_fc_cancel_dialog = 1315 @@ -342,3 +343,18 @@ def return_to_airship(self): field.ShowEntity(field_entity.PARTY0), field.RefreshEntities(), ) + + def fix_fly_offscreen_bug(self): + # ref: https://discord.com/channels/666661907628949504/666811452350398493/1025236553875857468 + # fixes the vanilla bug that can occur in which characters can fly offscreen to the bottom-right + # per Osteoclave's research, this all originates with the the H ($0871,Y)) and V ($0873,Y) + # values of (0x4D0, 0x39C) being set after changing party in WoB airship. + # CA/F5B2: C0 If ($1E80($06A) [$1E8D, bit 2] is clear), branch to $CAF5BC + # -> Replace with six [FD] (no-op) + Reserve(0xaf5b2, 0xaf5b7, "skip 06a bit clear check", field.NOP()) + # CA/F5BC: If ($1E80($06A) [$1E8D, bit 2] is set), branch to $CAF5C6 (force Locke and Celes into party) + # -> Replace with six [FD] (no-op) + # -> ref: https://discord.com/channels/666661907628949504/666811452350398493/1025271937750016020 + # CA/F5C2: Call subroutine $CAF601 + # -> Replace with four [FD] (no-op) + Reserve(0xaf5bc, 0xaf5c5, "skip force Locke/Celes into party", field.NOP()) \ No newline at end of file diff --git a/event/baren_falls.py b/event/baren_falls.py index b479b802..ebafea06 100644 --- a/event/baren_falls.py +++ b/event/baren_falls.py @@ -22,6 +22,9 @@ def mod(self): self.after_battle_mod() self.already_complete_mod() + if self.args.flashes_remove_most: + self.background_scrolling_mod() + if self.reward.type == RewardType.CHARACTER: self.character_mod(self.reward.id) elif self.reward.type == RewardType.ESPER: @@ -160,3 +163,16 @@ def item_mod(self, item): field.AddItem(item), field.Dialog(self.items.get_receive_dialog(item)), ]) + + def background_scrolling_mod(self): + # Slow the scrolling background by modifying the ADC command. + space = Reserve(0x2b1f7, 0x2b1f9, "waterfall background movement") + space.write( + asm.ADC(0x0001, asm.IMM16) #default: 0x0006 + ) + + # Eliminate the palette swaps without reducing any cpu cycles by just writing back the value from the previous LDA + space = Reserve(0x2b20b, 0x2b20d, "waterfall palette change") + space.write( + asm.STA(0xEC71, asm.ABS_X) + ) diff --git a/event/collapsing_house.py b/event/collapsing_house.py index 75cf0068..fbb22ecf 100644 --- a/event/collapsing_house.py +++ b/event/collapsing_house.py @@ -22,7 +22,7 @@ def mod(self): if self.args.character_gating: self.add_gating_condition() - if self.args.flashes_remove_most: + if self.args.flashes_remove_most or self.args.flashes_remove_worst: self.flash_mod() if self.reward.type == RewardType.CHARACTER: @@ -65,11 +65,11 @@ def dialogs_mod(self): space = Reserve(0xc5a79, 0xc5a7c, "collapsing house smash kefka dialog", field.NOP()) def flash_mod(self): - space = Reserve(0xc5848, 0xc5849, "collapsing house initial flash 1", field.NOP()) - space = Reserve(0xc5863, 0xc5864, "collapsing house initial flash 2", field.NOP()) - space = Reserve(0xc5868, 0xc5869, "collapsing house initial flash 3", field.NOP()) - space = Reserve(0xc59ec, 0xc59ed, "collapsing house final flash 1", field.NOP()) - space = Reserve(0xc59f5, 0xc59f6, "collapsing house final flash 2", field.NOP()) + space = Reserve(0xc5848, 0xc5849, "collapsing house initial flash 1", field.FlashScreen(field.Flash.NONE)) + space = Reserve(0xc5863, 0xc5864, "collapsing house initial flash 2", field.FlashScreen(field.Flash.NONE)) + space = Reserve(0xc5868, 0xc5869, "collapsing house initial flash 3", field.FlashScreen(field.Flash.NONE)) + space = Reserve(0xc59ec, 0xc59ed, "collapsing house final flash 1", field.FlashScreen(field.Flash.NONE)) + space = Reserve(0xc59f5, 0xc59f6, "collapsing house final flash 2", field.FlashScreen(field.Flash.NONE)) def add_gating_condition(self): start_event = 0xc5844 diff --git a/event/doma_wor.py b/event/doma_wor.py index ac17ea41..c38a9baf 100644 --- a/event/doma_wor.py +++ b/event/doma_wor.py @@ -165,10 +165,10 @@ def doma_mod(self): field.Branch(space.end_address + 1), # skip nops ) - if(self.args.flashes_remove_most): - space = Reserve(0xb9952, 0xb9953, "doma wor sword appears flash 1", field.NOP()) - space = Reserve(0xb9975, 0xb9976, "doma wor sword appears flashes", field.NOP()) - space = Reserve(0xb99a9, 0xb99aa, "doma wor sword appears flash 3", field.NOP()) + if(self.args.flashes_remove_most or self.args.flashes_remove_worst): + space = Reserve(0xb9952, 0xb9953, "doma wor sword appears flash 1", field.FlashScreen(field.Flash.NONE)) + space = Reserve(0xb9975, 0xb9976, "doma wor sword appears flashes", field.FlashScreen(field.Flash.NONE)) + space = Reserve(0xb99a9, 0xb99aa, "doma wor sword appears flash 3", field.FlashScreen(field.Flash.NONE)) space = Reserve(0xb997d, 0xb9984, "doma wor cyan kneeling", field.NOP()) space = Reserve(0xb99df, 0xb99e0, "doma wor pause before loading room slept in", field.NOP()) @@ -262,8 +262,8 @@ def cyan_esper_mod(self, esper): ) def finish_dream_awaken_mod(self): - if(self.args.flashes_remove_most): - space = Reserve(0xb9a47, 0xb9a48, "doma wor peak swordmanship flash", field.NOP()) + if(self.args.flashes_remove_most or self.args.flashes_remove_worst): + space = Reserve(0xb9a47, 0xb9a48, "doma wor peak swordmanship flash", field.FlashScreen(field.Flash.NONE)) src = [ field.FinishCheck(), diff --git a/event/duncan_house_wor.py b/event/duncan_house_wor.py index f925dcbe..4bd3ddac 100644 --- a/event/duncan_house_wor.py +++ b/event/duncan_house_wor.py @@ -60,4 +60,4 @@ def bum_rush_learn_mod(self): def bum_rush_flash_mod(self): flash_addresses = [0xc0d12, 0xc0d5f, 0xc0d7f, 0xc0d9f, 0xc0df0, 0xc0e09, 0xc0e22, 0xc0e3b, 0xc0e65, 0xc0e74] for address in flash_addresses: - space = Reserve(address, address + 1, "duncan house wor bum rush flash", field.NOP()) \ No newline at end of file + space = Reserve(address, address + 1, "duncan house wor bum rush flash", field.FlashScreen(field.Flash.NONE)) \ No newline at end of file diff --git a/event/event_reward.py b/event/event_reward.py index 0334886d..f1063e2e 100644 --- a/event/event_reward.py +++ b/event/event_reward.py @@ -6,6 +6,7 @@ class RewardType(Flag): ESPER = auto() ITEM = auto() +CHARACTER_ESPER_ONLY_REWARDS = 6 class Reward: def __init__(self, event, possible_types): self.id = None diff --git a/event/events.py b/event/events.py index cb0887b7..f1d40477 100644 --- a/event/events.py +++ b/event/events.py @@ -1,5 +1,5 @@ from memory.space import Bank, Allocate -from event.event_reward import RewardType, Reward, choose_reward, weighted_reward_choice +from event.event_reward import CHARACTER_ESPER_ONLY_REWARDS, RewardType, choose_reward, weighted_reward_choice import instruction.field as field class Events(): @@ -15,7 +15,9 @@ def __init__(self, rom, args, data): self.espers = data.espers self.shops = data.shops - self.mod() + events = self.mod() + + self.validate(events) def mod(self): # generate list of events from files @@ -58,6 +60,8 @@ def mod(self): from log import section section("Events", log_strings, []) + return events + def init_reward_slots(self, events): import random reward_slots = [] @@ -161,3 +165,10 @@ def open_world_mod(self, events): # choose the rest of the rewards, items given to events after all characters/events assigned self.choose_item_possible_rewards(reward_slots) + + def validate(self, events): + char_esper_checks = [] + for event in events: + char_esper_checks += [r for r in event.rewards if r.possible_types == (RewardType.CHARACTER | RewardType.ESPER)] + + assert len(char_esper_checks) == CHARACTER_ESPER_ONLY_REWARDS, "Number of char/esper only checks changed - Check usages of CHARACTER_ESPER_ONLY_REWARDS and ensure no breaking changes" \ No newline at end of file diff --git a/event/fanatics_tower.py b/event/fanatics_tower.py index 68089485..124992c0 100644 --- a/event/fanatics_tower.py +++ b/event/fanatics_tower.py @@ -15,6 +15,7 @@ def mod(self): self.strago_npc_id = 0x13 self.strago_npc = self.maps.get_npc(0x16a, self.strago_npc_id) + self.gau_magic_mod() self.relm_event_mod() self.tower_top_mod() self.magimaster_battle_mod() @@ -32,6 +33,11 @@ def mod(self): self.log_reward(self.reward1) self.log_reward(self.reward2) + def gau_magic_mod(self): + # normally only the Fight command replaces magic, causing Gau to only have Item. + # NOP the logic that blanks out everything except Magic + Item + Reserve(0x2538c, 0x2538d, "fanatics tower magic cmd", field.NOP()) + def relm_event_mod(self): # normally there are 4 event tiles surrounding player when they enter map and if player steps on one # with relm in party and strago not already recruited the relm/strago event is triggered diff --git a/event/floating_continent.py b/event/floating_continent.py index 927107cb..2782a099 100644 --- a/event/floating_continent.py +++ b/event/floating_continent.py @@ -143,6 +143,13 @@ def ultros_chupon_battle_mod(self): ) def air_force_battle_mod(self): + if self.args.flashes_remove_most or self.args.flashes_remove_worst: + # Slow the scrolling background by modifying the ADC command. + space = Reserve(0x2b1b1, 0x2b1b3, "falling through clouds background movement") + space.write( + asm.ADC(0x0001, asm.IMM16) #default: 0x0006 + ) + boss_pack_id = self.get_boss("Air Force") battle_background = 7 # sky, falling diff --git a/event/kefka_tower.py b/event/kefka_tower.py index ba1b1264..4b31ec08 100644 --- a/event/kefka_tower.py +++ b/event/kefka_tower.py @@ -28,6 +28,11 @@ def mod(self): self.item = self.atma_reward.id self.atma_battle_mod() self.atma_mod() + self.inferno_mod() + self.guardian_mod() + self.doom_mod() + self.goddess_mod() + self.poltergeist_mod() self.inferno_battle_mod() if self.args.fix_boss_skip: @@ -243,6 +248,67 @@ def atma_battle_mod(self): field.InvokeBattle(boss_pack_id), ) + # Copy no less than 4 bytes between start_target and end_target + # This will be called after one of the kt encounters has completed, but just prior to finishing the check + def kt_encounter_objective_mod(self, boss_name, bit, start_target, end_target, description): + src = Read(start_target, end_target) + src += [ + field.SetEventBit(bit), + field.CheckObjectives(), + field.Return(), + ] + post_battle = Write(Bank['CC'], src, f"{boss_name} post-battle. 1) Set event bit. 2) Finish check") + + space = Reserve(start_target, end_target, description, asm.NOP()) + space.write([ + field.Call(post_battle.start_address) + ]) + + def guardian_mod(self): + self.kt_encounter_objective_mod( + "Guardian", + event_bit.DEFEATED_GUARDIAN, + 0xc186c, + 0xc186f, + "Guardian battle post-script, wait for fade, set bit", + ) + + def inferno_mod(self): + self.kt_encounter_objective_mod( + "Inferno", + event_bit.DEFEATED_INFERNO, + 0xc18ae, + 0xc18b1, + "Inferno battle post-script, fade in, wait, set bit", + ) + + def doom_mod(self): + self.kt_encounter_objective_mod( + "Doom", + event_bit.DEFEATED_DOOM, + 0xc16f0, + 0xc16f3, + "Doom battle post-script, Hide NPC 5, set npc bit", + ) + + def goddess_mod(self): + self.kt_encounter_objective_mod( + "Goddess", + event_bit.DEFEATED_GODDESS, + 0xc1730, + 0xc1733, + "Goddess battle post-script, Hide NPC 2, set npc bit", + ) + + def poltergeist_mod(self): + self.kt_encounter_objective_mod( + "Goddess", + event_bit.DEFEATED_POLTERGEIST, + 0xc1786, + 0xc1789, + "Poltergeist battle post-script, Hide NPCs, set npc bit", + ) + def atma_mod(self): src = [ Read(0xc18d3, 0xc18d6), # show save point, set save point npc bit diff --git a/event/magitek_factory.py b/event/magitek_factory.py index edaca95a..c23a9b1e 100644 --- a/event/magitek_factory.py +++ b/event/magitek_factory.py @@ -105,7 +105,9 @@ def ifrit_shiva_mod(self, esper_item_instructions): space = Reserve(0xc79a4, 0xc79cf, "magitek factory ifrit/shiva magicite", field.NOP()) src = [] - if not self.args.flashes_remove_most: + if self.args.flashes_remove_most or self.args.flashes_remove_worst: + src.append(field.FlashScreen(field.Flash.NONE)) + else: src.append(field.FlashScreen(field.Flash.WHITE)) src.append([ diff --git a/event/narshe_battle.py b/event/narshe_battle.py index 59777176..abc7e062 100644 --- a/event/narshe_battle.py +++ b/event/narshe_battle.py @@ -208,6 +208,19 @@ def end_event_mod(self, reward_instructions): reward_instructions, field.SetParty(1), + + # ref: CB/7217 + field.HoldScreen(), + field.DisableEntityCollision(field_entity.PARTY0), + + field.EntityAct(field_entity.PARTY0, True, + field_entity.SetSpeed(field_entity.Speed.FAST), + field_entity.Move(direction.DOWN, 8), + ), + + field.FadeOutScreen(4), + field.WaitForFade(), + field.Call(field.REMOVE_ALL_CHARACTERS_FROM_ALL_PARTIES), field.Call(field.REFRESH_CHARACTERS_AND_SELECT_PARTY), @@ -215,8 +228,10 @@ def end_event_mod(self, reward_instructions): field.ShowEntity(field_entity.PARTY0), field.RefreshEntities(), - field.LoadMap(0x16, direction.DOWN, default_music = True, - x = 19, y = 36, fade_in = True, entrance_event = True), + field.FreeScreen(), + field.LoadMap(0x1e, direction.DOWN, default_music = True, + x = 60, y = 37, fade_in = True, entrance_event = True), + field.FinishCheck(), field.Return(), ] diff --git a/event/owzer_mansion.py b/event/owzer_mansion.py index c0e53dec..8fd17a90 100644 --- a/event/owzer_mansion.py +++ b/event/owzer_mansion.py @@ -42,7 +42,7 @@ def mod(self): self.log_reward(self.reward) def flash_mod(self): - space = Reserve(0xb4d10, 0xb4d11, "owzer mansion flash", field.NOP()) + space = Reserve(0xb4d10, 0xb4d11, "owzer mansion flash", field.FlashScreen(field.Flash.NONE)) def dialog_mod(self): space = Reserve(0xb4d0d, 0xb4d0f, "owzer mansion help that painting!!", field.NOP()) diff --git a/event/start.py b/event/start.py index 80f35b42..39f9b4b1 100644 --- a/event/start.py +++ b/event/start.py @@ -63,6 +63,7 @@ def mod(self): self.intro_loop_mod() self.init_characters_mod() self.start_party_mod() + self.start_esper_mod() self.start_gold_mod() self.start_items_mod() self.start_game_mod() @@ -73,6 +74,7 @@ def mod(self): field.Call(self.event_bit_init), field.Call(self.character_init), field.Call(self.start_party), + field.Call(self.start_esper), field.Call(self.start_gold), field.Call(self.start_items), field.Call(self.start_game), @@ -142,6 +144,21 @@ def start_party_mod(self): space = Write(Bank.CC, src, "start party") self.start_party = space.start_address + def start_esper_mod(self): + src = [] + + for esper_id in self.espers.starting_espers: + src += [ + field.AddEsper(esper_id, sound_effect = False) + ] + + src += [ + field.Return() + ] + + space = Write(Bank.CC, src, "start espers") + self.start_esper = space.start_address + def start_gold_mod(self): gold = self.args.gold if self.args.debug: @@ -170,7 +187,10 @@ def start_items_mod(self): src += [ field.AddItem("Moogle Charm", sound_effect = False), ] - + for mc in range(self.args.start_sprint_shoes): + src += [ + field.AddItem("Sprint Shoes", sound_effect = False), + ] for ws in range(self.args.start_warp_stones): src += [ field.AddItem("Warp Stone", sound_effect = False), @@ -188,6 +208,23 @@ def start_items_mod(self): field.AddItem(tool, sound_effect = False), ] + from constants.items import id_name + from data.shop_item_tiers import tiers + from data.item import Item + junk = [] + junk += tiers[Item.WEAPON][0] + junk += tiers[Item.SHIELD][0] + junk += tiers[Item.HELMET][0] + junk += tiers[Item.ARMOR][0] + junk += tiers[Item.RELIC][0] + + start_junk = random.sample(junk, self.args.start_junk) + + for junk_id in start_junk: + src += [ + field.AddItem(id_name[junk_id], sound_effect = False) + ] + if self.args.debug: src += [ field.AddItem("Dried Meat", sound_effect = False), diff --git a/instruction/c3.py b/instruction/c3.py new file mode 100644 index 00000000..0e10ecc0 --- /dev/null +++ b/instruction/c3.py @@ -0,0 +1,24 @@ +from memory.space import Bank, START_ADDRESS_SNES, Space, Reserve, Allocate, Free, Write +import instruction.asm as asm + +# Allow Eggers jumps into C3 -- that is, enable calls to JSR routines from other banks +# Ref: https://www.ff6hacking.com/forums/thread-2301.html +def _eggers_jump_return_mod(): + src = [ + asm.RTS(), + asm.RTL() + ] + space = Write(Bank.C3, src, "C3 eggers jump return") + return space.start_address +eggers_jump_return = _eggers_jump_return_mod() + +# Eggers jump src to jump to the specified C3 subroutine and successfully return to another bank +def eggers_jump(c3addr): + src = [ + asm.PHK(), + asm.PER(0x0009), + asm.PEA(eggers_jump_return), + asm.PEA(c3addr-1), # return after execution + asm.JMP(eggers_jump_return + START_ADDRESS_SNES, asm.LNG), + ] + return src \ No newline at end of file diff --git a/instruction/field/instructions.py b/instruction/field/instructions.py index 25351566..c9967eeb 100644 --- a/instruction/field/instructions.py +++ b/instruction/field/instructions.py @@ -406,6 +406,7 @@ def TintSpritePalette(tint, palette, invert = False): return TintSpritePalette(tint, palette * 0x10, palette * 0x10 + 0x0f, invert) class Flash(IntFlag): + NONE = 0x00 RED = 0x20 GREEN = 0x40 BLUE = 0x80 @@ -413,7 +414,7 @@ class Flash(IntFlag): WHITE = RED | GREEN | BLUE class FlashScreen(_Instruction): def __init__(self, color): - if not (color & Flash.RED) and not (color & Flash.GREEN) and not (color & Flash.BLUE): + if color != Flash.NONE and (not (color & Flash.RED) and not (color & Flash.GREEN) and not (color & Flash.BLUE)): raise ValueError(f"FlashScreen: invalid color {hex(color)}") super().__init__(0x55, color) diff --git a/log/__init__.py b/log/__init__.py index d2cfeb71..e7d89da5 100644 --- a/log/__init__.py +++ b/log/__init__.py @@ -20,7 +20,8 @@ if args.website_link: log_msg += f"Website {args.website_link}\n" log_msg += f"Seed {args.seed}\n" -log_msg += f"Flags {args.flags}\n" +if not args.hide_flags: + log_msg += f"Flags {args.flags}\n" log_msg += f"Hash {', '.join([entry.name for entry in args.sprite_hash])}" if args.debug: @@ -31,4 +32,5 @@ if not args.stdout_log: print(log_msg) -args.log() +if not args.hide_flags: + args.log() diff --git a/menus/flags.py b/menus/flags.py index 206b3741..35938c08 100644 --- a/menus/flags.py +++ b/menus/flags.py @@ -4,31 +4,42 @@ import args import menus.pregame_track_scroll_area as scroll_area +from data.text.text2 import text_value class Flags(scroll_area.ScrollArea): MENU_NUMBER = 12 def __init__(self): self.lines = [] - for _, group in args.group_modules.items(): - if hasattr(group, "menu"): - name, options = group.menu(args) - - self.lines.append(scroll_area.Line(name, f0.set_blue_text_color)) - for option in options: - key, value = option - - key = " " + key.replace("&", "+") - value = str(value) - if value == "True": - value = "T" - elif value == "False": - value = "F" - - padding = scroll_area.WIDTH - (len(key) + len(value)) - self.lines.append(scroll_area.Line(f"{key}{' ' * padding}{value}", f0.set_user_text_color)) - - self.lines.append(scroll_area.Line("", f0.set_user_text_color)) - del self.lines[-1] # exclude final empty line + self.submenus = {} # dictionary of submenus. key = line number, value = ScrollArea derived class + if args.hide_flags: + self.lines.append(scroll_area.Line("Flags Hidden", f0.set_blue_text_color)) + else: + for _, group in args.group_modules.items(): + if hasattr(group, "menu"): + name, options = group.menu(args) + + self.lines.append(scroll_area.Line(name, f0.set_blue_text_color)) + for option in options: + key, value = option + + key = " " + key.replace("&", "+") + + # if we're given a scroll area, save it as a sub-menu with a value of X …, where X is the number of items in the sub-menu + if isinstance(value, scroll_area.ScrollArea): + self.submenus[len(self.lines)] = value + value = f"{value.number_items} {chr(text_value['…'])}" + + value = str(value) + if value == "True": + value = "T" + elif value == "False": + value = "F" + + padding = scroll_area.WIDTH - (len(key) + len(value)) + self.lines.append(scroll_area.Line(f"{key}{' ' * padding}{value}", f0.set_user_text_color)) + + self.lines.append(scroll_area.Line("", f0.set_user_text_color)) + del self.lines[-1] # exclude final empty line super().__init__() diff --git a/menus/flags_remove_learnable_spells.py b/menus/flags_remove_learnable_spells.py new file mode 100644 index 00000000..8bb4eb21 --- /dev/null +++ b/menus/flags_remove_learnable_spells.py @@ -0,0 +1,55 @@ +import menus.pregame_track_scroll_area as scroll_area +from data.text.text2 import text_value +import instruction.f0 as f0 + +class FlagsRemoveLearnableSpells(scroll_area.ScrollArea): + MENU_NUMBER = 15 + + def __init__(self, spell_ids): + self.number_items = len(spell_ids) + self.lines = [] + + self.lines.append(scroll_area.Line(f"Remove Learnable Spells", f0.set_blue_text_color)) + + spell_lines = FlagsRemoveLearnableSpells._format_spells_menu(spell_ids) + + for list_value in spell_lines: + padding = scroll_area.WIDTH - (len(list_value)) + self.lines.append(scroll_area.Line(f"{' ' * padding}{list_value}", f0.set_user_text_color)) + + super().__init__() + + def _format_spells_menu(spell_ids): + from constants.spells import id_spell + COLUMN_WIDTHS = [8, 8, 8] + spell_lines = [] + + # Step through each spell by the number of columns + for spell_idx in range(0, len(spell_ids), len(COLUMN_WIDTHS)): + current_line = '' + # Populate each column on the line + for col in range(0, len(COLUMN_WIDTHS)): + if(spell_idx + col < len(spell_ids)): + a_spell_id = spell_ids[spell_idx + col] + icon = FlagsRemoveLearnableSpells._get_spell_icon(a_spell_id) + spell_str = f"{icon}{id_spell[a_spell_id]}" + padding = COLUMN_WIDTHS[col] - len(spell_str) + current_line += f"{spell_str}{' ' * padding}" + else: + # No spell, add padding + current_line += f"{' ' * COLUMN_WIDTHS[col]}" + # Write the line + spell_lines.append(current_line) + return spell_lines + + def _get_spell_icon(spell_id): + from constants.spells import black_magic_ids, gray_magic_ids, white_magic_ids + from data.text.text2 import text_value + icon = '' + if spell_id in black_magic_ids: + icon = chr(text_value['']) + elif spell_id in gray_magic_ids: + icon = chr(text_value['']) + elif spell_id in white_magic_ids: + icon = chr(text_value['']) + return icon diff --git a/menus/magic.py b/menus/magic.py new file mode 100644 index 00000000..af5e1ff9 --- /dev/null +++ b/menus/magic.py @@ -0,0 +1,42 @@ +from memory.space import Write, Bank, Reserve +import instruction.asm as asm +import args + +class MagicMenu: + def __init__(self): + self.mod() + + def draw_three_digits(self): + # Enable drawing of 3 digits + # Create string function + STRING_DRAW_ADDR = 0x2180 # Where to write strings to be written + src = [ + asm.LDA(0xF7, asm.DIR), # Hundreds digit + asm.STA(STRING_DRAW_ADDR, asm.ABS), # Add to string + # displaced vanilla logic, from C3/51E9 - 51ED + asm.LDA(0xF8, asm.DIR), # Tens digit + asm.STA(STRING_DRAW_ADDR, asm.ABS), # Add to string + asm.RTL() + ] + space = Write(Bank.F0, src, "Create MP Cost string") + create_string = space.start_address_snes + + space = Reserve(0x351e9, 0x351ed, "Call create_string", asm.NOP()) + space.write( + asm.JSL(create_string), + ) + + # Move where MP gets written 1 space to the left, + # to avoid having the number show up at the top of the "Espers" menu + space = Reserve(0x351cd, 0x351cd, "MP String location") + space.write(0xbd) #original: 0xbf (each text space is a value of 2) + + def fix_in_battle_mp_tens_digit(self): + # Fix Vanilla in-battle MP listing in which the ten's digit is blanked + # if it is 0 but the hundreds digit is not + space = Reserve(0x1057b, 0x1057b, "MP Hundreds non-zero BNE offset") + space.write(0x14) # original: 0x08; 0x14 causes it to jump to RTS if the hundreds place is non-zero + + def mod(self): + self.draw_three_digits() + self.fix_in_battle_mp_tens_digit() diff --git a/menus/menus.py b/menus/menus.py index 9ac749a9..db145c64 100644 --- a/menus/menus.py +++ b/menus/menus.py @@ -2,24 +2,30 @@ import menus.pregame as pregame import menus.track as track import menus.dance as dance +import menus.rage as rage import menus.status as status import menus.final_lineup as final_lineup import menus.coliseum as coliseum import menus.sell as sell +import menus.magic as magic class Menus: - def __init__(self, characters, dances): + def __init__(self, characters, dances, rages, enemies): self.characters = characters self.dances = dances + self.rages = rages + self.enemies = enemies self.pregame_track = pregame_track.PreGameTrack(self.characters) self.pregame_menu = pregame.PreGameMenu(self.pregame_track) self.track_menu = track.TrackMenu(self.pregame_track) self.dance_menu = dance.DanceMenu(self.dances) + self.rage_menu = rage.RageMenu(self.rages, self.enemies) self.status_menu = status.StatusMenu(self.characters) self.final_lineup_menu = final_lineup.FinalLineupMenu(self.characters) self.coliseum_menu = coliseum.ColiseumMenu() self.sell_menu = sell.SellMenu() + self.magic_menu = magic.MagicMenu() self.scrollbar_bugfix() diff --git a/menus/objectives.py b/menus/objectives.py index 310aa6ff..75cc2a74 100644 --- a/menus/objectives.py +++ b/menus/objectives.py @@ -17,18 +17,28 @@ def __init__(self): self.lines = [] self.line_color_addresses = [] for oi, objective in enumerate(objectives): - condition_fraction = chr(self.special_characters_start + oi) + "/" + str(objective.conditions_required) - result_line = objective.letter + " " + str(objective.result) + " " + condition_fraction + result_line = objective.letter + " " + str(objective.result) self.lines.append(scroll_area.Line(result_line, f0.set_blue_text_color)) - for condition in objective.conditions: - condition_line = " " + str(condition) - line_color_address = condition.menu( - asm.JMP(f0.set_gray_text_color, asm.ABS), - asm.JMP(f0.set_user_text_color, asm.ABS), - ).space.start_address - self.lines.append(scroll_area.Line(condition_line, line_color_address)) + if len(objective.conditions) == 0: + self.lines.append(scroll_area.Line(" " + chr(self.special_characters_start + oi) + " required", f0.set_user_text_color)) + else: + conditions_required = " All " + str(objective.conditions_required) + " of" + if objective.conditions_required < len(objective.conditions): + conditions_required = " Any " + str(objective.conditions_required) + " of" + self.lines.append(scroll_area.Line(conditions_required, f0.set_user_text_color)) + for condition in objective.conditions: + condition_line = " " + str(condition) + # When you finish a condition, gray it out + line_color_address = condition.menu( + asm.JMP(f0.set_gray_text_color, asm.ABS), + asm.JMP(f0.set_user_text_color, asm.ABS), + ).space.start_address + self.lines.append(scroll_area.Line(condition_line, line_color_address)) + + completed_line = " -- " + chr(self.special_characters_start + oi) + " completed --" + self.lines.append(scroll_area.Line(completed_line, f0.set_user_text_color)) self.lines.append(scroll_area.Line("", f0.set_user_text_color)) if len(self.lines) == 0: @@ -54,6 +64,14 @@ def draw_character_mod(self): space = Write(Bank.F0, src, "objectives menu count conditions complete table") count_table = space.start_address + src = [] + for objective in objectives: + src += [ + objective.conditions_required.to_bytes(1, "little"), + ] + space = Write(Bank.F0, src, "objectives menu conditions required table") + conditions_required_table = space.start_address_snes + src = [ asm.CMP(self.special_characters_start, asm.IMM8), asm.BLT("WRITE_CHARACTER"), # branch if less than first special character value @@ -62,14 +80,25 @@ def draw_character_mod(self): asm.BGE("WRITE_CHARACTER"), # branch if greater than last special character value asm.PHX(), + asm.PHY(), asm.XY8(), asm.SEC(), asm.SBC(self.special_characters_start, asm.IMM8), # a = 0 based objective index + asm.TAY(), # y = 0 based objective index asm.ASL(), # a = objective index * 2 asm.TAX(), # x = objective index * 2 asm.JSR(count_table, asm.ABS_X_16), # x = number conditions complete asm.TXA(), # a = number conditions complete + asm.TYX(), # x = 0 based objective index + # when you finish an objective, gray it out + asm.CMP(conditions_required_table, asm.LNG_X), + asm.BLT("SKIP_COLOR_CHANGE"), + asm.PHA(), + asm.JSR(f0.set_gray_text_color, asm.ABS), + asm.PLA(), + "SKIP_COLOR_CHANGE", asm.XY16(), + asm.PLY(), asm.PLX(), asm.CLC(), asm.ADC(text_value['0'], asm.IMM8), # a = number conditions complete converted to character diff --git a/menus/pregame.py b/menus/pregame.py index 1af6d27d..52dd93ed 100644 --- a/menus/pregame.py +++ b/menus/pregame.py @@ -1,5 +1,6 @@ -from memory.space import Bank, Write, Reserve, Allocate, Read +from memory.space import START_ADDRESS_SNES, Bank, Write, Reserve, Allocate, Read import instruction.asm as asm +import instruction.c3 as c3 class PreGameMenu: MENU_NUMBER = 9 @@ -8,6 +9,7 @@ class PreGameMenu: def __init__(self, pregame_track): self.common = pregame_track + self.invoke_flags_submenu = {} self.mod() def draw_options_mod(self): @@ -46,10 +48,10 @@ def draw_options_mod(self): def initialize_mod(self): src = [ - asm.JSR(self.common.initialize, asm.ABS), + asm.JSL(self.common.initialize + START_ADDRESS_SNES), asm.JSR(self.draw_options, asm.ABS), - asm.JSR(self.common.upload_bg123ab, asm.ABS), + asm.JSL(self.common.upload_bg123ab + START_ADDRESS_SNES), asm.LDA(self.MENU_NUMBER, asm.IMM8), asm.STA(0x0200, asm.ABS), @@ -60,6 +62,7 @@ def initialize_mod(self): asm.STA(0x26, asm.DIR), # add fade in pregame menu to queue asm.JMP(0x3541, asm.ABS), # set brightness and refresh screen ] + # called by C3 JSR jump table space = Write(Bank.C3, src, "pregame initialize") self.initialize = space.start_address @@ -79,6 +82,14 @@ def invoke_flags_menu_mod(self): space = Write(Bank.C3, src, "pregame invoke flags") self.invoke_flags = space.start_address + def invoke_flags_submenu_mod(self, submenu_idx): + src = [ + asm.JSR(0x6a3c, asm.ABS), # clear BG3 a (workaround for bizhawk snes9x core bug) + asm.JMP(self.common.invoke_flags_submenu[submenu_idx], asm.ABS), + ] + space = Write(Bank.C3, src, "pregame invoke flag submenu") + self.invoke_flags_submenu[submenu_idx] = space.start_address + def sustain_mod(self): src = [ asm.JSR(0x2a21, asm.ABS), # reset game play time @@ -120,12 +131,21 @@ def sustain_mod(self): src = [ asm.JSR(self.common.refresh_sprites, asm.ABS), - asm.LDA(0x0200, asm.ABS), + # if in a scroll area, sustain it + asm.LDA(0x0200, asm.ABS), asm.CMP(self.common.flags.MENU_NUMBER, asm.IMM8), asm.BEQ("SUSTAIN_SCROLL_AREA"), asm.CMP(self.common.objectives.MENU_NUMBER, asm.IMM8), asm.BEQ("SUSTAIN_SCROLL_AREA"), + ] + for submenu_idx in self.common.flags.submenus.keys(): + src += [ + asm.CMP(self.common.flags.submenus[submenu_idx].MENU_NUMBER, asm.IMM8), + asm.BEQ("SUSTAIN_SCROLL_AREA"), + ] + + src += [ asm.JSR(0x072d, asm.ABS), # handle d-pad asm.LDY(self.common.cursor_positions, asm.IMM16), asm.JSR(0x0640, asm.ABS), # update cursor position @@ -134,8 +154,9 @@ def sustain_mod(self): asm.LDA(0x08, asm.DIR), # load buttons pressed this frame asm.BIT(0x80, asm.IMM8), # a pressed? - asm.BEQ("RETURN"), # branch if not - + asm.BNE("A_PRESSED"), # branch if so + asm.RTS(), + "A_PRESSED", asm.TDC(), asm.JSR(0x0eb2, asm.ABS), # click sound asm.LDA(0x4b, asm.DIR), # a = cursor index @@ -144,36 +165,40 @@ def sustain_mod(self): asm.JMP(options_table, asm.ABS_X_16), "SUSTAIN_SCROLL_AREA", - asm.LDA(0x0d, asm.DIR), + asm.LDA(0x09, asm.DIR), asm.BIT(0x80, asm.IMM8), # b pressed? - asm.BNE("EXIT_SCROLL_AREA"), + asm.BNE("EXIT_SCROLL_AREA"), # branch if so + + ] + + for submenu_id in self.common.flags.submenus.keys(): + src.extend(self.common.get_submenu_src(submenu_id, self.invoke_flags_submenu[submenu_id])) + + src += [ asm.JMP(self.common.sustain_scroll_area, asm.ABS), "EXIT_SCROLL_AREA", - asm.JSR(self.common.exit_scroll_area, asm.ABS), - asm.LDA(self.MENU_NUMBER, asm.IMM8), - asm.STA(0x0200, asm.ABS), - - "RETURN", - asm.RTS(), ] + src.extend(self.common.get_scroll_area_exit_src(self.MENU_NUMBER, self.invoke_flags)) + + # Called by C3 JSR jump table space = Write(Bank.C3, src, "pregame sustain") self.sustain = space.start_address def initialize_config_menu_mod(self): src = [ - asm.JSR(0x352f, asm.ABS), # reset - + c3.eggers_jump(0x352f), # displaced code: reset + asm.STZ(0x4A, asm.DIR), # displaced code: screen 1st asm.LDA(0xc0, asm.IMM8), # hdma channels 6 and 7 asm.TRB(0x43, asm.DIR), - asm.RTS(), + asm.RTL(), ] - space = Write(Bank.C3, src, "pregame initialize config menu reset uncondense") + space = Write(Bank.F0, src, "pregame initialize config menu reset uncondense") reset_uncondense = space.start_address - space = Reserve(0x31c7d, 0x31c7f, "pregame initialize config menu reset") + space = Reserve(0x31c7d, 0x31c81, "pregame initialize config menu reset", asm.NOP()) space.write( - asm.JSR(reset_uncondense, asm.ABS), + asm.JSL(reset_uncondense + START_ADDRESS_SNES), ) def exit_config_menu_mod(self): @@ -235,6 +260,8 @@ def mod(self): self.initialize_mod() self.invoke_objectives_menu_mod() self.invoke_flags_menu_mod() + for submenu_idx in self.common.flags.submenus.keys(): + self.invoke_flags_submenu_mod(submenu_idx) self.sustain_mod() self.initialize_config_menu_mod() diff --git a/menus/pregame_track.py b/menus/pregame_track.py index c26edf45..975ea355 100644 --- a/menus/pregame_track.py +++ b/menus/pregame_track.py @@ -1,5 +1,6 @@ from memory.space import Bank, START_ADDRESS_SNES, Reserve, Allocate, Write, Read import instruction.asm as asm +import instruction.c3 as c3 import args import menus.pregame_track_scroll_area as scroll_area @@ -22,9 +23,67 @@ def __init__(self, characters): self.progress = progress.Progress() self.flags = flags.Flags() + self.invoke_flags_submenu = {} self.characters = characters self.mod() + def get_submenu_src(self, submenu_id, invoke_submenu_addr): + SUBMENU_LABEL = f"SUBMENU_CHECK{submenu_id}" + SUBMENU_END_LABEL = f"SUBMENU_CHECK{submenu_id}_END" + HANDLE_SCROLLING_LABEL = f"HANDLE_SCROLLING_{submenu_id}" + # get the ASM for sustain_mod that checks whether we are in the flags menu + # and the A button is clicked to launch a submenu. + src = [ + # if on the flags menu, check A button press + asm.LDA(0x200, asm.ABS), + asm.CMP(self.flags.MENU_NUMBER, asm.IMM8), # in Flags menu? + asm.BNE(HANDLE_SCROLLING_LABEL), # branch if not + asm.LDA(0x08, asm.DIR), + asm.BIT(0x80, asm.IMM8), # a pressed? + asm.BEQ(HANDLE_SCROLLING_LABEL), # branch if not + ] + src += [ + SUBMENU_LABEL, + asm.LDA(0x4b, asm.DIR), # a = cursor index + asm.CMP(submenu_id, asm.IMM8), # is the cursor index = this submenu? + asm.BNE(SUBMENU_END_LABEL), # branch if not + asm.TDC(), + asm.JSR(0x0eb2, asm.ABS), # click sound + asm.JSL(self.exit_scroll_area + START_ADDRESS_SNES), # save current submenu position + asm.JMP(invoke_submenu_addr, asm.ABS), # load the flags submenu + SUBMENU_END_LABEL, + ] + src += [HANDLE_SCROLLING_LABEL] + return src + + def get_scroll_area_exit_src(self, destination_menu_number, invoke_flags_addr): + # Get the ASM for sustain_mod that handles exit from a scroll area, either returning to flags if in + # a flags submenu or to the given destination_menu_number otherwise. + src = [ + asm.JSR(0x0EA9, asm.ABS), # cursor sound + asm.JSL(self.exit_scroll_area + START_ADDRESS_SNES), # save current submenu position + asm.LDA(0x0200, asm.ABS), + ] + + for submenu_idx in self.flags.submenus.keys(): + # if current menu is a flags sub-menu, cause it to return to that, rather than main menu + src += [ + asm.CMP(self.flags.submenus[submenu_idx].MENU_NUMBER, asm.IMM8), # in Flags submenu? + asm.BEQ("INVOKE_FLAGS"), # branch if so + ] + + src += [ + asm.LDA(destination_menu_number, asm.IMM8), # queue up this menu + asm.STA(0x0200, asm.ABS), + "RETURN", + asm.RTS(), + + "INVOKE_FLAGS", + asm.JMP(invoke_flags_addr, asm.ABS), + ] + + return src + def draw_layout_mod(self): # layouts: 2 bytes for bg/tilemap/position, 1 byte inner width, 1 byte inner height # e.g. $5849 is start of bg2 tilemap a, add 0x42 for top left of visible screen area @@ -33,6 +92,7 @@ def draw_layout_mod(self): 0x8b, 0x58, # top-left to top-right 0x1c, 0x07, # 28x7 ] + # Note: keep in C3 as this is then used by the C3/0341 subroutine called below space = Write(Bank.C3, src, "pregame track top window layout") top_window_layout = space.start_address @@ -40,19 +100,20 @@ def draw_layout_mod(self): 0xcb, 0x5a, # x/y position 0x1c, 0x0f, # width/height (excluding border) ] + # Note: keep in C3 as this is then used by the C3/0341 subroutine called below space = Write(Bank.C3, src, "pregame track bottom window layout") bottom_window_layout = space.start_address src = [ - asm.JSR(0x6a15, asm.ABS), # clear BG1 A - asm.JSR(0x6a3c, asm.ABS), # clear BG3 A + c3.eggers_jump(0x6a15), # clear BG1 A + c3.eggers_jump(0x6a3c), # clear BG3 A asm.LDY(top_window_layout, asm.IMM16), - asm.JSR(0x0341, asm.ABS), # draw top window + c3.eggers_jump(0x0341), # draw top window asm.LDY(bottom_window_layout, asm.IMM16), - asm.JSR(0x0341, asm.ABS), # draw bottom window + c3.eggers_jump(0x0341), # draw bottom window asm.RTS(), ] - space = Write(Bank.C3, src, "pregame track draw layout") + space = Write(Bank.F0, src, "pregame track draw layout") self.draw_layout = space.start_address def decrease_line_height_mod(self): @@ -65,6 +126,7 @@ def decrease_line_height_mod(self): 0x0c, 0x14, 0x00, # config/flags 0x00, # end ] + # Keep in C3 as it's used by C3 subroutine called below space = Write(Bank.C3, src, "pregame track bg3 shift table") bg3_shift_table = space.start_address @@ -97,17 +159,17 @@ def draw_labels_mod(self): ) src = [ - asm.JSR(0xc2f7, asm.ABS), # set text color to blue + c3.eggers_jump(0xc2f7), # set text color to blue ] for label in labels: src += [ asm.LDY(label, asm.IMM16), - asm.JSR(0x02f9, asm.ABS), # draw text + c3.eggers_jump(0x02f9), # draw text ] src += [ asm.RTS(), ] - space = Write(Bank.C3, src, "pregame track draw labels") + space = Write(Bank.F0, src, "pregame track draw labels") self.draw_labels = space.start_address def draw_entry_mod(self): @@ -123,7 +185,15 @@ def draw_entry_mod(self): asm.BEQ("DRAW_PROGRESS"), asm.CMP(self.flags.MENU_NUMBER, asm.IMM8), asm.BEQ("DRAW_FLAG"), + ] + + for submenu_idx in self.flags.submenus.keys(): + src += [ + asm.CMP(self.flags.submenus[submenu_idx].MENU_NUMBER, asm.IMM8), + asm.BEQ(f"DRAW_FLAGS_SUBMENU{submenu_idx}"), + ] + src += [ "DRAW_ITEM", Read(0x37fa1, 0x37fa3), asm.JMP(0x7fa4, asm.ABS), @@ -140,6 +210,13 @@ def draw_entry_mod(self): "DRAW_FLAG", asm.JMP(self.flags.draw_line, asm.ABS), ] + + for submenu_idx in self.flags.submenus.keys(): + src += [ + f"DRAW_FLAGS_SUBMENU{submenu_idx}", + asm.JMP(self.flags.submenus[submenu_idx].draw_line, asm.ABS), + ] + space = Write(Bank.C3, src, "pregame track draw entry") draw_entry = space.start_address @@ -150,12 +227,12 @@ def draw_entry_mod(self): def upload_bg123ab_mod(self): src = [ - asm.JSR(0x0e28, asm.ABS), # upload bg1 a+b - asm.JSR(0x0e52, asm.ABS), # upload bg2 a+b - asm.JSR(0x0e6e, asm.ABS), # upload bg3 a+b - asm.RTS(), + c3.eggers_jump(0x0e28), # upload bg1 a+b + c3.eggers_jump(0x0e52), # upload bg2 a+b + c3.eggers_jump(0x0e6e), # upload bg3 a+b + asm.RTL(), ] - space = Write(Bank.C3, src, "pregame track upload bg123ab") + space = Write(Bank.F0, src, "pregame track upload bg123ab") self.upload_bg123ab = space.start_address def initialize_cursor_mod(self): @@ -165,6 +242,7 @@ def initialize_cursor_mod(self): 0x08, 0x35, # flags / progress 0x08, 0x41, # config / flags ] + # Keep in C3 -- used by subroutines space = Write(Bank.C3, src, "pregame track cursor positions") self.cursor_positions = space.start_address @@ -175,15 +253,16 @@ def initialize_cursor_mod(self): 0x01, # columns 0x04, # rows ] + # Keep in C3 -- used by subroutines space = Write(Bank.C3, src, "pregame track navigation data") navigation_data = space.start_address src = [ asm.LDY(navigation_data, asm.IMM16), - asm.JSR(0x05fe, asm.ABS), # load navigation data + c3.eggers_jump(0x05fe), # load navigation data asm.RTS(), ] - space = Write(Bank.C3, src, "pregame track update navigation data") + space = Write(Bank.F0, src, "pregame track update navigation data") self.update_navigation_data = space.start_address src = [ @@ -191,16 +270,16 @@ def initialize_cursor_mod(self): asm.STA(0x4e, asm.DIR), # cursor row = saved row asm.RTS(), ] - space = Write(Bank.C3, src, "pregame track remember cursor position") + space = Write(Bank.F0, src, "pregame track remember cursor position") self.remember_cursor_position = space.start_address src = [ asm.LDY(self.cursor_positions, asm.IMM16), - asm.JSR(0x0640, asm.ABS), # update cursor position - asm.JSR(0x07b0, asm.ABS), # add cursor to animation queue - asm.RTS(), + c3.eggers_jump(0x0640), # update cursor position + c3.eggers_jump(0x07b0), # add cursor to animation queue + asm.RTS(), ] - space = Write(Bank.C3, src, "pregame track update cursor position") + space = Write(Bank.F0, src, "pregame track update cursor position") self.update_cursor_position = space.start_address src = [ @@ -211,9 +290,10 @@ def initialize_cursor_mod(self): asm.JSR(self.remember_cursor_position, asm.ABS), "UPDATE_CURSOR_POSITION", - asm.JMP(self.update_cursor_position, asm.ABS), + asm.JSR(self.update_cursor_position, asm.ABS), + asm.RTS(), ] - space = Write(Bank.C3, src, "pregame track initialize cursor") + space = Write(Bank.F0, src, "pregame track initialize cursor") self.initialize_cursor = space.start_address def initialize_scroll_area_mod(self): @@ -237,6 +317,11 @@ def initialize_scroll_area_mod(self): src+= [ asm.JSL(START_ADDRESS_SNES + self.flags.initialize), ] + for submenu_idx in self.flags.submenus.keys(): + if self.flags.submenus[submenu_idx].initialize is not None: + src+= [ + asm.JSL(START_ADDRESS_SNES + self.flags.submenus[submenu_idx].initialize), + ] src += [ asm.STZ(0x4a, asm.DIR), # index of first row displayed @@ -249,8 +334,8 @@ def initialize_scroll_area_mod(self): asm.LDA(self.objectives.number_excess_lines, asm.IMM8), asm.STA(0x5c, asm.DIR), - asm.JSR(0x07b0, asm.ABS), # queue scrollbar animation - asm.JSR(0x091f, asm.ABS), # create scrollbar + c3.eggers_jump(0x07b0), # queue scrollbar animation + c3.eggers_jump(0x091f), # create scrollbar asm.A16(), asm.LDA(self.objectives.scrollbar_speed, asm.IMM16), asm.STA(0x7e354a, asm.LNG_X), @@ -266,7 +351,7 @@ def initialize_scroll_area_mod(self): asm.LDA(0x1d4e, asm.ABS), # load game config asm.AND(0x40, asm.IMM8), # cursor memory enabled? asm.BNE("REMEMBER_SCROLL_AREA"), # branch if so - asm.JSR(scroll_area.draw, asm.ABS), + c3.eggers_jump(scroll_area.draw), asm.RTS(), "REMEMBER_SCROLL_AREA", @@ -278,7 +363,15 @@ def initialize_scroll_area_mod(self): asm.BEQ("REMEMBER_PROGRESS"), asm.CMP(self.flags.MENU_NUMBER, asm.IMM8), asm.BEQ("REMEMBER_FLAGS"), + ] + + for submenu_idx in self.flags.submenus.keys(): + src += [ + asm.CMP(self.flags.submenus[submenu_idx].MENU_NUMBER, asm.IMM8), + asm.BEQ(f"REMEMBER_FLAGS_SUBMENU{submenu_idx}"), + ] + src += [ "REMEMBER_OBJECTIVES", asm.LDA(self.objectives.MENU_NUMBER, asm.IMM8), # load objectives menu number asm.STA(self.MEMORY_SCROLL_AREA_NUMBER, asm.ABS), # save in case no scroll area memory @@ -293,7 +386,14 @@ def initialize_scroll_area_mod(self): "REMEMBER_FLAGS", asm.JMP(self.flags.remember_draw, asm.ABS), ] - space = Write(Bank.C3, src, "pregame track initialize scroll area") + + for submenu_idx in self.flags.submenus.keys(): + src += [ + f"REMEMBER_FLAGS_SUBMENU{submenu_idx}", + asm.JMP(self.flags.submenus[submenu_idx].remember_draw, asm.ABS), + ] + + space = Write(Bank.F0, src, "pregame track initialize scroll area") self.initialize_scroll_area = space.start_address def InvokeScrollArea(self, scroll_area_menu): @@ -301,7 +401,7 @@ def InvokeScrollArea(self, scroll_area_menu): asm.LDA(scroll_area_menu.MENU_NUMBER, asm.IMM8), asm.STA(self.MEMORY_SCROLL_AREA_NUMBER, asm.ABS), asm.JSR(scroll_area_menu.invoke, asm.ABS), - asm.JSR(self.refresh_sprites, asm.ABS), + asm.JSR(self.refresh_sprites, asm.ABS), # JSL asm.RTS(), ] @@ -333,7 +433,15 @@ def invoke_flags_mod(self): space = Write(Bank.C3, src, "pregame track invoke flags") self.invoke_flags = space.start_address + def invoke_flags_submenu_mod(self, submenu_idx): + src = [ + self.InvokeScrollArea(self.flags.submenus[submenu_idx]), + ] + space = Write(Bank.C3, src, "pregame track invoke flags submenu") + self.invoke_flags_submenu[submenu_idx] = space.start_address + def sustain_scroll_area_mod(self): + # Called via JMP src = [ asm.TDC(), asm.STA(0x2a, asm.DIR), @@ -370,13 +478,13 @@ def exit_scroll_area_mod(self): asm.STA(scroll_area.MEMORY_PAGE_POSITIONS_START_ADDR, asm.ABS_Y), "UPDATE_PREGAME_TRACK_CURSOR", - asm.JSR(self.update_navigation_data, asm.ABS), - asm.JSR(self.remember_cursor_position, asm.ABS), + asm.JSR(self.update_navigation_data, asm.ABS), + asm.JSR(self.remember_cursor_position, asm.ABS), asm.JSR(self.update_cursor_position, asm.ABS), - asm.RTS(), + asm.RTL(), ] - space = Write(Bank.C3, src, "pregame track exit scroll area") + space = Write(Bank.F0, src, "pregame track exit scroll area") self.exit_scroll_area = space.start_address def load_sprite_palettes_mod(self): @@ -384,7 +492,7 @@ def load_sprite_palettes_mod(self): # copy the original palettes here so the sprite hash is not affected by custom palette changes palette_size = 16 * 2 # 16 colors, 2 bytes each palettes_size = palette_size * 6 # 6 palettes * 16 colors * 2 bytes - palette_space = Allocate(Bank.C3, palettes_size, "pregame track palettes") + palette_space = Allocate(Bank.F0, palettes_size, "pregame track palettes") palette0_address = palette_space.next_address palette_space.write( @@ -452,7 +560,7 @@ def load_sprite_palettes_mod(self): "LOAD_COLOR_LOOP_START", asm.A16(), - asm.LDA(palettes, asm.LNG_X), # load current color + asm.LDA(palettes + START_ADDRESS_SNES, asm.LNG_X), # load current color asm.STA(0x7e3149, asm.LNG_X), # store in ram asm.A8(), asm.STA(0x2122, asm.ABS), # store low byte in cgram @@ -463,10 +571,10 @@ def load_sprite_palettes_mod(self): asm.CPX(palettes_size, asm.IMM16), # all colors loaded? asm.BNE("LOAD_COLOR_LOOP_START"), # loop if not - asm.JSR(0x6ce9, asm.ABS), # load single pose for characters terra, locke, ..., ghost, kefka + c3.eggers_jump(0x6ce9), # load single pose for characters terra, locke, ..., ghost, kefka asm.RTS(), ] - space = Write(Bank.C3, src, "pregame track load sprite palettes") + space = Write(Bank.F0, src, "pregame track load sprite palettes") self.load_sprite_palettes = space.start_address def refresh_sprites_mod(self): @@ -480,7 +588,7 @@ def refresh_sprites_mod(self): src += [ x_start + x_spacing * index, ] - space = Write(Bank.C3, src, "pregame track refresh sprites x positions") + space = Write(Bank.F0, src, "pregame track refresh sprites x positions") x_positions_address = space.start_address y_start = 0x32 # higher is lower on screen @@ -490,20 +598,20 @@ def refresh_sprites_mod(self): src += [ y_start + entry.y_offset * 8, ] - space = Write(Bank.C3, src, "pregame track refresh sprites y positions") + space = Write(Bank.F0, src, "pregame track refresh sprites y positions") y_positions_address = space.start_address # if not zero, these palettes override sprite oam palettes (at 0xc31324) src = [ 0x00, 0x00, 0x00, 0x00, ] - space = Write(Bank.C3, src, "pregame track refresh sprites palettes address") + space = Write(Bank.F0, src, "pregame track refresh sprites palettes address") palettes_address = space.start_address src = [ HASH_CHARACTERS, ] - space = Write(Bank.C3, src, "pregame track refresh sprites characters address") + space = Write(Bank.F0, src, "pregame track refresh sprites characters address") characters_address = space.start_address # modified version of c31903 used for save/load menus @@ -546,6 +654,7 @@ def refresh_sprites_mod(self): asm.RTS(), ] + # Keep in C3 -- called by JMP methods that are called from C3 JSR jump table space = Write(Bank.C3, src, "pregame track refresh sprites") self.refresh_sprites = space.start_address @@ -576,8 +685,8 @@ def initialize_mod(self): asm.LDA(menu_flags, asm.IMM8), asm.STA(0x46, asm.DIR), - asm.JSR(0x352f, asm.ABS), # reset oam/queue/etc.. blank screen - asm.JSR(0x6904, asm.ABS), # reset BG1-3 x/y + c3.eggers_jump(0x352f), # reset oam/queue/etc.. blank screen + c3.eggers_jump(0x6904), # reset BG1-3 x/y asm.LDA(bg1_size_position, asm.IMM8), # a = BG1 size and position asm.STA(0x2107, asm.ABS), # BG1 64x32 at $0000 @@ -585,24 +694,24 @@ def initialize_mod(self): asm.STA(0x43, asm.DIR), # set active hdma channels asm.JSR(self.draw_layout, asm.ABS), - asm.JSR(self.decrease_line_height, asm.ABS), + c3.eggers_jump(self.decrease_line_height), asm.JSR(self.initialize_scroll_area, asm.ABS), - asm.JSR(0x6a19, asm.ABS), # clear BG1 b - asm.JSR(0x6a3c, asm.ABS), # clear BG3 a - asm.JSR(0x6a41, asm.ABS), # clear BG3 b - asm.JSR(0x6a46, asm.ABS), # clear BG3 c - asm.JSR(0x6a4b, asm.ABS), # clear BG3 d + c3.eggers_jump(0x6a19), # clear BG1 b + c3.eggers_jump(0x6a3c), # clear BG3 a + c3.eggers_jump(0x6a41), # clear BG3 b + c3.eggers_jump(0x6a46), # clear BG3 c + c3.eggers_jump(0x6a4b), # clear BG3 d asm.JSR(self.draw_labels, asm.ABS), - asm.JSR(0x6ca5, asm.ABS), # load cursor colors, skip loading status icon colors at 0x6c84 + c3.eggers_jump(0x6ca5), # load cursor colors, skip loading status icon colors at 0x6c84 asm.JSR(self.load_sprite_palettes, asm.ABS), asm.JSR(self.initialize_cursor, asm.ABS), - asm.RTS(), + asm.RTL(), ] - space = Write(Bank.C3, src, "pregame track initialize") + space = Write(Bank.F0, src, "pregame track initialize") self.initialize = space.start_address def wait_for_fade_mod(self): @@ -614,6 +723,7 @@ def wait_for_fade_mod(self): "LOAD_SPRITE_DATA", asm.JMP(self.refresh_sprites, asm.ABS), ] + # Keep in C3 -- called by C3 JSR jump table space = Write(Bank.C3, src, "pregame track wait for fade") self.wait_for_fade = space.start_address @@ -626,6 +736,7 @@ def fade_in_mod(self): asm.STA(0x26, asm.DIR), # add wait for fade to queue asm.JMP(self.refresh_sprites, asm.ABS), ] + # Keep in C3 -- called by C3 JSR jump table space = Write(Bank.C3, src, "pregame track fade in") self.fade_in = space.start_address @@ -638,6 +749,7 @@ def fade_out_mod(self): asm.STA(0x26, asm.DIR), # add wait for fade to queue asm.JMP(self.refresh_sprites, asm.ABS), # refresh sprites ] + # Keep in C3 -- called by C3 JSR jump table space = Write(Bank.C3, src, "pregame track fade out") self.fade_out = space.start_address @@ -668,8 +780,10 @@ def mod(self): self.invoke_checks_mod() self.invoke_progress_mod() self.invoke_flags_mod() - self.sustain_scroll_area_mod() + for submenu_idx in self.flags.submenus.keys(): + self.invoke_flags_submenu_mod(submenu_idx) self.exit_scroll_area_mod() + self.sustain_scroll_area_mod() self.initialize_mod() self.wait_for_fade_mod() diff --git a/menus/pregame_track_scroll_area.py b/menus/pregame_track_scroll_area.py index 89ed581e..ac6bffa3 100644 --- a/menus/pregame_track_scroll_area.py +++ b/menus/pregame_track_scroll_area.py @@ -1,6 +1,7 @@ from memory.space import START_ADDRESS_SNES, Bank, Write import instruction.asm as asm import instruction.f0 as f0 +import instruction.c3 as c3 from enum import IntEnum from collections import namedtuple @@ -136,7 +137,7 @@ def invoke_mod(self): asm.AND(0x40, asm.IMM8), # cursor memory enabled? asm.BEQ("UPDATE_CURSOR"), # branch if not - asm.JSR(self.remember_cursor, asm.ABS), + asm.JSL(self.remember_cursor + START_ADDRESS_SNES), "UPDATE_CURSOR", asm.JSR(0x7d25, asm.ABS), # update cursor @@ -221,7 +222,7 @@ def draw_line_mod(self): asm.DEY(), # decrement count asm.BNE("LOOP_START"), # branch if more characters in line asm.STZ(0x2180, asm.ABS), # write end of string - asm.RTS(), + asm.RTL(), "WRITE_BLANK_LINE", asm.LDY(WIDTH, asm.IMM16), @@ -231,14 +232,14 @@ def draw_line_mod(self): asm.DEY(), asm.BNE("WRITE_BLANK_LINE_LOOP"), asm.STZ(0x2180, asm.ABS), - asm.RTS(), + asm.RTL(), ] - space = Write(Bank.C3, src, "pregame track scroll area write line") + space = Write(Bank.F0, src, "pregame track scroll area write line") write_line = space.start_address src = [ asm.JSL(START_ADDRESS_SNES + self.initialize_line), - asm.JSR(write_line, asm.ABS), + asm.JSL(START_ADDRESS_SNES + write_line), asm.JSR(0x37fd9, asm.ABS), # draw line asm.RTS(), ] @@ -252,10 +253,10 @@ def remember_cursor_mod(self): asm.LDA(0x4f, asm.DIR), asm.STA(0x4d, asm.DIR), asm.LDA(self.memory_page_position, asm.ABS), - asm.JSR(0x0e1e, asm.ABS), - asm.RTS(), + c3.eggers_jump(0x0e1e), + asm.RTL(), ] - space = Write(Bank.C3, src, "pregame track scroll area remember cursor") + space = Write(Bank.F0, src, "pregame track scroll area remember cursor") self.remember_cursor = space.start_address def remember_scrollbar_mod(self): @@ -268,9 +269,9 @@ def remember_scrollbar_mod(self): asm.STA(0x7e354a, asm.LNG_X), asm.A8(), - asm.RTS(), + asm.RTL(), ] - space = Write(Bank.C3, src, "pregame track scroll area remember scrollbar") + space = Write(Bank.F0, src, "pregame track scroll area remember scrollbar") self.remember_scrollbar = space.start_address def remember_draw_mod(self): @@ -278,13 +279,13 @@ def remember_draw_mod(self): asm.LDA(self.MENU_NUMBER, asm.IMM8), asm.STA(0x0200, asm.ABS), - asm.JSR(self.remember_cursor, asm.ABS), - asm.JSR(self.remember_scrollbar, asm.ABS), + asm.JSL(self.remember_cursor + START_ADDRESS_SNES), + asm.JSL(self.remember_scrollbar + START_ADDRESS_SNES), - asm.JSR(draw, asm.ABS), - asm.RTS(), + c3.eggers_jump(draw), + asm.RTS(), ] - space = Write(Bank.C3, src, "pregame track scroll area remember draw") + space = Write(Bank.F0, src, "pregame track scroll area remember draw") self.remember_draw = space.start_address def mod(self): diff --git a/menus/rage.py b/menus/rage.py new file mode 100644 index 00000000..71a51dce --- /dev/null +++ b/menus/rage.py @@ -0,0 +1,138 @@ +from memory.space import Bank, Reserve, Allocate, Write +import instruction.asm as asm +from data.spell_names import name_id, id_name + + +class RageMenu: + def __init__(self, rages, enemies): + self.rages = rages + self.enemies = enemies + + # Build an array to lookup enemy-specific Special command effects + from constants.status_effects import A, B, C, D + self.status_effects = [] + self.status_effects.extend(list(A.id_name.values())) + self.status_effects.extend(list(B.id_name.values())) + self.status_effects.extend(list(C.id_name.values())) + self.status_effects.extend(list(D.id_name.values())) + + # Remove death from the status effects list, as it requires a second bit from flags1 + self.status_effects = list(map(lambda x: x.replace("Death", ""), self.status_effects)) + + # Remove other statuses that aren't really relevant + self.status_effects = list(map(lambda x: x.replace("Near Fatal", ""), self.status_effects)) + self.status_effects = list(map(lambda x: x.replace("Hide", ""), self.status_effects)) + + self.special_effects = [] + self.special_effects.extend(self.status_effects) + for i in range(15, 95, 5): # going from 1.5x - 9.0x damage + dmg_multiplier = i / 10 + self.special_effects.append(f"{dmg_multiplier:.1f}x dmg") + self.special_effects.append("Drain HP") + self.special_effects.append("Drain MP") + self.special_effects.append("Remove Reflect") + + self.mod() + + def get_rage_string(self, id, attack_id): + from data.spell_names import id_name, name_id + + if attack_id == name_id["Special"]: + # handle special name lookup + special attack info (dmg multipler, status effect) + enemy = self.enemies.enemies[id] + special_name = enemy.special_name + special_effect = enemy.special_effect + + rage_str = f"{special_name}: " + rage_str += self.special_effects[special_effect] + else: + rage_str = f"{id_name[attack_id]}" + + # # remove duplicate white spaces + import re + rage_str = re.sub(' +', ' ', rage_str) + + # remove leading and trailing spaces + rage_str = rage_str.strip() + return rage_str + + def draw_ability_names_mod(self): + import data.text as text + + # Get the custom strings for each rage to be written to the ROM + lines = [] + for rage in self.rages.rages: + # Only focusing on attack2, as attack1 is simply "Battle" -- if that changes in the future, this string can be revisited + rage_str = f"{self.get_rage_string(rage.id, rage.attack2)}" + lines.append(rage_str) + + line_offsets = [0] + running_offset = 0 + # Write the lines to F0 + src = [] + for line in lines: + # convert to bytes + bytes = text.get_bytes(line, text.TEXT3) + running_offset += len(bytes) + line_offsets.append(running_offset) + src.append(bytes) + space = Write(Bank.F0, src, "rage description lines table") + lines_table = space.start_address_snes + + # write the 2-byte line offsets to F0 + src = [] + for offset in line_offsets: + src.append(offset.to_bytes(2, 'little')) + space = Write(Bank.F0, src, "rage description lines table offsets") + lines_table_offsets = space.start_address_snes + + src = [ + asm.LDX(0x9ec9, asm.IMM16), # dest WRAM LBs + asm.STX(0x2181, asm.ABS), # store dest WRAM LBs + + asm.TDC(), # a = 0x0000 + asm.LDA(0x4b, asm.DIR), # a = cursor index (rage index) + asm.TAX(), # x = cursor index (rage index) + asm.LDA(0x7e9d89, asm.LNG_X), # a = rage at cursor index + asm.CMP(0xff, asm.IMM8), # compare with no rage + asm.BEQ("END_STRING_RETURN"), # branch if rage at cursor index not learned + asm.A16(), + asm.ASL(), # a = rage index * 2 (2 bytes per table offset) + asm.TAX(), # x = rage index * 2 + asm.LDA(lines_table_offsets, asm.LNG_X), # get the offset + asm.TAX(), + asm.A8(), + "STRING_LOOP_START", + asm.LDA(lines_table, asm.LNG_X), # get the character + asm.STA(0x2180, asm.ABS), # add character to string + asm.CMP(0x00, asm.IMM8), # was it the end of the string? + asm.BEQ("RETURN"), # if so, be done + asm.INX(), # move to next character in ability name + asm.BRA("STRING_LOOP_START"), + "END_STRING_RETURN", + asm.STZ(0x2180, asm.ABS), # end string + "RETURN", + asm.RTS(), + ] + space = Write(Bank.C3, src, "draw ability names") + draw_ability_names = space.start_address + + sustain_replace = 0x328c6 # handle L and R + replace_size = 3 # replacing jsr instructions + + src = [ + asm.LDA(0x10, asm.IMM8),# enable description menu flag bitmask + asm.TRB(0x45, asm.DIR), # enable descriptions + asm.JSR(0x4c52, asm.ABS), # displaced code: handle D-Pad + asm.JMP(draw_ability_names, asm.ABS), + ] + space = Write(Bank.C3, src, "sustain rage list") + sustain_rage_list = space.start_address + + space = Reserve(sustain_replace, sustain_replace + replace_size - 1, "rage menu sustain handle D-Pad") + space.write( + asm.JSR(sustain_rage_list, asm.ABS), + ) + + def mod(self): + self.draw_ability_names_mod() \ No newline at end of file diff --git a/menus/track.py b/menus/track.py index 10f04fbf..28fd2d3d 100644 --- a/menus/track.py +++ b/menus/track.py @@ -1,5 +1,6 @@ -from memory.space import Bank, Write, Reserve, Allocate, Read +from memory.space import START_ADDRESS_SNES, Bank, Write, Reserve, Allocate, Read import instruction.asm as asm +import instruction.c3 as c3 class TrackMenu: MENU_NUMBER = 10 @@ -192,10 +193,10 @@ def invoke_mod(self): def initialize_mod(self): src = [ - asm.JSR(self.common.initialize, asm.ABS), + asm.JSL(self.common.initialize + START_ADDRESS_SNES), asm.JSR(self.draw_options, asm.ABS), - asm.JSR(self.common.upload_bg123ab, asm.ABS), + asm.JSL(self.common.upload_bg123ab + START_ADDRESS_SNES), asm.LDA(self.MENU_NUMBER, asm.IMM8), asm.STA(0x0200, asm.ABS), @@ -206,6 +207,7 @@ def initialize_mod(self): asm.STA(0x26, asm.DIR), # add fade in menu to queue asm.JMP(0x3541, asm.ABS), # set brightness and refresh screen ] + # called by C3 JSR jump table space = Write(Bank.C3, src, "track initialize") self.initialize = space.start_address @@ -222,7 +224,8 @@ def sustain_mod(self): src = [ asm.JSR(self.common.refresh_sprites, asm.ABS), - asm.LDA(0x0200, asm.ABS), + # if in a scroll-area menu, sustain the scroll area + asm.LDA(0x0200, asm.ABS), asm.CMP(self.common.objectives.MENU_NUMBER, asm.IMM8), asm.BEQ("SUSTAIN_SCROLL_AREA"), asm.CMP(self.common.checks.MENU_NUMBER, asm.IMM8), @@ -231,7 +234,15 @@ def sustain_mod(self): asm.BEQ("SUSTAIN_SCROLL_AREA"), asm.CMP(self.common.flags.MENU_NUMBER, asm.IMM8), asm.BEQ("SUSTAIN_SCROLL_AREA"), + ] + for submenu_idx in self.common.flags.submenus.keys(): + src += [ + asm.CMP(self.common.flags.submenus[submenu_idx].MENU_NUMBER, asm.IMM8), + asm.BEQ("SUSTAIN_SCROLL_AREA"), + ] + + src += [ asm.JSR(0x072d, asm.ABS), # handle d-pad asm.LDY(self.common.cursor_positions, asm.IMM16), asm.JSR(0x0640, asm.ABS), # update cursor position @@ -252,8 +263,9 @@ def sustain_mod(self): "HANDLE_B", asm.LDA(0x09, asm.DIR), # load buttons pressed this frame asm.BIT(0x80, asm.IMM8), # b pressed? - asm.BEQ("RETURN"), # return if not - + asm.BNE("B_PRESSED"), # return if not + asm.RTS(), + "B_PRESSED", asm.LDA(0x04, asm.IMM8), # a = initialize main menu command asm.STA(0x27, asm.DIR), # add initialize main menu to queue asm.LDA(self.common.FADE_OUT_COMMAND, asm.IMM8), @@ -261,19 +273,23 @@ def sustain_mod(self): asm.RTS(), "SUSTAIN_SCROLL_AREA", - asm.LDA(0x0d, asm.DIR), - asm.BIT(0x80, asm.IMM8), # b pressed? - asm.BNE("EXIT_SCROLL_AREA"), + asm.LDA(0x09, asm.DIR), + asm.BIT(0x80, asm.IMM8), # b pressed? + asm.BNE("EXIT_SCROLL_AREA"), # branch if so + ] + + for submenu_idx in self.common.flags.submenus.keys(): + src.extend(self.common.get_submenu_src(submenu_idx, self.common.invoke_flags_submenu[submenu_idx])) + + src += [ asm.JMP(self.common.sustain_scroll_area, asm.ABS), "EXIT_SCROLL_AREA", - asm.JSR(self.common.exit_scroll_area, asm.ABS), - asm.LDA(self.MENU_NUMBER, asm.IMM8), - asm.STA(0x0200, asm.ABS), - - "RETURN", - asm.RTS(), ] + + src.extend(self.common.get_scroll_area_exit_src(self.MENU_NUMBER, self.common.invoke_flags)) + + # Called by C3 JSR jump table space = Write(Bank.C3, src, "track sustain") self.sustain = space.start_address diff --git a/objectives/conditions/boss.py b/objectives/conditions/boss.py index 8e465871..7a852dbf 100644 --- a/objectives/conditions/boss.py +++ b/objectives/conditions/boss.py @@ -1,14 +1,15 @@ from objectives.conditions._objective_condition import * from constants.objectives.condition_bits import boss_bit +from data.bosses import name_formation, name_pack class Condition(ObjectiveCondition): NAME = "Boss" def __init__(self, boss): self.boss = boss + self.boss_name = boss_bit[self.boss].name + self.boss_formation = name_formation[self.boss_name] + self.boss_pack = name_pack[self.boss_name] super().__init__(ConditionType.BattleBit, boss_bit[self.boss].bit) def __str__(self): - return super().__str__(self.boss) - - def boss_name(self): - return boss_bit[self.boss].name + return super().__str__(self.boss) \ No newline at end of file diff --git a/objectives/conditions/dragon.py b/objectives/conditions/dragon.py index 90c0ca84..37a36664 100644 --- a/objectives/conditions/dragon.py +++ b/objectives/conditions/dragon.py @@ -1,14 +1,16 @@ from objectives.conditions._objective_condition import * from constants.objectives.condition_bits import dragon_bit +from data.bosses import name_formation, name_pack class Condition(ObjectiveCondition): NAME = "Dragon" def __init__(self, dragon): self.dragon = dragon + self.dragon_name = dragon_bit[self.dragon].name + self.dragon_formation = name_formation[self.dragon_name] + self.dragon_pack = name_pack[self.dragon_name] super().__init__(ConditionType.BattleBit, dragon_bit[self.dragon].bit) def __str__(self): return super().__str__(self.dragon) - def dragon_name(self): - return dragon_bit[self.dragon].name diff --git a/objectives/results/auto_dog_block.py b/objectives/results/auto_dog_block.py new file mode 100644 index 00000000..5a8c77a5 --- /dev/null +++ b/objectives/results/auto_dog_block.py @@ -0,0 +1,14 @@ +from objectives.results._objective_result import * + +class Field(field_result.Result): + def src(self): + return [] + +class Battle(battle_result.Result): + def src(self): + return [] + +class Result(ObjectiveResult): + NAME = "Auto Dog Block" + def __init__(self): + super().__init__(Field, Battle) diff --git a/objectives/results/auto_life_3.py b/objectives/results/auto_life_3.py new file mode 100644 index 00000000..7b20f62b --- /dev/null +++ b/objectives/results/auto_life_3.py @@ -0,0 +1,14 @@ +from objectives.results._objective_result import * + +class Field(field_result.Result): + def src(self): + return [] + +class Battle(battle_result.Result): + def src(self): + return [] + +class Result(ObjectiveResult): + NAME = "Auto Life 3" + def __init__(self): + super().__init__(Field, Battle) diff --git a/objectives/results/forget_spells.py b/objectives/results/forget_spells.py index d5846ba5..0b0eaf1a 100644 --- a/objectives/results/forget_spells.py +++ b/objectives/results/forget_spells.py @@ -5,12 +5,17 @@ def _random_spell_table(): from constants.spells import spell_id spell_table = list(range(len(spell_id))) - if args.no_ultima: - spell_table.remove(spell_id["Ultima"]) + + for a_spell_id in args.remove_learnable_spell_ids: + spell_table.remove(a_spell_id) + random.shuffle(spell_table) - space = Write(Bank.F0, spell_table, "forget spells random spell table") - return space.start_address, len(spell_table) + if len(spell_table) > 0: + space = Write(Bank.F0, spell_table, "forget spells random spell table") + return space.start_address, len(spell_table) + else: + return None, 0 random_forget_spell_table, random_forget_spell_table_size = _random_spell_table() def _forget_random_spells(): @@ -27,61 +32,66 @@ def _forget_random_spells(): learned_start = 0x30 # temporarily store learned_start_address in field/battle scratch ram spells_left = 0x32 # temporarily store number spells left to forget in field/battle scratch ram - src = [ - asm.PHP(), - asm.XY16(), - asm.LDY(learned_start, asm.DIR), - asm.PHY(), # store value at $learned_start to be restored before returning - asm.LDA(spells_left, asm.DIR), - asm.PHA(), # store value at $spells_left to be restored before returning - - asm.LDY(learned_start_address, asm.IMM16), - asm.STY(learned_start, asm.DIR), # $learned_start = start of character's learned spells - asm.LDY(character_count, asm.IMM16), # y = number of characters remaining - - "CHARACTER_LOOP_START", - asm.PHY(), - asm.TDC(), - asm.LDA(field.LongCall.ARG_ADDRESS, asm.DIR), # a = number of spells to forget - asm.STA(spells_left, asm.DIR), # $spells_left = number of spells left to forget - asm.LDX(0x0000, asm.IMM16), # x = spell table index - - "SPELL_LOOP_START", - asm.LDA(START_ADDRESS_SNES + random_forget_spell_table, asm.LNG_X), - asm.TAY(), # y = id of next spell in table - asm.LDA(0xff, asm.IMM8), # a = known spell value - asm.CMP(learned_start, asm.DIR_16_Y), # compare with value at learned start address for character + spell id - asm.BNE("NEXT_SPELL"), # branch if character does not know this spell - asm.LDA(0x00, asm.IMM8), - asm.STA(learned_start, asm.DIR_16_Y), # forget spell - asm.DEC(spells_left, asm.DIR), # decrement number of spells left to forget - asm.BEQ("NEXT_CHARACTER"), # next character if number of spells to forget is 0 - "NEXT_SPELL", - asm.INX(), # next spell in spell table - asm.CPX(random_forget_spell_table_size, asm.IMM16), - asm.BLT("SPELL_LOOP_START"), # branch if spell index < len(spell table) - - "NEXT_CHARACTER", - asm.PLY(), # y = characters remaining - asm.DEY(), - asm.BEQ("RETURN"), # return if zero characters remaining - - asm.A16(), - asm.LDA(learned_start, asm.DIR), # a = start of learned spells for character - asm.CLC(), - asm.ADC(spell_count, asm.IMM16), # add number of spells - asm.STA(learned_start, asm.DIR), # $learned_start = start of learned spells for next character - asm.A8(), - asm.BRA("CHARACTER_LOOP_START"), # forget spells with next character - - "RETURN", - asm.PLA(), - asm.STA(spells_left, asm.DIR), # restore original value at $spells_left - asm.PLY(), - asm.STY(learned_start, asm.DIR), # restore original value at $learned_start - asm.PLP(), - asm.RTL(), - ] + if random_forget_spell_table_size > 0: + src = [ + asm.PHP(), + asm.XY16(), + asm.LDY(learned_start, asm.DIR), + asm.PHY(), # store value at $learned_start to be restored before returning + asm.LDA(spells_left, asm.DIR), + asm.PHA(), # store value at $spells_left to be restored before returning + + asm.LDY(learned_start_address, asm.IMM16), + asm.STY(learned_start, asm.DIR), # $learned_start = start of character's learned spells + asm.LDY(character_count, asm.IMM16), # y = number of characters remaining + + "CHARACTER_LOOP_START", + asm.PHY(), + asm.TDC(), + asm.LDA(field.LongCall.ARG_ADDRESS, asm.DIR), # a = number of spells to forget + asm.STA(spells_left, asm.DIR), # $spells_left = number of spells left to forget + asm.LDX(0x0000, asm.IMM16), # x = spell table index + + "SPELL_LOOP_START", + asm.LDA(START_ADDRESS_SNES + random_forget_spell_table, asm.LNG_X), + asm.TAY(), # y = id of next spell in table + asm.LDA(0xff, asm.IMM8), # a = known spell value + asm.CMP(learned_start, asm.DIR_16_Y), # compare with value at learned start address for character + spell id + asm.BNE("NEXT_SPELL"), # branch if character does not know this spell + asm.LDA(0x00, asm.IMM8), + asm.STA(learned_start, asm.DIR_16_Y), # forget spell + asm.DEC(spells_left, asm.DIR), # decrement number of spells left to forget + asm.BEQ("NEXT_CHARACTER"), # next character if number of spells to forget is 0 + "NEXT_SPELL", + asm.INX(), # next spell in spell table + asm.CPX(random_forget_spell_table_size, asm.IMM16), + asm.BLT("SPELL_LOOP_START"), # branch if spell index < len(spell table) + + "NEXT_CHARACTER", + asm.PLY(), # y = characters remaining + asm.DEY(), + asm.BEQ("RETURN"), # return if zero characters remaining + + asm.A16(), + asm.LDA(learned_start, asm.DIR), # a = start of learned spells for character + asm.CLC(), + asm.ADC(spell_count, asm.IMM16), # add number of spells + asm.STA(learned_start, asm.DIR), # $learned_start = start of learned spells for next character + asm.A8(), + asm.BRA("CHARACTER_LOOP_START"), # forget spells with next character + + "RETURN", + asm.PLA(), + asm.STA(spells_left, asm.DIR), # restore original value at $spells_left + asm.PLY(), + asm.STY(learned_start, asm.DIR), # restore original value at $learned_start + asm.PLP(), + asm.RTL(), + ] + else: # no spells to forget + src = [ + asm.RTL() + ] space = Write(Bank.F0, src, "forget spells forget random spells") return space.start_address forget_random_spells = _forget_random_spells() diff --git a/objectives/results/learn_spells.py b/objectives/results/learn_spells.py index bb29dbf0..601212bd 100644 --- a/objectives/results/learn_spells.py +++ b/objectives/results/learn_spells.py @@ -5,12 +5,17 @@ def _random_spell_table(): from constants.spells import spell_id spell_table = list(range(len(spell_id))) - if args.no_ultima: - spell_table.remove(spell_id["Ultima"]) + + for a_spell_id in args.remove_learnable_spell_ids: + spell_table.remove(a_spell_id) + random.shuffle(spell_table) - space = Write(Bank.F0, spell_table, "learn spells random spell table") - return space.start_address, len(spell_table) + if len(spell_table) > 0: + space = Write(Bank.F0, spell_table, "learn spells random spell table") + return space.start_address, len(spell_table) + else: + return None, 0 random_learn_spell_table, random_learn_spell_table_size = _random_spell_table() def _learn_random_spells(): @@ -27,60 +32,65 @@ def _learn_random_spells(): learned_start = 0x30 # temporarily store learned_start_address in field/battle scratch ram spells_left = 0x32 # temporarily store number spells left to learn in field/battle scratch ram - src = [ - asm.PHP(), - asm.XY16(), - asm.LDY(learned_start, asm.DIR), - asm.PHY(), # store value at $learned_start to be restored before returning - asm.LDA(spells_left, asm.DIR), - asm.PHA(), # store value at $spells_left to be restored before returning - - asm.LDY(learned_start_address, asm.IMM16), - asm.STY(learned_start, asm.DIR), # $learned_start = start of character's learned spells - asm.LDY(character_count, asm.IMM16), # y = number of characters remaining - - "CHARACTER_LOOP_START", - asm.PHY(), - asm.TDC(), - asm.LDA(field.LongCall.ARG_ADDRESS, asm.DIR), # a = number of spells to learn - asm.STA(spells_left, asm.DIR), # $spells_left = number of spells left to learn - asm.LDX(0x0000, asm.IMM16), # x = spell table index - - "SPELL_LOOP_START", - asm.LDA(START_ADDRESS_SNES + random_learn_spell_table, asm.LNG_X), - asm.TAY(), # y = id of next spell in table - asm.LDA(0xff, asm.IMM8), # a = known spell value - asm.CMP(learned_start, asm.DIR_16_Y), # compare with value at learned start address for character + spell id - asm.BEQ("NEXT_SPELL"), # branch if character already knows this spell - asm.STA(learned_start, asm.DIR_16_Y), # learn spell - asm.DEC(spells_left, asm.DIR), # decrement number of spells left to learn - asm.BEQ("NEXT_CHARACTER"), # next character if number of spells to learn is 0 - "NEXT_SPELL", - asm.INX(), # next spell in spell table - asm.CPX(random_learn_spell_table_size, asm.IMM16), - asm.BLT("SPELL_LOOP_START"), # branch if spell index < len(spell table) - - "NEXT_CHARACTER", - asm.PLY(), # y = characters remaining - asm.DEY(), - asm.BEQ("RETURN"), # return if zero characters remaining - - asm.A16(), - asm.LDA(learned_start, asm.DIR), # a = start of learned spells for character - asm.CLC(), - asm.ADC(spell_count, asm.IMM16), # add number of spells - asm.STA(learned_start, asm.DIR), # $learned_start = start of learned spells for next character - asm.A8(), - asm.BRA("CHARACTER_LOOP_START"), # learn spells with next character - - "RETURN", - asm.PLA(), - asm.STA(spells_left, asm.DIR), # restore original value at $spells_left - asm.PLY(), - asm.STY(learned_start, asm.DIR), # restore original value at $learned_start - asm.PLP(), - asm.RTL(), - ] + if random_learn_spell_table_size > 0: + src = [ + asm.PHP(), + asm.XY16(), + asm.LDY(learned_start, asm.DIR), + asm.PHY(), # store value at $learned_start to be restored before returning + asm.LDA(spells_left, asm.DIR), + asm.PHA(), # store value at $spells_left to be restored before returning + + asm.LDY(learned_start_address, asm.IMM16), + asm.STY(learned_start, asm.DIR), # $learned_start = start of character's learned spells + asm.LDY(character_count, asm.IMM16), # y = number of characters remaining + + "CHARACTER_LOOP_START", + asm.PHY(), + asm.TDC(), + asm.LDA(field.LongCall.ARG_ADDRESS, asm.DIR), # a = number of spells to learn + asm.STA(spells_left, asm.DIR), # $spells_left = number of spells left to learn + asm.LDX(0x0000, asm.IMM16), # x = spell table index + + "SPELL_LOOP_START", + asm.LDA(START_ADDRESS_SNES + random_learn_spell_table, asm.LNG_X), + asm.TAY(), # y = id of next spell in table + asm.LDA(0xff, asm.IMM8), # a = known spell value + asm.CMP(learned_start, asm.DIR_16_Y), # compare with value at learned start address for character + spell id + asm.BEQ("NEXT_SPELL"), # branch if character already knows this spell + asm.STA(learned_start, asm.DIR_16_Y), # learn spell + asm.DEC(spells_left, asm.DIR), # decrement number of spells left to learn + asm.BEQ("NEXT_CHARACTER"), # next character if number of spells to learn is 0 + "NEXT_SPELL", + asm.INX(), # next spell in spell table + asm.CPX(random_learn_spell_table_size, asm.IMM16), + asm.BLT("SPELL_LOOP_START"), # branch if spell index < len(spell table) + + "NEXT_CHARACTER", + asm.PLY(), # y = characters remaining + asm.DEY(), + asm.BEQ("RETURN"), # return if zero characters remaining + + asm.A16(), + asm.LDA(learned_start, asm.DIR), # a = start of learned spells for character + asm.CLC(), + asm.ADC(spell_count, asm.IMM16), # add number of spells + asm.STA(learned_start, asm.DIR), # $learned_start = start of learned spells for next character + asm.A8(), + asm.BRA("CHARACTER_LOOP_START"), # learn spells with next character + + "RETURN", + asm.PLA(), + asm.STA(spells_left, asm.DIR), # restore original value at $spells_left + asm.PLY(), + asm.STY(learned_start, asm.DIR), # restore original value at $learned_start + asm.PLP(), + asm.RTL(), + ] + else: # no spells to learn + src = [ + asm.RTL() + ] space = Write(Bank.F0, src, "learn spells learn random spells") return space.start_address learn_random_spells = _learn_random_spells() diff --git a/objectives/results/magitek_upgrade.py b/objectives/results/magitek_upgrade.py new file mode 100644 index 00000000..381edcde --- /dev/null +++ b/objectives/results/magitek_upgrade.py @@ -0,0 +1,14 @@ +from objectives.results._objective_result import * + +class Field(field_result.Result): + def src(self): + return [] + +class Battle(battle_result.Result): + def src(self): + return [] + +class Result(ObjectiveResult): + NAME = "Magitek Upgrade" + def __init__(self): + super().__init__(Field, Battle) diff --git a/objectives/results/sprint_shoes.py b/objectives/results/sprint_shoes.py new file mode 100644 index 00000000..4318850c --- /dev/null +++ b/objectives/results/sprint_shoes.py @@ -0,0 +1,19 @@ +from objectives.results._objective_result import * +from data.item_names import name_id as item_name_id + +class Field(field_result.Result): + def src(self): + return [ + field.AddItem(item_name_id["Sprint Shoes"]), + ] + +class Battle(battle_result.Result): + def src(self): + return [ + battle_result.AddItem(item_name_id["Sprint Shoes"]), + ] + +class Result(ObjectiveResult): + NAME = "Sprint Shoes" + def __init__(self): + super().__init__(Field, Battle) diff --git a/settings/__init__.py b/settings/__init__.py index 4510fb25..d0355987 100644 --- a/settings/__init__.py +++ b/settings/__init__.py @@ -1,8 +1,9 @@ -from settings.auto_sprint import AutoSprint -from settings.scan_all import ScanAll +from settings.initial_spells import InitialSpells +from settings.movement import Movement from settings.random_rng import RandomRNG from settings.permadeath import Permadeath from settings.y_npc import YNPC +from settings.config import Config from memory.space import Reserve import instruction.asm as asm @@ -10,11 +11,12 @@ __all__ = ["Settings"] class Settings: def __init__(self): - self.auto_sprint = AutoSprint() - self.scan_all = ScanAll() + self.initial_spells = InitialSpells() + self.movement = Movement() self.random_rng = RandomRNG() self.permadeath = Permadeath() self.y_npc = YNPC() + self.config = Config() # do not auto load save file after game over space = Reserve(0x00c4fe, 0x00c500, "load where to return to after game over", asm.NOP()) diff --git a/settings/auto_sprint.py b/settings/auto_sprint.py deleted file mode 100644 index e03252db..00000000 --- a/settings/auto_sprint.py +++ /dev/null @@ -1,36 +0,0 @@ -from memory.space import Reserve -import instruction.asm as asm -import args - -class AutoSprint: - def __init__(self): - if args.auto_sprint: - self.mod() - - def mod(self): - # set sprint by default, b button to walk, sprint shoes do nothing - - WALK_SPEED = 2 - SPRINT_SPEED = 3 - - CONTROLLER1_BYTE2 = 0x4219 - B_BUTTON_MASK = 0x80 - FIELD_RAM_SPEED = 0x0875 - - src = [ - asm.LDA(CONTROLLER1_BYTE2, asm.ABS), - asm.AND(B_BUTTON_MASK, asm.IMM8), - asm.BNE("WALK"), # branch if b button down - - "SPRINT", - asm.LDA(SPRINT_SPEED, asm.IMM8), - asm.BRA("STORE_SPEED"), - - "WALK", - asm.LDA(WALK_SPEED, asm.IMM8), - - "STORE_SPEED", - asm.STA(FIELD_RAM_SPEED, asm.ABS_Y), - ] - space = Reserve(0x04e21, 0x04e37, "auto sprint", asm.NOP()) - space.write(src) diff --git a/settings/config.py b/settings/config.py new file mode 100644 index 00000000..d1677e1f --- /dev/null +++ b/settings/config.py @@ -0,0 +1,51 @@ +from memory.space import Reserve, Bank, Write +import instruction.asm as asm +import args + +class Config: + def __init__(self): + self.mod() + + def mod(self): + # Thanks to DoctorDT for most of this code + + # Set default configuration options to the most popular: + # Config1: Msg Speed = 1 (Fastest), Bat Speed = 6 (Slowest), Bat Mode = 1 (Wait) + + # Config 1, set by this code: + # C3/70B8: A92A LDA #$2A ; Bat.Mode, etc. + # RAM $1D4D, one byte sets: cmmm wbbb (command set c, message spd mmm + 1, battle mode w, battle speed bbb + 1) + space = Reserve(0x370b9, 0x370b9, "config 1 default") + space.write(0x0D) # default: 0x2A + + # Moving default location for Config 2 and 3 to support command line re-configuration + # Set default memory location for Config #2: + src = [ + asm.LDA(0x00, asm.IMM8), # LDA #$00; + asm.STA(0x1D54, asm.ABS), # STA $1D54; # Config #2 + asm.RTS(), + ] + space = Write(Bank.C3, src, "Config #2 default value") + + # Update the JSR for Config default #2 + config2_loc = space.start_address + space = Reserve(0x370c2, 0x370c4, "Config_2_default") # 0x0370C2: ['20', PP, NN, '20', PP + 06, NN]]) # JSR #$CONF2; JSR #$CONF3 + space.write( + asm.JSR(config2_loc, asm.ABS), + ) + # Config 3, set by this code: + # C3/70C5: 9C4E1D STZ $1D4E ; Wallpaper, etc. + # RAM $1D4E, one byte sets: gcsr wwww (gauge g, cursor c, sound s, reequip r, wallpaper wwww (0-7)) + src = [ + asm.LDA(0x00, asm.IMM8), # default: 0 + asm.STA(0x1D4E, asm.ABS), + asm.RTS(), + ] + space = Write(Bank.C3, src, "Config_3_default") + + # Update the JSR for Config default #3 + config3_loc = space.start_address + space = Reserve(0x370c5, 0x370c7, "Config_3_default") + space.write( + asm.JSR(config3_loc, asm.ABS), + ) diff --git a/settings/initial_spells.py b/settings/initial_spells.py new file mode 100644 index 00000000..cc83fecf --- /dev/null +++ b/settings/initial_spells.py @@ -0,0 +1,68 @@ +from memory.space import START_ADDRESS_SNES, Bank, Reserve, Write, Read +import instruction.asm as asm +import args + +class InitialSpells: + def __init__(self): + from data.spell_names import name_id + + self.initial_spells = [] + if args.scan_all: + self.initial_spells.append(name_id["Scan"]) + + if args.warp_all: + self.initial_spells.append(name_id["Warp"]) + + if len(self.initial_spells) > 0: + self.teach_spells() + + def teach_spells(self): + from data.spells import Spells + from data.characters import Characters + + learned_spells_start = 0x1a6e + learner_count = Characters.CHARACTER_COUNT - 2 # no gogo/umaro + last_offset = Spells.SPELL_COUNT * learner_count + + src = [ + Read(0x0bdcc, 0x0bdd6), # initialize spells to 0% learned + + asm.PHY(), + asm.LDX(0x00, asm.DIR), # x = 0x0000 + + "CHARACTER_LOOP_START", + ] + for spell_id in self.initial_spells: + src += [ + # Put spell offset from learned_spells_start in Y (X [character offset] + spell_id) + asm.A16(), + asm.TXA(), + asm.CLC(), + asm.ADC(spell_id, asm.IMM16), + asm.TAY(), + asm.A8(), + # Write 0xff to the learned_spells_start + Y to initialize it as learned + asm.LDA(0xff, asm.IMM8), # a = 0xff (spell learned value) + asm.STA(learned_spells_start, asm.ABS_Y), # set spell learned for current character + ] + src += [ + asm.A16(), + asm.TXA(), # a = spell address offset for current character + asm.CLC(), + asm.ADC(Spells.SPELL_COUNT, asm.IMM16), # go to next character + asm.TAX(), # x = spell address offset for next character + asm.A8(), + asm.CPX(last_offset, asm.IMM16), # all characters done? + asm.BLT("CHARACTER_LOOP_START"), # branch if not + + asm.PLY(), + asm.TDC(), + asm.RTL(), + ] + space = Write(Bank.F0, src, "scan all learn_scan") + learn_spells_snes = space.start_address_snes + + space = Reserve(0x0bdcc, 0x0bdd6, "initialize spells and learn initial", asm.NOP()) + space.write( + asm.JSL(learn_spells_snes), + ) diff --git a/settings/movement.py b/settings/movement.py new file mode 100644 index 00000000..de9b3e9f --- /dev/null +++ b/settings/movement.py @@ -0,0 +1,164 @@ +from memory.space import Allocate, Bank, Reserve, Write +import instruction.asm as asm +from data.movement import AUTO_SPRINT, B_DASH, ORIGINAL, SPRINT_SHOES_B_DASH, MovementSpeed + +class Movement: + def __init__(self): + import args + self.movement = args.movement or AUTO_SPRINT + + if self.movement != ORIGINAL: + self.mod() + + def mod(self): + length = 0 + src = [] + + src = self.get_auto_sprint_src() + + space = Write(Bank.F0, src, "Sprint subroutine") + + src = [ + asm.JSL(space.start_address_snes), + ] + space = Reserve(0x04e21, 0x04e37, "auto sprint", asm.NOP()) + space.write(src) + + self.sliding_dash_fix() + + + def get_auto_sprint_src(self): + import args + CURRENT_MAP_BYTE = 0x82 # 2 bytes + OWZERS_MANSION_ID = 0x00CF # the door room can create visual artifacts on the map while dashing + CONTROLLER1_BYTE2 = 0x4219 + SPRINT_SHOES_BYTE = 0x11df + SPRINT_SHOES_MASK = 0x20 + B_BUTTON_MASK = 0x80 + FIELD_RAM_SPEED = 0x0875 + + # moving at dash speed in Owzer's door room, or carrying it out via the door glitch will cause graphical artifacting randomly. + # Simply disabling B button in Owzers to keep it consistent in WC. Will not worry about the door glitch + owzers_src = [ + "CHECK_OWZERS", + asm.A16(), # set register A bit size to 16 + asm.LDA(CURRENT_MAP_BYTE, asm.ABS), # if current map owzers mansion, disable the b-button + asm.CMP(OWZERS_MANSION_ID, asm.IMM16), + asm.BEQ("STORE_DEFAULT"), + asm.LDA(0x0000, asm.IMM16), # clear A, otherwise will cause issues in albrook/imperial base + asm.A8(), + ] + + src = [ + "B_BUTTON_CHECK", + asm.LDA(CONTROLLER1_BYTE2, asm.ABS), + asm.AND(B_BUTTON_MASK, asm.IMM8), + asm.BEQ("STORE_DEFAULT"), # do nothing if b pressed + ] + + if self.movement == AUTO_SPRINT: + src += [ + "ON_B_BUTTON", + asm.LDA(MovementSpeed.WALK, asm.IMM8), + asm.BRA("STORE"), + ] + elif self.movement == B_DASH: + src += owzers_src + src += [ + "ON_B_BUTTON", + asm.LDA(MovementSpeed.DASH, asm.IMM8), + asm.BRA("STORE"), + ] + + elif self.movement == SPRINT_SHOES_B_DASH: + src += owzers_src + src += [ + "ON_B_BUTTON", + asm.LDA(SPRINT_SHOES_BYTE, asm.ABS), # If sprint shoes equipped, store secondary movement speed + asm.AND(SPRINT_SHOES_MASK, asm.IMM8), + asm.BEQ("WALK") + ] + src += [ + "DASH", + asm.LDA(MovementSpeed.DASH, asm.IMM8), + asm.BRA("STORE"), + "WALK", + asm.LDA(MovementSpeed.WALK, asm.IMM8), + asm.BRA("STORE"), + ] + + src += [ + "STORE_DEFAULT", + asm.A8(), + asm.LDA(MovementSpeed.SPRINT, asm.IMM8), + + "STORE", + asm.STA(FIELD_RAM_SPEED, asm.ABS_Y), # store speed in ram + asm.RTL(), # return + ] + + return src + + + # DIRECTION VALUE + # $087F ------dd + # d: facing direction + # 00 = up + # 01 = right + # 10 = down + # 11 = left + + # https://silentenigma.neocities.org/ff6/index.html + # Will leave bits of documentation about in the event neocities does not stand the test of time + + # With dash enabled, this causes a bug that the player will appear to be standing still when + # running down or right at move speed 5. This is because two sprite instances are thrown out of + # the animation cycle when running at that speed; while Up/Left correctly omits the standing + # sprite, Down/Right omits the stepping sprites, since the offsets lag by an iteration. + def sliding_dash_fix(self): + DIRECTION_VALUE = 0x087f + + # C0/0000: 4A LSR ; Shift offset bits right + # C0/0001: 4A LSR ; Shift offset bits right + # C0/0002: 48 PHA ; Push offset value to stack + # C0/0003: B9 7F 08 LDA $087F,y ; Load direction value + # C0/0006: C9 01 CMP #$01 ; Check if direction is Right + # C0/0008: F0 07 BEQ $D69E ; Branch if the direction is Right + # C0/000A: C9 02 CMP #$02 ; Check if the direction is Down + # C0/000C: F0 03 BEQ $D69E ; Branch if the direction is Down + # C0/000E: 68 PLA ; Pull offset back off of stack + # C0/000F: 80 02 BRA $D6A0 ; Branch to the third LSR + # C0/0011: 68 PLA ; Pull offset back off of stack + # C0/0012: 1A INC ; Increase the offset value by 1 + # C0/0013: 4A LSR ; Shift offset bits right + # C0/0014: 60 RTS ; Return from subfunction + subroutine_src = [ + asm.LSR(), + asm.LSR(), + asm.PHA(), + asm.LDA(DIRECTION_VALUE, asm.ABS_Y), + asm.DEC(), + asm.BEQ("FACING_RIGHT"), + asm.DEC(), + asm.BNE("RETURN"), + "FACING_RIGHT", + asm.PLA(), + asm.INC(), + asm.PHA(), + "RETURN", + asm.PLA(), + asm.LSR(), + asm.RTS(), + ] + subroutine_space = Allocate(Bank.C0, 20, "walking speed calculation", asm.NOP()) + subroutine_space.write(subroutine_src) + + src = [ + asm.JSR(subroutine_space.start_address, asm.ABS) + ] + + space = Reserve(0x5885, 0x5887, "Sprite offset calculation 1", asm.NOP()) + space.write(src) + + space = Reserve(0x5892, 0x5894, "Sprite offset calculation 2") + space.write(src) diff --git a/settings/scan_all.py b/settings/scan_all.py deleted file mode 100644 index 140f6491..00000000 --- a/settings/scan_all.py +++ /dev/null @@ -1,48 +0,0 @@ -from memory.space import Bank, Reserve, Write, Read -import instruction.asm as asm -import args - -class ScanAll: - def __init__(self): - if args.scan_all: - self.teach_scan() - - def teach_scan(self): - from data.spells import Spells - from data.spell_names import name_id - from data.characters import Characters - - learned_spells_start = 0x1a6e - scan_id = name_id["Scan"] - - start_addr = learned_spells_start + scan_id - learner_count = Characters.CHARACTER_COUNT - 2 # no gogo/umaro - last_offset = Spells.SPELL_COUNT * learner_count - - src = [ - Read(0x0bdcc, 0x0bdd6), # initialize spells to 0% learned - - asm.LDX(0x00, asm.DIR), # x = 0x0000 - - "LOOP_START", - asm.LDA(0xff, asm.IMM8), # a = 0xff (spell learned value) - asm.STA(start_addr, asm.ABS_X), # set scan learned for current character - asm.A16(), - asm.TXA(), # a = scan address offset for current character - asm.CLC(), - asm.ADC(Spells.SPELL_COUNT, asm.IMM16), # go to next character - asm.TAX(), # x = scan address offset for next character - asm.A8(), - asm.CPX(last_offset, asm.IMM16), # all characters done? - asm.BLT("LOOP_START"), # branch if not - - asm.TDC(), - asm.RTS(), - ] - space = Write(Bank.C0, src, "scan all learn_scan") - learn_scan = space.start_address - - space = Reserve(0x0bdcc, 0x0bdd6, "initialize spells and learn scan", asm.NOP()) - space.write( - asm.JSR(learn_scan, asm.ABS), - ) diff --git a/version.py b/version.py index 6849410a..c68196d1 100644 --- a/version.py +++ b/version.py @@ -1 +1 @@ -__version__ = "1.1.0" +__version__ = "1.2.0" diff --git a/wc.py b/wc.py index a6d8ea49..bff7d690 100644 --- a/wc.py +++ b/wc.py @@ -12,7 +12,7 @@ def main(): events = Events(memory.rom, args, data) from menus.menus import Menus - menus = Menus(data.characters, data.dances) + menus = Menus(data.characters, data.dances, data.rages, data.enemies) from battle import Battle battle = Battle()