The emulator supports Lua scripting for automating emulator interaction, monitoring CPU/memory state, and controlling the emulator at runtime. Scripts are loaded from a configurable directory and executed as coroutines alongside the emulator's frame loop. Scripting is currently wired in the Avalonia Desktop host application; the architecture supports extension to other host applications (SilkNet, SadConsole) without changes to the scripting core.
Two scripting styles are supported and can be combined within a single script:
- Linear loop (BizHawk-style): Write a top-level
while true do ... emu.frameadvance() endloop. The script reads like a sequential program that suspends and resumes each frame. - Event hooks: Define global callback functions (e.g.
on_before_frame(),on_started()) that the emulator calls at specific points.
Scripting is configured in appsettings.json under the "Highbyte.DotNet6502.Scripting" section:
"Highbyte.DotNet6502.Scripting": {
"Enabled": true,
"ScriptDirectory": "scripts",
"MaxExecutionWarningMs": 5,
"MaxInstructionsPerResume": 1000000,
"EnableScriptsAtStart": false,
"AllowFileIO": true,
"AllowFileWrite": false,
"AllowHttpRequests": true,
"AllowStore": true,
"StoreSubDirectory": ".store",
"AllowTcpClient": false
}| Setting | Type | Default | Description |
|---|---|---|---|
Enabled |
bool | true |
Master switch for the scripting system. |
ScriptDirectory |
string | "" |
Directory to load .lua files from. Absolute path, or relative to the application working directory. |
MaxExecutionWarningMs |
int | 5 |
Log a warning if a script hook takes longer than this many milliseconds. Set to 0 to disable. |
MaxInstructionsPerResume |
int | 1000000 |
Maximum Lua VM instructions per coroutine resume. Protects against runaway scripts. Set to 0 to disable. |
EnableScriptsAtStart |
bool | false |
Whether scripts start enabled when loaded. When false, scripts are loaded but must be enabled manually from the Scripts tab. |
AllowFileIO |
bool | true |
Whether the file global and emu.load() are available to Lua scripts. Set to false in environments without filesystem access (e.g. WASM/browser). |
AllowFileWrite |
bool | false |
Whether scripts may write, append, or delete files via the file global. Read operations are always permitted when AllowFileIO is true. |
FileBaseDirectory |
string | null |
Base directory for all file I/O. When null or empty, defaults to ScriptDirectory. All script-supplied paths are resolved relative to this directory; traversal outside it (e.g. ../) is blocked. |
AllowHttpRequests |
bool | true |
Whether the http global is available to Lua scripts. When true, scripts may make outbound HTTP GET and POST requests to arbitrary URLs. Default is true. |
AllowStore |
bool | true |
Whether the store global is available to Lua scripts. Provides a cross-platform key/value store. On desktop, backed by files in StoreSubDirectory. In browser, backed by localStorage. Default is true. |
StoreSubDirectory |
string | ".store" |
Subdirectory within ScriptDirectory used for the filesystem store backend (desktop only). Default is ".store". |
AllowTcpClient |
bool | false |
Whether the tcp global is available to Lua scripts. Desktop only — forced false in browser/WASM builds. Default is false. |
The Scripts tab in the application shows all loaded scripts with their current state. Each row displays:
- Status dot -- green (running), red (system-disabled), or grey (user-disabled / completed).
- Enable/disable checkbox -- toggle individual scripts on or off at runtime.
- Reload button (↻) -- re-reads the script from disk, recompiles, and runs it. Available when the script is not actively running (i.e. user-disabled, completed, or errored).
- Script name, status, yield type, and registered hooks.
The tab header shows a count of active scripts and, if any scripts were disabled by the system (syntax or runtime errors), a red count of disabled scripts.
Scripts run as coroutines. Use one of these to suspend execution and let the emulator continue:
| Function | Description |
|---|---|
emu.frameadvance() |
Yield until the next emulator frame. The script is frozen while the emulator is paused. Use this for per-frame logic tied to emulation. |
emu.yield() |
Yield until the next timer tick (~60 Hz). Keeps ticking even while the emulator is paused or stopped. Use this when the script needs to observe or control emulator state changes. |
A script must call one of these in its main loop. Scripts that return without yielding are marked as Completed (or HookOnly if they registered event hooks).
| Function | Returns | Description |
|---|---|---|
emu.framecount() |
number | Number of emulator frames executed since scripts were loaded (1-based). |
emu.time() |
number | Wall-clock seconds elapsed since scripts were loaded. |
emu.state() |
string | Current emulator state: "running", "paused", "stopped", or "unknown". |
emu.host() |
string | Host application type: "headless", "desktop", or "browser". Use this to write scripts that behave differently per host — e.g. call emu.quit() only when headless. |
emu.systems() |
table | List of available system names (e.g. {"C64", "Generic"}). |
emu.selected_system() |
string | Currently selected system name. |
emu.selected_variant() |
string | Currently selected system variant name. |
Control operations are deferred -- they take effect after the current frame completes.
| Function | Description |
|---|---|
emu.start() |
Request emulator start or resume. |
emu.pause() |
Request emulator pause. |
emu.stop() |
Request emulator stop. |
emu.reset() |
Request emulator stop + restart. |
emu.select(name [, variant]) |
Request system selection. The emulator must be stopped. |
emu.quit() |
Stop the emulator and terminate the host application. Useful for automation pipelines (CI/CD, batch runs) where the app should exit automatically when the script is done. |
emu.config_valid() |
Check whether the currently selected system's configuration is valid. Returns true if valid, or false plus a table of error strings if not. Use this before emu.start() to detect misconfiguration early (e.g. missing ROM files). |
All properties are read-only and return safe defaults (0 or false) before a system is started.
| Property | Type | Description |
|---|---|---|
cpu.pc |
int | Program Counter (0-65535) |
cpu.a |
int | Accumulator (0-255) |
cpu.x |
int | Index register X (0-255) |
cpu.y |
int | Index register Y (0-255) |
cpu.sp |
int | Stack Pointer (0-255) |
cpu.carry |
bool | Carry flag |
cpu.zero |
bool | Zero flag |
cpu.negative |
bool | Negative flag |
cpu.overflow |
bool | Overflow flag |
cpu.interrupt_disable |
bool | Interrupt disable flag |
cpu.decimal_mode |
bool | Decimal mode flag |
| Function | Description |
|---|---|
mem.read(address) |
Read a byte from emulator memory. Returns 0-255. Address is masked to 16-bit range. |
mem.write(address, value) |
Write a byte to emulator memory. Address is masked to 16-bit, value to 8-bit. |
Memory reads and writes go through the same address decoding as the emulated CPU, including I/O registers. For example, on the C64, mem.read(0xD012) reads the VIC-II raster line register and mem.write(0xD020, 1) sets the border color to white.
The input table provides access to keyboard and joystick state. Scripts can both read the current input state and inject synthetic input for automation.
Script-injected inputs are merged with real user input: a script can add key presses or joystick actions the user isn't pressing, but cannot suppress or override user input.
Script-injected inputs are ephemeral — they must be re-injected every frame in your script loop. The scripting engine clears all injected state at the start of each frame before your scripts run.
The input table is always registered, even when no input provider is active (e.g. on systems without input support). Functions that query state return false or nil gracefully in that case.
Key names are system-dependent — each system defines its own valid key names. Call input.available_keys() to discover the valid names for the current system. On the C64, key names include "a", "space", "return", "f1", "crsrright", "stop", "lira", etc.
| Function | Returns | Description |
|---|---|---|
input.key_press(name) |
— | Inject a key press for the current frame. The key will be considered "down" for this frame only; scripts must re-inject each frame if the key should remain held. |
input.key_release(name) |
— | Release a previously injected key. This only affects keys injected by the script, not user input. |
input.key_release_all() |
— | Release all keys injected by the script for this frame. |
input.is_key_down(name) |
boolean | Returns true if the key is currently pressed, whether by the user or by the script. Returns false if no input provider is active. |
input.available_keys() |
table | Returns a 1-indexed table of valid key name strings for the current system. Returns an empty table if no input provider is active. |
Joystick action names are standardized across all systems: "up", "down", "left", "right", "fire". Scripts use the same strings regardless of which system is running.
| Function | Returns | Description |
|---|---|---|
input.joystick_set(port, action, pressed) |
— | Inject a joystick action on the given port (1-based) for the current frame. action is one of "up", "down", "left", "right", "fire". |
input.joystick_action(port, action) |
boolean | Returns true if the joystick action is active on the given port, whether by the user or by the script. Returns false if no input provider is active. |
input.joystick_count() |
number | Returns the number of joystick ports on the current system (e.g. 2 on C64). Returns 0 if no input provider is active. |
input.available_joystick_actions() |
table | Returns a 1-indexed table of valid joystick action strings. Always {"up", "down", "left", "right", "fire"}. Returns an empty table if no input provider is active. |
The script's top-level code runs immediately when the script is enabled, before the first frame executes. The input provider is only wired once the first on_before_frame() fires. Therefore, input functions should only be called from hooks (on_started, on_before_frame) or after at least one emu.frameadvance().
Use emu.time() to measure durations in real seconds rather than counting frames — this keeps timing correct regardless of the system's frame rate (PAL vs NTSC).
Keyboard — press A, B, C in sequence:
-- Press A for 0.5s, pause 0.3s, press B for 0.5s, pause 0.3s, press C for 0.5s
local sequence = {
{ key = "a", hold = 0.5 },
{ key = nil, hold = 0.3 },
{ key = "b", hold = 0.5 },
{ key = nil, hold = 0.3 },
{ key = "c", hold = 0.5 },
}
local step = 0
local step_start = 0
function on_before_frame()
local now = emu.time()
if step == 0 then
step = 1
step_start = now
end
if step > #sequence then return end
local entry = sequence[step]
if entry.key then
input.key_press(entry.key)
end
if now - step_start >= entry.hold then
step = step + 1
if step <= #sequence then
step_start = now
end
end
endJoystick — timed action sequence with repeats:
-- Repeat 3 times: left 0.2s → pause 0.2s → right 0.2s → pause 0.2s → fire 0.5s → pause 0.5s
local PORT = 1
local REPEATS = 3
local sequence = {
{ action = "left", hold = 0.2 },
{ action = nil, hold = 0.2 },
{ action = "right", hold = 0.2 },
{ action = nil, hold = 0.2 },
{ action = "fire", hold = 0.5 },
{ action = nil, hold = 0.5 },
}
local step = 0
local step_start = 0
local rep = 0
function on_before_frame()
local now = emu.time()
if step == 0 then
rep = 1 ; step = 1 ; step_start = now
end
if rep > REPEATS then return end
local entry = sequence[step]
if entry.action then
input.joystick_set(PORT, entry.action, true)
end
if now - step_start >= entry.hold then
step = step + 1
if step > #sequence then
rep = rep + 1 ; step = 1
end
step_start = now
end
endFor complete demonstrations, see example_input_kb.lua (keyboard) and example_input_joystick.lua (joystick).
Log messages are prefixed with [Lua:filename.lua] in the application log output.
| Function | Log level |
|---|---|
log.info(msg) |
Information |
log.debug(msg) |
Debug |
log.warn(msg) |
Warning |
log.error(msg) |
Error |
Define any of these as global functions in your script. The emulator calls them at the corresponding points. A single script can register multiple hooks.
| Hook | Arguments | When called |
|---|---|---|
on_before_frame() |
none | Before each emulator frame executes. |
on_after_frame() |
none | After each emulator frame completes. |
on_started() |
none | Emulator started or resumed. |
on_paused() |
none | Emulator paused. |
on_stopped() |
none | Emulator stopped. |
on_system_selected(name) |
system name | System selection changed. |
on_variant_selected(name) |
variant name | System variant changed. |
Available when AllowFileIO: true. The file global is not registered when AllowFileIO is false, so scripts that use it will fail with a runtime error on platforms without filesystem access (e.g. WASM/browser).
All paths are relative to FileBaseDirectory (which defaults to ScriptDirectory). Paths that attempt to escape the base directory (e.g. ../) are blocked and treated as non-existent files or raise a runtime error on write attempts.
| Function | Returns | Description |
|---|---|---|
file.read(name) |
string or nil | Reads the entire contents of a text file. Returns nil if the file does not exist or the path is unsafe. |
file.read_bytes(name) |
table or nil | Reads a file as raw bytes. Returns a 1-indexed Lua table of integers (0–255), or nil if the file does not exist. Useful for inspecting binary data; for loading a binary directly into emulator memory, prefer emu.load(). |
file.exists(name) |
boolean | Returns true if the file exists within the base directory. |
file.list([pattern]) |
table | Returns a 1-indexed table of filenames in the base directory matching an optional glob pattern (default: "*"). Only filenames are returned, not full paths. |
If AllowFileWrite is false, calling any write operation raises a Lua runtime error and the script is auto-disabled.
| Function | Description |
|---|---|
file.write(name, text) |
Writes (overwrites) a text file. Creates the file if it does not exist. |
file.append(name, text) |
Appends text to a file. Creates the file if it does not exist. |
file.delete(name) |
Deletes a file. No-op if the file does not exist. |
Available when AllowFileIO: true. Loads a binary file from FileBaseDirectory directly into emulator memory, entirely on the C# side — no Lua byte array handling required, making it efficient even for large files.
| Function | Description |
|---|---|
emu.load(name) |
Reads the 2-byte little-endian load address from the file header (C64 .prg format) and loads the remaining bytes at that address. |
emu.load(name, true) |
Same as above, and also sets CPU PC to the load address after loading. |
emu.load(name, address) |
Loads the entire file as raw binary at the given address, without header parsing. |
emu.load(name, address, true) |
Same as above, and also sets CPU PC to the load address after loading. |
The operation is deferred (like emu.start() etc.) and takes effect after the current frame. Path confinement rules are the same as for file.*.
Available when AllowHttpRequests: true. The http global is not registered when AllowHttpRequests is false.
All methods return a response table with the following fields:
| Field | Type | Description |
|---|---|---|
ok |
boolean | true if the request succeeded (HTTP 2xx) and no network error occurred. |
status |
number | HTTP status code (e.g. 200, 404). 0 on network or timeout failure. |
body |
string, table, or nil | Response body. String for get / post / post_json. 1-indexed byte table for get_bytes. nil on failure or for download. |
error |
string or nil | Error description on failure. nil on success. |
| Function | Description |
|---|---|
http.get(url [, headers]) |
GET request. Returns the response body as a string in body. |
http.get_bytes(url [, headers]) |
GET request. Returns the response body as a 1-indexed Lua table of byte values (0–255) in body. Useful for binary data; for loading it directly into emulator memory see mem.write. |
http.post(url, body, content_type [, headers]) |
POST request with an explicit content type (e.g. "application/x-www-form-urlencoded"). Returns the response body as a string in body. |
http.post_json(url, json_body [, headers]) |
POST request with Content-Type: application/json. Shorthand for http.post(url, body, "application/json"). |
http.download(url, filename [, headers]) |
GET request that streams the response body directly to a file in the file sandbox. Requires AllowFileIO: true and AllowFileWrite: true. The body field is nil in the response; use file.read / file.read_bytes to access the saved file afterwards. |
The optional headers argument is a Lua table of key-value string pairs, e.g. {["Authorization"] = "Bearer token", ["Accept"] = "application/json"}.
All HTTP calls are non-blocking and async. When a script calls http.get(url), the coroutine is suspended immediately and the emulator continues running. The script resumes automatically on the next frame once the response arrives, with the response table returned as the value of the http.* call. From the Lua script's point of view the call is still a simple synchronous expression:
local resp = http.get(url) -- suspends until response arrives; script sees a normal return valueThis works the same on both desktop and browser/WASM hosts. Because the emulator keeps running during the HTTP wait, on_started() hooks that make HTTP calls will complete after the emulator has already started. If your script depends on data fetched in on_started being available before the first emulated frame runs, use emu.pause() at the start of the hook and emu.start() after the last HTTP call.
-- GET: call a REST API
local resp = http.get("https://api.example.com/status")
if resp.ok then
log.info("Response: " .. resp.body)
else
log.error("HTTP " .. resp.status .. ": " .. (resp.error or "?"))
end
-- GET bytes: download binary data and copy into emulator memory
local resp = http.get_bytes("https://example.com/data.bin")
if resp.ok then
for i, b in ipairs(resp.body) do
mem.write(0xC000 + i - 1, b)
end
end
-- POST JSON
local resp = http.post_json("https://api.example.com/save", '{"score":42}')
log.info("Saved: " .. tostring(resp.ok))
-- Download directly to the file sandbox, then load into memory via emu.load
local resp = http.download("https://example.com/game.prg", "game.prg")
if resp.ok then
emu.load("game.prg") -- reads PRG header and loads at the embedded address
end
-- Custom headers
local resp = http.get("https://api.example.com/private", {
["Authorization"] = "Bearer my-token"
})Available when AllowStore: true. The store global is not registered when AllowStore is false.
The store provides simple persistent key/value storage. Values are always strings. The storage backend depends on the environment:
- Desktop: each key is stored as a file named
<key>inside{ScriptDirectory}/{StoreSubDirectory}(default:scripts/.store/). The directory is created automatically on the first write. - Browser/WASM: each key is stored in
localStorageunder the prefixdotnet6502.store..
Keys must be valid filenames (no path separators, no ..). Attempting to use an invalid key raises a Lua runtime error.
| Function | Returns | Description |
|---|---|---|
store.get(key) |
string or nil | Returns the stored value for key, or nil if the key does not exist. |
store.set(key, value) |
— | Stores value under key, overwriting any existing entry. |
store.delete(key) |
— | Removes the entry for key. No-op if the key does not exist. |
store.exists(key) |
boolean | Returns true if an entry exists for key. |
store.list() |
table | Returns a 1-indexed Lua table of all stored keys. |
-- Save and retrieve a string value
store.set("high_score", "12345")
local score = store.get("high_score")
log.info("High score: " .. (score or "none"))
-- Persist a flag across sessions
if not store.exists("intro_shown") then
log.info("Showing intro for the first time")
store.set("intro_shown", "1")
end
-- Save downloaded content for later use
local resp = http.get("https://example.com/data.txt")
if resp.ok then
store.set("cached_data", resp.body)
end
-- List all stored keys
local keys = store.list()
for _, k in ipairs(keys) do
log.info("Store key: " .. k .. " = " .. (store.get(k) or ""))
end
-- Clean up
store.delete("high_score")Available when AllowTcpClient: true. The tcp global is not registered when AllowTcpClient is false. TCP client support is desktop only — it is not available in browser/WASM builds because System.Net.Sockets.TcpClient is not supported in WebAssembly.
The TCP API is designed for low-latency per-frame communication. The connection persists across frames;
MoonSharp strings are .NET System.String (UTF-16 code units), not raw byte arrays. Any byte value > 127 would be silently reinterpreted as a Unicode code point, corrupting binary payloads. All binary I/O in the TCP API therefore uses 1-indexed Lua tables of numbers (0–255) — the same convention as file.read_bytes() and http.get_bytes().
conn:send() accepts either format:
- A string — encoded to UTF-8 bytes before sending. Safe for text-only protocols.
- A 1-indexed byte table — sent verbatim as raw bytes. Use for binary protocols.
conn:receive(n) always returns a 1-indexed byte table regardless of the data content.
MoonSharp implements Lua 5.2. The Lua 5.3 bitwise operators (&, |, >>, <<) are not available. Use integer arithmetic instead:
| Lua 5.3 | Lua 5.2 equivalent |
|---|---|
n & 0xFF |
n % 256 |
(n >> 8) & 0xFF |
math.floor(n / 256) % 256 |
(n >> 16) & 0xFF |
math.floor(n / 65536) % 256 |
(n >> 24) & 0xFF |
math.floor(n / 16777216) % 256 |
local res = tcp.connect(host, port [, timeout_ms])host— hostname or IP address string.port— TCP port number (1–65535).timeout_ms— connection timeout in milliseconds. Default: 5000.
Returns a result table:
| Field | Type | Description |
|---|---|---|
ok |
boolean | true if the connection was established. |
data |
connection | Connection object (only present when ok = true). |
error |
string or nil | Error description on failure. nil on success. |
The call is non-blocking and async — the coroutine suspends until the connection is established or times out.
Once connected, the conn object exposes three methods:
Send data over the connection. data may be a string or a 1-indexed byte table.
Returns { ok=boolean, error=string|nil }.
Receive exactly n bytes. Suspends asynchronously until all bytes arrive or an error occurs.
Returns { ok=boolean, data=table, error=string|nil } where data is a 1-indexed byte table of n numbers (0–255).
Receive one line of text (reads until \n; the newline is stripped).
Returns { ok=boolean, data=string, error=string|nil } where data is the line as a string.
Close the connection. Safe to call multiple times.
tcp.connect(), conn:send(), and conn:receive() are all non-blocking and async: the coroutine suspends immediately and the emulator continues running frames. The script resumes automatically once the operation completes. From the Lua script's perspective each call looks like a normal synchronous expression:
local res = tcp.connect("127.0.0.1", 9000, 3000) -- suspends until connected or timed out
local sr = conn:send({1, 2, 3}) -- suspends until sent
local lr = conn:receive(4) -- suspends until 4 bytes arrive-- Connect to a TCP server
local res = tcp.connect("127.0.0.1", 9000, 3000)
if not res.ok then
log.error("Connection failed: " .. (res.error or "?"))
return
end
local conn = res.data
-- Send a length-prefixed binary payload (byte table)
local payload = { cpu.a, cpu.x, cpu.y }
local prefix = {
#payload % 256,
math.floor(#payload / 256) % 256,
math.floor(#payload / 65536) % 256,
math.floor(#payload / 16777216) % 256,
}
conn:send(prefix)
conn:send(payload)
-- Receive a length-prefixed response
local lr = conn:receive(4)
if lr.ok then
local resp_len = lr.data[1] + lr.data[2]*256 + lr.data[3]*65536 + lr.data[4]*16777216
if resp_len > 0 then
local ar = conn:receive(resp_len)
if ar.ok then
mem.write(0xD020, ar.data[1] % 16) -- apply first byte as C64 border color
end
end
end
-- Receive a text line (e.g. JSON or NDJSON)
local lr = conn:receive("*l")
if lr.ok then
log.info("Server says: " .. lr.data)
end
-- Close when done
conn:close()For a complete per-frame observation/action loop, see example_tcp_client.lua.
The following standard Lua modules are available: string, math, table, and the soft-sandbox base functions (print, type, tostring, tonumber, pairs, ipairs, etc.). The standard Lua io and os modules are not available; use the file global and emu.load() instead.
Scripts that encounter errors are automatically disabled:
- Syntax errors -- detected at load time. The script appears in the Scripts tab as system-disabled and cannot be toggled on.
- Runtime errors -- the script is stopped and marked as system-disabled. This applies to both coroutine execution and event hook invocations.
- Instruction limit exceeded -- if a coroutine resume exceeds
MaxInstructionsPerResume, the script is force-suspended and system-disabled.
Disabled scripts can be fixed on disk and reloaded via the reload button in the Scripts tab without restarting the emulator.
Example scripts are included in the scripts/ directory:
| Script | Style | Description |
|---|---|---|
example_frameadvance.lua |
Linear loop | Logs CPU state every 60 frames and detects changes to the A register. |
example_monitor.lua |
Event hooks | Defines on_before_frame and on_after_frame hooks to log CPU state and watch the C64 raster line register. |
example_emulator_control.lua |
Linear loop + hooks | Demonstrates the emulator control API: queries state, pauses at frame 300, resumes after 3 seconds, and defines all state-change event hooks. |
example_border_cycle.lua |
Linear loop | Waits for the C64 system to be running, waits 3 seconds, then cycles the border color through all 16 C64 colors. |
example_file_io.lua |
Linear loop | Demonstrates the file I/O API: lists scripts, reads a text file, writes a CSV log, and shows emu.load() and file.read_bytes() usage. Requires AllowFileIO: true; write operations also require AllowFileWrite: true. |
example_http.lua |
Event hook | Demonstrates the HTTP API in on_started(): GET with and without custom headers, post_json, post with explicit content type, get_bytes, download, and error handling for unreachable hosts. Requires AllowHttpRequests: true. |
example_store.lua |
Linear loop + hooks | Demonstrates the key/value store API: persistent run counter, first-run flag, overwrite/verify, listing all keys, saving a CPU snapshot on on_started, and writing a frame checkpoint every 60 frames. Requires AllowStore: true. |
example_tcp_client.lua |
Linear loop | Demonstrates the TCP client API with a per-frame observation/action loop mimicking a Machine Learning / Reinforcement Learning server protocol (length-prefixed binary). Connects to a local TCP server, sends CPU state as an observation each frame, and applies the first byte of the server's response to the C64 border color register. Requires AllowTcpClient: true. Desktop only. |
example_input_kb.lua |
Event hook | Demonstrates keyboard input injection: presses A, B, C in sequence using emu.time() for timing (0.5s hold per key, 0.3s pause between keys). |
example_input_joystick.lua |
Event hook | Demonstrates joystick input injection: repeats a timed sequence (left → pause → right → pause → fire → pause) three times on port 1. |
example_quit.lua |
Event hook | Demonstrates an automation pipeline: starts the emulator, polls a memory address each frame for a program result, saves to file, then calls emu.quit() to exit. Includes a timeout fallback. |
The scripting system uses MoonSharp, a Lua interpreter written entirely in C#. MoonSharp runs Lua 5.2-compatible code without native dependencies.
The engine is implemented in three layers:
| Layer | Project | Description |
|---|---|---|
| Abstraction | Highbyte.DotNet6502.Systems (Scripting/ subfolder) |
Defines IScriptingEngine, NoScriptingEngine, ScriptingEngine, IScriptingEngineAdapter, ScriptStatus, ScriptingConfig, IScriptStore, and the adapter DTOs (AdapterScriptHandle, AdapterScriptState, AdapterResumeResult). Also contains HostApp, the base host-app class that integrates scripting into the emulator lifecycle. No dependency on MoonSharp. |
| MoonSharp adapter | Highbyte.DotNet6502.Scripting.MoonSharp |
MoonSharpScriptingEngineAdapter implements IScriptingEngineAdapter using MoonSharp. Contains the Lua proxy classes (LuaCpuProxy, LuaMemProxy, LuaLogProxy, LuaFileProxy, LuaHttpProxy). Also contains FileSystemScriptStore and DelegateScriptStore (store backends). MoonSharpScriptingConfigurator is the factory entry point. |
| Host | Highbyte.DotNet6502.App.Avalonia.Core |
AvaloniaHostApp overrides the platform-specific virtual hooks from HostApp to wire the emu.yield() tick timer and drain deferred script actions on the Avalonia UI thread. |
ScriptingEngine (concrete class, in Systems/Scripting/) implements IScriptingEngine and contains all engine-agnostic orchestration: script file tracking, enable/disable state, hook routing, ScriptStatus building, event firing, and the deferred-action queue used by emu.start() / emu.stop() etc. It delegates all Lua-VM-specific operations to an IScriptingEngineAdapter.
IScriptingEngineAdapter covers the operations that differ per Lua engine: VM initialization, script compilation, coroutine creation and resume, yield-type detection, hook function caching, and hook invocation.
MoonSharpScriptingEngineAdapter is the MoonSharp-specific implementation. It wraps each .lua file in a MoonSharp Coroutine (held in a MoonSharpScriptHandle). The Lua environment uses a soft-sandbox preset (CoreModules.Preset_SoftSandbox | CoreModules.String | CoreModules.Math | CoreModules.Table). The standard Lua io module is intentionally excluded; file I/O is provided instead through LuaFileProxy, a plain C# class backed by System.IO that enforces path confinement and write guards independently of any Lua library. LuaFileProxy is registered as a Lua Table of DynValue.NewCallback entries (the same pattern as the emu table) rather than as a MoonSharp UserData type, so that file.list() and file.read_bytes() can construct and return Lua Table objects directly. HTTP operations follow the same pattern: LuaHttpProxy holds a single HttpClient instance and is registered as the http Lua table; http.download routes through LuaFileProxy.GetSafePath() to ensure downloaded files stay within the file sandbox.
The store Lua table is registered when AllowStore: true. The adapter resolves the backend from ScriptingConfig.StoreBackend: if set (browser/WASM sets it to a DelegateScriptStore wrapping localStorage JSInterop calls), that backend is used directly; otherwise a FileSystemScriptStore is created automatically from ScriptDirectory + StoreSubDirectory. Both implement IScriptStore (defined in Highbyte.DotNet6502.Systems). FileSystemScriptStore validates keys as plain filenames (no path traversal) and creates the store directory on first write. DelegateScriptStore is a thin wrapper over four Lua lambdas, making it easy to adapt any backend (localStorage, IndexedDB, in-memory, etc.) without adding a new implementation class.
To support a different Lua engine, implement IScriptingEngineAdapter and a matching AdapterScriptHandle subclass, then pass the adapter to new ScriptingEngine(adapter, config, loggerFactory). No changes to IScriptingEngine, NoScriptingEngine, or any host-app code are required.
The base class HostApp<TInputHandlerContext, TAudioHandlerContext> owns the scripting integration. A derived host app calls SetScriptingEngine(engine) once at startup (before the first Start()). HostApp then:
- Calls
engine.SetHostApp(this)so scripts can query and control the emulator via theIHostAppinterface. - Calls
engine.LoadScripts()to compile all.luafiles and run initial resumes. - Calls the virtual
OnScriptingEngineSet()so the derived host can start its platform-specific tick timer.
The base class calls scripting hooks directly in its concrete lifecycle methods (not in the virtual On* hooks):
| Lifecycle point | Call |
|---|---|
Start() |
OnSystemStarted(system) then InvokeEvent("on_started") |
Pause() |
InvokeEvent("on_paused") |
Stop() |
InvokeEvent("on_stopped") |
SelectSystem() |
InvokeEvent("on_system_selected", name) |
SelectSystemConfigurationVariant() |
InvokeEvent("on_variant_selected", name) |
RunEmulatorOneFrame() |
InvokeBeforeFrame() before the CPU frame, InvokeAfterFrame() after |
Close() |
StopScriptingTimer() |
The virtual OnAfterStart, OnAfterPause, OnAfterStop, etc. hooks remain empty integration points for derived classes; scripting is not invoked through them.
Scripts call emulator control functions (emu.start(), emu.pause(), etc.) synchronously from Lua, but those operations (e.g. IHostApp.Start()) are asynchronous and must not execute during an active frame. ScriptingEngine maintains an internal pending-action queue. When emu.start() is called from Lua, a lambda is queued; the host drains it after the frame or timer tick by calling DrainPendingScriptActionsAsync() (a protected helper on HostApp that delegates to ScriptingEngine.DrainPendingActionsAsync()).
The IScriptingEngineAdapter.InitializeVm receives an enqueueAction delegate from ScriptingEngine, which adapter implementations use to queue deferred IHostApp calls without holding a direct reference to the queue.
The IScriptingEngineAdapter interface was validated against NLua (the most popular .NET Lua library, wrapping the native Lua C API via KeraLua) to confirm all methods map cleanly. Key implementation notes for a future NLua adapter:
- VM setup —
new Lua()replacesnew Script(...). Object globals uselua["name"] = obj(NLua exposes all public members by default; use[LuaHide]to opt out, inverse of MoonSharp's[MoonSharpUserData]opt-in). Functions uselua.RegisterFunction(...). - Script loading —
lua.LoadFile(filePath)returns aLuaFunctionchunk;lua.NewThread(fn, out thread)creates the coroutine. The handle would wrap the resultingKeraLua.Luathread state. - Coroutine resume —
threadState.Resume(mainState, 0, out nResults)on theKeraLua.Luathread object. Yield/dead/error state is read from theLuaStatusenum return value. ForceSuspended(runaway protection) — MoonSharp'sAutoYieldCounterhas no direct equivalent. Implement viathreadState.SetHook(CountHook, LuaHookMask.Count, N): set abool WasKilledByHookflag on the handle before callingthreadState.Error(...)inside the hook to distinguish it from genuine runtime errors (both surface asLuaStatus.ErrRun).emu.frameadvance()yield — for Lua 5.4 correctness, implement at KeraLua level usingthreadState.YieldK(1, ctx, continuation)rather thanRegisterFunction+Yield(), since NLua'sRegisterFunctionwraps methods aslua_CFunctionwithout continuation support.- Hook cache — cache
LuaFunctionreferences (fromlua.GetFunction(name)) instead of MoonSharpDynValuereferences. - Proxy classes —
LuaCpuProxy,LuaMemProxy,LuaLogProxyare MoonSharp-specific. A NLua adapter would provide equivalent proxy classes with NLua-compatible attribute conventions. - File I/O and HTTP —
LuaFileProxyandLuaHttpProxyhave no MoonSharp dependency (System.IOandSystem.Net.Httprespectively) and can be reused as-is in a NLua adapter. The adapter would register their methods as individual NLua callbacks on Lua tables, exactly as the MoonSharp adapter does. - Key/value store —
IScriptStore,FileSystemScriptStore, andDelegateScriptStorehave no MoonSharp dependency and can be reused as-is. The adapter would register thestore.get/set/delete/exists/listcallbacks on a Lua table in the same way.
- Load -- On startup (when scripting is enabled), all
.luafiles in the configured directory are compiled. Files with syntax errors are recorded and shown as system-disabled in the UI. - Initial resume -- Each coroutine is resumed once. This executes top-level code (variable initialization, hook function definitions, etc.) until the script yields or returns. Hook function registrations are detected by comparing global function state before and after the initial resume.
- Per-frame execution -- On each emulator frame, coroutines that yielded via
emu.frameadvance()are resumed, andon_before_frame/on_after_framehooks are invoked. - Tick execution -- A separate timer (~60 Hz) resumes coroutines that yielded via
emu.yield(). This timer keeps firing even while the emulator is paused or stopped. - Disable/enable -- Individual scripts can be toggled from the Scripts tab. Disabled scripts have their coroutine resumes and hook invocations skipped.
- Reload -- A script can be reloaded from disk. The old coroutine and hook registrations are cleaned up, the file is recompiled, and a new coroutine is created and resumed. The script retains its position in the list.
The scripting threading model is intentionally simple: all script execution must run on the same thread as the emulator frame loop. This ensures Lua mem.read/mem.write and CPU memory access never overlap, with no locks or synchronization needed.
HostApp provides two protected helpers that derived host apps call from the appropriate thread:
InvokeScriptingTick()— resumesemu.yield()coroutines (for the tick timer callback).DrainPendingScriptActionsAsync()— executes deferred emulator control actions (e.g.emu.start()) queued by scripts during the previous frame or tick.
HostApp also provides two virtual methods for derived classes that need a platform-specific tick timer (e.g. Avalonia):
OnScriptingEngineSet()— called whenSetScriptingEngine()completes; start the tick timer here. Game-loop-based hosts (SilkNet, SadConsole) typically do not need to override this.StopScriptingTimer()— called fromClose(); stop and dispose the tick timer here. Only needed whenOnScriptingEngineSet()was overridden.
All script execution runs on the Avalonia UI thread. The emulator uses two periodic timers, both dispatching callbacks to the UI thread via Dispatcher.UIThread.InvokeAsync:
-
Update timer -- fires at the emulated system's refresh rate (e.g. ~50 Hz for PAL C64). Each tick runs the full frame sequence synchronously:
InvokeBeforeFrame()-- resumesemu.frameadvance()coroutines, then callson_before_framehooks.ProcessInputBeforeFrame()-- processes user input.ExecuteOneFrame()-- runs the emulated CPU for one frame's worth of cycles.InvokeAfterFrame()-- callson_after_framehooks.DrainPendingScriptActionsAsync()-- executes any emulator control operations queued by scripts.
-
Scripting tick timer -- fires at ~60 Hz independently, also dispatched to the UI thread. Each tick calls
InvokeScriptingTick()thenDrainPendingScriptActionsAsync().
Because both timers dispatch to the same UI thread, their callbacks are serialized by the Avalonia dispatcher queue and can never execute concurrently.
SilkNet host applications (e.g. SilkNetHostApp) run the emulator on the game loop thread managed by IWindow.Run(). The game loop already fires at ~60 Hz, so no separate tick timer is needed — OnScriptingEngineSet() does not need to be overridden.
The OnUpdate callback handles both frame execution and the scripting tick:
RunEmulatorOneFrame()— firesInvokeBeforeFrame(), the CPU frame, andInvokeAfterFrame()internally.InvokeScriptingTick()— resumesemu.yield()coroutines.DrainPendingScriptActionsAsync()— executes deferred emulator control operations.
Because all three steps run on the same game loop thread, no additional synchronization is needed.
SadConsole host applications (e.g. SadConsoleHostApp) drive the emulator from the MonoGame/SadConsole update loop (UpdateSadConsole, called by the game loop at ~60 Hz). As with SilkNet, the game loop itself is the tick mechanism and no separate timer is needed — OnScriptingEngineSet() does not need to be overridden.
The UpdateSadConsole method handles both frame execution and the scripting tick:
RunEmulatorOneFrame()— firesInvokeBeforeFrame(), the CPU frame, andInvokeAfterFrame()internally.InvokeScriptingTick()— resumesemu.yield()coroutines.DrainPendingScriptActionsAsync()— executes deferred emulator control operations.
Because all three steps run on the same game loop thread, no additional synchronization is needed.