Skip to content
230 changes: 228 additions & 2 deletions Rac2Client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -246,13 +464,15 @@ 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?"

aprac2_file = os.path.abspath(aprac2_file)
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)
Expand All @@ -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))

Expand All @@ -294,7 +521,6 @@ def get_pcsx2_crc(iso_path: str) -> Optional[int]:

return crc


def launch():
Utils.init_logging("RAC2 Client")

Expand Down
10 changes: 9 additions & 1 deletion Rac2Interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
return self.get_options_as_dict()
37 changes: 20 additions & 17 deletions pcsx2_interface/pine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.<PORT>`
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:
Expand Down