660 lines
17 KiB
Lua
660 lines
17 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
|
|
|
|
-- Set to log incoming requests
|
|
-- Will cause lag due to large console output
|
|
local DEBUG = false
|
|
|
|
--[[
|
|
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 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
|
|
|
|
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 base64 = require("base64")
|
|
local socket = require("socket")
|
|
local json = require("json")
|
|
|
|
local SOCKET_PORT_FIRST = 43055
|
|
local SOCKET_PORT_RANGE_SIZE = 5
|
|
local SOCKET_PORT_LAST = SOCKET_PORT_FIRST + SOCKET_PORT_RANGE_SIZE
|
|
|
|
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
|
|
|
|
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
|
|
|
|
request_handlers = {
|
|
["PING"] = function (req)
|
|
local res = {}
|
|
|
|
res["type"] = "PONG"
|
|
|
|
return res
|
|
end,
|
|
|
|
["SYSTEM"] = function (req)
|
|
local res = {}
|
|
|
|
res["type"] = "SYSTEM_RESPONSE"
|
|
res["value"] = emu.getsystemid()
|
|
|
|
return res
|
|
end,
|
|
|
|
["PREFERRED_CORES"] = function (req)
|
|
local res = {}
|
|
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
|
|
|
|
return res
|
|
end,
|
|
|
|
["HASH"] = function (req)
|
|
local res = {}
|
|
|
|
res["type"] = "HASH_RESPONSE"
|
|
res["value"] = rom_hash
|
|
|
|
return res
|
|
end,
|
|
|
|
["GUARD"] = function (req)
|
|
local res = {}
|
|
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["type"] = "GUARD_RESPONSE"
|
|
res["value"] = data_is_validated
|
|
res["address"] = req["address"]
|
|
|
|
return res
|
|
end,
|
|
|
|
["LOCK"] = function (req)
|
|
local res = {}
|
|
|
|
res["type"] = "LOCKED"
|
|
lock()
|
|
|
|
return res
|
|
end,
|
|
|
|
["UNLOCK"] = function (req)
|
|
local res = {}
|
|
|
|
res["type"] = "UNLOCKED"
|
|
unlock()
|
|
|
|
return res
|
|
end,
|
|
|
|
["READ"] = function (req)
|
|
local res = {}
|
|
|
|
res["type"] = "READ_RESPONSE"
|
|
res["value"] = base64.encode(memory.read_bytes_as_array(req["address"], req["size"], req["domain"]))
|
|
|
|
return res
|
|
end,
|
|
|
|
["WRITE"] = function (req)
|
|
local res = {}
|
|
|
|
res["type"] = "WRITE_RESPONSE"
|
|
memory.write_bytes_as_array(req["address"], base64.decode(req["value"]), req["domain"])
|
|
|
|
return res
|
|
end,
|
|
|
|
["DISPLAY_MESSAGE"] = function (req)
|
|
local res = {}
|
|
|
|
res["type"] = "DISPLAY_MESSAGE_RESPONSE"
|
|
message_queue:push(req["message"])
|
|
|
|
return res
|
|
end,
|
|
|
|
["SET_MESSAGE_INTERVAL"] = function (req)
|
|
local res = {}
|
|
|
|
res["type"] = "SET_MESSAGE_INTERVAL_RESPONSE"
|
|
message_interval = req["value"]
|
|
|
|
return res
|
|
end,
|
|
|
|
["default"] = function (req)
|
|
local res = {}
|
|
|
|
res["type"] = "ERROR"
|
|
res["err"] = "Unknown command: "..req["type"]
|
|
|
|
return res
|
|
end,
|
|
}
|
|
|
|
function process_request (req)
|
|
if request_handlers[req["type"]] then
|
|
return request_handlers[req["type"]](req)
|
|
else
|
|
return request_handlers["default"](req)
|
|
end
|
|
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
|
|
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
|
|
if type(response) ~= "string" then response = "Unknown error" end
|
|
res[i] = {type = "ERROR", err = response}
|
|
end
|
|
end
|
|
end
|
|
|
|
client_socket:send(json.encode(res).."\n")
|
|
end
|
|
end
|
|
|
|
function initialize_server ()
|
|
local err
|
|
local port = SOCKET_PORT_FIRST
|
|
local res = nil
|
|
|
|
server, err = socket.socket.tcp4()
|
|
while res == nil and port <= SOCKET_PORT_LAST do
|
|
res, err = server:bind("localhost", port)
|
|
if res == nil and err ~= "address already in use" then
|
|
print(err)
|
|
return
|
|
end
|
|
|
|
if res == nil then
|
|
port = port + 1
|
|
end
|
|
end
|
|
|
|
if port > SOCKET_PORT_LAST then
|
|
print("Too many instances of connector script already running. Exiting.")
|
|
return
|
|
end
|
|
|
|
res, err = server:listen(0)
|
|
|
|
if err ~= nil then
|
|
print(err)
|
|
return
|
|
end
|
|
|
|
server:settimeout(0)
|
|
end
|
|
|
|
function main ()
|
|
while true do
|
|
if server == nil then
|
|
initialize_server()
|
|
end
|
|
|
|
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() % 30 == 0 then
|
|
print("Looking for client...")
|
|
local client, timeout = server:accept()
|
|
if timeout == nil then
|
|
print("Client connected")
|
|
current_state = STATE_CONNECTED
|
|
client_socket = client
|
|
server:close()
|
|
server = nil
|
|
client_socket:settimeout(0)
|
|
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. This may take longer the more instances of this script you have open at once.\n")
|
|
|
|
local co = coroutine.create(main)
|
|
function tick ()
|
|
local status, err = coroutine.resume(co)
|
|
|
|
if not status and err ~= "cannot resume dead coroutine" 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" or emu.getsystemid() == "SGB" then
|
|
event.onmemoryexecute(tick, 0x40, "tick", "System Bus")
|
|
else
|
|
event.onframeend(tick)
|
|
end
|
|
|
|
while true do
|
|
emu.frameadvance()
|
|
end
|
|
end
|