From d5efc713444dec62a5d398561303ff3edbf0db6f Mon Sep 17 00:00:00 2001 From: PoryGone <98504756+PoryGone@users.noreply.github.com> Date: Tue, 25 Oct 2022 13:54:43 -0400 Subject: [PATCH] Core: SNI Client Refactor (#1083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * First Pass removal of game-specific code * SMW, DKC3, and SM hooked into AutoClient * All SNES autoclients functional * Fix ALttP Deathlink * Don't default to being ALttP, and properly error check ctx.game * Adjust variable naming * In response to: > we should probably document usage somewhere. I'm open to suggestions of where this should be documented. I think the most valuable documentation for APIs is docstrings and full typing. about websockets change in imports - from websockets documentation: > For convenience, many public APIs can be imported from the websockets package. However, this feature is incompatible with static code analysis. It breaks autocompletion in an IDE or type checking with mypy. If you’re using such tools, use the real import paths. * todo note for python 3.11 typing.NotRequired * missed staging in previous commit * added missing death Game States for DeathLink Co-authored-by: beauxq <beauxq@users.noreply.github.com> Co-authored-by: lordlou <87331798+lordlou@users.noreply.github.com> --- CommonClient.py | 6 + LttPAdjuster.py | 4 +- MultiServer.py | 6 +- Patch.py | 10 - SNIClient.py | 1129 ++++++-------------------------------- Utils.py | 15 +- host.yaml | 44 +- worlds/AutoSNIClient.py | 42 ++ worlds/alttp/Client.py | 693 +++++++++++++++++++++++ worlds/alttp/__init__.py | 1 + worlds/dkc3/Client.py | 64 +-- worlds/dkc3/__init__.py | 1 + worlds/sm/Client.py | 158 ++++++ worlds/sm/__init__.py | 1 + worlds/smw/Client.py | 145 +++-- worlds/smw/__init__.py | 1 + worlds/smz3/Client.py | 118 ++++ worlds/smz3/__init__.py | 1 + 18 files changed, 1304 insertions(+), 1135 deletions(-) create mode 100644 worlds/AutoSNIClient.py create mode 100644 worlds/alttp/Client.py create mode 100644 worlds/sm/Client.py create mode 100644 worlds/smz3/Client.py 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