diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index 4358c8032bdd..8ff0f8bb44e1 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -65,6 +65,7 @@ jobs: python -m pip install --upgrade pip pip install pytest pytest-subtests python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" + python Launcher.py --update_settings # make sure host.yaml exists for tests - name: Unittests run: | pytest diff --git a/.gitignore b/.gitignore index 3e242d89af9f..8e4cc86657a5 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ README.html EnemizerCLI/ /Players/ /SNI/ +/host.yaml /options.yaml /config.yaml /logs/ diff --git a/BaseClasses.py b/BaseClasses.py index 2cc7596e5fe0..7c12a94dea65 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -9,7 +9,8 @@ from argparse import Namespace from collections import ChainMap, Counter, deque from enum import IntEnum, IntFlag -from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union +from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \ + Type, ClassVar import NetUtils import Options @@ -788,6 +789,44 @@ def remove(self, item: Item): self.stale[item.player] = True +class Entrance: + access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) + hide_path: bool = False + player: int + name: str + parent_region: Optional[Region] + connected_region: Optional[Region] = None + # LttP specific, TODO: should make a LttPEntrance + addresses = None + target = None + + def __init__(self, player: int, name: str = '', parent: Region = None): + self.name = name + self.parent_region = parent + self.player = player + + def can_reach(self, state: CollectionState) -> bool: + if self.parent_region.can_reach(state) and self.access_rule(state): + if not self.hide_path and not self in state.path: + state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None))) + return True + + return False + + def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None: + self.connected_region = region + self.target = target + self.addresses = addresses + region.entrances.append(self) + + def __repr__(self): + return self.__str__() + + def __str__(self): + world = self.parent_region.multiworld if self.parent_region else None + return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' + + class Region: name: str _hint_text: str @@ -796,6 +835,7 @@ class Region: entrances: List[Entrance] exits: List[Entrance] locations: List[Location] + entrance_type: ClassVar[Type[Entrance]] = Entrance def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None): self.name = name @@ -823,20 +863,48 @@ def can_reach_private(self, state: CollectionState) -> bool: def hint_text(self) -> str: return self._hint_text if self._hint_text else self.name - def get_connecting_entrance(self, is_main_entrance: typing.Callable[[Entrance], bool]) -> Entrance: + def get_connecting_entrance(self, is_main_entrance: Callable[[Entrance], bool]) -> Entrance: for entrance in self.entrances: if is_main_entrance(entrance): return entrance for entrance in self.entrances: # BFS might be better here, trying DFS for now. return entrance.parent_region.get_connecting_entrance(is_main_entrance) - def add_locations(self, locations: Dict[str, Optional[int]], location_type: Optional[typing.Type[Location]] = None) -> None: - """Adds locations to the Region object, where location_type is your Location class and locations is a dict of - location names to address.""" + def add_locations(self, locations: Dict[str, Optional[int]], + location_type: Optional[Type[Location]] = None) -> None: + """ + Adds locations to the Region object, where location_type is your Location class and locations is a dict of + location names to address. + + :param locations: dictionary of locations to be created and added to this Region `{name: ID}` + :param location_type: Location class to be used to create the locations with""" if location_type is None: location_type = Location for location, address in locations.items(): self.locations.append(location_type(self.player, location, address, self)) + + def connect(self, connecting_region: Region, name: Optional[str] = None, + rule: Optional[Callable[[CollectionState], bool]] = None) -> None: + """ + Connects this Region to another Region, placing the provided rule on the connection. + + :param connecting_region: Region object to connect to path is `self -> exiting_region` + :param name: name of the connection being created + :param rule: callable to determine access of this connection to go from self to the exiting_region""" + exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}") + if rule: + exit_.access_rule = rule + exit_.connect(connecting_region) + + def create_exit(self, name: str) -> Entrance: + """ + Creates and returns an Entrance object as an exit of this region. + + :param name: name of the Entrance being created + """ + exit_ = self.entrance_type(self.player, name, self) + self.exits.append(exit_) + return exit_ def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]], rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None: @@ -850,11 +918,9 @@ def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]], if not isinstance(exits, Dict): exits = dict.fromkeys(exits) for connecting_region, name in exits.items(): - entrance = Entrance(self.player, name if name else f"{self.name} -> {connecting_region}", self) - if rules and connecting_region in rules: - entrance.access_rule = rules[connecting_region] - self.exits.append(entrance) - entrance.connect(self.multiworld.get_region(connecting_region, self.player)) + self.connect(self.multiworld.get_region(connecting_region, self.player), + name, + rules[connecting_region] if rules and connecting_region in rules else None) def __repr__(self): return self.__str__() @@ -863,44 +929,6 @@ def __str__(self): return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})' -class Entrance: - access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) - hide_path: bool = False - player: int - name: str - parent_region: Optional[Region] - connected_region: Optional[Region] = None - # LttP specific, TODO: should make a LttPEntrance - addresses = None - target = None - - def __init__(self, player: int, name: str = '', parent: Region = None): - self.name = name - self.parent_region = parent - self.player = player - - def can_reach(self, state: CollectionState) -> bool: - if self.parent_region.can_reach(state) and self.access_rule(state): - if not self.hide_path and not self in state.path: - state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None))) - return True - - return False - - def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None: - self.connected_region = region - self.target = target - self.addresses = addresses - region.entrances.append(self) - - def __repr__(self): - return self.__str__() - - def __str__(self): - world = self.parent_region.multiworld if self.parent_region else None - return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' - - class LocationProgressType(IntEnum): DEFAULT = 1 PRIORITY = 2 diff --git a/Generate.py b/Generate.py index bd265879fa31..bd1c4aa6fdea 100644 --- a/Generate.py +++ b/Generate.py @@ -14,44 +14,42 @@ ModuleUpdate.update() +import copy import Utils +import Options +from BaseClasses import seeddigits, get_seed, PlandoOptions +from Main import main as ERmain +from settings import get_settings +from Utils import parse_yamls, version_tuple, __version__, tuplize_version, user_path from worlds.alttp import Options as LttPOptions -from worlds.generic import PlandoConnection -from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, user_path from worlds.alttp.EntranceRandomizer import parse_arguments -from Main import main as ERmain -from BaseClasses import seeddigits, get_seed, PlandoOptions -import Options from worlds.alttp.Text import TextTable from worlds.AutoWorld import AutoWorldRegister -import copy +from worlds.generic import PlandoConnection def mystery_argparse(): - options = get_options() - defaults = options["generator"] - - def resolve_path(path: str, resolver: Callable[[str], str]) -> str: - return path if os.path.isabs(path) else resolver(path) + options = get_settings() + defaults = options.generator parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.") - parser.add_argument('--weights_file_path', default=defaults["weights_file_path"], + parser.add_argument('--weights_file_path', default=defaults.weights_file_path, help='Path to the weights file to use for rolling game settings, urls are also valid') parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player', action='store_true') - parser.add_argument('--player_files_path', default=resolve_path(defaults["player_files_path"], user_path), + parser.add_argument('--player_files_path', default=defaults.player_files_path, help="Input directory for player files.") parser.add_argument('--seed', help='Define seed number to generate.', type=int) - parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1)) - parser.add_argument('--spoiler', type=int, default=defaults["spoiler"]) - parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path), + parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1)) + parser.add_argument('--spoiler', type=int, default=defaults.spoiler) + parser.add_argument('--outputpath', default=options.general_options.output_path, help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd - parser.add_argument('--race', action='store_true', default=defaults["race"]) - parser.add_argument('--meta_file_path', default=defaults["meta_file_path"]) + parser.add_argument('--race', action='store_true', default=defaults.race) + parser.add_argument('--meta_file_path', default=defaults.meta_file_path) parser.add_argument('--log_level', default='info', help='Sets log level') parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0), help='Output rolled mystery results to yaml up to specified number (made for async multiworld)') - parser.add_argument('--plando', default=defaults["plando_options"], + parser.add_argument('--plando', default=defaults.plando_options, help='List of options that can be set manually. Can be combined, for example "bosses, items"') parser.add_argument("--skip_prog_balancing", action="store_true", help="Skip progression balancing step during generation.") @@ -71,6 +69,8 @@ def get_seed_name(random_source) -> str: def main(args=None, callback=ERmain): if not args: args, options = mystery_argparse() + else: + options = get_settings() seed = get_seed(args.seed) Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) @@ -137,7 +137,7 @@ def main(args=None, callback=ERmain): erargs = parse_arguments(['--multi', str(args.multi)]) erargs.seed = seed erargs.plando_options = args.plando - erargs.glitch_triforce = options["generator"]["glitch_triforce_room"] + erargs.glitch_triforce = options.generator.glitch_triforce_room erargs.spoiler = args.spoiler erargs.race = args.race erargs.outputname = seed_name diff --git a/Launcher.py b/Launcher.py index 84bdeeb72508..a1548d594ce8 100644 --- a/Launcher.py +++ b/Launcher.py @@ -22,6 +22,7 @@ from typing import Sequence, Union, Optional import Utils +import settings from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths if __name__ == "__main__": @@ -33,7 +34,8 @@ def open_host_yaml(): - file = user_path('host.yaml') + file = settings.get_settings().filename + assert file, "host.yaml missing" if is_linux: exe = which('sensible-editor') or which('gedit') or \ which('xdg-open') or which('gnome-open') or which('kde-open') @@ -84,6 +86,11 @@ def open_folder(folder_path): webbrowser.open(folder_path) +def update_settings(): + from settings import get_settings + get_settings().save() + + components.extend([ # Functions Component("Open host.yaml", func=open_host_yaml), @@ -256,11 +263,13 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None): if not component: logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}") + if args["update_settings"]: + update_settings() if 'file' in args: run_component(args["component"], args["file"], *args["args"]) elif 'component' in args: run_component(args["component"], *args["args"]) - else: + elif not args["update_settings"]: run_gui() @@ -269,9 +278,13 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None): Utils.freeze_support() multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work parser = argparse.ArgumentParser(description='Archipelago Launcher') - parser.add_argument('Patch|Game|Component', type=str, nargs='?', - help="Pass either a patch file, a generated game or the name of a component to run.") - parser.add_argument('args', nargs="*", help="Arguments to pass to component.") + run_group = parser.add_argument_group("Run") + run_group.add_argument("--update_settings", action="store_true", + help="Update host.yaml and exit.") + run_group.add_argument("Patch|Game|Component", type=str, nargs="?", + help="Pass either a patch file, a generated game or the name of a component to run.") + run_group.add_argument("args", nargs="*", + help="Arguments to pass to component.") main(parser.parse_args()) from worlds.LauncherComponents import processes diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py index 7a5972eb0182..8f1cbc1e9e83 100644 --- a/LinksAwakeningClient.py +++ b/LinksAwakeningClient.py @@ -9,16 +9,19 @@ import asyncio import base64 import binascii +import colorama import io -import logging +import os +import re import select +import shlex import socket +import struct +import sys +import subprocess import time import typing -import urllib -import colorama -import struct from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger, server_loop) @@ -30,6 +33,7 @@ from worlds.ladx.Locations import get_locations_to_id, meta_to_name from worlds.ladx.Tracker import LocationTracker, MagpieBridge + class GameboyException(Exception): pass @@ -115,17 +119,17 @@ def __init__(self, address, port) -> None: assert (self.socket) self.socket.setblocking(False) - def get_retroarch_version(self): - self.send(b'VERSION\n') - select.select([self.socket], [], []) - response_str, addr = self.socket.recvfrom(16) + async def send_command(self, command, timeout=1.0): + self.send(f'{command}\n') + response_str = await self.async_recv() + self.check_command_response(command, response_str) return response_str.rstrip() - def get_retroarch_status(self, timeout): - self.send(b'GET_STATUS\n') - select.select([self.socket], [], [], timeout) - response_str, addr = self.socket.recvfrom(1000, ) - return response_str.rstrip() + async def get_retroarch_version(self): + return await self.send_command("VERSION") + + async def get_retroarch_status(self): + return await self.send_command("GET_STATUS") def set_cache_limits(self, cache_start, cache_size): self.cache_start = cache_start @@ -141,8 +145,8 @@ def recv(self): response, _ = self.socket.recvfrom(4096) return response - async def async_recv(self): - response = await asyncio.get_event_loop().sock_recv(self.socket, 4096) + async def async_recv(self, timeout=1.0): + response = await asyncio.wait_for(asyncio.get_event_loop().sock_recv(self.socket, 4096), timeout) return response async def check_safe_gameplay(self, throw=True): @@ -169,6 +173,8 @@ async def check_wram(): raise InvalidEmulatorStateError() return False if not await check_wram(): + if throw: + raise InvalidEmulatorStateError() return False return True @@ -227,20 +233,30 @@ async def async_read_memory_safe(self, address, size=1): return r + def check_command_response(self, command: str, response: bytes): + if command == "VERSION": + ok = re.match("\d+\.\d+\.\d+", response.decode('ascii')) is not None + else: + ok = response.startswith(command.encode()) + if not ok: + logger.warning(f"Bad response to command {command} - {response}") + raise BadRetroArchResponse() + def read_memory(self, address, size=1): command = "READ_CORE_MEMORY" self.send(f'{command} {hex(address)} {size}\n') response = self.recv() - splits = response.decode().split(" ", 2) + self.check_command_response(command, response) - assert (splits[0] == command) + splits = response.decode().split(" ", 2) # Ignore the address for now - - # TODO: transform to bytes - if splits[2][:2] == "-1" or splits[0] != "READ_CORE_MEMORY": + if splits[2][:2] == "-1": raise BadRetroArchResponse() + + # TODO: check response address, check hex behavior between RA and BH + return bytearray.fromhex(splits[2]) async def async_read_memory(self, address, size=1): @@ -248,14 +264,21 @@ async def async_read_memory(self, address, size=1): self.send(f'{command} {hex(address)} {size}\n') response = await self.async_recv() + self.check_command_response(command, response) response = response[:-1] splits = response.decode().split(" ", 2) + try: + response_addr = int(splits[1], 16) + except ValueError: + raise BadRetroArchResponse() - assert (splits[0] == command) - # Ignore the address for now + if response_addr != address: + raise BadRetroArchResponse() - # TODO: transform to bytes - return bytearray.fromhex(splits[2]) + ret = bytearray.fromhex(splits[2]) + if len(ret) > size: + raise BadRetroArchResponse() + return ret def write_memory(self, address, bytes): command = "WRITE_CORE_MEMORY" @@ -263,7 +286,7 @@ def write_memory(self, address, bytes): self.send(f'{command} {hex(address)} {" ".join(hex(b) for b in bytes)}') select.select([self.socket], [], []) response, _ = self.socket.recvfrom(4096) - + self.check_command_response(command, response) splits = response.decode().split(" ", 3) assert (splits[0] == command) @@ -281,6 +304,9 @@ class LinksAwakeningClient(): pending_deathlink = False deathlink_debounce = True recvd_checks = {} + retroarch_address = None + retroarch_port = None + gameboy = None def msg(self, m): logger.info(m) @@ -288,50 +314,47 @@ def msg(self, m): self.gameboy.send(s) def __init__(self, retroarch_address="127.0.0.1", retroarch_port=55355): - self.gameboy = RAGameboy(retroarch_address, retroarch_port) + self.retroarch_address = retroarch_address + self.retroarch_port = retroarch_port + pass + stop_bizhawk_spam = False async def wait_for_retroarch_connection(self): - logger.info("Waiting on connection to Retroarch...") + if not self.stop_bizhawk_spam: + logger.info("Waiting on connection to Retroarch...") + self.stop_bizhawk_spam = True + self.gameboy = RAGameboy(self.retroarch_address, self.retroarch_port) + while True: try: - version = self.gameboy.get_retroarch_version() + version = await self.gameboy.get_retroarch_version() NO_CONTENT = b"GET_STATUS CONTENTLESS" status = NO_CONTENT core_type = None GAME_BOY = b"game_boy" while status == NO_CONTENT or core_type != GAME_BOY: - try: - status = self.gameboy.get_retroarch_status(0.1) - if status.count(b" ") < 2: - await asyncio.sleep(1.0) - continue - - GET_STATUS, PLAYING, info = status.split(b" ", 2) - if status.count(b",") < 2: - await asyncio.sleep(1.0) - continue - core_type, rom_name, self.game_crc = info.split(b",", 2) - if core_type != GAME_BOY: - logger.info( - f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?") - await asyncio.sleep(1.0) - continue - except (BlockingIOError, TimeoutError) as e: - await asyncio.sleep(0.1) - pass - logger.info(f"Connected to Retroarch {version} {info}") - self.gameboy.read_memory(0x1000) + status = await self.gameboy.get_retroarch_status() + if status.count(b" ") < 2: + await asyncio.sleep(1.0) + continue + GET_STATUS, PLAYING, info = status.split(b" ", 2) + if status.count(b",") < 2: + await asyncio.sleep(1.0) + continue + core_type, rom_name, self.game_crc = info.split(b",", 2) + if core_type != GAME_BOY: + logger.info( + f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?") + await asyncio.sleep(1.0) + continue + logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}") return - except ConnectionResetError: + except (BlockingIOError, TimeoutError, ConnectionResetError): await asyncio.sleep(1.0) pass - - def reset_auth(self): - auth = binascii.hexlify(self.gameboy.read_memory(0x0134, 12)).decode() - - if self.auth: - assert (auth == self.auth) - + self.stop_bizhawk_spam = False + async def reset_auth(self): + auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode() self.auth = auth async def wait_and_init_tracker(self): @@ -367,11 +390,14 @@ async def recved_item_from_ap(self, item_id, from_player, next_index): status = self.gameboy.write_memory(LAClientConstants.wLinkStatusBits, [status]) self.gameboy.write_memory(LAClientConstants.wRecvIndex, struct.pack(">H", next_index)) + should_reset_auth = False async def wait_for_game_ready(self): logger.info("Waiting on game to be in valid state...") while not await self.gameboy.check_safe_gameplay(throw=False): - pass - logger.info("Ready!") + if self.should_reset_auth: + self.should_reset_auth = False + raise GameboyException("Resetting due to wrong archipelago server") + logger.info("Game connection ready!") async def is_victory(self): return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1 @@ -398,7 +424,7 @@ async def main_tick(self, item_get_cb, win_cb, deathlink_cb): if await self.is_victory(): await win_cb() - recv_index = struct.unpack(">H", self.gameboy.read_memory(LAClientConstants.wRecvIndex, 2))[0] + recv_index = struct.unpack(">H", await self.gameboy.async_read_memory(LAClientConstants.wRecvIndex, 2))[0] # Play back one at a time if recv_index in self.recvd_checks: @@ -480,6 +506,15 @@ async def send_checks(self): message = [{"cmd": 'LocationChecks', "locations": self.found_checks}] await self.send_msgs(message) + had_invalid_slot_data = None + def event_invalid_slot(self): + # The next time we try to connect, reset the game loop for new auth + self.had_invalid_slot_data = True + self.auth = None + # Don't try to autoreconnect, it will just fail + self.disconnected_intentionally = True + CommonContext.event_invalid_slot(self) + ENABLE_DEATHLINK = False async def send_deathlink(self): if self.ENABLE_DEATHLINK: @@ -511,8 +546,17 @@ def new_checks(self, item_ids, ladxr_ids): async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: await super(LinksAwakeningContext, self).server_auth(password_requested) + + if self.had_invalid_slot_data: + # We are connecting when previously we had the wrong ROM or server - just in case + # re-read the ROM so that if the user had the correct address but wrong ROM, we + # allow a successful reconnect + self.client.should_reset_auth = True + self.had_invalid_slot_data = False + + while self.client.auth == None: + await asyncio.sleep(0.1) self.auth = self.client.auth - await self.get_username() await self.send_connect() def on_package(self, cmd: str, args: dict): @@ -520,9 +564,13 @@ def on_package(self, cmd: str, args: dict): self.game = self.slot_info[self.slot].game # TODO - use watcher_event if cmd == "ReceivedItems": - for index, item in enumerate(args["items"], args["index"]): + for index, item in enumerate(args["items"], start=args["index"]): self.client.recvd_checks[index] = item + async def sync(self): + sync_msg = [{'cmd': 'Sync'}] + await self.send_msgs(sync_msg) + item_id_lookup = get_locations_to_id() async def run_game_loop(self): @@ -539,17 +587,31 @@ async def deathlink(): if self.magpie_enabled: self.magpie_task = asyncio.create_task(self.magpie.serve()) - + # yield to allow UI to start await asyncio.sleep(0) while True: try: # TODO: cancel all client tasks - logger.info("(Re)Starting game loop") + if not self.client.stop_bizhawk_spam: + logger.info("(Re)Starting game loop") self.found_checks.clear() + # On restart of game loop, clear all checks, just in case we swapped ROMs + # this isn't totally neccessary, but is extra safety against cross-ROM contamination + self.client.recvd_checks.clear() await self.client.wait_for_retroarch_connection() - self.client.reset_auth() + await self.client.reset_auth() + # If we find ourselves with new auth after the reset, reconnect + if self.auth and self.client.auth != self.auth: + # It would be neat to reconnect here, but connection needs this loop to be running + logger.info("Detected new ROM, disconnecting...") + await self.disconnect() + continue + + if not self.client.recvd_checks: + await self.sync() + await self.client.wait_and_init_tracker() while True: @@ -560,39 +622,59 @@ async def deathlink(): self.last_resend = now await self.send_checks() if self.magpie_enabled: - self.magpie.set_checks(self.client.tracker.all_checks) - await self.magpie.set_item_tracker(self.client.item_tracker) - await self.magpie.send_gps(self.client.gps_tracker) + try: + self.magpie.set_checks(self.client.tracker.all_checks) + await self.magpie.set_item_tracker(self.client.item_tracker) + await self.magpie.send_gps(self.client.gps_tracker) + except Exception: + # Don't let magpie errors take out the client + pass + if self.client.should_reset_auth: + self.client.should_reset_auth = False + raise GameboyException("Resetting due to wrong archipelago server") + except (GameboyException, asyncio.TimeoutError, TimeoutError, ConnectionResetError): + await asyncio.sleep(1.0) - except GameboyException: - time.sleep(1.0) - pass +def run_game(romfile: str) -> None: + auto_start = typing.cast(typing.Union[bool, str], + Utils.get_options()["ladx_options"].get("rom_start", True)) + if auto_start is True: + import webbrowser + webbrowser.open(romfile) + elif isinstance(auto_start, str): + args = shlex.split(auto_start) + # Specify full path to ROM as we are going to cd in popen + full_rom_path = os.path.realpath(romfile) + args.append(full_rom_path) + try: + # set cwd so that paths to lua scripts are always relative to our client + if getattr(sys, 'frozen', False): + # The application is frozen + script_dir = os.path.dirname(sys.executable) + else: + script_dir = os.path.dirname(os.path.realpath(__file__)) + subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=script_dir) + except FileNotFoundError: + logger.error(f"Couldn't launch ROM, {args[0]} is missing") async def main(): parser = get_base_parser(description="Link's Awakening Client.") parser.add_argument("--url", help="Archipelago connection url") parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge") - parser.add_argument('diff_file', default="", type=str, nargs="?", help='Path to a .apladx Archipelago Binary Patch file') - + args = parser.parse_args() - logger.info(args) if args.diff_file: import Patch logger.info("patch file was supplied - creating rom...") meta, rom_file = Patch.create_rom_file(args.diff_file) - if "server" in meta: - args.url = meta["server"] + if "server" in meta and not args.connect: + args.connect = meta["server"] logger.info(f"wrote rom file to {rom_file}") - if args.url: - url = urllib.parse.urlparse(args.url) - args.connect = url.netloc - if url.password: - args.password = urllib.parse.unquote(url.password) ctx = LinksAwakeningContext(args.connect, args.password, args.magpie) @@ -604,6 +686,10 @@ async def main(): ctx.run_gui() ctx.run_cli() + # Down below run_gui so that we get errors out of the process + if args.diff_file: + run_game(rom_file) + await ctx.exit_event.wait() await ctx.shutdown() diff --git a/Main.py b/Main.py index 9804e6bd4582..c81466bf78e8 100644 --- a/Main.py +++ b/Main.py @@ -13,7 +13,8 @@ from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items from Options import StartInventoryPool -from Utils import __version__, get_options, output_path, version_tuple +from settings import get_settings +from Utils import __version__, output_path, version_tuple from worlds import AutoWorld from worlds.generic.Rules import exclusion_rules, locality_rules @@ -22,7 +23,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None): if not baked_server_options: - baked_server_options = get_options()["server_options"] + baked_server_options = get_settings().server_options.as_dict() + assert isinstance(baked_server_options, dict) if args.outputpath: os.makedirs(args.outputpath, exist_ok=True) output_path.cached_path = args.outputpath @@ -368,7 +370,6 @@ def precollect_hint(location): multidata = { "slot_data": slot_data, "slot_info": slot_info, - "names": names, # TODO: remove after 0.3.9 "connect_names": {name: (0, player) for player, name in world.player_name.items()}, "locations": locations_data, "checks_in_area": checks_in_area, diff --git a/MinecraftClient.py b/MinecraftClient.py index dd7a5cfd3efb..93385ec5385e 100644 --- a/MinecraftClient.py +++ b/MinecraftClient.py @@ -299,7 +299,7 @@ def is_correct_forge(forge_dir) -> bool: versions = get_minecraft_versions(data_version, channel) - forge_dir = Utils.user_path(options["minecraft_options"]["forge_directory"]) + forge_dir = options["minecraft_options"]["forge_directory"] max_heap = options["minecraft_options"]["max_heap_size"] forge_version = args.forge or versions["forge"] java_version = args.java or versions["java"] diff --git a/MultiServer.py b/MultiServer.py index aa3119c4f0ab..3e73910b9f86 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -2135,7 +2135,7 @@ async def console(ctx: Context): def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() - defaults = Utils.get_options()["server_options"] + defaults = Utils.get_options()["server_options"].as_dict() parser.add_argument('multidata', nargs="?", default=defaults["multidata"]) parser.add_argument('--host', default=defaults["host"]) parser.add_argument('--port', default=defaults["port"], type=int) diff --git a/NetUtils.py b/NetUtils.py index b30316ca6d7b..99c37238c35a 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -347,6 +347,18 @@ def local(self): class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tuple[int, int, int]]]): + def __init__(self, values: typing.MutableMapping[int, typing.Dict[int, typing.Tuple[int, int, int]]]): + super().__init__(values) + + if not self: + raise ValueError(f"Rejecting game with 0 players") + + if len(self) != max(self): + raise ValueError("Player IDs not continuous") + + if len(self.get(0, {})): + raise ValueError("Invalid player id 0 for location") + def find_item(self, slots: typing.Set[int], seeked_item_id: int ) -> typing.Generator[typing.Tuple[int, int, int, int, int], None, None]: for finding_player, check_data in self.items(): diff --git a/OoTClient.py b/OoTClient.py index fd93c09338ac..115490417334 100644 --- a/OoTClient.py +++ b/OoTClient.py @@ -296,8 +296,6 @@ async def patch_and_run_game(apz5_file): comp_path = base_name + '.z64' # Load vanilla ROM, patch file, compress ROM rom_file_name = Utils.get_options()["oot_options"]["rom_file"] - if not os.path.exists(rom_file_name): - rom_file_name = Utils.user_path(rom_file_name) rom = Rom(rom_file_name) sub_file = None diff --git a/Utils.py b/Utils.py index 1acd56514f86..f3e748d1cc09 100644 --- a/Utils.py +++ b/Utils.py @@ -13,8 +13,9 @@ import collections import importlib import logging -from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union +from settings import Settings, get_settings +from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union from yaml import load, load_all, dump, SafeLoader try: @@ -138,13 +139,16 @@ def user_path(*path: str) -> str: user_path.cached_path = local_path() else: user_path.cached_path = home_path() - # populate home from local - TODO: upgrade feature - if user_path.cached_path != local_path() and not os.path.exists(user_path("host.yaml")): - import shutil - for dn in ("Players", "data/sprites"): - shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True) - for fn in ("manifest.json", "host.yaml"): - shutil.copy2(local_path(fn), user_path(fn)) + # populate home from local + if user_path.cached_path != local_path(): + import filecmp + if not os.path.exists(user_path("manifest.json")) or \ + not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True): + import shutil + for dn in ("Players", "data/sprites"): + shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True) + for fn in ("manifest.json",): + shutil.copy2(local_path(fn), user_path(fn)) return os.path.join(user_path.cached_path, *path) @@ -238,155 +242,15 @@ def get_public_ipv6() -> str: return ip -OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]] +OptionsType = Settings # TODO: remove ~2 versions after 0.4.1 @cache_argsless -def get_default_options() -> OptionsType: - # Refer to host.yaml for comments as to what all these options mean. - options = { - "general_options": { - "output_path": "output", - }, - "factorio_options": { - "executable": os.path.join("factorio", "bin", "x64", "factorio"), - "filter_item_sends": False, - "bridge_chat_out": True, - }, - "sni_options": { - "sni_path": "SNI", - "snes_rom_start": True, - }, - "sm_options": { - "rom_file": "Super Metroid (JU).sfc", - }, - "soe_options": { - "rom_file": "Secret of Evermore (USA).sfc", - }, - "lttp_options": { - "rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc", - }, - "ladx_options": { - "rom_file": "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc", - }, - "server_options": { - "host": None, - "port": 38281, - "password": None, - "multidata": None, - "savefile": None, - "disable_save": False, - "loglevel": "info", - "server_password": None, - "disable_item_cheat": False, - "location_check_points": 1, - "hint_cost": 10, - "release_mode": "goal", - "collect_mode": "disabled", - "remaining_mode": "goal", - "auto_shutdown": 0, - "compatibility": 2, - "log_network": 0 - }, - "generator": { - "enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"), - "player_files_path": "Players", - "players": 0, - "weights_file_path": "weights.yaml", - "meta_file_path": "meta.yaml", - "spoiler": 3, - "glitch_triforce_room": 1, - "race": 0, - "plando_options": "bosses", - }, - "minecraft_options": { - "forge_directory": "Minecraft Forge server", - "max_heap_size": "2G", - "release_channel": "release" - }, - "oot_options": { - "rom_file": "The Legend of Zelda - Ocarina of Time.z64", - "rom_start": True - }, - "dkc3_options": { - "rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc", - }, - "smw_options": { - "rom_file": "Super Mario World (USA).sfc", - }, - "zillion_options": { - "rom_file": "Zillion (UE) [!].sms", - # RetroArch doesn't make it easy to launch a game from the command line. - # You have to know the path to the emulator core library on the user's computer. - "rom_start": "retroarch", - }, - "pokemon_rb_options": { - "red_rom_file": "Pokemon Red (UE) [S][!].gb", - "blue_rom_file": "Pokemon Blue (UE) [S][!].gb", - "rom_start": True - }, - "ffr_options": { - "display_msgs": True, - }, - "lufia2ac_options": { - "rom_file": "Lufia II - Rise of the Sinistrals (USA).sfc", - }, - "tloz_options": { - "rom_file": "Legend of Zelda, The (U) (PRG0) [!].nes", - "rom_start": True, - "display_msgs": True, - }, - "wargroove_options": { - "root_directory": "C:/Program Files (x86)/Steam/steamapps/common/Wargroove" - }, - "mmbn3_options": { - "rom_file": "Mega Man Battle Network 3 - Blue Version (USA).gba", - "rom_start": True - }, - "adventure_options": { - "rom_file": "ADVNTURE.BIN", - "display_msgs": True, - "rom_start": True, - "rom_args": "" - }, - } - return options - - -def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsType: - for key, value in src.items(): - new_keys = keys.copy() - new_keys.append(key) - option_name = '.'.join(new_keys) - if key not in dest: - dest[key] = value - if filename.endswith("options.yaml"): - logging.info(f"Warning: {filename} is missing {option_name}") - elif isinstance(value, dict): - if not isinstance(dest.get(key, None), dict): - if filename.endswith("options.yaml"): - logging.info(f"Warning: {filename} has {option_name}, but it is not a dictionary. overwriting.") - dest[key] = value - else: - dest[key] = update_options(value, dest[key], filename, new_keys) - return dest +def get_default_options() -> Settings: # TODO: remove ~2 versions after 0.4.1 + return Settings(None) -@cache_argsless -def get_options() -> OptionsType: - filenames = ("options.yaml", "host.yaml") - locations: typing.List[str] = [] - if os.path.join(os.getcwd()) != local_path(): - locations += filenames # use files from cwd only if it's not the local_path - locations += [user_path(filename) for filename in filenames] - - for location in locations: - if os.path.exists(location): - with open(location) as f: - options = parse_yaml(f.read()) - return update_options(get_default_options(), options, location, list()) - - raise FileNotFoundError(f"Could not find {filenames[1]} to load options.") +get_options = get_settings # TODO: add a warning ~2 versions after 0.4.1 and remove once all games are ported def persistent_store(category: str, key: typing.Any, value: typing.Any): @@ -677,7 +541,7 @@ def get_fuzzy_ratio(word1: str, word2: str) -> float: ) -def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \ +def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]], suggest: str = "") \ -> typing.Optional[str]: def run(*args: str): return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None @@ -688,11 +552,43 @@ def run(*args: str): kdialog = which("kdialog") if kdialog: k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) - return run(kdialog, f"--title={title}", "--getopenfilename", ".", k_filters) + return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters) zenity = which("zenity") if zenity: z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) - return run(zenity, f"--title={title}", "--file-selection", *z_filters) + selection = (f'--filename="{suggest}',) if suggest else () + return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) + + # fall back to tk + try: + import tkinter + import tkinter.filedialog + except Exception as e: + logging.error('Could not load tkinter, which is likely not installed. ' + f'This attempt was made because open_filename was used for "{title}".') + raise e + else: + root = tkinter.Tk() + root.withdraw() + return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes), + initialfile=suggest or None) + + +def open_directory(title: str, suggest: str = "") -> typing.Optional[str]: + def run(*args: str): + return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None + + if is_linux: + # prefer native dialog + from shutil import which + kdialog = None#which("kdialog") + if kdialog: + return run(kdialog, f"--title={title}", "--getexistingdirectory", suggest or ".") + zenity = None#which("zenity") + if zenity: + z_filters = ("--directory",) + selection = (f'--filename="{suggest}',) if suggest else () + return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) # fall back to tk try: @@ -705,7 +601,7 @@ def run(*args: str): else: root = tkinter.Tk() root.withdraw() - return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes)) + return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None) def messagebox(title: str, text: str, error: bool = False) -> None: diff --git a/WebHost.py b/WebHost.py index 40d366a02f9e..45d017cf1f67 100644 --- a/WebHost.py +++ b/WebHost.py @@ -10,6 +10,7 @@ # in case app gets imported by something like gunicorn import Utils +import settings Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8 @@ -21,6 +22,7 @@ from WebHostLib.lttpsprites import update_sprites_lttp from WebHostLib.options import create as create_options_files +settings.no_gui = True configpath = os.path.abspath("config.yaml") if not os.path.exists(configpath): # fall back to config.yaml in home configpath = os.path.abspath(Utils.user_path('config.yaml')) @@ -72,6 +74,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]] with zipfile.ZipFile(zipfile_path) as zf: for zfile in zf.infolist(): if not zfile.is_dir() and "/docs/" in zfile.filename: + zfile.filename = os.path.basename(zfile.filename) zf.extract(zfile, target_path) else: source_path = Utils.local_path(os.path.dirname(world.__file__), "docs") diff --git a/WebHostLib/templates/islandFooter.html b/WebHostLib/templates/islandFooter.html index 243eea7c1a73..7b89c4a9e079 100644 --- a/WebHostLib/templates/islandFooter.html +++ b/WebHostLib/templates/islandFooter.html @@ -1,6 +1,6 @@ {% block footer %}