diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py index 7a5972eb..8f1cbc1e 100644 --- a/LinksAwakeningClient.py +++ b/LinksAwakeningClient.py @@ -9,16 +9,19 @@ if __name__ == "__main__": 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.LADXR.checkMetadata import checkMetadataTable 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 @@ class RAGameboy(): 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 @@ class RAGameboy(): 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 @@ class RAGameboy(): raise InvalidEmulatorStateError() return False if not await check_wram(): + if throw: + raise InvalidEmulatorStateError() return False return True @@ -227,20 +233,30 @@ class RAGameboy(): 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() + self.check_command_response(command, response) + splits = response.decode().split(" ", 2) - - assert (splits[0] == command) # 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 @@ class RAGameboy(): 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 @@ class RAGameboy(): 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 @@ class LinksAwakeningClient(): 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 @@ class LinksAwakeningClient(): 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 @@ class LinksAwakeningClient(): 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 @@ class LinksAwakeningContext(CommonContext): 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 @@ class LinksAwakeningContext(CommonContext): 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 @@ class LinksAwakeningContext(CommonContext): 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 @@ class LinksAwakeningContext(CommonContext): 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 @@ class LinksAwakeningContext(CommonContext): 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/data/lua/socket.lua b/data/lua/socket.lua index 0005c327..72ffe0c8 100644 --- a/data/lua/socket.lua +++ b/data/lua/socket.lua @@ -46,10 +46,10 @@ function get_socket_path() local pwd = (io.popen and io.popen("cd"):read'*l') or "." return pwd .. "/" .. arch .. "/socket-" .. the_os .. "-" .. get_lua_version() .. "." .. ext end - +local lua_version = get_lua_version() local socket_path = get_socket_path() local socket = assert(package.loadlib(socket_path, "luaopen_socket_core"))() - +local event = event -- http://lua-users.org/wiki/ModulesTutorial local M = {} if setfenv then @@ -59,6 +59,20 @@ else end M.socket = socket +-- Bizhawk <= 2.8 has an issue where resetting the lua doesn't close the socket +-- ...to get around this, we register an exit handler to close the socket first +if lua_version == '5-1' then + local old_udp = socket.udp + function udp(self) + s = old_udp(self) + function close_socket(self) + s:close() + end + event.onexit(close_socket) + return s + end + socket.udp = udp +end ----------------------------------------------------------------------------- -- Exported auxiliar functions diff --git a/worlds/ladx/__init__.py b/worlds/ladx/__init__.py index 84fd235c..f1c3ba0d 100644 --- a/worlds/ladx/__init__.py +++ b/worlds/ladx/__init__.py @@ -39,8 +39,23 @@ class LinksAwakeningSettings(settings.Group): description = "LADX ROM File" md5s = [LADXDeltaPatch.hash] - rom_file: RomFile = RomFile(RomFile.copy_to) + class RomStart(str): + """ + Set this to false to never autostart a rom (such as after patching) + true for operating system default program + Alternatively, a path to a program to open the .gbc file with + Examples: + Retroarch: + rom_start: "C:/RetroArch-Win64/retroarch.exe -L sameboy" + BizHawk: + rom_start: "C:/BizHawk-2.9-win-x64/EmuHawk.exe --lua=data/lua/connector_ladx_bizhawk.lua" + """ + class DisplayMsgs(settings.Bool): + """Display message inside of Bizhawk""" + + rom_file: RomFile = RomFile(RomFile.copy_to) + rom_start: typing.Union[RomStart, bool] = True class LinksAwakeningWebWorld(WebWorld): tutorials = [Tutorial( @@ -451,12 +466,10 @@ class LinksAwakeningWorld(World): # Kind of kludge, make it possible for the location to differentiate between local and remote items loc.ladxr_item.location_owner = self.player - rom_name = "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc" + rom_name = Rom.get_base_rom_path() out_name = f"AP-{self.multiworld.seed_name}-P{self.player}-{self.multiworld.player_name[self.player]}.gbc" out_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}.gbc") - - parser = get_parser() args = parser.parse_args([rom_name, "-o", out_name, "--dump"])