diff --git a/CI.py b/CI.py index fe363f321..7bbda5d29 100644 --- a/CI.py +++ b/CI.py @@ -1,20 +1,22 @@ # This script is called by GitHub Actions, see .github/workflows/python.yml # To fix code style errors, run: python3 ./CI.py --fix --no_unit_tests +import argparse import json -import sys -from io import StringIO -import unittest import os.path import pathlib -import argparse +import sys +import unittest +from io import StringIO +from typing import NoReturn + import Unittest as Tests from SettingsList import logic_tricks, validate_settings from Utils import data_path -def error(msg, can_fix): +def error(msg: str, can_fix: bool) -> None: if not hasattr(error, "count"): error.count = 0 print(msg, file=sys.stderr) @@ -25,7 +27,7 @@ def error(msg, can_fix): error.cannot_fix = True -def run_unit_tests(): +def run_unit_tests() -> None: # Run Unit Tests stream = StringIO() runner = unittest.TextTestRunner(stream=stream) @@ -38,7 +40,7 @@ def run_unit_tests(): error('Unit Tests had an error, see output above.', False) -def check_presets_formatting(fix_errors=False): +def check_presets_formatting(fix_errors: bool = False) -> None: # Check the code style of presets_default.json with open(data_path('presets_default.json'), encoding='utf-8') as f: presets = json.load(f) @@ -59,7 +61,8 @@ def check_presets_formatting(fix_errors=False): json.dump(presets, file, indent=4) print(file=file) -def check_hell_mode_tricks(fix_errors=False): + +def check_hell_mode_tricks(fix_errors: bool = False) -> None: # Check for tricks missing from Hell Mode preset. with open(data_path('presets_default.json'), encoding='utf-8') as f: presets = json.load(f) @@ -79,11 +82,11 @@ def check_hell_mode_tricks(fix_errors=False): print(file=file) -def check_code_style(fix_errors=False): +def check_code_style(fix_errors: bool = False) -> None: # Check for code style errors repo_dir = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) - def check_file_format(path): + def check_file_format(path: pathlib.Path): fixed = '' with path.open(encoding='utf-8', newline='') as file: path = path.relative_to(repo_dir) @@ -129,7 +132,7 @@ def check_file_format(path): check_file_format(repo_dir / 'data' / 'presets_default.json') -def run_ci_checks(): +def run_ci_checks() -> NoReturn: parser = argparse.ArgumentParser() parser.add_argument('--no_unit_tests', help="Skip unit tests", action='store_true') parser.add_argument('--only_unit_tests', help="Only run unit tests", action='store_true') @@ -147,7 +150,7 @@ def run_ci_checks(): exit_ci(args.fix) -def exit_ci(fix_errors=False): +def exit_ci(fix_errors: bool = False) -> NoReturn: if hasattr(error, "count") and error.count: print(f'CI failed with {error.count} errors.', file=sys.stderr) if fix_errors: diff --git a/Colors.py b/Colors.py index d3e66af43..805997f9c 100644 --- a/Colors.py +++ b/Colors.py @@ -1,10 +1,11 @@ -from collections import namedtuple import random import re +from collections import namedtuple +from typing import Dict, Tuple, List Color = namedtuple('Color', ' R G B') -tunic_colors = { +tunic_colors: Dict[str, Color] = { "Kokiri Green": Color(0x1E, 0x69, 0x1B), "Goron Red": Color(0x64, 0x14, 0x00), "Zora Blue": Color(0x00, 0x3C, 0x64), @@ -38,7 +39,8 @@ "Lumen": Color(0x50, 0x8C, 0xF0), } -NaviColors = { # Inner Core Color Outer Glow Color +# Inner Core Color Outer Glow Color +NaviColors: Dict[str, Tuple[Color, Color]] = { "Rainbow": (Color(0x00, 0x00, 0x00), Color(0x00, 0x00, 0x00)), "Gold": (Color(0xFE, 0xCC, 0x3C), Color(0xFE, 0xC0, 0x07)), "White": (Color(0xFF, 0xFF, 0xFF), Color(0x00, 0x00, 0xFF)), @@ -61,7 +63,7 @@ "Phantom Zelda": (Color(0x97, 0x7A, 0x6C), Color(0x6F, 0x46, 0x67)), } -sword_trail_colors = { +sword_trail_colors: Dict[str, Color] = { "Rainbow": Color(0x00, 0x00, 0x00), "White": Color(0xFF, 0xFF, 0xFF), "Red": Color(0xFF, 0x00, 0x00), @@ -75,7 +77,7 @@ "Pink": Color(0xFF, 0x69, 0xB4), } -bombchu_trail_colors = { +bombchu_trail_colors: Dict[str, Color] = { "Rainbow": Color(0x00, 0x00, 0x00), "Red": Color(0xFA, 0x00, 0x00), "Green": Color(0x00, 0xFF, 0x00), @@ -88,7 +90,7 @@ "Pink": Color(0xFF, 0x69, 0xB4), } -boomerang_trail_colors = { +boomerang_trail_colors: Dict[str, Color] = { "Rainbow": Color(0x00, 0x00, 0x00), "Yellow": Color(0xFF, 0xFF, 0x64), "Red": Color(0xFF, 0x00, 0x00), @@ -102,7 +104,7 @@ "Pink": Color(0xFF, 0x69, 0xB4), } -gauntlet_colors = { +gauntlet_colors: Dict[str, Color] = { "Silver": Color(0xFF, 0xFF, 0xFF), "Gold": Color(0xFE, 0xCF, 0x0F), "Black": Color(0x00, 0x00, 0x06), @@ -118,7 +120,7 @@ "Purple": Color(0x80, 0x00, 0x80), } -shield_frame_colors = { +shield_frame_colors: Dict[str, Color] = { "Red": Color(0xD7, 0x00, 0x00), "Green": Color(0x00, 0xFF, 0x00), "Blue": Color(0x00, 0x40, 0xD8), @@ -131,14 +133,14 @@ "Pink": Color(0xFF, 0x69, 0xB4), } -heart_colors = { +heart_colors: Dict[str, Color] = { "Red": Color(0xFF, 0x46, 0x32), "Green": Color(0x46, 0xC8, 0x32), "Blue": Color(0x32, 0x46, 0xFF), "Yellow": Color(0xFF, 0xE0, 0x00), } -magic_colors = { +magic_colors: Dict[str, Color] = { "Green": Color(0x00, 0xC8, 0x00), "Red": Color(0xC8, 0x00, 0x00), "Blue": Color(0x00, 0x30, 0xFF), @@ -150,7 +152,7 @@ # A Button Text Cursor Shop Cursor Save/Death Cursor # Pause Menu A Cursor Pause Menu A Icon A Note -a_button_colors = { +a_button_colors: Dict[str, Tuple[Color, Color, Color, Color, Color, Color, Color]] = { "N64 Blue": (Color(0x5A, 0x5A, 0xFF), Color(0x00, 0x50, 0xC8), Color(0x00, 0x50, 0xFF), Color(0x64, 0x64, 0xFF), Color(0x00, 0x32, 0xFF), Color(0x00, 0x64, 0xFF), Color(0x50, 0x96, 0xFF)), "N64 Green": (Color(0x00, 0x96, 0x00), Color(0x00, 0x96, 0x00), Color(0x00, 0x96, 0x00), Color(0x64, 0x96, 0x64), @@ -186,7 +188,7 @@ } # B Button -b_button_colors = { +b_button_colors: Dict[str, Color] = { "N64 Blue": Color(0x5A, 0x5A, 0xFF), "N64 Green": Color(0x00, 0x96, 0x00), "N64 Red": Color(0xC8, 0x00, 0x00), @@ -206,7 +208,7 @@ } # C Button Pause Menu C Cursor Pause Menu C Icon C Note -c_button_colors = { +c_button_colors: Dict[str, Color] = { "N64 Blue": (Color(0x5A, 0x5A, 0xFF), Color(0x00, 0x32, 0xFF), Color(0x00, 0x64, 0xFF), Color(0x50, 0x96, 0xFF)), "N64 Green": (Color(0x00, 0x96, 0x00), Color(0x00, 0x96, 0x00), Color(0x00, 0x96, 0x00), Color(0x00, 0x96, 0x00)), "N64 Red": (Color(0xC8, 0x00, 0x00), Color(0xC8, 0x00, 0x00), Color(0xC8, 0x00, 0x00), Color(0xC8, 0x00, 0x00)), @@ -226,7 +228,7 @@ } # Start Button -start_button_colors = { +start_button_colors: Dict[str, Color] = { "N64 Blue": Color(0x5A, 0x5A, 0xFF), "N64 Green": Color(0x00, 0x96, 0x00), "N64 Red": Color(0xC8, 0x00, 0x00), @@ -245,138 +247,138 @@ "Orange": Color(0xFF, 0x80, 0x00), } -meta_color_choices = ["Random Choice", "Completely Random", "Custom Color"] +meta_color_choices: List[str] = ["Random Choice", "Completely Random", "Custom Color"] -def get_tunic_colors(): +def get_tunic_colors() -> List[str]: return list(tunic_colors.keys()) -def get_tunic_color_options(): +def get_tunic_color_options() -> List[str]: return meta_color_choices + ["Rainbow"] + get_tunic_colors() -def get_navi_colors(): +def get_navi_colors() -> List[str]: return list(NaviColors.keys()) -def get_navi_color_options(outer=False): +def get_navi_color_options(outer: bool = False) -> List[str]: if outer: return ["[Same as Inner]"] + meta_color_choices + get_navi_colors() else: return meta_color_choices + get_navi_colors() -def get_sword_trail_colors(): +def get_sword_trail_colors() -> List[str]: return list(sword_trail_colors.keys()) -def get_sword_trail_color_options(outer=False): +def get_sword_trail_color_options(outer: bool = False) -> List[str]: if outer: return ["[Same as Inner]"] + meta_color_choices + get_sword_trail_colors() else: return meta_color_choices + get_sword_trail_colors() -def get_bombchu_trail_colors(): +def get_bombchu_trail_colors() -> List[str]: return list(bombchu_trail_colors.keys()) -def get_bombchu_trail_color_options(outer=False): +def get_bombchu_trail_color_options(outer: bool = False) -> List[str]: if outer: return ["[Same as Inner]"] + meta_color_choices + get_bombchu_trail_colors() else: return meta_color_choices + get_bombchu_trail_colors() -def get_boomerang_trail_colors(): +def get_boomerang_trail_colors() -> List[str]: return list(boomerang_trail_colors.keys()) -def get_boomerang_trail_color_options(outer=False): +def get_boomerang_trail_color_options(outer: bool = False) -> List[str]: if outer: return ["[Same as Inner]"] + meta_color_choices + get_boomerang_trail_colors() else: return meta_color_choices + get_boomerang_trail_colors() -def get_gauntlet_colors(): +def get_gauntlet_colors() -> List[str]: return list(gauntlet_colors.keys()) -def get_gauntlet_color_options(): +def get_gauntlet_color_options() -> List[str]: return meta_color_choices + get_gauntlet_colors() -def get_shield_frame_colors(): +def get_shield_frame_colors() -> List[str]: return list(shield_frame_colors.keys()) -def get_shield_frame_color_options(): +def get_shield_frame_color_options() -> List[str]: return meta_color_choices + get_shield_frame_colors() -def get_heart_colors(): +def get_heart_colors() -> List[str]: return list(heart_colors.keys()) -def get_heart_color_options(): +def get_heart_color_options() -> List[str]: return meta_color_choices + get_heart_colors() -def get_magic_colors(): +def get_magic_colors() -> List[str]: return list(magic_colors.keys()) -def get_magic_color_options(): +def get_magic_color_options() -> List[str]: return meta_color_choices + get_magic_colors() -def get_a_button_colors(): +def get_a_button_colors() -> List[str]: return list(a_button_colors.keys()) -def get_a_button_color_options(): +def get_a_button_color_options() -> List[str]: return meta_color_choices + get_a_button_colors() -def get_b_button_colors(): +def get_b_button_colors() -> List[str]: return list(b_button_colors.keys()) -def get_b_button_color_options(): +def get_b_button_color_options() -> List[str]: return meta_color_choices + get_b_button_colors() -def get_c_button_colors(): +def get_c_button_colors() -> List[str]: return list(c_button_colors.keys()) -def get_c_button_color_options(): +def get_c_button_color_options() -> List[str]: return meta_color_choices + get_c_button_colors() -def get_start_button_colors(): +def get_start_button_colors() -> List[str]: return list(start_button_colors.keys()) -def get_start_button_color_options(): +def get_start_button_color_options() -> List[str]: return meta_color_choices + get_start_button_colors() -def contrast_ratio(color1, color2): +def contrast_ratio(color1: List[int], color2: List[int]) -> float: # Based on accessibility standards (WCAG 2.0) lum1 = relative_luminance(color1) lum2 = relative_luminance(color2) return (max(lum1, lum2) + 0.05) / (min(lum1, lum2) + 0.05) -def relative_luminance(color): +def relative_luminance(color: List[int]) -> float: color_ratios = list(map(lum_color_ratio, color)) return color_ratios[0] * 0.299 + color_ratios[1] * 0.587 + color_ratios[2] * 0.114 -def lum_color_ratio(val): +def lum_color_ratio(val: int) -> float: val /= 255 if val <= 0.03928: return val / 12.92 @@ -384,14 +386,17 @@ def lum_color_ratio(val): return pow((val + 0.055) / 1.055, 2.4) -def generate_random_color(): +def generate_random_color() -> List[int]: return [random.getrandbits(8), random.getrandbits(8), random.getrandbits(8)] -def hex_to_color(option): +def hex_to_color(option: str) -> List[int]: + if not hasattr(hex_to_color, "regex"): + hex_to_color.regex = re.compile(r'^(?:[0-9a-fA-F]{3}){1,2}$') + # build color from hex code option = option[1:] if option[0] == "#" else option - if not re.search(r'^(?:[0-9a-fA-F]{3}){1,2}$', option): + if not hex_to_color.regex.search(option): raise Exception(f"Invalid color value provided: {option}") if len(option) > 3: return list(int(option[i:i + 2], 16) for i in (0, 2, 4)) @@ -399,5 +404,5 @@ def hex_to_color(option): return list(int(f'{option[i]}{option[i]}', 16) for i in (0, 1, 2)) -def color_to_hex(color): +def color_to_hex(color: List[int]) -> str: return '#' + ''.join(['{:02X}'.format(c) for c in color]) diff --git a/Cosmetics.py b/Cosmetics.py index 053fe1f18..749a1012c 100644 --- a/Cosmetics.py +++ b/Cosmetics.py @@ -1,19 +1,25 @@ -from version import __version__ -from Utils import data_path -from Colors import * -import random -import logging -import Music as music -import Sounds as sfx -import IconManip as icon -from JSONDump import dump_obj, CollapseList, CollapseDict, AlignedDict, SortedDict -from Plandomizer import InvalidFileException import json -from itertools import chain +import logging import os +import random +from itertools import chain +from typing import TYPE_CHECKING, Dict, List, Tuple, Optional, Union, Iterable, Callable, Any + +import Colors +import IconManip +import Music +import Sounds +from JSONDump import dump_obj, CollapseList, CollapseDict, AlignedDict +from Plandomizer import InvalidFileException +from Utils import data_path +from version import __version__ + +if TYPE_CHECKING: + from Rom import Rom + from Settings import Settings -def patch_targeting(rom, settings, log, symbols): +def patch_targeting(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: # Set default targeting option to Hold if settings.default_targeting == 'hold': rom.write_byte(0xB71E6D, 0x01) @@ -21,7 +27,7 @@ def patch_targeting(rom, settings, log, symbols): rom.write_byte(0xB71E6D, 0x00) -def patch_dpad(rom, settings, log, symbols): +def patch_dpad(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: # Display D-Pad HUD if settings.display_dpad: rom.write_byte(symbols['CFG_DISPLAY_DPAD'], 0x01) @@ -30,7 +36,7 @@ def patch_dpad(rom, settings, log, symbols): log.display_dpad = settings.display_dpad -def patch_dpad_info(rom, settings, log, symbols): +def patch_dpad_info(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: # Display D-Pad HUD in pause menu for either dungeon info or equips if settings.dpad_dungeon_menu: rom.write_byte(symbols['CFG_DPAD_DUNGEON_INFO_ENABLE'], 0x01) @@ -39,19 +45,19 @@ def patch_dpad_info(rom, settings, log, symbols): log.dpad_dungeon_menu = settings.dpad_dungeon_menu -def patch_music(rom, settings, log, symbols): +def patch_music(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: # patch music if settings.background_music != 'normal' or settings.fanfares != 'normal' or log.src_dict.get('bgm', {}): - music.restore_music(rom) - music.randomize_music(rom, settings, log) + Music.restore_music(rom) + Music.randomize_music(rom, settings, log) else: - music.restore_music(rom) + Music.restore_music(rom) # Remove battle music if settings.disable_battle_music: rom.write_byte(0xBE447F, 0x00) -def patch_model_colors(rom, color, model_addresses): +def patch_model_colors(rom: "Rom", color: Optional[List[int]], model_addresses: Tuple[List[int], List[int], List[int]]) -> None: main_addresses, dark_addresses, light_addresses = model_addresses if color is None: @@ -72,7 +78,7 @@ def patch_model_colors(rom, color, model_addresses): rom.write_bytes(address, lightened_color) -def patch_tunic_icon(rom, tunic, color, rainbow=False): +def patch_tunic_icon(rom: "Rom", tunic: str, color: Optional[List[int]], rainbow: bool = False) -> None: # patch tunic icon colors icon_locations = { 'Kokiri Tunic': 0x007FE000, @@ -81,14 +87,14 @@ def patch_tunic_icon(rom, tunic, color, rainbow=False): } if color is not None: - tunic_icon = icon.generate_rainbow_tunic_icon() if rainbow else icon.generate_tunic_icon(color) + tunic_icon = IconManip.generate_rainbow_tunic_icon() if rainbow else IconManip.generate_tunic_icon(color) else: tunic_icon = rom.original.read_bytes(icon_locations[tunic], 0x1000) rom.write_bytes(icon_locations[tunic], tunic_icon) -def patch_tunic_colors(rom, settings, log, symbols): +def patch_tunic_colors(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: # Need to check for the existence of the CFG_TUNIC_COLORS symbol. # This was added with rainbow tunic but custom tunic colors should still support older patch versions. tunic_address = symbols.get('CFG_TUNIC_COLORS', 0x00B6DA38) # Use new tunic color ROM address. Fall back to vanilla tunic color ROM address. @@ -98,11 +104,11 @@ def patch_tunic_colors(rom, settings, log, symbols): ('Zora Tunic', 'zora_color', tunic_address+6), ] - tunic_color_list = get_tunic_colors() + tunic_color_list = Colors.get_tunic_colors() rainbow_error = None for tunic, tunic_setting, address in tunics: - tunic_option = settings.settings_dict[tunic_setting] + tunic_option = getattr(settings, tunic_setting) # Handle Plando if log.src_dict.get('equipment_colors', {}).get(tunic, {}).get('color', '') and log.src_dict['equipment_colors'][tunic][':option'] != 'Rainbow': @@ -130,15 +136,15 @@ def patch_tunic_colors(rom, settings, log, symbols): # handle completely random if tunic_option == 'Completely Random': - color = generate_random_color() + color = Colors.generate_random_color() # grab the color from the list - elif tunic_option in tunic_colors: - color = list(tunic_colors[tunic_option]) + elif tunic_option in Colors.tunic_colors: + color = list(Colors.tunic_colors[tunic_option]) elif tunic_option == 'Rainbow': - color = list(Color(0x00, 0x00, 0x00)) + color = list(Colors.Color(0x00, 0x00, 0x00)) # build color from hex code else: - color = hex_to_color(tunic_option) + color = Colors.hex_to_color(tunic_option) tunic_option = 'Custom' rom.write_bytes(address, color) @@ -151,14 +157,14 @@ def patch_tunic_colors(rom, settings, log, symbols): log.equipment_colors[tunic] = CollapseDict({ ':option': tunic_option, - 'color': color_to_hex(color), + 'color': Colors.color_to_hex(color), }) if rainbow_error: log.errors.append(rainbow_error) -def patch_navi_colors(rom, settings, log, symbols): +def patch_navi_colors(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: # patch navi colors navi = [ # colors for Navi @@ -177,12 +183,12 @@ def patch_navi_colors(rom, settings, log, symbols): symbols.get('CFG_RAINBOW_NAVI_PROP_INNER_ENABLED', None), symbols.get('CFG_RAINBOW_NAVI_PROP_OUTER_ENABLED', None)), ] - navi_color_list = get_navi_colors() + navi_color_list = Colors.get_navi_colors() rainbow_error = None for navi_action, navi_setting, navi_addresses, rainbow_inner_symbol, rainbow_outer_symbol in navi: - navi_option_inner = settings.settings_dict[navi_setting+'_inner'] - navi_option_outer = settings.settings_dict[navi_setting+'_outer'] + navi_option_inner = getattr(settings, f'{navi_setting}_inner') + navi_option_outer = getattr(settings, f'{navi_setting}_outer') plando_colors = log.src_dict.get('misc_colors', {}).get(navi_action, {}).get('colors', []) # choose a random choice for the whole group @@ -207,7 +213,7 @@ def patch_navi_colors(rom, settings, log, symbols): # Plando if len(plando_colors) > address_index and plando_colors[address_index].get(navi_part, ''): - color = hex_to_color(plando_colors[address_index][navi_part]) + color = Colors.hex_to_color(plando_colors[address_index][navi_part]) # set rainbow option if rainbow_symbol is not None and option == 'Rainbow': @@ -221,15 +227,15 @@ def patch_navi_colors(rom, settings, log, symbols): # completely random is random for every subgroup if color is None and option == 'Completely Random': - color = generate_random_color() + color = Colors.generate_random_color() # grab the color from the list - if color is None and option in NaviColors: - color = list(NaviColors[option][index]) + if color is None and option in Colors.NaviColors: + color = list(Colors.NaviColors[option][index]) # build color from hex code if color is None: - color = hex_to_color(option) + color = Colors.hex_to_color(option) option = 'Custom' # Check color validity @@ -255,7 +261,7 @@ def patch_navi_colors(rom, settings, log, symbols): log.misc_colors[navi_action]['colors'].append(address_colors_str) for part, color in address_colors.items(): if log.misc_colors[navi_action][f':option_{part}'] != "Rainbow" or rainbow_error: - address_colors_str[part] = color_to_hex(color) + address_colors_str[part] = Colors.color_to_hex(color) else: del log.misc_colors[navi_action]['colors'] @@ -263,7 +269,7 @@ def patch_navi_colors(rom, settings, log, symbols): log.errors.append(rainbow_error) -def patch_sword_trails(rom, settings, log, symbols): +def patch_sword_trails(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: # patch sword trail duration rom.write_byte(0x00BEFF8C, settings.sword_trail_duration) @@ -274,12 +280,12 @@ def patch_sword_trails(rom, settings, log, symbols): symbols.get('CFG_RAINBOW_SWORD_INNER_ENABLED', None), symbols.get('CFG_RAINBOW_SWORD_OUTER_ENABLED', None)), ] - sword_trail_color_list = get_sword_trail_colors() + sword_trail_color_list = Colors.get_sword_trail_colors() rainbow_error = None for trail_name, trail_setting, trail_addresses, rainbow_inner_symbol, rainbow_outer_symbol in sword_trails: - option_inner = settings.settings_dict[trail_setting+'_inner'] - option_outer = settings.settings_dict[trail_setting+'_outer'] + option_inner = getattr(settings, f'{trail_setting}_inner') + option_outer = getattr(settings, f'{trail_setting}_outer') plando_colors = log.src_dict.get('misc_colors', {}).get(trail_name, {}).get('colors', []) # handle random choice @@ -305,7 +311,7 @@ def patch_sword_trails(rom, settings, log, symbols): # Plando if len(plando_colors) > address_index and plando_colors[address_index].get(trail_part, ''): - color = hex_to_color(plando_colors[address_index][trail_part]) + color = Colors.hex_to_color(plando_colors[address_index][trail_part]) # set rainbow option if rainbow_symbol is not None and option == 'Rainbow': @@ -319,15 +325,15 @@ def patch_sword_trails(rom, settings, log, symbols): # completely random is random for every subgroup if color is None and option == 'Completely Random': - color = generate_random_color() + color = Colors.generate_random_color() # grab the color from the list - if color is None and option in sword_trail_colors: - color = list(sword_trail_colors[option]) + if color is None and option in Colors.sword_trail_colors: + color = list(Colors.sword_trail_colors[option]) # build color from hex code if color is None: - color = hex_to_color(option) + color = Colors.hex_to_color(option) option = 'Custom' # Check color validity @@ -359,7 +365,7 @@ def patch_sword_trails(rom, settings, log, symbols): log.misc_colors[trail_name]['colors'].append(address_colors_str) for part, color in address_colors.items(): if log.misc_colors[trail_name][f':option_{part}'] != "Rainbow" or rainbow_error: - address_colors_str[part] = color_to_hex(color) + address_colors_str[part] = Colors.color_to_hex(color) else: del log.misc_colors[trail_name]['colors'] @@ -367,10 +373,10 @@ def patch_sword_trails(rom, settings, log, symbols): log.errors.append(rainbow_error) -def patch_bombchu_trails(rom, settings, log, symbols): +def patch_bombchu_trails(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: # patch bombchu trail colors bombchu_trails = [ - ('Bombchu Trail', 'bombchu_trail_color', get_bombchu_trail_colors(), bombchu_trail_colors, + ('Bombchu Trail', 'bombchu_trail_color', Colors.get_bombchu_trail_colors(), Colors.bombchu_trail_colors, (symbols['CFG_BOMBCHU_TRAIL_INNER_COLOR'], symbols['CFG_BOMBCHU_TRAIL_OUTER_COLOR'], symbols['CFG_RAINBOW_BOMBCHU_TRAIL_INNER_ENABLED'], symbols['CFG_RAINBOW_BOMBCHU_TRAIL_OUTER_ENABLED'])), ] @@ -378,10 +384,10 @@ def patch_bombchu_trails(rom, settings, log, symbols): patch_trails(rom, settings, log, bombchu_trails) -def patch_boomerang_trails(rom, settings, log, symbols): +def patch_boomerang_trails(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: # patch boomerang trail colors boomerang_trails = [ - ('Boomerang Trail', 'boomerang_trail_color', get_boomerang_trail_colors(), boomerang_trail_colors, + ('Boomerang Trail', 'boomerang_trail_color', Colors.get_boomerang_trail_colors(), Colors.boomerang_trail_colors, (symbols['CFG_BOOM_TRAIL_INNER_COLOR'], symbols['CFG_BOOM_TRAIL_OUTER_COLOR'], symbols['CFG_RAINBOW_BOOM_TRAIL_INNER_ENABLED'], symbols['CFG_RAINBOW_BOOM_TRAIL_OUTER_ENABLED'])), ] @@ -389,11 +395,11 @@ def patch_boomerang_trails(rom, settings, log, symbols): patch_trails(rom, settings, log, boomerang_trails) -def patch_trails(rom, settings, log, trails): +def patch_trails(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', trails) -> None: for trail_name, trail_setting, trail_color_list, trail_color_dict, trail_symbols in trails: color_inner_symbol, color_outer_symbol, rainbow_inner_symbol, rainbow_outer_symbol = trail_symbols - option_inner = settings.settings_dict[trail_setting+'_inner'] - option_outer = settings.settings_dict[trail_setting+'_outer'] + option_inner = getattr(settings, f'{trail_setting}_inner') + option_outer = getattr(settings, f'{trail_setting}_outer') plando_colors = log.src_dict.get('misc_colors', {}).get(trail_name, {}).get('colors', []) # handle random choice @@ -416,7 +422,7 @@ def patch_trails(rom, settings, log, trails): # Plando if len(plando_colors) > 0 and plando_colors[0].get(trail_part, ''): - color = hex_to_color(plando_colors[0][trail_part]) + color = Colors.hex_to_color(plando_colors[0][trail_part]) # set rainbow option if option == 'Rainbow': @@ -432,10 +438,10 @@ def patch_trails(rom, settings, log, trails): fixed_dark_color = [0, 0, 0] color = [0, 0, 0] # Avoid colors which have a low contrast so the bombchu ticking is still visible - while contrast_ratio(color, fixed_dark_color) <= 4: - color = generate_random_color() + while Colors.contrast_ratio(color, fixed_dark_color) <= 4: + color = Colors.generate_random_color() else: - color = generate_random_color() + color = Colors.generate_random_color() # grab the color from the list if color is None and option in trail_color_dict: @@ -443,7 +449,7 @@ def patch_trails(rom, settings, log, trails): # build color from hex code if color is None: - color = hex_to_color(option) + color = Colors.hex_to_color(option) option = 'Custom' option_dict[trail_part] = option @@ -463,12 +469,12 @@ def patch_trails(rom, settings, log, trails): log.misc_colors[trail_name]['colors'].append(colors_str) for part, color in colors.items(): if log.misc_colors[trail_name][f':option_{part}'] != "Rainbow": - colors_str[part] = color_to_hex(color) + colors_str[part] = Colors.color_to_hex(color) else: del log.misc_colors[trail_name]['colors'] -def patch_gauntlet_colors(rom, settings, log, symbols): +def patch_gauntlet_colors(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: # patch gauntlet colors gauntlets = [ ('Silver Gauntlets', 'silver_gauntlets_color', 0x00B6DA44, @@ -476,10 +482,10 @@ def patch_gauntlet_colors(rom, settings, log, symbols): ('Gold Gauntlets', 'golden_gauntlets_color', 0x00B6DA47, ([0x173B4EC], [0x173B4F4, 0x173B52C, 0x173B534], [])), # GI Model DList colors ] - gauntlet_color_list = get_gauntlet_colors() + gauntlet_color_list = Colors.get_gauntlet_colors() for gauntlet, gauntlet_setting, address, model_addresses in gauntlets: - gauntlet_option = settings.settings_dict[gauntlet_setting] + gauntlet_option = getattr(settings, gauntlet_setting) # Handle Plando if log.src_dict.get('equipment_colors', {}).get(gauntlet, {}).get('color', ''): @@ -490,13 +496,13 @@ def patch_gauntlet_colors(rom, settings, log, symbols): gauntlet_option = random.choice(gauntlet_color_list) # handle completely random if gauntlet_option == 'Completely Random': - color = generate_random_color() + color = Colors.generate_random_color() # grab the color from the list - elif gauntlet_option in gauntlet_colors: - color = list(gauntlet_colors[gauntlet_option]) + elif gauntlet_option in Colors.gauntlet_colors: + color = list(Colors.gauntlet_colors[gauntlet_option]) # build color from hex code else: - color = hex_to_color(gauntlet_option) + color = Colors.hex_to_color(gauntlet_option) gauntlet_option = 'Custom' rom.write_bytes(address, color) if settings.correct_model_colors: @@ -505,20 +511,21 @@ def patch_gauntlet_colors(rom, settings, log, symbols): patch_model_colors(rom, None, model_addresses) log.equipment_colors[gauntlet] = CollapseDict({ ':option': gauntlet_option, - 'color': color_to_hex(color), + 'color': Colors.color_to_hex(color), }) -def patch_shield_frame_colors(rom, settings, log, symbols): + +def patch_shield_frame_colors(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: # patch shield frame colors shield_frames = [ ('Mirror Shield Frame', 'mirror_shield_frame_color', [0xFA7274, 0xFA776C, 0xFAA27C, 0xFAC564, 0xFAC984, 0xFAEDD4], ([0x1616FCC], [0x1616FD4], [])), ] - shield_frame_color_list = get_shield_frame_colors() + shield_frame_color_list = Colors.get_shield_frame_colors() for shield_frame, shield_frame_setting, addresses, model_addresses in shield_frames: - shield_frame_option = settings.settings_dict[shield_frame_setting] + shield_frame_option = getattr(settings, shield_frame_setting) # Handle Plando if log.src_dict.get('equipment_colors', {}).get(shield_frame, {}).get('color', ''): @@ -531,11 +538,11 @@ def patch_shield_frame_colors(rom, settings, log, symbols): if shield_frame_option == 'Completely Random': color = [random.getrandbits(8), random.getrandbits(8), random.getrandbits(8)] # grab the color from the list - elif shield_frame_option in shield_frame_colors: - color = list(shield_frame_colors[shield_frame_option]) + elif shield_frame_option in Colors.shield_frame_colors: + color = list(Colors.shield_frame_colors[shield_frame_option]) # build color from hex code else: - color = hex_to_color(shield_frame_option) + color = Colors.hex_to_color(shield_frame_option) shield_frame_option = 'Custom' for address in addresses: rom.write_bytes(address, color) @@ -546,11 +553,11 @@ def patch_shield_frame_colors(rom, settings, log, symbols): log.equipment_colors[shield_frame] = CollapseDict({ ':option': shield_frame_option, - 'color': color_to_hex(color), + 'color': Colors.color_to_hex(color), }) -def patch_heart_colors(rom, settings, log, symbols): +def patch_heart_colors(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: # patch heart colors hearts = [ ('Heart Color', 'heart_color', symbols['CFG_HEART_COLOR'], 0xBB0994, @@ -559,10 +566,10 @@ def patch_heart_colors(rom, settings, log, symbols): 0x14B706C, 0x14B707C, 0x14B708C, 0x14B709C, 0x14B70AC, 0x14B70BC, 0x14B70CC, 0x16092A4], [0x16092FC, 0x1609394])), # GI Model and Potion DList colors ] - heart_color_list = get_heart_colors() + heart_color_list = Colors.get_heart_colors() for heart, heart_setting, symbol, file_select_address, model_addresses in hearts: - heart_option = settings.settings_dict[heart_setting] + heart_option = getattr(settings, heart_setting) # Handle Plando if log.src_dict.get('ui_colors', {}).get(heart, {}).get('color', ''): @@ -573,13 +580,13 @@ def patch_heart_colors(rom, settings, log, symbols): heart_option = random.choice(heart_color_list) # handle completely random if heart_option == 'Completely Random': - color = generate_random_color() + color = Colors.generate_random_color() # grab the color from the list - elif heart_option in heart_colors: - color = list(heart_colors[heart_option]) + elif heart_option in Colors.heart_colors: + color = list(Colors.heart_colors[heart_option]) # build color from hex code else: - color = hex_to_color(heart_option) + color = Colors.hex_to_color(heart_option) heart_option = 'Custom' rom.write_int16s(symbol, color) # symbol for ingame HUD rom.write_int16s(file_select_address, color) # file select normal hearts @@ -590,16 +597,17 @@ def patch_heart_colors(rom, settings, log, symbols): rom.write_bytes(file_select_address + 6, original_dd_color) if settings.correct_model_colors and heart_option != 'Red': patch_model_colors(rom, color, model_addresses) # heart model colors - icon.patch_overworld_icon(rom, color, 0xF43D80) # Overworld Heart Icon + IconManip.patch_overworld_icon(rom, color, 0xF43D80) # Overworld Heart Icon else: patch_model_colors(rom, None, model_addresses) - icon.patch_overworld_icon(rom, None, 0xF43D80) + IconManip.patch_overworld_icon(rom, None, 0xF43D80) log.ui_colors[heart] = CollapseDict({ ':option': heart_option, - 'color': color_to_hex(color), + 'color': Colors.color_to_hex(color), }) -def patch_magic_colors(rom, settings, log, symbols): + +def patch_magic_colors(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: # patch magic colors magic = [ ('Magic Meter Color', 'magic_color', symbols["CFG_MAGIC_COLOR"], @@ -607,10 +615,10 @@ def patch_magic_colors(rom, settings, log, symbols): [0x154C65C, 0x154CFBC, 0x1609284], [0x16092DC, 0x160933C])), # GI Model and Potion DList colors ] - magic_color_list = get_magic_colors() + magic_color_list = Colors.get_magic_colors() for magic_color, magic_setting, symbol, model_addresses in magic: - magic_option = settings.settings_dict[magic_setting] + magic_option = getattr(settings, magic_setting) # Handle Plando if log.src_dict.get('ui_colors', {}).get(magic_color, {}).get('color', ''): @@ -620,29 +628,30 @@ def patch_magic_colors(rom, settings, log, symbols): magic_option = random.choice(magic_color_list) if magic_option == 'Completely Random': - color = generate_random_color() - elif magic_option in magic_colors: - color = list(magic_colors[magic_option]) + color = Colors.generate_random_color() + elif magic_option in Colors.magic_colors: + color = list(Colors.magic_colors[magic_option]) else: - color = hex_to_color(magic_option) + color = Colors.hex_to_color(magic_option) magic_option = 'Custom' rom.write_int16s(symbol, color) if magic_option != 'Green' and settings.correct_model_colors: patch_model_colors(rom, color, model_addresses) - icon.patch_overworld_icon(rom, color, 0xF45650, data_path('icons/magicSmallExtras.raw')) # Overworld Small Pot - icon.patch_overworld_icon(rom, color, 0xF47650, data_path('icons/magicLargeExtras.raw')) # Overworld Big Pot + IconManip.patch_overworld_icon(rom, color, 0xF45650, data_path('icons/magicSmallExtras.raw')) # Overworld Small Pot + IconManip.patch_overworld_icon(rom, color, 0xF47650, data_path('icons/magicLargeExtras.raw')) # Overworld Big Pot else: patch_model_colors(rom, None, model_addresses) - icon.patch_overworld_icon(rom, None, 0xF45650) - icon.patch_overworld_icon(rom, None, 0xF47650) + IconManip.patch_overworld_icon(rom, None, 0xF45650) + IconManip.patch_overworld_icon(rom, None, 0xF47650) log.ui_colors[magic_color] = CollapseDict({ ':option': magic_option, - 'color': color_to_hex(color), + 'color': Colors.color_to_hex(color), }) -def patch_button_colors(rom, settings, log, symbols): + +def patch_button_colors(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: buttons = [ - ('A Button Color', 'a_button_color', a_button_colors, + ('A Button Color', 'a_button_color', Colors.a_button_colors, [('A Button Color', symbols['CFG_A_BUTTON_COLOR'], None), ('Text Cursor Color', symbols['CFG_TEXT_CURSOR_COLOR'], @@ -658,11 +667,11 @@ def patch_button_colors(rom, settings, log, symbols): ('A Note Color', symbols['CFG_A_NOTE_COLOR'], # For Textbox Song Display [(0xBB299A, 0xBB299B, 0xBB299E), (0xBB2C8E, 0xBB2C8F, 0xBB2C92), (0xBB2F8A, 0xBB2F8B, 0xBB2F96)]), # Pause Menu Song Display ]), - ('B Button Color', 'b_button_color', b_button_colors, + ('B Button Color', 'b_button_color', Colors.b_button_colors, [('B Button Color', symbols['CFG_B_BUTTON_COLOR'], None), ]), - ('C Button Color', 'c_button_color', c_button_colors, + ('C Button Color', 'c_button_color', Colors.c_button_colors, [('C Button Color', symbols['CFG_C_BUTTON_COLOR'], None), ('Pause Menu C Cursor Color', None, @@ -672,14 +681,14 @@ def patch_button_colors(rom, settings, log, symbols): ('C Note Color', symbols['CFG_C_NOTE_COLOR'], # For Textbox Song Display [(0xBB2996, 0xBB2997, 0xBB29A2), (0xBB2C8A, 0xBB2C8B, 0xBB2C96), (0xBB2F86, 0xBB2F87, 0xBB2F9A)]), # Pause Menu Song Display ]), - ('Start Button Color', 'start_button_color', start_button_colors, + ('Start Button Color', 'start_button_color', Colors.start_button_colors, [('Start Button Color', None, [(0xAE9EC6, 0xAE9EC7, 0xAE9EDA)]), ]), ] for button, button_setting, button_colors, patches in buttons: - button_option = settings.settings_dict[button_setting] + button_option = getattr(settings, button_setting) color_set = None colors = {} log_dict = CollapseDict({':option': button_option, 'colors': {}}) @@ -696,22 +705,22 @@ def patch_button_colors(rom, settings, log, symbols): fixed_font_color = [10, 10, 10] color = [0, 0, 0] # Avoid colors which have a low contrast with the font inside buttons (eg. the A letter) - while contrast_ratio(color, fixed_font_color) <= 3: - color = generate_random_color() + while Colors.contrast_ratio(color, fixed_font_color) <= 3: + color = Colors.generate_random_color() # grab the color from the list elif button_option in button_colors: color_set = [button_colors[button_option]] if isinstance(button_colors[button_option][0], int) else list(button_colors[button_option]) color = color_set[0] # build color from hex code else: - color = hex_to_color(button_option) + color = Colors.hex_to_color(button_option) button_option = 'Custom' log_dict[':option'] = button_option # apply all button color patches for i, (patch, symbol, byte_addresses) in enumerate(patches): if plando_colors.get(patch, ''): - colors[patch] = hex_to_color(plando_colors[patch]) + colors[patch] = Colors.hex_to_color(plando_colors[patch]) elif color_set is not None and len(color_set) > i and color_set[i]: colors[patch] = color_set[i] else: @@ -726,39 +735,39 @@ def patch_button_colors(rom, settings, log, symbols): rom.write_byte(g_addr, colors[patch][1]) rom.write_byte(b_addr, colors[patch][2]) - log_dict['colors'][patch] = color_to_hex(colors[patch]) + log_dict['colors'][patch] = Colors.color_to_hex(colors[patch]) -def patch_sfx(rom, settings, log, symbols): +def patch_sfx(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: # Configurable Sound Effects sfx_config = [ - ('sfx_navi_overworld', sfx.SoundHooks.NAVI_OVERWORLD), - ('sfx_navi_enemy', sfx.SoundHooks.NAVI_ENEMY), - ('sfx_low_hp', sfx.SoundHooks.HP_LOW), - ('sfx_menu_cursor', sfx.SoundHooks.MENU_CURSOR), - ('sfx_menu_select', sfx.SoundHooks.MENU_SELECT), - ('sfx_nightfall', sfx.SoundHooks.NIGHTFALL), - ('sfx_horse_neigh', sfx.SoundHooks.HORSE_NEIGH), - ('sfx_hover_boots', sfx.SoundHooks.BOOTS_HOVER), - ('sfx_iron_boots', sfx.SoundHooks.BOOTS_IRON), - ('sfx_silver_rupee', sfx.SoundHooks.SILVER_RUPEE), - ('sfx_boomerang_throw',sfx.SoundHooks.BOOMERANG_THROW), - ('sfx_hookshot_chain', sfx.SoundHooks.HOOKSHOT_CHAIN), - ('sfx_arrow_shot', sfx.SoundHooks.ARROW_SHOT), - ('sfx_slingshot_shot', sfx.SoundHooks.SLINGSHOT_SHOT), - ('sfx_magic_arrow_shot', sfx.SoundHooks.MAGIC_ARROW_SHOT), - ('sfx_bombchu_move', sfx.SoundHooks.BOMBCHU_MOVE), - ('sfx_get_small_item', sfx.SoundHooks.GET_SMALL_ITEM), - ('sfx_explosion', sfx.SoundHooks.EXPLOSION), - ('sfx_daybreak', sfx.SoundHooks.DAYBREAK), - ('sfx_cucco', sfx.SoundHooks.CUCCO), + ('sfx_navi_overworld', Sounds.SoundHooks.NAVI_OVERWORLD), + ('sfx_navi_enemy', Sounds.SoundHooks.NAVI_ENEMY), + ('sfx_low_hp', Sounds.SoundHooks.HP_LOW), + ('sfx_menu_cursor', Sounds.SoundHooks.MENU_CURSOR), + ('sfx_menu_select', Sounds.SoundHooks.MENU_SELECT), + ('sfx_nightfall', Sounds.SoundHooks.NIGHTFALL), + ('sfx_horse_neigh', Sounds.SoundHooks.HORSE_NEIGH), + ('sfx_hover_boots', Sounds.SoundHooks.BOOTS_HOVER), + ('sfx_iron_boots', Sounds.SoundHooks.BOOTS_IRON), + ('sfx_silver_rupee', Sounds.SoundHooks.SILVER_RUPEE), + ('sfx_boomerang_throw', Sounds.SoundHooks.BOOMERANG_THROW), + ('sfx_hookshot_chain', Sounds.SoundHooks.HOOKSHOT_CHAIN), + ('sfx_arrow_shot', Sounds.SoundHooks.ARROW_SHOT), + ('sfx_slingshot_shot', Sounds.SoundHooks.SLINGSHOT_SHOT), + ('sfx_magic_arrow_shot', Sounds.SoundHooks.MAGIC_ARROW_SHOT), + ('sfx_bombchu_move', Sounds.SoundHooks.BOMBCHU_MOVE), + ('sfx_get_small_item', Sounds.SoundHooks.GET_SMALL_ITEM), + ('sfx_explosion', Sounds.SoundHooks.EXPLOSION), + ('sfx_daybreak', Sounds.SoundHooks.DAYBREAK), + ('sfx_cucco', Sounds.SoundHooks.CUCCO), ] - sound_dict = sfx.get_patch_dict() - sounds_keyword_label = {sound.value.keyword: sound.value.label for sound in sfx.Sounds} - sounds_label_keyword = {sound.value.label: sound.value.keyword for sound in sfx.Sounds} + sound_dict = Sounds.get_patch_dict() + sounds_keyword_label = {sound.value.keyword: sound.value.label for sound in Sounds.Sounds} + sounds_label_keyword = {sound.value.label: sound.value.keyword for sound in Sounds.Sounds} for setting, hook in sfx_config: - selection = settings.settings_dict[setting] + selection = getattr(settings, setting) # Handle Plando if log.src_dict.get('sfx', {}).get(hook.value.name, ''): @@ -768,17 +777,18 @@ def patch_sfx(rom, settings, log, symbols): elif selection_label in sounds_label_keyword: selection = sounds_label_keyword[selection_label] + sound_id = 0 if selection == 'default': for loc in hook.value.locations: sound_id = rom.original.read_int16(loc) rom.write_int16(loc, sound_id) else: if selection == 'random-choice': - selection = random.choice(sfx.get_hook_pool(hook)).value.keyword + selection = random.choice(Sounds.get_hook_pool(hook)).value.keyword elif selection == 'random-ear-safe': - selection = random.choice(sfx.get_hook_pool(hook, "TRUE")).value.keyword + selection = random.choice(Sounds.get_hook_pool(hook, True)).value.keyword elif selection == 'completely-random': - selection = random.choice(sfx.standard).value.keyword + selection = random.choice(Sounds.standard).value.keyword sound_id = sound_dict[selection] if hook.value.sfx_flag and sound_id > 0x7FF: sound_id -= 0x800 @@ -794,7 +804,7 @@ def patch_sfx(rom, settings, log, symbols): rom.write_int16(symbols['GET_ITEM_SEQ_ID'], sound_id) -def patch_instrument(rom, settings, log, symbols): +def patch_instrument(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: # Player Instrument instruments = { #'none': 0x00, @@ -820,7 +830,7 @@ def patch_instrument(rom, settings, log, symbols): log.sfx['Ocarina'] = ocarina_options[choice] -def read_default_voice_data(rom): +def read_default_voice_data(rom: "Rom") -> Dict[str, Dict[str, int]]: audiobank = 0xD390 audiotable = 0x79470 soundbank = audiobank + rom.read_int32(audiobank + 0x4) @@ -841,7 +851,7 @@ def read_default_voice_data(rom): return soundbank_entries -def patch_silent_voice(rom, sfxidlist, soundbank_entries, log): +def patch_silent_voice(rom: "Rom", sfxidlist: Iterable[int], soundbank_entries: Dict[str, Dict[str, int]], log: 'CosmeticsLog') -> None: binsfxfilename = os.path.join(data_path('Voices'), 'SilentVoiceSFX.bin') if not os.path.isfile(binsfxfilename): log.errors.append(f"Could not find silent voice sfx at {binsfxfilename}. Skipping voice patching") @@ -859,7 +869,7 @@ def patch_silent_voice(rom, sfxidlist, soundbank_entries, log): rom.write_bytes(soundbank_entries[sfxid]["romoffset"], injectme) -def apply_voice_patch(rom, voice_path, soundbank_entries): +def apply_voice_patch(rom: "Rom", voice_path: str, soundbank_entries: Dict[str, Dict[str, int]]) -> None: if not os.path.exists(voice_path): return @@ -875,7 +885,7 @@ def apply_voice_patch(rom, voice_path, soundbank_entries): rom.write_bytes(soundbank_entries[sfxid]["romoffset"], binsfx) -def patch_voices(rom, settings, log, symbols): +def patch_voices(rom: "Rom", settings: "Settings", log: 'CosmeticsLog', symbols: Dict[str, int]) -> None: # Reset the audiotable back to default to prepare patching voices and read data rom.write_bytes(0x00079470, rom.original.read_bytes(0x00079470, 0x460AD0)) @@ -886,8 +896,8 @@ def patch_voices(rom, settings, log, symbols): soundbank_entries = read_default_voice_data(rom) voice_ages = ( - ('Child', settings.sfx_link_child, sfx.get_voice_sfx_choices(0, False), chain([0x14, 0x87], range(0x1C, 0x36+1), range(0x3E, 0x4C+1))), - ('Adult', settings.sfx_link_adult, sfx.get_voice_sfx_choices(1, False), chain([0x37, 0x38, 0x3C, 0x3D, 0x86], range(0x00, 0x13+1), range(0x15, 0x1B+1), range(0x4D, 0x58+1))) + ('Child', settings.sfx_link_child, Sounds.get_voice_sfx_choices(0, False), chain([0x14, 0x87], range(0x1C, 0x36+1), range(0x3E, 0x4C+1))), + ('Adult', settings.sfx_link_adult, Sounds.get_voice_sfx_choices(1, False), chain([0x37, 0x38, 0x3C, 0x3D, 0x86], range(0x00, 0x13+1), range(0x15, 0x1B+1), range(0x4D, 0x58+1))) ) for name, voice_setting, choices, silence_sfx_ids in voice_ages: @@ -938,13 +948,13 @@ def patch_music_changes(rom, settings, log, symbols): log.slowdown_music_when_lowhp = settings.slowdown_music_when_lowhp -legacy_cosmetic_data_headers = [ +legacy_cosmetic_data_headers: List[int] = [ 0x03481000, 0x03480810, ] -patch_sets = {} -global_patch_sets = [ +patch_sets: Dict[int, Dict[str, Any]] = {} +global_patch_sets: List[Callable[["Rom", "Settings", 'CosmeticsLog', Dict[str, int]], type(None)]] = [ patch_targeting, patch_music, patch_tunic_colors, @@ -1091,7 +1101,8 @@ def patch_music_changes(rom, settings, log, symbols): } } -def patch_cosmetics(settings, rom): + +def patch_cosmetics(settings: "Settings", rom: "Rom") -> 'CosmeticsLog': # re-seed for aesthetic effects. They shouldn't be affected by the generation seed random.seed() settings.resolve_random_settings(cosmetic=True) @@ -1103,7 +1114,7 @@ def patch_cosmetics(settings, rom): cosmetic_version = None versioned_patch_set = None cosmetic_context = rom.read_int32(rom.sym('RANDO_CONTEXT') + 4) - if cosmetic_context >= 0x80000000 and cosmetic_context <= 0x80F7FFFC: + if 0x80000000 <= cosmetic_context <= 0x80F7FFFC: cosmetic_context = (cosmetic_context - 0x80400000) + 0x3480000 # convert from RAM to ROM address cosmetic_version = rom.read_int32(cosmetic_context) versioned_patch_set = patch_sets.get(cosmetic_version) @@ -1146,20 +1157,19 @@ def patch_cosmetics(settings, rom): return log -class CosmeticsLog(object): - - def __init__(self, settings): - self.settings = settings +class CosmeticsLog: + def __init__(self, settings: "Settings") -> None: + self.settings: "Settings" = settings - self.equipment_colors = {} - self.ui_colors = {} - self.misc_colors = {} - self.sfx = {} - self.bgm = {} - self.bgm_groups = {} + self.equipment_colors: Dict[str, dict] = {} + self.ui_colors: Dict[str, dict] = {} + self.misc_colors: Dict[str, dict] = {} + self.sfx: Dict[str, str] = {} + self.bgm: Dict[str, str] = {} + self.bgm_groups: Dict[str, Union[list, dict]] = {} - self.src_dict = {} - self.errors = [] + self.src_dict: dict = {} + self.errors: List[str] = [] if self.settings.enable_cosmetic_file: if self.settings.cosmetic_file: @@ -1171,7 +1181,7 @@ def __init__(self, settings): except json.decoder.JSONDecodeError as e: raise InvalidFileException(f"Invalid Cosmetic Plandomizer File. Make sure the file is a valid JSON file. Failure reason: {str(e)}") from None except FileNotFoundError: - message = "Cosmetic Plandomizer file not found at %s" % (self.settings.cosmetic_file) + message = "Cosmetic Plandomizer file not found at %s" % self.settings.cosmetic_file logging.getLogger('').warning(message) self.errors.append(message) self.settings.enable_cosmetic_file = False @@ -1204,8 +1214,7 @@ def __init__(self, settings): if self.src_dict['settings'].get('randomize_all_sfx', False): settings.resolve_random_settings(cosmetic=True, randomize_key='randomize_all_sfx') - - def to_json(self): + def to_json(self) -> dict: self_dict = { ':version': __version__, ':enable_cosmetic_file': True, @@ -1219,19 +1228,17 @@ def to_json(self): 'bgm': self.bgm, } - if (not self.settings.enable_cosmetic_file): + if not self.settings.enable_cosmetic_file: del self_dict[':enable_cosmetic_file'] # Done this way for ordering purposes. - if (not self.errors): + if not self.errors: del self_dict[':errors'] return self_dict - - def to_str(self): + def to_str(self) -> str: return dump_obj(self.to_json(), ensure_ascii=False) - - def to_file(self, filename): - json = self.to_str() + def to_file(self, filename: str) -> None: + json_str = self.to_str() with open(filename, 'w') as outfile: - outfile.write(json) + outfile.write(json_str) diff --git a/Dungeon.py b/Dungeon.py index 76d71b862..e184621e8 100644 --- a/Dungeon.py +++ b/Dungeon.py @@ -1,27 +1,30 @@ -import os +from typing import TYPE_CHECKING, List from Hints import HintArea -from Utils import data_path + +if TYPE_CHECKING: + from Item import Item + from Region import Region + from World import World class Dungeon: - def __init__(self, world, name, hint): - self.world = world - self.name = name - self.hint = hint - self.regions = [] - self.boss_key = [] - self.small_keys = [] - self.dungeon_items = [] - self.silver_rupees = [] + def __init__(self, world: "World", name: str, hint: HintArea) -> None: + self.world: "World" = world + self.name: str = name + self.hint: HintArea = hint + self.regions: "List[Region]" = [] + self.boss_key: "List[Item]" = [] + self.small_keys: "List[Item]" = [] + self.dungeon_items: "List[Item]" = [] + self.silver_rupees: "List[Item]" = [] for region in world.regions: if region.dungeon == self.name: region.dungeon = self self.regions.append(region) - - def copy(self, new_world): + def copy(self, new_world: "World") -> 'Dungeon': new_dungeon = Dungeon(new_world, self.name, self.hint) new_dungeon.boss_key = [item.copy(new_world) for item in self.boss_key] @@ -31,50 +34,22 @@ def copy(self, new_world): return new_dungeon - @property - def keys(self): + def keys(self) -> "List[Item]": return self.small_keys + self.boss_key - @property - def all_items(self): + def all_items(self) -> "List[Item]": return self.dungeon_items + self.keys + self.silver_rupees - - def item_name(self, text): + def item_name(self, text: str) -> str: return f"{text} ({self.name})" - - def is_dungeon_item(self, item): + def is_dungeon_item(self, item: "Item") -> bool: return item.name in [dungeon_item.name for dungeon_item in self.all_items] - - def __str__(self): + def __str__(self) -> str: return str(self.__unicode__()) - - def __unicode__(self): + def __unicode__(self) -> str: return '%s' % self.name - - -def create_dungeons(world): - savewarps_to_connect = [] - for hint_area in HintArea: - if hint_area.is_dungeon: - name = hint_area.dungeon_name - - if world.settings.logic_rules == 'glitched': - if not world.dungeon_mq[name]: - dungeon_json = os.path.join(data_path('Glitched World'), name + '.json') - else: - dungeon_json = os.path.join(data_path('Glitched World'), name + ' MQ.json') - else: - if not world.dungeon_mq[name]: - dungeon_json = os.path.join(data_path('World'), name + '.json') - else: - dungeon_json = os.path.join(data_path('World'), name + ' MQ.json') - - savewarps_to_connect += world.load_regions_from_json(dungeon_json) - world.dungeons.append(Dungeon(world, name, hint_area)) - return savewarps_to_connect diff --git a/Entrance.py b/Entrance.py index dd1c05e78..a790e755e 100644 --- a/Entrance.py +++ b/Entrance.py @@ -1,27 +1,32 @@ -from Region import TimeOfDay - - -class Entrance(object): - - def __init__(self, name='', parent=None): - self.name = name - self.parent_region = parent - self.world = parent.world - self.connected_region = None - self.access_rule = lambda state, **kwargs: True - self.access_rules = [] - self.reverse = None - self.replaces = None - self.assumed = None - self.type = None - self.shuffled = False - self.data = None - self.primary = False - self.always = False - self.never = False - - - def copy(self, new_region): +from typing import TYPE_CHECKING, List, Optional, Callable, Dict, Any + +from RulesCommon import AccessRule + +if TYPE_CHECKING: + from Region import Region + from World import World + + +class Entrance: + def __init__(self, name: str = '', parent: "Optional[Region]" = None) -> None: + self.name: str = name + self.parent_region: "Optional[Region]" = parent + self.world: "World" = parent.world + self.connected_region: "Optional[Region]" = None + self.access_rule: AccessRule = lambda state, **kwargs: True + self.access_rules: List[AccessRule] = [] + self.reverse: 'Optional[Entrance]' = None + self.replaces: 'Optional[Entrance]' = None + self.assumed: 'Optional[Entrance]' = None + self.type: Optional[str] = None + self.shuffled: bool = False + self.data: Optional[Dict[str, Any]] = None + self.primary: bool = False + self.always: bool = False + self.never: bool = False + self.rule_string: Optional[str] = None + + def copy(self, new_region: "Region") -> 'Entrance': new_entrance = Entrance(self.name, new_region) new_entrance.connected_region = self.connected_region.name new_entrance.access_rule = self.access_rule @@ -38,8 +43,7 @@ def copy(self, new_region): return new_entrance - - def add_rule(self, lambda_rule): + def add_rule(self, lambda_rule: AccessRule) -> None: if self.always: self.set_rule(lambda_rule) self.always = False @@ -49,30 +53,25 @@ def add_rule(self, lambda_rule): self.access_rules.append(lambda_rule) self.access_rule = lambda state, **kwargs: all(rule(state, **kwargs) for rule in self.access_rules) - - def set_rule(self, lambda_rule): + def set_rule(self, lambda_rule: AccessRule) -> None: self.access_rule = lambda_rule self.access_rules = [lambda_rule] - - def connect(self, region): + def connect(self, region: "Region") -> None: self.connected_region = region region.entrances.append(self) - - def disconnect(self): + def disconnect(self) -> "Optional[Region]": self.connected_region.entrances.remove(self) previously_connected = self.connected_region self.connected_region = None return previously_connected - - def bind_two_way(self, other_entrance): + def bind_two_way(self, other_entrance: 'Entrance') -> None: self.reverse = other_entrance other_entrance.reverse = self - - def get_new_target(self): + def get_new_target(self) -> 'Entrance': root = self.world.get_region('Root Exits') target_entrance = Entrance('Root -> ' + self.connected_region.name, root) target_entrance.connect(self.connected_region) @@ -80,18 +79,14 @@ def get_new_target(self): root.exits.append(target_entrance) return target_entrance - - def assume_reachable(self): - if self.assumed == None: + def assume_reachable(self) -> 'Entrance': + if self.assumed is None: self.assumed = self.get_new_target() self.disconnect() return self.assumed - - def __str__(self): + def __str__(self) -> str: return str(self.__unicode__()) - - def __unicode__(self): + def __unicode__(self) -> str: return '%s' % self.name - diff --git a/EntranceShuffle.py b/EntranceShuffle.py index 46fef6e28..cc4855964 100644 --- a/EntranceShuffle.py +++ b/EntranceShuffle.py @@ -1,18 +1,26 @@ import random import logging +from collections import OrderedDict from itertools import chain +from typing import TYPE_CHECKING, List, Iterable, Container, Tuple, Dict, Optional + from Fill import ShuffleError -from collections import OrderedDict from Search import Search -from Region import TimeOfDay +from Region import Region, TimeOfDay from Rules import set_entrances_based_rules from State import State from Item import ItemFactory from Hints import HintArea, HintAreaNotFound from HintList import misc_item_hint_table +if TYPE_CHECKING: + from Entrance import Entrance + from Location import Location + from Item import Item + from World import World -def set_all_entrances_data(world): + +def set_all_entrances_data(world: "World") -> None: for type, forward_entry, *return_entry in entrance_shuffle_table: forward_entrance = world.get_entrance(forward_entry[0]) forward_entrance.data = forward_entry[1] @@ -30,11 +38,11 @@ def set_all_entrances_data(world): return_entrance.data['index'] = 0x7FFF -def assume_entrance_pool(entrance_pool): +def assume_entrance_pool(entrance_pool: "List[Entrance]") -> "List[Entrance]": assumed_pool = [] for entrance in entrance_pool: assumed_forward = entrance.assume_reachable() - if entrance.reverse != None: + if entrance.reverse is not None: assumed_return = entrance.reverse.assume_reachable() if (entrance.type in ('Dungeon', 'Grotto', 'Grave') and entrance.reverse.name != 'Spirit Temple Lobby -> Desert Colossus From Spirit Lobby') or \ (entrance.type == 'Interior' and entrance.world.shuffle_special_interior_entrances): @@ -45,8 +53,8 @@ def assume_entrance_pool(entrance_pool): return assumed_pool -def build_one_way_targets(world, types_to_include, exclude=(), target_region_names=()): - one_way_entrances = [] +def build_one_way_targets(world: "World", types_to_include: Iterable[str], exclude: Container[str] = (), target_region_names: Container[str] = ()) -> "List[Entrance]": + one_way_entrances: "List[Entrance]" = [] for pool_type in types_to_include: one_way_entrances += world.get_shufflable_entrances(type=pool_type) valid_one_way_entrances = list(filter(lambda entrance: entrance.name not in exclude, one_way_entrances)) @@ -408,7 +416,7 @@ class EntranceShuffleError(ShuffleError): # Set entrances of all worlds, first initializing them to their default regions, then potentially shuffling part of them -def set_entrances(worlds, savewarps_to_connect): +def set_entrances(worlds: "List[World]", savewarps_to_connect: "List[Tuple[Entrance, str]]") -> None: for world in worlds: world.initialize_entrances() @@ -428,8 +436,7 @@ def set_entrances(worlds, savewarps_to_connect): # Shuffles entrances that need to be shuffled in all worlds -def shuffle_random_entrances(worlds): - +def shuffle_random_entrances(worlds: "List[World]") -> None: # Store all locations reachable before shuffling to differentiate which locations were already unreachable from those we made unreachable complete_itempool = [item for world in worlds for item in world.get_itempool_with_dungeon_items()] max_search = Search.max_explore([world.state for world in worlds], complete_itempool) @@ -555,7 +562,8 @@ def shuffle_random_entrances(worlds): one_way_target_entrance_pools[pool_type] = build_one_way_targets(world, valid_target_types) # Ensure that when trying to place the last entrance of a one way pool, we don't assume the rest of the targets are reachable for target in one_way_target_entrance_pools[pool_type]: - target.add_rule((lambda entrances=entrance_pool: (lambda state, **kwargs: any(entrance.connected_region == None for entrance in entrances)))()) + target.add_rule((lambda entrances=entrance_pool: (lambda state, **kwargs: any( + entrance.connected_region is None for entrance in entrances)))()) # Disconnect all one way entrances at this point (they need to be connected during all of the above process) for entrance in chain.from_iterable(one_way_entrance_pools.values()): entrance.disconnect() @@ -565,7 +573,7 @@ def shuffle_random_entrances(worlds): target_entrance_pools[pool_type] = assume_entrance_pool(entrance_pool) # Set entrances defined in the distribution - world.distribution.set_shuffled_entrances(worlds, dict(chain(one_way_entrance_pools.items(), entrance_pools.items())), dict(chain(one_way_target_entrance_pools.items(), target_entrance_pools.items())), locations_to_ensure_reachable, complete_itempool) + world.distribution.set_shuffled_entrances(worlds, {**one_way_entrance_pools, **entrance_pools}, {**one_way_target_entrance_pools, **target_entrance_pools}, locations_to_ensure_reachable, complete_itempool) # Check placed one way entrances and trim. # The placed entrances are already pointing at their new regions. @@ -593,7 +601,6 @@ def shuffle_random_entrances(worlds): if remaining_target.replaces in replaced_entrances: delete_target_entrance(remaining_target) - # Shuffle all entrances among the pools to shuffle for pool_type, entrance_pool in one_way_entrance_pools.items(): placed_one_way_entrances += shuffle_entrance_pool(world, worlds, entrance_pool, one_way_target_entrance_pools[pool_type], locations_to_ensure_reachable, check_all=True, placed_one_way_entrances=placed_one_way_entrances) @@ -659,14 +666,14 @@ def shuffle_random_entrances(worlds): # Check that all shuffled entrances are properly connected to a region for world in worlds: for entrance in world.get_shuffled_entrances(): - if entrance.connected_region == None: + if entrance.connected_region is None: logging.getLogger('').error('%s was shuffled but still isn\'t connected to any region [World %d]', entrance, world.id) - if entrance.replaces == None: + if entrance.replaces is None: logging.getLogger('').error('%s was shuffled but still doesn\'t replace any entrance [World %d]', entrance, world.id) - if len(world.get_region('Root Exits').exits) > 8: - for exit in world.get_region('Root Exits').exits: - logging.getLogger('').error('Root Exit: %s, Connected Region: %s', exit, exit.connected_region) - raise RuntimeError('Something went wrong, Root has too many entrances left after shuffling entrances [World %d]' % world.id) + if len(world.get_region('Root Exits').exits) > 8: + for exit in world.get_region('Root Exits').exits: + logging.getLogger('').error('Root Exit: %s, Connected Region: %s', exit, exit.connected_region) + raise RuntimeError('Something went wrong, Root has too many entrances left after shuffling entrances [World %d]' % world.id) # Check for game beatability in all worlds if not max_search.can_beat_game(False): @@ -680,7 +687,10 @@ def shuffle_random_entrances(worlds): raise EntranceShuffleError('Worlds are not valid after shuffling entrances, Reason: %s' % error) -def shuffle_one_way_priority_entrances(worlds, world, one_way_priorities, one_way_entrance_pools, one_way_target_entrance_pools, locations_to_ensure_reachable, complete_itempool, retry_count=2): +def shuffle_one_way_priority_entrances(worlds: "List[World]", world: "World", one_way_priorities: Dict[str, Tuple[List[str], List[str]]], + one_way_entrance_pools: "Dict[str, List[Entrance]]", one_way_target_entrance_pools: "Dict[str, List[Entrance]]", + locations_to_ensure_reachable: "Iterable[Location]", complete_itempool: "List[Item]", + retry_count: int = 2) -> "List[Tuple[Entrance, Entrance]]": while retry_count: retry_count -= 1 rollbacks = [] @@ -706,9 +716,13 @@ def shuffle_one_way_priority_entrances(worlds, world, one_way_priorities, one_wa raise EntranceShuffleError('Entrance placement attempt count exceeded for world %d. Some entrances in the Plandomizer File may have to be changed to create a valid seed. Reach out to Support on Discord for help.' % world.id) raise EntranceShuffleError('Entrance placement attempt count exceeded for world %d. Retry a few times or reach out to Support on Discord for help.' % world.id) -# Shuffle all entrances within a provided pool -def shuffle_entrance_pool(world, worlds, entrance_pool, target_entrances, locations_to_ensure_reachable, check_all=False, retry_count=20, placed_one_way_entrances=()): +# Shuffle all entrances within a provided pool +def shuffle_entrance_pool(world: "World", worlds: "List[World]", entrance_pool: "List[Entrance]", target_entrances: "List[Entrance]", + locations_to_ensure_reachable: "Iterable[Location]", check_all: bool = False, retry_count: int = 20, + placed_one_way_entrances: "Optional[List[Tuple[Entrance, Entrance]]]" = None) -> "List[Tuple[Entrance, Entrance]]": + if placed_one_way_entrances is None: + placed_one_way_entrances = [] # Split entrances between those that have requirements (restrictive) and those that do not (soft). These are primarily age or time of day requirements. restrictive_entrances, soft_entrances = split_entrances_by_requirements(worlds, entrance_pool, target_entrances) @@ -749,8 +763,7 @@ def shuffle_entrance_pool(world, worlds, entrance_pool, target_entrances, locati # Split entrances based on their requirements to figure out how each entrance should be handled when shuffling them -def split_entrances_by_requirements(worlds, entrances_to_split, assumed_entrances): - +def split_entrances_by_requirements(worlds: "List[World]", entrances_to_split: "List[Entrance]", assumed_entrances: "List[Entrance]") -> "Tuple[List[Entrance], List[Entrance]]": # First, disconnect all root assumed entrances and save which regions they were originally connected to, so we can reconnect them later original_connected_regions = {} entrances_to_disconnect = set(assumed_entrances).union(entrance.reverse for entrance in assumed_entrances if entrance.reverse) @@ -784,7 +797,10 @@ def split_entrances_by_requirements(worlds, entrances_to_split, assumed_entrance return restrictive_entrances, soft_entrances -def replace_entrance(worlds, entrance, target, rollbacks, locations_to_ensure_reachable, itempool, placed_one_way_entrances=()): +def replace_entrance(worlds: "List[World]", entrance: "Entrance", target: "Entrance", rollbacks: "List[Tuple[Entrance, Entrance]]", + locations_to_ensure_reachable: "Iterable[Location]", itempool: "List[Item]", placed_one_way_entrances: "Optional[List[Tuple[Entrance, Entrance]]]" = None) -> bool: + if placed_one_way_entrances is None: + placed_one_way_entrances = [] try: check_entrances_compatibility(entrance, target, rollbacks, placed_one_way_entrances) change_connections(entrance, target) @@ -803,7 +819,9 @@ def replace_entrance(worlds, entrance, target, rollbacks, locations_to_ensure_re # Connect one random entrance from entrance pools to one random target in the respective target pool. # Entrance chosen will have one of the allowed types. # Target chosen will lead to one of the allowed regions. -def place_one_way_priority_entrance(worlds, world, priority_name, allowed_regions, allowed_types, rollbacks, locations_to_ensure_reachable, complete_itempool, one_way_entrance_pools, one_way_target_entrance_pools): +def place_one_way_priority_entrance(worlds: "List[World]", world: "World", priority_name: str, allowed_regions: Container[str], allowed_types: Iterable[str], + rollbacks: "List[Tuple[Entrance, Entrance]]", locations_to_ensure_reachable: "Iterable[Location]", complete_itempool: "List[Item]", + one_way_entrance_pools: "Dict[str, List[Entrance]]", one_way_target_entrance_pools: "Dict[str, List[Entrance]]") -> None: # Combine the entrances for allowed types in one list. # Shuffle this list. # Pick the first one not already set, not adult spawn, that has a valid target entrance. @@ -832,8 +850,10 @@ def place_one_way_priority_entrance(worlds, world, priority_name, allowed_region # Shuffle entrances by placing them instead of entrances in the provided target entrances list # While shuffling entrances, the algorithm will ensure worlds are still valid based on multiple criterias -def shuffle_entrances(worlds, entrances, target_entrances, rollbacks, locations_to_ensure_reachable=(), placed_one_way_entrances=()): - +def shuffle_entrances(worlds: "List[World]", entrances: "List[Entrance]", target_entrances: "List[Entrance]", rollbacks: "List[Tuple[Entrance, Entrance]]", + locations_to_ensure_reachable: "Iterable[Location]" = (), placed_one_way_entrances: "Optional[List[Tuple[Entrance, Entrance]]]" = None) -> None: + if placed_one_way_entrances is None: + placed_one_way_entrances = [] # Retrieve all items in the itempool, all worlds included complete_itempool = [item for world in worlds for item in world.get_itempool_with_dungeon_items()] @@ -841,23 +861,26 @@ def shuffle_entrances(worlds, entrances, target_entrances, rollbacks, locations_ # Place all entrances in the pool, validating worlds during every placement for entrance in entrances: - if entrance.connected_region != None: + if entrance.connected_region is not None: continue random.shuffle(target_entrances) for target in target_entrances: - if target.connected_region == None: + if target.connected_region is None: continue if replace_entrance(worlds, entrance, target, rollbacks, locations_to_ensure_reachable, complete_itempool, placed_one_way_entrances=placed_one_way_entrances): break - if entrance.connected_region == None: + if entrance.connected_region is None: raise EntranceShuffleError('No more valid entrances to replace with %s in world %d' % (entrance, entrance.world.id)) # Check and validate that an entrance is compatible to replace a specific target -def check_entrances_compatibility(entrance, target, rollbacks=(), placed_one_way_entrances=()): +def check_entrances_compatibility(entrance: "Entrance", target: "Entrance", rollbacks: "List[Tuple[Entrance, Entrance]]" = (), + placed_one_way_entrances: "Optional[List[Tuple[Entrance, Entrance]]]" = None) -> None: + if placed_one_way_entrances is None: + placed_one_way_entrances = [] # An entrance shouldn't be connected to its own scene, so we fail in that situation if entrance.parent_region.get_scene() and entrance.parent_region.get_scene() == target.connected_region.get_scene(): raise EntranceShuffleError('Self scene connections are forbidden') @@ -880,8 +903,10 @@ def check_entrances_compatibility(entrance, target, rollbacks=(), placed_one_way # Validate the provided worlds' structures, raising an error if it's not valid based on our criterias -def validate_world(world, worlds, entrance_placed, locations_to_ensure_reachable, itempool, placed_one_way_entrances=()): - +def validate_world(world: "World", worlds: "List[World]", entrance_placed: "Optional[Entrance]", locations_to_ensure_reachable: "Iterable[Location]", + itempool: "List[Item]", placed_one_way_entrances: "Optional[List[Tuple[Entrance, Entrance]]]" = None) -> None: + if placed_one_way_entrances is None: + placed_one_way_entrances = [] # For various reasons, we don't want the player to end up through certain entrances as the wrong age # This means we need to hard check that none of the relevant entrances are ever reachable as that age # This is mostly relevant when shuffling special interiors (such as windmill or kak potion shop) @@ -930,7 +955,7 @@ def validate_world(world, worlds, entrance_placed, locations_to_ensure_reachable world.shuffle_interior_entrances and ( (world.dungeon_rewards_hinted and world.mixed_pools_bosses) or #TODO also enable if boss reward shuffle is on any(hint_type in world.settings.misc_hints for hint_type in misc_item_hint_table) or world.settings.hints != 'none' - ) and (entrance_placed == None or entrance_placed.type in ['Interior', 'SpecialInterior']) + ) and (entrance_placed is None or entrance_placed.type in ['Interior', 'SpecialInterior']) ): # Ensure Kak Potion Shop entrances are in the same hint area so there is no ambiguity as to which entrance is used for hints potion_front_entrance = get_entrance_replacing(world.get_region('Kak Potion Shop Front'), 'Kakariko Village -> Kak Potion Shop Front') @@ -998,8 +1023,8 @@ def validate_world(world, worlds, entrance_placed, locations_to_ensure_reachable # Returns whether or not we can affirm the entrance can never be accessed as the given age -def entrance_unreachable_as(entrance, age, already_checked=None): - if already_checked == None: +def entrance_unreachable_as(entrance: "Entrance", age: str, already_checked: "Optional[List[Entrance]]" = None) -> bool: + if already_checked is None: already_checked = [] already_checked.append(entrance) @@ -1027,7 +1052,7 @@ def entrance_unreachable_as(entrance, age, already_checked=None): # Returns whether two entrances are in the same hint area -def same_hint_area(first, second): +def same_hint_area(first: HintArea, second: HintArea) -> bool: try: return HintArea.at(first) == HintArea.at(second) except HintAreaNotFound: @@ -1035,7 +1060,7 @@ def same_hint_area(first, second): # Shorthand function to find an entrance with the requested name leading to a specific region -def get_entrance_replacing(region, entrance_name): +def get_entrance_replacing(region: Region, entrance_name: str) -> "Optional[Entrance]": original_entrance = region.world.get_entrance(entrance_name) if not original_entrance.shuffled: @@ -1050,7 +1075,7 @@ def get_entrance_replacing(region, entrance_name): # Change connections between an entrance and a target assumed entrance, in order to test the connections afterwards if necessary -def change_connections(entrance, target_entrance): +def change_connections(entrance: "Entrance", target_entrance: "Entrance") -> None: entrance.connect(target_entrance.disconnect()) entrance.replaces = target_entrance.replaces if entrance.reverse: @@ -1059,7 +1084,7 @@ def change_connections(entrance, target_entrance): # Restore connections between an entrance and a target assumed entrance -def restore_connections(entrance, target_entrance): +def restore_connections(entrance: "Entrance", target_entrance: "Entrance") -> None: target_entrance.connect(entrance.disconnect()) entrance.replaces = None if entrance.reverse: @@ -1068,7 +1093,7 @@ def restore_connections(entrance, target_entrance): # Confirm the replacement of a target entrance by a new entrance, logging the new connections and completely deleting the target entrances -def confirm_replacement(entrance, target_entrance): +def confirm_replacement(entrance: "Entrance", target_entrance: "Entrance") -> None: delete_target_entrance(target_entrance) logging.getLogger('').debug('Connected %s To %s [World %d]', entrance, entrance.connected_region, entrance.world.id) if entrance.reverse: @@ -1078,9 +1103,9 @@ def confirm_replacement(entrance, target_entrance): # Delete an assumed target entrance, by disconnecting it if needed and removing it from its parent region -def delete_target_entrance(target_entrance): - if target_entrance.connected_region != None: +def delete_target_entrance(target_entrance: "Entrance") -> None: + if target_entrance.connected_region is not None: target_entrance.disconnect() - if target_entrance.parent_region != None: + if target_entrance.parent_region is not None: target_entrance.parent_region.exits.remove(target_entrance) target_entrance.parent_region = None diff --git a/Fill.py b/Fill.py index 671122f03..ff69e0580 100644 --- a/Fill.py +++ b/Fill.py @@ -1,13 +1,18 @@ import random import logging +from typing import TYPE_CHECKING, List, Optional + from Hints import HintArea -from State import State -from Rules import set_shop_rules -from Location import DisableType -from LocationList import location_groups +from Item import Item, ItemFactory, ItemInfo from ItemPool import remove_junk_items -from Item import ItemFactory, ItemInfo +from Location import Location, DisableType +from LocationList import location_groups +from Rules import set_shop_rules from Search import Search +from State import State + +if TYPE_CHECKING: + from World import World logger = logging.getLogger('') @@ -21,7 +26,7 @@ class FillError(ShuffleError): # Places all items into the world -def distribute_items_restrictive(worlds, fill_locations=None): +def distribute_items_restrictive(worlds: "List[World]", fill_locations: Optional[List[Location]] = None) -> None: if worlds[0].settings.shuffle_song_items == 'song': song_location_names = location_groups['Song'] elif worlds[0].settings.shuffle_song_items == 'dungeon': @@ -40,13 +45,13 @@ def distribute_items_restrictive(worlds, fill_locations=None): song_locations = [] for world in worlds: - for location in song_location_names: + for location_name in song_location_names: try: - song_locations.append(world.get_location(location)) + song_locations.append(world.get_location(location_name)) except KeyError: pass - shop_locations = [location for world in worlds for location in world.get_unfilled_locations() if location.type == 'Shop' and location.price == None] + shop_locations = [location for world in worlds for location in world.get_unfilled_locations() if location.type == 'Shop' and location.price is None] # If not passed in, then get a shuffled list of locations to fill in if not fill_locations: @@ -137,11 +142,10 @@ def distribute_items_restrictive(worlds, fill_locations=None): fill_dungeons_restrictive(worlds, search, fill_locations, dungeon_items, itempool + songitempool) search.collect_locations() - # If some dungeons are supposed to be empty, fill them with useless items. if worlds[0].settings.empty_dungeons_mode != 'none': - empty_locations = [location for location in fill_locations \ - if world.empty_dungeons[HintArea.at(location).dungeon_name].empty] + empty_locations = [location for location in fill_locations + if location.world.empty_dungeons[HintArea.at(location).dungeon_name].empty] for location in empty_locations: fill_locations.remove(location) location.world.hint_type_overrides['sometimes'].append(location.name) @@ -163,7 +167,6 @@ def distribute_items_restrictive(worlds, fill_locations=None): # We don't have to worry about this if dungeon items stay in their own dungeons fast_fill(empty_locations, restitempool) - # places the songs into the world # Currently places songs only at song locations. if there's an option # to allow at other locations then they should be in the main pool. @@ -233,8 +236,8 @@ def distribute_items_restrictive(worlds, fill_locations=None): # Places restricted dungeon items into the worlds. To ensure there is room for them. -# they are placed first so it will assume all other items are reachable -def fill_dungeons_restrictive(worlds, search, shuffled_locations, dungeon_items, itempool): +# they are placed first, so it will assume all other items are reachable +def fill_dungeons_restrictive(worlds: "List[World]", search: Search, shuffled_locations: List[Location], dungeon_items: List[Item], itempool: List[Item]) -> None: # List of states with all non-key items base_search = search.copy() base_search.collect_all(itempool) @@ -254,9 +257,9 @@ def fill_dungeons_restrictive(worlds, search, shuffled_locations, dungeon_items, # Places items into dungeon locations. This is used when there should be exactly -# one progression item per dungeon. This should be ran before all the progression +# one progression item per dungeon. This should be run before all the progression # items are places to ensure there is space to place them. -def fill_dungeon_unique_item(worlds, search, fill_locations, itempool): +def fill_dungeon_unique_item(worlds: "List[World]", search: Search, fill_locations: List[Location], itempool: List[Item]) -> None: # We should make sure that we don't count event items, shop items, # token items, or dungeon items as a major item. itempool at this # point should only be able to have tokens of those restrictions @@ -312,7 +315,7 @@ def fill_dungeon_unique_item(worlds, search, fill_locations, itempool): # update the location and item pool, removing any placed items and filled locations # the fact that you can remove items from a list you're iterating over is python magic for item in itempool: - if item.location != None: + if item.location is not None: fill_locations.remove(item.location) itempool.remove(item) @@ -330,9 +333,8 @@ def fill_dungeon_unique_item(worlds, search, fill_locations, itempool): # Places items restricting placement to the recipient player's own world -def fill_ownworld_restrictive(worlds, search, locations, ownpool, itempool, description="Unknown", attempts=15): - # get the locations for each world - +def fill_ownworld_restrictive(worlds: "List[World]", search: Search, locations: List[Location], ownpool: List[Item], + itempool: List[Item], description: str = "Unknown", attempts: int = 15) -> None: # look for preplaced items placed_prizes = [loc.item.name for loc in locations if loc.item is not None] unplaced_prizes = [item for item in ownpool if item.name not in placed_prizes] @@ -390,7 +392,7 @@ def fill_ownworld_restrictive(worlds, search, locations, ownpool, itempool, desc # This function will modify the location and itempool arguments. placed items and # filled locations will be removed. If this returns and error, then the state of # those two lists cannot be guaranteed. -def fill_restrictive(worlds, base_search, locations, itempool, count=-1): +def fill_restrictive(worlds: "List[World]", base_search: Search, locations: List[Location], itempool: List[Item], count: int = -1) -> None: unplaced_items = [] # don't run over this search, just keep it as an item collection @@ -496,7 +498,7 @@ def fill_restrictive(worlds, base_search, locations, itempool, count=-1): # assert that the specified number of items were placed if count > 0: raise FillError('Could not place the specified number of item. %d remaining to be placed.' % count) - if count < 0 and len(itempool) > 0: + if count < 0 < len(itempool): raise FillError('Could not place all the items. %d remaining to be placed.' % len(itempool)) # re-add unplaced items that were skipped itempool.extend(unplaced_items) @@ -505,7 +507,7 @@ def fill_restrictive(worlds, base_search, locations, itempool, count=-1): # This places items in the itempool into the locations # It does not check for reachability, only that the item is # allowed in the location -def fill_restrictive_fast(worlds, locations, itempool): +def fill_restrictive_fast(worlds: "List[World]", locations: List[Location], itempool: List[Item]) -> None: while itempool and locations: item_to_place = itempool.pop() random.shuffle(locations) @@ -533,7 +535,7 @@ def fill_restrictive_fast(worlds, locations, itempool): # this places item in item_pool completely randomly into # fill_locations. There is no checks for validity since # there should be none for these remaining items -def fast_fill(locations, itempool): +def fast_fill(locations: List[Location], itempool: List[Item]) -> None: random.shuffle(locations) while itempool and locations: spot_to_fill = locations.pop() diff --git a/Goals.py b/Goals.py index 284aa5b47..45e9d5725 100644 --- a/Goals.py +++ b/Goals.py @@ -1,12 +1,21 @@ -from collections import OrderedDict, defaultdict -import logging +from collections import defaultdict +from typing import TYPE_CHECKING, List, Union, Dict, Optional, Any, Tuple, Iterable, Callable, Collection -from HintList import goalTable, getHintGroup, hintExclusions, misc_item_hint_table, misc_location_hint_table +from HintList import goalTable, get_hint_group, hint_exclusions from ItemList import item_table -from Search import Search +from Search import Search, ValidGoals +from Utils import TypeAlias +if TYPE_CHECKING: + from Location import Location + from Spoiler import Spoiler + from State import State + from World import World -validColors = [ +RequiredLocations: TypeAlias = "Dict[str, Union[Dict[str, Dict[int, List[Tuple[Location, int, int]]]], List[Location]]]" +GoalItem: TypeAlias = Dict[str, Union[str, int, bool]] + +validColors: List[str] = [ 'White', 'Red', 'Green', @@ -18,33 +27,34 @@ ] -class Goal(object): - - def __init__(self, world, name, hint_text, color, items=None, locations=None, lock_locations=None, lock_entrances=None, required_locations=None, create_empty=False): +class Goal: + def __init__(self, world: "World", name: str, hint_text: Union[str, Dict[str, str]], color: str, items: Optional[List[Dict[str, Any]]] = None, + locations=None, lock_locations=None, lock_entrances: List[str] = None, required_locations=None, create_empty: bool = False) -> None: # early exit if goal initialized incorrectly if not items and not locations and not create_empty: raise Exception('Invalid goal: no items or destinations set') - self.world = world - self.name = name - self.hint_text = hint_text - if color in validColors: - self.color = color - else: - raise Exception('Invalid goal: Color %r not supported' % color) - self.items = items - self.locations = locations - self.lock_locations = lock_locations - self.lock_entrances = lock_entrances - self.required_locations = required_locations or [] - self.weight = 0 - self.category = None - self._item_cache = {} - - def copy(self): - new_goal = Goal(self.world, self.name, self.hint_text, self.color, self.items, self.locations, self.lock_locations, self.lock_entrances, self.required_locations, True) + if color not in validColors: + raise Exception(f'Invalid goal: Color {color} not supported') + + self.world: "World" = world + self.name: str = name + self.hint_text: Union[str, Dict[str, str]] = hint_text + self.color: str = color + self.items: List[GoalItem] = items or [] + self.locations = locations # Unused? + self.lock_locations = lock_locations # Unused? + self.lock_entrances: List[str] = lock_entrances + self.required_locations: "List[Tuple[Location, int, int, List[int]]]" = required_locations or [] + self.weight: int = 0 + self.category: 'Optional[GoalCategory]' = None + self._item_cache: Dict[str, GoalItem] = {} + + def copy(self) -> 'Goal': + new_goal = Goal(self.world, self.name, self.hint_text, self.color, self.items, self.locations, self.lock_locations, + self.lock_entrances, self.required_locations, True) return new_goal - def get_item(self, item): + def get_item(self, item: str) -> GoalItem: try: return self._item_cache[item] except KeyError: @@ -54,7 +64,7 @@ def get_item(self, item): return i raise KeyError('No such item %r for goal %r' % (item, self.name)) - def requires(self, item): + def requires(self, item: str) -> bool: # Prevent direct hints for certain items that can have many duplicates, such as tokens and Triforce Pieces names = [item] if item_table[item][3] is not None and 'alias' in item_table[item][3]: @@ -62,31 +72,29 @@ def requires(self, item): return any(i['name'] in names and not i['hintable'] for i in self.items) -class GoalCategory(object): - - def __init__(self, name, priority, goal_count=0, minimum_goals=0, lock_locations=None, lock_entrances=None): - self.name = name - self.priority = priority - self.lock_locations = lock_locations - self.lock_entrances = lock_entrances - self.goals = [] - self.goal_count = goal_count - self.minimum_goals = minimum_goals - self.weight = 0 - self._goal_cache = {} - - - def copy(self): +class GoalCategory: + def __init__(self, name: str, priority: int, goal_count: int = 0, minimum_goals: int = 0, + lock_locations=None, lock_entrances: List[str] = None) -> None: + self.name: str = name + self.priority: int = priority + self.lock_locations = lock_locations # Unused? + self.lock_entrances: List[str] = lock_entrances + self.goals: List[Goal] = [] + self.goal_count: int = goal_count + self.minimum_goals: int = minimum_goals + self.weight: int = 0 + self._goal_cache: Dict[str, Goal] = {} + + def copy(self) -> 'GoalCategory': new_category = GoalCategory(self.name, self.priority, self.goal_count, self.minimum_goals, self.lock_locations, self.lock_entrances) new_category.goals = list(goal.copy() for goal in self.goals) return new_category - def add_goal(self, goal): + def add_goal(self, goal) -> None: goal.category = self self.goals.append(goal) - - def get_goal(self, goal): + def get_goal(self, goal) -> Goal: if isinstance(goal, Goal): return goal try: @@ -98,15 +106,13 @@ def get_goal(self, goal): return g raise KeyError('No such goal %r' % goal) - - def is_beaten(self, search): + def is_beaten(self, search: Search) -> bool: # if the category requirements are already satisfied by starting items (such as Links Pocket), # do not generate hints for other goals in the category starting_goals = search.beatable_goals_fast({ self.name: self }) return all(map(lambda s: len(starting_goals[self.name]['stateReverse'][s.world.id]) >= self.minimum_goals, search.state_list)) - - def update_reachable_goals(self, starting_search, full_search): + def update_reachable_goals(self, starting_search: Search, full_search: Search) -> None: # Only reduce goal item quantity if minimum goal requirements are reachable, # but not the full goal quantity. Primary use is to identify reachable # skull tokens, triforce pieces, and plentiful item duplicates with @@ -129,7 +135,7 @@ def update_reachable_goals(self, starting_search, full_search): i['quantity'] = min(full_search.state_list[index].item_name_count(i['name']), i['quantity']) -def replace_goal_names(worlds): +def replace_goal_names(worlds: "List[World]") -> None: for world in worlds: bosses = [location for location in world.get_filled_locations() if location.item.type == 'DungeonReward'] for cat_name, category in world.goal_categories.items(): @@ -146,10 +152,10 @@ def replace_goal_names(worlds): break -def update_goal_items(spoiler): +def update_goal_items(spoiler: "Spoiler") -> None: worlds = spoiler.worlds - # get list of all of the progressive items that can appear in hints + # get list of all the progressive items that can appear in hints # all_locations: all progressive items. have to collect from these # item_locations: only the ones that should appear as "required"/WotH all_locations = [location for world in worlds for location in world.get_filled_locations()] @@ -157,15 +163,15 @@ def update_goal_items(spoiler): item_locations = {location for location in all_locations if location.item.majoritem and not location.locked} # required_locations[category.name][goal.name][world_id] = [...] - required_locations = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) - priority_locations = {(world.id): {} for world in worlds} + required_locations: RequiredLocations = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) + priority_locations = {world.id: {} for world in worlds} # rebuild hint exclusion list for world in worlds: - hintExclusions(world, clear_cache=True) + hint_exclusions(world, clear_cache=True) # getHintGroup relies on hint exclusion list - always_locations = [location.name for world in worlds for location in getHintGroup('always', world)] + always_locations = [location.name for world in worlds for location in get_hint_group('always', world)] if worlds[0].enable_goal_hints: # References first world for goal categories only @@ -268,7 +274,7 @@ def update_goal_items(spoiler): spoiler.goal_locations = required_locations_dict -def lock_category_entrances(category, state_list): +def lock_category_entrances(category: GoalCategory, state_list: "Iterable[State]") -> "Dict[int, Dict[str, Callable[[State, ...], bool]]]": # Disable access rules for specified entrances category_locks = {} if category.lock_entrances is not None: @@ -281,7 +287,8 @@ def lock_category_entrances(category, state_list): return category_locks -def unlock_category_entrances(category_locks, state_list): +def unlock_category_entrances(category_locks: "Dict[int, Dict[str, Callable[[State, ...], bool]]]", + state_list: "List[State]") -> None: # Restore access rules for state_id, exits in category_locks.items(): for exit_name, access_rule in exits.items(): @@ -289,9 +296,11 @@ def unlock_category_entrances(category_locks, state_list): exit.access_rule = access_rule -def search_goals(categories, reachable_goals, search, priority_locations, all_locations, item_locations, always_locations, search_woth=False): +def search_goals(categories: Dict[str, GoalCategory], reachable_goals: ValidGoals, search: Search, priority_locations: Dict[int, Dict[str, str]], + all_locations: "List[Location]", item_locations: "Collection[Location]", always_locations: Collection[str], + search_woth: bool = False) -> RequiredLocations: # required_locations[category.name][goal.name][world_id] = [...] - required_locations = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) + required_locations: RequiredLocations = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) world_ids = [state.world.id for state in search.state_list] if search_woth: required_locations['way of the hero'] = [] @@ -336,29 +345,10 @@ def search_goals(categories, reachable_goals, search, priority_locations, all_lo if search_woth and not valid_goals['way of the hero']: required_locations['way of the hero'].append(location) location.item = old_item - maybe_set_misc_item_hints(location) + location.maybe_set_misc_item_hints() remaining_locations.remove(location) search.state_list[location.item.world.id].collect(location.item) for location in remaining_locations: # finally, collect unreachable locations for misc. item hints - maybe_set_misc_item_hints(location) + location.maybe_set_misc_item_hints() return required_locations - - -def maybe_set_misc_item_hints(location): - if not location.item: - return - if location.item.world.dungeon_rewards_hinted and location.item.name in location.item.world.rewardlist: - if location.item.name not in location.item.world.hinted_dungeon_reward_locations: - location.item.world.hinted_dungeon_reward_locations[location.item.name] = location - logging.getLogger('').debug(f'{location.item.name} [{location.item.world.id}] set to [{location.name}]') - for hint_type in misc_item_hint_table: - item = location.item.world.misc_hint_items[hint_type] - if hint_type not in location.item.world.misc_hint_item_locations and location.item.name == item: - location.item.world.misc_hint_item_locations[hint_type] = location - logging.getLogger('').debug(f'{item} [{location.item.world.id}] set to [{location.name}]') - for hint_type in misc_location_hint_table: - the_location = location.world.misc_hint_locations[hint_type] - if hint_type not in location.world.misc_hint_location_items and location.name == the_location: - location.world.misc_hint_location_items[hint_type] = location.item - logging.getLogger('').debug(f'{the_location} [{location.world.id}] set to [{location.item.name}]') diff --git a/Gui.py b/Gui.py index f0d633191..3f4f32588 100755 --- a/Gui.py +++ b/Gui.py @@ -10,14 +10,15 @@ input("Press enter to exit...") sys.exit(1) -import subprocess import shutil +import subprocess import webbrowser -from Utils import local_path, data_path, compare_version, VersionError + from SettingsToJson import create_settings_list_json +from Utils import local_path, data_path, compare_version, VersionError -def gui_main(): +def gui_main() -> None: try: version_check("Node", "14.15.0", "https://nodejs.org/en/download/") version_check("NPM", "6.12.0", "https://nodejs.org/en/download/") @@ -37,18 +38,18 @@ def gui_main(): subprocess.run(args, shell=False, cwd=local_path("GUI"), check=True) -def version_check(name, version, URL): +def version_check(name: str, version: str, url: str) -> None: try: process = subprocess.Popen([shutil.which(name.lower()), "--version"], stdout=subprocess.PIPE) except Exception as ex: - raise VersionError('{name} is not installed. Please install {name} {version} or later'.format(name=name, version=version), URL) + raise VersionError('{name} is not installed. Please install {name} {version} or later'.format(name=name, version=version), url) while True: line = str(process.stdout.readline().strip(), 'UTF-8') if line == '': break if compare_version(line, version) < 0: - raise VersionError('{name} {version} or later is required but you are using {line}'.format(name=name, version=version, line=line), URL) + raise VersionError('{name} {version} or later is required but you are using {line}'.format(name=name, version=version, line=line), url) print('Using {name} {line}'.format(name=name, line=line)) diff --git a/HintList.py b/HintList.py index b49b9dc90..a19aaa5ec 100644 --- a/HintList.py +++ b/HintList.py @@ -1,4 +1,8 @@ import random +from typing import TYPE_CHECKING, Union, List, Dict, Callable, Tuple, Optional, Any, Collection + +if TYPE_CHECKING: + from World import World # Abbreviations # DMC Death Mountain Crater @@ -21,46 +25,47 @@ # ZR Zora's River -class Hint(object): - def __init__(self, name, text, type, choice=None): - self.name = name - self.type = [type] if not isinstance(type, list) else type +class Hint: + def __init__(self, name: str, text: Union[str, List[str]], hint_type: Union[str, List[str]], choice: int = None) -> None: + self.name: str = name + self.type: List[str] = [hint_type] if not isinstance(hint_type, list) else hint_type + self.text: str if isinstance(text, str): self.text = text else: - if choice == None: + if choice is None: self.text = random.choice(text) else: self.text = text[choice] -class Multi(object): - def __init__(self, name, locations): - self.name = name - self.locations = locations +class Multi: + def __init__(self, name: str, locations: List[str]) -> None: + self.name: str = name + self.locations: List[str] = locations -def getHint(name, clearer_hint=False): - textOptions, clearText, type = hintTable[name] +def get_hint(name: str, clearer_hint: bool = False) -> Hint: + text_options, clear_text, hint_type = hintTable[name] if clearer_hint: - if clearText == None: - return Hint(name, textOptions, type, 0) - return Hint(name, clearText, type) + if clear_text is None: + return Hint(name, text_options, hint_type, 0) + return Hint(name, clear_text, hint_type) else: - return Hint(name, textOptions, type) + return Hint(name, text_options, hint_type) -def getMulti(name): +def get_multi(name: str) -> Multi: locations = multiTable[name] return Multi(name, locations) -def getHintGroup(group, world): +def get_hint_group(group: str, world: "World") -> List[Hint]: ret = [] for name in hintTable: - hint = getHint(name, world.settings.clearer_hints) + hint = get_hint(name, world.settings.clearer_hints) if hint.name in world.always_hints and group == 'always': hint.type = 'always' @@ -81,7 +86,7 @@ def getHintGroup(group, world): if hint.name in world.added_hint_types[group]: hint.type = group type_append = True - if nameIsLocation(name, hint.type, world): + if name_is_location(name, hint.type, world): location = world.get_location(name) for i in world.item_added_hint_types[group]: if i == location.item.name: @@ -95,39 +100,39 @@ def getHintGroup(group, world): if name in world.hint_type_overrides[group]: type_override = True if group in world.item_hint_type_overrides: - if nameIsLocation(name, hint.type, world): + if name_is_location(name, hint.type, world): location = world.get_location(name) if location.item.name in world.item_hint_type_overrides[group]: type_override = True elif name in multiTable.keys(): - multi = getMulti(name) + multi = get_multi(name) for locationName in multi.locations: - if locationName not in hintExclusions(world): + if locationName not in hint_exclusions(world): location = world.get_location(locationName) if location.item.name in world.item_hint_type_overrides[group]: type_override = True - if group in hint.type and (name not in hintExclusions(world)) and not type_override and (conditional_keep or type_append): + if group in hint.type and (name not in hint_exclusions(world)) and not type_override and (conditional_keep or type_append): ret.append(hint) return ret -def getRequiredHints(world): +def get_required_hints(world: "World") -> List[Hint]: ret = [] for name in hintTable: - hint = getHint(name) + hint = get_hint(name) if 'always' in hint.type or hint.name in conditional_always and conditional_always[hint.name](world): ret.append(hint) return ret # Get the multi hints containing the list of locations for a possible hint upgrade. -def getUpgradeHintList(world, locations): +def get_upgrade_hint_list(world: "World", locations: List[str]) -> List[Hint]: ret = [] for name in multiTable: - if name not in hintExclusions(world): - hint = getHint(name, world.settings.clearer_hints) - multi = getMulti(name) + if name not in hint_exclusions(world): + hint = get_hint(name, world.settings.clearer_hints) + multi = get_multi(name) if len(locations) < len(multi.locations) and all(location in multi.locations for location in locations) and (hint.name not in conditional_sometimes.keys() or conditional_sometimes[hint.name](world)): accepted_type = False @@ -139,7 +144,7 @@ def getUpgradeHintList(world, locations): type_override = True if hint_type in world.item_hint_type_overrides: for locationName in multi.locations: - if locationName not in hintExclusions(world): + if locationName not in hint_exclusions(world): location = world.get_location(locationName) if location.item.name in world.item_hint_type_overrides[hint_type]: type_override = True @@ -156,7 +161,8 @@ def getUpgradeHintList(world, locations): # Helpers for conditional always hints -def stones_required_by_settings(world): +# TODO: Make these properties of World or Settings. +def stones_required_by_settings(world: "World") -> int: stones = 0 if world.settings.bridge == 'stones' and not world.shuffle_special_dungeon_entrances: stones = max(stones, world.settings.bridge_stones) @@ -174,7 +180,7 @@ def stones_required_by_settings(world): return stones -def medallions_required_by_settings(world): +def medallions_required_by_settings(world: "World") -> int: medallions = 0 if world.settings.bridge == 'medallions' and not world.shuffle_special_dungeon_entrances: medallions = max(medallions, world.settings.bridge_medallions) @@ -192,7 +198,7 @@ def medallions_required_by_settings(world): return medallions -def tokens_required_by_settings(world): +def tokens_required_by_settings(world: "World") -> int: tokens = 0 if world.settings.bridge == 'tokens' and not world.shuffle_special_dungeon_entrances: tokens = max(tokens, world.settings.bridge_tokens) @@ -205,7 +211,7 @@ def tokens_required_by_settings(world): # Hints required under certain settings -conditional_always = { +conditional_always: "Dict[str, Callable[[World], bool]]" = { 'Market 10 Big Poes': lambda world: world.settings.big_poe_count > 3, 'Deku Theater Mask of Truth': lambda world: not world.settings.complete_mask_quest, 'Song from Ocarina of Time': lambda world: stones_required_by_settings(world) < 2, @@ -220,7 +226,7 @@ def tokens_required_by_settings(world): } # Entrance hints required under certain settings -conditional_entrance_always = { +conditional_entrance_always: "Dict[str, Callable[[World], bool]]" = { 'Ganons Castle Grounds -> Ganons Castle Lobby': lambda world: (world.settings.bridge != 'open' and (world.settings.bridge != 'stones' or world.settings.bridge_stones > 1) and (world.settings.bridge != 'medallions' or world.settings.bridge_medallions > 1) @@ -230,14 +236,14 @@ def tokens_required_by_settings(world): } # Dual hints required under certain settings -conditional_dual_always = { +conditional_dual_always: "Dict[str, Callable[[World], bool]]" = { 'HF Ocarina of Time Retrieval': lambda world: stones_required_by_settings(world) < 2, 'Deku Theater Rewards': lambda world: not world.settings.complete_mask_quest, 'ZR Frogs Rewards': lambda world: not world.settings.shuffle_frog_song_rupees and 'frogs2' not in world.settings.misc_hints, } # Some sometimes, dual, and entrance hints should only be enabled under certain settings -conditional_sometimes = { +conditional_sometimes: "Dict[str, Callable[[World], bool]]" = { # Conditional sometimes hints 'HC Great Fairy Reward': lambda world: world.settings.shuffle_interior_entrances == 'off', 'OGC Great Fairy Reward': lambda world: world.settings.shuffle_interior_entrances == 'off', @@ -284,7 +290,7 @@ def tokens_required_by_settings(world): # \u00A9 Down arrow # \u00AA Joystick -hintTable = { +hintTable: Dict[str, Tuple[List[str], Optional[str], Union[str, List[str]]]] = { 'Kokiri Emerald': (["a tree's farewell", "the Spiritual Stone of the Forest"], "the Kokiri Emerald", 'item'), 'Goron Ruby': (["the Gorons' hidden treasure", "the Spiritual Stone of Fire"], "the Goron Ruby", 'item'), 'Zora Sapphire': (["an engagement ring", "the Spiritual Stone of Water"], "the Zora Sapphire", 'item'), @@ -1429,7 +1435,7 @@ def tokens_required_by_settings(world): 'ZD Storms Grotto': ("a small #Fairy Fountain#", None, 'region'), 'GF Storms Grotto': ("a small #Fairy Fountain#", None, 'region'), - # Junk hints must satisfy all of the following conditions: + # Junk hints must satisfy all the following conditions: # - They aren't inappropriate. # - They aren't absurdly long copy pastas. # - They aren't quotes or references that are simply not funny when out-of-context. @@ -1717,7 +1723,7 @@ def tokens_required_by_settings(world): # Table containing the groups of locations for the multi hints (dual, etc.) # The is used in order to add the locations to the checked list -multiTable = { +multiTable: Dict[str, List[str]] = { 'Deku Theater Rewards': ['Deku Theater Skull Mask', 'Deku Theater Mask of Truth'], 'HF Ocarina of Time Retrieval': ['HF Ocarina of Time Item', 'Song from Ocarina of Time'], 'HF Valley Grotto': ['HF Cow Grotto Cow', 'HF GS Cow Grotto'], @@ -1765,7 +1771,8 @@ def tokens_required_by_settings(world): 'Ganons Castle Spirit Trial Chests': ['Ganons Castle Spirit Trial Crystal Switch Chest', 'Ganons Castle Spirit Trial Invisible Chest'], } -misc_item_hint_table = { +# TODO: Make these a type of some sort instead of a dict. +misc_item_hint_table: Dict[str, Dict[str, Any]] = { 'dampe_diary': { 'id': 0x5003, 'hint_location': 'Dampe Diary Hint', @@ -1794,7 +1801,7 @@ def tokens_required_by_settings(world): }, } -misc_location_hint_table = { +misc_location_hint_table: Dict[str, Dict[str, Any]] = { '10_skulltulas': { 'id': 0x9004, 'hint_location': '10 Skulltulas Reward Hint', @@ -1842,7 +1849,7 @@ def tokens_required_by_settings(world): # Separate table for goal names to avoid duplicates in the hint table. # Link's Pocket will always be an empty goal, but it's included here to # prevent key errors during the dungeon reward lookup. -goalTable = { +goalTable: Dict[str, Tuple[str, str, str]] = { 'Queen Gohma': ("path to the #Spider#", "path to #Queen Gohma#", "Green"), 'King Dodongo': ("path to the #Dinosaur#", "path to #King Dodongo#", "Red"), 'Barinade': ("path to the #Tentacle#", "path to #Barinade#", "Blue"), @@ -1856,23 +1863,25 @@ def tokens_required_by_settings(world): # This specifies which hints will never appear due to either having known or known useless contents or due to the locations not existing. -def hintExclusions(world, clear_cache=False): - if not clear_cache and world.id in hintExclusions.exclusions: - return hintExclusions.exclusions[world.id] +def hint_exclusions(world: "World", clear_cache: bool = False) -> List[str]: + exclusions: Dict[int, List[str]] = hint_exclusions.exclusions + + if not clear_cache and world.id in exclusions: + return exclusions[world.id] - hintExclusions.exclusions[world.id] = [] - hintExclusions.exclusions[world.id].extend(world.settings.disabled_locations) + exclusions[world.id] = [] + exclusions[world.id].extend(world.settings.disabled_locations) for location in world.get_locations(): if location.locked: - hintExclusions.exclusions[world.id].append(location.name) + exclusions[world.id].append(location.name) world_location_names = [ location.name for location in world.get_locations()] location_hints = [] for name in hintTable: - hint = getHint(name, world.settings.clearer_hints) + hint = get_hint(name, world.settings.clearer_hints) if any(item in hint.type for item in ['always', 'dual_always', @@ -1888,31 +1897,33 @@ def hintExclusions(world, clear_cache=False): if any(item in hint.type for item in ['dual', 'dual_always']): - multi = getMulti(hint.name) + multi = get_multi(hint.name) exclude_hint = False for location in multi.locations: if location not in world_location_names or world.get_location(location).locked: exclude_hint = True if exclude_hint: - hintExclusions.exclusions[world.id].append(hint.name) + exclusions[world.id].append(hint.name) else: - if hint.name not in world_location_names and hint.name not in hintExclusions.exclusions[world.id]: - hintExclusions.exclusions[world.id].append(hint.name) - return hintExclusions.exclusions[world.id] + if hint.name not in world_location_names and hint.name not in exclusions[world.id]: + exclusions[world.id].append(hint.name) + return exclusions[world.id] -hintExclusions.exclusions = {} +hint_exclusions.exclusions = {} -def nameIsLocation(name, hint_type, world): +def name_is_location(name: str, hint_type: Union[str, Collection[str]], world: "World") -> bool: if isinstance(hint_type, (list, tuple)): for htype in hint_type: - if htype in ['sometimes', 'song', 'overworld', 'dungeon', 'always', 'exclude'] and name not in hintExclusions(world): + if htype in ['sometimes', 'song', 'overworld', 'dungeon', 'always', 'exclude'] and name not in hint_exclusions( + world): return True - elif hint_type in ['sometimes', 'song', 'overworld', 'dungeon', 'always', 'exclude'] and name not in hintExclusions(world): + elif hint_type in ['sometimes', 'song', 'overworld', 'dungeon', 'always', 'exclude'] and name not in hint_exclusions( + world): return True return False -def clearHintExclusionCache(): - hintExclusions.exclusions.clear() +def clear_hint_exclusion_cache() -> None: + hint_exclusions.exclusions.clear() diff --git a/Hints.py b/Hints.py index f58f15e3f..0c72769cf 100644 --- a/Hints.py +++ b/Hints.py @@ -1,29 +1,42 @@ +import itertools +import json import logging import os import random -from collections import OrderedDict, defaultdict import urllib.request -from urllib.error import URLError, HTTPError -import json +from collections import OrderedDict, defaultdict from enum import Enum -import itertools +from typing import TYPE_CHECKING, Set, List, Optional, Dict, Union, MutableSet, Tuple, Callable, Iterable +from urllib.error import URLError, HTTPError -from HintList import getHint, getMulti, getHintGroup, getUpgradeHintList, hintExclusions, misc_item_hint_table, misc_location_hint_table -from Item import Item, MakeEventItem -from Messages import COLOR_MAP, update_message_by_id +from HintList import Hint, get_hint, get_multi, get_hint_group, get_upgrade_hint_list, hint_exclusions, \ + misc_item_hint_table, misc_location_hint_table +from Item import Item, make_event_item +from Messages import Message, COLOR_MAP, update_message_by_id from Region import Region from Search import Search from TextBox import line_wrap -from Utils import data_path +from Utils import TypeAlias, data_path +if TYPE_CHECKING: + from Entrance import Entrance + from Goals import GoalCategory + from Location import Location + from Spoiler import Spoiler + from World import World -bingoBottlesForHints = { +Spot: TypeAlias = "Union[Entrance, Location, Region]" +HintReturn: TypeAlias = "Optional[Tuple[GossipText, Optional[List[Location]]]]" +HintFunc: TypeAlias = "Callable[[Spoiler, World, MutableSet[str]], HintReturn]" +BarrenFunc: TypeAlias = "Callable[[Spoiler, World, MutableSet[str], MutableSet[str]], HintReturn]" + +bingoBottlesForHints: Set[str] = { "Bottle", "Bottle with Red Potion", "Bottle with Green Potion", "Bottle with Blue Potion", "Bottle with Fairy", "Bottle with Fish", "Bottle with Blue Fire", "Bottle with Bugs", "Bottle with Big Poe", "Bottle with Poe", } -defaultHintDists = [ +defaultHintDists: List[str] = [ 'balanced.json', 'bingo.json', 'chaos.json', @@ -42,7 +55,7 @@ 'weekly.json', ] -unHintableWothItems = {'Triforce Piece', 'Gold Skulltula Token', 'Piece of Heart', 'Piece of Heart (Treasure Chest Game)', 'Heart Container'} +unHintableWothItems: Set[str] = {'Triforce Piece', 'Gold Skulltula Token', 'Piece of Heart', 'Piece of Heart (Treasure Chest Game)', 'Heart Container'} class RegionRestriction(Enum): @@ -52,26 +65,27 @@ class RegionRestriction(Enum): class GossipStone: - def __init__(self, name, location): - self.name = name - self.location = location - self.reachable = True + def __init__(self, name: str, location: str) -> None: + self.name: str = name + self.location: str = location + self.reachable: bool = True class GossipText: - def __init__(self, text, colors=None, hinted_locations=None, hinted_items=None, prefix="They say that "): + def __init__(self, text: str, colors: Optional[List[str]] = None, hinted_locations: Optional[List[str]] = None, + hinted_items: Optional[List[str]] = None, prefix: str = "They say that ") -> None: text = prefix + text text = text[:1].upper() + text[1:] - self.text = text - self.colors = colors - self.hinted_locations = hinted_locations - self.hinted_items = hinted_items + self.text: str = text + self.colors: Optional[List[str]] = colors + self.hinted_locations: Optional[List[str]] = hinted_locations + self.hinted_items: Optional[List[str]] = hinted_items - def to_json(self): + def to_json(self) -> dict: return {'text': self.text, 'colors': self.colors, 'hinted_locations': self.hinted_locations, 'hinted_items': self.hinted_items} - def __str__(self): - return get_raw_text(line_wrap(colorText(self))) + def __str__(self) -> str: + return get_raw_text(line_wrap(color_text(self))) # Abbreviations @@ -90,7 +104,7 @@ def __str__(self): # ZF Zora's Fountain # ZR Zora's River -gossipLocations = { +gossipLocations: Dict[int, GossipStone] = { 0x0405: GossipStone('DMC (Bombable Wall)', 'DMC Gossip Stone'), 0x0404: GossipStone('DMT (Biggoron)', 'DMT Gossip Stone'), 0x041A: GossipStone('Colossus (Spirit Temple)', 'Colossus Gossip Stone'), @@ -134,19 +148,19 @@ def __str__(self): 0x044A: GossipStone('DMC (Upper Grotto)', 'DMC Upper Grotto Gossip Stone'), } -gossipLocations_reversemap = { - stone.name : stone_id for stone_id, stone in gossipLocations.items() +gossipLocations_reversemap: Dict[str, int] = { + stone.name: stone_id for stone_id, stone in gossipLocations.items() } -def getItemGenericName(item): +def get_item_generic_name(item: Item) -> str: if item.unshuffled_dungeon_item: return item.type else: return item.name -def is_restricted_dungeon_item(item): +def is_restricted_dungeon_item(item: Item) -> bool: return ( ((item.map or item.compass) and item.world.settings.shuffle_mapcompass == 'dungeon') or (item.type == 'SmallKey' and item.world.settings.shuffle_smallkeys == 'dungeon') or @@ -156,7 +170,8 @@ def is_restricted_dungeon_item(item): ) -def add_hint(spoiler, world, groups, gossip_text, count, locations=[], force_reachable=False): +def add_hint(spoiler: "Spoiler", world: "World", groups: List[List[int]], gossip_text: GossipText, count: int, + locations: "Optional[List[Location]]" = None, force_reachable: bool = False) -> bool: random.shuffle(groups) skipped_groups = [] duplicates = [] @@ -188,9 +203,9 @@ def add_hint(spoiler, world, groups, gossip_text, count, locations=[], force_rea for i, stone_name in enumerate(stone_names): # place the same event item in each location in the group if event_item is None: - event_item = MakeEventItem(stone_name, stone_locations[i], event_item) + event_item = make_event_item(stone_name, stone_locations[i], event_item) else: - MakeEventItem(stone_name, stone_locations[i], event_item) + make_event_item(stone_name, stone_locations[i], event_item) # This mostly guarantees that we don't lock the player out of an item hint # by establishing a (hint -> item) -> hint -> item -> (first hint) loop @@ -245,7 +260,7 @@ def add_hint(spoiler, world, groups, gossip_text, count, locations=[], force_rea return success -def can_reach_hint(worlds, hint_location, location): +def can_reach_hint(worlds: "List[World]", hint_location: "Location", location: "Location") -> bool: if location is None: return True @@ -258,19 +273,19 @@ def can_reach_hint(worlds, hint_location, location): and (hint_location.type != 'HintStone' or search.state_list[location.world.id].guarantee_hint())) -def writeGossipStoneHints(spoiler, world, messages): +def write_gossip_stone_hints(spoiler: "Spoiler", world: "World", messages: List[Message]) -> None: for id, gossip_text in spoiler.hints[world.id].items(): update_message_by_id(messages, id, str(gossip_text), 0x23) -def filterTrailingSpace(text): +def filter_trailing_space(text: str) -> str: if text.endswith('& '): return text[:-1] else: return text -hintPrefixes = [ +hintPrefixes: List[str] = [ 'a few ', 'some ', 'plenty of ', @@ -280,8 +295,9 @@ def filterTrailingSpace(text): '', ] -def getSimpleHintNoPrefix(item): - hint = getHint(item.name, True).text + +def get_simple_hint_no_prefix(item: Item) -> Hint: + hint = get_hint(item.name, True).text for prefix in hintPrefixes: if hint.startswith(prefix): @@ -292,24 +308,24 @@ def getSimpleHintNoPrefix(item): return hint -def colorText(gossip_text): +def color_text(gossip_text: GossipText) -> str: text = gossip_text.text colors = list(gossip_text.colors) if gossip_text.colors is not None else [] color = 'White' while '#' in text: - splitText = text.split('#', 2) + split_text = text.split('#', 2) if len(colors) > 0: color = colors.pop(0) for prefix in hintPrefixes: - if splitText[1].startswith(prefix): - splitText[0] += splitText[1][:len(prefix)] - splitText[1] = splitText[1][len(prefix):] + if split_text[1].startswith(prefix): + split_text[0] += split_text[1][:len(prefix)] + split_text[1] = split_text[1][len(prefix):] break - splitText[1] = '\x05' + COLOR_MAP[color] + splitText[1] + '\x05\x40' - text = ''.join(splitText) + split_text[1] = '\x05' + COLOR_MAP[color] + split_text[1] + '\x05\x40' + text = ''.join(split_text) return text @@ -363,7 +379,7 @@ class HintArea(Enum): # Performs a breadth first search to find the closest hint area from a given spot (region, location, or entrance). # May fail to find a hint if the given spot is only accessible from the root and not from any other region with a hint area @staticmethod - def at(spot, use_alt_hint=False): + def at(spot: Spot, use_alt_hint: bool = False) -> 'HintArea': if isinstance(spot, Region): original_parent = spot else: @@ -400,50 +416,50 @@ def at(spot, use_alt_hint=False): raise HintAreaNotFound('No hint area could be found for %s [World %d]' % (spot, spot.world.id)) @classmethod - def for_dungeon(cls, dungeon_name: str): + def for_dungeon(cls, dungeon_name: str) -> 'Optional[HintArea]': if '(' in dungeon_name and ')' in dungeon_name: # A dungeon item name was passed in - get the name of the dungeon from it. dungeon_name = dungeon_name[dungeon_name.index('(') + 1:dungeon_name.index(')')] if dungeon_name == "Thieves Hideout": # Special case for Thieves' Hideout since it's not considered a dungeon - return HintArea.THIEVES_HIDEOUT + return cls.THIEVES_HIDEOUT if dungeon_name == "Treasure Chest Game": # Special case for Treasure Chest Game keys: treat them as part of the market hint area regardless of where the treasure box shop actually is. - return HintArea.MARKET + return cls.MARKET for hint_area in cls: if hint_area.dungeon_name is not None and hint_area.dungeon_name in dungeon_name: return hint_area return None - def preposition(self, clearer_hints): + def preposition(self, clearer_hints: bool) -> str: return self.value[1 if clearer_hints else 0] - def __str__(self): + def __str__(self) -> str: return self.value[2] # used for dungeon reward locations in the pause menu @property - def short_name(self): + def short_name(self) -> str: return self.value[3] # Hint areas are further grouped into colored sections of the map by association with the medallions. # These colors are used to generate the text boxes for shuffled warp songs. @property - def color(self): + def color(self) -> str: return self.value[4] @property - def dungeon_name(self): + def dungeon_name(self) -> Optional[str]: return self.value[5] @property - def is_dungeon(self): + def is_dungeon(self) -> bool: return self.dungeon_name is not None - def is_dungeon_item(self, item): + def is_dungeon_item(self, item: Item) -> bool: for dungeon in item.world.dungeons: if dungeon.name == self.dungeon_name: return dungeon.is_dungeon_item(item) @@ -451,9 +467,9 @@ def is_dungeon_item(self, item): # Formats the hint text for this area with proper grammar. # Dungeons are hinted differently depending on the clearer_hints setting. - def text(self, clearer_hints, preposition=False, world=None): + def text(self, clearer_hints: bool, preposition: bool = False, world: "Optional[World]" = None) -> str: if self.is_dungeon: - text = getHint(self.dungeon_name, clearer_hints).text + text = get_hint(self.dungeon_name, clearer_hints).text else: text = str(self) prefix, suffix = text.replace('#', '').split(' ', 1) @@ -478,7 +494,7 @@ def text(self, clearer_hints, preposition=False, world=None): return text -def get_woth_hint(spoiler, world, checked): +def get_woth_hint(spoiler: "Spoiler", world: "World", checked: MutableSet[str]) -> HintReturn: locations = spoiler.required_locations[world.id] locations = list(filter(lambda location: location.name not in checked @@ -503,20 +519,20 @@ def get_woth_hint(spoiler, world, checked): return GossipText('%s is on the way of the hero.' % location_text, ['Light Blue'], [location.name], [location.item.name]), [location] -def get_checked_areas(world, checked): - def get_area_from_name(check): +def get_checked_areas(world: "World", checked: MutableSet[str]) -> Set[Union[HintArea, str]]: + def get_area_from_name(check: str) -> Union[HintArea, str]: try: location = world.get_location(check) except Exception: return check # Don't consider dungeons as already hinted from the reward hint on the Temple of Time altar - if location.type != 'Boss': #TODO or shuffled dungeon rewards + if location.type != 'Boss': # TODO or shuffled dungeon rewards return HintArea.at(location) return set(get_area_from_name(check) for check in checked) -def get_goal_category(spoiler, world, goal_categories): +def get_goal_category(spoiler: "Spoiler", world: "World", goal_categories: "Dict[str, GoalCategory]") -> "GoalCategory": cat_sizes = [] cat_names = [] zero_weights = True @@ -551,7 +567,7 @@ def get_goal_category(spoiler, world, goal_categories): return goal_category -def get_goal_hint(spoiler, world, checked): +def get_goal_hint(spoiler: "Spoiler", world: "World", checked: MutableSet[str]) -> HintReturn: goal_category = get_goal_category(spoiler, world, world.goal_categories) # check if no goals were generated (and thus no categories available) @@ -560,12 +576,13 @@ def get_goal_hint(spoiler, world, checked): goals = goal_category.goals category_locations = [] + zero_weights = True + location_reverse_map = defaultdict(list) # Collect unhinted locations for the category across all category goals. # If all locations for all goals in the category are hinted, try remaining goal categories # If all locations for all goal categories are hinted, return no hint. while not category_locations: - # Filter hinted goals until every goal in the category has been hinted. weights = [] zero_weights = True @@ -644,19 +661,19 @@ def get_goal_hint(spoiler, world, checked): return GossipText('%s is on %s %s.' % (location_text, player_text, goal_text), ['Light Blue', goal.color], [location.name], [location.item.name]), [location] -def get_barren_hint(spoiler, world, checked, allChecked): +def get_barren_hint(spoiler: "Spoiler", world: "World", checked: MutableSet[str], all_checked: MutableSet[str]) -> HintReturn: if not hasattr(world, 'get_barren_hint_prev'): world.get_barren_hint_prev = RegionRestriction.NONE checked_areas = get_checked_areas(world, checked) areas = list(filter(lambda area: area not in checked_areas - and area not in world.hint_type_overrides['barren'] + and str(area) not in world.hint_type_overrides['barren'] and not (world.barren_dungeon >= world.hint_dist_user['dungeons_barren_limit'] and world.empty_areas[area]['dungeon']) and any( - location.name not in allChecked + location.name not in all_checked and location.name not in world.hint_exclusions - and location.name not in hintExclusions(world) + and location.name not in hint_exclusions(world) and HintArea.at(location) == area for location in world.get_locations() ), @@ -703,11 +720,11 @@ def get_barren_hint(spoiler, world, checked, allChecked): return GossipText("plundering %s is a foolish choice." % area.text(world.settings.clearer_hints), ['Pink']), None -def is_not_checked(locations, checked): +def is_not_checked(locations: "Iterable[Location]", checked: MutableSet[Union[HintArea, str]]) -> bool: return not any(location.name in checked or HintArea.at(location) in checked for location in locations) -def get_good_item_hint(spoiler, world, checked): +def get_good_item_hint(spoiler: "Spoiler", world: "World", checked: MutableSet[str]) -> HintReturn: locations = list(filter(lambda location: is_not_checked([location], checked) and ((location.item.majoritem @@ -725,7 +742,7 @@ def get_good_item_hint(spoiler, world, checked): location = random.choice(locations) checked.add(location.name) - item_text = getHint(getItemGenericName(location.item), world.settings.clearer_hints).text + item_text = get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text hint_area = HintArea.at(location) if hint_area.is_dungeon: location_text = hint_area.text(world.settings.clearer_hints) @@ -735,7 +752,7 @@ def get_good_item_hint(spoiler, world, checked): return GossipText('#%s# can be found %s.' % (item_text, location_text), ['Green', 'Red'], [location.name], [location.item.name]), [location] -def get_specific_item_hint(spoiler, world, checked): +def get_specific_item_hint(spoiler: "Spoiler", world: "World", checked: MutableSet[str]) -> HintReturn: if len(world.named_item_pool) == 0: logger = logging.getLogger('') logger.info("Named item hint requested, but pool is empty.") @@ -779,7 +796,7 @@ def get_specific_item_hint(spoiler, world, checked): location = random.choice(locations) checked.add(location.name) - item_text = getHint(getItemGenericName(location.item), world.settings.clearer_hints).text + item_text = get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text hint_area = HintArea.at(location) if world.hint_dist_user.get('vague_named_items', False): @@ -807,7 +824,7 @@ def get_specific_item_hint(spoiler, world, checked): named_item_locations = [location for w in worlds for location in w.get_filled_locations() if (location.item.name in all_named_items)] spoiler._cached_named_item_locations = named_item_locations - always_hints = [(hint, w.id) for w in worlds for hint in getHintGroup('always', w)] + always_hints = [(hint, w.id) for w in worlds for hint in get_hint_group('always', w)] always_locations = [] for hint, id in always_hints: location = worlds[id].get_location(hint.name) @@ -860,7 +877,7 @@ def get_specific_item_hint(spoiler, world, checked): location = random.choice(locations) checked.add(location.name) - item_text = getHint(getItemGenericName(location.item), world.settings.clearer_hints).text + item_text = get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text hint_area = HintArea.at(location) if world.hint_dist_user.get('vague_named_items', False): @@ -874,7 +891,7 @@ def get_specific_item_hint(spoiler, world, checked): return GossipText('#%s# can be found %s.' % (item_text, location_text), ['Green', 'Red'], [location.name], [location.item.name]), [location] -def get_random_location_hint(spoiler, world, checked): +def get_random_location_hint(spoiler: "Spoiler", world: "World", checked: MutableSet[str]) -> HintReturn: locations = list(filter(lambda location: is_not_checked([location], checked) and location.item.type not in ('Drop', 'Event', 'Shop', 'DungeonReward') @@ -889,7 +906,7 @@ def get_random_location_hint(spoiler, world, checked): location = random.choice(locations) checked.add(location.name) - item_text = getHint(getItemGenericName(location.item), world.settings.clearer_hints).text + item_text = get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text hint_area = HintArea.at(location) if hint_area.is_dungeon: @@ -900,27 +917,28 @@ def get_random_location_hint(spoiler, world, checked): return GossipText('#%s# can be found %s.' % (item_text, location_text), ['Green', 'Red'], [location.name], [location.item.name]), [location] -def get_specific_hint(spoiler, world, checked, type): - hintGroup = getHintGroup(type, world) - hintGroup = list(filter(lambda hint: is_not_checked([world.get_location(hint.name)], checked), hintGroup)) - if not hintGroup: +def get_specific_hint(spoiler: "Spoiler", world: "World", checked: MutableSet[str], hint_type: str) -> HintReturn: + hint_group = get_hint_group(hint_type, world) + hint_group = list(filter(lambda hint: is_not_checked([world.get_location(hint.name)], checked), hint_group)) + if not hint_group: return None - hint = random.choice(hintGroup) + hint = random.choice(hint_group) if world.hint_dist_user['upgrade_hints'] in ['on', 'limited']: - upgrade_list = getUpgradeHintList(world, [hint.name]) - upgrade_list = list(filter(lambda upgrade: is_not_checked([world.get_location(location) for location in getMulti(upgrade.name).locations], checked), upgrade_list)) + upgrade_list = get_upgrade_hint_list(world, [hint.name]) + upgrade_list = list(filter(lambda upgrade: is_not_checked([world.get_location(location) for location in get_multi( + upgrade.name).locations], checked), upgrade_list)) if upgrade_list is not None: multi = None for upgrade in upgrade_list: - upgrade_multi = getMulti(upgrade.name) + upgrade_multi = get_multi(upgrade.name) if not multi or len(multi.locations) < len(upgrade_multi.locations): hint = upgrade - multi = getMulti(hint.name) + multi = get_multi(hint.name) if multi: return get_specific_multi_hint(spoiler, world, checked, hint) @@ -934,30 +952,31 @@ def get_specific_hint(spoiler, world, checked, type): location_text = hint.text if '#' not in location_text: location_text = '#%s#' % location_text - item_text = getHint(getItemGenericName(location.item), world.settings.clearer_hints).text + item_text = get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text return GossipText('%s #%s#.' % (location_text, item_text), ['Red', 'Green'], [location.name], [location.item.name]), [location] -def get_sometimes_hint(spoiler, world, checked): +def get_sometimes_hint(spoiler: "Spoiler", world: "World", checked: MutableSet[str]) -> HintReturn: return get_specific_hint(spoiler, world, checked, 'sometimes') -def get_song_hint(spoiler, world, checked): +def get_song_hint(spoiler: "Spoiler", world: "World", checked: MutableSet[str]) -> HintReturn: return get_specific_hint(spoiler, world, checked, 'song') -def get_overworld_hint(spoiler, world, checked): +def get_overworld_hint(spoiler: "Spoiler", world: "World", checked: MutableSet[str]) -> HintReturn: return get_specific_hint(spoiler, world, checked, 'overworld') -def get_dungeon_hint(spoiler, world, checked): +def get_dungeon_hint(spoiler: "Spoiler", world: "World", checked: MutableSet[str]) -> HintReturn: return get_specific_hint(spoiler, world, checked, 'dungeon') -def get_random_multi_hint(spoiler, world, checked, type): - hint_group = getHintGroup(type, world) - multi_hints = list(filter(lambda hint: is_not_checked([world.get_location(location) for location in getMulti(hint.name).locations], checked), hint_group)) +def get_random_multi_hint(spoiler: "Spoiler", world: "World", checked: "MutableSet", hint_type: str) -> HintReturn: + hint_group = get_hint_group(hint_type, world) + multi_hints = list(filter(lambda hint: is_not_checked([world.get_location(location) for location in get_multi( + hint.name).locations], checked), hint_group)) if not multi_hints: return None @@ -965,24 +984,25 @@ def get_random_multi_hint(spoiler, world, checked, type): hint = random.choice(multi_hints) if world.hint_dist_user['upgrade_hints'] in ['on', 'limited']: - multi = getMulti(hint.name) + multi = get_multi(hint.name) - upgrade_list = getUpgradeHintList(world, multi.locations) - upgrade_list = list(filter(lambda upgrade: is_not_checked([world.get_location(location) for location in getMulti(upgrade.name).locations], checked), upgrade_list)) + upgrade_list = get_upgrade_hint_list(world, multi.locations) + upgrade_list = list(filter(lambda upgrade: is_not_checked([world.get_location(location) for location in get_multi( + upgrade.name).locations], checked), upgrade_list)) if upgrade_list: for upgrade in upgrade_list: - upgrade_multi = getMulti(upgrade.name) + upgrade_multi = get_multi(upgrade.name) if len(multi.locations) < len(upgrade_multi.locations): hint = upgrade - multi = getMulti(hint.name) + multi = get_multi(hint.name) return get_specific_multi_hint(spoiler, world, checked, hint) -def get_specific_multi_hint(spoiler, world, checked, hint): - multi = getMulti(hint.name) +def get_specific_multi_hint(spoiler: "Spoiler", world: "World", checked: MutableSet[str], hint: Hint) -> HintReturn: + multi = get_multi(hint.name) locations = [world.get_location(location) for location in multi.locations] for location in locations: @@ -1006,22 +1026,22 @@ def get_specific_multi_hint(spoiler, world, checked, hint): gossip_string = gossip_string + '#%s# ' items = [location.item for location in locations] - text_segments = [multi_text] + [getHint(getItemGenericName(item), world.settings.clearer_hints).text for item in items] + text_segments = [multi_text] + [get_hint(get_item_generic_name(item), world.settings.clearer_hints).text for item in items] return GossipText(gossip_string % tuple(text_segments), colors, [location.name for location in locations], [item.name for item in items]), locations -def get_dual_hint(spoiler, world, checked): +def get_dual_hint(spoiler: "Spoiler", world: "World", checked: MutableSet[str]) -> HintReturn: return get_random_multi_hint(spoiler, world, checked, 'dual') -def get_entrance_hint(spoiler, world, checked): +def get_entrance_hint(spoiler: "Spoiler", world: "World", checked: MutableSet[str]) -> HintReturn: if not world.entrance_shuffle: return None - entrance_hints = list(filter(lambda hint: hint.name not in checked, getHintGroup('entrance', world))) + entrance_hints = list(filter(lambda hint: hint.name not in checked, get_hint_group('entrance', world))) shuffled_entrance_hints = list(filter(lambda entrance_hint: world.get_entrance(entrance_hint.name).shuffled, entrance_hints)) - regions_with_hint = [hint.name for hint in getHintGroup('region', world)] + regions_with_hint = [hint.name for hint in get_hint_group('region', world)] valid_entrance_hints = list(filter(lambda entrance_hint: (world.get_entrance(entrance_hint.name).connected_region.name in regions_with_hint or world.get_entrance(entrance_hint.name).connected_region.dungeon), shuffled_entrance_hints)) @@ -1040,9 +1060,9 @@ def get_entrance_hint(spoiler, world, checked): connected_region = entrance.connected_region if connected_region.dungeon: - region_text = getHint(connected_region.dungeon.name, world.settings.clearer_hints).text + region_text = get_hint(connected_region.dungeon.name, world.settings.clearer_hints).text else: - region_text = getHint(connected_region.name, world.settings.clearer_hints).text + region_text = get_hint(connected_region.name, world.settings.clearer_hints).text if '#' not in region_text: region_text = '#%s#' % region_text @@ -1050,8 +1070,8 @@ def get_entrance_hint(spoiler, world, checked): return GossipText('%s %s.' % (entrance_text, region_text), ['Green', 'Light Blue']), None -def get_junk_hint(spoiler, world, checked): - hints = getHintGroup('junk', world) +def get_junk_hint(spoiler: "Spoiler", world: "World", checked: MutableSet[str]) -> HintReturn: + hints = get_hint_group('junk', world) hints = list(filter(lambda hint: hint.name not in checked, hints)) if not hints: return None @@ -1061,7 +1081,7 @@ def get_junk_hint(spoiler, world, checked): return GossipText(hint.text, prefix=''), None -def get_important_check_hint(spoiler, world, checked): +def get_important_check_hint(spoiler: "Spoiler", world: "World", checked: MutableSet[str]) -> HintReturn: top_level_locations = [] for location in world.get_filled_locations(): if (HintArea.at(location).text(world.settings.clearer_hints) not in top_level_locations @@ -1106,7 +1126,7 @@ def get_important_check_hint(spoiler, world, checked): return GossipText('#%s# has #%d# major item%s.' % (hintLoc, item_count, "s" if item_count != 1 else ""), ['Green', numcolor]), None -hint_func = { +hint_func: "Dict[str, Union[HintFunc, BarrenFunc]]" = { 'trial': lambda spoiler, world, checked: None, 'always': lambda spoiler, world, checked: None, 'dual_always': lambda spoiler, world, checked: None, @@ -1127,7 +1147,7 @@ def get_important_check_hint(spoiler, world, checked): 'important_check': get_important_check_hint } -hint_dist_keys = { +hint_dist_keys: Set[str] = { 'trial', 'always', 'dual_always', @@ -1148,51 +1168,51 @@ def get_important_check_hint(spoiler, world, checked): } -def buildBingoHintList(boardURL): +def build_bingo_hint_list(board_url: str) -> List[str]: try: - if len(boardURL) > 256: - raise URLError(f"URL too large {len(boardURL)}") - with urllib.request.urlopen(boardURL + "/board") as board: + if len(board_url) > 256: + raise URLError(f"URL too large {len(board_url)}") + with urllib.request.urlopen(board_url + "/board") as board: if board.length and 0 < board.length < 4096: - goalList = board.read() + goal_list = board.read() else: - raise HTTPError(f"Board of invalid size {board.length}") + raise URLError(f"Board of invalid size {board.length}") except (URLError, HTTPError) as e: logger = logging.getLogger('') logger.info(f"Could not retrieve board info. Using default bingo hints instead: {e}") with open(data_path('Bingo/generic_bingo_hints.json'), 'r') as bingoFile: - genericBingo = json.load(bingoFile) - return genericBingo['settings']['item_hints'] + generic_bingo = json.load(bingoFile) + return generic_bingo['settings']['item_hints'] # Goal list returned from Bingosync is a sequential list of all of the goals on the bingo board, starting at top-left and moving to the right. # Each goal is a dictionary with attributes for name, slot, and colours. The only one we use is the name - goalList = [goal['name'] for goal in json.loads(goalList)] + goal_list = [goal['name'] for goal in json.loads(goal_list)] with open(data_path('Bingo/bingo_goals.json'), 'r') as bingoFile: - goalHintRequirements = json.load(bingoFile) + goal_hint_requirements = json.load(bingoFile) - hintsToAdd = {} - for goal in goalList: + hints_to_add = {} + for goal in goal_list: # Using 'get' here ensures some level of forward compatibility, where new goals added to randomiser bingo won't # cause the generator to crash (though those hints won't have item hints for them) - requirements = goalHintRequirements.get(goal,{}) + requirements = goal_hint_requirements.get(goal, {}) if len(requirements) != 0: for item in requirements: - hintsToAdd[item] = max(hintsToAdd.get(item, 0), requirements[item]['count']) + hints_to_add[item] = max(hints_to_add.get(item, 0), requirements[item]['count']) # Items to be hinted need to be included in the item_hints list once for each instance you want hinted # (e.g. if you want all three strength upgrades to be hintes it needs to be in the list three times) hints = [] - for key, value in hintsToAdd.items(): + for key, value in hints_to_add.items(): for _ in range(value): hints.append(key) - #Since there's no way to verify if the Bingosync URL is actually for OoTR, this exception catches that case + # Since there's no way to verify if the Bingosync URL is actually for OoTR, this exception catches that case if len(hints) == 0: raise Exception('No item hints found for goals on Bingosync card. Verify Bingosync URL is correct, or leave field blank for generic bingo hints.') return hints -def alwaysNamedItem(world, locations): +def always_named_item(world: "World", locations: "Iterable[Location]"): for location in locations: if location.item.name in bingoBottlesForHints and world.settings.hint_dist == 'bingo': always_item = 'Bottle' @@ -1202,39 +1222,38 @@ def alwaysNamedItem(world, locations): world.named_item_pool.remove(always_item) -def buildGossipHints(spoiler, worlds): - checkedLocations = dict() +def build_gossip_hints(spoiler: "Spoiler", worlds: "List[World]") -> None: + checked_locations = dict() # Add misc. item hint locations to "checked" locations if the respective hint is reachable without the hinted item. for world in worlds: for location in world.hinted_dungeon_reward_locations.values(): if 'altar' in world.settings.misc_hints and not world.settings.enhance_map_compass and can_reach_hint(worlds, world.get_location('ToT Child Altar Hint' if location.item.info.stone else 'ToT Adult Altar Hint'), location): item_world = location.world - if item_world.id not in checkedLocations: - checkedLocations[item_world.id] = set() - checkedLocations[item_world.id].add(location.name) + if item_world.id not in checked_locations: + checked_locations[item_world.id] = set() + checked_locations[item_world.id].add(location.name) for hint_type, location in world.misc_hint_item_locations.items(): if hint_type in world.settings.misc_hints and can_reach_hint(worlds, world.get_location(misc_item_hint_table[hint_type]['hint_location']), location): item_world = location.world - if item_world.id not in checkedLocations: - checkedLocations[item_world.id] = set() - checkedLocations[item_world.id].add(location.name) + if item_world.id not in checked_locations: + checked_locations[item_world.id] = set() + checked_locations[item_world.id].add(location.name) for hint_type in world.misc_hint_location_items.keys(): location = world.get_location(misc_location_hint_table[hint_type]['item_location']) if hint_type in world.settings.misc_hints and can_reach_hint(worlds, world.get_location(misc_location_hint_table[hint_type]['hint_location']), location): item_world = location.world - if item_world.id not in checkedLocations: - checkedLocations[item_world.id] = set() - checkedLocations[item_world.id].add(location.name) + if item_world.id not in checked_locations: + checked_locations[item_world.id] = set() + checked_locations[item_world.id].add(location.name) # Build all the hints. for world in worlds: world.update_useless_areas(spoiler) - buildWorldGossipHints(spoiler, world, checkedLocations.pop(world.id, None)) + build_world_gossip_hints(spoiler, world, checked_locations.pop(world.id, None)) # builds out general hints based on location and whether an item is required or not -def buildWorldGossipHints(spoiler, world, checkedLocations=None): - +def build_world_gossip_hints(spoiler: "Spoiler", world: "World", checked_locations: Optional[MutableSet[str]] = None) -> None: world.barren_dungeon = 0 world.woth_dungeon = 0 @@ -1244,16 +1263,16 @@ def buildWorldGossipHints(spoiler, world, checkedLocations=None): search.spot_access(world.get_location(stone.location)) and search.state_list[world.id].guarantee_hint()) - if checkedLocations is None: - checkedLocations = set() - checkedAlwaysLocations = set() + if checked_locations is None: + checked_locations = set() + checked_always_locations = set() - stoneIDs = list(gossipLocations.keys()) + stone_ids = list(gossipLocations.keys()) - world.distribution.configure_gossip(spoiler, stoneIDs) + world.distribution.configure_gossip(spoiler, stone_ids) # If all gossip stones already have plando'd hints, do not roll any more - if len(stoneIDs) == 0: + if len(stone_ids) == 0: return if 'disabled' in world.hint_dist_user: @@ -1262,12 +1281,12 @@ def buildWorldGossipHints(spoiler, world, checkedLocations=None): stone_id = gossipLocations_reversemap[stone_name] except KeyError: raise ValueError(f'Gossip stone location "{stone_name}" is not valid') - if stone_id in stoneIDs: - stoneIDs.remove(stone_id) - (gossip_text, _) = get_junk_hint(spoiler, world, checkedLocations) + if stone_id in stone_ids: + stone_ids.remove(stone_id) + (gossip_text, _) = get_junk_hint(spoiler, world, checked_locations) spoiler.hints[world.id][stone_id] = gossip_text - stoneGroups = [] + stone_groups = [] if 'groups' in world.hint_dist_user: for group_names in world.hint_dist_user['groups']: group = [] @@ -1277,27 +1296,27 @@ def buildWorldGossipHints(spoiler, world, checkedLocations=None): except KeyError: raise ValueError(f'Gossip stone location "{stone_name}" is not valid') - if stone_id in stoneIDs: - stoneIDs.remove(stone_id) + if stone_id in stone_ids: + stone_ids.remove(stone_id) group.append(stone_id) if len(group) != 0: - stoneGroups.append(group) + stone_groups.append(group) # put the remaining locations into singleton groups - stoneGroups.extend([[id] for id in stoneIDs]) + stone_groups.extend([[id] for id in stone_ids]) - random.shuffle(stoneGroups) + random.shuffle(stone_groups) # Create list of items for which we want hints. If Bingosync URL is supplied, include items specific to that bingo. # If not (or if the URL is invalid), use generic bingo hints if world.settings.hint_dist == "bingo": with open(data_path('Bingo/generic_bingo_hints.json'), 'r') as bingoFile: - bingoDefaults = json.load(bingoFile) + bingo_defaults = json.load(bingoFile) if world.settings.bingosync_url and world.settings.bingosync_url.startswith("https://bingosync.com/"): # Verify that user actually entered a bingosync URL logger = logging.getLogger('') logger.info("Got Bingosync URL. Building board-specific goals.") - world.item_hints = buildBingoHintList(world.settings.bingosync_url) + world.item_hints = build_bingo_hint_list(world.settings.bingosync_url) else: - world.item_hints = bingoDefaults['settings']['item_hints'] + world.item_hints = bingo_defaults['settings']['item_hints'] if world.settings.tokensanity in ("overworld", "all") and "Suns Song" not in world.item_hints: world.item_hints.append("Suns Song") @@ -1305,11 +1324,10 @@ def buildWorldGossipHints(spoiler, world, checkedLocations=None): if world.settings.shopsanity != "off" and "Progressive Wallet" not in world.item_hints: world.item_hints.append("Progressive Wallet") - - #Removes items from item_hints list if they are included in starting gear. - #This method ensures that the right number of copies are removed, e.g. - #if you start with one strength and hints call for two, you still get - #one hint for strength. This also handles items from Skip Child Zelda. + # Removes items from item_hints list if they are included in starting gear. + # This method ensures that the right number of copies are removed, e.g. + # if you start with one strength and hints call for two, you still get + # one hint for strength. This also handles items from Skip Child Zelda. for itemname, record in world.distribution.effective_starting_items.items(): for _ in range(record.count): if itemname in world.item_hints: @@ -1317,14 +1335,13 @@ def buildWorldGossipHints(spoiler, world, checkedLocations=None): world.named_item_pool = list(world.item_hints) - #Make sure the total number of hints won't pass 40. If so, we limit the always and trial hints + # Make sure the total number of hints won't pass 40. If so, we limit the always and trial hints if world.settings.hint_dist == "bingo": - numTrialHints = [0,1,2,3,2,1,0] - if (2*len(world.item_hints) + 2*len(getHintGroup('always', world)) + 2*numTrialHints[world.settings.trials] > 40) and (world.hint_dist_user['named_items_required']): + num_trial_hints = [0, 1, 2, 3, 2, 1, 0] + if (2 * len(world.item_hints) + 2 * len(get_hint_group('always', world)) + 2 * num_trial_hints[world.settings.trials] > 40) and (world.hint_dist_user['named_items_required']): world.hint_dist_user['distribution']['always']['copies'] = 1 world.hint_dist_user['distribution']['trial']['copies'] = 1 - # Load hint distro from distribution file or pre-defined settings # # 'fixed' key is used to mimic the tournament distribution, creating a list of fixed hint types to fill @@ -1362,82 +1379,84 @@ def buildWorldGossipHints(spoiler, world, checkedLocations=None): # Add required dual location hints, only if hint copies > 0 if 'dual_always' in hint_dist and hint_dist['dual_always'][1] > 0: - alwaysDuals = getHintGroup('dual_always', world) - for hint in alwaysDuals: - multi = getMulti(hint.name) - firstLocation = world.get_location(multi.locations[0]) - secondLocation = world.get_location(multi.locations[1]) - checkedAlwaysLocations.add(firstLocation.name) - checkedAlwaysLocations.add(secondLocation.name) + always_duals = get_hint_group('dual_always', world) + for hint in always_duals: + multi = get_multi(hint.name) + first_location = world.get_location(multi.locations[0]) + second_location = world.get_location(multi.locations[1]) + checked_always_locations.add(first_location.name) + checked_always_locations.add(second_location.name) - alwaysNamedItem(world, [firstLocation, secondLocation]) + always_named_item(world, [first_location, second_location]) if hint.name in world.hint_text_overrides: location_text = world.hint_text_overrides[hint.name] else: - location_text = getHint(hint.name, world.settings.clearer_hints).text + location_text = get_hint(hint.name, world.settings.clearer_hints).text if '#' not in location_text: location_text = '#%s#' % location_text - first_item_text = getHint(getItemGenericName(firstLocation.item), world.settings.clearer_hints).text - second_item_text = getHint(getItemGenericName(secondLocation.item), world.settings.clearer_hints).text - add_hint(spoiler, world, stoneGroups, GossipText('%s #%s# and #%s#.' % (location_text, first_item_text, second_item_text), ['Red', 'Green', 'Green'], [firstLocation.name, secondLocation.name], [firstLocation.item.name, secondLocation.item.name]), hint_dist['dual_always'][1], [firstLocation, secondLocation], force_reachable=True) + first_item_text = get_hint(get_item_generic_name(first_location.item), world.settings.clearer_hints).text + second_item_text = get_hint(get_item_generic_name(second_location.item), world.settings.clearer_hints).text + add_hint(spoiler, world, stone_groups, GossipText('%s #%s# and #%s#.' % (location_text, first_item_text, second_item_text), ['Red', 'Green', 'Green'], [first_location.name, second_location.name], [first_location.item.name, second_location.item.name]), hint_dist['dual_always'][1], [first_location, second_location], force_reachable=True) logging.getLogger('').debug('Placed dual_always hint for %s.', hint.name) # Add required location hints, only if hint copies > 0 if hint_dist['always'][1] > 0: - alwaysLocations = list(filter(lambda hint: is_not_checked([world.get_location(hint.name)], checkedAlwaysLocations), getHintGroup('always', world))) - for hint in alwaysLocations: + always_locations = list(filter(lambda hint: is_not_checked([world.get_location(hint.name)], checked_always_locations), + get_hint_group('always', world))) + for hint in always_locations: location = world.get_location(hint.name) - checkedAlwaysLocations.add(hint.name) + checked_always_locations.add(hint.name) - alwaysNamedItem(world, [location]) + always_named_item(world, [location]) if location.name in world.hint_text_overrides: location_text = world.hint_text_overrides[location.name] else: - location_text = getHint(location.name, world.settings.clearer_hints).text + location_text = get_hint(location.name, world.settings.clearer_hints).text if '#' not in location_text: location_text = '#%s#' % location_text - item_text = getHint(getItemGenericName(location.item), world.settings.clearer_hints).text - add_hint(spoiler, world, stoneGroups, GossipText('%s #%s#.' % (location_text, item_text), ['Red', 'Green'], [location.name], [location.item.name]), hint_dist['always'][1], [location], force_reachable=True) + item_text = get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text + add_hint(spoiler, world, stone_groups, GossipText('%s #%s#.' % (location_text, item_text), ['Red', 'Green'], [location.name], [location.item.name]), hint_dist['always'][1], [location], force_reachable=True) logging.getLogger('').debug('Placed always hint for %s.', location.name) # Add required entrance hints, only if hint copies > 0 if world.entrance_shuffle and 'entrance_always' in hint_dist and hint_dist['entrance_always'][1] > 0: - alwaysEntrances = getHintGroup('entrance_always', world) - for entrance_hint in alwaysEntrances: + always_entrances = get_hint_group('entrance_always', world) + for entrance_hint in always_entrances: entrance = world.get_entrance(entrance_hint.name) connected_region = entrance.connected_region - if entrance.shuffled and (connected_region.dungeon or any(hint.name == connected_region.name for hint in getHintGroup('region', world))): - checkedAlwaysLocations.add(entrance.name) + if entrance.shuffled and (connected_region.dungeon or any(hint.name == connected_region.name for hint in + get_hint_group('region', world))): + checked_always_locations.add(entrance.name) entrance_text = entrance_hint.text if '#' not in entrance_text: entrance_text = '#%s#' % entrance_text if connected_region.dungeon: - region_text = getHint(connected_region.dungeon.name, world.settings.clearer_hints).text + region_text = get_hint(connected_region.dungeon.name, world.settings.clearer_hints).text else: - region_text = getHint(connected_region.name, world.settings.clearer_hints).text + region_text = get_hint(connected_region.name, world.settings.clearer_hints).text if '#' not in region_text: region_text = '#%s#' % region_text - add_hint(spoiler, world, stoneGroups, GossipText('%s %s.' % (entrance_text, region_text), ['Green', 'Light Blue']), hint_dist['entrance_always'][1], None, force_reachable=True) + add_hint(spoiler, world, stone_groups, GossipText('%s %s.' % (entrance_text, region_text), ['Green', 'Light Blue']), hint_dist['entrance_always'][1], None, force_reachable=True) # Add trial hints, only if hint copies > 0 if hint_dist['trial'][1] > 0: if world.settings.trials_random and world.settings.trials == 6: - add_hint(spoiler, world, stoneGroups, GossipText("#Ganon's Tower# is protected by a powerful barrier.", ['Pink']), hint_dist['trial'][1], force_reachable=True) + add_hint(spoiler, world, stone_groups, GossipText("#Ganon's Tower# is protected by a powerful barrier.", ['Pink']), hint_dist['trial'][1], force_reachable=True) elif world.settings.trials_random and world.settings.trials == 0: - add_hint(spoiler, world, stoneGroups, GossipText("Sheik dispelled the barrier around #Ganon's Tower#.", ['Yellow']), hint_dist['trial'][1], force_reachable=True) - elif world.settings.trials < 6 and world.settings.trials > 3: - for trial,skipped in world.skipped_trials.items(): + add_hint(spoiler, world, stone_groups, GossipText("Sheik dispelled the barrier around #Ganon's Tower#.", ['Yellow']), hint_dist['trial'][1], force_reachable=True) + elif 3 < world.settings.trials < 6: + for trial, skipped in world.skipped_trials.items(): if skipped: - add_hint(spoiler, world, stoneGroups,GossipText("the #%s Trial# was dispelled by Sheik." % trial, ['Yellow']), hint_dist['trial'][1], force_reachable=True) - elif world.settings.trials <= 3 and world.settings.trials > 0: - for trial,skipped in world.skipped_trials.items(): + add_hint(spoiler, world, stone_groups, GossipText("the #%s Trial# was dispelled by Sheik." % trial, ['Yellow']), hint_dist['trial'][1], force_reachable=True) + elif 0 < world.settings.trials <= 3: + for trial, skipped in world.skipped_trials.items(): if not skipped: - add_hint(spoiler, world, stoneGroups, GossipText("the #%s Trial# protects Ganon's Tower." % trial, ['Pink']), hint_dist['trial'][1], force_reachable=True) + add_hint(spoiler, world, stone_groups, GossipText("the #%s Trial# protects Ganon's Tower." % trial, ['Pink']), hint_dist['trial'][1], force_reachable=True) # Add user-specified hinted item locations if using a built-in hint distribution # Raise error if hint copies is zero @@ -1448,19 +1467,19 @@ def buildWorldGossipHints(spoiler, world, checkedLocations=None): # Prevent conflict between Ganondorf Light Arrows hint and required named item hints. # Assumes that a "wasted" hint is desired since Light Arrows have to be added # explicitly to the list for named item hints. - filtered_checked = set(checkedLocations | checkedAlwaysLocations) - for location in (checkedLocations | checkedAlwaysLocations): + filtered_checked = set(checked_locations | checked_always_locations) + for location in (checked_locations | checked_always_locations): try: if world.get_location(location).item.name == 'Light Arrows': filtered_checked.remove(location) except KeyError: - pass # checkedAlwaysLocations can also contain entrances from entrance_always hints, ignore those here + pass # checked_always_locations can also contain entrances from entrance_always hints, ignore those here for i in range(0, len(world.named_item_pool)): hint = get_specific_item_hint(spoiler, world, filtered_checked) if hint: - checkedLocations.update(filtered_checked - checkedAlwaysLocations) + checked_locations.update(filtered_checked - checked_always_locations) gossip_text, location = hint - place_ok = add_hint(spoiler, world, stoneGroups, gossip_text, hint_dist['named-item'][1], location) + place_ok = add_hint(spoiler, world, stone_groups, gossip_text, hint_dist['named-item'][1], location) if not place_ok: raise Exception('Not enough gossip stones for user-provided item hints') @@ -1474,19 +1493,19 @@ def buildWorldGossipHints(spoiler, world, checkedLocations=None): hint_counts = {} custom_fixed = True - while stoneGroups: + while stone_groups: if fixed_hint_types: hint_type = fixed_hint_types.pop(0) copies = hint_dist[hint_type][1] - if copies > len(stoneGroups): + if copies > len(stone_groups): # Quiet to avoid leaking information. - logging.getLogger('').debug(f'Not enough gossip stone locations ({len(stoneGroups)} groups) for fixed hint type {hint_type} with {copies} copies, proceeding with available stones.') - copies = len(stoneGroups) + logging.getLogger('').debug(f'Not enough gossip stone locations ({len(stone_groups)} groups) for fixed hint type {hint_type} with {copies} copies, proceeding with available stones.') + copies = len(stone_groups) else: custom_fixed = False # Make sure there are enough stones left for each hint type num_types = len(hint_types) - hint_types = list(filter(lambda htype: hint_dist[htype][1] <= len(stoneGroups), hint_types)) + hint_types = list(filter(lambda htype: hint_dist[htype][1] <= len(stone_groups), hint_types)) new_num_types = len(hint_types) if new_num_types == 0: raise Exception('Not enough gossip stone locations for remaining weighted hint types.') @@ -1514,12 +1533,12 @@ def buildWorldGossipHints(spoiler, world, checkedLocations=None): except IndexError: raise Exception('Not enough valid hints to fill gossip stone locations.') - allCheckedLocations = checkedLocations | checkedAlwaysLocations + all_checked_locations = checked_locations | checked_always_locations if hint_type == 'barren': - hint = hint_func[hint_type](spoiler, world, checkedLocations, allCheckedLocations) + hint = hint_func[hint_type](spoiler, world, checked_locations, all_checked_locations) else: - hint = hint_func[hint_type](spoiler, world, allCheckedLocations) - checkedLocations.update(allCheckedLocations - checkedAlwaysLocations) + hint = hint_func[hint_type](spoiler, world, all_checked_locations) + checked_locations.update(all_checked_locations - checked_always_locations) if hint is None: index = hint_types.index(hint_type) @@ -1529,7 +1548,7 @@ def buildWorldGossipHints(spoiler, world, checkedLocations=None): hint_dist[hint_type] = (0.0, copies) else: gossip_text, locations = hint - place_ok = add_hint(spoiler, world, stoneGroups, gossip_text, copies, locations) + place_ok = add_hint(spoiler, world, stone_groups, gossip_text, copies, locations) if place_ok: hint_counts[hint_type] = hint_counts.get(hint_type, 0) + 1 if locations is None: @@ -1542,27 +1561,27 @@ def buildWorldGossipHints(spoiler, world, checkedLocations=None): # builds text that is displayed at the temple of time altar for child and adult, rewards pulled based off of item in a fixed order. -def buildAltarHints(world, messages, include_rewards=True, include_wincons=True): +def build_altar_hints(world: "World", messages: List[Message], include_rewards: bool = True, include_wincons: bool = True) -> None: # text that appears at altar as a child. child_text = '\x08' if include_rewards: - bossRewardsSpiritualStones = [ + boss_rewards_spiritual_stones = [ ('Kokiri Emerald', 'Green'), ('Goron Ruby', 'Red'), ('Zora Sapphire', 'Blue'), ] - child_text += getHint('Spiritual Stone Text Start', world.settings.clearer_hints).text + '\x04' - for (reward, color) in bossRewardsSpiritualStones: - child_text += buildBossString(reward, color, world) - child_text += getHint('Child Altar Text End', world.settings.clearer_hints).text + child_text += get_hint('Spiritual Stone Text Start', world.settings.clearer_hints).text + '\x04' + for (reward, color) in boss_rewards_spiritual_stones: + child_text += build_boss_string(reward, color, world) + child_text += get_hint('Child Altar Text End', world.settings.clearer_hints).text child_text += '\x0B' update_message_by_id(messages, 0x707A, get_raw_text(child_text), 0x20) # text that appears at altar as an adult. adult_text = '\x08' - adult_text += getHint('Adult Altar Text Start', world.settings.clearer_hints).text + '\x04' + adult_text += get_hint('Adult Altar Text Start', world.settings.clearer_hints).text + '\x04' if include_rewards: - bossRewardsMedallions = [ + boss_rewards_medallions = [ ('Light Medallion', 'Light Blue'), ('Forest Medallion', 'Green'), ('Fire Medallion', 'Red'), @@ -1570,20 +1589,20 @@ def buildAltarHints(world, messages, include_rewards=True, include_wincons=True) ('Shadow Medallion', 'Pink'), ('Spirit Medallion', 'Yellow'), ] - for (reward, color) in bossRewardsMedallions: - adult_text += buildBossString(reward, color, world) + for (reward, color) in boss_rewards_medallions: + adult_text += build_boss_string(reward, color, world) if include_wincons: - adult_text += buildBridgeReqsString(world) + adult_text += build_bridge_reqs_string(world) adult_text += '\x04' - adult_text += buildGanonBossKeyString(world) + adult_text += build_ganon_boss_key_string(world) else: - adult_text += getHint('Adult Altar Text End', world.settings.clearer_hints).text + adult_text += get_hint('Adult Altar Text End', world.settings.clearer_hints).text adult_text += '\x0B' update_message_by_id(messages, 0x7057, get_raw_text(adult_text), 0x20) # pulls text string from hintlist for reward after sending the location to hintlist. -def buildBossString(reward, color, world): +def build_boss_string(reward: str, color: str, world: "World") -> str: item_icon = chr(Item(reward).special['item_id']) if reward in world.distribution.effective_starting_items and world.distribution.effective_starting_items[reward].count > 0: if world.settings.clearer_hints: @@ -1597,12 +1616,12 @@ def buildBossString(reward, color, world): return str(text) + '\x04' -def buildBridgeReqsString(world): +def build_bridge_reqs_string(world: "World") -> str: string = "\x13\x12" # Light Arrow Icon if world.settings.bridge == 'open': string += "The awakened ones will have #already created a bridge# to the castle where the evil dwells." else: - item_req_string = getHint('bridge_' + world.settings.bridge, world.settings.clearer_hints).text + item_req_string = get_hint('bridge_' + world.settings.bridge, world.settings.clearer_hints).text if world.settings.bridge == 'medallions': item_req_string = str(world.settings.bridge_medallions) + ' ' + item_req_string elif world.settings.bridge == 'stones': @@ -1619,13 +1638,13 @@ def buildBridgeReqsString(world): return str(GossipText(string, ['Green'], prefix='')) -def buildGanonBossKeyString(world): +def build_ganon_boss_key_string(world: "World") -> str: string = "\x13\x74" # Boss Key Icon if world.settings.shuffle_ganon_bosskey == 'remove': string += "And the door to the \x05\x41evil one\x05\x40's chamber will be left #unlocked#." else: if world.settings.shuffle_ganon_bosskey == 'on_lacs': - item_req_string = getHint('lacs_' + world.settings.lacs_condition, world.settings.clearer_hints).text + item_req_string = get_hint('lacs_' + world.settings.lacs_condition, world.settings.clearer_hints).text if world.settings.lacs_condition == 'medallions': item_req_string = str(world.settings.lacs_medallions) + ' ' + item_req_string elif world.settings.lacs_condition == 'stones': @@ -1640,7 +1659,7 @@ def buildGanonBossKeyString(world): item_req_string = '#%s#' % item_req_string bk_location_string = "provided by Zelda once %s are retrieved" % item_req_string elif world.settings.shuffle_ganon_bosskey in ['stones', 'medallions', 'dungeons', 'tokens', 'hearts']: - item_req_string = getHint('ganonBK_' + world.settings.shuffle_ganon_bosskey, world.settings.clearer_hints).text + item_req_string = get_hint('ganonBK_' + world.settings.shuffle_ganon_bosskey, world.settings.clearer_hints).text if world.settings.shuffle_ganon_bosskey == 'medallions': item_req_string = str(world.settings.ganon_bosskey_medallions) + ' ' + item_req_string elif world.settings.shuffle_ganon_bosskey == 'stones': @@ -1655,26 +1674,27 @@ def buildGanonBossKeyString(world): item_req_string = '#%s#' % item_req_string bk_location_string = "automatically granted once %s are retrieved" % item_req_string else: - bk_location_string = getHint('ganonBK_' + world.settings.shuffle_ganon_bosskey, world.settings.clearer_hints).text + bk_location_string = get_hint('ganonBK_' + world.settings.shuffle_ganon_bosskey, + world.settings.clearer_hints).text string += "And the \x05\x41evil one\x05\x40's key will be %s." % bk_location_string return str(GossipText(string, ['Yellow'], prefix='')) # fun new lines for Ganon during the final battle -def buildGanonText(world, messages): +def build_ganon_text(world: "World", messages: List[Message]) -> None: # empty now unused messages to make space for ganon lines update_message_by_id(messages, 0x70C8, " ") update_message_by_id(messages, 0x70C9, " ") update_message_by_id(messages, 0x70CA, " ") # lines before battle - ganonLines = getHintGroup('ganonLine', world) + ganonLines = get_hint_group('ganonLine', world) random.shuffle(ganonLines) text = get_raw_text(ganonLines.pop().text) update_message_by_id(messages, 0x70CB, text) -def buildMiscItemHints(world, messages): +def build_misc_item_hints(world: "World", messages: List[Message]) -> None: for hint_type, data in misc_item_hint_table.items(): if hint_type in world.settings.misc_hints: item = world.misc_hint_items[hint_type] @@ -1689,17 +1709,17 @@ def buildMiscItemHints(world, messages): if item == data['default_item']: text = data['default_item_text'].format(area=area) else: - text = data['custom_item_text'].format(area=area, item=getHint(getItemGenericName(location.item), world.settings.clearer_hints).text) + text = data['custom_item_text'].format(area=area, item=get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text) elif 'custom_item_fallback' in data: if 'default_item_fallback' in data and item == data['default_item']: text = data['default_item_fallback'] else: text = data['custom_item_fallback'].format(item=item) else: - text = getHint('Validation Line', world.settings.clearer_hints).text + text = get_hint('Validation Line', world.settings.clearer_hints).text for location in world.get_filled_locations(): if location.name == 'Ganons Tower Boss Key Chest': - text += f"#{getHint(getItemGenericName(location.item), world.settings.clearer_hints).text}#" + text += f"#{get_hint(get_item_generic_name(location.item), world.settings.clearer_hints).text}#" break for find, replace in data.get('replace', {}).items(): text = text.replace(find, replace) @@ -1707,19 +1727,19 @@ def buildMiscItemHints(world, messages): update_message_by_id(messages, data['id'], str(GossipText(text, ['Green'], prefix=''))) -def buildMiscLocationHints(world, messages): +def build_misc_location_hints(world: "World", messages: List[Message]) -> None: for hint_type, data in misc_location_hint_table.items(): text = data['location_fallback'] if hint_type in world.settings.misc_hints: - location = world.misc_hint_locations[hint_type] if hint_type in world.misc_hint_location_items: item = world.misc_hint_location_items[hint_type] - text = data['location_text'].format(item=getHint(getItemGenericName(item), world.settings.clearer_hints).text) + text = data['location_text'].format(item=get_hint(get_item_generic_name(item), + world.settings.clearer_hints).text) update_message_by_id(messages, data['id'], str(GossipText(text, ['Green'], prefix='')), 0x23) -def get_raw_text(string): +def get_raw_text(string: str) -> str: text = '' for char in string: if char == '^': @@ -1735,29 +1755,27 @@ def get_raw_text(string): return text -def HintDistFiles(): +def hint_dist_files() -> List[str]: return [os.path.join(data_path('Hints/'), d) for d in defaultHintDists] + [ os.path.join(data_path('Hints/'), d) for d in sorted(os.listdir(data_path('Hints/'))) if d.endswith('.json') and d not in defaultHintDists] -def HintDistList(): +def hint_dist_list() -> Dict[str, str]: dists = {} - for d in HintDistFiles(): + for d in hint_dist_files(): with open(d, 'r') as dist_file: dist = json.load(dist_file) - dist_name = dist['name'] - gui_name = dist['gui_name'] - dists.update({ dist_name: gui_name }) + dists[dist['name']] = dist['gui_name'] return dists -def HintDistTips(): +def hint_dist_tips() -> str: tips = "" first_dist = True line_char_limit = 33 - for d in HintDistFiles(): + for d in hint_dist_files(): if not first_dist: tips = tips + "\n" else: diff --git a/IconManip.py b/IconManip.py index 7943536eb..7159038dd 100644 --- a/IconManip.py +++ b/IconManip.py @@ -1,10 +1,18 @@ -from Utils import data_path +from typing import TYPE_CHECKING, Sequence, MutableSequence, Optional + +from Utils import data_path, TypeAlias + +if TYPE_CHECKING: + from Rom import Rom + +RGBValues: TypeAlias = MutableSequence[MutableSequence[int]] + # TODO # Move the tunic to the generalized system # Function for adding hue to a greyscaled icon -def add_hue(image, color, tiff=False): +def add_hue(image: MutableSequence[int], color: Sequence[int], tiff: bool = False) -> MutableSequence[int]: start = 154 if tiff else 0 for i in range(start, len(image), 4): try: @@ -34,7 +42,7 @@ def add_rainbow(img, width, tiff=False): return img # Function for adding belt to tunic -def add_belt(tunic, belt, tiff=False): +def add_belt(tunic: MutableSequence[int], belt: MutableSequence[int], tiff: bool = False) -> MutableSequence[int]: start = 154 if tiff else 0 for i in range(start, len(tunic), 4): try: @@ -47,7 +55,7 @@ def add_belt(tunic, belt, tiff=False): return tunic # Function for putting tunic colors together -def generate_tunic_icon(color): +def generate_tunic_icon(color: Sequence[int]) -> MutableSequence[int]: with open(data_path('icons/grey.tiff'), 'rb') as grey_fil, open(data_path('icons/belt.tiff'), 'rb') as belt_fil: grey = list(grey_fil.read()) belt = list(belt_fil.read()) @@ -61,47 +69,52 @@ def generate_rainbow_tunic_icon(): # END TODO + # Function to add extra data on top of icon -def add_extra_data(rgbValues, fileName, intensity = 0.5): - fileRGB = [] - with open(fileName, "rb") as fil: +def add_extra_data(rgb_values: RGBValues, filename: str, intensity: float = 0.5) -> None: + file_rgb = [] + with open(filename, "rb") as fil: data = fil.read() for i in range(0, len(data), 4): - fileRGB.append([data[i+0], data[i+1], data[i+2], data[i+3]]) - for i in range(len(rgbValues)): - alpha = fileRGB[i][3] / 255 + file_rgb.append([data[i + 0], data[i + 1], data[i + 2], data[i + 3]]) + for i in range(len(rgb_values)): + alpha = file_rgb[i][3] / 255 for x in range(3): - rgbValues[i][x] = int((fileRGB[i][x] * alpha + intensity) + (rgbValues[i][x] * (1 - alpha - intensity))) + rgb_values[i][x] = int((file_rgb[i][x] * alpha + intensity) + (rgb_values[i][x] * (1 - alpha - intensity))) + # Function for desaturating RGB values -def greyscaleRGB(rgbValues, intensity: int = 2): - for rgb in rgbValues: +def greyscale_rgb(rgb_values: RGBValues, intensity: int = 2) -> RGBValues: + for rgb in rgb_values: rgb[0] = rgb[1] = rgb[2] = int((rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722) * intensity) - return rgbValues + return rgb_values + # Converts rgb5a1 values to RGBA lists -def rgb5a1ToRGB(rgb5a1Bytes): +def rgb5a1_to_rgb(rgb5a1_bytes: bytes) -> RGBValues: pixels = [] - for i in range(0, len(rgb5a1Bytes), 2): - bits = format(rgb5a1Bytes[i], '#010b')[2:] + format(rgb5a1Bytes[i+1], '#010b')[2:] + for i in range(0, len(rgb5a1_bytes), 2): + bits = format(rgb5a1_bytes[i], '#010b')[2:] + format(rgb5a1_bytes[i + 1], '#010b')[2:] r = int(int(bits[0:5], 2) * (255/31)) g = int(int(bits[5:10], 2) * (255/31)) b = int(int(bits[10:15], 2) * (255/31)) a = int(bits[15], 2) * 255 - pixels.append([r,g,b,a]) + pixels.append([r, g, b, a]) return pixels + # Adds a hue to RGB values -def addHueToRGB(rgbValues, color): - for rgb in rgbValues: +def add_hue_to_rgb(rgb_values: RGBValues, color: Sequence[int]) -> RGBValues: + for rgb in rgb_values: for i in range(3): rgb[i] = int(((rgb[i]/255) * (color[i]/255)) * 255) - return rgbValues + return rgb_values + # Convert RGB to RGB5a1 format -def rgbToRGB5a1(rgbValues): +def rgb_to_rgb5a1(rgb_values: RGBValues) -> bytes: rgb5a1 = [] - for rgb in rgbValues: + for rgb in rgb_values: r = int(rgb[0] / (255/31)) r = r if r <= 31 else 31 r = r if r >= 0 else 0 @@ -119,17 +132,18 @@ def rgbToRGB5a1(rgbValues): assert i <= 255, i return bytes(rgb5a1) + # Patch overworld icons -def patch_overworld_icon(rom, color, address, fileName = None): +def patch_overworld_icon(rom: "Rom", color: Optional[Sequence[int]], address: int, filename: Optional[str] = None) -> None: original = rom.original.read_bytes(address, 0x800) if color is None: rom.write_bytes(address, original) return - rgbBytes = rgb5a1ToRGB(original) - greyscaled = greyscaleRGB(rgbBytes) - rgbBytes = addHueToRGB(greyscaled, color) - if fileName != None: - add_extra_data(rgbBytes, fileName) - rom.write_bytes(address, rgbToRGB5a1(rgbBytes)) + rgb_bytes = rgb5a1_to_rgb(original) + greyscaled = greyscale_rgb(rgb_bytes) + rgb_bytes = add_hue_to_rgb(greyscaled, color) + if filename is not None: + add_extra_data(rgb_bytes, filename) + rom.write_bytes(address, rgb_to_rgb5a1(rgb_bytes)) diff --git a/Item.py b/Item.py index 64329a361..b368cce38 100644 --- a/Item.py +++ b/Item.py @@ -1,44 +1,48 @@ -import re +from typing import TYPE_CHECKING, Optional, Tuple, List, Dict, Union, Iterable, Set, Any, Callable from ItemList import item_table from RulesCommon import allowed_globals, escape_name +if TYPE_CHECKING: + from Location import Location + from World import World -class ItemInfo(object): - items = {} - events = {} - bottles = set() - medallions = set() - stones = set() - junk = {} - solver_ids = {} - bottle_ids = set() - medallion_ids = set() - stone_ids = set() +class ItemInfo: + items: 'Dict[str, ItemInfo]' = {} + events: 'Dict[str, ItemInfo]' = {} + bottles: Set[str] = set() + medallions: Set[str] = set() + stones: Set[str] = set() + junk: Dict[str, int] = {} - def __init__(self, name='', event=False): + solver_ids: Dict[str, int] = {} + bottle_ids: Set[int] = set() + medallion_ids: Set[int] = set() + stone_ids: Set[int] = set() + + def __init__(self, name: str = '', event: bool = False) -> None: if event: - type = 'Event' + item_type = 'Event' progressive = True - itemID = None + item_id = None special = None else: - (type, progressive, itemID, special) = item_table[name] - - self.name = name - self.advancement = (progressive is True) - self.priority = (progressive is False) - self.type = type - self.special = special or {} - self.index = itemID - self.price = self.special.get('price') - self.bottle = self.special.get('bottle', False) - self.medallion = self.special.get('medallion', False) - self.stone = self.special.get('stone', False) - self.alias = self.special.get('alias', None) - self.junk = self.special.get('junk', None) - self.trade = self.special.get('trade', False) + (item_type, progressive, item_id, special) = item_table[name] + + self.name: str = name + self.advancement: bool = (progressive is True) + self.priority: bool = (progressive is False) + self.type: str = item_type + self.special: Dict[str, Any] = special or {} + self.index: Optional[int] = item_id + self.price: Optional[int] = self.special.get('price', None) + self.bottle: bool = self.special.get('bottle', False) + self.medallion: bool = self.special.get('medallion', False) + self.stone: bool = self.special.get('stone', False) + self.alias: Optional[Tuple[str, int]] = self.special.get('alias', None) + self.junk: Optional[int] = self.special.get('junk', None) + self.trade: bool = self.special.get('trade', False) self.solver_id = None if name and self.junk is None: @@ -63,36 +67,34 @@ def __init__(self, name='', event=False): ItemInfo.junk[item_name] = ItemInfo.items[item_name].junk -class Item(object): - - def __init__(self, name='', world=None, event=False): - self.name = name - self.location = None - self.event = event +class Item: + def __init__(self, name: str = '', world: "Optional[World]" = None, event: bool = False) -> None: + self.name: str = name + self.location: "Optional[Location]" = None + self.event: bool = event if event: if name not in ItemInfo.events: ItemInfo.events[name] = ItemInfo(name, event=True) - self.info = ItemInfo.events[name] + self.info: ItemInfo = ItemInfo.events[name] else: - self.info = ItemInfo.items[name] - self.price = self.info.special.get('price') - self.world = world - self.looks_like_item = None - self.advancement = self.info.advancement - self.priority = self.info.priority - self.type = self.info.type - self.special = self.info.special - self.index = self.info.index - self.alias = self.info.alias + self.info: ItemInfo = ItemInfo.items[name] + self.price: Optional[int] = self.info.special.get('price', None) + self.world: "World" = world + self.looks_like_item: 'Optional[Item]' = None + self.advancement: bool = self.info.advancement + self.priority: bool = self.info.priority + self.type: str = self.info.type + self.special: dict = self.info.special + self.index: Optional[int] = self.info.index + self.alias: Optional[Tuple[str, int]] = self.info.alias self.solver_id = self.info.solver_id # Do not alias to junk--it has no solver id! self.alias_id = ItemInfo.solver_ids[escape_name(self.alias[0])] if self.alias else None + item_worlds_to_fix: 'Dict[Item, int]' = {} - item_worlds_to_fix = {} - - def copy(self, new_world=None): + def copy(self, new_world: "Optional[World]" = None) -> 'Item': if new_world is not None and self.world is not None and new_world.id != self.world.id: new_world = None @@ -104,9 +106,8 @@ def copy(self, new_world=None): return new_item - @classmethod - def fix_worlds_after_copy(cls, worlds): + def fix_worlds_after_copy(cls, worlds: "List[World]") -> None: items_fixed = [] for item, world_id in cls.item_worlds_to_fix.items(): item.world = worlds[world_id] @@ -114,38 +115,32 @@ def fix_worlds_after_copy(cls, worlds): for item in items_fixed: del cls.item_worlds_to_fix[item] - @property - def key(self): + def key(self) -> bool: return self.smallkey or self.bosskey - @property - def smallkey(self): + def smallkey(self) -> bool: return self.type == 'SmallKey' or self.type == 'HideoutSmallKey' or self.type == 'TCGSmallKey' - @property - def bosskey(self): + def bosskey(self) -> bool: return self.type == 'BossKey' or self.type == 'GanonBossKey' - @property - def map(self): + def map(self) -> bool: return self.type == 'Map' - @property - def compass(self): + def compass(self) -> bool: return self.type == 'Compass' - @property - def dungeonitem(self): + def dungeonitem(self) -> bool: return self.smallkey or self.bosskey or self.map or self.compass or self.type == 'SilverRupee' @property - def unshuffled_dungeon_item(self): + def unshuffled_dungeon_item(self) -> bool: return ((self.type == 'SmallKey' and self.world.settings.shuffle_smallkeys in ('remove', 'vanilla', 'dungeon')) or (self.type == 'HideoutSmallKey' and self.world.settings.shuffle_hideoutkeys == 'vanilla') or (self.type == 'TCGSmallKey' and self.world.settings.shuffle_tcgkeys in ('remove', 'vanilla')) or @@ -155,7 +150,7 @@ def unshuffled_dungeon_item(self): (self.type == 'SilverRupee' and self.world.settings.shuffle_silver_rupees in ['remove','vanilla','dungeon'])) @property - def majoritem(self): + def majoritem(self) -> bool: if self.type == 'Token': return (self.world.settings.bridge == 'tokens' or self.world.settings.shuffle_ganon_bosskey == 'tokens' or (self.world.settings.shuffle_ganon_bosskey == 'on_lacs' and self.world.settings.lacs_condition == 'tokens')) @@ -187,21 +182,18 @@ def majoritem(self): return True - @property - def goalitem(self): + def goalitem(self) -> bool: return self.name in self.world.goal_items - - def __str__(self): + def __str__(self) -> str: return str(self.__unicode__()) - - def __unicode__(self): + def __unicode__(self) -> str: return '%s' % self.name -def ItemFactory(items, world=None, event=False): +def ItemFactory(items: Union[str, Iterable[str]], world: "Optional[World]" = None, event: bool = False) -> Union[Item, List[Item]]: if isinstance(items, str): if not event and items not in ItemInfo.items: raise KeyError('Unknown Item: %s' % items) @@ -216,7 +208,7 @@ def ItemFactory(items, world=None, event=False): return ret -def MakeEventItem(name, location, item=None): +def make_event_item(name: str, location: "Location", item: Optional[Item] = None) -> Item: if item is None: item = Item(name, location.world, event=True) location.world.push_item(location, item) @@ -227,11 +219,11 @@ def MakeEventItem(name, location, item=None): return item -def IsItem(name): +def is_item(name: str) -> bool: return name in item_table -def ItemIterator(predicate=lambda loc: True, world=None): +def ItemIterator(predicate: Callable[[Item], bool] = lambda item: True, world: "Optional[World]" = None) -> Iterable[Item]: for item_name in item_table: item = ItemFactory(item_name, world) if predicate(item): diff --git a/ItemList.py b/ItemList.py index 87fb7bdc0..80aa147c0 100644 --- a/ItemList.py +++ b/ItemList.py @@ -1,8 +1,10 @@ +from typing import Dict, Tuple, Optional, Any + # Progressive: True -> Advancement # False -> Priority # None -> Normal # Item: (type, Progressive, GetItemID, special), -item_table = { +item_table: Dict[str, Tuple[str, Optional[bool], int, Dict[str, Any]]] = { 'Bombs (5)': ('Item', None, 0x0001, {'junk': 8}), 'Deku Nuts (5)': ('Item', None, 0x0002, {'junk': 5}), 'Bombchus (10)': ('Item', True, 0x0003, None), diff --git a/ItemPool.py b/ItemPool.py index 001b851dc..2b3381f62 100644 --- a/ItemPool.py +++ b/ItemPool.py @@ -1,13 +1,16 @@ import random from decimal import Decimal, ROUND_UP +from typing import TYPE_CHECKING, List, Dict, Tuple, Sequence, Union, Optional -from Item import ItemFactory, ItemInfo +from Item import ItemInfo, ItemFactory from Location import DisableType +if TYPE_CHECKING: + from Plandomizer import ItemPoolRecord + from World import World -# Generates item pools and places fixed items based on settings. -plentiful_items = ([ +plentiful_items: List[str] = ([ 'Biggoron Sword', 'Boomerang', 'Lens of Truth', @@ -37,7 +40,7 @@ # Ludicrous replaces all health upgrades with heart containers # as done in plentiful. The item list is used separately to # dynamically replace all junk with even levels of each item. -ludicrous_health = ['Heart Container'] * 8 +ludicrous_health: List[str] = ['Heart Container'] * 8 # List of items that can be multiplied in ludicrous mode. # Used to filter the pre-plando pool for candidates instead @@ -49,7 +52,7 @@ # # Base items will always be candidates to replace junk items, # even if the player starts with all "normal" copies of an item. -ludicrous_items_base = [ +ludicrous_items_base: List[str] = [ 'Light Arrows', 'Megaton Hammer', 'Progressive Hookshot', @@ -79,7 +82,7 @@ 'Deku Nut Capacity' ] -ludicrous_items_extended = [ +ludicrous_items_extended: List[str] = [ 'Zeldas Lullaby', 'Eponas Song', 'Suns Song', @@ -189,7 +192,7 @@ 'Silver Rupee Pouch (Ganons Castle Forest Trial)', ] -ludicrous_exclusions = [ +ludicrous_exclusions: List[str] = [ 'Triforce Piece', 'Gold Skulltula Token', 'Rutos Letter', @@ -198,7 +201,7 @@ 'Piece of Heart (Treasure Chest Game)' ] -item_difficulty_max = { +item_difficulty_max: Dict[str, Dict[str, int]] = { 'ludicrous': { 'Piece of Heart': 3, }, @@ -236,13 +239,13 @@ }, } -shopsanity_rupees = ( +shopsanity_rupees: List[str] = ( ['Rupees (20)'] * 5 + ['Rupees (50)'] * 3 + ['Rupees (200)'] * 2 ) -min_shop_items = ( +min_shop_items: List[str] = ( ['Buy Deku Shield'] + ['Buy Hylian Shield'] + ['Buy Goron Tunic'] + @@ -261,7 +264,7 @@ ['Buy Fish'] ) -deku_scrubs_items = { +deku_scrubs_items: Dict[str, Union[str, List[Tuple[str, int]]]] = { 'Buy Deku Shield': 'Deku Shield', 'Buy Deku Nut (5)': 'Deku Nuts (5)', 'Buy Deku Stick (1)': 'Deku Stick (1)', @@ -272,7 +275,7 @@ 'Buy Deku Seeds (30)': [('Arrows (30)', 3), ('Deku Seeds (30)', 1)], } -trade_items = ( +trade_items: Tuple[str, ...] = ( "Pocket Egg", "Pocket Cucco", "Cojiro", @@ -286,7 +289,7 @@ "Claim Check", ) -child_trade_items = ( +child_trade_items: Tuple[str, ...] = ( "Weird Egg", "Chicken", "Zeldas Letter", @@ -300,12 +303,12 @@ "Mask of Truth", ) -normal_bottles = [bottle for bottle in sorted(ItemInfo.bottles) if bottle not in ['Deliver Letter', 'Sell Big Poe']] + ['Bottle with Big Poe'] -song_list = [item.name for item in sorted([i for n, i in ItemInfo.items.items() if i.type == 'Song'], key=lambda x: x.index)] -junk_pool_base = [(item, weight) for (item, weight) in sorted(ItemInfo.junk.items()) if weight > 0] -remove_junk_items = [item for (item, weight) in sorted(ItemInfo.junk.items()) if weight >= 0] +normal_bottles: List[str] = [bottle for bottle in sorted(ItemInfo.bottles) if bottle not in ['Deliver Letter', 'Sell Big Poe']] + ['Bottle with Big Poe'] +song_list: List[str] = [item.name for item in sorted([i for n, i in ItemInfo.items.items() if i.type == 'Song'], key=lambda x: x.index)] +junk_pool_base: List[Tuple[str, int]] = [(item, weight) for (item, weight) in sorted(ItemInfo.junk.items()) if weight > 0] +remove_junk_items: List[str] = [item for (item, weight) in sorted(ItemInfo.junk.items()) if weight >= 0] -remove_junk_ludicrous_items = [ +remove_junk_ludicrous_items: List[str] = [ 'Ice Arrows', 'Deku Nut Capacity', 'Deku Stick Capacity', @@ -315,12 +318,12 @@ # a useless placeholder item placed at some skipped and inaccessible locations # (e.g. HC Malon Egg with Skip Child Zelda, or the carpenters with Open Gerudo Fortress) -IGNORE_LOCATION = 'Recovery Heart' +IGNORE_LOCATION: str = 'Recovery Heart' -pending_junk_pool = [] -junk_pool = [] +pending_junk_pool: List[str] = [] +junk_pool: List[Tuple[str, int]] = [] -exclude_from_major = [ +exclude_from_major: List[str] = [ 'Deliver Letter', 'Sell Big Poe', 'Magic Bean', @@ -336,7 +339,7 @@ 'Piece of Heart (Treasure Chest Game)', ] -item_groups = { +item_groups: Dict[str, Sequence[str]] = { 'Junk': remove_junk_items, 'JunkSong': ('Prelude of Light', 'Serenade of Water'), 'AdultTrade': trade_items, @@ -361,7 +364,7 @@ } -def get_junk_item(count=1, pool=None, plando_pool=None): +def get_junk_item(count: int = 1, pool: Optional[List[str]] = None, plando_pool: "Optional[Dict[str, ItemPoolRecord]]" = None) -> List[str]: if count < 1: raise ValueError("get_junk_item argument 'count' must be greater than 0.") @@ -385,16 +388,16 @@ def get_junk_item(count=1, pool=None, plando_pool=None): return return_pool -def replace_max_item(items, item, max_count): +def replace_max_item(items: List[str], item: str, max_count: int) -> None: count = 0 - for i,val in enumerate(items): + for i, val in enumerate(items): if val == item: if count >= max_count: items[i] = get_junk_item()[0] count += 1 -def generate_itempool(world): +def generate_itempool(world: "World") -> None: junk_pool[:] = list(junk_pool_base) if world.settings.junk_ice_traps == 'on': junk_pool.append(('Ice Trap', 10)) @@ -416,13 +419,14 @@ def generate_itempool(world): world.distribution.set_complete_itempool(world.itempool) # make sure that there are enough gold skulltulas for bridge/ganon boss key/lacs - world.available_tokens = placed_items_count.get("Gold Skulltula Token", 0) \ - + pool.count("Gold Skulltula Token") \ - + world.distribution.get_starting_item("Gold Skulltula Token") + world.available_tokens = (placed_items_count.get("Gold Skulltula Token", 0) + + pool.count("Gold Skulltula Token") + + world.distribution.get_starting_item("Gold Skulltula Token")) if world.max_progressions["Gold Skulltula Token"] > world.available_tokens: raise ValueError(f"Not enough available Gold Skulltula Tokens to meet requirements. Available: {world.available_tokens}, Required: {world.max_progressions['Gold Skulltula Token']}.") -def get_pool_core(world): + +def get_pool_core(world: "World") -> Tuple[List[str], Dict[str, str]]: pool = [] placed_items = {} remain_shop_items = [] diff --git a/JSONDump.py b/JSONDump.py index a4be2e7a5..a015c244a 100644 --- a/JSONDump.py +++ b/JSONDump.py @@ -1,38 +1,45 @@ import json - from functools import reduce +from typing import Optional, Tuple INDENT = ' ' + class CollapseList(list): pass + + class CollapseDict(dict): pass + + class AlignedDict(dict): - def __init__(self, src_dict, depth): + def __init__(self, src_dict: dict, depth: int) -> None: self.depth = depth - 1 super().__init__(src_dict) + + class SortedDict(dict): pass -def is_scalar(value): +def is_scalar(value) -> bool: return not is_list(value) and not is_dict(value) -def is_list(value): +def is_list(value) -> bool: return isinstance(value, list) or isinstance(value, tuple) -def is_dict(value): +def is_dict(value) -> bool: return isinstance(value, dict) -def dump_scalar(obj, ensure_ascii=False): +def dump_scalar(obj, ensure_ascii: bool = False) -> str: return json.dumps(obj, ensure_ascii=ensure_ascii) -def dump_list(obj, current_indent='', ensure_ascii=False): +def dump_list(obj: list, current_indent: str = '', ensure_ascii: bool = False) -> str: entries = [dump_obj(value, current_indent + INDENT, ensure_ascii=ensure_ascii) for value in obj] if len(entries) == 0: @@ -58,7 +65,7 @@ def dump_list(obj, current_indent='', ensure_ascii=False): return output -def get_keys(obj, depth): +def get_keys(obj: AlignedDict, depth: int): if depth == 0: yield from obj.keys() else: @@ -66,7 +73,7 @@ def get_keys(obj, depth): yield from get_keys(value, depth - 1) -def dump_dict(obj, current_indent='', sub_width=None, ensure_ascii=False): +def dump_dict(obj: dict, current_indent: str = '', sub_width: Optional[Tuple[int, int]] = None, ensure_ascii: bool = False) -> str: entries = [] key_width = None @@ -113,7 +120,7 @@ def dump_dict(obj, current_indent='', sub_width=None, ensure_ascii=False): return output -def dump_obj(obj, current_indent='', sub_width=None, ensure_ascii=False): +def dump_obj(obj, current_indent: str = '', sub_width: Optional[Tuple[int, int]] = None, ensure_ascii: bool = False) -> str: if is_list(obj): return dump_list(obj, current_indent, ensure_ascii) elif is_dict(obj): diff --git a/Location.py b/Location.py index 6c753b712..421c354e1 100644 --- a/Location.py +++ b/Location.py @@ -1,44 +1,56 @@ -from LocationList import location_table, location_is_viewable -from Region import TimeOfDay +import logging from enum import Enum -from itertools import chain - - -class Location(object): - - def __init__(self, name='', address=None, address2=None, default=None, type='Chest', scene=None, parent=None, - filter_tags=None, internal=False, vanilla_item=None): - self.name = name - self.parent_region = parent - self.item = None - self.vanilla_item = vanilla_item - self.address = address - self.address2 = address2 - self.default = default - self.type = type - self.scene = scene - self.internal = internal - self.staleness_count = 0 - self.access_rule = lambda state, **kwargs: True - self.access_rules = [] - self.item_rule = lambda location, item: True - self.locked = False - self.price = None - self.minor_only = False - self.world = None - self.disabled = DisableType.ENABLED - self.always = False - self.never = False - if filter_tags is None: - self.filter_tags = None - elif isinstance(filter_tags, str): - self.filter_tags = [filter_tags] - else: - self.filter_tags = list(filter_tags) +from typing import TYPE_CHECKING, Optional, List, Tuple, Callable, Union, Iterable + +from HintList import misc_item_hint_table, misc_location_hint_table +from LocationList import location_table, location_is_viewable, LocationAddress, LocationDefault, LocationFilterTags +from RulesCommon import AccessRule + +if TYPE_CHECKING: + from Dungeon import Dungeon + from Item import Item + from Region import Region + from State import State + from World import World + +class DisableType(Enum): + ENABLED = 0 + PENDING = 1 + DISABLED = 2 - def copy(self, new_region): - new_location = Location(self.name, self.address, self.address2, self.default, self.type, self.scene, new_region, self.filter_tags, self.vanilla_item) + +class Location: + def __init__(self, name: str = '', address: LocationAddress = None, address2: LocationAddress = None, default: LocationDefault = None, + location_type: str = 'Chest', scene: Optional[int] = None, parent: "Optional[Region]" = None, + filter_tags: LocationFilterTags = None, internal: bool = False, vanilla_item: Optional[str] = None) -> None: + self.name: str = name + self.parent_region: "Optional[Region]" = parent + self.item: "Optional[Item]" = None + self.vanilla_item: Optional[str] = vanilla_item + self.address: LocationAddress = address + self.address2: LocationAddress = address2 + self.default: LocationDefault = default + self.type: str = location_type + self.scene: Optional[int] = scene + self.internal: bool = internal + self.staleness_count: int = 0 + self.access_rule: AccessRule = lambda state, **kwargs: True + self.access_rules: List[AccessRule] = [] + self.item_rule: "Callable[[Location, Item], bool]" = lambda location, item: True + self.locked: bool = False + self.price: Optional[int] = None + self.minor_only: bool = False + self.world: "Optional[World]" = None + self.disabled: DisableType = DisableType.ENABLED + self.always: bool = False + self.never: bool = False + self.filter_tags: Tuple[str, ...] = (filter_tags,) if isinstance(filter_tags, str) else filter_tags + self.rule_string: Optional[str] = None + + def copy(self, new_region: "Region") -> 'Location': + new_location = Location(self.name, self.address, self.address2, self.default, self.type, self.scene, new_region, + self.filter_tags, self.internal, self.vanilla_item) new_location.world = new_region.world if self.item: new_location.item = self.item.copy(new_region.world) @@ -47,7 +59,6 @@ def copy(self, new_region): new_location.access_rules = list(self.access_rules) new_location.item_rule = self.item_rule new_location.locked = self.locked - new_location.internal = self.internal new_location.minor_only = self.minor_only new_location.disabled = self.disabled new_location.always = self.always @@ -55,13 +66,11 @@ def copy(self, new_region): return new_location - @property - def dungeon(self): + def dungeon(self) -> "Optional[Dungeon]": return self.parent_region.dungeon if self.parent_region is not None else None - - def add_rule(self, lambda_rule): + def add_rule(self, lambda_rule: AccessRule) -> None: if self.always: self.set_rule(lambda_rule) self.always = False @@ -78,56 +87,67 @@ def _run_rules(self, state, **kwargs): return False return True - - def set_rule(self, lambda_rule): + def set_rule(self, lambda_rule: AccessRule) -> None: self.access_rule = lambda_rule self.access_rules = [lambda_rule] - - def can_fill(self, state, item, check_access=True): + def can_fill(self, state: "State", item: "Item", check_access: bool = True) -> bool: if self.minor_only and item.majoritem: return False return ( - not self.is_disabled() and + not self.is_disabled and self.can_fill_fast(item) and (not check_access or state.search.spot_access(self, 'either')) ) + def can_fill_fast(self, item: "Item", manual: bool = False) -> bool: + return self.parent_region.can_fill(item, manual) and self.item_rule(self, item) - def can_fill_fast(self, item, manual=False): - return (self.parent_region.can_fill(item, manual) and self.item_rule(self, item)) - - - def is_disabled(self): - return (self.disabled == DisableType.DISABLED) or \ - (self.disabled == DisableType.PENDING and self.locked) - + @property + def is_disabled(self) -> bool: + return ((self.disabled == DisableType.DISABLED) or + (self.disabled == DisableType.PENDING and self.locked)) # Can the player see what's placed at this location without collecting it? # Used to reduce JSON spoiler noise - def has_preview(self): + def has_preview(self) -> bool: return location_is_viewable(self.name, self.world.settings.correct_chest_appearances, self.world.settings.fast_chests) - - def has_item(self): + def has_item(self) -> bool: return self.item is not None - def has_no_item(self): + def has_no_item(self) -> bool: return self.item is None - def has_progression_item(self): + def has_progression_item(self) -> bool: return self.item is not None and self.item.advancement - - def __str__(self): + def maybe_set_misc_item_hints(self) -> None: + if not self.item: + return + if self.item.world.dungeon_rewards_hinted and self.item.name in self.item.world.rewardlist: + if self.item.name not in self.item.world.hinted_dungeon_reward_locations: + self.item.world.hinted_dungeon_reward_locations[self.item.name] = self + logging.getLogger('').debug(f'{self.item.name} [{self.item.world.id}] set to [{self.name}]') + for hint_type in misc_item_hint_table: + item = self.item.world.misc_hint_items[hint_type] + if hint_type not in self.item.world.misc_hint_item_locations and self.item.name == item: + self.item.world.misc_hint_item_locations[hint_type] = self + logging.getLogger('').debug(f'{item} [{self.item.world.id}] set to [{self.name}]') + for hint_type in misc_location_hint_table: + the_location = self.world.misc_hint_locations[hint_type] + if hint_type not in self.world.misc_hint_location_items and self.name == the_location: + self.world.misc_hint_location_items[hint_type] = self.item + logging.getLogger('').debug(f'{the_location} [{self.world.id}] set to [{self.item.name}]') + + def __str__(self) -> str: return str(self.__unicode__()) - - def __unicode__(self): + def __unicode__(self) -> str: return '%s' % self.name -def LocationFactory(locations, world=None): +def LocationFactory(locations: Union[str, List[str]]) -> Union[Location, List[Location]]: ret = [] singleton = False if isinstance(locations, str): @@ -143,7 +163,7 @@ def LocationFactory(locations, world=None): if addresses is None: addresses = (None, None) address, address2 = addresses - ret.append(Location(match_location, address, address2, default, type, scene, filter_tags=filter_tags, vanilla_item=vanilla_item)) + ret.append(Location(match_location, address, address2, default, type, scene, None, filter_tags, False, vanilla_item)) else: raise KeyError('Unknown Location: %s', location) @@ -152,18 +172,12 @@ def LocationFactory(locations, world=None): return ret -def LocationIterator(predicate=lambda loc: True): +def LocationIterator(predicate: Callable[[Location], bool] = lambda loc: True) -> Iterable[Location]: for location_name in location_table: location = LocationFactory(location_name) if predicate(location): yield location -def IsLocation(name): +def is_location(name: str) -> bool: return name in location_table - - -class DisableType(Enum): - ENABLED = 0 - PENDING = 1 - DISABLED = 2 diff --git a/LocationList.py b/LocationList.py index 06b65a09b..7ff296011 100644 --- a/LocationList.py +++ b/LocationList.py @@ -1,9 +1,18 @@ from collections import OrderedDict +from typing import Dict, Tuple, Optional, Union, List +from Utils import TypeAlias -def shop_address(shop_id, shelf_id): +LocationDefault: TypeAlias = Optional[Union[int, Tuple[int, ...], List[Tuple[int, ...]]]] +LocationAddress: TypeAlias = Optional[Union[int, List[int]]] +LocationAddresses: TypeAlias = Optional[Tuple[LocationAddress, LocationAddress]] +LocationFilterTags: TypeAlias = Optional[Union[Tuple[str, ...], str]] + + +def shop_address(shop_id: int, shelf_id: int) -> int: return 0xC71ED0 + (0x40 * shop_id) + (0x08 * shelf_id) + # Abbreviations # DMC Death Mountain Crater # DMT Death Mountain Trail @@ -43,7 +52,7 @@ def shop_address(shop_id, shelf_id): # Note: for ActorOverride locations, the "Addresses" variable is in the form ([addresses], [bytes]) where addresses is a list of memory locations in ROM to be updated, and bytes is the data that will be written to that location # Location: Type Scene Default Addresses Vanilla Item Categories -location_table = OrderedDict([ +location_table: Dict[str, Tuple[str, Optional[int], LocationDefault, LocationAddresses, Optional[str], LocationFilterTags]] = OrderedDict([ ## Dungeon Rewards ("Links Pocket", ("Boss", None, None, None, 'Light Medallion', None)), ("Queen Gohma", ("Boss", None, 0x6C, (0x0CA315F, 0x2079571), 'Kokiri Emerald', None)), @@ -674,7 +683,7 @@ def shop_address(shop_id, shelf_id): ("LH Lab Dive Red Rupee 1", ("Freestanding", 0x38, (0,0,2), None, 'Rupees (20)', ("Lake Hylia", "Freestandings",))), ("LH Lab Dive Red Rupee 2", ("Freestanding", 0x38, (0,0,3), None, 'Rupees (20)', ("Lake Hylia", "Freestandings",))), ("LH Lab Dive Red Rupee 3", ("Freestanding", 0x38, (0,0,4), None, 'Rupees (20)', ("Lake Hylia", "Freestandings",))), - #Lake Hylia Beehives + # Lake Hylia Beehives ("LH Grotto Beehive", ("Beehive", 0x3E, (12,0,0x44 + (0x0F * 2)), None, 'Rupees (20)', ("Lake Hylia", "Grottos", "Beehives",))), # Gerudo Valley @@ -757,7 +766,7 @@ def shop_address(shop_id, shelf_id): # Wasteland Pots/Crates ("Wasteland Near GS Pot 1", ("Pot", 0x5E, (0,0,1), None, 'Recovery Heart', ("Haunted Wasteland", "Pots",))), ("Wasteland Near GS Pot 2", ("Pot", 0x5E, (0,0,2), None, 'Deku Nuts (5)', ("Haunted Wasteland", "Pots",))), - #("Wasteland Near GS Pot 3", ("Pot", 0x5E, (0,0,3), None, 'Rupees (5)', ("Haunted Wasteland", "Pots",))), Fairy + #("Wasteland Near GS Pot 3", ("Pot", 0x5E, (0,0,3), None, 'Rupees (5)', ("Haunted Wasteland", "Pots",))), Fairy ("Wasteland Near GS Pot 3", ("Pot", 0x5E, (0,0,4), None, 'Rupees (5)', ("Haunted Wasteland", "Pots",))), ("Wasteland Crate Before Quicksand", ("Crate", 0x5E, (1,0,38),None, 'Rupee (1)', ("Haunted Wasteland", "Crates",))), ("Wasteland Crate After Quicksand 1", ("Crate", 0x5E, (1,0,35),None, 'Rupee (1)', ("Haunted Wasteland", "Crates",))), @@ -1557,7 +1566,7 @@ def shop_address(shop_id, shelf_id): ("Shadow Temple 3 Spinning Pots Rupee 7", ("RupeeTower", 0x07, (12,0,26), None, 'Rupee (1)', ("Shadow Temple", "Vanilla Dungeons", "Rupee Towers",))), ("Shadow Temple 3 Spinning Pots Rupee 8", ("RupeeTower", 0x07, (12,0,27), None, 'Rupees (5)', ("Shadow Temple", "Vanilla Dungeons", "Rupee Towers",))), ("Shadow Temple 3 Spinning Pots Rupee 9", ("RupeeTower", 0x07, (12,0,28), None, 'Rupees (20)', ("Shadow Temple", "Vanilla Dungeons", "Rupee Towers",))), - #Shadow Temple Vanilla Pots + # Shadow Temple Vanilla Pots ("Shadow Temple Whispering Walls Near Dead Hand Pot", ("Pot", 0x07, (0,0,1), None, 'Rupees (5)', ("Shadow Temple", "Vanilla Dungeons", "Pots",))), ("Shadow Temple Whispering Walls Left Pot 1", ("Pot", 0x07, (0,0,2), None, 'Rupees (5)', ("Shadow Temple", "Vanilla Dungeons", "Pots",))), ("Shadow Temple Whispering Walls Left Pot 2", ("Pot", 0x07, (0,0,3), None, 'Recovery Heart', ("Shadow Temple", "Vanilla Dungeons", "Pots",))), @@ -2247,12 +2256,12 @@ def shop_address(shop_id, shelf_id): ("Ganondorf Hint", ("Hint", None, None, None, None, None)), ]) -location_sort_order = { +location_sort_order: Dict[str, int] = { loc: i for i, loc in enumerate(location_table.keys()) } # Business Scrub Details -business_scrubs = [ +business_scrubs: List[Tuple[int, int, int, List[str]]] = [ # id price text text replacement (0x30, 20, 0x10A0, ["Deku Nuts", "a \x05\x42mysterious item\x05\x40"]), (0x31, 15, 0x10A1, ["Deku Sticks", "a \x05\x42mysterious item\x05\x40"]), @@ -2267,8 +2276,8 @@ def shop_address(shop_id, shelf_id): (0x79, 40, 0x10DD, ["enable you to pick up more \x05\x41Deku\x01Nuts", "sell you a \x05\x42mysterious item"]), ] -dungeons = ('Deku Tree', 'Dodongo\'s Cavern', 'Jabu Jabu\'s Belly', 'Forest Temple', 'Fire Temple', 'Water Temple', 'Spirit Temple', 'Shadow Temple', 'Ice Cavern', 'Bottom of the Well', 'Gerudo Training Ground', 'Ganon\'s Castle') -location_groups = { +dungeons: Tuple[str, ...] = ('Deku Tree', "Dodongo's Cavern", "Jabu Jabu's Belly", 'Forest Temple', 'Fire Temple', 'Water Temple', 'Spirit Temple', 'Shadow Temple', 'Ice Cavern', 'Bottom of the Well', 'Gerudo Training Ground', "Ganon's Castle") +location_groups: Dict[str, List[str]] = { 'Song': [name for (name, data) in location_table.items() if data[0] == 'Song'], 'Chest': [name for (name, data) in location_table.items() if data[0] == 'Chest'], 'Collectable': [name for (name, data) in location_table.items() if data[0] == 'Collectable'], @@ -2284,14 +2293,6 @@ def shop_address(shop_id, shelf_id): } -def location_is_viewable(loc_name, correct_chest_appearances, fast_chests): +def location_is_viewable(loc_name: str, correct_chest_appearances: str, fast_chests: bool) -> bool: return (((correct_chest_appearances in ['textures', 'both', 'classic'] or not fast_chests) and loc_name in location_groups['Chest']) - or loc_name in location_groups['CanSee']) - - -# Function to run exactly once after after placing items in drop locations for each world -# Sets all Drop locations to a unique name in order to avoid name issues and to identify locations in the spoiler -def set_drop_location_names(world): - for location in world.get_locations(): - if location.type == 'Drop': - location.name = location.parent_region.name + " " + location.name + or loc_name in location_groups['CanSee']) diff --git a/MQ.py b/MQ.py index e9d5e6280..684117d40 100644 --- a/MQ.py +++ b/MQ.py @@ -3,7 +3,7 @@ # # Scenes: # -# Ice Cavern (Scene 9) needs to have it's header altered to support MQ's path list. This +# Ice Cavern (Scene 9) needs to have its header altered to support MQ's path list. This # expansion will delete the otherwise unused alternate headers command # # Transition actors will be patched over the old data, as the number of records is the same @@ -43,37 +43,45 @@ # the floor map data is missing a vertex pointer that would point within kaleido_scope. # As such, if the file moves, the patch will break. -from Utils import data_path -from Rom import Rom import json from struct import pack, unpack +from typing import Dict, List, Tuple, Optional, Union, Any -SCENE_TABLE = 0xB71440 +from Rom import Rom +from Utils import data_path +SCENE_TABLE: int = 0xB71440 -class File(object): - def __init__(self, file): - self.name = file['Name'] - self.start = int(file['Start'], 16) if 'Start' in file else 0 - self.end = int(file['End'], 16) if 'End' in file else self.start - self.remap = file['RemapStart'] if 'RemapStart' in file else None - self.from_file = self.start - # used to update the file's associated dmadata record - self.dma_key = self.start +class File: + def __init__(self, name: str, start: int = 0, end: Optional[int] = None, remap: Optional[int] = None) -> None: + self.name: str = name + self.start: int = start + self.end: int = end if end is not None else self.start + self.remap: Optional[int] = remap + self.from_file: int = self.start - if self.remap is not None: - self.remap = int(self.remap, 16) - - def __repr__(self): + # used to update the file's associated dmadata record + self.dma_key: int = self.start + + @classmethod + def from_json(cls, file: Dict[str, Optional[str]]) -> 'File': + return cls( + file['Name'], + int(file['Start'], 16) if file.get('Start', None) is not None else 0, + int(file['End'], 16) if file.get('End', None) is not None else None, + int(file['RemapStart'], 16) if file.get('RemapStart', None) is not None else None + ) + + def __repr__(self) -> str: remap = "None" if self.remap is not None: remap = "{0:x}".format(self.remap) return "{0}: {1:x} {2:x}, remap {3}".format(self.name, self.start, self.end, remap) - def relocate(self, rom:Rom): + def relocate(self, rom: Rom) -> None: if self.remap is None: - self.remap = rom.free_space() + self.remap = rom.dma.free_space() new_start = self.remap @@ -86,39 +94,39 @@ def relocate(self, rom:Rom): update_dmadata(rom, self) # The file will now refer to the new copy of the file - def copy(self, rom:Rom): + def copy(self, rom: Rom) -> None: self.dma_key = None self.relocate(rom) -class CollisionMesh(object): - def __init__(self, rom:Rom, start, offset): +class CollisionMesh: + def __init__(self, rom: Rom, start: int, offset: int) -> None: self.offset = offset self.poly_addr = rom.read_int32(start + offset + 0x18) self.polytypes_addr = rom.read_int32(start + offset + 0x1C) self.camera_data_addr = rom.read_int32(start + offset + 0x20) self.polytypes = (self.poly_addr - self.polytypes_addr) // 8 - def write_to_scene(self, rom:Rom, start): + def write_to_scene(self, rom: Rom, start: int) -> None: addr = start + self.offset + 0x18 rom.write_int32s(addr, [self.poly_addr, self.polytypes_addr, self.camera_data_addr]) -class ColDelta(object): - def __init__(self, delta): - self.is_larger = delta['IsLarger'] - self.polys = delta['Polys'] - self.polytypes = delta['PolyTypes'] - self.cams = delta['Cams'] +class ColDelta: + def __init__(self, delta: Dict[str, Union[bool, List[Dict[str, int]]]]) -> None: + self.is_larger: bool = delta['IsLarger'] + self.polys: List[Dict[str, int]] = delta['Polys'] + self.polytypes: List[Dict[str, int]] = delta['PolyTypes'] + self.cams: List[Dict[str, int]] = delta['Cams'] -class Icon(object): - def __init__(self, data): - self.icon = data["Icon"]; - self.count = data["Count"]; - self.points = [IconPoint(x) for x in data["IconPoints"]] +class Icon: + def __init__(self, data: Dict[str, Union[int, List[Dict[str, int]]]]) -> None: + self.icon: int = data["Icon"] + self.count: int = data["Count"] + self.points: List[IconPoint] = [IconPoint(x) for x in data["IconPoints"]] - def write_to_minimap(self, rom:Rom, addr): + def write_to_minimap(self, rom: Rom, addr: int) -> None: rom.write_sbyte(addr, self.icon) rom.write_byte(addr + 1, self.count) cur = 2 @@ -126,7 +134,7 @@ def write_to_minimap(self, rom:Rom, addr): p.write_to_minimap(rom, addr + cur) cur += 0x03 - def write_to_floormap(self, rom:Rom, addr): + def write_to_floormap(self, rom: Rom, addr: int) -> None: rom.write_int16(addr, self.icon) rom.write_int32(addr + 0x10, self.count) @@ -136,39 +144,38 @@ def write_to_floormap(self, rom:Rom, addr): cur += 0x0C -class IconPoint(object): - def __init__(self, point): +class IconPoint: + def __init__(self, point: Dict[str, int]) -> None: self.flag = point["Flag"] self.x = point["x"] self.y = point["y"] - def write_to_minimap(self, rom:Rom, addr): + def write_to_minimap(self, rom: Rom, addr: int) -> None: rom.write_sbyte(addr, self.flag) rom.write_byte(addr+1, self.x) rom.write_byte(addr+2, self.y) - def write_to_floormap(self, rom:Rom, addr): + def write_to_floormap(self, rom: Rom, addr: int) -> None: rom.write_int16(addr, self.flag) rom.write_f32(addr + 4, float(self.x)) rom.write_f32(addr + 8, float(self.y)) -class Scene(object): - def __init__(self, scene): - self.file = File(scene['File']) - self.id = scene['Id'] - self.transition_actors = [convert_actor_data(x) for x in scene['TActors']] - self.rooms = [Room(x) for x in scene['Rooms']] - self.paths = [] - self.coldelta = ColDelta(scene["ColDelta"]) - self.minimaps = [[Icon(icon) for icon in minimap['Icons']] for minimap in scene['Minimaps']] - self.floormaps = [[Icon(icon) for icon in floormap['Icons']] for floormap in scene['Floormaps']] +class Scene: + def __init__(self, scene: Dict[str, Any]) -> None: + self.file: File = File.from_json(scene['File']) + self.id: int = scene['Id'] + self.transition_actors: List[List[int]] = [convert_actor_data(x) for x in scene['TActors']] + self.rooms: List[Room] = [Room(x) for x in scene['Rooms']] + self.paths: List[List[List[int]]] = [] + self.coldelta: ColDelta = ColDelta(scene["ColDelta"]) + self.minimaps: List[List[Icon]] = [[Icon(icon) for icon in minimap['Icons']] for minimap in scene['Minimaps']] + self.floormaps: List[List[Icon]] = [[Icon(icon) for icon in floormap['Icons']] for floormap in scene['Floormaps']] temp_paths = scene['Paths'] for item in temp_paths: self.paths.append(item['Points']) - - def write_data(self, rom:Rom): + def write_data(self, rom: Rom) -> None: # write floormap and minimap data self.write_map_data(rom) @@ -183,22 +190,22 @@ def write_data(self, rom:Rom): code = rom.read_byte(headcur) loop = 0x20 - while loop > 0 and code != 0x14: #terminator + while loop > 0 and code != 0x14: # terminator loop -= 1 - if code == 0x03: #collision + if code == 0x03: # collision col_mesh_offset = rom.read_int24(headcur + 5) col_mesh = CollisionMesh(rom, start, col_mesh_offset) - self.patch_mesh(rom, col_mesh); + self.patch_mesh(rom, col_mesh) - elif code == 0x04: #rooms + elif code == 0x04: # rooms room_list_offset = rom.read_int24(headcur + 5) - elif code == 0x0D: #paths + elif code == 0x0D: # paths path_offset = self.append_path_data(rom) rom.write_int32(headcur + 4, path_offset) - elif code == 0x0E: #transition actors + elif code == 0x0E: # transition actors t_offset = rom.read_int24(headcur + 5) addr = self.file.start + t_offset write_actor_data(rom, addr, self.transition_actors) @@ -222,8 +229,7 @@ def write_data(self, rom:Rom): rom.write_int32s(cur, [room.file.start, room.file.end]) cur += 0x08 - - def write_map_data(self, rom:Rom): + def write_map_data(self, rom: Rom) -> None: if self.id >= 10: return @@ -231,7 +237,7 @@ def write_map_data(self, rom:Rom): floormap_indices = 0xB6C934 floormap_vrom = 0xBC7E00 floormap_index = rom.read_int16(floormap_indices + (self.id * 2)) - floormap_index //= 2 # game uses texture index, where two textures are used per floor + floormap_index //= 2 # game uses texture index, where two textures are used per floor cur = floormap_vrom + (floormap_index * 0x1EC) for floormap in self.floormaps: @@ -239,17 +245,16 @@ def write_map_data(self, rom:Rom): Icon.write_to_floormap(icon, rom, cur) cur += 0xA4 - # fixes jabu jabu floor B1 having no chest data if self.id == 2: cur = floormap_vrom + (0x08 * 0x1EC + 4) - kaleido_scope_chest_verts = 0x803A3DA0 # hax, should be vram 0x8082EA00 + kaleido_scope_chest_verts = 0x803A3DA0 # hax, should be vram 0x8082EA00 rom.write_int32s(cur, [0x17, kaleido_scope_chest_verts, 0x04]) # write minimaps map_mark_vrom = 0xBF40D0 map_mark_vram = 0x808567F0 - map_mark_array_vram = 0x8085D2DC # ptr array in map_mark_data to minimap "marks" + map_mark_array_vram = 0x8085D2DC # ptr array in map_mark_data to minimap "marks" array_vrom = map_mark_array_vram - map_mark_vram + map_mark_vrom map_mark_scene_vram = rom.read_int32(self.id * 4 + array_vrom) @@ -261,8 +266,7 @@ def write_map_data(self, rom:Rom): Icon.write_to_minimap(icon, rom, cur) cur += 0x26 - - def patch_mesh(self, rom:Rom, mesh:CollisionMesh): + def patch_mesh(self, rom: Rom, mesh: CollisionMesh) -> None: start = self.file.start final_cams = [] @@ -297,7 +301,7 @@ def patch_mesh(self, rom:Rom, mesh:CollisionMesh): self.write_cam_data(rom, addr, final_cams) # if polytypes needs to be moved, do so - if (types_move_addr != mesh.polytypes_addr): + if types_move_addr != mesh.polytypes_addr: a_start = self.file.start + (mesh.polytypes_addr & 0xFFFFFF) b_start = self.file.start + (types_move_addr & 0xFFFFFF) size = mesh.polytypes * 8 @@ -320,25 +324,23 @@ def patch_mesh(self, rom:Rom, mesh:CollisionMesh): flags = item['Flags'] addr = self.file.start + (mesh.poly_addr & 0xFFFFFF) + (id * 0x10) - vert_bit = rom.read_byte(addr + 0x02) & 0x1F # VertexA id data + vert_bit = rom.read_byte(addr + 0x02) & 0x1F # VertexA id data rom.write_int16(addr, t) rom.write_byte(addr + 0x02, (flags << 5) + vert_bit) # Write Mesh to Scene mesh.write_to_scene(rom, self.file.start) - - def write_cam_data(self, rom:Rom, addr, cam_data): - + @staticmethod + def write_cam_data(rom: Rom, addr: int, cam_data: List[Tuple[int, int]]) -> None: for item in cam_data: data, pos = item rom.write_int32s(addr, [data, pos]) addr += 8 - # appends path data to the end of the rom # returns segment address to path data - def append_path_data(self, rom:Rom): + def append_path_data(self, rom: Rom) -> int: start = self.file.start cur = self.file.end records = [] @@ -348,7 +350,7 @@ def append_path_data(self, rom:Rom): offset = get_segment_address(2, cur - start) records.append((nodes, offset)) - #flatten + # flatten points = [x for points in path for x in points] rom.write_int16s(cur, points) path_size = align4(len(path) * 6) @@ -364,14 +366,14 @@ def append_path_data(self, rom:Rom): return records_offset -class Room(object): - def __init__(self, room): - self.file = File(room['File']) - self.id = room['Id'] - self.objects = [int(x, 16) for x in room['Objects']] - self.actors = [convert_actor_data(x) for x in room['Actors']] +class Room: + def __init__(self, room: Dict[str, Union[int, List[str], Dict[str, Optional[str]]]]): + self.file: File = File.from_json(room['File']) + self.id: int = room['Id'] + self.objects: List[int] = [int(x, 16) for x in room['Objects']] + self.actors: List[List[int]] = [convert_actor_data(x) for x in room['Actors']] - def write_data(self, rom:Rom): + def write_data(self, rom: Rom) -> None: # move file to remap address if self.file.remap is not None: self.file.relocate(rom) @@ -381,7 +383,7 @@ def write_data(self, rom:Rom): code = rom.read_byte(headcur) loop = 0x20 - while loop != 0 and code != 0x14: #terminator + while loop != 0 and code != 0x14: # terminator loop -= 1 if code == 0x01: # actors @@ -405,8 +407,7 @@ def write_data(self, rom:Rom): self.file.end = align16(self.file.end) update_dmadata(rom, self.file) - - def append_object_data(self, rom:Rom, objects): + def append_object_data(self, rom: Rom, objects: List[int]) -> int: offset = self.file.end - self.file.start cur = self.file.end rom.write_int16s(cur, objects) @@ -416,8 +417,7 @@ def append_object_data(self, rom:Rom, objects): return offset -def patch_files(rom:Rom, mq_scenes:list): - +def patch_files(rom: Rom, mq_scenes: List[int]) -> None: data = get_json() scenes = [Scene(x) for x in data] for scene in scenes: @@ -427,30 +427,29 @@ def patch_files(rom:Rom, mq_scenes:list): scene.write_data(rom) - -def get_json(): +def get_json() -> Any: with open(data_path('mqu.json'), 'r') as stream: data = json.load(stream) return data -def convert_actor_data(str): - spawn_args = str.split(" ") +def convert_actor_data(string: str) -> List[int]: + spawn_args = string.split(" ") return [ int(x,16) for x in spawn_args ] -def get_segment_address(base, offset): +def get_segment_address(base: int, offset: int) -> int: offset &= 0xFFFFFF base *= 0x01000000 return base + offset -def patch_ice_cavern_scene_header(rom): +def patch_ice_cavern_scene_header(rom: Rom) -> None: rom.buffer[0x2BEB000:0x2BEB038] = rom.buffer[0x2BEB008:0x2BEB040] rom.write_int32s(0x2BEB038, [0x0D000000, 0x02000000]) -def patch_spirit_temple_mq_room_6(rom:Rom, room_addr): +def patch_spirit_temple_mq_room_6(rom: Rom, room_addr: int) -> None: cur = room_addr actor_list_addr = 0 @@ -458,8 +457,8 @@ def patch_spirit_temple_mq_room_6(rom:Rom, room_addr): # scan for actor list and header end code = rom.read_byte(cur) - while code != 0x14: #terminator - if code == 0x01: # actors + while code != 0x14: # terminator + if code == 0x01: # actors actor_list_addr = rom.read_int32(cur + 4) cmd_actors_offset = cur - room_addr @@ -475,7 +474,7 @@ def patch_spirit_temple_mq_room_6(rom:Rom, room_addr): alt_data_off = header_size + 8 # set new alternate header offset - alt_header_off = align16(alt_data_off + (4 * 3)) # alt header record size * num records + alt_header_off = align16(alt_data_off + (4 * 3)) # alt header record size * num records # write alternate header data # the first 3 words are mandatory. the last 3 are just to make the binary @@ -506,8 +505,8 @@ def patch_spirit_temple_mq_room_6(rom:Rom, room_addr): rom.write_int32s(room_addr, [0x18000000, seg]) -def verify_remap(scenes): - def test_remap(file:File): +def verify_remap(scenes: List[Scene]) -> None: + def test_remap(file: File) -> bool: if file.remap is not None: if file.start < file.remap: return False @@ -525,32 +524,36 @@ def test_remap(file:File): print("{0} - {1}".format(result, file)) -def update_dmadata(rom:Rom, file:File): +def update_dmadata(rom: Rom, file: File) -> None: key, start, end, from_file = file.dma_key, file.start, file.end, file.from_file - rom.update_dmadata_record(key, start, end, from_file) + rom.update_dmadata_record_by_key(key, start, end, from_file) file.dma_key = file.start -def update_scene_table(rom:Rom, sceneId, start, end): - cur = sceneId * 0x14 + SCENE_TABLE + +def update_scene_table(rom: Rom, scene_id: int, start: int, end: int) -> None: + cur = scene_id * 0x14 + SCENE_TABLE rom.write_int32s(cur, [start, end]) -def write_actor_data(rom:Rom, cur, actors): +def write_actor_data(rom: Rom, cur: int, actors: List[List[int]]) -> None: for actor in actors: rom.write_int16s(cur, actor) cur += 0x10 -def align4(value): + +def align4(value: int) -> int: return ((value + 3) // 4) * 4 -def align16(value): + +def align16(value: int) -> int: return ((value + 0xF) // 0x10) * 0x10 + # This function inserts space in a ovl section at the section's offset # The section size is expanded -# Every relocation entry in the section after the offet is moved accordingly +# Every relocation entry in the section after the offset is moved accordingly # Every relocation value that is after the inserted space is increased accordingly -def insert_space(rom, file, vram_start, insert_section, insert_offset, insert_size): +def insert_space(rom: Rom, file: File, vram_start: int, insert_section: int, insert_offset: int, insert_size: int) -> None: sections = [] val_hi = {} adr_hi = {} @@ -598,7 +601,7 @@ def insert_space(rom, file, vram_start, insert_section, insert_offset, insert_si # value contains the vram address value = rom.read_int32(address) - raw_value = value + reg = None if type == 2: # Data entry: value is the raw vram address pass @@ -624,7 +627,7 @@ def insert_space(rom, file, vram_start, insert_section, insert_offset, insert_si value = None # update the vram values if it's been moved - if value != None and value >= insert_vram: + if value is not None and value >= insert_vram: # value = new vram address new_value = value + insert_size @@ -659,7 +662,7 @@ def insert_space(rom, file, vram_start, insert_section, insert_offset, insert_si file.end += insert_size -def add_relocations(rom, file, addresses): +def add_relocations(rom: Rom, file: File, addresses: List[Union[int, Tuple[int, int]]]) -> None: relocations = [] sections = [] header_size = rom.read_int32(file.end - 4) diff --git a/Main.py b/Main.py index 59f52bc8e..f73601e10 100644 --- a/Main.py +++ b/Main.py @@ -1,4 +1,3 @@ -from collections import OrderedDict import copy import hashlib import logging @@ -8,34 +7,32 @@ import shutil import struct import time +from typing import List import zipfile -from World import World -from Spoiler import Spoiler -from Rom import Rom -from Patches import patch_rom -from Cosmetics import patch_cosmetics -from Dungeon import create_dungeons +from Cosmetics import CosmeticsLog, patch_cosmetics +from EntranceShuffle import set_entrances from Fill import distribute_items_restrictive, ShuffleError -from Item import Item +from Goals import update_goal_items, replace_goal_names +from Hints import build_gossip_hints +from HintList import clear_hint_exclusion_cache, misc_item_hint_table, misc_location_hint_table from ItemPool import generate_itempool -from Hints import buildGossipHints -from HintList import clearHintExclusionCache, misc_item_hint_table, misc_location_hint_table -from Utils import default_output_path, is_bundled, run_process, data_path +from MBSDIFFPatch import apply_ootr_3_web_patch from Models import patch_model_adult, patch_model_child from N64Patch import create_patch_file, apply_patch_file -from MBSDIFFPatch import apply_ootr_3_web_patch -from SettingsList import logic_tricks +from Patches import patch_rom +from Rom import Rom from Rules import set_rules, set_shop_rules -from Search import Search, RewindableSearch -from EntranceShuffle import set_entrances -from LocationList import set_drop_location_names -from Goals import update_goal_items, maybe_set_misc_item_hints, replace_goal_names +from Settings import Settings +from SettingsList import logic_tricks +from Spoiler import Spoiler +from Utils import default_output_path, is_bundled, run_process, data_path +from World import World from version import __version__ -def main(settings, max_attempts=10): - clearHintExclusionCache() +def main(settings: Settings, max_attempts: int = 10) -> Spoiler: + clear_hint_exclusion_cache() logger = logging.getLogger('') start = time.process_time() @@ -59,7 +56,7 @@ def main(settings, max_attempts=10): return spoiler -def resolve_settings(settings): +def resolve_settings(settings: Settings) -> Rom: logger = logging.getLogger('') old_tricks = settings.allowed_tricks @@ -110,9 +107,9 @@ def resolve_settings(settings): return rom -def generate(settings): +def generate(settings: Settings) -> Spoiler: worlds = build_world_graphs(settings) - place_items(settings, worlds) + place_items(worlds) for world in worlds: world.distribution.configure_effective_starting_items(worlds, world) if worlds[0].enable_goal_hints: @@ -120,7 +117,7 @@ def generate(settings): return make_spoiler(settings, worlds) -def build_world_graphs(settings): +def build_world_graphs(settings: Settings) -> List[World]: logger = logging.getLogger('') worlds = [] for i in range(0, settings.world_count): @@ -142,7 +139,7 @@ def build_world_graphs(settings): savewarps_to_connect += world.load_regions_from_json(os.path.join(path, filename)) # Compile the json rules based on settings - savewarps_to_connect += create_dungeons(world) + savewarps_to_connect += world.create_dungeons() world.create_internal_locations() if settings.shopsanity != 'off': @@ -155,7 +152,7 @@ def build_world_graphs(settings): logger.info('Generating Item Pool.') generate_itempool(world) set_shop_rules(world) - set_drop_location_names(world) + world.set_drop_location_names() world.fill_bosses() if settings.triforce_hunt: @@ -166,29 +163,29 @@ def build_world_graphs(settings): return worlds -def place_items(settings, worlds): +def place_items(worlds: List[World]) -> None: logger = logging.getLogger('') logger.info('Fill the world.') distribute_items_restrictive(worlds) -def make_spoiler(settings, worlds): +def make_spoiler(settings: Settings, worlds: List[World]) -> Spoiler: logger = logging.getLogger('') spoiler = Spoiler(worlds) if settings.create_spoiler: logger.info('Calculating playthrough.') - create_playthrough(spoiler) + spoiler.create_playthrough() if settings.create_spoiler or settings.hints != 'none': logger.info('Calculating hint data.') update_goal_items(spoiler) - buildGossipHints(spoiler, worlds) + build_gossip_hints(spoiler, worlds) elif any(world.dungeon_rewards_hinted for world in worlds) or any(hint_type in settings.misc_hints for hint_type in misc_item_hint_table) or any(hint_type in settings.misc_hints for hint_type in misc_location_hint_table): - find_misc_hint_items(spoiler) + spoiler.find_misc_hint_items() spoiler.build_file_hash() return spoiler -def prepare_rom(spoiler, world, rom, settings, rng_state=None, restore=True): +def prepare_rom(spoiler: Spoiler, world: World, rom: Rom, settings: Settings, rng_state: tuple = None, restore: bool = True) -> CosmeticsLog: if rng_state: random.setstate(rng_state) # Use different seeds for each world when patching. @@ -210,7 +207,7 @@ def prepare_rom(spoiler, world, rom, settings, rng_state=None, restore=True): return cosmetics_log -def compress_rom(input_file, output_file, delete_input=False): +def compress_rom(input_file: str, output_file: str, delete_input: bool = False) -> None: logger = logging.getLogger('') compressor_path = "./" if is_bundled() else "bin/Compress/" if platform.system() == 'Windows': @@ -241,9 +238,9 @@ def compress_rom(input_file, output_file, delete_input=False): os.remove(input_file) -def generate_wad(wad_file, rom_file, output_file, channel_title, channel_id, delete_input=False): +def generate_wad(wad_file: str, rom_file: str, output_file: str, channel_title: str, channel_id: str, delete_input: bool = False) -> None: logger = logging.getLogger('') - if wad_file == "" or wad_file == None: + if wad_file == "" or wad_file is None: raise Exception("Unspecified base WAD file.") if not os.path.isfile(wad_file): raise Exception("Cannot open base WAD file.") @@ -275,14 +272,14 @@ def generate_wad(wad_file, rom_file, output_file, channel_title, channel_id, del run_process(logger, [gzinject_path, "-a", "genkey"], b'45e') run_process(logger, [gzinject_path, "-a", "inject", "--rom", rom_file, "--wad", wad_file, - "-o", output_file, "-i", channel_id, "-t", channel_title, - "-p", gzinject_patch_path, "--cleanup"]) + "-o", output_file, "-i", channel_id, "-t", channel_title, + "-p", gzinject_patch_path, "--cleanup"]) os.remove("common-key.bin") if delete_input: os.remove(rom_file) -def patch_and_output(settings, spoiler, rom): +def patch_and_output(settings: Settings, spoiler: Spoiler, rom: Rom) -> None: logger = logging.getLogger('') worlds = spoiler.worlds cosmetics_log = None @@ -423,7 +420,7 @@ def patch_and_output(settings, spoiler, rom): logger.info('Success: Rom patched successfully') -def from_patch_file(settings): +def from_patch_file(settings: Settings) -> None: start = time.process_time() logger = logging.getLogger('') @@ -503,10 +500,8 @@ def from_patch_file(settings): logger.debug('Total Time: %s', time.process_time() - start) - return True - -def cosmetic_patch(settings): +def cosmetic_patch(settings: Settings) -> None: start = time.process_time() logger = logging.getLogger('') @@ -571,10 +566,8 @@ def cosmetic_patch(settings): logger.debug('Total Time: %s', time.process_time() - start) - return True - -def diff_roms(settings, diff_rom_file): +def diff_roms(settings: Settings, diff_rom_file: str) -> None: start = time.process_time() logger = logging.getLogger('') @@ -604,174 +597,3 @@ def diff_roms(settings, diff_rom_file): logger.info(f"Created patchfile at: {output_path}.zpf") logger.info('Done. Enjoy.') logger.debug('Total Time: %s', time.process_time() - start) - - -def copy_worlds(worlds): - worlds = [world.copy() for world in worlds] - Item.fix_worlds_after_copy(worlds) - return worlds - - -def find_misc_hint_items(spoiler): - search = Search([world.state for world in spoiler.worlds]) - all_locations = [location for world in spoiler.worlds for location in world.get_filled_locations()] - for location in search.iter_reachable_locations(all_locations[:]): - search.collect(location.item) - # include locations that are reachable but not part of the spoiler log playthrough in misc. item hints - maybe_set_misc_item_hints(location) - all_locations.remove(location) - for location in all_locations: - # finally, collect unreachable locations for misc. item hints - maybe_set_misc_item_hints(location) - - -def create_playthrough(spoiler): - logger = logging.getLogger('') - worlds = spoiler.worlds - if worlds[0].check_beatable_only and not Search([world.state for world in worlds]).can_beat_game(): - raise RuntimeError('Game unbeatable after placing all items.') - # create a copy as we will modify it - old_worlds = worlds - worlds = copy_worlds(worlds) - - # if we only check for beatable, we can do this sanity check first before writing down spheres - if worlds[0].check_beatable_only and not Search([world.state for world in worlds]).can_beat_game(): - raise RuntimeError('Uncopied world beatable but copied world is not.') - - search = RewindableSearch([world.state for world in worlds]) - logger.debug('Initial search: %s', search.state_list[0].get_prog_items()) - # Get all item locations in the worlds - item_locations = search.progression_locations() - # Omit certain items from the playthrough - internal_locations = {location for location in item_locations if location.internal} - # Generate a list of spheres by iterating over reachable locations without collecting as we go. - # Collecting every item in one sphere means that every item - # in the next sphere is collectable. Will contain every reachable item this way. - logger.debug('Building up collection spheres.') - collection_spheres = [] - entrance_spheres = [] - remaining_entrances = set(entrance for world in worlds for entrance in world.get_shuffled_entrances()) - - search.checkpoint() - search.collect_pseudo_starting_items() - logger.debug('With pseudo starting items: %s', search.state_list[0].get_prog_items()) - - while True: - search.checkpoint() - # Not collecting while the generator runs means we only get one sphere at a time - # Otherwise, an item we collect could influence later item collection in the same sphere - collected = list(search.iter_reachable_locations(item_locations)) - if not collected: break - random.shuffle(collected) - # Gather the new entrances before collecting items. - collection_spheres.append(collected) - accessed_entrances = set(filter(search.spot_access, remaining_entrances)) - entrance_spheres.append(list(accessed_entrances)) - remaining_entrances -= accessed_entrances - for location in collected: - # Collect the item for the state world it is for - search.state_list[location.item.world.id].collect(location.item) - maybe_set_misc_item_hints(location) - logger.info('Collected %d spheres', len(collection_spheres)) - spoiler.full_playthrough = dict((location.name, i + 1) for i, sphere in enumerate(collection_spheres) for location in sphere) - spoiler.max_sphere = len(collection_spheres) - - # Reduce each sphere in reverse order, by checking if the game is beatable - # when we remove the item. We do this to make sure that progressive items - # like bow and slingshot appear as early as possible rather than as late as possible. - required_locations = [] - for sphere in reversed(collection_spheres): - random.shuffle(sphere) - for location in sphere: - # we remove the item at location and check if the game is still beatable in case the item could be required - old_item = location.item - - # Uncollect the item and location. - search.state_list[old_item.world.id].remove(old_item) - search.unvisit(location) - - # Generic events might show up or not, as usual, but since we don't - # show them in the final output, might as well skip over them. We'll - # still need them in the final pass, so make sure to include them. - if location.internal: - required_locations.append(location) - continue - - location.item = None - - # An item can only be required if it isn't already obtained or if it's progressive - if search.state_list[old_item.world.id].item_count(old_item.solver_id) < old_item.world.max_progressions[old_item.name]: - # Test whether the game is still beatable from here. - logger.debug('Checking if %s is required to beat the game.', old_item.name) - if not search.can_beat_game(): - # still required, so reset the item - location.item = old_item - required_locations.append(location) - - # Reduce each entrance sphere in reverse order, by checking if the game is beatable when we disconnect the entrance. - required_entrances = [] - for sphere in reversed(entrance_spheres): - random.shuffle(sphere) - for entrance in sphere: - # we disconnect the entrance and check if the game is still beatable - old_connected_region = entrance.disconnect() - - # we use a new search to ensure the disconnected entrance is no longer used - sub_search = Search([world.state for world in worlds]) - - # Test whether the game is still beatable from here. - logger.debug('Checking if reaching %s, through %s, is required to beat the game.', old_connected_region.name, entrance.name) - if not sub_search.can_beat_game(): - # still required, so reconnect the entrance - entrance.connect(old_connected_region) - required_entrances.append(entrance) - - # Regenerate the spheres as we might not reach places the same way anymore. - search.reset() # search state has no items, okay to reuse sphere 0 cache - collection_spheres = [list( - filter(lambda loc: loc.item.advancement and loc.item.world.max_progressions[loc.item.name] > 0, - search.iter_pseudo_starting_locations()))] - entrance_spheres = [] - remaining_entrances = set(required_entrances) - collected = set() - while True: - # Not collecting while the generator runs means we only get one sphere at a time - # Otherwise, an item we collect could influence later item collection in the same sphere - collected.update(search.iter_reachable_locations(required_locations)) - if not collected: - break - internal = collected & internal_locations - if internal: - # collect only the internal events but don't record them in a sphere - for location in internal: - search.state_list[location.item.world.id].collect(location.item) - # Remaining locations need to be saved to be collected later - collected -= internal - continue - # Gather the new entrances before collecting items. - collection_spheres.append(list(collected)) - accessed_entrances = set(filter(search.spot_access, remaining_entrances)) - entrance_spheres.append(accessed_entrances) - remaining_entrances -= accessed_entrances - for location in collected: - # Collect the item for the state world it is for - search.state_list[location.item.world.id].collect(location.item) - collected.clear() - logger.info('Collected %d final spheres', len(collection_spheres)) - - if not search.can_beat_game(False): - logger.error('Playthrough could not beat the game!') - # Add temporary debugging info or breakpoint here if this happens - - # Then we can finally output our playthrough - spoiler.playthrough = OrderedDict((str(i), {location: location.item for location in sphere}) for i, sphere in enumerate(collection_spheres)) - # Copy our misc. hint items, since we set them in the world copy - for w, sw in zip(worlds, spoiler.worlds): - # But the actual location saved here may be in a different world - for item_name, item_location in w.hinted_dungeon_reward_locations.items(): - sw.hinted_dungeon_reward_locations[item_name] = spoiler.worlds[item_location.world.id].get_location(item_location.name) - for hint_type, item_location in w.misc_hint_item_locations.items(): - sw.misc_hint_item_locations[hint_type] = spoiler.worlds[item_location.world.id].get_location(item_location.name) - - if worlds[0].entrance_shuffle: - spoiler.entrance_playthrough = OrderedDict((str(i + 1), list(sphere)) for i, sphere in enumerate(entrance_spheres)) diff --git a/Messages.py b/Messages.py index adeea5ca7..b593bee46 100644 --- a/Messages.py +++ b/Messages.py @@ -1,30 +1,36 @@ # text details: https://wiki.cloudmodding.com/oot/Text_Format import random +from typing import TYPE_CHECKING, Dict, List, Tuple, Callable, Any, Union, Optional, Iterable, Set + from HintList import misc_item_hint_table, misc_location_hint_table from TextBox import line_wrap from Utils import find_last -ENG_TEXT_START = 0x92D000 -JPN_TEXT_START = 0x8EB000 -ENG_TEXT_SIZE_LIMIT = 0x39000 -JPN_TEXT_SIZE_LIMIT = 0x3B000 +if TYPE_CHECKING: + from Rom import Rom + from World import World + +ENG_TEXT_START: int = 0x92D000 +JPN_TEXT_START: int = 0x8EB000 +ENG_TEXT_SIZE_LIMIT: int = 0x39000 +JPN_TEXT_SIZE_LIMIT: int = 0x3B000 -JPN_TABLE_START = 0xB808AC -ENG_TABLE_START = 0xB849EC -CREDITS_TABLE_START = 0xB88C0C +JPN_TABLE_START: int = 0xB808AC +ENG_TABLE_START: int = 0xB849EC +CREDITS_TABLE_START: int = 0xB88C0C -JPN_TABLE_SIZE = ENG_TABLE_START - JPN_TABLE_START -ENG_TABLE_SIZE = CREDITS_TABLE_START - ENG_TABLE_START +JPN_TABLE_SIZE: int = ENG_TABLE_START - JPN_TABLE_START +ENG_TABLE_SIZE: int = CREDITS_TABLE_START - ENG_TABLE_START -EXTENDED_TABLE_START = JPN_TABLE_START # start writing entries to the jp table instead of english for more space -EXTENDED_TABLE_SIZE = JPN_TABLE_SIZE + ENG_TABLE_SIZE # 0x8360 bytes, 4204 entries +EXTENDED_TABLE_START: int = JPN_TABLE_START # start writing entries to the jp table instead of english for more space +EXTENDED_TABLE_SIZE: int = JPN_TABLE_SIZE + ENG_TABLE_SIZE # 0x8360 bytes, 4204 entries -EXTENDED_TEXT_START = JPN_TABLE_START # start writing text to the jp table instead of english for more space -EXTENDED_TEXT_SIZE_LIMIT = JPN_TEXT_SIZE_LIMIT + ENG_TEXT_SIZE_LIMIT # 0x74000 bytes +EXTENDED_TEXT_START: int = JPN_TABLE_START # start writing text to the jp table instead of english for more space +EXTENDED_TEXT_SIZE_LIMIT: int = JPN_TEXT_SIZE_LIMIT + ENG_TEXT_SIZE_LIMIT # 0x74000 bytes # name of type, followed by number of additional bytes to read, follwed by a function that prints the code -CONTROL_CODES = { +CONTROL_CODES: Dict[int, Tuple[str, int, Callable[[Any], str]]] = { 0x00: ('pad', 0, lambda _: '' ), 0x01: ('line-break', 0, lambda _: '\n' ), 0x02: ('end', 0, lambda _: '' ), @@ -59,7 +65,7 @@ } # Maps unicode characters to corresponding bytes in OOTR's character set. -CHARACTER_MAP = { +CHARACTER_MAP: Dict[str, int] = { 'Ⓐ': 0x9F, 'Ⓑ': 0xA0, 'Ⓒ': 0xA1, @@ -90,7 +96,7 @@ start=0x7f )) -SPECIAL_CHARACTERS = { +SPECIAL_CHARACTERS: Dict[int, str] = { 0x9F: '[A]', 0xA0: '[B]', 0xA1: '[C]', @@ -105,22 +111,22 @@ 0xAA: '[Control Stick]', } -REVERSE_MAP = list(chr(x) for x in range(256)) +REVERSE_MAP: List[str] = list(chr(x) for x in range(256)) for char, byte in CHARACTER_MAP.items(): SPECIAL_CHARACTERS.setdefault(byte, char) REVERSE_MAP[byte] = char # [0x0500,0x0560] (inclusive) are reserved for plandomakers -GOSSIP_STONE_MESSAGES = list( range(0x0401, 0x04FF) ) # ids of the actual hints -GOSSIP_STONE_MESSAGES += [0x2053, 0x2054] # shared initial stone messages -TEMPLE_HINTS_MESSAGES = [0x7057, 0x707A] # dungeon reward hints from the temple of time pedestal -GS_TOKEN_MESSAGES = [0x00B4, 0x00B5] # Get Gold Skulltula Token messages -ERROR_MESSAGE = 0x0001 +GOSSIP_STONE_MESSAGES: List[int] = list(range(0x0401, 0x04FF)) # ids of the actual hints +GOSSIP_STONE_MESSAGES += [0x2053, 0x2054] # shared initial stone messages +TEMPLE_HINTS_MESSAGES: List[int] = [0x7057, 0x707A] # dungeon reward hints from the temple of time pedestal +GS_TOKEN_MESSAGES: List[int] = [0x00B4, 0x00B5] # Get Gold Skulltula Token messages +ERROR_MESSAGE: int = 0x0001 # messages for shorter item messages # ids are in the space freed up by move_shop_item_messages() -ITEM_MESSAGES = [ +ITEM_MESSAGES: List[Tuple[int, str]] = [ (0x0001, "\x08\x06\x30\x05\x41TEXT ID ERROR!\x05\x40"), (0x9001, "\x08\x13\x2DYou borrowed a \x05\x41Pocket Egg\x05\x40!\x01A Pocket Cucco will hatch from\x01it overnight. Be sure to give it\x01back."), (0x0002, "\x08\x13\x2FYou returned the Pocket Cucco\x01and got \x05\x41Cojiro\x05\x40 in return!\x01Unlike other Cuccos, Cojiro\x01rarely crows."), @@ -267,7 +273,7 @@ (0x901A, "\x08You can't buy Bombchus without a\x01\x05\x41Bombchu Bag\x05\x40!") ] -KEYSANITY_MESSAGES = [ +KEYSANITY_MESSAGES: List[Tuple[int, str]] = [ (0x001C, "\x13\x74\x08You got the \x05\x41Boss Key\x05\x40\x01for the \x05\x41Fire Temple\x05\x40!\x09"), (0x0006, "\x13\x74\x08You got the \x05\x41Boss Key\x05\x40\x01for the \x05\x42Forest Temple\x05\x40!\x09"), (0x001D, "\x13\x74\x08You got the \x05\x41Boss Key\x05\x40\x01for the \x05\x43Water Temple\x05\x40!\x09"), @@ -479,7 +485,7 @@ KEYSANITY_MESSAGES.append((i, f"\x13\x77\x08You found a \x05\x41Key Ring\x05\x40\x01for {dungeon_name}!\x09\x01It includes the \x05\x41Boss Key\x05\x40!")) i += 1 -COLOR_MAP = { +COLOR_MAP: Dict[str, str] = { 'White': '\x40', 'Red': '\x41', 'Green': '\x42', @@ -490,12 +496,12 @@ 'Black': '\x47', } -MISC_MESSAGES = { +MISC_MESSAGES: Dict[int, Tuple[Union[str, bytearray], int]] = { 0x507B: (bytearray( - b"\x08I tell you, I saw him!\x04" \ - b"\x08I saw the ghostly figure of Damp\x96\x01" \ - b"the gravekeeper sinking into\x01" \ - b"his grave. It looked like he was\x01" \ + b"\x08I tell you, I saw him!\x04" + b"\x08I saw the ghostly figure of Damp\x96\x01" + b"the gravekeeper sinking into\x01" + b"his grave. It looked like he was\x01" b"holding some kind of \x05\x41treasure\x05\x40!\x02" ), None), 0x0422: ("They say that once \x05\x41Morpha's Curse\x05\x40\x01is lifted, striking \x05\x42this stone\x05\x40 can\x01shift the tides of \x05\x44Lake Hylia\x05\x40.\x02", 0x23), @@ -517,23 +523,23 @@ # convert byte array to an integer -def bytes_to_int(bytes, signed=False): - return int.from_bytes(bytes, byteorder='big', signed=signed) +def bytes_to_int(data: bytes, signed: bool = False) -> int: + return int.from_bytes(data, byteorder='big', signed=signed) # convert int to an array of bytes of the given width -def int_to_bytes(num, width, signed=False): +def int_to_bytes(num: int, width: int, signed: bool = False) -> bytes: return int.to_bytes(num, width, byteorder='big', signed=signed) -def display_code_list(codes): +def display_code_list(codes: 'List[TextCode]') -> str: message = "" for code in codes: message += str(code) return message -def encode_text_string(text): +def encode_text_string(text: str) -> List[int]: result = [] it = iter(text) for ch in it: @@ -554,26 +560,26 @@ def encode_text_string(text): return result -def parse_control_codes(text): +def parse_control_codes(text: Union[List[int], bytearray, str]) -> 'List[TextCode]': if isinstance(text, list): - bytes = text + text_bytes = text elif isinstance(text, bytearray): - bytes = list(text) + text_bytes = list(text) else: - bytes = encode_text_string(text) + text_bytes = encode_text_string(text) text_codes = [] index = 0 - while index < len(bytes): - next_char = bytes[index] + while index < len(text_bytes): + next_char = text_bytes[index] data = 0 index += 1 if next_char in CONTROL_CODES: extra_bytes = CONTROL_CODES[next_char][1] if extra_bytes > 0: - data = bytes_to_int(bytes[index : index + extra_bytes]) + data = bytes_to_int(text_bytes[index: index + extra_bytes]) index += extra_bytes - text_code = Text_Code(next_char, data) + text_code = TextCode(next_char, data) text_codes.append(text_code) if text_code.code == 0x02: # message end code break @@ -582,8 +588,16 @@ def parse_control_codes(text): # holds a single character or control code of a string -class Text_Code: - def display(self): +class TextCode: + def __init__(self, code: int, data: int) -> None: + self.code: int = code + if code in CONTROL_CODES: + self.type = CONTROL_CODES[code][0] + else: + self.type = 'character' + self.data: int = data + + def display(self) -> str: if self.code in CONTROL_CODES: return CONTROL_CODES[self.code][2](self.data) elif self.code in SPECIAL_CHARACTERS: @@ -593,23 +607,23 @@ def display(self): else: return chr(self.code) - def get_python_string(self): + def get_python_string(self) -> str: if self.code in CONTROL_CODES: ret = '' - subdata = self.data + data = self.data for _ in range(0, CONTROL_CODES[self.code][1]): - ret = ('\\x%02X' % (subdata & 0xFF)) + ret - subdata = subdata >> 8 - ret = '\\x%02X' % self.code + ret + ret = f'\\x{data & 0xFF:02X}{ret}' + data = data >> 8 + ret = f'\\x{self.code:02X}{ret}' return ret elif self.code in SPECIAL_CHARACTERS: - return '\\x%02X' % self.code + return f'\\x{self.code:02X}' elif self.code >= 0x7F: return '?' else: return chr(self.code) - def get_string(self): + def get_string(self) -> str: if self.code in CONTROL_CODES: ret = '' subdata = self.data @@ -622,15 +636,14 @@ def get_string(self): # raise ValueError(repr(REVERSE_MAP)) return REVERSE_MAP[self.code] - # writes the code to the given offset, and returns the offset of the next byte - def size(self): + def size(self) -> int: size = 1 if self.code in CONTROL_CODES: size += CONTROL_CODES[self.code][1] return size # writes the code to the given offset, and returns the offset of the next byte - def write(self, rom, text_start, offset): + def write(self, rom: "Rom", text_start: int, offset: int) -> int: rom.write_byte(text_start + offset, self.code) extra_bytes = 0 @@ -641,20 +654,42 @@ def write(self, rom, text_start, offset): return offset + 1 + extra_bytes - def __init__(self, code, data): - self.code = code - if code in CONTROL_CODES: - self.type = CONTROL_CODES[code][0] - else: - self.type = 'character' - self.data = data - __str__ = __repr__ = display # holds a single message, and all its data class Message: - def display(self): + def __init__(self, raw_text: Union[List[int], bytearray, str], index: int, id: int, opts: int, offset: int, length: int) -> None: + if isinstance(raw_text, str): + raw_text = bytearray(raw_text, encoding='utf-8') + elif not isinstance(raw_text, bytearray): + raw_text = bytearray(raw_text) + + self.raw_text: bytearray = raw_text + + self.index: int = index + self.id: int = id + self.opts: int = opts # Textbox type and y position + self.box_type: int = (self.opts & 0xF0) >> 4 + self.position: int = (self.opts & 0x0F) + self.offset: int = offset + self.length: int = length + + self.has_goto: bool = False + self.has_keep_open: bool = False + self.has_event: bool = False + self.has_fade: bool = False + self.has_ocarina: bool = False + self.has_two_choice: bool = False + self.has_three_choice: bool = False + self.ending: Optional[TextCode] = None + + self.text_codes: List[TextCode] = [] + self.text: str = '' + self.unpadded_length: int = 0 + self.parse_text() + + def display(self) -> str: meta_data = [ "#" + str(self.index), "ID: 0x" + "{:04x}".format(self.id), @@ -665,14 +700,14 @@ def display(self): ] return ', '.join(meta_data) + '\n' + self.text - def get_python_string(self): + def get_python_string(self) -> str: ret = '' for code in self.text_codes: ret = ret + code.get_python_string() return ret # check if this is an unused message that just contains it's own id as text - def is_id_message(self): + def is_id_message(self) -> bool: if self.unpadded_length != 5 or self.id == 0xFFFC: return False for i in range(4): @@ -685,7 +720,7 @@ def is_id_message(self): return False return True - def parse_text(self): + def parse_text(self) -> None: self.text_codes = parse_control_codes(self.raw_text) index = 0 @@ -715,11 +750,11 @@ def parse_text(self): self.text = display_code_list(self.text_codes) self.unpadded_length = index - def is_basic(self): + def is_basic(self) -> bool: return not (self.has_goto or self.has_keep_open or self.has_event or self.has_fade or self.has_ocarina or self.has_two_choice or self.has_three_choice) # computes the size of a message, including padding - def size(self): + def size(self) -> int: size = 0 for code in self.text_codes: @@ -730,14 +765,15 @@ def size(self): return size # applies whatever transformations we want to the dialogs - def transform(self, replace_ending=False, ending=None, always_allow_skip=True, speed_up_text=True): + def transform(self, replace_ending: bool = False, ending: Optional[TextCode] = None, + always_allow_skip: bool = True, speed_up_text: bool = True) -> None: ending_codes = [0x02, 0x07, 0x0A, 0x0B, 0x0E, 0x10] box_breaks = [0x04, 0x0C] slows_text = [0x08, 0x09, 0x14] slow_icons = [0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x04, 0x02] text_codes = [] - instant_text_code = Text_Code(0x08, 0) + instant_text_code = TextCode(0x08, 0) # # speed the text if speed_up_text: @@ -764,7 +800,7 @@ def transform(self, replace_ending=False, ending=None, always_allow_skip=True, s text_codes.append(code) text_codes.append(instant_text_code) # allow instant else: - text_codes.append(Text_Code(0x04, 0)) # un-delayed break + text_codes.append(TextCode(0x04, 0)) # un-delayed break text_codes.append(instant_text_code) # allow instant elif speed_up_text and code.code == 0x13 and code.data in slow_icons: text_codes.append(code) @@ -776,15 +812,15 @@ def transform(self, replace_ending=False, ending=None, always_allow_skip=True, s if replace_ending: if ending: if speed_up_text and ending.code == 0x10: # ocarina - text_codes.append(Text_Code(0x09, 0)) # disallow instant text + text_codes.append(TextCode(0x09, 0)) # disallow instant text text_codes.append(ending) # write special ending - text_codes.append(Text_Code(0x02, 0)) # write end code + text_codes.append(TextCode(0x02, 0)) # write end code self.text_codes = text_codes # writes a Message back into the rom, using the given index and offset to update the table # returns the offset of the next message - def write(self, rom, index, text_start, offset, bank): + def write(self, rom: "Rom", index: int, text_start: int, offset: int, bank: int) -> int: # construct the table entry id_bytes = int_to_bytes(self.id, 2) offset_bytes = int_to_bytes(offset, 3) @@ -797,36 +833,13 @@ def write(self, rom, index, text_start, offset, bank): offset = code.write(rom, text_start, offset) while offset % 4 > 0: - offset = Text_Code(0x00, 0).write(rom, text_start, offset) # pad to 4 byte align + offset = TextCode(0x00, 0).write(rom, text_start, offset) # pad to 4 byte align return offset - - def __init__(self, raw_text, index, id, opts, offset, length): - self.raw_text = raw_text - - self.index = index - self.id = id - self.opts = opts # Textbox type and y position - self.box_type = (self.opts & 0xF0) >> 4 - self.position = (self.opts & 0x0F) - self.offset = offset - self.length = length - - self.has_goto = False - self.has_keep_open = False - self.has_event = False - self.has_fade = False - self.has_ocarina = False - self.has_two_choice = False - self.has_three_choice = False - self.ending = None - - self.parse_text() - # read a single message from rom @classmethod - def from_rom(cls, rom, index, eng=True): + def from_rom(cls, rom: "Rom", index: int, eng: bool = True) -> 'Message': if eng: table_start = ENG_TABLE_START text_start = ENG_TEXT_START @@ -847,21 +860,21 @@ def from_rom(cls, rom, index, eng=True): return cls(raw_text, index, id, opts, offset, length) @classmethod - def from_string(cls, text, id=0, opts=0x00): + def from_string(cls, text: str, id: int = 0, opts: int = 0x00) -> 'Message': bytes = text + "\x02" return cls(bytes, 0, id, opts, 0, len(bytes) + 1) @classmethod - def from_bytearray(cls, bytearray, id=0, opts=0x00): - bytes = list(bytearray) + [0x02] - + def from_bytearray(cls, text: bytearray, id: int = 0, opts: int = 0x00) -> 'Message': + bytes = list(text) + [0x02] return cls(bytes, 0, id, opts, 0, len(bytes) + 1) __str__ = __repr__ = display + # wrapper for updating the text of a message, given its message id # if the id does not exist in the list, then it will add it -def update_message_by_id(messages, id, text, opts=None): +def update_message_by_id(messages: List[Message], id: int, text: Union[bytearray, str], opts: Optional[int] = None) -> None: # get the message index index = next( (m.index for m in messages if m.id == id), -1) # update if it was found @@ -870,8 +883,9 @@ def update_message_by_id(messages, id, text, opts=None): else: add_message(messages, text, id, opts) + # Gets the message by its ID. Returns None if the index does not exist -def get_message_by_id(messages, id): +def get_message_by_id(messages: List[Message], id: int) -> Optional[Message]: # get the message index index = next( (m.index for m in messages if m.id == id), -1) if index >= 0: @@ -879,8 +893,9 @@ def get_message_by_id(messages, id): else: return None + # wrapper for updating the text of a message, given its index in the list -def update_message_by_index(messages, index, text, opts=None): +def update_message_by_index(messages: List[Message], index: int, text: Union[bytearray, str], opts: Optional[int] = None) -> None: if opts is None: opts = messages[index].opts @@ -890,95 +905,101 @@ def update_message_by_index(messages, index, text, opts=None): messages[index] = Message.from_string(text, messages[index].id, opts) messages[index].index = index + # wrapper for adding a string message to a list of messages -def add_message(messages, text, id=0, opts=0x00): +def add_message(messages: List[Message], text: Union[bytearray, str], id: int = 0, opts: int = 0x00) -> None: if isinstance(text, bytearray): - messages.append( Message.from_bytearray(text, id, opts) ) + messages.append(Message.from_bytearray(text, id, opts)) else: - messages.append( Message.from_string(text, id, opts) ) + messages.append(Message.from_string(text, id, opts)) messages[-1].index = len(messages) - 1 -# holds a row in the shop item table (which contains pointers to the description and purchase messages) -class Shop_Item(): - - def display(self): - meta_data = ["#" + str(self.index), - "Item: 0x" + "{:04x}".format(self.get_item_id), - "Price: " + str(self.price), - "Amount: " + str(self.pieces), - "Object: 0x" + "{:04x}".format(self.object), - "Model: 0x" + "{:04x}".format(self.model), - "Description: 0x" + "{:04x}".format(self.description_message), - "Purchase: 0x" + "{:04x}".format(self.purchase_message),] - func_data = [ - "func1: 0x" + "{:08x}".format(self.func1), - "func2: 0x" + "{:08x}".format(self.func2), - "func3: 0x" + "{:08x}".format(self.func3), - "func4: 0x" + "{:08x}".format(self.func4),] - return ', '.join(meta_data) + '\n' + ', '.join(func_data) - - # write the shop item back - def write(self, rom, shop_table_address, index): +# holds a row in the shop item table (which contains pointers to the description and purchase messages) +class ShopItem: + # read a single message + def __init__(self, rom: "Rom", shop_table_address: int, index: int) -> None: entry_offset = shop_table_address + 0x20 * index + entry = rom.read_bytes(entry_offset, 0x20) - bytes = [] - bytes += int_to_bytes(self.object, 2) - bytes += int_to_bytes(self.model, 2) - bytes += int_to_bytes(self.func1, 4) - bytes += int_to_bytes(self.price, 2, signed=True) - bytes += int_to_bytes(self.pieces, 2) - bytes += int_to_bytes(self.description_message, 2) - bytes += int_to_bytes(self.purchase_message, 2) - bytes += [0x00, 0x00] - bytes += int_to_bytes(self.get_item_id, 2) - bytes += int_to_bytes(self.func2, 4) - bytes += int_to_bytes(self.func3, 4) - bytes += int_to_bytes(self.func4, 4) - - rom.write_bytes(entry_offset, bytes) + self.index: int = index + self.object: int = bytes_to_int(entry[0x00:0x02]) + self.model: int = bytes_to_int(entry[0x02:0x04]) + self.func1: int = bytes_to_int(entry[0x04:0x08]) + self.price: int = bytes_to_int(entry[0x08:0x0A]) + self.pieces: int = bytes_to_int(entry[0x0A:0x0C]) + self.description_message: int = bytes_to_int(entry[0x0C:0x0E]) + self.purchase_message: int = bytes_to_int(entry[0x0E:0x10]) + # 0x10-0x11 is always 0000 padded apparently + self.get_item_id: int = bytes_to_int(entry[0x12:0x14]) + self.func2: int = bytes_to_int(entry[0x14:0x18]) + self.func3: int = bytes_to_int(entry[0x18:0x1C]) + self.func4: int = bytes_to_int(entry[0x1C:0x20]) - # read a single message - def __init__(self, rom, shop_table_address, index): + def display(self) -> str: + meta_data = [ + "#" + str(self.index), + "Item: 0x" + "{:04x}".format(self.get_item_id), + "Price: " + str(self.price), + "Amount: " + str(self.pieces), + "Object: 0x" + "{:04x}".format(self.object), + "Model: 0x" + "{:04x}".format(self.model), + "Description: 0x" + "{:04x}".format(self.description_message), + "Purchase: 0x" + "{:04x}".format(self.purchase_message), + ] + func_data = [ + "func1: 0x" + "{:08x}".format(self.func1), + "func2: 0x" + "{:08x}".format(self.func2), + "func3: 0x" + "{:08x}".format(self.func3), + "func4: 0x" + "{:08x}".format(self.func4), + ] + return ', '.join(meta_data) + '\n' + ', '.join(func_data) + # write the shop item back + def write(self, rom: "Rom", shop_table_address: int, index: int) -> None: entry_offset = shop_table_address + 0x20 * index - entry = rom.read_bytes(entry_offset, 0x20) - self.index = index - self.object = bytes_to_int(entry[0x00:0x02]) - self.model = bytes_to_int(entry[0x02:0x04]) - self.func1 = bytes_to_int(entry[0x04:0x08]) - self.price = bytes_to_int(entry[0x08:0x0A]) - self.pieces = bytes_to_int(entry[0x0A:0x0C]) - self.description_message = bytes_to_int(entry[0x0C:0x0E]) - self.purchase_message = bytes_to_int(entry[0x0E:0x10]) - # 0x10-0x11 is always 0000 padded apparently - self.get_item_id = bytes_to_int(entry[0x12:0x14]) - self.func2 = bytes_to_int(entry[0x14:0x18]) - self.func3 = bytes_to_int(entry[0x18:0x1C]) - self.func4 = bytes_to_int(entry[0x1C:0x20]) + data = [] + data += int_to_bytes(self.object, 2) + data += int_to_bytes(self.model, 2) + data += int_to_bytes(self.func1, 4) + data += int_to_bytes(self.price, 2, signed=True) + data += int_to_bytes(self.pieces, 2) + data += int_to_bytes(self.description_message, 2) + data += int_to_bytes(self.purchase_message, 2) + data += [0x00, 0x00] + data += int_to_bytes(self.get_item_id, 2) + data += int_to_bytes(self.func2, 4) + data += int_to_bytes(self.func3, 4) + data += int_to_bytes(self.func4, 4) + + rom.write_bytes(entry_offset, data) __str__ = __repr__ = display + # reads each of the shop items -def read_shop_items(rom, shop_table_address): +def read_shop_items(rom: "Rom", shop_table_address: int) -> List[ShopItem]: shop_items = [] for index in range(0, 100): - shop_items.append( Shop_Item(rom, shop_table_address, index) ) + shop_items.append(ShopItem(rom, shop_table_address, index)) return shop_items + # writes each of the shop item back into rom -def write_shop_items(rom, shop_table_address, shop_items): +def write_shop_items(rom: "Rom", shop_table_address: int, shop_items: Iterable[ShopItem]) -> None: for s in shop_items: s.write(rom, shop_table_address, s.index) + # these are unused shop items, and contain text ids that are used elsewhere, and should not be moved -SHOP_ITEM_EXCEPTIONS = [0x0A, 0x0B, 0x11, 0x12, 0x13, 0x14, 0x29] +SHOP_ITEM_EXCEPTIONS: List[int] = [0x0A, 0x0B, 0x11, 0x12, 0x13, 0x14, 0x29] + # returns a set of all message ids used for shop items -def get_shop_message_id_set(shop_items): +def get_shop_message_id_set(shop_items: Iterable[ShopItem]) -> Set[int]: ids = set() for shop in shop_items: if shop.index not in SHOP_ITEM_EXCEPTIONS: @@ -986,18 +1007,21 @@ def get_shop_message_id_set(shop_items): ids.add(shop.purchase_message) return ids + # remove all messages that easy to tell are unused to create space in the message index table -def remove_unused_messages(messages): +def remove_unused_messages(messages: List[Message]) -> None: messages[:] = [m for m in messages if not m.is_id_message()] for index, m in enumerate(messages): m.index = index + # takes all messages used for shop items, and moves messages from the 00xx range into the unused 80xx range -def move_shop_item_messages(messages, shop_items): +def move_shop_item_messages(messages: List[Message], shop_items: Iterable[ShopItem]) -> None: # checks if a message id is in the item message range - def is_in_item_range(id): + def is_in_item_range(id: int) -> bool: bytes = int_to_bytes(id, 2) return bytes[0] == 0x00 + # get the ids we want to move ids = set( id for id in get_shop_message_id_set(shop_items) if is_in_item_range(id) ) # update them in the message list @@ -1016,7 +1040,8 @@ def is_in_item_range(id): if is_in_item_range(shop.purchase_message): shop.purchase_message |= 0x8000 -def make_player_message(text): + +def make_player_message(text: str) -> str: player_text = '\x05\x42\x0F\x05\x40' pronoun_mapping = { "You have ": player_text + " ", @@ -1064,7 +1089,7 @@ def make_player_message(text): # reduce item message sizes and add new item messages # make sure to call this AFTER move_shop_item_messages() -def update_item_messages(messages, world): +def update_item_messages(messages: List[Message], world: "World") -> None: new_item_messages = ITEM_MESSAGES + KEYSANITY_MESSAGES check_message_dupes(new_item_messages) for id, text in new_item_messages: @@ -1087,12 +1112,12 @@ def check_message_dupes(new_item_messages): raise Exception("Duplicate MessageID found: " + hex(message_id1) + ", " + message1 + ", " + message2) # run all keysanity related patching to add messages for dungeon specific items -def add_item_messages(messages, shop_items, world): +def add_item_messages(messages: List[Message], shop_items: Iterable[ShopItem], world: "World") -> None: move_shop_item_messages(messages, shop_items) update_item_messages(messages, world) # reads each of the game's messages into a list of Message objects -def read_messages(rom): +def read_messages(rom: "Rom") -> List[Message]: table_offset = ENG_TABLE_START index = 0 messages = [] @@ -1115,11 +1140,12 @@ def read_messages(rom): messages.append(read_fffc_message(rom)) return messages + # The JP text table is the only source for ID 0xFFFC, which is used by the # title and file select screens. Preserve this table entry and text data when # overwriting the JP data. The regular read_messages function only reads English # data. -def read_fffc_message(rom): +def read_fffc_message(rom: "Rom") -> Message: table_offset = JPN_TABLE_START index = 0 while True: @@ -1135,11 +1161,12 @@ def read_fffc_message(rom): return message -# write the messages back -def repack_messages(rom, messages, permutation=None, always_allow_skip=True, speed_up_text=True): - rom.update_dmadata_record(ENG_TEXT_START, ENG_TEXT_START, ENG_TEXT_START + ENG_TEXT_SIZE_LIMIT) - rom.update_dmadata_record(JPN_TEXT_START, JPN_TEXT_START, JPN_TEXT_START + JPN_TEXT_SIZE_LIMIT) +# write the messages back +def repack_messages(rom: "Rom", messages: List[Message], permutation: Optional[List[int]] = None, + always_allow_skip: bool = True, speed_up_text: bool = True) -> None: + rom.update_dmadata_record_by_key(ENG_TEXT_START, ENG_TEXT_START, ENG_TEXT_START + ENG_TEXT_SIZE_LIMIT) + rom.update_dmadata_record_by_key(JPN_TEXT_START, JPN_TEXT_START, JPN_TEXT_START + JPN_TEXT_SIZE_LIMIT) if permutation is None: permutation = range(len(messages)) @@ -1222,12 +1249,30 @@ def repack_messages(rom, messages, permutation=None, always_allow_skip=True, spe raise(TypeError("Message ID table is too large: 0x" + "{:x}".format(8 * (table_index + 1)) + " written / 0x" + "{:x}".format(EXTENDED_TABLE_SIZE) + " allowed.")) rom.write_bytes(entry_offset, [0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + # shuffles the messages in the game, making sure to keep various message types in their own group -def shuffle_messages(messages, except_hints=True, always_allow_skip=True): +def shuffle_messages(messages: List[Message], except_hints: bool = True) -> List[int]: + if not hasattr(shuffle_messages, "shop_item_messages"): + shuffle_messages.shop_item_messages = [] + if not hasattr(shuffle_messages, "scrubs_message_ids"): + shuffle_messages.scrubs_message_ids = [] + + hint_ids = ( + GOSSIP_STONE_MESSAGES + TEMPLE_HINTS_MESSAGES + + [data['id'] for data in misc_item_hint_table.values()] + + [data['id'] for data in misc_location_hint_table.values()] + + list(KEYSANITY_MESSAGES.keys()) + shuffle_messages.shop_item_messages + + shuffle_messages.scrubs_message_ids + + [0x5036, 0x70F5] # Chicken count and poe count respectively + ) + shuffle_exempt = [ + 0x208D, # "One more lap!" for Cow in House race. + 0xFFFC, # Character data from JP table used on title and file select screens + ] permutation = [i for i, _ in enumerate(messages)] - def is_exempt(m): + def is_exempt(m: Message) -> bool: hint_ids = ( GOSSIP_STONE_MESSAGES + TEMPLE_HINTS_MESSAGES + [data['id'] for data in misc_item_hint_table.values()] + @@ -1244,19 +1289,18 @@ def is_exempt(m): is_hint = (except_hints and m.id in hint_ids) is_error_message = (m.id == ERROR_MESSAGE) is_shuffle_exempt = (m.id in shuffle_exempt) - return (is_hint or is_error_message or m.is_id_message() or is_shuffle_exempt) - - have_goto = list( filter(lambda m: not is_exempt(m) and m.has_goto, messages) ) - have_keep_open = list( filter(lambda m: not is_exempt(m) and m.has_keep_open, messages) ) - have_event = list( filter(lambda m: not is_exempt(m) and m.has_event, messages) ) - have_fade = list( filter(lambda m: not is_exempt(m) and m.has_fade, messages) ) - have_ocarina = list( filter(lambda m: not is_exempt(m) and m.has_ocarina, messages) ) - have_two_choice = list( filter(lambda m: not is_exempt(m) and m.has_two_choice, messages) ) - have_three_choice = list( filter(lambda m: not is_exempt(m) and m.has_three_choice, messages) ) - basic_messages = list( filter(lambda m: not is_exempt(m) and m.is_basic(), messages) ) - - - def shuffle_group(group): + return is_hint or is_error_message or m.is_id_message() or is_shuffle_exempt + + have_goto = list(filter(lambda m: not is_exempt(m) and m.has_goto, messages)) + have_keep_open = list(filter(lambda m: not is_exempt(m) and m.has_keep_open, messages)) + have_event = list(filter(lambda m: not is_exempt(m) and m.has_event, messages)) + have_fade = list(filter(lambda m: not is_exempt(m) and m.has_fade, messages)) + have_ocarina = list(filter(lambda m: not is_exempt(m) and m.has_ocarina, messages)) + have_two_choice = list(filter(lambda m: not is_exempt(m) and m.has_two_choice, messages)) + have_three_choice = list(filter(lambda m: not is_exempt(m) and m.has_three_choice, messages)) + basic_messages = list(filter(lambda m: not is_exempt(m) and m.is_basic(), messages)) + + def shuffle_group(group: List[Message]) -> None: group_permutation = [i for i, _ in enumerate(group)] random.shuffle(group_permutation) @@ -1273,8 +1317,9 @@ def shuffle_group(group): return permutation + # Update warp song text boxes for ER -def update_warp_song_text(messages, world): +def update_warp_song_text(messages: List[Message], world: "World") -> None: from Hints import HintArea msg_list = { diff --git a/Models.py b/Models.py index 408871f8e..be75fd435 100644 --- a/Models.py +++ b/Models.py @@ -1,10 +1,17 @@ import os import random from enum import IntEnum +from typing import TYPE_CHECKING, List, Union, Dict, Tuple + from Utils import data_path +if TYPE_CHECKING: + from Cosmetics import CosmeticsLog + from Rom import Rom + from Settings import Settings + -def get_model_choices(age): +def get_model_choices(age: int) -> List[str]: names = ["Default"] path = data_path("Models/Adult") if age == 1: @@ -29,14 +36,13 @@ class ModelDefinitionError(ModelError): # Used for writer model pointers to the rom in place of the vanilla pointers class ModelPointerWriter: + def __init__(self, rom: "Rom") -> None: + self.rom: "Rom" = rom + self.offset: int = 0 + self.advance: int = 4 + self.base: int = CODE_START - def __init__(self, rom): - self.rom = rom - self.offset = 0 - self.advance = 4 - self.SetBase('Code') - - def SetBase(self, base): + def SetBase(self, base: str) -> None: if base == 'Code': self.base = CODE_START elif base == 'Player': @@ -54,30 +60,30 @@ def SetBase(self, base): elif base == 'RunningMan': self.base = RUNNING_MAN_START - def GoTo(self, dest): + def GoTo(self, dest: int) -> None: self.offset = dest - def SetAdvance(self, adv): + def SetAdvance(self, adv: int) -> None: self.advance = adv - def GetAddress(self): + def GetAddress(self) -> int: return self.base + self.offset - def WriteModelData(self, data): + def WriteModelData(self, data: int) -> None: self.rom.write_bytes(self.GetAddress(), data.to_bytes(4, 'big')) self.offset += self.advance - def WriteModelData16(self, data): + def WriteModelData16(self, data: int) -> None: self.rom.write_bytes(self.GetAddress(), data.to_bytes(2, 'big')) self.offset += 2 - def WriteModelDataHi(self, data): + def WriteModelDataHi(self, data: int) -> None: bytes = data.to_bytes(4, 'big') for i in range(2): self.rom.write_byte(self.GetAddress(), bytes[i]) self.offset += 1 - def WriteModelDataLo(self, data): + def WriteModelDataLo(self, data: int) -> None: bytes = data.to_bytes(4, 'big') for i in range(2, 4): self.rom.write_byte(self.GetAddress(), bytes[i]) @@ -86,7 +92,7 @@ def WriteModelDataLo(self, data): # Either return the starting index of the requested data (when start == 0) # or the offset of the element in the footer, if it exists (start > 0) -def scan(bytes, data, start=0): +def scan(bytes: bytearray, data: Union[bytearray, str], start: int = 0) -> int: databytes = data # If a string was passed, encode string as bytes if isinstance(data, str): @@ -174,20 +180,20 @@ def scan(bytes, data, start=0): # Follows pointers from the LUT until finding the actual DList, and returns the offset of the DList -def unwrap(zobj, address): +def unwrap(zobj: bytearray, address: int) -> int: # An entry in the LUT will look something like 0xDE 01 0000 06014050 # Only the last 3 bytes should be necessary. data = int.from_bytes(zobj[address+5:address+8], 'big') # If the data here points to another entry in the LUT, keep searching until # an address outside the table is found. - while LUT_START <= data and data <= LUT_END: + while LUT_START <= data <= LUT_END: address = data data = int.from_bytes(zobj[address+5:address+8], 'big') return address # Used to overwrite pointers in the displaylist with new ones -def WriteDLPointer(dl, index, data): +def WriteDLPointer(dl: List[int], index: int, data: int) -> None: bytes = data.to_bytes(4, 'big') for i in range(4): dl[index + i] = bytes[i] @@ -195,7 +201,8 @@ def WriteDLPointer(dl, index, data): # An extensive function which loads pieces from the vanilla Link model to add to the user-provided zobj # Based on https://github.com/hylian-modding/ML64-Z64Lib/blob/master/cores/Z64Lib/API/zzoptimize.ts function optimize() -def LoadVanilla(rom, missing, rebase, linkstart, linksize, pieces, skips): +def LoadVanilla(rom: "Rom", missing: List[str], rebase: int, linkstart: int, linksize: int, + pieces: 'Dict[str, Tuple[Offsets, int]]', skips: Dict[str, List[Tuple[int, int]]]) -> Tuple[List[int], Dict[str, int]]: # Get vanilla "zobj" of Link's model vanillaData = [] for i in range(linksize): @@ -381,19 +388,19 @@ def LoadVanilla(rom, missing, rebase, linkstart, linksize, pieces, skips): dlEntry = oldDL2New[lo & 0x00FFFFFF] WriteDLPointer(dl, i + 4, BASE_OFFSET + dlEntry + rebase) vanillaZobj.extend(dl) - # Pad to nearest multiple of 16 + # Pad to the nearest multiple of 16 while len(vanillaZobj) % 0x10 != 0: vanillaZobj.append(0x00) # Now find the relation of items to new offsets DLOffsets = {} for item in missing: DLOffsets[item] = oldDL2New[pieces[item][1]] - return (vanillaZobj, DLOffsets) + return vanillaZobj, DLOffsets # Finds the address of the model's hierarchy so we can write the hierarchy pointer # Based on https://github.com/hylian-modding/Z64Online/blob/master/src/Z64Online/common/cosmetics/UniversalAliasTable.ts function findHierarchy() -def FindHierarchy(zobj, agestr): +def FindHierarchy(zobj: bytearray, agestr: str) -> int: # Scan until we find a segmented pointer which is 0x0C or 0x10 more than # the preceeding data and loop until something that's not a segmented pointer is found # then return the position of the last segemented pointer. @@ -416,14 +423,15 @@ def FindHierarchy(zobj, agestr): raise ModelDefinitionError("No hierarchy found in " + agestr + " model- Did you check \"Link hierarchy format\" in zzconvert?") -TOLERANCE = 0x100 +TOLERANCE: int = 0x100 -def CheckDiff(limb, skeleton): + +def CheckDiff(limb: int, skeleton: int) -> bool: # The normal difference normalDiff = abs(limb - skeleton) # Underflow/overflow diff # For example, if limb is 0xFFFF and skeleton is 0x0001, then they are technically only 2 apart - # So subtract 0xFFFF from the absolute value of the difference to get the true differene in this case + # So subtract 0xFFFF from the absolute value of the difference to get the true difference in this case # Necessary since values are signed, but not represented as signed here flowDiff = abs(normalDiff - 0xFFFF) # Take the minimum of the two differences @@ -431,7 +439,8 @@ def CheckDiff(limb, skeleton): # Return true if diff is too big return diff > TOLERANCE -def CorrectSkeleton(zobj, skeleton, agestr): + +def CorrectSkeleton(zobj: bytearray, skeleton: List[List[int]], agestr: str) -> bool: # Get the hierarchy pointer hierarchy = FindHierarchy(zobj, agestr) # Get what the hierarchy pointer points to (pointer to limb 0) @@ -474,7 +483,7 @@ def CorrectSkeleton(zobj, skeleton, agestr): # Loads model from file and processes it by adding vanilla pieces and setting up the LUT if necessary. -def LoadModel(rom, model, age): +def LoadModel(rom: "Rom", model: str, age: int) -> int: # age 0 = adult, 1 = child linkstart = ADULT_START linksize = ADULT_SIZE @@ -594,10 +603,10 @@ def LoadModel(rom, model, age): # Write in the adult model and repoint references to it -def patch_model_adult(rom, settings, log): +def patch_model_adult(rom: "Rom", settings: "Settings", log: "CosmeticsLog") -> None: # Get model filepath model = settings.model_adult_filepicker - # Default to filepicker if non empty + # Default to filepicker if non-empty if len(model) == 0: model = settings.model_adult if settings.model_adult == "Random": @@ -611,7 +620,7 @@ def patch_model_adult(rom, settings, log): # Load and process model dfAddress = LoadModel(rom, model, 0) - dfAddress = dfAddress | 0x06000000 # Add segment to DF address + dfAddress = dfAddress | 0x06000000 # Add segment to DF address # Write adult Link pointer data writer = ModelPointerWriter(rom) @@ -706,8 +715,8 @@ def patch_model_adult(rom, settings, log): writer.GoTo(0xE6B64) writer.SetAdvance(4) writer.WriteModelData(Offsets.ADULT_LINK_LUT_DL_BOW_STRING) - writer.WriteModelData(0x00000000) # string anchor x: 0.0 - writer.WriteModelData(0xC3B43333) # string anchor y: -360.4 + writer.WriteModelData(0x00000000) # string anchor x: 0.0 + writer.WriteModelData(0xC3B43333) # string anchor y: -360.4 writer.GoTo(0x69112) writer.WriteModelDataHi(Offsets.ADULT_LINK_LUT_DL_UPGRADE_LFOREARM) @@ -762,14 +771,14 @@ def patch_model_adult(rom, settings, log): writer.SetBase('Code') writer.GoTo(0xE65A0) - writer.WriteModelData(ADULT_HIERARCHY) # Hierarchy pointer + writer.WriteModelData(ADULT_HIERARCHY) # Hierarchy pointer # Write in the child model and repoint references to it -def patch_model_child(rom, settings, log): +def patch_model_child(rom: "Rom", settings: "Settings", log: "CosmeticsLog") -> None: # Get model filepath model = settings.model_child_filepicker - # Default to filepicker if non empty + # Default to filepicker if non-empty if len(model) == 0: model = settings.model_child if settings.model_child == "Random": @@ -783,7 +792,7 @@ def patch_model_child(rom, settings, log): # Load and process model dfAddress = LoadModel(rom, model, 1) - dfAddress = dfAddress | 0x06000000 # Add segment to DF address + dfAddress = dfAddress | 0x06000000 # Add segment to DF address # Write child Link pointer data writer = ModelPointerWriter(rom) @@ -871,8 +880,8 @@ def patch_model_child(rom, settings, log): writer.GoTo(0xE6B74) writer.SetAdvance(4) writer.WriteModelData(Offsets.CHILD_LINK_LUT_DL_SLINGSHOT_STRING) - writer.WriteModelData(0x44178000) # string anchor x: 606.0 - writer.WriteModelData(0x436C0000) # string anchor y: 236.0 + writer.WriteModelData(0x44178000) # string anchor x: 606.0 + writer.WriteModelData(0x436C0000) # string anchor y: 236.0 writer.GoTo(0x6922E) writer.WriteModelDataHi(Offsets.CHILD_LINK_LUT_DL_GORON_BRACELET) @@ -927,7 +936,7 @@ def patch_model_child(rom, settings, log): writer.SetBase('Code') writer.GoTo(0xE65A4) - writer.WriteModelData(CHILD_HIERARCHY) # Hierarchy pointer + writer.WriteModelData(CHILD_HIERARCHY) # Hierarchy pointer # LUT offsets for adult and child @@ -1080,10 +1089,10 @@ class Offsets(IntEnum): # Adult model pieces and their offsets, both in the LUT and in vanilla -AdultPieces = { +AdultPieces: Dict[str, Tuple[Offsets, int]] = { "Sheath": (Offsets.ADULT_LINK_LUT_DL_SWORD_SHEATH, 0x249D8), "FPS.Hookshot": (Offsets.ADULT_LINK_LUT_DL_FPS_HOOKSHOT, 0x2A738), - "Hilt.2": (Offsets.ADULT_LINK_LUT_DL_SWORD_HILT, 0x22060), # 0x21F78 + 0xE8, skips blade + "Hilt.2": (Offsets.ADULT_LINK_LUT_DL_SWORD_HILT, 0x22060), # 0x21F78 + 0xE8, skips blade "Hilt.3": (Offsets.ADULT_LINK_LUT_DL_LONGSWORD_HILT, 0x238C8), "Blade.2": (Offsets.ADULT_LINK_LUT_DL_SWORD_BLADE, 0x21F78), "Hookshot.Spike": (Offsets.ADULT_LINK_LUT_DL_HOOKSHOT_HOOK, 0x2B288), @@ -1104,9 +1113,9 @@ class Offsets(IntEnum): "Bow.String": (Offsets.ADULT_LINK_LUT_DL_BOW_STRING, 0x2B108), "Bow": (Offsets.ADULT_LINK_LUT_DL_BOW, 0x22DA8), "Blade.3.Break": (Offsets.ADULT_LINK_LUT_DL_BLADEBREAK, 0x2BA38), - "Blade.3": (Offsets.ADULT_LINK_LUT_DL_LONGSWORD_BLADE, 0x23A28), # 0x238C8 + 0x160, skips hilt + "Blade.3": (Offsets.ADULT_LINK_LUT_DL_LONGSWORD_BLADE, 0x23A28), # 0x238C8 + 0x160, skips hilt "Bottle": (Offsets.ADULT_LINK_LUT_DL_BOTTLE, 0x2AD58), - "Broken.Blade.3": (Offsets.ADULT_LINK_LUT_DL_LONGSWORD_BROKEN, 0x23EB0), # 0x23D50 + 0x160, skips hilt + "Broken.Blade.3": (Offsets.ADULT_LINK_LUT_DL_LONGSWORD_BROKEN, 0x23EB0), # 0x23D50 + 0x160, skips hilt "Foot.2.L": (Offsets.ADULT_LINK_LUT_DL_BOOT_LIRON, 0x25918), "Foot.2.R": (Offsets.ADULT_LINK_LUT_DL_BOOT_RIRON, 0x25A60), "Foot.3.L": (Offsets.ADULT_LINK_LUT_DL_BOOT_LHOVER, 0x25BA8), @@ -1114,7 +1123,7 @@ class Offsets(IntEnum): "Hammer": (Offsets.ADULT_LINK_LUT_DL_HAMMER, 0x233E0), "Hookshot.Aiming.Reticule": (Offsets.ADULT_LINK_LUT_DL_HOOKSHOT_AIM, 0x2CB48), "Hookshot.Chain": (Offsets.ADULT_LINK_LUT_DL_HOOKSHOT_CHAIN, 0x2AFF0), - "Ocarina.2": (Offsets.ADULT_LINK_LUT_DL_OCARINA_TIME, 0x248D8), # 0x24698 + 0x240, skips hand + "Ocarina.2": (Offsets.ADULT_LINK_LUT_DL_OCARINA_TIME, 0x248D8), # 0x24698 + 0x240, skips hand "Shield.2": (Offsets.ADULT_LINK_LUT_DL_SHIELD_HYLIAN, 0x22970), "Shield.3": (Offsets.ADULT_LINK_LUT_DL_SHIELD_MIRROR, 0x241C0), "Limb 1": (Offsets.ADULT_LINK_LUT_DL_WAIST, 0x35330), @@ -1140,7 +1149,7 @@ class Offsets(IntEnum): # Note: Some skips which can be implemented by skipping the beginning portion of the model # rather than specifying those indices here, simply have their offset in the table above # increased by whatever amount of starting indices would be skipped. -adultSkips = { +adultSkips: Dict[str, List[Tuple[int, int]]] = { "FPS.Hookshot": [(0x2F0, 0x618)], "Hilt.2": [(0x1E8, 0x430)], "Hilt.3": [(0x160, 0x480)], @@ -1150,50 +1159,50 @@ class Offsets(IntEnum): "Blade.3": [(0xB8, 0x320)], "Broken.Blade.3": [(0xA0, 0x308)], "Hammer": [(0x278, 0x4E0)], - "Shield.2": [(0x158, 0x2B8), (0x3A8, 0x430)], # Fist is in 2 pieces + "Shield.2": [(0x158, 0x2B8), (0x3A8, 0x430)], # Fist is in 2 pieces "Shield.3": [(0x1B8, 0x3E8)], } -adultSkeleton = [ - [0xFFC7, 0x0D31, 0x0000], # Limb 0 - [0x0000, 0x0000, 0x0000], # Limb 1 - [0x03B1, 0x0000, 0x0000], # Limb 2 - [0xFE71, 0x0045, 0xFF07], # Limb 3 - [0x051A, 0x0000, 0x0000], # Limb 4 - [0x04E8, 0x0005, 0x000B], # Limb 5 - [0xFE74, 0x004C, 0x0108], # Limb 6 - [0x0518, 0x0000, 0x0000], # Limb 7 - [0x04E9, 0x0006, 0x0003], # Limb 8 - [0x0000, 0x0015, 0xFFF9], # Limb 9 - [0x0570, 0xFEFD, 0x0000], # Limb 10 - [0xFED6, 0xFD44, 0x0000], # Limb 11 - [0x0000, 0x0000, 0x0000], # Limb 12 - [0x040F, 0xFF54, 0x02A8], # Limb 13 - [0x0397, 0x0000, 0x0000], # Limb 14 - [0x02F2, 0x0000, 0x0000], # Limb 15 - [0x040F, 0xFF53, 0xFD58], # Limb 16 - [0x0397, 0x0000, 0x0000], # Limb 17 - [0x02F2, 0x0000, 0x0000], # Limb 18 - [0x03D2, 0xFD4C, 0x0156], # Limb 19 - [0x0000, 0x0000, 0x0000], # Limb 20 +adultSkeleton: List[List[int]] = [ + [0xFFC7, 0x0D31, 0x0000], # Limb 0 + [0x0000, 0x0000, 0x0000], # Limb 1 + [0x03B1, 0x0000, 0x0000], # Limb 2 + [0xFE71, 0x0045, 0xFF07], # Limb 3 + [0x051A, 0x0000, 0x0000], # Limb 4 + [0x04E8, 0x0005, 0x000B], # Limb 5 + [0xFE74, 0x004C, 0x0108], # Limb 6 + [0x0518, 0x0000, 0x0000], # Limb 7 + [0x04E9, 0x0006, 0x0003], # Limb 8 + [0x0000, 0x0015, 0xFFF9], # Limb 9 + [0x0570, 0xFEFD, 0x0000], # Limb 10 + [0xFED6, 0xFD44, 0x0000], # Limb 11 + [0x0000, 0x0000, 0x0000], # Limb 12 + [0x040F, 0xFF54, 0x02A8], # Limb 13 + [0x0397, 0x0000, 0x0000], # Limb 14 + [0x02F2, 0x0000, 0x0000], # Limb 15 + [0x040F, 0xFF53, 0xFD58], # Limb 16 + [0x0397, 0x0000, 0x0000], # Limb 17 + [0x02F2, 0x0000, 0x0000], # Limb 18 + [0x03D2, 0xFD4C, 0x0156], # Limb 19 + [0x0000, 0x0000, 0x0000], # Limb 20 ] -ChildPieces = { +ChildPieces: Dict[str, Tuple[Offsets, int]] = { "Slingshot.String": (Offsets.CHILD_LINK_LUT_DL_SLINGSHOT_STRING, 0x221A8), "Sheath": (Offsets.CHILD_LINK_LUT_DL_SWORD_SHEATH, 0x15408), - "Blade.2": (Offsets.CHILD_LINK_LUT_DL_MASTER_SWORD, 0x15698), # 0x15540 + 0x158, skips fist - "Blade.1": (Offsets.CHILD_LINK_LUT_DL_SWORD_BLADE, 0x14110), # 0x13F38 + 0x1D8, skips fist and hilt + "Blade.2": (Offsets.CHILD_LINK_LUT_DL_MASTER_SWORD, 0x15698), # 0x15540 + 0x158, skips fist + "Blade.1": (Offsets.CHILD_LINK_LUT_DL_SWORD_BLADE, 0x14110), # 0x13F38 + 0x1D8, skips fist and hilt "Boomerang": (Offsets.CHILD_LINK_LUT_DL_BOOMERANG, 0x14660), "Fist.L": (Offsets.CHILD_LINK_LUT_DL_LFIST, 0x13E18), "Fist.R": (Offsets.CHILD_LINK_LUT_DL_RFIST, 0x14320), - "Hilt.1": (Offsets.CHILD_LINK_LUT_DL_SWORD_HILT, 0x14048), # 0x13F38 + 0x110, skips fist + "Hilt.1": (Offsets.CHILD_LINK_LUT_DL_SWORD_HILT, 0x14048), # 0x13F38 + 0x110, skips fist "Shield.1": (Offsets.CHILD_LINK_LUT_DL_SHIELD_DEKU, 0x14440), - "Slingshot": (Offsets.CHILD_LINK_LUT_DL_SLINGSHOT, 0x15F08), # 0x15DF0 + 0x118, skips fist + "Slingshot": (Offsets.CHILD_LINK_LUT_DL_SLINGSHOT, 0x15F08), # 0x15DF0 + 0x118, skips fist "Ocarina.1": (Offsets.CHILD_LINK_LUT_DL_OCARINA_FAIRY, 0x15BA8), "Bottle": (Offsets.CHILD_LINK_LUT_DL_BOTTLE, 0x18478), - "Ocarina.2": (Offsets.CHILD_LINK_LUT_DL_OCARINA_TIME, 0x15AB8), # 0x15958 + 0x160, skips hand - "Bottle.Hand.L": (Offsets.CHILD_LINK_LUT_DL_LHAND_BOTTLE, 0x18478), # Just the bottle, couldn't find one with hand and bottle + "Ocarina.2": (Offsets.CHILD_LINK_LUT_DL_OCARINA_TIME, 0x15AB8), # 0x15958 + 0x160, skips hand + "Bottle.Hand.L": (Offsets.CHILD_LINK_LUT_DL_LHAND_BOTTLE, 0x18478), # Just the bottle, couldn't find one with hand and bottle "GoronBracelet": (Offsets.CHILD_LINK_LUT_DL_GORON_BRACELET, 0x16118), "Mask.Bunny": (Offsets.CHILD_LINK_LUT_DL_MASK_BUNNY, 0x2CA38), "Mask.Skull": (Offsets.CHILD_LINK_LUT_DL_MASK_SKULL, 0x2AD40), @@ -1205,7 +1214,7 @@ class Offsets(IntEnum): "Mask.Zora": (Offsets.CHILD_LINK_LUT_DL_MASK_ZORA, 0x2B580), "FPS.Forearm.R": (Offsets.CHILD_LINK_LUT_DL_FPS_RIGHT_ARM, 0x18048), "DekuStick": (Offsets.CHILD_LINK_LUT_DL_DEKU_STICK, 0x6CC0), - "Shield.2": (Offsets.CHILD_LINK_LUT_DL_SHIELD_HYLIAN_BACK, 0x14C30), # 0x14B40 + 0xF0, skips sheath + "Shield.2": (Offsets.CHILD_LINK_LUT_DL_SHIELD_HYLIAN_BACK, 0x14C30), # 0x14B40 + 0xF0, skips sheath "Limb 1": (Offsets.CHILD_LINK_LUT_DL_WAIST, 0x202A8), "Limb 3": (Offsets.CHILD_LINK_LUT_DL_RTHIGH, 0x204F0), "Limb 4": (Offsets.CHILD_LINK_LUT_DL_RSHIN, 0x206E8), @@ -1226,35 +1235,35 @@ class Offsets(IntEnum): } -childSkips = { +childSkips: Dict[str, List[Tuple[int, int]]] = { "Boomerang": [(0x140, 0x240)], "Hilt.1": [(0xC8, 0x170)], "Shield.1": [(0x140, 0x218)], "Ocarina.1": [(0x110, 0x240)], } -childSkeleton = [ - [0x0000, 0x0948, 0x0000], # Limb 0 - [0xFFFC, 0xFF98, 0x0000], # Limb 1 - [0x025F, 0x0000, 0x0000], # Limb 2 - [0xFF54, 0x0032, 0xFF42], # Limb 3 - [0x02B9, 0x0000, 0x0000], # Limb 4 - [0x0339, 0x0005, 0x000B], # Limb 5 - [0xFF56, 0x0039, 0x00C0], # Limb 6 - [0x02B7, 0x0000, 0x0000], # Limb 7 - [0x0331, 0x0008, 0x0004], # Limb 8 - [0x0000, 0xFF99, 0xFFF9], # Limb 9 - [0x03E4, 0xFF37, 0xFFFF], # Limb 10 - [0xFE93, 0xFD62, 0x0000], # Limb 11 - [0x0000, 0x0000, 0x0000], # Limb 12 - [0x02B8, 0xFF51, 0x01D2], # Limb 13 - [0x0245, 0x0000, 0x0000], # Limb 14 - [0x0202, 0x0000, 0x0000], # Limb 15 - [0x02B8, 0xFF51, 0xFE2E], # Limb 16 - [0x0241, 0x0000, 0x0000], # Limb 17 - [0x020D, 0x0000, 0x0000], # Limb 18 - [0x0291, 0xFDF5, 0x016F], # Limb 19 - [0x0000, 0x0000, 0x0000], # Limb 20 +childSkeleton: List[List[int]] = [ + [0x0000, 0x0948, 0x0000], # Limb 0 + [0xFFFC, 0xFF98, 0x0000], # Limb 1 + [0x025F, 0x0000, 0x0000], # Limb 2 + [0xFF54, 0x0032, 0xFF42], # Limb 3 + [0x02B9, 0x0000, 0x0000], # Limb 4 + [0x0339, 0x0005, 0x000B], # Limb 5 + [0xFF56, 0x0039, 0x00C0], # Limb 6 + [0x02B7, 0x0000, 0x0000], # Limb 7 + [0x0331, 0x0008, 0x0004], # Limb 8 + [0x0000, 0xFF99, 0xFFF9], # Limb 9 + [0x03E4, 0xFF37, 0xFFFF], # Limb 10 + [0xFE93, 0xFD62, 0x0000], # Limb 11 + [0x0000, 0x0000, 0x0000], # Limb 12 + [0x02B8, 0xFF51, 0x01D2], # Limb 13 + [0x0245, 0x0000, 0x0000], # Limb 14 + [0x0202, 0x0000, 0x0000], # Limb 15 + [0x02B8, 0xFF51, 0xFE2E], # Limb 16 + [0x0241, 0x0000, 0x0000], # Limb 17 + [0x020D, 0x0000, 0x0000], # Limb 18 + [0x0291, 0xFDF5, 0x016F], # Limb 19 + [0x0000, 0x0000, 0x0000], # Limb 20 ] # Maps old pipeline limb names to new pipeline names @@ -1279,39 +1288,39 @@ class Offsets(IntEnum): } # Misc. constants -CODE_START = 0x00A87000 -PLAYER_START = 0x00BCDB70 -HOOK_START = 0x00CAD2C0 -SHIELD_START = 0x00DB1F40 -STICK_START = 0x00EAD0F0 -GRAVEYARD_KID_START = 0x00E60920 -GUARD_START = 0x00D1A690 -RUNNING_MAN_START = 0x00E50440 - -BASE_OFFSET = 0x06000000 -LUT_START = 0x00005000 -LUT_END = 0x00005800 -PRE_CONSTANT_START = 0X0000500C - -ADULT_START = 0x00F86000 -ADULT_SIZE = 0x00037800 -ADULT_HIERARCHY = 0x06005380 -ADULT_POST_START = 0x00005238 - -CHILD_START = 0x00FBE000 -CHILD_SIZE = 0x0002CF80 -CHILD_HIERARCHY = 0x060053A8 -CHILD_POST_START = 0x00005228 +CODE_START: int = 0x00A87000 +PLAYER_START: int = 0x00BCDB70 +HOOK_START: int = 0x00CAD2C0 +SHIELD_START: int = 0x00DB1F40 +STICK_START: int = 0x00EAD0F0 +GRAVEYARD_KID_START: int = 0x00E60920 +GUARD_START: int = 0x00D1A690 +RUNNING_MAN_START: int = 0x00E50440 + +BASE_OFFSET: int = 0x06000000 +LUT_START: int = 0x00005000 +LUT_END: int = 0x00005800 +PRE_CONSTANT_START: int = 0X0000500C + +ADULT_START: int = 0x00F86000 +ADULT_SIZE: int = 0x00037800 +ADULT_HIERARCHY: int = 0x06005380 +ADULT_POST_START: int = 0x00005238 + +CHILD_START: int = 0x00FBE000 +CHILD_SIZE: int = 0x0002CF80 +CHILD_HIERARCHY: int = 0x060053A8 +CHILD_POST_START: int = 0x00005228 # Parts of the rom to not overwrite when applying a patch file -restrictiveBytes = [ - (ADULT_START, ADULT_SIZE), # Ignore adult model - (CHILD_START, CHILD_SIZE), # Ignore child model +restrictiveBytes: List[Tuple[int, int]] = [ + (ADULT_START, ADULT_SIZE), # Ignore adult model + (CHILD_START, CHILD_SIZE), # Ignore child model # Adult model pointers - (CODE_START + 0xE6718, 75 * 8), # Writes 75 4-byte pointers with 4 bytes between - (CODE_START + 0xE6A4C, 4 * 4), # Writes 4 4-byte pointers - (CODE_START + 0xE6B28, 1 * 4), # Writes 1 4-byte pointer - (CODE_START + 0xE6B64, 3 * 4), # Writes 1 4-byte pointer and 2 4-byte values + (CODE_START + 0xE6718, 75 * 8), # Writes 75 4-byte pointers with 4 bytes between + (CODE_START + 0xE6A4C, 4 * 4), # Writes 4 4-byte pointers + (CODE_START + 0xE6B28, 1 * 4), # Writes 1 4-byte pointer + (CODE_START + 0xE6B64, 3 * 4), # Writes 1 4-byte pointer and 2 4-byte values # 2 byte hi/lo segments of pointers (CODE_START + 0x69112, 2), (CODE_START + 0x69116, 2), @@ -1333,28 +1342,28 @@ class Offsets(IntEnum): (HOOK_START + 0xA76, 2), (HOOK_START + 0xB66, 2), (HOOK_START + 0xB6A, 2), - (HOOK_START + 0xBA8, 1 * 2), # Writes 1 2-byte value - (STICK_START + 0x32C, 1 * 4), # Writes 1 4-byte pointer - (STICK_START + 0x328, 1 * 2), # Writes 1 2-byte value - (CODE_START + 0xE65A0, 1 * 4), # Writes 4-byte hierarchy pointer + (HOOK_START + 0xBA8, 1 * 2), # Writes 1 2-byte value + (STICK_START + 0x32C, 1 * 4), # Writes 1 4-byte pointer + (STICK_START + 0x328, 1 * 2), # Writes 1 2-byte value + (CODE_START + 0xE65A0, 1 * 4), # Writes 4-byte hierarchy pointer # Child model pointers - (CODE_START + 0xE671C, 75 * 8), # Writes 75 4-byte pointers with 4 bytes between - (CODE_START + 0xE6B2C, 1 * 8), # Writes 1 4-byte pointer with 4 bytes after - (CODE_START + 0xE6B74, 3 * 4), # Writes 1 4-byte pointer and 2 4-byte values + (CODE_START + 0xE671C, 75 * 8), # Writes 75 4-byte pointers with 4 bytes between + (CODE_START + 0xE6B2C, 1 * 8), # Writes 1 4-byte pointer with 4 bytes after + (CODE_START + 0xE6B74, 3 * 4), # Writes 1 4-byte pointer and 2 4-byte values (CODE_START + 0x6922E, 2), (CODE_START + 0x69232, 2), (CODE_START + 0x6A80E, 2), (CODE_START + 0x6A812, 2), - (STICK_START + 0x334, 1 * 4), # Writes 1 4-byte pointer - (STICK_START + 0x330, 1 * 2), # Writes 1 2-byte value + (STICK_START + 0x334, 1 * 4), # Writes 1 4-byte pointer + (STICK_START + 0x330, 1 * 2), # Writes 1 2-byte value (SHIELD_START + 0x7EE, 2), (SHIELD_START + 0x7F2, 2), - (PLAYER_START + 0x2253C, 8 * 4), # Writes 8 4-byte pointers + (PLAYER_START + 0x2253C, 8 * 4), # Writes 8 4-byte pointers (GRAVEYARD_KID_START + 0xE62, 2), (GRAVEYARD_KID_START + 0xE66, 2), (GUARD_START + 0x1EA2, 2), (GUARD_START + 0x1EA6, 2), (RUNNING_MAN_START + 0x1142, 2), (RUNNING_MAN_START + 0x1146, 2), - (CODE_START + 0xE65A4, 1 * 4), # Writes 4-byte hierarchy pointer + (CODE_START + 0xE65A4, 1 * 4), # Writes 4-byte hierarchy pointer ] diff --git a/Music.py b/Music.py index 4a2a21fda..e2038ff4c 100644 --- a/Music.py +++ b/Music.py @@ -1,13 +1,20 @@ -#Much of this is heavily inspired from and/or based on az64's / Deathbasket's MM randomizer - +# Much of this is heavily inspired from and/or based on az64's / Deathbasket's MM randomizer import itertools -import random import os +import random +from typing import TYPE_CHECKING, Tuple, List, Dict, Iterable, Optional, Union + +from Rom import Rom from Utils import compare_version, data_path +if TYPE_CHECKING: + from Cosmetics import CosmeticsLog + from Settings import Settings + +AUDIOSEQ_DMADATA_INDEX: int = 4 # Format: (Title, Sequence ID) -bgm_sequence_ids = ( +bgm_sequence_ids: Tuple[Tuple[str, int], ...] = ( ("Hyrule Field", 0x02), ("Dodongos Cavern", 0x18), ("Kakariko Adult", 0x19), @@ -54,10 +61,10 @@ ("Ganondorf Battle", 0x64), ("Ganon Battle", 0x65), ("Fire Boss", 0x6B), - ("Mini-game", 0x6C) + ("Mini-game", 0x6C), ) -fanfare_sequence_ids = ( +fanfare_sequence_ids: Tuple[Tuple[str, int], ...] = ( ("Game Over", 0x20), ("Boss Defeated", 0x21), ("Item Get", 0x22), @@ -72,10 +79,10 @@ ("Medallion Get", 0x43), ("Zelda Turns Around", 0x51), ("Master Sword", 0x53), - ("Door of Time", 0x59) + ("Door of Time", 0x59), ) -ocarina_sequence_ids = ( +ocarina_sequence_ids: Tuple[Tuple[str, int], ...] = ( ("Prelude of Light", 0x25), ("Bolero of Fire", 0x33), ("Minuet of Forest", 0x34), @@ -87,35 +94,38 @@ ("Zelda's Lullaby", 0x46), ("Sun's Song", 0x47), ("Song of Time", 0x48), - ("Song of Storms", 0x49) + ("Song of Storms", 0x49), ) # Represents the information associated with a sequence, aside from the sequence data itself -class Sequence(object): - def __init__(self, name, cosmetic_name, type = 0x0202, instrument_set = 0x03, replaces = -1, vanilla_id = -1): - self.name = name - self.cosmetic_name = cosmetic_name - self.replaces = replaces - self.vanilla_id = vanilla_id - self.type = type - self.instrument_set = instrument_set - - - def copy(self): +class Sequence: + def __init__(self, name: str, cosmetic_name: str, type: int = 0x0202, instrument_set: int = 0x03, + replaces: int = -1, vanilla_id: int = -1) -> None: + self.name: str = name + self.cosmetic_name: str = cosmetic_name + self.replaces: int = replaces + self.vanilla_id: int = vanilla_id + self.type: int = type + self.instrument_set: int = instrument_set + + def copy(self) -> 'Sequence': copy = Sequence(self.name, self.cosmetic_name, self.type, self.instrument_set, self.replaces, self.vanilla_id) return copy # Represents actual sequence data, along with metadata for the sequence data block -class SequenceData(object): - def __init__(self): - self.address = -1 - self.size = -1 - self.data = [] +class SequenceData: + def __init__(self) -> None: + self.address: int = -1 + self.size: int = -1 + self.data: bytearray = bytearray() -def process_sequences(rom, ids, seq_type='bgm', disabled_source_sequences=None, disabled_target_sequences=None, include_custom=True, sequences=None, target_sequences=None, groups=None): +def process_sequences(rom: Rom, ids: Iterable[Tuple[str, int]], seq_type: str = 'bgm', disabled_source_sequences: Optional[List[str]] = None, + disabled_target_sequences: Optional[Dict[str, Tuple[str, int]]] = None, include_custom: bool = True, + sequences: Optional[Dict[str, Sequence]] = None, target_sequences: Optional[Dict[str, Sequence]] = None, + groups: Optional[Dict[str, List[str]]] = None) -> Tuple[Dict[str, Sequence], Dict[str, Sequence], Dict[str, List[str]]]: disabled_source_sequences = [] if disabled_source_sequences is None else disabled_source_sequences disabled_target_sequences = {} if disabled_target_sequences is None else disabled_target_sequences sequences = {} if sequences is None else sequences @@ -198,12 +208,13 @@ def process_sequences(rom, ids, seq_type='bgm', disabled_source_sequences=None, return sequences, target_sequences, groups -def shuffle_music(log, source_sequences, target_sequences, music_mapping, type="music"): +def shuffle_music(log: "CosmeticsLog", source_sequences: Dict[str, Sequence], target_sequences: Dict[str, Sequence], + music_mapping: Dict[str, Union[str, List[str]]], seq_type: str = "music") -> List[Sequence]: sequences = [] favorites = log.src_dict.get('bgm_groups', {}).get('favorites', []).copy() if not source_sequences: - raise Exception(f"Not enough custom {type} ({len(source_sequences)}) to omit base Ocarina of Time sequences ({len(target_sequences)}).") + raise Exception(f"Not enough custom {seq_type} ({len(source_sequences)}) to omit base Ocarina of Time sequences ({len(target_sequences)}).") # Shuffle the sequences sequence_ids = [name for name in source_sequences.keys() if name not in music_mapping.values()] @@ -228,11 +239,13 @@ def shuffle_music(log, source_sequences, target_sequences, music_mapping, type=" log.bgm[target_sequence.cosmetic_name] = sequence.cosmetic_name if refill_needed: - log.errors.append(f"Not enough {type} available to not have repeats. There were {len(source_sequences)} sequences available to fill {len(target_sequences)} target tracks.") + log.errors.append(f"Not enough {seq_type} available to not have repeats. There were {len(source_sequences)} sequences available to fill {len(target_sequences)} target tracks.") return sequences -def rebuild_sequences(rom, sequences): +def rebuild_sequences(rom: Rom, sequences: List[Sequence]) -> None: + dma_entry = rom.dma[AUDIOSEQ_DMADATA_INDEX] + audioseq_start, audioseq_end, audioseq_size = dma_entry.as_tuple() replacement_dict = {seq.replaces: seq for seq in sequences} # List of sequences (actual sequence data objects) containing the vanilla sequence data old_sequences = [] @@ -248,7 +261,7 @@ def rebuild_sequences(rom, sequences): # If size > 0, read the sequence data from the rom into the sequence object if entry.size > 0: - entry.data = rom.read_bytes(entry.address + 0x029DE0, entry.size) + entry.data = rom.read_bytes(entry.address + audioseq_start, entry.size) else: seq = replacement_dict.get(i, None) if seq and 0 < entry.address < 128: @@ -309,15 +322,15 @@ def rebuild_sequences(rom, sequences): # Increment the current address by the size of the new sequence address += new_entry.size - new_address = 0x029DE0 + new_address = audioseq_start # Check if the new audio sequence is larger than the vanilla one - if address > 0x04F690: + if address > audioseq_size: # Zero out the old audio sequence - rom.buffer[0x029DE0 : 0x029DE0 + 0x04F690] = [0] * 0x04F690 + rom.buffer[audioseq_start:audioseq_end] = [0] * audioseq_size # Find free space and update dmatable - new_address = rom.free_space() - rom.update_dmadata_record(0x029DE0, new_address, new_address + address) + new_address = rom.dma.free_space() + dma_entry.update(new_address, new_address + address) # Write new audio sequence file rom.write_bytes(new_address, new_audio_sequence) @@ -338,7 +351,7 @@ def rebuild_sequences(rom, sequences): rom.write_byte(base, j.instrument_set) -def rebuild_pointers_table(rom, sequences): +def rebuild_pointers_table(rom: Rom, sequences: List[Sequence]) -> None: for sequence in [s for s in sequences if s.vanilla_id and s.replaces]: bgm_sequence = rom.original.read_bytes(0xB89AE0 + (sequence.vanilla_id * 0x10), 0x10) bgm_instrument = rom.original.read_int16(0xB89910 + 0xDD + (sequence.vanilla_id * 2)) @@ -349,7 +362,7 @@ def rebuild_pointers_table(rom, sequences): rom.write_int16(0xB89910 + 0xDD + (0x57 * 2), rom.read_int16(0xB89910 + 0xDD + (0x28 * 2))) -def randomize_music(rom, settings, log): +def randomize_music(rom: Rom, settings: "Settings", log: "CosmeticsLog") -> None: shuffled_sequences = shuffled_fanfare_sequences = [] sequences = fanfare_sequences = target_sequences = target_fanfare_sequences = bgm_groups = fanfare_groups = {} disabled_source_sequences = log.src_dict.get('bgm_groups', {}).get('exclude', []).copy() @@ -436,7 +449,7 @@ def randomize_music(rom, settings, log): groups_alias = ff_groups sequences_alias = fanfare_sequences else: - log.error.append(f'Target sequence "{target}" from plando file is invalid.') + log.errors.append(f'Target sequence "{target}" from plando file is invalid.') del music_mapping[target] continue @@ -508,7 +521,7 @@ def randomize_music(rom, settings, log): disable_music(rom, log, disabled_target_sequences.values()) -def disable_music(rom, log, ids): +def disable_music(rom: Rom, log: "CosmeticsLog", ids: Iterable[Tuple[str, int]]) -> None: # First track is no music blank_track = rom.read_bytes(0xB89AE0 + (0 * 0x10), 0x10) for bgm in ids: @@ -516,7 +529,7 @@ def disable_music(rom, log, ids): log.bgm[bgm[0]] = "None" -def restore_music(rom): +def restore_music(rom: Rom) -> None: # Restore all music from original for bgm in bgm_sequence_ids + fanfare_sequence_ids + ocarina_sequence_ids: bgm_sequence = rom.original.read_bytes(0xB89AE0 + (bgm[1] * 0x10), 0x10) @@ -529,15 +542,16 @@ def restore_music(rom): rom.write_int16(0xB89910 + 0xDD + (0x57 * 2), bgm_instrument) # Rebuild audioseq - orig_start, orig_end, orig_size = rom.original._get_dmadata_record(0x7470) + orig_start, orig_end, orig_size = rom.original.dma[AUDIOSEQ_DMADATA_INDEX].as_tuple() rom.write_bytes(orig_start, rom.original.read_bytes(orig_start, orig_size)) # If Audioseq was relocated - start, end, size = rom._get_dmadata_record(0x7470) - if start != 0x029DE0: + dma_entry = rom.dma[AUDIOSEQ_DMADATA_INDEX] + start, end, size = dma_entry.as_tuple() + if start != orig_start: # Zero out old audioseq rom.write_bytes(start, [0] * size) - rom.update_dmadata_record(start, orig_start, orig_end) + dma_entry.update(orig_start, orig_end, start) def chain_groups(group_list, sequences): @@ -549,4 +563,3 @@ def chain_groups(group_list, sequences): else: result.setdefault(n, []).append(ns for ns in s if ns in sequences) return result - diff --git a/N64Patch.py b/N64Patch.py index d1aa964f5..61a4c498c 100644 --- a/N64Patch.py +++ b/N64Patch.py @@ -1,17 +1,20 @@ -import struct -import random -import io -import array -import zlib import copy +import random import zipfile +import zlib +from typing import TYPE_CHECKING, Tuple, List, Optional + +from Rom import Rom from ntype import BigStream +if TYPE_CHECKING: + from Settings import Settings + # get the next XOR key. Uses some location in the source rom. # This will skip of 0s, since if we hit a block of 0s, the # patch data will be raw. -def key_next(rom, key_address, address_range): +def key_next(rom: Rom, key_address: int, address_range: Tuple[int, int]) -> Tuple[int, int]: key = 0 while key == 0: key_address += 1 @@ -24,7 +27,8 @@ def key_next(rom, key_address, address_range): # creates a XOR block for the patch. This might break it up into # multiple smaller blocks if there is a concern about the XOR key # or if it is too long. -def write_block(rom, xor_address, xor_range, block_start, data, patch_data): +def write_block(rom: Rom, xor_address: int, xor_range: Tuple[int, int], block_start: int, + data: List[int], patch_data: BigStream) -> int: new_data = [] key_offset = 0 continue_block = False @@ -60,7 +64,7 @@ def write_block(rom, xor_address, xor_range, block_start, data, patch_data): new_data += [b ^ key] # Break the block if it's too long - if (len(new_data) == 0xFFFF): + if len(new_data) == 0xFFFF: write_block_section(block_start, key_offset, new_data, patch_data, continue_block) new_data = [] key_offset = 0 @@ -72,10 +76,10 @@ def write_block(rom, xor_address, xor_range, block_start, data, patch_data): # This saves a sub-block for the XOR block. If it's the first part -# then it will include the address to write to. Otherwise it will +# then it will include the address to write to. Otherwise, it will # have a number of XOR keys to skip and then continue writing after # the previous block -def write_block_section(start, key_skip, in_data, patch_data, is_continue): +def write_block_section(start: int, key_skip: int, in_data: List[int], patch_data: BigStream, is_continue: bool) -> None: if not is_continue: patch_data.append_int32(start) else: @@ -88,11 +92,11 @@ def write_block_section(start, key_skip, in_data, patch_data, is_continue): # xor_range is the range the XOR key will read from. This range is not # too important, but I tried to choose from a section that didn't really # have big gaps of 0s which we want to avoid. -def create_patch_file(rom, file, xor_range=(0x00B8AD30, 0x00F029A0)): - dma_start, dma_end = rom.get_dma_table_range() +def create_patch_file(rom: Rom, file: str, xor_range: Tuple[int, int] = (0x00B8AD30, 0x00F029A0)) -> None: + dma_start, dma_end = rom.dma.dma_start, rom.dma.dma_end # add header - patch_data = BigStream([]) + patch_data = BigStream(bytearray()) patch_data.append_bytes(list(map(ord, 'ZPFv1'))) patch_data.append_int32(dma_start) patch_data.append_int32(xor_range[0]) @@ -120,7 +124,7 @@ def create_patch_file(rom, file, xor_range=(0x00B8AD30, 0x00F029A0)): # Simulate moving the files to know which addresses have changed if from_file >= 0: - old_dma_start, old_dma_end, old_size = rom.original.get_dmadata_record_by_key(from_file) + old_dma_start, old_dma_end, old_size = rom.original.dma.get_dmadata_record_by_key(from_file).as_tuple() copy_size = min(size, old_size) new_buffer[start:start+copy_size] = rom.original.read_bytes(from_file, copy_size) new_buffer[start+copy_size:start+size] = [0] * (size - copy_size) @@ -133,16 +137,16 @@ def create_patch_file(rom, file, xor_range=(0x00B8AD30, 0x00F029A0)): # filter down the addresses that will actually need to change. # Make sure to not include any of the DMA table addresses - changed_addresses = [address for address,value in rom.changed_address.items() \ - if (address >= dma_end or address < dma_start) and \ - (address in rom.force_patch or new_buffer[address] != value)] + changed_addresses = [address for address, value in rom.changed_address.items() + if (address >= dma_end or address < dma_start) and + (address in rom.force_patch or new_buffer[address] != value)] changed_addresses.sort() # Write the address changes. We'll store the data with XOR so that # the patch data won't be raw data from the patched rom. data = [] - block_start = None - BLOCK_HEADER_SIZE = 7 # this is used to break up gaps + block_start = block_end = None + BLOCK_HEADER_SIZE = 7 # this is used to break up gaps for address in changed_addresses: # if there's a block to write and there's a gap, write it if block_start: @@ -161,7 +165,7 @@ def create_patch_file(rom, file, xor_range=(0x00B8AD30, 0x00F029A0)): # save the new data data += rom.buffer[block_end+1:address+1] - # if there was any left over blocks, write them out + # if there was any leftover blocks, write them out if block_start: xor_address = write_block(rom, xor_address, xor_range, block_start, data, patch_data) @@ -175,7 +179,7 @@ def create_patch_file(rom, file, xor_range=(0x00B8AD30, 0x00F029A0)): # This will apply a patch file to a source rom to generate a patched rom. -def apply_patch_file(rom, settings, sub_file=None): +def apply_patch_file(rom: Rom, settings: "Settings", sub_file: Optional[str] = None) -> None: file = settings.patch_file # load the patch file and decompress @@ -189,7 +193,7 @@ def apply_patch_file(rom, settings, sub_file=None): else: with open(file, 'rb') as stream: patch_data = stream.read() - patch_data = BigStream(zlib.decompress(patch_data)) + patch_data = BigStream(bytearray(zlib.decompress(patch_data))) # make sure the header is correct if patch_data.read_bytes(length=4) != b'ZPFv': @@ -230,7 +234,7 @@ def apply_patch_file(rom, settings, sub_file=None): if from_file != 0xFFFFFFFF: # If a source file is listed, copy from there - old_dma_start, old_dma_end, old_size = rom.original.get_dmadata_record_by_key(from_file) + old_dma_start, old_dma_end, old_size = rom.original.dma.get_dmadata_record_by_key(from_file).as_tuple() copy_size = min(size, old_size) rom.write_bytes(start, rom.original.read_bytes(from_file, copy_size)) rom.buffer[start+copy_size:start+size] = [0] * (size - copy_size) @@ -273,4 +277,3 @@ def apply_patch_file(rom, settings, sub_file=None): else: rom.write_bytes(block_start, data) block_start = block_start+block_size - diff --git a/OcarinaSongs.py b/OcarinaSongs.py index 312430ca3..746e76e4f 100644 --- a/OcarinaSongs.py +++ b/OcarinaSongs.py @@ -1,20 +1,32 @@ import random from itertools import chain +from typing import TYPE_CHECKING, Dict, List, Tuple, Sequence, Callable, Optional, Union + from Fill import ShuffleError +from Utils import TypeAlias + +if TYPE_CHECKING: + from Rom import Rom + from World import World + +ActivationTransform: TypeAlias = Callable[[List[int]], List[int]] +PlaybackTransform: TypeAlias = Callable[[List[Dict[str, int]]], List[Dict[str, int]]] +Transform: TypeAlias = Union[ActivationTransform, PlaybackTransform] -PLAYBACK_START = 0xB781DC -PLAYBACK_LENGTH = 0xA0 -ACTIVATION_START = 0xB78E5C -ACTIVATION_LENGTH = 0x09 +PLAYBACK_START: int = 0xB781DC +PLAYBACK_LENGTH: int = 0xA0 +ACTIVATION_START: int = 0xB78E5C +ACTIVATION_LENGTH: int = 0x09 -FORMAT_ACTIVATION = { +FORMAT_ACTIVATION: Dict[int, str] = { 0: 'A', 1: 'v', 2: '>', 3: '<', 4: '^', } -READ_ACTIVATION = { # support both Av><^ and ADRLU + +READ_ACTIVATION: Dict[str, int] = { # support both Av><^ and ADRLU 'a': 0, 'v': 1, 'd': 1, @@ -25,16 +37,17 @@ '^': 4, 'u': 4, } -ACTIVATION_TO_PLAYBACK_NOTE = { - 0: 0x02, # A - 1: 0x05, # Down - 2: 0x09, # Right - 3: 0x0B, # Left - 4: 0x0E, # Up - 0xFF: 0xFF, # Rest + +ACTIVATION_TO_PLAYBACK_NOTE: Dict[int, int] = { + 0: 0x02, # A + 1: 0x05, # Down + 2: 0x09, # Right + 3: 0x0B, # Left + 4: 0x0E, # Up + 0xFF: 0xFF, # Rest } -DIFFICULTY_ORDER = [ +DIFFICULTY_ORDER: List[str] = [ 'Zeldas Lullaby', 'Sarias Song', 'Eponas Song', @@ -50,7 +63,7 @@ ] # Song name: (rom index, warp, vanilla activation), -SONG_TABLE = { +SONG_TABLE: Dict[str, Tuple[int, bool, str]] = { 'Zeldas Lullaby': ( 8, False, '<^><^>'), 'Eponas Song': ( 7, False, '^<>^<>'), 'Sarias Song': ( 6, False, 'v><'), @@ -68,83 +81,134 @@ # checks if one list is a sublist of the other (in either direction) # python is magic..... -def subsong(song1, song2): +def subsong(song1: 'Song', song2: 'Song') -> bool: # convert both lists to strings s1 = ''.join( map(chr, song1.activation)) s2 = ''.join( map(chr, song2.activation)) # check if either is a substring of the other return (s1 in s2) or (s2 in s1) + # give random durations and volumes to the notes -def fast_playback(activation): +def fast_playback(activation: List[int]) -> List[Dict[str, int]]: playback = [] for note_index, note in enumerate(activation): - playback.append( {'note': note, 'duration': 0x04, 'volume': 0x57} ) + playback.append({'note': note, 'duration': 0x04, 'volume': 0x57}) return playback + # give random durations and volumes to the notes -def random_playback(activation): +def random_playback(activation: List[int]) -> List[Dict[str, int]]: playback = [] for note_index, note in enumerate(activation): duration = random.randint(0x8, 0x20) # make final note longer on average - if( note_index + 1 >= len(activation) ): + if note_index + 1 >= len(activation): duration = random.randint(0x10, 0x30) volume = random.randint(0x40, 0x60) - playback.append( {'note': note, 'duration': duration, 'volume': volume} ) + playback.append({'note': note, 'duration': duration, 'volume': volume}) # randomly rest - if( random.random() < 0.1 ): + if random.random() < 0.1: duration = random.randint(0x8, 0x18) - playback.append( {'note': 0xFF, 'duration': duration, 'volume': 0} ) + playback.append({'note': 0xFF, 'duration': duration, 'volume': 0}) return playback + # gives random volume and duration to the notes of piece -def random_piece_playback(piece): +def random_piece_playback(piece: List[int]) -> List[Dict[str, int]]: playback = [] for note in piece: duration = random.randint(0x8, 0x20) volume = random.randint(0x40, 0x60) - playback.append( {'note': note, 'duration': duration, 'volume': volume} ) + playback.append({'note': note, 'duration': duration, 'volume': volume}) return playback + # takes the volume/duration of playback, and notes of piece, and creates a playback piece # assumes the lists are the same length -def copy_playback_info(playback, piece): - return [ { 'note': n, 'volume': p['volume'], 'duration': p['duration']} for (p, n) in zip(playback, piece) ] +def copy_playback_info(playback: List[Dict[str, int]], piece: List[int]): + return [{'note': n, 'volume': p['volume'], 'duration': p['duration']} for (p, n) in zip(playback, piece)] + -def identity(x): +def identity(x: List[Union[int, Dict[str, int]]]) -> List[Union[int, Dict[str, int]]]: return x -def random_piece(count, allowed=range(0,5)): + +def random_piece(count: int, allowed: Sequence[int] = range(0, 5)) -> List[int]: return random.choices(allowed, k=count) -def invert_piece(piece): + +def invert_piece(piece: List[int]) -> List[int]: return [4 - note for note in piece] -def reverse_piece(piece): + +def reverse_piece(piece: List[Union[int, Dict[str, int]]]) -> List[Union[int, Dict[str, int]]]: return piece[::-1] -def clamp(val, low, high): + +def clamp(val: int, low: int, high: int) -> int: return max(low, min(high, val)) -def transpose_piece(amount): - def transpose(piece): + +def transpose_piece(amount: int) -> ActivationTransform: + def transpose(piece: List[int]) -> List[int]: return [clamp(note + amount, 0, 4) for note in piece] return transpose -def compose(f, g): + +def compose(f: Transform, g: Transform) -> Transform: return lambda x: f(g(x)) -def add_transform_to_piece(piece, transform): + +def add_transform_to_piece(piece: List[int], transform: ActivationTransform) -> List[int]: return piece + transform(piece) -def repeat(piece): + +def repeat(piece: List[int]) -> List[int]: return 2 * piece -# a Song contains it's simple note data, as well as the data to be stored into the rom -class Song(): - def increase_duration_to(self, duration): +# a Song contains its simple note data, as well as the data to be stored into the rom +class Song: + # create a song, based on a given scheme + def __init__(self, rand_song: bool = True, piece_size: int = 3, extra_position: str = 'none', + starting_range: Sequence[int] = range(0, 5), activation_transform: ActivationTransform = identity, + playback_transform: PlaybackTransform = identity, *, activation: Optional[List[int]] = None, + playback_fast: bool = False) -> None: + self.length: int = 0 + self.activation: List[int] = [] + self.playback: List[Dict[str, int]] = [] + self.activation_data: List[int] = [] + self.playback_data: List[int] = [] + self.total_duration: int = 0 + + if activation: + self.length = len(activation) + self.activation = activation + elif rand_song: + self.length = random.randint(4, 8) + self.activation = random.choices(range(0, 5), k=self.length) + else: + if extra_position != 'none': + piece_size = 3 + piece = random_piece(piece_size, starting_range) + self.two_piece_playback(piece, extra_position, activation_transform, playback_transform) + + if playback_fast: + self.playback = fast_playback(self.activation) + self.break_repeated_notes(0x03) + else: + if not self.playback: + self.playback = random_playback(self.activation) + self.break_repeated_notes() + + self.format_activation_data() + self.format_playback_data() + + if activation: + self.increase_duration_to(45) + + def increase_duration_to(self, duration: int) -> None: if self.total_duration >= duration: return @@ -160,15 +224,15 @@ def increase_duration_to(self, duration): self.playback[-1]['duration'] = 0x7F duration_needed -= last_note_adds while duration_needed >= 0x7F: - self.playback.append( {'note': 0xFF, 'duration': 0x7F, 'volume': 0} ) + self.playback.append({'note': 0xFF, 'duration': 0x7F, 'volume': 0}) duration_needed -= 0x7F - self.playback.append( {'note': 0xFF, 'duration': duration_needed, 'volume': 0} ) + self.playback.append({'note': 0xFF, 'duration': duration_needed, 'volume': 0}) self.format_playback_data() - - def two_piece_playback(self, piece, extra_position='none', activation_transform=identity, playback_transform=identity): + def two_piece_playback(self, piece: List[int], extra_position: str = 'none', activation_transform: ActivationTransform = identity, + playback_transform: PlaybackTransform = identity) -> None: piece_length = len(piece) - piece2 = activation_transform( piece ) + piece2 = activation_transform(piece) # add playback parameters playback_piece1 = random_piece_playback(piece) playback_piece2 = copy_playback_info(playback_transform(playback_piece1), piece2) @@ -194,7 +258,7 @@ def two_piece_playback(self, piece, extra_position='none', activation_transform= self.playback = playback_piece1 + playback_piece2 + extra_playback # add rests between repeated notes in the playback so that they work in-game - def break_repeated_notes(self, duration=0x08): + def break_repeated_notes(self, duration: int = 0x08) -> None: new_playback = [] for note_index, note in enumerate(self.playback): new_playback.append(note) @@ -204,14 +268,14 @@ def break_repeated_notes(self, duration=0x08): self.playback = new_playback # create the list of bytes that will be written into the rom for the activation - def format_activation_data(self): + def format_activation_data(self) -> None: # data is 1 byte for the song length, # len bytes for the song, and the remainder is padding padding = [0] * (ACTIVATION_LENGTH - (self.length + 1)) self.activation_data = [self.length] + self.activation + padding # create the list of byte that will be written in to the rom for the playback - def format_playback_data(self): + def format_playback_data(self) -> None: self.playback_data = [] self.total_duration = 0 @@ -225,7 +289,7 @@ def format_playback_data(self): padding = [0] * (PLAYBACK_LENGTH - len(self.playback_data)) self.playback_data += padding - def __repr__(self): + def __repr__(self) -> str: activation_string = 'Activation Data:\n\t' + ' '.join( map( "{:02x}".format, self.activation_data) ) # break playback into groups of 8... index = 0 @@ -236,44 +300,16 @@ def __repr__(self): playback_string = 'Playback Data:\n\t' + '\n\t'.join( map( lambda line: ' '.join( map( "{:02x}".format, line) ), broken_up_playback ) ) return activation_string + '\n' + playback_string - # create a song, based on a given scheme - def __init__(self, rand_song=True, piece_size=3, extra_position='none', starting_range=range(0,5), activation_transform=identity, playback_transform=identity, *, activation=None, playback_fast=False): - if activation: - self.length = len(activation) - self.activation = activation - elif rand_song: - self.length = random.randint(4, 8) - self.activation = random.choices(range(0,5), k=self.length) - else: - if extra_position != 'none': - piece_size = 3 - piece = random_piece(piece_size, starting_range) - self.two_piece_playback(piece, extra_position, activation_transform, playback_transform) - - if playback_fast: - self.playback = fast_playback(self.activation) - self.break_repeated_notes(0x03) - else: - if not hasattr(self, 'playback'): - self.playback = random_playback(self.activation) - self.break_repeated_notes() - - self.format_activation_data() - self.format_playback_data() - - if activation: - self.increase_duration_to(45) - @classmethod - def from_str(cls, notes): + def from_str(cls, notes: str) -> 'Song': return cls(activation=[READ_ACTIVATION[note.lower()] for note in notes]) - def __str__(self): + def __str__(self) -> str: return ''.join(FORMAT_ACTIVATION[note] for note in self.activation) -# randomly choose song parameters -def get_random_song(): +# randomly choose song parameters +def get_random_song() -> Song: rand_song = random.choices([True, False], [1, 9])[0] piece_size = random.choices([3, 4], [5, 2])[0] extra_position = random.choices(['none', 'start', 'middle', 'end'], [12, 1, 1, 1])[0] @@ -281,15 +317,15 @@ def get_random_song(): playback_transform = identity weight_damage = 0 should_transpose = random.choices([True, False], [1, 4])[0] - starting_range=range(0,5) + starting_range = range(0, 5) if should_transpose: weight_damage = 2 direction = random.choices(['up', 'down'], [1, 1])[0] if direction == 'up': - starting_range=range(0,4) + starting_range = range(0, 4) activation_transform = transpose_piece(1) elif direction == 'down': - starting_range=range(1,5) + starting_range = range(1, 5) activation_transform = transpose_piece(-1) should_invert = random.choices([True, False], [3 - weight_damage, 6])[0] if should_invert: @@ -300,7 +336,6 @@ def get_random_song(): activation_transform = compose(reverse_piece, activation_transform) playback_transform = reverse_piece - # print([rand_song, piece_size, extra_position, starting_range, should_transpose, should_invert, should_reflect]) song = Song(rand_song, piece_size, extra_position, starting_range, activation_transform, playback_transform) @@ -324,7 +359,7 @@ def get_random_song(): # create a list of 12 songs, none of which are sub-strings of any other song -def generate_song_list(world, frog, warp): +def generate_song_list(world: "World", frog: bool, warp: bool) -> Dict[str, Song]: fixed_songs = {} if not frog: fixed_songs.update({name: Song.from_str(notes) for name, (_, is_warp, notes) in SONG_TABLE.items() if not is_warp}) @@ -369,15 +404,14 @@ def generate_song_list(world, frog, warp): return fixed_songs - # replace the playback and activation requirements for the ocarina songs -def replace_songs(world, rom, *, frog, warp): +def replace_songs(world: "World", rom: "Rom", frog: bool, warp: bool) -> None: songs = generate_song_list(world, frog, warp) world.song_notes = songs for name, song in songs.items(): if str(song) == SONG_TABLE[name][2]: - continue # song activation is vanilla (possibly because this row wasn't randomized), don't randomize playback + continue # song activation is vanilla (possibly because this row wasn't randomized), don't randomize playback # fix the song of time and sun's song if name == 'Song of Time' or name == 'Suns Song': diff --git a/OoTRandomizer.py b/OoTRandomizer.py index bb45ddd51..d3bbb005e 100755 --- a/OoTRandomizer.py +++ b/OoTRandomizer.py @@ -5,11 +5,11 @@ sys.exit(1) import argparse -import os +import datetime import logging +import os import textwrap import time -import datetime class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): @@ -17,7 +17,7 @@ def _get_help_string(self, action): return textwrap.dedent(action.help) -def start(): +def start() -> None: from Main import main, from_patch_file, cosmetic_patch, diff_roms from Settings import get_settings_from_command_line_args from Utils import check_version, VersionError, local_path diff --git a/Patches.py b/Patches.py index 341dbd8fa..b3d9e806e 100644 --- a/Patches.py +++ b/Patches.py @@ -1,33 +1,38 @@ -import random -import struct +import datetime import itertools +import random import re +import struct import zlib -import datetime +from typing import Dict, List, Iterable, Tuple, Set, Callable, Optional, Any -from World import World -from Rom import Rom -from Spoiler import Spoiler -from Location import DisableType +from Entrance import Entrance +from HintList import get_hint +from Hints import GossipText, HintArea, write_gossip_stone_hints, build_altar_hints, \ + build_ganon_text, build_misc_item_hints, build_misc_location_hints, get_simple_hint_no_prefix, get_item_generic_name +from Item import Item +from ItemPool import song_list, trade_items, child_trade_items +from Location import Location, DisableType from LocationList import business_scrubs -from HintList import getHint -from Hints import GossipText, HintArea, writeGossipStoneHints, buildAltarHints, \ - buildGanonText, buildMiscItemHints, buildMiscLocationHints, getSimpleHintNoPrefix, getItemGenericName -from Utils import data_path from Messages import read_messages, update_message_by_id, read_shop_items, update_warp_song_text, \ write_shop_items, remove_unused_messages, make_player_message, \ add_item_messages, repack_messages, shuffle_messages, \ - get_message_by_id, Text_Code -from OcarinaSongs import replace_songs + get_message_by_id, TextCode from MQ import patch_files, File, update_dmadata, insert_space, add_relocations +from OcarinaSongs import replace_songs +from Rom import Rom from SaveContext import SaveContext, Scenes, FlagType -from version import __version__ -from ItemPool import song_list, trade_items, child_trade_items from SceneFlags import get_alt_list_bytes, get_collectible_flag_table, get_collectible_flag_table_bytes +from Spoiler import Spoiler +from Utils import TypeAlias, data_path +from World import World from texture_util import ci4_rgba16patch_to_ci8, rgba16_patch +from version import __version__ + +OverrideEntry: TypeAlias = Tuple[int, int, int, int, int, int] -def patch_rom(spoiler: Spoiler, world: World, rom: Rom) -> None: +def patch_rom(spoiler: Spoiler, world: World, rom: Rom) -> Rom: with open(data_path('generated/rom_patch.txt'), 'r') as stream: for line in stream: address, value = [int(x, 16) for x in line.split(',')] @@ -39,6 +44,7 @@ def patch_rom(spoiler: Spoiler, world: World, rom: Rom) -> None: (data_path('title.bin'), 0x01795300), # Randomizer title screen logo (data_path('keaton.bin'), 0x8A7C00), # Fixes the typo of "Keatan Mask" in the item select screen ] + for (bin_path, write_address) in bin_patches: with open(bin_path, 'rb') as stream: bytes_compressed = stream.read() @@ -55,7 +61,7 @@ def patch_rom(spoiler: Spoiler, world: World, rom: Rom) -> None: ('object_gi_chubag', data_path('ChuBag.zobj'), 0x197), # Bombchu Bag ] - extended_objects_start = start_address = rom.free_space() + extended_objects_start = start_address = rom.dma.free_space() for (name, zobj_path, object_id) in zobj_imports: with open(zobj_path, 'rb') as stream: obj_data = stream.read() @@ -96,7 +102,7 @@ def patch_rom(spoiler: Spoiler, world: World, rom: Rom) -> None: start_address = end_address # Add the extended objects data to the DMA table. - rom.update_dmadata_record(None, extended_objects_start, end_address) + rom.update_dmadata_record_by_key(None, extended_objects_start, end_address) # Create the textures for pots/crates. Note: No copyrighted material can be distributed w/ the randomizer. Because of this, patch files are used to create the new textures from the original texture in ROM. # Apply patches for custom textures for pots and crates and add as new files in rom @@ -136,7 +142,7 @@ def patch_rom(spoiler: Spoiler, world: World, rom: Rom) -> None: ] # Loop through the textures and apply the patch. Add the new textures as a new file in rom. - extended_textures_start = start_address = rom.free_space() + extended_textures_start = start_address = rom.dma.free_space() for texture_id, texture_name, rom_address_base, rom_address_palette, size, func, patch_file in crate_textures: # Apply the texture patch. Resulting texture will be stored in texture_data as a bytearray texture_data = func(rom, rom_address_base, rom_address_palette, size, data_path(patch_file) if patch_file else None) @@ -151,7 +157,7 @@ def patch_rom(spoiler: Spoiler, world: World, rom: Rom) -> None: start_address = end_address # Add the extended texture data to the DMA table. - rom.update_dmadata_record(None, extended_textures_start, end_address) + rom.update_dmadata_record_by_key(None, extended_textures_start, end_address) # Create an option so that recovery hearts no longer drop by changing the code which checks Link's health when an item is spawned. if world.settings.no_collectible_hearts: @@ -173,7 +179,7 @@ def patch_rom(spoiler: Spoiler, world: World, rom: Rom) -> None: # Can always return to youth rom.write_byte(0xCB6844, 0x35) - rom.write_byte(0x253C0E2, 0x03) # Moves sheik from pedestal + rom.write_byte(0x253C0E2, 0x03) # Moves sheik from pedestal # Fix Ice Cavern Alcove Camera if not world.dungeon_mq['Ice Cavern']: @@ -189,8 +195,8 @@ def patch_rom(spoiler: Spoiler, world: World, rom: Rom) -> None: rom.write_byte(0xE12ADD, 0x00) # Fix deku theater rewards to be static - rom.write_bytes(0xEC9A7C, [0x00, 0x00, 0x00, 0x00]) #Sticks - rom.write_byte(0xEC9CD5, 0x00) #Nuts + rom.write_bytes(0xEC9A7C, [0x00, 0x00, 0x00, 0x00]) # Sticks + rom.write_byte(0xEC9CD5, 0x00) # Nuts # Fix deku scrub who sells stick upgrade rom.write_bytes(0xDF8060, [0x00, 0x00, 0x00, 0x00]) @@ -219,20 +225,15 @@ def patch_rom(spoiler: Spoiler, world: World, rom: Rom) -> None: rom.write_int32(rom.sym('FREE_BOMBCHU_DROPS'), 1) # show seed info on file select screen - def makebytes(txt, size): + def make_bytes(txt: str, size: int) -> List[int]: bytes = list(ord(c) for c in txt[:size-1]) + [0] * size return bytes[:size] - def truncstr(txt, size): - if len(txt) > size: - txt = txt[:size-3] + "..." - return txt - line_len = 21 version_str = "version " + __version__ if len(version_str) > line_len: version_str = "ver. " + __version__ - rom.write_bytes(rom.sym('VERSION_STRING_TXT'), makebytes(version_str, 25)) + rom.write_bytes(rom.sym('VERSION_STRING_TXT'), make_bytes(version_str, 25)) if world.settings.create_spoiler: rom.write_byte(rom.sym('SPOILER_AVAILABLE'), 0x01) @@ -241,13 +242,13 @@ def truncstr(txt, size): rom.write_byte(rom.sym('PLANDOMIZER_USED'), 0x01) if world.settings.world_count > 1: - world_str = "{} of {}".format(world.id + 1, world.settings.world_count) + world_str = f"{world.id + 1} of {world.settings.world_count}" else: world_str = "" - rom.write_bytes(rom.sym('WORLD_STRING_TXT'), makebytes(world_str, 12)) + rom.write_bytes(rom.sym('WORLD_STRING_TXT'), make_bytes(world_str, 12)) time_str = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M") + " UTC" - rom.write_bytes(rom.sym('TIME_STRING_TXT'), makebytes(time_str, 25)) + rom.write_bytes(rom.sym('TIME_STRING_TXT'), make_bytes(time_str, 25)) if world.settings.show_seed_info: rom.write_byte(rom.sym('CFG_SHOW_SETTING_INFO'), 0x01) @@ -263,7 +264,7 @@ def truncstr(txt, size): if len(part1[-1]) + len(msg[1]) < line_len: msg = [" ".join(part1[:-1]), part1[-1] + msg[1]] else: - # Is it a URL? + # Is it a URL? part1 = msg[0].split('/') if len(part1[-1]) + len(msg[1]) < line_len: msg = ["/".join(part1[:-1]) + "/", part1[-1] + msg[1]] @@ -319,8 +320,8 @@ def truncstr(txt, size): rom.write_bytes(0xEFE950, [0x00, 0x00, 0x00, 0x00]) # Speed Zelda escaping from Hyrule Castle - Block_code = [0x00, 0x00, 0x00, 0x01, 0x00, 0x21, 0x00, 0x01, 0x00, 0x02, 0x00, 0x02] - rom.write_bytes(0x1FC0CF8, Block_code) + block_code = [0x00, 0x00, 0x00, 0x01, 0x00, 0x21, 0x00, 0x01, 0x00, 0x02, 0x00, 0x02] + rom.write_bytes(0x1FC0CF8, block_code) # songs as items flag songs_as_items = world.settings.shuffle_song_items != 'song' or \ @@ -337,7 +338,6 @@ def truncstr(txt, size): else: rom.write_int16s(None, [0x0073, 0x003B, 0x003C, 0x003C]) # ID, start, end, end - rom.write_int32s(0x02E8E91C, [0x00000013, 0x0000000C]) # Textbox, Count if songs_as_items: rom.write_int16s(None, [0xFFFF, 0x0000, 0x0010, 0xFFFF, 0xFFFF, 0xFFFF]) # ID, start, end, type, alt1, alt2 @@ -347,9 +347,9 @@ def truncstr(txt, size): # Speed learning Sun's Song if songs_as_items: - rom.write_int32(0x0332A4A4, 0xFFFFFFFF) # Header: frame_count + rom.write_int32(0x0332A4A4, 0xFFFFFFFF) # Header: frame_count else: - rom.write_int32(0x0332A4A4, 0x0000003C) # Header: frame_count + rom.write_int32(0x0332A4A4, 0x0000003C) # Header: frame_count rom.write_int32s(0x0332A868, [0x00000013, 0x00000008]) # Textbox, Count rom.write_int16s(None, [0x0018, 0x0000, 0x0010, 0x0002, 0x088B, 0xFFFF]) # ID, start, end, type, alt1, alt2 @@ -357,17 +357,17 @@ def truncstr(txt, size): # Speed learning Saria's Song if songs_as_items: - rom.write_int32(0x020B1734, 0xFFFFFFFF) # Header: frame_count + rom.write_int32(0x020B1734, 0xFFFFFFFF) # Header: frame_count else: - rom.write_int32(0x020B1734, 0x0000003C) # Header: frame_count + rom.write_int32(0x020B1734, 0x0000003C) # Header: frame_count rom.write_int32s(0x20B1DA8, [0x00000013, 0x0000000C]) # Textbox, Count rom.write_int16s(None, [0x0015, 0x0000, 0x0010, 0x0002, 0x088B, 0xFFFF]) # ID, start, end, type, alt1, alt2 rom.write_int16s(None, [0x00D1, 0x0011, 0x0020, 0x0000, 0xFFFF, 0xFFFF]) # ID, start, end, type, alt1, alt2 rom.write_int32s(0x020B19C0, [0x0000000A, 0x00000006]) # Link, Count - rom.write_int16s(0x020B19C8, [0x0011, 0x0000, 0x0010, 0x0000]) #action, start, end, ???? - rom.write_int16s(0x020B19F8, [0x003E, 0x0011, 0x0020, 0x0000]) #action, start, end, ???? + rom.write_int16s(0x020B19C8, [0x0011, 0x0000, 0x0010, 0x0000]) # action, start, end, ???? + rom.write_int16s(0x020B19F8, [0x003E, 0x0011, 0x0020, 0x0000]) # action, start, end, ???? rom.write_int32s(None, [0x80000000, # ??? 0x00000000, 0x000001D4, 0xFFFFF731, # start_XYZ 0x00000000, 0x000001D4, 0xFFFFF712]) # end_XYZ @@ -400,13 +400,13 @@ def truncstr(txt, size): rom.write_int16s(None, [0x0019, 0x0000, 0x0010, 0x0002, 0x088B, 0xFFFF]) # ID, start, end, type, alt1, alt2 rom.write_int16s(None, [0x00D5, 0x0011, 0x0020, 0x0000, 0xFFFF, 0xFFFF]) # ID, start, end, type, alt1, alt2 - rom.write_int32(0x01FC3B84, 0xFFFFFFFF) # Other Header?: frame_count + rom.write_int32(0x01FC3B84, 0xFFFFFFFF) # Other Header?: frame_count # Speed learning Song of Storms if songs_as_items: - rom.write_int32(0x03041084, 0xFFFFFFFF) # Header: frame_count + rom.write_int32(0x03041084, 0xFFFFFFFF) # Header: frame_count else: - rom.write_int32(0x03041084, 0x0000000A) # Header: frame_count + rom.write_int32(0x03041084, 0x0000000A) # Header: frame_count rom.write_int32s(0x03041088, [0x00000013, 0x00000002]) # Textbox, Count rom.write_int16s(None, [0x00D6, 0x0000, 0x0009, 0x0000, 0xFFFF, 0xFFFF]) # ID, start, end, type, alt1, alt2 @@ -414,72 +414,72 @@ def truncstr(txt, size): # Speed learning Minuet of Forest if songs_as_items: - rom.write_int32(0x020AFF84, 0xFFFFFFFF) # Header: frame_count + rom.write_int32(0x020AFF84, 0xFFFFFFFF) # Header: frame_count else: - rom.write_int32(0x020AFF84, 0x0000003C) # Header: frame_count + rom.write_int32(0x020AFF84, 0x0000003C) # Header: frame_count rom.write_int32s(0x020B0800, [0x00000013, 0x0000000A]) # Textbox, Count rom.write_int16s(None, [0x000F, 0x0000, 0x0010, 0x0002, 0x088B, 0xFFFF]) # ID, start, end, type, alt1, alt2 rom.write_int16s(None, [0x0073, 0x0011, 0x0020, 0x0000, 0xFFFF, 0xFFFF]) # ID, start, end, type, alt1, alt2 rom.write_int32s(0x020AFF88, [0x0000000A, 0x00000005]) # Link, Count - rom.write_int16s(0x020AFF90, [0x0011, 0x0000, 0x0010, 0x0000]) #action, start, end, ???? - rom.write_int16s(0x020AFFC1, [0x003E, 0x0011, 0x0020, 0x0000]) #action, start, end, ???? + rom.write_int16s(0x020AFF90, [0x0011, 0x0000, 0x0010, 0x0000]) # action, start, end, ???? + rom.write_int16s(0x020AFFC1, [0x003E, 0x0011, 0x0020, 0x0000]) # action, start, end, ???? rom.write_int32s(0x020B0488, [0x00000056, 0x00000001]) # Music Change, Count - rom.write_int16s(None, [0x003F, 0x0021, 0x0022, 0x0000]) #action, start, end, ???? + rom.write_int16s(None, [0x003F, 0x0021, 0x0022, 0x0000]) # action, start, end, ???? rom.write_int32s(0x020B04C0, [0x0000007C, 0x00000001]) # Music Fade Out, Count - rom.write_int16s(None, [0x0004, 0x0000, 0x0000, 0x0000]) #action, start, end, ???? + rom.write_int16s(None, [0x0004, 0x0000, 0x0000, 0x0000]) # action, start, end, ???? # Speed learning Bolero of Fire if songs_as_items: - rom.write_int32(0x0224B5D4, 0xFFFFFFFF) # Header: frame_count + rom.write_int32(0x0224B5D4, 0xFFFFFFFF) # Header: frame_count else: - rom.write_int32(0x0224B5D4, 0x0000003C) # Header: frame_count + rom.write_int32(0x0224B5D4, 0x0000003C) # Header: frame_count rom.write_int32s(0x0224D7E8, [0x00000013, 0x0000000A]) # Textbox, Count rom.write_int16s(None, [0x0010, 0x0000, 0x0010, 0x0002, 0x088B, 0xFFFF]) # ID, start, end, type, alt1, alt2 rom.write_int16s(None, [0x0074, 0x0011, 0x0020, 0x0000, 0xFFFF, 0xFFFF]) # ID, start, end, type, alt1, alt2 rom.write_int32s(0x0224B5D8, [0x0000000A, 0x0000000B]) # Link, Count - rom.write_int16s(0x0224B5E0, [0x0011, 0x0000, 0x0010, 0x0000]) #action, start, end, ???? - rom.write_int16s(0x0224B610, [0x003E, 0x0011, 0x0020, 0x0000]) #action, start, end, ???? + rom.write_int16s(0x0224B5E0, [0x0011, 0x0000, 0x0010, 0x0000]) # action, start, end, ???? + rom.write_int16s(0x0224B610, [0x003E, 0x0011, 0x0020, 0x0000]) # action, start, end, ???? rom.write_int32s(0x0224B7F0, [0x0000002F, 0x0000000E]) # Sheik, Count - rom.write_int16s(0x0224B7F8, [0x0000]) #action - rom.write_int16s(0x0224B828, [0x0000]) #action - rom.write_int16s(0x0224B858, [0x0000]) #action - rom.write_int16s(0x0224B888, [0x0000]) #action + rom.write_int16s(0x0224B7F8, [0x0000]) # action + rom.write_int16s(0x0224B828, [0x0000]) # action + rom.write_int16s(0x0224B858, [0x0000]) # action + rom.write_int16s(0x0224B888, [0x0000]) # action # Speed learning Serenade of Water if songs_as_items: - rom.write_int32(0x02BEB254, 0xFFFFFFFF) # Header: frame_count + rom.write_int32(0x02BEB254, 0xFFFFFFFF) # Header: frame_count else: - rom.write_int32(0x02BEB254, 0x0000003C) # Header: frame_count + rom.write_int32(0x02BEB254, 0x0000003C) # Header: frame_count rom.write_int32s(0x02BEC880, [0x00000013, 0x00000010]) # Textbox, Count rom.write_int16s(None, [0x0011, 0x0000, 0x0010, 0x0002, 0x088B, 0xFFFF]) # ID, start, end, type, alt1, alt2 rom.write_int16s(None, [0x0075, 0x0011, 0x0020, 0x0000, 0xFFFF, 0xFFFF]) # ID, start, end, type, alt1, alt2 rom.write_int32s(0x02BEB258, [0x0000000A, 0x0000000F]) # Link, Count - rom.write_int16s(0x02BEB260, [0x0011, 0x0000, 0x0010, 0x0000]) #action, start, end, ???? - rom.write_int16s(0x02BEB290, [0x003E, 0x0011, 0x0020, 0x0000]) #action, start, end, ???? + rom.write_int16s(0x02BEB260, [0x0011, 0x0000, 0x0010, 0x0000]) # action, start, end, ???? + rom.write_int16s(0x02BEB290, [0x003E, 0x0011, 0x0020, 0x0000]) # action, start, end, ???? rom.write_int32s(0x02BEB530, [0x0000002F, 0x00000006]) # Sheik, Count - rom.write_int16s(0x02BEB538, [0x0000, 0x0000, 0x018A, 0x0000]) #action, start, end, ???? + rom.write_int16s(0x02BEB538, [0x0000, 0x0000, 0x018A, 0x0000]) # action, start, end, ???? rom.write_int32s(None, [0x1BBB0000, # ??? 0xFFFFFB10, 0x8000011A, 0x00000330, # start_XYZ 0xFFFFFB10, 0x8000011A, 0x00000330]) # end_XYZ rom.write_int32s(0x02BEC848, [0x00000056, 0x00000001]) # Music Change, Count - rom.write_int16s(None, [0x0059, 0x0021, 0x0022, 0x0000]) #action, start, end, ???? + rom.write_int16s(None, [0x0059, 0x0021, 0x0022, 0x0000]) # action, start, end, ???? # Speed learning Nocturne of Shadow rom.write_int32s(0x01FFE458, [0x000003E8, 0x00000001]) # Other Scene? Terminator Execution rom.write_int16s(None, [0x002F, 0x0001, 0x0002, 0x0002]) # ID, start, end, end - rom.write_int32(0x01FFFDF4, 0x0000003C) # Header: frame_count + rom.write_int32(0x01FFFDF4, 0x0000003C) # Header: frame_count rom.write_int32s(0x02000FD8, [0x00000013, 0x0000000E]) # Textbox, Count if songs_as_items: @@ -495,7 +495,7 @@ def truncstr(txt, size): rom.write_int16s(None, [0x0032, 0x003A, 0x003B, 0x003B]) # ID, start, end, end # Speed learning Requiem of Spirit - rom.write_int32(0x0218AF14, 0x0000003C) # Header: frame_count + rom.write_int32(0x0218AF14, 0x0000003C) # Header: frame_count rom.write_int32s(0x0218C574, [0x00000013, 0x00000008]) # Textbox, Count if songs_as_items: @@ -511,35 +511,35 @@ def truncstr(txt, size): rom.write_int16s(None, [0x0030, 0x003A, 0x003B, 0x003B]) # ID, start, end, end rom.write_int32s(0x0218AF18, [0x0000000A, 0x0000000B]) # Link, Count - rom.write_int16s(0x0218AF20, [0x0011, 0x0000, 0x0010, 0x0000]) #action, start, end, ???? + rom.write_int16s(0x0218AF20, [0x0011, 0x0000, 0x0010, 0x0000]) # action, start, end, ???? rom.write_int32s(None, [0x40000000, # ??? 0xFFFFFAF9, 0x00000008, 0x00000001, # start_XYZ 0xFFFFFAF9, 0x00000008, 0x00000001, # end_XYZ 0x0F671408, 0x00000000, 0x00000001]) # normal_XYZ - rom.write_int16s(0x0218AF50, [0x003E, 0x0011, 0x0020, 0x0000]) #action, start, end, ???? + rom.write_int16s(0x0218AF50, [0x003E, 0x0011, 0x0020, 0x0000]) # action, start, end, ???? # Speed learning Prelude of Light if songs_as_items: - rom.write_int32(0x0252FD24, 0xFFFFFFFF) # Header: frame_count + rom.write_int32(0x0252FD24, 0xFFFFFFFF) # Header: frame_count else: - rom.write_int32(0x0252FD24, 0x0000004A) # Header: frame_count + rom.write_int32(0x0252FD24, 0x0000004A) # Header: frame_count rom.write_int32s(0x02531320, [0x00000013, 0x0000000E]) # Textbox, Count rom.write_int16s(None, [0x0014, 0x0000, 0x0010, 0x0002, 0x088B, 0xFFFF]) # ID, start, end, type, alt1, alt2 rom.write_int16s(None, [0x0078, 0x0011, 0x0020, 0x0000, 0xFFFF, 0xFFFF]) # ID, start, end, type, alt1, alt2 rom.write_int32s(0x0252FF10, [0x0000002F, 0x00000009]) # Sheik, Count - rom.write_int16s(0x0252FF18, [0x0006, 0x0000, 0x0000, 0x0000]) #action, start, end, ???? + rom.write_int16s(0x0252FF18, [0x0006, 0x0000, 0x0000, 0x0000]) # action, start, end, ???? rom.write_int32s(0x025313D0, [0x00000056, 0x00000001]) # Music Change, Count - rom.write_int16s(None, [0x003B, 0x0021, 0x0022, 0x0000]) #action, start, end, ???? + rom.write_int16s(None, [0x003B, 0x0021, 0x0022, 0x0000]) # action, start, end, ???? # Speed scene after Deku Tree rom.write_bytes(0x2077E20, [0x00, 0x07, 0x00, 0x01, 0x00, 0x02, 0x00, 0x02]) rom.write_bytes(0x2078A10, [0x00, 0x0E, 0x00, 0x1F, 0x00, 0x20, 0x00, 0x20]) - Block_code = [0x00, 0x80, 0x00, 0x00, 0x00, 0x1E, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + block_code = [0x00, 0x80, 0x00, 0x00, 0x00, 0x1E, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x1E, 0x00, 0x28, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF] - rom.write_bytes(0x2079570, Block_code) + rom.write_bytes(0x2079570, block_code) # Speed scene after Dodongo's Cavern rom.write_bytes(0x2221E88, [0x00, 0x0C, 0x00, 0x3B, 0x00, 0x3C, 0x00, 0x3C]) @@ -583,7 +583,7 @@ def truncstr(txt, size): rom.write_byte(0x2F5B559, 0x04) rom.write_byte(0x2F5B621, 0x04) rom.write_byte(0x2F5B761, 0x07) - rom.write_bytes(0x2F5B840, [0x00, 0x05, 0x00, 0x01, 0x00, 0x05, 0x00, 0x05]) #shorten white flash + rom.write_bytes(0x2F5B840, [0x00, 0x05, 0x00, 0x01, 0x00, 0x05, 0x00, 0x05]) # shorten white flash # Speed scene with all medallions rom.write_bytes(0x2512680, [0x00, 0x74, 0x00, 0x01, 0x00, 0x02, 0x00, 0x02]) @@ -621,23 +621,23 @@ def truncstr(txt, size): rom.write_bytes(0xE84C80, [0x10, 0x00]) # Speed completion of the trials in Ganon's Castle - rom.write_int16s(0x31A8090, [0x006B, 0x0001, 0x0002, 0x0002]) #Forest - rom.write_int16s(0x31A9E00, [0x006E, 0x0001, 0x0002, 0x0002]) #Fire - rom.write_int16s(0x31A8B18, [0x006C, 0x0001, 0x0002, 0x0002]) #Water - rom.write_int16s(0x31A9430, [0x006D, 0x0001, 0x0002, 0x0002]) #Shadow - rom.write_int16s(0x31AB200, [0x0070, 0x0001, 0x0002, 0x0002]) #Spirit - rom.write_int16s(0x31AA830, [0x006F, 0x0001, 0x0002, 0x0002]) #Light + rom.write_int16s(0x31A8090, [0x006B, 0x0001, 0x0002, 0x0002]) # Forest + rom.write_int16s(0x31A9E00, [0x006E, 0x0001, 0x0002, 0x0002]) # Fire + rom.write_int16s(0x31A8B18, [0x006C, 0x0001, 0x0002, 0x0002]) # Water + rom.write_int16s(0x31A9430, [0x006D, 0x0001, 0x0002, 0x0002]) # Shadow + rom.write_int16s(0x31AB200, [0x0070, 0x0001, 0x0002, 0x0002]) # Spirit + rom.write_int16s(0x31AA830, [0x006F, 0x0001, 0x0002, 0x0002]) # Light # Speed obtaining Fairy Ocarina rom.write_bytes(0x2151230, [0x00, 0x72, 0x00, 0x3C, 0x00, 0x3D, 0x00, 0x3D]) - Block_code = [0x00, 0x4A, 0x00, 0x00, 0x00, 0x3A, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + block_code = [0x00, 0x4A, 0x00, 0x00, 0x00, 0x3A, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x3C, 0x00, 0x81, 0xFF, 0xFF] - rom.write_bytes(0x2151240, Block_code) + rom.write_bytes(0x2151240, block_code) rom.write_bytes(0x2150E20, [0xFF, 0xFF, 0xFA, 0x4C]) if world.settings.shuffle_ocarinas: symbol = rom.sym('OCARINAS_SHUFFLED') - rom.write_byte(symbol,0x01) + rom.write_byte(symbol, 0x01) # Speed Zelda Light Arrow cutscene rom.write_bytes(0x2531B40, [0x00, 0x28, 0x00, 0x01, 0x00, 0x02, 0x00, 0x02]) @@ -663,27 +663,27 @@ def truncstr(txt, size): rom.write_bytes(0x292D810, [0x00, 0x02, 0x00, 0x3C]) rom.write_bytes(0x292D924, [0xFF, 0xFF, 0x00, 0x14, 0x00, 0x96, 0xFF, 0xFF]) - #Speed Pushing of All Pushable Objects - rom.write_bytes(0xDD2B86, [0x40, 0x80]) #block speed - rom.write_bytes(0xDD2D26, [0x00, 0x01]) #block delay - rom.write_bytes(0xDD9682, [0x40, 0x80]) #milk crate speed - rom.write_bytes(0xDD981E, [0x00, 0x01]) #milk crate delay - rom.write_bytes(0xCE1BD0, [0x40, 0x80, 0x00, 0x00]) #amy puzzle speed - rom.write_bytes(0xCE0F0E, [0x00, 0x01]) #amy puzzle delay - rom.write_bytes(0xC77CA8, [0x40, 0x80, 0x00, 0x00]) #fire block speed - rom.write_bytes(0xC770C2, [0x00, 0x01]) #fire block delay - rom.write_bytes(0xCC5DBC, [0x29, 0xE1, 0x00, 0x01]) #forest basement puzzle delay - rom.write_bytes(0xDBCF70, [0x2B, 0x01, 0x00, 0x00]) #spirit cobra mirror startup - rom.write_bytes(0xDBCF70, [0x2B, 0x01, 0x00, 0x01]) #spirit cobra mirror delay - rom.write_bytes(0xDBA230, [0x28, 0x41, 0x00, 0x19]) #truth spinner speed - rom.write_bytes(0xDBA3A4, [0x24, 0x18, 0x00, 0x00]) #truth spinner delay - - #Speed Deku Seed Upgrade Scrub Cutscene - rom.write_bytes(0xECA900, [0x24, 0x03, 0xC0, 0x00]) #scrub angle - rom.write_bytes(0xECAE90, [0x27, 0x18, 0xFD, 0x04]) #skip straight to giving item - rom.write_bytes(0xECB618, [0x25, 0x6B, 0x00, 0xD4]) #skip straight to digging back in - rom.write_bytes(0xECAE70, [0x00, 0x00, 0x00, 0x00]) #never initialize cs camera - rom.write_bytes(0xE5972C, [0x24, 0x08, 0x00, 0x01]) #timer set to 1 frame for giving item + # Speed Pushing of All Pushable Objects + rom.write_bytes(0xDD2B86, [0x40, 0x80]) # block speed + rom.write_bytes(0xDD2D26, [0x00, 0x01]) # block delay + rom.write_bytes(0xDD9682, [0x40, 0x80]) # milk crate speed + rom.write_bytes(0xDD981E, [0x00, 0x01]) # milk crate delay + rom.write_bytes(0xCE1BD0, [0x40, 0x80, 0x00, 0x00]) # amy puzzle speed + rom.write_bytes(0xCE0F0E, [0x00, 0x01]) # amy puzzle delay + rom.write_bytes(0xC77CA8, [0x40, 0x80, 0x00, 0x00]) # fire block speed + rom.write_bytes(0xC770C2, [0x00, 0x01]) # fire block delay + rom.write_bytes(0xCC5DBC, [0x29, 0xE1, 0x00, 0x01]) # forest basement puzzle delay + rom.write_bytes(0xDBCF70, [0x2B, 0x01, 0x00, 0x00]) # spirit cobra mirror startup + rom.write_bytes(0xDBCF70, [0x2B, 0x01, 0x00, 0x01]) # spirit cobra mirror delay + rom.write_bytes(0xDBA230, [0x28, 0x41, 0x00, 0x19]) # truth spinner speed + rom.write_bytes(0xDBA3A4, [0x24, 0x18, 0x00, 0x00]) # truth spinner delay + + # Speed Deku Seed Upgrade Scrub Cutscene + rom.write_bytes(0xECA900, [0x24, 0x03, 0xC0, 0x00]) # scrub angle + rom.write_bytes(0xECAE90, [0x27, 0x18, 0xFD, 0x04]) # skip straight to giving item + rom.write_bytes(0xECB618, [0x25, 0x6B, 0x00, 0xD4]) # skip straight to digging back in + rom.write_bytes(0xECAE70, [0x00, 0x00, 0x00, 0x00]) # never initialize cs camera + rom.write_bytes(0xE5972C, [0x24, 0x08, 0x00, 0x01]) # timer set to 1 frame for giving item # Remove remaining owls rom.write_bytes(0x1FE30CE, [0x01, 0x4B]) @@ -707,14 +707,14 @@ def truncstr(txt, size): # Ruto never disappears from Jabu Jabu's Belly rom.write_byte(0xD01EA3, 0x00) - #Shift octorock in jabu forward + # Shift octorock in jabu forward rom.write_bytes(0x275906E, [0xFF, 0xB3, 0xFB, 0x20, 0xF9, 0x56]) - #Move fire/forest temple switches down 1 unit to make it easier to press - rom.write_bytes(0x24860A8, [0xFC, 0xF4]) #forest basement 1 - rom.write_bytes(0x24860C8, [0xFC, 0xF4]) #forest basement 2 - rom.write_bytes(0x24860E8, [0xFC, 0xF4]) #forest basement 3 - rom.write_bytes(0x236C148, [0x11, 0x93]) #fire hammer room + # Move fire/forest temple switches down 1 unit to make it easier to press + rom.write_bytes(0x24860A8, [0xFC, 0xF4]) # forest basement 1 + rom.write_bytes(0x24860C8, [0xFC, 0xF4]) # forest basement 2 + rom.write_bytes(0x24860E8, [0xFC, 0xF4]) # forest basement 3 + rom.write_bytes(0x236C148, [0x11, 0x93]) # fire hammer room # Speed up Epona race start rom.write_bytes(0x29BE984, [0x00, 0x00, 0x00, 0x02]) @@ -737,7 +737,7 @@ def truncstr(txt, size): rom.write_byte(0x2025159, 0x02) rom.write_byte(0x2023E19, 0x02) - #Speed opening of Door of Time + # Speed opening of Door of Time rom.write_bytes(0xE0A176, [0x00, 0x02]) rom.write_bytes(0xE0A35A, [0x00, 0x01, 0x00, 0x02]) @@ -748,12 +748,12 @@ def truncstr(txt, size): rom.write_bytes(0x223B6B2, [0x00, 0x01]) # Speed up magic arrow equips - rom.write_int16(0xBB84CE, 0x0000) # Skips the initial growing glowing orb phase - rom.write_byte(0xBB84B7, 0xFF) # Set glowing orb above magic arrow to be small sized immediately - rom.write_byte(0xBB84CB, 0x01) # Sets timer for holding icon above magic arrow (1 frame) - rom.write_byte(0xBB7E67, 0x04) # speed up magic arrow icon -> bow icon interpolation (4 frames) - rom.write_byte(0xBB8957, 0x01) # Sets timer for holding icon above bow (1 frame) - rom.write_byte(0xBB854B, 0x05) # speed up bow icon -> c button interpolation (5 frames) + rom.write_int16(0xBB84CE, 0x0000) # Skips the initial growing glowing orb phase + rom.write_byte(0xBB84B7, 0xFF) # Set glowing orb above magic arrow to be small sized immediately + rom.write_byte(0xBB84CB, 0x01) # Sets timer for holding icon above magic arrow (1 frame) + rom.write_byte(0xBB7E67, 0x04) # speed up magic arrow icon -> bow icon interpolation (4 frames) + rom.write_byte(0xBB8957, 0x01) # Sets timer for holding icon above bow (1 frame) + rom.write_byte(0xBB854B, 0x05) # speed up bow icon -> c button interpolation (5 frames) # Poacher's Saw no longer messes up Forest Stage rom.write_bytes(0xAE72CC, [0x00, 0x00, 0x00, 0x00]) @@ -791,8 +791,8 @@ def truncstr(txt, size): # Change Mido, Saria, and Kokiri to check for Deku Tree complete flag # bitwise pointer for 0x80 - kokiriAddresses = [0xE52836, 0xE53A56, 0xE51D4E, 0xE51F3E, 0xE51D96, 0xE51E1E, 0xE51E7E, 0xE51EDE, 0xE51FC6, 0xE51F96, 0xE293B6, 0xE29B8E, 0xE62EDA, 0xE630D6, 0xE633AA, 0xE6369E] - for kokiri in kokiriAddresses: + kokiri_addresses = [0xE52836, 0xE53A56, 0xE51D4E, 0xE51F3E, 0xE51D96, 0xE51E1E, 0xE51E7E, 0xE51EDE, 0xE51FC6, 0xE51F96, 0xE293B6, 0xE29B8E, 0xE62EDA, 0xE630D6, 0xE633AA, 0xE6369E] + for kokiri in kokiri_addresses: rom.write_bytes(kokiri, [0x8C, 0x0C]) # Kokiri rom.write_bytes(0xE52838, [0x94, 0x48, 0x0E, 0xD4]) @@ -844,7 +844,7 @@ def truncstr(txt, size): rom.write_bytes(0x1FF93A4, [0x01, 0x8D, 0x00, 0x11, 0x01, 0x6C, 0xFF, 0x92, 0x00, 0x00, 0x01, 0x78, 0xFF, 0x2E, 0x00, 0x00, 0x00, 0x03, 0xFD, 0x2B, 0x00, 0xC8, 0xFF, 0xF9, 0xFD, 0x03, 0x00, 0xC8, 0xFF, 0xA9, 0xFD, 0x5D, - 0x00, 0xC8, 0xFE, 0x5F]) # re order the carpenter's path + 0x00, 0xC8, 0xFE, 0x5F]) # re-order the carpenter's path rom.write_byte(0x1FF93D0, 0x06) # set the path points to 6 rom.write_bytes(0x20160B6, [0x01, 0x8D, 0x00, 0x11, 0x01, 0x6C]) # set the carpenter's start position @@ -870,10 +870,10 @@ def truncstr(txt, size): rom.write_byte(address,0x01) # Allow Warp Songs in additional places - rom.write_byte(0xB6D3D2, 0x00) # Gerudo Training Ground - rom.write_byte(0xB6D42A, 0x00) # Inside Ganon's Castle + rom.write_byte(0xB6D3D2, 0x00) # Gerudo Training Ground + rom.write_byte(0xB6D42A, 0x00) # Inside Ganon's Castle - #Tell Sheik at Ice Cavern we are always an Adult + # Tell Sheik at Ice Cavern we are always an Adult rom.write_int32(0xC7B9C0, 0x00000000) rom.write_int32(0xC7BAEC, 0x00000000) rom.write_int32(0xc7BCA4, 0x00000000) @@ -887,31 +887,31 @@ def truncstr(txt, size): rom.write_byte(0xB6D30A, 0x51) # Archery # Remove disruptive text from Gerudo Training Ground and early Shadow Temple (vanilla) - Wonder_text = [0x27C00BC, 0x27C00CC, 0x27C00DC, 0x27C00EC, 0x27C00FC, 0x27C010C, 0x27C011C, 0x27C012C, 0x27CE080, + wonder_text = [0x27C00BC, 0x27C00CC, 0x27C00DC, 0x27C00EC, 0x27C00FC, 0x27C010C, 0x27C011C, 0x27C012C, 0x27CE080, 0x27CE090, 0x2887070, 0x2887080, 0x2887090, 0x2897070, 0x28C7134, 0x28D91BC, 0x28A60F4, 0x28AE084, 0x28B9174, 0x28BF168, 0x28BF178, 0x28BF188, 0x28A1144, 0x28A6104, 0x28D0094] - for address in Wonder_text: + for address in wonder_text: rom.write_byte(address, 0xFB) # Speed dig text for Dampe rom.write_bytes(0x9532F8, [0x08, 0x08, 0x08, 0x59]) # Make item descriptions into a single box - Short_item_descriptions = [0x92EC84, 0x92F9E3, 0x92F2B4, 0x92F37A, 0x92F513, 0x92F5C6, 0x92E93B, 0x92EA12] - for address in Short_item_descriptions: - rom.write_byte(address,0x02) + short_item_descriptions = [0x92EC84, 0x92F9E3, 0x92F2B4, 0x92F37A, 0x92F513, 0x92F5C6, 0x92E93B, 0x92EA12] + for address in short_item_descriptions: + rom.write_byte(address, 0x02) et_original = rom.read_bytes(0xB6FBF0, 4 * 0x0614) exit_updates = [] - def generate_exit_lookup_table(): + def generate_exit_lookup_table() -> Dict[int, List[int]]: # Assumes that the last exit on a scene's exit list cannot be 0000 exit_table = { - 0x0028: [0xAC95C2] #Jabu with the fish is entered from a cutscene hardcode - } + 0x0028: [0xAC95C2] # Jabu with the fish is entered from a cutscene hardcode + } - def add_scene_exits(scene_start, offset = 0): + def add_scene_exits(scene_start: int, offset: int = 0) -> None: current = scene_start + offset exit_list_start_off = 0 exit_list_end_off = 0 @@ -919,16 +919,16 @@ def add_scene_exits(scene_start, offset = 0): while command != 0x14: command = rom.read_byte(current) - if command == 0x18: # Alternate header list + if command == 0x18: # Alternate header list header_list = scene_start + (rom.read_int32(current + 4) & 0x00FFFFFF) for alt_id in range(0,3): header_offset = rom.read_int32(header_list) & 0x00FFFFFF if header_offset != 0: add_scene_exits(scene_start, header_offset) header_list += 4 - if command == 0x13: # Exit List + if command == 0x13: # Exit List exit_list_start_off = rom.read_int32(current + 4) & 0x00FFFFFF - if command == 0x0F: # Lighting list, follows exit list + if command == 0x0F: # Lighting list, follows exit list exit_list_end_off = rom.read_int32(current + 4) & 0x00FFFFFF current += 8 @@ -952,7 +952,7 @@ def add_scene_exits(scene_start, offset = 0): scene_table = 0x00B71440 for scene in range(0x00, 0x65): - scene_start = rom.read_int32(scene_table + (scene * 0x14)); + scene_start = rom.read_int32(scene_table + (scene * 0x14)) add_scene_exits(scene_start) return exit_table @@ -961,13 +961,13 @@ def add_scene_exits(scene_start, offset = 0): # Credit to rattus128 for this ASM block. # Gohma's save/death warp is optimized to use immediate 0 for the # deku tree respawn. Use the delay slot before the switch table - # to hold Gohmas jump entrance as actual data so we can substitute + # to hold Gohma's jump entrance as actual data so we can substitute # the entrance index later. - rom.write_int32(0xB06290, 0x240E0000) #li t6, 0 - rom.write_int32(0xB062B0, 0xAE0E0000) #sw t6, 0(s0) - rom.write_int32(0xBC60AC, 0x24180000) #li t8, 0 - rom.write_int32(0xBC6160, 0x24180000) #li t8, 0 - rom.write_int32(0xBC6168, 0xAD380000) #sw t8, 0(t1) + rom.write_int32(0xB06290, 0x240E0000) # li t6, 0 + rom.write_int32(0xB062B0, 0xAE0E0000) # sw t6, 0(s0) + rom.write_int32(0xBC60AC, 0x24180000) # li t8, 0 + rom.write_int32(0xBC6160, 0x24180000) # li t8, 0 + rom.write_int32(0xBC6168, 0xAD380000) # sw t8, 0(t1) # Credit to engineer124 # Update the Jabu-Jabu Boss Exit to actually useful coordinates (and to load the correct room) @@ -977,7 +977,7 @@ def add_scene_exits(scene_start, offset = 0): # Update the Water Temple Boss Exit to load the correct room rom.write_byte(0x25B82E3, 0x0B) - def set_entrance_updates(entrances): + def set_entrance_updates(entrances: Iterable[Entrance]) -> None: if world.settings.shuffle_bosses != 'off': # Connect lake hylia fill exit to revisit exit rom.write_int16(0xAC995A, 0x060C) @@ -1062,17 +1062,17 @@ def set_entrance_updates(entrances): for k in (0x028A, 0x028E, 0x0292): # Southern, Western, Eastern Gates exit_table[0x01F9] += exit_table[k] # Hyrule Field entrance from Lon Lon Ranch (main land entrance) del exit_table[k] - exit_table[0x01F9].append(0xD52722) # 0x0476, Front Gate + exit_table[0x01F9].append(0xD52722) # 0x0476, Front Gate # Combine the water exits between Hyrule Field and Zora River to lead to the land entrance instead of the water entrance - exit_table[0x00EA] += exit_table[0x01D9] # Hyrule Field -> Zora River - exit_table[0x0181] += exit_table[0x0311] # Zora River -> Hyrule Field + exit_table[0x00EA] += exit_table[0x01D9] # Hyrule Field -> Zora River + exit_table[0x0181] += exit_table[0x0311] # Zora River -> Hyrule Field del exit_table[0x01D9] del exit_table[0x0311] # Change Impa escorts to bring link at the hyrule castle grounds entrance from market, instead of hyrule field - rom.write_int16(0xACAA2E, 0x0138) # 1st Impa escort - rom.write_int16(0xD12D6E, 0x0138) # 2nd+ Impa escort + rom.write_int16(0xACAA2E, 0x0138) # 1st Impa escort + rom.write_int16(0xD12D6E, 0x0138) # 2nd+ Impa escort if world.settings.shuffle_hideout_entrances: rom.write_byte(rom.sym('HIDEOUT_SHUFFLED'), 1) @@ -1087,9 +1087,9 @@ def set_entrance_updates(entrances): # checking the well drain event flag instead of links age. This actor doesn't need a # code check for links age as the stone is absent for child via the scene alternate # lists. So replace the age logic with drain logic. - rom.write_int32(0xE2887C, rom.read_int32(0xE28870)) # relocate this to nop delay slot - rom.write_int32(0xE2886C, 0x95CEB4B0) # lhu - rom.write_int32(0xE28870, 0x31CE0080) # andi + rom.write_int32(0xE2887C, rom.read_int32(0xE28870)) # relocate this to nop delay slot + rom.write_int32(0xE2886C, 0x95CEB4B0) # lhu + rom.write_int32(0xE28870, 0x31CE0080) # andi remove_entrance_blockers(rom) @@ -1128,85 +1128,85 @@ def set_entrance_updates(entrances): # Initial Save Data if not world.settings.useful_cutscenes and 'Forest Temple' not in world.settings.dungeon_shortcuts: - save_context.write_bits(0x00D4 + 0x03 * 0x1C + 0x04 + 0x0, 0x08) # Forest Temple switch flag (Poe Sisters cutscene) + save_context.write_bits(0x00D4 + 0x03 * 0x1C + 0x04 + 0x0, 0x08) # Forest Temple switch flag (Poe Sisters cutscene) if 'Deku Tree' in world.settings.dungeon_shortcuts: # Deku Tree, flags are the same between vanilla/MQ - save_context.write_permanent_flag(Scenes.DEKU_TREE, FlagType.SWITCH, 0x1, 0x01) # Deku Block down - save_context.write_permanent_flag(Scenes.DEKU_TREE, FlagType.CLEAR, 0x2, 0x02) # Deku 231/312 - save_context.write_permanent_flag(Scenes.DEKU_TREE, FlagType.SWITCH, 0x3, 0x20) # Deku 1st Web - save_context.write_permanent_flag(Scenes.DEKU_TREE, FlagType.SWITCH, 0x3, 0x40) # Deku 2nd Web + save_context.write_permanent_flag(Scenes.DEKU_TREE, FlagType.SWITCH, 0x1, 0x01) # Deku Block down + save_context.write_permanent_flag(Scenes.DEKU_TREE, FlagType.CLEAR, 0x2, 0x02) # Deku 231/312 + save_context.write_permanent_flag(Scenes.DEKU_TREE, FlagType.SWITCH, 0x3, 0x20) # Deku 1st Web + save_context.write_permanent_flag(Scenes.DEKU_TREE, FlagType.SWITCH, 0x3, 0x40) # Deku 2nd Web if 'Dodongos Cavern' in world.settings.dungeon_shortcuts: # Dodongo's Cavern, flags are the same between vanilla/MQ - save_context.write_permanent_flag(Scenes.DODONGOS_CAVERN, FlagType.SWITCH, 0x3, 0x80) # DC Entrance Mud Wall - save_context.write_permanent_flag(Scenes.DODONGOS_CAVERN, FlagType.SWITCH, 0x0, 0x04) # DC Mouth + save_context.write_permanent_flag(Scenes.DODONGOS_CAVERN, FlagType.SWITCH, 0x3, 0x80) # DC Entrance Mud Wall + save_context.write_permanent_flag(Scenes.DODONGOS_CAVERN, FlagType.SWITCH, 0x0, 0x04) # DC Mouth # Extra permanent flag in MQ for the child route if world.dungeon_mq['Dodongos Cavern']: - save_context.write_permanent_flag(Scenes.DODONGOS_CAVERN, FlagType.SWITCH, 0x0, 0x02) # Armos wall switch + save_context.write_permanent_flag(Scenes.DODONGOS_CAVERN, FlagType.SWITCH, 0x0, 0x02) # Armos wall switch if 'Jabu Jabus Belly' in world.settings.dungeon_shortcuts: # Jabu if not world.dungeon_mq['Jabu Jabus Belly']: - save_context.write_permanent_flag(Scenes.JABU_JABU, FlagType.SWITCH, 0x0, 0x20) # Jabu Pathway down + save_context.write_permanent_flag(Scenes.JABU_JABU, FlagType.SWITCH, 0x0, 0x20) # Jabu Pathway down else: - save_context.write_permanent_flag(Scenes.JABU_JABU, FlagType.SWITCH, 0x1, 0x20) # Jabu Lobby Slingshot Door open - save_context.write_permanent_flag(Scenes.JABU_JABU, FlagType.SWITCH, 0x0, 0x20) # Jabu Pathway down - save_context.write_permanent_flag(Scenes.JABU_JABU, FlagType.CLEAR, 0x2, 0x01) # Jabu Red Slimy Thing defeated - save_context.write_permanent_flag(Scenes.JABU_JABU, FlagType.SWITCH, 0x2, 0x08) # Jabu Red Slimy Thing not in front of boss lobby - save_context.write_permanent_flag(Scenes.JABU_JABU, FlagType.SWITCH, 0x1, 0x10) # Jabu Boss Door Switch Activated + save_context.write_permanent_flag(Scenes.JABU_JABU, FlagType.SWITCH, 0x1, 0x20) # Jabu Lobby Slingshot Door open + save_context.write_permanent_flag(Scenes.JABU_JABU, FlagType.SWITCH, 0x0, 0x20) # Jabu Pathway down + save_context.write_permanent_flag(Scenes.JABU_JABU, FlagType.CLEAR, 0x2, 0x01) # Jabu Red Slimy Thing defeated + save_context.write_permanent_flag(Scenes.JABU_JABU, FlagType.SWITCH, 0x2, 0x08) # Jabu Red Slimy Thing not in front of boss lobby + save_context.write_permanent_flag(Scenes.JABU_JABU, FlagType.SWITCH, 0x1, 0x10) # Jabu Boss Door Switch Activated if 'Forest Temple' in world.settings.dungeon_shortcuts: # Forest, flags are the same between vanilla/MQ - save_context.write_permanent_flag(Scenes.FOREST_TEMPLE, FlagType.SWITCH, 0x0, 0x10) # Forest Elevator up - save_context.write_permanent_flag(Scenes.FOREST_TEMPLE, FlagType.SWITCH, 0x1, 0x01 + 0x02 + 0x04) # Forest Basement Puzzle Done + save_context.write_permanent_flag(Scenes.FOREST_TEMPLE, FlagType.SWITCH, 0x0, 0x10) # Forest Elevator up + save_context.write_permanent_flag(Scenes.FOREST_TEMPLE, FlagType.SWITCH, 0x1, 0x01 + 0x02 + 0x04) # Forest Basement Puzzle Done if 'Fire Temple' in world.settings.dungeon_shortcuts: # Fire, flags are the same between vanilla/MQ - save_context.write_permanent_flag(Scenes.FIRE_TEMPLE, FlagType.SWITCH, 0x2, 0x40) # Fire Pillar down + save_context.write_permanent_flag(Scenes.FIRE_TEMPLE, FlagType.SWITCH, 0x2, 0x40) # Fire Pillar down if 'Spirit Temple' in world.settings.dungeon_shortcuts: # Spirit if not world.dungeon_mq['Spirit Temple']: - save_context.write_permanent_flag(Scenes.SPIRIT_TEMPLE, FlagType.SWITCH, 0x1, 0x80) # Spirit Chains - save_context.write_permanent_flag(Scenes.SPIRIT_TEMPLE, FlagType.SWITCH, 0x2, 0x02 + 0x08 + 0x10) # Spirit main room elevator (N block, Rusted Switch, E block) - save_context.write_permanent_flag(Scenes.SPIRIT_TEMPLE, FlagType.SWITCH, 0x3, 0x10) # Spirit Face + save_context.write_permanent_flag(Scenes.SPIRIT_TEMPLE, FlagType.SWITCH, 0x1, 0x80) # Spirit Chains + save_context.write_permanent_flag(Scenes.SPIRIT_TEMPLE, FlagType.SWITCH, 0x2, 0x02 + 0x08 + 0x10) # Spirit main room elevator (N block, Rusted Switch, E block) + save_context.write_permanent_flag(Scenes.SPIRIT_TEMPLE, FlagType.SWITCH, 0x3, 0x10) # Spirit Face else: - save_context.write_permanent_flag(Scenes.SPIRIT_TEMPLE, FlagType.SWITCH, 0x2, 0x10) # Spirit Bombchu Boulder - save_context.write_permanent_flag(Scenes.SPIRIT_TEMPLE, FlagType.SWITCH, 0x2, 0x02) # Spirit Silver Block - save_context.write_permanent_flag(Scenes.SPIRIT_TEMPLE, FlagType.SWITCH, 0x1, 0x80) # Spirit Chains - save_context.write_permanent_flag(Scenes.SPIRIT_TEMPLE, FlagType.SWITCH, 0x3, 0x10) # Spirit Face + save_context.write_permanent_flag(Scenes.SPIRIT_TEMPLE, FlagType.SWITCH, 0x2, 0x10) # Spirit Bombchu Boulder + save_context.write_permanent_flag(Scenes.SPIRIT_TEMPLE, FlagType.SWITCH, 0x2, 0x02) # Spirit Silver Block + save_context.write_permanent_flag(Scenes.SPIRIT_TEMPLE, FlagType.SWITCH, 0x1, 0x80) # Spirit Chains + save_context.write_permanent_flag(Scenes.SPIRIT_TEMPLE, FlagType.SWITCH, 0x3, 0x10) # Spirit Face if 'Shadow Temple' in world.settings.dungeon_shortcuts: # Shadow if not world.dungeon_mq['Shadow Temple']: - save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.SWITCH, 0x0, 0x08) # Shadow Truthspinner - save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.SWITCH, 0x0, 0x20) # Shadow Boat Block - save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.SWITCH, 0x1, 0x01) # Shadow Bird Bridge + save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.SWITCH, 0x0, 0x08) # Shadow Truthspinner + save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.SWITCH, 0x0, 0x20) # Shadow Boat Block + save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.SWITCH, 0x1, 0x01) # Shadow Bird Bridge else: - save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.SWITCH, 0x2, 0x08) # Shadow Truthspinner - save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.SWITCH, 0x3, 0x20) # Shadow Fire Arrow Platform - save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.SWITCH, 0x3, 0x80) # Shadow Spinning Blades room Skulltulas defeated - save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.CLEAR, 0x3, 0x40) # Shadow Spinning Blades room Skulltulas defeated - save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.SWITCH, 0x0, 0x20) # Shadow Boat Block - save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.SWITCH, 0x1, 0x01) # Shadow Bird Bridge + save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.SWITCH, 0x2, 0x08) # Shadow Truthspinner + save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.SWITCH, 0x3, 0x20) # Shadow Fire Arrow Platform + save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.SWITCH, 0x3, 0x80) # Shadow Spinning Blades room Skulltulas defeated + save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.CLEAR, 0x3, 0x40) # Shadow Spinning Blades room Skulltulas defeated + save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.SWITCH, 0x0, 0x20) # Shadow Boat Block + save_context.write_permanent_flag(Scenes.SHADOW_TEMPLE, FlagType.SWITCH, 0x1, 0x01) # Shadow Bird Bridge if world.region_has_shortcuts('King Dodongo Boss Room'): - save_context.write_permanent_flag(Scenes.KING_DODONGO_LOBBY, FlagType.SWITCH, 0x3, 0x02) # DC Boss Floor + save_context.write_permanent_flag(Scenes.KING_DODONGO_LOBBY, FlagType.SWITCH, 0x3, 0x02) # DC Boss Floor set_spirit_shortcut_actors(rom) # Change elevator starting position to avoid waiting a half cycle from the temple entrance if world.settings.plant_beans: - save_context.write_permanent_flag(Scenes.GRAVEYARD, FlagType.SWITCH, 0x3, 0x08) # Plant Graveyard bean - save_context.write_permanent_flag(Scenes.ZORAS_RIVER, FlagType.SWITCH, 0x3, 0x08) # Plant Zora's River bean - save_context.write_permanent_flag(Scenes.KOKIRI_FOREST, FlagType.SWITCH, 0x2, 0x02) # Plant Kokiri Forest bean - save_context.write_permanent_flag(Scenes.LAKE_HYLIA, FlagType.SWITCH, 0x3, 0x02) # Plant Lake Hylia bean - save_context.write_permanent_flag(Scenes.GERUDO_VALLEY, FlagType.SWITCH, 0x3, 0x08) # Plant Gerudo Valley bean - save_context.write_permanent_flag(Scenes.LOST_WOODS, FlagType.SWITCH, 0x3, 0x10) # Plant Lost Woods bridge bean - save_context.write_permanent_flag(Scenes.LOST_WOODS, FlagType.SWITCH, 0x1, 0x04) # Plant Lost Woods theater bean - save_context.write_permanent_flag(Scenes.DESERT_COLOSSUS, FlagType.SWITCH, 0x0, 0x1) # Plant Desert Colossus bean - save_context.write_permanent_flag(Scenes.DEATH_MOUNTAIN_TRAIL, FlagType.SWITCH, 0x3, 0x40) # Plant Death Mountain Trail bean - save_context.write_permanent_flag(Scenes.DEATH_MOUNTAIN_CRATER, FlagType.SWITCH, 0x3, 0x08) # Plant Death Mountain Crater bean + save_context.write_permanent_flag(Scenes.GRAVEYARD, FlagType.SWITCH, 0x3, 0x08) # Plant Graveyard bean + save_context.write_permanent_flag(Scenes.ZORAS_RIVER, FlagType.SWITCH, 0x3, 0x08) # Plant Zora's River bean + save_context.write_permanent_flag(Scenes.KOKIRI_FOREST, FlagType.SWITCH, 0x2, 0x02) # Plant Kokiri Forest bean + save_context.write_permanent_flag(Scenes.LAKE_HYLIA, FlagType.SWITCH, 0x3, 0x02) # Plant Lake Hylia bean + save_context.write_permanent_flag(Scenes.GERUDO_VALLEY, FlagType.SWITCH, 0x3, 0x08) # Plant Gerudo Valley bean + save_context.write_permanent_flag(Scenes.LOST_WOODS, FlagType.SWITCH, 0x3, 0x10) # Plant Lost Woods bridge bean + save_context.write_permanent_flag(Scenes.LOST_WOODS, FlagType.SWITCH, 0x1, 0x04) # Plant Lost Woods theater bean + save_context.write_permanent_flag(Scenes.DESERT_COLOSSUS, FlagType.SWITCH, 0x0, 0x1) # Plant Desert Colossus bean + save_context.write_permanent_flag(Scenes.DEATH_MOUNTAIN_TRAIL, FlagType.SWITCH, 0x3, 0x40) # Plant Death Mountain Trail bean + save_context.write_permanent_flag(Scenes.DEATH_MOUNTAIN_CRATER, FlagType.SWITCH, 0x3, 0x08) # Plant Death Mountain Crater bean save_context.write_bits(0x00D4 + 0x05 * 0x1C + 0x04 + 0x1, 0x01) # Water temple switch flag (Ruto) save_context.write_bits(0x00D4 + 0x51 * 0x1C + 0x04 + 0x2, 0x08) # Hyrule Field switch flag (Owl) @@ -1218,67 +1218,67 @@ def set_entrance_updates(entrances): save_context.write_bits(0x00D4 + 0x5F * 0x1C + 0x04 + 0x3, 0x20) # Hyrule Castle switch flag (Owl) save_context.write_bits(0x0F2B, 0x20) # Spoke to Lake Hylia Owl once - save_context.write_bits(0x0ED4, 0x10) # "Met Deku Tree" - save_context.write_bits(0x0ED5, 0x20) # "Deku Tree Opened Mouth" - save_context.write_bits(0x0ED6, 0x08) # "Rented Horse From Ingo" - save_context.write_bits(0x0ED6, 0x10) # "Spoke to Mido After Deku Tree's Death" - save_context.write_bits(0x0EDA, 0x08) # "Began Nabooru Battle" - save_context.write_bits(0x0EDC, 0x80) # "Entered the Master Sword Chamber" - save_context.write_bits(0x0EDD, 0x20) # "Pulled Master Sword from Pedestal" - save_context.write_bits(0x0EE0, 0x80) # "Spoke to Kaepora Gaebora by Lost Woods" - save_context.write_bits(0x0EE7, 0x20) # "Nabooru Captured by Twinrova" - save_context.write_bits(0x0EE7, 0x10) # "Spoke to Nabooru in Spirit Temple" - save_context.write_bits(0x0EED, 0x20) # "Sheik, Spawned at Master Sword Pedestal as Adult" - save_context.write_bits(0x0EED, 0x01) # "Nabooru Ordered to Fight by Twinrova" - save_context.write_bits(0x0EED, 0x80) # "Watched Ganon's Tower Collapse / Caught by Gerudo" - save_context.write_bits(0x0EF9, 0x01) # "Greeted by Saria" - save_context.write_bits(0x0F0A, 0x04) # "Spoke to Ingo Once as Adult" - save_context.write_bits(0x0F0F, 0x40) # "Met Poe Collector in Ruined Market" + save_context.write_bits(0x0ED4, 0x10) # "Met Deku Tree" + save_context.write_bits(0x0ED5, 0x20) # "Deku Tree Opened Mouth" + save_context.write_bits(0x0ED6, 0x08) # "Rented Horse From Ingo" + save_context.write_bits(0x0ED6, 0x10) # "Spoke to Mido After Deku Tree's Death" + save_context.write_bits(0x0EDA, 0x08) # "Began Nabooru Battle" + save_context.write_bits(0x0EDC, 0x80) # "Entered the Master Sword Chamber" + save_context.write_bits(0x0EDD, 0x20) # "Pulled Master Sword from Pedestal" + save_context.write_bits(0x0EE0, 0x80) # "Spoke to Kaepora Gaebora by Lost Woods" + save_context.write_bits(0x0EE7, 0x20) # "Nabooru Captured by Twinrova" + save_context.write_bits(0x0EE7, 0x10) # "Spoke to Nabooru in Spirit Temple" + save_context.write_bits(0x0EED, 0x20) # "Sheik, Spawned at Master Sword Pedestal as Adult" + save_context.write_bits(0x0EED, 0x01) # "Nabooru Ordered to Fight by Twinrova" + save_context.write_bits(0x0EED, 0x80) # "Watched Ganon's Tower Collapse / Caught by Gerudo" + save_context.write_bits(0x0EF9, 0x01) # "Greeted by Saria" + save_context.write_bits(0x0F0A, 0x04) # "Spoke to Ingo Once as Adult" + save_context.write_bits(0x0F0F, 0x40) # "Met Poe Collector in Ruined Market" if not world.settings.useful_cutscenes: - save_context.write_bits(0x0F1A, 0x04) # "Met Darunia in Fire Temple" - - save_context.write_bits(0x0ED7, 0x01) # "Spoke to Child Malon at Castle or Market" - save_context.write_bits(0x0ED7, 0x20) # "Spoke to Child Malon at Ranch" - save_context.write_bits(0x0ED7, 0x40) # "Invited to Sing With Child Malon" - save_context.write_bits(0x0F09, 0x10) # "Met Child Malon at Castle or Market" - save_context.write_bits(0x0F09, 0x20) # "Child Malon Said Epona Was Scared of You" - - save_context.write_bits(0x0F21, 0x04) # "Ruto in JJ (M3) Talk First Time" - save_context.write_bits(0x0F21, 0x02) # "Ruto in JJ (M2) Meet Ruto" - - save_context.write_bits(0x0EE2, 0x01) # "Began Ganondorf Battle" - save_context.write_bits(0x0EE3, 0x80) # "Began Bongo Bongo Battle" - save_context.write_bits(0x0EE3, 0x40) # "Began Barinade Battle" - save_context.write_bits(0x0EE3, 0x20) # "Began Twinrova Battle" - save_context.write_bits(0x0EE3, 0x10) # "Began Morpha Battle" - save_context.write_bits(0x0EE3, 0x08) # "Began Volvagia Battle" - save_context.write_bits(0x0EE3, 0x04) # "Began Phantom Ganon Battle" - save_context.write_bits(0x0EE3, 0x02) # "Began King Dodongo Battle" - save_context.write_bits(0x0EE3, 0x01) # "Began Gohma Battle" - - save_context.write_bits(0x0EE8, 0x01) # "Entered Deku Tree" - save_context.write_bits(0x0EE9, 0x80) # "Entered Temple of Time" - save_context.write_bits(0x0EE9, 0x40) # "Entered Goron City" - save_context.write_bits(0x0EE9, 0x20) # "Entered Hyrule Castle" - save_context.write_bits(0x0EE9, 0x10) # "Entered Zora's Domain" - save_context.write_bits(0x0EE9, 0x08) # "Entered Kakariko Village" - save_context.write_bits(0x0EE9, 0x02) # "Entered Death Mountain Trail" - save_context.write_bits(0x0EE9, 0x01) # "Entered Hyrule Field" - save_context.write_bits(0x0EEA, 0x04) # "Entered Ganon's Castle (Exterior)" - save_context.write_bits(0x0EEA, 0x02) # "Entered Death Mountain Crater" - save_context.write_bits(0x0EEA, 0x01) # "Entered Desert Colossus" - save_context.write_bits(0x0EEB, 0x80) # "Entered Zora's Fountain" - save_context.write_bits(0x0EEB, 0x40) # "Entered Graveyard" - save_context.write_bits(0x0EEB, 0x20) # "Entered Jabu-Jabu's Belly" - save_context.write_bits(0x0EEB, 0x10) # "Entered Lon Lon Ranch" - save_context.write_bits(0x0EEB, 0x08) # "Entered Gerudo's Fortress" - save_context.write_bits(0x0EEB, 0x04) # "Entered Gerudo Valley" - save_context.write_bits(0x0EEB, 0x02) # "Entered Lake Hylia" - save_context.write_bits(0x0EEB, 0x01) # "Entered Dodongo's Cavern" - save_context.write_bits(0x0F08, 0x08) # "Entered Hyrule Castle" + save_context.write_bits(0x0F1A, 0x04) # "Met Darunia in Fire Temple" + + save_context.write_bits(0x0ED7, 0x01) # "Spoke to Child Malon at Castle or Market" + save_context.write_bits(0x0ED7, 0x20) # "Spoke to Child Malon at Ranch" + save_context.write_bits(0x0ED7, 0x40) # "Invited to Sing With Child Malon" + save_context.write_bits(0x0F09, 0x10) # "Met Child Malon at Castle or Market" + save_context.write_bits(0x0F09, 0x20) # "Child Malon Said Epona Was Scared of You" + + save_context.write_bits(0x0F21, 0x04) # "Ruto in JJ (M3) Talk First Time" + save_context.write_bits(0x0F21, 0x02) # "Ruto in JJ (M2) Meet Ruto" + + save_context.write_bits(0x0EE2, 0x01) # "Began Ganondorf Battle" + save_context.write_bits(0x0EE3, 0x80) # "Began Bongo Bongo Battle" + save_context.write_bits(0x0EE3, 0x40) # "Began Barinade Battle" + save_context.write_bits(0x0EE3, 0x20) # "Began Twinrova Battle" + save_context.write_bits(0x0EE3, 0x10) # "Began Morpha Battle" + save_context.write_bits(0x0EE3, 0x08) # "Began Volvagia Battle" + save_context.write_bits(0x0EE3, 0x04) # "Began Phantom Ganon Battle" + save_context.write_bits(0x0EE3, 0x02) # "Began King Dodongo Battle" + save_context.write_bits(0x0EE3, 0x01) # "Began Gohma Battle" + + save_context.write_bits(0x0EE8, 0x01) # "Entered Deku Tree" + save_context.write_bits(0x0EE9, 0x80) # "Entered Temple of Time" + save_context.write_bits(0x0EE9, 0x40) # "Entered Goron City" + save_context.write_bits(0x0EE9, 0x20) # "Entered Hyrule Castle" + save_context.write_bits(0x0EE9, 0x10) # "Entered Zora's Domain" + save_context.write_bits(0x0EE9, 0x08) # "Entered Kakariko Village" + save_context.write_bits(0x0EE9, 0x02) # "Entered Death Mountain Trail" + save_context.write_bits(0x0EE9, 0x01) # "Entered Hyrule Field" + save_context.write_bits(0x0EEA, 0x04) # "Entered Ganon's Castle (Exterior)" + save_context.write_bits(0x0EEA, 0x02) # "Entered Death Mountain Crater" + save_context.write_bits(0x0EEA, 0x01) # "Entered Desert Colossus" + save_context.write_bits(0x0EEB, 0x80) # "Entered Zora's Fountain" + save_context.write_bits(0x0EEB, 0x40) # "Entered Graveyard" + save_context.write_bits(0x0EEB, 0x20) # "Entered Jabu-Jabu's Belly" + save_context.write_bits(0x0EEB, 0x10) # "Entered Lon Lon Ranch" + save_context.write_bits(0x0EEB, 0x08) # "Entered Gerudo's Fortress" + save_context.write_bits(0x0EEB, 0x04) # "Entered Gerudo Valley" + save_context.write_bits(0x0EEB, 0x02) # "Entered Lake Hylia" + save_context.write_bits(0x0EEB, 0x01) # "Entered Dodongo's Cavern" + save_context.write_bits(0x0F08, 0x08) # "Entered Hyrule Castle" if world.dungeon_mq['Shadow Temple']: - save_context.write_bits(0x019F, 0x80) # "Turn On Clear Wall Blocking Hover Boots Room" + save_context.write_bits(0x019F, 0x80) # "Turn On Clear Wall Blocking Hover Boots Room" # Set the number of chickens to collect rom.write_byte(0x00E1E523, world.settings.chicken_count) @@ -1294,7 +1294,7 @@ def set_entrance_updates(entrances): # Make the Kakariko Gate not open with the MS if world.settings.open_kakariko != 'open': - rom.write_int32(0xDD3538, 0x34190000) # li t9, 0 + rom.write_int32(0xDD3538, 0x34190000) # li t9, 0 if world.settings.open_kakariko != 'closed': rom.write_byte(rom.sym('OPEN_KAKARIKO'), 1) @@ -1351,27 +1351,26 @@ def calculate_traded_flags(world): save_context.write_bits(0x00D4 + 0x5F * 0x1C + 0x04 + 0x3, 0x10) # "Moved crates to access the courtyard" if world.skip_child_zelda or "Zeldas Letter" in world.distribution.starting_items.keys(): if world.settings.open_kakariko != 'closed': - save_context.write_bits(0x0F07, 0x40) # "Spoke to Gate Guard About Mask Shop" + save_context.write_bits(0x0F07, 0x40) # "Spoke to Gate Guard About Mask Shop" if world.settings.complete_mask_quest: - save_context.write_bits(0x0F07, 0x80) # "Soldier Wears Keaton Mask" - save_context.write_bits(0x0EF6, 0x8F) # "Sold Masks & Unlocked Masks" / "Obtained Mask of Truth" - save_context.write_bits(0x0EE4, 0xF0) # "Paid Back Mask Fees" + save_context.write_bits(0x0F07, 0x80) # "Soldier Wears Keaton Mask" + save_context.write_bits(0x0EF6, 0x8F) # "Sold Masks & Unlocked Masks" / "Obtained Mask of Truth" + save_context.write_bits(0x0EE4, 0xF0) # "Paid Back Mask Fees" if world.settings.zora_fountain == 'open': - save_context.write_bits(0x0EDB, 0x08) # "Moved King Zora" + save_context.write_bits(0x0EDB, 0x08) # "Moved King Zora" elif world.settings.zora_fountain == 'adult': rom.write_byte(rom.sym('MOVED_ADULT_KING_ZORA'), 1) # Make all chest opening animations fast rom.write_byte(rom.sym('FAST_CHESTS'), int(world.settings.fast_chests)) - # Set up Rainbow Bridge conditions symbol = rom.sym('RAINBOW_BRIDGE_CONDITION') count_symbol = rom.sym('RAINBOW_BRIDGE_COUNT') if world.settings.bridge == 'open': rom.write_int32(symbol, 0) - save_context.write_bits(0xEDC, 0x20) # "Rainbow Bridge Built by Sages" + save_context.write_bits(0xEDC, 0x20) # "Rainbow Bridge Built by Sages" elif world.settings.bridge == 'medallions': rom.write_int32(symbol, 1) rom.write_int16(count_symbol, world.settings.bridge_medallions) @@ -1443,10 +1442,10 @@ def calculate_traded_flags(world): rom.write_int32(symbol, 0) if world.settings.open_forest == 'open': - save_context.write_bits(0xED5, 0x10) # "Showed Mido Sword & Shield" + save_context.write_bits(0xED5, 0x10) # "Showed Mido Sword & Shield" if world.settings.open_door_of_time: - save_context.write_bits(0xEDC, 0x08) # "Opened the Door of Time" + save_context.write_bits(0xEDC, 0x08) # "Opened the Door of Time" # "fast-ganon" stuff symbol = rom.sym('NO_ESCAPE_SEQUENCE') @@ -1457,35 +1456,35 @@ def calculate_traded_flags(world): else: rom.write_byte(symbol, 0x00) if world.skipped_trials['Forest']: - save_context.write_bits(0x0EEA, 0x08) # "Completed Forest Trial" + save_context.write_bits(0x0EEA, 0x08) # "Completed Forest Trial" if world.skipped_trials['Fire']: - save_context.write_bits(0x0EEA, 0x40) # "Completed Fire Trial" + save_context.write_bits(0x0EEA, 0x40) # "Completed Fire Trial" if world.skipped_trials['Water']: - save_context.write_bits(0x0EEA, 0x10) # "Completed Water Trial" + save_context.write_bits(0x0EEA, 0x10) # "Completed Water Trial" if world.skipped_trials['Spirit']: - save_context.write_bits(0x0EE8, 0x20) # "Completed Spirit Trial" + save_context.write_bits(0x0EE8, 0x20) # "Completed Spirit Trial" if world.skipped_trials['Shadow']: - save_context.write_bits(0x0EEA, 0x20) # "Completed Shadow Trial" + save_context.write_bits(0x0EEA, 0x20) # "Completed Shadow Trial" if world.skipped_trials['Light']: - save_context.write_bits(0x0EEA, 0x80) # "Completed Light Trial" + save_context.write_bits(0x0EEA, 0x80) # "Completed Light Trial" if world.settings.trials == 0: - save_context.write_bits(0x0EED, 0x08) # "Dispelled Ganon's Tower Barrier" + save_context.write_bits(0x0EED, 0x08) # "Dispelled Ganon's Tower Barrier" # open gerudo fortress if world.settings.gerudo_fortress == 'open': if not world.settings.shuffle_gerudo_card: - save_context.write_bits(0x00A5, 0x40) # Give Gerudo Card - save_context.write_bits(0x0EE7, 0x0F) # Free all 4 carpenters - save_context.write_bits(0x00D4 + 0x0C * 0x1C + 0x04 + 0x1, 0x0F) # Thieves' Hideout switch flags (started all fights) - save_context.write_bits(0x00D4 + 0x0C * 0x1C + 0x04 + 0x2, 0x01) # Thieves' Hideout switch flags (heard yells/unlocked doors) - save_context.write_bits(0x00D4 + 0x0C * 0x1C + 0x04 + 0x3, 0xFE) # Thieves' Hideout switch flags (heard yells/unlocked doors) - save_context.write_bits(0x00D4 + 0x0C * 0x1C + 0x0C + 0x2, 0xD4) # Thieves' Hideout collection flags (picked up keys, marks fights finished as well) + save_context.write_bits(0x00A5, 0x40) # Give Gerudo Card + save_context.write_bits(0x0EE7, 0x0F) # Free all 4 carpenters + save_context.write_bits(0x00D4 + 0x0C * 0x1C + 0x04 + 0x1, 0x0F) # Thieves' Hideout switch flags (started all fights) + save_context.write_bits(0x00D4 + 0x0C * 0x1C + 0x04 + 0x2, 0x01) # Thieves' Hideout switch flags (heard yells/unlocked doors) + save_context.write_bits(0x00D4 + 0x0C * 0x1C + 0x04 + 0x3, 0xFE) # Thieves' Hideout switch flags (heard yells/unlocked doors) + save_context.write_bits(0x00D4 + 0x0C * 0x1C + 0x0C + 0x2, 0xD4) # Thieves' Hideout collection flags (picked up keys, marks fights finished as well) elif world.settings.gerudo_fortress == 'fast': - save_context.write_bits(0x0EE7, 0x0E) # Free 3 carpenters - save_context.write_bits(0x00D4 + 0x0C * 0x1C + 0x04 + 0x1, 0x0D) # Thieves' Hideout switch flags (started all fights) - save_context.write_bits(0x00D4 + 0x0C * 0x1C + 0x04 + 0x2, 0x01) # Thieves' Hideout switch flags (heard yells/unlocked doors) - save_context.write_bits(0x00D4 + 0x0C * 0x1C + 0x04 + 0x3, 0xDC) # Thieves' Hideout switch flags (heard yells/unlocked doors) - save_context.write_bits(0x00D4 + 0x0C * 0x1C + 0x0C + 0x2, 0xC4) # Thieves' Hideout collection flags (picked up keys, marks fights finished as well) + save_context.write_bits(0x0EE7, 0x0E) # Free 3 carpenters + save_context.write_bits(0x00D4 + 0x0C * 0x1C + 0x04 + 0x1, 0x0D) # Thieves' Hideout switch flags (started all fights) + save_context.write_bits(0x00D4 + 0x0C * 0x1C + 0x04 + 0x2, 0x01) # Thieves' Hideout switch flags (heard yells/unlocked doors) + save_context.write_bits(0x00D4 + 0x0C * 0x1C + 0x04 + 0x3, 0xDC) # Thieves' Hideout switch flags (heard yells/unlocked doors) + save_context.write_bits(0x00D4 + 0x0C * 0x1C + 0x0C + 0x2, 0xC4) # Thieves' Hideout collection flags (picked up keys, marks fights finished as well) # Add a gate opening guard on the Wasteland side of the Gerudo Fortress' gate # Overrides the generic guard at the bottom of the ladder in Gerudo Fortress @@ -1539,15 +1538,15 @@ def calculate_traded_flags(world): # For Inventory_SwapAgeEquipment going child -> adult: # Change range in which items are read from slot instead of items # Extended to include hookshot and ocarina - rom.write_byte(0xAE5867, 0x07) # >= ITEM_OCARINA_FAIRY - rom.write_byte(0xAE5873, 0x0C) # <= ITEM_LONGSHOT - rom.write_byte(0xAE587B, 0x14) # >= ITEM_BOTTLE + rom.write_byte(0xAE5867, 0x07) # >= ITEM_OCARINA_FAIRY + rom.write_byte(0xAE5873, 0x0C) # <= ITEM_LONGSHOT + rom.write_byte(0xAE587B, 0x14) # >= ITEM_BOTTLE # Revert change that Skips the Epona Race if not world.settings.no_epona_race: rom.write_int32(0xA9E838, 0x03E00008) else: - save_context.write_bits(0xF0E, 0x01) # Set talked to Malon flag + save_context.write_bits(0xF0E, 0x01) # Set talked to Malon flag # skip castle guard stealth sequence if world.settings.no_guard_stealth: @@ -1591,11 +1590,7 @@ def calculate_traded_flags(world): ### Load Shop File # Move shop actor file to free space - shop_item_file = File({ - 'Name':'En_GirlA', - 'Start':'00C004E0', - 'End':'00C02E00', - }) + shop_item_file = File('En_GirlA', 0x00C004E0, 0x00C02E00) shop_item_file.relocate(rom) # Increase the shop item table size @@ -1620,19 +1615,15 @@ def calculate_traded_flags(world): update_dmadata(rom, shop_item_file) # Create 2nd Bazaar Room - bazaar_room_file = File({ - 'Name':'shop1_room_1', - 'Start':'028E4000', - 'End':'0290D7B0', - }) + bazaar_room_file = File('shop1_room_1', 0x028E4000, 0x0290D7B0) bazaar_room_file.copy(rom) # Add new Bazaar Room to Bazaar Scene - rom.write_int32s(0x28E3030, [0x00010000, 0x02000058]) #reduce position list size - rom.write_int32s(0x28E3008, [0x04020000, 0x02000070]) #expand room list size + rom.write_int32s(0x28E3030, [0x00010000, 0x02000058]) # reduce position list size + rom.write_int32s(0x28E3008, [0x04020000, 0x02000070]) # expand room list size rom.write_int32s(0x28E3070, [0x028E4000, 0x0290D7B0, - bazaar_room_file.start, bazaar_room_file.end]) #room list + bazaar_room_file.start, bazaar_room_file.end]) # room list rom.write_int16s(0x28E3080, [0x0000, 0x0001]) # entrance list rom.write_int16(0x28E4076, 0x0005) # Change shop to Kakariko Bazaar #rom.write_int16(0x3489076, 0x0005) # Change shop to Kakariko Bazaar @@ -1642,8 +1633,9 @@ def calculate_traded_flags(world): sum(f'{shop} Item {idx}' in world.shop_prices for idx in ('7', '5', '8', '6')) # number of special deals in this shop for shop in ( - 'KF Shop', 'Market Bazaar', 'Market Potion Shop', 'Market Bombchu Shop', 'Kak Bazaar', 'Kak Potion Shop', - 'GC Shop', 'ZD Shop') + 'KF Shop', 'Market Bazaar', 'Market Potion Shop', 'Market Bombchu Shop', 'Kak Bazaar', 'Kak Potion Shop', + 'GC Shop', 'ZD Shop' + ) ]) # Load Message and Shop Data @@ -1704,10 +1696,10 @@ def calculate_traded_flags(world): reward_text = None elif location.item.looks_like_item is not None: jabu_item = location.item.looks_like_item - reward_text = create_fake_name(getHint(getItemGenericName(location.item.looks_like_item), True).text) + reward_text = create_fake_name(get_hint(get_item_generic_name(location.item.looks_like_item), True).text) else: jabu_item = location.item - reward_text = getHint(getItemGenericName(location.item), True).text + reward_text = get_hint(get_item_generic_name(location.item), True).text # Update "Princess Ruto got the Spiritual Stone!" text before the midboss in Jabu if reward_text is None: @@ -1746,7 +1738,7 @@ def calculate_traded_flags(world): rom.write_byte(symbol, 0x01) if world.settings.skip_some_minigame_phases: - save_context.write_bits(0x00D4 + 0x48 * 0x1C + 0x08 + 0x3, 0x10) # Beat First Dampe Race (& Chest Spawned) + save_context.write_bits(0x00D4 + 0x48 * 0x1C + 0x08 + 0x3, 0x10) # Beat First Dampe Race (& Chest Spawned) rom.write_byte(rom.sym('CHAIN_HBA_REWARDS'), 1) # Update the first horseback archery text to make it clear both rewards are available from the start update_message_by_id(messages, 0x6040, "Hey newcomer, you have a fine \x01horse!\x04I don't know where you stole \x01it from, but...\x04OK, how about challenging this \x01\x05\x41horseback archery\x05\x40?\x04Once the horse starts galloping,\x01shoot the targets with your\x01arrows. \x04Let's see how many points you \x01can score. You get 20 arrows.\x04If you can score \x05\x411,000 points\x05\x40, I will \x01give you something good! And even \x01more if you score \x05\x411,500 points\x05\x40!\x0B\x02") @@ -1757,12 +1749,12 @@ def calculate_traded_flags(world): # Sets hooks for gossip stone changes - symbol = rom.sym("GOSSIP_HINT_CONDITION"); + symbol = rom.sym("GOSSIP_HINT_CONDITION") if world.settings.hints == 'none': rom.write_int32(symbol, 0) else: - writeGossipStoneHints(spoiler, world, messages) + write_gossip_stone_hints(spoiler, world, messages) if world.settings.hints == 'mask': rom.write_int32(symbol, 0) @@ -1771,16 +1763,15 @@ def calculate_traded_flags(world): else: rom.write_int32(symbol, 1) - # build silly ganon lines if 'ganondorf' in world.settings.misc_hints: - buildGanonText(world, messages) + build_ganon_text(world, messages) # build misc. item hints - buildMiscItemHints(world, messages) + build_misc_item_hints(world, messages) # build misc. location hints - buildMiscLocationHints(world, messages) + build_misc_location_hints(world, messages) if 'mask_shop' in world.settings.misc_hints: rom.write_int32(rom.sym('CFG_MASK_SHOP_HINT'), 1) @@ -1793,7 +1784,7 @@ def calculate_traded_flags(world): # Patch freestanding items if world.settings.shuffle_freestanding_items: - # Get freestanding item locations + # Get freestanding item locations actor_override_locations = [location for location in world.get_locations() if location.disabled == DisableType.ENABLED and location.type == 'ActorOverride'] rupeetower_locations = [location for location in world.get_locations() if location.disabled == DisableType.ENABLED and location.type == 'RupeeTower'] @@ -1832,7 +1823,7 @@ def calculate_traded_flags(world): if len(override_table) >= 1536: raise RuntimeError(f'Exceeded override table size: {len(override_table)}') rom.write_bytes(rom.sym('cfg_item_overrides'), get_override_table_bytes(override_table)) - rom.write_byte(rom.sym('PLAYER_ID'), world.id + 1) # Write player ID + rom.write_byte(rom.sym('PLAYER_ID'), world.id + 1) # Write player ID # Revert Song Get Override Injection if not songs_as_items: @@ -1848,7 +1839,6 @@ def calculate_traded_flags(world): # song of time rom.write_int32(0xDB532C, 0x24050003) - # Set damage multiplier if world.settings.damage_multiplier == 'half': rom.write_byte(rom.sym('CFG_DAMAGE_MULTIPLYER'), 0xFF) @@ -1888,37 +1878,37 @@ def calculate_traded_flags(world): rom.write_byte(secondaryaddress, next_song_id) if location.name == 'Song from Impa': rom.write_byte(0x0D12ECB, special['item_id']) - rom.write_byte(0x2E8E931, special['text_id']) #Fix text box + rom.write_byte(0x2E8E931, special['text_id']) # Fix text box elif location.name == 'Song from Malon': rom.write_byte(rom.sym('MALON_TEXT_ID'), special['text_id']) elif location.name == 'Song from Royal Familys Tomb': rom.write_int16(0xE09F66, bit_mask_pointer) - rom.write_byte(0x332A87D, special['text_id']) #Fix text box + rom.write_byte(0x332A87D, special['text_id']) # Fix text box elif location.name == 'Song from Saria': rom.write_byte(0x0E2A02B, special['item_id']) - rom.write_byte(0x20B1DBD, special['text_id']) #Fix text box + rom.write_byte(0x20B1DBD, special['text_id']) # Fix text box elif location.name == 'Song from Ocarina of Time': - rom.write_byte(0x252FC95, special['text_id']) #Fix text box + rom.write_byte(0x252FC95, special['text_id']) # Fix text box elif location.name == 'Song from Windmill': rom.write_byte(rom.sym('WINDMILL_SONG_ID'), next_song_id) rom.write_byte(rom.sym('WINDMILL_TEXT_ID'), special['text_id']) elif location.name == 'Sheik in Forest': rom.write_byte(0x0C7BAA3, special['item_id']) - rom.write_byte(0x20B0815, special['text_id']) #Fix text box + rom.write_byte(0x20B0815, special['text_id']) # Fix text box elif location.name == 'Sheik at Temple': rom.write_byte(0x0C805EF, special['item_id']) - rom.write_byte(0x2531335, special['text_id']) #Fix text box + rom.write_byte(0x2531335, special['text_id']) # Fix text box elif location.name == 'Sheik in Crater': rom.write_byte(0x0C7BC57, special['item_id']) - rom.write_byte(0x224D7FD, special['text_id']) #Fix text box + rom.write_byte(0x224D7FD, special['text_id']) # Fix text box elif location.name == 'Sheik in Ice Cavern': rom.write_byte(0x0C7BD77, special['item_id']) - rom.write_byte(0x2BEC895, special['text_id']) #Fix text box + rom.write_byte(0x2BEC895, special['text_id']) # Fix text box elif location.name == 'Sheik in Kakariko': rom.write_byte(0x0AC9A5B, special['item_id']) - rom.write_byte(0x2000FED, special['text_id']) #Fix text box + rom.write_byte(0x2000FED, special['text_id']) # Fix text box elif location.name == 'Sheik at Colossus': - rom.write_byte(0x218C589, special['text_id']) #Fix text box + rom.write_byte(0x218C589, special['text_id']) # Fix text box elif location.type == 'Boss' and location.name != 'Links Pocket': rom.write_byte(locationaddress, special['item_id']) rom.write_byte(secondaryaddress, special['addr2_data']) @@ -1960,9 +1950,8 @@ def calculate_traded_flags(world): # kokiri shop shop_locations = [location for location in world.get_region('KF Kokiri Shop').locations if location.type == 'Shop'] # Need to filter because of the freestanding item in KF Shop - shop_objs = place_shop_items(rom, world, shop_items, messages, - shop_locations, True) - shop_objs |= {0x00FC, 0x00B2, 0x0101, 0x0102, 0x00FD, 0x00C5} # Shop objects + shop_objs = place_shop_items(rom, world, shop_items, messages, shop_locations, True) + shop_objs |= {0x00FC, 0x00B2, 0x0101, 0x0102, 0x00FD, 0x00C5} # Shop objects rom.write_byte(0x2587029, len(shop_objs)) rom.write_int32(0x258702C, 0x0300F600) rom.write_int16s(0x2596600, list(shop_objs)) @@ -1970,7 +1959,7 @@ def calculate_traded_flags(world): # kakariko bazaar shop_objs = place_shop_items(rom, world, shop_items, messages, world.get_region('Kak Bazaar').locations) - shop_objs |= {0x005B, 0x00B2, 0x00C5, 0x0107, 0x00C9, 0x016B} # Shop objects + shop_objs |= {0x005B, 0x00B2, 0x00C5, 0x0107, 0x00C9, 0x016B} # Shop objects rom.write_byte(0x28E4029, len(shop_objs)) rom.write_int32(0x28E402C, 0x03007A40) rom.write_int16s(0x28EBA40, list(shop_objs)) @@ -1978,7 +1967,7 @@ def calculate_traded_flags(world): # castle town bazaar shop_objs = place_shop_items(rom, world, shop_items, messages, world.get_region('Market Bazaar').locations) - shop_objs |= {0x005B, 0x00B2, 0x00C5, 0x0107, 0x00C9, 0x016B} # Shop objects + shop_objs |= {0x005B, 0x00B2, 0x00C5, 0x0107, 0x00C9, 0x016B} # Shop objects rom.write_byte(bazaar_room_file.start + 0x29, len(shop_objs)) rom.write_int32(bazaar_room_file.start + 0x2C, 0x03007A40) rom.write_int16s(bazaar_room_file.start + 0x7A40, list(shop_objs)) @@ -1986,7 +1975,7 @@ def calculate_traded_flags(world): # goron shop shop_objs = place_shop_items(rom, world, shop_items, messages, world.get_region('GC Shop').locations) - shop_objs |= {0x00C9, 0x00B2, 0x0103, 0x00AF} # Shop objects + shop_objs |= {0x00C9, 0x00B2, 0x0103, 0x00AF} # Shop objects rom.write_byte(0x2D33029, len(shop_objs)) rom.write_int32(0x2D3302C, 0x03004340) rom.write_int16s(0x2D37340, list(shop_objs)) @@ -1994,7 +1983,7 @@ def calculate_traded_flags(world): # zora shop shop_objs = place_shop_items(rom, world, shop_items, messages, world.get_region('ZD Shop').locations) - shop_objs |= {0x005B, 0x00B2, 0x0104, 0x00FE} # Shop objects + shop_objs |= {0x005B, 0x00B2, 0x0104, 0x00FE} # Shop objects rom.write_byte(0x2D5B029, len(shop_objs)) rom.write_int32(0x2D5B02C, 0x03004B40) rom.write_int16s(0x2D5FB40, list(shop_objs)) @@ -2002,7 +1991,7 @@ def calculate_traded_flags(world): # kakariko potion shop shop_objs = place_shop_items(rom, world, shop_items, messages, world.get_region('Kak Potion Shop Front').locations) - shop_objs |= {0x0159, 0x00B2, 0x0175, 0x0122} # Shop objects + shop_objs |= {0x0159, 0x00B2, 0x0175, 0x0122} # Shop objects rom.write_byte(0x2D83029, len(shop_objs)) rom.write_int32(0x2D8302C, 0x0300A500) rom.write_int16s(0x2D8D500, list(shop_objs)) @@ -2010,7 +1999,7 @@ def calculate_traded_flags(world): # market potion shop shop_objs = place_shop_items(rom, world, shop_items, messages, world.get_region('Market Potion Shop').locations) - shop_objs |= {0x0159, 0x00B2, 0x0175, 0x00C5, 0x010C, 0x016B} # Shop objects + shop_objs |= {0x0159, 0x00B2, 0x0175, 0x00C5, 0x010C, 0x016B} # Shop objects rom.write_byte(0x2DB0029, len(shop_objs)) rom.write_int32(0x2DB002C, 0x03004E40) rom.write_int16s(0x2DB4E40, list(shop_objs)) @@ -2018,7 +2007,7 @@ def calculate_traded_flags(world): # bombchu shop shop_objs = place_shop_items(rom, world, shop_items, messages, world.get_region('Market Bombchu Shop').locations) - shop_objs |= {0x0165, 0x00B2} # Shop objects + shop_objs |= {0x0165, 0x00B2} # Shop objects rom.write_byte(0x2DD8029, len(shop_objs)) rom.write_int32(0x2DD802C, 0x03006A40) rom.write_int16s(0x2DDEA40, list(shop_objs)) @@ -2032,7 +2021,8 @@ def calculate_traded_flags(world): rom.write_int16s(0x3417400, list(shop_objs)) # Scrub text stuff. - def update_scrub_text(message, text_replacement, default_price, price, item_name=None): + def update_scrub_text(message: bytearray, text_replacement: List[str], default_price: int, price: int, + item_name: Optional[str] = None) -> bytearray: scrub_strip_text = ["some ", "1 piece ", "5 pieces ", "30 pieces "] for text in scrub_strip_text: message = message.replace(text.encode(), b'') @@ -2059,8 +2049,7 @@ def update_scrub_text(message, text_replacement, default_price, price, item_name 0x14410004, # bne v0, at, 0xd8 0x2463A5D0, # addiu v1, v1, -0x5a30 0x94790EF0]) # lhu t9, 0xef0(v1) - rom.write_int32(0xDF7CB0, - 0xA44F0EF0) # sh t7, 0xef0(v0) + rom.write_int32(0xDF7CB0, 0xA44F0EF0) # sh t7, 0xef0(v0) # Replace scrub text for 3 default shuffled scrubs. for (scrub_item, default_price, text_id, text_replacement) in business_scrubs: @@ -2115,7 +2104,7 @@ def update_scrub_text(message, text_replacement, default_price, price, item_name update_message_by_id(messages, 0x405E, "\x1AChomp chomp chomp...\x01We have... \x05\x41a mysterious item\x05\x40! \x01Do you want it...huh? Huh?\x04\x05\x41\x0860 Rupees\x05\x40 and it's yours!\x01Keyahahah!\x01\x1B\x05\x42Yes\x01No\x05\x40\x02") else: location = world.get_location("ZR Magic Bean Salesman") - item_text = getHint(getItemGenericName(location.item), True).text + item_text = get_hint(get_item_generic_name(location.item), True).text update_message_by_id(messages, 0x405E, "\x1AChomp chomp chomp...We have...\x01\x05\x41" + item_text + "\x05\x40! \x01Do you want it...huh? Huh?\x04\x05\x41\x0860 Rupees\x05\x40 and it's yours!\x01Keyahahah!\x01\x1B\x05\x42Yes\x01No\x05\x40\x02") update_message_by_id(messages, 0x4069, "You don't have enough money.\x01I can't sell it to you.\x01Chomp chomp...\x02") update_message_by_id(messages, 0x406C, "We hope you like it!\x01Chomp chomp chomp.\x02") @@ -2129,7 +2118,7 @@ def update_scrub_text(message, text_replacement, default_price, price, item_name update_message_by_id(messages, 0x6077, "\x06\x41Well Come!\x04I am selling stuff, strange and \x01rare, from all over the world to \x01everybody.\x01Today's special is...\x04A mysterious item! \x01Intriguing! \x01I won't tell you what it is until \x01I see the money....\x04How about \x05\x41200 Rupees\x05\x40?\x01\x01\x1B\x05\x42Buy\x01Don't buy\x05\x40\x02") else: location = world.get_location("Wasteland Bombchu Salesman") - item_text = getHint(getItemGenericName(location.item), True).text + item_text = get_hint(get_item_generic_name(location.item), True).text update_message_by_id(messages, 0x6077, "\x06\x41Well Come!\x04I am selling stuff, strange and \x01rare, from all over the world to \x01everybody. Today's special is...\x01\x05\x41"+ item_text + "\x05\x40! \x01\x04How about \x05\x41200 Rupees\x05\x40?\x01\x01\x1B\x05\x42Buy\x01Don't buy\x05\x40\x02") update_message_by_id(messages, 0x6078, "Thank you very much!\x04The mark that will lead you to\x01the Spirit Temple is the \x05\x41flag on\x01the left \x05\x40outside the shop.\x01Be seeing you!\x02") @@ -2141,7 +2130,7 @@ def update_scrub_text(message, text_replacement, default_price, price, item_name update_message_by_id(messages, 0x304F, "How about buying this cool item for \x01200 Rupees?\x01\x1B\x05\x42Buy\x01Don't buy\x05\x40\x02") else: location = world.get_location("GC Medigoron") - item_text = getHint(getItemGenericName(location.item), True).text + item_text = get_hint(get_item_generic_name(location.item), True).text update_message_by_id(messages, 0x304F, "For 200 Rupees, how about buying \x01\x05\x41" + item_text + "\x05\x40?\x01\x1B\x05\x42Buy\x01Don't buy\x05\x40\x02") rom.write_byte(rom.sym('SHUFFLE_GRANNYS_POTION_SHOP'), 0x01) @@ -2149,7 +2138,7 @@ def update_scrub_text(message, text_replacement, default_price, price, item_name update_message_by_id(messages, 0x500C, "Mysterious item! How about\x01\x05\x41100 Rupees\x05\x40?\x01\x1B\x05\x42Buy\x01Don't buy\x05\x40\x02") else: location = world.get_location("Kak Granny Buy Blue Potion") - item_text = getHint(getItemGenericName(location.item), True).text + item_text = get_hint(get_item_generic_name(location.item), True).text update_message_by_id(messages, 0x500C, "How about \x05\x41100 Rupees\x05\x40 for\x01\x05\x41"+ item_text +"\x05\x40?\x01\x1B\x05\x42Buy\x01Don't buy\x05\x40\x02") new_message = "All right. You don't have to play\x01if you don't want to.\x0B\x02" @@ -2164,7 +2153,7 @@ def update_scrub_text(message, text_replacement, default_price, price, item_name update_message_by_id(messages, 0x6D, "I seem to have misplaced my\x01keys, but I have a fun item to\x01sell instead.\x04How about \x05\x4110 Rupees\x05\x40?\x01\x01\x1B\x05\x42Buy\x01Don't Buy\x05\x40\x02") else: location = world.get_location("Market Treasure Chest Game Salesman") - item_text = getHint(getItemGenericName(location.item), True).text + item_text = get_hint(get_item_generic_name(location.item), True).text update_message_by_id(messages, 0x6D, "I seem to have misplaced my\x01keys, but I have a fun item to\x01sell instead.\x04How about \x05\x4110 Rupees\x05\x40 for\x01\x05\x41" + item_text + "\x05\x40?\x01\x1B\x05\x42Buy\x01Don't Buy\x05\x40\x02") update_message_by_id(messages, 0x908B, "That's OK!\x01More fun for me.\x0B\x02", 0x00) update_message_by_id(messages, 0x6E, "Wait, that room was off limits!\x02") @@ -2303,7 +2292,7 @@ def update_scrub_text(message, text_replacement, default_price, price, item_name elif dungeon in ('Bottom of the Well', 'Ice Cavern'): dungeon_name, boss_name, compass_id, map_id = dungeon_list[dungeon] if world.settings.world_count > 1: - map_message = "\x13\x76\x08\x05\x42\x0F\x05\x40 found the \x05\x41Dungeon Map\x05\x40\x01for %s\x05\x40!\x09" % (dungeon_name) + map_message = "\x13\x76\x08\x05\x42\x0F\x05\x40 found the \x05\x41Dungeon Map\x05\x40\x01for %s\x05\x40!\x09" % dungeon_name else: map_message = "\x13\x76\x08You found the \x05\x41Dungeon Map\x05\x40\x01for %s\x05\x40!\x01It\'s %s!\x09" % (dungeon_name, "masterful" if world.dungeon_mq[dungeon] else "ordinary") @@ -2326,7 +2315,7 @@ def update_scrub_text(message, text_replacement, default_price, price, item_name update_message_by_id(messages, compass_id, compass_message) if world.settings.mq_dungeons_mode == 'random' or world.settings.mq_dungeons_count != 0 and world.settings.mq_dungeons_count != 12: if world.settings.world_count > 1: - map_message = "\x13\x76\x08\x05\x42\x0F\x05\x40 found the \x05\x41Dungeon Map\x05\x40\x01for %s\x05\x40!\x09" % (dungeon_name) + map_message = "\x13\x76\x08\x05\x42\x0F\x05\x40 found the \x05\x41Dungeon Map\x05\x40\x01for %s\x05\x40!\x09" % dungeon_name else: map_message = "\x13\x76\x08You found the \x05\x41Dungeon Map\x05\x40\x01for %s\x05\x40!\x01It\'s %s!\x09" % (dungeon_name, "masterful" if world.dungeon_mq[dungeon] else "ordinary") update_message_by_id(messages, map_id, map_message) @@ -2334,7 +2323,9 @@ def update_scrub_text(message, text_replacement, default_price, price, item_name # Set hints on the altar inside ToT rom.write_int16(0xE2ADB2, 0x707A) rom.write_int16(0xE2ADB6, 0x7057) - buildAltarHints(world, messages, include_rewards='altar' in world.settings.misc_hints and not world.settings.enhance_map_compass, include_wincons='altar' in world.settings.misc_hints) + build_altar_hints(world, messages, + include_rewards='altar' in world.settings.misc_hints and not world.settings.enhance_map_compass, + include_wincons='altar' in world.settings.misc_hints) # Fix Dead Hand spawn coordinates in vanilla shadow temple and bottom of the well to be the exact centre of the room # This prevents the extremely small possibility of Dead Hand spawning outside of collision @@ -2353,7 +2344,7 @@ def update_scrub_text(message, text_replacement, default_price, price, item_name rom.write_int16(0xB6EC52, 999) tycoon_message = "\x08\x13\x57You got a \x05\x43Tycoon's Wallet\x05\x40!\x01Now you can hold\x01up to \x05\x46999\x05\x40 \x05\x46Rupees\x05\x40." if world.settings.world_count > 1: - tycoon_message = make_player_message(tycoon_message) + tycoon_message = make_player_message(tycoon_message) update_message_by_id(messages, 0x00F8, tycoon_message, 0x23) write_shop_items(rom, shop_item_file.start + 0x1DEC, shop_items) @@ -2364,11 +2355,11 @@ def update_scrub_text(message, text_replacement, default_price, price, item_name text_codes = [] chars_in_section = 1 for code in get_message_by_id(messages, message_id).text_codes: - if code.code == 0x04: # box-break - text_codes.append(Text_Code(0x0c, 80 + chars_in_section)) + if code.code == 0x04: # box-break + text_codes.append(TextCode(0x0c, 80 + chars_in_section)) chars_in_section = 1 - elif code.code == 0x02: # end - text_codes.append(Text_Code(0x0e, 80 + chars_in_section)) + elif code.code == 0x02: # end + text_codes.append(TextCode(0x0e, 80 + chars_in_section)) text_codes.append(code) else: chars_in_section += 1 @@ -2387,10 +2378,10 @@ def update_scrub_text(message, text_replacement, default_price, price, item_name update_warp_song_text(messages, world) if world.settings.blue_fire_arrows: - rom.write_byte(0xC230C1, 0x29) #Adds AT_TYPE_OTHER to arrows to allow collision with red ice - rom.write_byte(0xDB38FE, 0xEF) #disables ice arrow collision on secondary cylinder for red ice crystals - rom.write_byte(0xC9F036, 0x10) #enable ice arrow collision on mud walls - #increase cylinder radius/height for red ice sheets + rom.write_byte(0xC230C1, 0x29) # Adds AT_TYPE_OTHER to arrows to allow collision with red ice + rom.write_byte(0xDB38FE, 0xEF) # disables ice arrow collision on secondary cylinder for red ice crystals + rom.write_byte(0xC9F036, 0x10) # enable ice arrow collision on mud walls + # increase cylinder radius/height for red ice sheets rom.write_byte(0xDB391B, 0x50) rom.write_byte(0xDB3927, 0x5A) @@ -2403,7 +2394,6 @@ def update_scrub_text(message, text_replacement, default_price, price, item_name bfa_name_bytes = stream.read() rom.write_bytes(0x8a1c00, bfa_name_bytes) - repack_messages(rom, messages, permutation) # output a text dump, for testing... @@ -2431,8 +2421,8 @@ def update_scrub_text(message, text_replacement, default_price, price, item_name rom.write_byte(symbol, 0x01) # Autocollect incoming_item_id for magic jars are swapped in vanilla code - rom.write_int16(0xA88066, 0x0044) # Change GI_MAGIC_SMALL to GI_MAGIC_LARGE - rom.write_int16(0xA88072, 0x0043) # Change GI_MAGIC_LARGE to GI_MAGIC_SMALL + rom.write_int16(0xA88066, 0x0044) # Change GI_MAGIC_SMALL to GI_MAGIC_LARGE + rom.write_int16(0xA88072, 0x0043) # Change GI_MAGIC_LARGE to GI_MAGIC_SMALL else: # Remove deku shield drop from spirit pot because it's "vanilla behavior" # Replace actor parameters in scene 06, room 27 actor list @@ -2442,10 +2432,8 @@ def update_scrub_text(message, text_replacement, default_price, price, item_name # available number of skulls in the world instead of 100. rom.write_int16(0xBB340E, world.available_tokens) - replace_songs(world, rom, - frog=world.settings.ocarina_songs in ('frog', 'all'), - warp=world.settings.ocarina_songs in ('warp', 'all'), - ) + replace_songs(world, rom, frog=world.settings.ocarina_songs in ('frog', 'all'), + warp=world.settings.ocarina_songs in ('warp', 'all')) # Sets the torch count to open the entrance to Shadow Temple if world.settings.easier_fire_arrow_entry: @@ -2530,8 +2518,10 @@ def update_scrub_text(message, text_replacement, default_price, price, item_name return rom -NUM_VANILLA_OBJECTS = 0x192 -def add_to_extended_object_table(rom, object_id, start_adddress, end_address): +NUM_VANILLA_OBJECTS: int = 0x192 + + +def add_to_extended_object_table(rom: Rom, object_id: int, start_adddress: int, end_address: int) -> None: extended_id = object_id - NUM_VANILLA_OBJECTS - 1 extended_object_table = rom.sym('EXTENDED_OBJECT_TABLE') rom.write_int32s(extended_object_table + extended_id * 8, [start_adddress, end_address]) @@ -2543,45 +2533,51 @@ def add_to_extended_object_table(rom, object_id, start_adddress, end_address): 'upgrade_fn', 'effect_fn', 'effect_arg1', 'effect_arg2', 'collectible', 'alt_text_fn', ] -def read_rom_item(rom, item_id): + +def read_rom_item(rom: Rom, item_id: int) -> Dict[str, Any]: addr = rom.sym('item_table') + (item_id * item_row_struct.size) row_bytes = rom.read_bytes(addr, item_row_struct.size) row = item_row_struct.unpack(row_bytes) return { item_row_fields[i]: row[i] for i in range(len(item_row_fields)) } -def write_rom_item(rom, item_id, item): + +def write_rom_item(rom: Rom, item_id: int, item: Dict[str, Any]) -> None: addr = rom.sym('item_table') + (item_id * item_row_struct.size) row = [item[f] for f in item_row_fields] row_bytes = item_row_struct.pack(*row) rom.write_bytes(addr, row_bytes) -texture_struct = struct.Struct('>HBxxxxxII') # Match texture_t in textures.c -texture_fields = ['texture_id', 'file_buf', 'file_vrom_start', 'file_size'] +texture_struct = struct.Struct('>HBxxxxxII') # Match texture_t in textures.c +texture_fields: List[str] = ['texture_id', 'file_buf', 'file_vrom_start', 'file_size'] -def read_rom_texture(rom, texture_id): + +def read_rom_texture(rom: Rom, texture_id: int) -> Dict[str, Any]: addr = rom.sym('texture_table') + (texture_id * texture_struct.size) row_bytes = rom.read_bytes(addr, texture_struct.size) row = texture_struct.unpack(row_bytes) return {texture_fields[i]: row[i] for i in range(len(texture_fields))} -def write_rom_texture(rom, texture_id, texture): + +def write_rom_texture(rom: Rom, texture_id: int, texture: Dict[str, Any]) -> None: addr = rom.sym('texture_table') + (texture_id * texture_struct.size) row = [texture[f] for f in texture_fields] row_bytes = texture_struct.pack(*row) rom.write_bytes(addr, row_bytes) -def get_override_table(world): - return list(filter(lambda val: val != None, map(get_override_entry, world.get_filled_locations()))) +def get_override_table(world: World): + return list(filter(lambda val: val is not None, map(get_override_entry, world.get_filled_locations()))) + + +override_struct = struct.Struct('>BBHxxxxHBxHxx') # match override_t in get_items.c -override_struct = struct.Struct('>BBHxxxxHBxHxx') # match override_t in get_items.c def get_override_table_bytes(override_table): return b''.join(sorted(itertools.starmap(override_struct.pack, override_table))) -def get_override_entry(location): +def get_override_entry(location: Location) -> Optional[OverrideEntry]: scene = location.scene default = location.default item_id = location.item.index @@ -2626,47 +2622,48 @@ def get_override_entry(location): else: return None - return (scene, type, default, item_id, player_id, looks_like_item_id) + return scene, type, default, item_id, player_id, looks_like_item_id -def check_location_dupes(world): +def check_location_dupes(world: World) -> None: locations = list(world.get_filled_locations()) for i in range(0, len(locations)): for j in range(0, len(locations)): check_i = locations[i] check_j = locations[j] - if(check_i.name == check_j.name and i != j): + if check_i.name == check_j.name and i != j: raise Exception(f'Discovered duplicate location: {check_i.name}') -chestTypeMap = { - # small big boss - 0x0000: [0x5000, 0x0000, 0x2000], #Large - 0x1000: [0x7000, 0x1000, 0x1000], #Large, Appears, Clear Flag - 0x2000: [0x5000, 0x0000, 0x2000], #Boss Key’s Chest - 0x3000: [0x8000, 0x3000, 0x3000], #Large, Falling, Switch Flag - 0x4000: [0x6000, 0x4000, 0x4000], #Large, Invisible - 0x5000: [0x5000, 0x0000, 0x2000], #Small - 0x6000: [0x6000, 0x4000, 0x4000], #Small, Invisible - 0x7000: [0x7000, 0x1000, 0x1000], #Small, Appears, Clear Flag - 0x8000: [0x8000, 0x3000, 0x3000], #Small, Falling, Switch Flag - 0x9000: [0x9000, 0x9000, 0x9000], #Large, Appears, Zelda's Lullaby - 0xA000: [0xA000, 0xA000, 0xA000], #Large, Appears, Sun's Song Triggered - 0xB000: [0xB000, 0xB000, 0xB000], #Large, Appears, Switch Flag - 0xC000: [0x5000, 0x0000, 0x2000], #Large - 0xD000: [0x5000, 0x0000, 0x2000], #Large - 0xE000: [0x5000, 0x0000, 0x2000], #Large - 0xF000: [0x5000, 0x0000, 0x2000], #Large +chestTypeMap: Dict[int, List[int]] = { + # small big boss + 0x0000: [0x5000, 0x0000, 0x2000], # Large + 0x1000: [0x7000, 0x1000, 0x1000], # Large, Appears, Clear Flag + 0x2000: [0x5000, 0x0000, 0x2000], # Boss Key’s Chest + 0x3000: [0x8000, 0x3000, 0x3000], # Large, Falling, Switch Flag + 0x4000: [0x6000, 0x4000, 0x4000], # Large, Invisible + 0x5000: [0x5000, 0x0000, 0x2000], # Small + 0x6000: [0x6000, 0x4000, 0x4000], # Small, Invisible + 0x7000: [0x7000, 0x1000, 0x1000], # Small, Appears, Clear Flag + 0x8000: [0x8000, 0x3000, 0x3000], # Small, Falling, Switch Flag + 0x9000: [0x9000, 0x9000, 0x9000], # Large, Appears, Zelda's Lullaby + 0xA000: [0xA000, 0xA000, 0xA000], # Large, Appears, Sun's Song Triggered + 0xB000: [0xB000, 0xB000, 0xB000], # Large, Appears, Switch Flag + 0xC000: [0x5000, 0x0000, 0x2000], # Large + 0xD000: [0x5000, 0x0000, 0x2000], # Large + 0xE000: [0x5000, 0x0000, 0x2000], # Large + 0xF000: [0x5000, 0x0000, 0x2000], # Large } -def room_get_actors(rom, actor_func, room_data, scene, alternate=None): +def room_get_actors(rom: Rom, actor_func: Callable[[Rom, int, int, int], Any], room_data: int, scene: int, + alternate: Optional[int] = None) -> Dict[int, Any]: actors = {} room_start = alternate if alternate else room_data command = 0 - while command != 0x14: # 0x14 = end header + while command != 0x14: # 0x14 = end header command = rom.read_byte(room_data) - if command == 0x01: # actor list + if command == 0x01: # actor list actor_count = rom.read_byte(room_data + 1) actor_list = room_start + (rom.read_int32(room_data + 4) & 0x00FFFFFF) for _ in range(0, actor_count): @@ -2675,9 +2672,9 @@ def room_get_actors(rom, actor_func, room_data, scene, alternate=None): if entry: actors[actor_list] = entry actor_list = actor_list + 16 - if command == 0x18: # Alternate header list + if command == 0x18: # Alternate header list header_list = room_start + (rom.read_int32(room_data + 4) & 0x00FFFFFF) - for alt_id in range(0,3): + for alt_id in range(0, 3): header_data = room_start + (rom.read_int32(header_list) & 0x00FFFFFF) if header_data != 0 and not alternate: actors.update(room_get_actors(rom, actor_func, header_data, scene, room_start)) @@ -2686,25 +2683,26 @@ def room_get_actors(rom, actor_func, room_data, scene, alternate=None): return actors -def scene_get_actors(rom, actor_func, scene_data, scene, alternate=None, processed_rooms=None): - if processed_rooms == None: +def scene_get_actors(rom: Rom, actor_func: Callable[[Rom, int, int, int], Any], scene_data: int, scene: int, + alternate: Optional[int] = None, processed_rooms: Optional[List[int]] = None) -> Dict[int, Any]: + if processed_rooms is None: processed_rooms = [] actors = {} scene_start = alternate if alternate else scene_data command = 0 - while command != 0x14: # 0x14 = end header + while command != 0x14: # 0x14 = end header command = rom.read_byte(scene_data) - if command == 0x04: #room list + if command == 0x04: # room list room_count = rom.read_byte(scene_data + 1) room_list = scene_start + (rom.read_int32(scene_data + 4) & 0x00FFFFFF) for _ in range(0, room_count): - room_data = rom.read_int32(room_list); + room_data = rom.read_int32(room_list) - if not room_data in processed_rooms: + if room_data not in processed_rooms: actors.update(room_get_actors(rom, actor_func, room_data, scene)) processed_rooms.append(room_data) room_list = room_list + 8 - if command == 0x0E: #transition actor list + if command == 0x0E: # transition actor list actor_count = rom.read_byte(scene_data + 1) actor_list = scene_start + (rom.read_int32(scene_data + 4) & 0x00FFFFFF) for _ in range(0, actor_count): @@ -2713,9 +2711,9 @@ def scene_get_actors(rom, actor_func, scene_data, scene, alternate=None, process if entry: actors[actor_list] = entry actor_list = actor_list + 16 - if command == 0x18: # Alternate header list + if command == 0x18: # Alternate header list header_list = scene_start + (rom.read_int32(scene_data + 4) & 0x00FFFFFF) - for alt_id in range(0,3): + for alt_id in range(0, 3): header_data = scene_start + (rom.read_int32(header_list) & 0x00FFFFFF) if header_data != 0 and not alternate: actors.update(scene_get_actors(rom, actor_func, header_data, scene, scene_start, processed_rooms)) @@ -2725,36 +2723,38 @@ def scene_get_actors(rom, actor_func, scene_data, scene, alternate=None, process return actors -def get_actor_list(rom, actor_func): +def get_actor_list(rom: Rom, actor_func: Callable[[Rom, int, int, int], Any]) -> Dict[int, Any]: actors = {} scene_table = 0x00B71440 for scene in range(0x00, 0x65): - scene_data = rom.read_int32(scene_table + (scene * 0x14)); + scene_data = rom.read_int32(scene_table + (scene * 0x14)) actors.update(scene_get_actors(rom, actor_func, scene_data, scene)) return actors -def get_override_itemid(override_table, scene, type, flags): +def get_override_itemid(override_table: Iterable[OverrideEntry], scene: int, type: int, flags: int) -> Optional[int]: for entry in override_table: if entry[0] == scene and (entry[1] & 0x07) == type and entry[2] == flags: return entry[4] return None -def remove_entrance_blockers(rom): - def remove_entrance_blockers_do(rom, actor_id, actor, scene): + +def remove_entrance_blockers(rom: Rom) -> None: + def remove_entrance_blockers_do(rom: Rom, actor_id: int, actor: int, scene: int) -> None: if actor_id == 0x014E and scene == 97: - actor_var = rom.read_int16(actor + 14); + actor_var = rom.read_int16(actor + 14) if actor_var == 0xFF01: rom.write_int16(actor + 14, 0x0700) get_actor_list(rom, remove_entrance_blockers_do) -def set_cow_id_data(rom, world): - def set_cow_id(rom, actor_id, actor, scene): + +def set_cow_id_data(rom: Rom, world: World) -> None: + def set_cow_id(rom: Rom, actor_id: int, actor: int, scene: int) -> None: nonlocal last_scene nonlocal cow_count nonlocal last_actor - if actor_id == 0x01C6: #Cow + if actor_id == 0x01C6: # Cow if scene == last_scene and last_actor != actor: cow_count += 1 else: @@ -2762,8 +2762,8 @@ def set_cow_id(rom, actor_id, actor, scene): last_scene = scene last_actor = actor - if world.dungeon_mq['Jabu Jabus Belly'] and scene == 2: #If its an MQ jabu cow - rom.write_int16(actor + 0x8, 1 if cow_count == 17 else 0) #Give all wall cows ID 0, and set cow 11's ID to 1 + if world.dungeon_mq['Jabu Jabus Belly'] and scene == 2: # If it's an MQ jabu cow + rom.write_int16(actor + 0x8, 1 if cow_count == 17 else 0) # Give all wall cows ID 0, and set cow 11's ID to 1 else: rom.write_int16(actor + 0x8, cow_count) @@ -2774,9 +2774,9 @@ def set_cow_id(rom, actor_id, actor, scene): get_actor_list(rom, set_cow_id) -def set_grotto_shuffle_data(rom, world): - def override_grotto_data(rom, actor_id, actor, scene): - if actor_id == 0x009B: #Grotto +def set_grotto_shuffle_data(rom: Rom, world: World) -> None: + def override_grotto_data(rom: Rom, actor_id: int, actor: int, scene: int) -> None: + if actor_id == 0x009B: # Grotto actor_zrot = rom.read_int16(actor + 12) actor_var = rom.read_int16(actor + 14) grotto_type = (actor_var >> 8) & 0x0F @@ -2798,9 +2798,9 @@ def override_grotto_data(rom, actor_id, actor, scene): get_actor_list(rom, override_grotto_data) -def set_deku_salesman_data(rom): - def set_deku_salesman(rom, actor_id, actor, scene): - if actor_id == 0x0195: #Salesman +def set_deku_salesman_data(rom: Rom) -> None: + def set_deku_salesman(rom: Rom, actor_id: int, actor: int, scene: int) -> None: + if actor_id == 0x0195: # Salesman actor_var = rom.read_int16(actor + 14) if actor_var == 6: rom.write_int16(actor + 14, 0x0003) @@ -2808,8 +2808,8 @@ def set_deku_salesman(rom, actor_id, actor, scene): get_actor_list(rom, set_deku_salesman) -def set_jabu_stone_actors(rom, jabu_actor_type): - def set_jabu_stone_actor(rom, actor_id, actor, scene): +def set_jabu_stone_actors(rom: Rom, jabu_actor_type: int) -> None: + def set_jabu_stone_actor(rom: Rom, actor_id: int, actor: int, scene: int) -> None: if scene == 2 and actor_id == 0x008B: # Demo_Effect in Jabu Jabu actor_type = rom.read_byte(actor + 15) if actor_type == 0x15: @@ -2818,9 +2818,9 @@ def set_jabu_stone_actor(rom, actor_id, actor, scene): get_actor_list(rom, set_jabu_stone_actor) -def set_spirit_shortcut_actors(rom): - def set_spirit_shortcut(rom, actor_id, actor, scene): - if actor_id == 0x018e and scene == 6: # raise initial elevator height +def set_spirit_shortcut_actors(rom: Rom) -> None: + def set_spirit_shortcut(rom: Rom, actor_id: int, actor: int, scene: int) -> None: + if actor_id == 0x018e and scene == 6: # raise initial elevator height rom.write_int16(actor + 4, 0x015E) get_actor_list(rom, set_spirit_shortcut) @@ -2848,8 +2848,8 @@ def move_fado(rom, actor_id, actor, scene): # If boss keys are set to remove, returns boss key doors # If ganons boss key is set to remove, returns ganons boss key doors # If pot/crate shuffle is enabled, returns the first ganon's boss key door so that it can be unlocked separately to allow access to the room w/ the pots.. -def get_doors_to_unlock(rom, world): - def get_door_to_unlock(rom, actor_id, actor, scene): +def get_doors_to_unlock(rom: Rom, world: World) -> Dict[int, List[int]]: + def get_door_to_unlock(rom: Rom, actor_id: int, actor: int, scene: int) -> List[int]: actor_var = rom.read_int16(actor + 14) door_type = actor_var >> 6 switch_flag = actor_var & 0x003F @@ -2873,7 +2873,7 @@ def get_door_to_unlock(rom, actor_id, actor, scene): return get_actor_list(rom, get_door_to_unlock) -def create_fake_name(name): +def create_fake_name(name: str) -> str: vowels = 'aeiou' list_name = list(name) vowel_indexes = [i for i,c in enumerate(list_name) if c in vowels] @@ -2891,11 +2891,11 @@ def create_fake_name(name): return new_name -def place_shop_items(rom, world, shop_items, messages, locations, init_shop_id=False): +def place_shop_items(rom: Rom, world: World, shop_items, messages, locations, init_shop_id: bool = False) -> Set[int]: if init_shop_id: place_shop_items.shop_id = 0x32 - shop_objs = { 0x0148 } # "Sold Out" object + shop_objs = {0x0148} # "Sold Out" object for location in locations: if (location.item.type == 'Shop' or (location.type == 'MaskShop' and @@ -2911,7 +2911,7 @@ def place_shop_items(rom, world, shop_items, messages, locations, init_shop_id=F item_display = location.item # bottles in shops should look like empty bottles - # so that that are different than normal shop refils + # so that they are different than normal shop refills if 'shop_object' in item_display.special: rom_item = read_rom_item(rom, item_display.special['shop_object']) else: @@ -2966,7 +2966,7 @@ def place_shop_items(rom, world, shop_items, messages, locations, init_shop_id=F description_text = '\x08\x05\x41%s %d Rupees\x01%s\x01\x05\x40Special deal! ONE LEFT!\x01Get it while it lasts!\x09\x0A\x02' % (split_item_name[0], shop_item.price, split_item_name[1]) purchase_text = '\x08%s %d Rupees\x09\x01%s\x01\x1B\x05\x42Buy\x01Don\'t buy\x05\x40\x02' % (split_item_name[0], shop_item.price, split_item_name[1]) else: - shop_item_name = getSimpleHintNoPrefix(item_display) + shop_item_name = get_simple_hint_no_prefix(item_display) if location.item.name == 'Ice Trap': shop_item_name = create_fake_name(shop_item_name) @@ -2984,7 +2984,7 @@ def place_shop_items(rom, world, shop_items, messages, locations, init_shop_id=F return shop_objs -def boss_reward_index(item): +def boss_reward_index(item: Item) -> int: code = item.special['item_id'] if code >= 0x6C: return code - 0x6C @@ -2992,7 +2992,7 @@ def boss_reward_index(item): return 3 + code - 0x66 -def configure_dungeon_info(rom, world): +def configure_dungeon_info(rom: Rom, world: World) -> None: mq_enable = (world.settings.mq_dungeons_mode == 'random' or world.settings.mq_dungeons_count != 0 and world.settings.mq_dungeons_count != 12) enhance_map_compass = world.settings.enhance_map_compass @@ -3025,7 +3025,7 @@ def configure_dungeon_info(rom, world): # Overwrite an actor in rom w/ the actor data from LocationList -def patch_actor_override(location, rom: Rom): +def patch_actor_override(location: Location, rom: Rom) -> None: addresses = location.address patch = location.address2 if addresses is not None and patch is not None: @@ -3035,7 +3035,7 @@ def patch_actor_override(location, rom: Rom): # Patch rupee towers (circular patterns of rupees) to include their flag in their actor initialization data z rotation. # Also used for goron pot, shadow spinning pots -def patch_rupee_tower(location, rom: Rom): +def patch_rupee_tower(location: Location, rom: Rom) -> None: if isinstance(location.default, tuple): room, scene_setup, flag = location.default elif isinstance(location.default, list): @@ -3050,7 +3050,7 @@ def patch_rupee_tower(location, rom: Rom): # Patch the first boss key door in ganons tower that leads to the room w/ the pots -def patch_ganons_tower_bk_door(rom: Rom, flag): +def patch_ganons_tower_bk_door(rom: Rom, flag: int) -> None: var = (0x05 << 6) + (flag & 0x3F) bytes = [(var & 0xFF00) >> 8, var & 0xFF] rom.write_bytes(0x2EE30FE, bytes) diff --git a/Plandomizer.py b/Plandomizer.py index eaa8fc6e8..622f5cc7f 100644 --- a/Plandomizer.py +++ b/Plandomizer.py @@ -3,25 +3,30 @@ import math import re import random - -from functools import reduce from collections import defaultdict -from typing import Any, Mapping, MutableMapping, Optional, Sequence, MutableSequence, Union +from functools import reduce +from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Sequence, Iterable, Callable, Optional, Union -from Fill import FillError +import StartingItems from Entrance import Entrance from EntranceShuffle import EntranceShuffleError, change_connections, confirm_replacement, validate_world, check_entrances_compatibility +from Fill import FillError from Hints import HintArea, gossipLocations, GossipText -from Item import ItemFactory, ItemInfo, ItemIterator, IsItem, Item +from Item import ItemFactory, ItemInfo, ItemIterator, is_item, Item from ItemPool import item_groups, get_junk_item, song_list, trade_items, child_trade_items -from Location import LocationIterator, LocationFactory +from JSONDump import dump_obj, CollapseList, CollapseDict, AlignedDict, SortedDict +from Location import Location, LocationIterator, LocationFactory from LocationList import location_groups, location_table from Search import Search -from Spoiler import HASH_ICONS -from version import __version__ -from JSONDump import dump_obj, CollapseList, CollapseDict, AlignedDict, SortedDict -import StartingItems from SettingsList import build_close_match, validate_settings +from Spoiler import Spoiler, HASH_ICONS +from version import __version__ + +if TYPE_CHECKING: + from SaveContext import SaveContext + from Settings import Settings + from State import State + from World import World class InvalidFileException(Exception): @@ -46,11 +51,11 @@ class InvalidFileException(Exception): class Record: - def __init__(self, properties: Mapping[str, Any] = None, src_dict: Mapping[str, Any] = None) -> None: - self.properties: Mapping[str, Any] = properties if properties is not None else getattr(self, "properties") + def __init__(self, properties: Dict[str, Any] = None, src_dict: Dict[str, Any] = None) -> None: + self.properties: Dict[str, Any] = properties if properties is not None else getattr(self, "properties") self.update(src_dict, update_all=True) - def update(self, src_dict: Mapping[str, Any], update_all: bool = False) -> None: + def update(self, src_dict: Dict[str, Any], update_all: bool = False) -> None: if src_dict is None: src_dict = {} if isinstance(src_dict, list): @@ -59,7 +64,7 @@ def update(self, src_dict: Mapping[str, Any], update_all: bool = False) -> None: if update_all or k in src_dict: setattr(self, k, src_dict.get(k, p)) - def to_json(self) -> MutableMapping[str, Any]: + def to_json(self) -> Dict[str, Any]: return {k: getattr(self, k) for (k, d) in self.properties.items() if getattr(self, k) != d} def __str__(self) -> str: @@ -67,14 +72,15 @@ def __str__(self) -> str: class DungeonRecord(Record): - mapping: Mapping[str, Optional[bool]] = { + mapping: Dict[str, Optional[bool]] = { 'random': None, 'mq': True, 'vanilla': False, } - def __init__(self, src_dict: Union[str, Mapping[str, Optional[bool]]] = 'random') -> None: + def __init__(self, src_dict: Union[str, Dict[str, Optional[bool]]] = 'random') -> None: self.mq: Optional[bool] = None + if isinstance(src_dict, str): src_dict = {'mq': self.mapping.get(src_dict, None)} super().__init__({'mq': None}, src_dict) @@ -86,8 +92,9 @@ def to_json(self) -> str: class EmptyDungeonRecord(Record): - def __init__(self, src_dict: Union[Optional[bool], str, Mapping[str, Optional[bool]]] = 'random') -> None: + def __init__(self, src_dict: Union[Optional[bool], str, Dict[str, Optional[bool]]] = 'random') -> None: self.empty: Optional[bool] = None + if src_dict == 'random': src_dict = {'empty': None} elif isinstance(src_dict, bool): @@ -99,13 +106,13 @@ def to_json(self) -> Optional[bool]: class GossipRecord(Record): - def __init__(self, src_dict: Mapping[str, Any]) -> None: + def __init__(self, src_dict: Dict[str, Any]) -> None: self.colors: Optional[Sequence[str]] = None self.hinted_locations: Optional[Sequence[str]] = None self.hinted_items: Optional[Sequence[str]] = None super().__init__({'text': None, 'colors': None, 'hinted_locations': None, 'hinted_items': None}, src_dict) - def to_json(self) -> Mapping[str, Any]: + def to_json(self) -> Dict[str, Any]: if self.colors is not None: self.colors = CollapseList(self.colors) if self.hinted_locations is not None: @@ -119,6 +126,7 @@ class ItemPoolRecord(Record): def __init__(self, src_dict: int = 1) -> None: self.type: str = 'set' self.count: int = 1 + if isinstance(src_dict, int): src_dict = {'count': src_dict} super().__init__({'type': 'set', 'count': 1}, src_dict) @@ -129,7 +137,7 @@ def to_json(self) -> Union[int, CollapseDict]: else: return CollapseDict(super().to_json()) - def update(self, src_dict: Mapping[str, Any], update_all: bool = False) -> None: + def update(self, src_dict: Dict[str, Any], update_all: bool = False) -> None: super().update(src_dict, update_all) if self.count < 0: raise ValueError("Count cannot be negative in a ItemPoolRecord.") @@ -138,8 +146,10 @@ def update(self, src_dict: Mapping[str, Any], update_all: bool = False) -> None: class LocationRecord(Record): - def __init__(self, src_dict: Union[Mapping[str, Any], str]) -> None: - self.item: Optional[str] = None + def __init__(self, src_dict: Union[Dict[str, Any], str]) -> None: + self.item: Optional[Union[str, List[str]]] = None + self.player: Optional[int] = None + if isinstance(src_dict, str): src_dict = {'item': src_dict} super().__init__({'item': None, 'player': None, 'price': None, 'model': None}, src_dict) @@ -167,9 +177,10 @@ def from_item(item: Item) -> 'LocationRecord': class EntranceRecord(Record): - def __init__(self, src_dict: Union[Mapping[str, Optional[str]], str]) -> None: + def __init__(self, src_dict: Union[Dict[str, Optional[str]], str]) -> None: self.region: Optional[str] = None self.origin: Optional[str] = None + if isinstance(src_dict, str): src_dict = {'region': src_dict} if 'from' in src_dict: @@ -201,6 +212,7 @@ def from_entrance(entrance: Entrance) -> 'EntranceRecord': class StarterRecord(Record): def __init__(self, src_dict: int = 1) -> None: self.count: int = 1 + if isinstance(src_dict, int): src_dict = {'count': src_dict} super().__init__({'count': 1}, src_dict) @@ -213,14 +225,15 @@ def to_json(self) -> int: class TrialRecord(Record): - mapping: Mapping[str, Optional[bool]] = { + mapping: Dict[str, Optional[bool]] = { 'random': None, 'active': True, 'inactive': False, } - def __init__(self, src_dict: Union[str, Mapping[str, Optional[bool]]] = 'random') -> None: + def __init__(self, src_dict: Union[str, Dict[str, Optional[bool]]] = 'random') -> None: self.active: Optional[bool] = None + if isinstance(src_dict, str): src_dict = {'active': self.mapping.get(src_dict, None)} super().__init__({'active': None}, src_dict) @@ -232,42 +245,44 @@ def to_json(self) -> str: class SongRecord(Record): - def __init__(self, src_dict: Union[Optional[str], Mapping[str, Optional[str]]] = None) -> None: + def __init__(self, src_dict: Union[Optional[str], Dict[str, Optional[str]]] = None) -> None: self.notes: Optional[str] = None + if src_dict is None or isinstance(src_dict, str): src_dict = {'notes': src_dict} super().__init__({'notes': None}, src_dict) - def to_json(self): + def to_json(self) -> str: return self.notes class WorldDistribution: - def __init__(self, distribution, id, src_dict=None): - self.randomized_settings = None - self.dungeons = None - self.empty_dungeons = None - self.trials = None - self.songs = None - self.item_pool = None - self.entrances = None - self.locations = None - self.woth_locations = None - self.goal_locations = None - self.barren_regions = None - self.gossip_stones = None + def __init__(self, distribution: 'Distribution', id: int, src_dict: Optional[Dict[str, Any]] = None) -> None: + self.randomized_settings: Optional[Dict[str, Any]] = None + self.dungeons: Optional[Dict[str, DungeonRecord]] = None + self.empty_dungeons: Optional[Dict[str, EmptyDungeonRecord]] = None + self.trials: Optional[Dict[str, TrialRecord]] = None + self.songs: Optional[Dict[str, SongRecord]] = None + self.item_pool: Optional[Dict[str, ItemPoolRecord]] = None + self.entrances: Optional[Dict[str, EntranceRecord]] = None + self.locations: Optional[Dict[str, Union[LocationRecord, List[LocationRecord]]]] = None + self.woth_locations: Optional[Dict[str, LocationRecord]] = None + self.goal_locations: Optional[Dict[str, Dict[str, Dict[str, Union[LocationRecord, Dict[str, LocationRecord]]]]]] = None + self.barren_regions: Optional[List[str]] = None + self.gossip_stones: Optional[Dict[str, GossipRecord]] = None + + self.distribution: 'Distribution' = distribution + self.id: int = id + self.base_pool: List[str] = [] + self.major_group: List[str] = [] + self.song_as_items: bool = False + self.skipped_locations: List[Location] = [] + self.effective_starting_items: Dict[str, StarterRecord] = {} src_dict = {} if src_dict is None else src_dict - self.distribution = distribution - self.id = id - self.base_pool = [] - self.major_group = [] - self.song_as_items = False - self.skipped_locations = [] - self.effective_starting_items = {} self.update(src_dict, update_all=True) - def update(self, src_dict, update_all=False): + def update(self, src_dict: Dict[str, Any], update_all: bool = False) -> None: update_dict = { 'randomized_settings': {name: record for (name, record) in src_dict.get('randomized_settings', {}).items()}, 'dungeons': {name: DungeonRecord(record) for (name, record) in src_dict.get('dungeons', {}).items()}, @@ -298,7 +313,7 @@ def update(self, src_dict, update_all=False): else: setattr(self, k, None) - def to_json(self): + def to_json(self) -> Dict[str, Any]: return { 'randomized_settings': self.randomized_settings, 'dungeons': {name: record.to_json() for (name, record) in self.dungeons.items()}, @@ -315,10 +330,10 @@ def to_json(self): 'gossip_stones': SortedDict({name: [rec.to_json() for rec in record] if is_pattern(name) else record.to_json() for (name, record) in self.gossip_stones.items()}), } - def __str__(self): + def __str__(self) -> str: return dump_obj(self.to_json()) - def pattern_matcher(self, pattern): + def pattern_matcher(self, pattern: Union[str, List[str]]) -> Callable[[str], bool]: if isinstance(pattern, list): pattern_list = [] for pattern_item in pattern: @@ -394,14 +409,14 @@ def pattern_matcher(self, pattern): return lambda s: invert != (s == pattern) # adds the location entry only if there is no record for that location already - def add_location(self, new_location, new_item): + def add_location(self, new_location: str, new_item: str) -> None: for (location, record) in self.locations.items(): pattern = self.pattern_matcher(location) if pattern(new_location): raise KeyError('Cannot add location that already exists') self.locations[new_location] = LocationRecord(new_item) - def configure_dungeons(self, world, mq_dungeon_pool, empty_dungeon_pool): + def configure_dungeons(self, world: "World", mq_dungeon_pool: List[str], empty_dungeon_pool: List[str]) -> Tuple[int, int]: dist_num_mq, dist_num_empty = 0, 0 for (name, record) in self.dungeons.items(): if record.mq is not None: @@ -417,7 +432,7 @@ def configure_dungeons(self, world, mq_dungeon_pool, empty_dungeon_pool): world.empty_dungeons[name].empty = True return dist_num_mq, dist_num_empty - def configure_trials(self, trial_pool): + def configure_trials(self, trial_pool: List[str]) -> List[str]: dist_chosen = [] for (name, record) in self.trials.items(): if record.active is not None: @@ -426,7 +441,7 @@ def configure_trials(self, trial_pool): dist_chosen.append(name) return dist_chosen - def configure_songs(self): + def configure_songs(self) -> Dict[str, str]: dist_notes = {} for (name, record) in self.songs.items(): if record.notes is not None: @@ -434,7 +449,7 @@ def configure_songs(self): return dist_notes # Add randomized_settings defined in distribution to world's randomized settings list - def configure_randomized_settings(self, world): + def configure_randomized_settings(self, world: "World") -> None: settings = world.settings for name, record in self.randomized_settings.items(): if not hasattr(settings, name): @@ -443,7 +458,8 @@ def configure_randomized_settings(self, world): if name not in world.randomized_list: world.randomized_list.append(name) - def pool_remove_item(self, pools, item_name, count, world_id=None, use_base_pool=True): + def pool_remove_item(self, pools: List[List[Union[str, Item]]], item_name: str, count: int, + world_id: Optional[int] = None, use_base_pool: bool = True) -> List[Union[str, Item]]: removed_items = [] base_remove_matcher = self.pattern_matcher(item_name) @@ -457,7 +473,7 @@ def pool_remove_item(self, pools, item_name, count, world_id=None, use_base_pool removed_item = pull_random_element(pools, predicate) if removed_item is None: if not use_base_pool: - if IsItem(item_name): + if is_item(item_name): raise KeyError('No remaining items matching "%s" to be removed.' % (item_name)) else: raise KeyError('No items matching "%s"' % (item_name)) @@ -473,7 +489,7 @@ def pool_remove_item(self, pools, item_name, count, world_id=None, use_base_pool return removed_items - def pool_add_item(self, pool, item_name, count): + def pool_add_item(self, pool: List[str], item_name: str, count: int) -> List[str]: if item_name == '#Junk': added_items = get_junk_item(count, pool=pool, plando_pool=self.item_pool) elif is_pattern(item_name): @@ -486,7 +502,7 @@ def pool_add_item(self, pool, item_name, count): raise RuntimeError("Unknown item, or item set to 0 in the item pool could not be added: " + repr(item_name) + ". " + build_close_match(item_name, 'item')) added_items = random.choices(candidates, k=count) else: - if not IsItem(item_name): + if not is_item(item_name): raise RuntimeError("Unknown item could not be added: " + repr(item_name) + ". " + build_close_match(item_name, 'item')) added_items = [item_name] * count @@ -495,7 +511,7 @@ def pool_add_item(self, pool, item_name, count): return added_items - def alter_pool(self, world, pool): + def alter_pool(self, world: "World", pool: List[str]) -> List[str]: self.base_pool = list(pool) pool_size = len(pool) bottle_matcher = self.pattern_matcher("#Bottle") @@ -583,7 +599,7 @@ def alter_pool(self, world, pool): self.pool_remove_item([pool], "Chicken", record.count) except KeyError: raise KeyError('Tried to start with a Weird Egg or Chicken but could not remove it from the item pool. Are both Weird Egg and the Chicken shuffled?') - elif IsItem(item_name): + elif is_item(item_name): try: self.pool_remove_item([pool], item_name, record.count) except KeyError: @@ -599,7 +615,7 @@ def alter_pool(self, world, pool): return pool - def set_complete_itempool(self, pool): + def set_complete_itempool(self, pool: List[Item]) -> None: self.item_pool = {} for item in pool: if item.dungeonitem or item.type in ('Drop', 'Event', 'DungeonReward'): @@ -609,13 +625,13 @@ def set_complete_itempool(self, pool): else: self.item_pool[item.name] = ItemPoolRecord() - def collect_starters(self, state): + def collect_starters(self, state: "State") -> None: for (name, record) in self.starting_items.items(): for _ in range(record.count): item = ItemFactory("Bottle" if name == "Bottle with Milk (Half)" else name) state.collect(item) - def pool_replace_item(self, item_pools, item_group, player_id, new_item, worlds): + def pool_replace_item(self, item_pools: List[List[Item]], item_group: str, player_id: int, new_item: str, worlds: "List[World]") -> Item: removed_item = self.pool_remove_item(item_pools, item_group, 1, world_id=player_id)[0] item_matcher = lambda item: self.pattern_matcher(new_item)(item.name) if self.item_pool[removed_item.name].count > 1: @@ -629,7 +645,8 @@ def pool_replace_item(self, item_pools, item_group, player_id, new_item, worlds) return ItemFactory(get_junk_item(1))[0] return random.choice(list(ItemIterator(item_matcher, worlds[player_id]))) - def set_shuffled_entrances(self, worlds, entrance_pools, target_entrance_pools, locations_to_ensure_reachable, itempool): + def set_shuffled_entrances(self, worlds: "List[World]", entrance_pools: Dict[str, List[Entrance]], target_entrance_pools: Dict[str, List[Entrance]], + locations_to_ensure_reachable: Iterable[Location], itempool: List[Item]) -> None: for (name, record) in self.entrances.items(): if record.region is None: continue @@ -647,7 +664,7 @@ def set_shuffled_entrances(self, worlds, entrance_pools, target_entrance_pools, continue entrance_found = True - if matched_entrance.connected_region != None: + if matched_entrance.connected_region is not None: if matched_entrance.type == 'Overworld': continue else: @@ -672,7 +689,7 @@ def set_shuffled_entrances(self, worlds, entrance_pools, target_entrance_pools, matched_target = matched_targets_to_region[0] target_parent = matched_target.parent_region.name - if matched_target.connected_region == None: + if matched_target.connected_region is None: raise RuntimeError('Entrance leading to %s from %s is already shuffled in world %d' % (target_region, target_parent, self.id + 1)) @@ -689,7 +706,7 @@ def set_shuffled_entrances(self, worlds, entrance_pools, target_entrance_pools, if not entrance_found: raise RuntimeError('Entrance does not belong to a pool of shuffled entrances in world %d: %s' % (self.id + 1, name)) - def pattern_dict_items(self, pattern_dict): + def pattern_dict_items(self, pattern_dict: Dict[str, Any]) -> Iterable[Tuple[str, Any]]: """Retrieve a location by pattern. :param pattern_dict: the location dictionary. Capable of containing a pattern. @@ -708,7 +725,7 @@ def pattern_dict_items(self, pattern_dict): else: yield key, value - def get_valid_items_from_record(self, itempool, used_items, record): + def get_valid_items_from_record(self, itempool: List[Item], used_items: List[str], record: LocationRecord) -> List[str]: """Gets items that are valid for placement. :param itempool: a list of the item pool to search through for the record @@ -744,7 +761,7 @@ def get_valid_items_from_record(self, itempool, used_items, record): return valid_items - def pull_item_or_location(self, pools, world, name, remove=True): + def pull_item_or_location(self, pools: List[List[Union[Item, Location]]], world: "World", name: str, remove: bool = True) -> Optional[Union[Item, Location]]: """Finds and removes (unless told not to do so) an item or location matching the criteria from a list of pools. :param pools: the item pools to pull from @@ -761,7 +778,7 @@ def pull_item_or_location(self, pools, world, name, remove=True): else: return pull_first_element(pools, lambda e: e.world is world and e.name == name, remove) - def fill_bosses(self, world, prize_locs, prizepool): + def fill_bosses(self, world: "World", prize_locs: List[Location], prizepool: List[Item]) -> int: count = 0 used_items = [] for (name, record) in self.pattern_dict_items(self.locations): @@ -789,7 +806,7 @@ def fill_bosses(self, world, prize_locs, prizepool): if reward is None: if record.item not in item_groups['DungeonReward']: raise RuntimeError('Cannot place non-dungeon reward %s in world %d on location %s.' % (record.item, self.id + 1, name)) - if IsItem(record.item): + if is_item(record.item): raise RuntimeError('Reward already placed in world %d: %s' % (world.id + 1, record.item)) else: raise RuntimeError('Reward unknown in world %d: %s' % (world.id + 1, record.item)) @@ -797,15 +814,15 @@ def fill_bosses(self, world, prize_locs, prizepool): world.push_item(boss, reward, True) return count - def fill(self, worlds, location_pools, item_pools): + def fill(self, worlds: "List[World]", location_pools: List[List[Location]], item_pools: List[List[Item]]) -> None: """Fills the world with restrictions defined in a plandomizer JSON distribution file. :param worlds: A list of the world objects that define the rules of each game world. - :param location_pools: A list containing all of the location pools. + :param location_pools: A list containing all the location pools. 0: Shop Locations 1: Song Locations 2: Fill locations - :param item_pools: A list containing all of the item pools. + :param item_pools: A list containing all the item pools. 0: Shop Items 1: Dungeon Items 2: Songs @@ -819,6 +836,7 @@ def fill(self, worlds, location_pools, item_pools): if self.locations: locations = {loc: self.locations[loc] for loc in random.sample(sorted(self.locations), len(self.locations))} used_items = [] + record: LocationRecord for (location_name, record) in self.pattern_dict_items(locations): if record.item is None: continue @@ -864,7 +882,7 @@ def fill(self, worlds, location_pools, item_pools): if record.item in item_groups['DungeonReward']: raise RuntimeError('Cannot place dungeon reward %s in world %d in location %s.' % (record.item, self.id + 1, location_name)) - if record.item == '#Junk' and location.type == 'Song' and world.settings.shuffle_song_items == 'song' and not any(name in song_list and record.count for name, record in world.settings.starting_items.items()): + if record.item == '#Junk' and location.type == 'Song' and world.settings.shuffle_song_items == 'song' and not any(name in song_list and r.count for name, r in world.settings.starting_items.items()): record.item = '#JunkSong' ignore_pools = None @@ -894,11 +912,12 @@ def fill(self, worlds, location_pools, item_pools): if not search.can_beat_game(False): raise FillError('%s in world %d is not reachable without %s in world %d!' % (location.name, self.id + 1, item.name, player_id + 1)) - def get_item(self, ignore_pools, item_pools, location, player_id, record, worlds): + def get_item(self, ignore_pools: List[int], item_pools: List[List[Item]], location: Location, player_id: int, + record: LocationRecord, worlds: "List[World]") -> Item: """Get or create the item specified by the record and replace something in the item pool with it :param ignore_pools: Pools to not replace items in - :param item_pools: A list containing all of the item pools. + :param item_pools: A list containing all the item pools. :param location: Location record currently being assigned an item :param player_id: Integer representing the current player's ID number :param record: Item record from the distribution file to assign to a location @@ -975,7 +994,7 @@ def get_item(self, ignore_pools, item_pools, location, player_id, record, worlds item_pools[i] = new_pool return item - def cloak(self, worlds, location_pools, model_pools): + def cloak(self, worlds: "List[World]", location_pools: List[List[Location]], model_pools: List[List[Item]]) -> None: for (name, record) in self.pattern_dict_items(self.locations): if record.model is None: continue @@ -999,20 +1018,20 @@ def cloak(self, worlds, location_pools, model_pools): if can_cloak(location.item, model): location.item.looks_like_item = model - def configure_gossip(self, spoiler, stoneIDs): + def configure_gossip(self, spoiler: Spoiler, stone_ids: List[int]) -> None: for (name, record) in self.pattern_dict_items(self.gossip_stones): matcher = self.pattern_matcher(name) - stoneID = pull_random_element([stoneIDs], lambda id: matcher(gossipLocations[id].name)) - if stoneID is None: + stone_id = pull_random_element([stone_ids], lambda id: matcher(gossipLocations[id].name)) + if stone_id is None: # Allow planning of explicit textids match = re.match(r"^(?:\$|x|0x)?([0-9a-f]{4})$", name, flags=re.IGNORECASE) if match: - stoneID = int(match[1], base=16) + stone_id = int(match[1], base=16) else: raise RuntimeError('Gossip stone unknown or already assigned in world %d: %r. %s' % (self.id + 1, name, build_close_match(name, 'stone'))) - spoiler.hints[self.id][stoneID] = GossipText(text=record.text, colors=record.colors, prefix='') + spoiler.hints[self.id][stone_id] = GossipText(text=record.text, colors=record.colors, prefix='') - def give_items(self, world, save_context): + def give_items(self, world: "World", save_context: "SaveContext") -> None: # copy Triforce pieces to all worlds triforce_count = sum( world_dist.effective_starting_items['Triforce Piece'].count @@ -1027,7 +1046,7 @@ def give_items(self, world, save_context): continue save_context.give_item(world, name, record.count) - def get_starting_item(self, item): + def get_starting_item(self, item: str) -> int: items = self.starting_items if item in items: return items[item].count @@ -1035,7 +1054,7 @@ def get_starting_item(self, item): return 0 @property - def starting_items(self): + def starting_items(self) -> Dict[str, StarterRecord]: data = defaultdict(lambda: StarterRecord(0)) world_names = ['World %d' % (i + 1) for i in range(len(self.distribution.world_dists))] @@ -1052,7 +1071,7 @@ def starting_items(self): return data - def configure_effective_starting_items(self, worlds, world): + def configure_effective_starting_items(self, worlds: "List[World]", world: "World") -> None: items = {item_name: record.copy() for item_name, record in self.starting_items.items()} if world.settings.start_with_rupees: @@ -1120,11 +1139,15 @@ def configure_effective_starting_items(self, worlds, world): self.effective_starting_items = items -class Distribution(object): - def __init__(self, settings, src_dict=None): - self.src_dict = src_dict or {} - self.settings = settings - self.search_groups = { +class Distribution: + def __init__(self, settings: "Settings", src_dict: Optional[Dict[str, Any]] = None) -> None: + self.file_hash: Optional[List[str]] = None + self.playthrough: Optional[Dict[str, Dict[str, LocationRecord]]] = None + self.entrance_playthrough: Optional[Dict[str, Dict[str, EntranceRecord]]] = None + + self.src_dict: Dict[str, Any] = src_dict or {} + self.settings: "Settings" = settings + self.search_groups: Dict[str, Sequence[str]] = { **location_groups, **item_groups, } @@ -1134,7 +1157,7 @@ def __init__(self, settings, src_dict=None): if 'starting_items' in self.src_dict: raise ValueError('"starting_items" at the top level is no longer supported, please move it into "settings"') - self.world_dists = [WorldDistribution(self, id) for id in range(settings.world_count)] + self.world_dists: List[WorldDistribution] = [WorldDistribution(self, id) for id in range(settings.world_count)] # One-time init update_dict = { 'file_hash': (self.src_dict.get('file_hash', []) + [None, None, None, None, None])[0:5], @@ -1161,14 +1184,14 @@ def __init__(self, settings, src_dict=None): self.reset() # adds the location entry only if there is no record for that location already - def add_location(self, new_location, new_item): + def add_location(self, new_location: str, new_item: str) -> None: for world_dist in self.world_dists: try: world_dist.add_location(new_location, new_item) except KeyError: print('Cannot place item at excluded location because it already has an item defined in the Distribution.') - def fill(self, worlds, location_pools, item_pools): + def fill(self, worlds: "List[World]", location_pools: List[List[Location]], item_pools: List[List[Item]]) -> None: search = Search.max_explore([world.state for world in worlds], itertools.chain.from_iterable(item_pools)) if not search.can_beat_game(False): raise FillError('Item pool does not contain items required to beat game!') @@ -1176,11 +1199,11 @@ def fill(self, worlds, location_pools, item_pools): for world_dist in self.world_dists: world_dist.fill(worlds, location_pools, item_pools) - def cloak(self, worlds, location_pools, model_pools): + def cloak(self, worlds: "List[World]", location_pools: List[List[Location]], model_pools: List[List[Item]]) -> None: for world_dist in self.world_dists: world_dist.cloak(worlds, location_pools, model_pools) - def configure_triforce_hunt(self, worlds): + def configure_triforce_hunt(self, worlds: "List[World]") -> None: total_count = 0 total_starting_count = 0 for world in worlds: @@ -1201,7 +1224,7 @@ def configure_triforce_hunt(self, worlds): for world in worlds: world.total_starting_triforce_count = total_starting_count # used later in Rules.py - def reset(self): + def reset(self) -> None: for world in self.world_dists: world.update({}, update_all=True) @@ -1228,7 +1251,7 @@ def reset(self): # normalize starting items to use the dictionary format starting_items = itertools.chain(self.settings.starting_equipment, self.settings.starting_songs, self.settings.starting_inventory) - data = defaultdict(lambda: StarterRecord(0)) + data: Dict[str, Union[StarterRecord, Dict[str, StarterRecord]]] = defaultdict(lambda: StarterRecord(0)) if isinstance(self.settings.starting_items, dict) and self.settings.starting_items: world_names = ['World %d' % (i + 1) for i in range(len(self.world_dists))] for name, record in self.settings.starting_items.items(): @@ -1242,14 +1265,14 @@ def reset(self): if itemsetting in StartingItems.everything: item = StartingItems.everything[itemsetting] if not item.special: - add_starting_item_with_ammo(data, item.itemname) + add_starting_item_with_ammo(data, item.item_name) else: - if item.itemname == 'Rutos Letter' and self.settings.zora_fountain != 'open': + if item.item_name == 'Rutos Letter' and self.settings.zora_fountain != 'open': data['Rutos Letter'].count += 1 - elif item.itemname in ['Bottle', 'Rutos Letter']: + elif item.item_name in ['Bottle', 'Rutos Letter']: data['Bottle'].count += 1 else: - raise KeyError("invalid special item: {}".format(item.itemname)) + raise KeyError("invalid special item: {}".format(item.item_name)) else: raise KeyError("invalid starting item: {}".format(itemsetting)) self.settings.starting_equipment = [] @@ -1270,7 +1293,7 @@ def reset(self): data['Heart Container'].count += math.floor(num_hearts_to_collect / 2) self.settings.starting_items = data - def to_json(self, include_output=True, spoiler=True): + def to_json(self, include_output: bool = True, spoiler: bool = True) -> Dict[str, Any]: self_dict = { ':version': __version__, 'file_hash': CollapseList(self.file_hash), @@ -1311,13 +1334,13 @@ def to_json(self, include_output=True, spoiler=True): self_dict['settings'] = dict(self._settings) return self_dict - def to_str(self, include_output_only=True, spoiler=True): + def to_str(self, include_output_only: bool = True, spoiler: bool = True) -> str: return dump_obj(self.to_json(include_output_only, spoiler)) - def __str__(self): + def __str__(self) -> str: return dump_obj(self.to_json()) - def update_spoiler(self, spoiler, output_spoiler): + def update_spoiler(self, spoiler: Spoiler, output_spoiler: bool) -> None: self.file_hash = [HASH_ICONS[icon] for icon in spoiler.file_hash] if not output_spoiler: @@ -1392,7 +1415,7 @@ def update_spoiler(self, spoiler, output_spoiler): ent_rec_sphere[entrance_key] = EntranceRecord.from_entrance(entrance) @staticmethod - def from_file(settings, filename): + def from_file(settings: "Settings", filename: str) -> 'Distribution': if any(map(filename.endswith, ['.z64', '.n64', '.v64'])): raise InvalidFileException("Your Ocarina of Time ROM doesn't belong in the plandomizer setting. If you don't know what plandomizer is, or don't plan to use it, leave that setting blank and try again.") @@ -1403,28 +1426,28 @@ def from_file(settings, filename): raise InvalidFileException(f"Invalid Plandomizer File. Make sure the file is a valid JSON file. Failure reason: {str(e)}") from None return Distribution(settings, src_dict) - def to_file(self, filename, output_spoiler): + def to_file(self, filename: str, output_spoiler: bool) -> None: json = self.to_str(spoiler=output_spoiler) with open(filename, 'w', encoding='utf-8') as outfile: outfile.write(json) -def add_starting_ammo(starting_items): +def add_starting_ammo(starting_items: Dict[str, StarterRecord]) -> None: for item in StartingItems.inventory.values(): - if item.itemname in starting_items and item.ammo: + if item.item_name in starting_items and item.ammo: for ammo, qty in item.ammo.items(): # Add ammo to starter record, but not overriding existing count if present if ammo not in starting_items: starting_items[ammo] = StarterRecord(0) - starting_items[ammo].count = qty[starting_items[item.itemname].count - 1] + starting_items[ammo].count = qty[starting_items[item.item_name].count - 1] -def add_starting_item_with_ammo(starting_items, item_name, count=1): +def add_starting_item_with_ammo(starting_items: Dict[str, StarterRecord], item_name: str, count: int = 1) -> None: if item_name not in starting_items: starting_items[item_name] = StarterRecord(0) starting_items[item_name].count += count for item in StartingItems.inventory.values(): - if item.itemname == item_name and item.ammo: + if item.item_name == item_name and item.ammo: for ammo, qty in item.ammo.items(): if ammo not in starting_items: starting_items[ammo] = StarterRecord(0) @@ -1432,7 +1455,7 @@ def add_starting_item_with_ammo(starting_items, item_name, count=1): break -def strip_output_only(obj): +def strip_output_only(obj: Union[list, dict]) -> None: if isinstance(obj, list): for elem in obj: strip_output_only(elem) @@ -1444,19 +1467,19 @@ def strip_output_only(obj): strip_output_only(elem) -def can_cloak(actual_item, model): - return actual_item.index == 0x7C # Ice Trap +def can_cloak(actual_item: Item, model: Item) -> bool: + return actual_item.index == 0x7C # Ice Trap -def is_output_only(pattern): +def is_output_only(pattern: str) -> bool: return pattern.startswith(':') -def is_pattern(pattern): +def is_pattern(pattern: str) -> bool: return pattern.startswith('!') or pattern.startswith('*') or pattern.startswith('#') or pattern.endswith('*') -def pull_first_element(pools, predicate=lambda k:True, remove=True): +def pull_first_element(pools: List[List[Any]], predicate: Callable[[Any], bool] = lambda k: True, remove: bool = True) -> Optional[Any]: for pool in pools: for element in pool: if predicate(element): @@ -1466,7 +1489,7 @@ def pull_first_element(pools, predicate=lambda k:True, remove=True): return None -def pull_random_element(pools, predicate=lambda k:True, remove=True): +def pull_random_element(pools: List[List[Any]], predicate: Callable[[Any], bool] = lambda k: True, remove: bool = True) -> Optional[Any]: candidates = [(element, pool) for pool in pools for element in pool if predicate(element)] if len(candidates) == 0: return None @@ -1476,7 +1499,7 @@ def pull_random_element(pools, predicate=lambda k:True, remove=True): return element -def pull_all_elements(pools, predicate=lambda k:True, remove=True): +def pull_all_elements(pools: List[List[Any]], predicate: Callable[[Any], bool] = lambda k: True, remove: bool = True) -> Optional[List[Any]]: elements = [] for pool in pools: for element in pool: diff --git a/Region.py b/Region.py index eaf648ac4..050ca6303 100644 --- a/Region.py +++ b/Region.py @@ -1,53 +1,56 @@ from enum import Enum, unique +from typing import TYPE_CHECKING, List, Optional + +if TYPE_CHECKING: + from Dungeon import Dungeon + from Entrance import Entrance + from Hints import HintArea + from Item import Item + from Location import Location + from World import World @unique class RegionType(Enum): - Overworld = 1 Interior = 2 Dungeon = 3 Grotto = 4 - @property - def is_indoors(self): + def is_indoors(self) -> bool: """Shorthand for checking if Interior or Dungeon""" return self in (RegionType.Interior, RegionType.Dungeon, RegionType.Grotto) # Pretends to be an enum, but when the values are raw ints, it's much faster -class TimeOfDay(object): - NONE = 0 - DAY = 1 - DAMPE = 2 - ALL = DAY | DAMPE - - -class Region(object): - - def __init__(self, name, type=RegionType.Overworld): - self.name = name - self.type = type - self.entrances = [] - self.exits = [] - self.locations = [] - self.dungeon = None - self.world = None - self.hint_name = None - self.alt_hint_name = None - self.price = None - self.world = None - self.time_passes = False - self.provides_time = TimeOfDay.NONE - self.scene = None - self.is_boss_room = False - self.savewarp = None - - - def copy(self, new_world): - new_region = Region(self.name, self.type) - new_region.world = new_world +class TimeOfDay: + NONE: int = 0 + DAY: int = 1 + DAMPE: int = 2 + ALL: int = DAY | DAMPE + + +class Region: + def __init__(self, world: "World", name: str, region_type: RegionType = RegionType.Overworld) -> None: + self.world: "World" = world + self.name: str = name + self.type: RegionType = region_type + self.entrances: "List[Entrance]" = [] + self.exits: "List[Entrance]" = [] + self.locations: "List[Location]" = [] + self.dungeon: "Optional[Dungeon]" = None + self.hint_name: Optional[str] = None + self.alt_hint_name: Optional[str] = None + self.price: Optional[int] = None + self.time_passes: bool = False + self.provides_time: int = TimeOfDay.NONE + self.scene: Optional[str] = None + self.is_boss_room: bool = False + self.savewarp: "Optional[Entrance]" = None + + def copy(self, new_world: "World") -> 'Region': + new_region = Region(new_world, self.name, self.type) new_region.price = self.price new_region.hint_name = self.hint_name new_region.alt_hint_name = self.alt_hint_name @@ -63,9 +66,8 @@ def copy(self, new_world): return new_region - @property - def hint(self): + def hint(self) -> "HintArea": from Hints import HintArea if self.hint_name is not None: @@ -74,14 +76,13 @@ def hint(self): return self.dungeon.hint @property - def alt_hint(self): + def alt_hint(self) -> "HintArea": from Hints import HintArea if self.alt_hint_name is not None: return HintArea[self.alt_hint_name] - - def can_fill(self, item, manual=False): + def can_fill(self, item: "Item", manual: bool = False) -> bool: if not manual and self.world.settings.empty_dungeons_mode != 'none' and item.dungeonitem: # An empty dungeon can only store its own dungeon items if self.dungeon and self.dungeon.world.empty_dungeons[self.dungeon.name].empty: @@ -135,8 +136,7 @@ def can_fill(self, item, manual=False): return True - - def get_scene(self): + def get_scene(self) -> Optional[str]: if self.scene: return self.scene elif self.dungeon: @@ -144,11 +144,8 @@ def get_scene(self): else: return None - - def __str__(self): + def __str__(self) -> str: return str(self.__unicode__()) - - def __unicode__(self): + def __unicode__(self) -> str: return '%s' % self.name - diff --git a/Rom.py b/Rom.py index a9df24731..7e5e374e5 100644 --- a/Rom.py +++ b/Rom.py @@ -4,7 +4,7 @@ import struct import subprocess import copy -from typing import Sequence, MutableSequence, Tuple, Optional +from typing import List, Tuple, Sequence, Iterable, Optional from Utils import is_bundled, subprocess_args, local_path, data_path, get_version_bytes from ntype import BigStream @@ -12,22 +12,23 @@ from Models import restrictiveBytes from version import base_version, branch_identifier, supplementary_version -DMADATA_START = 0x7430 +DMADATA_START: int = 0x7430 # NTSC 1.0/1.1: 0x7430, NTSC 1.2: 0x7960, Debug: 0x012F70 +DMADATA_INDEX: int = 2 class Rom(BigStream): def __init__(self, file: str = None) -> None: super().__init__(bytearray()) - self.original: Rom + self.original: Optional[Rom] = None self.changed_address: dict[int, int] = {} self.changed_dma: dict[int, Tuple[int, int, int]] = {} - self.force_patch: MutableSequence[int] = [] + self.force_patch: List[int] = [] if file is None: return - decomp_file: str = local_path('ZOOTDEC.z64') + decompressed_file: str = local_path('ZOOTDEC.z64') os.chdir(local_path()) @@ -37,7 +38,7 @@ def __init__(self, file: str = None) -> None: if file == '': # if not specified, try to read from the previously decompressed rom - file = decomp_file + file = decompressed_file try: self.read_rom(file) except FileNotFoundError: @@ -47,12 +48,15 @@ def __init__(self, file: str = None) -> None: self.read_rom(file) # decompress rom, or check if it's already decompressed - self.decompress_rom_file(file, decomp_file) + self.decompress_rom_file(file, decompressed_file) # Add file to maximum size self.buffer.extend(bytearray([0x00] * (0x4000000 - len(self.buffer)))) self.original = self.copy() + # Easy access to DMA entries. + self.dma: 'DMAIterator' = DMAIterator(self, DMADATA_START, DMADATA_INDEX) + # Add version number to header. self.write_version_bytes() @@ -62,6 +66,7 @@ def copy(self) -> 'Rom': new_rom.changed_address = copy.copy(self.changed_address) new_rom.changed_dma = copy.copy(self.changed_dma) new_rom.force_patch = copy.copy(self.force_patch) + new_rom.dma = DMAIterator(new_rom, DMADATA_START, DMADATA_INDEX) return new_rom def decompress_rom_file(self, input_file: str, output_file: str, verify_crc: bool = True) -> None: @@ -179,36 +184,17 @@ def read_rom(self, file: str) -> None: # dmadata/file management helper functions - def _get_dmadata_record(self, cur: int) -> Tuple[int, int, int]: - start = self.read_int32(cur) - end = self.read_int32(cur+0x04) - size = end-start - return start, end, size - - def get_dmadata_record_by_key(self, key: int) -> Optional[Tuple[int, int, int]]: - cur = DMADATA_START - dma_start, dma_end, dma_size = self._get_dmadata_record(cur) - while True: - if dma_start == 0 and dma_end == 0: - return None - if dma_start == key: - return dma_start, dma_end, dma_size - cur += 0x10 - dma_start, dma_end, dma_size = self._get_dmadata_record(cur) - def verify_dmadata(self) -> None: - cur = DMADATA_START overlapping_records = [] dma_data = [] - while True: - this_start, this_end, this_size = self._get_dmadata_record(cur) + for dma_entry in self.dma: + this_start, this_end, this_size = dma_entry.as_tuple() if this_start == 0 and this_end == 0: break dma_data.append((this_start, this_end, this_size)) - cur += 0x10 dma_data.sort(key=lambda v: v[0]) @@ -218,78 +204,42 @@ def verify_dmadata(self) -> None: if this_end > next_start: overlapping_records.append( - '0x%08X - 0x%08X (Size: 0x%04X)\n0x%08X - 0x%08X (Size: 0x%04X)' % \ - (this_start, this_end, this_size, next_start, next_end, next_size) - ) + f'0x{this_start:08X} - 0x{this_end:08X} (Size: 0x{this_size:04X})\n0x{next_start:08X} - 0x{next_end:08X} (Size: 0x{next_size:04X})' + ) if len(overlapping_records) > 0: - raise Exception("Overlapping DMA Data Records!\n%s" % \ - '\n-------------------------------------\n'.join(overlapping_records)) + raise Exception("Overlapping DMA Data Records!\n%s" % + '\n-------------------------------------\n'.join(overlapping_records)) # update dmadata record with start vrom address "key" # if key is not found, then attempt to add a new dmadata entry - def update_dmadata_record(self, key: int, start: int, end: int, from_file: int = None) -> None: - cur, dma_data_end = self.get_dma_table_range() - dma_index = 0 - dma_start, dma_end, dma_size = self._get_dmadata_record(cur) - while dma_start != key: - if dma_start == 0 and dma_end == 0: - break - - cur += 0x10 - dma_index += 1 - dma_start, dma_end, dma_size = self._get_dmadata_record(cur) - - if cur >= (dma_data_end - 0x10): - raise Exception('dmadata update failed: key {0:x} not found in dmadata and dma table is full.'.format(key)) - else: - self.write_int32s(cur, [start, end, start, 0]) - if from_file is None: - if key is None: - from_file = -1 - else: - from_file = key - self.changed_dma[dma_index] = (from_file, start, end - start) - - def get_dma_table_range(self) -> Tuple[int, int]: - cur = DMADATA_START - dma_start, dma_end, dma_size = self._get_dmadata_record(cur) - while True: - if dma_start == 0 and dma_end == 0: - raise Exception('Bad DMA Table: DMA Table entry missing.') - - if dma_start == DMADATA_START: - return DMADATA_START, dma_end + def update_dmadata_record_by_key(self, key: Optional[int], start: int, end: int, from_file: Optional[int] = None) -> None: + dma_entry = self.dma.get_dmadata_record_by_key(key) + if dma_entry is None: + raise Exception(f"dmadata update failed: key {key:{'x' if key else ''}} not found in dmadata and dma table is full.") - cur += 0x10 - dma_start, dma_end, dma_size = self._get_dmadata_record(cur) + if from_file is None: + from_file = -1 if key is None else key + dma_entry.update(start, end, from_file) # This will scan for any changes that have been made to the DMA table # By default, this assumes any changes here are new files, so this should only be called # after patching in the new files, but before vanilla files are repointed def scan_dmadata_update(self, preserve_from_file: bool = False, assume_move: bool = False) -> None: - cur = DMADATA_START - dma_index = 0 - dma_start, dma_end, dma_size = self._get_dmadata_record(cur) - old_dma_start, old_dma_end, old_dma_size = self.original._get_dmadata_record(cur) - - while True: + for dma_entry in self.dma: + dma_start, dma_end, dma_size = dma_entry.as_tuple() + old_dma_start, old_dma_end, old_dma_size = self.original.dma[dma_entry.index].as_tuple() if (dma_start == 0 and dma_end == 0) and (old_dma_start == 0 and old_dma_end == 0): break # If the entries do not match, the flag the changed entry if not (dma_start == old_dma_start and dma_end == old_dma_end): from_file = -1 - if preserve_from_file and dma_index in self.changed_dma: - from_file = self.changed_dma[dma_index][0] - elif assume_move and dma_index < 1496: + if preserve_from_file and dma_entry.index in self.changed_dma: + from_file = self.changed_dma[dma_entry.index][0] + elif assume_move and dma_entry.index < 1496: from_file = old_dma_start - self.changed_dma[dma_index] = (from_file, dma_start, dma_end - dma_start) - - cur += 0x10 - dma_index += 1 - dma_start, dma_end, dma_size = self._get_dmadata_record(cur) - old_dma_start, old_dma_end, old_dma_size = self.original._get_dmadata_record(cur) + self.changed_dma[dma_entry.index] = (from_file, dma_start, dma_end - dma_start) # This will rescan the entire ROM, compare to original ROM, and repopulate changed_address. def rescan_changed_bytes(self) -> None: @@ -306,18 +256,78 @@ def rescan_changed_bytes(self) -> None: if size < original_size: self.changed_address.update(zip(range(size, original_size-1), [0]*(original_size-size))) + +class DMAEntry: + def __init__(self, rom: Rom, index: int) -> None: + self.rom = rom + self.index = index + if self.index < 0 or self.index > self.rom.dma.dma_entries: + raise ValueError(f"DMAEntry: Index out of range: {self.index}") + + @property + def start(self) -> int: + return self.rom.read_int32(self.rom.dma.dma_start + (self.index * 0x10)) + + @property + def end(self) -> int: + return self.rom.read_int32(self.rom.dma.dma_start + (self.index * 0x10) + 0x04) + + @property + def size(self) -> int: + return self.end - self.start + + def as_tuple(self) -> Tuple[int, int, int]: + start, end = self.start, self.end + return start, end, end - start + + def update(self, start: int, end: int, from_file: Optional[int] = None): + if from_file is None: + if self.index in self.rom.changed_dma: + from_file = self.rom.changed_dma[self.index][0] + elif self.start and self.end: + from_file = self.start + else: + from_file = -1 + self.rom.write_int32s(self.rom.dma.dma_start + (self.index * 0x10), [start, end, start, 0]) + self.rom.changed_dma[self.index] = (from_file, start, end - start) + + +class DMAIterator: + def __init__(self, rom: Rom, dma_start: int, dma_index: int) -> None: + self.rom: Rom = rom + self.dma_start: int = dma_start + self.dma_index: int = dma_index + self.dma_end: int = self.rom.read_int32(self.dma_start + (self.dma_index * 0x10) + 0x04) + self.dma_entries: int = (self.dma_end - self.dma_start) >> 4 + + def __getitem__(self, item: int) -> DMAEntry: + if not isinstance(item, int): + raise ValueError("DMAIterator only supports integer keys.") + if item < 0: + item = self.dma_entries + item + if item > self.dma_entries: + raise ValueError(f"Attempted to get DMA entry exceeding the table size: {item}") + + return DMAEntry(self.rom, item) + + def __iter__(self) -> Iterable[DMAEntry]: + for item in range(0, self.dma_entries): + yield self[item] + + # Gets a dmadata entry by the file start position. + def get_dmadata_record_by_key(self, key: Optional[int]) -> Optional[DMAEntry]: + for dma_entry in self: + if key is None and dma_entry.end == 0 and dma_entry.start == 0: + return dma_entry + elif dma_entry.start == key: + return dma_entry + return None + # gets the last used byte of rom defined in the DMA table def free_space(self) -> int: - cur = DMADATA_START max_end = 0 + for dma_entry in self: + max_end = max(max_end, dma_entry.end) - while True: - this_start, this_end, this_size = self._get_dmadata_record(cur) - - if this_start == 0 and this_end == 0: - break - - max_end = max(max_end, this_end) - cur += 0x10 max_end = ((max_end + 0x0F) >> 4) << 4 return max_end diff --git a/RuleParser.py b/RuleParser.py index 011b7064a..6e555985e 100644 --- a/RuleParser.py +++ b/RuleParser.py @@ -1,78 +1,83 @@ import ast -from collections import defaultdict import logging import re +from collections import defaultdict +from typing import TYPE_CHECKING, Dict, List, Tuple, Set, Pattern, Union, Optional, Any -from Item import ItemInfo, Item, MakeEventItem +from Entrance import Entrance +from Item import ItemInfo, Item, make_event_item from Location import Location from Region import TimeOfDay -from RulesCommon import allowed_globals, escape_name +from RulesCommon import AccessRule, allowed_globals, escape_name from State import State from Utils import data_path, read_logic_file +if TYPE_CHECKING: + from World import World -escaped_items = {} +escaped_items: Dict[str, str] = {} for item in ItemInfo.items: escaped_items[escape_name(item)] = item -event_name = re.compile(r'[A-Z]\w+') +event_name: Pattern[str] = re.compile(r'[A-Z]\w+') # All generated lambdas must accept these keyword args! # For evaluation at a certain age (required as all rules are evaluated at a specific age) # or at a certain spot (can be omitted in many cases) # or at a specific time of day (often unused) -kwarg_defaults = { +kwarg_defaults: Dict[str, Any] = { 'age': None, 'spot': None, 'tod': TimeOfDay.NONE, } -special_globals = {'TimeOfDay': TimeOfDay} +special_globals: Dict[str, Any] = {'TimeOfDay': TimeOfDay} allowed_globals.update(special_globals) -rule_aliases = {} -nonaliases = set() +rule_aliases: Dict[str, Tuple[List[Pattern[str]], str]] = {} +nonaliases: Set[str] = set() + -def load_aliases(): +def load_aliases() -> None: j = read_logic_file(data_path('LogicHelpers.json')) for s, repl in j.items(): if '(' in s: rule, args = s[:-1].split('(', 1) - args = [re.compile(r'\b%s\b' % a.strip()) for a in args.split(',')] + args = [re.compile(fr'\b{a.strip()}\b') for a in args.split(',')] else: rule = s args = () rule_aliases[rule] = (args, repl) - nonaliases = escaped_items.keys() - rule_aliases.keys() + nonaliases.update(escaped_items.keys()) + nonaliases.difference_update(rule_aliases.keys()) -def isliteral(expr): +def isliteral(expr: ast.expr) -> bool: return isinstance(expr, (ast.Num, ast.Str, ast.Bytes, ast.NameConstant)) class Rule_AST_Transformer(ast.NodeTransformer): - - def __init__(self, world): - self.world = world - self.events = set() + def __init__(self, world: "World") -> None: + self.world: "World" = world + self.current_spot: Optional[Location, Entrance] = None + self.events: Set[str] = set() # map Region -> rule ast string -> item name - self.replaced_rules = defaultdict(dict) + self.replaced_rules: Dict[str, Dict[str, ast.Call]] = defaultdict(dict) # delayed rules need to keep: region name, ast node, event name - self.delayed_rules = [] + self.delayed_rules: List[Tuple[str, ast.AST, str]] = [] # lazy load aliases if not rule_aliases: load_aliases() # final rule cache - self.rule_cache = {} - + self.rule_cache: Dict[str, AccessRule] = {} - def visit_Name(self, node): + def visit_Name(self, node: ast.Name) -> Any: if node.id in dir(self): return getattr(self, node.id)(node) elif node.id in rule_aliases: args, repl = rule_aliases[node.id] if args: - raise Exception('Parse Error: expected %d args for %s, not 0' % (len(args), node.id), - self.current_spot.name, ast.dump(node, False)) + raise Exception(f'Parse Error: expected {len(args):d} args for {node.id}, not 0', + self.current_spot.name, ast.dump(node, False)) return self.visit(ast.parse(repl, mode='eval').body) elif node.id in escaped_items: return ast.Call( @@ -105,7 +110,7 @@ def visit_Name(self, node): else: raise Exception('Parse Error: invalid node name %s' % node.id, self.current_spot.name, ast.dump(node, False)) - def visit_Str(self, node): + def visit_Str(self, node: ast.Str) -> Any: esc = escape_name(node.s) if esc not in ItemInfo.solver_ids: self.events.add(esc.replace('_', ' ')) @@ -120,13 +125,12 @@ def visit_Str(self, node): # python 3.8 compatibility: ast walking now uses visit_Constant for Constant subclasses # this includes Num, Str, NameConstant, Bytes, and Ellipsis. We only handle Str. - def visit_Constant(self, node): + def visit_Constant(self, node: ast.Constant) -> Any: if isinstance(node, ast.Str): return self.visit_Str(node) return node - - def visit_Tuple(self, node): + def visit_Tuple(self, node: ast.Tuple) -> Any: if len(node.elts) != 2: raise Exception('Parse Error: Tuple must have 2 values', self.current_spot.name, ast.dump(node, False)) @@ -155,8 +159,7 @@ def visit_Tuple(self, node): args=[item, count], keywords=[]) - - def visit_Call(self, node): + def visit_Call(self, node: ast.Call) -> Any: if not isinstance(node.func, ast.Name): return node @@ -217,8 +220,7 @@ def visit_Call(self, node): return self.make_call(node, node.func.id, new_args, node.keywords) - - def visit_Subscript(self, node): + def visit_Subscript(self, node: ast.Subscript) -> Any: if isinstance(node.value, ast.Name): s = node.slice if isinstance(node.slice, ast.Name) else node.slice.value return ast.Subscript( @@ -234,9 +236,8 @@ def visit_Subscript(self, node): else: return node - - def visit_Compare(self, node): - def escape_or_string(n): + def visit_Compare(self, node: ast.Compare) -> Any: + def escape_or_string(n: ast.AST) -> Any: if isinstance(n, ast.Name) and n.id in escaped_items: return ast.Str(escaped_items[n.id]) elif not isinstance(n, ast.Str): @@ -265,8 +266,7 @@ def escape_or_string(n): return self.visit(ast.parse('%r' % res, mode='eval').body) return node - - def visit_UnaryOp(self, node): + def visit_UnaryOp(self, node: ast.UnaryOp) -> Any: # visit the children first self.generic_visit(node) # if all the children are literals now, we can evaluate @@ -275,8 +275,7 @@ def visit_UnaryOp(self, node): return ast.parse('%r' % res, mode='eval').body return node - - def visit_BinOp(self, node): + def visit_BinOp(self, node: ast.BinOp) -> Any: # visit the children first self.generic_visit(node) # if all the children are literals now, we can evaluate @@ -285,8 +284,7 @@ def visit_BinOp(self, node): return ast.parse('%r' % res, mode='eval').body return node - - def visit_BoolOp(self, node): + def visit_BoolOp(self, node: ast.BoolOp) -> Any: # Everything else must be visited, then can be removed/reduced to. early_return = isinstance(node.op, ast.Or) groupable = 'has_any_of' if early_return else 'has_all_of' @@ -345,11 +343,10 @@ def visit_BoolOp(self, node): return node.values[0] return node - # Generates an ast.Call invoking the given State function 'name', # providing given args and keywords, and adding in additional # keyword args from kwarg_defaults (age, etc.) - def make_call(self, node, name, args, keywords): + def make_call(self, node: ast.AST, name: str, args: List[Any], keywords: List[ast.keyword]) -> ast.Call: if not hasattr(State, name): raise Exception('Parse Error: No such function State.%s' % name, self.current_spot.name, ast.dump(node, False)) @@ -361,8 +358,7 @@ def make_call(self, node, name, args, keywords): args=args, keywords=keywords) - - def replace_subrule(self, target, node): + def replace_subrule(self, target: str, node: ast.AST) -> ast.Call: rule = ast.dump(node, False) if rule in self.replaced_rules[target]: return self.replaced_rules[target][rule] @@ -385,12 +381,11 @@ def replace_subrule(self, target, node): self.replaced_rules[target][rule] = item_rule return item_rule - # Requires the target regions have been defined in the world. - def create_delayed_rules(self): + def create_delayed_rules(self) -> None: for region_name, node, subrule_name in self.delayed_rules: region = self.world.get_region(region_name) - event = Location(subrule_name, type='Event', parent=region, internal=True) + event = Location(subrule_name, location_type='Event', parent=region, internal=True) event.world = self.world self.current_spot = event @@ -406,12 +401,11 @@ def create_delayed_rules(self): event.set_rule(access_rule) region.locations.append(event) - MakeEventItem(subrule_name, event) + make_event_item(subrule_name, event) # Safeguard in case this is called multiple times per world self.delayed_rules.clear() - - def make_access_rule(self, body): + def make_access_rule(self, body: ast.AST) -> AccessRule: rule_str = ast.dump(body, False) if rule_str not in self.rule_cache: # requires consistent iteration on dicts @@ -437,44 +431,40 @@ def make_access_rule(self, body): raise Exception('Parse Error: %s' % e, self.current_spot.name, ast.dump(body, False)) return self.rule_cache[rule_str] - ## Handlers for specific internal functions used in the json logic. # at(region_name, rule) # Creates an internal event at the remote region and depends on it. - def at(self, node): + def at(self, node: ast.Call) -> ast.Call: # Cache this under the target (region) name if len(node.args) < 2 or not isinstance(node.args[0], ast.Str): raise Exception('Parse Error: invalid at() arguments', self.current_spot.name, ast.dump(node, False)) return self.replace_subrule(node.args[0].s, node.args[1]) - # here(rule) # Creates an internal event in the same region and depends on it. - def here(self, node): + def here(self, node: ast.Call) -> ast.Call: if not node.args: raise Exception('Parse Error: missing here() argument', self.current_spot.name, ast.dump(node, False)) - return self.replace_subrule( - self.current_spot.parent_region.name, - node.args[0]) + return self.replace_subrule(self.current_spot.parent_region.name, node.args[0]) ## Handlers for compile-time optimizations (former State functions) - def at_day(self, node): + def at_day(self, node: ast.Call) -> ast.expr: if self.world.ensure_tod_access: # tod has DAY or (tod == NONE and (ss or find a path from a provider)) # parsing is better than constructing this expression by hand return ast.parse("(tod & TimeOfDay.DAY) if tod else (state.has_all_of((Ocarina, Suns_Song)) or state.search.can_reach(spot.parent_region, age=age, tod=TimeOfDay.DAY))", mode='eval').body return ast.NameConstant(True) - def at_dampe_time(self, node): + def at_dampe_time(self, node: ast.Call) -> ast.expr: if self.world.ensure_tod_access: # tod has DAMPE or (tod == NONE and (find a path from a provider)) # parsing is better than constructing this expression by hand return ast.parse("(tod & TimeOfDay.DAMPE) if tod else state.search.can_reach(spot.parent_region, age=age, tod=TimeOfDay.DAMPE)", mode='eval').body return ast.NameConstant(True) - def at_night(self, node): + def at_night(self, node: ast.Call) -> ast.expr: if self.current_spot.type == 'GS Token' and self.world.settings.logic_no_night_tokens_without_suns_song: # Using visit here to resolve 'can_play' rule return self.visit(ast.parse('can_play(Suns_Song)', mode='eval').body) @@ -484,14 +474,13 @@ def at_night(self, node): return ast.parse("(tod & TimeOfDay.DAMPE) if tod else (state.has_all_of((Ocarina, Suns_Song)) or state.search.can_reach(spot.parent_region, age=age, tod=TimeOfDay.DAMPE))", mode='eval').body return ast.NameConstant(True) - # Parse entry point # If spot is None, here() rules won't work. - def parse_rule(self, rule_string, spot=None): + def parse_rule(self, rule_string: str, spot: Optional[Union[Location, Entrance]] = None) -> AccessRule: self.current_spot = spot return self.make_access_rule(self.visit(ast.parse(rule_string, mode='eval').body)) - def parse_spot_rule(self, spot): + def parse_spot_rule(self, spot: Union[Location, Entrance]) -> None: rule = spot.rule_string.split('#', 1)[0].strip() access_rule = self.parse_rule(rule, spot) diff --git a/Rules.py b/Rules.py index cf842c394..c876fc717 100644 --- a/Rules.py +++ b/Rules.py @@ -1,11 +1,19 @@ import logging +from typing import TYPE_CHECKING, Collection, Iterable, Callable, Union + from ItemPool import song_list -from Location import DisableType +from Location import Location, DisableType +from RulesCommon import AccessRule from Search import Search from State import State +if TYPE_CHECKING: + from Entrance import Entrance + from Item import Item + from World import World + -def set_rules(world): +def set_rules(world: "World") -> None: logger = logging.getLogger('') # ganon can only carry triforce @@ -76,8 +84,8 @@ def set_rules(world): logger.debug('Tried to disable location that does not exist: %s' % location) -def create_shop_rule(location): - def required_wallets(price): +def create_shop_rule(location: Location) -> AccessRule: + def required_wallets(price: int) -> int: if price > 500: return 3 if price > 200: @@ -88,26 +96,26 @@ def required_wallets(price): return location.world.parser.parse_rule('(Progressive_Wallet, %d)' % required_wallets(location.price)) -def set_rule(spot, rule): +def set_rule(spot: "Union[Location, Entrance]", rule: AccessRule) -> None: spot.access_rule = rule -def add_item_rule(spot, rule): +def add_item_rule(spot: Location, rule: "Callable[[Location, Item], bool]") -> None: old_rule = spot.item_rule spot.item_rule = lambda location, item: rule(location, item) and old_rule(location, item) -def forbid_item(location, item_name): +def forbid_item(location: Location, item_name: str) -> None: old_rule = location.item_rule location.item_rule = lambda loc, item: item.name != item_name and old_rule(loc, item) -def limit_to_itemset(location, itemset): +def limit_to_itemset(location: Location, itemset: "Collection[Item]"): old_rule = location.item_rule location.item_rule = lambda loc, item: item.name in itemset and old_rule(loc, item) -def item_in_locations(state, item, locations): +def item_in_locations(state: State, item: "Item", locations: Iterable[Location]) -> bool: for location in locations: if state.item_name(location) == item: return True @@ -120,7 +128,7 @@ def item_in_locations(state, item, locations): # accessible when all items are obtained and every shop item is not. # This function should also be called when a world is copied if the original world # had called this function because the world.copy does not copy the rules -def set_shop_rules(world): +def set_shop_rules(world: "World"): found_bombchus = world.parser.parse_rule('found_bombchus') wallet = world.parser.parse_rule('Progressive_Wallet') wallet2 = world.parser.parse_rule('(Progressive_Wallet, 2)') @@ -147,16 +155,15 @@ def set_shop_rules(world): 'Buy Red Potion for 30 Rupees', 'Buy Red Potion for 40 Rupees', 'Buy Red Potion for 50 Rupees', - 'Buy Fairy\'s Spirit']: + "Buy Fairy's Spirit"]: location.add_rule(State.has_bottle) if location.item.name in ['Buy Bombchu (10)', 'Buy Bombchu (20)', 'Buy Bombchu (5)']: location.add_rule(found_bombchus) -# This function should be ran once after setting up entrances and before placing items +# This function should be run once after setting up entrances and before placing items # The goal is to automatically set item rules based on age requirements in case entrances were shuffled -def set_entrances_based_rules(worlds): - +def set_entrances_based_rules(worlds: "Collection[World]") -> None: # Use the states with all items available in the pools for this seed complete_itempool = [item for world in worlds for item in world.get_itempool_with_dungeon_items()] search = Search([world.state for world in worlds]) diff --git a/RulesCommon.py b/RulesCommon.py index d113cc7d9..871e64c87 100644 --- a/RulesCommon.py +++ b/RulesCommon.py @@ -1,12 +1,29 @@ import re +import sys +from typing import TYPE_CHECKING, Dict, Pattern, Callable, Any + +if TYPE_CHECKING: + from State import State + +# The better way to type hint the access rule requires Python 3.8. +if sys.version_info >= (3, 8): + from typing import Protocol + + class AccessRule(Protocol): + def __call__(self, state: "State", **kwargs): + ... +else: + from Utils import TypeAlias + AccessRule: TypeAlias = Callable[["State"], bool] + # Variable names and values used by rule execution, # will be automatically filled by Items -allowed_globals = {} +allowed_globals: Dict[str, Any] = {} -_escape = re.compile(r'[\'()[\]-]') +_escape: Pattern[str] = re.compile(r'[\'()[\]-]') -def escape_name(name): - return _escape.sub('', name.replace(' ', '_')) +def escape_name(name: str) -> str: + return _escape.sub('', name.replace(' ', '_')) diff --git a/SaveContext.py b/SaveContext.py index 0e155e879..8121eb5f7 100644 --- a/SaveContext.py +++ b/SaveContext.py @@ -1,6 +1,15 @@ -from itertools import chain from enum import IntEnum +from typing import TYPE_CHECKING, Dict, List, Iterable, Callable, Optional, Union, Any + from ItemPool import IGNORE_LOCATION +from Utils import TypeAlias + +if TYPE_CHECKING: + from Rom import Rom + from World import World + +AddressesDict: TypeAlias = 'Dict[str, Union[Address, Dict[str, Union[Address, Dict[str, Address]]]]]' + class Scenes(IntEnum): # Dungeons @@ -25,6 +34,7 @@ class Scenes(IntEnum): DEATH_MOUNTAIN_CRATER = 0x61 GORON_CITY = 0x62 + class FlagType(IntEnum): CHEST = 0x00 SWITCH = 0x01 @@ -34,42 +44,36 @@ class FlagType(IntEnum): VISITED_ROOM = 0x05 VISITED_FLOOR = 0x06 -class Address(): - prev_address = None + +class Address: + prev_address: Optional[int] = None EXTENDED_CONTEXT_START = 0x1450 - def __init__(self, address=None, extended=False, size=4, mask=0xFFFFFFFF, max=None, choices=None, value=None): - if address is None: - self.address = Address.prev_address - else: - self.address = address + def __init__(self, address: Optional[int] = None, extended: bool = False, size: int = 4, mask: int = 0xFFFFFFFF, + max: Optional[int] = None, choices: Optional[Dict[str, int]] = None, value: Optional[str] = None) -> None: + self.address: int = Address.prev_address if address is None else address if extended and address is not None: self.address += Address.EXTENDED_CONTEXT_START - self.value = value - self.size = size - self.choices = choices - self.mask = mask + self.value: Optional[Union[str, int]] = value + self.size: int = size + self.choices: Optional[Dict[str, int]] = choices + self.mask: int = mask Address.prev_address = self.address + self.size - self.bit_offset = 0 + self.bit_offset: int = 0 while mask & 1 == 0: mask = mask >> 1 self.bit_offset += 1 - if max is None: - self.max = mask - else: - self.max = max - + self.max: int = mask if max is None else max - def get_value(self, default=0): + def get_value(self, default: Union[str, int] = 0) -> Union[str, int]: if self.value is None: return default return self.value - - def get_value_raw(self): + def get_value_raw(self) -> Optional[int]: if self.value is None: return None @@ -87,8 +91,7 @@ def get_value_raw(self): value = (value << self.bit_offset) & self.mask return value - - def set_value_raw(self, value): + def set_value_raw(self, value: int) -> None: if value is None: self.value = None return @@ -108,8 +111,7 @@ def set_value_raw(self, value): self.value = value - - def get_writes(self, save_context): + def get_writes(self, save_context: 'SaveContext') -> None: if self.value is None: return @@ -128,8 +130,8 @@ def get_writes(self, save_context): else: save_context.write_bits(self.address + i, byte, mask=mask) - - def to_bytes(value, size): + @staticmethod + def to_bytes(value: int, size: int) -> List[int]: ret = [] for _ in range(size): ret.insert(0, value & 0xFF) @@ -137,15 +139,14 @@ def to_bytes(value, size): return ret -class SaveContext(): +class SaveContext: def __init__(self): - self.save_bits = {} - self.save_bytes = {} - self.addresses = self.get_save_context_addresses() - + self.save_bits: Dict[int, int] = {} + self.save_bytes: Dict[int, int] = {} + self.addresses: AddressesDict = self.get_save_context_addresses() # will set the bits of value to the offset in the save (or'ing them with what is already there) - def write_bits(self, address, value, mask=None, predicate=None): + def write_bits(self, address: int, value: int, mask: Optional[int] = None, predicate: Optional[Callable[[int], bool]] = None) -> None: if predicate and not predicate(value): return @@ -165,9 +166,8 @@ def write_bits(self, address, value, mask=None, predicate=None): else: self.save_bits[address] = value - # will overwrite the byte at offset with the given value - def write_byte(self, address, value, predicate=None): + def write_byte(self, address: int, value: int, predicate: Optional[Callable[[int], bool]] = None) -> None: if predicate and not predicate(value): return @@ -176,14 +176,12 @@ def write_byte(self, address, value, predicate=None): self.save_bytes[address] = value - # will overwrite the byte at offset with the given value - def write_bytes(self, address, bytes, predicate=None): + def write_bytes(self, address: int, bytes: Iterable[int], predicate: Optional[Callable[[int], bool]] = None) -> None: for i, value in enumerate(bytes): self.write_byte(address + i, value, predicate) - - def write_save_entry(self, address): + def write_save_entry(self, address: Address) -> None: if isinstance(address, dict): for name, sub_address in address.items(): self.write_save_entry(sub_address) @@ -193,7 +191,7 @@ def write_save_entry(self, address): else: address.get_writes(self) - def write_permanent_flag(self, scene, type, byte_offset, bit_values): + def write_permanent_flag(self, scene: int, type: int, byte_offset: int, bit_values: int) -> None: # Save format is described here: https://wiki.cloudmodding.com/oot/Save_Format # Permanent flags start at offset 0x00D4. Each scene has 7 types of flags, one # of which is unused. Each flag type is 4 bytes wide per-scene, thus each scene @@ -203,11 +201,11 @@ def write_permanent_flag(self, scene, type, byte_offset, bit_values): self.write_bits(0x00D4 + scene * 0x1C + type * 0x04 + byte_offset, bit_values) # write all flags (int32) of a given type at once - def write_permanent_flags(self, scene, type, value): + def write_permanent_flags(self, scene: Scenes, flag_type: FlagType, value: int) -> None: byte_value = value.to_bytes(4, byteorder='big', signed=False) - self.write_bytes(0x00D4 + scene * 0x1C + type * 0x04, byte_value) + self.write_bytes(0x00D4 + scene * 0x1C + flag_type * 0x04, byte_value) - def set_ammo_max(self): + def set_ammo_max(self) -> None: ammo_maxes = { 'stick' : ('stick_upgrade', [10, 10, 20, 30]), 'nut' : ('nut_upgrade', [20, 20, 30, 40]), @@ -228,9 +226,8 @@ def set_ammo_max(self): else: self.addresses['ammo'][ammo].max = ammo_max - # will overwrite the byte at offset with the given value - def write_save_table(self, rom): + def write_save_table(self, rom: "Rom") -> None: self.set_ammo_max() for name, address in self.addresses.items(): self.write_save_entry(address) @@ -262,8 +259,7 @@ def write_save_table(self, rom): raise Exception("The Initial Extended Save Table has exceeded its maximum capacity: 0x%03X/0x100" % extended_table_len) rom.write_bytes(rom.sym('EXTENDED_INITIAL_SAVE_DATA'), extended_table) - - def give_bottle(self, item, count): + def give_bottle(self, item: str, count: int) -> None: for bottle_id in range(4): item_slot = 'bottle_%d' % (bottle_id + 1) if self.addresses['item_slot'][item_slot].get_value(0xFF) != 0xFF: @@ -275,8 +271,7 @@ def give_bottle(self, item, count): if count == 0: return - - def give_health(self, health): + def give_health(self, health: float): health += self.addresses['health_capacity'].get_value(0x30) / 0x10 health += self.addresses['quest']['heart_pieces'].get_value() / 4 @@ -284,8 +279,7 @@ def give_health(self, health): self.addresses['health'].value = int(health) * 0x10 self.addresses['quest']['heart_pieces'].value = int((health % 1) * 4) * 0x10 - - def give_item(self, world, item, count=1): + def give_item(self, world: "World", item: str, count: int = 1) -> None: if item.endswith(')'): item_base, implicit_count = item[:-1].split(' (', 1) if implicit_count.isdigit(): @@ -424,6 +418,7 @@ def give_item(self, world, item, count=1): address_value = self.addresses prev_sub_address = 'Save Context' + sub_address = None for sub_address in address.split('.'): if sub_address not in address_value: raise ValueError('Unknown key %s in %s of SaveContext' % (sub_address, prev_sub_address)) @@ -434,7 +429,7 @@ def give_item(self, world, item, count=1): address_value = address_value[sub_address] prev_sub_address = sub_address if not isinstance(address_value, Address): - raise ValueError('%s does not resolve to an Address in SaveContext' % (sub_address)) + raise ValueError('%s does not resolve to an Address in SaveContext' % sub_address) if isinstance(value, int) and value < address_value.get_value(): continue @@ -443,20 +438,16 @@ def give_item(self, world, item, count=1): else: raise ValueError("Cannot give unknown starting item %s" % item) - - def give_bombchu_item(self, world): + def give_bombchu_item(self, world: "World") -> None: self.give_item(world, "Bombchus", 0) - - def equip_default_items(self, age): + def equip_default_items(self, age: str) -> None: self.equip_items(age, 'equips_' + age) - - def equip_current_items(self, age): + def equip_current_items(self, age: str) -> None: self.equip_items(age, 'equips') - - def equip_items(self, age, equip_type): + def equip_items(self, age: str, equip_type: str) -> None: if age not in ['child', 'adult']: raise ValueError("Age must be 'adult' or 'child', not %s" % age) @@ -485,8 +476,8 @@ def equip_items(self, age, equip_type): self.addresses[equip_type]['button_items']['b'].value = item break - - def get_save_context_addresses(self): + @staticmethod + def get_save_context_addresses() -> AddressesDict: return { 'entrance_index' : Address(0x0000, size=4), 'link_age' : Address(size=4, max=1), @@ -890,8 +881,7 @@ def get_save_context_addresses(self): } } - - item_id_map = { + item_id_map: Dict[str, int] = { 'none' : 0xFF, 'stick' : 0x00, 'nut' : 0x01, @@ -1015,8 +1005,7 @@ def get_save_context_addresses(self): 'small_key' : 0x67, } - - slot_id_map = { + slot_id_map: Dict[str, int] = { 'stick' : 0x00, 'nut' : 0x01, 'bomb' : 0x02, @@ -1043,8 +1032,7 @@ def get_save_context_addresses(self): 'child_trade' : 0x17, } - - bottle_types = { + bottle_types: Dict[str, str] = { "Bottle" : 'bottle', "Bottle with Red Potion" : 'red_potion', "Bottle with Green Potion" : 'green_potion', @@ -1060,11 +1048,10 @@ def get_save_context_addresses(self): "Bottle with Poe" : 'poe', } - - save_writes_table = { + save_writes_table: Dict[str, Dict[str, Any]] = { "Deku Stick Capacity": { 'item_slot.stick' : 'stick', - 'upgrades.stick_upgrade' : [2,3], + 'upgrades.stick_upgrade' : [2, 3], }, "Deku Stick": { 'item_slot.stick' : 'stick', @@ -1078,7 +1065,7 @@ def get_save_context_addresses(self): }, "Deku Nut Capacity": { 'item_slot.nut' : 'nut', - 'upgrades.nut_upgrade' : [2,3], + 'upgrades.nut_upgrade' : [2, 3], }, "Deku Nuts": { 'item_slot.nut' : 'nut', @@ -1305,8 +1292,8 @@ def get_save_context_addresses(self): 'Silver Rupee (Ganons Castle Shadow Trial)': {'silver_rupee_counts.trials_shadow': None}, 'Silver Rupee (Ganons Castle Water Trial)': {'silver_rupee_counts.trials_water': None}, 'Silver Rupee (Ganons Castle Forest Trial)': {'silver_rupee_counts.trials_forest': None}, - #HACK: the following counts aren't used since exact counts based on whether the dungeon is MQ are defined above, - # but the entries need to be there for key rings and silver rupee pouches to be valid starting items + # HACK: these counts aren't used since exact counts based on whether the dungeon is MQ are defined above, + # but the entries need to be there for key rings to be valid starting items "Small Key Ring (Forest Temple)" : { 'keys.forest': 6, 'total_keys.forest': 6, @@ -1371,8 +1358,7 @@ def get_save_context_addresses(self): 'Silver Rupee Pouch (Ganons Castle Forest Trial)': {'silver_rupee_counts.trials_forest': 5}, } - - equipable_items = { + equipable_items: Dict[str, Dict[str, List[str]]] = { 'equips_adult' : { 'items': [ 'hookshot', diff --git a/SceneFlags.py b/SceneFlags.py index f9be41fdd..706f806e0 100644 --- a/SceneFlags.py +++ b/SceneFlags.py @@ -1,6 +1,10 @@ from math import ceil +from typing import TYPE_CHECKING, Dict, List, Tuple + +if TYPE_CHECKING: + from Location import Location + from World import World -from LocationList import location_table # Create a dict of dicts of the format: # { @@ -10,25 +14,23 @@ # } # where room_setup_number defines the room + scene setup as ((setup << 6) + room) for scene n # and max_flags is the highest used enemy flag for that setup/room -def get_collectible_flag_table(world): +def get_collectible_flag_table(world: "World") -> "Tuple[Dict[int, Dict[int, int]], List[Tuple[Location, Tuple[int, int, int], Tuple[int, int, int]]]]": scene_flags = {} alt_list = [] for i in range(0, 101): - max_room_num = 0 - max_enemy_flag = 0 scene_flags[i] = {} for location in world.get_locations(): - if(location.scene == i and location.type in ["Freestanding", "Pot", "FlyingPot", "Crate", "SmallCrate", "Beehive", "RupeeTower", "SilverRupee"]): + if location.scene == i and location.type in ["Freestanding", "Pot", "FlyingPot", "Crate", "SmallCrate", "Beehive", "RupeeTower", "SilverRupee"]: default = location.default - if(isinstance(default, list)): #List of alternative room/setup/flag to use + if isinstance(default, list): # List of alternative room/setup/flag to use primary_tuple = default[0] - for c in range(1,len(default)): + for c in range(1, len(default)): alt_list.append((location, default[c], primary_tuple)) - default = location.default[0] #Use the first tuple as the primary tuple - if(isinstance(default, tuple)): + default = location.default[0] # Use the first tuple as the primary tuple + if isinstance(default, tuple): room, setup, flag = default room_setup = room + (setup << 6) - if(room_setup in scene_flags[i].keys()): + if room_setup in scene_flags[i].keys(): curr_room_max_flag = scene_flags[i][room_setup] if flag > curr_room_max_flag: scene_flags[i][room_setup] = flag @@ -36,11 +38,11 @@ def get_collectible_flag_table(world): scene_flags[i][room_setup] = flag if len(scene_flags[i].keys()) == 0: del scene_flags[i] - #scene_flags.append((i, max_enemy_flag)) - return (scene_flags, alt_list) + return scene_flags, alt_list + # Create a byte array from the scene flag table created by get_collectible_flag_table -def get_collectible_flag_table_bytes(scene_flag_table): +def get_collectible_flag_table_bytes(scene_flag_table: Dict[int, Dict[int, int]]) -> Tuple[bytearray, int]: num_flag_bytes = 0 bytes = bytearray() bytes.append(len(scene_flag_table.keys())) @@ -57,7 +59,8 @@ def get_collectible_flag_table_bytes(scene_flag_table): return bytes, num_flag_bytes -def get_alt_list_bytes(alt_list): + +def get_alt_list_bytes(alt_list: "List[Tuple[Location, Tuple[int, int, int], Tuple[int, int, int]]]") -> bytearray: bytes = bytearray() for entry in alt_list: location, alt, primary = entry diff --git a/Search.py b/Search.py index 7513dfc9b..8405f2d4b 100644 --- a/Search.py +++ b/Search.py @@ -1,20 +1,31 @@ import copy -from collections import defaultdict import itertools +from typing import TYPE_CHECKING, Dict, List, Tuple, Iterable, Set, Callable, Union, Optional -from LocationList import location_groups -from Region import TimeOfDay +from Region import Region, TimeOfDay from State import State +from Utils import TypeAlias -class Search(object): +if TYPE_CHECKING: + from Entrance import Entrance + from Item import Item + from Location import Location + from Goals import GoalCategory - def __init__(self, state_list, initial_cache=None): - self.state_list = [state.copy() for state in state_list] +ValidGoals: TypeAlias = Dict[str, Union[bool, Dict[str, Union[List[int], Dict[int, List[str]]]]]] +SearchCache: TypeAlias = "Dict[str, Union[List[Entrance], Set[Location], Dict[Region, int]]]" + + +class Search: + def __init__(self, state_list: "Iterable[State]", initial_cache: Optional[SearchCache] = None) -> None: + self.state_list: List[State] = [state.copy() for state in state_list] # Let the states reference this search. for state in self.state_list: state.search = self + self._cache: SearchCache + self.cached_spheres: List[SearchCache] if initial_cache: self._cache = initial_cache self.cached_spheres = [self._cache] @@ -35,25 +46,21 @@ def __init__(self, state_list, initial_cache=None): self.cached_spheres = [self._cache] self.next_sphere() - - def copy(self): + def copy(self) -> 'Search': # we only need to copy the top sphere since that's what we're starting with and we don't go back new_cache = {k: copy.copy(v) for k,v in self._cache.items()} # copy always makes a nonreversible instance return Search(self.state_list, initial_cache=new_cache) - - def collect_all(self, itempool): + def collect_all(self, itempool: "Iterable[Item]") -> None: for item in itempool: self.state_list[item.world.id].collect(item) - - def collect(self, item): + def collect(self, item: "Item") -> None: self.state_list[item.world.id].collect(item) - @classmethod - def max_explore(cls, state_list, itempool=None): + def max_explore(cls, state_list: Iterable[State], itempool: "Optional[Iterable[Item]]" = None) -> 'Search': p = cls(state_list) if itempool: p.collect_all(itempool) @@ -61,7 +68,7 @@ def max_explore(cls, state_list, itempool=None): return p @classmethod - def with_items(cls, state_list, itempool=None): + def with_items(cls, state_list: Iterable[State], itempool: "Optional[Iterable[Item]]" = None) -> 'Search': p = cls(state_list) if itempool: p.collect_all(itempool) @@ -76,27 +83,24 @@ def with_items(cls, state_list, itempool=None): # Locations never visited in this Search are assumed to have been visited # in sphere 0, so unvisiting them will discard the entire cache. # Not safe to call during iteration. - def unvisit(self, location): + def unvisit(self, location: "Location") -> None: raise Exception('Unimplemented for Search. Perhaps you want RewindableSearch.') - # Drops the item from its respective state. # Has no effect on cache! - def uncollect(self, item): + def uncollect(self, item: "Item") -> None: self.state_list[item.world.id].remove(item) - # Resets the sphere cache to the first entry only. # Does not uncollect any items! # Not safe to call during iteration. - def reset(self): + def reset(self) -> None: raise Exception('Unimplemented for Search. Perhaps you want RewindableSearch.') - # Internal to the iteration. Modifies the exit_queue, regions. # Returns a queue of the exits whose access rule failed, # as a cache for the exits to try on the next iteration. - def _expand_regions(self, exit_queue, regions, age): + def _expand_regions(self, exit_queue: "List[Entrance]", regions: Dict[Region, int], age: Optional[str]) -> "List[Entrance]": failed = [] for exit in exit_queue: if exit.connected_region and exit.connected_region not in regions: @@ -115,8 +119,7 @@ def _expand_regions(self, exit_queue, regions, age): failed.append(exit) return failed - - def _expand_tod_regions(self, regions, goal_region, age, tod): + def _expand_tod_regions(self, regions: Dict[Region, int], goal_region: Region, age: Optional[str], tod: int) -> bool: # grab all the exits from the regions with the given tod in the same world as our goal. # we want those that go to existing regions without the tod, until we reach the goal. has_tod_world = lambda regtod: regtod[1] & tod and regtod[0].world == goal_region.world @@ -132,14 +135,12 @@ def _expand_tod_regions(self, regions, goal_region, age, tod): exit_queue.extend(exit.connected_region.exits) return False - # Explores available exits, updating relevant entries in the cache in-place. # Returns the regions accessible in the new sphere as child, # the regions accessible as adult, and the set of visited locations. # These are references to the new entry in the cache, so they can be modified # directly. - def next_sphere(self): - + def next_sphere(self) -> "Tuple[Dict[Region, int], Dict[Region, int], Set[Location]]": # Use the queue to iteratively add regions to the accessed set, # until we are stuck or out of regions. self._cache.update({ @@ -160,7 +161,7 @@ def next_sphere(self): # Inside the loop, the caller usually wants to collect items at these # locations to see if the game is beatable. Collection should be done # using internal State (recommended to just call search.collect). - def iter_reachable_locations(self, item_locations): + def iter_reachable_locations(self, item_locations: "Iterable[Location]") -> "Iterable[Location]": had_reachable_locations = True # will loop as long as any visits were made, and at least once while had_reachable_locations: @@ -187,27 +188,25 @@ def iter_reachable_locations(self, item_locations): visited_locations.add(loc) yield loc - # This collects all item locations available in the state list given that # the states have collected items. The purpose is that it will search for # all new items that become accessible with a new item set. - def collect_locations(self, item_locations=None): + def collect_locations(self, item_locations: "Optional[Iterable[Location]]" = None) -> None: item_locations = item_locations or self.progression_locations() for location in self.iter_reachable_locations(item_locations): # Collect the item for the state world it is for self.collect(location.item) # A shorthand way to iterate over locations without collecting items. - def visit_locations(self, locations=None): + def visit_locations(self, locations: "Optional[Iterable[Location]]" = None) -> None: locations = locations or self.progression_locations() for _ in self.iter_reachable_locations(locations): pass # Retrieve all item locations in the worlds that have progression items - def progression_locations(self): + def progression_locations(self) -> "List[Location]": return [location for state in self.state_list for location in state.world.get_locations() if location.item and location.item.advancement] - # This returns True if every state is beatable. It's important to ensure # all states beatable since items required in one world can be in another. # A state is beatable if it can ever collect the Triforce. @@ -220,8 +219,7 @@ def progression_locations(self): # amount of an item across all worlds matter, not specifcally who has it # # predicate must be a function (state) -> bool, that will be applied to all states - def can_beat_game(self, scan_for_items=True, predicate=State.won): - + def can_beat_game(self, scan_for_items: bool = True, predicate: Callable[[State], bool] = State.won) -> bool: # Check if already beaten if all(map(predicate, self.state_list)): return True @@ -236,8 +234,7 @@ def can_beat_game(self, scan_for_items=True, predicate=State.won): else: return False - - def beatable_goals_fast(self, goal_categories, world_filter = None): + def beatable_goals_fast(self, goal_categories: "Dict[str, GoalCategory]", world_filter: Optional[int] = None) -> ValidGoals: valid_goals = self.test_category_goals(goal_categories, world_filter) if all(map(State.won, self.state_list)): valid_goals['way of the hero'] = True @@ -245,8 +242,7 @@ def beatable_goals_fast(self, goal_categories, world_filter = None): valid_goals['way of the hero'] = False return valid_goals - - def beatable_goals(self, goal_categories): + def beatable_goals(self, goal_categories: "Dict[str, GoalCategory]") -> ValidGoals: # collect all available items # make a new search since we might be iterating over one already search = self.copy() @@ -258,9 +254,8 @@ def beatable_goals(self, goal_categories): valid_goals['way of the hero'] = False return valid_goals - - def test_category_goals(self, goal_categories, world_filter = None): - valid_goals = {} + def test_category_goals(self, goal_categories: "Dict[str, GoalCategory]", world_filter: Optional[int] = None) -> ValidGoals: + valid_goals: ValidGoals = {} for category_name, category in goal_categories.items(): valid_goals[category_name] = {} valid_goals[category_name]['stateReverse'] = {} @@ -287,8 +282,7 @@ def test_category_goals(self, goal_categories, world_filter = None): valid_goals[category_name]['stateReverse'][state.world.id].append(goal.name) return valid_goals - - def iter_pseudo_starting_locations(self): + def iter_pseudo_starting_locations(self) -> "Iterable[Location]": for state in self.state_list: for location in state.world.distribution.skipped_locations: # We need to use the locations in the current world @@ -296,14 +290,13 @@ def iter_pseudo_starting_locations(self): self._cache['visited_locations'].add(location) yield location - - def collect_pseudo_starting_items(self): + def collect_pseudo_starting_items(self) -> None: for location in self.iter_pseudo_starting_locations(): self.collect(location.item) # Use the cache in the search to determine region reachability. # Implicitly requires is_starting_age or Time_Travel. - def can_reach(self, region, age=None, tod=TimeOfDay.NONE): + def can_reach(self, region: Region, age: Optional[str] = None, tod: int = TimeOfDay.NONE) -> bool: if age == 'adult': if tod: return region in self._cache['adult_regions'] and (self._cache['adult_regions'][region] & tod or self._expand_tod_regions(self._cache['adult_regions'], region, age, tod)) @@ -320,27 +313,27 @@ def can_reach(self, region, age=None, tod=TimeOfDay.NONE): # treat None as either return self.can_reach(region, age='adult', tod=tod) or self.can_reach(region, age='child', tod=tod) - def can_reach_spot(self, state, locationName, age=None, tod=TimeOfDay.NONE): - location = state.world.get_location(locationName) + def can_reach_spot(self, state: State, location_name: str, age: Optional[str] = None, tod: int = TimeOfDay.NONE) -> bool: + location = state.world.get_location(location_name) return self.spot_access(location, age, tod) # Use the cache in the search to determine location reachability. # Only works for locations that had progression items... - def visited(self, location): + def visited(self, location: "Location") -> bool: return location in self._cache['visited_locations'] # Use the cache in the search to get all reachable regions. - def reachable_regions(self, age=None): + def reachable_regions(self, age: Optional[str] = None) -> Set[Region]: if age == 'adult': - return self._cache['adult_regions'].keys() + return set(self._cache['adult_regions'].keys()) elif age == 'child': - return self._cache['child_regions'].keys() + return set(self._cache['child_regions'].keys()) else: - return self._cache['adult_regions'].keys() + self._cache['child_regions'].keys() + return set(self._cache['adult_regions'].keys()).union(self._cache['child_regions'].keys()) # Returns whether the given age can access the spot at this age and tod, # by checking whether the search has reached the containing region, and evaluating the spot's access rule. - def spot_access(self, spot, age=None, tod=TimeOfDay.NONE): + def spot_access(self, spot: "Union[Location, Entrance]", age: Optional[str] = None, tod: int = TimeOfDay.NONE) -> bool: if age == 'adult' or age == 'child': return (self.can_reach(spot.parent_region, age=age, tod=tod) and spot.access_rule(self.state_list[spot.world.id], spot=spot, age=age, tod=tod)) @@ -356,8 +349,7 @@ def spot_access(self, spot, age=None, tod=TimeOfDay.NONE): class RewindableSearch(Search): - - def unvisit(self, location): + def unvisit(self, location: "Location") -> None: # A location being unvisited is either: # in the top two caches (if it's the first being unvisited for a sphere) # in the topmost cache only (otherwise) @@ -368,14 +360,12 @@ def unvisit(self, location): self._cache = self.cached_spheres[-1] self._cache['visited_locations'].discard(location) - - def reset(self): + def reset(self) -> None: self._cache = self.cached_spheres[0] self.cached_spheres[1:] = [] - # Adds a new layer to the sphere cache, as a copy of the previous. - def checkpoint(self): + def checkpoint(self) -> None: # Save the current data into the cache. self.cached_spheres.append({ k: copy.copy(v) for k, v in self._cache.items() diff --git a/SettingTypes.py b/SettingTypes.py new file mode 100644 index 000000000..973758da5 --- /dev/null +++ b/SettingTypes.py @@ -0,0 +1,264 @@ +import math +import operator +from typing import Any, Optional + + +# holds the info for a single setting +class SettingInfo: + def __init__(self, setting_type: type, gui_text: Optional[str], gui_type: Optional[str], shared: bool, choices=None, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: + self.type: type = setting_type # type of the setting's value, used to properly convert types to setting strings + self.shared: bool = shared # whether the setting is one that should be shared, used in converting settings to a string + self.cosmetic: bool = cosmetic # whether the setting should be included in the cosmetic log + self.gui_text: Optional[str] = gui_text + self.gui_type: Optional[str] = gui_type + self.gui_tooltip: Optional[str] = "" if gui_tooltip is None else gui_tooltip + self.gui_params: dict = {} if gui_params is None else gui_params # additional parameters that the randomizer uses for the gui + self.disable: dict = disable # dictionary of settings this setting disabled + self.dependency = None # lambda that determines if this is disabled. Generated later + + # dictionary of options to their text names + choices = {} if choices is None else choices + if isinstance(choices, list): + self.choices: dict = {k: k for k in choices} + self.choice_list: list = list(choices) + else: + self.choices: dict = dict(choices) + self.choice_list: list = list(choices.keys()) + self.reverse_choices: dict = {v: k for k, v in self.choices.items()} + + # number of bits needed to store the setting, used in converting settings to a string + if shared: + if self.gui_params.get('min') and self.gui_params.get('max') and not choices: + self.bitwidth = math.ceil(math.log(self.gui_params.get('max') - self.gui_params.get('min') + 1, 2)) + else: + self.bitwidth = self.calc_bitwidth(choices) + else: + self.bitwidth = 0 + + # default value if undefined/unset + self.default = default + if self.default is None: + if self.type == bool: + self.default = False + elif self.type == str: + self.default = "" + elif self.type == int: + self.default = 0 + elif self.type == list: + self.default = [] + elif self.type == dict: + self.default = {} + + # default value if disabled + self.disabled_default = self.default if disabled_default is None else disabled_default + + # used to when random options are set for this setting + if 'distribution' not in self.gui_params: + self.gui_params['distribution'] = [(choice, 1) for choice in self.choice_list] + + def __set_name__(self, owner, name: str) -> None: + self.name = name + + def __get__(self, obj, obj_type=None) -> Any: + return obj.settings_dict.get(self.name, self.default) + + def __set__(self, obj, value: Any) -> None: + obj.settings_dict[self.name] = value + + def __delete__(self, obj) -> None: + del obj.settings_dict[self.name] + + def calc_bitwidth(self, choices) -> int: + count = len(choices) + if count > 0: + if self.type == list: + # Need two special values for terminating additive and subtractive lists + count = count + 2 + return math.ceil(math.log(count, 2)) + return 0 + + def create_dependency(self, disabling_setting: 'SettingInfo', option, negative: bool = False) -> None: + op = operator.__ne__ if negative else operator.__eq__ + if self.dependency is None: + self.dependency = lambda settings: op(getattr(settings, disabling_setting.name), option) + else: + old_dependency = self.dependency + self.dependency = lambda settings: op(getattr(settings, disabling_setting.name), option) or old_dependency(settings) + + +class SettingInfoNone(SettingInfo): + def __init__(self, gui_text: Optional[str], gui_type: Optional[str], gui_tooltip=None, gui_params=None) -> None: + super().__init__(type(None), gui_text, gui_type, False, None, None, None, None, gui_tooltip, gui_params, False) + + def __get__(self, obj, obj_type=None) -> None: + raise Exception(f"{self.name} is not a setting and cannot be retrieved.") + + def __set__(self, obj, value: str) -> None: + raise Exception(f"{self.name} is not a setting and cannot be set.") + + +class SettingInfoBool(SettingInfo): + def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: + choices = { + True: 'checked', + False: 'unchecked', + } + + super().__init__(bool, gui_text, gui_type, shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + + def __get__(self, obj, obj_type=None) -> bool: + value = super().__get__(obj, obj_type) + if not isinstance(value, bool): + value = bool(value) + return value + + def __set__(self, obj, value: bool) -> None: + if not isinstance(value, bool): + value = bool(value) + super().__set__(obj, value) + + +class SettingInfoStr(SettingInfo): + def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool = False, choices=None, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: + super().__init__(str, gui_text, gui_type, shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + + def __get__(self, obj, obj_type=None) -> str: + value = super().__get__(obj, obj_type) + if not isinstance(value, str): + value = str(value) + return value + + def __set__(self, obj, value: str) -> None: + if not isinstance(value, str): + value = str(value) + super().__set__(obj, value) + + +class SettingInfoInt(SettingInfo): + def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, choices=None, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: + super().__init__(int, gui_text, gui_type, shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + + def __get__(self, obj, obj_type=None) -> int: + value = super().__get__(obj, obj_type) + if not isinstance(value, int): + value = int(value) + return value + + def __set__(self, obj, value: int) -> None: + if not isinstance(value, int): + value = int(value) + super().__set__(obj, value) + + +class SettingInfoList(SettingInfo): + def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, choices=None, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: + super().__init__(list, gui_text, gui_type, shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + + def __get__(self, obj, obj_type=None) -> list: + value = super().__get__(obj, obj_type) + if not isinstance(value, list): + value = list(value) + return value + + def __set__(self, obj, value: list) -> None: + if not isinstance(value, list): + value = list(value) + super().__set__(obj, value) + + +class SettingInfoDict(SettingInfo): + def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, choices=None, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: + super().__init__(dict, gui_text, gui_type, shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + + def __get__(self, obj, obj_type=None) -> dict: + value = super().__get__(obj, obj_type) + if not isinstance(value, dict): + value = dict(value) + return value + + def __set__(self, obj, value: dict) -> None: + if not isinstance(value, dict): + value = dict(value) + super().__set__(obj, value) + + +class Button(SettingInfoNone): + def __init__(self, gui_text: Optional[str], gui_tooltip=None, gui_params=None) -> None: + super().__init__(gui_text, "Button", gui_tooltip, gui_params) + + +class Textbox(SettingInfoNone): + def __init__(self, gui_text: Optional[str], gui_tooltip=None, gui_params=None) -> None: + super().__init__(gui_text, "Textbox", gui_tooltip, gui_params) + + +class Checkbutton(SettingInfoBool): + def __init__(self, gui_text: Optional[str], gui_tooltip: Optional[str] = None, disable=None, disabled_default=None, default=False, shared=False, gui_params=None, cosmetic=False): + super().__init__(gui_text, 'Checkbutton', shared, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + + +class Combobox(SettingInfoStr): + def __init__(self, gui_text, choices, default, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): + super().__init__(gui_text, 'Combobox', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + + +class Radiobutton(SettingInfoStr): + def __init__(self, gui_text, choices, default, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): + super().__init__(gui_text, 'Radiobutton', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + + +class Fileinput(SettingInfoStr): + def __init__(self, gui_text, choices=None, default=None, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): + super().__init__(gui_text, 'Fileinput', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + + +class Directoryinput(SettingInfoStr): + def __init__(self, gui_text, choices=None, default=None, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): + super().__init__(gui_text, 'Directoryinput', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + + +class Textinput(SettingInfoStr): + def __init__(self, gui_text, choices=None, default=None, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): + super().__init__(gui_text, 'Textinput', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + + +class ComboboxInt(SettingInfoInt): + def __init__(self, gui_text, choices, default, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): + super().__init__(gui_text, 'Combobox', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + + +class Scale(SettingInfoInt): + def __init__(self, gui_text, default, minimum, maximum, step=1, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): + choices = { + i: str(i) for i in range(minimum, maximum+1, step) + } + + if gui_params is None: + gui_params = {} + gui_params['min'] = minimum + gui_params['max'] = maximum + gui_params['step'] = step + + super().__init__(gui_text, 'Scale', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + + +class Numberinput(SettingInfoInt): + def __init__(self, gui_text, default, minimum=None, maximum=None, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): + if gui_params is None: + gui_params = {} + if minimum is not None: + gui_params['min'] = minimum + if maximum is not None: + gui_params['max'] = maximum + + super().__init__(gui_text, 'Numberinput', shared, None, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + + +class MultipleSelect(SettingInfoList): + def __init__(self, gui_text, choices, default, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): + super().__init__(gui_text, 'MultipleSelect', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) + + +class SearchBox(SettingInfoList): + def __init__(self, gui_text, choices, default, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): + super().__init__(gui_text, 'SearchBox', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) diff --git a/Settings.py b/Settings.py index 537e7cc28..6f13debe2 100644 --- a/Settings.py +++ b/Settings.py @@ -10,6 +10,7 @@ import string import sys import textwrap +from typing import TYPE_CHECKING, Dict, List, Tuple, Set, Any, Optional from version import __version__ from Utils import local_path, data_path @@ -17,7 +18,11 @@ from Plandomizer import Distribution import StartingItems -LEGACY_STARTING_ITEM_SETTINGS = {'starting_equipment': StartingItems.equipment, 'starting_inventory': StartingItems.inventory, 'starting_songs': StartingItems.songs} +LEGACY_STARTING_ITEM_SETTINGS: Dict[str, Dict[str, StartingItems.Entry]] = { + 'starting_equipment': StartingItems.equipment, + 'starting_inventory': StartingItems.inventory, + 'starting_songs': StartingItems.songs, +} class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): @@ -27,12 +32,12 @@ def _get_help_string(self, action): # 32 characters -letters = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" -index_to_letter = { i: letters[i] for i in range(32) } -letter_to_index = { v: k for k, v in index_to_letter.items() } +letters: str = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +index_to_letter: Dict[int, str] = {i: letters[i] for i in range(32)} +letter_to_index: Dict[str, int] = {v: k for k, v in index_to_letter.items()} -def bit_string_to_text(bits): +def bit_string_to_text(bits: List[int]) -> str: # pad the bits array to be multiple of 5 if len(bits) % 5 > 0: bits += [0] * (5 - len(bits) % 5) @@ -47,7 +52,7 @@ def bit_string_to_text(bits): return result -def text_to_bit_string(text): +def text_to_bit_string(text: str) -> List[int]: bits = [] for c in text: index = letter_to_index[c] @@ -56,7 +61,7 @@ def text_to_bit_string(text): return bits -def get_preset_files(): +def get_preset_files() -> List[str]: return [data_path('presets_default.json')] + sorted( os.path.join(data_path('Presets'), fn) for fn in os.listdir(data_path('Presets')) @@ -65,7 +70,40 @@ def get_preset_files(): # holds the particular choices for a run's settings class Settings(SettingInfos): - def get_settings_display(self): + # add the settings as fields, and calculate information based on them + def __init__(self, settings_dict: Dict[str, Any], strict: bool = False) -> None: + super().__init__() + self.numeric_seed: Optional[int] = None + if settings_dict.get('compress_rom', None): + # Old compress_rom setting is set, so set the individual output settings using it. + settings_dict['create_patch_file'] = settings_dict['compress_rom'] == 'Patch' or settings_dict.get('create_patch_file', False) + settings_dict['create_compressed_rom'] = settings_dict['compress_rom'] == 'True' or settings_dict.get('create_compressed_rom', False) + settings_dict['create_uncompressed_rom'] = settings_dict['compress_rom'] == 'False' or settings_dict.get('create_uncompressed_rom', False) + del settings_dict['compress_rom'] + if strict: + validate_settings(settings_dict) + self.settings_dict.update(settings_dict) + for info in self.setting_infos.values(): + if info.name not in self.settings_dict: + self.settings_dict[info.name] = info.default + + if self.world_count < 1: + self.world_count = 1 + if self.world_count > 255: + self.world_count = 255 + + self._disabled: Set[str] = set() + self.settings_string: str = self.get_settings_string() + self.distribution: Distribution = Distribution(self) + self.update_seed(self.seed) + self.custom_seed: bool = False + + def copy(self) -> 'Settings': + settings = copy.copy(self) + settings.settings_dict = copy.deepcopy(settings.settings_dict) + return settings + + def get_settings_display(self) -> str: padding = 0 for setting in filter(lambda s: s.shared, self.setting_infos.values()): padding = max(len(setting.name), padding) @@ -80,7 +118,7 @@ def get_settings_display(self): output += name + val + '\n' return output - def get_settings_string(self): + def get_settings_string(self) -> str: bits = [] for setting in filter(lambda s: s.shared and s.bitwidth > 0, self.setting_infos.values()): value = self.settings_dict[setting.name] @@ -89,12 +127,12 @@ def get_settings_string(self): items = LEGACY_STARTING_ITEM_SETTINGS[setting.name] value = [] for entry in items.values(): - if entry.itemname in self.starting_items: - count = self.starting_items[entry.itemname] + if entry.item_name in self.starting_items: + count = self.starting_items[entry.item_name] if not isinstance(count, int): count = count.count if count > entry.i: - value.append(entry.settingname) + value.append(entry.setting_name) if setting.type == bool: i_bits = [ 1 if value else 0 ] elif setting.type == str: @@ -143,7 +181,7 @@ def get_settings_string(self): bits += i_bits return bit_string_to_text(bits) - def update_with_settings_string(self, text): + def update_with_settings_string(self, text: str) -> None: bits = text_to_bit_string(text) for setting in filter(lambda s: s.shared and s.bitwidth > 0, self.setting_infos.values()): @@ -185,21 +223,22 @@ def update_with_settings_string(self, text): self.settings_dict[setting.name] = value + self.settings_dict['starting_items'] = {} # Settings string contains the GUI format, so clear the current value of the dict format. self.distribution.reset() # convert starting_items self.settings_string = self.get_settings_string() self.numeric_seed = self.get_numeric_seed() - def get_numeric_seed(self): + def get_numeric_seed(self) -> int: # salt seed with the settings, and hash to get a numeric seed distribution = json.dumps(self.distribution.to_json(include_output=False), sort_keys=True) full_string = self.settings_string + distribution + __version__ + self.seed return int(hashlib.sha256(full_string.encode('utf-8')).hexdigest(), 16) - def sanitize_seed(self): + def sanitize_seed(self) -> None: # leave only alphanumeric and some punctuation self.seed = re.sub(r'[^a-zA-Z0-9_-]', '', self.seed, re.UNICODE) - def update_seed(self, seed): + def update_seed(self, seed: str) -> None: if seed is None or seed == '': # https://stackoverflow.com/questions/2257441/random-string-generation-with-upper-case-letters-and-digits-in-python self.seed = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)) @@ -208,11 +247,11 @@ def update_seed(self, seed): self.sanitize_seed() self.numeric_seed = self.get_numeric_seed() - def update(self): + def update(self) -> None: self.settings_string = self.get_settings_string() self.numeric_seed = self.get_numeric_seed() - def load_distribution(self): + def load_distribution(self) -> None: if self.enable_distribution_file: if self.distribution_file: try: @@ -233,18 +272,16 @@ def load_distribution(self): self.numeric_seed = self.get_numeric_seed() - - def reset_distribution(self): + def reset_distribution(self) -> None: self.distribution.reset() for location in self.disabled_locations: self.distribution.add_location(location, '#Junk') - - def check_dependency(self, setting_name, check_random=True): + def check_dependency(self, setting_name: str, check_random: bool = True) -> bool: return self.get_dependency(setting_name, check_random) is None - def get_dependency(self, setting_name, check_random=True): + def get_dependency(self, setting_name: str, check_random: bool = True) -> Any: info = SettingInfos.setting_infos[setting_name] not_in_dist = '_settings' not in self.distribution.src_dict or info.name not in self.distribution.src_dict['_settings'].keys() if check_random and 'randomize_key' in info.gui_params and self.settings_dict[info.gui_params['randomize_key']] and not_in_dist: @@ -254,7 +291,7 @@ def get_dependency(self, setting_name, check_random=True): else: return None - def remove_disabled(self): + def remove_disabled(self) -> None: for info in self.setting_infos.values(): if info.dependency is not None: new_value = self.get_dependency(info.name) @@ -265,7 +302,7 @@ def remove_disabled(self): self.settings_string = self.get_settings_string() self.numeric_seed = self.get_numeric_seed() - def resolve_random_settings(self, cosmetic, randomize_key=None): + def resolve_random_settings(self, cosmetic: bool, randomize_key: Optional[str] = None) -> None: sorted_infos = list(self.setting_infos.values()) sort_key = lambda info: 0 if info.dependency is None else 1 sorted_infos.sort(key=sort_key) @@ -307,54 +344,21 @@ def resolve_random_settings(self, cosmetic, randomize_key=None): for randomize_keys in randomize_keys_enabled: self.settings_dict[randomize_keys] = True - # add the settings as fields, and calculate information based on them - def __init__(self, settings_dict, strict=False): - super().__init__() - self.numeric_seed = None - if settings_dict.get('compress_rom', None): - # Old compress_rom setting is set, so set the individual output settings using it. - settings_dict['create_patch_file'] = settings_dict['compress_rom'] == 'Patch' or settings_dict.get('create_patch_file', False) - settings_dict['create_compressed_rom'] = settings_dict['compress_rom'] == 'True' or settings_dict.get('create_compressed_rom', False) - settings_dict['create_uncompressed_rom'] = settings_dict['compress_rom'] == 'False' or settings_dict.get('create_uncompressed_rom', False) - del settings_dict['compress_rom'] - if strict: - validate_settings(settings_dict) - self.settings_dict.update(settings_dict) - for info in self.setting_infos.values(): - if info.name not in self.settings_dict: - self.settings_dict[info.name] = info.default - - if self.world_count < 1: - self.world_count = 1 - if self.world_count > 255: - self.world_count = 255 - - self._disabled = set() - self.settings_string = self.get_settings_string() - self.distribution = Distribution(self) - self.update_seed(self.seed) - self.custom_seed = False - - def copy(self) -> 'Settings': - settings = copy.copy(self) - settings.settings_dict = copy.deepcopy(settings.settings_dict) - return settings - - def to_json(self, *, legacy_starting_items=False): + def to_json(self, *, legacy_starting_items: bool = False) -> Dict[str, Any]: if legacy_starting_items: settings = self.copy() for setting_name, items in LEGACY_STARTING_ITEM_SETTINGS.items(): settings.settings_dict[setting_name] = [] for entry in items.values(): - if entry.itemname in self.starting_items: - count = self.starting_items[entry.itemname] + if entry.item_name in self.starting_items: + count = self.starting_items[entry.item_name] if not isinstance(count, int): count = count.count if count > entry.i: - settings.settings_dict[setting_name].append(entry.settingname) + settings.settings_dict[setting_name].append(entry.setting_name) else: settings = self - return { + return { # TODO: This should be done in a way that is less insane than a double-digit line dictionary comprehension. setting.name: ( {name: ( {name: record.to_json() for name, record in record.items()} if isinstance(record, dict) else record.to_json() @@ -370,14 +374,15 @@ def to_json(self, *, legacy_starting_items=False): ) # Don't want to include list starting equipment and songs, these are consolidated into starting_items and (legacy_starting_items or not (setting.name in LEGACY_STARTING_ITEM_SETTINGS)) + and (setting.name != 'starting_items' or not legacy_starting_items) } - def to_json_cosmetics(self): + def to_json_cosmetics(self) -> Dict[str, Any]: return {setting.name: self.settings_dict[setting.name] for setting in self.setting_infos.values() if setting.cosmetic} # gets the randomizer settings, whether to open the gui, and the logger level from command line arguments -def get_settings_from_command_line_args(): +def get_settings_from_command_line_args() -> Tuple[Settings, bool, str, bool, str]: parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter) parser.add_argument('--gui', help='Launch the GUI', action='store_true') diff --git a/SettingsList.py b/SettingsList.py index 72826915b..63572fac1 100644 --- a/SettingsList.py +++ b/SettingsList.py @@ -1,272 +1,23 @@ import difflib import json -import math -import operator -from typing import Any, Optional, Dict +from typing import TYPE_CHECKING, Dict, List, Iterable, Union, Optional, Any import Colors -from Hints import HintDistList, HintDistTips, gossipLocations +from Hints import hint_dist_list, hint_dist_tips, gossipLocations from Item import ItemInfo from Location import LocationIterator from LocationList import location_table from Models import get_model_choices from SettingsListTricks import logic_tricks +from SettingTypes import SettingInfo, SettingInfoStr, SettingInfoList, SettingInfoDict, Textbox, Button, Checkbutton, \ + Combobox, Radiobutton, Fileinput, Directoryinput, Textinput, ComboboxInt, Scale, Numberinput, MultipleSelect, \ + SearchBox import Sounds import StartingItems from Utils import data_path - -# holds the info for a single setting -class SettingInfo: - def __init__(self, setting_type: type, gui_text: Optional[str], gui_type: Optional[str], shared: bool, choices=None, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: - self.type: type = setting_type # type of the setting's value, used to properly convert types to setting strings - self.shared: bool = shared # whether the setting is one that should be shared, used in converting settings to a string - self.cosmetic: bool = cosmetic # whether the setting should be included in the cosmetic log - self.gui_text: Optional[str] = gui_text - self.gui_type: Optional[str] = gui_type - self.gui_tooltip: Optional[str] = "" if gui_tooltip is None else gui_tooltip - self.gui_params: dict = {} if gui_params is None else gui_params # additional parameters that the randomizer uses for the gui - self.disable: dict = disable # dictionary of settings this setting disabled - self.dependency = None # lambda that determines if this is disabled. Generated later - - # dictionary of options to their text names - choices = {} if choices is None else choices - if isinstance(choices, list): - self.choices: dict = {k: k for k in choices} - self.choice_list: list = list(choices) - else: - self.choices: dict = dict(choices) - self.choice_list: list = list(choices.keys()) - self.reverse_choices: dict = {v: k for k, v in self.choices.items()} - - # number of bits needed to store the setting, used in converting settings to a string - if shared: - if self.gui_params.get('min') and self.gui_params.get('max') and not choices: - self.bitwidth = math.ceil(math.log(self.gui_params.get('max') - self.gui_params.get('min') + 1, 2)) - else: - self.bitwidth = self.calc_bitwidth(choices) - else: - self.bitwidth = 0 - - # default value if undefined/unset - self.default = default - if self.default is None: - if self.type == bool: - self.default = False - elif self.type == str: - self.default = "" - elif self.type == int: - self.default = 0 - elif self.type == list: - self.default = [] - elif self.type == dict: - self.default = {} - - # default value if disabled - self.disabled_default = self.default if disabled_default is None else disabled_default - - # used to when random options are set for this setting - if 'distribution' not in self.gui_params: - self.gui_params['distribution'] = [(choice, 1) for choice in self.choice_list] - - def __set_name__(self, owner, name): - self.name = name - - def __get__(self, obj, obj_type=None) -> Any: - return obj.settings_dict.get(self.name, self.default) - - def __set__(self, obj, value: Any) -> None: - obj.settings_dict[self.name] = value - - def __delete__(self, obj): - del obj.settings_dict[self.name] - - def calc_bitwidth(self, choices): - count = len(choices) - if count > 0: - if self.type == list: - # Need two special values for terminating additive and subtractive lists - count = count + 2 - return math.ceil(math.log(count, 2)) - return 0 - - -class SettingInfoNone(SettingInfo): - def __init__(self, gui_text: Optional[str], gui_type: Optional[str], gui_tooltip=None, gui_params=None) -> None: - super().__init__(type(None), gui_text, gui_type, False, None, None, None, None, gui_tooltip, gui_params, False) - - def __get__(self, obj, obj_type=None) -> None: - raise Exception(f"{self.name} is not a setting and cannot be retrieved.") - - def __set__(self, obj, value: str) -> None: - raise Exception(f"{self.name} is not a setting and cannot be set.") - - -class SettingInfoBool(SettingInfo): - def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: - choices = { - True: 'checked', - False: 'unchecked', - } - - super().__init__(bool, gui_text, gui_type, shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) - - def __get__(self, obj, obj_type=None) -> bool: - value = super().__get__(obj, obj_type) - if not isinstance(value, bool): - value = bool(value) - return value - - def __set__(self, obj, value: bool) -> None: - if not isinstance(value, bool): - value = bool(value) - super().__set__(obj, value) - - -class SettingInfoStr(SettingInfo): - def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool = False, choices=None, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: - super().__init__(str, gui_text, gui_type, shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) - - def __get__(self, obj, obj_type=None) -> str: - value = super().__get__(obj, obj_type) - if not isinstance(value, str): - value = str(value) - return value - - def __set__(self, obj, value: str) -> None: - if not isinstance(value, str): - value = str(value) - super().__set__(obj, value) - - -class SettingInfoInt(SettingInfo): - def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, choices=None, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: - super().__init__(int, gui_text, gui_type, shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) - - def __get__(self, obj, obj_type=None) -> int: - value = super().__get__(obj, obj_type) - if not isinstance(value, int): - value = int(value) - return value - - def __set__(self, obj, value: int) -> None: - if not isinstance(value, int): - value = int(value) - super().__set__(obj, value) - - -class SettingInfoList(SettingInfo): - def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, choices=None, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: - super().__init__(list, gui_text, gui_type, shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) - - def __get__(self, obj, obj_type=None) -> list: - value = super().__get__(obj, obj_type) - if not isinstance(value, list): - value = list(value) - return value - - def __set__(self, obj, value: list) -> None: - if not isinstance(value, list): - value = list(value) - super().__set__(obj, value) - - -class SettingInfoDict(SettingInfo): - def __init__(self, gui_text: Optional[str], gui_type: Optional[str], shared: bool, choices=None, default=None, disabled_default=None, disable=None, gui_tooltip=None, gui_params=None, cosmetic: bool = False) -> None: - super().__init__(dict, gui_text, gui_type, shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) - - def __get__(self, obj, obj_type=None) -> dict: - value = super().__get__(obj, obj_type) - if not isinstance(value, dict): - value = dict(value) - return value - - def __set__(self, obj, value: dict) -> None: - if not isinstance(value, dict): - value = dict(value) - super().__set__(obj, value) - - -class Button(SettingInfoNone): - def __init__(self, gui_text: Optional[str], gui_tooltip=None, gui_params=None) -> None: - super().__init__(gui_text, "Button", gui_tooltip, gui_params) - - -class Textbox(SettingInfoNone): - def __init__(self, gui_text: Optional[str], gui_tooltip=None, gui_params=None) -> None: - super().__init__(gui_text, "Textbox", gui_tooltip, gui_params) - - -class Checkbutton(SettingInfoBool): - def __init__(self, gui_text: Optional[str], gui_tooltip: Optional[str] = None, disable=None, disabled_default=None, default=False, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'Checkbutton', shared, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) - - -class Combobox(SettingInfoStr): - def __init__(self, gui_text, choices, default, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'Combobox', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) - - -class Radiobutton(SettingInfoStr): - def __init__(self, gui_text, choices, default, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'Radiobutton', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) - - -class Fileinput(SettingInfoStr): - def __init__(self, gui_text, choices=None, default=None, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'Fileinput', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) - - -class Directoryinput(SettingInfoStr): - def __init__(self, gui_text, choices=None, default=None, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'Directoryinput', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) - - -class Textinput(SettingInfoStr): - def __init__(self, gui_text, choices=None, default=None, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'Textinput', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) - - -class ComboboxInt(SettingInfoInt): - def __init__(self, gui_text, choices, default, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'Combobox', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) - - -class Scale(SettingInfoInt): - def __init__(self, gui_text, default, minimum, maximum, step=1, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - choices = { - i: str(i) for i in range(minimum, maximum+1, step) - } - - if gui_params is None: - gui_params = {} - gui_params['min'] = minimum - gui_params['max'] = maximum - gui_params['step'] = step - - super().__init__(gui_text, 'Scale', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) - - -class Numberinput(SettingInfoInt): - def __init__(self, gui_text, default, minimum=None, maximum=None, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - if gui_params is None: - gui_params = {} - if minimum is not None: - gui_params['min'] = minimum - if maximum is not None: - gui_params['max'] = maximum - - super().__init__(gui_text, 'Numberinput', shared, None, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) - - -class MultipleSelect(SettingInfoList): - def __init__(self, gui_text, choices, default, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'MultipleSelect', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) - - -class SearchBox(SettingInfoList): - def __init__(self, gui_text, choices, default, gui_tooltip=None, disable=None, disabled_default=None, shared=False, gui_params=None, cosmetic=False): - super().__init__(gui_text, 'SearchBox', shared, choices, default, disabled_default, disable, gui_tooltip, gui_params, cosmetic) +if TYPE_CHECKING: + from Entrance import Entrance class SettingInfos: @@ -1227,7 +978,7 @@ class SettingInfos: ) trials_random = Checkbutton( - gui_text = 'Random Number of Ganon\'s Trials', + gui_text = "Random Number of Ganon's Trials", gui_tooltip = '''\ Sets a random number of trials to enter Ganon's Tower. ''', @@ -1258,10 +1009,10 @@ class SettingInfos: ) shuffle_ganon_bosskey = Combobox( - gui_text = 'Ganon\'s Boss Key', - default = 'dungeon', + gui_text = "Ganon's Boss Key", + default = 'dungeon', disabled_default = 'triforce', - choices = { + choices = { 'remove': "Remove (Keysy)", 'vanilla': "Vanilla Location", 'dungeon': "Own Dungeon", @@ -1276,7 +1027,7 @@ class SettingInfos: 'tokens': "Tokens", 'hearts': "Hearts", }, - gui_tooltip = '''\ + gui_tooltip = '''\ 'Remove': Ganon's Castle Boss Key is removed and the boss door in Ganon's Tower starts unlocked. @@ -1317,15 +1068,15 @@ class SettingInfos: 'Hearts': Ganon's Castle Boss Key will be awarded when reaching the target number of hearts. ''', - shared = True, - disable = { - '!stones': {'settings': ['ganon_bosskey_stones']}, + shared = True, + disable = { + '!stones': {'settings': ['ganon_bosskey_stones']}, '!medallions': {'settings': ['ganon_bosskey_medallions']}, - '!dungeons': {'settings': ['ganon_bosskey_rewards']}, - '!tokens': {'settings': ['ganon_bosskey_tokens']}, - '!hearts': {'settings': ['ganon_bosskey_hearts']}, + '!dungeons': {'settings': ['ganon_bosskey_rewards']}, + '!tokens': {'settings': ['ganon_bosskey_tokens']}, + '!hearts': {'settings': ['ganon_bosskey_hearts']}, }, - gui_params = { + gui_params = { 'randomize_key': 'randomize_settings', 'distribution': [ ('remove', 4), @@ -2084,9 +1835,7 @@ class SettingInfos: ''', shared = True, disable = { - 'off': {'settings': ['dungeon_shortcuts']}, - 'all': {'settings': ['dungeon_shortcuts']}, - 'random': {'settings': ['dungeon_shortcuts']}, + '!choice': {'settings': ['dungeon_shortcuts']}, }, ) @@ -2157,11 +1906,8 @@ class SettingInfos: ''', shared = True, disable = { - 'vanilla': {'settings': ['mq_dungeons_count', 'mq_dungeons_specific']}, - 'mq': {'settings': ['mq_dungeons_count', 'mq_dungeons_specific']}, - 'specific': {'settings': ['mq_dungeons_count']}, - 'count': {'settings': ['mq_dungeons_specific']}, - 'random': {'settings': ['mq_dungeons_count', 'mq_dungeons_specific']}, + '!specific': {'settings': ['mq_dungeons_specific']}, + '!count': {'settings': ['mq_dungeons_count']}, }, gui_params = { 'distribution': [ @@ -3120,7 +2866,7 @@ class SettingInfos: gui_text = "Starting Equipment", shared = True, choices = { - key: value.guitext for key, value in StartingItems.equipment.items() + key: value.gui_text for key, value in StartingItems.equipment.items() }, default = [], gui_tooltip = '''\ @@ -3132,7 +2878,7 @@ class SettingInfos: gui_text = "Starting Songs", shared = True, choices = { - key: value.guitext for key, value in StartingItems.songs.items() + key: value.gui_text for key, value in StartingItems.songs.items() }, default = [], gui_tooltip = '''\ @@ -3144,7 +2890,7 @@ class SettingInfos: gui_text = "Starting Items", shared = True, choices = { - key: value.guitext for key, value in StartingItems.inventory.items() + key: value.gui_text for key, value in StartingItems.inventory.items() }, default = [], gui_tooltip = '''\ @@ -3533,8 +3279,8 @@ class SettingInfos: hint_dist = Combobox( gui_text = 'Hint Distribution', default = 'balanced', - choices = HintDistList(), - gui_tooltip = HintDistTips(), + choices =hint_dist_list(), + gui_tooltip =hint_dist_tips(), gui_params = { "dynamic": True, }, @@ -5155,20 +4901,10 @@ class SettingInfos: setting_map: dict = {} def __init__(self) -> None: - self.settings_dict = {} - - -def create_dependency(setting, disabling_setting, option, negative=False): - disabled_info = SettingInfos.setting_infos[setting] - op = operator.__ne__ if negative else operator.__eq__ - if disabled_info.dependency is None: - disabled_info.dependency = lambda settings: op(getattr(settings, disabling_setting.name), option) - else: - old_dependency = disabled_info.dependency - disabled_info.dependency = lambda settings: op(getattr(settings, disabling_setting.name), option) or old_dependency(settings) + self.settings_dict: Dict[str, Any] = {} -def get_settings_from_section(section_name): +def get_settings_from_section(section_name: str) -> Iterable[str]: for tab in SettingInfos.setting_map['Tabs']: for section in tab['sections']: if section['name'] == section_name: @@ -5177,7 +4913,7 @@ def get_settings_from_section(section_name): return -def get_settings_from_tab(tab_name): +def get_settings_from_tab(tab_name: str) -> Iterable[str]: for tab in SettingInfos.setting_map['Tabs']: if tab['name'] == tab_name: for section in tab['sections']: @@ -5186,7 +4922,7 @@ def get_settings_from_tab(tab_name): return -def is_mapped(setting_name): +def is_mapped(setting_name: str) -> bool: for tab in SettingInfos.setting_map['Tabs']: for section in tab['sections']: if setting_name in section['settings']: @@ -5194,9 +4930,9 @@ def is_mapped(setting_name): return False -# When a string isn't found in the source list, attempt to get closest match from the list +# When a string isn't found in the source list, attempt to get the closest match from the list # ex. Given "Recovery Hart" returns "Did you mean 'Recovery Heart'?" -def build_close_match(name, value_type, source_list=None): +def build_close_match(name: str, value_type: str, source_list: "Optional[Union[List[str]], Dict[str, List[Entrance]]]" = None) -> str: source = [] if value_type == 'item': source = ItemInfo.items.keys() @@ -5219,7 +4955,7 @@ def build_close_match(name, value_type, source_list=None): return "" # No matches -def validate_settings(settings_dict, *, check_conflicts=True): +def validate_settings(settings_dict: Dict[str, Any], *, check_conflicts: bool = True) -> None: for setting, choice in settings_dict.items(): # Ensure the supplied setting name is a real setting if setting not in SettingInfos.setting_infos: @@ -5227,8 +4963,7 @@ def validate_settings(settings_dict, *, check_conflicts=True): info = SettingInfos.setting_infos[setting] # Ensure the type of the supplied choice is correct if type(choice) != info.type: - if setting != 'starting_items' or type(choice) != dict: # allow dict (plando syntax) for starting items in addition to the list syntax used by the GUI - raise TypeError('Supplied choice %r for setting %r is of type %r, expecting %r' % (choice, setting, type(choice).__name__, info.type.__name__)) + raise TypeError('Supplied choice %r for setting %r is of type %r, expecting %r' % (choice, setting, type(choice).__name__, info.type.__name__)) # If setting is a list, must check each element if isinstance(choice, list): for element in choice: @@ -5258,7 +4993,7 @@ def validate_settings(settings_dict, *, check_conflicts=True): validate_disabled_setting(settings_dict, setting, choice, other_setting) -def validate_disabled_setting(settings_dict, setting, choice, other_setting): +def validate_disabled_setting(settings_dict: Dict[str, Any], setting: str, choice, other_setting: str) -> None: if other_setting in settings_dict: if settings_dict[other_setting] != SettingInfos.setting_infos[other_setting].disabled_default: raise ValueError(f'The {other_setting!r} setting cannot be used since {setting!r} is set to {choice!r}') @@ -5282,11 +5017,11 @@ class UnmappedSettingError(Exception): if isinstance(option, str) and option[0] == '!': negative = True option = option[1:] - for setting in disabling.get('settings', []): - create_dependency(setting, info, option, negative) + for setting_name in disabling.get('settings', []): + SettingInfos.setting_infos[setting_name].create_dependency(info, option, negative) for section in disabling.get('sections', []): - for setting in get_settings_from_section(section): - create_dependency(setting, info, option, negative) + for setting_name in get_settings_from_section(section): + SettingInfos.setting_infos[setting_name].create_dependency(info, option, negative) for tab in disabling.get('tabs', []): - for setting in get_settings_from_tab(tab): - create_dependency(setting, info, option, negative) + for setting_name in get_settings_from_tab(tab): + SettingInfos.setting_infos[setting_name].create_dependency(info, option, negative) diff --git a/SettingsListTricks.py b/SettingsListTricks.py index 69804151d..790193223 100644 --- a/SettingsListTricks.py +++ b/SettingsListTricks.py @@ -2,7 +2,7 @@ # The order they are listed in is also the order in which # they appear to the user in the GUI, so a sensible order was chosen -logic_tricks = { +logic_tricks: dict = { # General tricks diff --git a/SettingsToJson.py b/SettingsToJson.py index fc45f0cc1..2908def32 100755 --- a/SettingsToJson.py +++ b/SettingsToJson.py @@ -1,19 +1,21 @@ #!/usr/bin/env python3 -from Hints import HintDistFiles +import copy +import json +import sys +from typing import Dict, List, Any, Optional + +from Hints import hint_dist_files from SettingsList import SettingInfos, get_settings_from_section, get_settings_from_tab from Utils import data_path -import sys -import json -import copy -tab_keys = ['text', 'app_type', 'footer'] -section_keys = ['text', 'app_type', 'is_colors', 'is_sfx', 'col_span', 'row_span', 'subheader'] -setting_keys = ['hide_when_disabled', 'min', 'max', 'size', 'max_length', 'file_types', 'no_line_break', 'function', 'option_remove', 'dynamic'] -types_with_options = ['Checkbutton', 'Radiobutton', 'Combobox', 'SearchBox', 'MultipleSelect'] +tab_keys: List[str] = ['text', 'app_type', 'footer'] +section_keys: List[str] = ['text', 'app_type', 'is_colors', 'is_sfx', 'col_span', 'row_span', 'subheader'] +setting_keys: List[str] = ['hide_when_disabled', 'min', 'max', 'size', 'max_length', 'file_types', 'no_line_break', 'function', 'option_remove', 'dynamic'] +types_with_options: List[str] = ['Checkbutton', 'Radiobutton', 'Combobox', 'SearchBox', 'MultipleSelect'] -def remove_trailing_lines(text): +def remove_trailing_lines(text: str) -> str: while text.endswith('
'): text = text[:-4] while text.startswith('
'): @@ -21,7 +23,7 @@ def remove_trailing_lines(text): return text -def deep_update(source, new_dict): +def deep_update(source: dict, new_dict: dict) -> dict: for k, v in new_dict.items(): if isinstance(v, dict): source[k] = deep_update(source.get(k, { }), v) @@ -32,7 +34,7 @@ def deep_update(source, new_dict): return source -def add_disable_option_to_json(disable_option, option_json): +def add_disable_option_to_json(disable_option: Dict[str, Any], option_json: Dict[str, Any]) -> None: if disable_option.get('settings') is not None: if 'controls_visibility_setting' not in option_json: option_json['controls_visibility_setting'] = ','.join(disable_option['settings']) @@ -50,7 +52,7 @@ def add_disable_option_to_json(disable_option, option_json): option_json['controls_visibility_tab'] += ',' + ','.join(disable_option['tabs']) -def get_setting_json(setting, web_version, as_array=False): +def get_setting_json(setting: str, web_version: bool, as_array: bool = False) -> Optional[Dict[str, Any]]: try: setting_info = SettingInfos.setting_infos[setting] except KeyError: @@ -62,7 +64,7 @@ def get_setting_json(setting, web_version, as_array=False): if setting_info.gui_text is None: return None - setting_json = { + setting_json: Dict[str, Any] = { 'options': [], 'default': setting_info.default, 'text': setting_info.gui_text, @@ -102,7 +104,7 @@ def get_setting_json(setting, web_version, as_array=False): if key in setting_keys and (key not in version_specific_keys or version_specific): setting_json[key] = value if key == 'disable': - for option,types in value.items(): + for option, types in value.items(): for s in types.get('settings', []): if SettingInfos.setting_infos[s].shared: raise ValueError(f'Cannot disable setting {s}. Disabling "shared" settings in the gui_params is forbidden. Use the non gui_param version of disable instead.') @@ -170,7 +172,6 @@ def get_setting_json(setting, web_version, as_array=False): if name != option_name[1:]: add_disable_option_to_json(setting_disable[option_name], option) - if tags_list: tags_list.sort() setting_json['tags'] = ['(all)'] + tags_list @@ -179,7 +180,7 @@ def get_setting_json(setting, web_version, as_array=False): return setting_json -def get_section_json(section, web_version, as_array=False): +def get_section_json(section: Dict[str, Any], web_version: bool, as_array: bool = False) -> Dict[str, Any]: if as_array: section_json = { 'name' : section['name'], @@ -204,7 +205,7 @@ def get_section_json(section, web_version, as_array=False): return section_json -def get_tab_json(tab, web_version, as_array=False): +def get_tab_json(tab: Dict[str, Any], web_version: bool, as_array: bool = False) -> Dict[str, Any]: if as_array: tab_json = { 'name' : tab['name'], @@ -234,7 +235,7 @@ def get_tab_json(tab, web_version, as_array=False): return tab_json -def create_settings_list_json(path, web_version=False): +def create_settings_list_json(path: str, web_version: bool = False) -> None: output_json = { 'settingsObj' : {}, 'settingsArray' : [], @@ -258,7 +259,7 @@ def create_settings_list_json(path, web_version=False): output_json['cosmeticsObj'][tab['name']] = tab_json_object output_json['cosmeticsArray'].append(tab_json_array) - for d in HintDistFiles(): + for d in hint_dist_files(): with open(d, 'r') as dist_file: dist = json.load(dist_file) if ('distribution' in dist and @@ -271,7 +272,7 @@ def create_settings_list_json(path, web_version=False): json.dump(output_json, f) -def get_setting_details(setting_key, web_version): +def get_setting_details(setting_key: str, web_version: bool) -> None: setting_json_object = get_setting_json(setting_key, web_version, as_array=False) setting_json_array = get_setting_json(setting_key, web_version, as_array=True) @@ -279,7 +280,7 @@ def get_setting_details(setting_key, web_version): print(json.dumps(setting_output)) -def main(): +def main() -> None: args = sys.argv[1:] web_version = '--web' in args diff --git a/Sounds.py b/Sounds.py index 2cf64994b..120bae69a 100644 --- a/Sounds.py +++ b/Sounds.py @@ -24,9 +24,11 @@ # hook would contain a bunch of addresses, whether they share the same default # value or not. -from enum import Enum import os import sys +from enum import Enum +from typing import Dict, List + from Utils import data_path # Python 3.6 support. We can drop the conditional usage of namedtuple if we decide to no longer support Python 3.6. @@ -61,7 +63,7 @@ class Sound: id: int keyword: str label: str - tags: list + tags: List[Tags] else: Sound = namedtuple('Sound', 'id keyword label tags') @@ -174,8 +176,8 @@ class Sounds(Enum): @dataclass(frozen=True) class SoundHook: name: str - pool: list - locations: list + pool: List[Sounds] + locations: List[int] sfx_flag: bool else: SoundHook = namedtuple('SoundHook', 'name pool locations sfx_flag') @@ -235,33 +237,33 @@ class SoundHooks(Enum): # SWORD_SLASH = SoundHook('Sword Slash', standard, [0xAC2942]) -def get_patch_dict(): +def get_patch_dict() -> Dict[str, int]: return {s.value.keyword: s.value.id for s in Sounds} -def get_hook_pool(sound_hook, earsafeonly = "FALSE"): - if earsafeonly == "TRUE": +def get_hook_pool(sound_hook: SoundHooks, earsafeonly: bool = False) -> List[Sounds]: + if earsafeonly: list = [s for s in sound_hook.value.pool if Tags.PAINFUL not in s.value.tags] return list else: return sound_hook.value.pool -def get_setting_choices(sound_hook): - pool = sound_hook.value.pool - choices = {s.value.keyword: s.value.label for s in sorted(pool, key=lambda s: s.value.label)} - result = { +def get_setting_choices(sound_hook: SoundHooks) -> Dict[str, str]: + pool = sound_hook.value.pool + choices = {s.value.keyword: s.value.label for s in sorted(pool, key=lambda s: s.value.label)} + result = { 'default': 'Default', 'completely-random': 'Completely Random', 'random-ear-safe': 'Random Ear-Safe', 'random-choice': 'Random Choice', 'none': 'None', **choices, - } + } return result -def get_voice_sfx_choices(age, include_random=True): +def get_voice_sfx_choices(age: int, include_random: bool = True) -> List[str]: # Dynamically populate the SettingsList entry for the voice effects # Voice packs should be a folder of .bin files in the Voices/{age} directory names = ['Default', 'Silent'] diff --git a/Spoiler.py b/Spoiler.py index 8fecf8015..f27c2c7a7 100644 --- a/Spoiler.py +++ b/Spoiler.py @@ -1,9 +1,21 @@ from collections import OrderedDict +import logging import random +from typing import TYPE_CHECKING, List, Dict +from Item import Item from LocationList import location_sort_order +from Search import Search, RewindableSearch -HASH_ICONS = [ +if TYPE_CHECKING: + from Entrance import Entrance + from Goals import GoalCategory + from Hints import GossipText + from Location import Location + from Settings import Settings + from World import World + +HASH_ICONS: List[str] = [ 'Deku Stick', 'Deku Nut', 'Bow', @@ -38,32 +50,29 @@ 'Big Magic', ] -class Spoiler(object): - - def __init__(self, worlds): - self.worlds = worlds - self.settings = worlds[0].settings - self.playthrough = {} - self.entrance_playthrough = {} - self.full_playthrough = {} - self.max_sphere = 0 - self.locations = {} - self.entrances = [] - self.metadata = {} - self.required_locations = {} - self.goal_locations = {} - self.goal_categories = {} - self.hints = {world.id: {} for world in worlds} - self.file_hash = [] +class Spoiler: + def __init__(self, worlds: "List[World]") -> None: + self.worlds: "List[World]" = worlds + self.settings: "Settings" = worlds[0].settings + self.playthrough: "Dict[str, List[Location]]" = {} + self.entrance_playthrough: "Dict[str, List[Entrance]]" = {} + self.full_playthrough: Dict[str, int] = {} + self.max_sphere: int = 0 + self.locations: "Dict[int, Dict[str, Item]]" = {} + self.entrances: "Dict[int, List[Entrance]]" = {} + self.required_locations: "Dict[int, List[Location]]" = {} + self.goal_locations: "Dict[int, Dict[str, Dict[str, Dict[int, List[Location]]]]]" = {} + self.goal_categories: "Dict[int, Dict[str, GoalCategory]]" = {} + self.hints: "Dict[int, Dict[int, GossipText]]" = {world.id: {} for world in worlds} + self.file_hash: List[int] = [] - def build_file_hash(self): + def build_file_hash(self) -> None: dist_file_hash = self.settings.distribution.file_hash for i in range(5): - self.file_hash.append(random.randint(0,31) if dist_file_hash[i] is None else HASH_ICONS.index(dist_file_hash[i])) + self.file_hash.append(random.randint(0, 31) if dist_file_hash[i] is None else HASH_ICONS.index(dist_file_hash[i])) - - def parse_data(self): + def parse_data(self) -> None: for (sphere_nr, sphere) in self.playthrough.items(): sorted_sphere = [location for location in sphere] sort_order = {"Song": 0, "Boss": -1} @@ -106,3 +115,172 @@ def parse_data(self): spoiler_entrances.sort(key=lambda entrance: entrance.name) spoiler_entrances.sort(key=lambda entrance: entrance_sort_order.get(entrance.type, -1)) self.entrances[world.id] = spoiler_entrances + + def copy_worlds(self) -> "List[World]": + worlds = [world.copy() for world in self.worlds] + Item.fix_worlds_after_copy(worlds) + return worlds + + def find_misc_hint_items(self) -> None: + search = Search([world.state for world in self.worlds]) + all_locations = [location for world in self.worlds for location in world.get_filled_locations()] + for location in search.iter_reachable_locations(all_locations[:]): + search.collect(location.item) + # include locations that are reachable but not part of the spoiler log playthrough in misc. item hints + location.maybe_set_misc_item_hints() + all_locations.remove(location) + for location in all_locations: + # finally, collect unreachable locations for misc. item hints + location.maybe_set_misc_item_hints() + + def create_playthrough(self) -> None: + logger = logging.getLogger('') + worlds = self.worlds + if worlds[0].check_beatable_only and not Search([world.state for world in worlds]).can_beat_game(): + raise RuntimeError('Game unbeatable after placing all items.') + # create a copy as we will modify it + old_worlds = worlds + worlds = self.copy_worlds() + + # if we only check for beatable, we can do this sanity check first before writing down spheres + if worlds[0].check_beatable_only and not Search([world.state for world in worlds]).can_beat_game(): + raise RuntimeError('Uncopied world beatable but copied world is not.') + + search = RewindableSearch([world.state for world in worlds]) + logger.debug('Initial search: %s', search.state_list[0].get_prog_items()) + # Get all item locations in the worlds + item_locations = search.progression_locations() + # Omit certain items from the playthrough + internal_locations = {location for location in item_locations if location.internal} + # Generate a list of spheres by iterating over reachable locations without collecting as we go. + # Collecting every item in one sphere means that every item + # in the next sphere is collectable. Will contain every reachable item this way. + logger.debug('Building up collection spheres.') + collection_spheres = [] + entrance_spheres = [] + remaining_entrances = set(entrance for world in worlds for entrance in world.get_shuffled_entrances()) + + search.checkpoint() + search.collect_pseudo_starting_items() + logger.debug('With pseudo starting items: %s', search.state_list[0].get_prog_items()) + + while True: + search.checkpoint() + # Not collecting while the generator runs means we only get one sphere at a time + # Otherwise, an item we collect could influence later item collection in the same sphere + collected = list(search.iter_reachable_locations(item_locations)) + if not collected: + break + random.shuffle(collected) + # Gather the new entrances before collecting items. + collection_spheres.append(collected) + accessed_entrances = set(filter(search.spot_access, remaining_entrances)) + entrance_spheres.append(list(accessed_entrances)) + remaining_entrances -= accessed_entrances + for location in collected: + # Collect the item for the state world it is for + search.state_list[location.item.world.id].collect(location.item) + location.maybe_set_misc_item_hints() + logger.info('Collected %d spheres', len(collection_spheres)) + self.full_playthrough = dict((location.name, i + 1) for i, sphere in enumerate(collection_spheres) for location in sphere) + self.max_sphere = len(collection_spheres) + + # Reduce each sphere in reverse order, by checking if the game is beatable + # when we remove the item. We do this to make sure that progressive items + # like bow and slingshot appear as early as possible rather than as late as possible. + required_locations = [] + for sphere in reversed(collection_spheres): + random.shuffle(sphere) + for location in sphere: + # we remove the item at location and check if the game is still beatable in case the item could be required + old_item = location.item + + # Uncollect the item and location. + search.state_list[old_item.world.id].remove(old_item) + search.unvisit(location) + + # Generic events might show up or not, as usual, but since we don't + # show them in the final output, might as well skip over them. We'll + # still need them in the final pass, so make sure to include them. + if location.internal: + required_locations.append(location) + continue + + location.item = None + + # An item can only be required if it isn't already obtained or if it's progressive + if search.state_list[old_item.world.id].item_count(old_item.solver_id) < old_item.world.max_progressions[old_item.name]: + # Test whether the game is still beatable from here. + logger.debug('Checking if %s is required to beat the game.', old_item.name) + if not search.can_beat_game(): + # still required, so reset the item + location.item = old_item + required_locations.append(location) + + # Reduce each entrance sphere in reverse order, by checking if the game is beatable when we disconnect the entrance. + required_entrances = [] + for sphere in reversed(entrance_spheres): + random.shuffle(sphere) + for entrance in sphere: + # we disconnect the entrance and check if the game is still beatable + old_connected_region = entrance.disconnect() + + # we use a new search to ensure the disconnected entrance is no longer used + sub_search = Search([world.state for world in worlds]) + + # Test whether the game is still beatable from here. + logger.debug('Checking if reaching %s, through %s, is required to beat the game.', old_connected_region.name, entrance.name) + if not sub_search.can_beat_game(): + # still required, so reconnect the entrance + entrance.connect(old_connected_region) + required_entrances.append(entrance) + + # Regenerate the spheres as we might not reach places the same way anymore. + search.reset() # search state has no items, okay to reuse sphere 0 cache + collection_spheres = [list( + filter(lambda loc: loc.item.advancement and loc.item.world.max_progressions[loc.item.name] > 0, + search.iter_pseudo_starting_locations()))] + entrance_spheres = [] + remaining_entrances = set(required_entrances) + collected = set() + while True: + # Not collecting while the generator runs means we only get one sphere at a time + # Otherwise, an item we collect could influence later item collection in the same sphere + collected.update(search.iter_reachable_locations(required_locations)) + if not collected: + break + internal = collected & internal_locations + if internal: + # collect only the internal events but don't record them in a sphere + for location in internal: + search.state_list[location.item.world.id].collect(location.item) + # Remaining locations need to be saved to be collected later + collected -= internal + continue + # Gather the new entrances before collecting items. + collection_spheres.append(list(collected)) + accessed_entrances = set(filter(search.spot_access, remaining_entrances)) + entrance_spheres.append(accessed_entrances) + remaining_entrances -= accessed_entrances + for location in collected: + # Collect the item for the state world it is for + search.state_list[location.item.world.id].collect(location.item) + collected.clear() + logger.info('Collected %d final spheres', len(collection_spheres)) + + if not search.can_beat_game(False): + logger.error('Playthrough could not beat the game!') + # Add temporary debugging info or breakpoint here if this happens + + # Then we can finally output our playthrough + self.playthrough = OrderedDict((str(i), {location: location.item for location in sphere}) for i, sphere in enumerate(collection_spheres)) + # Copy our misc. hint items, since we set them in the world copy + for w, sw in zip(worlds, self.worlds): + # But the actual location saved here may be in a different world + for item_name, item_location in w.hinted_dungeon_reward_locations.items(): + sw.hinted_dungeon_reward_locations[item_name] = self.worlds[item_location.world.id].get_location(item_location.name) + for hint_type, item_location in w.misc_hint_item_locations.items(): + sw.misc_hint_item_locations[hint_type] = self.worlds[item_location.world.id].get_location(item_location.name) + + if worlds[0].entrance_shuffle: + self.entrance_playthrough = OrderedDict((str(i + 1), list(sphere)) for i, sphere in enumerate(entrance_spheres)) diff --git a/StartingItems.py b/StartingItems.py index 42cd7af69..f922ebd9e 100644 --- a/StartingItems.py +++ b/StartingItems.py @@ -1,76 +1,77 @@ from collections import namedtuple from itertools import chain +from typing import Dict, List, Tuple, Optional +Entry = namedtuple("Entry", ['setting_name', 'item_name', 'available', 'gui_text', 'special', 'ammo', 'i']) -_Entry = namedtuple("_Entry", ['settingname', 'itemname', 'available', 'guitext', 'special', 'ammo', 'i']) - -def _entry(settingname, itemname=None, available=1, guitext=None, special=False, ammo=None): - if itemname is None: - itemname = settingname.capitalize() - if guitext is None: - guitext = itemname +def _entry(setting_name: str, item_name: Optional[str] = None, available: int = 1, gui_text: Optional[str] = None, + special: bool = False, ammo: Optional[Dict[str, Tuple[int, ...]]] = None) -> List[Tuple[str, Entry]]: + if item_name is None: + item_name = setting_name.capitalize() + if gui_text is None: + gui_text = item_name result = [] for i in range(available): if i == 0: - name = settingname + name = setting_name else: - name = f"{settingname}{i+1}" - result.append((name, _Entry(name, itemname, available, guitext, special, ammo, i))) + name = f"{setting_name}{i + 1}" + result.append((name, Entry(name, item_name, available, gui_text, special, ammo, i))) return result # Ammo items must be declared in ItemList.py. -inventory = dict(chain( +inventory: Dict[str, Entry] = dict(chain( _entry('deku_stick', 'Deku Stick Capacity', available=2, ammo={'Deku Sticks': (20, 30)}), _entry('deku_nut', 'Deku Nut Capacity', available=2, ammo={'Deku Nuts': (30, 40)}), _entry('bombs', 'Bomb Bag', available=3, ammo={'Bombs': (20, 30, 40)}), _entry('bow', available=3, ammo={'Arrows': (30, 40, 50)}), _entry('fire_arrow', 'Fire Arrows'), - _entry('dins_fire', 'Dins Fire', guitext="Din's Fire"), + _entry('dins_fire', 'Dins Fire', gui_text="Din's Fire"), _entry('slingshot', available=3, ammo={'Deku Seeds': (30, 40, 50)}), _entry('ocarina', available=2), _entry('bombchus', ammo={'Bombchus': (20,)}), # start with additional bombchus _entry('hookshot', 'Progressive Hookshot', available=2), _entry('ice_arrow', 'Ice Arrows'), - _entry('farores_wind', 'Farores Wind', guitext="Farore's Wind"), + _entry('farores_wind', 'Farores Wind', gui_text="Farore's Wind"), _entry('boomerang'), _entry('lens', 'Lens of Truth'), _entry('beans', 'Magic Bean', ammo={'Magic Bean': (10,)}), # start with additional beans - _entry('megaton_hammer', 'Megaton Hammer', guitext = 'Megaton Hammer'), + _entry('megaton_hammer', 'Megaton Hammer', gui_text='Megaton Hammer'), _entry('light_arrow', 'Light Arrows'), - _entry('nayrus_love', 'Nayrus Love', guitext="Nayru's Love"), + _entry('nayrus_love', 'Nayrus Love', gui_text="Nayru's Love"), _entry('bottle', available=3, special=True), - _entry('letter', 'Rutos Letter', guitext="Ruto's Letter", special=True), - _entry("pocket_egg", "Pocket Egg", guitext="Pocket Egg"), - _entry("pocket_cucco", "Pocket Cucco", guitext="Pocket Cucco"), - _entry("cojiro", "Cojiro", guitext="Cojiro"), - _entry("odd_mushroom", "Odd Mushroom", guitext="Odd Mushroom"), - _entry("odd_potion", "Odd Potion", guitext="Odd Potion"), - _entry("poachers_saw", "Poachers Saw", guitext="Poacher's Saw"), - _entry("broken_sword", "Broken Sword", guitext="Broken Sword"), - _entry("prescription", "Prescription", guitext="Prescription"), - _entry("eyeball_frog", "Eyeball Frog", guitext="Eyeball Frog"), - _entry("eyedrops", "Eyedrops", guitext="Eyedrops"), - _entry("claim_check", "Claim Check", guitext="Claim Check"), - _entry("weird_egg", "Weird Egg", guitext="Weird Egg"), - _entry("chicken", "Chicken", guitext="Chicken"), - _entry("zeldas_letter","Zeldas Letter", guitext="Zelda's Letter"), - _entry("keaton_mask", "Keaton Mask", guitext="Keaton Mask"), - _entry("skull_mask", "Skull Mask", guitext="Skull Mask"), - _entry("spooky_mask", "Spooky Mask", guitext="Spooky Mask"), - _entry("bunny_hood", "Bunny Hood", guitext="Bunny Hood"), - _entry("goron_mask", "Goron Mask", guitext="Goron Mask"), - _entry("zora_mask", "Zora Mask", guitext="Zora Mask"), - _entry("gerudo_mask", "Gerudo Mask", guitext="Gerudo Mask"), - _entry("mask_of_truth","Mask of Truth", guitext="Mask of Truth"), + _entry('letter', 'Rutos Letter', gui_text="Ruto's Letter", special=True), + _entry("pocket_egg", "Pocket Egg", gui_text="Pocket Egg"), + _entry("pocket_cucco", "Pocket Cucco", gui_text="Pocket Cucco"), + _entry("cojiro", "Cojiro", gui_text="Cojiro"), + _entry("odd_mushroom", "Odd Mushroom", gui_text="Odd Mushroom"), + _entry("odd_potion", "Odd Potion", gui_text="Odd Potion"), + _entry("poachers_saw", "Poachers Saw", gui_text="Poacher's Saw"), + _entry("broken_sword", "Broken Sword", gui_text="Broken Sword"), + _entry("prescription", "Prescription", gui_text="Prescription"), + _entry("eyeball_frog", "Eyeball Frog", gui_text="Eyeball Frog"), + _entry("eyedrops", "Eyedrops", gui_text="Eyedrops"), + _entry("claim_check", "Claim Check", gui_text="Claim Check"), + _entry("weird_egg", "Weird Egg", gui_text="Weird Egg"), + _entry("chicken", "Chicken", gui_text="Chicken"), + _entry("zeldas_letter","Zeldas Letter", gui_text="Zelda's Letter"), + _entry("keaton_mask", "Keaton Mask", gui_text="Keaton Mask"), + _entry("skull_mask", "Skull Mask", gui_text="Skull Mask"), + _entry("spooky_mask", "Spooky Mask", gui_text="Spooky Mask"), + _entry("bunny_hood", "Bunny Hood", gui_text="Bunny Hood"), + _entry("goron_mask", "Goron Mask", gui_text="Goron Mask"), + _entry("zora_mask", "Zora Mask", gui_text="Zora Mask"), + _entry("gerudo_mask", "Gerudo Mask", gui_text="Gerudo Mask"), + _entry("mask_of_truth","Mask of Truth", gui_text="Mask of Truth"), )) -songs = dict(chain( - _entry('lullaby', 'Zeldas Lullaby', guitext="Zelda's Lullaby"), - _entry('eponas_song', 'Eponas Song', guitext="Epona's Song"), - _entry('sarias_song', 'Sarias Song', guitext="Saria's Song"), - _entry('suns_song', 'Suns Song', guitext="Sun's Song"), +songs: Dict[str, Entry] = dict(chain( + _entry('lullaby', 'Zeldas Lullaby', gui_text="Zelda's Lullaby"), + _entry('eponas_song', 'Eponas Song', gui_text="Epona's Song"), + _entry('sarias_song', 'Sarias Song', gui_text="Saria's Song"), + _entry('suns_song', 'Suns Song', gui_text="Sun's Song"), _entry('song_of_time', 'Song of Time'), _entry('song_of_storms', 'Song of Storms'), _entry('minuet', 'Minuet of Forest'), @@ -81,7 +82,7 @@ def _entry(settingname, itemname=None, available=1, guitext=None, special=False, _entry('prelude', 'Prelude of Light'), )) -equipment = dict(chain( +equipment: Dict[str, Entry] = dict(chain( _entry('kokiri_sword', 'Kokiri Sword'), _entry('giants_knife', 'Giants Knife'), _entry('biggoron_sword', 'Biggoron Sword'), @@ -93,11 +94,11 @@ def _entry(settingname, itemname=None, available=1, guitext=None, special=False, _entry('iron_boots', 'Iron Boots'), _entry('hover_boots', 'Hover Boots'), _entry('magic', 'Magic Meter', available=2), - _entry('strength', 'Progressive Strength Upgrade', guitext='Progressive Strength', available=3), + _entry('strength', 'Progressive Strength Upgrade', available=3, gui_text='Progressive Strength'), _entry('scale', 'Progressive Scale', available=2), _entry('wallet', 'Progressive Wallet', available=3), _entry('stone_of_agony', 'Stone of Agony'), _entry('defense', 'Double Defense'), )) -everything = dict(chain(equipment.items(), inventory.items(), songs.items())) +everything: Dict[str, Entry] = {**equipment, **inventory, **songs} diff --git a/State.py b/State.py index 98d6f131f..c6c1cc1cc 100644 --- a/State.py +++ b/State.py @@ -1,25 +1,27 @@ -from collections import Counter -import copy -import logging +from typing import TYPE_CHECKING, Dict, List, Iterable, Optional, Union, Any -from Item import ItemInfo +from Item import Item, ItemInfo from RulesCommon import escape_name -Triforce_Piece = ItemInfo.solver_ids['Triforce_Piece'] -Triforce = ItemInfo.solver_ids['Triforce'] -Rutos_Letter = ItemInfo.solver_ids['Rutos_Letter'] -Piece_of_Heart = ItemInfo.solver_ids['Piece_of_Heart'] +if TYPE_CHECKING: + from Goals import GoalCategory, Goal + from Location import Location + from Search import Search + from World import World -class State(object): +Triforce_Piece: int = ItemInfo.solver_ids['Triforce_Piece'] +Triforce: int = ItemInfo.solver_ids['Triforce'] +Rutos_Letter: int = ItemInfo.solver_ids['Rutos_Letter'] +Piece_of_Heart: int = ItemInfo.solver_ids['Piece_of_Heart'] - def __init__(self, parent): - self.solv_items = [0] * len(ItemInfo.solver_ids) - self.world = parent - self.search = None - self._won = self.won_triforce_hunt if self.world.settings.triforce_hunt else self.won_normal +class State: + def __init__(self, parent: "World") -> None: + self.solv_items: List[int] = [0] * len(ItemInfo.solver_ids) + self.world: "World" = parent + self.search: "Optional[Search]" = None - def copy(self, new_world=None): + def copy(self, new_world: "Optional[World]" = None) -> 'State': if not new_world: new_world = self.world new_state = State(new_world) @@ -27,106 +29,89 @@ def copy(self, new_world=None): new_state.solv_items[i] = val return new_state - - def item_name(self, location): + def item_name(self, location: "Union[str, Location]") -> Optional[str]: location = self.world.get_location(location) if location.item is None: return None return location.item.name + def won(self) -> bool: + return self.won_triforce_hunt() if self.world.settings.triforce_hunt else self.won_normal() - def won(self): - return self._won() - - - def won_triforce_hunt(self): + def won_triforce_hunt(self) -> bool: return self.has(Triforce_Piece, self.world.settings.triforce_goal_per_world) - - def won_normal(self): + def won_normal(self) -> bool: return self.has(Triforce) - - def has(self, item, count=1): + def has(self, item: int, count: int = 1) -> bool: return self.solv_items[item] >= count - - def has_any_of(self, items): + def has_any_of(self, items: Iterable[int]) -> bool: for i in items: - if self.solv_items[i]: return True + if self.solv_items[i]: + return True return False - - def has_all_of(self, items): + def has_all_of(self, items: Iterable[int]) -> bool: for i in items: - if not self.solv_items[i]: return False + if not self.solv_items[i]: + return False return True - - def count_of(self, items): + def count_of(self, items: Iterable[int]) -> int: s = 0 for i in items: s += self.solv_items[i] return s - - def item_count(self, item): + def item_count(self, item: int) -> int: return self.solv_items[item] - - def item_name_count(self, name): + def item_name_count(self, name: str) -> int: return self.solv_items[ItemInfo.solver_ids[escape_name(name)]] - - def has_bottle(self, **kwargs): + def has_bottle(self, **kwargs) -> bool: # Extra Ruto's Letter are automatically emptied return self.has_any_of(ItemInfo.bottle_ids) or self.has(Rutos_Letter, 2) - - def has_hearts(self, count): + def has_hearts(self, count: int) -> bool: # Warning: This is limited by World.max_progressions so it currently only works if hearts are required for LACS, bridge, or Ganon bk return self.heart_count() >= count - - def heart_count(self): + def heart_count(self) -> int: # Warning: This is limited by World.max_progressions so it currently only works if hearts are required for LACS, bridge, or Ganon bk return ( self.item_count(Piece_of_Heart) // 4 # aliases ensure Heart Container and Piece of Heart (Treasure Chest Game) are included in this + 3 # starting hearts ) - def has_medallions(self, count): + def has_medallions(self, count: int) -> bool: return self.count_of(ItemInfo.medallion_ids) >= count - - def has_stones(self, count): + def has_stones(self, count: int) -> bool: return self.count_of(ItemInfo.stone_ids) >= count - - def has_dungeon_rewards(self, count): + def has_dungeon_rewards(self, count: int) -> bool: return (self.count_of(ItemInfo.medallion_ids) + self.count_of(ItemInfo.stone_ids)) >= count - # TODO: Store the item's solver id in the goal - def has_item_goal(self, item_goal): + def has_item_goal(self, item_goal: Dict[str, Any]) -> bool: return self.solv_items[ItemInfo.solver_ids[escape_name(item_goal['name'])]] >= item_goal['minimum'] - - def has_full_item_goal(self, category, goal, item_goal): + def has_full_item_goal(self, category: "GoalCategory", goal: "Goal", item_goal: Dict[str, Any]) -> bool: local_goal = self.world.goal_categories[category.name].get_goal(goal.name) per_world_max_quantity = local_goal.get_item(item_goal['name'])['quantity'] return self.solv_items[ItemInfo.solver_ids[escape_name(item_goal['name'])]] >= per_world_max_quantity - - def has_all_item_goals(self): + def has_all_item_goals(self) -> bool: for category in self.world.goal_categories.values(): for goal in category.goals: if not all(map(lambda i: self.has_full_item_goal(category, goal, i), goal.items)): return False return True - - def had_night_start(self): + def had_night_start(self) -> bool: stod = self.world.settings.starting_tod # These are all not between 6:30 and 18:00 if (stod == 'sunset' or # 18 @@ -137,9 +122,8 @@ def had_night_start(self): else: return False - # Used for fall damage and other situations where damage is unavoidable - def can_live_dmg(self, hearts): + def can_live_dmg(self, hearts: int) -> bool: mult = self.world.settings.damage_multiplier if hearts*4 >= 3: return mult != 'ohko' and mult != 'quadruple' @@ -148,15 +132,13 @@ def can_live_dmg(self, hearts): else: return True - # Use the guarantee_hint rule defined in json. - def guarantee_hint(self): + def guarantee_hint(self) -> bool: return self.world.parser.parse_rule('guarantee_hint')(self) - # Be careful using this function. It will not collect any # items that may be locked behind the item, only the item itself. - def collect(self, item): + def collect(self, item: Item) -> None: if 'Small Key Ring' in item.name and self.world.settings.keyring_give_bk: dungeon_name = item.name[:-1].split(' (', 1)[1] if dungeon_name in ['Forest Temple', 'Fire Temple', 'Water Temple', 'Shadow Temple', 'Spirit Temple']: @@ -167,10 +149,9 @@ def collect(self, item): if item.advancement: self.solv_items[item.solver_id] += 1 - # Be careful using this function. It will not uncollect any # items that may be locked behind the item, only the item itself. - def remove(self, item): + def remove(self, item: Item) -> None: if 'Small Key Ring' in item.name and self.world.settings.keyring_give_bk: dungeon_name = item.name[:-1].split(' (', 1)[1] if dungeon_name in ['Forest Temple', 'Fire Temple', 'Water Temple', 'Shadow Temple', 'Spirit Temple']: @@ -183,20 +164,16 @@ def remove(self, item): if self.solv_items[item.solver_id] > 0: self.solv_items[item.solver_id] -= 1 - - def region_has_shortcuts(self, region_name): + def region_has_shortcuts(self, region_name: str) -> bool: return self.world.region_has_shortcuts(region_name) - - def __getstate__(self): + def __getstate__(self) -> Dict[str, Any]: return self.__dict__.copy() - - def __setstate__(self, state): + def __setstate__(self, state: Dict[str, Any]) -> None: self.__dict__.update(state) - - def get_prog_items(self): + def get_prog_items(self) -> Dict[str, int]: return { **{item.name: self.solv_items[item.solver_id] for item in ItemInfo.items.values() @@ -205,4 +182,3 @@ def get_prog_items(self): for event in self.world.event_items if self.solv_items[ItemInfo.solver_ids[event]]} } - diff --git a/TextBox.py b/TextBox.py index 04e9f4114..499ea21e7 100644 --- a/TextBox.py +++ b/TextBox.py @@ -1,36 +1,44 @@ -import Messages import re +from typing import TYPE_CHECKING, Dict, List, Pattern, Match + +import Messages + +if TYPE_CHECKING: + from Messages import TextCode # Least common multiple of all possible character widths. A line wrap must occur when the combined widths of all of the # characters on a line reach this value. -NORMAL_LINE_WIDTH = 1801800 +NORMAL_LINE_WIDTH: int = 1801800 # Attempting to display more lines in a single text box will cause additional lines to bleed past the bottom of the box. -LINES_PER_BOX = 4 +LINES_PER_BOX: int = 4 # Attempting to display more characters in a single text box will cause buffer overflows. First, visual artifacts will # appear in lower areas of the text box. Eventually, the text box will become uncloseable. -MAX_CHARACTERS_PER_BOX = 200 +MAX_CHARACTERS_PER_BOX: int = 200 -CONTROL_CHARS = { +CONTROL_CHARS: Dict[str, List[str]] = { 'LINE_BREAK': ['&', '\x01'], 'BOX_BREAK': ['^', '\x04'], 'NAME': ['@', '\x0F'], 'COLOR': ['#', '\x05\x00'], } -TEXT_END = '\x02' +TEXT_END: str = '\x02' -def line_wrap(text, strip_existing_lines=False, strip_existing_boxes=False, replace_control_chars=True): +hex_string_regex: Pattern = re.compile(r"\$\{((?:[0-9a-f][0-9a-f] ?)+)}", flags=re.IGNORECASE) + + +def line_wrap(text: str, strip_existing_lines: bool = False, strip_existing_boxes: bool = False, replace_control_chars: bool = True): # Replace stand-in characters with their actual control code. if replace_control_chars: - def replace_bytes(matchobj): - return ''.join(chr(x) for x in bytes.fromhex(matchobj[1])) + def replace_bytes(match: Match) -> str: + return ''.join(chr(x) for x in bytes.fromhex(match[1])) for char in CONTROL_CHARS.values(): text = text.replace(char[0], char[1]) - text = re.sub(r"\$\{((?:[0-9a-f][0-9a-f] ?)+)}", replace_bytes, text, flags=re.IGNORECASE) + text = hex_string_regex.sub(replace_bytes, text) # Parse the text into a list of control codes. text_codes = Messages.parse_control_codes(text) @@ -57,7 +65,7 @@ def replace_bytes(matchobj): text_codes.pop(index) continue # Replace this text code with a space. - text_codes[index] = Messages.Text_Code(0x20, 0) + text_codes[index] = Messages.TextCode(0x20, 0) index += 1 # Split the text codes by current box breaks. @@ -94,7 +102,7 @@ def replace_bytes(matchobj): if index > 1: words.append(box_codes[0:index-1]) if text_code.code in [0x01, 0x04]: - # If we have ran into a line or box break, add it as a "word" as well. + # If we have run into a line or box break, add it as a "word" as well. words.append([box_codes[index-1]]) box_codes = box_codes[index:] index = 0 @@ -138,7 +146,7 @@ def replace_bytes(matchobj): return '\x04'.join(['\x01'.join([' '.join([''.join([code.get_string() for code in word]) for word in line]) for line in box]) for box in processed_boxes]) -def calculate_width(words): +def calculate_width(words: "List[List[TextCode]]"): words_width = 0 for word in words: index = 0 @@ -154,7 +162,7 @@ def calculate_width(words): return words_width + spaces_width -def get_character_width(character): +def get_character_width(character: str) -> int: try: return character_table[character] except KeyError: @@ -168,7 +176,7 @@ def get_character_width(character): return character_table[' '] -control_code_width = { +control_code_width: Dict[str, str] = { '\x0F': '00000000', '\x16': '00\'00"', '\x17': '00\'00"', @@ -186,7 +194,7 @@ def get_character_width(character): # at worst. This ensures that we will never bleed text out of the text box while line wrapping. # Larger numbers in the denominator mean more of that character fits on a line; conversely, larger values in this table # mean the character is wider and can't fit as many on one line. -character_table = { +character_table: Dict[str, int] = { '\x0F': 655200, '\x16': 292215, '\x17': 292215, @@ -195,88 +203,89 @@ def get_character_width(character): '\x1D': 85800, '\x1E': 300300, '\x1F': 265980, - 'a': 51480, # LINE_WIDTH / 35 - 'b': 51480, # LINE_WIDTH / 35 - 'c': 51480, # LINE_WIDTH / 35 - 'd': 51480, # LINE_WIDTH / 35 - 'e': 51480, # LINE_WIDTH / 35 - 'f': 34650, # LINE_WIDTH / 52 - 'g': 51480, # LINE_WIDTH / 35 - 'h': 51480, # LINE_WIDTH / 35 - 'i': 25740, # LINE_WIDTH / 70 - 'j': 34650, # LINE_WIDTH / 52 - 'k': 51480, # LINE_WIDTH / 35 - 'l': 25740, # LINE_WIDTH / 70 - 'm': 81900, # LINE_WIDTH / 22 - 'n': 51480, # LINE_WIDTH / 35 - 'o': 51480, # LINE_WIDTH / 35 - 'p': 51480, # LINE_WIDTH / 35 - 'q': 51480, # LINE_WIDTH / 35 - 'r': 42900, # LINE_WIDTH / 42 - 's': 51480, # LINE_WIDTH / 35 - 't': 42900, # LINE_WIDTH / 42 - 'u': 51480, # LINE_WIDTH / 35 - 'v': 51480, # LINE_WIDTH / 35 - 'w': 81900, # LINE_WIDTH / 22 - 'x': 51480, # LINE_WIDTH / 35 - 'y': 51480, # LINE_WIDTH / 35 - 'z': 51480, # LINE_WIDTH / 35 - 'A': 81900, # LINE_WIDTH / 22 - 'B': 51480, # LINE_WIDTH / 35 - 'C': 72072, # LINE_WIDTH / 25 - 'D': 72072, # LINE_WIDTH / 25 - 'E': 51480, # LINE_WIDTH / 35 - 'F': 51480, # LINE_WIDTH / 35 - 'G': 81900, # LINE_WIDTH / 22 - 'H': 60060, # LINE_WIDTH / 30 - 'I': 25740, # LINE_WIDTH / 70 - 'J': 51480, # LINE_WIDTH / 35 - 'K': 60060, # LINE_WIDTH / 30 - 'L': 51480, # LINE_WIDTH / 35 - 'M': 81900, # LINE_WIDTH / 22 - 'N': 72072, # LINE_WIDTH / 25 - 'O': 81900, # LINE_WIDTH / 22 - 'P': 51480, # LINE_WIDTH / 35 - 'Q': 81900, # LINE_WIDTH / 22 - 'R': 60060, # LINE_WIDTH / 30 - 'S': 60060, # LINE_WIDTH / 30 - 'T': 51480, # LINE_WIDTH / 35 - 'U': 60060, # LINE_WIDTH / 30 - 'V': 72072, # LINE_WIDTH / 25 - 'W': 100100, # LINE_WIDTH / 18 - 'X': 72072, # LINE_WIDTH / 25 - 'Y': 60060, # LINE_WIDTH / 30 - 'Z': 60060, # LINE_WIDTH / 30 - ' ': 51480, # LINE_WIDTH / 35 - '1': 25740, # LINE_WIDTH / 70 - '2': 51480, # LINE_WIDTH / 35 - '3': 51480, # LINE_WIDTH / 35 - '4': 60060, # LINE_WIDTH / 30 - '5': 51480, # LINE_WIDTH / 35 - '6': 51480, # LINE_WIDTH / 35 - '7': 51480, # LINE_WIDTH / 35 - '8': 51480, # LINE_WIDTH / 35 - '9': 51480, # LINE_WIDTH / 35 - '0': 60060, # LINE_WIDTH / 30 - '!': 51480, # LINE_WIDTH / 35 - '?': 72072, # LINE_WIDTH / 25 - '\'': 17325, # LINE_WIDTH / 104 - '"': 34650, # LINE_WIDTH / 52 - '.': 25740, # LINE_WIDTH / 70 - ',': 25740, # LINE_WIDTH / 70 - '/': 51480, # LINE_WIDTH / 35 - '-': 34650, # LINE_WIDTH / 52 - '_': 51480, # LINE_WIDTH / 35 - '(': 42900, # LINE_WIDTH / 42 - ')': 42900, # LINE_WIDTH / 42 - '$': 51480 # LINE_WIDTH / 35 + 'a': 51480, # LINE_WIDTH / 35 + 'b': 51480, # LINE_WIDTH / 35 + 'c': 51480, # LINE_WIDTH / 35 + 'd': 51480, # LINE_WIDTH / 35 + 'e': 51480, # LINE_WIDTH / 35 + 'f': 34650, # LINE_WIDTH / 52 + 'g': 51480, # LINE_WIDTH / 35 + 'h': 51480, # LINE_WIDTH / 35 + 'i': 25740, # LINE_WIDTH / 70 + 'j': 34650, # LINE_WIDTH / 52 + 'k': 51480, # LINE_WIDTH / 35 + 'l': 25740, # LINE_WIDTH / 70 + 'm': 81900, # LINE_WIDTH / 22 + 'n': 51480, # LINE_WIDTH / 35 + 'o': 51480, # LINE_WIDTH / 35 + 'p': 51480, # LINE_WIDTH / 35 + 'q': 51480, # LINE_WIDTH / 35 + 'r': 42900, # LINE_WIDTH / 42 + 's': 51480, # LINE_WIDTH / 35 + 't': 42900, # LINE_WIDTH / 42 + 'u': 51480, # LINE_WIDTH / 35 + 'v': 51480, # LINE_WIDTH / 35 + 'w': 81900, # LINE_WIDTH / 22 + 'x': 51480, # LINE_WIDTH / 35 + 'y': 51480, # LINE_WIDTH / 35 + 'z': 51480, # LINE_WIDTH / 35 + 'A': 81900, # LINE_WIDTH / 22 + 'B': 51480, # LINE_WIDTH / 35 + 'C': 72072, # LINE_WIDTH / 25 + 'D': 72072, # LINE_WIDTH / 25 + 'E': 51480, # LINE_WIDTH / 35 + 'F': 51480, # LINE_WIDTH / 35 + 'G': 81900, # LINE_WIDTH / 22 + 'H': 60060, # LINE_WIDTH / 30 + 'I': 25740, # LINE_WIDTH / 70 + 'J': 51480, # LINE_WIDTH / 35 + 'K': 60060, # LINE_WIDTH / 30 + 'L': 51480, # LINE_WIDTH / 35 + 'M': 81900, # LINE_WIDTH / 22 + 'N': 72072, # LINE_WIDTH / 25 + 'O': 81900, # LINE_WIDTH / 22 + 'P': 51480, # LINE_WIDTH / 35 + 'Q': 81900, # LINE_WIDTH / 22 + 'R': 60060, # LINE_WIDTH / 30 + 'S': 60060, # LINE_WIDTH / 30 + 'T': 51480, # LINE_WIDTH / 35 + 'U': 60060, # LINE_WIDTH / 30 + 'V': 72072, # LINE_WIDTH / 25 + 'W': 100100, # LINE_WIDTH / 18 + 'X': 72072, # LINE_WIDTH / 25 + 'Y': 60060, # LINE_WIDTH / 30 + 'Z': 60060, # LINE_WIDTH / 30 + ' ': 51480, # LINE_WIDTH / 35 + '1': 25740, # LINE_WIDTH / 70 + '2': 51480, # LINE_WIDTH / 35 + '3': 51480, # LINE_WIDTH / 35 + '4': 60060, # LINE_WIDTH / 30 + '5': 51480, # LINE_WIDTH / 35 + '6': 51480, # LINE_WIDTH / 35 + '7': 51480, # LINE_WIDTH / 35 + '8': 51480, # LINE_WIDTH / 35 + '9': 51480, # LINE_WIDTH / 35 + '0': 60060, # LINE_WIDTH / 30 + '!': 51480, # LINE_WIDTH / 35 + '?': 72072, # LINE_WIDTH / 25 + '\'': 17325, # LINE_WIDTH / 104 + '"': 34650, # LINE_WIDTH / 52 + '.': 25740, # LINE_WIDTH / 70 + ',': 25740, # LINE_WIDTH / 70 + '/': 51480, # LINE_WIDTH / 35 + '-': 34650, # LINE_WIDTH / 52 + '_': 51480, # LINE_WIDTH / 35 + '(': 42900, # LINE_WIDTH / 42 + ')': 42900, # LINE_WIDTH / 42 + '$': 51480, # LINE_WIDTH / 35 } + # To run tests, enter the following into a python3 REPL: # >>> import Messages # >>> from TextBox import line_wrap_tests # >>> line_wrap_tests() -def line_wrap_tests(): +def line_wrap_tests() -> None: test_wrap_simple_line() test_honor_forced_line_wraps() test_honor_box_breaks() @@ -287,7 +296,7 @@ def line_wrap_tests(): test_support_long_words() -def test_wrap_simple_line(): +def test_wrap_simple_line() -> None: words = 'Hello World! Hello World! Hello World!' expected = 'Hello World! Hello World! Hello\x01World!' result = line_wrap(words) @@ -298,7 +307,7 @@ def test_wrap_simple_line(): print('"Wrap Simple Line" test passed!') -def test_honor_forced_line_wraps(): +def test_honor_forced_line_wraps() -> None: words = 'Hello World! Hello World!&Hello World! Hello World! Hello World!' expected = 'Hello World! Hello World!\x01Hello World! Hello World! Hello\x01World!' result = line_wrap(words) @@ -309,7 +318,7 @@ def test_honor_forced_line_wraps(): print('"Honor Forced Line Wraps" test passed!') -def test_honor_box_breaks(): +def test_honor_box_breaks() -> None: words = 'Hello World! Hello World!^Hello World! Hello World! Hello World!' expected = 'Hello World! Hello World!\x04Hello World! Hello World! Hello\x01World!' result = line_wrap(words) @@ -320,7 +329,7 @@ def test_honor_box_breaks(): print('"Honor Box Breaks" test passed!') -def test_honor_control_characters(): +def test_honor_control_characters() -> None: words = 'Hello World! #Hello# World! Hello World!' expected = 'Hello World! \x05\x00Hello\x05\x00 World! Hello\x01World!' result = line_wrap(words) @@ -331,7 +340,7 @@ def test_honor_control_characters(): print('"Honor Control Characters" test passed!') -def test_honor_player_name(): +def test_honor_player_name() -> None: words = 'Hello @! Hello World! Hello World!' expected = 'Hello \x0F! Hello World!\x01Hello World!' result = line_wrap(words) @@ -342,7 +351,7 @@ def test_honor_player_name(): print('"Honor Player Name" test passed!') -def test_maintain_multiple_forced_breaks(): +def test_maintain_multiple_forced_breaks() -> None: words = 'Hello World!&&&Hello World!' expected = 'Hello World!\x01\x01\x01Hello World!' result = line_wrap(words) @@ -353,7 +362,7 @@ def test_maintain_multiple_forced_breaks(): print('"Maintain Multiple Forced Breaks" test passed!') -def test_trim_whitespace(): +def test_trim_whitespace() -> None: words = 'Hello World! & Hello World!' expected = 'Hello World!\x01Hello World!' result = line_wrap(words) @@ -364,7 +373,7 @@ def test_trim_whitespace(): print('"Trim Whitespace" test passed!') -def test_support_long_words(): +def test_support_long_words() -> None: words = 'Hello World! WWWWWWWWWWWWWWWWWWWW Hello World!' expected = 'Hello World!\x01WWWWWWWWWWWWWWWWWWWW\x01Hello World!' result = line_wrap(words) diff --git a/Unittest.py b/Unittest.py index bb3e0454c..e1ce540d9 100644 --- a/Unittest.py +++ b/Unittest.py @@ -2,24 +2,25 @@ # With python3.10, you can instead run pytest Unittest.py # See `python -m unittest -h` or `pytest -h` for more options. -from collections import Counter, defaultdict import json import logging import os import random import re import unittest +from collections import Counter, defaultdict +from typing import Dict, Tuple, Optional, Union, Any from EntranceShuffle import EntranceShuffleError from Fill import ShuffleError -from Hints import HintArea -from Hints import HintArea, buildMiscItemHints +from Hints import HintArea, build_misc_item_hints from Item import ItemInfo from ItemPool import remove_junk_items, remove_junk_ludicrous_items, ludicrous_items_base, ludicrous_items_extended, trade_items, ludicrous_exclusions from LocationList import location_is_viewable from Main import main, resolve_settings, build_world_graphs from Messages import Message from Settings import Settings, get_preset_files +from Spoiler import Spoiler test_dir = os.path.join(os.path.dirname(__file__), 'tests') output_dir = os.path.join(test_dir, 'Output') @@ -48,10 +49,10 @@ junk = set(remove_junk_items) shop_items = {i for i, nfo in ItemInfo.items.items() if nfo.type == 'Shop'} ludicrous_junk = set(remove_junk_ludicrous_items) -ludicrous_set = set(ludicrous_items_base) | set(ludicrous_items_extended) | ludicrous_junk | set(trade_items) | set(bottles) | set(ludicrous_exclusions) | set(['Bottle with Big Poe']) | shop_items +ludicrous_set = set(ludicrous_items_base) | set(ludicrous_items_extended) | ludicrous_junk | set(trade_items) | set(bottles) | set(ludicrous_exclusions) | {'Bottle with Big Poe'} | shop_items -def make_settings_for_test(settings_dict, seed=None, outfilename=None, strict=True): +def make_settings_for_test(settings_dict: Dict[str, Any], seed: Optional[str] = None, outfilename: str = None, strict: bool = True) -> Settings: # Some consistent settings for testability settings_dict.update({ 'create_patch_file': False, @@ -67,7 +68,7 @@ def make_settings_for_test(settings_dict, seed=None, outfilename=None, strict=Tr return Settings(settings_dict, strict=strict) -def load_settings(settings_file, seed=None, filename=None): +def load_settings(settings_file: Union[Dict[str, Any], str], seed: Optional[str] = None, filename: Optional[str] = None) -> Settings: if isinstance(settings_file, dict): # Check if settings_file is a distribution file settings dict try: j = settings_file @@ -85,12 +86,12 @@ def load_settings(settings_file, seed=None, filename=None): return make_settings_for_test(j, seed=seed, outfilename=filename) -def load_spoiler(json_file): +def load_spoiler(json_file: str) -> Any: with open(json_file) as f: return json.load(f) -def generate_with_plandomizer(filename, live_copy=False, max_attempts=10): +def generate_with_plandomizer(filename: str, live_copy: bool = False, max_attempts: int = 10) -> Tuple[Dict[str, Any], Union[Spoiler, Dict[str, Any]]]: distribution_file = load_spoiler(os.path.join(test_dir, 'plando', filename + '.json')) try: settings = load_settings(distribution_file['settings'], seed='TESTTESTTEST', filename=filename) @@ -109,11 +110,11 @@ def generate_with_plandomizer(filename, live_copy=False, max_attempts=10): }) spoiler = main(settings, max_attempts=max_attempts) if not live_copy: - spoiler = load_spoiler('%s_Spoiler.json' % settings.output_file) + spoiler = load_spoiler(f'{settings.output_file}_Spoiler.json') return distribution_file, spoiler -def get_actual_pool(spoiler): +def get_actual_pool(spoiler: Dict[str, Any]) -> Dict[str, int]: """Retrieves the actual item pool based on items placed in the spoiler log. :param spoiler: Spoiler log output from generator @@ -617,7 +618,7 @@ def test_those_pots_over_there(self): self.assertEqual(area, "#Ganondorf's Chamber#") # Build a test message with the same ID as the ganondorf hint (0x70CC) messages = [Message("Test", 0, 0x70CC, 0,0,0)] - buildMiscItemHints(spoiler.worlds[0], messages) + build_misc_item_hints(spoiler.worlds[0], messages) for message in messages: if(message.id == 0x70CC): # Ganondorf hint message self.assertTrue("thosepotsoverthere" in message.text.replace('\n', '').replace(' ', '')) diff --git a/Utils.py b/Utils.py index 77890b42c..510631edd 100644 --- a/Utils.py +++ b/Utils.py @@ -1,23 +1,30 @@ import io import json +import logging import os +import re import subprocess import sys import urllib.request +from typing import Dict, List, Sequence, Optional, AnyStr, Any from urllib.error import URLError, HTTPError -import re + from version import __version__, base_version, supplementary_version, branch_url -import random -import itertools -import bisect -import logging -def is_bundled(): +# For easy import of TypeAlias that won't break older versions of Python. +if sys.version_info >= (3, 10): + # noinspection PyUnresolvedReferences + from typing import TypeAlias +else: + TypeAlias = str + + +def is_bundled() -> bool: return getattr(sys, 'frozen', False) -def local_path(path=''): +def local_path(path: str = '') -> str: if not hasattr(local_path, "cached_path"): local_path.cached_path = None @@ -34,7 +41,7 @@ def local_path(path=''): return os.path.join(local_path.cached_path, path) -def data_path(path=''): +def data_path(path: str = '') -> str: if not hasattr(data_path, "cached_path"): data_path.cached_path = None @@ -49,7 +56,7 @@ def data_path(path=''): return os.path.join(data_path.cached_path, path) -def default_output_path(path): +def default_output_path(path: str) -> str: if path == '': path = local_path('Output') @@ -58,7 +65,7 @@ def default_output_path(path): return path -def read_logic_file(file_path): +def read_logic_file(file_path: str): json_string = "" with io.open(file_path, 'r') as file: for line in file.readlines(): @@ -72,7 +79,7 @@ def read_logic_file(file_path): " ^^\n") -def open_file(filename): +def open_file(filename: str) -> None: if sys.platform == 'win32': os.startfile(filename) else: @@ -80,9 +87,9 @@ def open_file(filename): subprocess.call([open_command, filename]) -def close_console(): +def close_console() -> None: if sys.platform == 'win32': - #windows + # windows import win32gui, win32con try: win32gui.ShowWindow(win32gui.GetForegroundWindow(), win32con.SW_HIDE) @@ -90,7 +97,7 @@ def close_console(): pass -def get_version_bytes(a, b=0x00, c=0x00): +def get_version_bytes(a: str, b: int = 0x00, c: int = 0x00) -> List[int]: version_bytes = [0x00, 0x00, 0x00, b, c] if not a: @@ -98,7 +105,7 @@ def get_version_bytes(a, b=0x00, c=0x00): sa = a.replace('v', '').replace(' ', '.').split('.') - for i in range(0,3): + for i in range(0, 3): try: version_byte = int(sa[i]) except ValueError: @@ -108,7 +115,7 @@ def get_version_bytes(a, b=0x00, c=0x00): return version_bytes -def compare_version(a, b): +def compare_version(a: str, b: str) -> int: if not a and not b: return 0 elif a and not b: @@ -131,16 +138,22 @@ class VersionError(Exception): pass -def check_version(checked_version): +def check_version(checked_version: str) -> None: + if not hasattr(check_version, "base_regex"): + check_version.base_regex = re.compile("""^[ \t]*__version__ = ['"](.+)['"]""", re.MULTILINE) + check_version.supplementary_regex = re.compile(r"^[ \t]*supplementary_version = (\d+)$", re.MULTILINE) + check_version.full_regex = re.compile("""^[ \t]*__version__ = f['"]*(.+)['"]""", re.MULTILINE) + check_version.url_regex = re.compile("""^[ \t]*branch_url = ['"](.+)['"]""", re.MULTILINE) + if compare_version(checked_version, __version__) < 0: try: with urllib.request.urlopen(f'{branch_url.replace("https://github.com", "https://raw.githubusercontent.com").replace("tree/", "")}/version.py') as versionurl: version_file = versionurl.read().decode("utf-8") - base_match = re.search("""^[ \t]*__version__ = ['"](.+)['"]""", version_file, re.MULTILINE) - supplementary_match = re.search(r"^[ \t]*supplementary_version = (\d+)$", version_file, re.MULTILINE) - full_match = re.search("""^[ \t]*__version__ = f['"]*(.+)['"]""", version_file, re.MULTILINE) - url_match = re.search("""^[ \t]*branch_url = ['"](.+)['"]""", version_file, re.MULTILINE) + base_match = check_version.base_regex.search(version_file, re.MULTILINE) + supplementary_match = check_version.supplementary_regex.search(version_file, re.MULTILINE) + full_match = check_version.full_regex.search(version_file, re.MULTILINE) + url_match = check_version.url_regex.search(version_file, re.MULTILINE) remote_base_version = base_match.group(1) if base_match else "" remote_supplementary_version = int(supplementary_match.group(1)) if supplementary_match else 0 @@ -168,7 +181,7 @@ def check_version(checked_version): # variants) call work with or without Pyinstaller, ``--noconsole`` or # not, on Windows and Linux. Typical use:: # subprocess.call(['program_to_run', 'arg_1'], **subprocess_args()) -def subprocess_args(include_stdout=True): +def subprocess_args(include_stdout: bool = True) -> Dict[str, Any]: # The following is true only on Windows. if hasattr(subprocess, 'STARTUPINFO'): # On Windows, subprocess calls will pop up a command window by default @@ -197,11 +210,11 @@ def subprocess_args(include_stdout=True): ret.update({'stdin': subprocess.PIPE, 'stderr': subprocess.PIPE, 'startupinfo': si, - 'env': env }) + 'env': env}) return ret -def run_process(logger, args, stdin=None): +def run_process(logger: logging.Logger, args: Sequence[str], stdin: Optional[AnyStr] = None) -> None: process = subprocess.Popen(args, bufsize=1, stdout=subprocess.PIPE, stdin=subprocess.PIPE) filecount = None if stdin is not None: @@ -221,7 +234,8 @@ def run_process(logger, args, stdin=None): # https://stackoverflow.com/a/23146126 -def find_last(source_list, sought_element): +def find_last(source_list: Sequence[Any], sought_element: Any) -> Optional[int]: for reverse_index, element in enumerate(reversed(source_list)): if element == sought_element: return len(source_list) - 1 - reverse_index + return None diff --git a/World.py b/World.py index 1c22f8cdf..d93052da1 100644 --- a/World.py +++ b/World.py @@ -1,65 +1,69 @@ -from collections import OrderedDict import copy +import json import logging +import os import random -import json +from collections import OrderedDict +from typing import Dict, List, Tuple, Set, Any, Union, Iterable, Optional +from Dungeon import Dungeon from Entrance import Entrance from Goals import Goal, GoalCategory -from HintList import getRequiredHints, misc_item_hint_table, misc_location_hint_table -from Hints import HintArea, hint_dist_keys, HintDistFiles -from Item import ItemFactory, ItemInfo, MakeEventItem -from ItemPool import child_trade_items +from HintList import get_required_hints, misc_item_hint_table, misc_location_hint_table +from Hints import HintArea, hint_dist_keys, hint_dist_files +from Item import Item, ItemFactory, ItemInfo, make_event_item from Location import Location, LocationFactory from LocationList import business_scrubs, location_groups -from Plandomizer import InvalidFileException +from Plandomizer import WorldDistribution, InvalidFileException from Region import Region, TimeOfDay from RuleParser import Rule_AST_Transformer +from Settings import Settings from SettingsList import SettingInfos, get_settings_from_section +from Spoiler import Spoiler from State import State -from Utils import read_logic_file - - -class World(object): - def __init__(self, id, settings, resolve_randomized_settings=True): - self.id = id - self.dungeons = [] - self.regions = [] - self.itempool = [] - self._cached_locations = None - self._entrance_cache = {} - self._region_cache = {} - self._location_cache = {} - self.required_locations = [] - self.shop_prices = {} - self.scrub_prices = {} - self.maximum_wallets = 0 - self.hinted_dungeon_reward_locations = {} - self.misc_hint_item_locations = {} - self.misc_hint_location_items = {} - self.triforce_count = 0 - self.total_starting_triforce_count = 0 - - self.parser = Rule_AST_Transformer(self) - self.event_items = set() - - # dump settings directly into world's namespace - # this gives the world an attribute for every setting listed in Settings.py - self.settings = settings - self.distribution = settings.distribution.world_dists[id] +from Utils import data_path, read_logic_file + + +class World: + def __init__(self, world_id: int, settings: Settings, resolve_randomized_settings: bool = True) -> None: + self.id: int = world_id + self.dungeons: List[Dungeon] = [] + self.regions: List[Region] = [] + self.itempool: List[Item] = [] + self._cached_locations: List[Location] = [] + self._entrance_cache: Dict[str, Entrance] = {} + self._region_cache: Dict[str, Region] = {} + self._location_cache: Dict[str, Location] = {} + self.shop_prices: Dict[str, int] = {} + self.scrub_prices: Dict[str, int] = {} + self.maximum_wallets: int = 0 + self.hinted_dungeon_reward_locations: Dict[str, Location] = {} + self.misc_hint_item_locations: Dict[str, Location] = {} + self.misc_hint_location_items: Dict[str, Item] = {} + self.triforce_count: int = 0 + self.total_starting_triforce_count: int = 0 + self.empty_areas: Dict[HintArea, Dict[str, Any]] = {} + self.barren_dungeon: int = 0 + self.woth_dungeon: int = 0 + self.randomized_list: List[str] = [] + + self.parser: Rule_AST_Transformer = Rule_AST_Transformer(self) + self.event_items: Set[str] = set() + self.settings: Settings = settings + self.distribution: WorldDistribution = settings.distribution.world_dists[world_id] # rename a few attributes... - self.keysanity = settings.shuffle_smallkeys in ['keysanity', 'remove', 'any_dungeon', 'overworld', 'regional'] + self.keysanity: bool = settings.shuffle_smallkeys in ['keysanity', 'remove', 'any_dungeon', 'overworld', 'regional'] self.shuffle_silver_rupees = settings.shuffle_silver_rupees != 'vanilla' - self.check_beatable_only = settings.reachable_locations != 'all' + self.check_beatable_only: bool = settings.reachable_locations != 'all' - self.shuffle_special_interior_entrances = settings.shuffle_interior_entrances == 'all' - self.shuffle_interior_entrances = settings.shuffle_interior_entrances in ['simple', 'all'] + self.shuffle_special_interior_entrances: bool = settings.shuffle_interior_entrances == 'all' + self.shuffle_interior_entrances: bool = settings.shuffle_interior_entrances in ['simple', 'all'] - self.shuffle_special_dungeon_entrances = settings.shuffle_dungeon_entrances == 'all' - self.shuffle_dungeon_entrances = settings.shuffle_dungeon_entrances in ['simple', 'all'] + self.shuffle_special_dungeon_entrances: bool = settings.shuffle_dungeon_entrances == 'all' + self.shuffle_dungeon_entrances: bool = settings.shuffle_dungeon_entrances in ['simple', 'all'] - self.entrance_shuffle = ( + self.entrance_shuffle: bool = ( self.shuffle_interior_entrances or settings.shuffle_grotto_entrances or self.shuffle_dungeon_entrances or settings.shuffle_overworld_entrances or settings.shuffle_gerudo_valley_river_exit or settings.owl_drops or settings.warp_songs or settings.spawn_positions or (settings.shuffle_bosses != 'off') @@ -67,25 +71,21 @@ def __init__(self, id, settings, resolve_randomized_settings=True): self.mixed_pools_bosses = False # this setting is still in active development at https://github.com/Roman971/OoT-Randomizer - self.ensure_tod_access = self.shuffle_interior_entrances or settings.shuffle_overworld_entrances or settings.spawn_positions - self.disable_trade_revert = self.shuffle_interior_entrances or settings.shuffle_overworld_entrances or settings.adult_trade_shuffle - self.skip_child_zelda = 'Zeldas Letter' not in settings.shuffle_child_trade and \ - 'Zeldas Letter' in self.distribution.starting_items - self.selected_adult_trade_item = '' - self.adult_trade_starting_inventory = '' - - if ( - settings.open_forest == 'closed' - and ( - self.shuffle_special_interior_entrances or settings.shuffle_hideout_entrances or settings.shuffle_overworld_entrances - or settings.warp_songs or settings.spawn_positions - ) - ): + self.ensure_tod_access: bool = self.shuffle_interior_entrances or settings.shuffle_overworld_entrances or settings.spawn_positions + self.disable_trade_revert: bool = self.shuffle_interior_entrances or settings.shuffle_overworld_entrances or settings.adult_trade_shuffle + self.skip_child_zelda: bool = 'Zeldas Letter' not in settings.shuffle_child_trade and \ + 'Zeldas Letter' in self.distribution.starting_items + self.selected_adult_trade_item: str = '' + self.adult_trade_starting_inventory: str = '' + + if (settings.open_forest == 'closed' + and (self.shuffle_special_interior_entrances or settings.shuffle_hideout_entrances or settings.shuffle_overworld_entrances + or settings.warp_songs or settings.spawn_positions)): self.settings.open_forest = 'closed_deku' if settings.triforce_goal_per_world > settings.triforce_count_per_world: raise ValueError("Triforces required cannot be more than the triforce count.") - self.triforce_goal = settings.triforce_goal_per_world * settings.world_count + self.triforce_goal: int = settings.triforce_goal_per_world * settings.world_count if settings.triforce_hunt: # Pin shuffle_ganon_bosskey to 'triforce' when triforce_hunt is enabled @@ -93,7 +93,7 @@ def __init__(self, id, settings, resolve_randomized_settings=True): self.settings.shuffle_ganon_bosskey = 'triforce' # trials that can be skipped will be decided later - self.skipped_trials = { + self.skipped_trials: Dict[str, bool] = { 'Forest': False, 'Fire': False, 'Water': False, @@ -104,12 +104,11 @@ def __init__(self, id, settings, resolve_randomized_settings=True): # empty dungeons will be decided later class EmptyDungeons(dict): - class EmptyDungeonInfo: - def __init__(self, boss_name): - self.empty = False - self.boss_name = boss_name - self.hint_name = None + def __init__(self, boss_name: Optional[str]) -> None: + self.empty: bool = False + self.boss_name: Optional[str] = boss_name + self.hint_name: Optional[HintArea] = None def __init__(self): super().__init__() @@ -126,13 +125,13 @@ def __init__(self): if area.is_dungeon and area.dungeon_name in self: self[area.dungeon_name].hint_name = area - def __missing__(self, dungeon_name): + def __missing__(self, dungeon_name: str) -> EmptyDungeonInfo: return self.EmptyDungeonInfo(None) - self.empty_dungeons = EmptyDungeons() + self.empty_dungeons: EmptyDungeons[str, EmptyDungeons.EmptyDungeonInfo] = EmptyDungeons() # dungeon forms will be decided later - self.dungeon_mq = { + self.dungeon_mq: Dict[str, bool] = { 'Deku Tree': False, 'Dodongos Cavern': False, 'Jabu Jabus Belly': False, @@ -151,11 +150,11 @@ def __missing__(self, dungeon_name): self.resolve_random_settings() if len(settings.hint_dist_user) == 0: - for d in HintDistFiles(): + for d in hint_dist_files(): with open(d, 'r') as dist_file: dist = json.load(dist_file) if dist['name'] == self.settings.hint_dist: - self.hint_dist_user = dist + self.hint_dist_user: Dict[str, Any] = dist else: self.settings.hint_dist = 'custom' self.hint_dist_user = self.settings.hint_dist_user @@ -187,13 +186,13 @@ def __missing__(self, dungeon_name): shuffled, set its order to 0. Hint type format is \"type\": { \"order\": 0, \"weight\": 0.0, \"fixed\": 0, \"copies\": 0 }""") - self.added_hint_types = {} - self.item_added_hint_types = {} - self.hint_exclusions = set() + self.added_hint_types: Dict[str, List[str]] = {} + self.item_added_hint_types: Dict[str, List[str]] = {} + self.hint_exclusions: Set[str] = set() if self.skip_child_zelda: self.hint_exclusions.add('Song from Impa') - self.hint_type_overrides = {} - self.item_hint_type_overrides = {} + self.hint_type_overrides: Dict[str, List[str]] = {} + self.item_hint_type_overrides: Dict[str, List[str]] = {} for dist in hint_dist_keys: self.added_hint_types[dist] = [] @@ -218,10 +217,9 @@ def __missing__(self, dungeon_name): if settings.empty_dungeons_mode != 'none': for info in self.empty_dungeons.values(): if info.empty: - self.hint_type_overrides['barren'].append(info.hint_name) - + self.hint_type_overrides['barren'].append(str(info.hint_name)) - self.hint_text_overrides = {} + self.hint_text_overrides: Dict[str, str] = {} for loc in self.hint_dist_user['add_locations']: if 'text' in loc: # Arbitrarily throw an error at 80 characters to prevent overfilling the text box. @@ -229,19 +227,19 @@ def __missing__(self, dungeon_name): raise Exception('Custom hint text too large for %s', loc['location']) self.hint_text_overrides.update({loc['location']: loc['text']}) - self.item_hints = self.settings.item_hints + self.item_added_hint_types["named-item"] - self.named_item_pool = list(self.item_hints) + self.item_hints: List[str] = self.settings.item_hints + self.item_added_hint_types["named-item"] + self.named_item_pool: List[str] = list(self.item_hints) - self.always_hints = [hint.name for hint in getRequiredHints(self)] + self.always_hints: List[str] = [hint.name for hint in get_required_hints(self)] - self.dungeon_rewards_hinted = 'altar' in settings.misc_hints or settings.enhance_map_compass - self.misc_hint_items = {hint_type: self.hint_dist_user.get('misc_hint_items', {}).get(hint_type, data['default_item']) for hint_type, data in misc_item_hint_table.items()} - self.misc_hint_locations = {hint_type: self.hint_dist_user.get('misc_hint_locations', {}).get(hint_type, data['item_location']) for hint_type, data in misc_location_hint_table.items()} + self.dungeon_rewards_hinted: bool = 'altar' in settings.misc_hints or settings.enhance_map_compass + self.misc_hint_items: Dict[str, str] = {hint_type: self.hint_dist_user.get('misc_hint_items', {}).get(hint_type, data['default_item']) for hint_type, data in misc_item_hint_table.items()} + self.misc_hint_locations: Dict[str, str] = {hint_type: self.hint_dist_user.get('misc_hint_locations', {}).get(hint_type, data['item_location']) for hint_type, data in misc_location_hint_table.items()} - self.state = State(self) + self.state: State = State(self) # Allows us to cut down on checking whether some items are required - self.max_progressions = {name: item.special.get('progressive', 1) for name, item in ItemInfo.items.items()} + self.max_progressions: Dict[str, int] = {name: item.special.get('progressive', 1) for name, item in ItemInfo.items.items()} max_tokens = 0 if self.settings.bridge == 'tokens': max_tokens = max(max_tokens, self.settings.bridge_tokens) @@ -269,19 +267,19 @@ def __missing__(self, dungeon_name): self.max_progressions['Rutos Letter'] = 2 # Available Gold Skulltula Tokens in world. Set to proper value in ItemPool.py. - self.available_tokens = 100 + self.available_tokens: int = 100 # Disable goal hints if the hint distro does not require them. # WOTH locations are always searched. - self.enable_goal_hints = False + self.enable_goal_hints: bool = False if ('distribution' in self.hint_dist_user and - 'goal' in self.hint_dist_user['distribution'] and - (self.hint_dist_user['distribution']['goal']['fixed'] != 0 or - self.hint_dist_user['distribution']['goal']['weight'] != 0)): + 'goal' in self.hint_dist_user['distribution'] and + (self.hint_dist_user['distribution']['goal']['fixed'] != 0 or + self.hint_dist_user['distribution']['goal']['weight'] != 0)): self.enable_goal_hints = True # Initialize default goals for win condition - self.goal_categories = OrderedDict() + self.goal_categories: Dict[str, GoalCategory] = OrderedDict() if self.hint_dist_user['use_default_goals']: self.set_goals() @@ -304,7 +302,7 @@ def __missing__(self, dungeon_name): # Sort goal hint categories by priority # For most settings this will be Bridge, GBK - self.goal_categories = OrderedDict(sorted(self.goal_categories.items(), key=lambda kv: kv[1].priority)) + self.goal_categories = OrderedDict({name: category for (name, category) in sorted(self.goal_categories.items(), key=lambda kv: kv[1].priority)}) # Turn on one hint per goal if all goal categories contain the same goals. # Reduces the changes of randomly choosing one smaller category over and @@ -330,10 +328,10 @@ def __missing__(self, dungeon_name): self.goal_items.append(item['name']) # Separate goal categories into locked and unlocked for search optimization - self.locked_goal_categories = dict(filter(lambda category: category[1].lock_entrances, self.goal_categories.items())) - self.unlocked_goal_categories = dict(filter(lambda category: not category[1].lock_entrances, self.goal_categories.items())) + self.locked_goal_categories: Dict[str, GoalCategory] = {name: category for (name, category) in self.goal_categories.items() if category.lock_entrances} + self.unlocked_goal_categories: Dict[str, GoalCategory] = {name: category for (name, category) in self.goal_categories.items() if not category.lock_entrances} - def copy(self): + def copy(self) -> 'World': new_world = World(self.id, self.settings, False) new_world.skipped_trials = copy.copy(self.skipped_trials) new_world.dungeon_mq = copy.copy(self.dungeon_mq) @@ -365,8 +363,7 @@ def copy(self): return new_world - - def set_random_bridge_values(self): + def set_random_bridge_values(self) -> None: if self.settings.bridge == 'medallions': self.settings.bridge_medallions = 6 self.randomized_list.append('bridge_medallions') @@ -377,8 +374,7 @@ def set_random_bridge_values(self): self.settings.bridge_stones = 3 self.randomized_list.append('bridge_stones') - - def resolve_random_settings(self): + def resolve_random_settings(self) -> None: # evaluate settings (important for logic, nice for spoiler) self.randomized_list = [] dist_keys = set() @@ -434,18 +430,18 @@ def resolve_random_settings(self): # Determine dungeons with shortcuts dungeons = ['Deku Tree', 'Dodongos Cavern', 'Jabu Jabus Belly', 'Forest Temple', 'Fire Temple', 'Water Temple', 'Shadow Temple', 'Spirit Temple'] - if (self.settings.dungeon_shortcuts_choice == 'random'): + if self.settings.dungeon_shortcuts_choice == 'random': self.settings.dungeon_shortcuts = random.sample(dungeons, random.randint(0, len(dungeons))) self.randomized_list.append('dungeon_shortcuts') - elif (self.settings.dungeon_shortcuts_choice == 'all'): + elif self.settings.dungeon_shortcuts_choice == 'all': self.settings.dungeon_shortcuts = dungeons - # Determine areas with key rings - if (self.settings.key_rings_choice == 'random'): + # Determine area with keyring + if self.settings.key_rings_choice == 'random': areas = ['Thieves Hideout', 'Treasure Chest Game', 'Forest Temple', 'Fire Temple', 'Water Temple', 'Shadow Temple', 'Spirit Temple', 'Bottom of the Well', 'Gerudo Training Ground', 'Ganons Castle'] self.settings.key_rings = random.sample(areas, random.randint(0, len(areas))) self.randomized_list.append('key_rings') - elif (self.settings.key_rings_choice == 'all'): + elif self.settings.key_rings_choice == 'all': self.settings.key_rings = ['Thieves Hideout', 'Treasure Chest Game', 'Forest Temple', 'Fire Temple', 'Water Temple', 'Shadow Temple', 'Spirit Temple', 'Bottom of the Well', 'Gerudo Training Ground', 'Ganons Castle'] # Handle random Rainbow Bridge condition @@ -542,14 +538,12 @@ def resolve_random_settings(self): elif self.settings.silver_rupee_pouches_choice == 'all': self.settings.silver_rupee_pouches = self.silver_rupee_puzzles() - - def load_regions_from_json(self, file_path): + def load_regions_from_json(self, file_path: str) -> "List[Tuple[Entrance, str]]": region_json = read_logic_file(file_path) savewarps_to_connect = [] for region in region_json: - new_region = Region(region['region_name']) - new_region.world = self + new_region = Region(self, region['region_name']) if 'scene' in region: new_region.scene = region['scene'] if 'hint' in region: @@ -581,7 +575,7 @@ def load_regions_from_json(self, file_path): for event, rule in region['events'].items(): # Allow duplicate placement of events lname = '%s from %s' % (event, new_region.name) - new_location = Location(lname, type='Event', parent=new_region) + new_location = Location(lname, location_type='Event', parent=new_region) new_location.rule_string = rule if self.settings.logic_rules != 'none': self.parser.parse_spot_rule(new_location) @@ -590,7 +584,7 @@ def load_regions_from_json(self, file_path): else: new_location.world = self new_region.locations.append(new_location) - MakeEventItem(event, new_location) + make_event_item(event, new_location) if 'exits' in region: for exit, rule in region['exits'].items(): new_exit = Entrance('%s -> %s' % (new_region.name, exit), new_region) @@ -610,45 +604,51 @@ def load_regions_from_json(self, file_path): self.regions.append(new_region) return savewarps_to_connect + def create_dungeons(self) -> "List[Tuple[Entrance, str]]": + savewarps_to_connect = [] + for hint_area in HintArea: + if hint_area.is_dungeon: + name = hint_area.dungeon_name + logic_folder = 'Glitched World' if self.settings.logic_rules == 'glitched' else 'World' + file_name = name + (' MQ.json' if self.dungeon_mq[name] else '.json') + savewarps_to_connect += self.load_regions_from_json(os.path.join(data_path(logic_folder), file_name)) + self.dungeons.append(Dungeon(self, name, hint_area)) + return savewarps_to_connect - def create_internal_locations(self): + def create_internal_locations(self) -> None: self.parser.create_delayed_rules() assert self.parser.events <= self.event_items, 'Parse error: undefined items %r' % (self.parser.events - self.event_items) - - def initialize_entrances(self): + def initialize_entrances(self) -> None: for region in self.regions: for exit in region.exits: exit.connect(self.get_region(exit.connected_region)) exit.world = self - - def initialize_regions(self): + def initialize_regions(self) -> None: for region in self.regions: region.world = self for location in region.locations: location.world = self - - def initialize_items(self): + def initialize_items(self) -> None: for item in self.itempool: item.world = self if (self.settings.shuffle_hideoutkeys in ['fortress', 'regional'] and item.type == 'HideoutSmallKey') or (self.settings.shuffle_tcgkeys == 'regional' and item.type == 'TCGSmallKey'): item.priority = True for region in self.regions: for location in region.locations: - if location.item != None: + if location.item is not None: location.item.world = self for item in [item for dungeon in self.dungeons for item in dungeon.all_items]: item.world = self - - def random_shop_prices(self): + def random_shop_prices(self) -> None: shop_item_indexes = ['7', '5', '8', '6'] self.shop_prices = {} for region in self.regions: if self.settings.shopsanity == 'random': - shop_item_count = random.randint(0,4) + shop_item_count = random.randint(0, 4) else: shop_item_count = int(self.settings.shopsanity) @@ -658,18 +658,17 @@ def random_shop_prices(self): if self.settings.shopsanity_prices == 'random': self.shop_prices[location.name] = int(random.betavariate(1.5, 2) * 60) * 5 elif self.settings.shopsanity_prices == 'random_starting': - self.shop_prices[location.name] = random.randrange(0,100,5) + self.shop_prices[location.name] = random.randrange(0, 100, 5) elif self.settings.shopsanity_prices == 'random_adult': - self.shop_prices[location.name] = random.randrange(0,201,5) + self.shop_prices[location.name] = random.randrange(0, 201, 5) elif self.settings.shopsanity_prices == 'random_giant': - self.shop_prices[location.name] = random.randrange(0,501,5) + self.shop_prices[location.name] = random.randrange(0, 501, 5) elif self.settings.shopsanity_prices == 'random_tycoon': - self.shop_prices[location.name] = random.randrange(0,1000,5) + self.shop_prices[location.name] = random.randrange(0, 1000, 5) elif self.settings.shopsanity_prices == 'affordable': self.shop_prices[location.name] = 10 - - def set_scrub_prices(self): + def set_scrub_prices(self) -> None: # Get Deku Scrub Locations scrub_locations = [location for location in self.get_locations() if location.type in ['Scrub', 'GrottoScrub']] scrub_dictionary = {} @@ -696,7 +695,6 @@ def set_scrub_prices(self): if location.item is not None: location.item.price = price - rewardlist = ( 'Kokiri Emerald', 'Goron Ruby', @@ -708,7 +706,8 @@ def set_scrub_prices(self): 'Shadow Medallion', 'Light Medallion' ) - def fill_bosses(self, bossCount=9): + + def fill_bosses(self, boss_count: int = 9) -> None: boss_rewards = ItemFactory(self.rewardlist, self) boss_locations = [self.get_location(loc) for loc in location_groups['Boss']] @@ -718,17 +717,17 @@ def fill_bosses(self, bossCount=9): prizepool = list(unplaced_prizes) prize_locs = list(empty_boss_locations) - bossCount -= self.distribution.fill_bosses(self, prize_locs, prizepool) + boss_count -= self.distribution.fill_bosses(self, prize_locs, prizepool) - while bossCount: - bossCount -= 1 + while boss_count: + boss_count -= 1 random.shuffle(prizepool) random.shuffle(prize_locs) item = prizepool.pop() loc = prize_locs.pop() self.push_item(loc, item) - def set_goals(self): + def set_goals(self) -> None: # Default goals are divided into 3 primary categories: # Bridge, Ganon's Boss Key, and Trials # The Triforce Hunt goal is mutually exclusive with @@ -799,7 +798,7 @@ def set_goals(self): # dungeon boss holding the specified reward. Only boss names/paths # are defined for this feature, and it is not extendable via plando. # Goal hint text colors are based on the dungeon reward, not the boss. - if ((self.settings.bridge_stones > 0 and self.settings.bridge == 'stones') or (self.settings.bridge_rewards > 0 and self.settings.bridge == 'dungeons')): + if (self.settings.bridge_stones > 0 and self.settings.bridge == 'stones') or (self.settings.bridge_rewards > 0 and self.settings.bridge == 'dungeons'): b.add_goal(Goal(self, 'Kokiri Emerald', { 'replace': 'Kokiri Emerald' }, 'Light Blue', items=[{'name': 'Kokiri Emerald', 'quantity': 1, 'minimum': 1, 'hintable': True}])) b.add_goal(Goal(self, 'Goron Ruby', { 'replace': 'Goron Ruby' }, 'Light Blue', items=[{'name': 'Goron Ruby', 'quantity': 1, 'minimum': 1, 'hintable': True}])) b.add_goal(Goal(self, 'Zora Sapphire', { 'replace': 'Zora Sapphire' }, 'Light Blue', items=[{'name': 'Zora Sapphire', 'quantity': 1, 'minimum': 1, 'hintable': True}])) @@ -975,22 +974,22 @@ def set_goals(self): # To avoid too many goals in the hint selection phase, # trials are reduced to one goal with six items to obtain. - if self.skipped_trials['Forest'] == False: + if not self.skipped_trials['Forest']: trial_goal.items.append({'name': 'Forest Trial Clear', 'quantity': 1, 'minimum': 1, 'hintable': True}) trials.goal_count += 1 - if self.skipped_trials['Fire'] == False: + if not self.skipped_trials['Fire']: trial_goal.items.append({'name': 'Fire Trial Clear', 'quantity': 1, 'minimum': 1, 'hintable': True}) trials.goal_count += 1 - if self.skipped_trials['Water'] == False: + if not self.skipped_trials['Water']: trial_goal.items.append({'name': 'Water Trial Clear', 'quantity': 1, 'minimum': 1, 'hintable': True}) trials.goal_count += 1 - if self.skipped_trials['Shadow'] == False: + if not self.skipped_trials['Shadow']: trial_goal.items.append({'name': 'Shadow Trial Clear', 'quantity': 1, 'minimum': 1, 'hintable': True}) trials.goal_count += 1 - if self.skipped_trials['Spirit'] == False: + if not self.skipped_trials['Spirit']: trial_goal.items.append({'name': 'Spirit Trial Clear', 'quantity': 1, 'minimum': 1, 'hintable': True}) trials.goal_count += 1 - if self.skipped_trials['Light'] == False: + if not self.skipped_trials['Light']: trial_goal.items.append({'name': 'Light Trial Clear', 'quantity': 1, 'minimum': 1, 'hintable': True}) trials.goal_count += 1 @@ -1009,20 +1008,19 @@ def set_goals(self): g.minimum_goals = 1 self.goal_categories[g.name] = g - def get_region(self, regionname): - if isinstance(regionname, Region): - return regionname + def get_region(self, region_name: Union[str, Region]) -> Region: + if isinstance(region_name, Region): + return region_name try: - return self._region_cache[regionname] + return self._region_cache[region_name] except KeyError: for region in self.regions: - if region.name == regionname: - self._region_cache[regionname] = region + if region.name == region_name: + self._region_cache[region_name] = region return region - raise KeyError('No such region %s' % regionname) + raise KeyError('No such region %s' % region_name) - - def get_entrance(self, entrance): + def get_entrance(self, entrance: Union[str, Entrance]) -> Entrance: if isinstance(entrance, Entrance): return entrance try: @@ -1035,8 +1033,7 @@ def get_entrance(self, entrance): return exit raise KeyError('No such entrance %s' % entrance) - - def get_location(self, location): + def get_location(self, location: Union[str, Location]) -> Location: if isinstance(location, Location): return location try: @@ -1049,17 +1046,14 @@ def get_location(self, location): return r_location raise KeyError('No such location %s' % location) - - def get_items(self): + def get_items(self) -> List[Item]: return [loc.item for loc in self.get_filled_locations()] + self.itempool - - def get_itempool_with_dungeon_items(self): + def get_itempool_with_dungeon_items(self) -> List[Item]: return self.get_restricted_dungeon_items() + self.itempool - # get a list of items that should stay in their proper dungeon - def get_restricted_dungeon_items(self): + def get_restricted_dungeon_items(self) -> List[Item]: itempool = [] if self.settings.shuffle_mapcompass == 'dungeon': @@ -1089,9 +1083,8 @@ def get_restricted_dungeon_items(self): item.world = self return itempool - # get a list of items that don't have to be in their proper dungeon - def get_unrestricted_dungeon_items(self): + def get_unrestricted_dungeon_items(self) -> List[Item]: itempool = [] if self.settings.shuffle_mapcompass in ['any_dungeon', 'overworld', 'keysanity', 'regional']: itempool.extend([item for dungeon in self.dungeons if not self.empty_dungeons[dungeon.name].empty for item in dungeon.dungeon_items]) @@ -1123,11 +1116,10 @@ def silver_rupee_puzzles(self): ) - def find_items(self, item): + def find_items(self, item: str) -> List[Location]: return [location for location in self.get_locations() if location.item is not None and location.item.name == item] - - def push_item(self, location, item, manual=False): + def push_item(self, location: Union[str, Location], item: Item, manual: bool = False) -> None: if not isinstance(location, Location): location = self.get_location(location) @@ -1142,48 +1134,41 @@ def push_item(self, location, item, manual=False): else: raise RuntimeError('Cannot assign item %s to location %s.' % (item, location)) - - def get_locations(self): - if self._cached_locations is None: - self._cached_locations = [] + def get_locations(self) -> List[Location]: + if not self._cached_locations: for region in self.regions: self._cached_locations.extend(region.locations) return self._cached_locations - - def get_unfilled_locations(self): + def get_unfilled_locations(self) -> Iterable[Location]: return filter(Location.has_no_item, self.get_locations()) - - def get_filled_locations(self): + def get_filled_locations(self) -> Iterable[Location]: return filter(Location.has_item, self.get_locations()) - - def get_progression_locations(self): + def get_progression_locations(self) -> Iterable[Location]: return filter(Location.has_progression_item, self.get_locations()) - - def get_entrances(self): + def get_entrances(self) -> List[Entrance]: return [exit for region in self.regions for exit in region.exits] + def get_shufflable_entrances(self, type=None, only_primary=False) -> List[Entrance]: + return [entrance for entrance in self.get_entrances() if (type is None or entrance.type == type) and (not only_primary or entrance.primary)] - def get_shufflable_entrances(self, type=None, only_primary=False): - return [entrance for entrance in self.get_entrances() if (type == None or entrance.type == type) and (not only_primary or entrance.primary)] - - - def get_shuffled_entrances(self, type=None, only_primary=False): + def get_shuffled_entrances(self, type=None, only_primary=False) -> List[Entrance]: return [entrance for entrance in self.get_shufflable_entrances(type=type, only_primary=only_primary) if entrance.shuffled] - - def region_has_shortcuts(self, region_name): + def region_has_shortcuts(self, region_name: str) -> bool: region = self.get_region(region_name) dungeon_name = HintArea.at(region).dungeon_name return dungeon_name in self.settings.dungeon_shortcuts - - def has_beaten_game(self, state): - return state.has('Triforce') - + # Function to run exactly once after after placing items in drop locations for each world + # Sets all Drop locations to a unique name in order to avoid name issues and to identify locations in the spoiler + def set_drop_location_names(self): + for location in self.get_locations(): + if location.type == 'Drop': + location.name = location.parent_region.name + " " + location.name # Useless areas are areas that have contain no items that could ever # be used to complete the seed. Unfortunately this is very difficult @@ -1193,8 +1178,8 @@ def has_beaten_game(self, state): # We further cull this list with woth info. This is an overestimate of # the true list of possible useless areas, but this will generate a # reasonably sized list of areas that fit this property. - def update_useless_areas(self, spoiler): - areas = {} + def update_useless_areas(self, spoiler: Spoiler) -> None: + areas: Dict[HintArea, Dict[str, Any]] = {} # Link's Pocket and None are not real areas excluded_areas = [None, HintArea.ROOT] for location in self.get_locations(): @@ -1221,7 +1206,7 @@ def update_useless_areas(self, spoiler): areas[area]['locations'].append(location) # Generate area list meta data - for area,area_info in areas.items(): + for area, area_info in areas.items(): # whether an area is a dungeon is calculated to prevent too many # dungeon barren hints since they are quite powerful. The area # names don't quite match the internal dungeon names so we need to @@ -1240,6 +1225,7 @@ def update_useless_areas(self, spoiler): exclude_item_list = [ 'Double Defense', ] + if (self.settings.damage_multiplier != 'ohko' and self.settings.damage_multiplier != 'quadruple' and self.settings.shuffle_scrubs == 'off' and not self.settings.shuffle_grotto_entrances): # nayru's love may be required to prevent forced damage @@ -1334,7 +1320,7 @@ def update_useless_areas(self, spoiler): # generate the empty area list self.empty_areas = {} - for area,area_info in areas.items(): + for area, area_info in areas.items(): useless_area = True for location in area_info['locations']: world_id = location.item.world.id diff --git a/crc.py b/crc.py index fa77a66da..6ff2c16eb 100644 --- a/crc.py +++ b/crc.py @@ -1,4 +1,5 @@ import itertools + from ntype import uint32, BigStream diff --git a/ntype.py b/ntype.py index 1639d096f..3eeaee24c 100644 --- a/ntype.py +++ b/ntype.py @@ -10,9 +10,9 @@ class uint16: def write(buffer: bytearray, address: int, value: int) -> None: struct.pack_into('>H', buffer, address, value) - @staticmethod - def read(buffer: bytearray, address: int = 0) -> int: - return uint16._struct.unpack_from(buffer, address)[0] + @classmethod + def read(cls, buffer: bytearray, address: int = 0) -> int: + return cls._struct.unpack_from(buffer, address)[0] @staticmethod def bytes(value: int) -> bytearray: @@ -31,9 +31,9 @@ class uint32: def write(buffer: bytearray, address: int, value: int) -> None: struct.pack_into('>I', buffer, address, value) - @staticmethod - def read(buffer: bytearray, address: int = 0) -> int: - return uint32._struct.unpack_from(buffer, address)[0] + @classmethod + def read(cls, buffer: bytearray, address: int = 0) -> int: + return cls._struct.unpack_from(buffer, address)[0] @staticmethod def bytes(value: int) -> bytearray: @@ -52,9 +52,9 @@ class int32: def write(buffer: bytearray, address: int, value: int) -> None: struct.pack_into('>i', buffer, address, value) - @staticmethod - def read(buffer: bytearray, address: int = 0) -> int: - return int32._struct.unpack_from(buffer, address)[0] + @classmethod + def read(cls, buffer: bytearray, address: int = 0) -> int: + return cls._struct.unpack_from(buffer, address)[0] @staticmethod def bytes(value: int) -> bytearray: diff --git a/texture_util.py b/texture_util.py index 07166b683..7b28d53a3 100755 --- a/texture_util.py +++ b/texture_util.py @@ -1,30 +1,32 @@ #!/usr/bin/env python3 +from typing import List, Tuple from Rom import Rom -from Utils import * + # Read a ci4 texture from rom and convert to rgba16 # rom - Rom # address - address of the ci4 texture in Rom # length - size of the texture in PIXELS # palette - 4-bit color palette to use (max of 16 colors) -def ci4_to_rgba16(rom: Rom, address, length, palette): - newPixels = [] +def ci4_to_rgba16(rom: Rom, address: int, length: int, palette: List[int]) -> List[int]: + new_pixels = [] texture = rom.read_bytes(address, length // 2) for byte in texture: - newPixels.append(palette[(byte & 0xF0) >> 4]) - newPixels.append(palette[byte & 0x0F]) - return newPixels + new_pixels.append(palette[(byte & 0xF0) >> 4]) + new_pixels.append(palette[byte & 0x0F]) + return new_pixels + # Convert an rgba16 texture to ci8 # rgba16_texture - texture to convert # returns - tuple (ci8_texture, palette) -def rgba16_to_ci8(rgba16_texture): +def rgba16_to_ci8(rgba16_texture: List[int]) -> Tuple[List[int], List[int]]: ci8_texture = [] - palette = get_colors_from_rgba16(rgba16_texture) # Get all of the colors in the texture - if len(palette) > 0x100: # Make sure there are <= 256 colors. Could probably do some fancy stuff to convert, but nah. + palette = get_colors_from_rgba16(rgba16_texture) # Get all the colors in the texture + if len(palette) > 0x100: # Make sure there are <= 256 colors. Could probably do some fancy stuff to convert, but nah. raise(Exception("RGB Texture exceeds maximum of 256 colors")) - if len(palette) < 0x100: #Pad the palette with 0x0001 #Pad the palette with 0001s to take up the full 256 colors + if len(palette) < 0x100: # Pad the palette with 0x0001 #Pad the palette with 0001s to take up the full 256 colors for i in range(0, 0x100 - len(palette)): palette.append(0x0001) @@ -32,28 +34,31 @@ def rgba16_to_ci8(rgba16_texture): for pixel in rgba16_texture: if pixel in palette: ci8_texture.append(palette.index(pixel)) - return (ci8_texture, palette) + return ci8_texture, palette + # Load a palette (essentially just an rgba16 texture) from rom -def load_palette(rom: Rom, address, length): +def load_palette(rom: Rom, address: int, length: int) -> List[int]: palette = [] for i in range(0, length): palette.append(rom.read_int16(address + 2 * i)) return palette + # Get a list of unique colors (palette) from an rgba16 texture -def get_colors_from_rgba16(rgba16_texture): +def get_colors_from_rgba16(rgba16_texture: List[int]) -> List[int]: colors = [] for pixel in rgba16_texture: if pixel not in colors: colors.append(pixel) return colors + # Apply a patch to a rgba16 texture. The patch texture is exclusive or'd with the original to produce the result # rgba16_texture - Original texture # rgba16_patch - Patch texture. If this parameter is not supplied, this function will simply return the original texture. # returns - new texture = texture xor patch -def apply_rgba16_patch(rgba16_texture, rgba16_patch): +def apply_rgba16_patch(rgba16_texture: List[int], rgba16_patch: List[int]) -> List[int]: if rgba16_patch is not None and (len(rgba16_texture) != len(rgba16_patch)): raise(Exception("OG Texture and Patch not the same length!")) @@ -66,46 +71,51 @@ def apply_rgba16_patch(rgba16_texture, rgba16_patch): new_texture.append(rgba16_texture[i] ^ rgba16_patch[i]) return new_texture + # Save a rgba16 texture to a file -def save_rgba16_texture(rgba16_texture, fileStr): - file = open(fileStr, 'wb') +def save_rgba16_texture(rgba16_texture: List[int], filename: str) -> None: + file = open(filename, 'wb') bytes = bytearray() for pixel in rgba16_texture: bytes.extend(pixel.to_bytes(2, 'big')) file.write(bytes) file.close() + # Save a ci8 texture to a file -def save_ci8_texture(ci8_texture, fileStr): - file = open(fileStr, 'wb') +def save_ci8_texture(ci8_texture: List[int], filename: str) -> None: + file = open(filename, 'wb') bytes = bytearray() for pixel in ci8_texture: bytes.extend(pixel.to_bytes(1, 'big')) file.write(bytes) file.close() + # Read an rgba16 texture from ROM # rom - Rom object to load the texture from # base_texture_address - Address of the rbga16 texture in ROM # size - Size of the texture in PIXELS # returns - list of ints representing each 16-bit pixel -def load_rgba16_texture_from_rom(rom: Rom, base_texture_address, size): +def load_rgba16_texture_from_rom(rom: Rom, base_texture_address: int, size: int) -> List[int]: texture = [] for i in range(0, size): texture.append(int.from_bytes(rom.read_bytes(base_texture_address + 2 * i, 2), 'big')) return texture + # Load an rgba16 texture from a binary file. -# fileStr - path to the file +# filename - path to the file # size - number of 16-bit pixels in the texture. -def load_rgba16_texture(fileStr, size): +def load_rgba16_texture(filename: str, size: int) -> List[int]: texture = [] - file = open(fileStr, 'rb') + file = open(filename, 'rb') for i in range(0, size): texture.append(int.from_bytes(file.read(2), 'big')) file.close() - return(texture) + return texture + # Create an new rgba16 texture byte array from a rgba16 binary file. Use this if you want to create complete new textures using no copyrighted content (or for testing). # rom - Unused set to None @@ -114,13 +124,14 @@ def load_rgba16_texture(fileStr, size): # size - Size of the texture in PIXELS # patchfile - File containing the texture to load # returns - bytearray containing the new texture -def rgba16_from_file(rom: Rom, base_texture_address, base_palette_address, size, patchfile): +def rgba16_from_file(rom: Rom, base_texture_address: int, base_palette_address: int, size: int, patchfile: str) -> bytearray: new_texture = load_rgba16_texture(patchfile, size) bytes = bytearray() for pixel in new_texture: bytes.extend(int.to_bytes(pixel, 2, 'big')) return bytes + # Create a new rgba16 texture from a original rgba16 texture and a rgba16 patch file # rom - Rom object to load the original texture from # base_texture_address - Address of the original rbga16 texture in ROM @@ -128,7 +139,7 @@ def rgba16_from_file(rom: Rom, base_texture_address, base_palette_address, size, # size - Size of the texture in PIXELS # patchfile - file path of a rgba16 binary texture to patch # returns - bytearray of the new texture -def rgba16_patch(rom: Rom, base_texture_address, base_palette_address, size, patchfile): +def rgba16_patch(rom: Rom, base_texture_address: int, base_palette_address: int, size: int, patchfile: str) -> bytearray: base_texture_rgba16 = load_rgba16_texture_from_rom(rom, base_texture_address, size) patch_rgba16 = None if patchfile: @@ -139,6 +150,7 @@ def rgba16_patch(rom: Rom, base_texture_address, base_palette_address, size, pat bytes.extend(int.to_bytes(pixel, 2, 'big')) return bytes + # Create a new ci8 texture from a ci4 texture/palette and a rgba16 patch file # rom - Rom object to load the original textures from # base_texture_address - Address of the original ci4 texture in ROM @@ -146,7 +158,7 @@ def rgba16_patch(rom: Rom, base_texture_address, base_palette_address, size, pat # size - Size of the texture in PIXELS # patchfile - file path of a rgba16 binary texture to patch # returns - bytearray of the new texture -def ci4_rgba16patch_to_ci8(rom, base_texture_address, base_palette_address, size, patchfile): +def ci4_rgba16patch_to_ci8(rom: Rom, base_texture_address: int, base_palette_address: int, size: int, patchfile: str) -> bytearray: palette = load_palette(rom, base_palette_address, 16) # load the original palette from rom base_texture_rgba16 = ci4_to_rgba16(rom, base_texture_address, size, palette) # load the original texture from rom and convert to ci8 patch_rgba16 = None @@ -162,8 +174,9 @@ def ci4_rgba16patch_to_ci8(rom, base_texture_address, base_palette_address, size bytes.extend(int.to_bytes(pixel, 1, 'big')) return bytes + # Function to create rgba16 texture patches for crates -def build_crate_ci8_patches(): +def build_crate_ci8_patches() -> None: # load crate textures from rom object_kibako2_addr = 0x018B6000 SIZE_CI4_32X128 = 4096 @@ -181,7 +194,7 @@ def build_crate_ci8_patches(): save_rgba16_texture(gold_patch, 'crate_heart_rgba16_patch.bin') # Function to create rgba16 texture patches for pots. -def build_pot_patches(): +def build_pot_patches() -> None: # load pot textures from rom object_tsubo_side_addr = 0x01738000 SIZE_32X64 = 2048 @@ -205,7 +218,8 @@ def build_pot_patches(): save_rgba16_texture(skull_patch, 'pot_skull_rgba16_patch.bin') save_rgba16_texture(bosskey_patch, 'pot_bosskey_rgba16_patch.bin') -def build_smallcrate_patches(): + +def build_smallcrate_patches() -> None: # load small crate texture from rom object_kibako_texture_addr = 0xF7ECA0