LADX: Client Fixes (#1934)

This commit is contained in:
zig-for 2023-07-09 06:17:24 -07:00 committed by GitHub
parent 736945658a
commit 36474c3ccc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 200 additions and 87 deletions

View File

@ -9,16 +9,19 @@ if __name__ == "__main__":
import asyncio import asyncio
import base64 import base64
import binascii import binascii
import colorama
import io import io
import logging import os
import re
import select import select
import shlex
import socket import socket
import struct
import sys
import subprocess
import time import time
import typing import typing
import urllib
import colorama
import struct
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger, from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
server_loop) 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.Locations import get_locations_to_id, meta_to_name
from worlds.ladx.Tracker import LocationTracker, MagpieBridge from worlds.ladx.Tracker import LocationTracker, MagpieBridge
class GameboyException(Exception): class GameboyException(Exception):
pass pass
@ -115,17 +119,17 @@ class RAGameboy():
assert (self.socket) assert (self.socket)
self.socket.setblocking(False) self.socket.setblocking(False)
def get_retroarch_version(self): async def send_command(self, command, timeout=1.0):
self.send(b'VERSION\n') self.send(f'{command}\n')
select.select([self.socket], [], []) response_str = await self.async_recv()
response_str, addr = self.socket.recvfrom(16) self.check_command_response(command, response_str)
return response_str.rstrip() return response_str.rstrip()
def get_retroarch_status(self, timeout): async def get_retroarch_version(self):
self.send(b'GET_STATUS\n') return await self.send_command("VERSION")
select.select([self.socket], [], [], timeout)
response_str, addr = self.socket.recvfrom(1000, ) async def get_retroarch_status(self):
return response_str.rstrip() return await self.send_command("GET_STATUS")
def set_cache_limits(self, cache_start, cache_size): def set_cache_limits(self, cache_start, cache_size):
self.cache_start = cache_start self.cache_start = cache_start
@ -141,8 +145,8 @@ class RAGameboy():
response, _ = self.socket.recvfrom(4096) response, _ = self.socket.recvfrom(4096)
return response return response
async def async_recv(self): async def async_recv(self, timeout=1.0):
response = await asyncio.get_event_loop().sock_recv(self.socket, 4096) response = await asyncio.wait_for(asyncio.get_event_loop().sock_recv(self.socket, 4096), timeout)
return response return response
async def check_safe_gameplay(self, throw=True): async def check_safe_gameplay(self, throw=True):
@ -169,6 +173,8 @@ class RAGameboy():
raise InvalidEmulatorStateError() raise InvalidEmulatorStateError()
return False return False
if not await check_wram(): if not await check_wram():
if throw:
raise InvalidEmulatorStateError()
return False return False
return True return True
@ -227,20 +233,30 @@ class RAGameboy():
return r 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): def read_memory(self, address, size=1):
command = "READ_CORE_MEMORY" command = "READ_CORE_MEMORY"
self.send(f'{command} {hex(address)} {size}\n') self.send(f'{command} {hex(address)} {size}\n')
response = self.recv() response = self.recv()
self.check_command_response(command, response)
splits = response.decode().split(" ", 2) splits = response.decode().split(" ", 2)
assert (splits[0] == command)
# Ignore the address for now # Ignore the address for now
if splits[2][:2] == "-1":
# TODO: transform to bytes
if splits[2][:2] == "-1" or splits[0] != "READ_CORE_MEMORY":
raise BadRetroArchResponse() raise BadRetroArchResponse()
# TODO: check response address, check hex behavior between RA and BH
return bytearray.fromhex(splits[2]) return bytearray.fromhex(splits[2])
async def async_read_memory(self, address, size=1): async def async_read_memory(self, address, size=1):
@ -248,14 +264,21 @@ class RAGameboy():
self.send(f'{command} {hex(address)} {size}\n') self.send(f'{command} {hex(address)} {size}\n')
response = await self.async_recv() response = await self.async_recv()
self.check_command_response(command, response)
response = response[:-1] response = response[:-1]
splits = response.decode().split(" ", 2) splits = response.decode().split(" ", 2)
try:
response_addr = int(splits[1], 16)
except ValueError:
raise BadRetroArchResponse()
assert (splits[0] == command) if response_addr != address:
# Ignore the address for now raise BadRetroArchResponse()
# TODO: transform to bytes ret = bytearray.fromhex(splits[2])
return bytearray.fromhex(splits[2]) if len(ret) > size:
raise BadRetroArchResponse()
return ret
def write_memory(self, address, bytes): def write_memory(self, address, bytes):
command = "WRITE_CORE_MEMORY" command = "WRITE_CORE_MEMORY"
@ -263,7 +286,7 @@ class RAGameboy():
self.send(f'{command} {hex(address)} {" ".join(hex(b) for b in bytes)}') self.send(f'{command} {hex(address)} {" ".join(hex(b) for b in bytes)}')
select.select([self.socket], [], []) select.select([self.socket], [], [])
response, _ = self.socket.recvfrom(4096) response, _ = self.socket.recvfrom(4096)
self.check_command_response(command, response)
splits = response.decode().split(" ", 3) splits = response.decode().split(" ", 3)
assert (splits[0] == command) assert (splits[0] == command)
@ -281,6 +304,9 @@ class LinksAwakeningClient():
pending_deathlink = False pending_deathlink = False
deathlink_debounce = True deathlink_debounce = True
recvd_checks = {} recvd_checks = {}
retroarch_address = None
retroarch_port = None
gameboy = None
def msg(self, m): def msg(self, m):
logger.info(m) logger.info(m)
@ -288,50 +314,47 @@ class LinksAwakeningClient():
self.gameboy.send(s) self.gameboy.send(s)
def __init__(self, retroarch_address="127.0.0.1", retroarch_port=55355): 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): 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: while True:
try: try:
version = self.gameboy.get_retroarch_version() version = await self.gameboy.get_retroarch_version()
NO_CONTENT = b"GET_STATUS CONTENTLESS" NO_CONTENT = b"GET_STATUS CONTENTLESS"
status = NO_CONTENT status = NO_CONTENT
core_type = None core_type = None
GAME_BOY = b"game_boy" GAME_BOY = b"game_boy"
while status == NO_CONTENT or core_type != GAME_BOY: while status == NO_CONTENT or core_type != GAME_BOY:
try: status = await self.gameboy.get_retroarch_status()
status = self.gameboy.get_retroarch_status(0.1) if status.count(b" ") < 2:
if status.count(b" ") < 2: await asyncio.sleep(1.0)
await asyncio.sleep(1.0) continue
continue GET_STATUS, PLAYING, info = status.split(b" ", 2)
if status.count(b",") < 2:
GET_STATUS, PLAYING, info = status.split(b" ", 2) await asyncio.sleep(1.0)
if status.count(b",") < 2: continue
await asyncio.sleep(1.0) core_type, rom_name, self.game_crc = info.split(b",", 2)
continue if core_type != GAME_BOY:
core_type, rom_name, self.game_crc = info.split(b",", 2) logger.info(
if core_type != GAME_BOY: f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?")
logger.info( await asyncio.sleep(1.0)
f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?") continue
await asyncio.sleep(1.0) logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}")
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)
return return
except ConnectionResetError: except (BlockingIOError, TimeoutError, ConnectionResetError):
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
pass pass
self.stop_bizhawk_spam = False
def reset_auth(self): async def reset_auth(self):
auth = binascii.hexlify(self.gameboy.read_memory(0x0134, 12)).decode() auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
if self.auth:
assert (auth == self.auth)
self.auth = auth self.auth = auth
async def wait_and_init_tracker(self): async def wait_and_init_tracker(self):
@ -367,11 +390,14 @@ class LinksAwakeningClient():
status = self.gameboy.write_memory(LAClientConstants.wLinkStatusBits, [status]) status = self.gameboy.write_memory(LAClientConstants.wLinkStatusBits, [status])
self.gameboy.write_memory(LAClientConstants.wRecvIndex, struct.pack(">H", next_index)) self.gameboy.write_memory(LAClientConstants.wRecvIndex, struct.pack(">H", next_index))
should_reset_auth = False
async def wait_for_game_ready(self): async def wait_for_game_ready(self):
logger.info("Waiting on game to be in valid state...") logger.info("Waiting on game to be in valid state...")
while not await self.gameboy.check_safe_gameplay(throw=False): while not await self.gameboy.check_safe_gameplay(throw=False):
pass if self.should_reset_auth:
logger.info("Ready!") self.should_reset_auth = False
raise GameboyException("Resetting due to wrong archipelago server")
logger.info("Game connection ready!")
async def is_victory(self): async def is_victory(self):
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1 return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
@ -398,7 +424,7 @@ class LinksAwakeningClient():
if await self.is_victory(): if await self.is_victory():
await win_cb() 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 # Play back one at a time
if recv_index in self.recvd_checks: if recv_index in self.recvd_checks:
@ -480,6 +506,15 @@ class LinksAwakeningContext(CommonContext):
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}] message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
await self.send_msgs(message) 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 ENABLE_DEATHLINK = False
async def send_deathlink(self): async def send_deathlink(self):
if self.ENABLE_DEATHLINK: if self.ENABLE_DEATHLINK:
@ -511,8 +546,17 @@ class LinksAwakeningContext(CommonContext):
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
await super(LinksAwakeningContext, self).server_auth(password_requested) 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 self.auth = self.client.auth
await self.get_username()
await self.send_connect() await self.send_connect()
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
@ -520,9 +564,13 @@ class LinksAwakeningContext(CommonContext):
self.game = self.slot_info[self.slot].game self.game = self.slot_info[self.slot].game
# TODO - use watcher_event # TODO - use watcher_event
if cmd == "ReceivedItems": 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 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() item_id_lookup = get_locations_to_id()
async def run_game_loop(self): async def run_game_loop(self):
@ -539,17 +587,31 @@ class LinksAwakeningContext(CommonContext):
if self.magpie_enabled: if self.magpie_enabled:
self.magpie_task = asyncio.create_task(self.magpie.serve()) self.magpie_task = asyncio.create_task(self.magpie.serve())
# yield to allow UI to start # yield to allow UI to start
await asyncio.sleep(0) await asyncio.sleep(0)
while True: while True:
try: try:
# TODO: cancel all client tasks # 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() 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() 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() await self.client.wait_and_init_tracker()
while True: while True:
@ -560,39 +622,59 @@ class LinksAwakeningContext(CommonContext):
self.last_resend = now self.last_resend = now
await self.send_checks() await self.send_checks()
if self.magpie_enabled: if self.magpie_enabled:
self.magpie.set_checks(self.client.tracker.all_checks) try:
await self.magpie.set_item_tracker(self.client.item_tracker) self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.send_gps(self.client.gps_tracker) 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: def run_game(romfile: str) -> None:
time.sleep(1.0) auto_start = typing.cast(typing.Union[bool, str],
pass 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(): async def main():
parser = get_base_parser(description="Link's Awakening Client.") parser = get_base_parser(description="Link's Awakening Client.")
parser.add_argument("--url", help="Archipelago connection url") 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("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
parser.add_argument('diff_file', default="", type=str, nargs="?", parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a .apladx Archipelago Binary Patch file') help='Path to a .apladx Archipelago Binary Patch file')
args = parser.parse_args() args = parser.parse_args()
logger.info(args)
if args.diff_file: if args.diff_file:
import Patch import Patch
logger.info("patch file was supplied - creating rom...") logger.info("patch file was supplied - creating rom...")
meta, rom_file = Patch.create_rom_file(args.diff_file) meta, rom_file = Patch.create_rom_file(args.diff_file)
if "server" in meta: if "server" in meta and not args.connect:
args.url = meta["server"] args.connect = meta["server"]
logger.info(f"wrote rom file to {rom_file}") 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) ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
@ -604,6 +686,10 @@ async def main():
ctx.run_gui() ctx.run_gui()
ctx.run_cli() 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.exit_event.wait()
await ctx.shutdown() await ctx.shutdown()

View File

@ -46,10 +46,10 @@ function get_socket_path()
local pwd = (io.popen and io.popen("cd"):read'*l') or "." local pwd = (io.popen and io.popen("cd"):read'*l') or "."
return pwd .. "/" .. arch .. "/socket-" .. the_os .. "-" .. get_lua_version() .. "." .. ext return pwd .. "/" .. arch .. "/socket-" .. the_os .. "-" .. get_lua_version() .. "." .. ext
end end
local lua_version = get_lua_version()
local socket_path = get_socket_path() local socket_path = get_socket_path()
local socket = assert(package.loadlib(socket_path, "luaopen_socket_core"))() local socket = assert(package.loadlib(socket_path, "luaopen_socket_core"))()
local event = event
-- http://lua-users.org/wiki/ModulesTutorial -- http://lua-users.org/wiki/ModulesTutorial
local M = {} local M = {}
if setfenv then if setfenv then
@ -59,6 +59,20 @@ else
end end
M.socket = socket 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 -- Exported auxiliar functions

View File

@ -39,8 +39,23 @@ class LinksAwakeningSettings(settings.Group):
description = "LADX ROM File" description = "LADX ROM File"
md5s = [LADXDeltaPatch.hash] 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): class LinksAwakeningWebWorld(WebWorld):
tutorials = [Tutorial( 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 # Kind of kludge, make it possible for the location to differentiate between local and remote items
loc.ladxr_item.location_owner = self.player 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_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") out_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}.gbc")
parser = get_parser() parser = get_parser()
args = parser.parse_args([rom_name, "-o", out_name, "--dump"]) args = parser.parse_args([rom_name, "-o", out_name, "--dump"])