import os import threading from pkgutil import get_data from typing import Dict, Any import bsdiff4 import Utils from BaseClasses import Item, Location, Region, Entrance, MultiWorld, ItemClassification, Tutorial from .ItemPool import generate_itempool, starting_weapons, dangerous_weapon_locations from .Items import item_table, item_prices, item_game_ids from .Locations import location_table, level_locations, major_locations, shop_locations, all_level_locations, \ standard_level_locations, shop_price_location_ids, secret_money_ids, location_ids, food_locations from .Options import tloz_options from .Rom import TLoZDeltaPatch, get_base_rom_path, first_quest_dungeon_items_early, first_quest_dungeon_items_late from .Rules import set_rules from worlds.AutoWorld import World, WebWorld from worlds.generic.Rules import add_rule class TLoZWeb(WebWorld): theme = "stone" setup = Tutorial( "Multiworld Setup Tutorial", "A guide to setting up The Legend of Zelda for Archipelago on your computer.", "English", "multiworld_en.md", "multiworld/en", ["Rosalie and Figment"] ) tutorials = [setup] class TLoZWorld(World): """ The Legend of Zelda needs almost no introduction. Gather the eight fragments of the Triforce of Wisdom, enter Death Mountain, defeat Ganon, and rescue Princess Zelda. This randomizer shuffles all the items in the game around, leading to a new adventure every time. """ option_definitions = tloz_options game = "The Legend of Zelda" topology_present = False data_version = 1 base_id = 7000 web = TLoZWeb() item_name_to_id = {name: data.code for name, data in item_table.items()} location_name_to_id = location_table item_name_groups = { 'weapons': starting_weapons, 'swords': { "Sword", "White Sword", "Magical Sword" }, "candles": { "Candle", "Red Candle" }, "arrows": { "Arrow", "Silver Arrow" } } for k, v in item_name_to_id.items(): item_name_to_id[k] = v + base_id for k, v in location_name_to_id.items(): if v is not None: location_name_to_id[k] = v + base_id def __init__(self, world: MultiWorld, player: int): super().__init__(world, player) self.generator_in_use = threading.Event() self.rom_name_available_event = threading.Event() self.levels = None self.filler_items = None @classmethod def stage_assert_generate(cls, multiworld: MultiWorld): rom_file = get_base_rom_path() if not os.path.exists(rom_file): raise FileNotFoundError(rom_file) def create_item(self, name: str): return TLoZItem(name, item_table[name].classification, self.item_name_to_id[name], self.player) def create_event(self, event: str): return TLoZItem(event, ItemClassification.progression, None, self.player) def create_location(self, name, id, parent, event=False): return_location = TLoZLocation(self.player, name, id, parent) return_location.event = event return return_location def create_regions(self): menu = Region("Menu", self.player, self.multiworld) overworld = Region("Overworld", self.player, self.multiworld) self.levels = [None] # Yes I'm making a one-indexed array in a zero-indexed language. I hate me too. for i in range(1, 10): level = Region(f"Level {i}", self.player, self.multiworld) self.levels.append(level) new_entrance = Entrance(self.player, f"Level {i}", overworld) new_entrance.connect(level) overworld.exits.append(new_entrance) self.multiworld.regions.append(level) for i, level in enumerate(level_locations): for location in level: if self.multiworld.ExpandedPool[self.player] or "Drop" not in location: self.levels[i + 1].locations.append( self.create_location(location, self.location_name_to_id[location], self.levels[i + 1])) for level in range(1, 9): boss_event = self.create_location(f"Level {level} Boss Status", None, self.multiworld.get_region(f"Level {level}", self.player), True) boss_event.show_in_spoiler = False self.levels[level].locations.append(boss_event) for location in major_locations: if self.multiworld.ExpandedPool[self.player] or "Take Any" not in location: overworld.locations.append( self.create_location(location, self.location_name_to_id[location], overworld)) for location in shop_locations: overworld.locations.append( self.create_location(location, self.location_name_to_id[location], overworld)) ganon = self.create_location("Ganon", None, self.multiworld.get_region("Level 9", self.player)) zelda = self.create_location("Zelda", None, self.multiworld.get_region("Level 9", self.player)) ganon.show_in_spoiler = False zelda.show_in_spoiler = False self.levels[9].locations.append(ganon) self.levels[9].locations.append(zelda) begin_game = Entrance(self.player, "Begin Game", menu) menu.exits.append(begin_game) begin_game.connect(overworld) self.multiworld.regions.append(menu) self.multiworld.regions.append(overworld) def create_items(self): # refer to ItemPool.py generate_itempool(self) # refer to Rules.py set_rules = set_rules def generate_basic(self): ganon = self.multiworld.get_location("Ganon", self.player) ganon.place_locked_item(self.create_event("Triforce of Power")) add_rule(ganon, lambda state: state.has("Silver Arrow", self.player) and state.has("Bow", self.player)) self.multiworld.get_location("Zelda", self.player).place_locked_item(self.create_event("Rescued Zelda!")) add_rule(self.multiworld.get_location("Zelda", self.player), lambda state: ganon in state.locations_checked) self.multiworld.completion_condition[self.player] = lambda state: state.has("Rescued Zelda!", self.player) def apply_base_patch(self, rom): # The base patch source is on a different repo, so here's the summary of changes: # Remove Triforce check for recorder, so you can always warp. # Remove level check for Triforce Fragments (and maps and compasses, but this won't matter) # Replace some code with a jump to free space # Check if we're picking up a Triforce Fragment. If so, increment the local count # In either case, we do the instructions we overwrote with the jump and then return to normal flow # Remove map/compass check so they're always on # Removing a bit from the boss roars flags, so we can have more dungeon items. This allows us to # go past 0x1F items for dungeon items. base_patch = get_data(__name__, os.path.join(os.path.dirname(__file__), "z1_base_patch.bsdiff4")) rom_data = bsdiff4.patch(rom.read(), base_patch) rom_data = bytearray(rom_data) # Set every item to the new nothing value, but keep room flags. Type 2 boss roars should # become type 1 boss roars, so we at least keep the sound of roaring where it should be. for i in range(0, 0x7F): item = rom_data[first_quest_dungeon_items_early + i] if item & 0b00100000: rom_data[first_quest_dungeon_items_early + i] = item & 0b11011111 rom_data[first_quest_dungeon_items_early + i] = item | 0b01000000 if item & 0b00011111 == 0b00000011: # Change all Item 03s to Item 3F, the proper "nothing" rom_data[first_quest_dungeon_items_early + i] = item | 0b00111111 item = rom_data[first_quest_dungeon_items_late + i] if item & 0b00100000: rom_data[first_quest_dungeon_items_late + i] = item & 0b11011111 rom_data[first_quest_dungeon_items_late + i] = item | 0b01000000 if item & 0b00011111 == 0b00000011: rom_data[first_quest_dungeon_items_late + i] = item | 0b00111111 return rom_data def apply_randomizer(self): with open(get_base_rom_path(), 'rb') as rom: rom_data = self.apply_base_patch(rom) # Write each location's new data in for location in self.multiworld.get_filled_locations(self.player): # Zelda and Ganon aren't real locations if location.name == "Ganon" or location.name == "Zelda": continue # Neither are boss defeat events if "Status" in location.name: continue item = location.item.name # Remote items are always going to look like Rupees. if location.item.player != self.player: item = "Rupee" item_id = item_game_ids[item] location_id = location_ids[location.name] # Shop prices need to be set if location.name in shop_locations: if location.name[-5:] == "Right": # Final item in stores has bit 6 and 7 set. It's what marks the cave a shop. item_id = item_id | 0b11000000 price_location = shop_price_location_ids[location.name] item_price = item_prices[item] if item == "Rupee": item_class = location.item.classification if item_class == ItemClassification.progression: item_price = item_price * 2 elif item_class == ItemClassification.useful: item_price = item_price // 2 elif item_class == ItemClassification.filler: item_price = item_price // 2 elif item_class == ItemClassification.trap: item_price = item_price * 2 rom_data[price_location] = item_price if location.name == "Take Any Item Right": # Same story as above: bit 6 is what makes this a Take Any cave item_id = item_id | 0b01000000 rom_data[location_id] = item_id # We shuffle the tiers of rupee caves. Caves that shared a value before still will. secret_caves = self.multiworld.per_slot_randoms[self.player].sample(sorted(secret_money_ids), 3) secret_cave_money_amounts = [20, 50, 100] for i, amount in enumerate(secret_cave_money_amounts): # Giving approximately double the money to keep grinding down amount = amount * self.multiworld.per_slot_randoms[self.player].triangular(1.5, 2.5) secret_cave_money_amounts[i] = int(amount) for i, cave in enumerate(secret_caves): rom_data[secret_money_ids[cave]] = secret_cave_money_amounts[i] return rom_data def generate_output(self, output_directory: str): try: patched_rom = self.apply_randomizer() outfilebase = 'AP_' + self.multiworld.seed_name outfilepname = f'_P{self.player}' outfilepname += f"_{self.multiworld.get_file_safe_player_name(self.player).replace(' ', '_')}" outputFilename = os.path.join(output_directory, f'{outfilebase}{outfilepname}.nes') self.rom_name_text = f'LOZ{Utils.__version__.replace(".", "")[0:3]}_{self.player}_{self.multiworld.seed:11}\0' self.romName = bytearray(self.rom_name_text, 'utf8')[:0x20] self.romName.extend([0] * (0x20 - len(self.romName))) self.rom_name = self.romName patched_rom[0x10:0x30] = self.romName self.playerName = bytearray(self.multiworld.player_name[self.player], 'utf8')[:0x20] self.playerName.extend([0] * (0x20 - len(self.playerName))) patched_rom[0x30:0x50] = self.playerName patched_filename = os.path.join(output_directory, outputFilename) with open(patched_filename, 'wb') as patched_rom_file: patched_rom_file.write(patched_rom) patch = TLoZDeltaPatch(os.path.splitext(outputFilename)[0] + TLoZDeltaPatch.patch_file_ending, player=self.player, player_name=self.multiworld.player_name[self.player], patched_path=outputFilename) patch.write() os.unlink(patched_filename) finally: self.rom_name_available_event.set() def modify_multidata(self, multidata: dict): import base64 self.rom_name_available_event.wait() rom_name = getattr(self, "rom_name", None) if rom_name: new_name = base64.b64encode(bytes(self.rom_name)).decode() multidata["connect_names"][new_name] = multidata["connect_names"][self.multiworld.player_name[self.player]] def get_filler_item_name(self) -> str: if self.filler_items is None: self.filler_items = [item for item in item_table if item_table[item].classification == ItemClassification.filler] return self.multiworld.random.choice(self.filler_items) def fill_slot_data(self) -> Dict[str, Any]: if self.multiworld.ExpandedPool[self.player]: take_any_left = self.multiworld.get_location("Take Any Item Left", self.player).item take_any_middle = self.multiworld.get_location("Take Any Item Middle", self.player).item take_any_right = self.multiworld.get_location("Take Any Item Right", self.player).item if take_any_left.player == self.player: take_any_left = take_any_left.code else: take_any_left = -1 if take_any_middle.player == self.player: take_any_middle = take_any_middle.code else: take_any_middle = -1 if take_any_right.player == self.player: take_any_right = take_any_right.code else: take_any_right = -1 slot_data = { "TakeAnyLeft": take_any_left, "TakeAnyMiddle": take_any_middle, "TakeAnyRight": take_any_right } else: slot_data = { "TakeAnyLeft": -1, "TakeAnyMiddle": -1, "TakeAnyRight": -1 } return slot_data class TLoZItem(Item): game = 'The Legend of Zelda' class TLoZLocation(Location): game = 'The Legend of Zelda'