✨ Initial commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/build/target
|
||||
.vscode/
|
||||
__pycache__/
|
||||
2
Sly3Client.py
Normal file
2
Sly3Client.py
Normal file
@@ -0,0 +1,2 @@
|
||||
def launch_client():
|
||||
pass
|
||||
378
Sly3Interface.py
Normal file
378
Sly3Interface.py
Normal file
@@ -0,0 +1,378 @@
|
||||
from typing import Optional, Dict, NamedTuple
|
||||
import struct
|
||||
from logging import Logger
|
||||
from enum import IntEnum
|
||||
|
||||
from .pcsx2_interface.pine import Pine
|
||||
from .data.Constants import ADDRESSES, MENU_RETURN_DATA
|
||||
|
||||
class Sly3Episode(IntEnum):
|
||||
Title_Screen = 0
|
||||
An_Opera_of_Fear = 1
|
||||
Rumble_Down_Under = 2
|
||||
Flight_of_Fancy = 3
|
||||
A_Cold_Alliance = 4
|
||||
Dead_Men_Tell_No_Tales = 5
|
||||
Honor_Among_Thieves = 6
|
||||
|
||||
class PowerUps(NamedTuple):
|
||||
attack = False
|
||||
binocucom = False
|
||||
bombs = False
|
||||
unknown = False
|
||||
trigger_Bomb = False
|
||||
fishing_pole = 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
|
||||
|
||||
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
|
||||
|
||||
diablo_fire_slam = False
|
||||
smoke_bomb = False
|
||||
combat_dodge = False
|
||||
paraglider = False
|
||||
silent_obliteration = False
|
||||
feral_pounce = False
|
||||
mega_jump = False
|
||||
knockout_dive = 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
|
||||
|
||||
pirate_disguise = False
|
||||
spin_1 = False
|
||||
spin_2 = False
|
||||
spin_3 = False
|
||||
jump_1 = False
|
||||
jump_2 = False
|
||||
jump_3 = False
|
||||
push_1 = False
|
||||
|
||||
push_2 = False
|
||||
push_3 = False
|
||||
|
||||
class GameInterface():
|
||||
"""
|
||||
Base class for connecting with a pcsx2 game
|
||||
"""
|
||||
|
||||
pcsx2_interface: Pine = Pine()
|
||||
logger: Logger
|
||||
game_id_error: Optional[str] = None
|
||||
current_game: Optional[str] = None
|
||||
addresses: Dict = {}
|
||||
|
||||
def __init__(self, logger) -> None:
|
||||
self.logger = logger
|
||||
|
||||
def _read8(self, address: int):
|
||||
return self.pcsx2_interface.read_int8(address)
|
||||
|
||||
def _read16(self, address: int):
|
||||
return self.pcsx2_interface.read_int16(address)
|
||||
|
||||
def _read32(self, address: int):
|
||||
return self.pcsx2_interface.read_int32(address)
|
||||
|
||||
def _read_bytes(self, address: int, n: int):
|
||||
return self.pcsx2_interface.read_bytes(address, n)
|
||||
|
||||
def _read_float(self, address: int):
|
||||
return struct.unpack("f",self.pcsx2_interface.read_bytes(address, 4))[0]
|
||||
|
||||
def _write8(self, address: int, value: int):
|
||||
self.pcsx2_interface.write_int8(address, value)
|
||||
|
||||
def _write16(self, address: int, value: int):
|
||||
self.pcsx2_interface.write_int16(address, value)
|
||||
|
||||
def _write32(self, address: int, value: int):
|
||||
self.pcsx2_interface.write_int32(address, value)
|
||||
|
||||
def _write_bytes(self, address: int, value: bytes):
|
||||
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))
|
||||
|
||||
def connect_to_game(self):
|
||||
"""
|
||||
Initializes the connection to PCSX2 and verifies it is connected to the
|
||||
right game
|
||||
"""
|
||||
if not self.pcsx2_interface.is_connected():
|
||||
self.pcsx2_interface.connect()
|
||||
if not self.pcsx2_interface.is_connected():
|
||||
return
|
||||
self.logger.info("Connected to PCSX2 Emulator")
|
||||
try:
|
||||
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 in ADDRESSES.keys():
|
||||
self.current_game = game_id
|
||||
self.addresses = ADDRESSES[game_id]
|
||||
if self.current_game is None and self.game_id_error != game_id and game_id != b'\x00\x00\x00\x00\x00\x00':
|
||||
self.logger.warning(
|
||||
f"Connected to the wrong game ({game_id})")
|
||||
self.game_id_error = game_id
|
||||
except RuntimeError:
|
||||
pass
|
||||
except ConnectionError:
|
||||
pass
|
||||
|
||||
def disconnect_from_game(self):
|
||||
self.pcsx2_interface.disconnect()
|
||||
self.current_game = None
|
||||
self.logger.info("Disconnected from PCSX2 Emulator")
|
||||
|
||||
def get_connection_state(self) -> bool:
|
||||
try:
|
||||
connected = self.pcsx2_interface.is_connected()
|
||||
return connected and self.current_game is not None
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
class Sly3Interface(GameInterface):
|
||||
def _reload(self, reload_data: bytes):
|
||||
self._write_bytes(
|
||||
self.addresses["reload values"],
|
||||
reload_data
|
||||
)
|
||||
self._write32(self.addresses["reload"], 1)
|
||||
|
||||
def _get_job_address(self, task: int) -> int:
|
||||
pointer = self._read32(self.addresses["DAG root"])
|
||||
for _ in range(task):
|
||||
pointer = self._read32(pointer+0x20)
|
||||
|
||||
return pointer
|
||||
|
||||
def get_current_map(self) -> int:
|
||||
return self._read32(self.addresses["map id"])
|
||||
|
||||
def get_current_job(self) -> int:
|
||||
return self._read32(self.addresses["job id"])
|
||||
|
||||
def set_current_job(self, job: int) -> None:
|
||||
self._write32(self.addresses["job id"], job)
|
||||
|
||||
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)
|
||||
|
||||
if __name__ == "__main__":
|
||||
interf = Sly3Interface(Logger("test"))
|
||||
interf.connect_to_game()
|
||||
|
||||
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
|
||||
],
|
||||
]
|
||||
|
||||
# byte_list = [[False for _ in range(8)] for _ in range(8)]
|
||||
|
||||
data = b''.join(
|
||||
int(''.join(str(int(i)) for i in byte[::-1]),2).to_bytes(1,"big")
|
||||
for byte in byte_list
|
||||
)
|
||||
|
||||
interf._write_bytes(0x468DCC,data)
|
||||
|
||||
# data = interf._read_bytes(0x468DCC, 8)
|
||||
# bits = [
|
||||
# bool(int(b))
|
||||
# for byte in data
|
||||
# for b in f"{byte:08b}"[::-1]
|
||||
# ]
|
||||
|
||||
# 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))
|
||||
119
Sly3Options.py
Normal file
119
Sly3Options.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from Options import (
|
||||
DeathLink,
|
||||
StartInventoryPool,
|
||||
PerGameCommonOptions,
|
||||
Choice,
|
||||
Toggle,
|
||||
DefaultOnToggle,
|
||||
Range,
|
||||
OptionGroup
|
||||
)
|
||||
from dataclasses import dataclass
|
||||
|
||||
class StartingEpisode(Choice):
|
||||
"""
|
||||
Select Which episode to start with.
|
||||
"""
|
||||
|
||||
display_name = "Starting Episode"
|
||||
option_An_Opera_of_Fear = 0
|
||||
option_Rumble_Down_Under = 1
|
||||
option_Flight_of_Fancy = 2
|
||||
option_A_Cold_Alliance = 3
|
||||
option_Dead_Men_Tell_No_Tales = 4
|
||||
default = 0
|
||||
|
||||
class Goal(Choice):
|
||||
"""
|
||||
Which boss you must defeat to goal.
|
||||
"""
|
||||
display_name = "Goal"
|
||||
option_Don_Octavio = 0
|
||||
option_Dark_Mask = 1
|
||||
option_Black_Baron = 2
|
||||
option_General_Tsao = 3
|
||||
option_Captain_LeFwee = 4
|
||||
option_Dr_M = 5
|
||||
option_All_Bosses = 6
|
||||
default = 5
|
||||
|
||||
|
||||
class IncludeMegaJump(Toggle):
|
||||
"""
|
||||
Add the Mega Jump ability to the pool.
|
||||
"""
|
||||
|
||||
display_name = "Include Mega Jump"
|
||||
|
||||
|
||||
class CoinsMinimum(Range):
|
||||
"""
|
||||
The minimum number of coins you'll receive when you get a "Coins" filler
|
||||
item.
|
||||
"""
|
||||
|
||||
display_name = "Coins Minimum"
|
||||
range_start = 0
|
||||
range_end = 1000
|
||||
default = 50
|
||||
|
||||
|
||||
class CoinsMaximum(Range):
|
||||
"""
|
||||
The maximum number of coins you'll receive when you get a "Coins" filler
|
||||
item.
|
||||
"""
|
||||
|
||||
display_name = "Coins Maximum"
|
||||
range_start = 0
|
||||
range_end = 1000
|
||||
default = 200
|
||||
|
||||
|
||||
class ThiefNetCostMinimum(Range):
|
||||
"""
|
||||
The minimum number of coins items on ThiefNet will cost.
|
||||
"""
|
||||
|
||||
display_name = "ThiefNet Cost Minimum"
|
||||
range_start = 0
|
||||
range_end = 9999
|
||||
default = 200
|
||||
|
||||
|
||||
class ThiefNetCostMaximum(Range):
|
||||
"""
|
||||
The maximum number of coins items on ThiefNet will cost.
|
||||
"""
|
||||
|
||||
display_name = "ThiefNet Cost Maximum"
|
||||
range_start = 0
|
||||
range_end = 9999
|
||||
default = 2000
|
||||
|
||||
@dataclass
|
||||
class Sly3Options(PerGameCommonOptions):
|
||||
start_inventory_from_pool: StartInventoryPool
|
||||
death_link: DeathLink
|
||||
starting_episode: StartingEpisode
|
||||
goal: Goal
|
||||
include_mega_jump: IncludeMegaJump
|
||||
coins_minimum: CoinsMinimum
|
||||
coins_maximum: CoinsMaximum
|
||||
thiefnet_minimum: ThiefNetCostMinimum
|
||||
thiefnet_maximum: ThiefNetCostMaximum
|
||||
|
||||
sly3_option_groups = [
|
||||
OptionGroup("Goal",[
|
||||
Goal
|
||||
]),
|
||||
OptionGroup("Items",[
|
||||
IncludeMegaJump,
|
||||
CoinsMinimum,
|
||||
CoinsMaximum
|
||||
]),
|
||||
OptionGroup("Locations",[
|
||||
ThiefNetCostMinimum,
|
||||
ThiefNetCostMaximum
|
||||
])
|
||||
]
|
||||
68
Sly3Pool.py
Normal file
68
Sly3Pool.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import typing
|
||||
|
||||
from BaseClasses import Item
|
||||
|
||||
from .data.Constants import EPISODES
|
||||
from .data.Items import item_groups
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from . import Sly3World
|
||||
|
||||
def gen_powerups(world: "Sly3World") -> list[Item]:
|
||||
"""Generate the power-ups for the item pool"""
|
||||
powerups = []
|
||||
for item_name in item_groups["Power-Up"]:
|
||||
if item_name == "Mega Jump" and not world.options.include_mega_jump:
|
||||
continue
|
||||
elif item_name == "Progressive Shadow Power":
|
||||
powerups.append(world.create_item(item_name))
|
||||
powerups.append(world.create_item(item_name))
|
||||
elif item_name[:11] == "Progressive":
|
||||
powerups.append(world.create_item(item_name))
|
||||
powerups.append(world.create_item(item_name))
|
||||
powerups.append(world.create_item(item_name))
|
||||
else:
|
||||
powerups.append(world.create_item(item_name))
|
||||
|
||||
return powerups
|
||||
|
||||
def gen_crew(world: "Sly3World") -> list[Item]:
|
||||
"""Generate the crew for the item pool"""
|
||||
crew = []
|
||||
for item_name in item_groups["Crew"]:
|
||||
crew.append(world.create_item(item_name))
|
||||
|
||||
return crew
|
||||
|
||||
def gen_episodes(world: "Sly3World") -> list[Item]:
|
||||
"""Generate the progressive 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]}"
|
||||
all_episodes.remove(starting_episode)
|
||||
world.multiworld.push_precollected(world.create_item(starting_episode))
|
||||
|
||||
return [world.create_item(e) for e in all_episodes]
|
||||
|
||||
|
||||
|
||||
def gen_pool_sly3(world: "Sly3World") -> list[Item]:
|
||||
"""Generate the item pool for the world"""
|
||||
item_pool = []
|
||||
item_pool += gen_powerups(world)
|
||||
item_pool += gen_episodes(world)
|
||||
item_pool += gen_crew(world)
|
||||
|
||||
unfilled_locations = world.multiworld.get_unfilled_locations(world.player)
|
||||
remaining = len(unfilled_locations)-len(item_pool)
|
||||
if world.options.goal.value < 6:
|
||||
remaining -= 1
|
||||
assert remaining >= 0, f"There are more items than locations ({len(item_pool)} items; {len(unfilled_locations)} locations)"
|
||||
item_pool += [world.create_item(world.get_filler_item_name()) for _ in range(remaining)]
|
||||
|
||||
return item_pool
|
||||
88
Sly3Regions.py
Normal file
88
Sly3Regions.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import typing
|
||||
|
||||
from BaseClasses import Region, CollectionState, Location
|
||||
|
||||
from .data.Locations import location_dict
|
||||
from .data.Constants import EPISODES, CHALLENGES
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from . import Sly3World
|
||||
from .Sly3Options import Sly3Options
|
||||
|
||||
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
|
||||
|
||||
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], [])
|
||||
access = access and all(state.has(i, player) for i in requirements)
|
||||
|
||||
return access
|
||||
|
||||
return rule
|
||||
|
||||
def create_regions_sly3(world: "Sly3World"):
|
||||
"""Creates a region for each chapter of each episode"""
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
world.multiworld.regions.append(menu)
|
||||
|
||||
for i, episode in enumerate(EPISODES.keys()):
|
||||
for n in range(1,5):
|
||||
if n == 2 and episode == "Honor Among Thieves":
|
||||
break
|
||||
|
||||
region = Region(f"Episode {i+1} ({n})", world.player, world.multiworld)
|
||||
region.add_locations({
|
||||
f"{episode} - {job}": location_dict[f"{episode} - {job}"].code
|
||||
for job in EPISODES[episode][n-1]
|
||||
})
|
||||
region.add_locations({
|
||||
f"{episode} - {challenge}": location_dict[f"{episode} - {challenge}"].code
|
||||
for challenge in CHALLENGES[episode][n-1]
|
||||
})
|
||||
|
||||
world.multiworld.regions.append(region)
|
||||
menu.connect(
|
||||
region,
|
||||
None,
|
||||
create_access_rule(episode, n, world.options, world.player)
|
||||
)
|
||||
|
||||
208
Sly3Rules.py
Normal file
208
Sly3Rules.py
Normal file
@@ -0,0 +1,208 @@
|
||||
import typing
|
||||
from math import ceil
|
||||
|
||||
from BaseClasses import CollectionState
|
||||
|
||||
from worlds.generic.Rules import add_rule
|
||||
from .data.Constants import EPISODES
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from . import Sly3World
|
||||
|
||||
def set_rules_sly3(world: "Sly3World"):
|
||||
player = world.player
|
||||
|
||||
# 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
|
||||
add_rule(
|
||||
world.get_location(f"ThiefNet {i:02}"),
|
||||
lambda state, n=episode_items_n: (
|
||||
state.has_group("Episode", 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)
|
||||
)
|
||||
)
|
||||
|
||||
### Job requirements
|
||||
## An Opera of fear
|
||||
# An Opera of Fear - Police HQ
|
||||
|
||||
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
|
||||
|
||||
### 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")
|
||||
|
||||
## 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
|
||||
|
||||
if world.options.goal.value < 6:
|
||||
victory_condition = [
|
||||
"An Opera of Fear - Operation: Tar-Be Gone!",
|
||||
"Rumble Down Under - Operation: Moon Crash",
|
||||
"Flight of Fancy - Operation: Turbo Dominant Eagle",
|
||||
"A Cold Alliance - Operation: Wedding Crasher",
|
||||
"Dead Men Tell No Tales - Operation: Reverse Double-Cross",
|
||||
"Honor Among Thieves - Final Legacy"
|
||||
][world.options.goal.value]
|
||||
|
||||
victory_location = world.multiworld.get_location(victory_condition, world.player)
|
||||
victory_location.address = None
|
||||
victory_location.place_locked_item(world.create_event("Victory"))
|
||||
world.multiworld.completion_condition[world.player] = lambda state: state.has("Victory", world.player)
|
||||
elif world.options.goal.value == 6:
|
||||
def access_rule(state: CollectionState):
|
||||
victory_conditions = [
|
||||
"An Opera of Fear - Operation: Tar-Be Gone!",
|
||||
"Rumble Down Under - Operation: Moon Crash",
|
||||
"Flight of Fancy - Operation: Turbo Dominant Eagle",
|
||||
"A Cold Alliance - Operation: Wedding Crasher",
|
||||
"Dead Men Tell No Tales - Operation: Reverse Double-Cross",
|
||||
"Honor Among Thieves - Final Legacy"
|
||||
]
|
||||
|
||||
return all(
|
||||
world.multiworld.get_location(cond,world.player).access_rule(state)
|
||||
for cond in victory_conditions
|
||||
)
|
||||
|
||||
world.multiworld.completion_condition[world.player] = access_rule
|
||||
167
__init__.py
Normal file
167
__init__.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from typing import Dict, List, Any, Optional, Mapping
|
||||
import logging
|
||||
|
||||
from BaseClasses import Item, ItemClassification
|
||||
from worlds.AutoWorld import World, WebWorld
|
||||
from worlds.LauncherComponents import (
|
||||
Component,
|
||||
Type,
|
||||
components,
|
||||
launch,
|
||||
icon_paths,
|
||||
)
|
||||
|
||||
from .Sly3Options import sly3_option_groups, Sly3Options
|
||||
from .Sly3Regions import create_regions_sly3
|
||||
from .Sly3Pool import gen_pool_sly3
|
||||
from .Sly3Rules import set_rules_sly3
|
||||
from .data.Items import item_dict, item_groups, Sly3Item
|
||||
from .data.Locations import location_dict, location_groups
|
||||
from .data.Constants import EPISODES
|
||||
|
||||
## Client stuff
|
||||
def run_client():
|
||||
from .Sly3Client import launch_client
|
||||
launch(launch_client, name="Sly3Client")
|
||||
|
||||
icon_paths["sly3_ico"] = f"ap:{__name__}/icon.png"
|
||||
components.append(
|
||||
Component("Sly 3 Client", func=run_client, component_type=Type.CLIENT, icon="sly3_ico")
|
||||
)
|
||||
|
||||
|
||||
## UT Stuff
|
||||
def map_page_index(episode: str) -> int:
|
||||
mapping = {k: i for i,k in enumerate(EPISODES.keys())}
|
||||
|
||||
return mapping.get(episode,0)
|
||||
|
||||
## The world
|
||||
class Sly3Web(WebWorld):
|
||||
game = "Sly 3: Honor Among Thieves"
|
||||
option_groups = sly3_option_groups
|
||||
|
||||
class Sly3World(World):
|
||||
"""
|
||||
Sly 3: Honor Among Thieves is a 2004 stealth action video game developed by
|
||||
Sucker Punch Productions and published by Sony Computer Entertainment for
|
||||
the PlayStation 2.
|
||||
"""
|
||||
|
||||
game = "Sly 3: Honor Among Thieves"
|
||||
web = Sly3Web()
|
||||
|
||||
options_dataclass = Sly3Options
|
||||
options: Sly3Options
|
||||
topology_present = True
|
||||
|
||||
item_name_to_id = {item.name: item.code for item in item_dict.values()}
|
||||
item_name_groups = item_groups
|
||||
location_name_to_id = {
|
||||
location.name: location.code for location in location_dict.values()
|
||||
}
|
||||
location_name_groups = location_groups
|
||||
|
||||
thiefnet_costs: List[int] = []
|
||||
|
||||
# this is how we tell the Universal Tracker we want to use re_gen_passthrough
|
||||
@staticmethod
|
||||
def interpret_slot_data(slot_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return slot_data
|
||||
|
||||
# and this is how we tell Universal Tracker we don't need the yaml
|
||||
ut_can_gen_without_yaml = True
|
||||
|
||||
def validate_options(self, opt: Sly3Options):
|
||||
if opt.coins_maximum < opt.coins_minimum:
|
||||
logging.warning(
|
||||
f"{self.player_name}: " +
|
||||
f"Coins minimum cannot be larger than maximum (min: {opt.coins_minimum}, max: {opt.coins_maximum}). Swapping values."
|
||||
)
|
||||
temp = opt.coins_minimum.value
|
||||
opt.coins_minimum.value = opt.coins_maximum.value
|
||||
opt.coins_maximum.value = temp
|
||||
|
||||
if opt.thiefnet_maximum < opt.thiefnet_minimum:
|
||||
logging.warning(
|
||||
f"{self.player_name}: " +
|
||||
f"Thiefnet minimum cannot be larger than maximum (min: {opt.thiefnet_minimum}, max: {opt.thiefnet_maximum}). Swapping values."
|
||||
)
|
||||
temp = opt.thiefnet_minimum.value
|
||||
opt.thiefnet_minimum.value = opt.thiefnet_maximum.value
|
||||
opt.thiefnet_maximum.value = temp
|
||||
|
||||
def generate_early(self) -> None:
|
||||
# implement .yaml-less Universal Tracker support
|
||||
if hasattr(self.multiworld, "generation_is_fake"):
|
||||
if hasattr(self.multiworld, "re_gen_passthrough"):
|
||||
# I'm doing getattr purely so pylance stops being mad at me
|
||||
re_gen_passthrough = getattr(self.multiworld, "re_gen_passthrough")
|
||||
|
||||
if "Sly 3: Honor Among Thieves" in re_gen_passthrough:
|
||||
slot_data = re_gen_passthrough["Sly 3: Honor Among Thieves"]
|
||||
self.thiefnet_costs = slot_data["thiefnet_costs"]
|
||||
self.options.starting_episode.value = slot_data["starting_episode"]
|
||||
self.options.goal.value = slot_data["goal"]
|
||||
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_minimum.value = slot_data["thiefnet_minimum"]
|
||||
self.options.thiefnet_maximum.value = slot_data["thiefnet_maximum"]
|
||||
return
|
||||
|
||||
self.validate_options(self.options)
|
||||
|
||||
thiefnet_min = self.options.thiefnet_minimum.value
|
||||
thiefnet_max = self.options.thiefnet_maximum.value
|
||||
self.thiefnet_costs = sorted([
|
||||
self.random.randint(thiefnet_min,thiefnet_max)
|
||||
for _ in range(37)
|
||||
])
|
||||
|
||||
def create_regions(self) -> None:
|
||||
create_regions_sly3(self)
|
||||
|
||||
def get_filler_item_name(self) -> str:
|
||||
# Currently just coins
|
||||
return self.random.choice(list(self.item_name_groups["Filler"]))
|
||||
|
||||
def create_item(
|
||||
self, name: str, override: Optional[ItemClassification] = None
|
||||
) -> Item:
|
||||
item = item_dict[name]
|
||||
|
||||
if override is not None:
|
||||
return Sly3Item(name, override, item.code, self.player)
|
||||
|
||||
return Sly3Item(name, item.classification, item.code, self.player)
|
||||
|
||||
def create_event(self, name: str):
|
||||
return Sly3Item(name, ItemClassification.progression, None, self.player)
|
||||
|
||||
def create_items(self) -> None:
|
||||
items_to_add = gen_pool_sly3(self)
|
||||
|
||||
self.multiworld.itempool += items_to_add
|
||||
|
||||
def set_rules(self) -> None:
|
||||
set_rules_sly3(self)
|
||||
|
||||
def get_options_as_dict(self) -> Dict[str, Any]:
|
||||
return self.options.as_dict(
|
||||
"death_link",
|
||||
"starting_episode",
|
||||
"goal",
|
||||
"include_mega_jump",
|
||||
"coins_minimum",
|
||||
"coins_maximum",
|
||||
"thiefnet_minimum",
|
||||
"thiefnet_maximum",
|
||||
)
|
||||
|
||||
def fill_slot_data(self) -> Mapping[str, Any]:
|
||||
slot_data = self.get_options_as_dict()
|
||||
slot_data["thiefnet_costs"] = self.thiefnet_costs
|
||||
|
||||
return slot_data
|
||||
|
||||
303
data/Constants.py
Normal file
303
data/Constants.py
Normal file
@@ -0,0 +1,303 @@
|
||||
EPISODES = {
|
||||
"An Opera of Fear": [
|
||||
[
|
||||
"Police HQ"
|
||||
],
|
||||
[
|
||||
"Octavio Snap",
|
||||
"Into the Depths",
|
||||
"Canal Chase"
|
||||
],
|
||||
[
|
||||
"Turf War!",
|
||||
"Tar Ball",
|
||||
"Run 'n Bomb",
|
||||
"Guard Duty",
|
||||
],
|
||||
[
|
||||
"Operation: Tar-Be Gone!"
|
||||
]
|
||||
],
|
||||
"Rumble Down Under": [
|
||||
[
|
||||
"Search for the Guru"
|
||||
],
|
||||
[
|
||||
"Spelunking",
|
||||
"Dark Caves",
|
||||
"Big Truck",
|
||||
"Unleash the Guru"
|
||||
],
|
||||
[
|
||||
"The Claw",
|
||||
"Lemon Rage",
|
||||
"Hungry Croc"
|
||||
],
|
||||
[
|
||||
"Operation: Moon Crash"
|
||||
]
|
||||
],
|
||||
"Flight of Fancy": [
|
||||
[
|
||||
"Hidden Flight Roster"
|
||||
],
|
||||
[
|
||||
"Frame Team Belgium",
|
||||
"Frame Team Iceland",
|
||||
"Cooper Hangar Defense",
|
||||
"ACES Semifinals"
|
||||
],
|
||||
[
|
||||
"Giant Wolf Massacre",
|
||||
"Windmill Firewall",
|
||||
"Beauty and the Beast"
|
||||
],
|
||||
[
|
||||
"Operation: Turbo Dominant Eagle"
|
||||
]
|
||||
],
|
||||
"A Cold Alliance": [
|
||||
[
|
||||
"King of Fire"
|
||||
],
|
||||
[
|
||||
"Get a Job",
|
||||
"Tearful Reunion",
|
||||
"Grapple-Cam Break-In",
|
||||
"Laptop Retrieval"
|
||||
],
|
||||
[
|
||||
"Vampiric Demise",
|
||||
"Down the Line",
|
||||
"A Battery of Peril"
|
||||
],
|
||||
[
|
||||
"Operation: Wedding Crasher"
|
||||
]
|
||||
],
|
||||
"Dead Men Tell No Tales": [
|
||||
[
|
||||
"The Talk of Pirates"
|
||||
],
|
||||
[
|
||||
"Dynamic Duo",
|
||||
"Jollyboat of Destruction",
|
||||
"X Marks the Spot"
|
||||
],
|
||||
[
|
||||
"Crusher from the Depths",
|
||||
"Deep Sea Danger",
|
||||
"Battle on the High Seas"
|
||||
],
|
||||
[
|
||||
"Operation: Reverse Double-Cross"
|
||||
]
|
||||
],
|
||||
"Honor Among Thieves": [
|
||||
[
|
||||
"Carmelita to the Rescue",
|
||||
"A Deadly Bite",
|
||||
"The Dark Current",
|
||||
"Bump-Charge-Jump",
|
||||
"Danger in the Skies",
|
||||
"The Ancestor's Gauntlet",
|
||||
"Stand Your Ground",
|
||||
"Final Legacy"
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
CHALLENGES = {
|
||||
"An Opera of Fear": [
|
||||
[],
|
||||
[
|
||||
"Canal Chase - Expert Course"
|
||||
],
|
||||
[
|
||||
"Air Time",
|
||||
"Tower Scramble",
|
||||
"Coin Chase",
|
||||
],
|
||||
[
|
||||
"Speed Bombing",
|
||||
"Octavio Canal Challenge",
|
||||
"Octavio's Last Stand",
|
||||
"Venice Treasure Hunt"
|
||||
]
|
||||
],
|
||||
"Rumble Down Under": [
|
||||
[
|
||||
"Rock Run"
|
||||
],
|
||||
[
|
||||
"Cave Sprint",
|
||||
"Cave Mayhem",
|
||||
"Scaling the Drill",
|
||||
"Guard Swappin'"
|
||||
],
|
||||
[
|
||||
"Quick Claw",
|
||||
"Pressure Brawl",
|
||||
"Croc and Coins"
|
||||
],
|
||||
[
|
||||
"Carmelita Climb",
|
||||
"Outback Treasure Hunt"
|
||||
]
|
||||
],
|
||||
"Flight of Fancy": [
|
||||
[
|
||||
"Castle Quick Climb"
|
||||
],
|
||||
[
|
||||
"Muggshot Goon Attack",
|
||||
"Security Breach",
|
||||
"Defend the Hangar",
|
||||
"Precision Air Duel"
|
||||
],
|
||||
[
|
||||
"Wolf Rampage",
|
||||
"One Woman Army",
|
||||
],
|
||||
[
|
||||
"Going Out On A Wing",
|
||||
"Holland Treasure Hunt"
|
||||
]
|
||||
],
|
||||
"A Cold Alliance": [
|
||||
[
|
||||
"Big Air in China"
|
||||
],
|
||||
[
|
||||
"Sharpshooter",
|
||||
"Treetop Tangle",
|
||||
"Tsao Showdown"
|
||||
],
|
||||
[],
|
||||
[
|
||||
"China Treasure Hunt"
|
||||
]
|
||||
],
|
||||
"Dead Men Tell No Tales": [
|
||||
[
|
||||
"Patch Grab",
|
||||
"Stealth Challenge"
|
||||
],
|
||||
[
|
||||
"Boat Bash",
|
||||
"Last Ship Sailing"
|
||||
],
|
||||
[],
|
||||
[
|
||||
"Pirate Treasure Hunt"
|
||||
]
|
||||
],
|
||||
"Honor Among Thieves": [
|
||||
[
|
||||
"Beauty versus the Beast",
|
||||
"Road Rage",
|
||||
"Dr. M Dogfight",
|
||||
"Ultimate Gauntlet",
|
||||
"Battle Against Time"
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
ADDRESSES = {
|
||||
"SCUS-97464" : {
|
||||
"map id": 0x47989C,
|
||||
"job id": 0x36DB98,
|
||||
"reload": 0x4797C4,
|
||||
"reload values": 0x4797CC,
|
||||
"episode unlocks": 0x56AEC8,
|
||||
"frame counter": 0x389BE0,
|
||||
"x pressed": 0x36E78E,
|
||||
"skip cutscene": 0x389C20,
|
||||
"jobs": [
|
||||
[
|
||||
[0x1335d10]
|
||||
],
|
||||
[
|
||||
[0x1350560,0x1357f80,0x135aba0]
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[]
|
||||
],
|
||||
"text": {
|
||||
"powerups": [
|
||||
{},
|
||||
{},
|
||||
{
|
||||
"Trigger Bomb": (0x592c40,0x592e00),
|
||||
"Fishing Pole": (0x59b000,0x59b2d0),
|
||||
"Alarm Clock": (0x5962b0,0x5964c0),
|
||||
"Adrenaline Burst": (0x593410,0x5934f0),
|
||||
"Health Extractor": (0x5935c0,0x593660),
|
||||
"Hover Pack": (0x593750,0x593840),
|
||||
"Insanity Strike": (0x598480,0x598690),
|
||||
"Grapple-Cam": (0x59acd0,0x59ae50),
|
||||
"Size Destabilizer": (0x592f30,0x593010),
|
||||
"Rage Bomb": (0x599250,0x599420),
|
||||
"Reduction Bomb": (0x5939b0,0x593b00),
|
||||
"Be The Ball": (0x59a9c0,0x59ab70),
|
||||
"Berserker Charge": (0x5955a0,0x595700),
|
||||
"Juggernaut Throw": (0x594c50,0x594dd0),
|
||||
"Guttural Roar": (0x595830,0x595920),
|
||||
"Fists of Flame": (0x5944a0,0x594610),
|
||||
"Temporal Lock": (0x593c40,0x593e60),
|
||||
"Raging Inferno Flop": (0x595a50,0x595bd0),
|
||||
"Diablo Fire Slam": (0x595260,0x595450),
|
||||
"Smoke Bomb": (0x595d60,0x595ee0),
|
||||
"Combat Dodge": (0x596050,0x596190),
|
||||
"Paraglide": (0x5966d0,0x5968d0),
|
||||
"Silent Obliteration": (0x596ba0,0x596df0),
|
||||
"Feral Pounce": (0x597290,0x5973f0),
|
||||
"Mega Jump": (0x5975f0,0x597780),
|
||||
"Knockout Dive": (0x597e10,0x598130),
|
||||
"Shadow Power Level 1": (0x599c30,0x599eb0),
|
||||
"Thief Reflexes": (0x596f70,0x597110),
|
||||
"Shadow Power Level 2": (0x59a140,0x59a310),
|
||||
"Rocket Boots": (0x57aa00,0x57ace0),
|
||||
"Treasure Map": (0x57a3e0,0x57a780),
|
||||
"ENGLISHpowerup_shield_name": (0x59b550,0x579c50),
|
||||
"Venice Disguise": (0x57ae40,0x57b040),
|
||||
"Photographer Disguise": (0x57b220,0x57b3b0),
|
||||
"Pirate Disguise": (0x57b5d0,0x57b7b0),
|
||||
"Spin Attack Level 1": (0x57b9e0,0x57bc40),
|
||||
"Spin Attack Level 2": (0x57be80,0x57c130),
|
||||
"Spin Attack Level 3": (0x57c300,0x57c5b0),
|
||||
"Jump Attack Level 1": (0x57c7c0,0x57c970),
|
||||
"Jump Attack Level 2": (0x57cb00,0x57cc30),
|
||||
"Jump Attack Level 3": (0x57cdf0,0x57d010),
|
||||
"Push Attack Level 1": (0x57d370,0x57d680),
|
||||
"Push Attack Level 2": (0x57d940,0x57dc80),
|
||||
"Push Attack Level 3": (0x57e070,0x57e3b0),
|
||||
},
|
||||
{},
|
||||
{},
|
||||
{}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MENU_RETURN_DATA = (
|
||||
"794C15EE"+
|
||||
"419A69B1"+
|
||||
"FA2319BC"+
|
||||
"FF2E5E8A"+
|
||||
"ACD1E787"+
|
||||
"3A2B7DB0"+
|
||||
"B94681B3"+
|
||||
"95777951"+
|
||||
"CE8FEAA9"+
|
||||
"07FB6D94"+
|
||||
"F890094F"+
|
||||
"3BFA55F6"+
|
||||
"A0310D22"+
|
||||
"F93E1EEE"+
|
||||
"7F2319BC"+
|
||||
"7B8274B1"
|
||||
)
|
||||
107
data/Items.py
Normal file
107
data/Items.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from typing import NamedTuple
|
||||
|
||||
from BaseClasses import Item, ItemClassification
|
||||
|
||||
from .Constants import EPISODES
|
||||
|
||||
class Sly3Item(Item):
|
||||
game: str = "Sly 3: Honor Among Thieves"
|
||||
|
||||
class Sly3ItemData(NamedTuple):
|
||||
name: str
|
||||
code: int
|
||||
category: str
|
||||
classification: ItemClassification
|
||||
|
||||
filler_list = [
|
||||
("Coins", ItemClassification.filler, "Filler"),
|
||||
]
|
||||
|
||||
powerup_list = [
|
||||
("Binocucom", ItemClassification.progression, "Power-Up"),
|
||||
("Smoke Bomb", ItemClassification.useful, "Power-Up"),
|
||||
("Knockout Dive", ItemClassification.useful, "Power-Up"),
|
||||
("Combat Dodge", ItemClassification.useful, "Power-Up"),
|
||||
("Paraglider", ItemClassification.progression, "Power-Up"),
|
||||
("Rocket Boots", ItemClassification.useful, "Power-Up"),
|
||||
("Silent Obliteration", ItemClassification.progression, "Power-Up"),
|
||||
("Feral Pounce", ItemClassification.useful, "Power-Up"),
|
||||
("Thief Reflexes", ItemClassification.useful, "Power-Up"),
|
||||
("Progressive Shadow Power", ItemClassification.useful, "Power-Up"),
|
||||
("Treasure Map", ItemClassification.progression, "Power-Up"),
|
||||
("Disguise (Venice)", ItemClassification.progression, "Power-Up"),
|
||||
("Disguise (Photographer)", ItemClassification.progression, "Power-Up"),
|
||||
("Disguise (Pirate)", ItemClassification.progression, "Power-Up"),
|
||||
("Progressive Spin Attack", ItemClassification.useful, "Power-Up"),
|
||||
("Progressive Jump Attack", ItemClassification.useful, "Power-Up"),
|
||||
("Progressive Push Attack", ItemClassification.useful, "Power-Up"),
|
||||
("Mega Jump", ItemClassification.useful, "Power-Up"),
|
||||
|
||||
("Bombs", ItemClassification.progression, "Power-Up"),
|
||||
("Trigger Bomb", ItemClassification.useful, "Power-Up"),
|
||||
("Fishing Pole", ItemClassification.progression, "Power-Up"),
|
||||
("Alarm Clock", ItemClassification.useful, "Power-Up"),
|
||||
("Adrenaline Burst", ItemClassification.useful, "Power-Up"),
|
||||
("Health Extractor", ItemClassification.useful, "Power-Up"),
|
||||
("Insanity Strike", ItemClassification.useful, "Power-Up"),
|
||||
("Grapple-Cam", ItemClassification.progression, "Power-Up"),
|
||||
("Size Destabilizer", ItemClassification.useful, "Power-Up"),
|
||||
("Rage Bomb", ItemClassification.useful, "Power-Up"),
|
||||
("Reduction Bomb", ItemClassification.useful, "Power-Up"),
|
||||
("Hover Pack", ItemClassification.progression, "Power-Up"),
|
||||
|
||||
("Ball Form", ItemClassification.progression, "Power-Up"),
|
||||
("Berserker Charge", ItemClassification.useful, "Power-Up"),
|
||||
("Juggernaut Throw", ItemClassification.useful, "Power-Up"),
|
||||
("Guttural Roar", ItemClassification.useful, "Power-Up"),
|
||||
("Fists of Flame", ItemClassification.useful, "Power-Up"),
|
||||
("Temporal Lock", ItemClassification.useful, "Power-Up"),
|
||||
("Raging Inferno Flop", ItemClassification.useful, "Power-Up"),
|
||||
("Diablo Fire Slam", ItemClassification.useful, "Power-Up")
|
||||
]
|
||||
|
||||
crew_list = [
|
||||
("Bentley", ItemClassification.progression, "Crew"),
|
||||
("Murray", ItemClassification.progression, "Crew"),
|
||||
("Guru", ItemClassification.progression, "Crew"),
|
||||
("Penelope", ItemClassification.progression, "Crew"),
|
||||
("Panda King", ItemClassification.progression, "Crew"),
|
||||
("Dimitri", ItemClassification.progression, "Crew"),
|
||||
("Carmelita", ItemClassification.progression, "Crew")
|
||||
]
|
||||
|
||||
progressive_episode_list = [
|
||||
(f"Progressive {e}", ItemClassification.progression, "Episode")
|
||||
for e in list(EPISODES.keys())[:-1]
|
||||
]
|
||||
|
||||
item_list = (
|
||||
filler_list +
|
||||
powerup_list +
|
||||
crew_list +
|
||||
progressive_episode_list
|
||||
)
|
||||
|
||||
base_code = 5318008
|
||||
|
||||
item_dict = {
|
||||
name: Sly3ItemData(name, base_code+code, category, classification)
|
||||
for code, (name, classification, category) in enumerate(item_list)
|
||||
}
|
||||
|
||||
item_groups = {
|
||||
key: {item.name for item in item_dict.values() if item.category == key}
|
||||
for key in [
|
||||
"Filler",
|
||||
"Power-Up",
|
||||
"Episode",
|
||||
"Crew"
|
||||
]
|
||||
}
|
||||
|
||||
def from_id(item_id: int) -> Sly3ItemData:
|
||||
matching = [item for item in item_dict.values() if item.code == item_id]
|
||||
if len(matching) == 0:
|
||||
raise ValueError(f"No item data for item id '{item_id}'")
|
||||
assert len(matching) < 2, f"Multiple item data with id '{item_id}'. Please report."
|
||||
return matching[0]
|
||||
52
data/Locations.py
Normal file
52
data/Locations.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from typing import NamedTuple
|
||||
|
||||
from .Constants import EPISODES, CHALLENGES
|
||||
|
||||
class Sly3LocationData(NamedTuple):
|
||||
name: str
|
||||
code: int
|
||||
category: str
|
||||
|
||||
jobs_list = [
|
||||
(f"{ep} - {job}", "Job")
|
||||
for ep, chapters in EPISODES.items()
|
||||
for jobs in chapters
|
||||
for job in jobs
|
||||
]
|
||||
|
||||
purchases_list = [
|
||||
(f"ThiefNet {i+1:02}", "Purchase")
|
||||
for i in range(37)
|
||||
]
|
||||
|
||||
challenges_list = [
|
||||
(f"{ep} - {challenge}", "Challenge")
|
||||
for ep, chapters in CHALLENGES.items()
|
||||
for challenges in chapters
|
||||
for challenge in challenges
|
||||
]
|
||||
|
||||
location_list = jobs_list + purchases_list + challenges_list
|
||||
|
||||
base_code = 8008135
|
||||
|
||||
location_dict = {
|
||||
name: Sly3LocationData(name, base_code+code, category)
|
||||
for code, (name, category) in enumerate(location_list)
|
||||
}
|
||||
|
||||
location_groups = {
|
||||
key: {location.name for location in location_dict.values() if location.category == key}
|
||||
for key in [
|
||||
"Job",
|
||||
"Purchase",
|
||||
"Challenge"
|
||||
]
|
||||
}
|
||||
|
||||
def from_id(location_id: int) -> Sly3LocationData:
|
||||
matching = [location for location in location_dict.values() if location.code == location_id]
|
||||
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]
|
||||
0
pcsx2_interface/__init__.py
Normal file
0
pcsx2_interface/__init__.py
Normal file
250
pcsx2_interface/pine.py
Normal file
250
pcsx2_interface/pine.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
The PINE API.
|
||||
This is the client side implementation of the PINE protocol.
|
||||
It allows for a three-way communication between the emulated game, the emulator and an external
|
||||
tool, using the external tool as a relay for all communication. It is a socket based IPC that
|
||||
is _very_ fast.
|
||||
|
||||
If you want to draw comparisons you can think of this as an equivalent of the BizHawk LUA API,
|
||||
although with the logic out of the core and in an external tool. While BizHawk would run a lua
|
||||
script at each frame in the core of the emulator we opt instead to keep the entire logic out of
|
||||
the emulator to make it more easily extensible, more portable, require less code and be more
|
||||
performant.
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
from enum import IntEnum
|
||||
from platform import system
|
||||
import socket
|
||||
|
||||
|
||||
class Pine:
|
||||
""" Exposes PS2 memory within a running instance of the PCSX2 emulator using the Pine IPC Protocol. """
|
||||
|
||||
""" Maximum memory used by an IPC message request. Equivalent to 50,000 Write64 requests. """
|
||||
MAX_IPC_SIZE: int = 650000
|
||||
|
||||
""" Maximum memory used by an IPC message reply. Equivalent to 50,000 Read64 replies. """
|
||||
MAX_IPC_RETURN_SIZE: int = 450000
|
||||
|
||||
""" Maximum number of commands sent in a batch message. """
|
||||
MAX_BATCH_REPLY_COUNT: int = 50000
|
||||
|
||||
class IPCResult(IntEnum):
|
||||
""" 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_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,
|
||||
|
||||
class DataSize(IntEnum):
|
||||
INT8 = 1,
|
||||
INT16 = 2,
|
||||
INT32 = 4,
|
||||
INT64 = 8,
|
||||
|
||||
def __init__(self, slot: int = 28011):
|
||||
if not 0 < slot <= 65536:
|
||||
raise ValueError("Provided slot number is outside valid range")
|
||||
self._slot: int = slot
|
||||
self._sock: socket.socket = socket.socket()
|
||||
self._sock_state: bool = False
|
||||
# self._init_socket()
|
||||
|
||||
def _init_socket(self) -> None:
|
||||
if system() == "Windows":
|
||||
socket_family = socket.AF_INET
|
||||
socket_name = ("127.0.0.1", self._slot)
|
||||
elif system() == "Linux":
|
||||
socket_family = socket.AF_UNIX
|
||||
socket_name = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
|
||||
socket_name += "/pcsx2.sock"
|
||||
elif system() == "Darwin":
|
||||
socket_family = socket.AF_UNIX
|
||||
socket_name = os.environ.get("TMPDIR", "/tmp")
|
||||
socket_name += "/pcsx2.sock"
|
||||
else:
|
||||
socket_family = socket.AF_UNIX
|
||||
socket_name = "/tmp/pcsx2.sock"
|
||||
|
||||
try:
|
||||
self._sock = socket.socket(socket_family, socket.SOCK_STREAM)
|
||||
self._sock.settimeout(5.0)
|
||||
self._sock.connect(socket_name)
|
||||
except socket.error:
|
||||
self._sock.close()
|
||||
self._sock_state = False
|
||||
return
|
||||
|
||||
self._sock_state = True
|
||||
|
||||
def connect(self) -> None:
|
||||
if not self._sock_state:
|
||||
self._init_socket()
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._sock_state:
|
||||
self._sock.close()
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
return self._sock_state
|
||||
|
||||
def read_int8(self, address: int) -> int:
|
||||
request = Pine._create_request(Pine.IPCCommand.READ8, address, 9)
|
||||
return Pine.from_bytes(self._send_request(request)[-1:])
|
||||
|
||||
def read_int16(self, address) -> int:
|
||||
request = Pine._create_request(Pine.IPCCommand.READ16, address, 9)
|
||||
return Pine.from_bytes(self._send_request(request)[-2:])
|
||||
|
||||
def read_int32(self, address) -> int:
|
||||
request = Pine._create_request(Pine.IPCCommand.READ32, address, 9)
|
||||
return Pine.from_bytes(self._send_request(request)[-4:])
|
||||
|
||||
def read_int64(self, address) -> int:
|
||||
request = Pine._create_request(Pine.IPCCommand.READ64, address, 9)
|
||||
return Pine.from_bytes(self._send_request(request)[-8:])
|
||||
|
||||
def read_bytes(self, address: int, length: int) -> bytes:
|
||||
"""Careful! This can be quite slow for large reads"""
|
||||
data = b''
|
||||
while len(data) < length:
|
||||
if length - len(data) >= 8:
|
||||
data += self._send_request(Pine._create_request(Pine.IPCCommand.READ64, address + len(data), 9))[-8:]
|
||||
elif length - len(data) >= 4:
|
||||
data += self._send_request(Pine._create_request(Pine.IPCCommand.READ32, address + len(data), 9))[-4:]
|
||||
elif length - len(data) >= 2:
|
||||
data += self._send_request(Pine._create_request(Pine.IPCCommand.READ16, address + len(data), 9))[-2:]
|
||||
elif length - len(data) >= 1:
|
||||
data += self._send_request(Pine._create_request(Pine.IPCCommand.READ8, address + len(data), 9))[-1:]
|
||||
|
||||
return data
|
||||
|
||||
def write_int8(self, address: int, value: int) -> None:
|
||||
request = Pine._create_request(Pine.IPCCommand.WRITE8, address, 9 + Pine.DataSize.INT8)
|
||||
request += value.to_bytes(length=1, byteorder="little")
|
||||
self._send_request(request)
|
||||
|
||||
def write_int16(self, address: int, value: int) -> None:
|
||||
request = Pine._create_request(Pine.IPCCommand.WRITE16, address, 9 + Pine.DataSize.INT16)
|
||||
request += value.to_bytes(length=2, byteorder="little")
|
||||
self._send_request(request)
|
||||
|
||||
def write_int32(self, address: int, value: int) -> None:
|
||||
request = Pine._create_request(Pine.IPCCommand.WRITE32, address, 9 + Pine.DataSize.INT32)
|
||||
request += value.to_bytes(length=4, byteorder="little")
|
||||
self._send_request(request)
|
||||
|
||||
def write_int64(self, address: int, value: int) -> None:
|
||||
request = Pine._create_request(Pine.IPCCommand.WRITE64, address, 9 + Pine.DataSize.INT64)
|
||||
request += value.to_bytes(length=8, byteorder="little")
|
||||
self._send_request(request)
|
||||
|
||||
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)
|
||||
self._send_request(request)
|
||||
|
||||
def write_bytes(self, address: int, data: bytes) -> None:
|
||||
"""Careful! This can be quite slow for large writes"""
|
||||
bytes_written = 0
|
||||
while bytes_written < len(data):
|
||||
if len(data) - bytes_written >= 8:
|
||||
request = self._create_request(Pine.IPCCommand.WRITE64, address + bytes_written, 9 + Pine.DataSize.INT64)
|
||||
request += data[bytes_written:bytes_written + 8]
|
||||
self._send_request(request)
|
||||
bytes_written += 8
|
||||
elif len(data) - bytes_written >= 4:
|
||||
request = self._create_request(Pine.IPCCommand.WRITE32, address + bytes_written, 9 + Pine.DataSize.INT32)
|
||||
request += data[bytes_written:bytes_written + 4]
|
||||
self._send_request(request)
|
||||
bytes_written += 4
|
||||
elif len(data) - bytes_written >= 2:
|
||||
request = self._create_request(Pine.IPCCommand.WRITE16, address + bytes_written, 9 + Pine.DataSize.INT16)
|
||||
request += data[bytes_written:bytes_written + 2]
|
||||
self._send_request(request)
|
||||
bytes_written += 2
|
||||
elif len(data) - bytes_written >= 1:
|
||||
request = self._create_request(Pine.IPCCommand.WRITE8, address + bytes_written, 9 + Pine.DataSize.INT8)
|
||||
request += data[bytes_written:bytes_written + 1]
|
||||
self._send_request(request)
|
||||
bytes_written += 1
|
||||
|
||||
def get_game_id(self) -> str:
|
||||
request = Pine.to_bytes(5, 4) + Pine.to_bytes(Pine.IPCCommand.ID, 1)
|
||||
response = self._send_request(request)
|
||||
return response[9:-1].decode("ascii")
|
||||
|
||||
def _send_request(self, request: bytes) -> bytes:
|
||||
if not self._sock_state:
|
||||
self._init_socket()
|
||||
|
||||
try:
|
||||
self._sock.sendall(request)
|
||||
except socket.error:
|
||||
self._sock.close()
|
||||
self._sock_state = False
|
||||
raise ConnectionError("Lost connection to PCSX2.")
|
||||
|
||||
end_length = 4
|
||||
result: bytes = b''
|
||||
while len(result) < end_length:
|
||||
try:
|
||||
response = self._sock.recv(4096)
|
||||
except TimeoutError:
|
||||
raise TimeoutError("Response timed out. "
|
||||
"This might be caused by having two PINE connections open on the same slot")
|
||||
|
||||
if len(response) <= 0:
|
||||
result = b''
|
||||
break
|
||||
|
||||
result += response
|
||||
|
||||
if end_length == 4 and len(response) >= 4:
|
||||
end_length = Pine.from_bytes(result[0:4])
|
||||
if end_length > Pine.MAX_IPC_SIZE:
|
||||
result = b''
|
||||
break
|
||||
|
||||
if len(result) == 0:
|
||||
raise ConnectionError("Invalid response from PCSX2.")
|
||||
if result[4] == Pine.IPCResult.IPC_FAIL:
|
||||
raise ConnectionError("Failure indicated in PCSX2 response.")
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _create_request(command: IPCCommand, address: int, size: int = 0) -> bytes:
|
||||
ipc = Pine.to_bytes(size, 4)
|
||||
ipc += Pine.to_bytes(command, 1)
|
||||
ipc += Pine.to_bytes(address, 4)
|
||||
return ipc
|
||||
|
||||
@staticmethod
|
||||
def to_bytes(value: int, size: int) -> bytes:
|
||||
return value.to_bytes(length=size, byteorder="little")
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(arr: bytes) -> int:
|
||||
return int.from_bytes(arr, byteorder="little")
|
||||
|
||||
Reference in New Issue
Block a user