diff --git a/CommonClient.py b/CommonClient.py index 7960be0e..c7133735 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -91,12 +91,18 @@ class ClientCommandProcessor(CommandProcessor): def _cmd_items(self): """List all item names for the currently running game.""" + if not self.ctx.game: + self.output("No game set, cannot determine existing items.") + return False self.output(f"Item Names for {self.ctx.game}") for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id: self.output(item_name) def _cmd_locations(self): """List all location names for the currently running game.""" + if not self.ctx.game: + self.output("No game set, cannot determine existing locations.") + return False self.output(f"Location Names for {self.ctx.game}") for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id: self.output(location_name) diff --git a/LttPAdjuster.py b/LttPAdjuster.py index 9fab226c..a2cc2eeb 100644 --- a/LttPAdjuster.py +++ b/LttPAdjuster.py @@ -26,7 +26,9 @@ ModuleUpdate.update() from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \ get_adjuster_settings, tkinter_center_window, init_logging -from Patch import GAME_ALTTP + + +GAME_ALTTP = "A Link to the Past" class AdjusterWorld(object): diff --git a/MultiServer.py b/MultiServer.py index 9f0865d4..bab762c8 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -998,7 +998,11 @@ class CommandMeta(type): return super(CommandMeta, cls).__new__(cls, name, bases, attrs) -def mark_raw(function): +_Return = typing.TypeVar("_Return") +# TODO: when python 3.10 is lowest supported, typing.ParamSpec + + +def mark_raw(function: typing.Callable[[typing.Any], _Return]) -> typing.Callable[[typing.Any], _Return]: function.raw_text = True return function diff --git a/Patch.py b/Patch.py index 4ff0e960..113d0658 100644 --- a/Patch.py +++ b/Patch.py @@ -11,16 +11,6 @@ if __name__ == "__main__": from worlds.Files import AutoPatchRegister, APDeltaPatch -GAME_ALTTP = "A Link to the Past" -GAME_SM = "Super Metroid" -GAME_SOE = "Secret of Evermore" -GAME_SMZ3 = "SMZ3" -GAME_DKC3 = "Donkey Kong Country 3" - -GAME_SMW = "Super Mario World" - - - class RomMeta(TypedDict): server: str player: Optional[int] diff --git a/SNIClient.py b/SNIClient.py index 188822bc..03e1ff57 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -7,7 +7,6 @@ import multiprocessing import os import subprocess import base64 -import shutil import logging import asyncio import enum @@ -20,24 +19,19 @@ from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui import Utils +from MultiServer import mark_raw +if typing.TYPE_CHECKING: + from worlds.AutoSNIClient import SNIClient + if __name__ == "__main__": Utils.init_logging("SNIClient", exception_logger="Client") import colorama -import websockets - -from NetUtils import ClientStatus, color -from worlds.alttp import Regions, Shops -from worlds.alttp.Rom import ROM_PLAYER_LIMIT -from worlds.sm.Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT -from worlds.smz3.Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT -from Patch import GAME_ALTTP, GAME_SM, GAME_SMZ3, GAME_DKC3, GAME_SMW - +from websockets.client import connect as websockets_connect, WebSocketClientProtocol +from websockets.exceptions import WebSocketException, ConnectionClosed snes_logger = logging.getLogger("SNES") -from MultiServer import mark_raw - class DeathState(enum.IntEnum): killing_player = 1 @@ -46,9 +40,9 @@ class DeathState(enum.IntEnum): class SNIClientCommandProcessor(ClientCommandProcessor): - ctx: Context + ctx: SNIContext - def _cmd_slow_mode(self, toggle: str = ""): + def _cmd_slow_mode(self, toggle: str = "") -> None: """Toggle slow mode, which limits how fast you send / receive items.""" if toggle: self.ctx.slow_mode = toggle.lower() in {"1", "true", "on"} @@ -63,6 +57,9 @@ class SNIClientCommandProcessor(ClientCommandProcessor): otherwise show available devices; and a SNES device number if more than one SNES is detected. Examples: "/snes", "/snes 1", "/snes localhost:23074 1" """ + return self.connect_to_snes(snes_options) + + def connect_to_snes(self, snes_options: str = "") -> bool: snes_address = self.ctx.snes_address snes_device_number = -1 @@ -79,8 +76,8 @@ class SNIClientCommandProcessor(ClientCommandProcessor): self.ctx.snes_reconnect_address = None if self.ctx.snes_connect_task: self.ctx.snes_connect_task.cancel() - self.ctx.snes_connect_task = asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number), - name="SNES Connect") + self.ctx.snes_connect_task = asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number), + name="SNES Connect") return True def _cmd_snes_close(self) -> bool: @@ -113,14 +110,36 @@ class SNIClientCommandProcessor(ClientCommandProcessor): # return True -class Context(CommonContext): - command_processor = SNIClientCommandProcessor - game = "A Link to the Past" +class SNIContext(CommonContext): + command_processor: typing.Type[SNIClientCommandProcessor] = SNIClientCommandProcessor + game = None # set in validate_rom items_handling = None # set in game_watcher - snes_connect_task: typing.Optional[asyncio.Task] = None + snes_connect_task: "typing.Optional[asyncio.Task[None]]" = None - def __init__(self, snes_address, server_address, password): - super(Context, self).__init__(server_address, password) + snes_address: str + snes_socket: typing.Optional[WebSocketClientProtocol] + snes_state: SNESState + snes_attached_device: typing.Optional[typing.Tuple[int, str]] + snes_reconnect_address: typing.Optional[str] + snes_recv_queue: "asyncio.Queue[bytes]" + snes_request_lock: asyncio.Lock + snes_write_buffer: typing.List[typing.Tuple[int, bytes]] + snes_connector_lock: threading.Lock + death_state: DeathState + killing_player_task: "typing.Optional[asyncio.Task[None]]" + allow_collect: bool + slow_mode: bool + + client_handler: typing.Optional[SNIClient] + awaiting_rom: bool + rom: typing.Optional[bytes] + prev_rom: typing.Optional[bytes] + + hud_message_queue: typing.List[str] # TODO: str is a guess, is this right? + death_link_allow_survive: bool + + def __init__(self, snes_address: str, server_address: str, password: str) -> None: + super(SNIContext, self).__init__(server_address, password) # snes stuff self.snes_address = snes_address @@ -137,39 +156,48 @@ class Context(CommonContext): self.allow_collect = False self.slow_mode = False + self.client_handler = None self.awaiting_rom = False self.rom = None self.prev_rom = None - async def connection_closed(self): - await super(Context, self).connection_closed() + async def connection_closed(self) -> None: + await super(SNIContext, self).connection_closed() self.awaiting_rom = False - def event_invalid_slot(self): + def event_invalid_slot(self) -> typing.NoReturn: if self.snes_socket is not None and not self.snes_socket.closed: asyncio.create_task(self.snes_socket.close()) raise Exception("Invalid ROM detected, " "please verify that you have loaded the correct rom and reconnect your snes (/snes)") - async def server_auth(self, password_requested: bool = False): + async def server_auth(self, password_requested: bool = False) -> None: if password_requested and not self.password: - await super(Context, self).server_auth(password_requested) + await super(SNIContext, self).server_auth(password_requested) if self.rom is None: self.awaiting_rom = True snes_logger.info( "No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)") return self.awaiting_rom = False + # TODO: This looks kind of hacky... + # Context.auth is meant to be the "name" parameter in send_connect, + # which has to be a str (bytes is not json serializable). + # But here, Context.auth is being used for something else + # (where it has to be bytes because it is compared with rom elsewhere). + # If we need to save something to compare with rom elsewhere, + # it should probably be in a different variable, + # and let auth be used for what it's meant for. self.auth = self.rom auth = base64.b64encode(self.rom).decode() await self.send_connect(name=auth) - def on_deathlink(self, data: dict): + def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: if not self.killing_player_task or self.killing_player_task.done(): self.killing_player_task = asyncio.create_task(deathlink_kill_player(self)) - super(Context, self).on_deathlink(data) + super(SNIContext, self).on_deathlink(data) - async def handle_deathlink_state(self, currently_dead: bool): + async def handle_deathlink_state(self, currently_dead: bool) -> None: # in this state we only care about triggering a death send if self.death_state == DeathState.alive: if currently_dead: @@ -184,25 +212,27 @@ class Context(CommonContext): if not currently_dead: self.death_state = DeathState.alive - async def shutdown(self): - await super(Context, self).shutdown() + async def shutdown(self) -> None: + await super(SNIContext, self).shutdown() if self.snes_connect_task: try: await asyncio.wait_for(self.snes_connect_task, 1) except asyncio.TimeoutError: self.snes_connect_task.cancel() - def on_package(self, cmd: str, args: dict): + def on_package(self, cmd: str, args: typing.Dict[str, typing.Any]) -> None: if cmd in {"Connected", "RoomUpdate"}: if "checked_locations" in args and args["checked_locations"]: new_locations = set(args["checked_locations"]) self.checked_locations |= new_locations self.locations_scouted |= new_locations - # Items belonging to the player should not be marked as checked in game, since the player will likely need that item. - # Once the games handled by SNIClient gets made to be remote items, this will no longer be needed. + # Items belonging to the player should not be marked as checked in game, + # since the player will likely need that item. + # Once the games handled by SNIClient gets made to be remote items, + # this will no longer be needed. asyncio.create_task(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}])) - def run_gui(self): + def run_gui(self) -> None: from kvui import GameManager class SNIManager(GameManager): @@ -213,391 +243,23 @@ class Context(CommonContext): base_title = "Archipelago SNI Client" self.ui = SNIManager(self) - self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") # type: ignore -async def deathlink_kill_player(ctx: Context): +async def deathlink_kill_player(ctx: SNIContext) -> None: ctx.death_state = DeathState.killing_player while ctx.death_state == DeathState.killing_player and \ ctx.snes_state == SNESState.SNES_ATTACHED: - if ctx.game == GAME_ALTTP: - invincible = await snes_read(ctx, WRAM_START + 0x037B, 1) - last_health = await snes_read(ctx, WRAM_START + 0xF36D, 1) - await asyncio.sleep(0.25) - health = await snes_read(ctx, WRAM_START + 0xF36D, 1) - if not invincible or not last_health or not health: - ctx.death_state = DeathState.dead - ctx.last_death_link = time.time() - continue - if not invincible[0] and last_health[0] == health[0]: - snes_buffered_write(ctx, WRAM_START + 0xF36D, bytes([0])) # set current health to 0 - snes_buffered_write(ctx, WRAM_START + 0x0373, - bytes([8])) # deal 1 full heart of damage at next opportunity - elif ctx.game == GAME_SM: - snes_buffered_write(ctx, WRAM_START + 0x09C2, bytes([1, 0])) # set current health to 1 (to prevent saving with 0 energy) - snes_buffered_write(ctx, WRAM_START + 0x0A50, bytes([255])) # deal 255 of damage at next opportunity - if not ctx.death_link_allow_survive: - snes_buffered_write(ctx, WRAM_START + 0x09D6, bytes([0, 0])) # set current reserve to 0 - elif ctx.game == GAME_SMW: - from worlds.smw.Client import deathlink_kill_player as smw_deathlink_kill_player - await smw_deathlink_kill_player(ctx) - await snes_flush_writes(ctx) - await asyncio.sleep(1) + if ctx.client_handler is None: + continue + + await ctx.client_handler.deathlink_kill_player(ctx) - if ctx.game == GAME_ALTTP: - gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) - if not gamemode or gamemode[0] in DEATH_MODES: - ctx.death_state = DeathState.dead - elif ctx.game == GAME_SM: - gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) - health = await snes_read(ctx, WRAM_START + 0x09C2, 2) - if health is not None: - health = health[0] | (health[1] << 8) - if not gamemode or gamemode[0] in SM_DEATH_MODES or ( - ctx.death_link_allow_survive and health is not None and health > 0): - ctx.death_state = DeathState.dead - elif ctx.game == GAME_DKC3: - from worlds.dkc3.Client import deathlink_kill_player as dkc3_deathlink_kill_player - await dkc3_deathlink_kill_player(ctx) ctx.last_death_link = time.time() -SNES_RECONNECT_DELAY = 5 - -# FXPAK Pro protocol memory mapping used by SNI -ROM_START = 0x000000 -WRAM_START = 0xF50000 -WRAM_SIZE = 0x20000 -SRAM_START = 0xE00000 - -ROMNAME_START = SRAM_START + 0x2000 -ROMNAME_SIZE = 0x15 - -INGAME_MODES = {0x07, 0x09, 0x0b} -ENDGAME_MODES = {0x19, 0x1a} -DEATH_MODES = {0x12} - -SAVEDATA_START = WRAM_START + 0xF000 -SAVEDATA_SIZE = 0x500 - -RECV_PROGRESS_ADDR = SAVEDATA_START + 0x4D0 # 2 bytes -RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte -RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte -ROOMID_ADDR = SAVEDATA_START + 0x4D4 # 2 bytes -ROOMDATA_ADDR = SAVEDATA_START + 0x4D6 # 1 byte -SCOUT_LOCATION_ADDR = SAVEDATA_START + 0x4D7 # 1 byte -SCOUTREPLY_LOCATION_ADDR = SAVEDATA_START + 0x4D8 # 1 byte -SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte -SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte -SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes -SHOP_LEN = (len(Shops.shop_table) * 3) + 5 - -DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte - -# SM -SM_ROMNAME_START = ROM_START + 0x007FC0 - -SM_INGAME_MODES = {0x07, 0x09, 0x0b} -SM_ENDGAME_MODES = {0x26, 0x27} -SM_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A} - -# RECV and SEND are from the gameplay's perspective: SNIClient writes to RECV queue and reads from SEND queue -SM_RECV_QUEUE_START = SRAM_START + 0x2000 -SM_RECV_QUEUE_WCOUNT = SRAM_START + 0x2602 -SM_SEND_QUEUE_START = SRAM_START + 0x2700 -SM_SEND_QUEUE_RCOUNT = SRAM_START + 0x2680 -SM_SEND_QUEUE_WCOUNT = SRAM_START + 0x2682 - -SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277f04 # 1 byte -SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277f06 # 1 byte - -# SMZ3 -SMZ3_ROMNAME_START = ROM_START + 0x00FFC0 - -SMZ3_INGAME_MODES = {0x07, 0x09, 0x0b} -SMZ3_ENDGAME_MODES = {0x26, 0x27} -SMZ3_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A} - -SMZ3_RECV_PROGRESS_ADDR = SRAM_START + 0x4000 # 2 bytes -SMZ3_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte -SMZ3_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte - - -location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()]) - -location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10), - "Blind's Hideout - Left": (0x11d, 0x20), - "Blind's Hideout - Right": (0x11d, 0x40), - "Blind's Hideout - Far Left": (0x11d, 0x80), - "Blind's Hideout - Far Right": (0x11d, 0x100), - 'Secret Passage': (0x55, 0x10), - 'Waterfall Fairy - Left': (0x114, 0x10), - 'Waterfall Fairy - Right': (0x114, 0x20), - "King's Tomb": (0x113, 0x10), - 'Floodgate Chest': (0x10b, 0x10), - "Link's House": (0x104, 0x10), - 'Kakariko Tavern': (0x103, 0x10), - 'Chicken House': (0x108, 0x10), - "Aginah's Cave": (0x10a, 0x10), - "Sahasrahla's Hut - Left": (0x105, 0x10), - "Sahasrahla's Hut - Middle": (0x105, 0x20), - "Sahasrahla's Hut - Right": (0x105, 0x40), - 'Kakariko Well - Top': (0x2f, 0x10), - 'Kakariko Well - Left': (0x2f, 0x20), - 'Kakariko Well - Middle': (0x2f, 0x40), - 'Kakariko Well - Right': (0x2f, 0x80), - 'Kakariko Well - Bottom': (0x2f, 0x100), - 'Lost Woods Hideout': (0xe1, 0x200), - 'Lumberjack Tree': (0xe2, 0x200), - 'Cave 45': (0x11b, 0x400), - 'Graveyard Cave': (0x11b, 0x200), - 'Checkerboard Cave': (0x126, 0x200), - 'Mini Moldorm Cave - Far Left': (0x123, 0x10), - 'Mini Moldorm Cave - Left': (0x123, 0x20), - 'Mini Moldorm Cave - Right': (0x123, 0x40), - 'Mini Moldorm Cave - Far Right': (0x123, 0x80), - 'Mini Moldorm Cave - Generous Guy': (0x123, 0x400), - 'Ice Rod Cave': (0x120, 0x10), - 'Bonk Rock Cave': (0x124, 0x10), - 'Desert Palace - Big Chest': (0x73, 0x10), - 'Desert Palace - Torch': (0x73, 0x400), - 'Desert Palace - Map Chest': (0x74, 0x10), - 'Desert Palace - Compass Chest': (0x85, 0x10), - 'Desert Palace - Big Key Chest': (0x75, 0x10), - 'Desert Palace - Desert Tiles 1 Pot Key': (0x63, 0x400), - 'Desert Palace - Beamos Hall Pot Key': (0x53, 0x400), - 'Desert Palace - Desert Tiles 2 Pot Key': (0x43, 0x400), - 'Desert Palace - Boss': (0x33, 0x800), - 'Eastern Palace - Compass Chest': (0xa8, 0x10), - 'Eastern Palace - Big Chest': (0xa9, 0x10), - 'Eastern Palace - Dark Square Pot Key': (0xba, 0x400), - 'Eastern Palace - Dark Eyegore Key Drop': (0x99, 0x400), - 'Eastern Palace - Cannonball Chest': (0xb9, 0x10), - 'Eastern Palace - Big Key Chest': (0xb8, 0x10), - 'Eastern Palace - Map Chest': (0xaa, 0x10), - 'Eastern Palace - Boss': (0xc8, 0x800), - 'Hyrule Castle - Boomerang Chest': (0x71, 0x10), - 'Hyrule Castle - Boomerang Guard Key Drop': (0x71, 0x400), - 'Hyrule Castle - Map Chest': (0x72, 0x10), - 'Hyrule Castle - Map Guard Key Drop': (0x72, 0x400), - "Hyrule Castle - Zelda's Chest": (0x80, 0x10), - 'Hyrule Castle - Big Key Drop': (0x80, 0x400), - 'Sewers - Dark Cross': (0x32, 0x10), - 'Hyrule Castle - Key Rat Key Drop': (0x21, 0x400), - 'Sewers - Secret Room - Left': (0x11, 0x10), - 'Sewers - Secret Room - Middle': (0x11, 0x20), - 'Sewers - Secret Room - Right': (0x11, 0x40), - 'Sanctuary': (0x12, 0x10), - 'Castle Tower - Room 03': (0xe0, 0x10), - 'Castle Tower - Dark Maze': (0xd0, 0x10), - 'Castle Tower - Dark Archer Key Drop': (0xc0, 0x400), - 'Castle Tower - Circle of Pots Key Drop': (0xb0, 0x400), - 'Spectacle Rock Cave': (0xea, 0x400), - 'Paradox Cave Lower - Far Left': (0xef, 0x10), - 'Paradox Cave Lower - Left': (0xef, 0x20), - 'Paradox Cave Lower - Right': (0xef, 0x40), - 'Paradox Cave Lower - Far Right': (0xef, 0x80), - 'Paradox Cave Lower - Middle': (0xef, 0x100), - 'Paradox Cave Upper - Left': (0xff, 0x10), - 'Paradox Cave Upper - Right': (0xff, 0x20), - 'Spiral Cave': (0xfe, 0x10), - 'Tower of Hera - Basement Cage': (0x87, 0x400), - 'Tower of Hera - Map Chest': (0x77, 0x10), - 'Tower of Hera - Big Key Chest': (0x87, 0x10), - 'Tower of Hera - Compass Chest': (0x27, 0x20), - 'Tower of Hera - Big Chest': (0x27, 0x10), - 'Tower of Hera - Boss': (0x7, 0x800), - 'Hype Cave - Top': (0x11e, 0x10), - 'Hype Cave - Middle Right': (0x11e, 0x20), - 'Hype Cave - Middle Left': (0x11e, 0x40), - 'Hype Cave - Bottom': (0x11e, 0x80), - 'Hype Cave - Generous Guy': (0x11e, 0x400), - 'Peg Cave': (0x127, 0x400), - 'Pyramid Fairy - Left': (0x116, 0x10), - 'Pyramid Fairy - Right': (0x116, 0x20), - 'Brewery': (0x106, 0x10), - 'C-Shaped House': (0x11c, 0x10), - 'Chest Game': (0x106, 0x400), - 'Mire Shed - Left': (0x10d, 0x10), - 'Mire Shed - Right': (0x10d, 0x20), - 'Superbunny Cave - Top': (0xf8, 0x10), - 'Superbunny Cave - Bottom': (0xf8, 0x20), - 'Spike Cave': (0x117, 0x10), - 'Hookshot Cave - Top Right': (0x3c, 0x10), - 'Hookshot Cave - Top Left': (0x3c, 0x20), - 'Hookshot Cave - Bottom Right': (0x3c, 0x80), - 'Hookshot Cave - Bottom Left': (0x3c, 0x40), - 'Mimic Cave': (0x10c, 0x10), - 'Swamp Palace - Entrance': (0x28, 0x10), - 'Swamp Palace - Map Chest': (0x37, 0x10), - 'Swamp Palace - Pot Row Pot Key': (0x38, 0x400), - 'Swamp Palace - Trench 1 Pot Key': (0x37, 0x400), - 'Swamp Palace - Hookshot Pot Key': (0x36, 0x400), - 'Swamp Palace - Big Chest': (0x36, 0x10), - 'Swamp Palace - Compass Chest': (0x46, 0x10), - 'Swamp Palace - Trench 2 Pot Key': (0x35, 0x400), - 'Swamp Palace - Big Key Chest': (0x35, 0x10), - 'Swamp Palace - West Chest': (0x34, 0x10), - 'Swamp Palace - Flooded Room - Left': (0x76, 0x10), - 'Swamp Palace - Flooded Room - Right': (0x76, 0x20), - 'Swamp Palace - Waterfall Room': (0x66, 0x10), - 'Swamp Palace - Waterway Pot Key': (0x16, 0x400), - 'Swamp Palace - Boss': (0x6, 0x800), - "Thieves' Town - Big Key Chest": (0xdb, 0x20), - "Thieves' Town - Map Chest": (0xdb, 0x10), - "Thieves' Town - Compass Chest": (0xdc, 0x10), - "Thieves' Town - Ambush Chest": (0xcb, 0x10), - "Thieves' Town - Hallway Pot Key": (0xbc, 0x400), - "Thieves' Town - Spike Switch Pot Key": (0xab, 0x400), - "Thieves' Town - Attic": (0x65, 0x10), - "Thieves' Town - Big Chest": (0x44, 0x10), - "Thieves' Town - Blind's Cell": (0x45, 0x10), - "Thieves' Town - Boss": (0xac, 0x800), - 'Skull Woods - Compass Chest': (0x67, 0x10), - 'Skull Woods - Map Chest': (0x58, 0x20), - 'Skull Woods - Big Chest': (0x58, 0x10), - 'Skull Woods - Pot Prison': (0x57, 0x20), - 'Skull Woods - Pinball Room': (0x68, 0x10), - 'Skull Woods - Big Key Chest': (0x57, 0x10), - 'Skull Woods - West Lobby Pot Key': (0x56, 0x400), - 'Skull Woods - Bridge Room': (0x59, 0x10), - 'Skull Woods - Spike Corner Key Drop': (0x39, 0x400), - 'Skull Woods - Boss': (0x29, 0x800), - 'Ice Palace - Jelly Key Drop': (0x0e, 0x400), - 'Ice Palace - Compass Chest': (0x2e, 0x10), - 'Ice Palace - Conveyor Key Drop': (0x3e, 0x400), - 'Ice Palace - Freezor Chest': (0x7e, 0x10), - 'Ice Palace - Big Chest': (0x9e, 0x10), - 'Ice Palace - Iced T Room': (0xae, 0x10), - 'Ice Palace - Many Pots Pot Key': (0x9f, 0x400), - 'Ice Palace - Spike Room': (0x5f, 0x10), - 'Ice Palace - Big Key Chest': (0x1f, 0x10), - 'Ice Palace - Hammer Block Key Drop': (0x3f, 0x400), - 'Ice Palace - Map Chest': (0x3f, 0x10), - 'Ice Palace - Boss': (0xde, 0x800), - 'Misery Mire - Big Chest': (0xc3, 0x10), - 'Misery Mire - Map Chest': (0xc3, 0x20), - 'Misery Mire - Main Lobby': (0xc2, 0x10), - 'Misery Mire - Bridge Chest': (0xa2, 0x10), - 'Misery Mire - Spikes Pot Key': (0xb3, 0x400), - 'Misery Mire - Spike Chest': (0xb3, 0x10), - 'Misery Mire - Fishbone Pot Key': (0xa1, 0x400), - 'Misery Mire - Conveyor Crystal Key Drop': (0xc1, 0x400), - 'Misery Mire - Compass Chest': (0xc1, 0x10), - 'Misery Mire - Big Key Chest': (0xd1, 0x10), - 'Misery Mire - Boss': (0x90, 0x800), - 'Turtle Rock - Compass Chest': (0xd6, 0x10), - 'Turtle Rock - Roller Room - Left': (0xb7, 0x10), - 'Turtle Rock - Roller Room - Right': (0xb7, 0x20), - 'Turtle Rock - Pokey 1 Key Drop': (0xb6, 0x400), - 'Turtle Rock - Chain Chomps': (0xb6, 0x10), - 'Turtle Rock - Pokey 2 Key Drop': (0x13, 0x400), - 'Turtle Rock - Big Key Chest': (0x14, 0x10), - 'Turtle Rock - Big Chest': (0x24, 0x10), - 'Turtle Rock - Crystaroller Room': (0x4, 0x10), - 'Turtle Rock - Eye Bridge - Bottom Left': (0xd5, 0x80), - 'Turtle Rock - Eye Bridge - Bottom Right': (0xd5, 0x40), - 'Turtle Rock - Eye Bridge - Top Left': (0xd5, 0x20), - 'Turtle Rock - Eye Bridge - Top Right': (0xd5, 0x10), - 'Turtle Rock - Boss': (0xa4, 0x800), - 'Palace of Darkness - Shooter Room': (0x9, 0x10), - 'Palace of Darkness - The Arena - Bridge': (0x2a, 0x20), - 'Palace of Darkness - Stalfos Basement': (0xa, 0x10), - 'Palace of Darkness - Big Key Chest': (0x3a, 0x10), - 'Palace of Darkness - The Arena - Ledge': (0x2a, 0x10), - 'Palace of Darkness - Map Chest': (0x2b, 0x10), - 'Palace of Darkness - Compass Chest': (0x1a, 0x20), - 'Palace of Darkness - Dark Basement - Left': (0x6a, 0x10), - 'Palace of Darkness - Dark Basement - Right': (0x6a, 0x20), - 'Palace of Darkness - Dark Maze - Top': (0x19, 0x10), - 'Palace of Darkness - Dark Maze - Bottom': (0x19, 0x20), - 'Palace of Darkness - Big Chest': (0x1a, 0x10), - 'Palace of Darkness - Harmless Hellway': (0x1a, 0x40), - 'Palace of Darkness - Boss': (0x5a, 0x800), - 'Ganons Tower - Conveyor Cross Pot Key': (0x8b, 0x400), - "Ganons Tower - Bob's Torch": (0x8c, 0x400), - 'Ganons Tower - Hope Room - Left': (0x8c, 0x20), - 'Ganons Tower - Hope Room - Right': (0x8c, 0x40), - 'Ganons Tower - Tile Room': (0x8d, 0x10), - 'Ganons Tower - Compass Room - Top Left': (0x9d, 0x10), - 'Ganons Tower - Compass Room - Top Right': (0x9d, 0x20), - 'Ganons Tower - Compass Room - Bottom Left': (0x9d, 0x40), - 'Ganons Tower - Compass Room - Bottom Right': (0x9d, 0x80), - 'Ganons Tower - Conveyor Star Pits Pot Key': (0x7b, 0x400), - 'Ganons Tower - DMs Room - Top Left': (0x7b, 0x10), - 'Ganons Tower - DMs Room - Top Right': (0x7b, 0x20), - 'Ganons Tower - DMs Room - Bottom Left': (0x7b, 0x40), - 'Ganons Tower - DMs Room - Bottom Right': (0x7b, 0x80), - 'Ganons Tower - Map Chest': (0x8b, 0x10), - 'Ganons Tower - Double Switch Pot Key': (0x9b, 0x400), - 'Ganons Tower - Firesnake Room': (0x7d, 0x10), - 'Ganons Tower - Randomizer Room - Top Left': (0x7c, 0x10), - 'Ganons Tower - Randomizer Room - Top Right': (0x7c, 0x20), - 'Ganons Tower - Randomizer Room - Bottom Left': (0x7c, 0x40), - 'Ganons Tower - Randomizer Room - Bottom Right': (0x7c, 0x80), - "Ganons Tower - Bob's Chest": (0x8c, 0x80), - 'Ganons Tower - Big Chest': (0x8c, 0x10), - 'Ganons Tower - Big Key Room - Left': (0x1c, 0x20), - 'Ganons Tower - Big Key Room - Right': (0x1c, 0x40), - 'Ganons Tower - Big Key Chest': (0x1c, 0x10), - 'Ganons Tower - Mini Helmasaur Room - Left': (0x3d, 0x10), - 'Ganons Tower - Mini Helmasaur Room - Right': (0x3d, 0x20), - 'Ganons Tower - Mini Helmasaur Key Drop': (0x3d, 0x400), - 'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40), - 'Ganons Tower - Validation Chest': (0x4d, 0x10)} - -boss_locations = {Regions.lookup_name_to_id[name] for name in {'Eastern Palace - Boss', - 'Desert Palace - Boss', - 'Tower of Hera - Boss', - 'Palace of Darkness - Boss', - 'Swamp Palace - Boss', - 'Skull Woods - Boss', - "Thieves' Town - Boss", - 'Ice Palace - Boss', - 'Misery Mire - Boss', - 'Turtle Rock - Boss', - 'Sahasrahla'}} - -location_table_uw_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_uw.items()} - -location_table_npc = {'Mushroom': 0x1000, - 'King Zora': 0x2, - 'Sahasrahla': 0x10, - 'Blacksmith': 0x400, - 'Magic Bat': 0x8000, - 'Sick Kid': 0x4, - 'Library': 0x80, - 'Potion Shop': 0x2000, - 'Old Man': 0x1, - 'Ether Tablet': 0x100, - 'Catfish': 0x20, - 'Stumpy': 0x8, - 'Bombos Tablet': 0x200} - -location_table_npc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_npc.items()} - -location_table_ow = {'Flute Spot': 0x2a, - 'Sunken Treasure': 0x3b, - "Zora's Ledge": 0x81, - 'Lake Hylia Island': 0x35, - 'Maze Race': 0x28, - 'Desert Ledge': 0x30, - 'Master Sword Pedestal': 0x80, - 'Spectacle Rock': 0x3, - 'Pyramid': 0x5b, - 'Digging Game': 0x68, - 'Bumper Cave Ledge': 0x4a, - 'Floating Island': 0x5} - -location_table_ow_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_ow.items()} - -location_table_misc = {'Bottle Merchant': (0x3c9, 0x2), - 'Purple Chest': (0x3c9, 0x10), - "Link's Uncle": (0x3c6, 0x1), - 'Hobo': (0x3c9, 0x1)} - -location_table_misc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_misc.items()} +_global_snes_reconnect_delay = 5 class SNESState(enum.IntEnum): @@ -607,13 +269,13 @@ class SNESState(enum.IntEnum): SNES_ATTACHED = 3 -def launch_sni(): - sni_path = Utils.get_options()["lttp_options"]["sni"] +def launch_sni() -> None: + sni_path = Utils.get_options()["sni_options"]["sni_path"] if not os.path.isdir(sni_path): sni_path = Utils.local_path(sni_path) if os.path.isdir(sni_path): - dir_entry: os.DirEntry + dir_entry: "os.DirEntry[str]" for dir_entry in os.scandir(sni_path): if dir_entry.is_file(): lower_file = dir_entry.name.lower() @@ -641,13 +303,13 @@ def launch_sni(): f"please start it yourself if it is not running") -async def _snes_connect(ctx: Context, address: str): +async def _snes_connect(ctx: SNIContext, address: str) -> WebSocketClientProtocol: address = f"ws://{address}" if "://" not in address else address snes_logger.info("Connecting to SNI at %s ..." % address) - seen_problems = set() - while 1: + seen_problems: typing.Set[str] = set() + while True: try: - snes_socket = await websockets.connect(address, ping_timeout=None, ping_interval=None) + snes_socket = await websockets_connect(address, ping_timeout=None, ping_interval=None) except Exception as e: problem = "%s" % e # only tell the user about new problems, otherwise silently lay in wait for a working connection @@ -664,15 +326,24 @@ async def _snes_connect(ctx: Context, address: str): return snes_socket -async def get_snes_devices(ctx: Context) -> typing.List[str]: +class SNESRequest(typing.TypedDict): + Opcode: str + Space: str + Operands: typing.List[str] + # TODO: When Python 3.11 is the lowest version supported, `Operands` can use `typing.NotRequired` (pep-0655) + # Then the `Operands` key doesn't need to be given for opcodes that don't use it. + + +async def get_snes_devices(ctx: SNIContext) -> typing.List[str]: socket = await _snes_connect(ctx, ctx.snes_address) # establish new connection to poll - DeviceList_Request = { + DeviceList_Request: SNESRequest = { "Opcode": "DeviceList", - "Space": "SNES" + "Space": "SNES", + "Operands": [] } await socket.send(dumps(DeviceList_Request)) - reply: dict = loads(await socket.recv()) + reply: typing.Dict[str, typing.Any] = loads(await socket.recv()) devices: typing.List[str] = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else [] if not devices: @@ -688,7 +359,7 @@ async def get_snes_devices(ctx: Context) -> typing.List[str]: return sorted(devices) -async def verify_snes_app(socket): +async def verify_snes_app(socket: WebSocketClientProtocol) -> None: AppVersion_Request = { "Opcode": "AppVersion", } @@ -699,8 +370,8 @@ async def verify_snes_app(socket): snes_logger.warning(f"Warning: Did not find SNI as the endpoint, instead {app} was found.") -async def snes_connect(ctx: Context, address, deviceIndex=-1): - global SNES_RECONNECT_DELAY +async def snes_connect(ctx: SNIContext, address: str, deviceIndex: int = -1) -> None: + global _global_snes_reconnect_delay if ctx.snes_socket is not None and ctx.snes_state == SNESState.SNES_CONNECTED: if ctx.rom: snes_logger.error('Already connected to SNES, with rom loaded.') @@ -722,6 +393,7 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1): if device_count == 1: device = devices[0] elif ctx.snes_reconnect_address: + assert ctx.snes_attached_device if ctx.snes_attached_device[1] in devices: device = ctx.snes_attached_device[1] else: @@ -746,7 +418,7 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1): snes_logger.info("Attaching to " + device) - Attach_Request = { + Attach_Request: SNESRequest = { "Opcode": "Attach", "Space": "SNES", "Operands": [device] @@ -770,35 +442,37 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1): if not ctx.snes_reconnect_address: snes_logger.error("Error connecting to snes (%s)" % e) else: - snes_logger.error(f"Error connecting to snes, attempt again in {SNES_RECONNECT_DELAY}s") + snes_logger.error(f"Error connecting to snes, attempt again in {_global_snes_reconnect_delay}s") asyncio.create_task(snes_autoreconnect(ctx)) - SNES_RECONNECT_DELAY *= 2 + _global_snes_reconnect_delay *= 2 else: - SNES_RECONNECT_DELAY = ctx.starting_reconnect_delay + _global_snes_reconnect_delay = ctx.starting_reconnect_delay snes_logger.info(f"Attached to {device}") -async def snes_disconnect(ctx: Context): +async def snes_disconnect(ctx: SNIContext) -> None: if ctx.snes_socket: if not ctx.snes_socket.closed: await ctx.snes_socket.close() ctx.snes_socket = None -async def snes_autoreconnect(ctx: Context): - await asyncio.sleep(SNES_RECONNECT_DELAY) +async def snes_autoreconnect(ctx: SNIContext) -> None: + await asyncio.sleep(_global_snes_reconnect_delay) if ctx.snes_reconnect_address and ctx.snes_socket is None: await snes_connect(ctx, ctx.snes_reconnect_address) -async def snes_recv_loop(ctx: Context): +async def snes_recv_loop(ctx: SNIContext) -> None: try: + if ctx.snes_socket is None: + raise Exception("invalid context state - snes_socket not connected") async for msg in ctx.snes_socket: - ctx.snes_recv_queue.put_nowait(msg) + ctx.snes_recv_queue.put_nowait(typing.cast(bytes, msg)) snes_logger.warning("Snes disconnected") except Exception as e: - if not isinstance(e, websockets.WebSocketException): + if not isinstance(e, WebSocketException): snes_logger.exception(e) snes_logger.error("Lost connection to the snes, type /snes to reconnect") finally: @@ -813,28 +487,33 @@ async def snes_recv_loop(ctx: Context): ctx.rom = None if ctx.snes_reconnect_address: - snes_logger.info(f"...reconnecting in {SNES_RECONNECT_DELAY}s") + snes_logger.info(f"...reconnecting in {_global_snes_reconnect_delay}s") asyncio.create_task(snes_autoreconnect(ctx)) -async def snes_read(ctx: Context, address, size): +async def snes_read(ctx: SNIContext, address: int, size: int) -> typing.Optional[bytes]: try: await ctx.snes_request_lock.acquire() - if ctx.snes_state != SNESState.SNES_ATTACHED or ctx.snes_socket is None or not ctx.snes_socket.open or ctx.snes_socket.closed: + if ( + ctx.snes_state != SNESState.SNES_ATTACHED or + ctx.snes_socket is None or + not ctx.snes_socket.open or + ctx.snes_socket.closed + ): return None - GetAddress_Request = { + GetAddress_Request: SNESRequest = { "Opcode": "GetAddress", "Space": "SNES", "Operands": [hex(address)[2:], hex(size)[2:]] } try: await ctx.snes_socket.send(dumps(GetAddress_Request)) - except websockets.ConnectionClosed: + except ConnectionClosed: return None - data = bytes() + data: bytes = bytes() while len(data) < size: try: data += await asyncio.wait_for(ctx.snes_recv_queue.get(), 5) @@ -855,7 +534,7 @@ async def snes_read(ctx: Context, address, size): ctx.snes_request_lock.release() -async def snes_write(ctx: Context, write_list): +async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int, bytes]]) -> bool: try: await ctx.snes_request_lock.acquire() @@ -863,16 +542,18 @@ async def snes_write(ctx: Context, write_list): not ctx.snes_socket.open or ctx.snes_socket.closed: return False - PutAddress_Request = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'} + PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'} try: for address, data in write_list: PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]] + # REVIEW: above: `if snes_socket is None: return False` + # Does it need to be checked again? if ctx.snes_socket is not None: await ctx.snes_socket.send(dumps(PutAddress_Request)) await ctx.snes_socket.send(data) else: snes_logger.warning(f"Could not send data to SNES: {data}") - except websockets.ConnectionClosed: + except ConnectionClosed: return False return True @@ -880,7 +561,7 @@ async def snes_write(ctx: Context, write_list): ctx.snes_request_lock.release() -def snes_buffered_write(ctx: Context, address, data): +def snes_buffered_write(ctx: SNIContext, address: int, data: bytes) -> None: if ctx.snes_write_buffer and (ctx.snes_write_buffer[-1][0] + len(ctx.snes_write_buffer[-1][1])) == address: # append to existing write command, bundling them ctx.snes_write_buffer[-1] = (ctx.snes_write_buffer[-1][0], ctx.snes_write_buffer[-1][1] + data) @@ -888,7 +569,7 @@ def snes_buffered_write(ctx: Context, address, data): ctx.snes_write_buffer.append((address, data)) -async def snes_flush_writes(ctx: Context): +async def snes_flush_writes(ctx: SNIContext) -> None: if not ctx.snes_write_buffer: return @@ -897,142 +578,7 @@ async def snes_flush_writes(ctx: Context): await snes_write(ctx, writes) -async def track_locations(ctx: Context, roomid, roomdata): - new_locations = [] - - def new_check(location_id): - new_locations.append(location_id) - ctx.locations_checked.add(location_id) - location = ctx.location_names[location_id] - snes_logger.info( - f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') - - try: - shop_data = await snes_read(ctx, SHOP_ADDR, SHOP_LEN) - shop_data_changed = False - shop_data = list(shop_data) - for cnt, b in enumerate(shop_data): - location = Shops.SHOP_ID_START + cnt - if int(b) and location not in ctx.locations_checked: - new_check(location) - if ctx.allow_collect and location in ctx.checked_locations and location not in ctx.locations_checked \ - and location in ctx.locations_info and ctx.locations_info[location].player != ctx.slot: - if not int(b): - shop_data[cnt] += 1 - shop_data_changed = True - if shop_data_changed: - snes_buffered_write(ctx, SHOP_ADDR, bytes(shop_data)) - except Exception as e: - snes_logger.info(f"Exception: {e}") - - for location_id, (loc_roomid, loc_mask) in location_table_uw_id.items(): - try: - if location_id not in ctx.locations_checked and loc_roomid == roomid and \ - (roomdata << 4) & loc_mask != 0: - new_check(location_id) - except Exception as e: - snes_logger.exception(f"Exception: {e}") - - uw_begin = 0x129 - ow_end = uw_end = 0 - uw_unchecked = {} - uw_checked = {} - for location, (roomid, mask) in location_table_uw.items(): - location_id = Regions.lookup_name_to_id[location] - if location_id not in ctx.locations_checked: - uw_unchecked[location_id] = (roomid, mask) - uw_begin = min(uw_begin, roomid) - uw_end = max(uw_end, roomid + 1) - if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \ - and location_id not in ctx.locations_checked and location_id in ctx.locations_info \ - and ctx.locations_info[location_id].player != ctx.slot: - uw_begin = min(uw_begin, roomid) - uw_end = max(uw_end, roomid + 1) - uw_checked[location_id] = (roomid, mask) - - if uw_begin < uw_end: - uw_data = await snes_read(ctx, SAVEDATA_START + (uw_begin * 2), (uw_end - uw_begin) * 2) - if uw_data is not None: - for location_id, (roomid, mask) in uw_unchecked.items(): - offset = (roomid - uw_begin) * 2 - roomdata = uw_data[offset] | (uw_data[offset + 1] << 8) - if roomdata & mask != 0: - new_check(location_id) - if uw_checked: - uw_data = list(uw_data) - for location_id, (roomid, mask) in uw_checked.items(): - offset = (roomid - uw_begin) * 2 - roomdata = uw_data[offset] | (uw_data[offset + 1] << 8) - roomdata |= mask - uw_data[offset] = roomdata & 0xFF - uw_data[offset + 1] = roomdata >> 8 - snes_buffered_write(ctx, SAVEDATA_START + (uw_begin * 2), bytes(uw_data)) - - ow_begin = 0x82 - ow_unchecked = {} - ow_checked = {} - for location_id, screenid in location_table_ow_id.items(): - if location_id not in ctx.locations_checked: - ow_unchecked[location_id] = screenid - ow_begin = min(ow_begin, screenid) - ow_end = max(ow_end, screenid + 1) - if ctx.allow_collect and location_id in ctx.checked_locations and location_id in ctx.locations_info \ - and ctx.locations_info[location_id].player != ctx.slot: - ow_checked[location_id] = screenid - - if ow_begin < ow_end: - ow_data = await snes_read(ctx, SAVEDATA_START + 0x280 + ow_begin, ow_end - ow_begin) - if ow_data is not None: - for location_id, screenid in ow_unchecked.items(): - if ow_data[screenid - ow_begin] & 0x40 != 0: - new_check(location_id) - if ow_checked: - ow_data = list(ow_data) - for location_id, screenid in ow_checked.items(): - ow_data[screenid - ow_begin] |= 0x40 - snes_buffered_write(ctx, SAVEDATA_START + 0x280 + ow_begin, bytes(ow_data)) - - if not ctx.locations_checked.issuperset(location_table_npc_id): - npc_data = await snes_read(ctx, SAVEDATA_START + 0x410, 2) - if npc_data is not None: - npc_value_changed = False - npc_value = npc_data[0] | (npc_data[1] << 8) - for location_id, mask in location_table_npc_id.items(): - if npc_value & mask != 0 and location_id not in ctx.locations_checked: - new_check(location_id) - if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \ - and location_id not in ctx.locations_checked and location_id in ctx.locations_info \ - and ctx.locations_info[location_id].player != ctx.slot: - npc_value |= mask - npc_value_changed = True - if npc_value_changed: - npc_data = bytes([npc_value & 0xFF, npc_value >> 8]) - snes_buffered_write(ctx, SAVEDATA_START + 0x410, npc_data) - - if not ctx.locations_checked.issuperset(location_table_misc_id): - misc_data = await snes_read(ctx, SAVEDATA_START + 0x3c6, 4) - if misc_data is not None: - misc_data = list(misc_data) - misc_data_changed = False - for location_id, (offset, mask) in location_table_misc_id.items(): - assert (0x3c6 <= offset <= 0x3c9) - if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked: - new_check(location_id) - if ctx.allow_collect and location_id in ctx.checked_locations and location_id not in ctx.locations_checked \ - and location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot: - misc_data_changed = True - misc_data[offset - 0x3c6] |= mask - if misc_data_changed: - snes_buffered_write(ctx, SAVEDATA_START + 0x3c6, bytes(misc_data)) - - - if new_locations: - await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}]) - await snes_flush_writes(ctx) - - -async def game_watcher(ctx: Context): - prev_game_timer = 0 +async def game_watcher(ctx: SNIContext) -> None: perf_counter = time.perf_counter() while not ctx.exit_event.is_set(): try: @@ -1041,54 +587,24 @@ async def game_watcher(ctx: Context): pass ctx.watcher_event.clear() - if not ctx.rom: + if not ctx.rom or not ctx.client_handler: ctx.finished_game = False ctx.death_link_allow_survive = False - from worlds.dkc3.Client import dkc3_rom_init - init_handled = await dkc3_rom_init(ctx) - if not init_handled: - from worlds.smw.Client import smw_rom_init - init_handled = await smw_rom_init(ctx) - if not init_handled: - game_name = await snes_read(ctx, SM_ROMNAME_START, 5) - if game_name is None: - continue - elif game_name[:2] == b"SM": - ctx.game = GAME_SM - # versions lower than 0.3.0 dont have item handling flag nor remote item support - romVersion = int(game_name[2:5].decode('UTF-8')) - if romVersion < 30: - ctx.items_handling = 0b001 # full local - else: - item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1) - ctx.items_handling = 0b001 if item_handling is None else item_handling[0] - else: - game_name = await snes_read(ctx, SMZ3_ROMNAME_START, 3) - if game_name == b"ZSM": - ctx.game = GAME_SMZ3 - ctx.items_handling = 0b101 # local items and remote start inventory - else: - ctx.game = GAME_ALTTP - ctx.items_handling = 0b001 # full local + from worlds.AutoSNIClient import AutoSNIClientRegister + ctx.client_handler = await AutoSNIClientRegister.get_handler(ctx) - rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else SMZ3_ROMNAME_START if ctx.game == GAME_SMZ3 else ROMNAME_START, ROMNAME_SIZE) - if rom is None or rom == bytes([0] * ROMNAME_SIZE): - continue + if not ctx.client_handler: + continue - ctx.rom = rom - if ctx.game != GAME_SMZ3: - death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else - SM_DEATH_LINK_ACTIVE_ADDR, 1) - if death_link: - ctx.allow_collect = bool(death_link[0] & 0b100) - ctx.death_link_allow_survive = bool(death_link[0] & 0b10) - await ctx.update_death_link(bool(death_link[0] & 0b1)) - if not ctx.prev_rom or ctx.prev_rom != ctx.rom: - ctx.locations_checked = set() - ctx.locations_scouted = set() - ctx.locations_info = {} - ctx.prev_rom = ctx.rom + if not ctx.rom: + continue + + if not ctx.prev_rom or ctx.prev_rom != ctx.rom: + ctx.locations_checked = set() + ctx.locations_scouted = set() + ctx.locations_info = {} + ctx.prev_rom = ctx.rom if ctx.awaiting_rom: await ctx.server_auth(False) @@ -1096,234 +612,40 @@ async def game_watcher(ctx: Context): snes_logger.warning("ROM detected but no active multiworld server connection. " + "Connect using command: /connect server:port") - if ctx.auth and ctx.auth != ctx.rom: + if not ctx.client_handler: + continue + + rom_validated = await ctx.client_handler.validate_rom(ctx) + + if not rom_validated or (ctx.auth and ctx.auth != ctx.rom): snes_logger.warning("ROM change detected, please reconnect to the multiworld server") await ctx.disconnect() + ctx.client_handler = None + ctx.rom = None + ctx.command_processor(ctx).connect_to_snes() + continue - if ctx.game == GAME_ALTTP: - gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) - if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): - currently_dead = gamemode[0] in DEATH_MODES - await ctx.handle_deathlink_state(currently_dead) + delay = 7 if ctx.slow_mode else 0 + if time.perf_counter() - perf_counter < delay: + continue - gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1) - game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4) - if gamemode is None or gameend is None or game_timer is None or \ - (gamemode[0] not in INGAME_MODES and gamemode[0] not in ENDGAME_MODES): - continue + perf_counter = time.perf_counter() - delay = 7 if ctx.slow_mode else 2 - if gameend[0]: - if not ctx.finished_game: - await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) - ctx.finished_game = True - - if time.perf_counter() - perf_counter < delay: - continue - else: - perf_counter = time.perf_counter() - else: - game_timer = game_timer[0] | (game_timer[1] << 8) | (game_timer[2] << 16) | (game_timer[3] << 24) - if abs(game_timer - prev_game_timer) < (delay * 60): - continue - else: - prev_game_timer = game_timer - - if gamemode in ENDGAME_MODES: # triforce room and credits - continue - - data = await snes_read(ctx, RECV_PROGRESS_ADDR, 8) - if data is None: - continue - - recv_index = data[0] | (data[1] << 8) - recv_item = data[2] - roomid = data[4] | (data[5] << 8) - roomdata = data[6] - scout_location = data[7] - - if recv_index < len(ctx.items_received) and recv_item == 0: - item = ctx.items_received[recv_index] - recv_index += 1 - logging.info('Received %s from %s (%s) (%d/%d in list)' % ( - color(ctx.item_names[item.item], 'red', 'bold'), - color(ctx.player_names[item.player], 'yellow'), - ctx.location_names[item.location], recv_index, len(ctx.items_received))) - - snes_buffered_write(ctx, RECV_PROGRESS_ADDR, - bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) - snes_buffered_write(ctx, RECV_ITEM_ADDR, - bytes([item.item])) - snes_buffered_write(ctx, RECV_ITEM_PLAYER_ADDR, - bytes([min(ROM_PLAYER_LIMIT, item.player) if item.player != ctx.slot else 0])) - if scout_location > 0 and scout_location in ctx.locations_info: - snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR, - bytes([scout_location])) - snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR, - bytes([ctx.locations_info[scout_location].item])) - snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR, - bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location].player)])) - - await snes_flush_writes(ctx) - - if scout_location > 0 and scout_location not in ctx.locations_scouted: - ctx.locations_scouted.add(scout_location) - await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}]) - await track_locations(ctx, roomid, roomdata) - elif ctx.game == GAME_SM: - if ctx.server is None or ctx.slot is None: - # not successfully connected to a multiworld server, cannot process the game sending items - continue - gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) - if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): - currently_dead = gamemode[0] in SM_DEATH_MODES - await ctx.handle_deathlink_state(currently_dead) - if gamemode is not None and gamemode[0] in SM_ENDGAME_MODES: - if not ctx.finished_game: - await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) - ctx.finished_game = True - continue - - data = await snes_read(ctx, SM_SEND_QUEUE_RCOUNT, 4) - if data is None: - continue - - recv_index = data[0] | (data[1] << 8) - recv_item = data[2] | (data[3] << 8) # this is actually SM_SEND_QUEUE_WCOUNT - - while (recv_index < recv_item): - itemAdress = recv_index * 8 - message = await snes_read(ctx, SM_SEND_QUEUE_START + itemAdress, 8) - # worldId = message[0] | (message[1] << 8) # unused - # itemId = message[2] | (message[3] << 8) # unused - itemIndex = (message[4] | (message[5] << 8)) >> 3 - - recv_index += 1 - snes_buffered_write(ctx, SM_SEND_QUEUE_RCOUNT, - bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) - - from worlds.sm import locations_start_id - location_id = locations_start_id + itemIndex - - ctx.locations_checked.add(location_id) - location = ctx.location_names[location_id] - snes_logger.info( - f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') - await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) - - data = await snes_read(ctx, SM_RECV_QUEUE_WCOUNT, 2) - if data is None: - continue - - itemOutPtr = data[0] | (data[1] << 8) - - from worlds.sm import items_start_id - from worlds.sm import locations_start_id - if itemOutPtr < len(ctx.items_received): - item = ctx.items_received[itemOutPtr] - itemId = item.item - items_start_id - if bool(ctx.items_handling & 0b010): - locationId = (item.location - locations_start_id) if (item.location >= 0 and item.player == ctx.slot) else 0xFF - else: - locationId = 0x00 #backward compat - - playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0 - snes_buffered_write(ctx, SM_RECV_QUEUE_START + itemOutPtr * 4, bytes( - [playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, locationId & 0xFF])) - itemOutPtr += 1 - snes_buffered_write(ctx, SM_RECV_QUEUE_WCOUNT, - bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF])) - logging.info('Received %s from %s (%s) (%d/%d in list)' % ( - color(ctx.item_names[item.item], 'red', 'bold'), - color(ctx.player_names[item.player], 'yellow'), - ctx.location_names[item.location], itemOutPtr, len(ctx.items_received))) - await snes_flush_writes(ctx) - elif ctx.game == GAME_SMZ3: - if ctx.server is None or ctx.slot is None: - # not successfully connected to a multiworld server, cannot process the game sending items - continue - currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2) - if (currentGame is not None): - if (currentGame[0] != 0): - gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) - endGameModes = SM_ENDGAME_MODES - else: - gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) - endGameModes = ENDGAME_MODES - - if gamemode is not None and (gamemode[0] in endGameModes): - if not ctx.finished_game: - await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) - ctx.finished_game = True - continue - - data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, 4) - if data is None: - continue - - recv_index = data[0] | (data[1] << 8) - recv_item = data[2] | (data[3] << 8) - - while (recv_index < recv_item): - itemAdress = recv_index * 8 - message = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x700 + itemAdress, 8) - # worldId = message[0] | (message[1] << 8) # unused - # itemId = message[2] | (message[3] << 8) # unused - isZ3Item = ((message[5] & 0x80) != 0) - maskedPart = (message[5] & 0x7F) if isZ3Item else message[5] - itemIndex = ((message[4] | (maskedPart << 8)) >> 3) + (256 if isZ3Item else 0) - - recv_index += 1 - snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) - - from worlds.smz3.TotalSMZ3.Location import locations_start_id - from worlds.smz3 import convertLocSMZ3IDToAPID - location_id = locations_start_id + convertLocSMZ3IDToAPID(itemIndex) - - ctx.locations_checked.add(location_id) - location = ctx.location_names[location_id] - snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') - await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) - - data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x600, 4) - if data is None: - continue - - # recv_itemOutPtr = data[0] | (data[1] << 8) # unused - itemOutPtr = data[2] | (data[3] << 8) - - from worlds.smz3.TotalSMZ3.Item import items_start_id - if itemOutPtr < len(ctx.items_received): - item = ctx.items_received[itemOutPtr] - itemId = item.item - items_start_id - - playerID = item.player if item.player <= SMZ3_ROM_PLAYER_LIMIT else 0 - snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes([playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, (itemId >> 8) & 0xFF])) - itemOutPtr += 1 - snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x602, bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF])) - logging.info('Received %s from %s (%s) (%d/%d in list)' % ( - color(ctx.item_names[item.item], 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'), - ctx.location_names[item.location], itemOutPtr, len(ctx.items_received))) - await snes_flush_writes(ctx) - elif ctx.game == GAME_DKC3: - from worlds.dkc3.Client import dkc3_game_watcher - await dkc3_game_watcher(ctx) - elif ctx.game == GAME_SMW: - from worlds.smw.Client import smw_game_watcher - await smw_game_watcher(ctx) + await ctx.client_handler.game_watcher(ctx) -async def run_game(romfile): - auto_start = Utils.get_options()["lttp_options"].get("rom_start", True) +async def run_game(romfile: str) -> None: + auto_start = typing.cast(typing.Union[bool, str], + Utils.get_options()["sni_options"].get("snes_rom_start", True)) if auto_start is True: import webbrowser webbrowser.open(romfile) - elif os.path.isfile(auto_start): + elif isinstance(auto_start, str) and os.path.isfile(auto_start): subprocess.Popen([auto_start, romfile], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) -async def main(): +async def main() -> None: multiprocessing.freeze_support() parser = get_base_parser() parser.add_argument('diff_file', default="", type=str, nargs="?", @@ -1350,12 +672,13 @@ async def main(): time.sleep(3) sys.exit() elif args.diff_file.endswith(".aplttp"): + from worlds.alttp.Client import get_alttp_settings adjustedromfile, adjusted = get_alttp_settings(romfile) asyncio.create_task(run_game(adjustedromfile if adjusted else romfile)) else: asyncio.create_task(run_game(romfile)) - ctx = Context(args.snes, args.connect, args.password) + ctx = SNIContext(args.snes, args.connect, args.password) if ctx.server_task is None: ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") @@ -1376,132 +699,6 @@ async def main(): await ctx.shutdown() -def get_alttp_settings(romfile: str): - lastSettings = Utils.get_adjuster_settings(GAME_ALTTP) - adjustedromfile = '' - if lastSettings: - choice = 'no' - if not hasattr(lastSettings, 'auto_apply') or 'ask' in lastSettings.auto_apply: - - whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap", - "uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes", - "reduceflashing", "deathlink", "allowcollect"} - printed_options = {name: value for name, value in vars(lastSettings).items() if name in whitelist} - if hasattr(lastSettings, "sprite_pool"): - sprite_pool = {} - for sprite in lastSettings.sprite_pool: - if sprite in sprite_pool: - sprite_pool[sprite] += 1 - else: - sprite_pool[sprite] = 1 - if sprite_pool: - printed_options["sprite_pool"] = sprite_pool - import pprint - - if gui_enabled: - - try: - from tkinter import Tk, PhotoImage, Label, LabelFrame, Frame, Button - applyPromptWindow = Tk() - except Exception as e: - logging.error('Could not load tkinter, which is likely not installed.') - return '', False - - applyPromptWindow.resizable(False, False) - applyPromptWindow.protocol('WM_DELETE_WINDOW', lambda: onButtonClick()) - logo = PhotoImage(file=Utils.local_path('data', 'icon.png')) - applyPromptWindow.tk.call('wm', 'iconphoto', applyPromptWindow._w, logo) - applyPromptWindow.wm_title("Last adjuster settings LttP") - - label = LabelFrame(applyPromptWindow, - text='Last used adjuster settings were found. Would you like to apply these?') - label.grid(column=0, row=0, padx=5, pady=5, ipadx=5, ipady=5) - label.grid_columnconfigure(0, weight=1) - label.grid_columnconfigure(1, weight=1) - label.grid_columnconfigure(2, weight=1) - label.grid_columnconfigure(3, weight=1) - - def onButtonClick(answer: str = 'no'): - setattr(onButtonClick, 'choice', answer) - applyPromptWindow.destroy() - - framedOptions = Frame(label) - framedOptions.grid(column=0, columnspan=4, row=0) - framedOptions.grid_columnconfigure(0, weight=1) - framedOptions.grid_columnconfigure(1, weight=1) - framedOptions.grid_columnconfigure(2, weight=1) - curRow = 0 - curCol = 0 - for name, value in printed_options.items(): - Label(framedOptions, text=name + ": " + str(value)).grid(column=curCol, row=curRow, padx=5) - if (curCol == 2): - curRow += 1 - curCol = 0 - else: - curCol += 1 - - yesButton = Button(label, text='Yes', command=lambda: onButtonClick('yes'), width=10) - yesButton.grid(column=0, row=1) - noButton = Button(label, text='No', command=lambda: onButtonClick('no'), width=10) - noButton.grid(column=1, row=1) - alwaysButton = Button(label, text='Always', command=lambda: onButtonClick('always'), width=10) - alwaysButton.grid(column=2, row=1) - neverButton = Button(label, text='Never', command=lambda: onButtonClick('never'), width=10) - neverButton.grid(column=3, row=1) - - Utils.tkinter_center_window(applyPromptWindow) - applyPromptWindow.mainloop() - choice = getattr(onButtonClick, 'choice') - else: - choice = input(f"Last used adjuster settings were found. Would you like to apply these? \n" - f"{pprint.pformat(printed_options)}\n" - f"Enter yes, no, always or never: ") - if choice and choice.startswith("y"): - choice = 'yes' - elif choice and "never" in choice: - choice = 'no' - lastSettings.auto_apply = 'never' - Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings) - elif choice and "always" in choice: - choice = 'yes' - lastSettings.auto_apply = 'always' - Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings) - else: - choice = 'no' - elif 'never' in lastSettings.auto_apply: - choice = 'no' - elif 'always' in lastSettings.auto_apply: - choice = 'yes' - - if 'yes' in choice: - from worlds.alttp.Rom import get_base_rom_path - lastSettings.rom = romfile - lastSettings.baserom = get_base_rom_path() - lastSettings.world = None - - if hasattr(lastSettings, "sprite_pool"): - from LttPAdjuster import AdjusterWorld - lastSettings.world = AdjusterWorld(getattr(lastSettings, "sprite_pool")) - - adjusted = True - import LttPAdjuster - _, adjustedromfile = LttPAdjuster.adjust(lastSettings) - - if hasattr(lastSettings, "world"): - delattr(lastSettings, "world") - else: - adjusted = False - if adjusted: - try: - shutil.move(adjustedromfile, romfile) - adjustedromfile = romfile - except Exception as e: - logging.exception(e) - else: - adjusted = False - return adjustedromfile, adjusted - - if __name__ == '__main__': colorama.init() asyncio.run(main()) diff --git a/Utils.py b/Utils.py index d28834b6..64a028fc 100644 --- a/Utils.py +++ b/Utils.py @@ -141,7 +141,7 @@ def user_path(*path: str) -> str: return os.path.join(user_path.cached_path, *path) -def output_path(*path: str): +def output_path(*path: str) -> str: if hasattr(output_path, 'cached_path'): return os.path.join(output_path.cached_path, *path) output_path.cached_path = user_path(get_options()["general_options"]["output_path"]) @@ -232,19 +232,18 @@ def get_default_options() -> OptionsType: "factorio_options": { "executable": os.path.join("factorio", "bin", "x64", "factorio"), }, + "sni_options": { + "sni": "SNI", + "snes_rom_start": True, + }, "sm_options": { "rom_file": "Super Metroid (JU).sfc", - "sni": "SNI", - "rom_start": True, }, "soe_options": { "rom_file": "Secret of Evermore (USA).sfc", }, "lttp_options": { "rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc", - "sni": "SNI", - "rom_start": True, - }, "server_options": { "host": None, @@ -287,13 +286,9 @@ def get_default_options() -> OptionsType: }, "dkc3_options": { "rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc", - "sni": "SNI", - "rom_start": True, }, "smw_options": { "rom_file": "Super Mario World (USA).sfc", - "sni": "SNI", - "rom_start": True, }, "zillion_options": { "rom_file": "Zillion (UE) [!].sms", diff --git a/host.yaml b/host.yaml index 2bb0e5ef..2c5a8e3e 100644 --- a/host.yaml +++ b/host.yaml @@ -82,24 +82,19 @@ generator: # List of options that can be plando'd. Can be combined, for example "bosses, items" # Available options: bosses, items, texts, connections plando_options: "bosses" +sni_options: + # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found + sni_path: "SNI" + # Set this to false to never autostart a rom (such as after patching) + # True for operating system default program + # Alternatively, a path to a program to open the .sfc file with + snes_rom_start: true lttp_options: # File name of the v1.0 J rom rom_file: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc" - # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found - sni: "SNI" - # Set this to false to never autostart a rom (such as after patching) - # True for operating system default program - # Alternatively, a path to a program to open the .sfc file with - rom_start: true sm_options: # File name of the v1.0 J rom rom_file: "Super Metroid (JU).sfc" - # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found - sni: "SNI" - # Set this to false to never autostart a rom (such as after patching) - # True for operating system default program - # Alternatively, a path to a program to open the .sfc file with - rom_start: true factorio_options: executable: "factorio/bin/x64/factorio" # by default, no settings are loaded if this file does not exist. If this file does exist, then it will be used. @@ -122,22 +117,12 @@ soe_options: rom_file: "Secret of Evermore (USA).sfc" ffr_options: display_msgs: true -smz3_options: - # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found - sni: "SNI" - # Set this to false to never autostart a rom (such as after patching) - # True for operating system default program - # Alternatively, a path to a program to open the .sfc file with - rom_start: true dkc3_options: # File name of the DKC3 US rom rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc" - # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found - sni: "SNI" - # Set this to false to never autostart a rom (such as after patching) - # True for operating system default program - # Alternatively, a path to a program to open the .sfc file with - rom_start: true +smw_options: + # File name of the SMW US rom + rom_file: "Super Mario World (USA).sfc" pokemon_rb_options: # File names of the Pokemon Red and Blue roms red_rom_file: "Pokemon Red (UE) [S][!].gb" @@ -146,15 +131,6 @@ pokemon_rb_options: # True for operating system default program # Alternatively, a path to a program to open the .gb file with rom_start: true -smw_options: - # File name of the SMW US rom - rom_file: "Super Mario World (USA).sfc" - # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found - sni: "SNI" - # Set this to false to never autostart a rom (such as after patching) - # True for operating system default program - # Alternatively, a path to a program to open the .sfc file with - rom_start: true zillion_options: # File name of the Zillion US rom rom_file: "Zillion (UE) [!].sms" diff --git a/worlds/AutoSNIClient.py b/worlds/AutoSNIClient.py new file mode 100644 index 00000000..a30dbbb4 --- /dev/null +++ b/worlds/AutoSNIClient.py @@ -0,0 +1,42 @@ + +from __future__ import annotations +import abc +from typing import TYPE_CHECKING, ClassVar, Dict, Tuple, Any, Optional + +if TYPE_CHECKING: + from SNIClient import SNIContext + + +class AutoSNIClientRegister(abc.ABCMeta): + game_handlers: ClassVar[Dict[str, SNIClient]] = {} + + def __new__(cls, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoSNIClientRegister: + # construct class + new_class = super().__new__(cls, name, bases, dct) + if "game" in dct: + AutoSNIClientRegister.game_handlers[dct["game"]] = new_class() + return new_class + + @staticmethod + async def get_handler(ctx: SNIContext) -> Optional[SNIClient]: + for _game, handler in AutoSNIClientRegister.game_handlers.items(): + if await handler.validate_rom(ctx): + return handler + return None + + +class SNIClient(abc.ABC, metaclass=AutoSNIClientRegister): + + @abc.abstractmethod + async def validate_rom(self, ctx: SNIContext) -> bool: + """ TODO: interface documentation here """ + ... + + @abc.abstractmethod + async def game_watcher(self, ctx: SNIContext) -> None: + """ TODO: interface documentation here """ + ... + + async def deathlink_kill_player(self, ctx: SNIContext) -> None: + """ override this with implementation to kill player """ + pass diff --git a/worlds/alttp/Client.py b/worlds/alttp/Client.py new file mode 100644 index 00000000..b3a12a7f --- /dev/null +++ b/worlds/alttp/Client.py @@ -0,0 +1,693 @@ +from __future__ import annotations + +import logging +import asyncio +import shutil +import time + +import Utils + +from NetUtils import ClientStatus, color +from worlds.AutoSNIClient import SNIClient + +from worlds.alttp import Shops, Regions +from .Rom import ROM_PLAYER_LIMIT + +snes_logger = logging.getLogger("SNES") + +GAME_ALTTP = "A Link to the Past" + +# FXPAK Pro protocol memory mapping used by SNI +ROM_START = 0x000000 +WRAM_START = 0xF50000 +WRAM_SIZE = 0x20000 +SRAM_START = 0xE00000 + +ROMNAME_START = SRAM_START + 0x2000 +ROMNAME_SIZE = 0x15 + +INGAME_MODES = {0x07, 0x09, 0x0b} +ENDGAME_MODES = {0x19, 0x1a} +DEATH_MODES = {0x12} + +SAVEDATA_START = WRAM_START + 0xF000 +SAVEDATA_SIZE = 0x500 + +RECV_PROGRESS_ADDR = SAVEDATA_START + 0x4D0 # 2 bytes +RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte +RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte +ROOMID_ADDR = SAVEDATA_START + 0x4D4 # 2 bytes +ROOMDATA_ADDR = SAVEDATA_START + 0x4D6 # 1 byte +SCOUT_LOCATION_ADDR = SAVEDATA_START + 0x4D7 # 1 byte +SCOUTREPLY_LOCATION_ADDR = SAVEDATA_START + 0x4D8 # 1 byte +SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte +SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte +SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes +SHOP_LEN = (len(Shops.shop_table) * 3) + 5 + +DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte + +location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()]) + +location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10), + "Blind's Hideout - Left": (0x11d, 0x20), + "Blind's Hideout - Right": (0x11d, 0x40), + "Blind's Hideout - Far Left": (0x11d, 0x80), + "Blind's Hideout - Far Right": (0x11d, 0x100), + 'Secret Passage': (0x55, 0x10), + 'Waterfall Fairy - Left': (0x114, 0x10), + 'Waterfall Fairy - Right': (0x114, 0x20), + "King's Tomb": (0x113, 0x10), + 'Floodgate Chest': (0x10b, 0x10), + "Link's House": (0x104, 0x10), + 'Kakariko Tavern': (0x103, 0x10), + 'Chicken House': (0x108, 0x10), + "Aginah's Cave": (0x10a, 0x10), + "Sahasrahla's Hut - Left": (0x105, 0x10), + "Sahasrahla's Hut - Middle": (0x105, 0x20), + "Sahasrahla's Hut - Right": (0x105, 0x40), + 'Kakariko Well - Top': (0x2f, 0x10), + 'Kakariko Well - Left': (0x2f, 0x20), + 'Kakariko Well - Middle': (0x2f, 0x40), + 'Kakariko Well - Right': (0x2f, 0x80), + 'Kakariko Well - Bottom': (0x2f, 0x100), + 'Lost Woods Hideout': (0xe1, 0x200), + 'Lumberjack Tree': (0xe2, 0x200), + 'Cave 45': (0x11b, 0x400), + 'Graveyard Cave': (0x11b, 0x200), + 'Checkerboard Cave': (0x126, 0x200), + 'Mini Moldorm Cave - Far Left': (0x123, 0x10), + 'Mini Moldorm Cave - Left': (0x123, 0x20), + 'Mini Moldorm Cave - Right': (0x123, 0x40), + 'Mini Moldorm Cave - Far Right': (0x123, 0x80), + 'Mini Moldorm Cave - Generous Guy': (0x123, 0x400), + 'Ice Rod Cave': (0x120, 0x10), + 'Bonk Rock Cave': (0x124, 0x10), + 'Desert Palace - Big Chest': (0x73, 0x10), + 'Desert Palace - Torch': (0x73, 0x400), + 'Desert Palace - Map Chest': (0x74, 0x10), + 'Desert Palace - Compass Chest': (0x85, 0x10), + 'Desert Palace - Big Key Chest': (0x75, 0x10), + 'Desert Palace - Desert Tiles 1 Pot Key': (0x63, 0x400), + 'Desert Palace - Beamos Hall Pot Key': (0x53, 0x400), + 'Desert Palace - Desert Tiles 2 Pot Key': (0x43, 0x400), + 'Desert Palace - Boss': (0x33, 0x800), + 'Eastern Palace - Compass Chest': (0xa8, 0x10), + 'Eastern Palace - Big Chest': (0xa9, 0x10), + 'Eastern Palace - Dark Square Pot Key': (0xba, 0x400), + 'Eastern Palace - Dark Eyegore Key Drop': (0x99, 0x400), + 'Eastern Palace - Cannonball Chest': (0xb9, 0x10), + 'Eastern Palace - Big Key Chest': (0xb8, 0x10), + 'Eastern Palace - Map Chest': (0xaa, 0x10), + 'Eastern Palace - Boss': (0xc8, 0x800), + 'Hyrule Castle - Boomerang Chest': (0x71, 0x10), + 'Hyrule Castle - Boomerang Guard Key Drop': (0x71, 0x400), + 'Hyrule Castle - Map Chest': (0x72, 0x10), + 'Hyrule Castle - Map Guard Key Drop': (0x72, 0x400), + "Hyrule Castle - Zelda's Chest": (0x80, 0x10), + 'Hyrule Castle - Big Key Drop': (0x80, 0x400), + 'Sewers - Dark Cross': (0x32, 0x10), + 'Hyrule Castle - Key Rat Key Drop': (0x21, 0x400), + 'Sewers - Secret Room - Left': (0x11, 0x10), + 'Sewers - Secret Room - Middle': (0x11, 0x20), + 'Sewers - Secret Room - Right': (0x11, 0x40), + 'Sanctuary': (0x12, 0x10), + 'Castle Tower - Room 03': (0xe0, 0x10), + 'Castle Tower - Dark Maze': (0xd0, 0x10), + 'Castle Tower - Dark Archer Key Drop': (0xc0, 0x400), + 'Castle Tower - Circle of Pots Key Drop': (0xb0, 0x400), + 'Spectacle Rock Cave': (0xea, 0x400), + 'Paradox Cave Lower - Far Left': (0xef, 0x10), + 'Paradox Cave Lower - Left': (0xef, 0x20), + 'Paradox Cave Lower - Right': (0xef, 0x40), + 'Paradox Cave Lower - Far Right': (0xef, 0x80), + 'Paradox Cave Lower - Middle': (0xef, 0x100), + 'Paradox Cave Upper - Left': (0xff, 0x10), + 'Paradox Cave Upper - Right': (0xff, 0x20), + 'Spiral Cave': (0xfe, 0x10), + 'Tower of Hera - Basement Cage': (0x87, 0x400), + 'Tower of Hera - Map Chest': (0x77, 0x10), + 'Tower of Hera - Big Key Chest': (0x87, 0x10), + 'Tower of Hera - Compass Chest': (0x27, 0x20), + 'Tower of Hera - Big Chest': (0x27, 0x10), + 'Tower of Hera - Boss': (0x7, 0x800), + 'Hype Cave - Top': (0x11e, 0x10), + 'Hype Cave - Middle Right': (0x11e, 0x20), + 'Hype Cave - Middle Left': (0x11e, 0x40), + 'Hype Cave - Bottom': (0x11e, 0x80), + 'Hype Cave - Generous Guy': (0x11e, 0x400), + 'Peg Cave': (0x127, 0x400), + 'Pyramid Fairy - Left': (0x116, 0x10), + 'Pyramid Fairy - Right': (0x116, 0x20), + 'Brewery': (0x106, 0x10), + 'C-Shaped House': (0x11c, 0x10), + 'Chest Game': (0x106, 0x400), + 'Mire Shed - Left': (0x10d, 0x10), + 'Mire Shed - Right': (0x10d, 0x20), + 'Superbunny Cave - Top': (0xf8, 0x10), + 'Superbunny Cave - Bottom': (0xf8, 0x20), + 'Spike Cave': (0x117, 0x10), + 'Hookshot Cave - Top Right': (0x3c, 0x10), + 'Hookshot Cave - Top Left': (0x3c, 0x20), + 'Hookshot Cave - Bottom Right': (0x3c, 0x80), + 'Hookshot Cave - Bottom Left': (0x3c, 0x40), + 'Mimic Cave': (0x10c, 0x10), + 'Swamp Palace - Entrance': (0x28, 0x10), + 'Swamp Palace - Map Chest': (0x37, 0x10), + 'Swamp Palace - Pot Row Pot Key': (0x38, 0x400), + 'Swamp Palace - Trench 1 Pot Key': (0x37, 0x400), + 'Swamp Palace - Hookshot Pot Key': (0x36, 0x400), + 'Swamp Palace - Big Chest': (0x36, 0x10), + 'Swamp Palace - Compass Chest': (0x46, 0x10), + 'Swamp Palace - Trench 2 Pot Key': (0x35, 0x400), + 'Swamp Palace - Big Key Chest': (0x35, 0x10), + 'Swamp Palace - West Chest': (0x34, 0x10), + 'Swamp Palace - Flooded Room - Left': (0x76, 0x10), + 'Swamp Palace - Flooded Room - Right': (0x76, 0x20), + 'Swamp Palace - Waterfall Room': (0x66, 0x10), + 'Swamp Palace - Waterway Pot Key': (0x16, 0x400), + 'Swamp Palace - Boss': (0x6, 0x800), + "Thieves' Town - Big Key Chest": (0xdb, 0x20), + "Thieves' Town - Map Chest": (0xdb, 0x10), + "Thieves' Town - Compass Chest": (0xdc, 0x10), + "Thieves' Town - Ambush Chest": (0xcb, 0x10), + "Thieves' Town - Hallway Pot Key": (0xbc, 0x400), + "Thieves' Town - Spike Switch Pot Key": (0xab, 0x400), + "Thieves' Town - Attic": (0x65, 0x10), + "Thieves' Town - Big Chest": (0x44, 0x10), + "Thieves' Town - Blind's Cell": (0x45, 0x10), + "Thieves' Town - Boss": (0xac, 0x800), + 'Skull Woods - Compass Chest': (0x67, 0x10), + 'Skull Woods - Map Chest': (0x58, 0x20), + 'Skull Woods - Big Chest': (0x58, 0x10), + 'Skull Woods - Pot Prison': (0x57, 0x20), + 'Skull Woods - Pinball Room': (0x68, 0x10), + 'Skull Woods - Big Key Chest': (0x57, 0x10), + 'Skull Woods - West Lobby Pot Key': (0x56, 0x400), + 'Skull Woods - Bridge Room': (0x59, 0x10), + 'Skull Woods - Spike Corner Key Drop': (0x39, 0x400), + 'Skull Woods - Boss': (0x29, 0x800), + 'Ice Palace - Jelly Key Drop': (0x0e, 0x400), + 'Ice Palace - Compass Chest': (0x2e, 0x10), + 'Ice Palace - Conveyor Key Drop': (0x3e, 0x400), + 'Ice Palace - Freezor Chest': (0x7e, 0x10), + 'Ice Palace - Big Chest': (0x9e, 0x10), + 'Ice Palace - Iced T Room': (0xae, 0x10), + 'Ice Palace - Many Pots Pot Key': (0x9f, 0x400), + 'Ice Palace - Spike Room': (0x5f, 0x10), + 'Ice Palace - Big Key Chest': (0x1f, 0x10), + 'Ice Palace - Hammer Block Key Drop': (0x3f, 0x400), + 'Ice Palace - Map Chest': (0x3f, 0x10), + 'Ice Palace - Boss': (0xde, 0x800), + 'Misery Mire - Big Chest': (0xc3, 0x10), + 'Misery Mire - Map Chest': (0xc3, 0x20), + 'Misery Mire - Main Lobby': (0xc2, 0x10), + 'Misery Mire - Bridge Chest': (0xa2, 0x10), + 'Misery Mire - Spikes Pot Key': (0xb3, 0x400), + 'Misery Mire - Spike Chest': (0xb3, 0x10), + 'Misery Mire - Fishbone Pot Key': (0xa1, 0x400), + 'Misery Mire - Conveyor Crystal Key Drop': (0xc1, 0x400), + 'Misery Mire - Compass Chest': (0xc1, 0x10), + 'Misery Mire - Big Key Chest': (0xd1, 0x10), + 'Misery Mire - Boss': (0x90, 0x800), + 'Turtle Rock - Compass Chest': (0xd6, 0x10), + 'Turtle Rock - Roller Room - Left': (0xb7, 0x10), + 'Turtle Rock - Roller Room - Right': (0xb7, 0x20), + 'Turtle Rock - Pokey 1 Key Drop': (0xb6, 0x400), + 'Turtle Rock - Chain Chomps': (0xb6, 0x10), + 'Turtle Rock - Pokey 2 Key Drop': (0x13, 0x400), + 'Turtle Rock - Big Key Chest': (0x14, 0x10), + 'Turtle Rock - Big Chest': (0x24, 0x10), + 'Turtle Rock - Crystaroller Room': (0x4, 0x10), + 'Turtle Rock - Eye Bridge - Bottom Left': (0xd5, 0x80), + 'Turtle Rock - Eye Bridge - Bottom Right': (0xd5, 0x40), + 'Turtle Rock - Eye Bridge - Top Left': (0xd5, 0x20), + 'Turtle Rock - Eye Bridge - Top Right': (0xd5, 0x10), + 'Turtle Rock - Boss': (0xa4, 0x800), + 'Palace of Darkness - Shooter Room': (0x9, 0x10), + 'Palace of Darkness - The Arena - Bridge': (0x2a, 0x20), + 'Palace of Darkness - Stalfos Basement': (0xa, 0x10), + 'Palace of Darkness - Big Key Chest': (0x3a, 0x10), + 'Palace of Darkness - The Arena - Ledge': (0x2a, 0x10), + 'Palace of Darkness - Map Chest': (0x2b, 0x10), + 'Palace of Darkness - Compass Chest': (0x1a, 0x20), + 'Palace of Darkness - Dark Basement - Left': (0x6a, 0x10), + 'Palace of Darkness - Dark Basement - Right': (0x6a, 0x20), + 'Palace of Darkness - Dark Maze - Top': (0x19, 0x10), + 'Palace of Darkness - Dark Maze - Bottom': (0x19, 0x20), + 'Palace of Darkness - Big Chest': (0x1a, 0x10), + 'Palace of Darkness - Harmless Hellway': (0x1a, 0x40), + 'Palace of Darkness - Boss': (0x5a, 0x800), + 'Ganons Tower - Conveyor Cross Pot Key': (0x8b, 0x400), + "Ganons Tower - Bob's Torch": (0x8c, 0x400), + 'Ganons Tower - Hope Room - Left': (0x8c, 0x20), + 'Ganons Tower - Hope Room - Right': (0x8c, 0x40), + 'Ganons Tower - Tile Room': (0x8d, 0x10), + 'Ganons Tower - Compass Room - Top Left': (0x9d, 0x10), + 'Ganons Tower - Compass Room - Top Right': (0x9d, 0x20), + 'Ganons Tower - Compass Room - Bottom Left': (0x9d, 0x40), + 'Ganons Tower - Compass Room - Bottom Right': (0x9d, 0x80), + 'Ganons Tower - Conveyor Star Pits Pot Key': (0x7b, 0x400), + 'Ganons Tower - DMs Room - Top Left': (0x7b, 0x10), + 'Ganons Tower - DMs Room - Top Right': (0x7b, 0x20), + 'Ganons Tower - DMs Room - Bottom Left': (0x7b, 0x40), + 'Ganons Tower - DMs Room - Bottom Right': (0x7b, 0x80), + 'Ganons Tower - Map Chest': (0x8b, 0x10), + 'Ganons Tower - Double Switch Pot Key': (0x9b, 0x400), + 'Ganons Tower - Firesnake Room': (0x7d, 0x10), + 'Ganons Tower - Randomizer Room - Top Left': (0x7c, 0x10), + 'Ganons Tower - Randomizer Room - Top Right': (0x7c, 0x20), + 'Ganons Tower - Randomizer Room - Bottom Left': (0x7c, 0x40), + 'Ganons Tower - Randomizer Room - Bottom Right': (0x7c, 0x80), + "Ganons Tower - Bob's Chest": (0x8c, 0x80), + 'Ganons Tower - Big Chest': (0x8c, 0x10), + 'Ganons Tower - Big Key Room - Left': (0x1c, 0x20), + 'Ganons Tower - Big Key Room - Right': (0x1c, 0x40), + 'Ganons Tower - Big Key Chest': (0x1c, 0x10), + 'Ganons Tower - Mini Helmasaur Room - Left': (0x3d, 0x10), + 'Ganons Tower - Mini Helmasaur Room - Right': (0x3d, 0x20), + 'Ganons Tower - Mini Helmasaur Key Drop': (0x3d, 0x400), + 'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40), + 'Ganons Tower - Validation Chest': (0x4d, 0x10)} + +boss_locations = {Regions.lookup_name_to_id[name] for name in {'Eastern Palace - Boss', + 'Desert Palace - Boss', + 'Tower of Hera - Boss', + 'Palace of Darkness - Boss', + 'Swamp Palace - Boss', + 'Skull Woods - Boss', + "Thieves' Town - Boss", + 'Ice Palace - Boss', + 'Misery Mire - Boss', + 'Turtle Rock - Boss', + 'Sahasrahla'}} + +location_table_uw_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_uw.items()} + +location_table_npc = {'Mushroom': 0x1000, + 'King Zora': 0x2, + 'Sahasrahla': 0x10, + 'Blacksmith': 0x400, + 'Magic Bat': 0x8000, + 'Sick Kid': 0x4, + 'Library': 0x80, + 'Potion Shop': 0x2000, + 'Old Man': 0x1, + 'Ether Tablet': 0x100, + 'Catfish': 0x20, + 'Stumpy': 0x8, + 'Bombos Tablet': 0x200} + +location_table_npc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_npc.items()} + +location_table_ow = {'Flute Spot': 0x2a, + 'Sunken Treasure': 0x3b, + "Zora's Ledge": 0x81, + 'Lake Hylia Island': 0x35, + 'Maze Race': 0x28, + 'Desert Ledge': 0x30, + 'Master Sword Pedestal': 0x80, + 'Spectacle Rock': 0x3, + 'Pyramid': 0x5b, + 'Digging Game': 0x68, + 'Bumper Cave Ledge': 0x4a, + 'Floating Island': 0x5} + +location_table_ow_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_ow.items()} + +location_table_misc = {'Bottle Merchant': (0x3c9, 0x2), + 'Purple Chest': (0x3c9, 0x10), + "Link's Uncle": (0x3c6, 0x1), + 'Hobo': (0x3c9, 0x1)} +location_table_misc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_misc.items()} + + +async def track_locations(ctx, roomid, roomdata): + from SNIClient import snes_read, snes_buffered_write, snes_flush_writes + new_locations = [] + + def new_check(location_id): + new_locations.append(location_id) + ctx.locations_checked.add(location_id) + location = ctx.location_names[location_id] + snes_logger.info( + f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') + + try: + shop_data = await snes_read(ctx, SHOP_ADDR, SHOP_LEN) + shop_data_changed = False + shop_data = list(shop_data) + for cnt, b in enumerate(shop_data): + location = Shops.SHOP_ID_START + cnt + if int(b) and location not in ctx.locations_checked: + new_check(location) + if ctx.allow_collect and location in ctx.checked_locations and location not in ctx.locations_checked \ + and location in ctx.locations_info and ctx.locations_info[location].player != ctx.slot: + if not int(b): + shop_data[cnt] += 1 + shop_data_changed = True + if shop_data_changed: + snes_buffered_write(ctx, SHOP_ADDR, bytes(shop_data)) + except Exception as e: + snes_logger.info(f"Exception: {e}") + + for location_id, (loc_roomid, loc_mask) in location_table_uw_id.items(): + try: + if location_id not in ctx.locations_checked and loc_roomid == roomid and \ + (roomdata << 4) & loc_mask != 0: + new_check(location_id) + except Exception as e: + snes_logger.exception(f"Exception: {e}") + + uw_begin = 0x129 + ow_end = uw_end = 0 + uw_unchecked = {} + uw_checked = {} + for location, (roomid, mask) in location_table_uw.items(): + location_id = Regions.lookup_name_to_id[location] + if location_id not in ctx.locations_checked: + uw_unchecked[location_id] = (roomid, mask) + uw_begin = min(uw_begin, roomid) + uw_end = max(uw_end, roomid + 1) + if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \ + and location_id not in ctx.locations_checked and location_id in ctx.locations_info \ + and ctx.locations_info[location_id].player != ctx.slot: + uw_begin = min(uw_begin, roomid) + uw_end = max(uw_end, roomid + 1) + uw_checked[location_id] = (roomid, mask) + + if uw_begin < uw_end: + uw_data = await snes_read(ctx, SAVEDATA_START + (uw_begin * 2), (uw_end - uw_begin) * 2) + if uw_data is not None: + for location_id, (roomid, mask) in uw_unchecked.items(): + offset = (roomid - uw_begin) * 2 + roomdata = uw_data[offset] | (uw_data[offset + 1] << 8) + if roomdata & mask != 0: + new_check(location_id) + if uw_checked: + uw_data = list(uw_data) + for location_id, (roomid, mask) in uw_checked.items(): + offset = (roomid - uw_begin) * 2 + roomdata = uw_data[offset] | (uw_data[offset + 1] << 8) + roomdata |= mask + uw_data[offset] = roomdata & 0xFF + uw_data[offset + 1] = roomdata >> 8 + snes_buffered_write(ctx, SAVEDATA_START + (uw_begin * 2), bytes(uw_data)) + + ow_begin = 0x82 + ow_unchecked = {} + ow_checked = {} + for location_id, screenid in location_table_ow_id.items(): + if location_id not in ctx.locations_checked: + ow_unchecked[location_id] = screenid + ow_begin = min(ow_begin, screenid) + ow_end = max(ow_end, screenid + 1) + if ctx.allow_collect and location_id in ctx.checked_locations and location_id in ctx.locations_info \ + and ctx.locations_info[location_id].player != ctx.slot: + ow_checked[location_id] = screenid + + if ow_begin < ow_end: + ow_data = await snes_read(ctx, SAVEDATA_START + 0x280 + ow_begin, ow_end - ow_begin) + if ow_data is not None: + for location_id, screenid in ow_unchecked.items(): + if ow_data[screenid - ow_begin] & 0x40 != 0: + new_check(location_id) + if ow_checked: + ow_data = list(ow_data) + for location_id, screenid in ow_checked.items(): + ow_data[screenid - ow_begin] |= 0x40 + snes_buffered_write(ctx, SAVEDATA_START + 0x280 + ow_begin, bytes(ow_data)) + + if not ctx.locations_checked.issuperset(location_table_npc_id): + npc_data = await snes_read(ctx, SAVEDATA_START + 0x410, 2) + if npc_data is not None: + npc_value_changed = False + npc_value = npc_data[0] | (npc_data[1] << 8) + for location_id, mask in location_table_npc_id.items(): + if npc_value & mask != 0 and location_id not in ctx.locations_checked: + new_check(location_id) + if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \ + and location_id not in ctx.locations_checked and location_id in ctx.locations_info \ + and ctx.locations_info[location_id].player != ctx.slot: + npc_value |= mask + npc_value_changed = True + if npc_value_changed: + npc_data = bytes([npc_value & 0xFF, npc_value >> 8]) + snes_buffered_write(ctx, SAVEDATA_START + 0x410, npc_data) + + if not ctx.locations_checked.issuperset(location_table_misc_id): + misc_data = await snes_read(ctx, SAVEDATA_START + 0x3c6, 4) + if misc_data is not None: + misc_data = list(misc_data) + misc_data_changed = False + for location_id, (offset, mask) in location_table_misc_id.items(): + assert (0x3c6 <= offset <= 0x3c9) + if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked: + new_check(location_id) + if ctx.allow_collect and location_id in ctx.checked_locations and location_id not in ctx.locations_checked \ + and location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot: + misc_data_changed = True + misc_data[offset - 0x3c6] |= mask + if misc_data_changed: + snes_buffered_write(ctx, SAVEDATA_START + 0x3c6, bytes(misc_data)) + + + if new_locations: + await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}]) + await snes_flush_writes(ctx) + + +def get_alttp_settings(romfile: str): + lastSettings = Utils.get_adjuster_settings(GAME_ALTTP) + adjustedromfile = '' + if lastSettings: + choice = 'no' + if not hasattr(lastSettings, 'auto_apply') or 'ask' in lastSettings.auto_apply: + + whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap", + "uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes", + "reduceflashing", "deathlink", "allowcollect"} + printed_options = {name: value for name, value in vars(lastSettings).items() if name in whitelist} + if hasattr(lastSettings, "sprite_pool"): + sprite_pool = {} + for sprite in lastSettings.sprite_pool: + if sprite in sprite_pool: + sprite_pool[sprite] += 1 + else: + sprite_pool[sprite] = 1 + if sprite_pool: + printed_options["sprite_pool"] = sprite_pool + import pprint + + from CommonClient import gui_enabled + if gui_enabled: + + try: + from tkinter import Tk, PhotoImage, Label, LabelFrame, Frame, Button + applyPromptWindow = Tk() + except Exception as e: + logging.error('Could not load tkinter, which is likely not installed.') + return '', False + + applyPromptWindow.resizable(False, False) + applyPromptWindow.protocol('WM_DELETE_WINDOW', lambda: onButtonClick()) + logo = PhotoImage(file=Utils.local_path('data', 'icon.png')) + applyPromptWindow.tk.call('wm', 'iconphoto', applyPromptWindow._w, logo) + applyPromptWindow.wm_title("Last adjuster settings LttP") + + label = LabelFrame(applyPromptWindow, + text='Last used adjuster settings were found. Would you like to apply these?') + label.grid(column=0, row=0, padx=5, pady=5, ipadx=5, ipady=5) + label.grid_columnconfigure(0, weight=1) + label.grid_columnconfigure(1, weight=1) + label.grid_columnconfigure(2, weight=1) + label.grid_columnconfigure(3, weight=1) + + def onButtonClick(answer: str = 'no'): + setattr(onButtonClick, 'choice', answer) + applyPromptWindow.destroy() + + framedOptions = Frame(label) + framedOptions.grid(column=0, columnspan=4, row=0) + framedOptions.grid_columnconfigure(0, weight=1) + framedOptions.grid_columnconfigure(1, weight=1) + framedOptions.grid_columnconfigure(2, weight=1) + curRow = 0 + curCol = 0 + for name, value in printed_options.items(): + Label(framedOptions, text=name + ": " + str(value)).grid(column=curCol, row=curRow, padx=5) + if (curCol == 2): + curRow += 1 + curCol = 0 + else: + curCol += 1 + + yesButton = Button(label, text='Yes', command=lambda: onButtonClick('yes'), width=10) + yesButton.grid(column=0, row=1) + noButton = Button(label, text='No', command=lambda: onButtonClick('no'), width=10) + noButton.grid(column=1, row=1) + alwaysButton = Button(label, text='Always', command=lambda: onButtonClick('always'), width=10) + alwaysButton.grid(column=2, row=1) + neverButton = Button(label, text='Never', command=lambda: onButtonClick('never'), width=10) + neverButton.grid(column=3, row=1) + + Utils.tkinter_center_window(applyPromptWindow) + applyPromptWindow.mainloop() + choice = getattr(onButtonClick, 'choice') + else: + choice = input(f"Last used adjuster settings were found. Would you like to apply these? \n" + f"{pprint.pformat(printed_options)}\n" + f"Enter yes, no, always or never: ") + if choice and choice.startswith("y"): + choice = 'yes' + elif choice and "never" in choice: + choice = 'no' + lastSettings.auto_apply = 'never' + Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings) + elif choice and "always" in choice: + choice = 'yes' + lastSettings.auto_apply = 'always' + Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings) + else: + choice = 'no' + elif 'never' in lastSettings.auto_apply: + choice = 'no' + elif 'always' in lastSettings.auto_apply: + choice = 'yes' + + if 'yes' in choice: + from worlds.alttp.Rom import get_base_rom_path + lastSettings.rom = romfile + lastSettings.baserom = get_base_rom_path() + lastSettings.world = None + + if hasattr(lastSettings, "sprite_pool"): + from LttPAdjuster import AdjusterWorld + lastSettings.world = AdjusterWorld(getattr(lastSettings, "sprite_pool")) + + adjusted = True + import LttPAdjuster + _, adjustedromfile = LttPAdjuster.adjust(lastSettings) + + if hasattr(lastSettings, "world"): + delattr(lastSettings, "world") + else: + adjusted = False + if adjusted: + try: + shutil.move(adjustedromfile, romfile) + adjustedromfile = romfile + except Exception as e: + logging.exception(e) + else: + adjusted = False + return adjustedromfile, adjusted + + +class ALTTPSNIClient(SNIClient): + game = "A Link to the Past" + + async def deathlink_kill_player(self, ctx): + from SNIClient import DeathState, snes_read, snes_buffered_write, snes_flush_writes + invincible = await snes_read(ctx, WRAM_START + 0x037B, 1) + last_health = await snes_read(ctx, WRAM_START + 0xF36D, 1) + await asyncio.sleep(0.25) + health = await snes_read(ctx, WRAM_START + 0xF36D, 1) + if not invincible or not last_health or not health: + ctx.death_state = DeathState.dead + ctx.last_death_link = time.time() + return + if not invincible[0] and last_health[0] == health[0]: + snes_buffered_write(ctx, WRAM_START + 0xF36D, bytes([0])) # set current health to 0 + snes_buffered_write(ctx, WRAM_START + 0x0373, + bytes([8])) # deal 1 full heart of damage at next opportunity + + await snes_flush_writes(ctx) + await asyncio.sleep(1) + + gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) + if not gamemode or gamemode[0] in DEATH_MODES: + ctx.death_state = DeathState.dead + + + async def validate_rom(self, ctx): + from SNIClient import snes_read, snes_buffered_write, snes_flush_writes + + rom_name = await snes_read(ctx, ROMNAME_START, ROMNAME_SIZE) + if rom_name is None or rom_name == bytes([0] * ROMNAME_SIZE) or rom_name[:2] != b"AP": + return False + + ctx.game = self.game + ctx.items_handling = 0b001 # full local + + ctx.rom = rom_name + + death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR, 1) + + if death_link: + ctx.allow_collect = bool(death_link[0] & 0b100) + ctx.death_link_allow_survive = bool(death_link[0] & 0b10) + await ctx.update_death_link(bool(death_link[0] & 0b1)) + + return True + + + async def game_watcher(self, ctx): + from SNIClient import snes_read, snes_buffered_write, snes_flush_writes + gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) + if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): + currently_dead = gamemode[0] in DEATH_MODES + await ctx.handle_deathlink_state(currently_dead) + + gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1) + game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4) + if gamemode is None or gameend is None or game_timer is None or \ + (gamemode[0] not in INGAME_MODES and gamemode[0] not in ENDGAME_MODES): + return + + if gameend[0]: + if not ctx.finished_game: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + + if gamemode in ENDGAME_MODES: # triforce room and credits + return + + data = await snes_read(ctx, RECV_PROGRESS_ADDR, 8) + if data is None: + return + + recv_index = data[0] | (data[1] << 8) + recv_item = data[2] + roomid = data[4] | (data[5] << 8) + roomdata = data[6] + scout_location = data[7] + + if recv_index < len(ctx.items_received) and recv_item == 0: + item = ctx.items_received[recv_index] + recv_index += 1 + logging.info('Received %s from %s (%s) (%d/%d in list)' % ( + color(ctx.item_names[item.item], 'red', 'bold'), + color(ctx.player_names[item.player], 'yellow'), + ctx.location_names[item.location], recv_index, len(ctx.items_received))) + + snes_buffered_write(ctx, RECV_PROGRESS_ADDR, + bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) + snes_buffered_write(ctx, RECV_ITEM_ADDR, + bytes([item.item])) + snes_buffered_write(ctx, RECV_ITEM_PLAYER_ADDR, + bytes([min(ROM_PLAYER_LIMIT, item.player) if item.player != ctx.slot else 0])) + if scout_location > 0 and scout_location in ctx.locations_info: + snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR, + bytes([scout_location])) + snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR, + bytes([ctx.locations_info[scout_location].item])) + snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR, + bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location].player)])) + + await snes_flush_writes(ctx) + + if scout_location > 0 and scout_location not in ctx.locations_scouted: + ctx.locations_scouted.add(scout_location) + await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}]) + await track_locations(ctx, roomid, roomdata) diff --git a/worlds/alttp/__init__.py b/worlds/alttp/__init__.py index ce53154e..8431af9a 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -15,6 +15,7 @@ from .Items import item_init_table, item_name_groups, item_table, GetBeemizerIte from .Options import alttp_options, smallkey_shuffle from .Regions import lookup_name_to_id, create_regions, mark_light_world_regions, lookup_vanilla_location_to_entrance, \ is_main_entrance +from .Client import ALTTPSNIClient from .Rom import LocalRom, patch_rom, patch_race_rom, check_enemizer, patch_enemizer, apply_rom_settings, \ get_hash_string, get_base_rom_path, LttPDeltaPatch from .Rules import set_rules diff --git a/worlds/dkc3/Client.py b/worlds/dkc3/Client.py index 7ab82187..77ed51fe 100644 --- a/worlds/dkc3/Client.py +++ b/worlds/dkc3/Client.py @@ -2,75 +2,69 @@ import logging import asyncio from NetUtils import ClientStatus, color -from SNIClient import Context, snes_buffered_write, snes_flush_writes, snes_read -from Patch import GAME_DKC3 +from worlds.AutoSNIClient import SNIClient snes_logger = logging.getLogger("SNES") -# DKC3 - DKC3_TODO: Check these values +# FXPAK Pro protocol memory mapping used by SNI ROM_START = 0x000000 WRAM_START = 0xF50000 WRAM_SIZE = 0x20000 SRAM_START = 0xE00000 -SAVEDATA_START = WRAM_START + 0xF000 -SAVEDATA_SIZE = 0x500 - DKC3_ROMNAME_START = 0x00FFC0 DKC3_ROMHASH_START = 0x7FC0 ROMNAME_SIZE = 0x15 ROMHASH_SIZE = 0x15 -DKC3_RECV_PROGRESS_ADDR = WRAM_START + 0x632 # DKC3_TODO: Find a permanent home for this +DKC3_RECV_PROGRESS_ADDR = WRAM_START + 0x632 DKC3_FILE_NAME_ADDR = WRAM_START + 0x5D9 DEATH_LINK_ACTIVE_ADDR = DKC3_ROMNAME_START + 0x15 # DKC3_TODO: Find a permanent home for this -async def deathlink_kill_player(ctx: Context): - pass - #if ctx.game == GAME_DKC3: +class DKC3SNIClient(SNIClient): + game = "Donkey Kong Country 3" + + async def deathlink_kill_player(self, ctx): + pass # DKC3_TODO: Handle Receiving Deathlink -async def dkc3_rom_init(ctx: Context): - if not ctx.rom: - ctx.finished_game = False - ctx.death_link_allow_survive = False - game_name = await snes_read(ctx, DKC3_ROMNAME_START, 0x15) - if game_name is None or game_name != b"DONKEY KONG COUNTRY 3": - return False - else: - ctx.game = GAME_DKC3 - ctx.items_handling = 0b111 # remote items + async def validate_rom(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read - rom = await snes_read(ctx, DKC3_ROMHASH_START, ROMHASH_SIZE) - if rom is None or rom == bytes([0] * ROMHASH_SIZE): + rom_name = await snes_read(ctx, DKC3_ROMHASH_START, ROMHASH_SIZE) + if rom_name is None or rom_name == bytes([0] * ROMHASH_SIZE) or rom_name[:2] != b"D3": return False - ctx.rom = rom + ctx.game = self.game + ctx.items_handling = 0b111 # remote items + + ctx.rom = rom_name #death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR, 1) ## DKC3_TODO: Handle Deathlink #if death_link: # ctx.allow_collect = bool(death_link[0] & 0b100) # await ctx.update_death_link(bool(death_link[0] & 0b1)) - return True + return True -async def dkc3_game_watcher(ctx: Context): - if ctx.game == GAME_DKC3: + async def game_watcher(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read # DKC3_TODO: Handle Deathlink save_file_name = await snes_read(ctx, DKC3_FILE_NAME_ADDR, 0x5) - if save_file_name is None or save_file_name[0] == 0x00: + if save_file_name is None or save_file_name[0] == 0x00 or save_file_name == bytes([0x55] * 0x05): # We haven't loaded a save file return new_checks = [] from worlds.dkc3.Rom import location_rom_data, item_rom_data, boss_location_ids, level_unlock_map + location_ram_data = await snes_read(ctx, WRAM_START + 0x5FE, 0x81) for loc_id, loc_data in location_rom_data.items(): if loc_id not in ctx.locations_checked: - data = await snes_read(ctx, WRAM_START + loc_data[0], 1) - masked_data = data[0] & (1 << loc_data[1]) + data = location_ram_data[loc_data[0] - 0x5FE] + masked_data = data & (1 << loc_data[1]) bit_set = (masked_data != 0) invert_bit = ((len(loc_data) >= 3) and loc_data[2]) if bit_set != invert_bit: @@ -78,8 +72,9 @@ async def dkc3_game_watcher(ctx: Context): new_checks.append(loc_id) verify_save_file_name = await snes_read(ctx, DKC3_FILE_NAME_ADDR, 0x5) - if verify_save_file_name is None or verify_save_file_name[0] == 0x00 or verify_save_file_name != save_file_name: + if verify_save_file_name is None or verify_save_file_name[0] == 0x00 or verify_save_file_name == bytes([0x55] * 0x05) or verify_save_file_name != save_file_name: # We have somehow exited the save file (or worse) + ctx.rom = None return rom = await snes_read(ctx, DKC3_ROMHASH_START, ROMHASH_SIZE) @@ -184,8 +179,9 @@ async def dkc3_game_watcher(ctx: Context): await snes_flush_writes(ctx) - # DKC3_TODO: This method of collect should work, however it does not unlock the next level correctly when previous is flagged # Handle Collected Locations + levels_to_tiles = await snes_read(ctx, ROM_START + 0x3FF800, 0x60) + tiles_to_levels = await snes_read(ctx, ROM_START + 0x3FF860, 0x60) for loc_id in ctx.checked_locations: if loc_id not in ctx.locations_checked and loc_id not in boss_location_ids: loc_data = location_rom_data[loc_id] @@ -193,30 +189,24 @@ async def dkc3_game_watcher(ctx: Context): invert_bit = ((len(loc_data) >= 3) and loc_data[2]) if not invert_bit: masked_data = data[0] | (1 << loc_data[1]) - #print("Collected Location: ", hex(loc_data[0]), " | ", loc_data[1]) snes_buffered_write(ctx, WRAM_START + loc_data[0], bytes([masked_data])) if (loc_data[1] == 1): # Make the next levels accessible level_id = loc_data[0] - 0x632 - levels_to_tiles = await snes_read(ctx, ROM_START + 0x3FF800, 0x60) - tiles_to_levels = await snes_read(ctx, ROM_START + 0x3FF860, 0x60) tile_id = levels_to_tiles[level_id] if levels_to_tiles[level_id] != 0xFF else level_id tile_id = tile_id + 0x632 - #print("Tile ID: ", hex(tile_id)) if tile_id in level_unlock_map: for next_level_address in level_unlock_map[tile_id]: next_level_id = next_level_address - 0x632 next_tile_id = tiles_to_levels[next_level_id] if tiles_to_levels[next_level_id] != 0xFF else next_level_id next_tile_id = next_tile_id + 0x632 - #print("Next Level ID: ", hex(next_tile_id)) next_data = await snes_read(ctx, WRAM_START + next_tile_id, 1) snes_buffered_write(ctx, WRAM_START + next_tile_id, bytes([next_data[0] | 0x01])) await snes_flush_writes(ctx) else: masked_data = data[0] & ~(1 << loc_data[1]) - print("Collected Inverted Location: ", hex(loc_data[0]), " | ", loc_data[1]) snes_buffered_write(ctx, WRAM_START + loc_data[0], bytes([masked_data])) await snes_flush_writes(ctx) ctx.locations_checked.add(loc_id) diff --git a/worlds/dkc3/__init__.py b/worlds/dkc3/__init__.py index d45de8f8..332f23e4 100644 --- a/worlds/dkc3/__init__.py +++ b/worlds/dkc3/__init__.py @@ -11,6 +11,7 @@ from .Regions import create_regions, connect_regions from .Levels import level_list from .Rules import set_rules from .Names import ItemName, LocationName +from .Client import DKC3SNIClient from ..AutoWorld import WebWorld, World from .Rom import LocalRom, patch_rom, get_base_rom_path, DKC3DeltaPatch import Patch diff --git a/worlds/sm/Client.py b/worlds/sm/Client.py new file mode 100644 index 00000000..190ce29e --- /dev/null +++ b/worlds/sm/Client.py @@ -0,0 +1,158 @@ +import logging +import asyncio +import time + +from NetUtils import ClientStatus, color +from worlds.AutoSNIClient import SNIClient +from .Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT + +snes_logger = logging.getLogger("SNES") + +GAME_SM = "Super Metroid" + +# FXPAK Pro protocol memory mapping used by SNI +ROM_START = 0x000000 +WRAM_START = 0xF50000 +WRAM_SIZE = 0x20000 +SRAM_START = 0xE00000 + +# SM +SM_ROMNAME_START = ROM_START + 0x007FC0 +ROMNAME_SIZE = 0x15 + +SM_INGAME_MODES = {0x07, 0x09, 0x0b} +SM_ENDGAME_MODES = {0x26, 0x27} +SM_DEATH_MODES = {0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A} + +# RECV and SEND are from the gameplay's perspective: SNIClient writes to RECV queue and reads from SEND queue +SM_RECV_QUEUE_START = SRAM_START + 0x2000 +SM_RECV_QUEUE_WCOUNT = SRAM_START + 0x2602 +SM_SEND_QUEUE_START = SRAM_START + 0x2700 +SM_SEND_QUEUE_RCOUNT = SRAM_START + 0x2680 +SM_SEND_QUEUE_WCOUNT = SRAM_START + 0x2682 + +SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277F04 # 1 byte +SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277F06 # 1 byte + + +class SMSNIClient(SNIClient): + game = "Super Metroid" + + async def deathlink_kill_player(self, ctx): + from SNIClient import DeathState, snes_buffered_write, snes_flush_writes, snes_read + snes_buffered_write(ctx, WRAM_START + 0x09C2, bytes([1, 0])) # set current health to 1 (to prevent saving with 0 energy) + snes_buffered_write(ctx, WRAM_START + 0x0A50, bytes([255])) # deal 255 of damage at next opportunity + if not ctx.death_link_allow_survive: + snes_buffered_write(ctx, WRAM_START + 0x09D6, bytes([0, 0])) # set current reserve to 0 + + await snes_flush_writes(ctx) + await asyncio.sleep(1) + + gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) + health = await snes_read(ctx, WRAM_START + 0x09C2, 2) + if health is not None: + health = health[0] | (health[1] << 8) + if not gamemode or gamemode[0] in SM_DEATH_MODES or ( + ctx.death_link_allow_survive and health is not None and health > 0): + ctx.death_state = DeathState.dead + + + async def validate_rom(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + + rom_name = await snes_read(ctx, SM_ROMNAME_START, ROMNAME_SIZE) + if rom_name is None or rom_name == bytes([0] * ROMNAME_SIZE) or rom_name[:2] != b"SM" or rom_name[:3] == b"SMW": + return False + + ctx.game = self.game + + # versions lower than 0.3.0 dont have item handling flag nor remote item support + romVersion = int(rom_name[2:5].decode('UTF-8')) + if romVersion < 30: + ctx.items_handling = 0b001 # full local + else: + item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1) + ctx.items_handling = 0b001 if item_handling is None else item_handling[0] + + ctx.rom = rom_name + + death_link = await snes_read(ctx, SM_DEATH_LINK_ACTIVE_ADDR, 1) + + if death_link: + ctx.allow_collect = bool(death_link[0] & 0b100) + ctx.death_link_allow_survive = bool(death_link[0] & 0b10) + await ctx.update_death_link(bool(death_link[0] & 0b1)) + + return True + + + async def game_watcher(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + if ctx.server is None or ctx.slot is None: + # not successfully connected to a multiworld server, cannot process the game sending items + return + + gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) + if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): + currently_dead = gamemode[0] in SM_DEATH_MODES + await ctx.handle_deathlink_state(currently_dead) + if gamemode is not None and gamemode[0] in SM_ENDGAME_MODES: + if not ctx.finished_game: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + return + + data = await snes_read(ctx, SM_SEND_QUEUE_RCOUNT, 4) + if data is None: + return + + recv_index = data[0] | (data[1] << 8) + recv_item = data[2] | (data[3] << 8) # this is actually SM_SEND_QUEUE_WCOUNT + + while (recv_index < recv_item): + item_address = recv_index * 8 + message = await snes_read(ctx, SM_SEND_QUEUE_START + item_address, 8) + item_index = (message[4] | (message[5] << 8)) >> 3 + + recv_index += 1 + snes_buffered_write(ctx, SM_SEND_QUEUE_RCOUNT, + bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) + + from worlds.sm import locations_start_id + location_id = locations_start_id + item_index + + ctx.locations_checked.add(location_id) + location = ctx.location_names[location_id] + snes_logger.info( + f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') + await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) + + data = await snes_read(ctx, SM_RECV_QUEUE_WCOUNT, 2) + if data is None: + return + + item_out_ptr = data[0] | (data[1] << 8) + + from worlds.sm import items_start_id + from worlds.sm import locations_start_id + if item_out_ptr < len(ctx.items_received): + item = ctx.items_received[item_out_ptr] + item_id = item.item - items_start_id + if bool(ctx.items_handling & 0b010): + location_id = (item.location - locations_start_id) if (item.location >= 0 and item.player == ctx.slot) else 0xFF + else: + location_id = 0x00 #backward compat + + player_id = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0 + snes_buffered_write(ctx, SM_RECV_QUEUE_START + item_out_ptr * 4, bytes( + [player_id & 0xFF, (player_id >> 8) & 0xFF, item_id & 0xFF, location_id & 0xFF])) + item_out_ptr += 1 + snes_buffered_write(ctx, SM_RECV_QUEUE_WCOUNT, + bytes([item_out_ptr & 0xFF, (item_out_ptr >> 8) & 0xFF])) + logging.info('Received %s from %s (%s) (%d/%d in list)' % ( + color(ctx.item_names[item.item], 'red', 'bold'), + color(ctx.player_names[item.player], 'yellow'), + ctx.location_names[item.location], item_out_ptr, len(ctx.items_received))) + + await snes_flush_writes(ctx) + diff --git a/worlds/sm/__init__.py b/worlds/sm/__init__.py index 500233bb..fc19b4e1 100644 --- a/worlds/sm/__init__.py +++ b/worlds/sm/__init__.py @@ -14,6 +14,7 @@ logger = logging.getLogger("Super Metroid") from .Regions import create_regions from .Rules import set_rules, add_entrance_rule from .Options import sm_options +from .Client import SMSNIClient from .Rom import get_base_rom_path, ROM_PLAYER_LIMIT, SMDeltaPatch, get_sm_symbols import Utils diff --git a/worlds/smw/Client.py b/worlds/smw/Client.py index 6ddd4e10..9cf5a5fc 100644 --- a/worlds/smw/Client.py +++ b/worlds/smw/Client.py @@ -3,21 +3,17 @@ import asyncio import time from NetUtils import ClientStatus, color -from worlds import AutoWorldRegister -from SNIClient import Context, snes_buffered_write, snes_flush_writes, snes_read +from worlds.AutoSNIClient import SNIClient from .Names.TextBox import generate_received_text -from Patch import GAME_SMW snes_logger = logging.getLogger("SNES") +# FXPAK Pro protocol memory mapping used by SNI ROM_START = 0x000000 WRAM_START = 0xF50000 WRAM_SIZE = 0x20000 SRAM_START = 0xE00000 -SAVEDATA_START = WRAM_START + 0xF000 -SAVEDATA_SIZE = 0x500 - SMW_ROMHASH_START = 0x7FC0 ROMHASH_SIZE = 0x15 @@ -58,8 +54,12 @@ SMW_BAD_TEXT_BOX_LEVELS = [0x26, 0x02, 0x4B] SMW_BOSS_STATES = [0x80, 0xC0, 0xC1] SMW_UNCOLLECTABLE_LEVELS = [0x25, 0x07, 0x0B, 0x40, 0x0E, 0x1F, 0x20, 0x1B, 0x1A, 0x35, 0x34, 0x31, 0x32] -async def deathlink_kill_player(ctx: Context): - if ctx.game == GAME_SMW: + +class SMWSNIClient(SNIClient): + game = "Super Mario World" + + async def deathlink_kill_player(self, ctx): + from SNIClient import DeathState, snes_buffered_write, snes_flush_writes, snes_read game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) if game_state[0] != 0x14: return @@ -88,25 +88,19 @@ async def deathlink_kill_player(ctx: Context): await snes_flush_writes(ctx) - from SNIClient import DeathState ctx.death_state = DeathState.dead ctx.last_death_link = time.time() - return + async def validate_rom(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read -async def smw_rom_init(ctx: Context): - if not ctx.rom: - ctx.finished_game = False - ctx.death_link_allow_survive = False - game_hash = await snes_read(ctx, SMW_ROMHASH_START, ROMHASH_SIZE) - if game_hash is None or game_hash == bytes([0] * ROMHASH_SIZE) or game_hash[:3] != b"SMW": + rom_name = await snes_read(ctx, SMW_ROMHASH_START, ROMHASH_SIZE) + if rom_name is None or rom_name == bytes([0] * ROMHASH_SIZE) or rom_name[:3] != b"SMW": return False - else: - ctx.game = GAME_SMW - ctx.items_handling = 0b111 # remote items - ctx.rom = game_hash + ctx.game = self.game + ctx.items_handling = 0b111 # remote items receive_option = await snes_read(ctx, SMW_RECEIVE_MSG_DATA, 0x1) send_option = await snes_read(ctx, SMW_SEND_MSG_DATA, 0x1) @@ -114,73 +108,73 @@ async def smw_rom_init(ctx: Context): ctx.receive_option = receive_option[0] ctx.send_option = send_option[0] - ctx.message_queue = [] - ctx.allow_collect = True death_link = await snes_read(ctx, SMW_DEATH_LINK_ACTIVE_ADDR, 1) if death_link: await ctx.update_death_link(bool(death_link[0] & 0b1)) - return True + + ctx.rom = rom_name + + return True -def add_message_to_queue(ctx: Context, new_message): + def add_message_to_queue(self, new_message): - if not hasattr(ctx, "message_queue"): - ctx.message_queue = [] + if not hasattr(self, "message_queue"): + self.message_queue = [] - ctx.message_queue.append(new_message) - - return + self.message_queue.append(new_message) -async def handle_message_queue(ctx: Context): + async def handle_message_queue(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + + if not hasattr(self, "message_queue") or len(self.message_queue) == 0: + return + + game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) + if game_state[0] != 0x14: + return + + mario_state = await snes_read(ctx, SMW_MARIO_STATE_ADDR, 0x1) + if mario_state[0] != 0x00: + return + + message_box = await snes_read(ctx, SMW_MESSAGE_BOX_ADDR, 0x1) + if message_box[0] != 0x00: + return + + pause_state = await snes_read(ctx, SMW_PAUSE_ADDR, 0x1) + if pause_state[0] != 0x00: + return + + current_level = await snes_read(ctx, SMW_CURRENT_LEVEL_ADDR, 0x1) + if current_level[0] in SMW_BAD_TEXT_BOX_LEVELS: + return + + boss_state = await snes_read(ctx, SMW_BOSS_STATE_ADDR, 0x1) + if boss_state[0] in SMW_BOSS_STATES: + return + + active_boss = await snes_read(ctx, SMW_ACTIVE_BOSS_ADDR, 0x1) + if active_boss[0] != 0x00: + return + + next_message = self.message_queue.pop(0) + + snes_buffered_write(ctx, SMW_MESSAGE_QUEUE_ADDR, bytes(next_message)) + snes_buffered_write(ctx, SMW_MESSAGE_BOX_ADDR, bytes([0x03])) + snes_buffered_write(ctx, SMW_SFX_ADDR, bytes([0x22])) + + await snes_flush_writes(ctx) - game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) - if game_state[0] != 0x14: return - mario_state = await snes_read(ctx, SMW_MARIO_STATE_ADDR, 0x1) - if mario_state[0] != 0x00: - return - message_box = await snes_read(ctx, SMW_MESSAGE_BOX_ADDR, 0x1) - if message_box[0] != 0x00: - return + async def game_watcher(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read - pause_state = await snes_read(ctx, SMW_PAUSE_ADDR, 0x1) - if pause_state[0] != 0x00: - return - - current_level = await snes_read(ctx, SMW_CURRENT_LEVEL_ADDR, 0x1) - if current_level[0] in SMW_BAD_TEXT_BOX_LEVELS: - return - - boss_state = await snes_read(ctx, SMW_BOSS_STATE_ADDR, 0x1) - if boss_state[0] in SMW_BOSS_STATES: - return - - active_boss = await snes_read(ctx, SMW_ACTIVE_BOSS_ADDR, 0x1) - if active_boss[0] != 0x00: - return - - if not hasattr(ctx, "message_queue") or len(ctx.message_queue) == 0: - return - - next_message = ctx.message_queue.pop(0) - - snes_buffered_write(ctx, SMW_MESSAGE_QUEUE_ADDR, bytes(next_message)) - snes_buffered_write(ctx, SMW_MESSAGE_BOX_ADDR, bytes([0x03])) - snes_buffered_write(ctx, SMW_SFX_ADDR, bytes([0x22])) - - await snes_flush_writes(ctx) - - return - - -async def smw_game_watcher(ctx: Context): - if ctx.game == GAME_SMW: - # SMW_TODO: Handle Deathlink game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) mario_state = await snes_read(ctx, SMW_MARIO_STATE_ADDR, 0x1) if game_state is None: @@ -234,7 +228,7 @@ async def smw_game_watcher(ctx: Context): snes_buffered_write(ctx, SMW_BONUS_STAR_ADDR, bytes([egg_count[0]])) await snes_flush_writes(ctx) - await handle_message_queue(ctx) + await self.handle_message_queue(ctx) new_checks = [] event_data = await snes_read(ctx, SMW_EVENT_ROM_DATA, 0x60) @@ -243,6 +237,7 @@ async def smw_game_watcher(ctx: Context): dragon_coins_active = await snes_read(ctx, SMW_DRAGON_COINS_ACTIVE_ADDR, 0x1) from worlds.smw.Rom import item_rom_data, ability_rom_data from worlds.smw.Levels import location_id_to_level_id, level_info_dict + from worlds import AutoWorldRegister for loc_name, level_data in location_id_to_level_id.items(): loc_id = AutoWorldRegister.world_types[ctx.game].location_name_to_id[loc_name] if loc_id not in ctx.locations_checked: @@ -262,7 +257,6 @@ async def smw_game_watcher(ctx: Context): bit_set = (masked_data != 0) if bit_set: - # SMW_TODO: Handle non-included checks new_checks.append(loc_id) else: event_id_value = event_id + level_data[1] @@ -275,7 +269,6 @@ async def smw_game_watcher(ctx: Context): bit_set = (masked_data != 0) if bit_set: - # SMW_TODO: Handle non-included checks new_checks.append(loc_id) verify_game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) @@ -320,7 +313,7 @@ async def smw_game_watcher(ctx: Context): player_name = ctx.player_names[item.player] receive_message = generate_received_text(item_name, player_name) - add_message_to_queue(ctx, receive_message) + self.add_message_to_queue(receive_message) snes_buffered_write(ctx, SMW_RECV_PROGRESS_ADDR, bytes([recv_index])) if item.item in item_rom_data: @@ -372,7 +365,7 @@ async def smw_game_watcher(ctx: Context): rand_trap = random.choice(lit_trap_text_list) for message in rand_trap: - add_message_to_queue(ctx, message) + self.add_message_to_queue(message) await snes_flush_writes(ctx) diff --git a/worlds/smw/__init__.py b/worlds/smw/__init__.py index 1dd64f53..2e9be535 100644 --- a/worlds/smw/__init__.py +++ b/worlds/smw/__init__.py @@ -12,6 +12,7 @@ from .Levels import full_level_list, generate_level_list, location_id_to_level_i from .Rules import set_rules from ..generic.Rules import add_rule from .Names import ItemName, LocationName +from .Client import SMWSNIClient from ..AutoWorld import WebWorld, World from .Rom import LocalRom, patch_rom, get_base_rom_path, SMWDeltaPatch diff --git a/worlds/smz3/Client.py b/worlds/smz3/Client.py new file mode 100644 index 00000000..c942c66c --- /dev/null +++ b/worlds/smz3/Client.py @@ -0,0 +1,118 @@ +import logging +import asyncio +import time + +from NetUtils import ClientStatus, color +from worlds.AutoSNIClient import SNIClient +from .Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT + +snes_logger = logging.getLogger("SNES") + +# FXPAK Pro protocol memory mapping used by SNI +ROM_START = 0x000000 +WRAM_START = 0xF50000 +WRAM_SIZE = 0x20000 +SRAM_START = 0xE00000 + +# SMZ3 +SMZ3_ROMNAME_START = ROM_START + 0x00FFC0 +ROMNAME_SIZE = 0x15 + +SAVEDATA_START = WRAM_START + 0xF000 + +SMZ3_INGAME_MODES = {0x07, 0x09, 0x0B} +ENDGAME_MODES = {0x19, 0x1A} +SM_ENDGAME_MODES = {0x26, 0x27} +SMZ3_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A} + +SMZ3_RECV_PROGRESS_ADDR = SRAM_START + 0x4000 # 2 bytes +SMZ3_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte +SMZ3_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte + + +class SMZ3SNIClient(SNIClient): + game = "SMZ3" + + async def validate_rom(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + + rom_name = await snes_read(ctx, SMZ3_ROMNAME_START, ROMNAME_SIZE) + if rom_name is None or rom_name == bytes([0] * ROMNAME_SIZE) or rom_name[:3] != b"ZSM": + return False + + ctx.game = self.game + ctx.items_handling = 0b101 # local items and remote start inventory + + ctx.rom = rom_name + + return True + + + async def game_watcher(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + if ctx.server is None or ctx.slot is None: + # not successfully connected to a multiworld server, cannot process the game sending items + return + + currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2) + if (currentGame is not None): + if (currentGame[0] != 0): + gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) + endGameModes = SM_ENDGAME_MODES + else: + gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) + endGameModes = ENDGAME_MODES + + if gamemode is not None and (gamemode[0] in endGameModes): + if not ctx.finished_game: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + return + + data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, 4) + if data is None: + return + + recv_index = data[0] | (data[1] << 8) + recv_item = data[2] | (data[3] << 8) + + while (recv_index < recv_item): + item_address = recv_index * 8 + message = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x700 + item_address, 8) + is_z3_item = ((message[5] & 0x80) != 0) + masked_part = (message[5] & 0x7F) if is_z3_item else message[5] + item_index = ((message[4] | (masked_part << 8)) >> 3) + (256 if is_z3_item else 0) + + recv_index += 1 + snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) + + from worlds.smz3.TotalSMZ3.Location import locations_start_id + from worlds.smz3 import convertLocSMZ3IDToAPID + location_id = locations_start_id + convertLocSMZ3IDToAPID(item_index) + + ctx.locations_checked.add(location_id) + location = ctx.location_names[location_id] + snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') + await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) + + data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x600, 4) + if data is None: + return + + item_out_ptr = data[2] | (data[3] << 8) + + from worlds.smz3.TotalSMZ3.Item import items_start_id + if item_out_ptr < len(ctx.items_received): + item = ctx.items_received[item_out_ptr] + item_id = item.item - items_start_id + + player_id = item.player if item.player <= SMZ3_ROM_PLAYER_LIMIT else 0 + snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + item_out_ptr * 4, bytes([player_id & 0xFF, (player_id >> 8) & 0xFF, item_id & 0xFF, (item_id >> 8) & 0xFF])) + item_out_ptr += 1 + snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x602, bytes([item_out_ptr & 0xFF, (item_out_ptr >> 8) & 0xFF])) + logging.info('Received %s from %s (%s) (%d/%d in list)' % ( + color(ctx.item_names[item.item], 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'), + ctx.location_names[item.location], item_out_ptr, len(ctx.items_received))) + + await snes_flush_writes(ctx) + diff --git a/worlds/smz3/__init__.py b/worlds/smz3/__init__.py index 753fb556..320d506f 100644 --- a/worlds/smz3/__init__.py +++ b/worlds/smz3/__init__.py @@ -17,6 +17,7 @@ from worlds.smz3.TotalSMZ3.Location import LocationType, locations_start_id, Loc from worlds.smz3.TotalSMZ3.Patch import Patch as TotalSMZ3Patch, getWord, getWordArray from worlds.smz3.TotalSMZ3.WorldState import WorldState from ..AutoWorld import World, AutoLogicRegister, WebWorld +from .Client import SMZ3SNIClient from .Rom import get_base_rom_bytes, SMZ3DeltaPatch from .ips import IPS_Patch from .Options import smz3_options