2023-10-03 00:44:19 +00:00
|
|
|
"""
|
|
|
|
A module containing context and functions relevant to running the client. This module should only be imported for type
|
|
|
|
checking or launching the client, otherwise it will probably cause circular import issues.
|
|
|
|
"""
|
|
|
|
|
|
|
|
import asyncio
|
2023-10-28 19:48:31 +00:00
|
|
|
import enum
|
2023-10-19 05:07:15 +00:00
|
|
|
import subprocess
|
2023-10-03 00:44:19 +00:00
|
|
|
from typing import Any, Dict, Optional
|
|
|
|
|
|
|
|
from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, server_loop, logger, gui_enabled
|
|
|
|
import Patch
|
|
|
|
import Utils
|
|
|
|
|
2023-10-27 01:55:46 +00:00
|
|
|
from . import BizHawkContext, ConnectionStatus, NotConnectedError, RequestFailedError, connect, disconnect, get_hash, \
|
|
|
|
get_script_version, get_system, ping
|
2023-10-03 00:44:19 +00:00
|
|
|
from .client import BizHawkClient, AutoBizHawkClientRegister
|
|
|
|
|
|
|
|
|
|
|
|
EXPECTED_SCRIPT_VERSION = 1
|
|
|
|
|
|
|
|
|
2023-10-28 19:48:31 +00:00
|
|
|
class AuthStatus(enum.IntEnum):
|
|
|
|
NOT_AUTHENTICATED = 0
|
|
|
|
NEED_INFO = 1
|
|
|
|
PENDING = 2
|
|
|
|
AUTHENTICATED = 3
|
|
|
|
|
|
|
|
|
2023-10-03 00:44:19 +00:00
|
|
|
class BizHawkClientCommandProcessor(ClientCommandProcessor):
|
|
|
|
def _cmd_bh(self):
|
|
|
|
"""Shows the current status of the client's connection to BizHawk"""
|
|
|
|
if isinstance(self.ctx, BizHawkClientContext):
|
|
|
|
if self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.NOT_CONNECTED:
|
|
|
|
logger.info("BizHawk Connection Status: Not Connected")
|
|
|
|
elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.TENTATIVE:
|
|
|
|
logger.info("BizHawk Connection Status: Tentatively Connected")
|
|
|
|
elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.CONNECTED:
|
|
|
|
logger.info("BizHawk Connection Status: Connected")
|
|
|
|
|
|
|
|
|
|
|
|
class BizHawkClientContext(CommonContext):
|
|
|
|
command_processor = BizHawkClientCommandProcessor
|
2023-10-28 19:48:31 +00:00
|
|
|
auth_status: AuthStatus
|
|
|
|
password_requested: bool
|
2023-10-03 00:44:19 +00:00
|
|
|
client_handler: Optional[BizHawkClient]
|
|
|
|
slot_data: Optional[Dict[str, Any]] = None
|
|
|
|
rom_hash: Optional[str] = None
|
|
|
|
bizhawk_ctx: BizHawkContext
|
|
|
|
|
|
|
|
watcher_timeout: float
|
|
|
|
"""The maximum amount of time the game watcher loop will wait for an update from the server before executing"""
|
|
|
|
|
|
|
|
def __init__(self, server_address: Optional[str], password: Optional[str]):
|
|
|
|
super().__init__(server_address, password)
|
2023-10-28 19:48:31 +00:00
|
|
|
self.auth_status = AuthStatus.NOT_AUTHENTICATED
|
|
|
|
self.password_requested = False
|
2023-10-03 00:44:19 +00:00
|
|
|
self.client_handler = None
|
|
|
|
self.bizhawk_ctx = BizHawkContext()
|
|
|
|
self.watcher_timeout = 0.5
|
|
|
|
|
|
|
|
def run_gui(self):
|
|
|
|
from kvui import GameManager
|
|
|
|
|
|
|
|
class BizHawkManager(GameManager):
|
|
|
|
base_title = "Archipelago BizHawk Client"
|
|
|
|
|
|
|
|
self.ui = BizHawkManager(self)
|
|
|
|
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
|
|
|
|
|
|
|
def on_package(self, cmd, args):
|
|
|
|
if cmd == "Connected":
|
|
|
|
self.slot_data = args.get("slot_data", None)
|
2023-10-28 19:48:31 +00:00
|
|
|
self.auth_status = AuthStatus.AUTHENTICATED
|
2023-10-03 00:44:19 +00:00
|
|
|
|
|
|
|
if self.client_handler is not None:
|
|
|
|
self.client_handler.on_package(self, cmd, args)
|
|
|
|
|
2024-05-23 00:03:42 +00:00
|
|
|
async def server_auth(self, password_requested: bool=False):
|
2023-10-28 19:48:31 +00:00
|
|
|
self.password_requested = password_requested
|
|
|
|
|
|
|
|
if self.bizhawk_ctx.connection_status != ConnectionStatus.CONNECTED:
|
|
|
|
logger.info("Awaiting connection to BizHawk before authenticating")
|
|
|
|
return
|
|
|
|
|
|
|
|
if self.client_handler is None:
|
|
|
|
return
|
|
|
|
|
|
|
|
# Ask handler to set auth
|
|
|
|
if self.auth is None:
|
|
|
|
self.auth_status = AuthStatus.NEED_INFO
|
|
|
|
await self.client_handler.set_auth(self)
|
|
|
|
|
|
|
|
# Handler didn't set auth, ask user for slot name
|
|
|
|
if self.auth is None:
|
|
|
|
await self.get_username()
|
|
|
|
|
|
|
|
if password_requested and not self.password:
|
|
|
|
self.auth_status = AuthStatus.NEED_INFO
|
|
|
|
await super(BizHawkClientContext, self).server_auth(password_requested)
|
|
|
|
|
|
|
|
await self.send_connect()
|
|
|
|
self.auth_status = AuthStatus.PENDING
|
|
|
|
|
2024-05-23 00:03:42 +00:00
|
|
|
async def disconnect(self, allow_autoreconnect: bool=False):
|
2023-10-28 19:48:31 +00:00
|
|
|
self.auth_status = AuthStatus.NOT_AUTHENTICATED
|
|
|
|
await super().disconnect(allow_autoreconnect)
|
|
|
|
|
2023-10-03 00:44:19 +00:00
|
|
|
|
|
|
|
async def _game_watcher(ctx: BizHawkClientContext):
|
|
|
|
showed_connecting_message = False
|
|
|
|
showed_connected_message = False
|
|
|
|
showed_no_handler_message = False
|
|
|
|
|
|
|
|
while not ctx.exit_event.is_set():
|
|
|
|
try:
|
|
|
|
await asyncio.wait_for(ctx.watcher_event.wait(), ctx.watcher_timeout)
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
pass
|
|
|
|
|
|
|
|
ctx.watcher_event.clear()
|
|
|
|
|
|
|
|
try:
|
|
|
|
if ctx.bizhawk_ctx.connection_status == ConnectionStatus.NOT_CONNECTED:
|
|
|
|
showed_connected_message = False
|
|
|
|
|
|
|
|
if not showed_connecting_message:
|
|
|
|
logger.info("Waiting to connect to BizHawk...")
|
|
|
|
showed_connecting_message = True
|
|
|
|
|
BizHawkClient: Add support for multiple concurrent instances (#2475)
This allows multiple client/connector pairs to run at the same time. It also includes a few other miscellaneous small changes that accumulated as I went. They can be split if desired
- Whatever the `client_socket:send` line (~440) was doing with that missing operator, it's no longer doing. Don't ask me how it was working before. Lua is witchcraft.
- Removed the `settimeout(2)` which causes the infamous emulator freeze (and replaced it with a `settimeout(0)` when the server socket is created). It appears to be unnecessary to set a timeout for discovering a client. Maybe at some point in time it was useful to keep the success rate for connecting high, but it seems to not be a problem if the timeout is 0 instead.
- Also updated the Emerald setup to remove mention of the freezing.
- Connector script now picks the first port that's not in use in a range of 5 ports.
- To summarize why I was previously under the impression that multiple running scripts would not detect when a port was in use:
1. Calling `socket.bind` in the existing script will first create an ipv6 socket.
2. A second concurrent script trying to bind to the same port would I think fail to create an ipv6 socket but then succeed in creating an ipv4 socket on the same port.
3. That second socket could never communicate with a client; extra clients would just bounce off the first script.
4. The third concurrent script will then fail on both and actually give an `address already in use` error.
- I'm not _really_ sure what's going on there. But forcing one or the other by calling `socket.tcp4()` or `socket.tcp6()` means that only one script will believe it has the port while any others will give `address already in use` as you'd expect.
- As a side note, our `socket.lua` is much wonkier than I had previously thought. I understand some parts were added for LADX and when BizHawk 2.9 came out, but as far back as the file's history in this repo, it has provided a strange, modified interface as compared to the file it was originally derived from, to no benefit as far as I can tell.
- The connector script closes `server` once it finds a client and opens a new one if the connection drops. I'm not sure this ultimately has an effect, but it seems more proper.
- If the connector script's main function returns because of some error or refusal to proceed, the script no longer tries to resume the coroutine it was part of, which would flood the log with irrelevant errors.
- Creating `SyncError`s in `guarded_read` and `guarded_write` would raise its own error because the wrong variable was being used in its message.
- A call to `_bizhawk.connect` can take a while as the client tries the possible ports. There's a modification that will wait on either the `connect` or the exit event. And if the exit event fires while still looking for a connector script, this cancels the `connect` so the window can close.
- Related: It takes 2-3 seconds for a call to `asyncio.open_connection` to come back with any sort of response on my machine, which can be significant now that we're trying multiple ports in sequence. I guess it could fire off 5 tasks at once. Might cause some weirdness if there exist multiple scripts and multiple clients looking for each other at the same time.
- Also related: The first time a client attempts to connect to a script, they accept each other and start communicating as expected. The second client to try that port seems to believe it connects and will then time out on the first message. And then all subsequent attempts to connect to that port by any client will be refused (as expected) until the script shuts down or restarts. I haven't been able to explain this behavior. It adds more time to a client's search for a script, but doesn't ultimately cause problems.
2023-11-23 14:00:46 +00:00
|
|
|
# Since a call to `connect` can take a while to return, this will cancel connecting
|
|
|
|
# if the user has decided to close the client.
|
|
|
|
connect_task = asyncio.create_task(connect(ctx.bizhawk_ctx), name="BizHawkConnect")
|
|
|
|
exit_task = asyncio.create_task(ctx.exit_event.wait(), name="ExitWait")
|
|
|
|
await asyncio.wait([connect_task, exit_task], return_when=asyncio.FIRST_COMPLETED)
|
|
|
|
|
|
|
|
if exit_task.done():
|
|
|
|
connect_task.cancel()
|
|
|
|
return
|
|
|
|
|
|
|
|
if not connect_task.result():
|
|
|
|
# Failed to connect
|
2023-10-03 00:44:19 +00:00
|
|
|
continue
|
|
|
|
|
|
|
|
showed_no_handler_message = False
|
|
|
|
|
|
|
|
script_version = await get_script_version(ctx.bizhawk_ctx)
|
|
|
|
|
|
|
|
if script_version != EXPECTED_SCRIPT_VERSION:
|
2024-05-23 00:03:42 +00:00
|
|
|
logger.info(f"Connector script is incompatible. Expected version {EXPECTED_SCRIPT_VERSION} but "
|
|
|
|
f"got {script_version}. Disconnecting.")
|
2023-10-03 00:44:19 +00:00
|
|
|
disconnect(ctx.bizhawk_ctx)
|
|
|
|
continue
|
|
|
|
|
|
|
|
showed_connecting_message = False
|
|
|
|
|
|
|
|
await ping(ctx.bizhawk_ctx)
|
|
|
|
|
|
|
|
if not showed_connected_message:
|
|
|
|
showed_connected_message = True
|
|
|
|
logger.info("Connected to BizHawk")
|
|
|
|
|
|
|
|
rom_hash = await get_hash(ctx.bizhawk_ctx)
|
|
|
|
if ctx.rom_hash is not None and ctx.rom_hash != rom_hash:
|
2023-10-28 19:48:31 +00:00
|
|
|
if ctx.server is not None and not ctx.server.socket.closed:
|
2023-10-03 00:44:19 +00:00
|
|
|
logger.info(f"ROM changed. Disconnecting from server.")
|
|
|
|
|
|
|
|
ctx.auth = None
|
|
|
|
ctx.username = None
|
2023-10-28 19:48:31 +00:00
|
|
|
ctx.client_handler = None
|
2024-06-04 12:06:41 +00:00
|
|
|
ctx.finished_game = False
|
2023-10-28 19:48:31 +00:00
|
|
|
await ctx.disconnect(False)
|
2023-10-03 00:44:19 +00:00
|
|
|
ctx.rom_hash = rom_hash
|
|
|
|
|
|
|
|
if ctx.client_handler is None:
|
|
|
|
system = await get_system(ctx.bizhawk_ctx)
|
|
|
|
ctx.client_handler = await AutoBizHawkClientRegister.get_handler(ctx, system)
|
|
|
|
|
|
|
|
if ctx.client_handler is None:
|
|
|
|
if not showed_no_handler_message:
|
2024-05-27 00:27:04 +00:00
|
|
|
logger.info("No handler was found for this game. Double-check that the apworld is installed "
|
|
|
|
"correctly and that you loaded the right ROM file.")
|
2023-10-03 00:44:19 +00:00
|
|
|
showed_no_handler_message = True
|
|
|
|
continue
|
|
|
|
else:
|
|
|
|
showed_no_handler_message = False
|
|
|
|
logger.info(f"Running handler for {ctx.client_handler.game}")
|
|
|
|
|
|
|
|
except RequestFailedError as exc:
|
|
|
|
logger.info(f"Lost connection to BizHawk: {exc.args[0]}")
|
|
|
|
continue
|
2023-10-27 01:55:46 +00:00
|
|
|
except NotConnectedError:
|
|
|
|
continue
|
2023-10-03 00:44:19 +00:00
|
|
|
|
2023-10-28 19:48:31 +00:00
|
|
|
# Server auth
|
|
|
|
if ctx.server is not None and not ctx.server.socket.closed:
|
|
|
|
if ctx.auth_status == AuthStatus.NOT_AUTHENTICATED:
|
|
|
|
Utils.async_start(ctx.server_auth(ctx.password_requested))
|
|
|
|
else:
|
|
|
|
ctx.auth_status = AuthStatus.NOT_AUTHENTICATED
|
2023-10-03 00:44:19 +00:00
|
|
|
|
2023-10-28 19:48:31 +00:00
|
|
|
# Call the handler's game watcher
|
2023-10-03 00:44:19 +00:00
|
|
|
await ctx.client_handler.game_watcher(ctx)
|
|
|
|
|
|
|
|
|
|
|
|
async def _run_game(rom: str):
|
2023-10-19 05:07:15 +00:00
|
|
|
import os
|
|
|
|
auto_start = Utils.get_settings().bizhawkclient_options.rom_start
|
|
|
|
|
|
|
|
if auto_start is True:
|
|
|
|
emuhawk_path = Utils.get_settings().bizhawkclient_options.emuhawk_path
|
2023-11-28 21:56:27 +00:00
|
|
|
subprocess.Popen(
|
|
|
|
[
|
|
|
|
emuhawk_path,
|
|
|
|
f"--lua={Utils.local_path('data', 'lua', 'connector_bizhawk_generic.lua')}",
|
|
|
|
os.path.realpath(rom),
|
|
|
|
],
|
|
|
|
cwd=Utils.local_path("."),
|
|
|
|
stdin=subprocess.DEVNULL,
|
|
|
|
stdout=subprocess.DEVNULL,
|
|
|
|
stderr=subprocess.DEVNULL,
|
|
|
|
)
|
2023-10-19 05:07:15 +00:00
|
|
|
elif isinstance(auto_start, str):
|
|
|
|
import shlex
|
|
|
|
|
2023-11-28 21:56:27 +00:00
|
|
|
subprocess.Popen(
|
|
|
|
[
|
|
|
|
*shlex.split(auto_start),
|
|
|
|
os.path.realpath(rom)
|
|
|
|
],
|
|
|
|
cwd=Utils.local_path("."),
|
|
|
|
stdin=subprocess.DEVNULL,
|
|
|
|
stdout=subprocess.DEVNULL,
|
|
|
|
stderr=subprocess.DEVNULL
|
|
|
|
)
|
2023-10-03 00:44:19 +00:00
|
|
|
|
|
|
|
|
|
|
|
async def _patch_and_run_game(patch_file: str):
|
2024-04-14 01:26:25 +00:00
|
|
|
try:
|
|
|
|
metadata, output_file = Patch.create_rom_file(patch_file)
|
|
|
|
Utils.async_start(_run_game(output_file))
|
|
|
|
except Exception as exc:
|
|
|
|
logger.exception(exc)
|
2023-10-03 00:44:19 +00:00
|
|
|
|
|
|
|
|
|
|
|
def launch() -> None:
|
|
|
|
async def main():
|
|
|
|
parser = get_base_parser()
|
|
|
|
parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an Archipelago patch file")
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
ctx = BizHawkClientContext(args.connect, args.password)
|
|
|
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
|
|
|
|
|
|
|
if gui_enabled:
|
|
|
|
ctx.run_gui()
|
|
|
|
ctx.run_cli()
|
|
|
|
|
|
|
|
if args.patch_file != "":
|
|
|
|
Utils.async_start(_patch_and_run_game(args.patch_file))
|
|
|
|
|
|
|
|
watcher_task = asyncio.create_task(_game_watcher(ctx), name="GameWatcher")
|
|
|
|
|
|
|
|
try:
|
|
|
|
await watcher_task
|
|
|
|
except Exception as e:
|
2024-03-11 06:16:48 +00:00
|
|
|
logger.exception(e)
|
2023-10-03 00:44:19 +00:00
|
|
|
|
|
|
|
await ctx.exit_event.wait()
|
|
|
|
await ctx.shutdown()
|
|
|
|
|
|
|
|
Utils.init_logging("BizHawkClient", exception_logger="Client")
|
|
|
|
import colorama
|
|
|
|
colorama.init()
|
|
|
|
asyncio.run(main())
|
|
|
|
colorama.deinit()
|