diff --git a/JakAndDaxterClient.py b/JakAndDaxterClient.py new file mode 100644 index 0000000000..040f8ff389 --- /dev/null +++ b/JakAndDaxterClient.py @@ -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() diff --git a/worlds/jakanddaxter/Client.py b/worlds/jakanddaxter/Client.py index 659f67bec7..c451162ae9 100644 --- a/worlds/jakanddaxter/Client.py +++ b/worlds/jakanddaxter/Client.py @@ -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(" 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() \ No newline at end of file diff --git a/worlds/jakanddaxter/__init__.py b/worlds/jakanddaxter/__init__.py index e0985b7560..a07798475b 100644 --- a/worlds/jakanddaxter/__init__.py +++ b/worlds/jakanddaxter/__init__.py @@ -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)) diff --git a/worlds/jakanddaxter/client/MemoryReader.py b/worlds/jakanddaxter/client/MemoryReader.py new file mode 100644 index 0000000000..020805b32b --- /dev/null +++ b/worlds/jakanddaxter/client/MemoryReader.py @@ -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 diff --git a/worlds/jakanddaxter/client/ReplClient.py b/worlds/jakanddaxter/client/ReplClient.py new file mode 100644 index 0000000000..0a7ca9f97c --- /dev/null +++ b/worlds/jakanddaxter/client/ReplClient.py @@ -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(" 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) + "))") diff --git a/worlds/jakanddaxter/requirements.txt b/worlds/jakanddaxter/requirements.txt new file mode 100644 index 0000000000..fe25267f67 --- /dev/null +++ b/worlds/jakanddaxter/requirements.txt @@ -0,0 +1 @@ +Pymem>=1.13.0 \ No newline at end of file