diff --git a/.github/workflows/lua.yml b/.github/workflows/lua.yml new file mode 100644 index 00000000..9ea3bed3 --- /dev/null +++ b/.github/workflows/lua.yml @@ -0,0 +1,32 @@ +name: Lua TCP bridge +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + lua: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Verify build opt-out + run: | + cmake -S tests/lua -B build/lua-off -DSNESRECOMP_ENABLE_LUA=OFF + cmake --build build/lua-off --config Release + - name: Build Lua host + run: | + cmake -S tests/lua -B build/lua-on -DSNESRECOMP_ENABLE_LUA=ON + cmake --build build/lua-on --config Release + - name: Exercise real TCP bridge + shell: pwsh + run: | + $exe = if ($IsWindows) { 'build/lua-on/Release/lua_bridge_host.exe' } else { 'build/lua-on/lua_bridge_host' } + python tests/lua/test_bridge.py $exe diff --git a/docs/LUA_TCP.md b/docs/LUA_TCP.md new file mode 100644 index 00000000..0d80a391 --- /dev/null +++ b/docs/LUA_TCP.md @@ -0,0 +1,149 @@ +# Lua over TCP spike + +An opt-in Lua 5.4.9 interpreter exposes a useful subset of the +[BizHawk Lua API](https://tasvideos.org/Bizhawk/LuaFunctions). This is a developer +experiment, not a complete EmuHawk replacement. The first host integration and +gameplay examples live in SuperMarioWorldRecomp, `tools/lua/`. + +Build the game with `-DSNESRECOMP_ENABLE_LUA=ON`, then launch with +`SNESRECOMP_LUA_PORT=4380`. `SNESRECOMP_LUA_PAUSED=1` starts at frame zero. +Both the build option and runtime port are required. Ordinary builds do not +download/link Lua or open this socket. Lua's source archive and SHA-256 are +pinned in `runner/lua.cmake`; its MIT license is in the downloaded source. + +## Client + +From this repository, with a game listening on port 4380: + +```powershell +python tools/lua_tcp.py eval 'return emu.framecount(),mainmemory.read_u8(0x19)' +python tools/lua_tcp.py eval 'mainmemory.write_u8(0x19,3)' +python tools/lua_tcp.py load path/to/helpers.lua +python tools/lua_tcp.py run path/to/frame_loop.lua +python tools/lua_tcp.py pause +python tools/lua_tcp.py step 60 +python tools/lua_tcp.py resume +python tools/lua_tcp.py reset +``` + +Use `--port N` before the subcommand to select another port. `LuaClient` in +`tools/lua_tcp.py` also provides a persistent Python context manager. + +## Supported compatibility subset + +| Library | Implemented functions | +| --- | --- | +| `memory` | `read_u8`, `read_s8`, `write_u8`, `write_s8`; signed/unsigned 16/24/32-bit reads/writes with `_le` and `_be`; `readbyte`, `writebyte`, `readbyterange`, `read_bytes_as_array`; `getmemorydomainlist`, `usememorydomain`, `getcurrentmemorydomain`, `getmemorydomainsize` | +| `mainmemory` | Same reads/writes/ranges, always WRAM; `getsize` | +| `joypad` | `set(buttons[, controller])`, `get([controller])` | +| `event` | `onframestart`, `onframeend`, `unregisterbyid`, `unregisterbyname` | +| `emu` | `framecount`, `getsystemid`, `frameadvance` | +| `client` | `pause`, `unpause`, `ispaused` | +| `console` | `log`; global `print` also captures output | + +`WRAM` offsets span 0..0x1ffff. `CARTROM` is the host-provided, read-only ROM +image, indexed by file offset. `System Bus` supports only banks $7E/$7F and +their low-bank WRAM mirrors. Other bus addresses, unknown domains and ranges +crossing a domain/mirror boundary raise errors. No MMIO side effects, ROM +patching, SRAM, VRAM, CPU-register or instruction-hook API is implemented. +`readbyterange` is zero-indexed; `read_bytes_as_array` is one-indexed. Reads +are capped at 4096 bytes per range call. + +Controller numbers are 1 and 2, with `B,Y,Select,Start,Up,Down,Left,Right,A,X,L,R`. +When the controller is omitted, keys are prefixed, e.g. `"P1 Right"`. +Overrides last one simulated frame; omitted buttons retain host input, while +`false` explicitly releases a button. Repeat `joypad.set` in a frame callback +or loop to hold a button. Input issued after a frame ends targets the next frame. + +```lua +event.onframestart(function() + mainmemory.write_u8(0x1497, 2) -- SMW invulnerability timer +end, "invincible") + +-- Send with `run`, which owns one resumable script coroutine: +for i = 1, 120 do + joypad.set({Right=true}, 1) + emu.frameadvance() +end +``` + +Frame callbacks have optional names and return registration IDs. Up to 64 +callbacks can be registered. A failing callback is removed and its error is +available in `status`. Registrations created during a callback dispatch begin +on a subsequent dispatch. `emu.frameadvance` works in a `run` script, not +inside `eval` or a callback. Each yield permits one frame even while Lua is +paused. The frame counter measures completed host frames since bridge startup; +it does not rewind with the game's savestates. + +GUI drawing, savestates, movie/TAS APIs, `comm.*`, `emu.yield`, and other +unlisted BizHawk APIs are not implemented. Lua base/table/string/math/utf8 +libraries are available, but `os`, `io`, `package`, `debug`, `coroutine`, +`dofile`, `loadfile`, `pcall`, and `xpcall` are omitted. This trusted local +developer interface is not a security sandbox. An instruction hook limits +each evaluation/callback/coroutine resume to 100,000 Lua instructions, and +the VM allocator caps live memory at 16 MiB. C library calls do not have a +wall-clock deadline. Memory writes before an error are not rolled back. + +## Wire protocol and lifetime + +One command per LF-terminated ASCII line; responses are one JSON line. +`eval HEX` executes UTF-8 Lua source encoded as hexadecimal, allowing scripts +to contain newlines without ambiguity. `run HEX` replaces the running +coroutine, while preserving globals and event registrations. Source must be +text; wire lines must be shorter than 131072 bytes including the newline. + +Other commands: `ping`, `status`, `pause`, `resume`, `step N` (1..100000), +`stop` (coroutine and pending input only), `reset` (new VM, removes globals, +callbacks and pending input; preserves game RAM, frame count and pause state). +For removing callbacks without resetting globals, use event unregistration. +`step` acknowledges scheduling; wait for `status.frame` to reach the target +or use Python's `LuaClient.step`. A running coroutine/callback can deliberately +change pause/step behavior, so stop it before independent exact stepping. + +```json +{"ok":true,"frame":123,"paused":true,"running":false,"values":["3"],"output":"","error":""} +``` + +Up to 16 return values are reported: booleans/null are JSON primitives; +numbers are strings to preserve 64-bit integers; strings are limited to 1024 +bytes each; other values become type names (e.g. `"table"`). Inspect a table +in Lua or format its contents. Binary/high bytes in strings are escaped as +`\u00xx`, preserving bytes rather than decoding UTF-8. Console output has a +4095-byte buffer, drained by each response. `status` retains the last +asynchronous error; other commands clear it before execution. + +The nonblocking server binds only 127.0.0.1 and accepts one client at a time. +Extra clients are closed. Disconnecting leaves scripts and pause state intact +so short-lived CLI commands compose; use `reset` to stop all automation. +Partial reads/writes and pipelined commands are handled without blocking the +game. A full input buffer without LF drops the connection. + +## Host integration + +Call `lua_bridge_init(wram, size, rom, rom_size, port)` after initializing +memory. Each desktop-loop iteration calls `lua_bridge_poll`, including while +paused. Admit no new frame while `lua_bridge_paused()` is true. Wrap each +actual game frame with `inputs = lua_bridge_frame_start(inputs)` and +`lua_bridge_frame_end()`, and call shutdown on exit. These calls run on the +game thread, outside the CPU/frame runner; the socket has no worker thread. +Input bits use the runner's 12 bits per player plus controller-present bits. + +Games may register `lua_bridge_set_game_command_handler(handler)` after init. +The host extension `game.command(name, args)` dispatches strings to that handler +on the game thread and returns its result string, or raises a Lua error on +failure. This is not a BizHawk API. The handler also receives `__reset` on TCP +VM reset and shutdown to clear its automation. SMW uses it for a separate +fireball pool (`fire_stream`, `fire_stream_status`, `fire_stream_reset`), with +Lua helpers for continuous emission and holding the normal fire buttons. + +The SMW spike supports stock single-player and rejects co-op builds. Use Lua's +pause controls: a separate debugger breakpoint or the host pause key can still +prevent frames from advancing. Lua state is not included in game saves or +netplay. SMW validates the integration with `tools/lua/validate.py`, including +real game navigation, spawning, projectile motion and cadence measurements. + +The ROM-free TCP regression harness is built with +`cmake -S tests/lua -B build/lua-on -DSNESRECOMP_ENABLE_LUA=ON`, followed by +`cmake --build build/lua-on`. Run `python tests/lua/test_bridge.py` with the +resulting `lua_bridge_host` executable path. CI runs it on Windows, Linux and +macOS, and separately builds with Lua disabled. diff --git a/runner/lua.cmake b/runner/lua.cmake new file mode 100644 index 00000000..33c9c3a7 --- /dev/null +++ b/runner/lua.cmake @@ -0,0 +1,24 @@ +# Opt-in, independently of the trace debugger. No Lua download in normal builds. +option(SNESRECOMP_ENABLE_LUA "Build opt-in local TCP Lua scripting" OFF) +if(SNESRECOMP_ENABLE_LUA) + if(POLICY CMP0135) + cmake_policy(SET CMP0135 NEW) + endif() + include(FetchContent) + FetchContent_Declare(snes_lua + URL https://www.lua.org/ftp/lua-5.4.9.tar.gz + URL_HASH SHA256=2335b6c582a52654f94612bf10d2f4672805d05329aa6568b1d8cd9e5c6fb8e6) + FetchContent_MakeAvailable(snes_lua) + file(GLOB _snes_lua_sources "${snes_lua_SOURCE_DIR}/src/*.c") + list(FILTER _snes_lua_sources EXCLUDE REGEX "/(lua|luac)\\.c$") + add_library(snesrecomp_lua STATIC ${_snes_lua_sources} + ${CMAKE_CURRENT_LIST_DIR}/src/lua_bridge.c) + target_include_directories(snesrecomp_lua PRIVATE "${snes_lua_SOURCE_DIR}/src") + target_compile_definitions(snesrecomp_lua PUBLIC SNESRECOMP_ENABLE_LUA=1) + if(WIN32) + target_link_libraries(snesrecomp_lua PRIVATE ws2_32) + else() + target_link_libraries(snesrecomp_lua PRIVATE m) + endif() + list(APPEND SNESRECOMP_RUNNER_LIBRARIES snesrecomp_lua) +endif() diff --git a/runner/runner.cmake b/runner/runner.cmake index 7ad56796..4dad86c8 100644 --- a/runner/runner.cmake +++ b/runner/runner.cmake @@ -188,6 +188,7 @@ else() endif() set(SNESRECOMP_RUNNER_LIBRARIES) +include(${CMAKE_CURRENT_LIST_DIR}/lua.cmake) if(NOT WIN32) # cx4.c synthesizes its internal data ROM with libm. list(APPEND SNESRECOMP_RUNNER_LIBRARIES m) diff --git a/runner/src/lua_bridge.c b/runner/src/lua_bridge.c new file mode 100644 index 00000000..fd0ce66d --- /dev/null +++ b/runner/src/lua_bridge.c @@ -0,0 +1,521 @@ +/* Developer scripting spike. One VM and one nonblocking loopback client; + * everything, including socket servicing, runs on the host/game thread. */ +#include "lua_bridge.h" +#include +#include +#include +#include +#include "lua.h" +#include "lauxlib.h" +#include "lualib.h" +#ifdef _WIN32 +#include +#include +typedef SOCKET Socket; +#define BAD_SOCKET INVALID_SOCKET +#define close_socket closesocket +#else +#include +#include +#include +#include +typedef int Socket; +#define BAD_SOCKET (-1) +#define close_socket close +#ifndef MSG_NOSIGNAL +#define MSG_NOSIGNAL 0 +#endif +#endif + +#define LINE_CAP 131072 +#define RESPONSE_CAP 131072 +#define CALLBACK_CAP 64 +#define LUA_MEMORY_CAP (16u * 1024u * 1024u) +static Socket listener = BAD_SOCKET, peer = BAD_SOCKET; +static lua_State *vm, *script; +static int script_ref = LUA_NOREF, paused, steps; +static uint8_t *ram; +static const uint8_t *rom; +static uint32_t rom_size; +static uint32_t ram_size, input_mask, input_values, current_inputs; +static unsigned long long frame; +static size_t lua_bytes, rx_len, tx_len, tx_sent; +static char rx[LINE_CAP], tx[RESPONSE_CAP], last_error[512], output[4096]; +static unsigned next_id; +static struct { int ref, phase; unsigned id; char name[96]; } callbacks[CALLBACK_CAP]; +static const char *buttons[] = {"B","Y","Select","Start","Up","Down","Left","Right","A","X","L","R"}; +static LuaBridgeGameCommand game_command; +void lua_bridge_set_game_command_handler(LuaBridgeGameCommand handler) { game_command = handler; } +static int call_game(lua_State *L) { + char result[4096] = {0}; + if (!game_command) return luaL_error(L, "host has no game command handler"); + int ok = game_command(luaL_checkstring(L, 1), luaL_optstring(L, 2, ""), result, sizeof(result)); + result[sizeof(result)-1] = 0; + if (!ok) return luaL_error(L, "%s", result); + lua_pushstring(L, result); return 1; +} + +static void *limited_alloc(void *ud, void *ptr, size_t old, size_t size) { + (void)ud; + if (!ptr) old = 0; + if (!size) { free(ptr); lua_bytes -= old; return NULL; } + if (size > LUA_MEMORY_CAP || lua_bytes - old > LUA_MEMORY_CAP - size) return NULL; + void *p = realloc(ptr, size); + if (p) lua_bytes = lua_bytes - old + size; + return p; +} +static void budget_hook(lua_State *L, lua_Debug *ar) { + (void)ar; + luaL_error(L, "Lua instruction budget exceeded (100000 instructions per call)"); +} +static void remember_error(lua_State *L) { + const char *s = lua_tostring(L, -1); + snprintf(last_error, sizeof(last_error), "%s", s ? s : "Lua error"); + fprintf(stderr, "[lua] %s\n", last_error); +} +static int protected_call(lua_State *L, int args, int results) { + lua_sethook(L, budget_hook, LUA_MASKCOUNT, 100000); + int rc = lua_pcall(L, args, results, 0); + lua_sethook(L, NULL, 0, 0); + if (rc != LUA_OK) remember_error(L); + return rc; +} + +/* Domain handling deliberately excludes MMIO: reading RAM never triggers + * bus side effects. System Bus only implements WRAM and its low-bank mirrors. */ +static int selected_bus; +static const char *domain_names[] = {"WRAM", "System Bus", "CARTROM"}; +static int domain(lua_State *L, int arg, int mainmemory) { + if (mainmemory) return 0; + const char *s = luaL_optstring(L, arg, domain_names[selected_bus]); + if (!strcmp(s, "WRAM")) return 0; + if (!strcmp(s, "System Bus")) return 1; + if (!strcmp(s, "CARTROM")) return 2; + return luaL_error(L, "unsupported memory domain: %s", s); +} +static uint32_t address(lua_State *L, lua_Integer a, unsigned size, int bus) { + if (bus == 2) { + if (!rom || a < 0 || a > rom_size || size > rom_size - (uint32_t)a) + luaL_error(L, "memory range outside CARTROM"); + return (uint32_t)a; + } + if (bus == 1) { + if (a >= 0x7e0000 && a <= 0x7fffff) a -= 0x7e0000; + else if (a >= 0 && a <= 0xffffff && (a & 0x7f0000) < 0x400000 && (a & 0xffff) < 0x2000) { + if (size > 0x2000 - (a & 0xffff)) luaL_error(L, "range crosses WRAM mirror boundary"); + a &= 0x1fff; + } else luaL_error(L, "System Bus supports WRAM only in this spike"); + } + if (a < 0 || a > ram_size || size > ram_size - (uint32_t)a) + luaL_error(L, "memory range outside WRAM"); + return (uint32_t)a; +} +static int mem_access(lua_State *L) { + int flags = (int)lua_tointeger(L, lua_upvalueindex(1)); + unsigned n = flags & 7; + int writing = flags & 8, big = flags & 16, sign = flags & 32; + int bus = domain(L, writing ? 3 : 2, flags & 64); + if (writing && bus == 2) return luaL_error(L, "CARTROM is read-only"); + uint32_t a = address(L, luaL_checkinteger(L, 1), n, bus), value = 0; + if (writing) value = (uint32_t)luaL_checkinteger(L, 2); + for (unsigned i = 0; i < n; ++i) { + unsigned shift = 8 * (big ? n - 1 - i : i); + if (writing) ram[a + i] = (uint8_t)(value >> shift); + else value |= (uint32_t)(bus == 2 ? rom[a + i] : ram[a + i]) << shift; + } + if (writing) return 0; + lua_Integer result = value; + if (sign && (value & (1u << (n * 8 - 1)))) result -= (lua_Integer)1 << (n * 8); + lua_pushinteger(L, result); + return 1; +} +static int mem_range(lua_State *L) { + int flags = (int)lua_tointeger(L, lua_upvalueindex(1)); + lua_Integer count = luaL_checkinteger(L, 2); + luaL_argcheck(L, count >= 0 && count <= 4096, 2, "range must be 0..4096 bytes"); + int bus = domain(L, 3, flags & 64); + uint32_t a = address(L, luaL_checkinteger(L, 1), (unsigned)count, bus); + lua_createtable(L, (int)count, 0); + for (int i = 0; i < count; ++i) { + lua_pushinteger(L, bus == 2 ? rom[a + i] : ram[a + i]); lua_rawseti(L, -2, i + (flags & 1)); + } + return 1; +} +static int mem_domains(lua_State *L) { + lua_newtable(L); + lua_pushliteral(L, "WRAM"); lua_rawseti(L, -2, 0); + lua_pushliteral(L, "System Bus"); lua_rawseti(L, -2, 1); + lua_pushliteral(L, "CARTROM"); lua_rawseti(L, -2, 2); + return 1; +} +static int mem_use(lua_State *L) { + const char *s = luaL_checkstring(L, 1); + int valid = 0; + for (int i = 0; i < 3; ++i) if (!strcmp(s, domain_names[i])) { valid = 1; selected_bus = i; } + lua_pushboolean(L, valid); return 1; +} +static int mem_name(lua_State *L) { lua_pushstring(L, domain_names[selected_bus]); return 1; } +static int mem_size(lua_State *L) { + int d = domain(L, 1, 0); lua_pushinteger(L, d == 2 ? rom_size : d == 1 ? 0x1000000 : ram_size); return 1; +} +static int mem_main_size(lua_State *L) { lua_pushinteger(L, ram_size); return 1; } +static int emu_frame(lua_State *L) { lua_pushinteger(L, (lua_Integer)frame); return 1; } +static int emu_system(lua_State *L) { lua_pushliteral(L, "SNES"); return 1; } +static int emu_advance(lua_State *L) { + if (L != script) return luaL_error(L, "emu.frameadvance requires a TCP run script"); + if (paused) steps = 1; + return lua_yield(L, 0); +} +static int client_pause(lua_State *L) { (void)L; paused = 1; steps = 0; return 0; } +static int client_unpause(lua_State *L) { (void)L; paused = 0; steps = 0; return 0; } +static int client_paused(lua_State *L) { lua_pushboolean(L, paused); return 1; } +static int console_log(lua_State *L) { + for (int i = 1; i <= lua_gettop(L); ++i) { + size_t n; const char *s = luaL_tolstring(L, i, &n); + size_t used = strlen(output), space = sizeof(output) - used - 1; + if (n > space) n = space; + memcpy(output + used, s, n); output[used + n] = 0; + lua_pop(L, 1); + } + size_t used = strlen(output); + if (used + 1 < sizeof(output)) { output[used] = '\n'; output[used + 1] = 0; } + return 0; +} +static int joy_set(lua_State *L) { + luaL_checktype(L, 1, LUA_TTABLE); + int player = (int)luaL_optinteger(L, 2, 0); + luaL_argcheck(L, player >= 0 && player <= 2, 2, "controller must be 1 or 2"); + for (int p = 1; p <= 2; ++p) { + if (player && player != p) continue; + for (int i = 0; i < 12; ++i) { + char key[32]; + if (player) snprintf(key, sizeof(key), "%s", buttons[i]); + else snprintf(key, sizeof(key), "P%d %s", p, buttons[i]); + lua_getfield(L, 1, key); + if (!lua_isnil(L, -1)) { + luaL_argcheck(L, lua_isboolean(L, -1), 1, "button values must be booleans"); + uint32_t bit = 1u << ((p - 1) * 12 + i); + input_mask |= bit; + if (lua_toboolean(L, -1)) input_values |= bit; + else input_values &= ~bit; + } + lua_pop(L, 1); + } + } + return 0; +} +static int joy_get(lua_State *L) { + int player = (int)luaL_optinteger(L, 1, 0); + luaL_argcheck(L, player >= 0 && player <= 2, 1, "controller must be 1 or 2"); + uint32_t value = (current_inputs & ~input_mask) | input_values; + lua_newtable(L); + for (int p = 1; p <= 2; ++p) { + if (player && player != p) continue; + for (int i = 0; i < 12; ++i) { + char key[32]; + if (player) snprintf(key, sizeof(key), "%s", buttons[i]); + else snprintf(key, sizeof(key), "P%d %s", p, buttons[i]); + lua_pushboolean(L, value & (1u << ((p - 1) * 12 + i))); lua_setfield(L, -2, key); + } + } + return 1; +} +static void remove_callback(int i) { + if (callbacks[i].id) luaL_unref(vm, LUA_REGISTRYINDEX, callbacks[i].ref); + memset(&callbacks[i], 0, sizeof(callbacks[i])); +} +static int event_add(lua_State *L) { + luaL_checktype(L, 1, LUA_TFUNCTION); + const char *name = luaL_optstring(L, 2, ""); + luaL_argcheck(L, strlen(name) < sizeof(callbacks[0].name), 2, "name too long"); + for (int i = 0; i < CALLBACK_CAP; ++i) if (!callbacks[i].id) { + callbacks[i].id = ++next_id; + callbacks[i].phase = (int)lua_tointeger(L, lua_upvalueindex(1)); + snprintf(callbacks[i].name, sizeof(callbacks[i].name), "%s", name); + lua_pushvalue(L, 1); callbacks[i].ref = luaL_ref(L, LUA_REGISTRYINDEX); + lua_pushfstring(L, "lua-%d", callbacks[i].id); return 1; + } + return luaL_error(L, "callback limit reached (64)"); +} +static int event_remove(lua_State *L) { + const char *key = luaL_checkstring(L, 1); + int by_name = (int)lua_tointeger(L, lua_upvalueindex(1)), removed = 0; + for (int i = 0; i < CALLBACK_CAP; ++i) if (callbacks[i].id) { + char id[40]; snprintf(id, sizeof(id), "lua-%u", callbacks[i].id); + if (!strcmp(key, by_name ? callbacks[i].name : id)) { remove_callback(i); removed = 1; } + } + lua_pushboolean(L, removed); return 1; +} +static void dispatch_callbacks(int phase) { + unsigned snapshot[CALLBACK_CAP]; + for (int i = 0; i < CALLBACK_CAP; ++i) snapshot[i] = callbacks[i].id; + for (int i = 0; i < CALLBACK_CAP; ++i) { + if (!snapshot[i] || callbacks[i].id != snapshot[i] || callbacks[i].phase != phase) continue; + lua_rawgeti(vm, LUA_REGISTRYINDEX, callbacks[i].ref); + if (protected_call(vm, 0, 0) != LUA_OK) { + lua_pop(vm, 1); + if (callbacks[i].id == snapshot[i]) remove_callback(i); + } + } +} +static void function(lua_State *L, const char *name, lua_CFunction fn, int flags) { + lua_pushinteger(L, flags); lua_pushcclosure(L, fn, 1); lua_setfield(L, -2, name); +} +static void memory_library(int main) { + lua_newtable(vm); + for (int n = 1; n <= 4; ++n) for (int sign = 0; sign <= 1; ++sign) + for (int big = 0; big <= (n > 1); ++big) for (int wr = 0; wr <= 1; ++wr) { + char name[48]; + if (n == 1) snprintf(name, sizeof(name), "%s_%c8", wr ? "write" : "read", sign ? 's' : 'u'); + else snprintf(name, sizeof(name), "%s_%c%d_%s", wr ? "write" : "read", sign ? 's' : 'u', n*8, big ? "be" : "le"); + function(vm, name, mem_access, n | wr*8 | big*16 | sign*32 | main*64); + } + function(vm, "readbyte", mem_access, 1 | main*64); + function(vm, "writebyte", mem_access, 9 | main*64); + function(vm, "readbyterange", mem_range, main*64); + function(vm, "read_bytes_as_array", mem_range, 1 | main*64); + if (!main) { + function(vm, "getmemorydomainlist", mem_domains, 0); + function(vm, "usememorydomain", mem_use, 0); + function(vm, "getcurrentmemorydomain", mem_name, 0); + function(vm, "getmemorydomainsize", mem_size, 0); + } else function(vm, "getsize", mem_main_size, 0); + lua_setglobal(vm, main ? "mainmemory" : "memory"); +} +static int create_vm(void) { + vm = lua_newstate(limited_alloc, NULL); + if (!vm) return -1; + const luaL_Reg libs[] = {{LUA_GNAME,luaopen_base},{LUA_TABLIBNAME,luaopen_table}, + {LUA_STRLIBNAME,luaopen_string},{LUA_MATHLIBNAME,luaopen_math},{LUA_UTF8LIBNAME,luaopen_utf8},{NULL,NULL}}; + for (const luaL_Reg *lib = libs; lib->name; ++lib) { + luaL_requiref(vm, lib->name, lib->func, 1); lua_pop(vm, 1); + } + /* No blocking file/process/module APIs. pcall/xpcall are omitted so scripts + * cannot catch and continually swallow the instruction-budget exception. */ + const char *removed[] = {"dofile","loadfile","pcall","xpcall",NULL}; + for (int i = 0; removed[i]; ++i) { lua_pushnil(vm); lua_setglobal(vm, removed[i]); } + memory_library(0); memory_library(1); + lua_newtable(vm); + function(vm, "framecount", emu_frame, 0); function(vm, "getsystemid", emu_system, 0); + function(vm, "frameadvance", emu_advance, 0); lua_setglobal(vm, "emu"); + lua_newtable(vm); + function(vm, "pause", client_pause, 0); function(vm, "unpause", client_unpause, 0); + function(vm, "ispaused", client_paused, 0); lua_setglobal(vm, "client"); + lua_newtable(vm); function(vm, "log", console_log, 0); lua_setglobal(vm, "console"); + lua_pushcfunction(vm, console_log); lua_setglobal(vm, "print"); + lua_newtable(vm); function(vm, "set", joy_set, 0); function(vm, "get", joy_get, 0); lua_setglobal(vm, "joypad"); + lua_newtable(vm); + function(vm, "onframestart", event_add, 0); function(vm, "onframeend", event_add, 1); + function(vm, "unregisterbyid", event_remove, 0); function(vm, "unregisterbyname", event_remove, 1); + lua_setglobal(vm, "event"); + lua_newtable(vm); function(vm, "command", call_game, 0); lua_setglobal(vm, "game"); + return 0; +} +static void stop_script(void) { + if (script_ref != LUA_NOREF) luaL_unref(vm, LUA_REGISTRYINDEX, script_ref); + script_ref = LUA_NOREF; script = NULL; +} +static int resume_script(void) { + int results; + lua_sethook(script, budget_hook, LUA_MASKCOUNT, 100000); + int rc = lua_resume(script, vm, 0, &results); + lua_sethook(script, NULL, 0, 0); + if (rc != LUA_OK && rc != LUA_YIELD) remember_error(script); + lua_settop(script, 0); + if (rc != LUA_YIELD) stop_script(); + return rc == LUA_OK || rc == LUA_YIELD; +} + +static void append(const char *s) { + size_t n = strlen(s); + if (n > sizeof(tx) - tx_len - 1) n = sizeof(tx) - tx_len - 1; + memcpy(tx + tx_len, s, n); tx_len += n; tx[tx_len] = 0; +} +static void json_string(const char *s, size_t n) { + append("\""); + for (size_t i = 0; i < n && tx_len + 16 < sizeof(tx); ++i) { + unsigned char c = (unsigned char)s[i]; char escaped[8]; + /* Escape all high bytes too: output is valid JSON even for binary Lua strings. */ + if (c < 32 || c >= 127) { snprintf(escaped, sizeof(escaped), "\\u%04x", c); append(escaped); } + else if (c == '"' || c == '\\') { escaped[0] = '\\'; escaped[1] = c; escaped[2] = 0; append(escaped); } + else { escaped[0] = c; escaped[1] = 0; append(escaped); } + } + append("\""); +} +static void response(int ok, int values) { + char number[160]; tx_len = tx_sent = 0; + snprintf(number, sizeof(number), "{\"ok\":%s,\"frame\":%llu,\"paused\":%s,\"running\":%s,\"values\":[", ok ? "true":"false", frame, paused ? "true":"false", script ? "true":"false"); + append(number); + for (int i = 1; i <= values && i <= 16; ++i) { + if (i > 1) append(","); + int kind = lua_type(vm, i); + if (kind == LUA_TNIL) append("null"); + else if (kind == LUA_TBOOLEAN) append(lua_toboolean(vm, i) ? "true" : "false"); + else { + size_t n = 0; const char *s = lua_tolstring(vm, i, &n); + if (!s) { s = lua_typename(vm, kind); n = strlen(s); } + /* Return numbers as strings to preserve exact 64-bit Lua integers. */ + if (n > 1024) n = 1024; + json_string(s, n); + } + } + append("],\"output\":"); json_string(output, strlen(output)); + append(",\"error\":"); json_string(last_error, strlen(last_error)); append("}\n"); + lua_settop(vm, 0); output[0] = 0; +} +static int unhex(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} +static void process(char *line) { + int ok = 1, values = 0; + lua_settop(vm, 0); + /* status preserves asynchronous callback/script errors for inspection. */ + if (strcmp(line, "status")) last_error[0] = 0; + if (!strncmp(line, "eval ", 5) || !strncmp(line, "run ", 4)) { + int run = line[0] == 'r'; char *source = line + (run ? 4 : 5); + size_t hex_len = strlen(source), n = hex_len / 2; + if (hex_len % 2) { ok = 0; snprintf(last_error, sizeof(last_error), "odd hex payload length"); } + for (size_t i = 0; ok && i < n; ++i) { + int a = unhex(source[2*i]), b = unhex(source[2*i+1]); + if (a < 0 || b < 0) { ok = 0; snprintf(last_error, sizeof(last_error), "invalid hex payload"); } + else source[i] = (char)((a << 4) | b); + } + if (ok) { + if (luaL_loadbufferx(vm, source, n, "tcp", "t") != LUA_OK) { remember_error(vm); ok = 0; } + else if (run) { + stop_script(); + script = lua_newthread(vm); script_ref = luaL_ref(vm, LUA_REGISTRYINDEX); + lua_xmove(vm, script, 1); ok = resume_script(); + } else { + ok = protected_call(vm, 0, LUA_MULTRET) == LUA_OK; + if (ok) values = lua_gettop(vm); + } + } + } else if (!strcmp(line, "pause")) { paused = 1; steps = 0; } + else if (!strcmp(line, "resume")) { paused = 0; steps = 0; } + else if (!strncmp(line, "step ", 5)) { + char *end; long n = strtol(line + 5, &end, 10); + if (*end || end == line + 5 || n < 1 || n > 100000) { ok = 0; snprintf(last_error, sizeof(last_error), "step requires 1..100000 frames"); } + else { paused = 1; steps = (int)n; } + } else if (!strcmp(line, "stop")) { stop_script(); input_mask = input_values = 0; } + else if (!strcmp(line, "reset")) { + if (game_command) { char ignored[64]; game_command("__reset", "", ignored, sizeof(ignored)); } + stop_script(); + for (int i = 0; i < CALLBACK_CAP; ++i) remove_callback(i); + lua_close(vm); vm = NULL; + input_mask = input_values = current_inputs = 0; selected_bus = 0; steps = 0; + if (create_vm()) { lua_bridge_shutdown(); return; } + } else if (strcmp(line, "status") && strcmp(line, "ping")) { + ok = 0; snprintf(last_error, sizeof(last_error), "commands: eval HEX, run HEX, status, pause, resume, step N, stop, reset"); + } + response(ok, values); +} +static int nonblocking(Socket s) { +#ifdef _WIN32 + u_long yes = 1; return ioctlsocket(s, FIONBIO, &yes); +#else + int flags = fcntl(s, F_GETFL, 0); + return flags < 0 ? -1 : fcntl(s, F_SETFL, flags | O_NONBLOCK); +#endif +} +static int would_block(void) { +#ifdef _WIN32 + return WSAGetLastError() == WSAEWOULDBLOCK; +#else + return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR; +#endif +} +static void disconnect(void) { + if (peer != BAD_SOCKET) close_socket(peer); + peer = BAD_SOCKET; rx_len = tx_len = tx_sent = 0; +} +int lua_bridge_init(uint8_t *wram, uint32_t size, const uint8_t *cart, uint32_t cart_size, int port) { + if (!port) return 0; + if (port < 1 || port > 65535 || !wram || !size || vm) return -1; +#ifdef _WIN32 + WSADATA data; if (WSAStartup(MAKEWORD(2,2), &data)) return -1; +#endif + ram = wram; ram_size = size; rom = cart; rom_size = cart_size; + listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); addr.sin_port = htons((unsigned short)port); + if (listener == BAD_SOCKET || nonblocking(listener) || bind(listener, (struct sockaddr *)&addr, sizeof(addr)) || listen(listener, 1) || create_vm()) { + lua_bridge_shutdown(); return -1; + } + fprintf(stderr, "[lua] BizHawk-style Lua TCP listening on 127.0.0.1:%d\n", port); + const char *start_paused = getenv("SNESRECOMP_LUA_PAUSED"); + paused = start_paused && !strcmp(start_paused, "1"); + return 0; +} +void lua_bridge_poll(void) { + if (!vm || listener == BAD_SOCKET) return; + Socket incoming = accept(listener, NULL, NULL); + if (incoming != BAD_SOCKET) { + if (peer != BAD_SOCKET || nonblocking(incoming)) close_socket(incoming); + else { peer = incoming; rx_len = tx_len = tx_sent = 0; } + } + if (peer == BAD_SOCKET) return; + /* Drain partial replies before executing another command. A slow client + * cannot block gameplay or create an unbounded queue. */ + if (tx_sent < tx_len) { + int n = send(peer, tx + tx_sent, (int)(tx_len - tx_sent), +#ifdef _WIN32 + 0 +#else + MSG_NOSIGNAL +#endif + ); + if (n > 0) tx_sent += n; + else if (n == 0 || !would_block()) disconnect(); + return; + } + int n = recv(peer, rx + rx_len, (int)(sizeof(rx) - rx_len - 1), 0); + if (n > 0) rx_len += n; + else if (n == 0 || !would_block()) { disconnect(); return; } + rx[rx_len] = 0; + char *nl = memchr(rx, '\n', rx_len); + if (nl) { + size_t consumed = (size_t)(nl - rx) + 1; + *nl = 0; if (nl > rx && nl[-1] == '\r') nl[-1] = 0; + if (memchr(rx, 0, (size_t)(nl - rx) - (nl > rx && nl[-1] == 0 ? 1 : 0))) { disconnect(); return; } + process(rx); + if (peer == BAD_SOCKET) return; + memmove(rx, rx + consumed, rx_len - consumed); rx_len -= consumed; + } else if (rx_len == sizeof(rx) - 1) disconnect(); +} +int lua_bridge_paused(void) { return vm && paused && steps == 0; } +uint32_t lua_bridge_frame_start(uint32_t inputs) { + if (!vm) return inputs; + current_inputs = inputs; + dispatch_callbacks(0); + current_inputs = (inputs & ~input_mask) | input_values; + if (input_mask & 0xfff) current_inputs |= 1u << 30; + if (input_mask & 0xfff000) current_inputs |= 1u << 31; + return current_inputs; +} +void lua_bridge_frame_end(void) { + if (!vm) return; + ++frame; + if (steps) --steps; + input_mask = input_values = 0; + dispatch_callbacks(1); + if (script) resume_script(); +} +void lua_bridge_shutdown(void) { + if (game_command) { char ignored[64]; game_command("__reset", "", ignored, sizeof(ignored)); } + game_command = NULL; + disconnect(); + if (listener != BAD_SOCKET) close_socket(listener); + listener = BAD_SOCKET; + if (vm) lua_close(vm); + vm = script = NULL; script_ref = LUA_NOREF; + memset(callbacks, 0, sizeof(callbacks)); + input_mask = input_values = current_inputs = 0; paused = steps = 0; + selected_bus = 0; frame = next_id = 0; last_error[0] = output[0] = 0; +#ifdef _WIN32 + WSACleanup(); +#endif +} diff --git a/runner/src/lua_bridge.h b/runner/src/lua_bridge.h new file mode 100644 index 00000000..ec0f2c60 --- /dev/null +++ b/runner/src/lua_bridge.h @@ -0,0 +1,25 @@ +#ifndef SNESRECOMP_LUA_BRIDGE_H +#define SNESRECOMP_LUA_BRIDGE_H +#include +#include +#if SNESRECOMP_ENABLE_LUA +typedef int (*LuaBridgeGameCommand)(const char *name, const char *args, char *result, size_t capacity); +void lua_bridge_set_game_command_handler(LuaBridgeGameCommand handler); +/* All calls belong to the host/game thread, outside RtlRunFrame. Port 0 disables. + * Inputs use the runner layout: B,Y,Select,Start,Up,Down,Left,Right,A,X,L,R. + * frame_end must be called exactly once for every admitted frame. */ +int lua_bridge_init(uint8_t *wram, uint32_t size, const uint8_t *rom, uint32_t rom_size, int port); +void lua_bridge_poll(void); +int lua_bridge_paused(void); +uint32_t lua_bridge_frame_start(uint32_t inputs); +void lua_bridge_frame_end(void); +void lua_bridge_shutdown(void); +#else +static inline int lua_bridge_init(uint8_t *r, uint32_t s, const uint8_t *rom, uint32_t rs, int p) { (void)r; (void)s; (void)rom; (void)rs; (void)p; return 0; } +static inline void lua_bridge_poll(void) {} +static inline int lua_bridge_paused(void) { return 0; } +static inline uint32_t lua_bridge_frame_start(uint32_t i) { return i; } +static inline void lua_bridge_frame_end(void) {} +static inline void lua_bridge_shutdown(void) {} +#endif +#endif diff --git a/tests/lua/CMakeLists.txt b/tests/lua/CMakeLists.txt new file mode 100644 index 00000000..5e791c50 --- /dev/null +++ b/tests/lua/CMakeLists.txt @@ -0,0 +1,7 @@ +cmake_minimum_required(VERSION 3.16) +project(lua_bridge_test C) +set(CMAKE_C_STANDARD 11) +include(../../runner/lua.cmake) +add_executable(lua_bridge_host host.c) +target_include_directories(lua_bridge_host PRIVATE ../../runner/src) +target_link_libraries(lua_bridge_host PRIVATE ${SNESRECOMP_RUNNER_LIBRARIES}) diff --git a/tests/lua/host.c b/tests/lua/host.c new file mode 100644 index 00000000..d6ed1ee0 --- /dev/null +++ b/tests/lua/host.c @@ -0,0 +1,43 @@ +/* ROM-free host for exercising the real bridge over TCP. */ +#include "lua_bridge.h" +#include +#include +#include +#ifdef _WIN32 +#include +#else +#include +#endif +static uint8_t ram[0x20000], rom[64]; +#if SNESRECOMP_ENABLE_LUA +static unsigned resets; +static int command(const char *name, const char *args, char *out, size_t size) { + if (!strcmp(name, "__reset")) { ++resets; return 1; } + if (!strcmp(name, "test.echo")) { snprintf(out, size, "%s", args); return 1; } + if (!strcmp(name, "test.resets")) { snprintf(out, size, "%u", resets); return 1; } + snprintf(out, size, "unknown test command"); return 0; +} +#endif +int main(int argc, char **argv) { + int port = argc > 1 ? atoi(argv[1]) : 0; + if (lua_bridge_init(ram, sizeof(ram), rom, sizeof(rom), port)) return 1; + if (!port) { lua_bridge_shutdown(); return 0; } +#if SNESRECOMP_ENABLE_LUA + lua_bridge_set_game_command_handler(command); +#else + return 0; +#endif + for (;;) { + lua_bridge_poll(); + if (!lua_bridge_paused()) { + uint32_t inputs = lua_bridge_frame_start(0); + for (unsigned i = 0; i < 4; ++i) ram[0x100+i] = (uint8_t)(inputs >> (8*i)); + lua_bridge_frame_end(); + } +#ifdef _WIN32 + Sleep(1); +#else + struct timespec delay = {0, 1000000}; nanosleep(&delay, NULL); +#endif + } +} diff --git a/tests/lua/test_bridge.py b/tests/lua/test_bridge.py new file mode 100644 index 00000000..62bb55ee --- /dev/null +++ b/tests/lua/test_bridge.py @@ -0,0 +1,66 @@ +"""ROM-free TCP integration checks; pass the built lua_bridge_host executable.""" +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import time + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tools")) +from lua_tcp import LuaClient + +exe = str(Path(sys.argv[1]).resolve()) +subprocess.run([exe, "0"], check=True) # Runtime opt-out is inert. +with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] +process = subprocess.Popen([exe, str(port)], env=dict(os.environ, SNESRECOMP_LUA_PAUSED="1")) +try: + for _ in range(100): + try: + client = LuaClient(port=port) + break + except OSError: + if process.poll() is not None: raise RuntimeError("host exited") + time.sleep(.05) + else: raise TimeoutError("host did not listen") + with client as c: + def values(source): return c.eval(source)["values"] + def fails(source, message): + try: c.eval(source) + except RuntimeError as error: assert message in str(error), error + else: raise AssertionError(source) + assert c.command("status")["frame"] == 0 + assert values("return game.command('test.echo','hello')") == ["hello"] + fails("game.command('bad')", "unknown test command") + assert values("memory.write_u32_le(0x120,0x89abcdef); return memory.read_u32_be(0x120),memory.read_s8(0x120)") == ["4023233417", "-17"] + fails("memory.write_u8(0,1,'CARTROM')", "read-only") + fails("memory.read_u16_le(0x1ffff)", "outside WRAM") + fails("while true do end", "budget exceeded") + fails("string.rep('x',32*1024*1024)", "not enough memory") + assert values("return true,false,nil,string.char(0,10,34,92,255)") == [True, False, None, '\x00\n"\\\xff'] + wire = b"eval " + b"return 42".hex().encode() + b"\n" + c.socket.sendall(wire[:7]); time.sleep(.01) + c.socket.sendall(wire[7:]+b"ping\n") + assert json.loads(c.reader.readline())["values"] == ["42"] + assert json.loads(c.reader.readline())["ok"] + c.eval("event.onframestart(function() joypad.set({Y=true},1) end,'held')") + c.step(3) + assert values("return mainmemory.read_u32_le(0x100),joypad.get(1).Y") == [str((1 << 30) | 2), True] + c.command("reset") + assert values("return joypad.get(1).Y,game.command('test.resets')") == [False, "1"] + c.step(1) + assert values("return mainmemory.read_u32_le(0x100)") == ["0"] + c.run("for i=1,4 do emu.frameadvance() end; completed=true") + for _ in range(100): + if not c.command("status")["running"]: break + time.sleep(.01) + assert values("return completed,emu.framecount()") == [True, "8"] + c.eval("event.onframeend(function() error('callback failed') end,'bad')") + assert "callback failed" in c.step(1)["error"] + assert values("return event.unregisterbyname('bad')") == [False] + print("PASS: Lua TCP framing, memory, limits, callbacks, input/reset and host commands") +finally: + process.terminate() + process.wait(timeout=5) diff --git a/tools/lua_tcp.py b/tools/lua_tcp.py new file mode 100644 index 00000000..6ca0e2ab --- /dev/null +++ b/tools/lua_tcp.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Small persistent client for the snesrecomp Lua TCP spike (Python stdlib).""" +import argparse +import json +from pathlib import Path +import socket +import time + + +class LuaClient: + def __init__(self, host="127.0.0.1", port=4380, timeout=10): + self.socket = socket.create_connection((host, port), timeout) + self.reader = self.socket.makefile("rb") + + def close(self): + self.reader.close() + self.socket.close() + + def __enter__(self): + return self + + def __exit__(self, *_): + self.close() + + def command(self, command): + data = (command + "\n").encode("ascii") + if b"\n" in data[:-1] or len(data) >= 131072: + raise ValueError("command contains a newline or exceeds the wire limit") + self.socket.sendall(data) + line = self.reader.readline(131072) + if not line.endswith(b"\n"): + raise ConnectionError("server disconnected or sent an oversized response") + result = json.loads(line) + if not result["ok"]: + raise RuntimeError(result["error"]) + return result + + def eval(self, source): + return self.command("eval " + source.encode("utf-8").hex()) + + def run(self, source): + return self.command("run " + source.encode("utf-8").hex()) + + def step(self, frames=1, timeout=30): + # Caller pauses first; the acknowledgement is before simulation. + before = self.command("pause")["frame"] + self.command(f"step {frames}") + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = self.command("status") + if result["frame"] >= before + frames: + if result["frame"] != before + frames: + raise RuntimeError("script resumed/advanced beyond the requested frame") + return result + time.sleep(0.01) + raise TimeoutError("game did not finish stepping") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=4380) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("eval").add_argument("source") + for name in ("load", "run"): + commands.add_parser(name).add_argument("file", type=Path) + commands.add_parser("step").add_argument("frames", type=int) + for name in ("status", "pause", "resume", "stop", "reset", "ping"): + commands.add_parser(name) + args = parser.parse_args() + with LuaClient(args.host, args.port) as client: + if args.command == "eval": + result = client.eval(args.source) + elif args.command in ("load", "run"): + source = args.file.read_text(encoding="utf-8") + result = client.run(source) if args.command == "run" else client.eval(source) + elif args.command == "step": + result = client.step(args.frames) + else: + result = client.command(args.command) + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main()