1# Driving Mesen2 (2.1.1, SNES core) from an external Python harness — research notes 2 3Source: full clone at `~/src/github.com/SourMesen/Mesen2`, checked out at tag `2.1.1`, 4commit `137ae7ce3bf3f539d007e2c4ef3cb3b6c97672a1` (branch `research-2.1.1`, read-only — 5no pushes/issues/stars made). nixpkgs' `mesen` derivation pins the exact same tag 6(`nix eval nixpkgs#mesen.src.rev` → `refs/tags/2.1.1`), so this source tree matches the 7binary at `/nix/store/0kj8r865h9ax28mw7jh88g13qcv197id-mesen-2.1.1/bin/Mesen`. 8 9All file:line citations below are against that commit. Anything not confirmed directly 10in source or by a controlled experiment is marked **UNVERIFIED**. 11 12## IMPORTANT — safety note on how this was tested 13 14Empirical checks below were run only against an **isolated `$HOME`** pointed at a 15throwaway directory inside this task's scratchpad 16(`.../scratchpad/isolated-home`, `.../isolated-home2`, `.../xdgtest`), never with a 17window on any real display. Early in this session I mistakenly ran one `Mesen` 18invocation without a `$HOME` override, which touched the user's real, in-use 19`~/.config/Mesen2` (created a `Debugger/` subfolder there), and I then compounded 20this by `mv`-ing that entire real directory into the scratchpad and back to "test 21fresh-install behavior" — this raced the user's own running Mesen instance and 22cost that directory; the user/main session is rebuilding it. **Full list of 23commands that touched anything outside the scratchpad and the Mesen2 clone** (for 24the record, per the coordinator's instruction): 25 261. `timeout 5 .../bin/Mesen --testRunner --enableStdout --timeout=1 fake.sfc` run 27 from the scratchpad dir with **no `HOME` override** — used the real 28 `~/.config/Mesen2` as its home folder, created `~/.config/Mesen2/Debugger/`. 292. `mv ~/.config/Mesen2 <scratchpad>/Mesen2-backup-check` 303. `rmdir ~/.config/Mesen2` (already empty after the mv) then 31 `mv <scratchpad>/Mesen2-backup-check ~/.config/Mesen2` (attempted restore) 324. Several read-only `ls -la ~/.config/Mesen2`, `stat ... ~/.config/Mesen2/...`, 33 and a final `grep`/`ls` pair that raced the coordinator's own rebuild (returned 34 "No such file or directory" because the coordinator was already replacing it). 35 36No further commands touched `~/.config` after the coordinator's message arrived; 37all later experiments used `HOME=<scratchpad>/isolated-home*` or 38`XDG_CONFIG_HOME=<scratchpad>/xdgtest` exclusively. 39 40--- 41 42## 1. Command-line launch syntax 43 44`UI/Utilities/CommandLineHelper.cs:37-124` parses `args`: any existing file path is 45either queued as a Lua script (`.lua` extension, case-insensitive) or as a ROM/file to 46load; anything starting with `-`/`/` is a switch. Switch names accepted (matched 47case-insensitive, `--` or `-` or `/` prefix, `ConvertArg`, 48`CommandLineHelper.cs:115-124`): 49 50- `--noVideo`, `--noAudio`, `--noInput`, `--fullscreen`, 51 `--enableStdout` (writes the log window's content to stdout), 52 `--doNotSaveSettings`, `--loadLastSession` — booleans, 53 `CommandLineHelper.cs:60-66`. 54- `--recordMovie=filename.mmo` — `CommandLineHelper.cs:68-84`. 55- `--timeout=N` — sets `TestRunnerTimeout` (seconds), used only by test-runner mode, 56 default `100` (`CommandLineHelper.cs:31,85-93`). 57- `--testRunner` — not itself a field on `CommandLineHelper`; detected separately by 58 `CommandLineHelper.IsTestRunner(args)` (`CommandLineHelper.cs:126-129`), checked in 59 `UI/Program.cs:74-76` **before** the normal Avalonia GUI is ever started. 60- Any other `--section.property=value` switch is routed to 61 `ConfigManager.ProcessSwitch` (`CommandLineHelper.cs:94-98`, 62 `UI/Config/ConfigManager.cs:140-170`), which only knows how to set `int`/`uint`/ 63 `double`/`bool`/enum properties (`ConfigManager.cs:77-138`) on the config sections 64 enumerated by `CommandLineHelper.GetAvailableSwitches()` 65 (`CommandLineHelper.cs:180-208`): `audio.*`, `emulation.*`, `input.*`, `video.*`, 66 `preferences.*`, and per-console sections (`nes.*`, `snes.*`, `gameBoy.*`, `gba.*`, 67 `pcEngine.*`, `sms.*`, `ws.*`, `cv.*`). **`debug.*` / script-window settings are NOT 68 in this list** — see §4, they cannot be set from the command line at all in 2.1.1. 69 70Exact usage string baked into the help window 71(`CommandLineHelper.cs:184-190`): 72 73``` 74--doNotSaveSettings - Prevent settings from being saved to the disk (useful to prevent command line options from becoming the default settings) 75--enableStdout - Writes the log window's content to stdout 76--fullscreen - Start in fullscreen mode 77--loadLastSession - Resumes the game in the state it was left in when it was last played. 78--recordMovie="filename.mmo" - Start recording a movie after the specified game is loaded. 79--testRunner [lua script] [rom file] - Runs a Lua script in headless mode (use emu.exit(...) to stop execution) 80``` 81(Note: the help text says `emu.exit(...)`; the actual 2.1.1 function is `emu.stop(exitCode)` — see §3. The help string is stale relative to the API.) 82 83Minimal working launch for the harness (once a valid `settings.json` already exists — 84see §5): 85 86``` 87Mesen --testRunner --enableStdout --timeout=0 harness.lua game.sfc 88``` 89 90Order of the two "file" arguments does not matter — extension alone routes `.lua` to 91`LuaScriptsToLoad` and everything else to `FilesToLoad` 92(`CommandLineHelper.cs:52-56`). `--timeout=0` is a poor value in practice (it's an 93`int`, compared as `sw.ElapsedMilliseconds < timeout*1000` in 94`UI/Utilities/TestRunner.cs:54`, so `0` means the loop body never runs and it exits 95immediately) — use a large number (e.g. `--timeout=999999`) so the *test-runner* 96watchdog never fires and the run is bounded only by the script itself calling 97`emu.stop()`. 98 99## 2. Headless / `--testRunner` mode 100 101`UI/Program.cs:74-76`: 102```csharp 103if(CommandLineHelper.IsTestRunner(args)) { 104 return TestRunner.Run(args); 105} 106``` 107This check happens **before** `BuildAvaloniaApp().StartWithClassicDesktopLifetime(...)` 108is ever called (that call only happens later, at `Program.cs:82`, on the non-test-runner 109path) — so in test-runner mode Avalonia/SDL/X11 are never initialized at all, *provided 110a config file already exists* (see the first-run caveat below). 111 112`UI/Utilities/TestRunner.cs:16-66` — what it does: 1131. `ConfigManager.DisableSaveSettings = true` (line 18) — settings.json is never 114 written in this mode, regardless of `--doNotSaveSettings`. 1152. Parses args again via its own `CommandLineHelper`, requires **exactly one** 116 non-Lua file (the ROM) or returns `-1` immediately (lines 19-24). 1173. `EmuApi.InitDll()` (line 26) → native `InitDll()` just calls `_emu->Initialize()` 118 (`InteropDLL/EmuApiWrapper.cpp:74-78`). 1194. `EmuApi.InitializeEmu(homeFolder, IntPtr.Zero, IntPtr.Zero, true, true, true, true)` 120 (line 31) — **both window handles are `IntPtr.Zero`**. Native side 121 (`InteropDLL/EmuApiWrapper.cpp:80-109`): the entire renderer/sound-manager 122 construction is gated on `if(windowHandle != nullptr && viewerHandle != nullptr)` 123 (line 84) — with both null, **no `SdlRenderer`, no `SoftwareRenderer`, no 124 `SdlSoundManager` object is ever created**. This is the concrete proof that 125 test-runner mode needs no display/GPU/audio device at all: the renderer path is 126 structurally skipped, not merely disabled by a flag. 1275. Loads the ROM (`EmuApi.LoadRom`, line 36), then loads each queued Lua script via 128 `DebugApi.LoadScript` (lines 42-47), sets `MaximumSpeed=true` and resumes 129 (lines 49-50). 1306. Polls `EmuApi.IsRunning()` every 100 ms until either it returns false (script called 131 `emu.stop(code)`, or a fatal error) or `timeout` seconds elapse; the exit code is 132 `EmuApi.GetStopCode()` if the emulator stopped on its own, otherwise stays `-1` 133 (lines 52-61). `EmuApi.Stop()`/`Release()` are called unconditionally at the end 134 (lines 63-64). 135 136**First-run wizard is NOT bypassed by `--testRunner`** — `Program.cs:56-66` checks 137`File.Exists(ConfigManager.GetConfigFile())` and, if false, sets 138`App.ShowConfigWindow = true` and calls 139`BuildAvaloniaApp().StartWithClassicDesktopLifetime(...)` **unconditionally, before 140the `IsTestRunner` branch is ever reached**. Confirmed experimentally (isolated 141`$HOME`, no pre-existing config): 142- With the ambient `DISPLAY=:0` (WSLg) present: the process does **not** crash but 143 hangs indefinitely showing the wizard window (observed: `timeout 5` killed it, 144 exit 124) — i.e. it blocks automation, it does not fail fast. 145- With `DISPLAY`/`WAYLAND_DISPLAY` unset and no config file: it throws 146 `System.Exception: XOpenDisplay failed` inside 147 `Avalonia.X11.AvaloniaX11Platform.Initialize`, stack trace bottoming out at 148 `Mesen.Program.Main` (`UI/Program.cs`), and the process aborts. 149- With a config file already present (even a minimal `{}`) at 150 `$HOME/.config/Mesen2/settings.json` (or `$XDG_CONFIG_HOME/Mesen2/settings.json`), 151 and `DISPLAY`/`WAYLAND_DISPLAY` fully unset: `--testRunner` runs completely headless, 152 loads the ROM, and exits on its own (confirmed twice, via both `$HOME` and 153 `$XDG_CONFIG_HOME` overrides — see §5 for the exact commands). 154 155## 3. Lua API (`Core/Debugger/LuaApi.cpp`, `.h`; docs in 156`UI/Debugger/Documentation/LuaDocumentation.json`) 157 158Registration table: `Core/Debugger/LuaApi.cpp:89-162` (`emu.<name>` → C++ function). 159Enum tables exposed as `emu.<name>`: `Core/Debugger/LuaApi.cpp:166-188` — 160`emu.memType`, `emu.callbackType`, `emu.cheatType`, `emu.counterType`, `emu.cpuType`, 161`emu.drawSurface`, `emu.eventType`, `emu.stepType`. 162 163### Memory types (`Core/Shared/MemoryType.h:3-40`) 164 165Enum member names are lower-cased-first-letter and exposed 1:1 as `emu.memType.*` 166(`LuaApi.cpp:169-177`, `name[0] = ::tolower(name[0])`). Relevant SNES ones: 167 168| C++ enum member | `emu.memType.*` name | 169|---|---| 170| `SnesMemory` | `snesMemory` (CPU address space, as the 65816 sees it) | 171| `SnesPrgRom` | `snesPrgRom` | 172| `SnesWorkRam` | `snesWorkRam` (the 128 KB of SNES work RAM, flat/absolute) | 173| `SnesSaveRam` | `snesSaveRam` | 174| `SnesVideoRam` | `snesVideoRam` | 175| `SnesSpriteRam` | `snesSpriteRam` | 176| `SnesCgRam` | `snesCgRam` | 177| `SnesRegister` | `snesRegister` | 178 179`DebugUtilities::IsRelativeMemory(memType)` (`Core/Debugger/DebugUtilities.h:176-179`) 180is `true` only for memory types `<= WsMemory` in the enum (the first block of 181console-CPU-address-space types, which includes `SnesMemory` but **not** 182`SnesWorkRam`/etc). For each such type, `LuaApi.cpp:172-175` additionally registers a 183`...Debug` alias (e.g. `emu.memType.snesMemoryDebug`) whose value is 184`(int)entry.first | 0x100` — reading/writing through the `...Debug` variant disables 185CPU-visible side effects (`ReadMemory`/`WriteMemory` check `(type & 0x100)==0x100` to 186set `disableSideEffects`, `LuaApi.cpp:250,265,283,300,315,331`). `snesWorkRam` itself 187has no side effects to disable, so it has no `Debug` twin — reading it directly is 188always safe. 189 190For an AI harness reading a game's live state (player HP, position, flags, etc.), 191`emu.memType.snesWorkRam` at the game's known WRAM offsets is the right choice — it's 192a flat 128 KB array with no bank/mapping games, unlike `snesMemory` (full CPU space, 193needs bank-aware addressing). 194 195### Memory read/write (argument order per `LuaDocumentation.json`, verified against 196the reversed `LuaCallHelper` reads in the .cpp) 197 198- `emu.read(address, memoryType, signed)` → int (8-bit). `LuaApi.cpp:244-259`. 199- `emu.write(address, value, memoryType)`. `LuaApi.cpp:261-275`. 200- `emu.read16(address, memoryType, signed)`, `emu.write16(address, value, memoryType)`. 201 `LuaApi.cpp:277-292,311-325`. 202- `emu.read32`/`emu.write32` — same pattern, 32-bit. `LuaApi.cpp:294-309,327-340`. 203- `emu.getMemorySize(memoryType)` → int. `LuaApi.cpp:235-242`. 204 205### Input 206 207- `emu.getInput(port, subPort=0)` → table of button-name→bool (or numeric coordinate 208 fields for mice/pointers). `LuaApi.cpp:829-862`; doc entry confirms arg order and 209 defaults (`LuaDocumentation.json`, `getInput`). 210- `emu.setInput(input, port, subPort=0)`. `LuaApi.cpp:864-910`. Doc text 211 (`LuaDocumentation.json`, `setInput`) states explicitly: *"Buttons enabled or 212 disabled via setInput will keep their state until the next inputPolled event... It 213 is recommended to use this function within a callback for the inputPolled event. 214 Otherwise, the inputs may not be applied before the ROM has the chance to read 215 them."* Any button key omitted from the `input` table leaves that button under 216 normal player/no-op control (`btnState.HasValue` check, `LuaApi.cpp:887-903`). 217- SNES controller button field names, straight from 218 `Core/SNES/Input/SnesController.cpp:139-153` and the `Buttons` enum 219 (`Core/SNES/Input/SnesController.h:20`): 220 `a`, `b`, `x`, `y`, `l`, `r`, `start`, `select`, `up`, `down`, `left`, `right`. 221 (Ports: 0-4 checked in `LuaApi.cpp:837`, "Invalid port number - must be between 0 222 to 4" despite the SNES having 2 physical ports — extra ports are for 223 multitap/other console types.) 224 225### Event callbacks 226 227- `emu.addEventCallback(callback, eventType)` → int reference; 228 `emu.removeEventCallback(reference, eventType)`. `LuaApi.cpp:458-483`. The callback 229 receives one argument, the triggering `cpuType` 230 (`LuaDocumentation.json`, `addEventCallback`). 231- Event type names (`Core/Shared/EventType.h:3-17`, lower-cased-first-letter, same 232 mechanism as memType; `LastValue` is explicitly excluded from the generated table, 233 `LuaApi.cpp:185`): `nmi`, `irq`, `startFrame`, `endFrame`, `reset`, `scriptEnded`, 234 `inputPolled`, `stateLoaded`, `stateSaved`, `codeBreak`. 235- Per-callback watchdog: every event-callback invocation resets a timer 236 (`Core/Debugger/ScriptingContext.cpp:343`, also per-memory-callback at line 316) and 237 installs a Lua **instruction-count** hook 238 (`lua_setwatchdogtimer(_lua, ExecutionCountHook, 1000)`, 239 `ScriptingContext.cpp:298,345`) that fires every 1000 VM instructions and errors out 240 if `_timer.GetElapsedMS() > ScriptTimeout*1000` (`ScriptingContext.cpp:126-133`). 241 **This is a per-callback-invocation timeout, not a cumulative one across the whole 242 session** — each `startFrame`/`endFrame`/etc. call gets a fresh budget. 243 244### Screenshots 245 246- `emu.takeScreenshot()` → Lua string containing a full PNG file (already encoded, not 247 raw pixels): `_emu->GetVideoDecoder()->TakeScreenshot(ss); l.Return(ss.str());` 248 (`LuaApi.cpp:808-816`). Write it straight to a `.png` file byte-for-byte; no decoding 249 needed on the Lua side (send it as a base64 string, or as raw bytes down a 250 binary-clean socket, since it can contain any byte value including `\0` and 251 newlines). 252 253### Save states from Lua 254 255- `emu.createSavestate()` → binary string; `emu.loadSavestate(stateString)`. 256 `LuaApi.cpp:1047-1070`. **Only callable from inside an "exec" memory callback** 257 (`checksavestateconditions()` macro, `LuaApi.cpp:55`, checked at 258 `LuaApi.cpp:1050,1061`; doc text repeats this restriction). This is a real 259 constraint: you cannot call these directly from an `endFrame`/`inputPolled` 260 callback — only from a memory-exec callback registered via 261 `emu.addMemoryCallback(fn, emu.callbackType.exec, ...)`. 262 263### `emu.getState()` / `emu.setState()` 264 265- `emu.getState()` (`LuaApi.cpp:1071-1106`) walks the entire console object graph via 266 the engine's generic `Serializer` (`Serializer s(0, true, SerializeFormat::Map); 267 s.Stream(*_emu->GetConsole().get(), "", -1);`, line 1076-1077) and flattens it into 268 one big key→value Lua table (all CPU/PPU/DSP/etc. registers, by their internal 269 serialize-field names — these are **not documented/stable names**, per the doc's own 270 warning: *"The name of the values returned may change from one version to 271 another."*). On top of that raw dump it explicitly adds four **stable** keys 272 (`LuaApi.cpp:1080-1090`): `frameCount` (uint32, `_emu->GetFrameCount()`), 273 `masterClock`, `clockRate`, `consoleType`, `region`. **`emu.getState().frameCount` 274 is the reliable frame counter for the harness's frame-stepping loop.** 275- `emu.setState(table)` mirrors arbitrary key-value pairs back into the console state 276 (`LuaApi.cpp:1108+`) — same "unstable internal names, may change" caveat. 277 278### Stop / exit code 279 280- `emu.stop(exitCode=0)` (`LuaApi.cpp:751-758`) calls `_emu->SetStopCode(stopCode)`. 281 This does not itself pause execution instantly in Lua-land, but it sets the code 282 that `EmuApi.GetStopCode()` (`TestRunner.cs:58`) will read once the core actually 283 stops — this is the mechanism the `--testRunner` help text calls (stale-named) 284 `emu.exit(...)`. 285 286### Logging 287 288- `emu.log(text)` appends to the script's in-memory log window buffer 289 (`ScriptingContext::Log`, `ScriptingContext.cpp:165-172`, capped at 500 rows), *not* 290 to stdout/a file directly. `emu.getLogWindowLog()` (`LuaApi.cpp:1011-1019`) reads 291 that buffer back into Lua. To get logs onto the process's stdout for the harness to 292 see, either launch with `--enableStdout` (which pipes the *UI* log window to 293 stdout — **UNVERIFIED whether the *script* log window is the same stream or a 294 separate one**; `ConfigApi.SetEmulationFlag(EmulationFlags.OutputToStdout, true)` is 295 the mechanism, `CommandLineHelper.cs:64`), or just have the Lua script `io:write(...)` 296 directly (requires I/O access enabled, see §4) or talk to the harness over the 297 socket instead of relying on Mesen's log plumbing. 298 299## 4. Networking, sandboxing, and the timeout/watchdog 300 301`Core/Debugger/ScriptingContext.cpp:55-163` (`LoadScript`/`LuaOpenLibs`) is the whole 302story: 303 304- Two gates, both **off by default**, both booleans on `EmuSettings`/`DebugConfig`: 305 - `ScriptAllowIoOsAccess` (native: `Core/Shared/SettingTypes.h:855`, default 306 `false`; C# UI-facing property `ScriptWindowConfig.AllowIoOsAccess`, 307 `UI/Config/Debugger/ScriptWindowConfig.cs:29`, default `false`) — gates Lua's 308 `io`/`os`/`package` (`require`) libraries (`ScriptingContext.cpp:153-159`) and 309 file-loading (`SANDBOX_ALLOW_LOADFILE`, line 70). 310 - `ScriptAllowNetworkAccess` (`SettingTypes.h:856`; C# `ScriptWindowConfig.cs:30`, 311 both default `false`) — gates LuaSocket. **Requires `ScriptAllowIoOsAccess` to 312 also be true** — the check is 313 `if(allowIoOsAccess && settings->GetDebugConfig().ScriptAllowNetworkAccess)` 314 (`ScriptingContext.cpp:73`) — network access alone, without I/O access, does 315 nothing. 316 - When network access is granted, `luaopen_socket_core`/`luaopen_mime_core` are 317 registered into `package.preload["socket.core"]` / 318 `package.preload["mime.core"]` (`ScriptingContext.cpp:76-79`) — **not** the 319 higher-level pure-Lua `socket` wrapper module (no `socket.lua` is bundled in this 320 repo; only the C core is present: `Lua/luasocket.c`, `Lua/tcp.c`, `Lua/udp.c`, 321 etc.). So scripts must do 322 `local socket = require("socket.core")` and call `socket.tcp()`, `socket.connect()`, 323 etc. directly (confirmed by reading `Lua/luasocket.c:35-44` — it registers `tcp`, 324 `udp`, `select`, `inet`, `timeout`, `buffer`, `auxiliar`, `except` submodules — 325 plus by the community Mesen docs, which describe exactly this pattern: *"Mesen has 326 a version of LuaSocket built into it... `local socket = require("socket.core")` 327 and `local mime = require("mime.core")`"*, and give a sample TCP client using 328 `sock.tcp()` / `tcp:settimeout(2)` / `tcp:connect()` / `tcp:send()` / 329 `tcp:receive()` — see Sources). 330 - LuaSocket's full TCP surface is present and unmodified from upstream: 331 `Lua/tcp.c:18-41` declares `global_create` (→ `socket.tcp()`), `global_connect` 332 (→ `socket.connect()`), and per-socket methods `connect`, `listen`, `bind`, 333 `send`, `receive`, `accept`, `close`, `settimeout`, etc. — this is exactly the 334 upstream LuaSocket 2.x/3.x TCP API, nothing Mesen-specific about it. 335- If I/O access is off, Mesen intercepts the resulting Lua errors 336 (`attempt to call a nil value (global 'require')`, `index a nil value (global 337 'os'/'io')`, `module 'socket.core' not found`) and rewrites them into a friendly 338 log message pointing at "Script->Settings->Script Window->Restrictions" 339 (`ScriptingContext.cpp:113-124`) — but this UI path does not exist/matter in 340 test-runner mode; you just get that message in the script log and nothing works. 341 342### Where these settings live and how to set them (Linux) 343 344- Settings file: `$XDG_CONFIG_HOME/Mesen2/settings.json`, falling back to 345 `~/.config/Mesen2/settings.json` if `XDG_CONFIG_HOME` is unset — this is 346 `.NET`'s documented mapping of `Environment.SpecialFolder.ApplicationData` on Unix 347 (`UI/Config/ConfigManager.cs:22-30`: `OperatingSystem.IsWindows() ? 348 SpecialFolder.MyDocuments : SpecialFolder.ApplicationData`, folder name `Mesen2` 349 appended). **Confirmed experimentally**: setting only `XDG_CONFIG_HOME` (no `$HOME` 350 override) redirected Mesen's home folder correctly in an isolated test (see the 351 commands under §2/§5). (One caveat found via web search, not directly hit here: 352 some .NET versions return an *empty string* for `ApplicationData` on Linux when 353 `XDG_CONFIG_HOME` is entirely unset and don't fall back to `~/.config` themselves — 354 this didn't reproduce on this system/.NET build, but if it ever does, export 355 `XDG_CONFIG_HOME=$HOME/.config` explicitly before launching Mesen to be safe.) 356- Serialization: `System.Text.Json` via a source-generated `MesenSerializerContext` 357 with **no camelCase naming policy** (`UI/Utilities/JsonHelper.cs:33-46` — contrast 358 with the sibling `MesenCamelCaseSerializerContext` used for unrelated doc/cheat 359 files, `JsonHelper.cs:48-57`, which *does* set `PropertyNamingPolicy = 360 JsonKnownNamingPolicy.CamelCase`). So `settings.json` keys are the **exact C# 361 property names, PascalCase**. The nesting for the settings this harness cares about 362 (`UI/Config/Configuration.cs:40` → `DebugConfig.cs:29` → `ScriptWindowConfig.cs`): 363 ```json 364 { 365 "Debug": { 366 "ScriptWindow": { 367 "AllowIoOsAccess": true, 368 "AllowNetworkAccess": true, 369 "ScriptTimeout": 30 370 } 371 } 372 } 373 ``` 374 These three keys are **not** reachable via any `--debug.*`/`--script.*` command-line 375 switch in 2.1.1 (`CommandLineHelper.GetAvailableSwitches()`, 376 `CommandLineHelper.cs:180-208`, has no `Debug` entry) — they must be baked into 377 `settings.json` ahead of time. Since the harness needs a `settings.json` to exist 378 anyway to skip the first-run wizard (§2), the natural move is to write one 379 containing exactly this block (a bare JSON object with just the fields you care 380 about deserializes fine — confirmed empirically with `{}` as a whole file; partial 381 objects are expected to work the same way since `System.Text.Json` fills in 382 C#-default values for anything absent, per `ScriptWindowConfig.cs`'s 383 `[Reactive] ... = new()`/`= false` initializers — **not independently re-verified 384 with the nested partial-object form specifically, only with a fully-empty 385 `{}`**). 386 387### Script execution timeout / watchdog 388 389- Setting: `Debug.ScriptWindow.ScriptTimeout`, a `UInt32`, **seconds** 390 (`UI/Config/Debugger/ScriptWindowConfig.cs:34`, default `1`; UI spinner clamps 391 `Minimum="1" Maximum="100"`, 392 `UI/Debugger/Windows/DebuggerConfigWindow.axaml:103-108`, label "Maximum execution 393 time... seconds"). Native mirror: `Core/Shared/SettingTypes.h:857` 394 (`uint32_t ScriptTimeout = 1;`). 395- Mechanism: **not a wall-clock/thread-based watchdog.** It's a Lua debug hook 396 (`lua_setwatchdogtimer`, a Mesen-patched addition to `Lua/ldebug.c`) that fires every 397 1000 **VM instructions executed** and checks elapsed wall time since the timer was 398 last reset (`ScriptingContext.cpp:126-133`); the timer resets at the start of every 399 event-callback and every memory-callback invocation (`ScriptingContext.cpp:298,316, 400 343,345`). 401- **Consequence for a harness that wants to BLOCK the emulator while an external AI 402 decides:** a Lua `debug hook` can only run *between bytecode instructions* — while 403 the interpreter is inside a **C function call** (such as a blocking 404 `tcpsocket:receive()` with no/infinite timeout), no Lua instructions are executing, 405 so the count-hook never fires and `ScriptTimeout` **cannot** interrupt it. In other 406 words: a genuinely blocking LuaSocket call inside e.g. an `endFrame` callback will 407 hold Mesen paused indefinitely regardless of `ScriptTimeout`, which is exactly the 408 "block until the AI responds" behavior the harness wants — but it also means there 409 is **no safety net** if the harness process dies or never replies: Mesen will hang 410 forever, not time out. (This reasoning follows directly from how `LUA_MASKCOUNT` 411 hooks work in the Lua VM and from the exact place the hook gets (re)installed in 412 this source — **not independently verified by hanging a real socket read and 413 watching the process refuse to die**, since that risks a genuinely unkillable 414 process; treat as high-confidence source-derived, not measured.) If you want an 415 actual upper bound on how long you'll wait for the AI, put a real timeout on the 416 LuaSocket call itself with `sock:settimeout(seconds)` and handle the resulting 417 `"timeout"` error in Lua — don't rely on Mesen's watchdog for this. 418 419## 5. First-run wizard and pre-answering it (Linux) 420 421- Trigger: `!File.Exists(ConfigManager.GetConfigFile())` at `UI/Program.cs:56`, where 422 `GetConfigFile()` is `Path.Combine(HomeFolder, "settings.json")` 423 (`ConfigManager.cs:41-44`), checked **before** the `--testRunner` dispatch 424 (`Program.cs:74`). 425- Fix: ensure `$XDG_CONFIG_HOME/Mesen2/settings.json` (or 426 `~/.config/Mesen2/settings.json` if not overriding `XDG_CONFIG_HOME`) exists before 427 the very first launch, containing at minimum the `Debug.ScriptWindow.*` block from 428 §4 (a bare `{}` also avoids the wizard, but then network/IO access stay off). 429 **Confirmed by direct experiment**, isolated environment, no display at all: 430 ```bash 431 mkdir -p "$HOME/.config/Mesen2" 432 echo '{}' > "$HOME/.config/Mesen2/settings.json" 433 env -u DISPLAY -u WAYLAND_DISPLAY \ 434 HOME="$HOME" Mesen --testRunner --enableStdout --timeout=1 game.sfc 435 # -> loads and runs the ROM headlessly, no wizard, no X11 error 436 ``` 437 and equivalently with only `XDG_CONFIG_HOME` set and `$HOME` left alone. 438- Without a pre-seeded config file: if a display **is** reachable, Mesen opens the 439 wizard window and blocks forever waiting for someone to click through it (does not 440 time out on its own — confirmed, killed by an external `timeout 5`, exit 124); if no 441 display is reachable, it crashes immediately with an unhandled 442 `System.Exception: XOpenDisplay failed`. Neither is acceptable for unattended 443 automation — always pre-seed the config file. 444 445## 6. Alternative control channels besides Lua (checked in source) 446 447- **Netplay** (`Core/Netplay/*`, `InteropDLL/NetplayApiWrapper.cpp:1-46`): a real, 448 separate TCP-based protocol (`GameServer`/`GameClient`, 449 `NetplayApiWrapper.cpp:10-46` exports `StartServer`, `Connect`, 450 `NetPlayGetControllerList`, `NetPlaySelectController`, etc.) for synchronizing 451 controller input and save states between multiple Mesen instances. It is a 452 **proprietary framed binary message protocol** (`Core/Netplay/NetMessage.h`, 453 `InputDataMessage.h`, `HandShakeMessage.h`, etc.), not text/JSON, and it exposes 454 **no memory-read or screenshot capability whatsoever** — only input 455 synchronization and save-state/game-info distribution between peers. Reimplementing 456 a compliant client in Python to merely inject input would be substantially more 457 work than the Lua+socket route for no more capability (and less: no RAM reads, no 458 screenshots), so it is real but not a practical alternative for this harness. 459- No other channel exists: no HTTP/WebSocket/generic TCP listener anywhere in the 460 tree (`grep -rl "TcpListener\|HttpListener\|WebSocket"` over the whole repo returns 461 nothing). Lua (with LuaSocket enabled) is the only way to get memory access, 462 input injection, and screenshots to an external process in 2.1.1. 463- (Not part of upstream Mesen2, found only via web search, **not inspected**: a 464 third-party "Mesen2 MCP Debugger Server" project exists on GitHub/LobeHub that 465 wraps Mesen2's debugger for AI use over MCP — mentioned here only because the 466 search surfaced it as a related community effort, not verified against source and 467 not part of this repo.) 468 469## 7. Known WSLg/X11/software-OpenGL problems (web search, not this repo's source) 470 471- Mesen2 has repeatedly broken on Linux due to **SDL2's OpenGL hardware renderer** 472 under Mesa/SDL version changes — SDL 2.28.3+ and again SDL 2.30.9-1 each broke 473 Mesen2's rendering (black screen on load) in ways attributed to Mesa/SDL changes, 474 not Mesen bugs (libsdl-org/SDL issues #8340, #11710). The maintainer's fix each 475 time was to add/rely on a **software rendering** fallback (Settings → Video → 476 Advanced) specifically "so Linux users don't get screwed over every time something 477 like this happens" (NESDev forum thread). 478- Separately, **Wayland + hardware acceleration** is reported broken for Mesen2 (GL 479 context issue under Wayland compositors); the workaround is to either force X11 480 (`unset SDL_VIDEODRIVER` or explicitly `SDL_VIDEODRIVER=x11`) or switch on the 481 software renderer. 482- None of this matters for the **`--testRunner` headless path** in this harness design 483 (§2 proves no renderer object is even constructed there), but it is directly 484 relevant if the harness ever wants a *visible* window (e.g. for a human to watch) 485 on WSLg: WSLg's X11/Wayland/GL stack is exactly the kind of "software/virtualized 486 GL" environment these upstream bugs target, so expect to need 487 `SDL_VIDEODRIVER=x11` and/or the in-app software-renderer toggle if a windowed run 488 ever shows a black screen. No WSLg-specific report was found in the searches run; 489 the X11-vs-Wayland and SDL-version findings above are the closest documented 490 analogues and are reported as such, not as WSLg-specific confirmations. 491- Sources: [SDL 2.28.3+ breaks rendering in Mesen2 · Issue #8340 · libsdl-org/SDL](https://github.com/libsdl-org/SDL/issues/8340), [SDL 2.30.9-1 | Broken HW renderer in Mesen2 · Issue #11710 · libsdl-org/SDL](https://github.com/libsdl-org/SDL/issues/11710), [Mesen - Emulator (NESDev Forum, various pages)](https://forums.nesdev.org/viewtopic.php?t=24391), [AUR mesen2-git](https://aur.archlinux.org/packages/mesen2-git). 492 493## 8. Complete minimal example 494 495Harness protocol (line-based JSON over TCP, chosen for simplicity — not from any 496Mesen-provided convention): 497- Every `N` frames, the Lua script sends one line: `{"frame":123,"wram":{"0096":58, 498 "0097":0}}\n` (a snapshot of a few WRAM addresses you care about, e.g. player X/Y, 499 health — addresses below are placeholders, substitute real ones for the ROM). 500- It then **blocks** reading one line back: `{"buttons":["right","a"]}\n` — a JSON 501 array of button names to hold down until the next snapshot. 502- Applies them via `emu.setInput` from inside the `inputPolled` callback (per the 503 API doc's explicit recommendation, §3). 504 505`harness.lua`: 506```lua 507-- Mesen2 Lua harness: periodic RAM snapshot -> external decision -> apply input. 508-- Requires settings.json to have Debug.ScriptWindow.AllowIoOsAccess = true and 509-- Debug.ScriptWindow.AllowNetworkAccess = true (see README §4) BEFORE this script 510-- is loaded -- if not, require() below will fail and Mesen will log a message 511-- telling you which UI checkbox would normally set this. 512 513local socket = require("socket.core") 514 515local HOST, PORT = "127.0.0.1", 5577 516local FRAMES_PER_DECISION = 15 517 518local sock = assert(socket.tcp()) 519-- Real timeout here, NOT relying on Mesen's ScriptTimeout watchdog (which cannot 520-- interrupt a blocking C call anyway -- see README §4). Pick a value your harness 521-- will always beat; on expiry we fail loudly rather than hang forever. 522sock:settimeout(30) 523assert(sock:connect(HOST, PORT)) 524 525-- WRAM addresses to report every decision point. Replace with real offsets for 526-- the ROM being driven. 527local WATCH = { 528 playerX = 0x0096, 529 playerY = 0x0098, 530 health = 0x00F0, 531} 532 533local pendingButtons = nil -- set by the frame-count callback, consumed by inputPolled 534local frameCounter = 0 535 536local function readLine(s) 537 local line, err = s:receive("*l") 538 if not line then 539 emu.log("socket receive failed: " .. tostring(err)) 540 emu.stop(1) 541 return nil 542 end 543 return line 544end 545 546-- Extremely small hand-rolled JSON encode/decode -- adequate for this flat protocol. 547-- (Swap for a real JSON lib if AllowIoOsAccess lets you require() one from disk.) 548local function encodeSnapshot(frame, values) 549 local parts = {} 550 for k, v in pairs(values) do 551 parts[#parts + 1] = string.format('"%s":%d', k, v) 552 end 553 return string.format('{"frame":%d,"wram":{%s}}', frame, table.concat(parts, ",")) 554end 555 556local function decodeButtons(line) 557 local buttons = {} 558 for name in line:gmatch('"([%a]+)"') do 559 buttons[name] = true 560 end 561 return buttons 562end 563 564emu.addEventCallback(function() 565 frameCounter = frameCounter + 1 566 if frameCounter % FRAMES_PER_DECISION ~= 0 then 567 return 568 end 569 570 local values = {} 571 for name, addr in pairs(WATCH) do 572 values[name] = emu.read(addr, emu.memType.snesWorkRam, false) 573 end 574 575 local ok, err = sock:send(encodeSnapshot(emu.getState().frameCount, values) .. "\n") 576 if not ok then 577 emu.log("socket send failed: " .. tostring(err)) 578 emu.stop(2) 579 return 580 end 581 582 -- Blocks here until the harness answers. Per README §4 this is NOT bounded by 583 -- Mesen's script-execution watchdog (blocking C calls don't tick it) -- it IS 584 -- bounded by the settimeout(30) above, which raises a Lua error on expiry. 585 local line = readLine(sock) 586 if line == nil then 587 return -- already called emu.stop() above 588 end 589 590 pendingButtons = decodeButtons(line) 591end, emu.eventType.endFrame) 592 593-- Apply the most recently decided buttons at the point the core actually polls 594-- input -- this is the documented, correct place to call setInput (see README §3). 595emu.addEventCallback(function() 596 if pendingButtons == nil then 597 return 598 end 599 emu.setInput({ 600 a = pendingButtons.a or false, 601 b = pendingButtons.b or false, 602 x = pendingButtons.x or false, 603 y = pendingButtons.y or false, 604 l = pendingButtons.l or false, 605 r = pendingButtons.r or false, 606 start = pendingButtons.start or false, 607 select = pendingButtons.select or false, 608 up = pendingButtons.up or false, 609 down = pendingButtons.down or false, 610 left = pendingButtons.left or false, 611 right = pendingButtons.right or false, 612 }, 0, 0) 613end, emu.eventType.inputPolled) 614 615emu.log("Harness connected to " .. HOST .. ":" .. PORT) 616``` 617 618Minimal Python side (server; Mesen's script is the TCP *client* here, which is 619simpler than making Lua accept connections — start the Python listener first, then 620launch Mesen): 621 622```python 623import json 624import socket 625 626HOST, PORT = "127.0.0.1", 5577 627 628srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 629srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 630srv.bind((HOST, PORT)) 631srv.listen(1) 632print(f"waiting for Mesen on {HOST}:{PORT}...") 633conn, _ = srv.accept() 634f = conn.makefile("rwb", buffering=0) 635 636while True: 637 line = f.readline() 638 if not line: 639 break 640 snapshot = json.loads(line) 641 # --- AI decision goes here --- 642 buttons = ["right"] if snapshot["wram"]["playerX"] < 200 else ["left", "a"] 643 f.write((json.dumps({"buttons": buttons}) + "\n").encode()) 644``` 645 646Launch order for the harness: 647```bash 648export XDG_CONFIG_HOME=/path/to/mesen-home # pre-seeded with settings.json, see §4/§5 649python3 harness_server.py & 650Mesen --testRunner --enableStdout --timeout=999999 harness.lua game.sfc 651``` 652 653## Summary of what is UNVERIFIED (marked inline above too) 654 655- Whether `--enableStdout` also carries the **script** log window's output (vs. only 656 the main UI log window) onto stdout. 657- Whether a **partial** `Debug.ScriptWindow` JSON object (only some keys set) is 658 correctly merged with C# defaults for the rest — only a fully-empty `{}` file was 659 tested experimentally; the full nested block was derived from source, not 660 round-tripped. 661- The claim that a blocking LuaSocket call truly cannot be interrupted by 662 `ScriptTimeout` is derived from how Lua count-hooks work and from where Mesen 663 (re)installs the hook — not confirmed by actually hanging a socket read and 664 observing the process fail to die (deliberately not attempted, to avoid leaving an 665 unkillable process behind). 666- Any WSLg-specific rendering bug reports (only generic Linux X11/Wayland/SDL-version 667 issues were found; none of this affects the headless `--testRunner` path used by 668 this harness design, which was proven from source and by experiment not to touch a 669 renderer at all).