-
Notifications
You must be signed in to change notification settings - Fork 751
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Co-authored-by: Nicholas Saylor <[email protected]>
- Loading branch information
1 parent
bcbb06d
commit 7384bbd
Showing
1 changed file
with
279 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,279 @@ | ||
# BizHawk Client | ||
|
||
`BizHawkClient` is an abstract base class for a client that can access the memory of a ROM running in BizHawk. It does | ||
the legwork of connecting Python to a Lua connector script, letting you focus on the loop of checking locations and | ||
making on-the-fly modifications based on updates from the server. It also provides the same experience to users across | ||
multiple games that use it, and was built in response to a growing number of similar but separate bespoke game clients | ||
which are/were largely exclusive to BizHawk anyway. | ||
|
||
It's similar to `SNIClient`, but where `SNIClient` is designed to work for specifically SNES games across different | ||
emulators/hardware, `BizHawkClient` is designed to work for specifically BizHawk across the different systems BizHawk | ||
supports. | ||
|
||
The idea is that `BizHawkClient` connects to and communicates with a Lua script running in BizHawk. It provides an API | ||
that will call BizHawk functions for you to do things like read and write memory. And on an interval, control will be | ||
handed to a function you write for your game (`game_watcher`) which should interact with the game's memory to check what | ||
locations have been checked, give the player items, detect and send deathlinks, etc... | ||
|
||
Table of Contents: | ||
- [Connector Requests](#connector-requests) | ||
- [Requests that depend on other requests](#requests-that-depend-on-other-requests) | ||
- [Implementing a Client](#implementing-a-client) | ||
- [Example](#example) | ||
- [Tips](#tips) | ||
|
||
## Connector Requests | ||
|
||
Communication with BizHawk is done through `connector_bizhawk_generic.lua`. The client sends requests to the Lua script | ||
via sockets; the Lua script processes the request and sends the corresponding responses. | ||
|
||
The Lua script includes its own documentation, but you probably don't need to worry about the specifics. Instead, you'll | ||
be using the functions in `worlds/_bizhawk/__init__.py`. If you do need more control over the specific requests being | ||
sent or their order, you can still use `send_requests` to directly communicate with the connector script. | ||
|
||
It's not necessary to use the UI or client context if you only want to interact with the connector script. You can | ||
import and use just `worlds/_bizhawk/__init__.py`, which only depends on default modules. | ||
|
||
Here's a list of the included classes and functions. I would highly recommend looking at the actual function signatures | ||
and docstrings to learn more about each function. | ||
|
||
``` | ||
class ConnectionStatus | ||
class BizHawkContext | ||
class NotConnectedError | ||
class RequestFailedError | ||
class ConnectorError | ||
class SyncError | ||
async def read(ctx, read_list) -> list[bytes] | ||
async def write(ctx, write_list) -> None: | ||
async def guarded_read(ctx, read_list, guard_list) -> (list[bytes] | None) | ||
async def guarded_write(ctx, write_list, guard_list) -> bool | ||
async def lock(ctx) -> None | ||
async def unlock(ctx) -> None | ||
async def get_hash(ctx) -> str | ||
async def get_system(ctx) -> str | ||
async def get_cores(ctx) -> dict[str, str] | ||
async def ping(ctx) -> None | ||
async def display_message(ctx, message: str) -> None | ||
async def set_message_interval(ctx, value: float) -> None | ||
async def connect(ctx) -> bool | ||
def disconnect(ctx) -> None | ||
async def get_script_version(ctx) -> int | ||
async def send_requests(ctx, req_list) -> list[dict[str, Any]] | ||
``` | ||
|
||
`send_requests` is what actually communicates with the connector, and any functions like `guarded_read` will build the | ||
requests and then call `send_requests` for you. You can call `send_requests` yourself for more direct control, but make | ||
sure to read the docs in `connector_bizhawk_generic.lua`. | ||
|
||
A bundle of requests sent by `send_requests` will all be executed on the same frame, and by extension, so will any | ||
helper that calls `send_requests`. For example, if you were to call `read` with 3 items on your `read_list`, all 3 | ||
addresses will be read on the same frame and then sent back. | ||
|
||
It also means that, by default, the only way to run multiple requests on the same frame is for them to be included in | ||
the same `send_requests` call. As soon as the connector finishes responding to a list of requests, it will advance the | ||
frame before checking for the next batch. | ||
|
||
### Requests that depend on other requests | ||
|
||
The fact that you have to wait at least a frame to act on any response may raise concerns. For example, Pokemon | ||
Emerald's save data is at a dynamic location in memory; it moves around when you load a new map. There is a static | ||
variable that holds the address of the save data, so we want to read the static variable to get the save address, and | ||
then use that address in a `write` to send the player an item. But between the `read` that tells us the address of the | ||
save data and the `write` to save data itself, an arbitrary number of frames have been executed, and the player may have | ||
loaded a new map, meaning we've written data to who knows where. | ||
|
||
There are two solutions to this problem. | ||
|
||
1. Use `guarded_write` instead of `write`. We can include a guard against the address changing, and the script will only | ||
perform the write if the data in memory matches what's in the guard. In the below example, `write_result` will be `True` | ||
if the guard validated and the data was written, and `False` if the guard failed to validate. | ||
|
||
```py | ||
# Get the address of the save data | ||
read_result: bytes = (await _bizhawk.read(ctx, [(0x3001111, 4, "System Bus")]))[0] | ||
save_data_address = int.from_bytes(read_result, "little") | ||
|
||
# Write to `save_data_address` if it hasn't changed | ||
write_result: bool = await _bizhawk.guarded_write( | ||
ctx, | ||
[(save_data_address, [0xAA, 0xBB], "System Bus")], | ||
[(0x3001111, read_result, "System Bus")] | ||
) | ||
|
||
if write_result: | ||
# The data at 0x3001111 was still the same value as | ||
# what was returned from the first `_bizhawk.read`, | ||
# so the data was written. | ||
... | ||
else: | ||
# The data at 0x3001111 has changed since the | ||
# first `_bizhawk.read`, so the data was not written. | ||
... | ||
``` | ||
|
||
2. Use `lock` and `unlock` (discouraged if not necessary). When you call `lock`, you tell the emulator to stop advancing | ||
frames and just process requests until it receives an unlock request. This means you can lock, read the address, write | ||
the data, and then unlock on a single frame. **However**, this is _slow_. If you can't get in and get out quickly | ||
enough, players will notice a stutter in the emulation. | ||
|
||
```py | ||
# Pause emulation | ||
await _bizhawk.lock(ctx) | ||
|
||
# Get the address of the save data | ||
read_result: bytes = (await _bizhawk.read(ctx, [(0x3001111, 4, "System Bus")]))[0] | ||
save_data_address = int.from_bytes(read_result, "little") | ||
|
||
# Write to `save_data_address` | ||
await _bizhawk.write(ctx, [(save_data_address, [0xAA, 0xBB], "System Bus")]) | ||
|
||
# Resume emulation | ||
await _bizhawk.unlock(ctx) | ||
``` | ||
|
||
You should always use `guarded_read` and `guarded_write` instead of locking the emulator if possible. It may be | ||
unreliable, but that's by design. Most of the time you should have no problem giving up and retrying. Data that is | ||
volatile but only changes occasionally is the perfect use case. | ||
|
||
If data is almost guaranteed to change between frames, locking may be the better solution. You can lower the time spent | ||
locked by using `send_requests` directly to include as many requests alongside the `LOCK` and `UNLOCK` requests as | ||
possible. But in general it's probably worth doing some extra asm hacking and designing to make guards work instead. | ||
|
||
## Implementing a Client | ||
|
||
`BizHawkClient` itself is built on `CommonClient` and inspired heavily by `SNIClient`. Your world's client should | ||
inherit from `BizHawkClient` in `worlds/_bizhawk/client.py`. It must implement `validate_rom` and `game_watcher`, and | ||
must define values for `system` and `game`. | ||
|
||
As with the functions and classes in the previous section, I would highly recommend looking at the types and docstrings | ||
of the code itself. | ||
|
||
`game` should be the same value you use for your world definition. | ||
|
||
`system` can either be a string or a tuple of strings. This is the system (or systems) that your client is intended to | ||
handle games on (SNES, GBA, etc.). It's used to prevent validators from running on unknown systems and crashing. The | ||
actual abbreviation corresponds to whatever BizHawk returns from `emu.getsystemid()`. | ||
|
||
`patch_suffix` is an optional `ClassVar` meant to specify the file extensions you want to register. It can be a string | ||
or tuple of strings. When a player clicks "Open Patch" in a launcher, the suffix(es) will be whitelisted in the file | ||
select dialog and they will be associated with BizHawkClient. This does not affect whether the user's computer will | ||
associate the file extension with Archipelago. | ||
|
||
`validate_rom` is called to figure out whether a given ROM belongs to your client. It will only be called when a ROM is | ||
running on a system you specified in your `system` class variable. In most cases, that will be a single system and you | ||
can be sure that you're not about to try to read from nonexistent domains or out of bounds. If you decide to claim this | ||
ROM as yours, this is where you should do setup for things like `items_handling`. | ||
|
||
`game_watcher` is the "main loop" of your client where you should be checking memory and sending new items to the ROM. | ||
`BizHawkClient` will make sure that your `game_watcher` only runs when your client has validated the ROM, and will do | ||
its best to make sure you're connected to the connector script before calling your watcher. It runs this loop either | ||
immediately once it receives a message from the server, or a specified amount of time after the last iteration of the | ||
loop finished. | ||
|
||
`validate_rom`, `game_watcher`, and other methods will be passed an instance of `BizHawkClientContext`, which is a | ||
subclass of `CommonContext`. It additionally includes `slot_data` (if you are connected and asked for slot data), | ||
`bizhawk_ctx` (the instance of `BizHawkContext` that you should be giving to functions like `guarded_read`), and | ||
`watcher_timeout` (the amount of time in seconds between iterations of the game watcher loop). | ||
|
||
### Example | ||
|
||
A very simple client might look like this. All addresses here are made up; you should instead be using addresses that | ||
make sense for your specific ROM. The `validate_rom` here tries to read the name of the ROM. If it gets the value it | ||
wanted, it sets a couple values on `ctx` and returns `True`. The `game_watcher` reads some data from memory and acts on | ||
it by sending messages to AP. You should be smarter than this example, which will send `LocationChecks` messages even if | ||
there's nothing new since the last loop. | ||
|
||
```py | ||
from typing import TYPE_CHECKING | ||
|
||
from NetUtils import ClientStatus | ||
|
||
import worlds._bizhawk as bizhawk | ||
from worlds._bizhawk.client import BizHawkClient | ||
|
||
if TYPE_CHECKING: | ||
from worlds._bizhawk.context import BizHawkClientContext | ||
|
||
|
||
class MyGameClient(BizHawkClient): | ||
game = "My Game" | ||
system = "GBA" | ||
patch_suffix = ".apextension" | ||
|
||
async def validate_rom(self, ctx: "BizHawkClientContext") -> bool: | ||
try: | ||
# Check ROM name/patch version | ||
rom_name = ((await bizhawk.read(ctx.bizhawk_ctx, [(0x100, 6, "ROM")]))[0]).decode("ascii") | ||
if rom_name != "MYGAME": | ||
return False # Not a MYGAME ROM | ||
except bizhawk.RequestFailedError: | ||
return False # Not able to get a response, say no for now | ||
|
||
# This is a MYGAME ROM | ||
ctx.game = self.game | ||
ctx.items_handling = 0b001 | ||
ctx.want_slot_data = True | ||
|
||
return True | ||
|
||
async def game_watcher(self, ctx: "BizHawkClientContext") -> None: | ||
try: | ||
# Read save data | ||
save_data = await bizhawk.read( | ||
ctx.bizhawk_ctx, | ||
[(0x3000100, 20, "System Bus")] | ||
)[0] | ||
|
||
# Check locations | ||
if save_data[2] & 0x04: | ||
await ctx.send_msgs([{ | ||
"cmd": "LocationChecks", | ||
"locations": [23] | ||
}]) | ||
|
||
# Send game clear | ||
if not ctx.finished_game and (save_data[5] & 0x01): | ||
await ctx.send_msgs([{ | ||
"cmd": "StatusUpdate", | ||
"status": ClientStatus.CLIENT_GOAL | ||
}]) | ||
|
||
except bizhawk.RequestFailedError: | ||
# The connector didn't respond. Exit handler and return to main loop to reconnect | ||
pass | ||
``` | ||
|
||
### Tips | ||
|
||
- Make sure your client gets imported when your world is imported. You probably don't need to actually use anything in | ||
your `client.py` elsewhere, but you still have to import the file for your client to register itself. | ||
- When it comes to performance, there are two directions to optimize: | ||
1. If you need to execute multiple commands on the same frame, do as little work as possible. Only read and write necessary data, | ||
and if you have to use locks, unlock as soon as it's okay to advance frames. This is probably the obvious one. | ||
2. Multiple things that don't have to happen on the same frame should be split up if they're likely to be slow. | ||
Remember, the game watcher runs only a few times per second. Extra function calls on the client aren't that big of a | ||
deal; the player will not notice if your `game_watcher` is slow. But the emulator has to be done with any given set of | ||
commands in 1/60th of a second to avoid hiccups (faster still if your players use speedup). Too many reads of too much | ||
data at the same time is more likely to cause a bad user experience. | ||
- Your `game_watcher` will be called regardless of the status of the client's connection to the server. Double-check the | ||
server connection before trying to interact with it. | ||
- By default, the player will be asked to provide their slot name after connecting to the server and validating, and | ||
that input will be used to authenticate with the `Connect` command. You can override `set_auth` in your own client to | ||
set it automatically based on data in the ROM or on your client instance. | ||
- You can override `on_package` in your client to watch raw packages, but don't forget you also have access to a | ||
subclass of `CommonContext` and its API. | ||
- You can import `BizHawkClientContext` for type hints using `typing.TYPE_CHECKING`. Importing it without conditions at | ||
the top of the file will probably cause a circular dependency. | ||
- Your game's system may have multiple usable cores in BizHawk. You can use `get_cores` to try to determine which one is | ||
currently loaded (it's the best we can do). Some cores may differ in the names of memory domains. It's good to check all | ||
the available cores to find differences before your users do. | ||
- The connector script includes a DEBUG variable that you can use to log requests/responses. (Be aware that as the log | ||
grows in size in BizHawk, it begins to stutter while trying to print it.) |