This commit is contained in:
2026-03-05 23:08:22 +01:00
parent 54bf55db45
commit a4c776590f
6 changed files with 384 additions and 64 deletions

View File

@@ -139,7 +139,7 @@ async def set_thiefnet(ctx: "Sly3Context"):
ctx.thiefnet_items.append(string)
ctx.thiefnet_purchases = PowerUps(*[
ctx.thiefnet_purchases = PowerUps(*[False]*4+[
Locations.location_dict[f"ThiefNet {i+1:02}"].code in ctx.checked_locations
for i in range(thiefnet_n)
])
@@ -235,11 +235,19 @@ async def kick_from_episode(ctx: "Sly3Context", availability: Dict):
ep_not_unlocked = not ctx.available_episodes[Sly3Episode(ctx.current_episode)]
job_not_available = not availability.get(ctx.current_job,True)
try:
job_not_available = not availability[ctx.current_job]
except:
if ctx.current_job != 0xffffffff:
print(f"Job ID not accounted for: {ctx.current_job}")
job_not_available = False
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.logger.debug(
f"\nNot connected: {not_connected}"+
f"\nEpisode not unlocked: {ep_not_unlocked}"+
f"\nJob not available: {job_not_available}"
)
ctx.game_interface.to_episode_menu()
async def check_locations(ctx: "Sly3Context"):
@@ -325,7 +333,7 @@ async def receive_items(ctx: "Sly3Context"):
available_episodes[episode] = True
elif item.category == "Power-Up":
item_name = item.name.lower().replace(" ","_")
item_name = item.name.lower().replace(" ","_").replace("-","_")
if item_name == "Progressive Shadow Power":
if new_powerups[30]:
idx = 32
@@ -394,7 +402,7 @@ async def handle_job_markers(ctx: "Sly3Context", availability: Dict):
inactive_jobs.append(job_id)
ctx.game_interface.activate_jobs(active_jobs)
ctx.game_interface.activate_jobs(inactive_jobs)
ctx.game_interface.deactivate_jobs(inactive_jobs)
async def handle_notifications(ctx: "Sly3Context"):
if (
@@ -458,6 +466,8 @@ async def update(ctx: "Sly3Context") -> None:
return
if not ctx.game_interface.is_game_started():
if ctx.game_interface.intro_done():
ctx.game_interface.to_episode_menu()
return
availability = accessibility(ctx)
@@ -471,7 +481,8 @@ async def update(ctx: "Sly3Context") -> None:
await send_checks(ctx)
await receive_items(ctx)
await check_goal(ctx)
if ctx.game_interface.in_hub():
if ctx.current_episode != 0 and ctx.game_interface.in_hub():
await handle_job_markers(ctx, availability)
if ctx.current_map != 0 and not ctx.in_safehouse:

View File

@@ -3,11 +3,12 @@ import asyncio
import multiprocessing
import traceback
from .data import Items, Locations
from .data.Constants import EPISODES, CHALLENGES
from CommonClient import logger, server_loop, gui_enabled, get_base_parser
from BaseClasses import ItemClassification
import Utils
from .data import Items, Locations
from .data.Constants import EPISODES, CHALLENGES, REQUIREMENTS
from .Sly3Interface import Sly3Interface, Sly3Episode, PowerUps
from .Sly3Callbacks import init, update
@@ -44,11 +45,21 @@ class Sly3CommandProcessor(ClientCommandProcessor): # type: ignore[misc]
if isinstance(self.ctx, Sly3Context):
self.ctx.game_interface.to_episode_menu()
def _cmd_reload(self):
"""Reload (in case you're stuck)"""
if isinstance(self.ctx, Sly3Context):
self.ctx.game_interface._reload()
def _cmd_coins(self, amount: str):
"""Add coins to game."""
if isinstance(self.ctx, Sly3Context):
self.ctx.game_interface.add_coins(int(amount))
def _cmd_notification(self, text: str):
"""Add coins to game."""
if isinstance(self.ctx, Sly3Context):
self.ctx.notification(text)
class Sly3Context(CommonContext): # type: ignore[misc]
command_processor = Sly3CommandProcessor
game_interface: Sly3Interface
@@ -115,7 +126,135 @@ class Sly3Context(CommonContext): # type: ignore[misc]
# AP version is added behind this automatically
ui.base_title += " | Archipelago"
return ui
# Making the out of logic tab
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.label import Label
from kivy.uix.scrollview import ScrollView
from kivy.metrics import dp
def make_left_label(**kwargs):
lbl = Label(**kwargs)
lbl.bind(width=lambda instance, value: setattr(instance, 'text_size', (value, None))) # type: ignore
lbl.bind(texture_size=lambda instance, value: setattr(instance, 'height', value[1])) # type: ignore
return lbl
container = BoxLayout(
orientation='vertical',
padding=dp(10),
spacing=dp(8),
size_hint_y=None,
)
container.bind(minimum_height=container.setter('height')) # type: ignore
container.add_widget(Label(
text="Out of logic locations and their required progression items",
font_size=dp(16),
bold=True,
size_hint_y=None,
height=dp(40),
halign="center",
valign="middle",
))
container.add_widget(make_left_label(
text="Jobs",
font_size=dp(14),
bold=True,
size_hint_y=None,
height=dp(30),
halign="left",
valign="bottom",
))
self.jobs_label = make_left_label(
size_hint_y=None,
halign="left",
valign="top",
)
container.add_widget(self.jobs_label)
container.add_widget(make_left_label(
text="Master Thief Challenges",
font_size=dp(14),
bold=True,
size_hint_y=None,
height=dp(30),
halign="left",
valign="bottom",
))
self.challenges_label = make_left_label(
size_hint_y=None,
halign="left",
valign="top",
)
container.add_widget(self.challenges_label)
scroll = ScrollView(size_hint=(1, 1))
scroll.add_widget(container)
self.out_of_logic_tab = scroll
class Manager(ui):
def build(self):
super().build()
self.add_client_tab("Out-of-Logic", self.ctx.out_of_logic_tab)
return self.container
return Manager
def update_gui(self):
received_items = [Items.from_id(i.item) for i in self.items_received]
progression_items = [i.name for i in received_items if i.classification == ItemClassification.progression]
section_requirements = {
episode_name: [
list(set(sum([
sum(
ep_reqs,
[]
)
for ep_reqs
in episode[:i-1]
], [])))
for i in range(1,5)
]
for episode_name, episode in REQUIREMENTS["Jobs"].items()
}
# Jobs
jobs = [
f"{ep_name} - {job}"
for ep_name, ep in EPISODES.items() for chapter in ep for job in chapter
]
job_requirements = [
[r for r in reqs+section_requirements[ep_name][chapter_idx] if r not in progression_items]
for ep_name, ep in REQUIREMENTS["Jobs"].items()
for chapter_idx, chapter in enumerate(ep) for reqs in chapter
]
job_pairs = zip(jobs,job_requirements)
self.jobs_label.text = "\n".join([
f"{job}: {', '.join(reqs)}"
for job, reqs in job_pairs
if reqs != []
])
# Challenges
challenges = [
f"{ep_name} - {challenge}"
for ep_name, ep in CHALLENGES.items() for chapter in ep for challenge in chapter
]
challenge_requirements = [
sorted([r for r in list(set(reqs+section_requirements[ep_name][chapter_idx])) if r not in progression_items])
for ep_name, ep in REQUIREMENTS["Challenges"].items()
for chapter_idx, chapter in enumerate(ep) for reqs in chapter
]
challenge_pairs = zip(challenges,challenge_requirements)
self.challenges_label.text = "\n".join([
f"{challenge}: {', '.join(reqs)}"
for challenge, reqs in challenge_pairs
if reqs != []
])
# async def server_auth(self, password_requested: bool = False) -> None:
# if password_requested and not self.password:
@@ -150,10 +289,12 @@ class Sly3Context(CommonContext): # type: ignore[misc]
for i in range(args["slot_data"]["thiefnet_locations"])
]
}]))
self.update_gui()
if cmd in ["RoomUpdate", "ReceivedItems"]:
self.update_gui()
def notification(self, text: str):
# TODO: Notifications
pass
self.notification_queue.append(text)
def update_connection_status(ctx: Sly3Context, status: bool):
if ctx.is_connected_to_game == status:

View File

@@ -3,6 +3,7 @@ import struct
from logging import Logger
from enum import IntEnum
import traceback
from time import sleep
from .pcsx2_interface.pine import Pine
from .data.Constants import ADDRESSES, MENU_RETURN_DATA, POWER_UP_TEXT
@@ -125,6 +126,11 @@ class GameInterface():
def _write_float(self, address: int, value: float):
self.pcsx2_interface.write_float(address, value)
def _batch_write32(self, operations: list[tuple[int,int]]):
self.pcsx2_interface.batch_write_int32(operations)
# for address, data in operations:
# self._write32(address, data)
def connect_to_game(self):
"""
Initializes the connection to PCSX2 and verifies it is connected to the
@@ -171,11 +177,12 @@ class Sly3Interface(GameInterface):
############################
## Private Helper Methods ##
############################
def _reload(self, reload_data: bytes):
self._write_bytes(
self.addresses["reload values"],
reload_data
)
def _reload(self, reload_data: bytes = b""):
if reload_data != b"":
self._write_bytes(
self.addresses["reload values"],
reload_data
)
self._write32(self.addresses["reload"], 1)
def _get_job_address(self, task: int) -> int:
@@ -197,6 +204,10 @@ class Sly3Interface(GameInterface):
return self._read32(string_table_address+i*8+4)
i += 1
def _job_parents_finished(self, job: int) -> bool:
# TODO: Job Markers
return True
###################
## Current State ##
###################
@@ -211,7 +222,7 @@ class Sly3Interface(GameInterface):
return (
not self.is_loading() and
self.in_hub() and
self._read32(self.addresses["infobox string"]) in [
self.current_infobox() in [
5345,5346,5347,5348,5349,5350,5351
]
)
@@ -232,17 +243,17 @@ class Sly3Interface(GameInterface):
)
def showing_infobox(self) -> bool:
# TODO: Notifications
return False
infobox_pointer = self._read32(self.addresses["infobox"])
return self._read32(infobox_pointer+0x64) == 2
def alive(self) -> bool:
active_character = self._read32(self.addresses["active character pointer"])
if active_character == 0:
return True
health_gui = self._read32(active_character+0x16c)
health_gui_pointer = self._read32(active_character+0x168)
health = self._read32(active_character+0x16c)
return health_gui != 2 or health != 0
return health_gui_pointer == 0 or health != 0
#######################
## Getters & Setters ##
@@ -254,9 +265,11 @@ class Sly3Interface(GameInterface):
if i not in [28,36,37,39,40,42,43]
][:len(data)]
operations = []
for i, address in enumerate(addresses):
self._write32(address,data[i][0])
self._write32(address+0xC,0)
operations.append((address,data[i][0]))
operations.append((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}")
@@ -267,7 +280,9 @@ class Sly3Interface(GameInterface):
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)
operations.append((address+0xC,10))
self._batch_write32(operations)
def reset_thiefnet(self) -> None:
for i in range(44):
@@ -344,12 +359,40 @@ class Sly3Interface(GameInterface):
return PowerUps(*relevant_bits)
def activate_jobs(self, job_ids: int|list[int]):
# TODO: Job Markers
pass
if isinstance(job_ids, int):
job_ids = [job_ids]
markers = self.addresses["job markers"]
to_read = []
for job in job_ids:
if job not in markers:
self.logger.debug(f"Job {job} not able to be activated")
continue
to_read.append(job)
statuses = self._batch_read32([markers[j]+0x44 for j in to_read])
to_write = [j for i,j in enumerate(to_read) if statuses[i] == 0 and self._job_parents_finished(j)]
operations = [(markers[j]+0x44,1) for j in to_write]
self._batch_write32(operations)
def deactivate_jobs(self, job_ids: int|list[int]):
# TODO: Job Markers
pass
if isinstance(job_ids, int):
job_ids = [job_ids]
markers = self.addresses["job markers"]
to_read = []
for job in job_ids:
if job not in markers:
self.logger.debug(f"Job {job} not able to be deactivated")
continue
to_read.append(job)
statuses = self._batch_read32([markers[j]+0x44 for j in to_read])
to_write = [j for i,j in enumerate(to_read) if statuses[i] == 1]
operations = [(markers[j]+0x44,0) for j in to_write]
self._batch_write32(operations)
def jobs_completed(self) -> list[bool]:
addresses = [a for ep in self.addresses["job completed"].values() for c in ep for a in c]
@@ -358,8 +401,7 @@ class Sly3Interface(GameInterface):
return [s != 0 for s in states]
def current_infobox(self) -> int:
# TODO: Notifications
return 0
return self._read32(self.addresses["infobox string"])
def get_damage_type(self) -> int:
# TODO: Death Messages
@@ -368,14 +410,19 @@ class Sly3Interface(GameInterface):
#################
## Other Utils ##
#################
def intro_done(self) -> bool:
return self._read32(self.addresses["intro complete"]) != 0
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.get_current_job() == 1797 and
not self.intro_done()
):
self.set_current_job(0xffffffff)
self.set_items_received(0)
self._write32(self.addresses["intro complete"],1)
self._reload(bytes.fromhex(MENU_RETURN_DATA))
@@ -394,12 +441,22 @@ class Sly3Interface(GameInterface):
self._write32(self.addresses["coins"],new_amount)
def disable_infobox(self):
# TODO: Notifications
pass
infobox_pointer = self._read32(self.addresses["infobox"])
if self._read32(infobox_pointer+0x54) != 1:
self._write32(infobox_pointer+0x54,2)
self._write32(infobox_pointer+0x54,1)
def set_infobox(self, text: str):
# TODO: Notifications
pass
ep = self.get_current_episode()
if ep == 0 or self.in_safehouse():
return
infobox_pointer = self._read32(self.addresses["infobox"])
self._write32(self.addresses["infobox scrolling"],1)
self.set_text("infobox"," "*10+text)
self._write32(self.addresses["infobox string"],1)
self._write32(infobox_pointer+0x54,2)
self._write32(self.addresses["infobox duration"],0xffffffff)
def kill_player(self):
if self.in_safehouse() or self.get_current_episode() == Sly3Episode.Title_Screen:
@@ -553,6 +610,19 @@ def current_job_info(interf: Sly3Interface):
print("Job index:", i)
print("Job state (should be 2):", interf._read32(address+0x44))
def active_jobs_info(interf: Sly3Interface):
address = interf._read32(interf.addresses["DAG root"])
i = 0
while address != 0:
job_pointer = interf._read32(address+0x6c)
job_id = interf._read32(job_pointer+0x18)
job_state = interf._read32(address+0x44)
if job_state == 1:
print(f"{job_id}: {hex(address)}")
address = interf._read32(address+0x20)
i += 1
if __name__ == "__main__":
interf = Sly3Interface(Logger("test"))
interf.connect_to_game()
@@ -587,4 +657,17 @@ if __name__ == "__main__":
# print(find_string_address(interf, address))
# print("======")
print_thiefnet_text(interf)
# print(hex(find_string_id(interf, 1)))
# print([hex(i) for i in find_text(interf, "merges")])
# active_jobs_info(interf)
# interf.set_infobox("test")
# interf.disable_infobox()
# pointer = interf._read32(0x46F798)
# print(interf._read32(pointer+0x54))
# print(interf._read32(pointer+0x64))
# interf.to_episode_menu()
# print(interf.get_items_received())
# interf.set_items_received(28)
# interf._write32(interf.addresses["intro complete"], 1)
print(interf._read32(interf.addresses["reload"]))

View File

@@ -14,8 +14,10 @@ class StartingEpisode(Choice):
"""
Select Which episode to start with.
Flight of Fancy, A Cold Alliance and Dead Men Tell No Tales require starting
items, so starting with them will break a solo game.
Flight of Fancy and Dead Men Tell No Tales require items to do the first
jobs, meaning you'll only have ThiefNet in logic.
A Cold Alliance also requires items to even have ThiefNet in logic, so if you
start with that episode, you will start with no locations in logic.
"""
display_name = "Starting Episode"

View File

@@ -10,6 +10,27 @@ from .data.Locations import location_dict
if typing.TYPE_CHECKING:
from . import Sly3World
def make_thiefnet_rule(player: int, n: int):
def new_rule(state: CollectionState):
if (
state.count_group("Episode", player) == 1 and
state.has("A Cold Alliance", player) and
not all(
state.has(item, player)
for item in ["Bentley", "Murray", "Guru", "Penelope", "Binocucom"]
)
):
return False
progression_items = (
state.count_group("Episode", player) +
state.count_group("Crew", player)
)
return progression_items >= n
return new_rule
def set_rules_sly3(world: "Sly3World"):
player = world.player
thiefnet_items = world.options.thiefnet_locations.value
@@ -17,18 +38,14 @@ 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+1):
if not hasattr(world.multiworld, "generation_is_fake"): # (unless tracking)
divisor = ceil(thiefnet_items/12)
episode_items_n = ceil(i/divisor)
add_rule(
world.get_location(f"ThiefNet {i:02}"),
lambda state, n=episode_items_n: (
(
state.count_group("Episode", player) +
state.count_group("Crew", player)
) >= n
for i in range(1,thiefnet_items+1):
episode_items_n = ceil(i/divisor)
add_rule(
world.get_location(f"ThiefNet {i:02}"),
make_thiefnet_rule(player, episode_items_n)
)
)
### Job requirements
for episode, sections in EPISODES.items():

View File

@@ -211,7 +211,7 @@ REQUIREMENTS = {
[[]],
[
["Binocucom"],
[],
["Bombs"],
["Bentley"],
],
[
@@ -227,14 +227,14 @@ REQUIREMENTS = {
"Rumble Down Under" :[
[[]],
[
["Murray"],
["Murray", "Ball Form"],
[],
[],
["Guru"],
["Murray", "Ball Form"],
["Bentley", "Murray", "Ball Form", "Guru"],
],
[
[],
["Bentley"],
[],
[]
],
[[]]
@@ -244,10 +244,10 @@ REQUIREMENTS = {
["Bentley"]
],
[
["Murray", "Bentley", "Guru", "Fishing Pole"],
["Murray", "Guru", "Fishing Pole"],
["Murray"],
["Penelope"],
["Murray", "Bentley", "Guru", "Fishing Pole", "Penelope"]
["Murray","Penelope"],
["Murray", "Guru", "Fishing Pole", "Penelope"]
],
[
["Binocucom"],
@@ -293,6 +293,18 @@ REQUIREMENTS = {
["Guru"]
]
],
"Honor Among Thieves": [
[
["Bentley", "Murray", "Guru", "Penelope", "Panda King", "Dimitri", "Carmelita"],
["Bentley", "Murray", "Guru", "Penelope", "Panda King", "Dimitri", "Carmelita"],
["Bentley", "Murray", "Guru", "Penelope", "Panda King", "Dimitri", "Carmelita"],
["Bentley", "Murray", "Guru", "Penelope", "Panda King", "Dimitri", "Carmelita"],
["Bentley", "Murray", "Guru", "Penelope", "Panda King", "Dimitri", "Carmelita"],
["Bentley", "Murray", "Guru", "Penelope", "Panda King", "Dimitri", "Carmelita"],
["Bentley", "Murray", "Guru", "Penelope", "Panda King", "Dimitri", "Carmelita"],
["Bentley", "Murray", "Guru", "Penelope", "Panda King", "Dimitri", "Carmelita"],
]
],
},
"Challenges": {
"An Opera of Fear": [
@@ -317,8 +329,8 @@ REQUIREMENTS = {
[
[],
[],
[],
["Guru"]
["Murray", "Ball Form"],
["Murray", "Ball Form", "Guru"]
],
[
[],
@@ -331,12 +343,14 @@ REQUIREMENTS = {
]
],
"Flight of Fancy": [
[[]],
[
["Penelope"],
["Penelope"],
["Penelope"],
["Murray", "Bentley", "Guru", "Fishing Pole", "Penelope"],
["Bentley"]
],
[
["Murray", "Penelope"],
["Murray", "Penelope"],
["Murray", "Penelope"],
["Murray", "Guru", "Fishing Pole", "Penelope"],
],
[
[],
@@ -374,6 +388,15 @@ REQUIREMENTS = {
[],
[[]]
],
"Honor Among Thieves": [
[
["Bentley", "Murray", "Guru", "Penelope", "Panda King", "Dimitri", "Carmelita"],
["Bentley", "Murray", "Guru", "Penelope", "Panda King", "Dimitri", "Carmelita"],
["Bentley", "Murray", "Guru", "Penelope", "Panda King", "Dimitri", "Carmelita"],
["Bentley", "Murray", "Guru", "Penelope", "Panda King", "Dimitri", "Carmelita"],
["Bentley", "Murray", "Guru", "Penelope", "Panda King", "Dimitri", "Carmelita"],
]
],
}
}
@@ -429,6 +452,7 @@ ADDRESSES = {
"gadgets": 0x468DCC,
"coins": 0x468DDC,
"DAG root": 0x478C8C,
"intro complete": 0x468EEC,
"job completed": {
"An Opera of Fear": [
[
@@ -537,8 +561,42 @@ ADDRESSES = {
]
],
},
"job markers": {
2085: 0x1335d10,
2230: 0x1350560,
2283: 0x1357f80,
2329: 0x135aba0,
2139: 0x1330c40,
2168: 0x1335dc0,
2187: 0x133e9b0,
2352: 0x1351520,
2419: 0x135e550,
2577: 0x6b4250,
2596: 0x6b80f0,
2805: 0x6d0770,
2695: 0x5d26f0,
2663: 0x6bdaf0,
2623: 0x5caa20,
2730: 0x5fe390,
2780: 0x6ca940,
2843: 0x6d4330,
2983: 0x794360,
3025: 0x627a60,
3061: 0x62c7b0,
3101: 0x630220,
3140: 0xecb450,
3202: 0x642a90,
3164: 0x63b4d0,
3225: 0x7adf50,
3259: 0x651eb0
},
"active character pointer": 0x36F84C,
"infobox scrolling": 0x46F780,
"infobox string": 0x46F788,
"infobox duration": 0x46F78C,
"infobox": 0x46F798,
# "infobox": 0x47671C,
# "infobox": 0x479758,
"thiefnet start": 0x343208,
"string table": 0x47A2D8,
"text": {
@@ -551,6 +609,14 @@ ADDRESSES = {
"A Cold Alliance": 0x53b710,
"Dead Men Tell No Tales": 0x53b7b0,
"Honor Among Thieves": 0x53b900,
"infobox": {
3: 0x5765d0,
8: 0x564170,
15: 0x579ec0,
23: 0x579ec0,
31: 0x0,
35: 0x0,
},
}
}
}