From 0e0cc0ad169aa058ccaa972a44756ce319b425a9 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Mon, 1 Nov 2021 19:37:47 +0100 Subject: [PATCH] LttP: Implement DeathLink --- CommonClient.py | 21 ++++++++- FactorioClient.py | 21 +++------ LttPClient.py | 89 +++++++++++++++++++++++++++----------- Options.py | 5 +++ worlds/alttp/Options.py | 5 ++- worlds/alttp/Rom.py | 1 + worlds/factorio/Options.py | 7 +-- 7 files changed, 99 insertions(+), 50 deletions(-) diff --git a/CommonClient.py b/CommonClient.py index 567a55cb..9a928846 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -5,6 +5,7 @@ import urllib.parse import sys import os import typing +import time import websockets @@ -135,6 +136,8 @@ class CommonContext(): self.input_queue = asyncio.Queue() self.input_requests = 0 + self.last_death_link: float = time.time() # last send/received death link on AP layer + # game state self.player_names: typing.Dict[int: str] = {0: "Archipelago"} self.exit_event = asyncio.Event() @@ -256,6 +259,20 @@ class CommonContext(): except Exception as e: # safeguard against permissions that may be implemented in the future logger.exception(e) + def on_deathlink(self, data: dict): + """Gets dispatched when a new DeathLink is triggered by another linked player.""" + raise NotImplementedError + + async def send_death(self): + self.last_death_link = time.time() + await self.send_msgs([{ + "cmd": "Bounce", "tags": ["DeathLink"], + "data": { + "time": self.last_death_link, + "source": self.player_names[self.slot] + } + }]) + async def keep_alive(ctx: CommonContext, seconds_between_checks=100): """some ISPs/network configurations drop TCP connections if no payload is sent (ignore TCP-keep-alive) @@ -461,7 +478,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict): logger.warning(f"Invalid Packet of {args['type']}: {args['text']}") elif cmd == "Bounced": - pass + tags = args.get("tags", []) + if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]: + ctx.on_deathlink(args["data"]) else: logger.debug(f"unknown command {cmd}") diff --git a/FactorioClient.py b/FactorioClient.py index 7d7454f2..916ff327 100644 --- a/FactorioClient.py +++ b/FactorioClient.py @@ -58,7 +58,6 @@ class FactorioContext(CommonContext): self.rcon_client = None self.awaiting_bridge = False self.write_data_path = None - self.last_death_link: float = time.time() # last send/received death link on AP layer self.death_link_tick: int = 0 # last send death link on Factorio layer self.factorio_json_text_parser = FactorioJSONtoTextParser(self) @@ -102,6 +101,10 @@ class FactorioContext(CommonContext): self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] " f"{text}") + def on_deathlink(self, data: dict): + if self.rcon_client: + self.rcon_client.send_command(f"/ap-deathlink {data['source']}") + def on_package(self, cmd: str, args: dict): if cmd in {"Connected", "RoomUpdate"}: # catch up sync anything that is already cleared. @@ -109,12 +112,6 @@ class FactorioContext(CommonContext): self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for item_name in args["checked_locations"]}) - elif cmd == "Bounced": - if self.rcon_client: - tags = args.get("tags", []) - if "DeathLink" in tags and self.last_death_link != args["data"]["time"]: - self.rcon_client.send_command(f"/ap-deathlink {args['data']['source']}") - async def game_watcher(ctx: FactorioContext): bridge_logger = logging.getLogger("FactorioWatcher") @@ -150,14 +147,8 @@ async def game_watcher(ctx: FactorioContext): death_link_tick = data.get("death_link_tick", 0) if death_link_tick != ctx.death_link_tick: ctx.death_link_tick = death_link_tick - ctx.last_death_link = time.time() - await ctx.send_msgs([{ - "cmd": "Bounce", "tags": ["DeathLink"], - "data": { - "time": ctx.last_death_link, - "source": ctx.player_names[ctx.slot] - } - }]) + await ctx.send_death() + await asyncio.sleep(0.1) except Exception as e: diff --git a/LttPClient.py b/LttPClient.py index 79b21534..73227e58 100644 --- a/LttPClient.py +++ b/LttPClient.py @@ -1,5 +1,7 @@ +from __future__ import annotations import argparse import atexit + exit_func = atexit.register(input, "Press enter to close.") import threading import time @@ -12,13 +14,10 @@ import logging import asyncio from json import loads, dumps -from Utils import get_item_name_from_id - - import ModuleUpdate - ModuleUpdate.update() +from Utils import get_item_name_from_id import colorama from NetUtils import * @@ -35,7 +34,10 @@ snes_logger = logging.getLogger("SNES") from MultiServer import mark_raw + class LttPCommandProcessor(ClientCommandProcessor): + ctx: Context + def _cmd_slow_mode(self, toggle: str = ""): """Toggle slow mode, which limits how fast you send / receive items.""" if toggle: @@ -47,17 +49,18 @@ class LttPCommandProcessor(ClientCommandProcessor): @mark_raw def _cmd_snes(self, snes_options: str = "") -> bool: - """Connect to a snes. Optionally include network address of a snes to connect to, otherwise show available devices; and a SNES device number if more than one SNES is detected""" - + """Connect to a snes. Optionally include network address of a snes to connect to, + otherwise show available devices; and a SNES device number if more than one SNES is detected""" + snes_address = self.ctx.snes_address snes_device_number = -1 - + options = snes_options.split() num_options = len(options) - + if num_options > 0: snes_address = options[0] - + if num_options > 1: try: snes_device_number = int(options[1]) @@ -94,6 +97,7 @@ class Context(CommonContext): self.snes_request_lock = asyncio.Lock() self.snes_write_buffer = [] self.snes_connector_lock = threading.Lock() + self.death_state = False # for death link flop behaviour self.awaiting_rom = False self.rom = None @@ -109,7 +113,7 @@ class Context(CommonContext): 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): + async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: await super(Context, self).server_auth(password_requested) if self.rom is None: @@ -121,10 +125,17 @@ class Context(CommonContext): self.auth = self.rom auth = base64.b64encode(self.rom).decode() await self.send_msgs([{"cmd": 'Connect', - 'password': self.password, 'name': auth, 'version': Utils.version_tuple, - 'tags': self.tags, - 'uuid': Utils.get_unique_identifier(), 'game': "A Link to the Past" - }]) + 'password': self.password, 'name': auth, 'version': Utils.version_tuple, + 'tags': self.tags, + 'uuid': Utils.get_unique_identifier(), 'game': "A Link to the Past" + }]) + + def on_deathlink(self, data: dict): + snes_buffered_write(self, WRAM_START+0xF36D, bytes([0])) + snes_buffered_write(self, WRAM_START+0x0373, bytes([8])) + asyncio.create_task(snes_flush_writes(self)) + self.death_state = True + snes_logger.info(f"Received DeathLink from {data['source']}") def color_item(item_id: int, green: bool = False) -> str: @@ -147,6 +158,7 @@ ROMNAME_SIZE = 0x15 INGAME_MODES = {0x07, 0x09, 0x0b} ENDGAME_MODES = {0x19, 0x1a} +DEATH_MODES = {0x12} SAVEDATA_START = WRAM_START + 0xF000 SAVEDATA_SIZE = 0x500 @@ -162,6 +174,8 @@ SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes +DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x18008D # 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), @@ -385,7 +399,7 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10), 'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40), 'Ganons Tower - Validation Chest': (0x4d, 0x10)} -location_table_uw_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_uw.items()} +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, @@ -401,7 +415,7 @@ location_table_npc = {'Mushroom': 0x1000, '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_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, @@ -416,14 +430,15 @@ location_table_ow = {'Flute Spot': 0x2a, '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_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()} +location_table_misc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_misc.items()} + class SNESState(enum.IntEnum): SNES_DISCONNECTED = 0 @@ -446,10 +461,11 @@ def launch_sni(ctx: Context): if os.path.isfile(sni_path): snes_logger.info(f"Attempting to start {sni_path}") import subprocess - if Utils.is_frozen(): # if it spawns a visible console, may as well populate it + if Utils.is_frozen(): # if it spawns a visible console, may as well populate it subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path)) else: - subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path), stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) else: snes_logger.info( f"Attempt to start SNI was aborted as path {sni_path} was not found, " @@ -500,12 +516,11 @@ async def get_snes_devices(ctx: Context): reply = loads(await socket.recv()) devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None - await socket.close() return devices -async def snes_connect(ctx: Context, address, deviceIndex = -1): +async def snes_connect(ctx: Context, address, deviceIndex=-1): global SNES_RECONNECT_DELAY if ctx.snes_socket is not None and ctx.snes_state == SNESState.SNES_CONNECTED: if ctx.rom: @@ -534,7 +549,8 @@ async def snes_connect(ctx: Context, address, deviceIndex = -1): device = devices[ctx.snes_attached_device[0]] elif numDevices > 1: if deviceIndex == -1: - snes_logger.info("Found " + str(numDevices) + " SNES devices; connect to one with /snes
:") + snes_logger.info( + "Found " + str(numDevices) + " SNES devices; connect to one with /snes
:") for idx, availableDevice in enumerate(devices): snes_logger.info(str(idx + 1) + ": " + availableDevice) @@ -544,7 +560,7 @@ async def snes_connect(ctx: Context, address, deviceIndex = -1): else: device = devices[deviceIndex - 1] - + if device is None: await snes_disconnect(ctx) return @@ -676,6 +692,7 @@ async def snes_write(ctx: Context, write_list): for address, data in write_list: PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]] if ctx.snes_socket is not None: + snes_logger.info((PutAddress_Request, data)) await ctx.snes_socket.send(dumps(PutAddress_Request)) await ctx.snes_socket.send(data) else: @@ -712,7 +729,8 @@ async def track_locations(ctx: Context, roomid, roomdata): new_locations.append(location_id) ctx.locations_checked.add(location_id) location = ctx.location_name_getter(location_id) - snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') + snes_logger.info( + f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') try: if roomid in location_shop_ids: @@ -782,7 +800,6 @@ async def track_locations(ctx: Context, roomid, roomdata): if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked: new_check(location_id) - if new_locations: await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}]) @@ -804,6 +821,17 @@ async def game_watcher(ctx: Context): continue ctx.rom = rom + death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR, 1) + if death_link: + death_link = bool(death_link[0]) + old_tags = ctx.tags.copy() + if death_link: + ctx.tags.add("DeathLink") + else: + ctx.tags -= {"DeathLink"} + if old_tags != ctx.tags and ctx.server and not ctx.server.socket.closed: + snes_logger.info("Forcing reconnect to set DeathLink state.") + await ctx.disconnect() # set correct tags if not ctx.prev_rom or ctx.prev_rom != ctx.rom: ctx.locations_checked = set() ctx.locations_scouted = set() @@ -817,6 +845,14 @@ async def game_watcher(ctx: Context): await ctx.disconnect() gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) + if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): + snes_logger.info((ctx.last_death_link + 1 < time.time(), ctx.last_death_link, time.time())) + if gamemode[0] in DEATH_MODES: + if not ctx.death_state: # new death + await ctx.send_death() + ctx.death_state = True + else: + ctx.death_state = False # reset death state, so next death can trigger 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 \ @@ -891,6 +927,7 @@ async def run_game(romfile): subprocess.Popen([auto_start, romfile], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + async def main(): multiprocessing.freeze_support() parser = argparse.ArgumentParser() diff --git a/Options.py b/Options.py index 5b25e347..a87618f9 100644 --- a/Options.py +++ b/Options.py @@ -388,6 +388,11 @@ class ExcludeLocations(OptionSet): verify_location_name = True +class DeathLink(Toggle): + """When you die, everyone dies. Of course the reverse is true too.""" + displayname = "Death Link" + + per_game_common_options = { "local_items": LocalItems, "non_local_items": NonLocalItems, diff --git a/worlds/alttp/Options.py b/worlds/alttp/Options.py index 774de725..75ee5b36 100644 --- a/worlds/alttp/Options.py +++ b/worlds/alttp/Options.py @@ -1,6 +1,6 @@ import typing -from Options import Choice, Range, Option, Toggle, DefaultOnToggle +from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink class Logic(Choice): @@ -292,6 +292,7 @@ alttp_options: typing.Dict[str, type(Option)] = { "music": Music, "reduceflashing": ReduceFlashing, "triforcehud": TriforceHud, - "glitch_boots": DefaultOnToggle + "glitch_boots": DefaultOnToggle, + "death_link": DeathLink } diff --git a/worlds/alttp/Rom.py b/worlds/alttp/Rom.py index 97965c3a..2226012a 100644 --- a/worlds/alttp/Rom.py +++ b/worlds/alttp/Rom.py @@ -1645,6 +1645,7 @@ def patch_rom(world, rom, player, enemized): # remote items flag, does not currently work rom.write_byte(0x18637C, int(world.worlds[player].remote_items)) + rom.write_byte(0x18008D, int(world.death_link[player])) # set rom name # 21 bytes from Main import __version__ diff --git a/worlds/factorio/Options.py b/worlds/factorio/Options.py index 285835ae..0c9e3cd8 100644 --- a/worlds/factorio/Options.py +++ b/worlds/factorio/Options.py @@ -1,7 +1,7 @@ from __future__ import annotations import typing -from Options import Choice, OptionDict, ItemDict, Option, DefaultOnToggle, Range, Toggle +from Options import Choice, OptionDict, ItemDict, Option, DefaultOnToggle, Range, DeathLink from schema import Schema, Optional, And, Or # schema helpers @@ -284,11 +284,6 @@ class ImportedBlueprint(DefaultOnToggle): displayname = "Blueprints" -class DeathLink(Toggle): - """When you die, everyone dies. Of course the reverse is true too.""" - displayname = "Death Link" - - factorio_options: typing.Dict[str, type(Option)] = { "max_science_pack": MaxSciencePack, "tech_tree_layout": TechTreeLayout,