From 240bb6c255b4454c21bcf9ce88fa8f8478e4310a Mon Sep 17 00:00:00 2001 From: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com> Date: Thu, 9 May 2024 17:23:03 -0400 Subject: [PATCH] Jak 1: Simplify user interaction with agents, make process more robust/less dependent on order of ops. --- worlds/jakanddaxter/Client.py | 35 +++--- worlds/jakanddaxter/client/MemoryReader.py | 123 +++++++++++--------- worlds/jakanddaxter/client/ReplClient.py | 126 ++++++++++++++------- 3 files changed, 169 insertions(+), 115 deletions(-) diff --git a/worlds/jakanddaxter/Client.py b/worlds/jakanddaxter/Client.py index f3c2ef6187..bcbbc175cc 100644 --- a/worlds/jakanddaxter/Client.py +++ b/worlds/jakanddaxter/Client.py @@ -33,7 +33,6 @@ def create_task_log_exception(awaitable: typing.Awaitable) -> asyncio.Task: class JakAndDaxterClientCommandProcessor(ClientCommandProcessor): ctx: "JakAndDaxterContext" - # TODO - Clean up commands related to the REPL, make them more user friendly. # The REPL has a specific order of operations it needs to do in order to process our input: # 1. Connect (we need to open a socket connection on ip/port to the REPL). # 2. Listen (have the REPL compiler connect and listen on the game's internal socket). @@ -41,24 +40,24 @@ class JakAndDaxterClientCommandProcessor(ClientCommandProcessor): # All 3 need to be done, and in this order, for this to work. def _cmd_repl(self, *arguments: str): """Sends a command to the OpenGOAL REPL. Arguments: - - connect : connect a new client to the REPL. - - listen : listen to the game's internal socket. - - compile : compile the game into executable object code. - - verify : verify successful compilation.""" + - connect : connect the client to the REPL (goalc). + - status : check internal status of the REPL.""" if arguments: if arguments[0] == "connect": - if arguments[1] and arguments[2]: - self.ctx.repl.ip = str(arguments[1]) - self.ctx.repl.port = int(arguments[2]) - self.ctx.repl.connect() - else: - logging.error("You must provide the ip address and port (default 127.0.0.1 port 8181).") - if arguments[0] == "listen": - self.ctx.repl.listen() - if arguments[0] == "compile": - self.ctx.repl.compile() - if arguments[0] == "verify": - self.ctx.repl.verify() + logger.info("This may take a bit... Wait for the success audio cue before continuing!") + self.ctx.repl.user_connect = True # Will attempt to reconnect on next tick. + if arguments[0] == "status": + self.ctx.repl.print_status() + + def _cmd_memr(self, *arguments: str): + """Sends a command to the Memory Reader. Arguments: + - connect : connect the memory reader to the game process (gk). + - status : check the internal status of the Memory Reader.""" + if arguments: + if arguments[0] == "connect": + self.ctx.memr.connect() + if arguments[0] == "status": + self.ctx.memr.print_status() class JakAndDaxterContext(CommonContext): @@ -131,8 +130,6 @@ async def main(): ctx = JakAndDaxterContext(None, None) - await ctx.repl.init() - ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") ctx.repl_task = create_task_log_exception(ctx.run_repl_loop()) ctx.memr_task = create_task_log_exception(ctx.run_memr_loop()) diff --git a/worlds/jakanddaxter/client/MemoryReader.py b/worlds/jakanddaxter/client/MemoryReader.py index 1c06289dea..04fa025512 100644 --- a/worlds/jakanddaxter/client/MemoryReader.py +++ b/worlds/jakanddaxter/client/MemoryReader.py @@ -1,7 +1,7 @@ import typing import pymem from pymem import pattern -from pymem.exception import ProcessNotFound +from pymem.exception import ProcessNotFound, ProcessError, MemoryReadError, WinAPIError from CommonClient import logger from worlds.jakanddaxter.locs import CellLocations as Cells, ScoutLocations as Flies @@ -16,88 +16,107 @@ end_marker_offset = 868 # buzzers_offset + (sizeof uint32 * 112 flies) = 4 class JakAndDaxterMemoryReader: marker: typing.ByteString - connected: bool = False - marked: bool = False - - process: pymem.process = None - marker_address = None goal_address = None + connected: bool = False + + # The memory reader just needs the game running. + gk_process: pymem.process = None location_outbox = [] outbox_index = 0 def __init__(self, marker: typing.ByteString = b'UnLiStEdStRaTs_JaK1\x00'): self.marker = marker - self.connected = self.connect() - if self.connected and self.marker: - self.marked = self.find_marker() + self.connect() async def main_tick(self, location_callback: typing.Callable): + if self.connected: + try: + self.gk_process.read_bool(self.gk_process.base_address) # Ping to see if it's alive. + except (ProcessError, MemoryReadError, WinAPIError): + logger.error("The gk process has died. Restart the game and run \"/memr connect\" again.") + self.connected = False + else: + return + + # Read the memory address to check the state of the game. self.read_memory() - location_callback(self.location_outbox) + location_callback(self.location_outbox) # TODO - I forgot why call this here when it's already down below... # Checked Locations in game. Handle the entire outbox every tick until we're up to speed. if len(self.location_outbox) > self.outbox_index: location_callback(self.location_outbox) self.outbox_index += 1 - def connect(self) -> bool: + def connect(self): try: - self.process = pymem.Pymem("gk.exe") # The GOAL Kernel - logger.info("Found the gk process: " + str(self.process.process_id)) - return True + self.gk_process = pymem.Pymem("gk.exe") # The GOAL Kernel + logger.info("Found the gk process: " + str(self.gk_process.process_id)) except ProcessNotFound: logger.error("Could not find the gk process.") - return False + self.connected = False + return - def find_marker(self) -> bool: - - # If we don't find the marker in the first module's worth of memory, we've failed. - modules = list(self.process.list_modules()) - self.marker_address = pattern.pattern_scan_module(self.process.process_handle, modules[0], self.marker) - if self.marker_address: + # If we don't find the marker in the first loaded module, we've failed. + modules = list(self.gk_process.list_modules()) + marker_address = pattern.pattern_scan_module(self.gk_process.process_handle, modules[0], self.marker) + if marker_address: # At this address is another address that contains the struct we're looking for: the game's state. # From here we need to add the length in bytes for the marker and 4 bytes of padding, # and the struct address is 8 bytes long (it's u64). - goal_pointer = self.marker_address + len(self.marker) + 4 - self.goal_address = int.from_bytes(self.process.read_bytes(goal_pointer, 8), + goal_pointer = marker_address + len(self.marker) + 4 + self.goal_address = int.from_bytes(self.gk_process.read_bytes(goal_pointer, 8), byteorder="little", signed=False) logger.info("Found the archipelago memory address: " + str(self.goal_address)) - return True - logger.error("Could not find the archipelago memory address.") - return False + self.connected = True + else: + logger.error("Could not find the archipelago memory address.") + self.connected = False + + if self.connected: + logger.info("The Memory Reader is ready!") + + def print_status(self): + logger.info("Memory Reader Status:") + logger.info(" Game process ID: " + (str(self.gk_process.process_id) if self.gk_process else "None")) + logger.info(" Game state memory address: " + str(self.goal_address)) + logger.info(" Last location checked: " + (str(self.location_outbox[self.outbox_index]) + if self.outbox_index else "None")) def read_memory(self) -> typing.List[int]: - next_cell_index = int.from_bytes( - self.process.read_bytes(self.goal_address, 8), - byteorder="little", - signed=False) - next_buzzer_index = int.from_bytes( - self.process.read_bytes(self.goal_address + next_buzzer_index_offset, 8), - byteorder="little", - signed=False) - - for k in range(0, next_cell_index): - next_cell = int.from_bytes( - self.process.read_bytes(self.goal_address + cells_offset + (k * 4), 4), + try: + next_cell_index = int.from_bytes( + self.gk_process.read_bytes(self.goal_address, 8), byteorder="little", signed=False) - cell_ap_id = Cells.to_ap_id(next_cell) - if cell_ap_id not in self.location_outbox: - self.location_outbox.append(cell_ap_id) - logger.info("Checked power cell: " + str(next_cell)) - - for k in range(0, next_buzzer_index): - next_buzzer = int.from_bytes( - self.process.read_bytes(self.goal_address + buzzers_offset + (k * 4), 4), + next_buzzer_index = int.from_bytes( + self.gk_process.read_bytes(self.goal_address + next_buzzer_index_offset, 8), byteorder="little", signed=False) - buzzer_ap_id = Flies.to_ap_id(next_buzzer) - if buzzer_ap_id not in self.location_outbox: - self.location_outbox.append(buzzer_ap_id) - logger.info("Checked scout fly: " + str(next_buzzer)) + + for k in range(0, next_cell_index): + next_cell = int.from_bytes( + self.gk_process.read_bytes(self.goal_address + cells_offset + (k * 4), 4), + byteorder="little", + signed=False) + cell_ap_id = Cells.to_ap_id(next_cell) + if cell_ap_id not in self.location_outbox: + self.location_outbox.append(cell_ap_id) + logger.info("Checked power cell: " + str(next_cell)) + + for k in range(0, next_buzzer_index): + next_buzzer = int.from_bytes( + self.gk_process.read_bytes(self.goal_address + buzzers_offset + (k * 4), 4), + byteorder="little", + signed=False) + buzzer_ap_id = Flies.to_ap_id(next_buzzer) + if buzzer_ap_id not in self.location_outbox: + self.location_outbox.append(buzzer_ap_id) + logger.info("Checked scout fly: " + str(next_buzzer)) + + except (ProcessError, MemoryReadError, WinAPIError): + logger.error("The gk process has died. Restart the game and run \"/memr connect\" again.") + self.connected = False return self.location_outbox - - diff --git a/worlds/jakanddaxter/client/ReplClient.py b/worlds/jakanddaxter/client/ReplClient.py index 9149950b6c..cae7e6c863 100644 --- a/worlds/jakanddaxter/client/ReplClient.py +++ b/worlds/jakanddaxter/client/ReplClient.py @@ -1,6 +1,10 @@ import time import struct from socket import socket, AF_INET, SOCK_STREAM + +import pymem +from pymem.exception import ProcessNotFound, ProcessError + from CommonClient import logger from worlds.jakanddaxter.locs import CellLocations as Cells, ScoutLocations as Flies, OrbLocations as Orbs from worlds.jakanddaxter.GameID import jak1_id @@ -9,10 +13,14 @@ from worlds.jakanddaxter.GameID import jak1_id class JakAndDaxterReplClient: ip: str port: int - socket: socket + sock: socket connected: bool = False - listening: bool = False - compiled: bool = False + user_connect: bool = False # Signals when user tells us to try reconnecting. + + # The REPL client needs the REPL/compiler process running, but that process + # also needs the game running. Therefore, the REPL client needs both running. + gk_process: pymem.process = None + goalc_process: pymem.process = None item_inbox = {} inbox_index = 0 @@ -20,15 +28,26 @@ class JakAndDaxterReplClient: def __init__(self, ip: str = "127.0.0.1", port: int = 8181): self.ip = ip self.port = port - - async def init(self): - self.connected = self.connect() - if self.connected: - self.listening = self.listen() - if self.connected and self.listening: - self.compiled = self.compile() + self.connect() async def main_tick(self): + if self.user_connect: + await self.connect() + self.user_connect = False + + if self.connected: + try: + self.gk_process.read_bool(self.gk_process.base_address) # Ping to see if it's alive. + except ProcessError: + logger.error("The gk process has died. Restart the game and run \"/repl connect\" again.") + self.connected = False + try: + self.goalc_process.read_bool(self.goalc_process.base_address) # Ping to see if it's alive. + except ProcessError: + logger.error("The goalc process has died. Restart the compiler and run \"/repl connect\" again.") + self.connected = False + else: + return # Receive Items from AP. Handle 1 item per tick. if len(self.item_inbox) > self.inbox_index: @@ -41,8 +60,8 @@ class JakAndDaxterReplClient: # any log info in the meantime. Is that a problem? def send_form(self, form: str, print_ok: bool = True) -> bool: header = struct.pack(" bool: - logger.info("Connecting to the OpenGOAL REPL...") - if not self.ip or not self.port: - logger.error(f"Unable to connect: IP address \"{self.ip}\" or port \"{self.port}\" was not provided.") - return False + async def connect(self): + try: + self.gk_process = pymem.Pymem("gk.exe") # The GOAL Kernel + logger.info("Found the gk process: " + str(self.gk_process.process_id)) + except ProcessNotFound: + logger.error("Could not find the gk process.") + return try: - self.socket = socket(AF_INET, SOCK_STREAM) - self.socket.connect((self.ip, self.port)) + self.goalc_process = pymem.Pymem("goalc.exe") # The GOAL Compiler and REPL + logger.info("Found the goalc process: " + str(self.goalc_process.process_id)) + except ProcessNotFound: + logger.error("Could not find the goalc process.") + return + + try: + self.sock = socket(AF_INET, SOCK_STREAM) + self.sock.connect((self.ip, self.port)) time.sleep(1) - welcome_message = self.socket.recv(1024).decode() + welcome_message = self.sock.recv(1024).decode() # Should be the OpenGOAL welcome message (ignore version number). if "Connected to OpenGOAL" and "nREPL!" in welcome_message: logger.info(welcome_message) - return True else: - logger.error(f"Unable to connect: unexpected welcome message \"{welcome_message}\"") - return False + logger.error(f"Unable to connect to REPL websocket: unexpected welcome message \"{welcome_message}\"") except ConnectionRefusedError as e: - logger.error(f"Unable to connect: {e.strerror}") - return False + logger.error(f"Unable to connect to REPL websocket: {e.strerror}") + return - def listen(self) -> bool: - logger.info("Listening for the game...") - return self.send_form("(lt)") - - def compile(self) -> bool: - logger.info("Compiling the game... Wait for the success sound before continuing!") ok_count = 0 - try: + if self.sock: + + # Have the REPL listen to the game's internal websocket. + if self.send_form("(lt)", print_ok=False): + ok_count += 1 + # Show this visual cue when compilation is started. # It's the version number of the OpenGOAL Compiler. if self.send_form("(set! *debug-segment* #t)", print_ok=False): @@ -112,19 +137,32 @@ class JakAndDaxterReplClient: if self.send_form("(set! *cheat-mode* #f)"): ok_count += 1 + # Now wait until we see the success message... 6 times. + if ok_count == 6: + self.connected = True + else: + self.connected = False + + if self.connected: + logger.info("The REPL is ready!") + + def print_status(self): + logger.info("REPL Status:") + logger.info(" REPL process ID: " + (str(self.goalc_process.process_id) if self.goalc_process else "None")) + logger.info(" Game process ID: " + (str(self.gk_process.process_id) if self.gk_process else "None")) + try: + if self.sock: + ip, port = self.sock.getpeername() + logger.info(" Game websocket: " + (str(ip) + ", " + str(port) if ip else "None")) + self.send_form("(dotimes (i 1) " + "(sound-play-by-name " + "(static-sound-name \"menu-close\") " + "(new-sound-id) 1024 0 0 (sound-group sfx) #t))", print_ok=False) except: - logger.error(f"Unable to compile: commands were not sent properly.") - return False - - # Now wait until we see the success message... 5 times. - return ok_count == 5 - - def verify(self) -> bool: - logger.info("Verifying compilation... if you don't hear the success sound, try listening and compiling again!") - return self.send_form("(dotimes (i 1) " - "(sound-play-by-name " - "(static-sound-name \"menu-close\") " - "(new-sound-id) 1024 0 0 (sound-group sfx) #t))") + logger.warn(" Game websocket not found!") + logger.info(" Did you hear the success audio cue?") + logger.info(" Last item received: " + (str(getattr(self.item_inbox[self.inbox_index], "item")) + if self.inbox_index else "None")) def receive_item(self): ap_id = getattr(self.item_inbox[self.inbox_index], "item")