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:
parent
24403eba1b
commit
bc11c9dfd4
|
@ -0,0 +1,9 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ModuleUpdate
|
||||||
|
ModuleUpdate.update()
|
||||||
|
|
||||||
|
from worlds._bizhawk.context import launch
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
launch()
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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, [])
|
|
@ -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()))
|
|
@ -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()
|
Loading…
Reference in New Issue