内容
# mesen-lua-bridge-mcp
MCP 服务器允许 LLM 在运行的 Mesen 2 模拟器中执行任意 Lua 代码。LLM 可以阅读 Mesen 2 Lua 文档并编写任意 Lua 代码,无需将 API 编码到 MCP 命令中。这使得架构和版本兼容性更加简单,也为 LLM 提供了更多的自由来提出有趣的解决方案。
应该适用于 Mesen 2 支持的任何系统;已在 SNES 上测试。
每次调用都带有简短的描述,该描述记录到 Mesen 的脚本窗口中,因此您可以跟踪,审计已运行的内容,并从过程中学习 — 这使其非常适合使用 LLM 进行逆向工程,并由 LLM 讲述每一步。稍后,您可以询问 LLM 为什么它执行了某些步骤。
演示如下(轻微脚本化以保持简短,但基于真实的调试会话)
https://github.com/user-attachments/assets/3379bf00-03aa-425e-a089-717eec883cae
我使用此 MCP + Claude Code 进行逆向工程,并编写了此 [Inindo Way of the Ninja Patcher Web Tool](https://github.com/gareve/inindo-way-of-the-ninja-snes-patcher)
## 前置条件
- macOS, Linux 或 Windows(bridge 目录从 `TMPDIR` / `TMP` / `TEMP` 解析,默认为 `/tmp`)
- Node.js ≥ 18(测试版本为 22)
- [Mesen 2](https://www.mesen.ca/)
- 任意 Mesen 支持的 ROM
## 一次性 Mesen 设置
在 Mesen 的 UI 中:
1. 启动 Mesen,加载任意 ROM。
2. **工具 → 脚本窗口**。
3. 在脚本窗口中,打开其 **设置** (齿轮图标或菜单),并启用 **"允许访问 I/O 和 OS 功能"** 。不需要启用 **"允许网络访问"** 。
4. 在同一设置对话框中,将 **"脚本超时"** 从默认值 1 秒提高到 **10 秒** 。1 秒的默认值将中止合法的 RE 代码段,例如内存扫描。
5. 保持 **"在加载时自动启动脚本"** , **"文件更改时自动重新加载脚本"** 和 **"电源循环后自动重启脚本"** 为默认值(全部开启)。这些为您提供干净的重启 UX。
https://github.com/user-attachments/assets/3d1a4c9d-0ab6-46a8-b2ba-331ba4b2c1ea
## 安装服务器
```bash
git clone https://github.com/gareve/mesen-lua-bridge-mcp.git
cd mesen-lua-bridge-mcp
npm install
```
## 在 Mesen 中加载桥
在 Mesen 的脚本窗口中: **文件 → 打开** → 选择此仓库中的 `lua/mesen_bridge.lua` → 点击 **运行**。
您应该在脚本控制台中看到一行:
```
[mesen-mcp] bridge registered; waiting for an MCP server to claim <tmp>/mesen-mcp/active
```
(或 `[mesen-mcp] bound to session ...` 如果 MCP 服务器已经运行)。
## 连接到 Claude Code(或其他 MCP 兼容 LLM)
此仓库中提交了一个项目范围的 `.mcp.json`,因此当您从项目目录运行 `claude` 时,Claude Code 将自动拾取它。确认如下:
```bash
claude mcp list
```
如果您想在用户范围内连接(从任意目录可用),请在克隆的仓库中运行:
```bash
claude mcp add mesen --cwd "$(pwd)" -- npx tsx src/server.ts
```
**其他 MCP 客户端** (ChatGPT,Cursor,Cline,Zed,Continue 等) — 这是一个标准的 stdio MCP 服务器。配置您的客户端运行 `npx tsx src/server.ts`,并将克隆的仓库作为其工作目录。
## LLM 烟雾测试
在您的 MCP 客户端(例如 Claude)中:
> 使用 mesen 工具运行 `return 1 + 1`。
预期结果为 `2`。然后尝试一个真正的示例:
> 使用 mesen 工具运行 `return emu.read(0x7E0000)`。
预期结果为 0-255 之间的数字(RAM 字节)。
## LLM 可靠性检查清单(手动端到端验证)
按照以下步骤确认无重启故事成立。每个步骤都假设上述烟雾测试已经成功。
| # | 测试 | 预期结果 |
|---|---|---|
| 1 | `execute_lua("return 1+1")` 冷启动 | `2` |
| 2 | 杀死 MCP 服务器(找到其 PID,`kill <pid>`),再次调用 | 无需触摸 Mesen,MCP 客户端重新生成服务器 |
| 3 | 退出 Mesen,重新启动,重新加载 ROM,再次调用 | 成功(脚本自动启动,纪元增量) |
| 4 | 在 Mesen 的脚本窗口中:停止,然后运行桥脚本 | 成功 |
| 5 | 在 Mesen 中交换 ROM,再次调用 | 成功(桥自动重启) |
| 6 | 退出并重启 MCP 客户端 | 成功(新的 MCP PID,旧会话目录已回收) |
| 7 | 连续发出 50 个快速调用 | 全部按顺序返回 |
| 8 | `execute_lua("for i=1,1e9 do end")` | Mesen 在 `脚本超时` 后中止脚本;MCP 返回超时诊断 |
| 9 | 运行过程中删除 `/tmp/mesen-mcp/<pid>/` | 在 ~1 秒内恢复 |
| 10 | 切换 **允许访问 I/O 和 OS 功能** 关闭,重新加载脚本,再次调用 | MCP 超时,并带有 **检查 AllowIoOsAccess** 提示 |
| 11 | 退出 ROM(保持 Mesen 打开),调用 | MCP 超时;加载 ROM → 下一次调用成功 |
| 12 | `execute_lua("return emu.read(0x7E0000)")` | 数值结果 |
## 故障排除
**"timeout: no response from Mesen Lua bridge"** — 诊断消息列出了常见原因。大多数情况下是:
- **允许访问 I/O 和 OS 功能** 关闭 → 桥无法写入文件。
- 桥脚本未加载(或已加载但停止)。
- 没有运行 ROM → 没有 `endFrame` 回调 → 没有轮询。
- 代码段超过 Mesen 的 **脚本超时** → Mesen中止脚本。
**"Error from Mesen Lua: ... attempt to call a nil value"** — 您的代码段引用了加载的系统上不存在的 `emu.*` 函数。检查 [API 参考](https://www.mesen.ca/docs/apireference.html) — 某些函数是特定于系统的。
**超时后结果过时** — 通过 wire 格式中的 SEQ 标签修复;如果您遇到此问题,请报告。
**您的 MCP 客户端看不到工具** — 对于 Claude Code,`claude mcp list` 应该显示 `mesen`;检查 `.mcp.json` 是否正在加载(从项目目录使用 `claude`)或运行上述 `claude mcp add` 命令。对于其他客户端,请验证 stdio 服务器是否在克隆的仓库作为其工作目录的情况下启动。
## 错误可见性
每个 `execute_lua` 响应 — 成功或失败 — 都包含一个尾部:
```
---
bridge_errors: 3
```
`bridge_errors` 是自桥脚本上次加载以来累积的 Lua 错误计数。它在每次桥重新加载时重置为 0。当 LLM 看到它增加时,它知道出了问题,即使最近的调用成功了。
错误也会:
- 打印到 Mesen 的脚本窗口控制台(仅第一行,无堆栈跟踪): `[mesen-mcp] error #3: ...`
- 写入 `/tmp/mesen-mcp/mesen_errors.log`,带有完整的堆栈跟踪和序列号,在每次桥重新加载时清除。
## 回调跟踪
`emu.addEventCallback` 和 `emu.addMemoryCallback` 由桥封装。通过 `execute_lua` 注册的每个回调都存储在全局 `_mcpCallbacks` 表中。MCP 服务器公开了一个 `clear_callbacks` 工具,可以按标签或一次性删除它们。
**始终在注册回调时传递标签:**
```lua
-- addEventCallback 的第三个参数是标签
emu.addEventCallback(fn, emu.eventType.endFrame, "frame counter")
-- addMemoryCallback 的第六个参数是标签
emu.addMemoryCallback(fn, emu.callbackType.write, 0x7E0010, 0x7E0010, emu.memType.snesMemory, "hp watcher")
```
**通过 `clear_callbacks` 工具清除:**
```
clear_callbacks({ label: "frame counter" }) -- 仅删除具有该标签的回调
clear_callbacks({}) -- 删除所有跟踪的回调
```
如果您在会话开始时调用 `clear_callbacks({})`,则可以避免来自以前的 MCP 运行的过时回调在 Mesen 中累积。
桥的内部 tick 回调永远不会被跟踪,无法被清除。
## 已知限制(PoC 范围)
- **每个会话的单个请求**。并发 `execute_lua` 调用通过互斥锁序列化。对于交互式 RE 工作来说已经足够;如果您想要流水线批量操作,请重新审视。
- **`tostring()`-based 结果渲染**。表返回为 `table: 0x...`。当出现第一个嵌套结果用例时,在 Lua 中添加一个小型 JSON 编码器。
- **无流式输出**。代码段内部的 `print()` 丢失(它仅进入 Mesen 的脚本控制台)。如果您想要代码段的标准输出,请在会话目录中附加到日志文件,并让服务器收集它。
## 为什么使用基于文件的传输,而不是套接字
套接字是可能的,但不可靠:Mesen 的 Lua `accept()` 每个脚本生命周期只接受一个客户端,然后需要重新加载脚本 — 这与 MCP 客户端启动和重启服务器的方式冲突。文件更简单、更可靠。
额外优点:
- **可靠性**。在 MCP 服务器、Mesen、桥脚本、MCP 客户端和 ROM 交换重启后仍然存活 — 任意顺序。
- **带宽**。没有套接字缓冲区或碎片限制。多兆字节的响应(内存转储、截图)无需分段或调整 — 仅受 tmpfs/磁盘限制。
- **不需要网络访问权限**。只需要启用 **允许访问 I/O 和 OS 功能** ; **允许网络访问** 保持关闭。
- **可使用 `cat` 检查**。请求中的文件只是文件。