From 5431d32a4fe5583514739f4535556636176adc8c Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 10 Sep 2026 23:25:20 -0700 Subject: [PATCH 1/6] Integrate Lua TCP playground and live SMW manipulation demos --- CMakeLists.txt | 13 ++- src/main.c | 47 +++++++++-- tools/lua/README.md | 136 ++++++++++++++++++++++++++++++ tools/lua/enter_level.lua | 30 +++++++ tools/lua/smw.lua | 117 ++++++++++++++++++++++++++ tools/lua/start.ps1 | 27 ++++++ tools/lua/validate.py | 168 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 530 insertions(+), 8 deletions(-) create mode 100644 tools/lua/README.md create mode 100644 tools/lua/enter_level.lua create mode 100644 tools/lua/smw.lua create mode 100644 tools/lua/start.ps1 create mode 100644 tools/lua/validate.py diff --git a/CMakeLists.txt b/CMakeLists.txt index edc2618..f206ebf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,12 @@ set(SNESRECOMP_ENABLE_MODS ON CACHE BOOL set(SNESRECOMP_ROOT "${CMAKE_SOURCE_DIR}/snesrecomp" CACHE PATH "Path to the snesrecomp checkout used by this game") include(${SNESRECOMP_ROOT}/runner/runner.cmake) +if(SNESRECOMP_ENABLE_LUA AND NOT TARGET snesrecomp_lua) + message(FATAL_ERROR "SNESRECOMP_ENABLE_LUA requires the paired snesrecomp Lua spike checkout") +endif() +if(SNESRECOMP_ENABLE_LUA AND SMW_BUILD_COOP) + message(FATAL_ERROR "The Lua TCP spike currently supports the stock single-player target only") +endif() set(RECOMP_UI_ROOT "${CMAKE_SOURCE_DIR}/recomp-ui" CACHE PATH "Path to the recomp-ui checkout used by this game") include(${RECOMP_UI_ROOT}/recomp_ui.cmake) @@ -130,7 +136,6 @@ set(SMW_RUNTIME_SOURCES ${SNESRECOMP_RUNNER_SOURCES} src/config.c src/foreign_controller.c - src/mod_audio.c src/mod_external_rom_compat.c src/main.c src/opengl.c @@ -155,6 +160,12 @@ set(SMW_RUNTIME_SOURCES ${SMW_OVERRIDE_SOURCES} ) +# Newer framework checkouts provide this shared helper. Retain compatibility +# with the game's older submodule pin without linking both implementations. +if(NOT EXISTS "${SNESRECOMP_ROOT}/runner/src/mod_audio.c") + list(APPEND SMW_RUNTIME_SOURCES src/mod_audio.c) +endif() + if(SNESRECOMP_ENABLE_TRACE) set(_SMW_TRACE 1) else() diff --git a/src/main.c b/src/main.c index 9c2b1c2..c9252d9 100644 --- a/src/main.c +++ b/src/main.c @@ -3,7 +3,10 @@ #include #include #include -#include "debug_server.h" +#include "debug_server.h" +#if SNESRECOMP_ENABLE_LUA +#include "lua_bridge.h" +#endif #include "desktop/sdl_compat.h" #ifdef _WIN32 #include @@ -1661,9 +1664,23 @@ error_reading:; // a production build resolves this to a no-op stub — do NOT redeclare it // `extern` here, or the call bypasses the stub and only fails at link // time on a non-Windows production build. - debug_server_set_ram(snes->ram, 0x20000); - -#ifdef ENABLE_ORACLE_BACKEND + debug_server_set_ram(snes->ram, 0x20000); +#if SNESRECOMP_ENABLE_LUA + { + const char *port_text = getenv("SNESRECOMP_LUA_PORT"); + if (port_text && *port_text) { + char *end; + long port = strtol(port_text, &end, 10); + if (*end || port < 1 || port > 65535 || + lua_bridge_init(snes->ram, 0x20000, kRom, kRom_SIZE, (int)port) != 0) { + fprintf(stderr, "[lua] Could not start requested Lua TCP server\n"); + return 1; + } + } + } +#endif + +#ifdef ENABLE_ORACLE_BACKEND // Start the emulator-oracle backend with the same ROM. Gated on the // Oracle build configuration only; Release|x64 never sees any of this. // The runner typically loads smw.sfc from cwd via the asset pipeline @@ -1978,7 +1995,14 @@ error_reading:; SetAudioPaused(audiopaused != 0); } - if (g_paused) { +#if SNESRECOMP_ENABLE_LUA + lua_bridge_poll(); + if (lua_bridge_paused()) { + SDL_Delay(16); + continue; + } +#endif + if (g_paused) { SDL_Delay(16); continue; } @@ -2056,8 +2080,14 @@ error_reading:; (uint32)g_gamepad[1].axis_buttons << 12; inputs |= TickScript(); inputs |= debug_server_get_controller_inputs(); - RtlRunFrame(inputs | GetActiveControllers() | - debug_server_get_controller_active_mask()); + inputs |= GetActiveControllers() | debug_server_get_controller_active_mask(); +#if SNESRECOMP_ENABLE_LUA + inputs = lua_bridge_frame_start(inputs); +#endif + RtlRunFrame(inputs); +#if SNESRECOMP_ENABLE_LUA + lua_bridge_frame_end(); +#endif } stall_t_run = SDL_GetPerformanceCounter(); @@ -2366,6 +2396,9 @@ error_reading:; SwitchImpl_Exit(); #endif +#if SNESRECOMP_ENABLE_LUA + lua_bridge_shutdown(); +#endif SDL_Quit(); return 0; } diff --git a/tools/lua/README.md b/tools/lua/README.md new file mode 100644 index 0000000..897cbd3 --- /dev/null +++ b/tools/lua/README.md @@ -0,0 +1,136 @@ +# SMW Lua / TCP playground + +This spike runs BizHawk-style Lua inside the native game and accepts commands +over a local TCP server. Both worktrees use branch `codex/bizhawk-lua-tcp-spike`: + +- Framework: `F:\Projects\snesrecomp\_wt-bizhawk-lua-snesrecomp`, based on + freshly fetched `origin/main` `43e857d`. +- Game: `F:\Projects\snesrecomp\_wt-bizhawk-lua-smw`, based on freshly fetched + `origin/main` `703786e`. + +The game CMake cache explicitly selects the paired framework worktree via +`SNESRECOMP_ROOT`; the existing source checkouts and their local edits are +untouched. The framework's `docs/LUA_TCP.md` lists the supported API and wire +protocol. Reference: [BizHawk Lua functions](https://tasvideos.org/Bizhawk/LuaFunctions). + +## Try the built spike + +From the game worktree in PowerShell (native Python 3 required): + +```powershell +.\tools\lua\start.ps1 -Paused +$python = 'C:\Users\Matthew\AppData\Local\Programs\Python\Python312\python.exe' +$client = '..\_wt-bizhawk-lua-snesrecomp\tools\lua_tcp.py' +# Wait for the game window/server to finish booting, then: +& $python $client load tools/lua/smw.lua +& $python $client run tools/lua/enter_level.lua +& $python $client status +# Wait for running=false: the script navigates from title to Yoshi's Island 1. +& $python $client eval 'return smw.status()' +& $python $client eval 'smw.powerup(3); smw.invincible(true)' +& $python $client eval 'return smw.spawn(0x0f)' # Goomba, 64 pixels ahead +& $python $client eval 'return smw.spawn(0x04)' # green Koopa +& $python $client eval 'smw.autofire(4)' # attempt a shot every 4 frames +& $python $client resume +& $python $client eval 'smw.autofire(24)' # slower cadence, live +& $python $client eval 'smw.teleport(128,352)' +& $python $client eval 'smw.stop()' +``` + +The helper definitions persist across CLI connections. `pause` and `step 60` +let you inspect state deterministically. `reset` removes all Lua globals, +callbacks and input overrides, while leaving the game's RAM intact. Reload +`smw.lua` after a reset. Use `stop` to stop only the running frame-loop script; +`smw.stop()` removes the gameplay helper callbacks. Close the game window to +stop the server. `start.ps1 -Port 4382` and client `--port 4382` support a +second independent instance. + +Powerups use 0=small, 1=big, 2=cape, 3=fire. Teleport coordinates are level +pixels; choose valid terrain. Spawning accepts stock sprite IDs 0..0xC8, but +sprite graphics and behavior still depend on the level's loaded graphics and +object context. The demonstrated IDs are Goomba and green Koopa. Spawn +initializes the slot's tables from the ROM and lets the normal game INIT run. + +Autofire creates genuine type-5 extended sprites using SMW's own fireball +state layout; the game handles movement, rendering and collision. This is a +scripted firing mode, not a patch to the stock Y-button cooldown. It can use +all ten extended-sprite slots (stock shooting allocates two), never overwrites +occupied slots, and reports skipped attempts in `smw.missed_shots`. Interval +0 disables it. `smw.fireball()` fires once. Helpers reject the opening message +and require a playable level; player transitions and sprite-lock frames +suspend autofire. + +Arbitrary Lua is supported, for example: + +```powershell +& $python $client eval 'return memory.read_u16_le(0x94,"WRAM")' +& $python $client eval 'mainmemory.write_u8(0x0dbf,99)' # coins +& $python $client eval 'event.onframestart(function() joypad.set({Right=true,B=true},1) end,"runjump")' +& $python $client eval 'event.unregisterbyname("runjump")' +``` + +This is a compatibility subset: memory, input, frame events and coroutines; +GUI drawing, savestates, movies and instruction hooks are not implemented. +The server binds localhost and is a trusted development interface. It runs on +the game thread without enabling the heavyweight TCP trace debugger. + +## Rebuild + +`recomp-ui` is initialized at the game's tracked pin. Supply your own verified +stock USA `smw.sfc`. Generated game code is untracked. Native Windows tools +must be invoked explicitly on this machine because PATH contains MSYS shims. + +```powershell +$env:SNESRECOMP_ROOT='../_wt-bizhawk-lua-snesrecomp' +$env:SNESRECOMP_ANALYSIS_BACKEND='python' +$env:PYTHON='C:/Users/Matthew/AppData/Local/Programs/Python/Python312/python.exe' +& C:\msys64\usr\bin\bash.exe tools/regen.sh --stock --no-tests +& C:\msys64\mingw64\bin\cmake.exe -S . -B build-lua -G Ninja ` + -DCMAKE_MAKE_PROGRAM=C:/msys64/mingw64/bin/ninja.exe ` + -DCMAKE_C_COMPILER=C:/msys64/mingw64/bin/gcc.exe ` + -DCMAKE_CXX_COMPILER=C:/msys64/mingw64/bin/g++.exe ` + -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=C:/msys64/mingw64 ` + -DPython3_EXECUTABLE=C:/Users/Matthew/AppData/Local/Programs/Python/Python312/python.exe ` + -DSNESRECOMP_ROOT=F:/Projects/snesrecomp/_wt-bizhawk-lua-snesrecomp ` + -DSNESRECOMP_ENABLE_LUA=ON +& C:\msys64\mingw64\bin\cmake.exe --build build-lua -j 6 +Copy-Item -LiteralPath config.ini -Destination build-lua/config.ini +``` + +The build fetches pinned MIT Lua 5.4.9 from lua.org when enabled. Without the +option there is no Lua dependency/listener. A small build compatibility fix +selects the framework audio helper when present, because latest SMW and latest +framework otherwise define the same functions twice. Co-op plus Lua is +explicitly rejected for this spike. + +## Reproduce validation + +```powershell +$env:PATH='C:\msys64\mingw64\bin;' + $env:PATH +& $python tools/lua/validate.py ` + --engine ../_wt-bizhawk-lua-snesrecomp ` + --exe build-lua/SuperMarioWorldSNESRecomp.exe --rom smw.sfc +``` + +The test launches a separate game on port 4381, navigates via Lua input, runs +live assertions, then terminates only its own process. `--keep-running` leaves +the successful instance paused for inspection (its test process uses turbo). +Evidence is written to `build-lua/lua-validation.json` and +`build-lua/lua-validation-game.log`. + +Validated September 10, 2026 with the stock 524288-byte USA ROM: + +- WRAM signed/endian operations, ROM reads, domain/boundary rejection, Lua + instruction/memory limit recovery, JSON escaping, TCP fragmentation and + pipelined requests passed. +- Exact stepping, frame callback removal/error isolation, coroutine + `emu.frameadvance`, input-driven navigation, and VM reset passed. +- Reached Yoshi's Island 1 (translevel 41); changed Mario's powerup and moved + him from x=16 to x=32. +- Goomba in slot 11 transitioned from INIT to active and moved x=96 to x=91 + after 24 further frames. A fireball moved x=40 to x=49 in three frames. +- Over 96 frames, interval 24 produced 4 shots; interval 4 produced 16 shots + plus 8 attempts skipped because the projectile pool was occupied. + +The validation is a spike demonstration, not a full-game or full-BizHawk +compatibility certification. diff --git a/tools/lua/enter_level.lua b/tools/lua/enter_level.lua new file mode 100644 index 0000000..07c00ea --- /dev/null +++ b/tools/lua/enter_level.lua @@ -0,0 +1,30 @@ +-- From a fresh boot/title, navigate to Yoshi's Island 1 using real inputs. +-- Use TCP `run`; this script advances itself even when Lua pause is active. +local function wait(n) + for _ = 1, n do emu.frameadvance() end +end +local function press(key, hold, settle) + for _ = 1, hold do joypad.set({[key]=true},1); emu.frameadvance() end + wait(settle) +end +-- Bound every wait so a wrong menu state produces a useful error. +for _ = 1, 900 do + if mainmemory.read_u8(0x100) == 7 then break end + emu.frameadvance() +end +assert(mainmemory.read_u8(0x100) == 7, "title screen not reached") +press("Start",1,40) +press("A",1,40) -- MARIO A +press("A",1,600) -- one player, opening message +for i = 1, 1200 do + if mainmemory.read_u8(0x100) == 0x0e then break end + if i % 60 == 1 then joypad.set({B=true},1) end -- dismiss opening text + emu.frameadvance() +end +assert(mainmemory.read_u8(0x100) == 0x0e, "overworld not reached") +wait(60) +press("Left",1,90) -- Yoshi's Island 1 +press("A",1,180) +assert(mainmemory.read_u8(0x100) == 0x14, "level not reached; inspect smw.status()") +assert(mainmemory.read_u8(0x109) == 0, "still in opening message") +print("Entered Yoshi's Island 1") diff --git a/tools/lua/smw.lua b/tools/lua/smw.lua new file mode 100644 index 0000000..8ca0101 --- /dev/null +++ b/tools/lua/smw.lua @@ -0,0 +1,117 @@ +-- SMW USA / stock single-player helpers for the snesrecomp Lua TCP spike. +-- Addresses are WRAM offsets from src/variables.h. Sprite initialization and +-- fireball fields follow SMW's own status-1 dispatcher / $00:FEA6 routine. +-- Load with lua_tcp.py load tools/lua/smw.lua, then call smw.* via eval. +local r, w = mainmemory.read_u8, mainmemory.write_u8 +local r16, w16 = mainmemory.read_u16_le, mainmemory.write_u16_le +event.unregisterbyname("smw.autofire") +event.unregisterbyname("smw.invincible") +smw = {shots = 0, missed_shots = 0, fire_interval = 0} + +local function integer(value, lo, hi, label) + assert(math.type(value) == "integer" and value >= lo and value <= hi, + label .. " must be an integer in " .. lo .. ".." .. hi) + return value +end +local function in_level() + assert(r(0x100) == 0x14, "enter a playable level first (game mode $14)") + assert(r(0x109) == 0, "dismiss the opening message and enter a regular level first") +end +function smw.status() + local sprites, fireballs = 0, 0 + for s = 0, 11 do if r(0x14c8+s) ~= 0 then sprites = sprites+1 end end + for s = 0, 9 do if r(0x170b+s) == 5 then fireballs = fireballs+1 end end + return string.format("mode=%02X x=%d y=%d powerup=%d sprites=%d fireballs=%d interval=%d shots=%d missed=%d", + r(0x100), r16(0x94), r16(0x96), r(0x19), sprites, fireballs, + smw.fire_interval, smw.shots, smw.missed_shots) +end +function smw.powerup(value) + in_level() + w(0x19, integer(value, 0, 3, "powerup")) -- small, big, cape, fire +end +function smw.teleport(x, y) + in_level() + integer(x, 0, 65535, "x"); integer(y, 0, 65535, "y") + w16(0x94, x); w16(0x96, y); w(0x7b, 0); w(0x7d, 0) +end +function smw.spawn(id, x, y) + in_level() + integer(id, 0, 0xc8, "stock sprite id") + x = integer(x or (r16(0x94)+64), 0, 65535, "x") + y = integer(y or r16(0x96), 0, 65535, "y") + for s = 11, 0, -1 do + if r(0x14c8+s) == 0 then + -- Mirror ZeroSpriteTables and LoadSpriteTables before status 1 + -- invokes the type-specific INIT. Status 1 alone leaves stale + -- collision/tweaker bytes from the previous occupant of the slot. + for _, base in ipairs({0x164a,0x1632,0xc2,0x151c,0x1528,0x1534, + 0x157c,0x1588,0x15c4,0x1602,0x1540,0x154c,0x1558,0x1564, + 0x1fe2,0x1626,0x1570,0xb6,0x14f8,0xaa,0x14ec,0x15dc, + 0x15d0,0x163e,0x187b,0x160e,0x1594,0x1504,0x1fd6}) do w(base+s,0) end + local destinations = {0x1656,0x1662,0x166e,0x167a,0x1686,0x190f} + local sources = {0x3f26c,0x3f335,0x3f3fe,0x3f4c7,0x3f590,0x3f659} + for i, base in ipairs(destinations) do + w(base+s,memory.read_u8(sources[i]+id,"CARTROM")) + end + w(0x15f6+s,r(0x166e+s) & 15); w(0x15a0+s,1) + -- Dynamic spawn: no level-list entry to erase. + w(0x9e+s, id); w(0xe4+s, x & 255); w(0x14e0+s, x >> 8) + w(0xd8+s, y & 255); w(0x14d4+s, y >> 8) + w(0x161a+s, 0xff); w(0x14c8+s, 1) + return s + end + end + error("all 12 normal sprite slots are occupied") +end +function smw.fireball() + in_level() + if r(0x9d) ~= 0 or r(0x71) ~= 0 then return nil end + -- Use the engine's ten extended-sprite slots, preserving occupied slots. + -- Stock player shooting only allocates slots 8/9; the sprite dispatcher + -- can update type-5 projectiles in all ten. No ROM/code patch is needed. + for s = 9, 0, -1 do + if r(0x170b+s) == 0 then + local right = r(0x76) ~= 0 + local x, y = r16(0x94) + (right and 8 or 0), r16(0x96)+8 + for _, base in ipairs({0x1751,0x175b,0x1765,0x176f,0x1779}) do w(base+s,0) end + w(0x171f+s,x & 255); w(0x1733+s,(x >> 8) & 255) + w(0x1715+s,y & 255); w(0x1729+s,(y >> 8) & 255) + w(0x173d+s,0x30); w(0x1747+s,right and 3 or 0xfd) + w(0x1779+s,r(0x13f9)); w(0x170b+s,5) + w(0x149c,10); w(0x1dfc,6) + smw.shots = smw.shots+1 + return s + end + end + smw.missed_shots = smw.missed_shots+1 + return nil +end +function smw.autofire(interval) + integer(interval, 0, 600, "interval in frames (0 disables)") + event.unregisterbyname("smw.autofire") + smw.fire_interval = interval + if interval == 0 then return end + local next_frame = emu.framecount() + event.onframestart(function() + if r(0x100) ~= 0x14 or r(0x109) ~= 0 or r(0x9d) ~= 0 or r(0x71) ~= 0 then return end + w(0x19,3) + if emu.framecount() >= next_frame then + smw.fireball() + next_frame = emu.framecount()+smw.fire_interval + end + end, "smw.autofire") +end +function smw.invincible(enabled) + assert(type(enabled) == "boolean", "enabled must be boolean") + event.unregisterbyname("smw.invincible") + if enabled then + event.onframestart(function() + if r(0x100) == 0x14 then w(0x1497,2) end + end, "smw.invincible") + end +end +function smw.stop() + smw.autofire(0) + event.unregisterbyname("smw.invincible") +end +return "SMW helpers loaded" diff --git a/tools/lua/start.ps1 b/tools/lua/start.ps1 new file mode 100644 index 0000000..73932d1 --- /dev/null +++ b/tools/lua/start.ps1 @@ -0,0 +1,27 @@ +param( + [int]$Port = 4380, + [switch]$Paused, + [string]$RomPath, + [string]$ExePath +) +$ErrorActionPreference = 'Stop' +$smwRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) +if (!$RomPath) { $RomPath = Join-Path $smwRoot 'smw.sfc' } +if (!$ExePath) { $ExePath = Join-Path $smwRoot 'build-lua/SuperMarioWorldSNESRecomp.exe' } +$RomPath = (Resolve-Path -LiteralPath $RomPath).Path +$ExePath = (Resolve-Path -LiteralPath $ExePath).Path +if ($Port -lt 1 -or $Port -gt 65535) { throw 'Port must be 1..65535' } +$env:SNESRECOMP_LUA_PORT = "$Port" +$env:SNESRECOMP_LUA_PAUSED = if ($Paused) { '1' } else { '0' } +$env:SNESRECOMP_FORCE_TURBO = '0' +$env:SNESRECOMP_NO_LAUNCHER = '1' +# The local worktree build uses native MinGW; let its runtime DLLs resolve. +if (Test-Path -LiteralPath 'C:/msys64/mingw64/bin') { + $env:PATH = 'C:\msys64\mingw64\bin;' + $env:PATH +} +$runDir = Split-Path -Parent $ExePath +$game = Start-Process -FilePath $ExePath -ArgumentList ('"{0}"' -f $RomPath) ` + -WorkingDirectory $runDir -WindowStyle Hidden -PassThru ` + -RedirectStandardOutput (Join-Path $runDir "lua-$Port.stdout.log") ` + -RedirectStandardError (Join-Path $runDir "lua-$Port.stderr.log") +Write-Output "Started SMW PID $($game.Id); Lua TCP will listen on 127.0.0.1:$Port after boot." diff --git a/tools/lua/validate.py b/tools/lua/validate.py new file mode 100644 index 0000000..1aded23 --- /dev/null +++ b/tools/lua/validate.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Exercise the real SMW executable through TCP; requires the user's stock ROM.""" +import argparse +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import time + +ROOT = Path(__file__).resolve().parents[2] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--engine", type=Path, required=True) + parser.add_argument("--exe", type=Path, required=True) + parser.add_argument("--rom", type=Path, required=True) + parser.add_argument("--port", type=int, default=4381) + parser.add_argument("--keep-running", action="store_true") + args = parser.parse_args() + sys.path.insert(0, str(args.engine.resolve() / "tools")) + from lua_tcp import LuaClient + exe, rom = args.exe.resolve(), args.rom.resolve() + report = {"checks": [], "exe": str(exe), "rom_bytes": rom.stat().st_size} + environment = dict(os.environ, SNESRECOMP_LUA_PORT=str(args.port), + SNESRECOMP_LUA_PAUSED="1", SNESRECOMP_NO_LAUNCHER="1", + SNESRECOMP_FORCE_TURBO="1") + log = open(exe.parent / "lua-validation-game.log", "w") + process = subprocess.Popen([str(exe), str(rom)], cwd=exe.parent, env=environment, + stdout=log, stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0) + report["pid"] = process.pid + client = None + + def checked(name, evidence=None): + report["checks"].append({"name": name, "evidence": evidence}) + print(f"PASS {name}: {evidence}", flush=True) + + def values(source): + return client.eval(source)["values"] + + def fails(source, expected): + try: + client.eval(source) + except RuntimeError as error: + assert expected in str(error), str(error) + else: + raise AssertionError(f"expected {expected}: {source}") + + def wait_script(timeout=60): + deadline = time.monotonic()+timeout + while time.monotonic() < deadline: + result = client.command("status") + assert not result["error"], result + if not result["running"]: + return result + time.sleep(.02) + raise TimeoutError("Lua script did not finish") + + try: + deadline = time.monotonic()+90 + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError(f"game exited {process.returncode}; see {log.name}") + try: + client = LuaClient(port=args.port, timeout=60) + break + except OSError: + time.sleep(.2) + if not client: + raise TimeoutError("Lua server did not start") + assert client.command("status")["frame"] == 0 + checked("boot paused with live TCP") + # The untouched boot WRAM is restored before any frame is simulated. + original = values("return mainmemory.read_u32_le(0x1ff00)")[0] + assert values("memory.write_u32_le(0x1ff00,0x89abcdef); return memory.read_u32_be(0x1ff00),memory.read_s8(0x1ff00),memory.read_s24_le(0x1ff00)") == ["4023233417", "-17", "-5517841"] + assert values("return memory.read_u8(0x7fff00,'System Bus'),memory.readbyterange(0x1ff00,2)[0],memory.read_bytes_as_array(0x1ff00,2)[1]") == ["239","239","239"] + assert values("memory.usememorydomain('CARTROM'); return mainmemory.getsize(),memory.getmemorydomainsize(),memory.getcurrentmemorydomain()") == ["131072","524288","CARTROM"] + values(f"mainmemory.write_u32_le(0x1ff00,{original}); memory.usememorydomain('WRAM')") + for source, expected in [ + ("memory.write_u16_le(0x1ffff,1)", "outside WRAM"), + ("memory.read_u8(-1)", "outside WRAM"), + ("memory.read_u8(0x2100,'System Bus')", "WRAM only"), + ("memory.read_u16_le(0x1fff,'System Bus')", "mirror boundary"), + ("memory.write_u8(0,0,'CARTROM')", "read-only"), + ("memory.read_u8(0x80000,'CARTROM')", "outside CARTROM"), + ("while true do end", "budget exceeded"), + ("local x=string.rep('x',32*1024*1024)", "not enough memory"), + ("emu.frameadvance()", "TCP run script"), + ]: + fails(source, expected) + checked("memory domains, endian/sign, bounds and runaway-script recovery") + # Send fragmented then coalesced requests through the real socket. + payload = b"eval " + b"return 42".hex().encode() + b"\n" + client.socket.sendall(payload[:7]); time.sleep(.04) + client.socket.sendall(payload[7:]+b"ping\n") + assert json.loads(client.reader.readline())["values"] == ["42"] + assert json.loads(client.reader.readline())["ok"] + checked("fragmented and pipelined TCP framing") + assert values("return true,false,nil,string.char(0,10,34,92,255)") == [True,False,None,'\x00\n"\\\xff'] + client.eval("counter=0; event.onframestart(function() counter=counter+1 end,'counter')") + client.step(3) + assert values("return counter") == ["3"] + client.eval("event.unregisterbyname('counter')") + client.step(2) + assert values("return counter") == ["3"] + client.eval("event.onframeend(function() error('callback broke') end,'bad')") + result = client.step(1) + assert "callback broke" in result["error"] + assert values("return event.unregisterbyname('bad')") == [False] + client.run("local f=emu.framecount(); for i=1,4 do emu.frameadvance() end; coroutine_frames=emu.framecount()-f") + wait_script() + assert values("return coroutine_frames") == ["4"] + checked("frame callbacks, callback error isolation, coroutine frameadvance") + client.eval((ROOT / "tools/lua/smw.lua").read_text()) + client.run((ROOT / "tools/lua/enter_level.lua").read_text()) + wait_script() + assert values("return mainmemory.read_u8(0x100),mainmemory.read_u8(0x109)") == ["20","0"] + checked("navigated into playable level with joypad.set", values("return smw.status(),mainmemory.read_u8(0x13bf)")) + x, y = map(int, values("return mainmemory.read_u16_le(0x94),mainmemory.read_u16_le(0x96)")) + client.eval(f"smw.invincible(true); smw.powerup(3); smw.teleport({x+16},{y}); mainmemory.write_u8(0x76,1)") + assert values("return mainmemory.read_u16_le(0x94),mainmemory.read_u8(0x19)") == [str(x+16),"3"] + checked("powerup and teleport", {"x_before": x, "x_after": x+16}) + slot = int(values("spawn_slot=smw.spawn(0x0f); return spawn_slot")[0]) + client.step(1) + assert values(f"return mainmemory.read_u8(0x14c8+{slot}),mainmemory.read_u8(0x9e+{slot})") == ["8","15"] + sx = int(values(f"return mainmemory.read_u8(0xe4+{slot})+256*mainmemory.read_u8(0x14e0+{slot})")[0]) + client.step(24) + sx_after = int(values(f"return mainmemory.read_u8(0xe4+{slot})+256*mainmemory.read_u8(0x14e0+{slot})")[0]) + assert sx != sx_after, "spawned sprite did not move" + checked("spawned Goomba initialized and moved under game simulation", {"slot":slot,"x_before":sx,"x_after":sx_after}) + client.eval("shot_slot=smw.fireball(); assert(shot_slot)") + fx = int(values("return mainmemory.read_u8(0x171f+shot_slot)+256*mainmemory.read_u8(0x1733+shot_slot)")[0]) + client.step(3) + fx_after = int(values("return mainmemory.read_u8(0x171f+shot_slot)+256*mainmemory.read_u8(0x1733+shot_slot)")[0]) + assert fx != fx_after + checked("fireball moved under game simulation", {"x_before":fx,"x_after":fx_after}) + rates = {} + for interval in (24,4): + client.eval(f"smw.autofire(0); for s=0,9 do if mainmemory.read_u8(0x170b+s)==5 then mainmemory.write_u8(0x170b+s,0) end end; smw.shots=0; smw.missed_shots=0; smw.autofire({interval})") + client.step(96) + rates[interval] = list(map(int, values("return smw.shots,smw.missed_shots"))) + assert rates[24][0] == 4 and rates[4][0] > rates[24][0]*2, rates + checked("changing fire cadence changes real projectile creation", rates) + client.eval("smw.stop()") + client.command("reset") + assert values("return smw,counter,joypad.get(1).Y") == [None,None,False] + client.step(2) + checked("VM reset clears scripts, callbacks and input overrides") + client.eval((ROOT / "tools/lua/smw.lua").read_text()) + checked("final playable state", values("return smw.status()")) + report["ok"] = True + finally: + if client: client.close() + if not args.keep_running or not report.get("ok"): + process.terminate() + try: process.wait(timeout=5) + except subprocess.TimeoutExpired: process.kill(); process.wait() + log.close() + report_path = exe.parent / "lua-validation.json" + report_path.write_text(json.dumps(report, indent=2)+"\n") + print(f"Report: {report_path}", flush=True) + + +if __name__ == "__main__": + main() From 2af0a2a0b75607ff9b797c323a5bee510b2f5d54 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 11 Sep 2026 00:15:07 -0700 Subject: [PATCH 2/6] Keep Lua spawns within stock fireball drawing and collision slots --- tools/lua/README.md | 26 +++++++++++++++++++------- tools/lua/smw.lua | 16 ++++++++++------ tools/lua/validate.py | 17 +++++++++++++++-- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/tools/lua/README.md b/tools/lua/README.md index 897cbd3..f04b727 100644 --- a/tools/lua/README.md +++ b/tools/lua/README.md @@ -53,13 +53,21 @@ initializes the slot's tables from the ROM and lets the normal game INIT run. Autofire creates genuine type-5 extended sprites using SMW's own fireball state layout; the game handles movement, rendering and collision. This is a -scripted firing mode, not a patch to the stock Y-button cooldown. It can use -all ten extended-sprite slots (stock shooting allocates two), never overwrites -occupied slots, and reports skipped attempts in `smw.missed_shots`. Interval +scripted firing mode, not a patch to the stock Y-button cooldown. It uses only +the two supported player-fireball slots (8/9), never overwrites occupied slots, +and reports skipped attempts in `smw.missed_shots`. Interval 0 disables it. `smw.fireball()` fires once. Helpers reject the opening message and require a playable level; player transitions and sprite-lock frames suspend autofire. +The earlier ten-fireball version was incorrect: the stock player-fireball +renderer maps slots 0..7 to unaligned OAM offsets, producing garbled objects. +Having ten extended-sprite simulation entries does not mean all ten can draw +player fireballs. Additional simultaneous fireballs would require deliberate +renderer/OAM allocation changes. Ordinary spawned enemies now use slots 0..9, +which the stock fireball collision loop checks, rather than special slots +10/11. Both restrictions have regression coverage. + Arbitrary Lua is supported, for example: ```powershell @@ -118,7 +126,7 @@ the successful instance paused for inspection (its test process uses turbo). Evidence is written to `build-lua/lua-validation.json` and `build-lua/lua-validation-game.log`. -Validated September 10, 2026 with the stock 524288-byte USA ROM: +Validated September 11, 2026 with the stock 524288-byte USA ROM: - WRAM signed/endian operations, ROM reads, domain/boundary rejection, Lua instruction/memory limit recovery, JSON escaping, TCP fragmentation and @@ -127,10 +135,14 @@ Validated September 10, 2026 with the stock 524288-byte USA ROM: `emu.frameadvance`, input-driven navigation, and VM reset passed. - Reached Yoshi's Island 1 (translevel 41); changed Mario's powerup and moved him from x=16 to x=32. -- Goomba in slot 11 transitioned from INIT to active and moved x=96 to x=91 +- Goomba in slot 9 transitioned from INIT to active and moved x=96 to x=91 after 24 further frames. A fireball moved x=40 to x=49 in three frames. -- Over 96 frames, interval 24 produced 4 shots; interval 4 produced 16 shots - plus 8 attempts skipped because the projectile pool was occupied. +- With both player-fireball slots occupied, a new shot is skipped without + writing a type-5 projectile into any of slots 0..7. Fireballs can hit the + spawned enemy and convert it into a coin. +- Over 96 frames, interval 48 produced 2 shots; interval 4 produced 4 shots + plus 20 attempts skipped because the two-slot pool was occupied. The earlier + 16-shot measurement used the invalid OAM slots and is not a valid result. The validation is a spike demonstration, not a full-game or full-BizHawk compatibility certification. diff --git a/tools/lua/smw.lua b/tools/lua/smw.lua index 8ca0101..5e2854e 100644 --- a/tools/lua/smw.lua +++ b/tools/lua/smw.lua @@ -39,7 +39,10 @@ function smw.spawn(id, x, y) integer(id, 0, 0xc8, "stock sprite id") x = integer(x or (r16(0x94)+64), 0, 65535, "x") y = integer(y or r16(0x96), 0, 65535, "y") - for s = 11, 0, -1 do + -- The stock fireball collision loop checks normal slots 0..9. Slots 10/11 + -- exist in RAM but are special slots, so don't allocate ordinary enemies + -- there: they can render/move while being skipped by projectile collision. + for s = 9, 0, -1 do if r(0x14c8+s) == 0 then -- Mirror ZeroSpriteTables and LoadSpriteTables before status 1 -- invokes the type-specific INIT. Status 1 alone leaves stale @@ -61,15 +64,16 @@ function smw.spawn(id, x, y) return s end end - error("all 12 normal sprite slots are occupied") + error("all 10 ordinary sprite slots are occupied") end function smw.fireball() in_level() if r(0x9d) ~= 0 or r(0x71) ~= 0 then return nil end - -- Use the engine's ten extended-sprite slots, preserving occupied slots. - -- Stock player shooting only allocates slots 8/9; the sprite dispatcher - -- can update type-5 projectiles in all ten. No ROM/code patch is needed. - for s = 9, 0, -1 do + -- Only slots 8/9 have valid player-fireball OAM mappings. The stock draw + -- path indexes $02:9FA3: slots 0..7 yield $05,$03,$02,... (unaligned OAM + -- offsets), corrupting unrelated sprites. A ten-entry simulation table + -- does not imply ten drawable player fireballs. Never steal other slots. + for s = 9, 8, -1 do if r(0x170b+s) == 0 then local right = r(0x76) ~= 0 local x, y = r16(0x94) + (right and 8 or 0), r16(0x96)+8 diff --git a/tools/lua/validate.py b/tools/lua/validate.py index 1aded23..accbc4a 100644 --- a/tools/lua/validate.py +++ b/tools/lua/validate.py @@ -131,18 +131,31 @@ def wait_script(timeout=60): sx_after = int(values(f"return mainmemory.read_u8(0xe4+{slot})+256*mainmemory.read_u8(0x14e0+{slot})")[0]) assert sx != sx_after, "spawned sprite did not move" checked("spawned Goomba initialized and moved under game simulation", {"slot":slot,"x_before":sx,"x_after":sx_after}) + assert slot <= 9, "ordinary enemies must be in the stock fireball collision scan" + client.eval("demo_hit=false; event.onframeend(function() if mainmemory.read_u8(0x9e+spawn_slot)==0x21 then demo_hit=true end end,'demo.collision')") client.eval("shot_slot=smw.fireball(); assert(shot_slot)") fx = int(values("return mainmemory.read_u8(0x171f+shot_slot)+256*mainmemory.read_u8(0x1733+shot_slot)")[0]) client.step(3) fx_after = int(values("return mainmemory.read_u8(0x171f+shot_slot)+256*mainmemory.read_u8(0x1733+shot_slot)")[0]) assert fx != fx_after checked("fireball moved under game simulation", {"x_before":fx,"x_after":fx_after}) + # With both supported fireball slots occupied, do not spill into the + # other eight extended slots (their player-fireball OAM offsets are + # unaligned). This is the regression for the visible garbage pixels. + client.eval("smw.autofire(0); saved_ext={}; for s=0,9 do saved_ext[s]=mainmemory.read_u8(0x170b+s) end; mainmemory.write_u8(0x1713,5); mainmemory.write_u8(0x1714,5)") + assert values("return smw.fireball()") == [None] + assert values("local unchanged=true; for s=0,7 do unchanged=unchanged and mainmemory.read_u8(0x170b+s)==saved_ext[s] end; return unchanged") == [True] + client.eval("for s=0,9 do mainmemory.write_u8(0x170b+s,saved_ext[s]) end") + checked("full stock fireball pool never spills into unsupported OAM slots") rates = {} - for interval in (24,4): + for interval in (48,4): client.eval(f"smw.autofire(0); for s=0,9 do if mainmemory.read_u8(0x170b+s)==5 then mainmemory.write_u8(0x170b+s,0) end end; smw.shots=0; smw.missed_shots=0; smw.autofire({interval})") client.step(96) rates[interval] = list(map(int, values("return smw.shots,smw.missed_shots"))) - assert rates[24][0] == 4 and rates[4][0] > rates[24][0]*2, rates + assert values("local valid=true; for s=0,7 do valid=valid and mainmemory.read_u8(0x170b+s)~=5 end; return valid") == [True] + assert rates[48][0] == 2 and rates[4][0] > rates[48][0], rates + assert values("return demo_hit") == [True], "fireballs did not convert the spawned enemy to a coin" + checked("stock fireball collision reaches the allocated enemy slot") checked("changing fire cadence changes real projectile creation", rates) client.eval("smw.stop()") client.command("reset") From 1f45d53e06a08355b80c4c3648b9cf36f464bf55 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 11 Sep 2026 00:47:38 -0700 Subject: [PATCH 3/6] Pin opt-in Lua framework and ship a 100-fireballs-per-second playground --- .gitmodules | 2 +- CMakeLists.txt | 20 ++++- README.md | 8 ++ RELEASE.md | 7 ++ lua/100_fireballs.lua | 19 ++++ lua/LICENSE-Lua.txt | 20 +++++ lua/README.md | 65 ++++++++++++++ snesrecomp | 2 +- src/lua_fire_stream.c | 193 +++++++++++++++++++++++++++++++++++++++++ src/lua_fire_stream.h | 8 ++ src/main.c | 9 +- tools/build-linux.sh | 18 ++++ tools/lua/README.md | 59 ++++++++----- tools/lua/smw.lua | 31 +++++++ tools/lua/validate.py | 51 +++++++++++ tools/make_release.ps1 | 11 +++ 16 files changed, 499 insertions(+), 24 deletions(-) create mode 100644 lua/100_fireballs.lua create mode 100644 lua/LICENSE-Lua.txt create mode 100644 lua/README.md create mode 100644 src/lua_fire_stream.c create mode 100644 src/lua_fire_stream.h diff --git a/.gitmodules b/.gitmodules index 0fdc044..60ebd0f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "snesrecomp"] path = snesrecomp - url = https://github.com/mstan/snesrecomp.git + url = https://github.com/RetroPortingToolKit/snesrecomp.git [submodule "recomp-ui"] path = recomp-ui url = https://github.com/mstan/recomp-ui.git diff --git a/CMakeLists.txt b/CMakeLists.txt index f206ebf..f1aa091 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,7 +37,7 @@ set(SNESRECOMP_ROOT "${CMAKE_SOURCE_DIR}/snesrecomp" CACHE PATH "Path to the snesrecomp checkout used by this game") include(${SNESRECOMP_ROOT}/runner/runner.cmake) if(SNESRECOMP_ENABLE_LUA AND NOT TARGET snesrecomp_lua) - message(FATAL_ERROR "SNESRECOMP_ENABLE_LUA requires the paired snesrecomp Lua spike checkout") + message(FATAL_ERROR "SNESRECOMP_ENABLE_LUA requires snesrecomp with Lua bridge support") endif() if(SNESRECOMP_ENABLE_LUA AND SMW_BUILD_COOP) message(FATAL_ERROR "The Lua TCP spike currently supports the stock single-player target only") @@ -165,6 +165,9 @@ set(SMW_RUNTIME_SOURCES if(NOT EXISTS "${SNESRECOMP_ROOT}/runner/src/mod_audio.c") list(APPEND SMW_RUNTIME_SOURCES src/mod_audio.c) endif() +if(SNESRECOMP_ENABLE_LUA) + list(APPEND SMW_RUNTIME_SOURCES src/lua_fire_stream.c) +endif() if(SNESRECOMP_ENABLE_TRACE) set(_SMW_TRACE 1) @@ -225,6 +228,21 @@ function(smw_add_recomp_target target_name generated_sources) recomp_target_launcher_ui(${target_name} CONSOLE snes BOXART ${CMAKE_SOURCE_DIR}/recomp/launcher/boxart.tga) + if(SNESRECOMP_ENABLE_LUA) + set(_lua_dir "$/lua") + add_custom_command(TARGET ${target_name} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/lua ${_lua_dir} + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${CMAKE_SOURCE_DIR}/tools/lua/smw.lua ${_lua_dir}/smw.lua + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${SNESRECOMP_ROOT}/tools/lua_tcp.py ${_lua_dir}/lua_tcp.py) + set_property(TARGET ${target_name} APPEND PROPERTY LINK_DEPENDS + ${CMAKE_SOURCE_DIR}/lua/100_fireballs.lua + ${CMAKE_SOURCE_DIR}/lua/README.md + ${CMAKE_SOURCE_DIR}/lua/LICENSE-Lua.txt + ${CMAKE_SOURCE_DIR}/tools/lua/smw.lua + ${SNESRECOMP_ROOT}/tools/lua_tcp.py) + endif() endfunction() smw_add_recomp_target(SuperMarioWorldSNESRecomp diff --git a/README.md b/README.md index fd79e4f..d942844 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,14 @@ See [`RELEASE.md`](RELEASE.md) for the latest release notes. The ROM is **never** redistributed — supply your own dump. +## Optional Lua scripting + +The v0.12.0 Windows/Linux packages include an opt-in localhost Lua server and +a `lua/` folder with a **100-fireballs-per-second** hold-to-fire example. +See [lua/README.md](lua/README.md) for activation and controls. Nothing runs or +listens during ordinary play. Source builds enable this with +`-DSNESRECOMP_ENABLE_LUA=ON`; the framework option defaults OFF. + ## Widescreen The one-player launcher's **Aspect ratio** setting offers three view modes: **Standard diff --git a/RELEASE.md b/RELEASE.md index 923d888..a227d2d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -27,6 +27,13 @@ The co-op executable is additive and opt-in (`-DSMW_BUILD_COOP=ON` / `--coop`); ship it only when the release notes call for it. +For releases that include Lua (v0.12.0 onward), configure the stock Windows +build with `-DSNESRECOMP_ENABLE_LUA=ON` and pass `--lua` to `build-linux.sh`. +The listener remains runtime opt-in through `SNESRECOMP_LUA_PORT`. Both +packages must include `lua/100_fireballs.lua`, its README, the client/helpers +and Lua's license. AppImage first launch seeds missing examples beside itself +without overwriting user edits. Co-op currently rejects Lua builds. + ## Windows ```powershell diff --git a/lua/100_fireballs.lua b/lua/100_fireballs.lua new file mode 100644 index 0000000..66b8575 --- /dev/null +++ b/lua/100_fireballs.lua @@ -0,0 +1,19 @@ +-- Load through lua_tcp.py after starting SMW with SNESRECOMP_LUA_PORT=4380. +-- Safe to load at the title screen. No ROM patch or other helper is needed. +-- Hold the normal fire/run buttons: keyboard A / S by default, SNES Y / X. +local RATE = 100 -- fireballs per 60 active gameplay frames +local r = mainmemory.read_u8 +event.unregisterbyname("example.fire_stream") +game.command("fire_stream_reset") +local previous = 0 +event.onframestart(function() + local buttons = joypad.get(1) + local firing = (buttons.Y or buttons.X) and r(0x100) == 0x14 + and r(0x109) == 0 and r(0x71) == 0 + local requested = firing and RATE or 0 + if requested ~= previous then + game.command("fire_stream", tostring(requested)) + previous = requested + end +end, "example.fire_stream") +print("Hold A / S (SNES Y / X) for 100 fireballs/second. TCP reset disables it.") diff --git a/lua/LICENSE-Lua.txt b/lua/LICENSE-Lua.txt new file mode 100644 index 0000000..f6d9253 --- /dev/null +++ b/lua/LICENSE-Lua.txt @@ -0,0 +1,20 @@ +Copyright (C) 1994-2026 Lua.org, PUC-Rio. + +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. diff --git a/lua/README.md b/lua/README.md new file mode 100644 index 0000000..a8a4700 --- /dev/null +++ b/lua/README.md @@ -0,0 +1,65 @@ +# Optional Lua playground + +Lua is inactive during normal play. These release builds include the feature; +source builds enable it with `-DSNESRECOMP_ENABLE_LUA=ON` (default OFF). +The server binds only `127.0.0.1` and starts only when `SNESRECOMP_LUA_PORT` +is set. This is a trusted local development interface with a BizHawk-style API +subset, not full BizHawk compatibility. + +## Windows + +From the extracted release directory in PowerShell, start the game: + +```powershell +$env:SNESRECOMP_LUA_PORT = '4380' +Start-Process -FilePath .\SuperMarioWorldSNESRecomp.exe +``` + +Select your own Super Mario World (USA) ROM in the launcher and start playing. +With Python 3 installed, run in that same directory: + +```powershell +python lua/lua_tcp.py load lua/100_fireballs.lua +``` + +## Linux / Steam Deck + +On first launch the AppImage copies its bundled `lua/` examples beside itself +without replacing existing files. From that directory: + +```bash +SNESRECOMP_LUA_PORT=4380 ./SuperMarioWorldRecomp-linux-0.12.0-x86_64.AppImage +# In a second terminal, after starting the game through the launcher: +python3 lua/lua_tcp.py load lua/100_fireballs.lua +``` + +## Play and experiment + +Hold **A or S on the default keyboard layout** (SNES Y/X) to emit 100 fireballs +per second at normal speed. Release to stop new shots. Mario gains fire power +while firing; movement and jumping still work. The script can be loaded at +the title screen and waits for a playable level. + +```bash +python lua/lua_tcp.py eval 'return game.command("fire_stream_status")' +python lua/lua_tcp.py reset +``` + +`reset` removes the script and extra projectiles. Close the game and launch +without `SNESRECOMP_LUA_PORT` for normal play with no listener. In PowerShell, +remove the setting with `Remove-Item Env:SNESRECOMP_LUA_PORT` before relaunching. + +The included `smw.lua` provides additional helpers: load it with the client, +then try `smw.holdfire(100)`, `smw.fire_stream(100)` for continuous emission, +`smw.spawn(0x04)` for a green Koopa, or `smw.stop()`. Reset before switching +between the standalone example and the helper callbacks. + +The 256-entry stream pool runs stock projectile movement/collisions in private +working memory and draws separately from SNES OAM. It uses the native +256-pixel camera bounds and draws over the finished frame; foreground priority, +window effects and color math are not reproduced for extra projectiles. +Sprite locks/transitions suspend emission. Very high rates can fill the pool; +status reports dropped shots. Extra pool/Lua state is not saved in savestates. +This example supports the stock single-player USA game, not co-op. + +Lua 5.4.9 is distributed under the included MIT license. diff --git a/snesrecomp b/snesrecomp index 4454da6..266804e 160000 --- a/snesrecomp +++ b/snesrecomp @@ -1 +1 @@ -Subproject commit 4454da6077bce8cf1ce984392b387093baae0a15 +Subproject commit 266804e860560ff7084ed74258d803204b7d7bd0 diff --git a/src/lua_fire_stream.c b/src/lua_fire_stream.c new file mode 100644 index 0000000..f80e6b2 --- /dev/null +++ b/src/lua_fire_stream.c @@ -0,0 +1,193 @@ +/* Lua playground projectile pool. Each projectile executes SMW's original + * extended-sprite routine on a private RAM bus and private CPU/stack. This + * preserves native slopes, gravity and enemy-hit behavior without advancing + * the main CPU/PPU clocks or putting extra objects in SNES OAM. Only persistent + * enemy/score/SFX consequences are copied back; scratch, stack, OAM, and the + * borrowed native slot never touch the live game. Rendering uses ROM-owned + * tiles and palettes from the current PPU in a separate host overlay. */ +#include "lua_fire_stream.h" +#include "snes/interp816.h" +#include "snes/ppu.h" +#include +#include +#include + +#define POOL_SIZE 256 +#define WRAM_SIZE 0x20000 +#define FIELD_COUNT 12 +static const unsigned fields[FIELD_COUNT] = { + 0x170b,0x1715,0x171f,0x1729,0x1733,0x173d,0x1747, + 0x1751,0x175b,0x1765,0x176f,0x1779 +}; +typedef struct Projectile { + uint8_t field[FIELD_COUNT]; + uint8_t tile, attr, size, sx, sy, drawn; + unsigned age; +} Projectile; +static Projectile pool[POOL_SIZE]; +static uint8_t shadow[WRAM_SIZE], dirty[WRAM_SIZE]; +static uint8_t *live; +static const uint8_t *cart; +static uint32_t cart_size; +static unsigned rate, credit, peak, active, ticks; +static unsigned long long spawned, dropped, collisions, opcodes; +static int failed; +static char error[192]; + +static void fault(const char *operation, uint32_t address) { + if (!failed) snprintf(error, sizeof(error), "unsupported native fireball %s at $%06X", operation, address); + failed = 1; +} +static int ram_address(uint32_t a) { + if (a >= 0x7e0000 && a <= 0x7fffff) return (int)(a - 0x7e0000); + if ((a & 0x7f0000) < 0x400000 && (a & 0xffff) < 0x2000) return (int)(a & 0x1fff); + return -1; +} +static uint8_t read_bus(void *unused, uint32_t address) { + (void)unused; + int a = ram_address(address); + if (a >= 0) return shadow[a]; + if ((address & 0xffff) >= 0x8000) { + uint32_t offset = ((address & 0x7f0000) >> 1) | (address & 0x7fff); + if (offset < cart_size) return cart[offset]; + } + fault("read", address); return 0; +} +static void write_bus(void *unused, uint32_t address, uint8_t value) { + (void)unused; + int a = ram_address(address); + if (a < 0) { fault("write", address); return; } + shadow[a] = value; dirty[a] = 1; +} +static int persistent(unsigned a) { + /* Normal sprite state, score sprites, player score, and sound requests. + * All other writes belong to the private execution context. */ + return (a >= 0x9e && a <= 0xef) || + (a >= 0x14c8 && a < 0x1692) || + (a >= 0x16e1 && a < 0x170b) || + (a >= 0x187b && a < 0x1887) || + (a >= 0x190f && a < 0x191b) || + (a >= 0x1fd6 && a < 0x1fee) || + (a >= 0x0f34 && a < 0x0f3a) || + (a >= 0x1df9 && a <= 0x1dfc); +} +static void reset(void) { + memset(pool, 0, sizeof(pool)); + rate = credit = peak = active = ticks = 0; + spawned = dropped = collisions = opcodes = 0; + failed = 0; error[0] = 0; +} +void smw_fire_stream_init(uint8_t *ram, const uint8_t *rom, uint32_t size) { + live = ram; cart = rom; cart_size = size; reset(); +} +static unsigned word(const uint8_t *ram, unsigned a) { return ram[a] | ((unsigned)ram[a+1] << 8); } +static void spawn(unsigned subframe, unsigned count) { + for (unsigned i = 0; i < POOL_SIZE; ++i) if (!pool[i].field[0]) { + Projectile *p = &pool[i]; memset(p, 0, sizeof(*p)); + int right = live[0x76] != 0; + int offset = (int)(subframe * 3 / count); + unsigned x = (word(live,0x94) + (right ? 8+offset : -offset)) & 0xffff; + unsigned y = (word(live,0x96)+8) & 0xffff; + p->field[0] = 5; + p->field[1] = y; p->field[2] = x; p->field[3] = y >> 8; p->field[4] = x >> 8; + p->field[5] = 0x30; p->field[6] = right ? 3 : 0xfd; + p->field[11] = live[0x13f9]; + ++spawned; return; + } + ++dropped; +} +static void update(Projectile *p, unsigned index) { + for (unsigned j = 0; j < FIELD_COUNT; ++j) shadow[fields[j]+9] = p->field[j]; + shadow[0x15e9] = 9; + /* Collision work in SMW is staggered modulo four using X xor $13. + * Distinct virtual slots get distinct phases, while preserving frequency. */ + shadow[0x13] = (uint8_t)(live[0x13] + index); + shadow[0x02fd] = 0xf0; /* no draw this tick unless native code emits OAM */ + Interp816 cpu; memset(&cpu, 0, sizeof(cpu)); + cpu.read = read_bus; cpu.write = write_bus; + cpu.k = cpu.db = 2; cpu.pc = 0x9b16; cpu.x = 9; + cpu.mf = cpu.xf = cpu.i = true; cpu.sp = 0x1fd; + shadow[0x1fe] = 0xff; shadow[0x1ff] = 0x7f; /* balanced RTS sentinel */ + unsigned instructions; + for (instructions = 0; instructions < 20000 && !failed; ++instructions) { + interp816_runOpcode(&cpu); + if (cpu.sp == 0x1ff && cpu.pc == 0x8000 && cpu.k == 2) break; + if (cpu.waiting || cpu.stopped) { fault("wait/stop", ((uint32_t)cpu.k<<16)|cpu.pc); break; } + } + opcodes += instructions+1; + if (instructions == 20000) fault("instruction limit", ((uint32_t)cpu.k<<16)|cpu.pc); + for (unsigned j = 0; j < FIELD_COUNT; ++j) p->field[j] = shadow[fields[j]+9]; + p->sx = shadow[0x02fc]; p->sy = shadow[0x02fd]; + p->tile = shadow[0x02fe]; p->attr = shadow[0x02ff]; p->size = shadow[0x045f]; + p->drawn = p->field[0] && p->sy < 0xf0; + if (++p->age > 600) p->field[0] = p->drawn = 0; +} +void smw_fire_stream_tick(void) { + if (!live || failed) return; + if (live[0x100] != 0x14 || live[0x109] != 0) { + memset(pool, 0, sizeof(pool)); active = credit = 0; return; + } + if (live[0x9d] || live[0x13d4]) return; + if (!rate && !active) return; + ++ticks; + if (rate && !live[0x71]) { + credit += rate; + unsigned count = credit / 60; credit %= 60; + for (unsigned i = 0; i < count; ++i) spawn(i, count); + if (count) { + live[0x19] = 3; live[0x149c] = 10; + if (ticks % 6 == 0) live[0x1dfc] = 6; + } + } + memcpy(shadow, live, sizeof(shadow)); memset(dirty, 0, sizeof(dirty)); + active = 0; + for (unsigned i = 0; i < POOL_SIZE; ++i) if (pool[i].field[0]) { + update(&pool[i], i); + if (failed) { rate = 0; fprintf(stderr,"[lua fire stream] %s\n",error); return; } + if (pool[i].field[0]) ++active; + } + for (unsigned a = 0; a < WRAM_SIZE; ++a) if (dirty[a] && persistent(a)) { + if (a >= 0x9e && a < 0xaa && live[a] != 0x21 && shadow[a] == 0x21) ++collisions; + live[a] = shadow[a]; + } + if (active > peak) peak = active; +} +int smw_fire_stream_command(const char *name, const char *args, char *out, size_t capacity) { + if (!strcmp(name,"__reset") || !strcmp(name,"fire_stream_reset")) { reset(); snprintf(out,capacity,"reset"); return 1; } + if (!strcmp(name,"fire_stream")) { + char *end; long requested = strtol(args,&end,10); + if (end == args || *end || requested < 0 || requested > 1000) { snprintf(out,capacity,"rate must be 0..1000 fireballs/second"); return 0; } + if (failed) { snprintf(out,capacity,"%s; reset the stream first",error); return 0; } + rate = (unsigned)requested; credit = 0; + } else if (strcmp(name,"fire_stream_status")) { snprintf(out,capacity,"unknown SMW command: %s",name); return 0; } + snprintf(out,capacity,"rate=%u spawned=%llu active=%u peak=%u dropped=%llu hits=%llu ticks=%u opcodes=%llu error=%s", + rate,spawned,active,peak,dropped,collisions,ticks,opcodes,error); + return 1; +} +void smw_fire_stream_draw(Ppu *ppu, uint8_t *pixels, size_t pitch, int width, int height) { + if (!live || failed || live[0x100] != 0x14 || !pixels) return; + for (unsigned i = 0; i < POOL_SIZE; ++i) { + const Projectile *p = &pool[i]; + if (!p->drawn) continue; + int size = (p->size & 2) ? 16 : 8; + int x = p->sx + (width-256)/2, y = p->sy; + unsigned base = p->attr & 1 ? PPU_objTileAdr2(ppu) : PPU_objTileAdr1(ppu); + unsigned palette = 128 + ((p->attr >> 1) & 7)*16; + for (int dy = 0; dy < size; ++dy) for (int dx = 0; dx < size; ++dx) { + int sx = x+dx, sy = y+dy; + if (sx < 0 || sx >= width || sy < 0 || sy >= height) continue; + int tx = (p->attr & 0x40) ? size-1-dx : dx; + int ty = (p->attr & 0x80) ? size-1-dy : dy; + unsigned tile = (p->tile & 0xf0) | ((p->tile + tx/8) & 15); + tile = (tile + (ty/8)*16) & 255; + unsigned at = (base + tile*16 + (ty & 7)) & 0x7fff; + unsigned a = PpuRenderVram(ppu)[at], b = PpuRenderVram(ppu)[(at+8)&0x7fff]; + unsigned bit = 7-(tx&7); + unsigned color = ((a>>bit)&1) | (((a>>(bit+8))&1)<<1) | (((b>>bit)&1)<<2) | (((b>>(bit+8))&1)<<3); + if (!color) continue; + unsigned rgb = ppu->cgram[palette+color]; + unsigned red = (rgb&31)*255/31, green = ((rgb>>5)&31)*255/31, blue = ((rgb>>10)&31)*255/31; + ((uint32_t*)(pixels+(size_t)sy*pitch))[sx] = 0xff000000u | (red<<16) | (green<<8) | blue; + } + } +} diff --git a/src/lua_fire_stream.h b/src/lua_fire_stream.h new file mode 100644 index 0000000..e8e27e8 --- /dev/null +++ b/src/lua_fire_stream.h @@ -0,0 +1,8 @@ +#pragma once +#include +#include +typedef struct Ppu Ppu; +void smw_fire_stream_init(uint8_t *ram, const uint8_t *rom, uint32_t rom_size); +void smw_fire_stream_tick(void); +void smw_fire_stream_draw(Ppu *ppu, uint8_t *pixels, size_t pitch, int width, int height); +int smw_fire_stream_command(const char *name, const char *args, char *out, size_t capacity); diff --git a/src/main.c b/src/main.c index c9252d9..9292941 100644 --- a/src/main.c +++ b/src/main.c @@ -6,6 +6,7 @@ #include "debug_server.h" #if SNESRECOMP_ENABLE_LUA #include "lua_bridge.h" +#include "lua_fire_stream.h" #endif #include "desktop/sdl_compat.h" #ifdef _WIN32 @@ -521,7 +522,10 @@ static void DrawPpuFrameWithPerf(void) { if (g_display_perf) RenderNumber(pixel_buffer + pitch * render_scale, pitch, g_curr_fps, render_scale == 4); - g_renderer_funcs.EndDraw(); +#if SNESRECOMP_ENABLE_LUA + smw_fire_stream_draw(g_ppu, pixel_buffer, pitch, g_snes_width, g_snes_height); +#endif + g_renderer_funcs.EndDraw(); } static SDL_mutex *g_audio_mutex; @@ -1676,6 +1680,8 @@ error_reading:; fprintf(stderr, "[lua] Could not start requested Lua TCP server\n"); return 1; } + smw_fire_stream_init(snes->ram, kRom, kRom_SIZE); + lua_bridge_set_game_command_handler(smw_fire_stream_command); } } #endif @@ -2086,6 +2092,7 @@ error_reading:; #endif RtlRunFrame(inputs); #if SNESRECOMP_ENABLE_LUA + smw_fire_stream_tick(); lua_bridge_frame_end(); #endif } diff --git a/tools/build-linux.sh b/tools/build-linux.sh index c1cc734..27cb231 100644 --- a/tools/build-linux.sh +++ b/tools/build-linux.sh @@ -30,6 +30,7 @@ # Usage: # bash tools/build-linux.sh # prod AppImage (default) # bash tools/build-linux.sh --version 0.10.0 # stamp + name a release build +# bash tools/build-linux.sh --lua # include opt-in Lua + examples # bash tools/build-linux.sh --coop # simultaneous co-op AppImage # bash tools/build-linux.sh --config debug # debug build (TCP server + rings) # bash tools/build-linux.sh --regen # regen src/gen first (tools/regen.sh) @@ -86,6 +87,7 @@ DO_REGEN=0 DO_RUN=0 DO_PACKAGE=1 VERSION="" +ENABLE_LUA=OFF JOBS="$(nproc 2>/dev/null || echo 4)" REPO="$(cd "$(dirname "$0")/.." && pwd)" OUT="$REPO/release-linux" @@ -98,6 +100,7 @@ while [ $# -gt 0 ]; do --prod) CONFIG="prod"; shift;; --debug) CONFIG="debug"; shift;; --version) VERSION="$2"; shift 2;; + --lua) ENABLE_LUA=ON; shift;; --regen) DO_REGEN=1; shift;; --run) DO_RUN=1; shift;; --no-package) DO_PACKAGE=0; shift;; @@ -128,6 +131,7 @@ if [ -z "$VERSION" ]; then [ -n "$VERSION" ] || VERSION="dev" fi FLAGS+=( -DSNESRECOMP_BUILD_VERSION="$VERSION" ) +FLAGS+=( -DSNESRECOMP_ENABLE_LUA="$ENABLE_LUA" ) # SDL3 is the default; SNESRECOMP_SDL_BACKEND=SDL2 selects the compatibility # package. Prefer the host package over any cross-platform dependency prefix. @@ -263,6 +267,12 @@ $LINUXDEPLOY --appdir "$APPDIR" --executable "$BIN" \ } echo " staging launcher assets/ -> AppDir/usr/bin/assets" cp -r "$(dirname "$BIN")/assets" "$APPDIR/usr/bin/assets" +if [ "$ENABLE_LUA" = ON ]; then + for name in 100_fireballs.lua README.md LICENSE-Lua.txt lua_tcp.py smw.lua; do + [ -f "$(dirname "$BIN")/lua/$name" ] || { echo "ERROR: Lua payload missing $name" >&2; exit 1; } + done + cp -r "$(dirname "$BIN")/lua" "$APPDIR/usr/bin/lua" +fi # Extra read-only payload (co-op IPS). Never user state. for rel in "${EXTRA_PAYLOAD[@]}"; do @@ -311,6 +321,14 @@ if [ -f "\$HERE/usr/bin/smw_coop.ips" ]; then fi SELF="\${APPIMAGE:-\$0}" ROMDIR="\$(dirname "\$(readlink -f "\$SELF")")" +# Seed examples for the user to edit; never overwrite an existing script. +if [ -d "\$HERE/usr/bin/lua" ] && [ -w "\$ROMDIR" ]; then + mkdir -p "\$ROMDIR/lua" + for example in "\$HERE/usr/bin/lua/"*; do + target="\$ROMDIR/lua/\$(basename "\$example")" + [ -e "\$target" ] || cp "\$example" "\$target" + done +fi # Seed/refresh the release-owned mod catalog beside the .AppImage. Directory # trees get mkdir -p + cp of their CONTENTS (never a file where a dir belongs, # never a dir where a file belongs); user files are left alone entirely. diff --git a/tools/lua/README.md b/tools/lua/README.md index f04b727..b1d31e0 100644 --- a/tools/lua/README.md +++ b/tools/lua/README.md @@ -1,17 +1,10 @@ # SMW Lua / TCP playground -This spike runs BizHawk-style Lua inside the native game and accepts commands -over a local TCP server. Both worktrees use branch `codex/bizhawk-lua-tcp-spike`: - -- Framework: `F:\Projects\snesrecomp\_wt-bizhawk-lua-snesrecomp`, based on - freshly fetched `origin/main` `43e857d`. -- Game: `F:\Projects\snesrecomp\_wt-bizhawk-lua-smw`, based on freshly fetched - `origin/main` `703786e`. - -The game CMake cache explicitly selects the paired framework worktree via -`SNESRECOMP_ROOT`; the existing source checkouts and their local edits are -untouched. The framework's `docs/LUA_TCP.md` lists the supported API and wire -protocol. Reference: [BizHawk Lua functions](https://tasvideos.org/Bizhawk/LuaFunctions). +This opt-in feature runs BizHawk-style Lua inside the native game and accepts +commands over a local TCP server. The tracked `snesrecomp` submodule includes +the bridge; its `docs/LUA_TCP.md` describes the supported API and protocol. +Release users should start with [the bundled example](../../lua/README.md). +Reference: [BizHawk Lua functions](https://tasvideos.org/Bizhawk/LuaFunctions). ## Try the built spike @@ -20,7 +13,7 @@ From the game worktree in PowerShell (native Python 3 required): ```powershell .\tools\lua\start.ps1 -Paused $python = 'C:\Users\Matthew\AppData\Local\Programs\Python\Python312\python.exe' -$client = '..\_wt-bizhawk-lua-snesrecomp\tools\lua_tcp.py' +$client = 'snesrecomp\tools\lua_tcp.py' # Wait for the game window/server to finish booting, then: & $python $client load tools/lua/smw.lua & $python $client run tools/lua/enter_level.lua @@ -30,9 +23,9 @@ $client = '..\_wt-bizhawk-lua-snesrecomp\tools\lua_tcp.py' & $python $client eval 'smw.powerup(3); smw.invincible(true)' & $python $client eval 'return smw.spawn(0x0f)' # Goomba, 64 pixels ahead & $python $client eval 'return smw.spawn(0x04)' # green Koopa -& $python $client eval 'smw.autofire(4)' # attempt a shot every 4 frames +& $python $client eval 'smw.holdfire(100)' # hold Y/X: 100 fireballs/second & $python $client resume -& $python $client eval 'smw.autofire(24)' # slower cadence, live +& $python $client eval 'smw.fire_stream(100)' # continuous stream without holding & $python $client eval 'smw.teleport(128,352)' & $python $client eval 'smw.stop()' ``` @@ -45,6 +38,28 @@ callbacks and input overrides, while leaving the game's RAM intact. Reload stop the server. `start.ps1 -Port 4382` and client `--port 4382` support a second independent instance. +`smw.holdfire(100)` binds the stream to the normal fire/run buttons: **A or S +on the default keyboard layout**, or SNES Y/X on a controller. Hold to fire, +release to stop creating shots; existing fireballs finish their flight. Moving +and jumping still work. `smw.holdfire(0)` disables this mode. Reloading helpers, +`smw.stop()`, or switching to `smw.autofire` / `smw.fire_stream` removes the +hold callback. This mode grants fire power when firing. + +The stream has a separate 256-projectile pool. `smw.fire_stream(100)` emits +100 projectiles per 60 active game frames; `smw.fire_stream(0)` stops emission. +`smw.stream_status()` reports rate, total spawned, active/peak counts, dropped +shots and enemy-to-coin hits. Both stream controls use `game.command`, a host +extension to the BizHawk-style API. Sprite locks and player transitions suspend +emission; capacity can limit extreme rates or long-lived projectiles. + +Each extra projectile executes the stock USA ROM's fireball routine on a +private CPU/WRAM copy. Enemy, score and sound effects are copied back; native +OAM, CPU clocks and the two live player-fireball slots are untouched. The host +draws the resulting tiles using the game's current VRAM and palette. This spike +uses native 256-pixel camera culling and draws above the finished frame; it does +not reproduce SNES foreground priority, windows or color math for these extra +shots. Stream state is not serialized in savestates. + Powerups use 0=small, 1=big, 2=cape, 3=fire. Teleport coordinates are level pixels; choose valid terrain. Spawning accepts stock sprite IDs 0..0xC8, but sprite graphics and behavior still depend on the level's loaded graphics and @@ -63,8 +78,8 @@ suspend autofire. The earlier ten-fireball version was incorrect: the stock player-fireball renderer maps slots 0..7 to unaligned OAM offsets, producing garbled objects. Having ten extended-sprite simulation entries does not mean all ten can draw -player fireballs. Additional simultaneous fireballs would require deliberate -renderer/OAM allocation changes. Ordinary spawned enemies now use slots 0..9, +player fireballs. The new stream uses the separate host pool and rendering +described above. Ordinary spawned enemies now use slots 0..9, which the stock fireball collision loop checks, rather than special slots 10/11. Both restrictions have regression coverage. @@ -89,7 +104,7 @@ stock USA `smw.sfc`. Generated game code is untracked. Native Windows tools must be invoked explicitly on this machine because PATH contains MSYS shims. ```powershell -$env:SNESRECOMP_ROOT='../_wt-bizhawk-lua-snesrecomp' +$env:SNESRECOMP_ROOT='snesrecomp' $env:SNESRECOMP_ANALYSIS_BACKEND='python' $env:PYTHON='C:/Users/Matthew/AppData/Local/Programs/Python/Python312/python.exe' & C:\msys64\usr\bin\bash.exe tools/regen.sh --stock --no-tests @@ -99,7 +114,6 @@ $env:PYTHON='C:/Users/Matthew/AppData/Local/Programs/Python/Python312/python.exe -DCMAKE_CXX_COMPILER=C:/msys64/mingw64/bin/g++.exe ` -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=C:/msys64/mingw64 ` -DPython3_EXECUTABLE=C:/Users/Matthew/AppData/Local/Programs/Python/Python312/python.exe ` - -DSNESRECOMP_ROOT=F:/Projects/snesrecomp/_wt-bizhawk-lua-snesrecomp ` -DSNESRECOMP_ENABLE_LUA=ON & C:\msys64\mingw64\bin\cmake.exe --build build-lua -j 6 Copy-Item -LiteralPath config.ini -Destination build-lua/config.ini @@ -116,7 +130,7 @@ explicitly rejected for this spike. ```powershell $env:PATH='C:\msys64\mingw64\bin;' + $env:PATH & $python tools/lua/validate.py ` - --engine ../_wt-bizhawk-lua-snesrecomp ` + --engine snesrecomp ` --exe build-lua/SuperMarioWorldSNESRecomp.exe --rom smw.sfc ``` @@ -143,6 +157,11 @@ Validated September 11, 2026 with the stock 524288-byte USA ROM: - Over 96 frames, interval 48 produced 2 shots; interval 4 produced 4 shots plus 20 attempts skipped because the two-slot pool was occupied. The earlier 16-shot measurement used the invalid OAM slots and is not a valid result. +- The host stream emitted exactly 100 shots in 60 frames, reached 99 active + projectiles, hit an enemy and dropped zero shots. No live extended slots were + written. Disabling emission let every projectile drain. +- Holding Y and X separately each emitted 100 shots in 60 frames. Release and + disabling hold mode stopped new shots. Live normal-speed play was enabled. The validation is a spike demonstration, not a full-game or full-BizHawk compatibility certification. diff --git a/tools/lua/smw.lua b/tools/lua/smw.lua index 5e2854e..fc2603f 100644 --- a/tools/lua/smw.lua +++ b/tools/lua/smw.lua @@ -5,7 +5,9 @@ local r, w = mainmemory.read_u8, mainmemory.write_u8 local r16, w16 = mainmemory.read_u16_le, mainmemory.write_u16_le event.unregisterbyname("smw.autofire") +event.unregisterbyname("smw.holdfire") event.unregisterbyname("smw.invincible") +if game and game.command then game.command("fire_stream", "0") end smw = {shots = 0, missed_shots = 0, fire_interval = 0} local function integer(value, lo, hi, label) @@ -93,6 +95,8 @@ end function smw.autofire(interval) integer(interval, 0, 600, "interval in frames (0 disables)") event.unregisterbyname("smw.autofire") + event.unregisterbyname("smw.holdfire") + if game and game.command then game.command("fire_stream", "0") end smw.fire_interval = interval if interval == 0 then return end local next_frame = emu.framecount() @@ -117,5 +121,32 @@ end function smw.stop() smw.autofire(0) event.unregisterbyname("smw.invincible") + if game and game.command then game.command("fire_stream_reset") end +end +function smw.fire_stream(rate) + in_level() + integer(rate,0,1000,"fireballs per second") + smw.autofire(0) + return game.command("fire_stream",tostring(rate)) +end +function smw.stream_status() + return game.command("fire_stream_status") +end +function smw.holdfire(rate) + in_level() + integer(rate,0,1000,"fireballs per second (0 disables)") + smw.autofire(0) + if rate == 0 then return end + local previous = 0 + event.onframestart(function() + local buttons = joypad.get(1) + local firing = (buttons.Y or buttons.X) and r(0x100) == 0x14 + and r(0x109) == 0 and r(0x71) == 0 + local requested = firing and rate or 0 + if requested ~= previous then + game.command("fire_stream",tostring(requested)) + previous = requested + end + end,"smw.holdfire") end return "SMW helpers loaded" diff --git a/tools/lua/validate.py b/tools/lua/validate.py index accbc4a..3d8bbc8 100644 --- a/tools/lua/validate.py +++ b/tools/lua/validate.py @@ -3,6 +3,7 @@ import argparse import json import os +import re from pathlib import Path import socket import subprocess @@ -158,6 +159,56 @@ def wait_script(timeout=60): checked("stock fireball collision reaches the allocated enemy slot") checked("changing fire cadence changes real projectile creation", rates) client.eval("smw.stop()") + # Host pool runs the native projectile routine without borrowing live + # OAM or extended slots. 100/s must mean 100 actual creations/60 ticks. + client.eval("for s=0,9 do mainmemory.write_u8(0x170b+s,0) end; smw.teleport(32,352); mainmemory.write_u8(0x76,1); smw.spawn(0x04,144,320); smw.fire_stream(100)") + stream_start = client.command("status")["frame"] + client.step(60) + stream = values("return smw.stream_status()")[0] + metrics = {key:int(value) for key,value in re.findall(r"(\w+)=(\d+)",stream)} + assert stream.endswith("error="), stream + assert metrics["spawned"] == 100 and metrics["dropped"] == 0, stream + assert metrics["peak"] > 2 and metrics["hits"] > 0, stream + assert values("local untouched=true; for s=0,9 do untouched=untouched and mainmemory.read_u8(0x170b+s)==0 end; return untouched") == [True] + assert client.command("status")["frame"] == stream_start+60 + checked("100 fireballs/second with native collisions and no live extended-slot writes", metrics) + client.eval("smw.fire_stream(0)") + client.step(150) + drained = values("return smw.stream_status()")[0] + drained_metrics = {key:int(value) for key,value in re.findall(r"(\w+)=(\d+)",drained)} + assert drained_metrics["spawned"] == 100 and drained_metrics["active"] == 0, drained + checked("stopping the stream drains existing projectiles", drained_metrics) + client.eval("smw.stop(); held_button=''; event.onframestart(function() joypad.set({Y=held_button=='Y',X=held_button=='X'},1) end,'test.hold'); smw.holdfire(100)") + client.step(30) + assert "spawned=0 " in values("return smw.stream_status()")[0] + held_counts = [] + for button, expected in (("Y",100),("X",200)): + client.eval(f"held_button='{button}'") + client.step(60) + held = values("return smw.stream_status()")[0] + assert f"spawned={expected} " in held and "dropped=0 " in held, held + client.eval("held_button=''") + client.step(30) + released = values("return smw.stream_status()")[0] + assert f"spawned={expected} " in released and "rate=0 " in released, released + held_counts.append(held) + client.eval("smw.holdfire(0); held_button='Y'") + client.step(30) + assert "spawned=200 " in values("return smw.stream_status()")[0] + checked("holding either fire button emits 100/s; release and disable stop emission", held_counts) + client.eval("smw.stop(); held_button=''") + example = exe.parent / "lua/100_fireballs.lua" + client.eval(example.read_text()) + client.step(30) + assert "spawned=0 " in values("return smw.stream_status()")[0] + client.eval("held_button='Y'") + client.step(60) + example_metrics = values("return smw.stream_status()")[0] + assert "spawned=100 " in example_metrics and "dropped=0 " in example_metrics, example_metrics + client.eval("held_button=''") + client.step(30) + assert "rate=0 spawned=100 " in values("return smw.stream_status()")[0] + checked("bundled standalone 100_fireballs.lua starts/stops on held input", example_metrics) client.command("reset") assert values("return smw,counter,joypad.get(1).Y") == [None,None,False] client.step(2) diff --git a/tools/make_release.ps1 b/tools/make_release.ps1 index 4a4f63e..83fd308 100644 --- a/tools/make_release.ps1 +++ b/tools/make_release.ps1 @@ -96,6 +96,17 @@ if ($Variant -eq 'coop') { } Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $stage Copy-Item -LiteralPath $assets -Destination $stage -Recurse +$luaEnabled = Select-String -LiteralPath (Join-Path $build 'CMakeCache.txt') ` + -Pattern '^SNESRECOMP_ENABLE_LUA:BOOL=ON$' -Quiet +if ($luaEnabled) { + $luaPayload = Join-Path $build 'lua' + foreach ($name in @('100_fireballs.lua', 'README.md', 'LICENSE-Lua.txt', 'lua_tcp.py', 'smw.lua')) { + if (-not (Test-Path -LiteralPath (Join-Path $luaPayload $name))) { + throw "Lua-enabled release is missing lua/$name" + } + } + Copy-Item -LiteralPath $luaPayload -Destination $stage -Recurse +} # Release-owned mod catalog, when the build stages one. Ships as a nested # directory tree, which is exactly what made portable ZIP entry names matter # (see the archive writer below). From 28f55202edcb4fc7bc987fdeabc0a3dc4c211145 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 11 Sep 2026 00:49:55 -0700 Subject: [PATCH 4/6] Verify AppImage Lua examples preserve user edits --- tools/test_appimage_layout.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tools/test_appimage_layout.sh b/tools/test_appimage_layout.sh index 07f0789..1f95f57 100644 --- a/tools/test_appimage_layout.sh +++ b/tools/test_appimage_layout.sh @@ -63,6 +63,12 @@ if [ -n "$mod_manifest" ]; then test -f "$state1/mods/$mod_manifest" || { echo "FAIL: release mod catalog not seeded beside the AppImage" >&2; exit 1; } fi +if [ -f "$appdir/usr/bin/lua/100_fireballs.lua" ]; then + cmp "$appdir/usr/bin/lua/100_fireballs.lua" "$state1/lua/100_fireballs.lua" || { + echo "FAIL: Lua example not seeded beside the AppImage" >&2; exit 1; } + printf '\n-- user-owned Lua marker\n' >> "$state1/lua/100_fireballs.lua" + lua_before=$(cat "$state1/lua/100_fireballs.lua") +fi # 2. User state survives a relaunch: an edited config line and a # user-installed third-party mod package. @@ -71,6 +77,10 @@ cfg_before=$(cat "$state1/config.ini") mkdir -p "$state1/mods/packages/user.thirdparty.example/1.0.0" printf 'user-owned\n' > "$state1/mods/packages/user.thirdparty.example/1.0.0/manifest.toml" run_apprun "$state1/SuperMarioWorld.AppImage" +if [ -n "${lua_before:-}" ]; then + test "$(cat "$state1/lua/100_fireballs.lua")" = "$lua_before" || { + echo "FAIL: user Lua edits clobbered by relaunch" >&2; exit 1; } +fi test "$(cat "$state1/config.ini")" = "$cfg_before" || { echo "FAIL: user config.ini edit clobbered by relaunch" >&2; exit 1; } test "$(cat "$state1/mods/packages/user.thirdparty.example/1.0.0/manifest.toml")" = "user-owned" || { From 0d898b583b15017593461d235347081404985f6c Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 11 Sep 2026 00:51:31 -0700 Subject: [PATCH 5/6] Pin versioned AppImage tools after continuous asset drift --- tools/build-linux.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tools/build-linux.sh b/tools/build-linux.sh index 27cb231..a9c4bee 100644 --- a/tools/build-linux.sh +++ b/tools/build-linux.sh @@ -75,11 +75,12 @@ PROD_CMAKE_FLAGS=( -DSNESRECOMP_ENABLE_TRACE=OFF ) DEBUG_CMAKE_FLAGS=( -DSNESRECOMP_ENABLE_TRACE=ON ) # ============================================================================ -# Pinned AppImage tooling (same pins as the Mega Man X / Tomba Linux releases). -LINUXDEPLOY_URL=https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage -LINUXDEPLOY_SHA=421ca71d5c69ea97c6309276232990d43df1dcece0edfaa26bbf926ff96ed12e -APPIMAGETOOL_URL=https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -APPIMAGETOOL_SHA=a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0 +# Versioned assets and digests from the upstream GitHub release metadata. +# Floating continuous URLs can replace their payload and invalidate a pin. +LINUXDEPLOY_URL=https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20251107-1/linuxdeploy-x86_64.AppImage +LINUXDEPLOY_SHA=c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d +APPIMAGETOOL_URL=https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage +APPIMAGETOOL_SHA=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 CONFIG="prod" VARIANT="stock" From 544284b65c47cdc585323634751890ed039c3b94 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 11 Sep 2026 00:53:43 -0700 Subject: [PATCH 6/6] Make first-run AppImage Lua examples writable --- tools/build-linux.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/build-linux.sh b/tools/build-linux.sh index a9c4bee..9fbe032 100644 --- a/tools/build-linux.sh +++ b/tools/build-linux.sh @@ -327,7 +327,10 @@ if [ -d "\$HERE/usr/bin/lua" ] && [ -w "\$ROMDIR" ]; then mkdir -p "\$ROMDIR/lua" for example in "\$HERE/usr/bin/lua/"*; do target="\$ROMDIR/lua/\$(basename "\$example")" - [ -e "\$target" ] || cp "\$example" "\$target" + if [ ! -e "\$target" ]; then + cp "\$example" "\$target" + chmod u+rw "\$target" + fi done fi # Seed/refresh the release-owned mod catalog beside the .AppImage. Directory