BizHawkClient: Add BizHawkClient (#1978)

Adds a generic client that can communicate with BizHawk. Similar to SNIClient, but for arbitrary systems and doesn't have an intermediary application like SNI.
This commit is contained in:
Bryce Wilson 2023-10-02 17:44:19 -07:00 committed by GitHub
parent 24403eba1b
commit bc11c9dfd4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 1300 additions and 0 deletions

9
BizHawkClient.py Normal file
View File

@ -0,0 +1,9 @@
from __future__ import annotations
import ModuleUpdate
ModuleUpdate.update()
from worlds._bizhawk.context import launch
if __name__ == "__main__":
launch()

119
data/lua/base64.lua Normal file
View File

@ -0,0 +1,119 @@
-- This file originates from this repository: https://github.com/iskolbin/lbase64
-- It was modified to translate between base64 strings and lists of bytes instead of base64 strings and strings.
local base64 = {}
local extract = _G.bit32 and _G.bit32.extract -- Lua 5.2/Lua 5.3 in compatibility mode
if not extract then
if _G._VERSION == "Lua 5.4" then
extract = load[[return function( v, from, width )
return ( v >> from ) & ((1 << width) - 1)
end]]()
elseif _G.bit then -- LuaJIT
local shl, shr, band = _G.bit.lshift, _G.bit.rshift, _G.bit.band
extract = function( v, from, width )
return band( shr( v, from ), shl( 1, width ) - 1 )
end
elseif _G._VERSION == "Lua 5.1" then
extract = function( v, from, width )
local w = 0
local flag = 2^from
for i = 0, width-1 do
local flag2 = flag + flag
if v % flag2 >= flag then
w = w + 2^i
end
flag = flag2
end
return w
end
end
end
function base64.makeencoder( s62, s63, spad )
local encoder = {}
for b64code, char in pairs{[0]='A','B','C','D','E','F','G','H','I','J',
'K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y',
'Z','a','b','c','d','e','f','g','h','i','j','k','l','m','n',
'o','p','q','r','s','t','u','v','w','x','y','z','0','1','2',
'3','4','5','6','7','8','9',s62 or '+',s63 or'/',spad or'='} do
encoder[b64code] = char:byte()
end
return encoder
end
function base64.makedecoder( s62, s63, spad )
local decoder = {}
for b64code, charcode in pairs( base64.makeencoder( s62, s63, spad )) do
decoder[charcode] = b64code
end
return decoder
end
local DEFAULT_ENCODER = base64.makeencoder()
local DEFAULT_DECODER = base64.makedecoder()
local char, concat = string.char, table.concat
function base64.encode( arr, encoder )
encoder = encoder or DEFAULT_ENCODER
local t, k, n = {}, 1, #arr
local lastn = n % 3
for i = 1, n-lastn, 3 do
local a, b, c = arr[i], arr[i + 1], arr[i + 2]
local v = a*0x10000 + b*0x100 + c
local s
s = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[extract(v,0,6)])
t[k] = s
k = k + 1
end
if lastn == 2 then
local a, b = arr[n-1], arr[n]
local v = a*0x10000 + b*0x100
t[k] = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[64])
elseif lastn == 1 then
local v = arr[n]*0x10000
t[k] = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[64], encoder[64])
end
return concat( t )
end
function base64.decode( b64, decoder )
decoder = decoder or DEFAULT_DECODER
local pattern = '[^%w%+%/%=]'
if decoder then
local s62, s63
for charcode, b64code in pairs( decoder ) do
if b64code == 62 then s62 = charcode
elseif b64code == 63 then s63 = charcode
end
end
pattern = ('[^%%w%%%s%%%s%%=]'):format( char(s62), char(s63) )
end
b64 = b64:gsub( pattern, '' )
local t, k = {}, 1
local n = #b64
local padding = b64:sub(-2) == '==' and 2 or b64:sub(-1) == '=' and 1 or 0
for i = 1, padding > 0 and n-4 or n, 4 do
local a, b, c, d = b64:byte( i, i+3 )
local s
local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40 + decoder[d]
table.insert(t,extract(v,16,8))
table.insert(t,extract(v,8,8))
table.insert(t,extract(v,0,8))
end
if padding == 1 then
local a, b, c = b64:byte( n-3, n-1 )
local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40
table.insert(t,extract(v,16,8))
table.insert(t,extract(v,8,8))
elseif padding == 2 then
local a, b = b64:byte( n-3, n-2 )
local v = decoder[a]*0x40000 + decoder[b]*0x1000
table.insert(t,extract(v,16,8))
end
return t
end
return base64

View File

@ -0,0 +1,564 @@
--[[
Copyright (c) 2023 Zunawe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
]]
local SCRIPT_VERSION = 1
--[[
This script expects to receive JSON and will send JSON back. A message should
be a list of 1 or more requests which will be executed in order. Each request
will have a corresponding response in the same order.
Every individual request and response is a JSON object with at minimum one
field `type`. The value of `type` determines what other fields may exist.
To get the script version, instead of JSON, send "VERSION" to get the script
version directly (e.g. "2").
#### Ex. 1
Request: `[{"type": "PING"}]`
Response: `[{"type": "PONG"}]`
---
#### Ex. 2
Request: `[{"type": "LOCK"}, {"type": "HASH"}]`
Response: `[{"type": "LOCKED"}, {"type": "HASH_RESPONSE", "value": "F7D18982"}]`
---
#### Ex. 3
Request:
```json
[
{"type": "GUARD", "address": 100, "expected_data": "aGVsbG8=", "domain": "System Bus"},
{"type": "READ", "address": 500, "size": 4, "domain": "ROM"}
]
```
Response:
```json
[
{"type": "GUARD_RESPONSE", "address": 100, "value": true},
{"type": "READ_RESPONSE", "value": "dGVzdA=="}
]
```
---
#### Ex. 4
Request:
```json
[
{"type": "GUARD", "address": 100, "expected_data": "aGVsbG8=", "domain": "System Bus"},
{"type": "READ", "address": 500, "size": 4, "domain": "ROM"}
]
```
Response:
```json
[
{"type": "GUARD_RESPONSE", "address": 100, "value": false},
{"type": "GUARD_RESPONSE", "address": 100, "value": false}
]
```
---
### Supported Request Types
- `PING`
Does nothing; resets timeout.
Expected Response Type: `PONG`
- `SYSTEM`
Returns the system of the currently loaded ROM (N64, GBA, etc...).
Expected Response Type: `SYSTEM_RESPONSE`
- `PREFERRED_CORES`
Returns the user's default cores for systems with multiple cores. If the
current ROM's system has multiple cores, the one that is currently
running is very probably the preferred core.
Expected Response Type: `PREFERRED_CORES_RESPONSE`
- `HASH`
Returns the hash of the currently loaded ROM calculated by BizHawk.
Expected Response Type: `HASH_RESPONSE`
- `GUARD`
Checks a section of memory against `expected_data`. If the bytes starting
at `address` do not match `expected_data`, the response will have `value`
set to `false`, and all subsequent requests will not be executed and
receive the same `GUARD_RESPONSE`.
Expected Response Type: `GUARD_RESPONSE`
Additional Fields:
- `address` (`int`): The address of the memory to check
- `expected_data` (string): A base64 string of contiguous data
- `domain` (`string`): The name of the memory domain the address
corresponds to
- `LOCK`
Halts emulation and blocks on incoming requests until an `UNLOCK` request
is received or the client times out. All requests processed while locked
will happen on the same frame.
Expected Response Type: `LOCKED`
- `UNLOCK`
Resumes emulation after the current list of requests is done being
executed.
Expected Response Type: `UNLOCKED`
- `READ`
Reads an array of bytes at the provided address.
Expected Response Type: `READ_RESPONSE`
Additional Fields:
- `address` (`int`): The address of the memory to read
- `size` (`int`): The number of bytes to read
- `domain` (`string`): The name of the memory domain the address
corresponds to
- `WRITE`
Writes an array of bytes to the provided address.
Expected Response Type: `WRITE_RESPONSE`
Additional Fields:
- `address` (`int`): The address of the memory to write to
- `value` (`string`): A base64 string representing the data to write
- `domain` (`string`): The name of the memory domain the address
corresponds to
- `DISPLAY_MESSAGE`
Adds a message to the message queue which will be displayed using
`gui.addmessage` according to the message interval.
Expected Response Type: `DISPLAY_MESSAGE_RESPONSE`
Additional Fields:
- `message` (`string`): The string to display
- `SET_MESSAGE_INTERVAL`
Sets the minimum amount of time to wait between displaying messages.
Potentially useful if you add many messages quickly but want players
to be able to read each of them.
Expected Response Type: `SET_MESSAGE_INTERVAL_RESPONSE`
Additional Fields:
- `value` (`number`): The number of seconds to set the interval to
### Response Types
- `PONG`
Acknowledges `PING`.
- `SYSTEM_RESPONSE`
Contains the name of the system for currently running ROM.
Additional Fields:
- `value` (`string`): The returned system name
- `PREFERRED_CORES_RESPONSE`
Contains the user's preferred cores for systems with multiple supported
cores. Currently includes NES, SNES, GB, GBC, DGB, SGB, PCE, PCECD, and
SGX.
Additional Fields:
- `value` (`{[string]: [string]}`): A dictionary map from system name to
core name
- `HASH_RESPONSE`
Contains the hash of the currently loaded ROM calculated by BizHawk.
Additional Fields:
- `value` (`string`): The returned hash
- `GUARD_RESPONSE`
The result of an attempted `GUARD` request.
Additional Fields:
- `value` (`boolean`): true if the memory was validated, false if not
- `address` (`int`): The address of the memory that was invalid (the same
address provided by the `GUARD`, not the address of the individual invalid
byte)
- `LOCKED`
Acknowledges `LOCK`.
- `UNLOCKED`
Acknowledges `UNLOCK`.
- `READ_RESPONSE`
Contains the result of a `READ` request.
Additional Fields:
- `value` (`string`): A base64 string representing the read data
- `WRITE_RESPONSE`
Acknowledges `WRITE`.
- `DISPLAY_MESSAGE_RESPONSE`
Acknowledges `DISPLAY_MESSAGE`.
- `SET_MESSAGE_INTERVAL_RESPONSE`
Acknowledges `SET_MESSAGE_INTERVAL`.
- `ERROR`
Signifies that something has gone wrong while processing a request.
Additional Fields:
- `err` (`string`): A description of the problem
]]
local base64 = require("base64")
local socket = require("socket")
local json = require("json")
-- Set to log incoming requests
-- Will cause lag due to large console output
local DEBUG = false
local SOCKET_PORT = 43055
local STATE_NOT_CONNECTED = 0
local STATE_CONNECTED = 1
local server = nil
local client_socket = nil
local current_state = STATE_NOT_CONNECTED
local timeout_timer = 0
local message_timer = 0
local message_interval = 0
local prev_time = 0
local current_time = 0
local locked = false
local rom_hash = nil
local lua_major, lua_minor = _VERSION:match("Lua (%d+)%.(%d+)")
lua_major = tonumber(lua_major)
lua_minor = tonumber(lua_minor)
if lua_major > 5 or (lua_major == 5 and lua_minor >= 3) then
require("lua_5_3_compat")
end
local bizhawk_version = client.getversion()
local bizhawk_major, bizhawk_minor, bizhawk_patch = bizhawk_version:match("(%d+)%.(%d+)%.?(%d*)")
bizhawk_major = tonumber(bizhawk_major)
bizhawk_minor = tonumber(bizhawk_minor)
if bizhawk_patch == "" then
bizhawk_patch = 0
else
bizhawk_patch = tonumber(bizhawk_patch)
end
function queue_push (self, value)
self[self.right] = value
self.right = self.right + 1
end
function queue_is_empty (self)
return self.right == self.left
end
function queue_shift (self)
value = self[self.left]
self[self.left] = nil
self.left = self.left + 1
return value
end
function new_queue ()
local queue = {left = 1, right = 1}
return setmetatable(queue, {__index = {is_empty = queue_is_empty, push = queue_push, shift = queue_shift}})
end
local message_queue = new_queue()
function lock ()
locked = true
client_socket:settimeout(2)
end
function unlock ()
locked = false
client_socket:settimeout(0)
end
function process_request (req)
local res = {}
if req["type"] == "PING" then
res["type"] = "PONG"
elseif req["type"] == "SYSTEM" then
res["type"] = "SYSTEM_RESPONSE"
res["value"] = emu.getsystemid()
elseif req["type"] == "PREFERRED_CORES" then
local preferred_cores = client.getconfig().PreferredCores
res["type"] = "PREFERRED_CORES_RESPONSE"
res["value"] = {}
res["value"]["NES"] = preferred_cores.NES
res["value"]["SNES"] = preferred_cores.SNES
res["value"]["GB"] = preferred_cores.GB
res["value"]["GBC"] = preferred_cores.GBC
res["value"]["DGB"] = preferred_cores.DGB
res["value"]["SGB"] = preferred_cores.SGB
res["value"]["PCE"] = preferred_cores.PCE
res["value"]["PCECD"] = preferred_cores.PCECD
res["value"]["SGX"] = preferred_cores.SGX
elseif req["type"] == "HASH" then
res["type"] = "HASH_RESPONSE"
res["value"] = rom_hash
elseif req["type"] == "GUARD" then
res["type"] = "GUARD_RESPONSE"
local expected_data = base64.decode(req["expected_data"])
local actual_data = memory.read_bytes_as_array(req["address"], #expected_data, req["domain"])
local data_is_validated = true
for i, byte in ipairs(actual_data) do
if byte ~= expected_data[i] then
data_is_validated = false
break
end
end
res["value"] = data_is_validated
res["address"] = req["address"]
elseif req["type"] == "LOCK" then
res["type"] = "LOCKED"
lock()
elseif req["type"] == "UNLOCK" then
res["type"] = "UNLOCKED"
unlock()
elseif req["type"] == "READ" then
res["type"] = "READ_RESPONSE"
res["value"] = base64.encode(memory.read_bytes_as_array(req["address"], req["size"], req["domain"]))
elseif req["type"] == "WRITE" then
res["type"] = "WRITE_RESPONSE"
memory.write_bytes_as_array(req["address"], base64.decode(req["value"]), req["domain"])
elseif req["type"] == "DISPLAY_MESSAGE" then
res["type"] = "DISPLAY_MESSAGE_RESPONSE"
message_queue:push(req["message"])
elseif req["type"] == "SET_MESSAGE_INTERVAL" then
res["type"] = "SET_MESSAGE_INTERVAL_RESPONSE"
message_interval = req["value"]
else
res["type"] = "ERROR"
res["err"] = "Unknown command: "..req["type"]
end
return res
end
-- Receive data from AP client and send message back
function send_receive ()
local message, err = client_socket:receive()
-- Handle errors
if err == "closed" then
if current_state == STATE_CONNECTED then
print("Connection to client closed")
end
current_state = STATE_NOT_CONNECTED
return
elseif err == "timeout" then
unlock()
return
elseif err ~= nil then
print(err)
current_state = STATE_NOT_CONNECTED
unlock()
return
end
-- Reset timeout timer
timeout_timer = 5
-- Process received data
if DEBUG then
print("Received Message ["..emu.framecount().."]: "..'"'..message..'"')
end
if message == "VERSION" then
local result, err client_socket:send(tostring(SCRIPT_VERSION).."\n")
else
local res = {}
local data = json.decode(message)
local failed_guard_response = nil
for i, req in ipairs(data) do
if failed_guard_response ~= nil then
res[i] = failed_guard_response
else
-- An error is more likely to cause an NLua exception than to return an error here
local status, response = pcall(process_request, req)
if status then
res[i] = response
-- If the GUARD validation failed, skip the remaining commands
if response["type"] == "GUARD_RESPONSE" and not response["value"] then
failed_guard_response = response
end
else
res[i] = {type = "ERROR", err = response}
end
end
end
client_socket:send(json.encode(res).."\n")
end
end
function main ()
server, err = socket.bind("localhost", SOCKET_PORT)
if err ~= nil then
print(err)
return
end
while true do
current_time = socket.socket.gettime()
timeout_timer = timeout_timer - (current_time - prev_time)
message_timer = message_timer - (current_time - prev_time)
prev_time = current_time
if message_timer <= 0 and not message_queue:is_empty() then
gui.addmessage(message_queue:shift())
message_timer = message_interval
end
if current_state == STATE_NOT_CONNECTED then
if emu.framecount() % 60 == 0 then
server:settimeout(2)
local client, timeout = server:accept()
if timeout == nil then
print("Client connected")
current_state = STATE_CONNECTED
client_socket = client
client_socket:settimeout(0)
else
print("No client found. Trying again...")
end
end
else
repeat
send_receive()
until not locked
if timeout_timer <= 0 then
print("Client timed out")
current_state = STATE_NOT_CONNECTED
end
end
coroutine.yield()
end
end
event.onexit(function ()
print("\n-- Restarting Script --\n")
if server ~= nil then
server:close()
end
end)
if bizhawk_major < 2 or (bizhawk_major == 2 and bizhawk_minor < 7) then
print("Must use BizHawk 2.7.0 or newer")
elseif bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 9) then
print("Warning: This version of BizHawk is newer than this script. If it doesn't work, consider downgrading to 2.9.")
else
if emu.getsystemid() == "NULL" then
print("No ROM is loaded. Please load a ROM.")
while emu.getsystemid() == "NULL" do
emu.frameadvance()
end
end
rom_hash = gameinfo.getromhash()
print("Waiting for client to connect. Emulation will freeze intermittently until a client is found.\n")
local co = coroutine.create(main)
function tick ()
local status, err = coroutine.resume(co)
if not status then
print("\nERROR: "..err)
print("Consider reporting this crash.\n")
if server ~= nil then
server:close()
end
co = coroutine.create(main)
end
end
-- Gambatte has a setting which can cause script execution to become
-- misaligned, so for GB and GBC we explicitly set the callback on
-- vblank instead.
-- https://github.com/TASEmulators/BizHawk/issues/3711
if emu.getsystemid() == "GB" or emu.getsystemid() == "GBC" then
event.onmemoryexecute(tick, 0x40, "tick", "System Bus")
else
event.onframeend(tick)
end
while true do
emu.frameadvance()
end
end

View File

@ -74,6 +74,7 @@ Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup";
Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/smw"; Description: "SNI Client - Super Mario World Patch Setup"; Types: full playing; Flags: disablenouninstallwarning Name: "client/sni/smw"; Description: "SNI Client - Super Mario World Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/l2ac"; Description: "SNI Client - Lufia II Ancient Cave Patch Setup"; Types: full playing; Flags: disablenouninstallwarning Name: "client/sni/l2ac"; Description: "SNI Client - Lufia II Ancient Cave Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/bizhawk"; Description: "BizHawk Client"; Types: full playing
Name: "client/factorio"; Description: "Factorio"; Types: full playing Name: "client/factorio"; Description: "Factorio"; Types: full playing
Name: "client/kh2"; Description: "Kingdom Hearts 2"; Types: full playing Name: "client/kh2"; Description: "Kingdom Hearts 2"; Types: full playing
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278 Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
@ -122,6 +123,7 @@ Source: "{#source_path}\ArchipelagoServer.exe"; DestDir: "{app}"; Flags: ignorev
Source: "{#source_path}\ArchipelagoFactorioClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/factorio Source: "{#source_path}\ArchipelagoFactorioClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/factorio
Source: "{#source_path}\ArchipelagoTextClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/text Source: "{#source_path}\ArchipelagoTextClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/text
Source: "{#source_path}\ArchipelagoSNIClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni Source: "{#source_path}\ArchipelagoSNIClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni
Source: "{#source_path}\ArchipelagoBizHawkClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/bizhawk
Source: "{#source_path}\ArchipelagoLinksAwakeningClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ladx Source: "{#source_path}\ArchipelagoLinksAwakeningClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ladx
Source: "{#source_path}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni/lttp or generator/lttp Source: "{#source_path}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni/lttp or generator/lttp
Source: "{#source_path}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft Source: "{#source_path}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft
@ -146,6 +148,7 @@ Name: "{group}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLauncher.exe"
Name: "{group}\{#MyAppName} Server"; Filename: "{app}\ArchipelagoServer"; Components: server Name: "{group}\{#MyAppName} Server"; Filename: "{app}\ArchipelagoServer"; Components: server
Name: "{group}\{#MyAppName} Text Client"; Filename: "{app}\ArchipelagoTextClient.exe"; Components: client/text Name: "{group}\{#MyAppName} Text Client"; Filename: "{app}\ArchipelagoTextClient.exe"; Components: client/text
Name: "{group}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Components: client/sni Name: "{group}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Components: client/sni
Name: "{group}\{#MyAppName} BizHawk Client"; Filename: "{app}\ArchipelagoBizHawkClient.exe"; Components: client/bizhawk
Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio
Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft
Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot
@ -166,6 +169,7 @@ Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopic
Name: "{commondesktop}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLauncher.exe"; Tasks: desktopicon Name: "{commondesktop}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLauncher.exe"; Tasks: desktopicon
Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\ArchipelagoServer"; Tasks: desktopicon; Components: server Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\ArchipelagoServer"; Tasks: desktopicon; Components: server
Name: "{commondesktop}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Tasks: desktopicon; Components: client/sni Name: "{commondesktop}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Tasks: desktopicon; Components: client/sni
Name: "{commondesktop}\{#MyAppName} BizHawk Client"; Filename: "{app}\ArchipelagoBizHawkClient.exe"; Tasks: desktopicon; Components: client/bizhawk
Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio
Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Tasks: desktopicon; Components: client/minecraft Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Tasks: desktopicon; Components: client/minecraft
Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot

View File

@ -89,6 +89,9 @@ components: List[Component] = [
Component('SNI Client', 'SNIClient', Component('SNI Client', 'SNIClient',
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3',
'.apsmw', '.apl2ac')), '.apsmw', '.apl2ac')),
# BizHawk
Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT,
file_identifier=SuffixIdentifier()),
Component('Links Awakening DX Client', 'LinksAwakeningClient', Component('Links Awakening DX Client', 'LinksAwakeningClient',
file_identifier=SuffixIdentifier('.apladx')), file_identifier=SuffixIdentifier('.apladx')),
Component('LttP Adjuster', 'LttPAdjuster'), Component('LttP Adjuster', 'LttPAdjuster'),

326
worlds/_bizhawk/__init__.py Normal file
View File

@ -0,0 +1,326 @@
"""
A module for interacting with BizHawk through `connector_bizhawk_generic.lua`.
Any mention of `domain` in this module refers to the names BizHawk gives to memory domains in its own lua api. They are
naively passed to BizHawk without validation or modification.
"""
import asyncio
import base64
import enum
import json
import typing
BIZHAWK_SOCKET_PORT = 43055
EXPECTED_SCRIPT_VERSION = 1
class ConnectionStatus(enum.IntEnum):
NOT_CONNECTED = 1
TENTATIVE = 2
CONNECTED = 3
class BizHawkContext:
streams: typing.Optional[typing.Tuple[asyncio.StreamReader, asyncio.StreamWriter]]
connection_status: ConnectionStatus
def __init__(self) -> None:
self.streams = None
self.connection_status = ConnectionStatus.NOT_CONNECTED
class NotConnectedError(Exception):
"""Raised when something tries to make a request to the connector script before a connection has been established"""
pass
class RequestFailedError(Exception):
"""Raised when the connector script did not respond to a request"""
pass
class ConnectorError(Exception):
"""Raised when the connector script encounters an error while processing a request"""
pass
class SyncError(Exception):
"""Raised when the connector script responded with a mismatched response type"""
pass
async def connect(ctx: BizHawkContext) -> bool:
"""Attempts to establish a connection with the connector script. Returns True if successful."""
try:
ctx.streams = await asyncio.open_connection("localhost", BIZHAWK_SOCKET_PORT)
ctx.connection_status = ConnectionStatus.TENTATIVE
return True
except (TimeoutError, ConnectionRefusedError):
ctx.streams = None
ctx.connection_status = ConnectionStatus.NOT_CONNECTED
return False
def disconnect(ctx: BizHawkContext) -> None:
"""Closes the connection to the connector script."""
if ctx.streams is not None:
ctx.streams[1].close()
ctx.streams = None
ctx.connection_status = ConnectionStatus.NOT_CONNECTED
async def get_script_version(ctx: BizHawkContext) -> int:
if ctx.streams is None:
raise NotConnectedError("You tried to send a request before a connection to BizHawk was made")
try:
reader, writer = ctx.streams
writer.write("VERSION".encode("ascii") + b"\n")
await asyncio.wait_for(writer.drain(), timeout=5)
version = await asyncio.wait_for(reader.readline(), timeout=5)
if version == b"":
writer.close()
ctx.streams = None
ctx.connection_status = ConnectionStatus.NOT_CONNECTED
raise RequestFailedError("Connection closed")
return int(version.decode("ascii"))
except asyncio.TimeoutError as exc:
writer.close()
ctx.streams = None
ctx.connection_status = ConnectionStatus.NOT_CONNECTED
raise RequestFailedError("Connection timed out") from exc
except ConnectionResetError as exc:
writer.close()
ctx.streams = None
ctx.connection_status = ConnectionStatus.NOT_CONNECTED
raise RequestFailedError("Connection reset") from exc
async def send_requests(ctx: BizHawkContext, req_list: typing.List[typing.Dict[str, typing.Any]]) -> typing.List[typing.Dict[str, typing.Any]]:
"""Sends a list of requests to the BizHawk connector and returns their responses.
It's likely you want to use the wrapper functions instead of this."""
if ctx.streams is None:
raise NotConnectedError("You tried to send a request before a connection to BizHawk was made")
try:
reader, writer = ctx.streams
writer.write(json.dumps(req_list).encode("utf-8") + b"\n")
await asyncio.wait_for(writer.drain(), timeout=5)
res = await asyncio.wait_for(reader.readline(), timeout=5)
if res == b"":
writer.close()
ctx.streams = None
ctx.connection_status = ConnectionStatus.NOT_CONNECTED
raise RequestFailedError("Connection closed")
if ctx.connection_status == ConnectionStatus.TENTATIVE:
ctx.connection_status = ConnectionStatus.CONNECTED
ret = json.loads(res.decode("utf-8"))
for response in ret:
if response["type"] == "ERROR":
raise ConnectorError(response["err"])
return ret
except asyncio.TimeoutError as exc:
writer.close()
ctx.streams = None
ctx.connection_status = ConnectionStatus.NOT_CONNECTED
raise RequestFailedError("Connection timed out") from exc
except ConnectionResetError as exc:
writer.close()
ctx.streams = None
ctx.connection_status = ConnectionStatus.NOT_CONNECTED
raise RequestFailedError("Connection reset") from exc
async def ping(ctx: BizHawkContext) -> None:
"""Sends a PING request and receives a PONG response."""
res = (await send_requests(ctx, [{"type": "PING"}]))[0]
if res["type"] != "PONG":
raise SyncError(f"Expected response of type PONG but got {res['type']}")
async def get_hash(ctx: BizHawkContext) -> str:
"""Gets the system name for the currently loaded ROM"""
res = (await send_requests(ctx, [{"type": "HASH"}]))[0]
if res["type"] != "HASH_RESPONSE":
raise SyncError(f"Expected response of type HASH_RESPONSE but got {res['type']}")
return res["value"]
async def get_system(ctx: BizHawkContext) -> str:
"""Gets the system name for the currently loaded ROM"""
res = (await send_requests(ctx, [{"type": "SYSTEM"}]))[0]
if res["type"] != "SYSTEM_RESPONSE":
raise SyncError(f"Expected response of type SYSTEM_RESPONSE but got {res['type']}")
return res["value"]
async def get_cores(ctx: BizHawkContext) -> typing.Dict[str, str]:
"""Gets the preferred cores for systems with multiple cores. Only systems with multiple available cores have
entries."""
res = (await send_requests(ctx, [{"type": "PREFERRED_CORES"}]))[0]
if res["type"] != "PREFERRED_CORES_RESPONSE":
raise SyncError(f"Expected response of type PREFERRED_CORES_RESPONSE but got {res['type']}")
return res["value"]
async def lock(ctx: BizHawkContext) -> None:
"""Locks BizHawk in anticipation of receiving more requests this frame.
Consider using guarded reads and writes instead of locks if possible.
While locked, emulation will halt and the connector will block on incoming requests until an `UNLOCK` request is
sent. Remember to unlock when you're done, or the emulator will appear to freeze.
Sending multiple lock commands is the same as sending one."""
res = (await send_requests(ctx, [{"type": "LOCK"}]))[0]
if res["type"] != "LOCKED":
raise SyncError(f"Expected response of type LOCKED but got {res['type']}")
async def unlock(ctx: BizHawkContext) -> None:
"""Unlocks BizHawk to allow it to resume emulation. See `lock` for more info.
Sending multiple unlock commands is the same as sending one."""
res = (await send_requests(ctx, [{"type": "UNLOCK"}]))[0]
if res["type"] != "UNLOCKED":
raise SyncError(f"Expected response of type UNLOCKED but got {res['type']}")
async def display_message(ctx: BizHawkContext, message: str) -> None:
"""Displays the provided message in BizHawk's message queue."""
res = (await send_requests(ctx, [{"type": "DISPLAY_MESSAGE", "message": message}]))[0]
if res["type"] != "DISPLAY_MESSAGE_RESPONSE":
raise SyncError(f"Expected response of type DISPLAY_MESSAGE_RESPONSE but got {res['type']}")
async def set_message_interval(ctx: BizHawkContext, value: float) -> None:
"""Sets the minimum amount of time in seconds to wait between queued messages. The default value of 0 will allow one
new message to display per frame."""
res = (await send_requests(ctx, [{"type": "SET_MESSAGE_INTERVAL", "value": value}]))[0]
if res["type"] != "SET_MESSAGE_INTERVAL_RESPONSE":
raise SyncError(f"Expected response of type SET_MESSAGE_INTERVAL_RESPONSE but got {res['type']}")
async def guarded_read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[int, int, str]],
guard_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> typing.Optional[typing.List[bytes]]:
"""Reads an array of bytes at 1 or more addresses if and only if every byte in guard_list matches its expected
value.
Items in read_list should be organized (address, size, domain) where
- `address` is the address of the first byte of data
- `size` is the number of bytes to read
- `domain` is the name of the region of memory the address corresponds to
Items in `guard_list` should be organized `(address, expected_data, domain)` where
- `address` is the address of the first byte of data
- `expected_data` is the bytes that the data starting at this address is expected to match
- `domain` is the name of the region of memory the address corresponds to
Returns None if any item in guard_list failed to validate. Otherwise returns a list of bytes in the order they
were requested."""
res = await send_requests(ctx, [{
"type": "GUARD",
"address": address,
"expected_data": base64.b64encode(bytes(expected_data)).decode("ascii"),
"domain": domain
} for address, expected_data, domain in guard_list] + [{
"type": "READ",
"address": address,
"size": size,
"domain": domain
} for address, size, domain in read_list])
ret: typing.List[bytes] = []
for item in res:
if item["type"] == "GUARD_RESPONSE":
if not item["value"]:
return None
else:
if item["type"] != "READ_RESPONSE":
raise SyncError(f"Expected response of type READ_RESPONSE or GUARD_RESPONSE but got {res['type']}")
ret.append(base64.b64decode(item["value"]))
return ret
async def read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[int, int, str]]) -> typing.List[bytes]:
"""Reads data at 1 or more addresses.
Items in `read_list` should be organized `(address, size, domain)` where
- `address` is the address of the first byte of data
- `size` is the number of bytes to read
- `domain` is the name of the region of memory the address corresponds to
Returns a list of bytes in the order they were requested."""
return await guarded_read(ctx, read_list, [])
async def guarded_write(ctx: BizHawkContext, write_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]],
guard_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> bool:
"""Writes data to 1 or more addresses if and only if every byte in guard_list matches its expected value.
Items in `write_list` should be organized `(address, value, domain)` where
- `address` is the address of the first byte of data
- `value` is a list of bytes to write, in order, starting at `address`
- `domain` is the name of the region of memory the address corresponds to
Items in `guard_list` should be organized `(address, expected_data, domain)` where
- `address` is the address of the first byte of data
- `expected_data` is the bytes that the data starting at this address is expected to match
- `domain` is the name of the region of memory the address corresponds to
Returns False if any item in guard_list failed to validate. Otherwise returns True."""
res = await send_requests(ctx, [{
"type": "GUARD",
"address": address,
"expected_data": base64.b64encode(bytes(expected_data)).decode("ascii"),
"domain": domain
} for address, expected_data, domain in guard_list] + [{
"type": "WRITE",
"address": address,
"value": base64.b64encode(bytes(value)).decode("ascii"),
"domain": domain
} for address, value, domain in write_list])
for item in res:
if item["type"] == "GUARD_RESPONSE":
if not item["value"]:
return False
else:
if item["type"] != "WRITE_RESPONSE":
raise SyncError(f"Expected response of type WRITE_RESPONSE or GUARD_RESPONSE but got {res['type']}")
return True
async def write(ctx: BizHawkContext, write_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> None:
"""Writes data to 1 or more addresses.
Items in write_list should be organized `(address, value, domain)` where
- `address` is the address of the first byte of data
- `value` is a list of bytes to write, in order, starting at `address`
- `domain` is the name of the region of memory the address corresponds to"""
await guarded_write(ctx, write_list, [])

87
worlds/_bizhawk/client.py Normal file
View File

@ -0,0 +1,87 @@
"""
A module containing the BizHawkClient base class and metaclass
"""
from __future__ import annotations
import abc
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional, Tuple, Union
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, launch_subprocess
if TYPE_CHECKING:
from .context import BizHawkClientContext
else:
BizHawkClientContext = object
class AutoBizHawkClientRegister(abc.ABCMeta):
game_handlers: ClassVar[Dict[Tuple[str, ...], Dict[str, BizHawkClient]]] = {}
def __new__(cls, name: str, bases: Tuple[type, ...], namespace: Dict[str, Any]) -> AutoBizHawkClientRegister:
new_class = super().__new__(cls, name, bases, namespace)
if "system" in namespace:
systems = (namespace["system"],) if type(namespace["system"]) is str else tuple(sorted(namespace["system"]))
if systems not in AutoBizHawkClientRegister.game_handlers:
AutoBizHawkClientRegister.game_handlers[systems] = {}
if "game" in namespace:
AutoBizHawkClientRegister.game_handlers[systems][namespace["game"]] = new_class()
return new_class
@staticmethod
async def get_handler(ctx: BizHawkClientContext, system: str) -> Optional[BizHawkClient]:
for systems, handlers in AutoBizHawkClientRegister.game_handlers.items():
if system in systems:
for handler in handlers.values():
if await handler.validate_rom(ctx):
return handler
return None
class BizHawkClient(abc.ABC, metaclass=AutoBizHawkClientRegister):
system: ClassVar[Union[str, Tuple[str, ...]]]
"""The system that the game this client is for runs on"""
game: ClassVar[str]
"""The game this client is for"""
@abc.abstractmethod
async def validate_rom(self, ctx: BizHawkClientContext) -> bool:
"""Should return whether the currently loaded ROM should be handled by this client. You might read the game name
from the ROM header, for example. This function will only be asked to validate ROMs from the system set by the
client class, so you do not need to check the system yourself.
Once this function has determined that the ROM should be handled by this client, it should also modify `ctx`
as necessary (such as setting `ctx.game = self.game`, modifying `ctx.items_handling`, etc...)."""
...
async def set_auth(self, ctx: BizHawkClientContext) -> None:
"""Should set ctx.auth in anticipation of sending a `Connected` packet. You may override this if you store slot
name in your patched ROM. If ctx.auth is not set after calling, the player will be prompted to enter their
username."""
pass
@abc.abstractmethod
async def game_watcher(self, ctx: BizHawkClientContext) -> None:
"""Runs on a loop with the approximate interval `ctx.watcher_timeout`. The currently loaded ROM is guaranteed
to have passed your validator when this function is called, and the emulator is very likely to be connected."""
...
def on_package(self, ctx: BizHawkClientContext, cmd: str, args: dict) -> None:
"""For handling packages from the server. Called from `BizHawkClientContext.on_package`."""
pass
def launch_client(*args) -> None:
from .context import launch
launch_subprocess(launch, name="BizHawkClient")
if not any(component.script_name == "BizHawkClient" for component in components):
components.append(Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,
file_identifier=SuffixIdentifier()))

188
worlds/_bizhawk/context.py Normal file
View File

@ -0,0 +1,188 @@
"""
A module containing context and functions relevant to running the client. This module should only be imported for type
checking or launching the client, otherwise it will probably cause circular import issues.
"""
import asyncio
import traceback
from typing import Any, Dict, Optional
from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, server_loop, logger, gui_enabled
import Patch
import Utils
from . import BizHawkContext, ConnectionStatus, RequestFailedError, connect, disconnect, get_hash, get_script_version, \
get_system, ping
from .client import BizHawkClient, AutoBizHawkClientRegister
EXPECTED_SCRIPT_VERSION = 1
class BizHawkClientCommandProcessor(ClientCommandProcessor):
def _cmd_bh(self):
"""Shows the current status of the client's connection to BizHawk"""
if isinstance(self.ctx, BizHawkClientContext):
if self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.NOT_CONNECTED:
logger.info("BizHawk Connection Status: Not Connected")
elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.TENTATIVE:
logger.info("BizHawk Connection Status: Tentatively Connected")
elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.CONNECTED:
logger.info("BizHawk Connection Status: Connected")
class BizHawkClientContext(CommonContext):
command_processor = BizHawkClientCommandProcessor
client_handler: Optional[BizHawkClient]
slot_data: Optional[Dict[str, Any]] = None
rom_hash: Optional[str] = None
bizhawk_ctx: BizHawkContext
watcher_timeout: float
"""The maximum amount of time the game watcher loop will wait for an update from the server before executing"""
def __init__(self, server_address: Optional[str], password: Optional[str]):
super().__init__(server_address, password)
self.client_handler = None
self.bizhawk_ctx = BizHawkContext()
self.watcher_timeout = 0.5
def run_gui(self):
from kvui import GameManager
class BizHawkManager(GameManager):
base_title = "Archipelago BizHawk Client"
self.ui = BizHawkManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def on_package(self, cmd, args):
if cmd == "Connected":
self.slot_data = args.get("slot_data", None)
if self.client_handler is not None:
self.client_handler.on_package(self, cmd, args)
async def _game_watcher(ctx: BizHawkClientContext):
showed_connecting_message = False
showed_connected_message = False
showed_no_handler_message = False
while not ctx.exit_event.is_set():
try:
await asyncio.wait_for(ctx.watcher_event.wait(), ctx.watcher_timeout)
except asyncio.TimeoutError:
pass
ctx.watcher_event.clear()
try:
if ctx.bizhawk_ctx.connection_status == ConnectionStatus.NOT_CONNECTED:
showed_connected_message = False
if not showed_connecting_message:
logger.info("Waiting to connect to BizHawk...")
showed_connecting_message = True
if not await connect(ctx.bizhawk_ctx):
continue
showed_no_handler_message = False
script_version = await get_script_version(ctx.bizhawk_ctx)
if script_version != EXPECTED_SCRIPT_VERSION:
logger.info(f"Connector script is incompatible. Expected version {EXPECTED_SCRIPT_VERSION} but got {script_version}. Disconnecting.")
disconnect(ctx.bizhawk_ctx)
continue
showed_connecting_message = False
await ping(ctx.bizhawk_ctx)
if not showed_connected_message:
showed_connected_message = True
logger.info("Connected to BizHawk")
rom_hash = await get_hash(ctx.bizhawk_ctx)
if ctx.rom_hash is not None and ctx.rom_hash != rom_hash:
if ctx.server is not None:
logger.info(f"ROM changed. Disconnecting from server.")
await ctx.disconnect(True)
ctx.auth = None
ctx.username = None
ctx.rom_hash = rom_hash
if ctx.client_handler is None:
system = await get_system(ctx.bizhawk_ctx)
ctx.client_handler = await AutoBizHawkClientRegister.get_handler(ctx, system)
if ctx.client_handler is None:
if not showed_no_handler_message:
logger.info("No handler was found for this game")
showed_no_handler_message = True
continue
else:
showed_no_handler_message = False
logger.info(f"Running handler for {ctx.client_handler.game}")
except RequestFailedError as exc:
logger.info(f"Lost connection to BizHawk: {exc.args[0]}")
continue
# Get slot name and send `Connect`
if ctx.server is not None and ctx.username is None:
await ctx.client_handler.set_auth(ctx)
if ctx.auth is None:
await ctx.get_username()
await ctx.send_connect()
await ctx.client_handler.game_watcher(ctx)
async def _run_game(rom: str):
import webbrowser
webbrowser.open(rom)
async def _patch_and_run_game(patch_file: str):
metadata, output_file = Patch.create_rom_file(patch_file)
Utils.async_start(_run_game(output_file))
def launch() -> None:
async def main():
parser = get_base_parser()
parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an Archipelago patch file")
args = parser.parse_args()
ctx = BizHawkClientContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
if args.patch_file != "":
Utils.async_start(_patch_and_run_game(args.patch_file))
watcher_task = asyncio.create_task(_game_watcher(ctx), name="GameWatcher")
try:
await watcher_task
except Exception as e:
logger.error("".join(traceback.format_exception(e)))
await ctx.exit_event.wait()
await ctx.shutdown()
Utils.init_logging("BizHawkClient", exception_logger="Client")
import colorama
colorama.init()
asyncio.run(main())
colorama.deinit()