Jak 1: Refactored client into components, working on async communication between the client and the game.

This commit is contained in:
massimilianodelliubaldini
2024-04-26 22:56:16 -04:00
parent 3de94fb2bc
commit 39364b38fd
6 changed files with 269 additions and 108 deletions

9
JakAndDaxterClient.py Normal file
View File

@@ -0,0 +1,9 @@
import Utils
from worlds.jakanddaxter.Client import launch
import ModuleUpdate
ModuleUpdate.update()
if __name__ == '__main__':
Utils.init_logging("JakAndDaxterClient", exception_logger="Client")
launch()

View File

@@ -1,16 +1,33 @@
import time
import struct
import typing
import asyncio
from socket import socket, AF_INET, SOCK_STREAM
import colorama
import Utils
from GameID import jak1_name
from .locs import CellLocations as Cells, ScoutLocations as Flies
from CommonClient import ClientCommandProcessor, CommonContext, logger, server_loop, gui_enabled
from .GameID import jak1_name
from .client.ReplClient import JakAndDaxterReplClient
from .client.MemoryReader import JakAndDaxterMemoryReader
import ModuleUpdate
ModuleUpdate.update()
all_tasks = set()
def create_task_log_exception(awaitable: typing.Awaitable) -> asyncio.Task:
async def _log_exception(a):
try:
return await a
except Exception as e:
logger.exception(e)
finally:
all_tasks.remove(task)
task = asyncio.create_task(_log_exception(awaitable))
all_tasks.add(task)
return task
class JakAndDaxterClientCommandProcessor(ClientCommandProcessor):
ctx: "JakAndDaxterContext"
@@ -23,112 +40,29 @@ class JakAndDaxterClientCommandProcessor(ClientCommandProcessor):
# All 3 need to be done, and in this order, for this to work.
class JakAndDaxterReplClient:
ip: str
port: int
socket: socket
connected: bool = False
listening: bool = False
compiled: bool = False
def __init__(self, ip: str = "127.0.0.1", port: int = 8181):
self.ip = ip
self.port = port
self.connected = self.g_connect()
if self.connected:
self.listening = self.g_listen()
if self.connected and self.listening:
self.compiled = self.g_compile()
# This helper function formats and sends `form` as a command to the REPL.
# ALL commands to the REPL should be sent using this function.
def send_form(self, form: str) -> None:
header = struct.pack("<II", len(form), 10)
self.socket.sendall(header + form.encode())
logger.info("Sent Form: " + form)
def g_connect(self) -> bool:
if not self.ip or not self.port:
return False
self.socket = socket(AF_INET, SOCK_STREAM)
self.socket.connect((self.ip, self.port))
time.sleep(1)
print(self.socket.recv(1024).decode())
return True
def g_listen(self) -> bool:
self.send_form("(lt)")
return True
def g_compile(self) -> bool:
# Show this visual cue when compilation is started.
# It's the version number of the OpenGOAL Compiler.
self.send_form("(set! *debug-segment* #t)")
# Play this audio cue when compilation is started.
# It's the sound you hear when you press START + CIRCLE to open the Options menu.
self.send_form("(dotimes (i 1) "
"(sound-play-by-name "
"(static-sound-name \"start-options\") "
"(new-sound-id) 1024 0 0 (sound-group sfx) #t))")
# Start compilation. This is blocking, so nothing will happen until the REPL is done.
self.send_form("(mi)")
# Play this audio cue when compilation is complete.
# It's the sound you hear when you press START + START to close the Options menu.
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))")
# Disable cheat-mode and debug (close the visual cue).
self.send_form("(set! *cheat-mode* #f)")
self.send_form("(set! *debug-segment* #f)")
return True
def g_verify(self) -> bool:
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))")
return True
# TODO - In ArchipelaGOAL, override the 'get-pickup event so that it doesn't give you the item,
# it just plays the victory animation. Then define a new event type like 'get-archipelago
# to actually give ourselves the item. See game-info.gc and target-handler.gc.
def give_power_cell(self, ap_id: int) -> None:
cell_id = Cells.to_game_id(ap_id)
self.send_form("(send-event "
"*target* \'get-archipelago "
"(pickup-type fuel-cell) "
"(the float " + str(cell_id) + "))")
def give_scout_fly(self, ap_id: int) -> None:
fly_id = Flies.to_game_id(ap_id)
self.send_form("(send-event "
"*target* \'get-archipelago "
"(pickup-type buzzer) "
"(the float " + str(fly_id) + "))")
class JakAndDaxterContext(CommonContext):
tags = {"AP"}
game = jak1_name
items_handling = 0b111 # Full item handling
command_processor = JakAndDaxterClientCommandProcessor
# We'll need two agents working in tandem to handle two-way communication with the game.
# The REPL Client will handle the server->game direction by issuing commands directly to the running game.
# But the REPL cannot send information back to us, it only ingests information we send it.
# Luckily OpenGOAL sets up memory addresses to write to, that AutoSplit can read from, for speedrunning.
# We'll piggyback off this system with a Memory Reader, and that will handle the game->server direction.
repl: JakAndDaxterReplClient
memr: JakAndDaxterMemoryReader
# And two associated tasks, so we have handles on them.
repl_task: asyncio.Task
memr_task: asyncio.Task
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
self.repl = JakAndDaxterReplClient()
self.memr = JakAndDaxterMemoryReader()
super().__init__(server_address, password)
def on_package(self, cmd: str, args: dict):
if cmd == "":
pass
def run_gui(self):
from kvui import GameManager
@@ -141,28 +75,47 @@ class JakAndDaxterContext(CommonContext):
self.ui = JakAndDaxterManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def on_package(self, cmd: str, args: dict):
if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], start=args["index"]):
self.repl.item_inbox[index] = item
def run_game():
pass
async def ap_inform_location_checks(self, location_ids: typing.List[int]):
message = [{"cmd": "LocationChecks", "locations": location_ids}]
await self.send_msgs(message)
def on_locations(self, location_ids: typing.List[int]):
create_task_log_exception(self.ap_inform_location_checks(location_ids))
async def run_repl_loop(self):
await self.repl.main_tick()
await asyncio.sleep(0.1)
async def run_memr_loop(self):
await self.memr.main_tick(self.on_locations)
await asyncio.sleep(0.1)
async def main():
Utils.init_logging("JakAndDaxterClient", exception_logger="Client")
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())
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
run_game()
await ctx.exit_event.wait()
await ctx.shutdown()
if __name__ == "__main__":
def launch():
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -7,6 +7,7 @@ from .locs import CellLocations as Cells, ScoutLocations as Scouts, OrbLocations
from .Regions import create_regions
from .Rules import set_rules
from ..AutoWorld import World, WebWorld
from ..LauncherComponents import components, Component, launch_subprocess, Type
class JakAndDaxterWebWorld(WebWorld):
@@ -44,8 +45,8 @@ class JakAndDaxterWorld(World):
"Power Cell": {item_table[k]: k for k in item_table
if k in range(jak1_id, jak1_id + Scouts.fly_offset)},
"Scout Fly": {item_table[k]: k for k in item_table
if k in range(jak1_id + Scouts.fly_offset, jak1_id + Orbs.orb_offset)},
"Precursor Orb": {} # TODO
if k in range(jak1_id + Scouts.fly_offset, jak1_id + Orbs.orb_offset)}
# "Precursor Orb": {} # TODO
}
def create_regions(self):
@@ -73,3 +74,14 @@ class JakAndDaxterWorld(World):
item = JakAndDaxterItem(name, classification, item_id, self.player)
return item
def launch_client():
from .Client import launch
launch_subprocess(launch, name="JakAndDaxterClient")
components.append(Component("Jak and Daxter Client",
"JakAndDaxterClient",
func=launch_client,
component_type=Type.CLIENT))

View File

@@ -0,0 +1,59 @@
import typing
import subprocess
import pymem
from pymem import pattern
from pymem.exception import ProcessNotFound
class JakAndDaxterMemoryReader:
marker: typing.ByteString
connected: bool = False
marked: bool = False
process = None
marker_address = None
goal_address = 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()
pass
async def main_tick(self, location_callback: typing.Callable):
self.read_memory()
# Checked Locations in game. Handle 1 location per tick.
if len(self.location_outbox) > self.outbox_index:
await location_callback(self.location_outbox[self.outbox_index])
self.outbox_index += 1
def connect(self) -> bool:
try:
self.process = pymem.Pymem("gk.exe") # The GOAL Kernel
return True
except ProcessNotFound:
return False
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:
# 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),
byteorder="little",
signed=False)
return True
return False
def read_memory(self) -> typing.Dict:
pass

View File

@@ -0,0 +1,127 @@
import time
import struct
from socket import socket, AF_INET, SOCK_STREAM
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
class JakAndDaxterReplClient:
ip: str
port: int
socket: socket
connected: bool = False
listening: bool = False
compiled: bool = False
item_inbox = {}
inbox_index = 0
def __init__(self, ip: str = "127.0.0.1", port: int = 8181):
self.ip = ip
self.port = port
async def init(self):
self.connected = await self.connect()
if self.connected:
self.listening = await self.listen()
if self.connected and self.listening:
self.compiled = await self.compile()
async def main_tick(self):
# Receive Items from AP. Handle 1 item per tick.
if len(self.item_inbox) > self.inbox_index:
await self.receive_item()
self.inbox_index += 1
# This helper function formats and sends `form` as a command to the REPL.
# ALL commands to the REPL should be sent using this function.
# TODO - this needs to block on receiving an acknowledgement from the REPL server.
# Problem is, it doesn't ack anything right now. So we need that to happen first.
async def send_form(self, form: str) -> None:
header = struct.pack("<II", len(form), 10)
self.socket.sendall(header + form.encode())
logger.info("Sent Form: " + form)
async def connect(self) -> bool:
if not self.ip or not self.port:
return False
try:
self.socket = socket(AF_INET, SOCK_STREAM)
self.socket.connect((self.ip, self.port))
time.sleep(1)
logger.info(self.socket.recv(1024).decode())
return True
except ConnectionRefusedError:
return False
async def listen(self) -> bool:
await self.send_form("(lt)")
return True
async def compile(self) -> bool:
# Show this visual cue when compilation is started.
# It's the version number of the OpenGOAL Compiler.
await self.send_form("(set! *debug-segment* #t)")
# Play this audio cue when compilation is started.
# It's the sound you hear when you press START + CIRCLE to open the Options menu.
await self.send_form("(dotimes (i 1) "
"(sound-play-by-name "
"(static-sound-name \"start-options\") "
"(new-sound-id) 1024 0 0 (sound-group sfx) #t))")
# Start compilation. This is blocking, so nothing will happen until the REPL is done.
await self.send_form("(mi)")
# Play this audio cue when compilation is complete.
# It's the sound you hear when you press START + START to close the Options menu.
await 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))")
# Disable cheat-mode and debug (close the visual cue).
await self.send_form("(set! *cheat-mode* #f)")
# await self.send_form("(set! *debug-segment* #f)")
return True
async def verify(self) -> bool:
await 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))")
return True
async def receive_item(self):
ap_id = self.item_inbox[self.inbox_index]["item"]
# Determine the type of item to receive.
if ap_id in range(jak1_id, jak1_id + Flies.fly_offset):
await self.receive_power_cell(ap_id)
elif ap_id in range(jak1_id + Flies.fly_offset, jak1_id + Orbs.orb_offset):
await self.receive_scout_fly(ap_id)
elif ap_id > jak1_id + Orbs.orb_offset:
pass # TODO
# TODO - In ArchipelaGOAL, override the 'get-pickup event so that it doesn't give you the item,
# it just plays the victory animation. Then define a new event type like 'get-archipelago
# to actually give ourselves the item. See game-info.gc and target-handler.gc.
async def receive_power_cell(self, ap_id: int) -> None:
cell_id = Cells.to_game_id(ap_id)
await self.send_form("(send-event "
"*target* \'get-archipelago "
"(pickup-type fuel-cell) "
"(the float " + str(cell_id) + "))")
async def receive_scout_fly(self, ap_id: int) -> None:
fly_id = Flies.to_game_id(ap_id)
await self.send_form("(send-event "
"*target* \'get-archipelago "
"(pickup-type buzzer) "
"(the float " + str(fly_id) + "))")

View File

@@ -0,0 +1 @@
Pymem>=1.13.0