|
| 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