 cf0ae5e31b
			
		
	
	cf0ae5e31b
	
	
	
		
			
			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)
 |