diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py index e2e16922fa95..ff932e7c76fa 100644 --- a/LinksAwakeningClient.py +++ b/LinksAwakeningClient.py @@ -28,6 +28,7 @@ from NetUtils import ClientStatus from worlds.ladx.Common import BASE_ID as LABaseID from worlds.ladx.GpsTracker import GpsTracker +from worlds.ladx.TrackerConsts import storage_key from worlds.ladx.ItemTracker import ItemTracker from worlds.ladx.LADXR.checkMetadata import checkMetadataTable from worlds.ladx.Locations import get_locations_to_id, meta_to_name @@ -100,19 +101,23 @@ class LAClientConstants: WRamCheckSize = 0x4 WRamSafetyValue = bytearray([0]*WRamCheckSize) + wRamStart = 0xC000 + hRamStart = 0xFF80 + hRamSize = 0x80 + MinGameplayValue = 0x06 MaxGameplayValue = 0x1A VictoryGameplayAndSub = 0x0102 - class RAGameboy(): cache = [] - cache_start = 0 - cache_size = 0 last_cache_read = None socket = None def __init__(self, address, port) -> None: + self.cache_start = LAClientConstants.wRamStart + self.cache_size = LAClientConstants.hRamStart + LAClientConstants.hRamSize - LAClientConstants.wRamStart + self.address = address self.port = port self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -131,9 +136,14 @@ async def get_retroarch_version(self): async def get_retroarch_status(self): return await self.send_command("GET_STATUS") - def set_cache_limits(self, cache_start, cache_size): - self.cache_start = cache_start - self.cache_size = cache_size + def set_checks_range(self, checks_start, checks_size): + self.checks_start = checks_start + self.checks_size = checks_size + + def set_location_range(self, location_start, location_size, critical_addresses): + self.location_start = location_start + self.location_size = location_size + self.critical_location_addresses = critical_addresses def send(self, b): if type(b) is str: @@ -188,21 +198,57 @@ async def update_cache(self): if not await self.check_safe_gameplay(): return - cache = [] - remaining_size = self.cache_size - while remaining_size: - block = await self.async_read_memory(self.cache_start + len(cache), remaining_size) - remaining_size -= len(block) - cache += block + attempts = 0 + while True: + # RA doesn't let us do an atomic read of a large enough block of RAM + # Some bytes can't change in between reading location_block and hram_block + location_block = await self.read_memory_block(self.location_start, self.location_size) + hram_block = await self.read_memory_block(LAClientConstants.hRamStart, LAClientConstants.hRamSize) + verification_block = await self.read_memory_block(self.location_start, self.location_size) + + valid = True + for address in self.critical_location_addresses: + if location_block[address - self.location_start] != verification_block[address - self.location_start]: + valid = False + + if valid: + break + + attempts += 1 + + # Shouldn't really happen, but keep it from choking + if attempts > 5: + return + + checks_block = await self.read_memory_block(self.checks_start, self.checks_size) if not await self.check_safe_gameplay(): return - self.cache = cache + self.cache = bytearray(self.cache_size) + + start = self.checks_start - self.cache_start + self.cache[start:start + len(checks_block)] = checks_block + + start = self.location_start - self.cache_start + self.cache[start:start + len(location_block)] = location_block + + start = LAClientConstants.hRamStart - self.cache_start + self.cache[start:start + len(hram_block)] = hram_block + self.last_cache_read = time.time() + + async def read_memory_block(self, address: int, size: int): + block = bytearray() + remaining_size = size + while remaining_size: + chunk = await self.async_read_memory(address + len(block), remaining_size) + remaining_size -= len(chunk) + block += chunk + + return block async def read_memory_cache(self, addresses): - # TODO: can we just update once per frame? if not self.last_cache_read or self.last_cache_read + 0.1 < time.time(): await self.update_cache() if not self.cache: @@ -359,11 +405,12 @@ async def reset_auth(self): auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode() self.auth = auth - async def wait_and_init_tracker(self): + async def wait_and_init_tracker(self, magpie: MagpieBridge): await self.wait_for_game_ready() self.tracker = LocationTracker(self.gameboy) self.item_tracker = ItemTracker(self.gameboy) self.gps_tracker = GpsTracker(self.gameboy) + magpie.gps_tracker = self.gps_tracker async def recved_item_from_ap(self, item_id, from_player, next_index): # Don't allow getting an item until you've got your first check @@ -405,9 +452,11 @@ async def is_victory(self): return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1 async def main_tick(self, item_get_cb, win_cb, deathlink_cb): + await self.gameboy.update_cache() await self.tracker.readChecks(item_get_cb) await self.item_tracker.readItems() await self.gps_tracker.read_location() + await self.gps_tracker.read_entrances() current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth] if self.deathlink_debounce and current_health != 0: @@ -465,6 +514,10 @@ class LinksAwakeningContext(CommonContext): magpie_task = None won = False + @property + def slot_storage_key(self): + return f"{self.slot_info[self.slot].name}_{storage_key}" + def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None: self.client = LinksAwakeningClient() self.slot_data = {} @@ -507,7 +560,19 @@ def set_center(_, center): self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") async def send_checks(self): - message = [{"cmd": 'LocationChecks', "locations": self.found_checks}] + message = [{"cmd": "LocationChecks", "locations": self.found_checks}] + await self.send_msgs(message) + + async def send_new_entrances(self, entrances: typing.Dict[str, str]): + # Store the entrances we find on the server for future sessions + message = [{ + "cmd": "Set", + "key": self.slot_storage_key, + "default": {}, + "want_reply": False, + "operations": [{"operation": "update", "value": entrances}], + }] + await self.send_msgs(message) had_invalid_slot_data = None @@ -536,6 +601,12 @@ async def send_victory(self): logger.info("victory!") await self.send_msgs(message) self.won = True + + async def request_found_entrances(self): + await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}]) + + # Ask for updates so that players can co-op entrances in a seed + await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}]) async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: if self.ENABLE_DEATHLINK: @@ -576,6 +647,12 @@ def on_package(self, cmd: str, args: dict): if cmd == "ReceivedItems": for index, item in enumerate(args["items"], start=args["index"]): self.client.recvd_checks[index] = item + + if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]: + self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key]) + + if cmd == "SetReply" and self.magpie_enabled and args["key"] == self.slot_storage_key: + self.client.gps_tracker.receive_found_entrances(args["value"]) async def sync(self): sync_msg = [{'cmd': 'Sync'}] @@ -589,6 +666,12 @@ def on_item_get(ladxr_checks): checkMetadataTable[check.id])] for check in ladxr_checks] self.new_checks(checks, [check.id for check in ladxr_checks]) + for check in ladxr_checks: + if check.value and check.linkedItem: + linkedItem = check.linkedItem + if 'condition' not in linkedItem or linkedItem['condition'](self.slot_data): + self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty']) + async def victory(): await self.send_victory() @@ -622,12 +705,20 @@ async def deathlink(): if not self.client.recvd_checks: await self.sync() - await self.client.wait_and_init_tracker() + await self.client.wait_and_init_tracker(self.magpie) + min_tick_duration = 0.1 + last_tick = time.time() while True: await self.client.main_tick(on_item_get, victory, deathlink) - await asyncio.sleep(0.1) + now = time.time() + tick_duration = now - last_tick + sleep_duration = max(min_tick_duration - tick_duration, 0) + await asyncio.sleep(sleep_duration) + + last_tick = now + if self.last_resend + 5.0 < now: self.last_resend = now await self.send_checks() @@ -635,8 +726,15 @@ async def deathlink(): try: self.magpie.set_checks(self.client.tracker.all_checks) await self.magpie.set_item_tracker(self.client.item_tracker) - await self.magpie.send_gps(self.client.gps_tracker) self.magpie.slot_data = self.slot_data + + if self.client.gps_tracker.needs_found_entrances: + await self.request_found_entrances() + self.client.gps_tracker.needs_found_entrances = False + + new_entrances = await self.magpie.send_gps(self.client.gps_tracker) + if new_entrances: + await self.send_new_entrances(new_entrances) except Exception: # Don't let magpie errors take out the client pass diff --git a/worlds/ladx/GpsTracker.py b/worlds/ladx/GpsTracker.py index 1ea465eb162c..d98acf71b4a1 100644 --- a/worlds/ladx/GpsTracker.py +++ b/worlds/ladx/GpsTracker.py @@ -1,92 +1,266 @@ import json -roomAddress = 0xFFF6 -mapIdAddress = 0xFFF7 -indoorFlagAddress = 0xDBA5 -entranceRoomOffset = 0xD800 -screenCoordAddress = 0xFFFA - -mapMap = { - 0x00: 0x01, - 0x01: 0x01, - 0x02: 0x01, - 0x03: 0x01, - 0x04: 0x01, - 0x05: 0x01, - 0x06: 0x02, - 0x07: 0x02, - 0x08: 0x02, - 0x09: 0x02, - 0x0A: 0x02, - 0x0B: 0x02, - 0x0C: 0x02, - 0x0D: 0x02, - 0x0E: 0x02, - 0x0F: 0x02, - 0x10: 0x02, - 0x11: 0x02, - 0x12: 0x02, - 0x13: 0x02, - 0x14: 0x02, - 0x15: 0x02, - 0x16: 0x02, - 0x17: 0x02, - 0x18: 0x02, - 0x19: 0x02, - 0x1D: 0x01, - 0x1E: 0x01, - 0x1F: 0x01, - 0xFF: 0x03, -} +import typing +from websockets import WebSocketServerProtocol + +from . import TrackerConsts as Consts +from .TrackerConsts import EntranceCoord +from .LADXR.entranceInfo import ENTRANCE_INFO + +class Entrance: + outdoor_room: int + indoor_map: int + indoor_address: int + name: str + other_side_name: str = None + changed: bool = False + known_to_server: bool = False + + def __init__(self, outdoor: int, indoor: int, name: str, indoor_address: int=None): + self.outdoor_room = outdoor + self.indoor_map = indoor + self.indoor_address = indoor_address + self.name = name + + def map(self, other_side: str, known_to_server: bool = False): + if other_side != self.other_side_name: + self.changed = True + self.known_to_server = known_to_server + + self.other_side_name = other_side class GpsTracker: - room = None - location_changed = False - screenX = 0 - screenY = 0 - indoors = None + room: int = None + last_room: int = None + last_different_room: int = None + room_same_for: int = 0 + room_changed: bool = False + screen_x: int = 0 + screen_y: int = 0 + spawn_x: int = 0 + spawn_y: int = 0 + indoors: int = None + indoors_changed: bool = False + spawn_map: int = None + spawn_room: int = None + spawn_changed: bool = False + spawn_same_for: int = 0 + entrance_mapping: typing.Dict[str, str] = None + entrances_by_name: typing.Dict[str, Entrance] = {} + needs_found_entrances: bool = False + needs_slot_data: bool = True def __init__(self, gameboy) -> None: self.gameboy = gameboy - async def read_byte(self, b): - return (await self.gameboy.async_read_memory(b))[0] + self.gameboy.set_location_range( + Consts.link_motion_state, + Consts.transition_sequence - Consts.link_motion_state + 1, + [Consts.transition_state] + ) + + async def read_byte(self, b: int): + return (await self.gameboy.read_memory_cache([b]))[b] + + def load_slot_data(self, slot_data: typing.Dict[str, typing.Any]): + if 'entrance_mapping' not in slot_data: + return + + # We need to know how entrances were mapped at generation before we can autotrack them + self.entrance_mapping = {} + + # Convert to upstream's newer format + for outside, inside in slot_data['entrance_mapping'].items(): + new_inside = f"{inside}:inside" + self.entrance_mapping[outside] = new_inside + self.entrance_mapping[new_inside] = outside + + self.entrances_by_name = {} + + for name, info in ENTRANCE_INFO.items(): + alternate_address = ( + Consts.entrance_address_overrides[info.target] + if info.target in Consts.entrance_address_overrides + else None + ) + + entrance = Entrance(info.room, info.target, name, alternate_address) + self.entrances_by_name[name] = entrance + + inside_entrance = Entrance(info.target, info.room, f"{name}:inside", alternate_address) + self.entrances_by_name[f"{name}:inside"] = inside_entrance + + self.needs_slot_data = False + self.needs_found_entrances = True async def read_location(self): - indoors = await self.read_byte(indoorFlagAddress) + # We need to wait for screen transitions to finish + transition_state = await self.read_byte(Consts.transition_state) + transition_target_x = await self.read_byte(Consts.transition_target_x) + transition_target_y = await self.read_byte(Consts.transition_target_y) + transition_scroll_x = await self.read_byte(Consts.transition_scroll_x) + transition_scroll_y = await self.read_byte(Consts.transition_scroll_y) + transition_sequence = await self.read_byte(Consts.transition_sequence) + motion_state = await self.read_byte(Consts.link_motion_state) + if (transition_state != 0 + or transition_target_x != transition_scroll_x + or transition_target_y != transition_scroll_y + or transition_sequence != 0x04): + return + + indoors = await self.read_byte(Consts.indoor_flag) if indoors != self.indoors and self.indoors != None: - self.indoorsChanged = True - + self.indoors_changed = True + self.indoors = indoors - mapId = await self.read_byte(mapIdAddress) - if mapId not in mapMap: - print(f'Unknown map ID {hex(mapId)}') + # We use the spawn point to know which entrance was most recently entered + spawn_map = await self.read_byte(Consts.spawn_map) + map_digit = Consts.map_map[spawn_map] << 8 if self.spawn_map else 0 + spawn_room = await self.read_byte(Consts.spawn_room) + map_digit + spawn_x = await self.read_byte(Consts.spawn_x) + spawn_y = await self.read_byte(Consts.spawn_y) + + # The spawn point needs to be settled before we can trust location data + if ((spawn_room != self.spawn_room and self.spawn_room != None) + or (spawn_map != self.spawn_map and self.spawn_map != None) + or (spawn_x != self.spawn_x and self.spawn_x != None) + or (spawn_y != self.spawn_y and self.spawn_y != None)): + self.spawn_changed = True + self.spawn_same_for = 0 + else: + self.spawn_same_for += 1 + + self.spawn_map = spawn_map + self.spawn_room = spawn_room + self.spawn_x = spawn_x + self.spawn_y = spawn_y + + # Spawn point is preferred, but doesn't work for the sidescroller entrances + # Those can be addressed by keeping track of which room we're in + # Also used to validate that we came from the right room for what the spawn point is mapped to + map_id = await self.read_byte(Consts.map_id) + if map_id not in Consts.map_map: + print(f'Unknown map ID {hex(map_id)}') + return + + map_digit = Consts.map_map[map_id] << 8 if indoors else 0 + self.last_room = self.room + self.room = await self.read_byte(Consts.room) + map_digit + + # Again, the room needs to settle before we can trust location data + if self.last_room != self.room: + self.room_same_for = 0 + self.room_changed = True + self.last_different_room = self.last_room + else: + self.room_same_for += 1 + + # Only update Link's location when he's not in the air to avoid weirdness + if motion_state in [0, 1]: + coords = await self.read_byte(Consts.screen_coord) + self.screen_x = coords & 0x0F + self.screen_y = (coords & 0xF0) >> 4 + + async def read_entrances(self): + if not self.last_different_room or not self.entrance_mapping: return - mapDigit = mapMap[mapId] << 8 if indoors else 0 - last_room = self.room - self.room = await self.read_byte(roomAddress) + mapDigit + if self.spawn_changed and self.spawn_same_for > 0 and self.room_same_for > 0: + # Use the spawn location, last room, and entrance mapping at generation to map the right entrance + # A bit overkill for simple ER, but necessary for upstream's advanced ER + spawn_coord = EntranceCoord(None, self.spawn_room, self.spawn_x, self.spawn_y) + if str(spawn_coord) in Consts.entrance_lookup: + valid_sources = {x.name for x in Consts.entrance_coords if x.room == self.last_different_room} + dest_entrance = Consts.entrance_lookup[str(spawn_coord)].name + source_entrance = [ + x for x in self.entrance_mapping + if self.entrance_mapping[x] == dest_entrance and x in valid_sources + ] + + if source_entrance: + self.entrances_by_name[source_entrance[0]].map(dest_entrance) + + self.spawn_changed = False + elif self.room_changed and self.room_same_for > 0: + # Check for the stupid sidescroller rooms that don't set your spawn point + if self.last_different_room in Consts.sidescroller_rooms: + source_entrance = Consts.sidescroller_rooms[self.last_different_room] + if source_entrance in self.entrance_mapping: + dest_entrance = self.entrance_mapping[source_entrance] + + expected_room = self.entrances_by_name[dest_entrance].outdoor_room + if dest_entrance.endswith(":indoor"): + expected_room = self.entrances_by_name[dest_entrance].indoor_map + + if expected_room == self.room: + self.entrances_by_name[source_entrance].map(dest_entrance) + + if self.room in Consts.sidescroller_rooms: + valid_sources = {x.name for x in Consts.entrance_coords if x.room == self.last_different_room} + dest_entrance = Consts.sidescroller_rooms[self.room] + source_entrance = [ + x for x in self.entrance_mapping + if self.entrance_mapping[x] == dest_entrance and x in valid_sources + ] - coords = await self.read_byte(screenCoordAddress) - self.screenX = coords & 0x0F - self.screenY = (coords & 0xF0) >> 4 + if source_entrance: + self.entrances_by_name[source_entrance[0]].map(dest_entrance) - if (self.room != last_room): - self.location_changed = True - - last_message = {} - async def send_location(self, socket, diff=False): - if self.room is None: + self.room_changed = False + + last_location_message = {} + async def send_location(self, socket: WebSocketServerProtocol) -> None: + if self.room is None or self.room_same_for < 1: return + message = { "type":"location", "refresh": True, - "version":"1.0", "room": f'0x{self.room:02X}', - "x": self.screenX, - "y": self.screenY, + "x": self.screen_x, + "y": self.screen_y, + "drawFine": True, } - if message != self.last_message: - self.last_message = message + + if message != self.last_location_message: + self.last_location_message = message await socket.send(json.dumps(message)) + + async def send_entrances(self, socket: WebSocketServerProtocol, diff: bool=True) -> typing.Dict[str, str]: + if not self.entrance_mapping: + return + + new_entrances = [x for x in self.entrances_by_name.values() if x.changed or (not diff and x.other_side_name)] + + if not new_entrances: + return + + message = { + "type":"entrance", + "refresh": True, + "diff": True, + "entranceMap": {}, + } + + for entrance in new_entrances: + message['entranceMap'][entrance.name] = entrance.other_side_name + entrance.changed = False + + await socket.send(json.dumps(message)) + + new_to_server = { + entrance.name: entrance.other_side_name + for entrance in new_entrances + if not entrance.known_to_server + } + + return new_to_server + + def receive_found_entrances(self, found_entrances: typing.Dict[str, str]): + if not found_entrances: + return + + for entrance, destination in found_entrances.items(): + if entrance in self.entrances_by_name: + self.entrances_by_name[entrance].map(destination, known_to_server=True) diff --git a/worlds/ladx/ItemTracker.py b/worlds/ladx/ItemTracker.py index 92ef71633e0f..b288bba84339 100644 --- a/worlds/ladx/ItemTracker.py +++ b/worlds/ladx/ItemTracker.py @@ -1,12 +1,16 @@ import json -gameStateAddress = 0xDB95 -validGameStates = {0x0B, 0x0C} -gameStateResetThreshold = 0x06 inventorySlotCount = 16 inventoryStartAddress = 0xDB00 inventoryEndAddress = inventoryStartAddress + inventorySlotCount +rupeesHigh = 0xDB5D +rupeesLow = 0xDB5E +addRupeesHigh = 0xDB8F +addRupeesLow = 0xDB90 +removeRupeesHigh = 0xDB91 +removeRupeesLow = 0xDB92 + inventoryItemIds = { 0x02: 'BOMB', 0x05: 'BOW', @@ -98,10 +102,11 @@ 'STONE_BEAK{}': 2, 'NIGHTMARE_KEY{}': 3, 'KEY{}': 4, + 'UNUSED_KEY{}': 4, } class Item: - def __init__(self, id, address, threshold=0, mask=None, increaseOnly=False, count=False, max=None): + def __init__(self, id, address, threshold=0, mask=None, increaseOnly=False, count=False, max=None, encodedCount=True): self.id = id self.address = address self.threshold = threshold @@ -112,6 +117,7 @@ def __init__(self, id, address, threshold=0, mask=None, increaseOnly=False, coun self.rawValue = 0 self.diff = 0 self.max = max + self.encodedCount = encodedCount def set(self, byte, extra): oldValue = self.value @@ -121,7 +127,7 @@ def set(self, byte, extra): if not self.count: byte = int(byte > self.threshold) - else: + elif self.encodedCount: # LADX seems to store one decimal digit per nibble byte = byte - (byte // 16 * 6) @@ -165,6 +171,7 @@ def loadItems(self): Item('BOOMERANG', None), Item('TOADSTOOL', None), Item('ROOSTER', None), + Item('RUPEE_COUNT', None, count=True, encodedCount=False), Item('SWORD', 0xDB4E, count=True), Item('POWER_BRACELET', 0xDB43, count=True), Item('SHIELD', 0xDB44, count=True), @@ -219,9 +226,9 @@ def loadItems(self): self.itemDict = {item.id: item for item in self.items} - async def readItems(state): - extraItems = state.extraItems - missingItems = {x for x in state.items if x.address == None} + async def readItems(self): + extraItems = self.extraItems + missingItems = {x for x in self.items if x.address == None and x.id != 'RUPEE_COUNT'} # Add keys for opened key doors for i in range(len(dungeonKeyDoors)): @@ -230,16 +237,16 @@ async def readItems(state): for address, masks in dungeonKeyDoors[i].items(): for mask in masks: - value = await state.readRamByte(address) & mask + value = await self.readRamByte(address) & mask if value > 0: extraItems[item] += 1 # Main inventory items for i in range(inventoryStartAddress, inventoryEndAddress): - value = await state.readRamByte(i) + value = await self.readRamByte(i) if value in inventoryItemIds: - item = state.itemDict[inventoryItemIds[value]] + item = self.itemDict[inventoryItemIds[value]] extra = extraItems[item.id] if item.id in extraItems else 0 item.set(1, extra) missingItems.remove(item) @@ -249,9 +256,21 @@ async def readItems(state): item.set(0, extra) # All other items - for item in [x for x in state.items if x.address]: + for item in [x for x in self.items if x.address]: extra = extraItems[item.id] if item.id in extraItems else 0 - item.set(await state.readRamByte(item.address), extra) + item.set(await self.readRamByte(item.address), extra) + + # The current rupee count is BCD, but the add/remove values are not + currentRupees = self.calculateRupeeCount(await self.readRamByte(rupeesHigh), await self.readRamByte(rupeesLow)) + addingRupees = (await self.readRamByte(addRupeesHigh) << 8) + await self.readRamByte(addRupeesLow) + removingRupees = (await self.readRamByte(removeRupeesHigh) << 8) + await self.readRamByte(removeRupeesLow) + self.itemDict['RUPEE_COUNT'].set(currentRupees + addingRupees - removingRupees, 0) + + def calculateRupeeCount(self, high: int, low: int) -> int: + return (high - (high // 16 * 6)) * 100 + (low - (low // 16 * 6)) + + def setExtraItem(self, item: str, qty: int) -> None: + self.extraItems[item] = qty async def sendItems(self, socket, diff=False): if not self.items: @@ -259,7 +278,6 @@ async def sendItems(self, socket, diff=False): message = { "type":"item", "refresh": True, - "version":"1.0", "diff": diff, "items": [], } diff --git a/worlds/ladx/Tracker.py b/worlds/ladx/Tracker.py index 5f48b64c4f5e..1842ceaec820 100644 --- a/worlds/ladx/Tracker.py +++ b/worlds/ladx/Tracker.py @@ -1,3 +1,6 @@ +import typing + +from worlds.ladx.GpsTracker import GpsTracker from .LADXR.checkMetadata import checkMetadataTable import json import logging @@ -10,13 +13,14 @@ # kbranch you're a hero # https://github.com/kbranch/Magpie/blob/master/autotracking/checks.py class Check: - def __init__(self, id, address, mask, alternateAddress=None): + def __init__(self, id, address, mask, alternateAddress=None, linkedItem=None): self.id = id self.address = address self.alternateAddress = alternateAddress self.mask = mask self.value = None self.diff = 0 + self.linkedItem = linkedItem def set(self, bytes): oldValue = self.value @@ -86,6 +90,27 @@ def __init__(self, gameboy): blacklist = {'None', '0x2A1-2'} + def seashellCondition(slot_data): + return 'goal' not in slot_data or slot_data['goal'] != 'seashells' + + linkedCheckItems = { + '0x2E9': {'item': 'SEASHELL', 'qty': 20, 'condition': seashellCondition}, + '0x2A2': {'item': 'TOADSTOOL', 'qty': 1}, + '0x2A6-Trade': {'item': 'TRADING_ITEM_YOSHI_DOLL', 'qty': 1}, + '0x2B2-Trade': {'item': 'TRADING_ITEM_RIBBON', 'qty': 1}, + '0x2FE-Trade': {'item': 'TRADING_ITEM_DOG_FOOD', 'qty': 1}, + '0x07B-Trade': {'item': 'TRADING_ITEM_BANANAS', 'qty': 1}, + '0x087-Trade': {'item': 'TRADING_ITEM_STICK', 'qty': 1}, + '0x2D7-Trade': {'item': 'TRADING_ITEM_HONEYCOMB', 'qty': 1}, + '0x019-Trade': {'item': 'TRADING_ITEM_PINEAPPLE', 'qty': 1}, + '0x2D9-Trade': {'item': 'TRADING_ITEM_HIBISCUS', 'qty': 1}, + '0x2A8-Trade': {'item': 'TRADING_ITEM_LETTER', 'qty': 1}, + '0x0CD-Trade': {'item': 'TRADING_ITEM_BROOM', 'qty': 1}, + '0x2F5-Trade': {'item': 'TRADING_ITEM_FISHING_HOOK', 'qty': 1}, + '0x0C9-Trade': {'item': 'TRADING_ITEM_NECKLACE', 'qty': 1}, + '0x297-Trade': {'item': 'TRADING_ITEM_SCALE', 'qty': 1}, + } + # in no dungeons boss shuffle, the d3 boss in d7 set 0x20 in fascade's room (0x1BC) # after beating evil eagile in D6, 0x1BC is now 0xAC (other things may have happened in between) # entered d3, slime eye flag had already been set (0x15A 0x20). after killing angler fish, bits 0x0C were set @@ -98,6 +123,8 @@ def __init__(self, gameboy): address = addressOverrides[check_id] if check_id in addressOverrides else 0xD800 + int( room, 16) + linkedItem = linkedCheckItems[check_id] if check_id in linkedCheckItems else None + if 'Trade' in check_id or 'Owl' in check_id: mask = 0x20 @@ -111,13 +138,19 @@ def __init__(self, gameboy): highest_check = max( highest_check, alternateAddresses[check_id]) - check = Check(check_id, address, mask, - alternateAddresses[check_id] if check_id in alternateAddresses else None) + check = Check( + check_id, + address, + mask, + (alternateAddresses[check_id] if check_id in alternateAddresses else None), + linkedItem, + ) + if check_id == '0x2A3': self.start_check = check self.all_checks.append(check) self.remaining_checks = [check for check in self.all_checks] - self.gameboy.set_cache_limits( + self.gameboy.set_checks_range( lowest_check, highest_check - lowest_check + 1) def has_start_item(self): @@ -147,10 +180,17 @@ class MagpieBridge: server = None checks = None item_tracker = None + gps_tracker: GpsTracker = None ws = None features = [] slot_data = {} + def use_entrance_tracker(self): + return "entrances" in self.features \ + and self.slot_data \ + and "entrance_mapping" in self.slot_data \ + and any([k != v for k, v in self.slot_data["entrance_mapping"].items()]) + async def handler(self, websocket): self.ws = websocket while True: @@ -159,14 +199,18 @@ async def handler(self, websocket): logger.info( f"Connected, supported features: {message['features']}") self.features = message["features"] + + await self.send_handshAck() - if message["type"] in ("handshake", "sendFull"): + if message["type"] == "sendFull": if "items" in self.features: await self.send_all_inventory() if "checks" in self.features: await self.send_all_checks() - if "slot_data" in self.features: + if "slot_data" in self.features and self.slot_data: await self.send_slot_data(self.slot_data) + if self.use_entrance_tracker(): + await self.send_gps(diff=False) # Translate renamed IDs back to LADXR IDs @staticmethod @@ -176,6 +220,18 @@ def fixup_id(the_id): if the_id == "0x2A7": return "0x2A1-1" return the_id + + async def send_handshAck(self): + if not self.ws: + return + + message = { + "type": "handshAck", + "version": "1.32", + "name": "archipelago-ladx-client", + } + + await self.ws.send(json.dumps(message)) async def send_all_checks(self): while self.checks == None: @@ -185,7 +241,6 @@ async def send_all_checks(self): message = { "type": "check", "refresh": True, - "version": "1.0", "diff": False, "checks": [{"id": self.fixup_id(check.id), "checked": check.value} for check in self.checks] } @@ -200,7 +255,6 @@ async def send_new_checks(self, checks): message = { "type": "check", "refresh": True, - "version": "1.0", "diff": True, "checks": [{"id": self.fixup_id(check), "checked": True} for check in checks] } @@ -222,10 +276,17 @@ async def send_inventory_diffs(self): return await self.item_tracker.sendItems(self.ws, diff=True) - async def send_gps(self, gps): + async def send_gps(self, diff: bool=True) -> typing.Dict[str, str]: if not self.ws: return - await gps.send_location(self.ws) + + await self.gps_tracker.send_location(self.ws) + + if self.use_entrance_tracker(): + if self.slot_data and self.gps_tracker.needs_slot_data: + self.gps_tracker.load_slot_data(self.slot_data) + + return await self.gps_tracker.send_entrances(self.ws, diff) async def send_slot_data(self, slot_data): if not self.ws: diff --git a/worlds/ladx/TrackerConsts.py b/worlds/ladx/TrackerConsts.py new file mode 100644 index 000000000000..99452608ecbb --- /dev/null +++ b/worlds/ladx/TrackerConsts.py @@ -0,0 +1,291 @@ +class EntranceCoord: + name: str + room: int + x: int + y: int + + def __init__(self, name: str, room: int, x: int, y: int): + self.name = name + self.room = room + self.x = x + self.y = y + + def __repr__(self): + return EntranceCoord.coordString(self.room, self.x, self.y) + + def coordString(room: int, x: int, y: int): + return f"{room:#05x}, {x}, {y}" + +storage_key = "found_entrances" + +room = 0xFFF6 +map_id = 0xFFF7 +indoor_flag = 0xDBA5 +spawn_map = 0xDB60 +spawn_room = 0xDB61 +spawn_x = 0xDB62 +spawn_y = 0xDB63 +entrance_room_offset = 0xD800 +transition_state = 0xC124 +transition_target_x = 0xC12C +transition_target_y = 0xC12D +transition_scroll_x = 0xFF96 +transition_scroll_y = 0xFF97 +link_motion_state = 0xC11C +transition_sequence = 0xC16B +screen_coord = 0xFFFA + +entrance_address_overrides = { + 0x312: 0xDDF2, +} + +map_map = { + 0x00: 0x01, + 0x01: 0x01, + 0x02: 0x01, + 0x03: 0x01, + 0x04: 0x01, + 0x05: 0x01, + 0x06: 0x02, + 0x07: 0x02, + 0x08: 0x02, + 0x09: 0x02, + 0x0A: 0x02, + 0x0B: 0x02, + 0x0C: 0x02, + 0x0D: 0x02, + 0x0E: 0x02, + 0x0F: 0x02, + 0x10: 0x02, + 0x11: 0x02, + 0x12: 0x02, + 0x13: 0x02, + 0x14: 0x02, + 0x15: 0x02, + 0x16: 0x02, + 0x17: 0x02, + 0x18: 0x02, + 0x19: 0x02, + 0x1D: 0x01, + 0x1E: 0x01, + 0x1F: 0x01, + 0xFF: 0x03, +} + +sidescroller_rooms = { + 0x2e9: "seashell_mansion:inside", + 0x08a: "seashell_mansion", + 0x2fd: "mambo:inside", + 0x02a: "mambo", + 0x1eb: "castle_secret_exit:inside", + 0x049: "castle_secret_exit", + 0x1ec: "castle_secret_entrance:inside", + 0x04a: "castle_secret_entrance", + 0x117: "d1:inside", # not a sidescroller, but acts weird +} + +entrance_coords = [ + EntranceCoord("writes_house:inside", 0x2a8, 80, 124), + EntranceCoord("rooster_grave", 0x92, 88, 82), + EntranceCoord("start_house:inside", 0x2a3, 80, 124), + EntranceCoord("dream_hut", 0x83, 40, 66), + EntranceCoord("papahl_house_right:inside", 0x2a6, 80, 124), + EntranceCoord("papahl_house_right", 0x82, 120, 82), + EntranceCoord("papahl_house_left:inside", 0x2a5, 80, 124), + EntranceCoord("papahl_house_left", 0x82, 88, 82), + EntranceCoord("d2:inside", 0x136, 80, 124), + EntranceCoord("shop", 0x93, 72, 98), + EntranceCoord("armos_maze_cave:inside", 0x2fc, 104, 96), + EntranceCoord("start_house", 0xa2, 88, 82), + EntranceCoord("animal_house3:inside", 0x2d9, 80, 124), + EntranceCoord("trendy_shop", 0xb3, 88, 82), + EntranceCoord("mabe_phone:inside", 0x2cb, 80, 124), + EntranceCoord("mabe_phone", 0xb2, 88, 82), + EntranceCoord("ulrira:inside", 0x2a9, 80, 124), + EntranceCoord("ulrira", 0xb1, 72, 98), + EntranceCoord("moblin_cave:inside", 0x2f0, 80, 124), + EntranceCoord("kennel", 0xa1, 88, 66), + EntranceCoord("madambowwow:inside", 0x2a7, 80, 124), + EntranceCoord("madambowwow", 0xa1, 56, 66), + EntranceCoord("library:inside", 0x1fa, 80, 124), + EntranceCoord("library", 0xb0, 56, 50), + EntranceCoord("d5:inside", 0x1a1, 80, 124), + EntranceCoord("d1", 0xd3, 104, 34), + EntranceCoord("d1:inside", 0x117, 80, 124), + EntranceCoord("d3:inside", 0x152, 80, 124), + EntranceCoord("d3", 0xb5, 104, 32), + EntranceCoord("banana_seller", 0xe3, 72, 48), + EntranceCoord("armos_temple:inside", 0x28f, 80, 124), + EntranceCoord("boomerang_cave", 0xf4, 24, 32), + EntranceCoord("forest_madbatter:inside", 0x1e1, 136, 80), + EntranceCoord("ghost_house", 0xf6, 88, 66), + EntranceCoord("prairie_low_phone:inside", 0x29d, 80, 124), + EntranceCoord("prairie_low_phone", 0xe8, 56, 98), + EntranceCoord("prairie_madbatter_connector_entrance:inside", 0x1f6, 136, 112), + EntranceCoord("prairie_madbatter_connector_entrance", 0xf9, 120, 80), + EntranceCoord("prairie_madbatter_connector_exit", 0xe7, 104, 32), + EntranceCoord("prairie_madbatter_connector_exit:inside", 0x1e5, 40, 48), + EntranceCoord("ghost_house:inside", 0x1e3, 80, 124), + EntranceCoord("prairie_madbatter", 0xe6, 72, 64), + EntranceCoord("d4:inside", 0x17a, 80, 124), + EntranceCoord("d5", 0xd9, 88, 64), + EntranceCoord("prairie_right_cave_bottom:inside", 0x293, 48, 124), + EntranceCoord("prairie_right_cave_bottom", 0xc8, 40, 80), + EntranceCoord("prairie_right_cave_high", 0xb8, 88, 48), + EntranceCoord("prairie_right_cave_high:inside", 0x295, 112, 124), + EntranceCoord("prairie_right_cave_top", 0xb8, 120, 96), + EntranceCoord("prairie_right_cave_top:inside", 0x292, 48, 124), + EntranceCoord("prairie_to_animal_connector:inside", 0x2d0, 40, 64), + EntranceCoord("prairie_to_animal_connector", 0xaa, 136, 64), + EntranceCoord("animal_to_prairie_connector", 0xab, 120, 80), + EntranceCoord("animal_to_prairie_connector:inside", 0x2d1, 120, 64), + EntranceCoord("animal_phone:inside", 0x2e3, 80, 124), + EntranceCoord("animal_phone", 0xdb, 120, 82), + EntranceCoord("animal_house1:inside", 0x2db, 80, 124), + EntranceCoord("animal_house1", 0xcc, 40, 80), + EntranceCoord("animal_house2:inside", 0x2dd, 80, 124), + EntranceCoord("animal_house2", 0xcc, 120, 80), + EntranceCoord("hookshot_cave:inside", 0x2b3, 80, 124), + EntranceCoord("animal_house3", 0xcd, 40, 80), + EntranceCoord("animal_house4:inside", 0x2da, 80, 124), + EntranceCoord("animal_house4", 0xcd, 88, 80), + EntranceCoord("banana_seller:inside", 0x2fe, 80, 124), + EntranceCoord("animal_house5", 0xdd, 88, 66), + EntranceCoord("animal_cave:inside", 0x2f7, 96, 124), + EntranceCoord("animal_cave", 0xcd, 136, 32), + EntranceCoord("d6", 0x8c, 56, 64), + EntranceCoord("madbatter_taltal:inside", 0x1e2, 136, 80), + EntranceCoord("desert_cave", 0xcf, 88, 16), + EntranceCoord("dream_hut:inside", 0x2aa, 80, 124), + EntranceCoord("armos_maze_cave", 0xae, 72, 112), + EntranceCoord("shop:inside", 0x2a1, 80, 124), + EntranceCoord("armos_temple", 0xac, 88, 64), + EntranceCoord("d6_connector_exit:inside", 0x1f0, 56, 16), + EntranceCoord("d6_connector_exit", 0x9c, 88, 16), + EntranceCoord("desert_cave:inside", 0x1f9, 120, 96), + EntranceCoord("d6_connector_entrance:inside", 0x1f1, 136, 96), + EntranceCoord("d6_connector_entrance", 0x9d, 56, 48), + EntranceCoord("armos_fairy:inside", 0x1ac, 80, 124), + EntranceCoord("armos_fairy", 0x8d, 56, 32), + EntranceCoord("raft_return_enter:inside", 0x1f7, 136, 96), + EntranceCoord("raft_return_enter", 0x8f, 8, 32), + EntranceCoord("raft_return_exit", 0x2f, 24, 112), + EntranceCoord("raft_return_exit:inside", 0x1e7, 72, 16), + EntranceCoord("raft_house:inside", 0x2b0, 80, 124), + EntranceCoord("raft_house", 0x3f, 40, 34), + EntranceCoord("heartpiece_swim_cave:inside", 0x1f2, 72, 124), + EntranceCoord("heartpiece_swim_cave", 0x2e, 88, 32), + EntranceCoord("rooster_grave:inside", 0x1f4, 88, 112), + EntranceCoord("d4", 0x2b, 72, 34), + EntranceCoord("castle_phone:inside", 0x2cc, 80, 124), + EntranceCoord("castle_phone", 0x4b, 72, 34), + EntranceCoord("castle_main_entrance:inside", 0x2d3, 80, 124), + EntranceCoord("castle_main_entrance", 0x69, 88, 64), + EntranceCoord("castle_upper_left", 0x59, 24, 48), + EntranceCoord("castle_upper_left:inside", 0x2d5, 80, 124), + EntranceCoord("witch:inside", 0x2a2, 80, 124), + EntranceCoord("castle_upper_right", 0x59, 88, 64), + EntranceCoord("prairie_left_cave2:inside", 0x2f4, 64, 124), + EntranceCoord("castle_jump_cave", 0x78, 40, 112), + EntranceCoord("prairie_left_cave1:inside", 0x2cd, 80, 124), + EntranceCoord("seashell_mansion", 0x8a, 88, 64), + EntranceCoord("prairie_right_phone:inside", 0x29c, 80, 124), + EntranceCoord("prairie_right_phone", 0x88, 88, 82), + EntranceCoord("prairie_left_fairy:inside", 0x1f3, 80, 124), + EntranceCoord("prairie_left_fairy", 0x87, 40, 16), + EntranceCoord("bird_cave:inside", 0x27e, 96, 124), + EntranceCoord("prairie_left_cave2", 0x86, 24, 64), + EntranceCoord("prairie_left_cave1", 0x84, 152, 98), + EntranceCoord("prairie_left_phone:inside", 0x2b4, 80, 124), + EntranceCoord("prairie_left_phone", 0xa4, 56, 66), + EntranceCoord("mamu:inside", 0x2fb, 136, 112), + EntranceCoord("mamu", 0xd4, 136, 48), + EntranceCoord("richard_house:inside", 0x2c7, 80, 124), + EntranceCoord("richard_house", 0xd6, 72, 80), + EntranceCoord("richard_maze:inside", 0x2c9, 128, 124), + EntranceCoord("richard_maze", 0xc6, 56, 80), + EntranceCoord("graveyard_cave_left:inside", 0x2de, 56, 64), + EntranceCoord("graveyard_cave_left", 0x75, 56, 64), + EntranceCoord("graveyard_cave_right:inside", 0x2df, 56, 48), + EntranceCoord("graveyard_cave_right", 0x76, 104, 80), + EntranceCoord("trendy_shop:inside", 0x2a0, 80, 124), + EntranceCoord("d0", 0x77, 120, 46), + EntranceCoord("boomerang_cave:inside", 0x1f5, 72, 124), + EntranceCoord("witch", 0x65, 72, 50), + EntranceCoord("toadstool_entrance:inside", 0x2bd, 80, 124), + EntranceCoord("toadstool_entrance", 0x62, 120, 66), + EntranceCoord("toadstool_exit", 0x50, 136, 50), + EntranceCoord("toadstool_exit:inside", 0x2ab, 80, 124), + EntranceCoord("prairie_madbatter:inside", 0x1e0, 136, 112), + EntranceCoord("hookshot_cave", 0x42, 56, 66), + EntranceCoord("castle_upper_right:inside", 0x2d6, 80, 124), + EntranceCoord("forest_madbatter", 0x52, 104, 48), + EntranceCoord("writes_phone:inside", 0x29b, 80, 124), + EntranceCoord("writes_phone", 0x31, 104, 82), + EntranceCoord("d0:inside", 0x312, 80, 92), + EntranceCoord("writes_house", 0x30, 120, 50), + EntranceCoord("writes_cave_left:inside", 0x2ae, 80, 124), + EntranceCoord("writes_cave_left", 0x20, 136, 50), + EntranceCoord("writes_cave_right:inside", 0x2af, 80, 124), + EntranceCoord("writes_cave_right", 0x21, 24, 50), + EntranceCoord("d6:inside", 0x1d4, 80, 124), + EntranceCoord("d2", 0x24, 56, 34), + EntranceCoord("animal_house5:inside", 0x2d7, 80, 124), + EntranceCoord("moblin_cave", 0x35, 104, 80), + EntranceCoord("crazy_tracy:inside", 0x2ad, 80, 124), + EntranceCoord("crazy_tracy", 0x45, 136, 66), + EntranceCoord("photo_house:inside", 0x2b5, 80, 124), + EntranceCoord("photo_house", 0x37, 72, 66), + EntranceCoord("obstacle_cave_entrance:inside", 0x2b6, 80, 124), + EntranceCoord("obstacle_cave_entrance", 0x17, 56, 50), + EntranceCoord("left_to_right_taltalentrance:inside", 0x2ee, 120, 48), + EntranceCoord("left_to_right_taltalentrance", 0x7, 56, 80), + EntranceCoord("obstacle_cave_outside_chest:inside", 0x2bb, 80, 124), + EntranceCoord("obstacle_cave_outside_chest", 0x18, 104, 18), + EntranceCoord("obstacle_cave_exit:inside", 0x2bc, 48, 124), + EntranceCoord("obstacle_cave_exit", 0x18, 136, 18), + EntranceCoord("papahl_entrance:inside", 0x289, 64, 124), + EntranceCoord("papahl_entrance", 0x19, 136, 64), + EntranceCoord("papahl_exit:inside", 0x28b, 80, 124), + EntranceCoord("papahl_exit", 0xa, 24, 112), + EntranceCoord("rooster_house:inside", 0x29f, 80, 124), + EntranceCoord("rooster_house", 0xa, 72, 34), + EntranceCoord("d7:inside", 0x20e, 80, 124), + EntranceCoord("bird_cave", 0xa, 120, 112), + EntranceCoord("multichest_top:inside", 0x2f2, 80, 124), + EntranceCoord("multichest_top", 0xd, 24, 112), + EntranceCoord("multichest_left:inside", 0x2f9, 32, 124), + EntranceCoord("multichest_left", 0x1d, 24, 48), + EntranceCoord("multichest_right:inside", 0x2fa, 112, 124), + EntranceCoord("multichest_right", 0x1d, 120, 80), + EntranceCoord("right_taltal_connector1:inside", 0x280, 32, 124), + EntranceCoord("right_taltal_connector1", 0x1e, 56, 16), + EntranceCoord("right_taltal_connector3:inside", 0x283, 128, 124), + EntranceCoord("right_taltal_connector3", 0x1e, 120, 16), + EntranceCoord("right_taltal_connector2:inside", 0x282, 112, 124), + EntranceCoord("right_taltal_connector2", 0x1f, 40, 16), + EntranceCoord("right_fairy:inside", 0x1fb, 80, 124), + EntranceCoord("right_fairy", 0x1f, 56, 80), + EntranceCoord("right_taltal_connector4:inside", 0x287, 96, 124), + EntranceCoord("right_taltal_connector4", 0x1f, 88, 64), + EntranceCoord("right_taltal_connector5:inside", 0x28c, 96, 124), + EntranceCoord("right_taltal_connector5", 0x1f, 120, 16), + EntranceCoord("right_taltal_connector6:inside", 0x28e, 112, 124), + EntranceCoord("right_taltal_connector6", 0xf, 72, 80), + EntranceCoord("d7", 0x0e, 88, 48), + EntranceCoord("left_taltal_entrance:inside", 0x2ea, 80, 124), + EntranceCoord("left_taltal_entrance", 0x15, 136, 64), + EntranceCoord("castle_jump_cave:inside", 0x1fd, 88, 80), + EntranceCoord("madbatter_taltal", 0x4, 120, 112), + EntranceCoord("fire_cave_exit:inside", 0x1ee, 24, 64), + EntranceCoord("fire_cave_exit", 0x3, 72, 80), + EntranceCoord("fire_cave_entrance:inside", 0x1fe, 112, 124), + EntranceCoord("fire_cave_entrance", 0x13, 88, 16), + EntranceCoord("phone_d8:inside", 0x299, 80, 124), + EntranceCoord("phone_d8", 0x11, 104, 50), + EntranceCoord("kennel:inside", 0x2b2, 80, 124), + EntranceCoord("d8", 0x10, 88, 16), + EntranceCoord("d8:inside", 0x25d, 80, 124), +] + +entrance_lookup = {str(coord): coord for coord in entrance_coords}