Skip to content

Commit 34a2a9c

Browse files
committed
Add Avalonia desktop command line parameter --script to load & run a Lua script when app is started
1 parent 3f7d4e4 commit 34a2a9c

5 files changed

Lines changed: 204 additions & 14 deletions

File tree

doc/SCRIPTING-TEMP-TODO.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
11
Scripting:
2-
- How to send input (keyboard and joystick) in addition to change memory locations?
3-
- Specific c64 Lua namespace with keyboard and joystick input?
4-
- How to make system-specific Scripting/Lua handlers without hard-coding in scripting engine? Should there a scripting abstraction for sending keyboard and joystick input?
2+
- Expose more C64-specific functions to Lua scripting:
3+
- Has basic started (check flag or event or both?)
4+
- Copy current Basic source code to a string variable
5+
- Write a string to Basic
6+
- Load and start a .d64 image
7+
- Set C64 config:
8+
- Check if config is valid, get list of validation errors.
9+
- If running on desktop:
10+
- Get/set C64 ROM directory.
11+
- Get/set C64 ROM files.
12+
13+
Scripting on Browser
14+
- Load & run a Lua script supplied in query parameter as a URL to download from?
15+
- C64 exposed functions if running on browser:
16+
- Auto-download C64 ROMs
17+
- MAYBE: Get or set C64 ROMs from browser local storage
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
-- example_c64_cli_demo.lua
2+
--
3+
-- Demonstrates end-to-end CLI-driven automation for the C64 emulator.
4+
--
5+
-- Launch command (from the app output directory):
6+
-- ./Highbyte.DotNet6502.App.Avalonia.Desktop --script scripts/example_c64_cli_demo.lua
7+
--
8+
-- The --script path is resolved relative to the current working directory.
9+
-- An absolute path also works:
10+
-- ./Highbyte.DotNet6502.App.Avalonia.Desktop --script /path/to/example_c64_cli_demo.lua
11+
--
12+
-- The script selects the C64 system and starts the emulator itself,
13+
-- so no --system or --start flags are needed.
14+
--
15+
-- Sequence:
16+
-- 1. Ensure the C64 system is selected and running
17+
-- 2. Wait for the BASIC "READY." prompt to appear in screen RAM
18+
-- 3. Cycle the border through all 16 C64 colors for ~2 seconds
19+
-- 4. Restore the default border color
20+
-- 5. Type "HELLO!" at a human-like typing speed
21+
22+
-- ── Step 1: Ensure C64 is running ────────────────────────────────────────────
23+
24+
if emu.selected_system() ~= "C64" then
25+
log.info("[demo] Selecting C64 system...")
26+
emu.select("C64")
27+
end
28+
29+
if emu.state() ~= "running" then
30+
log.info("[demo] Starting emulator...")
31+
emu.start()
32+
end
33+
34+
-- Wait until C64 is actually up and running
35+
while emu.state() ~= "running" or emu.selected_system() ~= "C64" do
36+
emu.yield()
37+
end
38+
39+
log.info("[demo] C64 is running. Waiting for BASIC READY prompt...")
40+
41+
-- ── Step 2: Wait for BASIC "READY." in screen RAM ────────────────────────────
42+
-- Wait 3 seconds (wall-clock)
43+
local start_time = emu.time()
44+
while emu.time() - start_time < 3.0 do
45+
emu.yield()
46+
end
47+
48+
log.info("[demo] BASIC READY detected. Cycling border color for 2 seconds...")
49+
50+
-- ── Step 3: Cycle border color (~2 seconds at C64 PAL 50 fps ≈ 100 frames) ──
51+
--
52+
-- C64 border color register: $D020 (bits 3–0, 16 colors 0–15)
53+
54+
local BORDER_REG = 0xD020
55+
local border_start = emu.time()
56+
local color = 0
57+
while emu.time() - border_start < 2.0 do
58+
mem.write(BORDER_REG, color)
59+
color = (color + 1) % 16
60+
emu.frameadvance()
61+
end
62+
63+
-- ── Step 4: Restore default border (C64 default: light blue = 14) ─────────
64+
65+
mem.write(BORDER_REG, 14)
66+
log.info('[demo] Border cycling done. Typing "HELLO!" ...')
67+
68+
-- ── Step 5: Type "HELLO!" at human-like speed ────────────────────────────────
69+
--
70+
-- C64 BASIC starts in uppercase mode, so h–o produce uppercase letters on screen.
71+
-- '!' on C64 keyboard = lshift + 1.
72+
--
73+
-- Input injection pattern: input.key_press() must be called every frame to keep
74+
-- a key held (ClearScriptInput fires at the start of each frame). So we loop for
75+
-- hold_frames, pressing the key each iteration, then wait gap_frames with no press.
76+
--
77+
-- Timing at 50 fps (PAL C64):
78+
-- hold_frames = 4 → ~80 ms per key
79+
-- gap_frames = 6 → ~120 ms between keys
80+
-- Total per keystroke ≈ 200 ms → ~5 characters/second
81+
82+
local HOLD_FRAMES = 4
83+
local GAP_FRAMES = 6
84+
85+
local function type_key(key, with_shift)
86+
for _ = 1, HOLD_FRAMES do
87+
input.key_press(key)
88+
if with_shift then input.key_press("lshift") end
89+
emu.frameadvance()
90+
end
91+
for _ = 1, GAP_FRAMES do
92+
emu.frameadvance()
93+
end
94+
end
95+
96+
type_key("h")
97+
type_key("e")
98+
type_key("l")
99+
type_key("l")
100+
type_key("o")
101+
type_key("1", true) -- lshift + 1 → '!'
102+
103+
log.info('[demo] Done. "HELLO!" typed.')

src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/App.axaml.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public partial class App : Application
4545
private readonly Action<string, string>? _saveScript;
4646
private readonly Action<string>? _deleteScript;
4747
private readonly Func<Task>? _loadExamples;
48+
private readonly bool _skipDefaultSystemSelection;
4849
private AvaloniaHostApp _hostApp = default!;
4950
private IServiceProvider _serviceProvider = default!;
5051

@@ -92,6 +93,7 @@ public partial class App : Application
9293
/// <param name="saveScript">Optional callback to persist a script by file name and content (browser: to localStorage).</param>
9394
/// <param name="deleteScript">Optional callback to remove a script by file name (browser: from localStorage).</param>
9495
/// <param name="loadExamples">Optional callback to fetch and seed bundled example scripts (browser-only).</param>
96+
/// <param name="skipDefaultSystemSelection">When true, suppresses the UI's automatic default system selection on startup (e.g. when a script or automated startup handles it).</param>
9597
public App(
9698
IConfiguration configuration,
9799
EmulatorConfig emulatorConfig,
@@ -106,7 +108,8 @@ public App(
106108
Func<string, string?>? loadScript = null,
107109
Action<string, string>? saveScript = null,
108110
Action<string>? deleteScript = null,
109-
Func<Task>? loadExamples = null)
111+
Func<Task>? loadExamples = null,
112+
bool skipDefaultSystemSelection = false)
110113
{
111114
WriteBootstrapLog("App constructor called");
112115

@@ -123,6 +126,7 @@ public App(
123126
_saveScript = saveScript;
124127
_deleteScript = deleteScript;
125128
_loadExamples = loadExamples;
129+
_skipDefaultSystemSelection = skipDefaultSystemSelection;
126130

127131
// Set static reference for external access (e.g., debug adapter)
128132
Current = this;
@@ -331,6 +335,11 @@ private void InitializeHostApp()
331335
// Wire Lua scripting engine (NoScriptingEngine used when null, e.g. in WASM)
332336
_hostApp.SetScriptingEngine(_scriptingEngine ?? new NoScriptingEngine());
333337

338+
// Suppress UI default system selection when automated startup or a script handles it.
339+
// Set before TrySetResult so the flag is visible to MainViewModel.InitializeAsync().
340+
if (_skipDefaultSystemSelection)
341+
_hostApp.SkipDefaultSystemSelection = true;
342+
334343
// Signal waiters (e.g. automated startup on a background thread) that HostApp is ready.
335344
// TrySetResult guarantees all writes above are visible to awaiters before they resume.
336345
s_hostAppReady.TrySetResult(_hostApp);

src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/Program.cs

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.IO;
34
using System.Linq;
45
using System.Threading.Tasks;
@@ -65,6 +66,20 @@ internal sealed partial class Program
6566
/// Only effective when used with <c>--debug-port</c>. Times out after 30 seconds.
6667
/// </description>
6768
/// </item>
69+
/// <item>
70+
/// <term><c>--script &lt;path&gt;</c></term>
71+
/// <description>
72+
/// Load and auto-enable a specific Lua script file (absolute or relative to CWD).
73+
/// Can be specified multiple times to load several scripts.
74+
/// Overrides the ScriptDirectory from configuration; only the specified files are loaded.
75+
/// </description>
76+
/// </item>
77+
/// <item>
78+
/// <term><c>--scriptDir &lt;path&gt;</c></term>
79+
/// <description>
80+
/// Override the Lua script directory from configuration. All .lua files in the directory are loaded and auto-enabled.
81+
/// </description>
82+
/// </item>
6883
/// </list>
6984
/// <para>
7085
/// <b>Examples:</b>
@@ -138,6 +153,10 @@ public static int Main(string[] args)
138153
string? loadPrgPath = AutomatedStartupHandler.ParseStringArgument(args, "--loadPrg");
139154
bool runLoadedProgram = args.Contains("--runLoadedProgram");
140155

156+
// Parse scripting override arguments
157+
List<string> scriptFilePaths = ParseMultipleStringArgument(args, "--script");
158+
string? scriptDirectoryOverride = AutomatedStartupHandler.ParseStringArgument(args, "--scriptDir");
159+
141160
// Validate automated startup arguments
142161
if (!AutomatedStartupHandler.ValidateArguments(systemName, systemVariant, autoStart, waitForSystemReady, loadPrgPath, runLoadedProgram))
143162
{
@@ -240,13 +259,17 @@ public static int Main(string[] args)
240259
// ----------
241260
// Initialize Lua scripting engine
242261
// ----------
243-
var scriptingEngine = MoonSharpScriptingConfigurator.Create(configuration, loggerFactory);
262+
var scriptingEngine = MoonSharpScriptingConfigurator.Create(configuration, loggerFactory, scriptFilePaths, scriptDirectoryOverride);
263+
264+
// Skip the UI's default system selection when a script or --system arg will handle it,
265+
// to avoid a race where the UI tries to select a system while the script/handler already has.
266+
bool skipDefaultSystemSelection = systemName != null || scriptFilePaths.Count > 0 || scriptDirectoryOverride != null;
244267

245268
// ----------
246269
// Start Avalonia app
247270
// ----------
248271
WriteBootstrapLog($"Starting Avalonia app.");
249-
var app = BuildAvaloniaApp(configuration, emulatorConfig, logStore, logConfig, loggerFactory, avaloniaLoggerBridge, gamepad, debugController, scriptingEngine);
272+
var app = BuildAvaloniaApp(configuration, emulatorConfig, logStore, logConfig, loggerFactory, avaloniaLoggerBridge, gamepad, debugController, scriptingEngine, skipDefaultSystemSelection);
250273

251274
// If automated startup is requested, handle it after the app starts
252275
if (systemName != null)
@@ -267,10 +290,6 @@ public static int Main(string[] args)
267290
var hostApp = await Core.App.WhenHostAppReadyAsync.ConfigureAwait(false);
268291
startupLogger.LogInformation("HostApp initialized.");
269292

270-
// Suppress default system selection in the Avalonia UI (automated startup handles it)
271-
if (hostApp is AvaloniaHostApp avaloniaHostApp)
272-
avaloniaHostApp.SkipDefaultSystemSelection = true;
273-
274293
await AutomatedStartupHandler.ExecuteAsync(
275294
hostApp,
276295
systemName,
@@ -316,7 +335,8 @@ public static AppBuilder BuildAvaloniaApp(
316335
AvaloniaLoggerBridge avaloniaLoggerBridge,
317336
IGamepad? gamepad = null,
318337
IExternalDebugController? externalDebugController = null,
319-
IScriptingEngine? scriptingEngine = null)
338+
IScriptingEngine? scriptingEngine = null,
339+
bool skipDefaultSystemSelection = false)
320340
=> AppBuilder.Configure(() => new Core.App(
321341
configuration,
322342
emulatorConfig,
@@ -327,7 +347,8 @@ public static AppBuilder BuildAvaloniaApp(
327347
saveCustomConfigSection: null,
328348
gamepad: gamepad,
329349
externalDebugController: externalDebugController,
330-
scriptingEngine: scriptingEngine))
350+
scriptingEngine: scriptingEngine,
351+
skipDefaultSystemSelection: skipDefaultSystemSelection))
331352
.UsePlatformDetect()
332353
.WithInterFont()
333354
.LogToTrace()
@@ -371,6 +392,21 @@ private static LogLevel ParseLogLevel(string[] args, LogLevel defaultLevel)
371392
return defaultLevel;
372393
}
373394

395+
/// <summary>
396+
/// Parses all occurrences of a named argument from command line arguments.
397+
/// Usage: --script foo.lua --script bar.lua → ["foo.lua", "bar.lua"]
398+
/// </summary>
399+
private static List<string> ParseMultipleStringArgument(string[] args, string argumentName)
400+
{
401+
var result = new List<string>();
402+
for (int i = 0; i < args.Length - 1; i++)
403+
{
404+
if (args[i] == argumentName)
405+
result.Add(args[i + 1]);
406+
}
407+
return result;
408+
}
409+
374410
/// <summary>
375411
/// Parses the debug port from command line arguments.
376412
/// Usage: --debug-port 6502

src/libraries/Highbyte.DotNet6502.Scripting.MoonSharp/MoonSharpScriptingConfigurator.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ namespace Highbyte.DotNet6502.Scripting.MoonSharp;
1111
/// </summary>
1212
public static class MoonSharpScriptingConfigurator
1313
{
14-
public static IScriptingEngine Create(IConfiguration configuration, ILoggerFactory loggerFactory)
14+
public static IScriptingEngine Create(
15+
IConfiguration configuration,
16+
ILoggerFactory loggerFactory,
17+
IReadOnlyList<string>? scriptFilePaths = null,
18+
string? scriptDirectoryOverride = null)
1519
{
1620
var logger = loggerFactory.CreateLogger(nameof(MoonSharpScriptingConfigurator));
1721

@@ -24,7 +28,32 @@ public static IScriptingEngine Create(IConfiguration configuration, ILoggerFacto
2428
return new NoScriptingEngine();
2529
}
2630

27-
if (string.IsNullOrWhiteSpace(config.ScriptDirectory))
31+
// Apply CLI overrides
32+
if (scriptDirectoryOverride != null)
33+
{
34+
logger.LogInformation("[Scripting] ScriptDirectory overridden via CLI: {Dir}", scriptDirectoryOverride);
35+
config.ScriptDirectory = scriptDirectoryOverride;
36+
}
37+
38+
if (scriptFilePaths != null && scriptFilePaths.Count > 0)
39+
{
40+
logger.LogInformation("[Scripting] Loading {Count} specific script(s) via CLI: {Files}",
41+
scriptFilePaths.Count, string.Join(", ", scriptFilePaths));
42+
config.ScriptLoader = () => scriptFilePaths.Select(path =>
43+
{
44+
var fullPath = Path.GetFullPath(path);
45+
return (Path.GetFileName(fullPath), File.ReadAllText(fullPath));
46+
});
47+
}
48+
49+
// Force auto-enable when CLI overrides are in effect (automation intent)
50+
if (scriptFilePaths?.Count > 0 || scriptDirectoryOverride != null)
51+
{
52+
config.EnableScriptsAtStart = true;
53+
}
54+
55+
// ScriptDirectory is only required when no ScriptLoader is set
56+
if (config.ScriptLoader == null && string.IsNullOrWhiteSpace(config.ScriptDirectory))
2857
{
2958
logger.LogWarning("[Scripting] Enabled but ScriptDirectory is not set. Using NoScriptingEngine.");
3059
return new NoScriptingEngine();

0 commit comments

Comments
 (0)