Stuff

This commit is contained in:
2026-01-28 17:49:05 +01:00
parent 39ed75298f
commit 9cb7bcedf1
9 changed files with 5848 additions and 57 deletions

12
Sly3Callbacks.py Normal file
View File

@@ -0,0 +1,12 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .Sly3Client import Sly3Context
async def init(ctx: "Sly3Context", ap_connected: bool):
"""Called when the player connects to the AP server or changes map"""
pass
async def update(ctx: "Sly3Context", ap_connected: bool):
"""Called continuously"""
pass

View File

@@ -1,2 +1,261 @@
from typing import Optional, Dict
import asyncio
import multiprocessing
import traceback
from .data import Items, Locations
from .data.Constants import EPISODES, CHALLENGES
from CommonClient import logger, server_loop, gui_enabled, get_base_parser
import Utils
from .Sly3Interface import Sly3Interface, Sly3Episode, PowerUps
from .Sly3Callbacks import init, update
# Load Universal Tracker
tracker_loaded: bool = False
try:
from worlds.tracker.TrackerClient import (
TrackerCommandProcessor as ClientCommandProcessor,
TrackerGameContext as CommonContext,
UT_VERSION
)
tracker_loaded = True
except ImportError:
from CommonClient import ClientCommandProcessor, CommonContext
class Sly3CommandProcessor(ClientCommandProcessor): # type: ignore[misc]
def _cmd_deathlink(self):
"""Toggle deathlink from client. Overrides default setting."""
if isinstance(self.ctx, Sly3Context):
self.ctx.death_link_enabled = not self.ctx.death_link_enabled
Utils.async_start(
self.ctx.update_death_link(
self.ctx.death_link_enabled
),
name="Update Deathlink"
)
message = f"Deathlink {'enabled' if self.ctx.death_link_enabled else 'disabled'}"
logger.info(message)
self.ctx.notification(message)
def _cmd_menu(self):
"""Reload to the episode menu"""
if isinstance(self.ctx, Sly3Context):
self.ctx.game_interface.to_episode_menu()
def _cmd_coins(self, amount: str):
"""Add coins to game."""
if isinstance(self.ctx, Sly3Context):
self.ctx.game_interface.add_coins(int(amount))
class Sly3Context(CommonContext): # type: ignore[misc]
command_processor = Sly3CommandProcessor
game_interface: Sly3Interface
game = "Sly 3: Honor Among Thieves"
items_handling = 0b111
pcsx2_sync_task: Optional[asyncio.Task] = None
is_connected_to_game: bool = False
is_connected_to_server: bool = False
slot_data: Optional[Dict[str, Utils.Any]] = None
last_error_message: Optional[str] = None
# notification_queue: list[str] = []
# notification_timestamp: float = 0
# showing_notification: bool = False
deathlink_timestamp: float = 0
death_link_enabled = False
queued_deaths: int = 0
# Game state
is_loading: bool = False
in_safehouse: bool = False
in_hub: bool = False
current_map: Optional[int] = None
# Items and checks
inventory: Dict[int,int] = {l.code: 0 for l in Items.item_dict.values()}
available_episodes: Dict[Sly3Episode,int] = {e: 0 for e in Sly3Episode}
thiefnet_items: Optional[list[str]] = None
powerups: PowerUps = PowerUps()
thiefnet_purchases: PowerUps = PowerUps()
jobs_completed: list[list[list[bool]]] = [
[[False for _ in chapter] for chapter in episode]
for episode in EPISODES.values()
]
challenges_completed: list[list[list[bool]]] = [
[[False for _ in chapter] for chapter in episode]
for episode in CHALLENGES.values()
]
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.version = [0,0,0]
self.game_interface = Sly3Interface(logger)
def on_deathlink(self, data: Utils.Dict[str, Utils.Any]) -> None:
pass
def make_gui(self):
ui = super().make_gui()
ui.base_title = f"Sly 3 Client v{'.'.join([str(i) for i in self.version])}"
if tracker_loaded:
ui.base_title += f" | Universal Tracker {UT_VERSION}"
# AP version is added behind this automatically
ui.base_title += " | Archipelago"
return ui
# async def server_auth(self, password_requested: bool = False) -> None:
# if password_requested and not self.password:
# await super(Sly3Context, self).server_auth(password_requested)
# await self.get_username()
# await self.send_connect()
def on_package(self, cmd: str, args: dict):
super().on_package(cmd, args)
if cmd == "Connected":
self.slot_data = args["slot_data"]
if self.version[:2] != args["slot_data"]["world_version"][:2]:
raise Exception(f"World generation version and client version don't match up. The world was generated with version {args["slot_data"]["world_version"]}, but the client is version {self.version}")
self.thiefnet_purchases = PowerUps(*[
Locations.location_dict[f"ThiefNet {i+1:02}"].code in self.checked_locations
for i in range(24)
])
# Set death link tag if it was requested in options
if "death_link" in args["slot_data"]:
self.death_link_enabled = bool(args["slot_data"]["death_link"])
Utils.async_start(self.update_death_link(
bool(args["slot_data"]["death_link"])
))
Utils.async_start(self.send_msgs([{
"cmd": "LocationScouts",
"locations": [
Locations.location_dict[location].code
for location in Locations.location_groups["Purchase"]
]
}]))
def update_connection_status(ctx: Sly3Context, status: bool):
if ctx.is_connected_to_game == status:
return
if status:
logger.info("Connected to Sly 3")
else:
logger.info("Unable to connect to the PCSX2 instance, attempting to reconnect...")
ctx.is_connected_to_game = status
async def _handle_game_not_ready(ctx: Sly3Context):
"""If the game is not connected, this will attempt to retry connecting to the game."""
if not ctx.exit_event.is_set():
ctx.game_interface.connect_to_game()
await asyncio.sleep(3)
async def _handle_game_ready(ctx: Sly3Context) -> None:
current_map = ctx.game_interface.get_current_map()
ctx.game_interface.skip_cutscene()
if ctx.game_interface.is_loading():
ctx.is_loading = True
await asyncio.sleep(0.1)
return
elif ctx.is_loading:
ctx.is_loading = False
await asyncio.sleep(1)
connected_to_server = (ctx.server is not None) and (ctx.slot is not None)
new_connection = ctx.is_connected_to_server != connected_to_server
if ctx.current_map != current_map or new_connection:
ctx.current_map = current_map
ctx.is_connected_to_server = connected_to_server
await init(ctx, connected_to_server)
await update(ctx, connected_to_server)
if ctx.server:
ctx.last_error_message = None
if not ctx.slot:
await asyncio.sleep(1)
return
await asyncio.sleep(0.1)
else:
message = "Waiting for player to connect to server"
if ctx.last_error_message is not message:
logger.info("Waiting for player to connect to server")
ctx.last_error_message = message
await asyncio.sleep(1)
async def pcsx2_sync_task(ctx: Sly3Context):
logger.info("Starting Sly 3 Connector, attempting to connect to emulator...")
ctx.game_interface.connect_to_game()
while not ctx.exit_event.is_set():
try:
is_connected = ctx.game_interface.get_connection_state()
update_connection_status(ctx, is_connected)
if is_connected:
await _handle_game_ready(ctx)
else:
await _handle_game_not_ready(ctx)
except ConnectionError:
ctx.game_interface.disconnect_from_game()
except Exception as e:
if isinstance(e, RuntimeError):
logger.error(str(e))
else:
logger.error(traceback.format_exc())
await asyncio.sleep(3)
continue
def launch_client():
pass
Utils.init_logging("Sly 3 Client")
async def main(args):
multiprocessing.freeze_support()
logger.info("main")
ctx = Sly3Context(args.connect, args.password)
logger.info("Connecting to server...")
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
# Runs Universal Tracker's internal generator
if tracker_loaded:
ctx.run_generator()
ctx.tags.remove("Tracker")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
logger.info("Running game...")
ctx.pcsx2_sync_task = asyncio.create_task(pcsx2_sync_task(ctx), name="PCSX2 Sync")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.pcsx2_sync_task:
await asyncio.sleep(3)
await ctx.pcsx2_sync_task
import colorama
colorama.init()
parser = get_base_parser()
args, _ = parser.parse_known_args()
asyncio.run(main(args))
colorama.deinit()
if __name__ == "__main__":
launch_client()

View File

@@ -2,6 +2,7 @@ from typing import Optional, Dict, NamedTuple
import struct
from logging import Logger
from enum import IntEnum
import traceback
from .pcsx2_interface.pine import Pine
from .data.Constants import ADDRESSES, MENU_RETURN_DATA
@@ -100,6 +101,15 @@ class GameInterface():
def _read_float(self, address: int):
return struct.unpack("f",self.pcsx2_interface.read_bytes(address, 4))[0]
def _batch_read8(self, addresses: list[int]) -> list[int]:
return self.pcsx2_interface.batch_read_int8(addresses)
def _batch_read16(self, addresses: list[int]) -> list[int]:
return self.pcsx2_interface.batch_read_int16(addresses)
def _batch_read32(self, addresses: list[int]) -> list[int]:
return self.pcsx2_interface.batch_read_int32(addresses)
def _write8(self, address: int, value: int):
self.pcsx2_interface.write_int8(address, value)
@@ -113,7 +123,7 @@ class GameInterface():
self.pcsx2_interface.write_bytes(address, value)
def _write_float(self, address: int, value: float):
self.pcsx2_interface.write_bytes(address, struct.pack("f", value))
self.pcsx2_interface.write_float(address, value)
def connect_to_game(self):
"""
@@ -129,6 +139,10 @@ class GameInterface():
game_id = self.pcsx2_interface.get_game_id()
# The first read of the address will be null if the client is faster than the emulator
self.current_game = None
if game_id == "":
self.logger.debug("No game connected")
return
if game_id in ADDRESSES.keys():
self.current_game = game_id
self.addresses = ADDRESSES[game_id]
@@ -137,9 +151,9 @@ class GameInterface():
f"Connected to the wrong game ({game_id})")
self.game_id_error = game_id
except RuntimeError:
pass
self.logger.debug(traceback.format_exc())
except ConnectionError:
pass
self.logger.debug(traceback.format_exc())
def disconnect_from_game(self):
self.pcsx2_interface.disconnect()
@@ -154,6 +168,9 @@ class GameInterface():
return False
class Sly3Interface(GameInterface):
############################
## Private Helper Methods ##
############################
def _reload(self, reload_data: bytes):
self._write_bytes(
self.addresses["reload values"],
@@ -168,6 +185,23 @@ class Sly3Interface(GameInterface):
return pointer
###################
## Current State ##
###################
def in_cutscene(self) -> bool:
frame_counter = self._read16(self.addresses["frame counter"])
return frame_counter > 10
def is_loading(self) -> bool:
return self._read32(self.addresses["loading"]) == 2
#######################
## Getters & Setters ##
#######################
def get_current_episode(self) -> Sly3Episode:
episode_num = self._read32(self.addresses["world id"])
return Sly3Episode(episode_num)
def get_current_map(self) -> int:
return self._read32(self.addresses["map id"])
@@ -180,31 +214,7 @@ class Sly3Interface(GameInterface):
def set_items_received(self, n:int) -> None:
self._write32(self.addresses["items received"], n)
def to_episode_menu(self) -> None:
self.logger.info("Skipping to episode menu")
if (
self.get_current_map() == 35 and
self.get_current_job() == 1797
):
self.set_current_job(0xffffffff)
# self.set_items_received(0)
self._reload(bytes.fromhex(MENU_RETURN_DATA))
def unlock_episodes(self) -> None:
self._write8(self.addresses["episode unlocks"], 8)
def in_cutscene(self) -> bool:
frame_counter = self._read16(self.addresses["frame counter"])
return frame_counter > 10
def skip_cutscene(self) -> None:
pressing_x = self._read8(self.addresses["x pressed"]) == 255
if self.in_cutscene() and pressing_x:
self._write32(self.addresses["skip cutscene"],0)
def load_powerups(self, powerups: PowerUps):
def set_powerups(self, powerups: PowerUps):
booleans = list(powerups)
byte_list = [
[False]*2+booleans[0:6],
@@ -223,7 +233,7 @@ class Sly3Interface(GameInterface):
self._write_bytes(self.addresses["gadgets"], data)
def read_powerups(self):
def get_powerups(self):
data = self._read_bytes(self.addresses["gadgets"], 8)
bits = [
bool(int(b))
@@ -234,6 +244,29 @@ class Sly3Interface(GameInterface):
relevant_bits = bits[2:48]
return PowerUps(*relevant_bits)
#################
## Other Utils ##
#################
def to_episode_menu(self) -> None:
self.logger.info("Skipping to episode menu")
if (
self.get_current_map() == 35 and
self.get_current_job() == 1797
):
self.set_current_job(0xffffffff)
# self.set_items_received(0)
self._reload(bytes.fromhex(MENU_RETURN_DATA))
def unlock_episodes(self) -> None:
self._write8(self.addresses["episode unlocks"], 8)
def skip_cutscene(self) -> None:
pressing_x = self._read8(self.addresses["x pressed"]) == 255
if self.in_cutscene() and pressing_x:
self._write32(self.addresses["skip cutscene"],0)
def add_coins(self, to_add: int):
current_amount = self._read32(self.addresses["coins"])
new_amount = max(current_amount + to_add,0)
@@ -323,7 +356,7 @@ if __name__ == "__main__":
# Loading all power-ups (except the one I don't know)
power_ups = PowerUps(True, True, True, False, *[True]*44)
interf.load_powerups(power_ups)
interf.set_powerups(power_ups)
# Adding 10000 coins
#interf.add_coins(10000)

View File

@@ -164,6 +164,7 @@ class Sly3World(World):
def fill_slot_data(self) -> Mapping[str, Any]:
slot_data = self.get_options_as_dict()
slot_data["thiefnet_costs"] = self.thiefnet_costs
slot_data["world_version"] = self.world_version
return slot_data

6
archipelago.json Normal file
View File

@@ -0,0 +1,6 @@
{
"compatible_version": 7,
"version": 7,
"game": "Sly 3: Honor Among Thieves",
"world_version": "0.0.0"
}

View File

@@ -377,8 +377,10 @@ REQUIREMENTS = {
ADDRESSES = {
"SCUS-97464" : {
"world id": 0x468D30,
"map id": 0x47989C,
"job id": 0x36DB98,
"loading": 0x467B00,
"reload": 0x4797C4,
"reload values": 0x4797CC,
"episode unlocks": 0x56AEC8,

5364
data/savefile_data.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -34,33 +34,33 @@ class Pine:
""" IPC result codes. A list of possible result codes the IPC can send back. Each one of them is what we call an
"opcode" or "tag" and is the first byte sent by the IPC to differentiate between results.
"""
IPC_OK = 0, # IPC command successfully completed.
IPC_OK = 0 # IPC command successfully completed.
IPC_FAIL = 0xFF # IPC command failed to complete.
class IPCCommand(IntEnum):
READ8 = 0,
READ16 = 1,
READ32 = 2,
READ64 = 3,
WRITE8 = 4,
WRITE16 = 5,
WRITE32 = 6,
WRITE64 = 7,
VERSION = 8,
SAVE_STATE = 9,
LOAD_STATE = 0xA,
TITLE = 0xB,
ID = 0xC,
UUID = 0xD,
GAME_VERSION = 0xE,
STATUS = 0xF,
UNIMPLEMENTED = 0xFF,
READ8 = 0
READ16 = 1
READ32 = 2
READ64 = 3
WRITE8 = 4
WRITE16 = 5
WRITE32 = 6
WRITE64 = 7
VERSION = 8
SAVE_STATE = 9
LOAD_STATE = 0xA
TITLE = 0xB
ID = 0xC
UUID = 0xD
GAME_VERSION = 0xE
STATUS = 0xF
UNIMPLEMENTED = 0xFF
class DataSize(IntEnum):
INT8 = 1,
INT16 = 2,
INT32 = 4,
INT64 = 8,
INT8 = 1
INT16 = 2
INT32 = 4
INT64 = 8
def __init__(self, slot: int = 28011):
if not 0 < slot <= 65536:
@@ -161,7 +161,7 @@ class Pine:
def write_float(self, address: int, value: float) -> None:
request = Pine._create_request(Pine.IPCCommand.WRITE32, address, 9 + Pine.DataSize.INT32)
request + struct.pack("<f", value)
request += struct.pack("<f", value)
self._send_request(request)
def write_bytes(self, address: int, data: bytes) -> None:
@@ -189,9 +189,124 @@ class Pine:
self._send_request(request)
bytes_written += 1
def batch_read_int32(self, addresses: list[int]) -> list[int]:
"""Read multiple int32 values in a single request. Much faster than individual reads."""
if not addresses:
return []
# Build batched request
request = b''
for address in addresses:
request += Pine.to_bytes(Pine.IPCCommand.READ32, 1)
request += Pine.to_bytes(address, 4)
# Prepend total size
total_size = len(request) + 4
request = Pine.to_bytes(total_size, 4) + request
response = self._send_request(request)
# Parse results - one status byte for the entire batch, then data
results = []
offset = 4 # Skip overall size header
offset += 1 # Skip single status byte for entire batch
for _ in addresses:
value = Pine.from_bytes(response[offset:offset + 4])
results.append(value)
offset += 4
return results
def batch_read_int16(self, addresses: list[int]) -> list[int]:
"""Read multiple int16 values in a single request."""
if not addresses:
return []
request = b''
for address in addresses:
request += Pine.to_bytes(Pine.IPCCommand.READ16, 1)
request += Pine.to_bytes(address, 4)
total_size = len(request) + 4
request = Pine.to_bytes(total_size, 4) + request
response = self._send_request(request)
# Parse results - one status byte for the entire batch, then data
results = []
offset = 4 # Skip overall size header
offset += 1 # Skip single status byte for entire batch
for _ in addresses:
value = Pine.from_bytes(response[offset:offset + 2])
results.append(value)
offset += 2
return results
def batch_read_int8(self, addresses: list[int]) -> list[int]:
"""Read multiple int8 values in a single request."""
if not addresses:
return []
request = b''
for address in addresses:
request += Pine.to_bytes(Pine.IPCCommand.READ8, 1)
request += Pine.to_bytes(address, 4)
total_size = len(request) + 4
request = Pine.to_bytes(total_size, 4) + request
response = self._send_request(request)
# Parse results - one status byte for the entire batch, then data
results = []
offset = 4 # Skip overall size header
offset += 1 # Skip single status byte for entire batch
for _ in addresses:
value = Pine.from_bytes(response[offset:offset + 1])
results.append(value)
offset += 1
return results
def batch_write_int32(self, operations: list[tuple[int, int]]) -> None:
"""Write multiple int32 values in a single request. Each operation is (address, value)."""
if not operations:
return
request = b''
for address, value in operations:
request += Pine.to_bytes(Pine.IPCCommand.WRITE32, 1)
request += Pine.to_bytes(address, 4)
request += value.to_bytes(length=4, byteorder="little")
total_size = len(request) + 4
request = Pine.to_bytes(total_size, 4) + request
self._send_request(request)
def batch_write_float(self, operations: list[tuple[int, float]]) -> None:
"""Write multiple float values in a single request. Each operation is (address, value)."""
if not operations:
return
request = b''
for address, value in operations:
request += Pine.to_bytes(Pine.IPCCommand.WRITE32, 1)
request += Pine.to_bytes(address, 4)
request += struct.pack("<f", value)
total_size = len(request) + 4
request = Pine.to_bytes(total_size, 4) + request
self._send_request(request)
def get_game_id(self) -> str:
request = Pine.to_bytes(5, 4) + Pine.to_bytes(Pine.IPCCommand.ID, 1)
response = self._send_request(request)
try:
response = self._send_request(request)
except ConnectionError:
return ""
return response[9:-1].decode("ascii")
def _send_request(self, request: bytes) -> bytes:
@@ -247,4 +362,3 @@ class Pine:
@staticmethod
def from_bytes(arr: bytes) -> int:
return int.from_bytes(arr, byteorder="little")