diff --git a/Rac2Client.py b/Rac2Client.py index bead15e..5a1d422 100644 --- a/Rac2Client.py +++ b/Rac2Client.py @@ -7,6 +7,11 @@ import os import subprocess import traceback +import socket +import platform +import errno +import tkinter as tk +from tkinter import filedialog from CommonClient import ClientCommandProcessor, CommonContext, get_base_parser, logger, server_loop, gui_enabled from NetUtils import ClientStatus @@ -19,8 +24,176 @@ from .Callbacks import update, init from .ClientReceiveItems import handle_received_items from .NotificationManager import NotificationManager -from .Rac2Interface import HUD_MESSAGE_DURATION, ConnectionState, Rac2Interface, Rac2Planet +from .Rac2Interface import HUD_MESSAGE_DURATION, ConnectionState, create_pine_interface, Rac2Interface, Rac2Planet +from configparser import ConfigParser + +DEFAULT_PINE_PORT = 28011 + +def find_free_port(start=28021, end=28031): + system_name = platform.system() + + # On Windows, keep the old TCP-based logic + if system_name == "Windows": + for port in range(start, end + 1): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("127.0.0.1", port)) + return port + except OSError: + continue + return DEFAULT_PINE_PORT + + # On Linux/macOS, check for existing socket files instead + base_dir = os.environ.get("XDG_RUNTIME_DIR") or os.environ.get("TMPDIR") or "/tmp" + + for port in range(start, end + 1): + if port == DEFAULT_PINE_PORT: + sock_path = os.path.join(base_dir, "pcsx2.sock") + else: + sock_path = os.path.join(base_dir, f"pcsx2.sock.{port}") + + # If socket file exists, test whether it’s actually active + if os.path.exists(sock_path): + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: + s.connect(sock_path) + # If we connected, it’s in use + continue + except OSError as e: + # Connection failed → likely stale socket file, safe to remove + if e.errno in (errno.ECONNREFUSED, errno.ENOENT): + try: + os.remove(sock_path) + except OSError: + pass + # In either case, we can reuse this port now + return port + else: + # No such file → definitely free + return port + + # Fallback if all taken + return DEFAULT_PINE_PORT + +def ensure_pine_settings(ini_path: str, port: int = 28011): + """Ensure INI has configuration for PINE.""" + config = ConfigParser() + config.optionxform = str # Preserve key case exactly + + ini_dir = os.path.dirname(ini_path) + if not os.path.exists(ini_dir): + os.makedirs(ini_dir, exist_ok=True) + + # Create minimal INI if missing + if not os.path.exists(ini_path): + with open(ini_path, 'w') as f: + f.write("[EmuCore]\n") + + config.read(ini_path) + + # --- EmuCore section --- + if 'EmuCore' not in config: + config['EmuCore'] = {} + # Normalize capitalization + for key in list(config['EmuCore'].keys()): + if key.lower() == 'enablepine' and key != 'EnablePINE': + config['EmuCore']['EnablePINE'] = config['EmuCore'].pop(key) + elif key.lower() == 'pineslot' and key != 'PINESlot': + config['EmuCore']['PINESlot'] = config['EmuCore'].pop(key) + # Ensure required settings exist and are correct + config['EmuCore']['EnablePINE'] = 'true' + config['EmuCore']['PINESlot'] = str(port) + + # --- Achievements section --- + if 'Achievements' not in config: + config['Achievements'] = {} + # Normalize capitalization + for key in list(config['Achievements'].keys()): + if key.lower() == 'enabled' and key != 'Enabled': + config['Achievements']['Enabled'] = config['Achievements'].pop(key) + config['Achievements']['Enabled'] = 'false' + + # Write updated config back + with open(ini_path, 'w') as f: + config.write(f) + +def setup_pine(): + """Determine port and create Pine instance.""" + host_settings = get_settings() + game_ini = host_settings.get('rac2_options', {}).get('game_ini') + + # Only pick port here; do not touch ini yet + if game_ini and os.path.exists(os.path.dirname(game_ini)): + port = find_free_port() + else: + port = 28011 + + create_pine_interface(port) + return port + +# Run early so Pine instance exists for Rac2Interface +pine_port = setup_pine() + +def validate_rac2_settings() -> bool: + """Validate rac2_options from host.yaml before continuing. + Logs warnings but does not abort.""" + host_settings = get_settings() + rac2_opts = host_settings.get("rac2_options", {}) + + problems = [] + + # ISO file check + iso_file = rac2_opts.get("iso_file") + if not iso_file: + problems.append("Missing 'iso_file' in rac2_options.") + else: + iso_file_expanded = os.path.expandvars(os.path.expanduser(iso_file)) + if not os.path.isfile(iso_file_expanded): + problems.append(f"ISO file not found: {iso_file_expanded}") + + # ISO start (PCSX2 path) + iso_start = rac2_opts.get("iso_start") + if not iso_start: + problems.append("Missing 'iso_start' — should be path to PCSX2 executable.") + elif isinstance(iso_start, str): + iso_start_expanded = os.path.expandvars(os.path.expanduser(iso_start)) + if not os.path.isfile(iso_start_expanded): + problems.append(f"'iso_start' path does not exist: {iso_start_expanded}") + else: + system = platform.system().lower() + exe_name = os.path.basename(iso_start_expanded).lower() + # Windows check + if system == "windows" and not exe_name.endswith(("pcsx2.exe", "pcsx2-qt.exe")): + problems.append(f"On Windows, PCSX2 executable usually ends with pcsx2.exe — got {exe_name}") + # Linux/macOS check + elif system in ("linux", "darwin") and "pcsx2" not in exe_name: + problems.append(f"Expected 'pcsx2' binary on {system.capitalize()}, got {exe_name}") + + # Game INI + game_ini = rac2_opts.get("game_ini") + if not game_ini: + problems.append("Missing 'game_ini' path — should point to a PCSX2 game settings INI file.") + else: + game_ini_expanded = os.path.expandvars(os.path.expanduser(game_ini)) + if not os.path.isfile(game_ini_expanded): + problems.append(f"Game INI not found: {game_ini_expanded}") + + # Always warn, never block + if problems: + logger.warning("⚠ Rac2 configuration issues detected:") + for p in problems: + logger.warning(f" - {p}") + logger.warning("Continuing anyway; the game may still launch normally.") + + # Notify user in-client + try: + ctx = globals().get("ctx") # use context if available + if ctx and hasattr(ctx, "notification_manager"): + ctx.notification_manager.queue_notification("Some RAC2 config issues found (see above). Continuing anyway.") + except Exception: + pass + return True # Always return True now class Rac2CommandProcessor(ClientCommandProcessor): def __init__(self, ctx: CommonContext): @@ -58,6 +231,51 @@ def _cmd_deathlink(self): logger.info(message) self.ctx.notification_manager.queue_notification(message) + def _cmd_start(self): + """Select and start with a .aprac2 patch file.""" + if not isinstance(self.ctx, Rac2Context): + logger.error("Not in a valid RAC2 context.") + return + + # Prevent launching if already connected to PCSX2 + if self.ctx.game_interface.get_connection_state(): + msg = "Already connected to Ratchet & Clank 2 / PCSX2 — please close old instance or open another client." + logger.warning(msg) + self.ctx.notification_manager.queue_notification(msg) + return + + # Validate rac2 host.yaml options + if not validate_rac2_settings(): + return + + # Open file dialog + root = tk.Tk() + root.withdraw() + file_path = filedialog.askopenfilename( + title="Select a Ratchet & Clank 2 .aprac2 file", + filetypes=[("Archipelago RAC2 Patch Files", "*.aprac2"), ("All Files", "*.*")] + ) + root.destroy() + + if not file_path: + logger.info("No file selected.") + return + + # Launch async patching + game startup + logger.info(f"Selected patch: {file_path}") + self.ctx.notification_manager.queue_notification("Launching selected patch file...") + + async def start_patch(): + try: + await patch_and_run_game(file_path) + self.ctx.auth = get_name_from_aprac2(file_path) + logger.info("Game launch initiated.") + except Exception as e: + logger.error(f"Failed to start patch: {e}") + self.ctx.notification_manager.queue_notification(f"Error: {e}") + + Utils.async_start(start_patch(), name="Manual Patch Launch") + class Rac2Context(CommonContext): current_planet: Optional[Rac2Planet] = None @@ -246,6 +464,7 @@ async def run_game(iso_file): async def patch_and_run_game(aprac2_file: str): + """Patch ISO if needed, ensure copied INI has correct PINE configuration, and launch game.""" settings: Optional[Rac2Settings] = get_settings().get("rac2_options", False) assert settings, "No Rac2 Settings?" @@ -253,6 +472,7 @@ async def patch_and_run_game(aprac2_file: str): base_name = os.path.splitext(aprac2_file)[0] output_path = base_name + '.iso' + # Patch ISO if missing if not os.path.exists(output_path): from .PatcherUI import PatcherUI patcher = PatcherUI(aprac2_file, output_path, logger) @@ -267,7 +487,14 @@ async def patch_and_run_game(aprac2_file: str): if version and crc: file_name = f"{version}_{crc:X}.ini" file_path = os.path.join(os.path.dirname(game_ini_path), file_name) + + # Always create or refresh a CRC-based ini copy shutil.copy(game_ini_path, file_path) + ensure_pine_settings(file_path, pine_port) + + logger.info(f"Configured PINE (port {pine_port}) in {os.path.basename(file_path)}") + else: + logger.warning("No valid game_ini found; skipping INI setup.") Utils.async_start(run_game(output_path)) @@ -294,7 +521,6 @@ def get_pcsx2_crc(iso_path: str) -> Optional[int]: return crc - def launch(): Utils.init_logging("RAC2 Client") diff --git a/Rac2Interface.py b/Rac2Interface.py index aac47b7..ca210ab 100644 --- a/Rac2Interface.py +++ b/Rac2Interface.py @@ -452,10 +452,16 @@ def planet_by_id(planet_id) -> Optional[Rac2Planet]: # current_amount: int # current_capacity: int +pcsx2_interface: Pine | None = None + +def create_pine_interface(slot: int = 28011): + global pcsx2_interface + pcsx2_interface = Pine(slot=slot) class Rac2Interface: """Interface sitting in front of the pcsx2_interface to provide higher level functions for interacting with RAC2""" - pcsx2_interface: Pine = Pine() + # Pass the dynamic port if available + pcsx2_interface: Pine | None = None # This will be set via create_pine_interface() addresses: Addresses = None vendor: Vendor = None connection_status: str @@ -469,6 +475,8 @@ class Rac2Interface: def __init__(self, logger) -> None: self.logger = logger self.vendor = Vendor(self) + global pcsx2_interface + self.pcsx2_interface = pcsx2_interface def give_equipment_to_player(self, equipment: EquipmentData): if isinstance(equipment, WeaponData) and equipment.base_weapon_offset is not None: diff --git a/__init__.py b/__init__.py index 8f656aa..2161bb2 100644 --- a/__init__.py +++ b/__init__.py @@ -147,4 +147,4 @@ def get_options_as_dict(self) -> Dict[str, Any]: ) def fill_slot_data(self) -> Mapping[str, Any]: - return self.get_options_as_dict() \ No newline at end of file + return self.get_options_as_dict() diff --git a/pcsx2_interface/pine.py b/pcsx2_interface/pine.py index bcbe967..b8ad651 100644 --- a/pcsx2_interface/pine.py +++ b/pcsx2_interface/pine.py @@ -71,30 +71,33 @@ def __init__(self, slot: int = 28011): # self._init_socket() def _init_socket(self) -> None: - if system() == "Windows": - socket_family = socket.AF_INET - socket_name = ("127.0.0.1", self._slot) - elif system() == "Linux": - socket_family = socket.AF_UNIX - socket_name = os.environ.get("XDG_RUNTIME_DIR", "/tmp") - socket_name += "/pcsx2.sock" - elif system() == "Darwin": - socket_family = socket.AF_UNIX - socket_name = os.environ.get("TMPDIR", "/tmp") - socket_name += "/pcsx2.sock" - else: - socket_family = socket.AF_UNIX - socket_name = "/tmp/pcsx2.sock" - + system_name = system() try: + if system_name == "Windows": + socket_family = socket.AF_INET + socket_name = ("127.0.0.1", self._slot) + elif system_name in ("Linux", "Darwin"): + socket_family = socket.AF_UNIX + # Use XDG_RUNTIME_DIR or fallback to /tmp + base_dir = os.environ.get("XDG_RUNTIME_DIR") or os.environ.get("TMPDIR") or "/tmp" + # PCSX2 uses `.sock` for 28011, otherwise `.sock.` + if self._slot == 28011: + socket_name = os.path.join(base_dir, "pcsx2.sock") + else: + socket_name = os.path.join(base_dir, f"pcsx2.sock.{self._slot}") + else: + # Fallback for unknown UNIX-like systems + socket_family = socket.AF_UNIX + socket_name = f"/tmp/pcsx2.sock.{self._slot}" if self._slot != 28011 else "/tmp/pcsx2.sock" + self._sock = socket.socket(socket_family, socket.SOCK_STREAM) self._sock.settimeout(5.0) self._sock.connect(socket_name) - except socket.error: + except socket.error as e: self._sock.close() self._sock_state = False return - + self._sock_state = True def connect(self) -> None: