Skip to content

Commit e290002

Browse files
Zunawenicholassaylor
authored andcommitted
BizHawkClient: Add README (ArchipelagoMW#2689)
Co-authored-by: Nicholas Saylor <[email protected]>
1 parent f72a286 commit e290002

File tree

1 file changed

+279
-0
lines changed

1 file changed

+279
-0
lines changed

worlds/_bizhawk/README.md

+279
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
# BizHawk Client
2+
3+
`BizHawkClient` is an abstract base class for a client that can access the memory of a ROM running in BizHawk. It does
4+
the legwork of connecting Python to a Lua connector script, letting you focus on the loop of checking locations and
5+
making on-the-fly modifications based on updates from the server. It also provides the same experience to users across
6+
multiple games that use it, and was built in response to a growing number of similar but separate bespoke game clients
7+
which are/were largely exclusive to BizHawk anyway.
8+
9+
It's similar to `SNIClient`, but where `SNIClient` is designed to work for specifically SNES games across different
10+
emulators/hardware, `BizHawkClient` is designed to work for specifically BizHawk across the different systems BizHawk
11+
supports.
12+
13+
The idea is that `BizHawkClient` connects to and communicates with a Lua script running in BizHawk. It provides an API
14+
that will call BizHawk functions for you to do things like read and write memory. And on an interval, control will be
15+
handed to a function you write for your game (`game_watcher`) which should interact with the game's memory to check what
16+
locations have been checked, give the player items, detect and send deathlinks, etc...
17+
18+
Table of Contents:
19+
- [Connector Requests](#connector-requests)
20+
- [Requests that depend on other requests](#requests-that-depend-on-other-requests)
21+
- [Implementing a Client](#implementing-a-client)
22+
- [Example](#example)
23+
- [Tips](#tips)
24+
25+
## Connector Requests
26+
27+
Communication with BizHawk is done through `connector_bizhawk_generic.lua`. The client sends requests to the Lua script
28+
via sockets; the Lua script processes the request and sends the corresponding responses.
29+
30+
The Lua script includes its own documentation, but you probably don't need to worry about the specifics. Instead, you'll
31+
be using the functions in `worlds/_bizhawk/__init__.py`. If you do need more control over the specific requests being
32+
sent or their order, you can still use `send_requests` to directly communicate with the connector script.
33+
34+
It's not necessary to use the UI or client context if you only want to interact with the connector script. You can
35+
import and use just `worlds/_bizhawk/__init__.py`, which only depends on default modules.
36+
37+
Here's a list of the included classes and functions. I would highly recommend looking at the actual function signatures
38+
and docstrings to learn more about each function.
39+
40+
```
41+
class ConnectionStatus
42+
class BizHawkContext
43+
44+
class NotConnectedError
45+
class RequestFailedError
46+
class ConnectorError
47+
class SyncError
48+
49+
async def read(ctx, read_list) -> list[bytes]
50+
async def write(ctx, write_list) -> None:
51+
async def guarded_read(ctx, read_list, guard_list) -> (list[bytes] | None)
52+
async def guarded_write(ctx, write_list, guard_list) -> bool
53+
54+
async def lock(ctx) -> None
55+
async def unlock(ctx) -> None
56+
57+
async def get_hash(ctx) -> str
58+
async def get_system(ctx) -> str
59+
async def get_cores(ctx) -> dict[str, str]
60+
async def ping(ctx) -> None
61+
62+
async def display_message(ctx, message: str) -> None
63+
async def set_message_interval(ctx, value: float) -> None
64+
65+
async def connect(ctx) -> bool
66+
def disconnect(ctx) -> None
67+
68+
async def get_script_version(ctx) -> int
69+
async def send_requests(ctx, req_list) -> list[dict[str, Any]]
70+
```
71+
72+
`send_requests` is what actually communicates with the connector, and any functions like `guarded_read` will build the
73+
requests and then call `send_requests` for you. You can call `send_requests` yourself for more direct control, but make
74+
sure to read the docs in `connector_bizhawk_generic.lua`.
75+
76+
A bundle of requests sent by `send_requests` will all be executed on the same frame, and by extension, so will any
77+
helper that calls `send_requests`. For example, if you were to call `read` with 3 items on your `read_list`, all 3
78+
addresses will be read on the same frame and then sent back.
79+
80+
It also means that, by default, the only way to run multiple requests on the same frame is for them to be included in
81+
the same `send_requests` call. As soon as the connector finishes responding to a list of requests, it will advance the
82+
frame before checking for the next batch.
83+
84+
### Requests that depend on other requests
85+
86+
The fact that you have to wait at least a frame to act on any response may raise concerns. For example, Pokemon
87+
Emerald's save data is at a dynamic location in memory; it moves around when you load a new map. There is a static
88+
variable that holds the address of the save data, so we want to read the static variable to get the save address, and
89+
then use that address in a `write` to send the player an item. But between the `read` that tells us the address of the
90+
save data and the `write` to save data itself, an arbitrary number of frames have been executed, and the player may have
91+
loaded a new map, meaning we've written data to who knows where.
92+
93+
There are two solutions to this problem.
94+
95+
1. Use `guarded_write` instead of `write`. We can include a guard against the address changing, and the script will only
96+
perform the write if the data in memory matches what's in the guard. In the below example, `write_result` will be `True`
97+
if the guard validated and the data was written, and `False` if the guard failed to validate.
98+
99+
```py
100+
# Get the address of the save data
101+
read_result: bytes = (await _bizhawk.read(ctx, [(0x3001111, 4, "System Bus")]))[0]
102+
save_data_address = int.from_bytes(read_result, "little")
103+
104+
# Write to `save_data_address` if it hasn't changed
105+
write_result: bool = await _bizhawk.guarded_write(
106+
ctx,
107+
[(save_data_address, [0xAA, 0xBB], "System Bus")],
108+
[(0x3001111, read_result, "System Bus")]
109+
)
110+
111+
if write_result:
112+
# The data at 0x3001111 was still the same value as
113+
# what was returned from the first `_bizhawk.read`,
114+
# so the data was written.
115+
...
116+
else:
117+
# The data at 0x3001111 has changed since the
118+
# first `_bizhawk.read`, so the data was not written.
119+
...
120+
```
121+
122+
2. Use `lock` and `unlock` (discouraged if not necessary). When you call `lock`, you tell the emulator to stop advancing
123+
frames and just process requests until it receives an unlock request. This means you can lock, read the address, write
124+
the data, and then unlock on a single frame. **However**, this is _slow_. If you can't get in and get out quickly
125+
enough, players will notice a stutter in the emulation.
126+
127+
```py
128+
# Pause emulation
129+
await _bizhawk.lock(ctx)
130+
131+
# Get the address of the save data
132+
read_result: bytes = (await _bizhawk.read(ctx, [(0x3001111, 4, "System Bus")]))[0]
133+
save_data_address = int.from_bytes(read_result, "little")
134+
135+
# Write to `save_data_address`
136+
await _bizhawk.write(ctx, [(save_data_address, [0xAA, 0xBB], "System Bus")])
137+
138+
# Resume emulation
139+
await _bizhawk.unlock(ctx)
140+
```
141+
142+
You should always use `guarded_read` and `guarded_write` instead of locking the emulator if possible. It may be
143+
unreliable, but that's by design. Most of the time you should have no problem giving up and retrying. Data that is
144+
volatile but only changes occasionally is the perfect use case.
145+
146+
If data is almost guaranteed to change between frames, locking may be the better solution. You can lower the time spent
147+
locked by using `send_requests` directly to include as many requests alongside the `LOCK` and `UNLOCK` requests as
148+
possible. But in general it's probably worth doing some extra asm hacking and designing to make guards work instead.
149+
150+
## Implementing a Client
151+
152+
`BizHawkClient` itself is built on `CommonClient` and inspired heavily by `SNIClient`. Your world's client should
153+
inherit from `BizHawkClient` in `worlds/_bizhawk/client.py`. It must implement `validate_rom` and `game_watcher`, and
154+
must define values for `system` and `game`.
155+
156+
As with the functions and classes in the previous section, I would highly recommend looking at the types and docstrings
157+
of the code itself.
158+
159+
`game` should be the same value you use for your world definition.
160+
161+
`system` can either be a string or a tuple of strings. This is the system (or systems) that your client is intended to
162+
handle games on (SNES, GBA, etc.). It's used to prevent validators from running on unknown systems and crashing. The
163+
actual abbreviation corresponds to whatever BizHawk returns from `emu.getsystemid()`.
164+
165+
`patch_suffix` is an optional `ClassVar` meant to specify the file extensions you want to register. It can be a string
166+
or tuple of strings. When a player clicks "Open Patch" in a launcher, the suffix(es) will be whitelisted in the file
167+
select dialog and they will be associated with BizHawkClient. This does not affect whether the user's computer will
168+
associate the file extension with Archipelago.
169+
170+
`validate_rom` is called to figure out whether a given ROM belongs to your client. It will only be called when a ROM is
171+
running on a system you specified in your `system` class variable. In most cases, that will be a single system and you
172+
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
173+
ROM as yours, this is where you should do setup for things like `items_handling`.
174+
175+
`game_watcher` is the "main loop" of your client where you should be checking memory and sending new items to the ROM.
176+
`BizHawkClient` will make sure that your `game_watcher` only runs when your client has validated the ROM, and will do
177+
its best to make sure you're connected to the connector script before calling your watcher. It runs this loop either
178+
immediately once it receives a message from the server, or a specified amount of time after the last iteration of the
179+
loop finished.
180+
181+
`validate_rom`, `game_watcher`, and other methods will be passed an instance of `BizHawkClientContext`, which is a
182+
subclass of `CommonContext`. It additionally includes `slot_data` (if you are connected and asked for slot data),
183+
`bizhawk_ctx` (the instance of `BizHawkContext` that you should be giving to functions like `guarded_read`), and
184+
`watcher_timeout` (the amount of time in seconds between iterations of the game watcher loop).
185+
186+
### Example
187+
188+
A very simple client might look like this. All addresses here are made up; you should instead be using addresses that
189+
make sense for your specific ROM. The `validate_rom` here tries to read the name of the ROM. If it gets the value it
190+
wanted, it sets a couple values on `ctx` and returns `True`. The `game_watcher` reads some data from memory and acts on
191+
it by sending messages to AP. You should be smarter than this example, which will send `LocationChecks` messages even if
192+
there's nothing new since the last loop.
193+
194+
```py
195+
from typing import TYPE_CHECKING
196+
197+
from NetUtils import ClientStatus
198+
199+
import worlds._bizhawk as bizhawk
200+
from worlds._bizhawk.client import BizHawkClient
201+
202+
if TYPE_CHECKING:
203+
from worlds._bizhawk.context import BizHawkClientContext
204+
205+
206+
class MyGameClient(BizHawkClient):
207+
game = "My Game"
208+
system = "GBA"
209+
patch_suffix = ".apextension"
210+
211+
async def validate_rom(self, ctx: "BizHawkClientContext") -> bool:
212+
try:
213+
# Check ROM name/patch version
214+
rom_name = ((await bizhawk.read(ctx.bizhawk_ctx, [(0x100, 6, "ROM")]))[0]).decode("ascii")
215+
if rom_name != "MYGAME":
216+
return False # Not a MYGAME ROM
217+
except bizhawk.RequestFailedError:
218+
return False # Not able to get a response, say no for now
219+
220+
# This is a MYGAME ROM
221+
ctx.game = self.game
222+
ctx.items_handling = 0b001
223+
ctx.want_slot_data = True
224+
225+
return True
226+
227+
async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
228+
try:
229+
# Read save data
230+
save_data = await bizhawk.read(
231+
ctx.bizhawk_ctx,
232+
[(0x3000100, 20, "System Bus")]
233+
)[0]
234+
235+
# Check locations
236+
if save_data[2] & 0x04:
237+
await ctx.send_msgs([{
238+
"cmd": "LocationChecks",
239+
"locations": [23]
240+
}])
241+
242+
# Send game clear
243+
if not ctx.finished_game and (save_data[5] & 0x01):
244+
await ctx.send_msgs([{
245+
"cmd": "StatusUpdate",
246+
"status": ClientStatus.CLIENT_GOAL
247+
}])
248+
249+
except bizhawk.RequestFailedError:
250+
# The connector didn't respond. Exit handler and return to main loop to reconnect
251+
pass
252+
```
253+
254+
### Tips
255+
256+
- Make sure your client gets imported when your world is imported. You probably don't need to actually use anything in
257+
your `client.py` elsewhere, but you still have to import the file for your client to register itself.
258+
- When it comes to performance, there are two directions to optimize:
259+
1. If you need to execute multiple commands on the same frame, do as little work as possible. Only read and write necessary data,
260+
and if you have to use locks, unlock as soon as it's okay to advance frames. This is probably the obvious one.
261+
2. Multiple things that don't have to happen on the same frame should be split up if they're likely to be slow.
262+
Remember, the game watcher runs only a few times per second. Extra function calls on the client aren't that big of a
263+
deal; the player will not notice if your `game_watcher` is slow. But the emulator has to be done with any given set of
264+
commands in 1/60th of a second to avoid hiccups (faster still if your players use speedup). Too many reads of too much
265+
data at the same time is more likely to cause a bad user experience.
266+
- Your `game_watcher` will be called regardless of the status of the client's connection to the server. Double-check the
267+
server connection before trying to interact with it.
268+
- By default, the player will be asked to provide their slot name after connecting to the server and validating, and
269+
that input will be used to authenticate with the `Connect` command. You can override `set_auth` in your own client to
270+
set it automatically based on data in the ROM or on your client instance.
271+
- You can override `on_package` in your client to watch raw packages, but don't forget you also have access to a
272+
subclass of `CommonContext` and its API.
273+
- You can import `BizHawkClientContext` for type hints using `typing.TYPE_CHECKING`. Importing it without conditions at
274+
the top of the file will probably cause a circular dependency.
275+
- Your game's system may have multiple usable cores in BizHawk. You can use `get_cores` to try to determine which one is
276+
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
277+
the available cores to find differences before your users do.
278+
- The connector script includes a DEBUG variable that you can use to log requests/responses. (Be aware that as the log
279+
grows in size in BizHawk, it begins to stutter while trying to print it.)

0 commit comments

Comments
 (0)