280 lines
14 KiB
Markdown
280 lines
14 KiB
Markdown
|
# 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.)
|