mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 12:11:33 -06:00

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.
740 lines
31 KiB
Python
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)
|