mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-23 21:13:21 -07:00
Temper and Harden Text Client (#52)
* Provide config path so OpenGOAL can use mod-specific saves and settings. * Add versioning to MemoryReader. Harden the client against user errors. * Updated comments. * Add Deathlink as a "statement of intent" to the YAML. Small updates to client. * Revert deathlink changes. * Update error message. * Added color markup to log messages printed in text client. * Separate loggers by agent, write markup to GUI and non-markup to disk simultaneously. * Refactor MemoryReader callbacks from main_tick to constructor. * Make callback names more... informative. * Give users explicit instructions in error messages.
This commit is contained in:
committed by
GitHub
parent
5a2da8ea4d
commit
9bec9377fd
@@ -1,5 +1,8 @@
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from logging import Logger
|
||||
|
||||
import colorama
|
||||
|
||||
import asyncio
|
||||
@@ -12,7 +15,7 @@ from pymem.exception import ProcessNotFound
|
||||
|
||||
import Utils
|
||||
from NetUtils import ClientStatus
|
||||
from CommonClient import ClientCommandProcessor, CommonContext, logger, server_loop, gui_enabled
|
||||
from CommonClient import ClientCommandProcessor, CommonContext, server_loop, gui_enabled
|
||||
from .Options import EnableOrbsanity
|
||||
|
||||
from .GameID import jak1_name
|
||||
@@ -23,6 +26,7 @@ import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
|
||||
logger = logging.getLogger("JakClient")
|
||||
all_tasks: Set[Task] = set()
|
||||
|
||||
|
||||
@@ -51,7 +55,7 @@ class JakAndDaxterClientCommandProcessor(ClientCommandProcessor):
|
||||
- status : check internal status of the REPL."""
|
||||
if arguments:
|
||||
if arguments[0] == "connect":
|
||||
logger.info("This may take a bit... Wait for the success audio cue before continuing!")
|
||||
self.ctx.on_log_info(logger, "This may take a bit... Wait for the success audio cue before continuing!")
|
||||
self.ctx.repl.initiated_connect = True
|
||||
if arguments[0] == "status":
|
||||
create_task_log_exception(self.ctx.repl.print_status())
|
||||
@@ -64,7 +68,7 @@ class JakAndDaxterClientCommandProcessor(ClientCommandProcessor):
|
||||
if arguments[0] == "connect":
|
||||
self.ctx.memr.initiated_connect = True
|
||||
if arguments[0] == "status":
|
||||
self.ctx.memr.print_status()
|
||||
create_task_log_exception(self.ctx.memr.print_status())
|
||||
|
||||
|
||||
class JakAndDaxterContext(CommonContext):
|
||||
@@ -85,8 +89,19 @@ class JakAndDaxterContext(CommonContext):
|
||||
memr_task: asyncio.Task
|
||||
|
||||
def __init__(self, server_address: Optional[str], password: Optional[str]) -> None:
|
||||
self.repl = JakAndDaxterReplClient()
|
||||
self.memr = JakAndDaxterMemoryReader()
|
||||
self.repl = JakAndDaxterReplClient(self.on_log_error,
|
||||
self.on_log_warn,
|
||||
self.on_log_success,
|
||||
self.on_log_info)
|
||||
self.memr = JakAndDaxterMemoryReader(self.on_location_check,
|
||||
self.on_finish_check,
|
||||
self.on_deathlink_check,
|
||||
self.on_deathlink_toggle,
|
||||
self.on_orb_trade,
|
||||
self.on_log_error,
|
||||
self.on_log_warn,
|
||||
self.on_log_success,
|
||||
self.on_log_info)
|
||||
# self.repl.load_data()
|
||||
# self.memr.load_data()
|
||||
super().__init__(server_address, password)
|
||||
@@ -219,7 +234,7 @@ class JakAndDaxterContext(CommonContext):
|
||||
player = self.player_names[self.slot] if self.slot is not None else "Jak"
|
||||
death_text = self.memr.cause_of_death.replace("Jak", player)
|
||||
await self.send_death(death_text)
|
||||
logger.info(death_text)
|
||||
self.on_log_warn(logger, death_text)
|
||||
|
||||
# Reset all flags, but leave the death count alone.
|
||||
self.memr.send_deathlink = False
|
||||
@@ -246,6 +261,33 @@ class JakAndDaxterContext(CommonContext):
|
||||
def on_orb_trade(self, orbs_changed: int):
|
||||
create_task_log_exception(self.ap_inform_orb_trade(orbs_changed))
|
||||
|
||||
def on_log_error(self, lg: Logger, message: str):
|
||||
lg.error(message)
|
||||
if self.ui:
|
||||
color = self.jsontotextparser.color_codes["red"]
|
||||
self.ui.log_panels["Archipelago"].on_message_markup(f"[color={color}]{message}[/color]")
|
||||
self.ui.log_panels["All"].on_message_markup(f"[color={color}]{message}[/color]")
|
||||
|
||||
def on_log_warn(self, lg: Logger, message: str):
|
||||
lg.warning(message)
|
||||
if self.ui:
|
||||
color = self.jsontotextparser.color_codes["orange"]
|
||||
self.ui.log_panels["Archipelago"].on_message_markup(f"[color={color}]{message}[/color]")
|
||||
self.ui.log_panels["All"].on_message_markup(f"[color={color}]{message}[/color]")
|
||||
|
||||
def on_log_success(self, lg: Logger, message: str):
|
||||
lg.info(message)
|
||||
if self.ui:
|
||||
color = self.jsontotextparser.color_codes["green"]
|
||||
self.ui.log_panels["Archipelago"].on_message_markup(f"[color={color}]{message}[/color]")
|
||||
self.ui.log_panels["All"].on_message_markup(f"[color={color}]{message}[/color]")
|
||||
|
||||
def on_log_info(self, lg: Logger, message: str):
|
||||
lg.info(message)
|
||||
if self.ui:
|
||||
self.ui.log_panels["Archipelago"].on_message_markup(f"{message}")
|
||||
self.ui.log_panels["All"].on_message_markup(f"{message}")
|
||||
|
||||
async def run_repl_loop(self):
|
||||
while True:
|
||||
await self.repl.main_tick()
|
||||
@@ -253,66 +295,125 @@ class JakAndDaxterContext(CommonContext):
|
||||
|
||||
async def run_memr_loop(self):
|
||||
while True:
|
||||
await self.memr.main_tick(self.on_location_check,
|
||||
self.on_finish_check,
|
||||
self.on_deathlink_check,
|
||||
self.on_deathlink_toggle,
|
||||
self.on_orb_trade)
|
||||
await self.memr.main_tick()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
async def run_game(ctx: JakAndDaxterContext):
|
||||
|
||||
# These may already be running. If they are not running, try to start them.
|
||||
# TODO - Support other OS's. cmd for some reason does not work with goalc. Pymem is Windows-only.
|
||||
gk_running = False
|
||||
try:
|
||||
pymem.Pymem("gk.exe") # The GOAL Kernel
|
||||
gk_running = True
|
||||
except ProcessNotFound:
|
||||
logger.info("Game not running, attempting to start.")
|
||||
ctx.on_log_warn(logger, "Game not running, attempting to start.")
|
||||
|
||||
goalc_running = False
|
||||
try:
|
||||
pymem.Pymem("goalc.exe") # The GOAL Compiler and REPL
|
||||
goalc_running = True
|
||||
except ProcessNotFound:
|
||||
logger.info("Compiler not running, attempting to start.")
|
||||
ctx.on_log_warn(logger, "Compiler not running, attempting to start.")
|
||||
|
||||
# Don't mind all the arguments, they are exactly what you get when you run "task boot-game" or "task repl".
|
||||
# TODO - Support other OS's. cmd for some reason does not work with goalc. Pymem is Windows-only.
|
||||
if not gk_running:
|
||||
try:
|
||||
gk_path = Utils.get_settings()["jakanddaxter_options"]["root_directory"]
|
||||
gk_path = os.path.normpath(gk_path)
|
||||
gk_path = os.path.join(gk_path, "gk.exe")
|
||||
except AttributeError as e:
|
||||
logger.error(f"Hosts.yaml does not contain {e.args[0]}, unable to locate game executables.")
|
||||
try:
|
||||
# Validate folder and file structures of the ArchipelaGOAL root directory.
|
||||
root_path = Utils.get_settings()["jakanddaxter_options"]["root_directory"]
|
||||
|
||||
# Always trust your instincts.
|
||||
if "/" not in root_path:
|
||||
msg = (f"The ArchipelaGOAL root directory contains no path. (Are you missing forward slashes?)\n"
|
||||
f"Please check that the value of 'jakanddaxter_options > root_directory' in your host.yaml file "
|
||||
f"is a valid existing path, and all backslashes have been replaced with forward slashes.")
|
||||
ctx.on_log_error(logger, msg)
|
||||
return
|
||||
|
||||
if gk_path:
|
||||
# Prefixing ampersand and wrapping gk_path in quotes is necessary for paths with spaces in them.
|
||||
# Start by checking the existence of the root directory provided in the host.yaml file.
|
||||
root_path = os.path.normpath(root_path)
|
||||
if not os.path.exists(root_path):
|
||||
msg = (f"The ArchipelaGOAL root directory does not exist, unable to locate the Game and Compiler.\n"
|
||||
f"Please check that the value of 'jakanddaxter_options > root_directory' in your host.yaml file "
|
||||
f"is a valid existing path, and all backslashes have been replaced with forward slashes.")
|
||||
ctx.on_log_error(logger, msg)
|
||||
return
|
||||
|
||||
# Now double-check the existence of the two executables we need.
|
||||
gk_path = os.path.join(root_path, "gk.exe")
|
||||
goalc_path = os.path.join(root_path, "goalc.exe")
|
||||
if not os.path.exists(gk_path) or not os.path.exists(goalc_path):
|
||||
msg = (f"The Game and Compiler could not be found in the ArchipelaGOAL root directory.\n"
|
||||
f"Please check the value of 'jakanddaxter_options > root_directory' in your host.yaml file, "
|
||||
f"and ensure that path contains gk.exe, goalc.exe, and a data folder.")
|
||||
ctx.on_log_error(logger, msg)
|
||||
return
|
||||
|
||||
# IMPORTANT: Before we check the existence of the next piece, we must ask "Are you a developer?"
|
||||
# The OpenGOAL Compiler checks the existence of the "data" folder to determine if you're running from source
|
||||
# or from a built package. As a developer, your repository folder itself IS the data folder and the Compiler
|
||||
# knows this. You would have created your "iso_data" folder here as well, so we can skip the "iso_data" check.
|
||||
# HOWEVER, for everyone who is NOT a developer, we must ensure that they copied the "iso_data" folder INTO
|
||||
# the "data" folder per the setup instructions.
|
||||
data_path = os.path.join(root_path, "data")
|
||||
if os.path.exists(data_path):
|
||||
|
||||
# NOW double-check the existence of the iso_data folder under <root directory>/data. This is necessary
|
||||
# for the compiler to compile the game correctly.
|
||||
# TODO - If the GOALC compiler is updated to take the iso_data folder as a runtime argument,
|
||||
# we may be able to remove this step.
|
||||
iso_data_path = os.path.join(root_path, "data", "iso_data")
|
||||
if not os.path.exists(iso_data_path):
|
||||
msg = (f"The iso_data folder could not be found in the ArchipelaGOAL data directory.\n"
|
||||
f"Please follow these steps:\n"
|
||||
f" Run the OpenGOAL Launcher, click Jak and Daxter > Advanced > Open Game Data Folder.\n"
|
||||
f" Copy the iso_data folder from this location.\n"
|
||||
f" Click Jak and Daxter > Features > Mods > ArchipelaGOAL > Advanced > Open Game Data Folder.\n"
|
||||
f" Paste the iso_data folder in this location.\n"
|
||||
f" Click Advanced > Compile. When this is done, click Continue.\n"
|
||||
f" Close all launchers, games, clients, and Powershell windows, then restart Archipelago.\n"
|
||||
f"(See Setup Guide for more details.)")
|
||||
ctx.on_log_error(logger, msg)
|
||||
return
|
||||
|
||||
# Now we can FINALLY attempt to start the programs.
|
||||
if not gk_running:
|
||||
# Per-mod saves and settings are stored in a spot that is a little unusual to get to. We have to .. out of
|
||||
# ArchipelaGOAL root folder, then traverse down to _settings/archipelagoal. Then we normalize this path
|
||||
# and pass it in as an argument to gk. This folder will be created if it does not exist.
|
||||
config_relative_path = "../_settings/archipelagoal"
|
||||
config_path = os.path.normpath(
|
||||
os.path.join(
|
||||
os.path.normpath(root_path),
|
||||
os.path.normpath(config_relative_path)))
|
||||
|
||||
# Prefixing ampersand and wrapping in quotes is necessary for paths with spaces in them.
|
||||
gk_process = subprocess.Popen(
|
||||
["powershell.exe", f"& \"{gk_path}\"", "--game jak1", "--", "-v", "-boot", "-fakeiso", "-debug"],
|
||||
["powershell.exe",
|
||||
f"& \"{gk_path}\"",
|
||||
f"--config-path \"{config_path}\"",
|
||||
"--game jak1",
|
||||
"--", "-v", "-boot", "-fakeiso", "-debug"],
|
||||
creationflags=subprocess.CREATE_NEW_CONSOLE) # These need to be new consoles for stability.
|
||||
|
||||
if not goalc_running:
|
||||
try:
|
||||
goalc_path = Utils.get_settings()["jakanddaxter_options"]["root_directory"]
|
||||
goalc_path = os.path.normpath(goalc_path)
|
||||
goalc_path = os.path.join(goalc_path, "goalc.exe")
|
||||
except AttributeError as e:
|
||||
logger.error(f"Hosts.yaml does not contain {e.args[0]}, unable to locate game executables.")
|
||||
return
|
||||
|
||||
if goalc_path:
|
||||
if not goalc_running:
|
||||
# Prefixing ampersand and wrapping goalc_path in quotes is necessary for paths with spaces in them.
|
||||
goalc_process = subprocess.Popen(
|
||||
["powershell.exe", f"& \"{goalc_path}\"", "--game jak1"],
|
||||
creationflags=subprocess.CREATE_NEW_CONSOLE) # These need to be new consoles for stability.
|
||||
|
||||
except AttributeError as e:
|
||||
ctx.on_log_error(logger, f"Host.yaml does not contain {e.args[0]}, unable to locate game executables.")
|
||||
return
|
||||
except FileNotFoundError as e:
|
||||
msg = (f"The ArchipelaGOAL root directory path is invalid.\n"
|
||||
f"Please check that the value of 'jakanddaxter_options > root_directory' in your host.yaml file "
|
||||
f"is a valid existing path, and all backslashes have been replaced with forward slashes.")
|
||||
ctx.on_log_error(logger, msg)
|
||||
return
|
||||
|
||||
# Auto connect the repl and memr agents. Sleep 5 because goalc takes just a little bit of time to load,
|
||||
# and it's not something we can await.
|
||||
logger.info("This may take a bit... Wait for the success audio cue before continuing!")
|
||||
ctx.on_log_info(logger, "This may take a bit... Wait for the game's title sequence before continuing!")
|
||||
await asyncio.sleep(5)
|
||||
ctx.repl.initiated_connect = True
|
||||
ctx.memr.initiated_connect = True
|
||||
@@ -331,7 +432,7 @@ async def main():
|
||||
ctx.run_cli()
|
||||
|
||||
# Find and run the game (gk) and compiler/repl (goalc).
|
||||
await run_game(ctx)
|
||||
create_task_log_exception(run_game(ctx))
|
||||
await ctx.exit_event.wait()
|
||||
await ctx.shutdown()
|
||||
|
||||
|
||||
@@ -16,26 +16,26 @@ cell_item_table = {
|
||||
0: "Power Cell",
|
||||
}
|
||||
|
||||
# Scout flies are interchangeable within their respective sets of 7. Notice the abbreviated level name after each item.
|
||||
# Scout flies are interchangeable within their respective sets of 7. Notice the level name after each item.
|
||||
# Also, notice that their Item ID equals their respective Power Cell's Location ID. This is necessary for
|
||||
# game<->archipelago communication.
|
||||
scout_item_table = {
|
||||
95: "Scout Fly - GR",
|
||||
75: "Scout Fly - SV",
|
||||
7: "Scout Fly - FJ",
|
||||
20: "Scout Fly - SB",
|
||||
28: "Scout Fly - MI",
|
||||
68: "Scout Fly - FC",
|
||||
76: "Scout Fly - RV",
|
||||
57: "Scout Fly - PB",
|
||||
49: "Scout Fly - LPC",
|
||||
43: "Scout Fly - BS",
|
||||
88: "Scout Fly - MP",
|
||||
77: "Scout Fly - VC",
|
||||
85: "Scout Fly - SC",
|
||||
65: "Scout Fly - SM",
|
||||
90: "Scout Fly - LT",
|
||||
91: "Scout Fly - GMC",
|
||||
95: "Scout Fly - Geyser Rock",
|
||||
75: "Scout Fly - Sandover Village",
|
||||
7: "Scout Fly - Sentinel Beach",
|
||||
20: "Scout Fly - Forbidden Jungle",
|
||||
28: "Scout Fly - Misty Island",
|
||||
68: "Scout Fly - Fire Canyon",
|
||||
76: "Scout Fly - Rock Village",
|
||||
57: "Scout Fly - Lost Precursor City",
|
||||
49: "Scout Fly - Boggy Swamp",
|
||||
43: "Scout Fly - Precursor Basin",
|
||||
88: "Scout Fly - Mountain Pass",
|
||||
77: "Scout Fly - Volcanic Crater",
|
||||
85: "Scout Fly - Snowy Mountain",
|
||||
65: "Scout Fly - Spider Cave",
|
||||
90: "Scout Fly - Lava Tube",
|
||||
91: "Scout Fly - Citadel", # Had to shorten, it was >32 characters.
|
||||
}
|
||||
|
||||
# Orbs are also generic and interchangeable.
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import logging
|
||||
import random
|
||||
import struct
|
||||
from typing import ByteString, List, Callable
|
||||
from typing import ByteString, List, Callable, Optional
|
||||
import json
|
||||
import pymem
|
||||
from pymem import pattern
|
||||
from pymem.exception import ProcessNotFound, ProcessError, MemoryReadError, WinAPIError
|
||||
from dataclasses import dataclass
|
||||
|
||||
from CommonClient import logger
|
||||
from ..locs import (OrbLocations as Orbs,
|
||||
CellLocations as Cells,
|
||||
ScoutLocations as Flies,
|
||||
SpecialLocations as Specials,
|
||||
OrbCacheLocations as Caches)
|
||||
|
||||
|
||||
logger = logging.getLogger("MemoryReader")
|
||||
|
||||
|
||||
# Some helpful constants.
|
||||
sizeof_uint64 = 8
|
||||
sizeof_uint32 = 4
|
||||
@@ -21,6 +25,12 @@ sizeof_uint8 = 1
|
||||
sizeof_float = 4
|
||||
|
||||
|
||||
# *****************************************************************************
|
||||
# **** This number must match (-> *ap-info-jak1* version) in ap-struct.gc! ****
|
||||
# *****************************************************************************
|
||||
expected_memory_version = 2
|
||||
|
||||
|
||||
# IMPORTANT: OpenGOAL memory structures are particular about the alignment, in memory, of member elements according to
|
||||
# their size in bits. The address for an N-bit field must be divisible by N. Use this class to define the memory offsets
|
||||
# of important values in the struct. It will also do the byte alignment properly for you.
|
||||
@@ -89,6 +99,12 @@ their_item_owner_offset = offsets.define(sizeof_uint8, 32)
|
||||
my_item_name_offset = offsets.define(sizeof_uint8, 32)
|
||||
my_item_finder_offset = offsets.define(sizeof_uint8, 32)
|
||||
|
||||
# Version of the memory struct, to cut down on mod/apworld version mismatches.
|
||||
memory_version_offset = offsets.define(sizeof_uint32)
|
||||
|
||||
# Connection status to AP server (not the game!)
|
||||
server_connection_offset = offsets.define(sizeof_uint8)
|
||||
|
||||
# The End.
|
||||
end_marker_offset = offsets.define(sizeof_uint8, 4)
|
||||
|
||||
@@ -161,61 +177,94 @@ class JakAndDaxterMemoryReader:
|
||||
orbsanity_enabled: bool = False
|
||||
orbs_paid: int = 0
|
||||
|
||||
def __init__(self, marker: ByteString = b'UnLiStEdStRaTs_JaK1\x00'):
|
||||
self.marker = marker
|
||||
self.connect()
|
||||
# Game-related callbacks (inform the AP server of changes to game state)
|
||||
inform_checked_location: Callable
|
||||
inform_finished_game: Callable
|
||||
inform_died: Callable
|
||||
inform_toggled_deathlink: Callable
|
||||
inform_traded_orbs: Callable
|
||||
|
||||
async def main_tick(self,
|
||||
location_callback: Callable,
|
||||
finish_callback: Callable,
|
||||
deathlink_callback: Callable,
|
||||
deathlink_toggle: Callable,
|
||||
paid_orbs_callback: Callable):
|
||||
# Logging callbacks
|
||||
# These will write to the provided logger, as well as the Client GUI with color markup.
|
||||
log_error: Callable # Red
|
||||
log_warn: Callable # Orange
|
||||
log_success: Callable # Green
|
||||
log_info: Callable # White (default)
|
||||
|
||||
def __init__(self,
|
||||
location_check_callback: Callable,
|
||||
finish_game_callback: Callable,
|
||||
send_deathlink_callback: Callable,
|
||||
toggle_deathlink_callback: Callable,
|
||||
orb_trade_callback: Callable,
|
||||
log_error_callback: Callable,
|
||||
log_warn_callback: Callable,
|
||||
log_success_callback: Callable,
|
||||
log_info_callback: Callable,
|
||||
marker: ByteString = b'UnLiStEdStRaTs_JaK1\x00'):
|
||||
self.marker = marker
|
||||
|
||||
self.inform_checked_location = location_check_callback
|
||||
self.inform_finished_game = finish_game_callback
|
||||
self.inform_died = send_deathlink_callback
|
||||
self.inform_toggled_deathlink = toggle_deathlink_callback
|
||||
self.inform_traded_orbs = orb_trade_callback
|
||||
|
||||
self.log_error = log_error_callback
|
||||
self.log_warn = log_warn_callback
|
||||
self.log_success = log_success_callback
|
||||
self.log_info = log_info_callback
|
||||
|
||||
async def main_tick(self):
|
||||
if self.initiated_connect:
|
||||
await self.connect()
|
||||
await self.verify_memory_version()
|
||||
self.initiated_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, MemoryReadError, WinAPIError):
|
||||
logger.error("The gk process has died. Restart the game and run \"/memr connect\" again.")
|
||||
self.log_error(logger, "The gk process has died. Restart the game and run \"/memr connect\" again.")
|
||||
self.connected = False
|
||||
else:
|
||||
return
|
||||
|
||||
# Save some state variables temporarily.
|
||||
old_deathlink_enabled = self.deathlink_enabled
|
||||
# TODO - How drastic of a change is this, to wrap all of main_tick in a self.connected check?
|
||||
if self.connected:
|
||||
|
||||
# Read the memory address to check the state of the game.
|
||||
self.read_memory()
|
||||
# Save some state variables temporarily.
|
||||
old_deathlink_enabled = self.deathlink_enabled
|
||||
|
||||
# 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.save_data()
|
||||
self.outbox_index += 1
|
||||
# Read the memory address to check the state of the game.
|
||||
self.read_memory()
|
||||
|
||||
if self.finished_game:
|
||||
finish_callback()
|
||||
# Checked Locations in game. Handle the entire outbox every tick until we're up to speed.
|
||||
if len(self.location_outbox) > self.outbox_index:
|
||||
self.inform_checked_location(self.location_outbox)
|
||||
self.save_data()
|
||||
self.outbox_index += 1
|
||||
|
||||
if old_deathlink_enabled != self.deathlink_enabled:
|
||||
deathlink_toggle()
|
||||
logger.debug("Toggled DeathLink " + ("ON" if self.deathlink_enabled else "OFF"))
|
||||
if self.finished_game:
|
||||
self.inform_finished_game()
|
||||
|
||||
if self.send_deathlink:
|
||||
deathlink_callback()
|
||||
if old_deathlink_enabled != self.deathlink_enabled:
|
||||
self.inform_toggled_deathlink()
|
||||
logger.debug("Toggled DeathLink " + ("ON" if self.deathlink_enabled else "OFF"))
|
||||
|
||||
if self.orbs_paid > 0:
|
||||
paid_orbs_callback(self.orbs_paid)
|
||||
self.orbs_paid = 0
|
||||
if self.send_deathlink:
|
||||
self.inform_died()
|
||||
|
||||
if self.orbs_paid > 0:
|
||||
self.inform_traded_orbs(self.orbs_paid)
|
||||
self.orbs_paid = 0
|
||||
|
||||
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))
|
||||
logger.debug("Found the gk process: " + str(self.gk_process.process_id))
|
||||
except ProcessNotFound:
|
||||
logger.error("Could not find the gk process.")
|
||||
self.log_error(logger, "Could not find the gk process.")
|
||||
self.connected = False
|
||||
return
|
||||
|
||||
@@ -230,21 +279,45 @@ class JakAndDaxterMemoryReader:
|
||||
self.goal_address = int.from_bytes(self.gk_process.read_bytes(goal_pointer, sizeof_uint64),
|
||||
byteorder="little",
|
||||
signed=False)
|
||||
logger.info("Found the archipelago memory address: " + str(self.goal_address))
|
||||
logger.debug("Found the archipelago memory address: " + str(self.goal_address))
|
||||
self.connected = True
|
||||
else:
|
||||
logger.error("Could not find the archipelago memory address.")
|
||||
self.log_error(logger, "Could not find the archipelago memory address!")
|
||||
self.connected = False
|
||||
|
||||
if self.connected:
|
||||
logger.info("The Memory Reader is ready!")
|
||||
async def verify_memory_version(self):
|
||||
if not self.connected:
|
||||
self.log_error(logger, "The Memory Reader is not connected!")
|
||||
|
||||
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 - 1])
|
||||
if self.outbox_index else "None"))
|
||||
memory_version: Optional[int] = None
|
||||
try:
|
||||
memory_version = self.read_goal_address(memory_version_offset, sizeof_uint32)
|
||||
if memory_version == expected_memory_version:
|
||||
self.log_success(logger, "The Memory Reader is ready!")
|
||||
else:
|
||||
raise MemoryReadError(memory_version_offset, sizeof_uint32)
|
||||
except (ProcessError, MemoryReadError, WinAPIError):
|
||||
msg = (f"The OpenGOAL memory structure is incompatible with the current Archipelago client!\n"
|
||||
f" Expected Version: {str(expected_memory_version)}\n"
|
||||
f" Found Version: {str(memory_version)}\n"
|
||||
f"Please follow these steps:\n"
|
||||
f" Run the OpenGOAL Launcher, click Jak and Daxter > Features > Mods > ArchipelaGOAL.\n"
|
||||
f" Click Update (if one is available).\n"
|
||||
f" Click Advanced > Compile. When this is done, click Continue.\n"
|
||||
f" Click Versions and verify the latest version is marked 'Active'.\n"
|
||||
f" Close all launchers, games, clients, and Powershell windows, then restart Archipelago.")
|
||||
self.log_error(logger, msg)
|
||||
self.connected = False
|
||||
|
||||
async def print_status(self):
|
||||
proc_id = str(self.gk_process.process_id) if self.gk_process else "None"
|
||||
last_loc = str(self.location_outbox[self.outbox_index - 1] if self.outbox_index else "None")
|
||||
msg = (f"Memory Reader Status:\n"
|
||||
f" Game process ID: {proc_id}\n"
|
||||
f" Game state memory address: {str(self.goal_address)}\n"
|
||||
f" Last location checked: {last_loc}")
|
||||
await self.verify_memory_version()
|
||||
self.log_info(logger, msg)
|
||||
|
||||
def read_memory(self) -> List[int]:
|
||||
try:
|
||||
@@ -350,10 +423,10 @@ class JakAndDaxterMemoryReader:
|
||||
completed = self.read_goal_address(completed_offset, sizeof_uint8)
|
||||
if completed > 0 and not self.finished_game:
|
||||
self.finished_game = True
|
||||
logger.info("Congratulations! You finished the game!")
|
||||
self.log_success(logger, "Congratulations! You finished the game!")
|
||||
|
||||
except (ProcessError, MemoryReadError, WinAPIError):
|
||||
logger.error("The gk process has died. Restart the game and run \"/memr connect\" again.")
|
||||
self.log_error(logger, "The gk process has died. Restart the game and run \"/memr connect\" again.")
|
||||
self.connected = False
|
||||
|
||||
return self.location_outbox
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import time
|
||||
import struct
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from queue import Queue
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, Optional, Callable
|
||||
|
||||
import pymem
|
||||
from pymem.exception import ProcessNotFound, ProcessError
|
||||
@@ -13,7 +14,6 @@ from pymem.exception import ProcessNotFound, ProcessError
|
||||
import asyncio
|
||||
from asyncio import StreamReader, StreamWriter, Lock
|
||||
|
||||
from CommonClient import logger
|
||||
from NetUtils import NetworkItem
|
||||
from ..GameID import jak1_id, jak1_max
|
||||
from ..Items import item_table
|
||||
@@ -25,6 +25,9 @@ from ..locs import (
|
||||
OrbCacheLocations as Caches)
|
||||
|
||||
|
||||
logger = logging.getLogger("ReplClient")
|
||||
|
||||
|
||||
@dataclass
|
||||
class JsonMessageData:
|
||||
my_item_name: Optional[str] = None
|
||||
@@ -53,11 +56,27 @@ class JakAndDaxterReplClient:
|
||||
inbox_index = 0
|
||||
json_message_queue: Queue[JsonMessageData] = queue.Queue()
|
||||
|
||||
def __init__(self, ip: str = "127.0.0.1", port: int = 8181):
|
||||
# Logging callbacks
|
||||
# These will write to the provided logger, as well as the Client GUI with color markup.
|
||||
log_error: Callable # Red
|
||||
log_warn: Callable # Orange
|
||||
log_success: Callable # Green
|
||||
log_info: Callable # White (default)
|
||||
|
||||
def __init__(self,
|
||||
log_error_callback: Callable,
|
||||
log_warn_callback: Callable,
|
||||
log_success_callback: Callable,
|
||||
log_info_callback: Callable,
|
||||
ip: str = "127.0.0.1",
|
||||
port: int = 8181):
|
||||
self.ip = ip
|
||||
self.port = port
|
||||
self.lock = asyncio.Lock()
|
||||
self.connect()
|
||||
self.log_error = log_error_callback
|
||||
self.log_warn = log_warn_callback
|
||||
self.log_success = log_success_callback
|
||||
self.log_info = log_info_callback
|
||||
|
||||
async def main_tick(self):
|
||||
if self.initiated_connect:
|
||||
@@ -68,12 +87,13 @@ class JakAndDaxterReplClient:
|
||||
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.log_error(logger, "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.log_error(logger,
|
||||
"The goalc process has died. Restart the compiler and run \"/repl connect\" again.")
|
||||
self.connected = False
|
||||
else:
|
||||
return
|
||||
@@ -111,22 +131,22 @@ class JakAndDaxterReplClient:
|
||||
logger.debug(response)
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Unexpected response from REPL: {response}")
|
||||
self.log_error(logger, f"Unexpected response from REPL: {response}")
|
||||
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))
|
||||
logger.debug("Found the gk process: " + str(self.gk_process.process_id))
|
||||
except ProcessNotFound:
|
||||
logger.error("Could not find the gk process.")
|
||||
self.log_error(logger, "Could not find the gk process.")
|
||||
return
|
||||
|
||||
try:
|
||||
self.goalc_process = pymem.Pymem("goalc.exe") # The GOAL Compiler and REPL
|
||||
logger.info("Found the goalc process: " + str(self.goalc_process.process_id))
|
||||
logger.debug("Found the goalc process: " + str(self.goalc_process.process_id))
|
||||
except ProcessNotFound:
|
||||
logger.error("Could not find the goalc process.")
|
||||
self.log_error(logger, "Could not find the goalc process.")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -139,9 +159,10 @@ class JakAndDaxterReplClient:
|
||||
if "Connected to OpenGOAL" and "nREPL!" in welcome_message:
|
||||
logger.debug(welcome_message)
|
||||
else:
|
||||
logger.error(f"Unable to connect to REPL websocket: unexpected welcome message \"{welcome_message}\"")
|
||||
self.log_error(logger,
|
||||
f"Unable to connect to REPL websocket: unexpected welcome message \"{welcome_message}\"")
|
||||
except ConnectionRefusedError as e:
|
||||
logger.error(f"Unable to connect to REPL websocket: {e.strerror}")
|
||||
self.log_error(logger, f"Unable to connect to REPL websocket: {e.strerror}")
|
||||
return
|
||||
|
||||
ok_count = 0
|
||||
@@ -175,7 +196,7 @@ class JakAndDaxterReplClient:
|
||||
if await self.send_form("(set! *cheat-mode* #f)", print_ok=False):
|
||||
ok_count += 1
|
||||
|
||||
# Run the retail game start sequence (while still in debug).
|
||||
# Run the retail game start sequence (while still connected with REPL).
|
||||
if await self.send_form("(start \'play (get-continue-by-name *game-info* \"title-start\"))"):
|
||||
ok_count += 1
|
||||
|
||||
@@ -186,25 +207,29 @@ class JakAndDaxterReplClient:
|
||||
self.connected = False
|
||||
|
||||
if self.connected:
|
||||
logger.info("The REPL is ready!")
|
||||
self.log_success(logger, "The REPL is ready!")
|
||||
|
||||
async 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"))
|
||||
gc_proc_id = str(self.goalc_process.process_id) if self.goalc_process else "None"
|
||||
gk_proc_id = str(self.gk_process.process_id) if self.gk_process else "None"
|
||||
msg = (f"REPL Status:\n"
|
||||
f" REPL process ID: {gc_proc_id}\n"
|
||||
f" Game process ID: {gk_proc_id}\n")
|
||||
try:
|
||||
if self.reader and self.writer:
|
||||
addr = self.writer.get_extra_info("peername")
|
||||
logger.info(" Game websocket: " + (str(addr) if addr else "None"))
|
||||
addr = str(addr) if addr else "None"
|
||||
msg += f" Game websocket: {addr}\n"
|
||||
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))", print_ok=False)
|
||||
except ConnectionResetError:
|
||||
logger.warn(" Connection to the game was lost or reset!")
|
||||
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"))
|
||||
msg += f" Connection to the game was lost or reset!"
|
||||
last_item = str(getattr(self.item_inbox[self.inbox_index], "item")) if self.inbox_index else "None"
|
||||
msg += f" Last item received: {last_item}\n"
|
||||
msg += f" Did you hear the success audio cue?"
|
||||
self.log_info(logger, msg)
|
||||
|
||||
# To properly display in-game text, it must be alphanumeric and uppercase.
|
||||
# I also only allotted 32 bytes to each string in OpenGOAL, so we must truncate.
|
||||
@@ -255,7 +280,7 @@ class JakAndDaxterReplClient:
|
||||
elif ap_id == jak1_max:
|
||||
await self.receive_green_eco() # Ponder why I chose to do ID's this way.
|
||||
else:
|
||||
raise KeyError(f"Tried to receive item with unknown AP ID {ap_id}.")
|
||||
self.log_error(logger, f"Tried to receive item with unknown AP ID {ap_id}!")
|
||||
|
||||
async def receive_power_cell(self, ap_id: int) -> bool:
|
||||
cell_id = Cells.to_game_id(ap_id)
|
||||
@@ -266,7 +291,7 @@ class JakAndDaxterReplClient:
|
||||
if ok:
|
||||
logger.debug(f"Received a Power Cell!")
|
||||
else:
|
||||
logger.error(f"Unable to receive a Power Cell!")
|
||||
self.log_error(logger, f"Unable to receive a Power Cell!")
|
||||
return ok
|
||||
|
||||
async def receive_scout_fly(self, ap_id: int) -> bool:
|
||||
@@ -278,7 +303,7 @@ class JakAndDaxterReplClient:
|
||||
if ok:
|
||||
logger.debug(f"Received a {item_table[ap_id]}!")
|
||||
else:
|
||||
logger.error(f"Unable to receive a {item_table[ap_id]}!")
|
||||
self.log_error(logger, f"Unable to receive a {item_table[ap_id]}!")
|
||||
return ok
|
||||
|
||||
async def receive_special(self, ap_id: int) -> bool:
|
||||
@@ -290,7 +315,7 @@ class JakAndDaxterReplClient:
|
||||
if ok:
|
||||
logger.debug(f"Received special unlock {item_table[ap_id]}!")
|
||||
else:
|
||||
logger.error(f"Unable to receive special unlock {item_table[ap_id]}!")
|
||||
self.log_error(logger, f"Unable to receive special unlock {item_table[ap_id]}!")
|
||||
return ok
|
||||
|
||||
async def receive_move(self, ap_id: int) -> bool:
|
||||
@@ -302,7 +327,7 @@ class JakAndDaxterReplClient:
|
||||
if ok:
|
||||
logger.debug(f"Received the ability to {item_table[ap_id]}!")
|
||||
else:
|
||||
logger.error(f"Unable to receive the ability to {item_table[ap_id]}!")
|
||||
self.log_error(logger, f"Unable to receive the ability to {item_table[ap_id]}!")
|
||||
return ok
|
||||
|
||||
async def receive_precursor_orb(self, ap_id: int) -> bool:
|
||||
@@ -314,7 +339,7 @@ class JakAndDaxterReplClient:
|
||||
if ok:
|
||||
logger.debug(f"Received {orb_amount} Precursor Orbs!")
|
||||
else:
|
||||
logger.error(f"Unable to receive {orb_amount} Precursor Orbs!")
|
||||
self.log_error(logger, f"Unable to receive {orb_amount} Precursor Orbs!")
|
||||
return ok
|
||||
|
||||
# Green eco pills are our filler item. Use the get-pickup event instead to handle being full health.
|
||||
@@ -323,7 +348,7 @@ class JakAndDaxterReplClient:
|
||||
if ok:
|
||||
logger.debug(f"Received a green eco pill!")
|
||||
else:
|
||||
logger.error(f"Unable to receive a green eco pill!")
|
||||
self.log_error(logger, f"Unable to receive a green eco pill!")
|
||||
return ok
|
||||
|
||||
async def receive_deathlink(self) -> bool:
|
||||
@@ -343,7 +368,7 @@ class JakAndDaxterReplClient:
|
||||
if ok:
|
||||
logger.debug(f"Received deathlink signal!")
|
||||
else:
|
||||
logger.error(f"Unable to receive deathlink signal!")
|
||||
self.log_error(logger, f"Unable to receive deathlink signal!")
|
||||
return ok
|
||||
|
||||
async def subtract_traded_orbs(self, orb_count: int) -> bool:
|
||||
@@ -357,11 +382,15 @@ class JakAndDaxterReplClient:
|
||||
if ok:
|
||||
logger.debug(f"Subtracting {orb_count} traded orbs!")
|
||||
else:
|
||||
logger.error(f"Unable to subtract {orb_count} traded orbs!")
|
||||
self.log_error(logger, f"Unable to subtract {orb_count} traded orbs!")
|
||||
return ok
|
||||
|
||||
return True
|
||||
|
||||
# OpenGOAL has a limit of 8 parameters per function. We've already hit this limit. We may have to split these
|
||||
# options into two groups, both of which required to be sent successfully, in the future.
|
||||
# TODO - Alternatively, define a new datatype in OpenGOAL that holds all these options, instantiate the type here,
|
||||
# and rewrite the ap-setup-options! function to take that instance as input.
|
||||
async def setup_options(self,
|
||||
os_option: int, os_bundle: int,
|
||||
fc_count: int, mp_count: int,
|
||||
@@ -373,14 +402,23 @@ class JakAndDaxterReplClient:
|
||||
f"(the float {lt_count}) (the float {ct_amount}) "
|
||||
f"(the float {ot_amount}) (the uint {goal_id}))")
|
||||
message = (f"Setting options: \n"
|
||||
f" Orbsanity Option {os_option}, Orbsanity Bundle {os_bundle}, \n"
|
||||
f" FC Cell Count {fc_count}, MP Cell Count {mp_count}, \n"
|
||||
f" LT Cell Count {lt_count}, Citizen Orb Amt {ct_amount}, \n"
|
||||
f" Oracle Orb Amt {ot_amount}, Completion GOAL {goal_id}... ")
|
||||
f" Orbsanity Option {os_option}, Orbsanity Bundle {os_bundle}, \n"
|
||||
f" FC Cell Count {fc_count}, MP Cell Count {mp_count}, \n"
|
||||
f" LT Cell Count {lt_count}, Citizen Orb Amt {ct_amount}, \n"
|
||||
f" Oracle Orb Amt {ot_amount}, Completion GOAL {goal_id}... ")
|
||||
if ok:
|
||||
logger.debug(message + "Success!")
|
||||
status = 1
|
||||
else:
|
||||
logger.error(message + "Failed!")
|
||||
self.log_error(logger, message + "Failed!")
|
||||
status = 2
|
||||
|
||||
ok = await self.send_form(f"(ap-set-connection-status! (the uint {status}))")
|
||||
if ok:
|
||||
logger.debug(f"Connection Status {status} set!")
|
||||
else:
|
||||
self.log_error(logger, f"Connection Status {status} failed to set!")
|
||||
|
||||
return ok
|
||||
|
||||
async def save_data(self):
|
||||
|
||||
Reference in New Issue
Block a user