mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-24 23:03:23 -07:00
Jak 1: Simplify user interaction with agents, make process more robust/less dependent on order of ops.
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user