Exemple #1
0
    def __init__(self):
        super(WWRandomizerWindow, self).__init__()
        self.ui = Ui_MainWindow()
        self.ui.setupUi(self)

        self.preserve_default_settings()

        self.load_settings()

        self.ui.clean_iso_path.editingFinished.connect(self.update_settings)
        self.ui.output_folder.editingFinished.connect(self.update_settings)
        self.ui.seed.editingFinished.connect(self.update_settings)
        self.ui.clean_iso_path_browse_button.clicked.connect(
            self.browse_for_clean_iso)
        self.ui.output_folder_browse_button.clicked.connect(
            self.browse_for_output_folder)
        self.ui.permalink.textEdited.connect(self.permalink_modified)

        for option_name in OPTIONS:
            widget = getattr(self.ui, option_name)
            if isinstance(widget, QAbstractButton):
                widget.clicked.connect(self.update_settings)
            elif isinstance(widget, QComboBox):
                widget.currentIndexChanged.connect(self.update_settings)
            else:
                raise Exception("Option widget is invalid: %s" % option_name)

        self.ui.generate_seed_button.clicked.connect(self.generate_seed)

        self.ui.randomize_button.clicked.connect(self.randomize)
        self.ui.reset_settings_to_default.clicked.connect(
            self.reset_settings_to_default)
        self.ui.about_button.clicked.connect(self.open_about)

        for option_name in OPTIONS:
            getattr(self.ui, option_name).installEventFilter(self)
            label_for_option = getattr(self.ui, "label_for_" + option_name,
                                       None)
            if label_for_option:
                label_for_option.installEventFilter(self)
        self.set_option_description(None)

        self.update_settings()

        self.setWindowTitle("Wind Waker Randomizer %s" % VERSION)

        icon_path = os.path.join(ASSETS_PATH, "icon.ico")
        self.setWindowIcon(QIcon(icon_path))

        self.show()

        self.update_checker_thread = UpdateCheckerThread()
        self.update_checker_thread.finished_checking_for_updates.connect(
            self.show_update_check_results)
        self.update_checker_thread.start()
Exemple #2
0
class WWRandomizerWindow(QMainWindow):
    VALID_SEED_CHARACTERS = "-_'%%.%s%s" % (string.ascii_letters,
                                            string.digits)

    def __init__(self):
        super(WWRandomizerWindow, self).__init__()
        self.ui = Ui_MainWindow()
        self.ui.setupUi(self)

        self.preserve_default_settings()

        self.load_settings()

        self.ui.clean_iso_path.editingFinished.connect(self.update_settings)
        self.ui.output_folder.editingFinished.connect(self.update_settings)
        self.ui.seed.editingFinished.connect(self.update_settings)
        self.ui.clean_iso_path_browse_button.clicked.connect(
            self.browse_for_clean_iso)
        self.ui.output_folder_browse_button.clicked.connect(
            self.browse_for_output_folder)
        self.ui.permalink.textEdited.connect(self.permalink_modified)

        for option_name in OPTIONS:
            widget = getattr(self.ui, option_name)
            if isinstance(widget, QAbstractButton):
                widget.clicked.connect(self.update_settings)
            elif isinstance(widget, QComboBox):
                widget.currentIndexChanged.connect(self.update_settings)
            else:
                raise Exception("Option widget is invalid: %s" % option_name)

        self.ui.generate_seed_button.clicked.connect(self.generate_seed)

        self.ui.randomize_button.clicked.connect(self.randomize)
        self.ui.reset_settings_to_default.clicked.connect(
            self.reset_settings_to_default)
        self.ui.about_button.clicked.connect(self.open_about)

        for option_name in OPTIONS:
            getattr(self.ui, option_name).installEventFilter(self)
            label_for_option = getattr(self.ui, "label_for_" + option_name,
                                       None)
            if label_for_option:
                label_for_option.installEventFilter(self)
        self.set_option_description(None)

        self.update_settings()

        self.setWindowTitle("Wind Waker Randomizer %s" % VERSION)

        icon_path = os.path.join(ASSETS_PATH, "icon.ico")
        self.setWindowIcon(QIcon(icon_path))

        self.show()

        self.update_checker_thread = UpdateCheckerThread()
        self.update_checker_thread.finished_checking_for_updates.connect(
            self.show_update_check_results)
        self.update_checker_thread.start()

    def generate_seed(self):
        random.seed(None)

        with open(os.path.join(SEEDGEN_PATH, "adjectives.txt")) as f:
            adjectives = random.sample(f.read().splitlines(), 2)
        noun_file_to_use = random.choice(["nouns.txt", "names.txt"])
        with open(os.path.join(SEEDGEN_PATH, noun_file_to_use)) as f:
            noun = random.choice(f.read().splitlines())
        words = adjectives + [noun]
        capitalized_words = []
        for word in words:
            capitalized_word = ""
            seen_first_letter = False
            for char in word:
                if char in string.ascii_letters and not seen_first_letter:
                    capitalized_word += char.capitalize()
                    seen_first_letter = True
                else:
                    capitalized_word += char
            capitalized_words.append(capitalized_word)
        seed = "".join(capitalized_words)

        seed = self.sanitize_seed(seed)

        self.settings["seed"] = seed
        self.ui.seed.setText(seed)
        self.update_settings()

    def sanitize_seed(self, seed):
        seed = str(seed)
        seed = seed.strip()
        seed = "".join(char for char in seed
                       if char in self.VALID_SEED_CHARACTERS)
        return seed

    def randomize(self):
        clean_iso_path = self.settings["clean_iso_path"].strip()
        output_folder = self.settings["output_folder"].strip()
        self.settings["clean_iso_path"] = clean_iso_path
        self.settings["output_folder"] = output_folder
        self.ui.clean_iso_path.setText(clean_iso_path)
        self.ui.output_folder.setText(output_folder)

        if not os.path.isfile(clean_iso_path):
            QMessageBox.warning(
                self, "Clean ISO path not specified",
                "Must specify path to clean your Wind Waker ISO (USA).")
            return
        if not os.path.isdir(output_folder):
            QMessageBox.warning(
                self, "No output folder specified",
                "Must specify a valid output folder for the randomized files.")
            return

        seed = self.settings["seed"]
        seed = self.sanitize_seed(seed)

        if not seed:
            self.generate_seed()
            seed = self.settings["seed"]

        self.settings["seed"] = seed
        self.ui.seed.setText(seed)
        self.update_settings()

        options = OrderedDict()
        for option_name in OPTIONS:
            options[option_name] = self.get_option_value(option_name)

        max_progress_val = 20
        self.progress_dialog = RandomizerProgressDialog(
            "Randomizing", "Initializing...", max_progress_val)

        try:
            rando = Randomizer(seed, clean_iso_path, output_folder, options)
        except TooFewProgressionLocationsError as e:
            error_message = str(e)
            self.randomization_failed(error_message)
            return
        except Exception as e:
            stack_trace = traceback.format_exc()
            error_message = "Randomization failed with error:\n" + str(
                e) + "\n\n" + stack_trace
            self.randomization_failed(error_message)
            return

        self.randomizer_thread = RandomizerThread(rando)
        self.randomizer_thread.update_progress.connect(
            self.update_progress_dialog)
        self.randomizer_thread.randomization_complete.connect(
            self.randomization_complete)
        self.randomizer_thread.randomization_failed.connect(
            self.randomization_failed)
        self.randomizer_thread.start()

    def update_progress_dialog(self, next_option_description,
                               options_finished):
        self.progress_dialog.setLabelText(next_option_description)
        self.progress_dialog.setValue(options_finished)

    def randomization_complete(self):
        self.progress_dialog.reset()

        msg = "Randomization complete.\n\n"
        msg += "If you get stuck, check the progression spoiler log in the output folder."
        QMessageBox.information(self, "Done", msg)

    def randomization_failed(self, error_message):
        self.progress_dialog.reset()

        try:
            self.randomizer_thread.randomizer.write_error_log(error_message)
        except:
            # If an error happened when writing the error log just ignore it.
            pass

        print(error_message)
        QMessageBox.critical(self, "Randomization Failed", error_message)

    def show_update_check_results(self, new_version):
        if not new_version:
            self.ui.update_checker_label.setText(
                "No new updates to the randomizer are available.")
        elif new_version == "error":
            self.ui.update_checker_label.setText(
                "There was an error checking for updates.")
        else:
            new_text = "<b>Version %s of the randomizer is available!</b>" % new_version
            new_text += " <a href=\"%s\">Click here</a> to go to the download page." % LATEST_RELEASE_DOWNLOAD_PAGE_URL
            self.ui.update_checker_label.setText(new_text)

    def preserve_default_settings(self):
        self.default_settings = OrderedDict()
        for option_name in OPTIONS:
            self.default_settings[option_name] = self.get_option_value(
                option_name)

    def reset_settings_to_default(self):
        any_setting_changed = False
        for option_name in OPTIONS:
            if option_name in self.default_settings:
                default_value = self.default_settings[option_name]
                current_value = self.get_option_value(option_name)
                if default_value != current_value:
                    any_setting_changed = True
                self.set_option_value(option_name, default_value)

        self.update_settings()

        if not any_setting_changed:
            QMessageBox.information(
                self, "Settings already default",
                "You already have all the default randomization settings.")

    def load_settings(self):
        self.settings_path = "settings.txt"
        if os.path.isfile(self.settings_path):
            with open(self.settings_path) as f:
                self.settings = yaml.safe_load(f)
            if self.settings is None:
                self.settings = OrderedDict()
        else:
            self.settings = OrderedDict()

        if "clean_iso_path" in self.settings:
            self.ui.clean_iso_path.setText(self.settings["clean_iso_path"])
        if "output_folder" in self.settings:
            self.ui.output_folder.setText(self.settings["output_folder"])
        if "seed" in self.settings:
            self.ui.seed.setText(self.settings["seed"])

        for option_name in OPTIONS:
            if option_name in self.settings:
                self.set_option_value(option_name, self.settings[option_name])

    def save_settings(self):
        with open(self.settings_path, "w") as f:
            yaml.dump(self.settings,
                      f,
                      default_flow_style=False,
                      Dumper=yaml.Dumper)

    def update_settings(self):
        self.settings["clean_iso_path"] = self.ui.clean_iso_path.text()
        self.settings["output_folder"] = self.ui.output_folder.text()
        self.settings["seed"] = self.ui.seed.text()

        for option_name in OPTIONS:
            self.settings[option_name] = self.get_option_value(option_name)

        self.save_settings()

        permalink = self.encode_permalink()

    def permalink_modified(self):
        permalink = self.ui.permalink.text()
        try:
            self.decode_permalink(permalink)
        except Exception as e:
            stack_trace = traceback.format_exc()
            error_message = "Failed to parse permalink:\n" + str(
                e) + "\n\n" + stack_trace
            print(error_message)
            QMessageBox.critical(self, "Invalid permalink",
                                 "The permalink you pasted is invalid.")

        self.encode_permalink()

    def encode_permalink(self):
        seed = self.settings["seed"]
        seed = self.sanitize_seed(seed)
        if not seed:
            self.ui.permalink.setText("")
            return

        permalink = b""
        permalink += VERSION.encode("ascii")
        permalink += b"\0"
        permalink += seed.encode("ascii")
        permalink += b"\0"

        option_bytes = []
        current_byte = 0
        current_bit_index = 0
        for option_name in OPTIONS:
            value = self.settings[option_name]

            widget = getattr(self.ui, option_name)
            if isinstance(widget, QAbstractButton):
                if current_bit_index >= 8:
                    option_bytes.append(current_byte)
                    current_bit_index = 0
                    current_byte = 0

                current_byte |= (int(value) << current_bit_index)
                current_bit_index += 1
            elif isinstance(widget, QComboBox):
                if current_bit_index > 0:
                    # End the current bitfield byte.
                    option_bytes.append(current_byte)
                    current_bit_index = 0
                    current_byte = 0

                value = int(value)
                assert 0 <= value <= 255
                option_bytes.append(value)

        if current_bit_index > 0:
            # End the current bitfield byte.
            option_bytes.append(current_byte)

        for byte in option_bytes:
            permalink += struct.pack(">B", byte)
        base64_encoded_permalink = base64.b64encode(permalink).decode("ascii")
        self.ui.permalink.setText(base64_encoded_permalink)

    def decode_permalink(self, base64_encoded_permalink):
        base64_encoded_permalink = base64_encoded_permalink.strip()
        if not base64_encoded_permalink:
            # Empty
            return

        permalink = base64.b64decode(base64_encoded_permalink)
        given_version_num, seed, options_bytes = permalink.split(b"\0", 2)
        given_version_num = given_version_num.decode("ascii")
        seed = seed.decode("ascii")
        if given_version_num != VERSION:
            QMessageBox.critical(
                self, "Invalid permalink",
                "The permalink you pasted is for version %s of the randomizer, it cannot be used with the version you are currently using (%s)."
                % (given_version_num, VERSION))
            return

        self.ui.seed.setText(seed)

        option_bytes = struct.unpack(">" + "B" * len(options_bytes),
                                     options_bytes)

        current_byte_index = 0
        current_bit_index = 0
        for option_name in OPTIONS:
            if current_bit_index >= 8:
                current_byte_index += 1
                current_bit_index = 0

            widget = getattr(self.ui, option_name)
            if isinstance(widget, QAbstractButton):
                current_byte = option_bytes[current_byte_index]
                current_bit = ((current_byte >> current_bit_index) & 1)
                current_bit_index += 1

                boolean_value = bool(current_bit)
                self.set_option_value(option_name, boolean_value)
            elif isinstance(widget, QComboBox):
                if current_bit_index > 0:
                    # End the current bitfield byte.
                    current_byte_index += 1
                    current_bit_index = 0
                current_byte = option_bytes[current_byte_index]

                integer_value = str(current_byte)
                self.set_option_value(option_name, integer_value)
                current_byte_index += 1
                current_bit_index = 0

        self.update_settings()

    def browse_for_clean_iso(self):
        if self.settings["clean_iso_path"] and os.path.isfile(
                self.settings["clean_iso_path"]):
            default_dir = os.path.dirname(self.settings["clean_iso_path"])
        else:
            default_dir = None

        clean_iso_path, selected_filter = QFileDialog.getOpenFileName(
            self, "Select clean Wind Waker ISO", default_dir,
            "GC ISO Files (*.iso *.gcm)")
        if not clean_iso_path:
            return
        self.ui.clean_iso_path.setText(clean_iso_path)
        self.update_settings()

    def browse_for_output_folder(self):
        if self.settings["output_folder"] and os.path.isdir(
                self.settings["output_folder"]):
            default_dir = self.settings["output_folder"]
        else:
            default_dir = None

        output_folder_path = QFileDialog.getExistingDirectory(
            self, "Select output folder", default_dir)
        if not output_folder_path:
            return
        self.ui.output_folder.setText(output_folder_path)
        self.update_settings()

    def eventFilter(self, target, event):
        if event.type() == QEvent.Enter:
            option_name = target.objectName()

            if option_name.startswith("label_for_"):
                option_name = option_name[len("label_for_"):]

            if option_name in OPTIONS:
                self.set_option_description(OPTIONS[option_name])
            else:
                self.set_option_description(None)
            return True
        elif event.type() == QEvent.Leave:
            self.set_option_description(None)
            return True

        return QMainWindow.eventFilter(self, target, event)

    def get_option_value(self, option_name):
        widget = getattr(self.ui, option_name)
        if isinstance(widget, QAbstractButton):
            return widget.isChecked()
        elif isinstance(widget, QComboBox):
            return widget.itemText(widget.currentIndex())
        else:
            print("Option widget is invalid: %s" % option_name)

    def set_option_value(self, option_name, new_value):
        widget = getattr(self.ui, option_name)
        if isinstance(widget, QAbstractButton):
            widget.setChecked(new_value)
        elif isinstance(widget, QComboBox):
            index_of_value = None
            for i in range(widget.count()):
                text = widget.itemText(i)
                if text == new_value:
                    index_of_value = i
                    break

            if index_of_value is None:
                print("Cannot find value %s in combobox %s" %
                      (new_value, option_name))
                index_of_value = 0

            widget.setCurrentIndex(index_of_value)
        else:
            print("Option widget is invalid: %s" % option_name)

    def set_option_description(self, new_description):
        if new_description is None:
            self.ui.option_description.setText(
                "(Hover over an option to see a description of what it does.)")
            self.ui.option_description.setStyleSheet("color: grey;")
        else:
            self.ui.option_description.setText(new_description)
            self.ui.option_description.setStyleSheet("")

    def open_about(self):
        text = """Wind Waker Randomizer Version %s<br><br>
      Created by LagoLunatic<br><br>
      Report issues here:<br><a href=\"https://github.com/LagoLunatic/wwrando/issues\">https://github.com/LagoLunatic/wwrando/issues</a><br><br>
      Source code:<br><a href=\"https://github.com/LagoLunatic/wwrando\">https://github.com/LagoLunatic/wwrando</a>""" % VERSION

        self.about_dialog = QMessageBox()
        self.about_dialog.setTextFormat(Qt.TextFormat.RichText)
        self.about_dialog.setWindowTitle("Wind Waker Randomizer")
        self.about_dialog.setText(text)
        self.about_dialog.setWindowIcon(self.windowIcon())
        self.about_dialog.show()

    def keyPressEvent(self, event):
        if event.key() == Qt.Key_Escape:
            self.close()
Exemple #3
0
class WWRandomizerWindow(QMainWindow):
  VALID_SEED_CHARACTERS = "-_'%%.%s%s" % (string.ascii_letters, string.digits)
  MAX_SEED_LENGTH = 42 # Limited by maximum length of game name in banner
  
  def __init__(self, cmd_line_args=[]):
    super(WWRandomizerWindow, self).__init__()
    self.ui = Ui_MainWindow()
    self.ui.setupUi(self)
    
    self.cmd_line_args = cmd_line_args
    self.bulk_test = ("-bulk" in cmd_line_args)
    
    self.custom_color_selector_buttons = OrderedDict()
    self.custom_color_selector_hex_inputs = OrderedDict()
    self.custom_colors = OrderedDict()
    self.initialize_custom_player_model_list()
    
    self.preserve_default_settings()
    
    self.cached_item_locations = Logic.load_and_parse_item_locations()
    
    self.load_settings()
    
    self.ui.clean_iso_path.editingFinished.connect(self.update_settings)
    self.ui.output_folder.editingFinished.connect(self.update_settings)
    self.ui.seed.editingFinished.connect(self.update_settings)
    self.ui.clean_iso_path_browse_button.clicked.connect(self.browse_for_clean_iso)
    self.ui.output_folder_browse_button.clicked.connect(self.browse_for_output_folder)
    self.ui.permalink.textEdited.connect(self.permalink_modified)
    
    self.ui.custom_player_model.currentIndexChanged.connect(self.custom_model_changed)
    self.ui.player_in_casual_clothes.clicked.connect(self.custom_model_changed)
    
    for option_name in OPTIONS:
      widget = getattr(self.ui, option_name)
      if isinstance(widget, QAbstractButton):
        widget.clicked.connect(self.update_settings)
      elif isinstance(widget, QComboBox):
        widget.currentIndexChanged.connect(self.update_settings)
      else:
        raise Exception("Option widget is invalid: %s" % option_name)
    
    self.ui.generate_seed_button.clicked.connect(self.generate_seed)
    
    self.ui.randomize_button.clicked.connect(self.randomize)
    self.ui.reset_settings_to_default.clicked.connect(self.reset_settings_to_default)
    self.ui.about_button.clicked.connect(self.open_about)
    
    for option_name in OPTIONS:
      getattr(self.ui, option_name).installEventFilter(self)
      label_for_option = getattr(self.ui, "label_for_" + option_name, None)
      if label_for_option:
        label_for_option.installEventFilter(self)
    self.set_option_description(None)
    
    self.update_settings()
    
    self.setWindowTitle("Wind Waker Randomizer %s" % VERSION)
    
    icon_path = os.path.join(ASSETS_PATH, "icon.ico")
    self.setWindowIcon(QIcon(icon_path))
    
    self.show()
    
    self.update_checker_thread = UpdateCheckerThread()
    self.update_checker_thread.finished_checking_for_updates.connect(self.show_update_check_results)
    self.update_checker_thread.start()
  
  def generate_seed(self):
    random.seed(None)
    
    with open(os.path.join(SEEDGEN_PATH, "adjectives.txt")) as f:
      adjectives = random.sample(f.read().splitlines(), 2)
    noun_file_to_use = random.choice(["nouns.txt", "names.txt"])
    with open(os.path.join(SEEDGEN_PATH, noun_file_to_use)) as f:
      noun = random.choice(f.read().splitlines())
    words = adjectives + [noun]
    capitalized_words = []
    for word in words:
      capitalized_word = ""
      seen_first_letter = False
      for char in word:
        if char in string.ascii_letters and not seen_first_letter:
          capitalized_word += char.capitalize()
          seen_first_letter = True
        else:
          capitalized_word += char
      capitalized_words.append(capitalized_word)
    seed = "".join(capitalized_words)
    
    seed = self.sanitize_seed(seed)
    
    self.settings["seed"] = seed
    self.ui.seed.setText(seed)
    self.update_settings()
  
  def sanitize_seed(self, seed):
    seed = str(seed)
    seed = seed.strip()
    seed = "".join(char for char in seed if char in self.VALID_SEED_CHARACTERS)
    seed = seed[:self.MAX_SEED_LENGTH]
    return seed
  
  def randomize(self):
    clean_iso_path = self.settings["clean_iso_path"].strip()
    output_folder = self.settings["output_folder"].strip()
    self.settings["clean_iso_path"] = clean_iso_path
    self.settings["output_folder"] = output_folder
    self.ui.clean_iso_path.setText(clean_iso_path)
    self.ui.output_folder.setText(output_folder)
    
    if not os.path.isfile(clean_iso_path):
      QMessageBox.warning(self, "Clean ISO path not specified", "Must specify path to clean your Wind Waker ISO (USA).")
      return
    if not os.path.isdir(output_folder):
      QMessageBox.warning(self, "No output folder specified", "Must specify a valid output folder for the randomized files.")
      return
    
    seed = self.settings["seed"]
    seed = self.sanitize_seed(seed)
    
    if not seed:
      self.generate_seed()
      seed = self.settings["seed"]
    
    self.settings["seed"] = seed
    self.ui.seed.setText(seed)
    self.update_settings()
    
    options = OrderedDict()
    for option_name in OPTIONS:
      options[option_name] = self.get_option_value(option_name)
    options["custom_colors"] = self.custom_colors
    
    permalink = self.ui.permalink.text()
    
    max_progress_val = 20
    self.progress_dialog = RandomizerProgressDialog("Randomizing", "Initializing...", max_progress_val)
    
    if self.bulk_test:
      failures_done = 0
      total_done = 0
      for i in range(100):
        temp_seed = str(i)
        try:
          rando = Randomizer(temp_seed, clean_iso_path, output_folder, options, permalink=permalink, cmd_line_args=self.cmd_line_args)
          randomizer_generator = rando.randomize()
          while True:
            next_option_description, options_finished = next(randomizer_generator)
            if options_finished == -1:
              break
        except Exception as e:
          stack_trace = traceback.format_exc()
          error_message = "Error on seed " + temp_seed + ":\n" + str(e) + "\n\n" + stack_trace
          print(error_message)
          failures_done += 1
        total_done += 1
        print("%d/%d seeds failed" % (failures_done, total_done))
    
    try:
      rando = Randomizer(seed, clean_iso_path, output_folder, options, permalink=permalink, cmd_line_args=self.cmd_line_args)
    except TooFewProgressionLocationsError as e:
      error_message = str(e)
      self.randomization_failed(error_message)
      return
    except Exception as e:
      stack_trace = traceback.format_exc()
      error_message = "Randomization failed with error:\n" + str(e) + "\n\n" + stack_trace
      self.randomization_failed(error_message)
      return
    
    self.randomizer_thread = RandomizerThread(rando)
    self.randomizer_thread.update_progress.connect(self.update_progress_dialog)
    self.randomizer_thread.randomization_complete.connect(self.randomization_complete)
    self.randomizer_thread.randomization_failed.connect(self.randomization_failed)
    self.randomizer_thread.start()
  
  def update_progress_dialog(self, next_option_description, options_finished):
    self.progress_dialog.setLabelText(next_option_description)
    self.progress_dialog.setValue(options_finished)
  
  def randomization_complete(self):
    self.progress_dialog.reset()
    
    text = """Randomization complete.<br><br>
      If you get stuck, check the progression spoiler log in the output folder.<br><br>
      <b>If you try to load the game in Dolphin and get a black screen, you should update to the latest development build of Dolphin:<br><a href=\"https://en.dolphin-emu.org/download/\">https://en.dolphin-emu.org/download/</a></b>"""
    
    self.complete_dialog = QMessageBox()
    self.complete_dialog.setTextFormat(Qt.TextFormat.RichText)
    self.complete_dialog.setWindowTitle("Randomization complete")
    self.complete_dialog.setText(text)
    self.complete_dialog.setWindowIcon(self.windowIcon())
    self.complete_dialog.show()
  
  def randomization_failed(self, error_message):
    self.progress_dialog.reset()
    
    try:
      self.randomizer_thread.randomizer.write_error_log(error_message)
    except:
      # If an error happened when writing the error log just ignore it.
      pass
    
    print(error_message)
    QMessageBox.critical(
      self, "Randomization Failed",
      error_message
    )
  
  def show_update_check_results(self, new_version):
    if not new_version:
      self.ui.update_checker_label.setText("No new updates to the randomizer are available.")
    elif new_version == "error":
      self.ui.update_checker_label.setText("There was an error checking for updates.")
    else:
      new_text = "<b>Version %s of the randomizer is available!</b>" % new_version
      new_text += " <a href=\"%s\">Click here</a> to go to the download page." % LATEST_RELEASE_DOWNLOAD_PAGE_URL
      self.ui.update_checker_label.setText(new_text)
  
  def preserve_default_settings(self):
    self.default_settings = OrderedDict()
    for option_name in OPTIONS:
      self.default_settings[option_name] = self.get_option_value(option_name)
  
  def reset_settings_to_default(self):
    any_setting_changed = False
    for option_name in OPTIONS:
      if option_name in self.default_settings:
        default_value = self.default_settings[option_name]
        current_value = self.get_option_value(option_name)
        if default_value != current_value:
          any_setting_changed = True
        self.set_option_value(option_name, default_value)
    
    any_color_changed = self.reset_color_selectors_to_model_default_colors()
    if any_color_changed:
      any_setting_changed = True
    
    self.update_settings()
    
    if not any_setting_changed:
      QMessageBox.information(self,
        "Settings already default",
        "You already have all the default randomization settings."
      )
  
  def load_settings(self):
    self.settings_path = "settings.txt"
    if os.path.isfile(self.settings_path):
      with open(self.settings_path) as f:
        self.settings = yaml.safe_load(f)
      if self.settings is None:
        self.settings = OrderedDict()
    else:
      self.settings = OrderedDict()
    
    if "clean_iso_path" in self.settings:
      self.ui.clean_iso_path.setText(self.settings["clean_iso_path"])
    if "output_folder" in self.settings:
      self.ui.output_folder.setText(self.settings["output_folder"])
    if "seed" in self.settings:
      self.ui.seed.setText(self.settings["seed"])
    
    for option_name in OPTIONS:
      if option_name in self.settings:
        self.set_option_value(option_name, self.settings[option_name])
    
    self.custom_model_changed()
    if "custom_colors" in self.settings:
      custom_colors_from_settings = self.settings["custom_colors"]
      for custom_color_name in self.custom_colors:
        if custom_color_name in custom_colors_from_settings:
          self.custom_colors[custom_color_name] = custom_colors_from_settings[custom_color_name]
      for custom_color_name, color in self.custom_colors.items():
        option_name = "custom_color_" + custom_color_name
        self.set_color(option_name, color, update_preview=False)
    
    self.update_model_preview()
  
  def save_settings(self):
    with open(self.settings_path, "w") as f:
      yaml.dump(self.settings, f, default_flow_style=False, Dumper=yaml.Dumper)
  
  def update_settings(self):
    self.settings["clean_iso_path"] = self.ui.clean_iso_path.text()
    self.settings["output_folder"] = self.ui.output_folder.text()
    self.settings["seed"] = self.ui.seed.text()
    
    self.disable_invalid_cosmetic_options()
    
    for option_name in OPTIONS:
      self.settings[option_name] = self.get_option_value(option_name)
    self.settings["custom_colors"] = self.custom_colors
    
    self.save_settings()
    
    self.encode_permalink()
    
    self.update_total_progress_locations()
  
  def update_total_progress_locations(self):
    options = OrderedDict()
    for option_name in OPTIONS:
      options[option_name] = self.get_option_value(option_name)
    num_progress_locations = Logic.get_num_progression_locations_static(self.cached_item_locations, options)
    
    text = "Where Should Progress Items Appear? (Selected: %d Possible Progression Locations)" % num_progress_locations
    self.ui.groupBox.setTitle(text)
  
  def permalink_modified(self):
    permalink = self.ui.permalink.text()
    try:
      self.decode_permalink(permalink)
    except Exception as e:
      stack_trace = traceback.format_exc()
      error_message = "Failed to parse permalink:\n" + str(e) + "\n\n" + stack_trace
      print(error_message)
      QMessageBox.critical(
        self, "Invalid permalink",
        "The permalink you pasted is invalid."
      )
    
    self.encode_permalink()
  
  def encode_permalink(self):
    seed = self.settings["seed"]
    seed = self.sanitize_seed(seed)
    if not seed:
      self.ui.permalink.setText("")
      return
    
    permalink = b""
    permalink += VERSION.encode("ascii")
    permalink += b"\0"
    permalink += seed.encode("ascii")
    permalink += b"\0"
    
    option_bytes = []
    current_byte = 0
    current_bit_index = 0
    for option_name in OPTIONS:
      if option_name in NON_PERMALINK_OPTIONS:
        continue
      
      value = self.settings[option_name]
      
      widget = getattr(self.ui, option_name)
      if isinstance(widget, QAbstractButton):
        if current_bit_index >= 8:
          option_bytes.append(current_byte)
          current_bit_index = 0
          current_byte = 0
        
        current_byte |= (int(value) << current_bit_index)
        current_bit_index += 1
      elif isinstance(widget, QComboBox):
        if current_bit_index > 0:
          # End the current bitfield byte.
          option_bytes.append(current_byte)
          current_bit_index = 0
          current_byte = 0
        
        value = widget.currentIndex()
        assert 0 <= value <= 255
        option_bytes.append(value)
    
    if current_bit_index > 0:
      # End the current bitfield byte.
      option_bytes.append(current_byte)
    
    for byte in option_bytes:
      permalink += struct.pack(">B", byte)
    base64_encoded_permalink = base64.b64encode(permalink).decode("ascii")
    self.ui.permalink.setText(base64_encoded_permalink)
  
  def decode_permalink(self, base64_encoded_permalink):
    base64_encoded_permalink = base64_encoded_permalink.strip()
    if not base64_encoded_permalink:
      # Empty
      return
    
    permalink = base64.b64decode(base64_encoded_permalink)
    given_version_num, seed, options_bytes = permalink.split(b"\0", 2)
    given_version_num = given_version_num.decode("ascii")
    seed = seed.decode("ascii")
    if given_version_num != VERSION:
      QMessageBox.critical(
        self, "Invalid permalink",
        "The permalink you pasted is for version %s of the randomizer, it cannot be used with the version you are currently using (%s)." % (given_version_num, VERSION)
      )
      return
    
    self.ui.seed.setText(seed)
    
    option_bytes = struct.unpack(">" + "B"*len(options_bytes), options_bytes)
    
    current_byte_index = 0
    current_bit_index = 0
    for option_name in OPTIONS:
      if option_name in NON_PERMALINK_OPTIONS:
        continue
      
      if current_bit_index >= 8:
        current_byte_index += 1
        current_bit_index = 0
      
      widget = getattr(self.ui, option_name)
      if isinstance(widget, QAbstractButton):
        current_byte = option_bytes[current_byte_index]
        current_bit = ((current_byte >> current_bit_index) & 1)
        current_bit_index += 1
        
        boolean_value = bool(current_bit)
        self.set_option_value(option_name, boolean_value)
      elif isinstance(widget, QComboBox):
        if current_bit_index > 0:
          # End the current bitfield byte.
          current_byte_index += 1
          current_bit_index = 0
        current_byte = option_bytes[current_byte_index]
        
        index = current_byte
        if index >= widget.count() or index < 0:
          index = 0
        value = widget.itemText(index)
        self.set_option_value(option_name, value)
        current_byte_index += 1
        current_bit_index = 0
    
    self.update_settings()
  
  def browse_for_clean_iso(self):
    if self.settings["clean_iso_path"] and os.path.isfile(self.settings["clean_iso_path"]):
      default_dir = os.path.dirname(self.settings["clean_iso_path"])
    else:
      default_dir = None
    
    clean_iso_path, selected_filter = QFileDialog.getOpenFileName(self, "Select clean Wind Waker ISO", default_dir, "GC ISO Files (*.iso *.gcm)")
    if not clean_iso_path:
      return
    self.ui.clean_iso_path.setText(clean_iso_path)
    self.update_settings()
  
  def browse_for_output_folder(self):
    if self.settings["output_folder"] and os.path.isdir(self.settings["output_folder"]):
      default_dir = self.settings["output_folder"]
    else:
      default_dir = None
    
    output_folder_path = QFileDialog.getExistingDirectory(self, "Select output folder", default_dir)
    if not output_folder_path:
      return
    self.ui.output_folder.setText(output_folder_path)
    self.update_settings()
  
  def eventFilter(self, target, event):
    if event.type() == QEvent.Enter:
      option_name = target.objectName()
      
      if option_name.startswith("label_for_"):
        option_name = option_name[len("label_for_"):]
      
      if option_name in OPTIONS:
        self.set_option_description(OPTIONS[option_name])
      else:
        self.set_option_description(None)
      return True
    elif event.type() == QEvent.Leave:
      self.set_option_description(None)
      return True
    
    return QMainWindow.eventFilter(self, target, event)
  
  def get_option_value(self, option_name):
    widget = getattr(self.ui, option_name)
    if isinstance(widget, QCheckBox) or isinstance(widget, QRadioButton):
      return widget.isChecked()
    elif isinstance(widget, QComboBox):
      return widget.itemText(widget.currentIndex())
    else:
      print("Option widget is invalid: %s" % option_name)
  
  def set_option_value(self, option_name, new_value):
    widget = getattr(self.ui, option_name)
    if isinstance(widget, QCheckBox) or isinstance(widget, QRadioButton):
      widget.setChecked(new_value)
    elif isinstance(widget, QComboBox):
      index_of_value = None
      for i in range(widget.count()):
        text = widget.itemText(i)
        if text == new_value:
          index_of_value = i
          break
      
      if index_of_value is None:
        print("Cannot find value %s in combobox %s" % (new_value, option_name))
        index_of_value = 0
      
      widget.setCurrentIndex(index_of_value)
    else:
      print("Option widget is invalid: %s" % option_name)
  
  def set_option_description(self, new_description):
    if new_description is None:
      self.ui.option_description.setText("(Hover over an option to see a description of what it does.)")
      self.ui.option_description.setStyleSheet("color: grey;")
    else:
      self.ui.option_description.setText(new_description)
      self.ui.option_description.setStyleSheet("")
  
  def initialize_custom_player_model_list(self):
    self.ui.custom_player_model.addItem("Link")
    
    custom_model_names = customizer.get_all_custom_model_names()
    for custom_model_name in custom_model_names:
      self.ui.custom_player_model.addItem(custom_model_name)
    
    if custom_model_names:
      self.ui.custom_player_model.addItem("Random")
      self.ui.custom_player_model.addItem("Random (exclude Link)")
    else:
      self.ui.custom_player_model.setEnabled(False)
  
  def custom_model_changed(self):
    self.disable_invalid_cosmetic_options()
    
    while self.ui.custom_colors_layout.count():
      item = self.ui.custom_colors_layout.takeAt(0)
      hlayout = item.layout()
      while hlayout.count():
        item = hlayout.takeAt(0)
        widget = item.widget()
        if widget:
          widget.deleteLater()
    self.custom_color_selector_buttons = OrderedDict()
    self.custom_color_selector_hex_inputs = OrderedDict()
    
    custom_model_name = self.get_option_value("custom_player_model")
    metadata = customizer.get_model_metadata(custom_model_name)
    if metadata is None:
      return
    if "error_message" in metadata:
      error_message = "Syntax error when trying to read metadata.txt for custom model: %s\n\n%s" %(custom_model_name, metadata["error_message"])
      print(error_message)
      QMessageBox.critical(
        self, "Failed to load model metadata",
        error_message
      )
    
    model_author = metadata.get("author", None)
    model_comment = metadata.get("comment", None)
    comment_lines = []
    if model_author:
      comment_lines.append("Model author: %s" % model_author)
    if model_comment:
      comment_lines.append("Model author comment: %s" % model_comment)
    self.ui.custom_model_comment.setText("\n".join(comment_lines))
    if len(comment_lines) <= 0:
      self.ui.custom_model_comment.hide()
    else:
      self.ui.custom_model_comment.show()
    
    is_casual = self.get_option_value("player_in_casual_clothes")
    if is_casual:
      prefix = "casual"
    else:
      prefix = "hero"
    
    self.custom_colors = OrderedDict()
    custom_colors = metadata.get(prefix + "_custom_colors", {})
    
    for custom_color_name, default_color in custom_colors.items():
      option_name = "custom_color_" + custom_color_name
      hlayout = QHBoxLayout()
      label_for_color_selector = QLabel(self.ui.tab_2)
      label_for_color_selector.setText("Player %s Color" % custom_color_name)
      hlayout.addWidget(label_for_color_selector)
      color_hex_code_input = QLineEdit(self.ui.tab_2)
      color_hex_code_input.setText("")
      color_hex_code_input.setObjectName(option_name + "_hex_code_input")
      color_hex_code_input.setFixedWidth(52)
      hlayout.addWidget(color_hex_code_input)
      color_selector_button = QPushButton(self.ui.tab_2)
      color_selector_button.setText("Click to set color")
      color_selector_button.setObjectName(option_name)
      hlayout.addWidget(color_selector_button)
      
      self.custom_color_selector_buttons[option_name] = color_selector_button
      color_selector_button.clicked.connect(self.open_custom_color_chooser)
      self.custom_color_selector_hex_inputs[option_name] = color_hex_code_input
      color_hex_code_input.textEdited.connect(self.custom_color_hex_code_changed)
      color_hex_code_input.editingFinished.connect(self.custom_color_hex_code_finished_editing)
      
      self.ui.custom_colors_layout.addLayout(hlayout)
      
      self.set_color(option_name, default_color, update_preview=False)
    
    if len(custom_colors) == 0:
      # Need to push the preview over to the right even when there are no colors to do it, so add a spacer.
      hlayout = QHBoxLayout()
      hspacer = QSpacerItem(20, 40, QSizePolicy.Expanding, QSizePolicy.Minimum)
      hlayout.addItem(hspacer)
      self.ui.custom_colors_layout.addLayout(hlayout)
    
    self.update_model_preview()
  
  def reset_color_selectors_to_model_default_colors(self):
    custom_model_name = self.get_option_value("custom_player_model")
    is_casual = self.get_option_value("player_in_casual_clothes")
    if is_casual:
      prefix = "casual"
    else:
      prefix = "hero"
    
    metadata = customizer.get_model_metadata(custom_model_name)
    if metadata is None:
      return
    
    custom_colors = metadata.get(prefix + "_custom_colors", {})
    
    any_color_changed = False
    for custom_color_name, default_color in custom_colors.items():
      if self.custom_colors[custom_color_name] != default_color:
        any_color_changed = True
      option_name = "custom_color_" + custom_color_name
      self.set_color(option_name, default_color, update_preview=False)
    
    if any_color_changed:
      self.update_model_preview()
    
    return any_color_changed
  
  def disable_invalid_cosmetic_options(self):
    custom_model_name = self.get_option_value("custom_player_model")
    metadata = customizer.get_model_metadata(custom_model_name)
    
    if metadata is None:
      self.ui.player_in_casual_clothes.setEnabled(True)
      self.set_option_value("player_in_casual_clothes", False)
    else:
      disable_casual_clothes = metadata.get("disable_casual_clothes", False)
      if disable_casual_clothes:
        self.ui.player_in_casual_clothes.setEnabled(False)
        self.ui.player_in_casual_clothes.setChecked(False)
      else:
        self.ui.player_in_casual_clothes.setEnabled(True)
  
  def set_color(self, option_name, color, update_preview=True):
    if not (isinstance(color, list) and len(color) == 3):
      color = [255, 255, 255]
    
    assert option_name.startswith("custom_color_")
    color_name = option_name[len("custom_color_"):]
    self.custom_colors[color_name] = color
    
    color_button = self.custom_color_selector_buttons[option_name]
    hex_input = self.custom_color_selector_hex_inputs[option_name]
    if color is None:
      color_button.setStyleSheet("")
      hex_input.setText("")
    else:
      hex_input.setText("%02X%02X%02X" % tuple(color))
      
      r, g, b = color
      
      # Depending on the value of the background color of the button, we need to make the text color either black or white for contrast.
      h, s, v = colorsys.rgb_to_hsv(r/255, g/255, b/255)
      if v > 0.5:
        text_color = (0, 0, 0)
      else:
        text_color = (255, 255, 255)
      
      color_button.setStyleSheet(
        "background-color: rgb(%d, %d, %d);" % (r, g, b) + \
        "color: rgb(%d, %d, %d);" % text_color,
      )
    
    if update_preview:
      self.update_model_preview()
  
  def open_custom_color_chooser(self):
    option_name = self.sender().objectName()
    
    assert option_name.startswith("custom_color_")
    color_name = option_name[len("custom_color_"):]
    
    r, g, b = self.custom_colors[color_name]
    initial_color = QColor(r, g, b, 255)
    color = QColorDialog.getColor(initial_color, self, "Select color")
    if not color.isValid():
      return
    r = color.red()
    g = color.green()
    b = color.blue()
    self.set_color(option_name, [r, g, b])
    self.update_settings()
  
  def custom_color_hex_code_changed(self):
    option_name = self.sender().objectName()
    
    assert option_name.endswith("_hex_code_input")
    option_name = option_name[:len(option_name)-len("_hex_code_input")]
    
    assert option_name.startswith("custom_color_")
    color_name = option_name[len("custom_color_"):]
    
    text = self.sender().text().strip().lstrip("#").upper()
    if len(text) != 6 or any(c for c in text if c not in "0123456789ABCDEF"):
      return False
    r = int(text[0:2], 16)
    g = int(text[2:4], 16)
    b = int(text[4:6], 16)
    self.set_color(option_name, [r, g, b])
    self.update_settings()
    return True
  
  def custom_color_hex_code_finished_editing(self):
    is_valid_color = self.custom_color_hex_code_changed()
    if not is_valid_color:
      # If the hex code is invalid reset the text to the correct hex code for the current color.
      self.set_color(option_name, self.custom_colors[color_name])
  
  def update_model_preview(self):
    custom_model_name = self.get_option_value("custom_player_model")
    custom_model_metadata = customizer.get_model_metadata(custom_model_name)
    disable_casual_clothes = custom_model_metadata.get("disable_casual_clothes", False)
    if self.get_option_value("player_in_casual_clothes") and not disable_casual_clothes:
      prefix = "casual"
    else:
      prefix = "hero"
    
    try:
      preview_image = customizer.get_model_preview_image(custom_model_name, prefix, self.custom_colors)
    except Exception as e:
      stack_trace = traceback.format_exc()
      error_message = "Failed to load model preview image for model %s.\nError:\n" % (custom_model_name) + str(e) + "\n\n" + stack_trace
      print(error_message)
      QMessageBox.critical(
        self, "Failed to load model preview",
        error_message
      )
      return
    
    if preview_image is None:
      self.ui.custom_model_preview_label.hide()
      return
    
    self.ui.custom_model_preview_label.show()
    
    data = preview_image.tobytes('raw', 'BGRA')
    qimage = QImage(data, preview_image.size[0], preview_image.size[1], QImage.Format_ARGB32)
    scaled_pixmap = QPixmap.fromImage(qimage).scaled(225, 350, Qt.KeepAspectRatio, Qt.SmoothTransformation)
    self.ui.custom_model_preview_label.setPixmap(scaled_pixmap)
  
  def open_about(self):
    text = """Wind Waker Randomizer Version %s<br><br>
      Created by LagoLunatic<br><br>
      Report issues here:<br><a href=\"https://github.com/LagoLunatic/wwrando/issues\">https://github.com/LagoLunatic/wwrando/issues</a><br><br>
      Source code:<br><a href=\"https://github.com/LagoLunatic/wwrando\">https://github.com/LagoLunatic/wwrando</a>""" % VERSION
    
    self.about_dialog = QMessageBox()
    self.about_dialog.setTextFormat(Qt.TextFormat.RichText)
    self.about_dialog.setWindowTitle("Wind Waker Randomizer")
    self.about_dialog.setText(text)
    self.about_dialog.setWindowIcon(self.windowIcon())
    self.about_dialog.show()
  
  def keyPressEvent(self, event):
    if event.key() == Qt.Key_Escape:
      self.close()
  
  def closeEvent(self, event):
    # Need to wait for the update checker before exiting, or the program will crash when closing.
    self.update_checker_thread.quit()
    self.update_checker_thread.wait()
    event.accept()