This commit is contained in:
2026-03-04 20:54:48 +01:00
parent 072b9a0503
commit 54bf55db45
9 changed files with 687 additions and 250 deletions

View File

@@ -1,10 +1,13 @@
from typing import TYPE_CHECKING, Dict, List
from time import time
from random import randint
import asyncio
from NetUtils import ClientStatus
from BaseClasses import ItemClassification
from .data.Constants import REQUIREMENTS, DEATH_TYPES, EPISODES, CHALLENGES
from .Sly3Interface import Sly3Episode, PowerUps
from .data.Constants import REQUIREMENTS, DEATH_TYPES, EPISODES, CHALLENGES, JOB_IDS
from .data import Items, Locations
if TYPE_CHECKING:
@@ -14,7 +17,7 @@ if TYPE_CHECKING:
# Helpers #
###########
def accessibility(ctx: "Sly3Context") -> Dict[str, Dict[str, List[List[bool]]]]:
def accessibility(ctx: "Sly3Context") -> Dict[int, bool]:
section_requirements = {
episode_name: [
list(set(sum([
@@ -112,30 +115,73 @@ def accessibility(ctx: "Sly3Context") -> Dict[str, Dict[str, List[List[bool]]]]:
}
return {
"Jobs": job_accessibility,
"Challenges": challenge_accessibility
}
JOB_IDS[ep][i][j]: avail
for ep, ep_avail in job_accessibility.items()
for i, section_avail in enumerate(ep_avail)
for j, avail in enumerate(section_avail)
} # TODO: Challenges
def set_thiefnet(ctx: "Sly3Context"):
# TODO
pass
async def set_thiefnet(ctx: "Sly3Context"):
if ctx.slot_data is None:
return
def unset_thiefnet(ctx: "Sly3Context"):
# TODO
pass
thiefnet_n = ctx.slot_data["thiefnet_locations"]
if ctx.thiefnet_items is None:
info = ctx.locations_info
ctx.thiefnet_items = []
for i in range(thiefnet_n):
location_info = info[Locations.location_dict[f"ThiefNet {i+1:02}"].code]
player_name = ctx.player_names[location_info.player]
item_name = ctx.item_names.lookup_in_slot(location_info.item,location_info.player)
string = f"{player_name}'s {item_name}"
ctx.thiefnet_items.append(string)
ctx.thiefnet_purchases = PowerUps(*[
Locations.location_dict[f"ThiefNet {i+1:02}"].code in ctx.checked_locations
for i in range(thiefnet_n)
])
if ctx.slot_data["scout_thiefnet"]:
await ctx.send_msgs([{
"cmd": "LocationScouts",
"locations": [
Locations.location_dict[f"ThiefNet {i+1:02}"].code
for i in range(thiefnet_n)
],
"create_as_hint": 2
}])
ctx.game_interface.set_powerups(ctx.thiefnet_purchases)
thiefnet_data = [
(ctx.slot_data["thiefnet_costs"][i], ctx.thiefnet_items[i])
for i in range(thiefnet_n)
]
ctx.game_interface.set_thiefnet(thiefnet_data)
async def reset_thiefnet(ctx: "Sly3Context"):
if ctx.in_hub:
ctx.thiefnet_purchases = ctx.game_interface.get_powerups()
set_powerups(ctx)
ctx.game_interface.reset_thiefnet()
def check_jobs(ctx: "Sly3Context"):
# TODO
pass
ctx.jobs_completed = ctx.game_interface.jobs_completed()
def check_challenges(ctx: "Sly3Context"):
# TODO
# TODO: Challenges
pass
def set_powerups(ctx: "Sly3Context"):
if not ctx.in_safehouse():
if not ctx.in_safehouse:
ctx.game_interface.set_powerups(ctx.powerups)
async def unlock_episodes(ctx):
await asyncio.sleep(1)
ctx.game_interface.unlock_episodes()
#########
# Steps #
#########
@@ -144,29 +190,61 @@ async def update_in_safehouse(ctx: "Sly3Context"):
in_safehouse = ctx.game_interface.in_safehouse()
if in_safehouse and not ctx.in_safehouse:
ctx.in_safehouse = True
set_thiefnet(ctx)
await set_thiefnet(ctx)
elif ctx.in_safehouse and not in_safehouse:
ctx.in_safehouse = False
unset_thiefnet(ctx)
await reset_thiefnet(ctx)
async def replace_text(ctx: "Sly3Context"):
# TODO
# I'm not totally sure yet which text I'm replacing
pass
if ctx.current_episode != Sly3Episode.Title_Screen:
return
if ctx.current_map == 35:
ctx.game_interface.set_text(
"Press START (start)",
"Connected to Archipelago"
)
ctx.game_interface.set_text(
"Press START (resume)",
"Connected to Archipelago"
)
elif ctx.current_map == 0:
for i in range(1,7):
ep_name = Sly3Episode(i).name.replace("_"," ")
if ctx.available_episodes[Sly3Episode(i)]:
rep_text = ep_name
elif i == 6:
obtained_crew = len([
i for i in ctx.items_received
if Items.from_id(i.item).category == "Crew"
])
rep_text = f"{obtained_crew}/7 crew members"
else:
rep_text = "Locked"
ctx.game_interface.set_text(
ep_name,
rep_text
)
async def kick_from_episode(ctx: "Sly3Context", availability: Dict):
# TODO
not_connected = ctx.current_map == 0 and not ctx.is_connected_to_server
current_episode = ctx.game_interface.get_current_episode()
ep_not_unlocked = current_episode == 0 or ctx.available_episodes[current_episode-1]
job_not_available = False # if current job/challenge not available
not_connected = ctx.current_episode != 0 and not ctx.is_connected_to_server
ep_not_unlocked = not ctx.available_episodes[Sly3Episode(ctx.current_episode)]
job_not_available = not availability.get(ctx.current_job,True)
if not_connected or ep_not_unlocked or job_not_available:
print("Kicking")
print(not_connected, ep_not_unlocked, job_not_available)
ctx.game_interface.to_episode_menu()
async def check_jobs_and_challenges(ctx: "Sly3Context"):
check_jobs()
check_challenges()
async def check_locations(ctx: "Sly3Context"):
check_jobs(ctx)
check_challenges(ctx)
async def send_checks(ctx: "Sly3Context"):
if ctx.slot_data is None:
@@ -174,7 +252,7 @@ async def send_checks(ctx: "Sly3Context"):
# ThiefNet purchases
if ctx.in_safehouse:
ctx.thiefnet_purchases = ctx.game_interface.read_powerups()
ctx.thiefnet_purchases = ctx.game_interface.get_powerups()
purchases = list(ctx.thiefnet_purchases)
purchases = (
purchases[4:32] +
@@ -192,32 +270,106 @@ async def send_checks(ctx: "Sly3Context"):
ctx.locations_checked.add(location_code)
# Jobs
for i, episode in enumerate(ctx.jobs_completed):
episode_name = list(EPISODES.keys())[i]
for j, chapter in enumerate(episode):
for k, job in enumerate(chapter):
if job:
job_name = EPISODES[episode_name][j][k]
i = 0
for episode_name, episode in EPISODES.items():
for chapter in episode:
for job_name in chapter:
if ctx.jobs_completed[i]:
location_name = f"{episode_name} - {job_name}"
location_code = Locations.location_dict[location_name].code
ctx.locations_checked.add(location_code)
i += 1
# Challenges
for i, episode in enumerate(ctx.challenges_completed):
episode_name = list(CHALLENGES.keys())[i]
for j, chapter in enumerate(episode):
for k, job in enumerate(chapter):
if job:
job_name = CHALLENGES[episode_name][j][k]
location_name = f"{episode_name} - {job_name}"
i = 0
for episode_name, episode in CHALLENGES.items():
for chapter in episode:
for challenge_name in chapter:
if ctx.challenges_completed[i]:
location_name = f"{episode_name} - {challenge_name}"
location_code = Locations.location_dict[location_name].code
ctx.locations_checked.add(location_code)
i += 1
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": ctx.locations_checked}])
async def receive_items(ctx: "Sly3Context"):
# TODO
pass
if ctx.slot_data is None:
return
items_n = ctx.game_interface.get_items_received()
network_items = ctx.items_received
available_episodes = {e: False for e in Sly3Episode}
available_episodes[Sly3Episode.Title_Screen] = True
available_episodes[Sly3Episode.Honor_Among_Thieves] = len([
i for i in network_items
if Items.from_id(i.item).category == "Crew"
]) == 7
new_powerups = list(PowerUps(True))
powerup_fields = PowerUps._fields
for i, network_item in enumerate(network_items):
item = Items.from_id(network_item.item)
player = ctx.player_names[network_item.player]
if i >= items_n:
ctx.inventory[network_item.item] += 1
ctx.notification(f"Received {item.name} from {player}")
if item.category == "Episode":
episode = Sly3Episode[
item.name.replace(" ","_")
]
available_episodes[episode] = True
elif item.category == "Power-Up":
item_name = item.name.lower().replace(" ","_")
if item_name == "Progressive Shadow Power":
if new_powerups[30]:
idx = 32
else:
idx = 30
elif item_name == "Progressive Spin Attack":
if new_powerups[40]:
idx = 41
elif new_powerups[39]:
idx = 40
else:
idx = 39
elif item_name == "Progressive Jump Power":
if new_powerups[43]:
idx = 44
elif new_powerups[42]:
idx = 43
else:
idx = 42
elif item_name == "Progressive Push Attack":
if new_powerups[46]:
idx = 47
elif new_powerups[45]:
idx = 46
else:
idx = 45
else:
idx = powerup_fields.index(item_name)
new_powerups[idx] = True
elif item.name == "Coins" and i >= items_n:
amount = randint(
ctx.slot_data["coins_minimum"],
ctx.slot_data["coins_maximum"]
)
ctx.game_interface.add_coins(amount)
if ctx.current_episode != 0 and not ctx.in_safehouse:
set_powerups(ctx)
ctx.game_interface.set_items_received(len(network_items))
ctx.available_episodes = available_episodes
ctx.powerups = PowerUps(*new_powerups)
async def check_goal(ctx: "Sly3Context"):
if ctx.slot_data is None:
@@ -230,8 +382,19 @@ async def check_goal(ctx: "Sly3Context"):
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
async def handle_job_markers(ctx: "Sly3Context", availability: Dict):
# TODO
pass
episode = ctx.current_episode.name.replace("_"," ")
job_ids = JOB_IDS[episode]
active_jobs = []
inactive_jobs = []
for section in job_ids:
for job_id in section:
if availability[job_id]:
active_jobs.append(job_id)
else:
inactive_jobs.append(job_id)
ctx.game_interface.activate_jobs(active_jobs)
ctx.game_interface.activate_jobs(inactive_jobs)
async def handle_notifications(ctx: "Sly3Context"):
if (
@@ -283,8 +446,9 @@ async def init(ctx: "Sly3Context") -> None:
return
if ctx.current_map == 0:
ctx.game_interface.unlock_episodes()
asyncio.create_task(unlock_episodes(ctx))
await receive_items(ctx)
await replace_text(ctx)
# Maybe fix jobs if they break?
@@ -293,19 +457,23 @@ async def update(ctx: "Sly3Context") -> None:
if ctx.current_map is None:
return
if not ctx.game_interface.is_game_started():
return
availability = accessibility(ctx)
kick_from_episode(ctx, availability)
await kick_from_episode(ctx, availability)
if not ctx.is_connected_to_server:
return
await update_in_safehouse(ctx)
await check_jobs_and_challenges(ctx)
await check_locations(ctx)
await send_checks(ctx)
await receive_items(ctx)
await check_goal(ctx)
await handle_job_markers(ctx, availability["Jobs"])
if ctx.game_interface.in_hub():
await handle_job_markers(ctx, availability)
if ctx.in_safehouse:
if ctx.current_map != 0 and not ctx.in_safehouse:
await handle_notifications(ctx)
await handle_deathlink(ctx)

View File

@@ -72,6 +72,7 @@ class Sly3Context(CommonContext): # type: ignore[misc]
in_hub: bool = False
current_map: Optional[int] = None
current_job: Optional[int] = None
current_episode: Sly3Episode = Sly3Episode.Title_Screen
# Items and checks
inventory: Dict[int,int] = {l.code: 0 for l in Items.item_dict.values()}
@@ -79,23 +80,32 @@ class Sly3Context(CommonContext): # type: ignore[misc]
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()
jobs_completed: list[bool] = [
False for episode in EPISODES.values()
for chapter in episode
for _ in chapter
]
challenges_completed: list[list[list[bool]]] = [
[[False for _ in chapter] for chapter in episode]
for episode in CHALLENGES.values()
challenges_completed: list[bool] = [
False for episode in CHALLENGES.values()
for chapter in episode
for _ in chapter
]
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.version = [0,0,0]
self.game_interface = Sly3Interface(logger)
self.available_episodes[Sly3Episode.Title_Screen] = True
def on_deathlink(self, data: Utils.Dict[str, Utils.Any]) -> None:
# TODO
pass
super().on_deathlink(data)
if self.death_link_enabled:
self.queued_deaths += 1
cause = data.get("cause", "")
if cause:
self.notification(f"DeathLink: {cause}")
else:
self.notification(f"DeathLink: Received from {data['source']}")
def make_gui(self):
ui = super().make_gui()
@@ -136,13 +146,13 @@ class Sly3Context(CommonContext): # type: ignore[misc]
Utils.async_start(self.send_msgs([{
"cmd": "LocationScouts",
"locations": [
Locations.location_dict[location].code
for location in Locations.location_groups["Purchase"]
Locations.location_dict[f"ThiefNet {i+1:02}"].code
for i in range(args["slot_data"]["thiefnet_locations"])
]
}]))
def notification(self):
# TODO
def notification(self, text: str):
# TODO: Notifications
pass
def update_connection_status(ctx: Sly3Context, status: bool):
@@ -166,6 +176,7 @@ async def _handle_game_ready(ctx: Sly3Context) -> None:
current_map = ctx.game_interface.get_current_map()
ctx.in_hub = ctx.game_interface.in_hub()
ctx.current_job = ctx.game_interface.get_current_job()
ctx.current_episode = ctx.game_interface.get_current_episode()
ctx.game_interface.skip_cutscene()
@@ -208,8 +219,7 @@ async def pcsx2_sync_task(ctx: Sly3Context):
try:
is_connected = ctx.game_interface.get_connection_state()
update_connection_status(ctx, is_connected)
game_started = ctx.game_interface.is_game_started()
if is_connected and game_started:
if is_connected:
await _handle_game_ready(ctx)
else:
await _handle_game_not_ready(ctx)

View File

@@ -5,7 +5,7 @@ from enum import IntEnum
import traceback
from .pcsx2_interface.pine import Pine
from .data.Constants import ADDRESSES, MENU_RETURN_DATA
from .data.Constants import ADDRESSES, MENU_RETURN_DATA, POWER_UP_TEXT
class Sly3Episode(IntEnum):
Title_Screen = 0
@@ -185,6 +185,18 @@ class Sly3Interface(GameInterface):
return pointer
def _find_string_address(self, _id: int) -> int:
# 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 = self._read32(self.addresses["string table"])
i = 0
while True:
string_id = self._read32(string_table_address+i*8)
if string_id == _id:
return self._read32(string_table_address+i*8+4)
i += 1
###################
## Current State ##
###################
@@ -196,35 +208,95 @@ class Sly3Interface(GameInterface):
return self._read32(self.addresses["loading"]) == 2
def in_safehouse(self) -> bool:
# TODO
return False
return (
not self.is_loading() and
self.in_hub() and
self._read32(self.addresses["infobox string"]) in [
5345,5346,5347,5348,5349,5350,5351
]
)
def in_hub(self) -> bool:
# TODO
return False
return self.get_current_map() in [3,8,15,23,31,35]
def is_goaled(self) -> bool:
# TODO
def is_goaled(self, goal: int) -> bool:
# TODO: Goal
return False
def is_game_started(self) -> bool:
# TODO
return False
world_id = self.get_current_episode()
map_id = self.get_current_map()
return not (
world_id == 0 and
map_id in [35,0xffffffff]
)
def showing_infobox(self) -> bool:
# TODO
# TODO: Notifications
return False
def alive(self) -> bool:
# TODO
return False
active_character = self._read32(self.addresses["active character pointer"])
if active_character == 0:
return True
health_gui = self._read32(active_character+0x16c)
health = self._read32(active_character+0x16c)
return health_gui != 2 or health != 0
#######################
## Getters & Setters ##
#######################
def set_thiefnet(self, data: list[tuple[int,str]]) -> None:
addresses = [
self.addresses["thiefnet start"]+i*0x3c
for i in range(44)
if i not in [28,36,37,39,40,42,43]
][:len(data)]
for i, address in enumerate(addresses):
self._write32(address,data[i][0])
self._write32(address+0xC,0)
name_id = self._read32(address+0x14)
name_address = self._find_string_address(name_id)
self.set_text(name_address, f"Check #{i+1}")
description_id = self._read32(address+0x18)
description_address = self._find_string_address(description_id)
self.set_text(description_address, data[i][1])
for i in [28,36,37,39,40,42,43]+list(range(len(data),44)):
address = self.addresses["thiefnet start"]+i*0x3c
self._write32(address+0xC,10)
def reset_thiefnet(self) -> None:
for i in range(44):
address = self.addresses["thiefnet start"]+i*0x3c
name_id = self._read32(address+0x14)
name_address = self._find_string_address(name_id)
self.set_text(name_address, POWER_UP_TEXT[i][0])
description_id = self._read32(address+0x18)
description_address = self._find_string_address(description_id)
self.set_text(description_address, POWER_UP_TEXT[i][1])
def set_text(self, text: int|str, replacement: str) -> None:
if isinstance(text,str):
text_pointer = self.addresses["text"].get(text, None)
if isinstance(text_pointer, dict):
text_pointer = text_pointer.get(self.get_current_map(), None)
if not isinstance(text_pointer,int):
return
else:
text_pointer = text
replacement_string = replacement.encode("utf-16-le")+b"\x00\x00"
self._write_bytes(text_pointer,replacement_string)
def get_current_episode(self) -> Sly3Episode:
episode_num = self._read32(self.addresses["world id"])
return Sly3Episode(episode_num)
episode_num = self._read32(self.addresses["world id"]) - 2
return Sly3Episode(max(0,episode_num))
def get_current_map(self) -> int:
return self._read32(self.addresses["map id"])
@@ -235,6 +307,9 @@ class Sly3Interface(GameInterface):
def set_current_job(self, job: int) -> None:
self._write32(self.addresses["job id"], job)
def get_items_received(self) -> int:
return self._read32(self.addresses["items received"])
def set_items_received(self, n:int) -> None:
self._write32(self.addresses["items received"], n)
@@ -269,24 +344,26 @@ class Sly3Interface(GameInterface):
return PowerUps(*relevant_bits)
def activate_jobs(self, job_ids: int|list[int]):
# TODO
# TODO: Job Markers
pass
def deactivate_jobs(self, job_ids: int|list[int]):
# TODO
# TODO: Job Markers
pass
def jobs_completed(self, job_ids: int|list[int]):
# TODO
pass
def jobs_completed(self) -> list[bool]:
addresses = [a for ep in self.addresses["job completed"].values() for c in ep for a in c]
states = self._batch_read32(addresses)
return [s != 0 for s in states]
def current_infobox(self) -> int:
# TODO
pass
# TODO: Notifications
return 0
def get_damage_type(self) -> int:
# TODO
pass
# TODO: Death Messages
return 0
#################
## Other Utils ##
@@ -298,7 +375,7 @@ class Sly3Interface(GameInterface):
self.get_current_job() == 1797
):
self.set_current_job(0xffffffff)
# self.set_items_received(0)
self.set_items_received(0)
self._reload(bytes.fromhex(MENU_RETURN_DATA))
@@ -317,16 +394,18 @@ class Sly3Interface(GameInterface):
self._write32(self.addresses["coins"],new_amount)
def disable_infobox(self):
# TODO
# TODO: Notifications
pass
def set_infobox(self):
# TODO
def set_infobox(self, text: str):
# TODO: Notifications
pass
def kill_player(self):
# TODO
pass
if self.in_safehouse() or self.get_current_episode() == Sly3Episode.Title_Screen:
return
self._write32(self.addresses["reload"],1)
#### TESTING ZONE ####
@@ -362,6 +441,55 @@ def find_string_id(interf: Sly3Interface, _id: int):
return interf._read32(string_table_address+i*8+4)
i += 1
def find_string_address(interf: Sly3Interface, address: 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_address = interf._read32(string_table_address+i*8+4)
if string_address == address:
return interf._read32(string_table_address+i*8)
i += 1
def print_string_table(interf: Sly3Interface, n: int):
"""Prints n entries in the string table"""
# 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)
for i in range(n):
address = interf._read32(string_table_address+i*8+4)
print(read_text(interf, address), i)
def find_text(interf: Sly3Interface, text: str):
"""Prints n entries in the string table"""
# 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)
results = []
for i in range(2000):
address = interf._read32(string_table_address+i*8+4)
try:
if text in read_text(interf, address):
results.append(address)
except:
return results
return results
def print_thiefnet_addresses(interf: Sly3Interface):
print(" {")
for i in range(44):
@@ -384,6 +512,28 @@ def print_thiefnet_addresses(interf: Sly3Interface):
print(" }")
def print_thiefnet_text(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)
description_text = read_text(interf, description_address)
print(
" " +
f"(\"{name_text}\",\"{description_text}\"),"
)
print("]")
def current_job_info(interf: Sly3Interface):
current_job = interf._read32(0x36DB98)
@@ -411,17 +561,30 @@ if __name__ == "__main__":
# interf.skip_cutscene()
# 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)
# power_ups = PowerUps(True, True, True, False, *[True]*44)
# interf.set_powerups(power_ups)
# Adding 10000 coins
#interf.add_coins(10000)
# === Testing Zone ===
# print_thiefnet_addresses(interf)
# print_thiefnet_addresses(interf)
# disabling first job of episode 1 (0 = disabled, 1 = available, 2 = in progress, 3 = complete)
# interf._write32(0x1335d10+0x44, 0)
current_job_info(interf)
# current_job_info(interf)
# find_string_id
# print_string_table(interf, 500)
# addresses = find_text(interf, "Press &2X&. to ")
# print("======")
# for address in addresses:
# print(hex(address))
# print(read_text(interf, address))
# print(find_string_address(interf, address))
# print("======")
print_thiefnet_text(interf)

View File

@@ -14,8 +14,8 @@ class StartingEpisode(Choice):
"""
Select Which episode to start with.
A Cold Alliance and Dead Men Tell No Tales require starting items, so
starting with them will break a solo game.
Flight of Fancy, A Cold Alliance and Dead Men Tell No Tales require starting
items, so starting with them will break a solo game.
"""
display_name = "Starting Episode"
@@ -118,6 +118,14 @@ class ThiefNetCostMaximum(Range):
range_end = 9999
default = 2000
class ScoutThiefnet(DefaultOnToggle):
"""
Whether to scout/hint ThiefNet checks. They will still be displayed in game.
"""
display_name = "Scout Thiefnet"
@dataclass
class Sly3Options(PerGameCommonOptions):
start_inventory_from_pool: StartInventoryPool
@@ -131,6 +139,7 @@ class Sly3Options(PerGameCommonOptions):
thiefnet_minimum: ThiefNetCostMinimum
thiefnet_maximum: ThiefNetCostMaximum
bonus_crew_member: BonusCrewMember
scout_thiefnet: ScoutThiefnet
sly3_option_groups = [
OptionGroup("Goal",[
@@ -145,6 +154,7 @@ sly3_option_groups = [
OptionGroup("Locations",[
ThiefNetLocations,
ThiefNetCostMinimum,
ThiefNetCostMaximum
ThiefNetCostMaximum,
ScoutThiefnet
])
]

View File

@@ -17,7 +17,7 @@ def set_rules_sly3(world: "Sly3World"):
# Putting ThiefNet stuff out of logic, to make early game less slow.
# 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):
for i in range(1,thiefnet_items+1):
divisor = ceil(thiefnet_items/12)
episode_items_n = ceil(i/divisor)
add_rule(
@@ -30,14 +30,6 @@ def set_rules_sly3(world: "Sly3World"):
)
)
def require(location: str, item: str|list[str]):
add_rule(
world.get_location(location),
lambda state, i=item: (
all(state.has(j, player) for j in i)
)
)
### Job requirements
for episode, sections in EPISODES.items():
if episode == "Honor Among Thieves":

View File

@@ -110,6 +110,7 @@ class Sly3World(World):
self.options.thiefnet_minimum.value = slot_data["thiefnet_minimum"]
self.options.thiefnet_maximum.value = slot_data["thiefnet_maximum"]
self.options.bonus_crew_member.value = slot_data["bonus_crew_member"]
self.options.scout_thiefnet.value = slot_data["scout_thiefnet"]
return
self.validate_options(self.options)
@@ -161,6 +162,7 @@ class Sly3World(World):
"thiefnet_minimum",
"thiefnet_maximum",
"bonus_crew_member",
"scout_thiefnet",
)
def fill_slot_data(self) -> Mapping[str, Any]:

View File

@@ -240,7 +240,9 @@ REQUIREMENTS = {
[[]]
],
"Flight of Fancy": [
[[]],
[
["Bentley"]
],
[
["Murray", "Bentley", "Guru", "Fishing Pole"],
["Murray"],
@@ -375,8 +377,45 @@ REQUIREMENTS = {
}
}
JOB_IDS = {
"An Opera of Fear": [
[2085],
[2230,2283,2329],
[2139,2168,2187,2352],
[2419]
],
"Rumble Down Under": [
[2577],
[2596,2805,2695,2663],
[2623,2730,2780],
[2843]
],
"Flight of Fancy": [
[2983],
[3025,3061,3101,3140],
[3202,3164,3225],
[3259]
],
"A Cold Alliance": [
[3381],
[3449,3509,3540,3584],
[3629,3672,3684],
[3712]
],
"Dead Men Tell No Tales": [
[3848],
[3907,4038,3991],
[4071,4101,4120],
[4145]
],
"Honor Among Thieves": [
[4327,4369,4396,4412,4436,4479,4505,4544]
]
}
ADDRESSES = {
"SCUS-97464" : {
"items received": 0x4695A0,
"world id": 0x468D30,
"map id": 0x47989C,
"job id": 0x36DB98,
@@ -390,117 +429,128 @@ ADDRESSES = {
"gadgets": 0x468DCC,
"coins": 0x468DDC,
"DAG root": 0x478C8C,
"jobs": [
[
[0x1335d10]
"job completed": {
"An Opera of Fear": [
[
0x468FEC,
],
[
0x468FAC,
0x468FBC,
0x468F7C,
],
[
0x46900C,
0x468F6C,
0x468FFC,
0x468F9C,
],
[
0x468FCC,
],
],
[
[0x1350560,0x1357f80,0x135aba0]
"Rumble Down Under": [
[
0x46909C,
],
[
0x46902C,
0x46903C,
0x46905C,
0x4690AC,
],
[
0x46904C,
0x46901C,
0x46906C,
],
[
0x46907C,
],
],
[],
[],
[],
[]
],
"Flight of Fancy": [
[
0x46916C,
],
[
0x4690DC,
0x4690EC,
0x46910C,
0x46917C,
],
[
0x46919C,
0x46918C,
0x46914C,
],
[
0x46911C,
],
],
"A Cold Alliance": [
[
0x46920C,
],
[
0x4691BC,
0x46922C,
0x46923C,
0x46921C,
],
[
0x46925C,
0x4691FC,
0x4691AC,
],
[
0x4691CC,
],
],
"Dead Men Tell No Tales": [
[
0x4692FC,
],
[
0x4692CC,
0x4692BC,
0x46931C,
],
[
0x46930C,
0x46929C,
0x46926C,
],
[
0x4692AC,
],
],
"Honor Among Thieves": [
[
0x46936C,
0x46938C,
0x46935C,
0x46934C,
0x46932C,
0x46937C,
0x46939C,
0x46933C,
]
],
},
"active character pointer": 0x36F84C,
"infobox string": 0x46F788,
"thiefnet start": 0x343208,
"string table": 0x47A2D8,
"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),
"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),
},
{},
{},
{}
]
"Press START (start)": 0x5639b0,
"Press START (resume)": 0x563bd0,
"Press SELECT": 0x564380,
"An Opera of Fear": 0x53b380,
"Rumble Down Under": 0x53b4e0,
"Flight of Fancy": 0x53b5a0,
"A Cold Alliance": 0x53b710,
"Dead Men Tell No Tales": 0x53b7b0,
"Honor Among Thieves": 0x53b900,
}
}
}
@@ -524,4 +574,52 @@ MENU_RETURN_DATA = (
"7B8274B1"
)
DEATH_TYPES = {} # TODO
# TODO: Death Messages
DEATH_TYPES = {}
POWER_UP_TEXT = [
("Trigger Bomb","Throwable bomb with remote detonation"),
("Fishing Pole","Fish loot out of guards' pockets"),
("Alarm Clock","Confuse your enemies with this distracting alarm clock"),
("Adrenaline Burst","Move like no turtle has moved before"),
("Health Extractor","Capture guards and extract medicine from them"),
("Hover Pack","Extend your jumps by hovering in the air"),
("Insanity Strike","Make enemies attack each other with the wheelchair spin attack"),
("Grapple-Cam","A remote camera with amazing abilities"),
("Size Destabilizer","Shrink guards by whacking them with your wheelchair"),
("Rage Bomb","Confuse all enemies in the area into attacking each other"),
("Reduction Bomb","Shrink enemies in the area"),
("Be The Ball","Roll around like a ball"),
("Berserker Charge","Scatter enemies with this powerful run"),
("Juggernaut Throw","Thrown enemies explode on impact"),
("Guttural Roar","Terrify your foes"),
("Fists of Flame","Turn ordinary punches into fiery ones"),
("Temporal Lock","Freeze time around the guards…temporarily, at least"),
("Raging Inferno Flop","Use while jumping to create a wall of flame on impact"),
("Diablo Fire Slam","Use while carrying an enemy to create a deadly firestorm"),
("Smoke Bomb","Obscure the vision of your enemies for a hasty getaway"),
("Combat Dodge","Sidestep enemies in combat"),
("Paraglide","Fly through the air with this quick-deploy paraglider"),
("Silent Obliteration","Juggle an unaware enemy with &2T&."),
("Feral Pounce","Jump over vast distances"),
("Mega Jump","Jump to impressive heights"),
("Knockout Dive","Leap at enemies, leaving them stunned on the ground"),
("Shadow Power Level 1","Move without being seen"),
("Thief Reflexes","Slow time to a crawl"),
("Shadow Power Level 2","Attack foes while invisible"),
("Rocket Boots","Zoom through the world with these speedy boots"),
("Treasure Map","Follow the trail to find buried treasure"),
("ENGLISHpowerup_shield_name","ENGLISHpowerup_shield_desc"),
("Venice Disguise","Fool guards in Venice with this disguise"),
("Photographer Disguise","Fool guards in China with this disguise"),
("Pirate Disguise","Fool Guards in Blood Bath Bay with this disguise"),
("Spin Attack Level 1","Press &2T&. and &2S&. to do a spin attack"),
("Spin Attack Level 2","Press &2T&., &2T&. and &2S&. to do a powerful spin attack"),
("Spin Attack Level 3","Press &2T&., &2T&., &2T&. and &2S&. to do a devastating spin attack"),
("Jump Attack Level 1","Press &2T&. and &2X&. to do a jump attack"),
("Jump Attack Level 2","Press &2T&., &2T&. and &2X&. to do a powerful jump attack"),
("Jump Attack Level 3","Press &2T&., &2T&., &2T&. and &2X&. to do a devastating jump attack"),
("Push Attack Level 1","Press &2T&. then &2O&. to do a push attack"),
("Push Attack Level 2","Press &2T&., &2T&. and &2O&. to do a powerful push attack"),
("Push Attack Level 3","Press &2T&., &2T&., &2T&. and &2O&. to do a devastating push attack"),
]

View File

@@ -78,8 +78,8 @@ m6_dive - 46935C
m6_car - 46934C
m6_biplane - 46932C
m6_gauntlet - 46937C
m6_boss - 46933C
m6_vault - 46939C
m6_boss - 46933C
#################

View File

@@ -156,13 +156,6 @@ 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
@@ -175,6 +168,12 @@ Job address: 0x63b4d0
Job index: 70
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
## Operation: Turbo Dominant Eagle (sly)
Job ID: 3259
Job address: 0x651eb0
@@ -190,46 +189,41 @@ 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
## Grapple-Cam Break-in (sly)
Job ID: 3540
Job address: 0x1625780
Job index: 63
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
## Down the Line (murray)
Job ID: 3672
Job address: 0x16377e0
Job index: 117
Job state (should be 2): 2
## A Battery of Peril (bentley)
Job ID: 3684
@@ -270,6 +264,12 @@ Job address: 0x788780
Job index: 59
Job state (should be 2): 2
## Crusher from the Depths (sly)
Job ID: 4071
Job address: 0x602a30
Job index: 98
Job state (should be 2): 2
## Deep Sea Danger (sly)
Job ID: 4101
Job address: 0x6e52c0
@@ -282,12 +282,6 @@ Job address: 0x60fa80
Job index: 116
Job state (should be 2): 2
## Crusher from the Depths (sly)
Job ID: 4071
Job address: 0x602a30
Job index: 98
Job state (should be 2): 2
## Operation: Reverse Double-Cross
Job ID: 4145
Job address: 0x7825e0