jevsnes.git / research / mesen-automation.md
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).