Files
Grinch-AP/worlds/tww/TWWClient.py
Jonathan Tan cf0ae5e31b The Wind Waker: Implement New Game (#4458)
Adds The Legend of Zelda: The Wind Waker as a supported game in Archipelago. The game uses [LagoLunatic's randomizer](https://github.com/LagoLunatic/wwrando) as its base (regarding logic, options, etc.) and builds from there.
2025-03-23 00:42:17 +01:00

740 lines
31 KiB
Python

import asyncio
import time
import traceback
from typing import TYPE_CHECKING, Any, Optional
import dolphin_memory_engine
import Utils
from CommonClient import ClientCommandProcessor, CommonContext, get_base_parser, gui_enabled, logger, server_loop
from NetUtils import ClientStatus
from .Items import ITEM_TABLE, LOOKUP_ID_TO_NAME
from .Locations import ISLAND_NAME_TO_SALVAGE_BIT, LOCATION_TABLE, TWWLocation, TWWLocationData, TWWLocationType
from .randomizers.Charts import ISLAND_NUMBER_TO_NAME
if TYPE_CHECKING:
import kvui
CONNECTION_REFUSED_GAME_STATUS = (
"Dolphin failed to connect. Please load a randomized ROM for The Wind Waker. Trying again in 5 seconds..."
)
CONNECTION_REFUSED_SAVE_STATUS = (
"Dolphin failed to connect. Please load into the save file. Trying again in 5 seconds..."
)
CONNECTION_LOST_STATUS = (
"Dolphin connection was lost. Please restart your emulator and make sure The Wind Waker is running."
)
CONNECTION_CONNECTED_STATUS = "Dolphin connected successfully."
CONNECTION_INITIAL_STATUS = "Dolphin connection has not been initiated."
# This address is used to check/set the player's health for DeathLink.
CURR_HEALTH_ADDR = 0x803C4C0A
# These addresses are used for the Moblin's Letter check.
LETTER_BASE_ADDR = 0x803C4C8E
LETTER_OWND_ADDR = 0x803C4C98
# These addresses are used to check flags for locations.
CHARTS_BITFLD_ADDR = 0x803C4CFC
BASE_CHESTS_BITFLD_ADDR = 0x803C4F88
BASE_SWITCHES_BITFLD_ADDR = 0x803C4F8C
BASE_PICKUPS_BITFLD_ADDR = 0x803C4F9C
CURR_STAGE_CHESTS_BITFLD_ADDR = 0x803C5380
CURR_STAGE_SWITCHES_BITFLD_ADDR = 0x803C5384
CURR_STAGE_PICKUPS_BITFLD_ADDR = 0x803C5394
# The expected index for the following item that should be received. Uses event bits 0x60 and 0x61.
EXPECTED_INDEX_ADDR = 0x803C528C
# These bytes contain whether the player has been rewarded for finding a particular Tingle statue.
TINGLE_STATUE_1_ADDR = 0x803C523E # 0x40 is the bit for the Dragon Tingle statue.
TINGLE_STATUE_2_ADDR = 0x803C5249 # 0x0F are the bits for the remaining Tingle statues.
# This address contains the current stage ID.
CURR_STAGE_ID_ADDR = 0x803C53A4
# This address is used to check the stage name to verify that the player is in-game before sending items.
CURR_STAGE_NAME_ADDR = 0x803C9D3C
# This is an array of length 0x10 where each element is a byte and contains item IDs for items to give the player.
# 0xFF represents no item. The array is read and cleared every frame.
GIVE_ITEM_ARRAY_ADDR = 0x803FE87C
# This is the address that holds the player's slot name.
# This way, the player does not have to manually authenticate their slot name.
SLOT_NAME_ADDR = 0x803FE8A0
# This address is the start of an array that we use to inform us of which charts lead where.
# The array is of length 49, and each element is two bytes. The index represents the chart's original destination, and
# the value represents the new destination.
# The chart name is inferrable from the chart's original destination.
CHARTS_MAPPING_ADDR = 0x803FE8E0
# This address contains the most recent spawn ID from which the player spawned.
MOST_RECENT_SPAWN_ID_ADDR = 0x803C9D44
# This address contains the most recent room number the player spawned in.
MOST_RECENT_ROOM_NUMBER_ADDR = 0x803C9D46
# Values used to detect exiting onto the highest isle in Cliff Plateau Isles.
# 42. Starting at 1 and going left to right, top to bottom, Cliff Plateau Isles is the 42nd square in the sea stage.
CLIFF_PLATEAU_ISLES_ROOM_NUMBER = 0x2A
CLIFF_PLATEAU_ISLES_HIGHEST_ISLE_SPAWN_ID = 1 # As a note, the lower isle's spawn ID is 2.
# The dummy stage name used to identify the highest isle in Cliff Plateau Isles.
CLIFF_PLATEAU_ISLES_HIGHEST_ISLE_DUMMY_STAGE_NAME = "CliPlaH"
# Data storage key
AP_VISITED_STAGE_NAMES_KEY_FORMAT = "tww_visited_stages_%i"
class TWWCommandProcessor(ClientCommandProcessor):
"""
Command Processor for The Wind Waker client commands.
This class handles commands specific to The Wind Waker.
"""
def __init__(self, ctx: CommonContext):
"""
Initialize the command processor with the provided context.
:param ctx: Context for the client.
"""
super().__init__(ctx)
def _cmd_dolphin(self) -> None:
"""
Display the current Dolphin emulator connection status.
"""
if isinstance(self.ctx, TWWContext):
logger.info(f"Dolphin Status: {self.ctx.dolphin_status}")
class TWWContext(CommonContext):
"""
The context for The Wind Waker client.
This class manages all interactions with the Dolphin emulator and the Archipelago server for The Wind Waker.
"""
command_processor = TWWCommandProcessor
game: str = "The Wind Waker"
items_handling: int = 0b111
def __init__(self, server_address: Optional[str], password: Optional[str]) -> None:
"""
Initialize the TWW context.
:param server_address: Address of the Archipelago server.
:param password: Password for server authentication.
"""
super().__init__(server_address, password)
self.dolphin_sync_task: Optional[asyncio.Task[None]] = None
self.dolphin_status: str = CONNECTION_INITIAL_STATUS
self.awaiting_rom: bool = False
self.has_send_death: bool = False
# Bitfields used for checking locations.
self.charts_bitfield: int
self.chests_bitfields: dict[int, int]
self.switches_bitfields: dict[int, int]
self.pickups_bitfields: dict[int, int]
self.curr_stage_chests_bitfield: int
self.curr_stage_switches_bitfield: int
self.curr_stage_pickups_bitfield: int
# Keep track of whether the player has yet received their first progressive magic meter.
self.received_magic: bool = False
# A dictionary that maps salvage locations to their sunken treasure bit.
self.salvage_locations_map: dict[str, int] = {}
# Name of the current stage as read from the game's memory. Sent to trackers whenever its value changes to
# facilitate automatically switching to the map of the current stage.
self.current_stage_name: str = ""
# Set of visited stages. A dictionary (used as a set) of all visited stages is set in the server's data storage
# and updated when the player visits a new stage for the first time. To track which stages are new and need to
# cause the server's data storage to update, the TWW AP Client keeps track of the visited stages in a set.
# Trackers can request the dictionary from data storage to see which stages the player has visited.
# It starts as `None` until it has been read from the server.
self.visited_stage_names: Optional[set[str]] = None
# Length of the item get array in memory.
self.len_give_item_array: int = 0x10
async def disconnect(self, allow_autoreconnect: bool = False) -> None:
"""
Disconnect the client from the server and reset game state variables.
:param allow_autoreconnect: Allow the client to auto-reconnect to the server. Defaults to `False`.
"""
self.auth = None
self.salvage_locations_map = {}
self.current_stage_name = ""
self.visited_stage_names = None
await super().disconnect(allow_autoreconnect)
async def server_auth(self, password_requested: bool = False) -> None:
"""
Authenticate with the Archipelago server.
:param password_requested: Whether the server requires a password. Defaults to `False`.
"""
if password_requested and not self.password:
await super().server_auth(password_requested)
if not self.auth:
if self.awaiting_rom:
return
self.awaiting_rom = True
logger.info("Awaiting connection to Dolphin to get player information.")
return
await self.send_connect()
def on_package(self, cmd: str, args: dict[str, Any]) -> None:
"""
Handle incoming packages from the server.
:param cmd: The command received from the server.
:param args: The command arguments.
"""
if cmd == "Connected":
self.update_salvage_locations_map()
if "death_link" in args["slot_data"]:
Utils.async_start(self.update_death_link(bool(args["slot_data"]["death_link"])))
# Request the connected slot's dictionary (used as a set) of visited stages.
visited_stages_key = AP_VISITED_STAGE_NAMES_KEY_FORMAT % self.slot
Utils.async_start(self.send_msgs([{"cmd": "Get", "keys": [visited_stages_key]}]))
elif cmd == "Retrieved":
requested_keys_dict = args["keys"]
# Read the connected slot's dictionary (used as a set) of visited stages.
if self.slot is not None:
visited_stages_key = AP_VISITED_STAGE_NAMES_KEY_FORMAT % self.slot
if visited_stages_key in requested_keys_dict:
visited_stages = requested_keys_dict[visited_stages_key]
# If it has not been set before, the value in the response will be `None`.
visited_stage_names = set() if visited_stages is None else set(visited_stages.keys())
# If the current stage name is not in the set, send a message to update the dictionary on the
# server.
current_stage_name = self.current_stage_name
if current_stage_name and current_stage_name not in visited_stage_names:
visited_stage_names.add(current_stage_name)
Utils.async_start(self.update_visited_stages(current_stage_name))
self.visited_stage_names = visited_stage_names
def on_deathlink(self, data: dict[str, Any]) -> None:
"""
Handle a DeathLink event.
:param data: The data associated with the DeathLink event.
"""
super().on_deathlink(data)
_give_death(self)
def make_gui(self) -> type["kvui.GameManager"]:
"""
Initialize the GUI for The Wind Waker client.
:return: The client's GUI.
"""
ui = super().make_gui()
ui.base_title = "Archipelago The Wind Waker Client"
return ui
async def update_visited_stages(self, newly_visited_stage_name: str) -> None:
"""
Update the server's data storage of the visited stage names to include the newly visited stage name.
:param newly_visited_stage_name: The name of the stage recently visited.
"""
if self.slot is not None:
visited_stages_key = AP_VISITED_STAGE_NAMES_KEY_FORMAT % self.slot
await self.send_msgs(
[
{
"cmd": "Set",
"key": visited_stages_key,
"default": {},
"want_reply": False,
"operations": [{"operation": "update", "value": {newly_visited_stage_name: True}}],
}
]
)
def update_salvage_locations_map(self) -> None:
"""
Update the client's mapping of salvage locations to their bitfield bit.
This is necessary for the client to handle randomized charts correctly.
"""
self.salvage_locations_map = {}
for offset in range(49):
island_name = ISLAND_NUMBER_TO_NAME[offset + 1]
salvage_bit = ISLAND_NAME_TO_SALVAGE_BIT[island_name]
shuffled_island_number = read_short(CHARTS_MAPPING_ADDR + offset * 2)
shuffled_island_name = ISLAND_NUMBER_TO_NAME[shuffled_island_number]
salvage_location_name = f"{shuffled_island_name} - Sunken Treasure"
self.salvage_locations_map[salvage_location_name] = salvage_bit
def read_short(console_address: int) -> int:
"""
Read a 2-byte short from Dolphin memory.
:param console_address: Address to read from.
:return: The value read from memory.
"""
return int.from_bytes(dolphin_memory_engine.read_bytes(console_address, 2), byteorder="big")
def write_short(console_address: int, value: int) -> None:
"""
Write a 2-byte short to Dolphin memory.
:param console_address: Address to write to.
:param value: Value to write.
"""
dolphin_memory_engine.write_bytes(console_address, value.to_bytes(2, byteorder="big"))
def read_string(console_address: int, strlen: int) -> str:
"""
Read a string from Dolphin memory.
:param console_address: Address to start reading from.
:param strlen: Length of the string to read.
:return: The string.
"""
return dolphin_memory_engine.read_bytes(console_address, strlen).split(b"\0", 1)[0].decode()
def _give_death(ctx: TWWContext) -> None:
"""
Trigger the player's death in-game by setting their current health to zero.
:param ctx: The Wind Waker client context.
"""
if (
ctx.slot is not None
and dolphin_memory_engine.is_hooked()
and ctx.dolphin_status == CONNECTION_CONNECTED_STATUS
and check_ingame()
):
ctx.has_send_death = True
write_short(CURR_HEALTH_ADDR, 0)
def _give_item(ctx: TWWContext, item_name: str) -> bool:
"""
Give an item to the player in-game.
:param ctx: The Wind Waker client context.
:param item_name: Name of the item to give.
:return: Whether the item was successfully given.
"""
if not check_ingame() or dolphin_memory_engine.read_byte(CURR_STAGE_ID_ADDR) == 0xFF:
return False
item_id = ITEM_TABLE[item_name].item_id
# Loop through the item array, placing the item in an empty slot.
for idx in range(ctx.len_give_item_array):
slot = dolphin_memory_engine.read_byte(GIVE_ITEM_ARRAY_ADDR + idx)
if slot == 0xFF:
# Special case: Use a different item ID for the second progressive magic meter.
if item_name == "Progressive Magic Meter":
if ctx.received_magic:
item_id = 0xB2
else:
ctx.received_magic = True
dolphin_memory_engine.write_byte(GIVE_ITEM_ARRAY_ADDR + idx, item_id)
return True
# If unable to place the item in the array, return `False`.
return False
async def give_items(ctx: TWWContext) -> None:
"""
Give the player all outstanding items they have yet to receive.
:param ctx: The Wind Waker client context.
"""
if check_ingame() and dolphin_memory_engine.read_byte(CURR_STAGE_ID_ADDR) != 0xFF:
# Read the expected index of the player, which is the index of the next item they're expecting to receive.
# The expected index starts at 0 for a fresh save file.
expected_idx = read_short(EXPECTED_INDEX_ADDR)
# Check if there are new items.
received_items = ctx.items_received
if len(received_items) <= expected_idx:
# There are no new items.
return
# Loop through items to give.
# Give the player all items at an index greater than or equal to the expected index.
for idx, item in enumerate(received_items[expected_idx:], start=expected_idx):
# Attempt to give the item and increment the expected index.
while not _give_item(ctx, LOOKUP_ID_TO_NAME[item.item]):
await asyncio.sleep(0.01)
# Increment the expected index.
write_short(EXPECTED_INDEX_ADDR, idx + 1)
def check_special_location(location_name: str, data: TWWLocationData) -> bool:
"""
Check that the player has checked a given location.
This function handles locations that require special logic.
:param location_name: The name of the location.
:param data: The data associated with the location.
:raises NotImplementedError: If an unknown location name is provided.
"""
checked = False
# For "Windfall Island - Lenzo's House - Become Lenzo's Assistant"
# 0x6 is delivered the final picture for Lenzo, 0x7 is a day has passed since becoming his assistant
# Either is fine for sending the check, so check both conditions.
if location_name == "Windfall Island - Lenzo's House - Become Lenzo's Assistant":
checked = (
dolphin_memory_engine.read_byte(data.address) & 0x6 == 0x6
or dolphin_memory_engine.read_byte(data.address) & 0x7 == 0x7
)
# The "Windfall Island - Maggie - Delivery Reward" flag remains unknown.
# However, as a temporary workaround, we can check if the player had Moblin's letter at some point, but it's no
# longer in their Delivery Bag.
elif location_name == "Windfall Island - Maggie - Delivery Reward":
was_moblins_owned = (dolphin_memory_engine.read_word(LETTER_OWND_ADDR) >> 15) & 1
dbag_contents = [dolphin_memory_engine.read_byte(LETTER_BASE_ADDR + offset) for offset in range(8)]
checked = was_moblins_owned and 0x9B not in dbag_contents
# For Letter from Hoskit's Girlfriend, we need to check two bytes.
# 0x1 = Golden Feathers delivered, 0x2 = Mail sent by Hoskit's Girlfriend, 0x3 = Mail read by Link
elif location_name == "Mailbox - Letter from Hoskit's Girlfriend":
checked = dolphin_memory_engine.read_byte(data.address) & 0x3 == 0x3
# For Letter from Baito's Mother, we need to check two bytes.
# 0x1 = Note to Mom sent, 0x2 = Mail sent by Baito's Mother, 0x3 = Mail read by Link
elif location_name == "Mailbox - Letter from Baito's Mother":
checked = dolphin_memory_engine.read_byte(data.address) & 0x3 == 0x3
# For Letter from Grandma, we need to check two bytes.
# 0x1 = Grandma saved, 0x2 = Mail sent by Grandma, 0x3 = Mail read by Link
elif location_name == "Mailbox - Letter from Grandma":
checked = dolphin_memory_engine.read_byte(data.address) & 0x3 == 0x3
# We check if the bits for turning all five statues are set for the Ankle's reward.
# For some reason, the bit for the Dragon Tingle Statue is separate from the rest.
elif location_name == "Tingle Island - Ankle - Reward for All Tingle Statues":
dragon_tingle_statue_rewarded = dolphin_memory_engine.read_byte(TINGLE_STATUE_1_ADDR) & 0x40 == 0x40
other_tingle_statues_rewarded = dolphin_memory_engine.read_byte(TINGLE_STATUE_2_ADDR) & 0x0F == 0x0F
checked = dragon_tingle_statue_rewarded and other_tingle_statues_rewarded
else:
raise NotImplementedError(f"Unknown special location: {location_name}")
return checked
def check_regular_location(ctx: TWWContext, curr_stage_id: int, data: TWWLocationData) -> bool:
"""
Check that the player has checked a given location.
This function handles locations that only require checking that a particular bit is set.
The check looks at the saved data for the stage at which the location is located and the data for the current stage.
In the latter case, this data includes data that has not yet been written to the saved data.
:param ctx: The Wind Waker client context.
:param curr_stage_id: The current stage at which the player is.
:param data: The data associated with the location.
:raises NotImplementedError: If a location with an unknown type is provided.
"""
checked = False
# Check the saved bitfields for the stage.
if data.type == TWWLocationType.CHEST:
checked = bool((ctx.chests_bitfields[data.stage_id] >> data.bit) & 1)
elif data.type == TWWLocationType.SWTCH:
checked = bool((ctx.switches_bitfields[data.stage_id] >> data.bit) & 1)
elif data.type == TWWLocationType.PCKUP:
checked = bool((ctx.pickups_bitfields[data.stage_id] >> data.bit) & 1)
else:
raise NotImplementedError(f"Unknown location type: {data.type}")
# If the location is in the current stage, check the bitfields for the current stage as well.
if not checked and curr_stage_id == data.stage_id:
if data.type == TWWLocationType.CHEST:
checked = bool((ctx.curr_stage_chests_bitfield >> data.bit) & 1)
elif data.type == TWWLocationType.SWTCH:
checked = bool((ctx.curr_stage_switches_bitfield >> data.bit) & 1)
elif data.type == TWWLocationType.PCKUP:
checked = bool((ctx.curr_stage_pickups_bitfield >> data.bit) & 1)
else:
raise NotImplementedError(f"Unknown location type: {data.type}")
return checked
async def check_locations(ctx: TWWContext) -> None:
"""
Iterate through all locations and check whether the player has checked each location.
Update the server with all newly checked locations since the last update. If the player has completed the goal,
notify the server.
:param ctx: The Wind Waker client context.
"""
# Read the bitfield for sunken treasure locations.
ctx.charts_bitfield = int.from_bytes(dolphin_memory_engine.read_bytes(CHARTS_BITFLD_ADDR, 8), byteorder="big")
# Read the bitfields once before the loop to speed things up a bit.
ctx.chests_bitfields = {}
ctx.switches_bitfields = {}
ctx.pickups_bitfields = {}
for stage_id in range(0xE):
chest_bitfield_addr = BASE_CHESTS_BITFLD_ADDR + (0x24 * stage_id)
switches_bitfield_addr = BASE_SWITCHES_BITFLD_ADDR + (0x24 * stage_id)
pickups_bitfield_addr = BASE_PICKUPS_BITFLD_ADDR + (0x24 * stage_id)
ctx.chests_bitfields[stage_id] = int(dolphin_memory_engine.read_word(chest_bitfield_addr))
ctx.switches_bitfields[stage_id] = int.from_bytes(
dolphin_memory_engine.read_bytes(switches_bitfield_addr, 10), byteorder="big"
)
ctx.pickups_bitfields[stage_id] = int(dolphin_memory_engine.read_word(pickups_bitfield_addr))
ctx.curr_stage_chests_bitfield = int(dolphin_memory_engine.read_word(CURR_STAGE_CHESTS_BITFLD_ADDR))
ctx.curr_stage_switches_bitfield = int.from_bytes(
dolphin_memory_engine.read_bytes(CURR_STAGE_SWITCHES_BITFLD_ADDR, 10), byteorder="big"
)
ctx.curr_stage_pickups_bitfield = int(dolphin_memory_engine.read_word(CURR_STAGE_PICKUPS_BITFLD_ADDR))
# We check which locations are currently checked on the current stage.
curr_stage_id = dolphin_memory_engine.read_byte(CURR_STAGE_ID_ADDR)
# Loop through all locations to see if each has been checked.
for location, data in LOCATION_TABLE.items():
checked = False
if data.type == TWWLocationType.CHART:
assert location in ctx.salvage_locations_map, f'Location "{location}" salvage bit not set!'
salvage_bit = ctx.salvage_locations_map[location]
checked = bool((ctx.charts_bitfield >> salvage_bit) & 1)
elif data.type == TWWLocationType.BOCTO:
assert data.address is not None
checked = bool((read_short(data.address) >> data.bit) & 1)
elif data.type == TWWLocationType.EVENT:
checked = bool((dolphin_memory_engine.read_byte(data.address) >> data.bit) & 1)
elif data.type == TWWLocationType.SPECL:
checked = check_special_location(location, data)
else:
checked = check_regular_location(ctx, curr_stage_id, data)
if checked:
if data.code is None:
if not ctx.finished_game:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
else:
ctx.locations_checked.add(TWWLocation.get_apid(data.code))
# Send the list of newly-checked locations to the server.
locations_checked = ctx.locations_checked.difference(ctx.checked_locations)
if locations_checked:
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations_checked}])
async def check_current_stage_changed(ctx: TWWContext) -> None:
"""
Check if the player has moved to a new stage.
If so, update all trackers with the new stage name.
If the stage has never been visited, additionally update the server.
:param ctx: The Wind Waker client context.
"""
new_stage_name = read_string(CURR_STAGE_NAME_ADDR, 8)
# Special handling is required for the Cliff Plateau Isles Inner Cave exit, which exits out onto the sea stage
# rather than a unique stage.
if (
new_stage_name == "sea"
and dolphin_memory_engine.read_byte(MOST_RECENT_ROOM_NUMBER_ADDR) == CLIFF_PLATEAU_ISLES_ROOM_NUMBER
and read_short(MOST_RECENT_SPAWN_ID_ADDR) == CLIFF_PLATEAU_ISLES_HIGHEST_ISLE_SPAWN_ID
):
new_stage_name = CLIFF_PLATEAU_ISLES_HIGHEST_ISLE_DUMMY_STAGE_NAME
current_stage_name = ctx.current_stage_name
if new_stage_name != current_stage_name:
ctx.current_stage_name = new_stage_name
# Send a Bounced message containing the new stage name to all trackers connected to the current slot.
data_to_send = {"tww_stage_name": new_stage_name}
message = {
"cmd": "Bounce",
"slots": [ctx.slot],
"data": data_to_send,
}
await ctx.send_msgs([message])
# If the stage has never been visited before, update the server's data storage to indicate that it has been
# visited.
visited_stage_names = ctx.visited_stage_names
if visited_stage_names is not None and new_stage_name not in visited_stage_names:
visited_stage_names.add(new_stage_name)
await ctx.update_visited_stages(new_stage_name)
async def check_alive() -> bool:
"""
Check if the player is currently alive in-game.
:return: `True` if the player is alive, otherwise `False`.
"""
cur_health = read_short(CURR_HEALTH_ADDR)
return cur_health > 0
async def check_death(ctx: TWWContext) -> None:
"""
Check if the player is currently dead in-game.
If DeathLink is on, notify the server of the player's death.
:return: `True` if the player is dead, otherwise `False`.
"""
if ctx.slot is not None and check_ingame():
cur_health = read_short(CURR_HEALTH_ADDR)
if cur_health <= 0:
if not ctx.has_send_death and time.time() >= ctx.last_death_link + 3:
ctx.has_send_death = True
await ctx.send_death(ctx.player_names[ctx.slot] + " ran out of hearts.")
else:
ctx.has_send_death = False
def check_ingame() -> bool:
"""
Check if the player is currently in-game.
:return: `True` if the player is in-game, otherwise `False`.
"""
return read_string(CURR_STAGE_NAME_ADDR, 8) not in ["", "sea_T", "Name"]
async def dolphin_sync_task(ctx: TWWContext) -> None:
"""
The task loop for managing the connection to Dolphin.
While connected, read the emulator's memory to look for any relevant changes made by the player in the game.
:param ctx: The Wind Waker client context.
"""
logger.info("Starting Dolphin connector. Use /dolphin for status information.")
sleep_time = 0.0
while not ctx.exit_event.is_set():
if sleep_time > 0.0:
try:
# ctx.watcher_event gets set when receiving ReceivedItems or LocationInfo, or when shutting down.
await asyncio.wait_for(ctx.watcher_event.wait(), sleep_time)
except asyncio.TimeoutError:
pass
sleep_time = 0.0
ctx.watcher_event.clear()
try:
if dolphin_memory_engine.is_hooked() and ctx.dolphin_status == CONNECTION_CONNECTED_STATUS:
if not check_ingame():
# Reset the give item array while not in the game.
dolphin_memory_engine.write_bytes(GIVE_ITEM_ARRAY_ADDR, bytes([0xFF] * ctx.len_give_item_array))
sleep_time = 0.1
continue
if ctx.slot is not None:
if "DeathLink" in ctx.tags:
await check_death(ctx)
await give_items(ctx)
await check_locations(ctx)
await check_current_stage_changed(ctx)
else:
if not ctx.auth:
ctx.auth = read_string(SLOT_NAME_ADDR, 0x40)
if ctx.awaiting_rom:
await ctx.server_auth()
sleep_time = 0.1
else:
if ctx.dolphin_status == CONNECTION_CONNECTED_STATUS:
logger.info("Connection to Dolphin lost, reconnecting...")
ctx.dolphin_status = CONNECTION_LOST_STATUS
logger.info("Attempting to connect to Dolphin...")
dolphin_memory_engine.hook()
if dolphin_memory_engine.is_hooked():
if dolphin_memory_engine.read_bytes(0x80000000, 6) != b"GZLE99":
logger.info(CONNECTION_REFUSED_GAME_STATUS)
ctx.dolphin_status = CONNECTION_REFUSED_GAME_STATUS
dolphin_memory_engine.un_hook()
sleep_time = 5
else:
logger.info(CONNECTION_CONNECTED_STATUS)
ctx.dolphin_status = CONNECTION_CONNECTED_STATUS
ctx.locations_checked = set()
else:
logger.info("Connection to Dolphin failed, attempting again in 5 seconds...")
ctx.dolphin_status = CONNECTION_LOST_STATUS
await ctx.disconnect()
sleep_time = 5
continue
except Exception:
dolphin_memory_engine.un_hook()
logger.info("Connection to Dolphin failed, attempting again in 5 seconds...")
logger.error(traceback.format_exc())
ctx.dolphin_status = CONNECTION_LOST_STATUS
await ctx.disconnect()
sleep_time = 5
continue
def main(connect: Optional[str] = None, password: Optional[str] = None) -> None:
"""
Run the main async loop for the Wind Waker client.
:param connect: Address of the Archipelago server.
:param password: Password for server authentication.
"""
Utils.init_logging("The Wind Waker Client")
async def _main(connect: Optional[str], password: Optional[str]) -> None:
ctx = TWWContext(connect, password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
await asyncio.sleep(1)
ctx.dolphin_sync_task = asyncio.create_task(dolphin_sync_task(ctx), name="DolphinSync")
await ctx.exit_event.wait()
# Wake the sync task, if it is currently sleeping, so it can start shutting down when it sees that the
# exit_event is set.
ctx.watcher_event.set()
ctx.server_address = None
await ctx.shutdown()
if ctx.dolphin_sync_task:
await ctx.dolphin_sync_task
import colorama
colorama.init()
asyncio.run(_main(connect, password))
colorama.deinit()
if __name__ == "__main__":
parser = get_base_parser()
args = parser.parse_args()
main(args.connect, args.password)