Compare commits

..

9 Commits

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
6911df1f92 2025-12-13 21:53:24 +01:00
Nikolaj
fd1405ada0 Handful of logic fixes 2025-12-09 13:31:49 +01:00
15 changed files with 6655 additions and 434 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
@@ -16,60 +17,60 @@ class Sly3Episode(IntEnum):
Honor_Among_Thieves = 6
class PowerUps(NamedTuple):
attack = False
binocucom = False
bombs = False
unknown = False
trigger_Bomb = False
fishing_pole = False
attack: bool = False
binocucom: bool = False
bombs: bool = False
unknown: bool = False
trigger_Bomb: bool = False
fishing_pole: bool = False
alarm_clock = False
adrenaline_burst = False
health_extractor = False
hover_pack = False
insanity_strike = False
grapple_cam = False
size_destabilizer = False
rage_bomb = False
alarm_clock: bool = False
adrenaline_burst: bool = False
health_extractor: bool = False
hover_pack: bool = False
insanity_strike: bool = False
grapple_cam: bool = False
size_destabilizer: bool = False
rage_bomb: bool = False
reduction_bomb = False
ball_form = False
berserker_charge = False
juggernaut_throw = False
guttural_roar = False
fists_of_flame = False
temporal_lock = False
raging_inferno_flop = False
reduction_bomb: bool = False
ball_form: bool = False
berserker_charge: bool = False
juggernaut_throw: bool = False
guttural_roar: bool = False
fists_of_flame: bool = False
temporal_lock: bool = False
raging_inferno_flop: bool = False
diablo_fire_slam = False
smoke_bomb = False
combat_dodge = False
paraglider = False
silent_obliteration = False
feral_pounce = False
mega_jump = False
knockout_dive = False
diablo_fire_slam: bool = False
smoke_bomb: bool = False
combat_dodge: bool = False
paraglider: bool = False
silent_obliteration: bool = False
feral_pounce: bool = False
mega_jump: bool = False
knockout_dive: bool = False
shadow_power_1 = False
thief_reflexes = False
shadow_power_2 = False
rocket_boots = False
treasure_map = False
shield = False
venice_disguise = False
photographer_disguise = False
shadow_power_1: bool = False
thief_reflexes: bool = False
shadow_power_2: bool = False
rocket_boots: bool = False
treasure_map: bool = False
shield: bool = False
venice_disguise: bool = False
photographer_disguise: bool = False
pirate_disguise = False
spin_1 = False
spin_2 = False
spin_3 = False
jump_1 = False
jump_2 = False
jump_3 = False
push_1 = False
pirate_disguise: bool = False
spin_1: bool = False
spin_2: bool = False
spin_3: bool = False
jump_1: bool = False
jump_2: bool = False
jump_3: bool = False
push_1: bool = False
push_2 = False
push_3 = False
push_2: bool = False
push_3: bool = False
class GameInterface():
"""
@@ -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,6 +214,39 @@ class Sly3Interface(GameInterface):
def set_items_received(self, n:int) -> None:
self._write32(self.addresses["items received"], n)
def set_powerups(self, powerups: PowerUps):
booleans = list(powerups)
byte_list = [
[False]*2+booleans[0:6],
booleans[6:14],
booleans[14:22],
booleans[22:30],
booleans[30:38],
booleans[38:46],
booleans[46:48]+[False]*2,
[False]*8
]
data = b''.join(
int(''.join(str(int(i)) for i in byte[::-1]),2).to_bytes(1,"big")
for byte in byte_list
)
self._write_bytes(self.addresses["gadgets"], data)
def get_powerups(self):
data = self._read_bytes(self.addresses["gadgets"], 8)
bits = [
bool(int(b))
for byte in data
for b in f"{byte:08b}"[::-1]
]
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 (
@@ -194,185 +261,111 @@ class Sly3Interface(GameInterface):
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 add_coins(self, to_add: int):
current_amount = self._read32(self.addresses["coins"])
new_amount = max(current_amount + to_add,0)
self._write32(self.addresses["coins"],new_amount)
#### TESTING ZONE ####
def read_text(interf: Sly3Interface, address: int):
"""Reads text at a specific address"""
text = ""
while True:
character = interf._read_bytes(address,2)
if character == b"\x00\x00":
break
text += character.decode("utf-16-le")
address += 2
return text
def find_string_id(interf: Sly3Interface, _id: int):
"""Searches for a specific string by ID"""
# String table starts at 0x47A2D8
# Each entry in the string table has 4 bytes of its ID and then 4 bytes of an
# address to the string
string_table_address = interf._read32(0x47A2D8)
i = 0
while True:
string_id = interf._read32(string_table_address+i*8)
if string_id == _id:
return interf._read32(string_table_address+i*8+4)
i += 1
def print_thiefnet_addresses(interf: Sly3Interface):
print(" {")
for i in range(44):
address = 0x343208+i*0x3c
interf._write32(address,i+1)
interf._write32(address+0xC,0)
name_id = interf._read32(address+0x14)
name_address = find_string_id(interf, name_id)
name_text = read_text(interf, name_address)
description_id = interf._read32(address+0x18)
description_address = find_string_id(interf, description_id)
print(
" " +
f"\"{name_text}\": "+
f"({hex(name_address)},{hex(description_address)}),"
)
print(" }")
def current_job_info(interf: Sly3Interface):
current_job = interf._read32(0x36DB98)
address = interf._read32(interf.addresses["DAG root"])
i = 0
while address != 0:
job_pointer = interf._read32(address+0x6c)
job_id = interf._read32(job_pointer+0x18)
if job_id == current_job:
break
address = interf._read32(address+0x20)
i += 1
print("Job ID:", current_job)
print("Job address:", hex(address))
print("Job index:", i)
print("Job state (should be 2):", interf._read32(address+0x44))
if __name__ == "__main__":
interf = Sly3Interface(Logger("test"))
interf.connect_to_game()
#interf.to_episode_menu()
#interf.unlock_episodes()
# interf.skip_cutscene()
byte_list = [
[
False, # ???
False, # ???
True, # Sly/Bentley square attack
True, # Binocucom
True, # Bentley's bombs
False, # ???
True, # Trigger Bomb
True # Fishing Pole
],
[
True, # Alarm Clock
True, # Adrenaline Burst
True, # Health Extractor
True, # Hover Pack (Doesn't activate until you reload)
True, # Insanity Strike
True, # Grapple Came
True, # Size Destabilizer
True # Rage Bomb
],
[
True, # Reduction Bomb
True, # Ball Form
True, # Berskerker Charge
True, # Juggernaut Throw
True, # Gutteral Roar
True, # Fists of Flame
True, # Temporal Lock
True # Raging Inferno Flop
],
[
True, # Diablo Fire Slam
True, # Smoke Bomb
True, # Combat Dodge
True, # Paraglider
True, # Silent Obliteration
True, # Feral Pounce
True, # Mega Jump
True # Knockout Dive
],
[
True, # Shadow Power Lvl 1
True, # Thief Reflexes
True, # Shadow Power Lvl 2
True, # Rocket Boots
True, # Treasure Map
False, # ???
True, # Venice Disguise
True # Photographer Disguise
],
[
True, # Pirate Disguise
True, # Spin Attack lvl 1
True, # Spin Attack lvl 2
True, # Spin Attack lvl 3
True, # Jump Attack lvl 1
True, # Jump Attack lvl 2
True, # Jump Attack lvl 3
True # Push Attack lvl 1
],
[
True, # Push Attack lvl 1
True, # Push Attack lvl 1
False,
False,
False,
False,
False,
False
],
[
False,
False,
False,
False,
False,
False,
False,
False
],
]
# Loading all power-ups (except the one I don't know)
power_ups = PowerUps(True, True, True, False, *[True]*44)
interf.set_powerups(power_ups)
# byte_list = [[False for _ in range(8)] for _ in range(8)]
# Adding 10000 coins
#interf.add_coins(10000)
data = b''.join(
int(''.join(str(int(i)) for i in byte[::-1]),2).to_bytes(1,"big")
for byte in byte_list
)
# === Testing Zone ===
interf._write_bytes(0x468DCC,data)
# print_thiefnet_addresses(interf)
# data = interf._read_bytes(0x468DCC, 8)
# bits = [
# bool(int(b))
# for byte in data
# for b in f"{byte:08b}"[::-1]
# ]
# disabling first job of episode 1 (0 = disabled, 1 = available, 2 = in progress, 3 = complete)
# interf._write32(0x1335d10+0x44, 0)
# print(bits)
interf._write32(0x468DDC, 10000)
# thiefnet_values = list(range(9)) + list(range(10,))
# def read_text(address: int):
# text = ""
# while True:
# character = interf._read_bytes(address,2)
# if character == b"\x00\x00":
# break
# text += character.decode("utf-16-le")
# address += 2
# return text
# def find_string_id(_id: int):
# string_table_address = interf._read32(0x47A2D8)
# i = 0
# while True:
# string_id = interf._read32(string_table_address+i*8)
# if string_id == _id:
# return interf._read32(string_table_address+i*8+4)
# i += 1
# print(" {")
# for i in range(44):
# address = 0x343208+i*0x3c
# interf._write32(address,i+1)
# interf._write32(address+0xC,0)
# name_id = interf._read32(address+0x14)
# name_address = find_string_id(name_id)
# name_text = read_text(name_address)
# description_id = interf._read32(address+0x18)
# description_address = find_string_id(description_id)
# print(
# " " +
# f"\"{name_text}\": "+
# f"({hex(name_address)},{hex(description_address)}),"
# )
# print(" }")
# string_table_address = interf._read32(0x47A2D8)
# for i in range(10):
# print("----")
# string_id = interf._read32(string_table_address+i*8)
# print(string_id)
# string_address = interf._read32(string_table_address+i*8+4)
# print(hex(string_address))
# print(read_text(string_address))
# print(interf._read32(0x6b4110+0x44))
print(interf._read32(0x1365be0+0x44))
print()
print(interf._read32(0x1357f80+0x44))
print(interf._read32(0x1350560+0x44))
print(interf._read32(0x135aba0+0x44))
print(interf._read32(0x36DB98))
current_job_info(interf)

View File

@@ -69,6 +69,15 @@ class CoinsMaximum(Range):
range_end = 1000
default = 200
class ThiefNetLocations(Range):
"""
The number ThiefNet locations.
"""
display_name = "ThiefNet Locations"
range_start = 0
range_end = 37
default = 25
class ThiefNetCostMinimum(Range):
"""
@@ -100,6 +109,7 @@ class Sly3Options(PerGameCommonOptions):
include_mega_jump: IncludeMegaJump
coins_minimum: CoinsMinimum
coins_maximum: CoinsMaximum
thiefnet_locations: ThiefNetLocations
thiefnet_minimum: ThiefNetCostMinimum
thiefnet_maximum: ThiefNetCostMaximum
@@ -113,6 +123,7 @@ sly3_option_groups = [
CoinsMaximum
]),
OptionGroup("Locations",[
ThiefNetLocations,
ThiefNetCostMinimum,
ThiefNetCostMaximum
])

View File

@@ -35,15 +35,14 @@ def gen_crew(world: "Sly3World") -> list[Item]:
return crew
def gen_episodes(world: "Sly3World") -> list[Item]:
"""Generate the progressive episodes items for the item pool"""
"""Generate the episodes items for the item pool"""
all_episodes = [
item_name for item_name in item_groups["Episode"]
for _ in range(4)
]
# Make sure the starting episode is precollected
starting_episode_n = world.options.starting_episode.value
starting_episode = f"Progressive {list(EPISODES.keys())[starting_episode_n]}"
starting_episode = list(EPISODES.keys())[starting_episode_n]
all_episodes.remove(starting_episode)
world.multiworld.push_precollected(world.create_item(starting_episode))

View File

@@ -1,9 +1,9 @@
import typing
from BaseClasses import Region, CollectionState, Location
from BaseClasses import Region, CollectionState
from .data.Locations import location_dict
from .data.Constants import EPISODES, CHALLENGES
from .data.Constants import EPISODES, CHALLENGES, REQUIREMENTS
if typing.TYPE_CHECKING:
from . import Sly3World
@@ -13,40 +13,21 @@ def create_access_rule(episode: str, n: int, options: "Sly3Options", player: int
"""Returns a function that checks if the player has access to a specific region"""
def rule(state: CollectionState):
access = True
item_name = f"Progressive {episode}"
if episode == "Honor Among Thieves":
access = access and state.count_group("Crew", player) == 7
else:
access = access and state.count(item_name, player) >= n
access = access and state.count(episode, player) == 1
if n > 1:
requirements = sum({
"An Opera of Fear": [
[],
["Binocucom", "Bentley"],
["Carmelita", "Murray", "Ball Form", "Disguise (Venice)"]
],
"Rumble Down Under": [
[],
["Murray", "Guru"],
["Bentley"]
],
"Flight of Fancy": [
[],
["Murray", "Bentley", "Guru", "Fishing Pole", "Penelope"],
["Hover Pack", "Carmelita", "Binocucom"]
],
"A Cold Alliance": [
["Bentley", "Murray", "Guru", "Penelope", "Binocucom"],
["Disguise (Photographer)", "Grapple-Cam", "Panda King"],
["Carmelita"]
],
"Dead Men Tell No Tales": [
[],
["Bentley", "Penelope", "Grapple-Cam", "Murray", "Silent Obliteration", "Treasure Map"],
["Panda King", "Dimitri"]
]
}[episode][:n-2], [])
section_requirements = [
sum(
ep_reqs,
[]
)
for ep_reqs
in REQUIREMENTS["Jobs"][episode][:n-1]
]
requirements = list(set(sum(section_requirements, [])))
access = access and all(state.has(i, player) for i in requirements)
return access
@@ -59,12 +40,13 @@ def create_regions_sly3(world: "Sly3World"):
menu = Region("Menu", world.player, world.multiworld)
menu.add_locations({
f"ThiefNet {i+1:02}": location_dict[f"ThiefNet {i+1:02}"].code
for i in range(37)
for i in range(world.options.thiefnet_locations)
})
world.multiworld.regions.append(menu)
for i, episode in enumerate(EPISODES.keys()):
print(f"==={episode}===")
for n in range(1,5):
if n == 2 and episode == "Honor Among Thieves":
break
@@ -80,6 +62,7 @@ def create_regions_sly3(world: "Sly3World"):
})
world.multiworld.regions.append(region)
menu.connect(
region,
None,

View File

@@ -4,176 +4,68 @@ from math import ceil
from BaseClasses import CollectionState
from worlds.generic.Rules import add_rule
from .data.Constants import EPISODES
from .data.Constants import EPISODES, CHALLENGES, REQUIREMENTS
if typing.TYPE_CHECKING:
from . import Sly3World
def set_rules_sly3(world: "Sly3World"):
player = world.player
thiefnet_items = world.options.thiefnet_locations.value
# Putting ThiefNet stuff out of logic, to make early game less slow.
# Divides the items into 8 groups of 3. First groups requires 2 episodes
# items to be in logic, second group requires 4, etc.
for i in range(1,35):
episode_items_n = ceil(i/4)*2
# Divides the items into groups that require a number of episode and crew
# items to be in logic
for i in range(1,thiefnet_items):
divisor = ceil(thiefnet_items/12)
episode_items_n = ceil(i/divisor)
add_rule(
world.get_location(f"ThiefNet {i:02}"),
lambda state, n=episode_items_n: (
state.has_group("Episode", player, n)
(
state.count_group("Episode", player) +
state.count_group("Crew", player)
) >= n
)
)
def require(location: str, item: str|list[str]):
if isinstance(item,str):
add_rule(
world.get_location(location),
lambda state, i=item: (
state.has(i, player)
)
)
else:
add_rule(
world.get_location(location),
lambda state, i=item: (
all(state.has(j, player) for j in i)
)
add_rule(
world.get_location(location),
lambda state, i=item: (
all(state.has(j, player) for j in i)
)
)
### Job requirements
## An Opera of fear
# An Opera of Fear - Police HQ
for episode, sections in EPISODES.items():
if episode == "Honor Among Thieves":
continue
require("An Opera of Fear - Octavio Snap", "Binocucom")
# An Opera of Fear - Into the Depths
require("An Opera of Fear - Canal Chase", "Bentley")
require("An Opera of Fear - Turf War!", "Carmelita")
require("An Opera of Fear - Tar Ball", ["Murray", "Ball Form"])
# An Opera of Fear - Run 'n Bomb
require("An Opera of Fear - Guard Duty", "Disguise (Venice)")
require("An Opera of Fear - Operation: Tar-Be Gone!", "Bombs")
## Rumble Down Under
# Rumble Down Under - Search for the Guru
require("Rumble Down Under - Spelunking", "Murray")
# Rumble Down Under - Dark Caves
# Rumble Down Under - Big Truck
require("Rumble Down Under - Unleash the Guru", "Guru")
# Rumble Down Under - The Claw
require("Rumble Down Under - Lemon Rage", "Bentley")
# Rumble Down Under - Hungry Croc
# Rumble Down Under - Operation: Moon Crash
## Flight of Fancy
# Flight of Fancy - Hidden Flight Roster
require("Flight of Fancy - Frame Team Belgium", ["Murray", "Bentley", "Guru", "Fishing Pole"])
require("Flight of Fancy - Frame Team Iceland", "Murray")
require("Flight of Fancy - Cooper Hangar Defense", "Penelope")
require("Flight of Fancy - ACES Semifinals", ["Murray", "Bentley", "Guru", "Fishing Pole", "Penelope"])
require("Flight of Fancy - Giant Wolf Massacre", "Binocucom")
require("Flight of Fancy - Windmill Firewall", "Hover Pack")
require("Flight of Fancy - Beauty and the Beast", "Carmelita")
require("Flight of Fancy - Operation: Turbo Dominant Eagle", "Paraglider")
## A Cold Alliance
require("A Cold Alliance - King of Fire", ["Bentley", "Murray", "Guru", "Penelope", "Binocucom"])
require("A Cold Alliance - Get a Job", "Disguise (Photographer)")
require("A Cold Alliance - Tearful Reunion", "Panda King")
require("A Cold Alliance - Grapple-Cam Break-In", "Grapple-Cam")
require("A Cold Alliance - Laptop Retrieval", ["Disguise (Photographer)", "Panda King", "Grapple-Cam"])
# A Cold Alliance - Vampiric Defense
# A Cold Alliance - Down the Line
require("A Cold Alliance - A Battery of Peril", "Carmelita")
# A Cold Alliance - Operation: Wedding Crasher
## Dead Men Tell No Tales
require("Dead Men Tell No Tales - The Talk of Pirates", "Disguise (Pirate)")
require("Dead Men Tell No Tales - Dynamic Duo", ["Bentley", "Penelope", "Grapple-Cam"])
require("Dead Men Tell No Tales - Jollyboat of Destruction", "Murray")
require("Dead Men Tell No Tales - X Marks the Spot", ["Bentley", "Penelope", "Grapple-Cam", "Murray", "Silent Obliteration", "Treasure Map"])
require("Dead Men Tell No Tales - Crusher from the Depths", "Panda King")
require("Dead Men Tell No Tales - Deep Sea Danger", "Dimitri")
# Dead Men Tell No Tales - Battle on the High Seas
require("Dead Men Tell No Tales - Operation: Reverse Double-Cross", "Guru")
## Honor Among Thieves
# Honor Among Thieves - Carmelita to the Rescue
# Honor Among Thieves - A Deadly Bite
# Honor Among Thieves - The Dark Current
# Honor Among Thieves - Bump-Charge-Jump
# Honor Among Thieves - Danger in the Skie
# Honor Among Thieves - The Ancestors' Gauntlet
# Honor Among Thieves - Stand your Ground
# Honor Among Thieves - Final Legacy
for i, s in enumerate(sections):
for j, job in enumerate(s):
reqs = REQUIREMENTS["Jobs"][episode][i][j]
add_rule(
world.get_location(f"{episode} - {job}"),
lambda state, items=reqs: (
all(state.has(item, player) for item in items)
)
)
### Challenge requirements
## An Opera of Fear
require("An Opera of Fear - Canal Chase - Expert Course", "Bentley")
require("An Opera of Fear - Air Time", ["Murray", "Ball Form"])
# An Opera of Fear - Tower Scramble
# An Opera of Fear - Coin Chase
require("An Opera of Fear - Speed Bombing", "Bombs")
# An Opera of Fear - Octavio Canal Challenge
# An Opera of Fear - Octavio's Last Stand
require("An Opera of Fear - Venice Treasure Hunt", "Treasure Map")
for episode, sections in CHALLENGES.items():
if episode == "Honor Among Thieves":
continue
## Rumble Down Under
# Rumble Down Under - Rock Run
# Rumble Down Under - Cave Sprint
# Rumble Down Under - Cave Mayhem
# Rumble Down Under - Scaling the Drill
require("Rumble Down Under - Guard Swappin'", "Guru")
# Rumble Down Under - Quick Claw
require("Rumble Down Under - Pressure Brawl", "Bentley")
# Rumble Down Under - Croc and Coins
# Rumble Down Under - Carmelita Climb
require("Rumble Down Under - Outback Treasure Hunt", "Treasure Map")
## Flight of Fancy
# Flight of Fancy - Castle Quick Climb
require("Flight of Fancy - Muggshot Goon Attack", "Penelope")
require("Flight of Fancy - Security Breach", "Penelope")
require("Flight of Fancy - Defend the Hangar", "Penelope")
require("Flight of Fancy - Precision Air Duel", ["Murray", "Bentley", "Guru", "Fishing Pole", "Penelope"])
require("Flight of Fancy - Wolf Rampage", "Guru")
require("Flight of Fancy - One Woman Army", "Carmelita")
require("Flight of Fancy - Going Out On A Wing", "Paraglider")
require("Flight of Fancy - Holland Treasure Hunt", "Treasure Map")
## A Cold Alliance
require("A Cold Alliance - Big Air in China", ["Bentley", "Murray", "Guru", "Penelope", "Binocucom"])
require("A Cold Alliance - Sharpshooter", "Panda King")
# A Cold Alliance - Treetop Tangle
# A Cold Alliance - Tsao Showdown
require("A Cold Alliance - China Treasure Hunt", "Treasure Map")
## Dead Men Tell No Tales
# Dead Men Tell No Tales - Patch Grab
# Dead Men Tell No Tales - Stealth Challenge
require("Dead Men Tell No Tales - Boat Bash", "Murray")
require("Dead Men Tell No Tales - Last Ship Sailing", ["Bentley", "Penelope", "Grapple-Cam", "Murray", "Silent Obliteration", "Treasure Map"])
# Dead Men Tell No Tales - Pirate Treasure Hunt
## Honor Among Thieves
# Beauty versus the Beast
# Road Rage
# Dr. M Dogfight
# Ultimate Gauntlet
# Battle Against Time
for i, s in enumerate(sections):
for j, challenge in enumerate(s):
reqs = REQUIREMENTS["Challenges"][episode][i][j]
add_rule(
world.get_location(f"{episode} - {challenge}"),
lambda state, items=reqs: (
all(state.has(item, player) for item in items)
)
)
if world.options.goal.value < 6:
victory_condition = [

View File

@@ -106,6 +106,7 @@ class Sly3World(World):
self.options.include_mega_jump.value = slot_data["include_mega_jump"]
self.options.coins_minimum.value = slot_data["coins_minimum"]
self.options.coins_maximum.value = slot_data["coins_maximum"]
self.options.thiefnet_locations.value = slot_data["thiefnet_locations"]
self.options.thiefnet_minimum.value = slot_data["thiefnet_minimum"]
self.options.thiefnet_maximum.value = slot_data["thiefnet_maximum"]
return
@@ -155,6 +156,7 @@ class Sly3World(World):
"include_mega_jump",
"coins_minimum",
"coins_maximum",
"thiefnet_locations",
"thiefnet_minimum",
"thiefnet_maximum",
)
@@ -162,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

@@ -203,16 +203,193 @@ CHALLENGES = {
]
}
# Jobs/Challenges -> episode -> section -> job
# dict[ list[ list[ list[]]]]
REQUIREMENTS = {
"Jobs": {
"An Opera of Fear": [
[[]],
[
["Binocucom"],
[],
["Bentley"],
],
[
["Carmelita"],
["Murray", "Ball Form"],
[],
["Disguise (Venice)"],
],
[
["Bombs"]
]
],
"Rumble Down Under" :[
[[]],
[
["Murray"],
[],
[],
["Guru"],
],
[
[],
["Bentley"],
[]
],
[[]]
],
"Flight of Fancy": [
[[]],
[
["Murray", "Bentley", "Guru", "Fishing Pole"],
["Murray"],
["Penelope"],
["Murray", "Bentley", "Guru", "Fishing Pole", "Penelope"]
],
[
["Binocucom"],
["Hover Pack"],
["Carmelita"]
],
[
["Paraglider"]
]
],
"A Cold Alliance": [
[
["Bentley", "Murray", "Guru", "Penelope", "Binocucom"]
],
[
["Disguise (Photographer)"],
["Panda King"],
["Grapple-Cam"],
["Disguise (Photographer)", "Panda King", "Grapple-Cam"]
],
[
[],
[],
["Carmelita"]
],
[[]]
],
"Dead Men Tell No Tales": [
[
["Disguise (Pirate)"]
],
[
["Bentley", "Penelope", "Grapple-Cam"],
["Murray"],
["Bentley", "Penelope", "Grapple-Cam", "Murray", "Silent Obliteration", "Treasure Map"]
],
[
["Panda King"],
["Dimitri"],
[]
],
[
["Guru"]
]
],
},
"Challenges": {
"An Opera of Fear": [
[],
[
["Bentley"]
],
[
["Murray", "Ball Form"],
[],
[]
],
[
["Bombs"],
[],
[],
["Treasure Map"]
]
],
"Rumble Down Under" :[
[[]],
[
[],
[],
[],
["Guru"]
],
[
[],
["Bentley"],
[]
],
[
[],
["Treasure Map"]
]
],
"Flight of Fancy": [
[[]],
[
["Penelope"],
["Penelope"],
["Penelope"],
["Murray", "Bentley", "Guru", "Fishing Pole", "Penelope"],
],
[
[],
["Carmelita"]
],
[
["Paraglider"],
["Treasure Map"]
]
],
"A Cold Alliance": [
[
["Bentley", "Murray", "Guru", "Penelope", "Binocucom"]
],
[
["Panda King"],
[],
[],
[]
],
[],
[
["Treasure Map"]
]
],
"Dead Men Tell No Tales": [
[
["Disguise (Pirate)"],
["Disguise (Pirate)"]
],
[
["Murray"],
["Bentley", "Penelope", "Grapple-Cam", "Murray", "Silent Obliteration", "Treasure Map"]
],
[],
[[]]
],
}
}
ADDRESSES = {
"SCUS-97464" : {
"world id": 0x468D30,
"map id": 0x47989C,
"job id": 0x36DB98,
"loading": 0x467B00,
"reload": 0x4797C4,
"reload values": 0x4797CC,
"episode unlocks": 0x56AEC8,
"frame counter": 0x389BE0,
"x pressed": 0x36E78E,
"skip cutscene": 0x389C20,
"gadgets": 0x468DCC,
"coins": 0x468DDC,
"DAG root": 0x478C8C,
"jobs": [
[
[0x1335d10]
@@ -227,7 +404,52 @@ ADDRESSES = {
],
"text": {
"powerups": [
{},
{
"Trigger Bomb": (0x58db60,0x58dcf0),
"Fishing Pole": (0x595da0,0x595fc0),
"Alarm Clock": (0x591db0,0x591f40),
"Adrenaline Burst": (0x58e800,0x58e9c0),
"Health Extractor": (0x58ebe0,0x58ee00),
"Hover Pack": (0x58ef90,0x58f1b0),
"Insanity Strike": (0x593a40,0x593b70),
"Grapple-Cam": (0x5957d0,0x595ae0),
"Size Destabilizer": (0x58df70,0x58e170),
"Rage Bomb": (0x594160,0x5942d0),
"Reduction Bomb": (0x58f260,0x58f390),
"Be The Ball": (0x5955c0,0x595730),
"Berserker Charge": (0x5912d0,0x591380),
"Juggernaut Throw": (0x590730,0x590850),
"Guttural Roar": (0x5914e0,0x591610),
"Fists of Flame": (0x58f960,0x5900b0),
"Temporal Lock": (0x58f440,0x58f5a0),
"Raging Inferno Flop": (0x5916c0,0x5917f0),
"Diablo Fire Slam": (0x590fa0,0x591090),
"Smoke Bomb": (0x5918f0,0x591a00),
"Combat Dodge": (0x591b40,0x591c90),
"Paraglide": (0x5921f0,0x5924c0),
"Silent Obliteration": (0x592690,0x592870),
"Feral Pounce": (0x592c50,0x592de0),
"Mega Jump": (0x592fc0,0x593180),
"Knockout Dive": (0x5936d0,0x5938e0),
"Shadow Power Level 1": (0x594770,0x594880),
"Thief Reflexes": (0x592a10,0x592b50),
"Shadow Power Level 2": (0x5949e0,0x594d00),
"Rocket Boots": (0x577060,0x577300),
"Treasure Map": (0x576af0,0x576dc0),
"ENGLISHpowerup_shield_name": (0x596280,0x576450),
"Venice Disguise": (0x577510,0x577670),
"Photographer Disguise": (0x5778f0,0x577ac0),
"Pirate Disguise": (0x577ca0,0x577e20),
"Spin Attack Level 1": (0x577fe0,0x5781b0),
"Spin Attack Level 2": (0x578350,0x578500),
"Spin Attack Level 3": (0x578770,0x578af0),
"Jump Attack Level 1": (0x578d80,0x579070),
"Jump Attack Level 2": (0x579390,0x579620),
"Jump Attack Level 3": (0x5797b0,0x579950),
"Push Attack Level 1": (0x579ae0,0x579d70),
"Push Attack Level 2": (0x579f70,0x57a1f0),
"Push Attack Level 3": (0x57a670,0x57a940),
},
{},
{
"Trigger Bomb": (0x592c40,0x592e00),
@@ -300,4 +522,4 @@ MENU_RETURN_DATA = (
"F93E1EEE"+
"7F2319BC"+
"7B8274B1"
)
)

View File

@@ -70,16 +70,16 @@ crew_list = [
("Carmelita", ItemClassification.progression, "Crew")
]
progressive_episode_list = [
(f"Progressive {e}", ItemClassification.progression, "Episode")
for e in list(EPISODES.keys())[:-1]
episode_list = [
(episode, ItemClassification.progression, "Episode")
for episode in list(EPISODES.keys())[:-1]
]
item_list = (
filler_list +
powerup_list +
crew_list +
progressive_episode_list
episode_list
)
base_code = 5318008

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