Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 156 additions & 36 deletions MultiWorld.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,41 +118,132 @@ def _ensure_writable_kivy_data(src: str) -> str:
os.makedirs(os.environ["KIVY_HOME"], exist_ok=True)


def run_client(*args, queue=None):
def make_arg_parser() -> ArgumentParser:
"""Build the MultiWorldGG argument parser.

The optional positional is what OS file associations and the
archipelago://-style URL protocols hand us: every registered suffix and
protocol points at `MultiWorldGG.exe "%1"`."""
parser = ArgumentParser()
parser.add_argument("launch_file", type=str, default=None, nargs="?", metavar="PATCH_FILE|URL",
help="A patch file to route to its game's client (OS file-association double-click), "
"a file handled by a tool component (e.g. a .archipelago multidata for the Host), "
"or an archipelago:// launch URL")
parser.add_argument("--game", type=str, default=None, required=False, help="The game module to launch\nGame Name will not work, use the apworld abbreviation")
parser.add_argument("--server-address", type=str, default=None, required=False, help="The server address to connect to")
parser.add_argument("--slot-name", type=str, default=None, required=False, help="The slot name to connect to")
parser.add_argument("--password", type=str, default=None, required=False, help="The password to connect to")
parser.add_argument("--update-modules", action="store_true", default=False, required=False, help="Whether to update modules")
parser.add_argument("--worlds", nargs="+", default=None, required=False, help="List of worlds to update")
parser.add_argument("--loglevel", default="debug",
choices=['debug', 'info', 'warning', 'error', 'critical'],
help="Set the logging level")
parser.add_argument("--frontend", default="gui", choices=["gui", "tui"],
help="Which frontend to launch: 'gui' (Kivy desktop, default) or 'tui' (Textual terminal)")
# Internal: set by Utils._restart_client_with_args() so a second launch
# failure surfaces an error dialog instead of looping forever.
parser.add_argument("--no-restart", action="store_true", default=False,
help=argparse.SUPPRESS)
return parser


async def _route_module_when_ui_ready(module_name: str, timeout: float = 30.0, **launch_kwargs) -> None:
"""Launch a world module's client once the launcher frontend is up.

Beta clients cannot run standalone — they take over the launcher UI — so
this mirrors the GUI's own launch flow (mwgg_gui launcher._launch_module):
wait for the frontend, then route through Utils.discover_and_launch_module,
which installs the world on demand and forwards `launch_kwargs` (e.g.
patch_file from a double-clicked patch, or server_address from a CLI
--game launch) to the resolved client. The screen-flip hooks are
feature-detected so the TUI (or an older GUI) simply skips them."""
from frontend_protocol import resolve_frontend_class

logger = logging.getLogger("MultiWorld")
loop = asyncio.get_event_loop()
deadline = loop.time() + timeout
app = None
while loop.time() < deadline:
app = getattr(resolve_frontend_class(), "_active_instance", None)
if app is not None and (getattr(app, "root", None) is not None # Kivy: root built
or getattr(app, "is_running", False)): # Textual: running
break
await asyncio.sleep(0.2)
if app is None:
logger.error(f"Frontend did not come up; cannot launch module {module_name}")
return

pre_hook = getattr(app, "client_console_init", None)
if callable(pre_hook):
try:
pre_hook()
except Exception:
logger.exception("client_console_init failed; continuing module launch")

def ready_callback(*_cb_args):
try:
console_init = getattr(app, "console_init", None)
change_screen = getattr(app, "change_screen", None)
if callable(console_init):
console_init()
if callable(change_screen):
change_screen("console")
except Exception:
logger.exception("Could not switch to the console screen after module launch")

def error_callback(*_cb_args):
logger.error(f"Failed to launch a client for module {module_name}")

from Utils import discover_and_launch_module
try:
discover_and_launch_module(module_name=module_name,
ready_callback=ready_callback,
error_callback=error_callback,
**launch_kwargs)
except Exception:
logger.exception(f"Module launch failed for {module_name}")


def run_client(args=None, queue=None):
"""Start the MWGG client"""

async def main(args: list[str]):
async def main(args):
from CommonClient import InitContext

logger = logging.getLogger("MultiWorld")
ctx = InitContext()

# Check if a specific module was requested

# Resolve a requested module launch (CLI --game, or a routed patch
# file). Beta clients take over the launcher UI rather than running
# standalone, so the launch itself is deferred until the frontend is up.
route_module = None
route_kwargs = {}
try:
if args and args.game and args.server_address:
logger.info(f"Attempting to launch game: {args.game}")
from Utils import get_available_worlds, discover_and_launch_module
from Utils import get_available_worlds

if args.game not in get_available_worlds():
raise Exception(f"Game {args.game} not found in available worlds")
if args.game in get_available_worlds():
route_module = args.game
route_kwargs = {"server_address": args.server_address,
"_restarted": getattr(args, "no_restart", False)}
else:
logger.error(f"Game {args.game} not found in available worlds; falling back to launcher")
elif args and getattr(args, "patch_module", None):
route_module = args.patch_module
route_kwargs = {"patch_file": args.patch_file}
except Exception:
logger.exception("Could not resolve requested module launch; falling back to launcher")

# Try to launch the module via entrypoints
try:
discover_and_launch_module(module_name=args.game,
server_address=args.server_address,
_restarted=getattr(args, "no_restart", False))
return # Module takeover successful, exit initial client
except Exception as e:
logger.error(f"Module launch failed: {e}")
# Fall back to initial client
logger.info("Falling back to launcher")
except Exception as e:
pass

# Default initial client behavior
logger.info("Launching default GUI")
try:
ctx.run_gui(splash_queue=queue)
if route_module:
# Keep a reference so the routing task isn't garbage-collected.
routing_task = asyncio.create_task( # noqa: F841
_route_module_when_ui_ready(route_module, **route_kwargs),
name="ModuleRouting")
await ctx.exit_event.wait()
except Exception as e:
logger.error(f"Error during GUI execution: {e}", exc_info=True)
Expand Down Expand Up @@ -191,22 +282,7 @@ async def main(args: list[str]):
freeze_support()

# Parse the command line arguments
parser = ArgumentParser()
parser.add_argument("--game", type=str, default=None, required=False, help="The game module to launch\nGame Name will not work, use the apworld abbreviation")
parser.add_argument("--server-address", type=str, default=None, required=False, help="The server address to connect to")
parser.add_argument("--slot-name", type=str, default=None, required=False, help="The slot name to connect to")
parser.add_argument("--password", type=str, default=None, required=False, help="The password to connect to")
parser.add_argument("--update-modules", action="store_true", default=False, required=False, help="Whether to update modules")
parser.add_argument("--worlds", nargs="+", default=None, required=False, help="List of worlds to update")
parser.add_argument("--loglevel", default="debug",
choices=['debug', 'info', 'warning', 'error', 'critical'],
help="Set the logging level")
parser.add_argument("--frontend", default="gui", choices=["gui", "tui"],
help="Which frontend to launch: 'gui' (Kivy desktop, default) or 'tui' (Textual terminal)")
# Internal: set by Utils._restart_client_with_args() so a second launch
# failure surfaces an error dialog instead of looping forever.
parser.add_argument("--no-restart", action="store_true", default=False,
help=argparse.SUPPRESS)
parser = make_arg_parser()

if sys.argv[1:]:
args = parser.parse_args(sys.argv[1:])
Expand Down Expand Up @@ -287,5 +363,49 @@ async def main(args: list[str]):
except Exception as e:
logger.warning(f"Could not scan custom_worlds on launch: {e}", exc_info=True)

# Route a double-clicked / positional file before the GUI comes up. Patch
# containers carry their game name in their root archipelago.json (same
# pre-load pattern as Patch.py), which resolves to a world module without
# importing any worlds; the client launch itself is deferred until the
# launcher UI is running (beta clients take over the launcher UI). Files
# claimed by a builtin tool component (.archipelago/.mwgg/.zip multidata ->
# Host) launch that component directly instead.
args.patch_module = None
args.patch_file = None
if args.launch_file:
if args.launch_file.startswith(("archipelago://", "mwgg://", "multiworldgg://")):
# Launch URLs open the launcher GUI; connection details are entered there.
logger.info(f"Launched with URL {args.launch_file}; opening launcher.")
elif os.path.isfile(args.launch_file):
from Utils import read_patch_game_name
game_name = read_patch_game_name(args.launch_file)
if game_name:
from mwgg_igdb import GameIndex
module_name = GameIndex.get_module_for_game(game_name)
if module_name:
args.patch_module = module_name
args.patch_file = os.path.abspath(args.launch_file)
logger.info(f"Routing patch file {args.launch_file} to {game_name} ({module_name})")
else:
logger.warning(f"No installed or indexed world handles game {game_name!r} "
f"from {args.launch_file}; opening launcher.")
else:
from worlds.LauncherComponents import identify, get_exe, launch_exe
component = identify(args.launch_file)
if component is not None:
logger.info(f"Routing {args.launch_file} to component {component.display_name}")
if component.func:
component.func(args.launch_file)
sys.exit(0)
exe = get_exe(component)
if exe:
launch_exe([*exe, args.launch_file], component.cli)
sys.exit(0)
logger.warning(f"Component {component.display_name} is not executable; opening launcher.")
else:
logger.warning(f"Could not identify a handler for {args.launch_file}; opening launcher.")
else:
logger.warning(f"File not found: {args.launch_file}; opening launcher.")

# Run the main client in the current process
run_client(args, queue=splash_queue)
55 changes: 46 additions & 9 deletions Utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,22 @@ def discover_custom_world_module(custom_world: Path) -> Optional[str]:
return None


def read_patch_game_name(patch_path: str) -> Optional[str]:
"""Read the game name from a patch container's root archipelago.json.

Every APContainer-derived patch format (.aplttp, .apkh3, .apemerald, ...)
is a zip with the manifest at the archive root. Returns None for anything
else (multidata, apworlds, stray files) so callers can fall back to
component-suffix routing."""
try:
with zipfile.ZipFile(patch_path, "r") as zf:
manifest = json.loads(zf.read("archipelago.json").decode("utf-8"))
except (OSError, zipfile.BadZipFile, KeyError, ValueError):
return None
game = manifest.get("game") if isinstance(manifest, dict) else None
return game if isinstance(game, str) and game else None


def _resolve_launch_from_custom_world(wrapper_func: callable, module_id: str) -> Optional[callable]:
"""
Returns the inner callable for custom worlds, or None if the wrapper doesn't match the
Expand Down Expand Up @@ -407,6 +423,10 @@ def discover_and_launch_module(module_name: str, **kwargs) -> Optional[callable]
Frontend-neutral: worker-thread callbacks marshal back to the asyncio loop
via loop.call_soon_threadsafe, which works for both the Kivy GUI and the
Textual TUI (both run inside the same asyncio loop driven by MultiWorld.py).

A `patch_file` kwarg is forwarded to the resolved client: as a positional
CLI arg for entry-point/component launch functions, as `diff_file=` for the
SNI client, and as the patch positional for the BizHawk fallback.
"""
import threading
import asyncio
Expand Down Expand Up @@ -493,23 +513,35 @@ def _fire_pending_error_callback() -> None:
logging.error(f"Error in error callback: {cb_err}")


def _client_launch_argv(server_address, slot_name: typing.Optional[str], label: str,
patch_file: typing.Optional[str] = None) -> typing.List[str]:
"""Translate launcher-provided kwargs into the CLI argv world clients parse:
an optional positional patch file followed by --connect/--name flags."""
launch_argv: typing.List[str] = []
if patch_file:
launch_argv.append(patch_file)
if isinstance(server_address, str) and server_address:
launch_argv.append(f"--connect={server_address}")
if slot_name and label == "universal_tracker":
launch_argv.append(f"--name={slot_name}")
return launch_argv


def _defer_cli_launch(launch_function, label: str, server_address,
already_restarted: bool,
dep_install_module: typing.Optional[str] = None,
slot_name: typing.Optional[str] = None) -> None:
slot_name: typing.Optional[str] = None,
patch_file: typing.Optional[str] = None) -> None:
"""Defer a CLI-style world launch() to the next asyncio iteration with
sys.argv translated from the launcher-provided server_address."""
sys.argv translated from the launcher-provided server_address/patch_file."""
loop = asyncio.get_event_loop()

def _deferred_launch():
import inspect
saved_argv = sys.argv[:]
try:
launch_argv: list[str] = []
if isinstance(server_address, str) and server_address:
launch_argv.append(f"--connect={server_address}")
if slot_name and label == "universal_tracker":
launch_argv.append(f"--name={slot_name}")
launch_argv = _client_launch_argv(server_address, slot_name, label, patch_file)
if launch_argv:
sys.argv = [sys.argv[0], *launch_argv]
try:
accepts_positional = any(
Expand Down Expand Up @@ -575,6 +607,7 @@ def _perform_module_launch(module_id: str, **kwargs):
client_type = kwargs.pop("client_type", "text")
server_address = kwargs.pop("server_address", None)
slot_name = kwargs.pop("slot_name", None)
patch_file = kwargs.pop("patch_file", None)
already_restarted = kwargs.pop("_restarted", False)
CommonClient._set_pending_launch_callbacks(ready_callback, error_callback)

Expand Down Expand Up @@ -644,6 +677,7 @@ def _perform_module_launch(module_id: str, **kwargs):
launch_function, module_id, server_address, already_restarted,
dep_install_module=module_id,
slot_name=slot_name,
patch_file=patch_file,
)
return None

Expand All @@ -655,7 +689,7 @@ def _perform_module_launch(module_id: str, **kwargs):
if AutoSNIClientRegister.is_sni_world(module_name=game_name):
logging.info(f"Detected SNI client for {game_name}")
from worlds._sni.context import launch as _sni_launch
return _sni_launch(server_address=server_address)
return _sni_launch(server_address=server_address, diff_file=patch_file)
except ImportError:
logging.debug("SNI client not available")

Expand All @@ -665,7 +699,8 @@ def _perform_module_launch(module_id: str, **kwargs):
if AutoBizHawkClientRegister.is_bizhawk_world(module_name=game_name):
logging.info(f"Detected BizHawk client for {game_name}")
from worlds._bizhawk.context import launch as _bizhawk_launch
_defer_cli_launch(_bizhawk_launch, "bizhawk", server_address, already_restarted)
_defer_cli_launch(_bizhawk_launch, "bizhawk", server_address, already_restarted,
patch_file=patch_file)
return None
except ImportError:
logging.debug("BizHawk client not available")
Expand All @@ -680,6 +715,8 @@ def _perform_module_launch(module_id: str, **kwargs):
return None

# Fallback to text client
if patch_file:
logging.warning(f"No specialized client claims patch file {patch_file}; it will be ignored.")
logging.info(f"No specialized client, using text client")
from CommonClient import main_textclient
result = main_textclient(server_address)
Expand Down
Loading