mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-31 01:53:27 -07:00
Jak 1: Refactored client into components, working on async communication between the client and the game.
This commit is contained in:
9
JakAndDaxterClient.py
Normal file
9
JakAndDaxterClient.py
Normal 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()
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
59
worlds/jakanddaxter/client/MemoryReader.py
Normal file
59
worlds/jakanddaxter/client/MemoryReader.py
Normal 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
|
||||
127
worlds/jakanddaxter/client/ReplClient.py
Normal file
127
worlds/jakanddaxter/client/ReplClient.py
Normal 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) + "))")
|
||||
1
worlds/jakanddaxter/requirements.txt
Normal file
1
worlds/jakanddaxter/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
Pymem>=1.13.0
|
||||
Reference in New Issue
Block a user