diff --git a/BizHawkClient.py b/BizHawkClient.py new file mode 100644 index 000000000000..86c8e5197e3f --- /dev/null +++ b/BizHawkClient.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import ModuleUpdate +ModuleUpdate.update() + +from worlds._bizhawk.context import launch + +if __name__ == "__main__": + launch() diff --git a/data/lua/base64.lua b/data/lua/base64.lua new file mode 100644 index 000000000000..ebe80643531b --- /dev/null +++ b/data/lua/base64.lua @@ -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 diff --git a/data/lua/connector_bizhawk_generic.lua b/data/lua/connector_bizhawk_generic.lua new file mode 100644 index 000000000000..b0b06de447bb --- /dev/null +++ b/data/lua/connector_bizhawk_generic.lua @@ -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 diff --git a/inno_setup.iss b/inno_setup.iss index 147cd74dca07..3c1bdc4571e0 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -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/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/bizhawk"; Description: "BizHawk Client"; Types: full playing Name: "client/factorio"; Description: "Factorio"; Types: full playing Name: "client/kh2"; Description: "Kingdom Hearts 2"; Types: full playing 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}\ArchipelagoTextClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/text 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}\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 @@ -146,6 +148,7 @@ Name: "{group}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLauncher.exe" Name: "{group}\{#MyAppName} Server"; Filename: "{app}\ArchipelagoServer"; Components: server 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} BizHawk Client"; Filename: "{app}\ArchipelagoBizHawkClient.exe"; Components: client/bizhawk 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} 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} 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} 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} 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 diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index c3ae2b0495b0..2d445a77b8e0 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -89,6 +89,9 @@ def launch_textclient(): Component('SNI Client', 'SNIClient', file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', '.apsmw', '.apl2ac')), + # BizHawk + Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, + file_identifier=SuffixIdentifier()), Component('Links Awakening DX Client', 'LinksAwakeningClient', file_identifier=SuffixIdentifier('.apladx')), Component('LttP Adjuster', 'LttPAdjuster'), diff --git a/worlds/_bizhawk/__init__.py b/worlds/_bizhawk/__init__.py new file mode 100644 index 000000000000..cdf227ec7bdc --- /dev/null +++ b/worlds/_bizhawk/__init__.py @@ -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, []) diff --git a/worlds/_bizhawk/client.py b/worlds/_bizhawk/client.py new file mode 100644 index 000000000000..b614c083ba4e --- /dev/null +++ b/worlds/_bizhawk/client.py @@ -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())) diff --git a/worlds/_bizhawk/context.py b/worlds/_bizhawk/context.py new file mode 100644 index 000000000000..6e53b370af1c --- /dev/null +++ b/worlds/_bizhawk/context.py @@ -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()