import os import threading from pkgutil import get_data import bsdiff4 import Utils import settings import typing from typing import NamedTuple, Union, Dict, Any 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 TlozOptions 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 TLoZSettings(settings.Group): class RomFile(settings.UserFilePath): """File name of the Zelda 1""" description = "The Legend of Zelda (U) ROM File" copy_to = "Legend of Zelda, The (U) (PRG0) [!].nes" md5s = [TLoZDeltaPatch.hash] class RomStart(str): """ Set this to false to never autostart a rom (such as after patching) true for operating system default program Alternatively, a path to a program to open the .nes file with """ class DisplayMsgs(settings.Bool): """Display message inside of Bizhawk""" rom_file: RomFile = RomFile(RomFile.copy_to) rom_start: typing.Union[RomStart, bool] = True display_msgs: typing.Union[DisplayMsgs, bool] = True class TLoZWeb(WebWorld): theme = "stone" setup = Tutorial( "Multiworld Setup Guide", "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. """ options_dataclass = TlozOptions options: TlozOptions settings: typing.ClassVar[TLoZSettings] 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 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.options.ExpandedPool 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.options.ExpandedPool 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: state.has("Triforce of Power", self.player)) 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__, "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: item = item & 0b11011111 item = item | 0b01000000 rom_data[first_quest_dungeon_items_early + i] = item 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: item = item & 0b11011111 item = item | 0b01000000 rom_data[first_quest_dungeon_items_late + i] = item 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.options.ExpandedPool: 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'