Compare commits

7 Commits
main ... main

Author SHA1 Message Date
54b80d5a7d Stuff 2026-02-02 20:16:38 +01:00
Sebastian Larsen Prehn
dc08eee6c8 A Cold Alliance data 2026-01-31 15:02:46 +01:00
Sebastian Larsen Prehn
5985e5d2f1 First mission of cold alliance 2026-01-30 21:17:24 +01:00
9cb7bcedf1 Stuff 2026-01-28 17:49:05 +01:00
Sebastian Larsen Prehn
39ed75298f Flight of Fancy data 2026-01-25 14:21:43 +01:00
Sebastian Larsen Prehn
aa9562afe1 Rumble down under data 2026-01-23 22:25:22 +01:00
Sebastian Larsen Prehn
6688c76f34 Opera of Fear job data 2026-01-23 19:15:29 +01:00
10 changed files with 6219 additions and 66 deletions

131
Sly3Callbacks.py Normal file
View File

@@ -0,0 +1,131 @@
from typing import TYPE_CHECKING, Dict, List
from BaseClasses import ItemClassification
from .data.Constants import REQUIREMENTS
from .data import Items
if TYPE_CHECKING:
from .Sly3Client import Sly3Context
###########
# Helpers #
###########
def accessibility(ctx: "Sly3Context") -> Dict[str, Dict[str, List[List[bool]]]]:
section_requirements = {
episode_name: [
list(set(sum([
sum(
ep_reqs,
[]
)
for ep_reqs
in episode[:i-1]
], [])))
for i in range(1,5)
]
for episode_name, episode in REQUIREMENTS["Jobs"].items()
}
job_requirements = {
episode_name: [
[
list(set(reqs + section_requirements[episode_name][section_idx]))
for reqs in section
]
for section_idx, section in enumerate(episode)
]
for episode_name, episode in REQUIREMENTS["Jobs"].items()
}
job_requirements["Honor Among Thieves"] = [[
[
"Bentley",
"Murray",
"Guru",
"Penelope",
"Panda King",
"Dimitri",
"Carmelita"
]
for _ in range(8)
]]
challenge_requirements = {
episode_name: [
[
list(set(reqs + section_requirements[episode_name][section_idx]))
for reqs in section
]
for section_idx, section in enumerate(episode)
]
for episode_name, episode in REQUIREMENTS["Challenges"].items()
}
challenge_requirements["Honor Among Thieves"] = [[
[
"Bentley",
"Murray",
"Guru",
"Penelope",
"Panda King",
"Dimitri",
"Carmelita"
]
for _ in range(5)
]]
items_received = [
Items.from_id(i.item)
for i in ctx.items_received
]
progression_item_names = set([
i.name
for i in items_received
if i.classification == ItemClassification.progression
])
job_accessibility = {
episode_name: [
[
all(r in progression_item_names for r in reqs)
for reqs in section
]
for section in episode
]
for episode_name, episode in job_requirements.items()
}
challenge_accessibility = {
episode_name: [
[
all(r in progression_item_names for r in reqs)
for reqs in section
]
for section in episode
]
for episode_name, episode in challenge_requirements.items()
}
return {
"Jobs": job_accessibility,
"Challenges": challenge_accessibility
}
#########
# Steps #
#########
##################
# Main Functions #
##################
async def init(ctx: "Sly3Context", ap_connected: bool) -> None:
"""Called when the player connects to the AP server or changes map"""
pass
async def update(ctx: "Sly3Context", ap_connected: bool) -> None:
"""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)
@@ -317,23 +350,22 @@ def current_job_info(interf: Sly3Interface):
if __name__ == "__main__":
interf = Sly3Interface(Logger("test"))
interf.connect_to_game()
# interf.to_episode_menu()
# interf.unlock_episodes()
#interf.to_episode_menu()
#interf.unlock_episodes()
# interf.skip_cutscene()
# 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)
power_ups = PowerUps(True, True, True, False, *[True]*44)
interf.set_powerups(power_ups)
# Adding 10000 coins
# interf.add_coins(10000)
#interf.add_coins(10000)
# === Testing Zone ===
# power_ups = PowerUps()
# interf.load_powerups(power_ups)
# print_thiefnet_addresses(interf)
# print_thiefnet_addresses(interf)
# disabling first job of episode 1 (0 = disabled, 1 = available, 2 = in progress, 3 = complete)
# interf._write32(0x1335d10+0x44, 0)
# current_job_info(interf)
current_job_info(interf)

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,
@@ -520,4 +522,4 @@ MENU_RETURN_DATA = (
"F93E1EEE"+
"7F2319BC"+
"7B8274B1"
)
)

View File

@@ -49,4 +49,4 @@ def from_id(location_id: int) -> Sly3LocationData:
if len(matching) == 0:
raise ValueError(f"No location data for location id '{location_id}'")
assert len(matching) < 2, f"Multiple locations data with id '{location_id}'. Please report."
return matching[0]
return matching[0]

244
data/jobs.md Normal file
View File

@@ -0,0 +1,244 @@
# Opera of Fear
## Police HQ (sly)
Job ID: 2085
Job address: 0x1335d10
Job index: 2
Job state (should be 2): 2
## Octavio Snap (sly)
Job ID: 2230
Job address: 0x1350560
Job index: 54
Job state (should be 2): 2
## Into the Depths (sly)
Job ID: 2283
Job address: 0x1357f80
Job index: 68
Job state (should be 2): 2
## Canal Chase (bentley)
Job ID: 2329
Job address: 0x844ff0
Job index: 87
Job state (should be 2): 2
## Turf War! (sly)
Job ID: 2139
Job address: 0x1330c40
Job index: 19
Job state (should be 2): 2
## Tar Ball (bentley)
Job ID: 2168
Job address: 0x1335dc0
Job index: 29
Job state (should be 2): 2
## Run 'n Bomb (sly)
Job ID: 2187
Job address: 0x133e9b0
Job index: 39
Job state (should be 2): 2
## Guard Duty (sly)
Job ID: 2352
Job address: 0x1351520
Job index: 95
Job state (should be 2): 2
## Operation: Tar-Be Gone! (sly)
Job ID: 2419
Job address: 0x135e550
Job index: 120
Job state (should be 2): 2
# Rumble Down Under
## Search for the Guru
Job ID: 2577
Job address: 0x6b4250
Job index: 3
Job state (should be 2): 2
## Spelunking (Murray)
Job ID: 2596
Job address: 0x6b80f0
Job index: 14
Job state (should be 2): 2
## Dark Caves (sly)
Job ID: 2805
Job address: 0x6d0770
Job index: 84
Job state (should be 2): 2
## Big Truck (murray)
Job ID: 2695
Job address: 0x5d26f0
Job index: 59
Job state (should be 2): 2
## Unleash the Guru (bentley)
Job ID: 2663
Job address: 0x6bdaf0
Job index: 42
Job state (should be 2): 2
## The Claw (sly)
Job ID: 2623
Job address: 0x5caa20
Job index: 25
Job state (should be 2): 2
## Lemon Rage (sly)
Job ID: 2730
Job address: 0x5fe390
Job index: 72
Job state (should be 2): 2
## Hungry Croc (murray)
Job ID: 2780
Job address: 0x6ca940
Job index: 80
Job state (should be 2): 2
## Operation: Moon Crash (sly)
(during mission)
Job ID: 2843
Job address: 0x6d4330
Job index: 102
Job state (should be 2): 2
or
(carmelita fight)
Job ID: 2843
Job address: 0x5deb20
Job index: 102
Job state (should be 2): 2
# Flight of Fancy
## Hidden Flight Roster
Job ID: 2983
Job address: 0x794360
Job index: 1
Job state (should be 2): 2
## Frame Team Belgium
Job ID: 3025
Job address: 0x7a0c20
Job index: 15
Job state (should be 2): 2
## Frame Team Iceland
Job ID: 3061
Job address: 0x65f3d0
Job index: 29
Job state (should be 2): 2
## Cooper Hangar Defense (murray)
Job ID: 3101
Job address: 0x663d80
Job index: 49
Job state (should be 2): 2
## ACES Semifinals (sly)
Job ID: 3140
Job address: 0xecb450
Job index: 62
Job state (should be 2): 2
## Beauty and the Beast (bentley)
Job ID: 3225
Job address: 0x7adf50
Job index: 95
Job state (should be 2): 2
## Giant Wolf Massacre (bentley)
Job ID: 3202
Job address: 0x642a90
Job index: 87
Job state (should be 2): 2
## Windmill Firewall (bentley)
Job ID: 3164
Job address: 0x63b4d0
Job index: 70
Job state (should be 2): 2
## Operation: Turbo Dominant Eagle (sly)
Job ID: 3259
Job address: 0x651eb0
Job index: 108
Job state (should be 2): 2
# A Cold Alliance
##
Job ID: 3381
Job address: 0x67de40
Job index: 1
Job state (should be 2): 2
## Grapple-Cam Break-in (sly)
Job ID: 3540
Job address: 0x1625780
Job index: 63
Job state (should be 2): 2
## Get a Job (bentley)
Job ID: 3449
Job address: 0x682ba0
Job index: 36
Job state (should be 2): 2
## Tearful Reunion (murray)
Job ID: 3509
Job address: 0x16200f0
Job index: 52
Job state (should be 2): 2
## Laptop Retrieval (bentley)
Job ID: 3584
Job address: 0x68e560
Job index: 82
Job state (should be 2): 2
## Down the Line (murray)
Job ID: 3672
Job address: 0x16377e0
Job index: 117
Job state (should be 2): 2
## Vampiric Demise (sly)
Job ID: 3629
Job address: 0x1630060
Job index: 97
Job state (should be 2): 2
## A Battery of Peril (bentley)
Job ID: 3684
Job address: 0x163a930
Job index: 121
Job state (should be 2): 2
## Operation: Wedding Crasher! (sly)
Job ID: 3712
Job address: 0x6bf580
Job index: 129
Job state (should be 2): 2

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")