forked from NikolajDanger/APSly3
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54b80d5a7d | |||
|
|
dc08eee6c8 | ||
|
|
5985e5d2f1 | ||
| 9cb7bcedf1 | |||
|
|
39ed75298f | ||
|
|
aa9562afe1 | ||
|
|
6688c76f34 |
131
Sly3Callbacks.py
Normal file
131
Sly3Callbacks.py
Normal 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
|
||||
261
Sly3Client.py
261
Sly3Client.py
@@ -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()
|
||||
|
||||
108
Sly3Interface.py
108
Sly3Interface.py
@@ -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)
|
||||
|
||||
@@ -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
6
archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"compatible_version": 7,
|
||||
"version": 7,
|
||||
"game": "Sly 3: Honor Among Thieves",
|
||||
"world_version": "0.0.0"
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
244
data/jobs.md
Normal 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
5364
data/savefile_data.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user