Skip to content

Commit e4083eb

Browse files
authored
Feature/avalonia browser automation (#192)
* Move AutomatedStartupHandler from DebugAdapter to Systems library to be reusable from all host apps that is started with parameters for automation purposes. * Implement query parameters for Avalonia Browser app for automated startup and scripts. * Update Avalonia Browser automation doc. * Fix bug in Avalonia Log header not being updated and cleared correctly. * Automatically show ROM acknowledgement dialog when starting C64 from Avalonia Browser query parameters. * Preserve Lua MoonSharp library from AOT trimming to make it more compatible with running in Browser/WebAssembly. * Remove non-ascii characters from .lua scripts * Fix Avalonia Browser script editor dialog height * Add links to start screen
1 parent 3737f1c commit e4083eb

38 files changed

Lines changed: 1580 additions & 428 deletions

docs/tools/scripting/configuration.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Configuration
22

3-
Scripting is configured in `appsettings.json` under the `"Highbyte.DotNet6502.Scripting"` section:
3+
On desktop targets, scripting is configured in `appsettings.json` under the `"Highbyte.DotNet6502.Scripting"` section. On the Avalonia Browser app, the same section is persisted in browser `localStorage` by the settings UI.
44

55
```json
66
"Highbyte.DotNet6502.Scripting": {
@@ -14,7 +14,8 @@ Scripting is configured in `appsettings.json` under the `"Highbyte.DotNet6502.Sc
1414
"AllowHttpRequests": true,
1515
"AllowStore": true,
1616
"StoreSubDirectory": ".store",
17-
"AllowTcpClient": false
17+
"AllowTcpClient": false,
18+
"AllowUrlScripts": false
1819
}
1920
```
2021

@@ -32,3 +33,4 @@ Scripting is configured in `appsettings.json` under the `"Highbyte.DotNet6502.Sc
3233
| `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`. |
3334
| `StoreSubDirectory` | string | `".store"` | Subdirectory within `ScriptDirectory` used for the filesystem store backend (desktop only). Default is `".store"`. |
3435
| `AllowTcpClient` | bool | `false` | Whether the `tcp` global is available to Lua scripts. Desktop only — forced `false` in browser/WASM builds. Default is `false`. |
36+
| `AllowUrlScripts` | bool | `false` | Browser-only. When `true`, the Avalonia Browser app honours the `script` and `scriptUrl` URL query parameters at startup. Disabled by default because a crafted link could otherwise execute Lua against the user's emulator session and `localStorage`. Takes effect on the next page load. |

docs/web-apps/avalonia-browser.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,84 @@ To self-host, see [Run from command line](#run-from-command-line) below.
4242

4343
The browser app supports the same Lua scripting API as the Avalonia Desktop app, except for filesystem and TCP access (the browser sandbox does not allow them; the key/value store falls back to `localStorage`). For the full guide, see [Tools / Scripting](../tools/scripting/overview.md).
4444

45+
### URL query parameters
46+
47+
The Avalonia Browser app supports URL-driven startup automation. This is the browser counterpart to the Avalonia Desktop app's [CLI arguments](../desktop-apps/avalonia-desktop.md#cli-arguments): instead of passing `--system` or `--script`, you encode the request in the page URL query string.
48+
49+
Query parameter names are case-insensitive. Boolean flags treat an empty value, `1`, `true`, and `yes` as true.
50+
51+
| Query parameter | Purpose | Notes |
52+
| --- | --- | --- |
53+
| `system` | Pre-select a system such as `C64` or `Generic`. | Mirrors desktop `--system`. |
54+
| `systemVariant` | Pre-select a system variant. | Requires `system`. Mirrors desktop `--systemVariant`. |
55+
| `start` | Auto-start the selected system. | Requires `system`. Mirrors desktop `--start`. |
56+
| `waitForSystemReady` | Wait until the system reports ready. | Requires `system` and `start`. Mirrors desktop `--waitForSystemReady`. |
57+
| `loadPrgUrl` | Fetch a `.prg` over HTTP and load it into memory. | Requires `system` and `start`. Browser equivalent of desktop `--loadPrg`, but uses a URL instead of a local file path. Relative URLs are resolved from the app origin. |
58+
| `runLoadedProgram` | Start executing the loaded PRG from its load address. | Requires `loadPrgUrl`. Mirrors desktop `--runLoadedProgram`. |
59+
| `basicText` | Paste inline C64 BASIC source text into the running C64. | Base64url-encoded UTF-8 text. Requires `system=C64`, `start`, and `waitForSystemReady`. Mutually exclusive with `loadPrgUrl` and `runLoadedProgram`. |
60+
| `basicUrl` | Fetch C64 BASIC source text over HTTP and paste it into the running C64. | Same semantics as `basicText`, but uses a text file URL instead of embedding the source in the query string. |
61+
| `runBasic` | Queue `RUN` after BASIC source has been pasted. | Requires `basicText` or `basicUrl`. |
62+
| `script` | Run an inline Lua script supplied as base64url-encoded UTF-8 text. | Browser only. Mutually exclusive with all system-driven parameters below. Disabled by default; gated by `Scripting.AllowUrlScripts`. |
63+
| `scriptUrl` | Fetch a Lua script from a relative or absolute URL and run it. | Same behavior as `script`, but avoids URL-length limits. Disabled by default; gated by `Scripting.AllowUrlScripts`. |
64+
65+
Validation rules are intentionally forgiving: invalid combinations are ignored and the normal UI still loads.
66+
67+
When a URL starts `system=C64` and the app does not yet have the required C64 ROMs, the browser startup flow prompts the user to acknowledge the ROM download terms and can download the ROMs before continuing. This lets first-run automation links work without opening the C64 config dialog first.
68+
69+
1. `systemVariant` requires `system`.
70+
2. `start` and `waitForSystemReady` require `system`.
71+
3. `waitForSystemReady` requires `start`.
72+
4. `loadPrgUrl` requires `system` and `start`.
73+
5. `runLoadedProgram` requires `loadPrgUrl`.
74+
6. `basicText` and `basicUrl` are mutually exclusive.
75+
7. `basicText` and `basicUrl` require `system=C64`, `start`, and `waitForSystemReady`.
76+
8. `basicText` and `basicUrl` are mutually exclusive with `loadPrgUrl` and `runLoadedProgram`.
77+
9. `runBasic` requires `basicText` or `basicUrl`.
78+
10. `script` and `scriptUrl` are mutually exclusive.
79+
11. `script` and `scriptUrl` are also mutually exclusive with `system`, `start`, `waitForSystemReady`, `loadPrgUrl`, `runLoadedProgram`, `basicText`, `basicUrl`, and `runBasic`.
80+
81+
Examples:
82+
83+
```text
84+
# Start C64 PAL and wait until the machine is ready
85+
?system=C64&systemVariant=PAL&start=1&waitForSystemReady=1
86+
87+
# Load and run a bundled PRG
88+
?system=C64&start=1&waitForSystemReady=1&loadPrgUrl=prg/c64/smooth_scroller_and_raster.prg&runLoadedProgram=1
89+
90+
# Paste BASIC source from a browser-served text file and run it
91+
?system=C64&start=1&waitForSystemReady=1&basicUrl=basic/c64/hello-world.bas&runBasic=1
92+
93+
# Paste the same BASIC source inline and run it
94+
?system=C64&start=1&waitForSystemReady=1&basicText=MTAgYzE9NzpjMj0xNAoyMCBjPWMxCjMwIGlmIGM9YzEgdGhlbiBjPWMyIDogZ290byA1MAo0MCBpZiBjPWMyIHRoZW4gYz1jMQo1MCBwb2tlIDUzMjgwLGMKNjAgcHJpbnQgImhlbGxvIHdvcmxkISIKNzAgZm9yIGk9MSB0byAxNTA6bmV4dAo4MCBnb3RvIDMwCg&runBasic=1
95+
96+
# Run an inline Lua script (base64url for: log.info('hello'))
97+
?script=bG9nLmluZm8oJ2hlbGxvJyk
98+
99+
# Run a Lua script fetched over HTTP
100+
?scriptUrl=scripts/example_emulator_control.lua
101+
```
102+
103+
The browser app ships the `basicUrl` sample above as `basic/c64/hello-world.bas`, containing:
104+
105+
```basic
106+
10 c1=7:c2=14
107+
20 c=c1
108+
30 if c=c1 then c=c2 : goto 50
109+
40 if c=c2 then c=c1
110+
50 poke 53280,c
111+
60 print "hello world!"
112+
70 for i=1 to 150:next
113+
80 goto 30
114+
```
115+
116+
Important differences from desktop automation:
117+
118+
- `loadPrgUrl`, `basicUrl`, and `scriptUrl` use browser HTTP fetch semantics, so normal browser origin and CORS rules apply.
119+
- `basicText` and `basicUrl` are C64-only and use the normal keyboard paste path after BASIC is ready; `runBasic=1` simply appends `RUN` and Return after the pasted source.
120+
- URL-driven Lua is **disabled by default**. Enable **Allow URL-driven scripts (script / scriptUrl query params)** in the browser app's general settings, save, then reload the page.
121+
- URL-driven scripts do not behave exactly like desktop `--script`: the browser app still selects the configured default system first, then enables the injected script. The script can still take over by calling APIs such as `emu.select(...)` and `emu.start()`.
122+
45123
## How to run locally for development
46124

47125
For development system requirements, see [Development](../home/development.md).

docs/web-apps/overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,6 @@ The two apps share the same emulator core — they differ in UI/rendering tech.
1515

1616
- Lua TCP client (`tcp` global) is unavailable — `System.Net.Sockets.TcpClient` is not supported in WebAssembly.
1717
- Lua filesystem access (`file` / `emu.load`) is sandboxed; the key/value `store` falls back to `localStorage`.
18-
- No CLI arguments, no VS Code debug adapter, no remote control endpoint — those are desktop-only features. See [Tools](../tools/overview.md) for the full list of integrations.
18+
- No CLI arguments, no VS Code debug adapter, no remote control endpoint — those are desktop-only features. The Avalonia Browser app does support URL query parameters for automated startup and script injection; see [Avalonia Browser app](avalonia-browser.md#url-query-parameters).
1919

2020
For general project limitations, see [Limitations](../home/limitations.md).

resources/scripts/desktop/example_c64_screenshot.lua

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
-- emu.screenshot(filename, quality) -> saves as JPEG (determined by .jpg/.jpeg extension),
2424
-- quality 1-100 (default 90)
2525

26-
-- ── Step 1: Ensure C64 is selected and running ───────────────────────────────
26+
-- == Step 1: Ensure C64 is selected and running ========================
2727

2828
if emu.selected_system() ~= "C64" then
2929
log.info("[screenshot] Selecting C64 system...")
@@ -42,7 +42,7 @@ end
4242

4343
log.info("[screenshot] C64 is running. Waiting for BASIC to start...")
4444

45-
-- ── Step 2: Wait for BASIC to initialize ─────────────────────────────────────
45+
-- == Step 2: Wait for BASIC to initialize ==============================
4646
--
4747
-- c64.basic_started() checks the TXTAB pointer ($002B-$002C) to detect
4848
-- whether the BASIC interpreter has initialized and the READY. prompt is up.
@@ -66,7 +66,7 @@ emu.frameadvance()
6666

6767
log.info("[screenshot] BASIC ready. Taking screenshot...")
6868

69-
-- ── Step 3: Take the screenshot ───────────────────────────────────────────────
69+
-- == Step 3: Take the screenshot =======================================
7070
--
7171
-- The file path is relative to the scripting base directory.
7272
-- The screenshots/ subdirectory is created automatically if it does not exist.
@@ -75,7 +75,7 @@ local filename = "screenshots/c64_basic_ready.png"
7575
emu.screenshot(filename)
7676
log.info("[screenshot] Saved: " .. filename)
7777

78-
-- ── Step 4: Quit if running headless ─────────────────────────────────────────
78+
-- == Step 4: Quit if running headless ==================================
7979
--
8080
-- In headless mode the process has nothing left to do after the screenshot,
8181
-- so quit cleanly. In desktop/browser mode the emulator keeps running.

resources/scripts/desktop/example_file_io.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ local log_file = "frame_log.csv"
4141
file.write(log_file, "frame,pc,a,x,y\n")
4242
log.info("Logging CPU state every 60 frames to " .. log_file)
4343

44-
-- ---- Binary load example (commented out requires a PRG file in the scripts directory) ----
44+
-- ---- Binary load example (commented out - requires a PRG file in the scripts directory) ----
4545
-- emu.load("my_program.prg") -- auto-detects load address from 2-byte PRG header
4646
-- emu.load("raw_data.bin", 0xC000) -- loads raw binary at address $C000 (no header)
4747

resources/scripts/desktop/example_tcp_client.lua

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ local PORT = 9000
1919
local CONNECT_TIMEOUT_MS = 3000
2020

2121
-- Helper: encode a little-endian 4-byte length prefix.
22-
-- Returns a 1-indexed Lua table of 4 numbers (0-255), e.g. encode_u32_le(5) {5, 0, 0, 0}.
22+
-- Returns a 1-indexed Lua table of 4 numbers (0-255), e.g. encode_u32_le(5) -> {5, 0, 0, 0}.
2323
--
2424
-- Why a number table instead of a Lua string?
2525
-- In standard Lua, strings are byte arrays and can hold arbitrary binary data.
@@ -30,8 +30,8 @@ local CONNECT_TIMEOUT_MS = 3000
3030
--
3131
-- Note: MoonSharp implements Lua 5.2. Lua 5.3 bitwise operators (&, |, >>, <<) are NOT
3232
-- supported. Use integer arithmetic instead:
33-
-- n & 0xFF n % 256
34-
-- (n >> 8) & 0xFF math.floor(n / 256) % 256 (etc.)
33+
-- n & 0xFF -> n % 256
34+
-- (n >> 8) & 0xFF -> math.floor(n / 256) % 256 (etc.)
3535
local function encode_u32_le(n)
3636
return {
3737
n % 256, -- byte 0 (least significant)

resources/scripts/shared/example_c64_basic_readwrite.lua

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,20 @@ end
3535

3636
log.info("BASIC ready. Typing BASIC program...")
3737

38-
-- ── Step 3: Type a 2-line BASIC program ──────────────────────────────────────
38+
-- == Step 3: Type a 2-line BASIC program ===============================
3939
--
4040
-- c64.print_text() queues text into the C64 keyboard buffer exactly as if the
4141
-- user typed it. Each line must end with "\n" (mapped to C64 Return key).
4242
-- BASIC tokenizes keywords (print, goto) as each line is confirmed with Return.
43-
-- Note: input must be lowercase the C64 keyboard uses PETSCII where lowercase
43+
-- Note: input must be lowercase - the C64 keyboard uses PETSCII where lowercase
4444
-- letters map to uppercase display characters (the C64's default text mode).
4545

4646
local PROGRAM_LINE1 = "10 print \"hello from lua\""
4747
local PROGRAM_LINE2 = "20 goto 10"
4848

4949
c64.print_text(PROGRAM_LINE1 .. "\n" .. PROGRAM_LINE2 .. "\n")
5050

51-
-- ── Step 4: Wait for the C64 to process the input ────────────────────────────
51+
-- == Step 4: Wait for the C64 to process the input =====================
5252
--
5353
-- The keyboard buffer drains at most one character per frame. The two lines
5454
-- total ~35 characters; 120 frames (~2.4 s at 50 fps PAL) gives ample margin.
@@ -59,13 +59,13 @@ for _ = 1, 120 do
5959
emu.frameadvance()
6060
end
6161

62-
-- ── Step 5: Read the program back from memory ────────────────────────────────
62+
-- == Step 5: Read the program back from memory =========================
6363

6464
local source = c64.get_basic_source()
6565
log.info("Retrieved BASIC source:")
6666
log.info(source)
6767

68-
-- ── Step 6: Check that both lines round-tripped correctly ────────────────────
68+
-- == Step 6: Check that both lines round-tripped correctly =============
6969

7070
local ok1 = string.find(source, "10") ~= nil and string.find(source, "PRINT") ~= nil -- tokenizer uppercases keywords
7171
local ok2 = string.find(source, "20") ~= nil and string.find(source, "GOTO") ~= nil -- tokenizer uppercases keywords

resources/scripts/shared/example_c64_cli_demo.lua

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
-- 4. Restore the default border color
2020
-- 5. Type "HELLO!" at a human-like typing speed
2121

22-
-- ── Step 1: Ensure C64 is running ────────────────────────────────────────────
22+
-- == Step 1: Ensure C64 is running =====================================
2323

2424
if emu.selected_system() ~= "C64" then
2525
log.info("[demo] Selecting C64 system...")
@@ -34,7 +34,7 @@ end
3434
-- Validate config before attempting to start
3535
local ok, errors = emu.config_valid()
3636
if not ok then
37-
log.error("[demo] C64 system config is invalid cannot start.")
37+
log.error("[demo] C64 system config is invalid - cannot start.")
3838
for _, e in ipairs(errors) do
3939
log.error("[demo] " .. e)
4040
end
@@ -53,16 +53,16 @@ end
5353

5454
log.info("[demo] C64 is running. Waiting for BASIC READY prompt...")
5555

56-
-- ── Step 2: Wait for BASIC to initialize ─────────────────────────────────────
56+
-- == Step 2: Wait for BASIC to initialize ==============================
5757
while not c64.basic_started() do
5858
emu.frameadvance()
5959
end
6060

6161
log.info("[demo] BASIC READY detected. Cycling border color for 2 seconds...")
6262

63-
-- ── Step 3: Cycle border color (~2 seconds at C64 PAL 50 fps 100 frames) ──
63+
-- == Step 3: Cycle border color (~2 seconds at C64 PAL 50 fps ~= 100 frames) ==
6464
--
65-
-- C64 border color register: $D020 (bits 30, 16 colors 015)
65+
-- C64 border color register: $D020 (bits 3-0, 16 colors 0-15)
6666

6767
local BORDER_REG = 0xD020
6868
local border_start = emu.time()
@@ -73,24 +73,24 @@ while emu.time() - border_start < 2.0 do
7373
emu.frameadvance()
7474
end
7575

76-
-- ── Step 4: Restore default border (C64 default: light blue = 14) ─────────
76+
-- == Step 4: Restore default border (C64 default: light blue = 14) ====
7777

7878
mem.write(BORDER_REG, 14)
7979
log.info('[demo] Border cycling done. Typing "HELLO!" ...')
8080

81-
-- ── Step 5: Type "HELLO!" at human-like speed ────────────────────────────────
81+
-- == Step 5: Type "HELLO!" at human-like speed =========================
8282
--
83-
-- C64 BASIC starts in uppercase mode, so ho produce uppercase letters on screen.
83+
-- C64 BASIC starts in uppercase mode, so h-o produce uppercase letters on screen.
8484
-- '!' on C64 keyboard = lshift + 1.
8585
--
8686
-- Input injection pattern: input.key_press() must be called every frame to keep
8787
-- a key held (ClearScriptInput fires at the start of each frame). So we loop for
8888
-- hold_frames, pressing the key each iteration, then wait gap_frames with no press.
8989
--
9090
-- Timing at 50 fps (PAL C64):
91-
-- hold_frames = 4 ~80 ms per key
92-
-- gap_frames = 6 ~120 ms between keys
93-
-- Total per keystroke 200 ms ~5 characters/second
91+
-- hold_frames = 4 -> ~80 ms per key
92+
-- gap_frames = 6 -> ~120 ms between keys
93+
-- Total per keystroke ~= 200 ms -> ~5 characters/second
9494

9595
local HOLD_FRAMES = 4
9696
local GAP_FRAMES = 6
@@ -111,6 +111,6 @@ type_key("e")
111111
type_key("l")
112112
type_key("l")
113113
type_key("o")
114-
type_key("1", true) -- lshift + 1 '!'
114+
type_key("1", true) -- lshift + 1 -> '!'
115115

116116
log.info('[demo] Done. "HELLO!" typed.')

resources/scripts/shared/example_c64_load_d64.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
-- emulated DiskDrive 1541 via c64.load_d64().
44
--
55
-- The path supports ~ (home directory) on Mac/Linux and %USERPROFILE% on
6-
-- Windows expansion is handled automatically by the engine.
6+
-- Windows - expansion is handled automatically by the engine.
77
--
88
-- After a successful load the directory listing command is typed automatically.
99

@@ -32,7 +32,7 @@ end
3232

3333
log.info("BASIC ready. Loading disk image...")
3434

35-
-- ── Load the disk image ───────────────────────────────────────────────────────
35+
-- == Load the disk image ===============================================
3636
--
3737
-- Adjust the path below to point at an actual .d64 file on your system.
3838
-- ~ is expanded to the current user's home directory on Mac/Linux;

resources/scripts/shared/example_emulator_control.lua

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
-- on_system_selected(name) -- system selection changed
3030
-- on_variant_selected(name) -- system variant changed
3131

32-
-- ── State-change event hooks ──────────────────────────────────────────
32+
-- == State-change event hooks ==========================================
3333

3434
function on_started()
3535
log.info("[event] Emulator started")
@@ -51,7 +51,7 @@ function on_variant_selected(name)
5151
log.info(string.format("[event] Variant selected: %s", name))
5252
end
5353

54-
-- ── Top-level init ────────────────────────────────────────────────────
54+
-- == Top-level init ====================================================
5555

5656
local function list_systems()
5757
local systems = emu.systems()
@@ -70,7 +70,7 @@ log.info(string.format(
7070
list_systems()
7171
))
7272

73-
-- ── Main loop (uses emu.yield to keep ticking while paused) ───────────
73+
-- == Main loop (uses emu.yield to keep ticking while paused) ===========
7474

7575
log.info(string.format("Script start"))
7676

@@ -92,7 +92,7 @@ while true do
9292

9393
-- Pause at frame 300 (~5 seconds on C64), then resume after 3 seconds
9494
if not pause_demo_done and frame >= 300 and emu.state() == "running" then
95-
log.info("Frame 300 reached requesting pause")
95+
log.info("Frame 300 reached - requesting pause")
9696
emu.pause()
9797
paused_at_time = emu.time()
9898
end

0 commit comments

Comments
 (0)