Jak 1: Simplify user interaction with agents, make process more robust/less dependent on order of ops.

This commit is contained in:
massimilianodelliubaldini
2024-05-09 17:23:03 -04:00
parent 6637452b64
commit 240bb6c255
3 changed files with 169 additions and 115 deletions

View File

@@ -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 <ip> <port> : 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())

View File

@@ -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

View File

@@ -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("<II", len(form), 10)
self.socket.sendall(header + form.encode())
response = self.socket.recv(1024).decode()
self.sock.sendall(header + form.encode())
response = self.sock.recv(1024).decode()
if "OK!" in response:
if print_ok:
logger.info(response)
@@ -51,37 +70,43 @@ class JakAndDaxterReplClient:
logger.error(f"Unexpected response from REPL: {response}")
return False
def connect(self) -> 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")