mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 20:21:32 -06:00

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.
565 lines
16 KiB
Lua
565 lines
16 KiB
Lua
--[[
|
|
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
|