Skip to content

Commit c803c17

Browse files
authored
Feature/headless app (#176)
* New headless app * Add screenshot functionality exposed to Lua scripts (including from headless app). * Remove unnecessary command line option for headless app. * Implement emulator quit support * Add publish of Headless app to GitHub release * Update desktop app release workflow for package managers to also include the headless app. * New doc for installing desktop headless app. Rename existing install instruction for desktop apps. * Fix some reliability and security issues in bash scripts * Enforce script/lifecycle CLI exclusivity and add emu.host() API - --script/--scriptDir are now mutually exclusive with --system, --systemVariant, --start, --waitForSystemReady, --loadPrg, and --runLoadedProgram; scripts own all emulator setup and lifecycle - Automated startup mode (--start etc.) suppresses scripts configured via appsettings.json to avoid conflicts - Add emu.host() Lua API returning "headless", "desktop", or "browser" so scripts can adapt behavior per host (e.g. conditional emu.quit()) - Update example Lua scripts to call emu.select/emu.start themselves and use emu.host()-gated quit instead of unconditional emu.quit() - Add CLI args section to APPS_AVALONIA.md; restructure APPS_HEADLESS.md into scripting mode vs automated startup mode
1 parent 655e9f8 commit c803c17

50 files changed

Lines changed: 2196 additions & 297 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/release-desktop-apps.yml

Lines changed: 74 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -32,36 +32,45 @@ jobs:
3232

3333
- name: Publish SilkNetNative App
3434
run: |
35-
chmod +x src/apps/Highbyte.DotNet6502.App.SilkNetNative/publish.sh
35+
chmod u+x src/apps/Highbyte.DotNet6502.App.SilkNetNative/publish.sh
3636
src/apps/Highbyte.DotNet6502.App.SilkNetNative/publish.sh ${{ matrix.runtime }}
3737
3838
- name: Publish Avalonia Desktop App
3939
run: |
40-
chmod +x src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/publish.sh
40+
chmod u+x src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/publish.sh
4141
src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/publish.sh ${{ matrix.runtime }}
4242
4343
- name: Publish SadConsole App
4444
run: |
45-
chmod +x src/apps/Highbyte.DotNet6502.App.SadConsole/publish.sh
45+
chmod u+x src/apps/Highbyte.DotNet6502.App.SadConsole/publish.sh
4646
src/apps/Highbyte.DotNet6502.App.SadConsole/publish.sh ${{ matrix.runtime }}
4747
48+
- name: Publish Headless App
49+
run: |
50+
chmod u+x src/apps/Highbyte.DotNet6502.App.Headless/publish.sh
51+
src/apps/Highbyte.DotNet6502.App.Headless/publish.sh ${{ matrix.runtime }}
52+
4853
- name: Zip Apps
4954
run: |
5055
cd src/apps/Highbyte.DotNet6502.App.SilkNetNative/publish/${{ matrix.runtime }}
5156
zip -r $GITHUB_WORKSPACE/DotNet6502-SilkNetNative-${{ matrix.runtime }}.zip .
52-
57+
5358
cd $GITHUB_WORKSPACE/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/publish/${{ matrix.runtime }}
5459
zip -r $GITHUB_WORKSPACE/DotNet6502-Avalonia-${{ matrix.runtime }}.zip .
55-
60+
5661
cd $GITHUB_WORKSPACE/src/apps/Highbyte.DotNet6502.App.SadConsole/publish/${{ matrix.runtime }}
5762
zip -r $GITHUB_WORKSPACE/DotNet6502-SadConsole-${{ matrix.runtime }}.zip .
5863
64+
cd $GITHUB_WORKSPACE/src/apps/Highbyte.DotNet6502.App.Headless/publish/${{ matrix.runtime }}
65+
zip -r $GITHUB_WORKSPACE/DotNet6502-Headless-${{ matrix.runtime }}.zip .
66+
5967
- name: Generate SHA256 checksums
6068
run: |
6169
cd $GITHUB_WORKSPACE
6270
sha256sum DotNet6502-SilkNetNative-${{ matrix.runtime }}.zip >> checksums-${{ matrix.runtime }}.sha256
6371
sha256sum DotNet6502-Avalonia-${{ matrix.runtime }}.zip >> checksums-${{ matrix.runtime }}.sha256
6472
sha256sum DotNet6502-SadConsole-${{ matrix.runtime }}.zip >> checksums-${{ matrix.runtime }}.sha256
73+
sha256sum DotNet6502-Headless-${{ matrix.runtime }}.zip >> checksums-${{ matrix.runtime }}.sha256
6574
6675
- name: Upload Release Assets
6776
if: github.event_name == 'release'
@@ -72,6 +81,7 @@ jobs:
7281
DotNet6502-SilkNetNative-${{ matrix.runtime }}.zip \
7382
DotNet6502-Avalonia-${{ matrix.runtime }}.zip \
7483
DotNet6502-SadConsole-${{ matrix.runtime }}.zip \
84+
DotNet6502-Headless-${{ matrix.runtime }}.zip \
7585
checksums-${{ matrix.runtime }}.sha256
7686
7787
- name: Upload Workflow Artifacts (non-release)
@@ -83,6 +93,7 @@ jobs:
8393
DotNet6502-SilkNetNative-${{ matrix.runtime }}.zip
8494
DotNet6502-Avalonia-${{ matrix.runtime }}.zip
8595
DotNet6502-SadConsole-${{ matrix.runtime }}.zip
96+
DotNet6502-Headless-${{ matrix.runtime }}.zip
8697
checksums-${{ matrix.runtime }}.sha256
8798
8899
update-package-managers:
@@ -112,13 +123,18 @@ jobs:
112123
id: hashes
113124
run: |
114125
extract_hash() {
115-
grep "DotNet6502-Avalonia-${1}.zip" "checksums-${1}.sha256" | awk '{print $1}'
126+
grep "DotNet6502-${1}-${2}.zip" "checksums-${2}.sha256" | awk '{print $1}'
116127
}
117-
echo "osx_arm64=$(extract_hash osx-arm64)" >> "$GITHUB_OUTPUT"
118-
echo "linux_x64=$(extract_hash linux-x64)" >> "$GITHUB_OUTPUT"
119-
echo "linux_arm64=$(extract_hash linux-arm64)" >> "$GITHUB_OUTPUT"
120-
echo "win_x64=$(extract_hash win-x64)" >> "$GITHUB_OUTPUT"
121-
echo "win_arm64=$(extract_hash win-arm64)" >> "$GITHUB_OUTPUT"
128+
echo "osx_arm64=$(extract_hash Avalonia osx-arm64)" >> "$GITHUB_OUTPUT"
129+
echo "linux_x64=$(extract_hash Avalonia linux-x64)" >> "$GITHUB_OUTPUT"
130+
echo "linux_arm64=$(extract_hash Avalonia linux-arm64)" >> "$GITHUB_OUTPUT"
131+
echo "win_x64=$(extract_hash Avalonia win-x64)" >> "$GITHUB_OUTPUT"
132+
echo "win_arm64=$(extract_hash Avalonia win-arm64)" >> "$GITHUB_OUTPUT"
133+
echo "headless_osx_arm64=$(extract_hash Headless osx-arm64)" >> "$GITHUB_OUTPUT"
134+
echo "headless_linux_x64=$(extract_hash Headless linux-x64)" >> "$GITHUB_OUTPUT"
135+
echo "headless_linux_arm64=$(extract_hash Headless linux-arm64)" >> "$GITHUB_OUTPUT"
136+
echo "headless_win_x64=$(extract_hash Headless win-x64)" >> "$GITHUB_OUTPUT"
137+
echo "headless_win_arm64=$(extract_hash Headless win-arm64)" >> "$GITHUB_OUTPUT"
122138
123139
- name: Update Homebrew tap
124140
env:
@@ -127,40 +143,48 @@ jobs:
127143
OSX_ARM64_HASH: ${{ steps.hashes.outputs.osx_arm64 }}
128144
LINUX_X64_HASH: ${{ steps.hashes.outputs.linux_x64 }}
129145
LINUX_ARM64_HASH: ${{ steps.hashes.outputs.linux_arm64 }}
146+
HEADLESS_OSX_ARM64_HASH: ${{ steps.hashes.outputs.headless_osx_arm64 }}
147+
HEADLESS_LINUX_X64_HASH: ${{ steps.hashes.outputs.headless_linux_x64 }}
148+
HEADLESS_LINUX_ARM64_HASH: ${{ steps.hashes.outputs.headless_linux_arm64 }}
130149
run: |
131150
git clone "https://x-access-token:${GH_TOKEN}@github.com/highbyte/homebrew-dotnet-6502.git"
132151
cd homebrew-dotnet-6502
133152
134-
# Update Cask (macOS)
153+
# Update Cask (macOS desktop app)
135154
sed -i "s/version \".*\"/version \"${VERSION}\"/" Casks/dotnet-6502.rb
136155
sed -i "s/sha256 \".*\"/sha256 \"${OSX_ARM64_HASH}\"/" Casks/dotnet-6502.rb
137156
138-
# Update Formula (Linux) - use Python for multi-hash replacement
157+
# Update Formulas - use Python for multi-hash replacement
139158
python3 <<'PYEOF'
140159
import re, os
141160
161+
def update_formula(path, version, hashes):
162+
with open(path, "r") as f:
163+
content = f.read()
164+
content = re.sub(r'version ".*?"', f'version "{version}"', content)
165+
idx = [0]
166+
def replace_hash(m):
167+
if idx[0] < len(hashes):
168+
h = hashes[idx[0]]
169+
idx[0] += 1
170+
return f'sha256 "{h}"'
171+
return m.group(0)
172+
content = re.sub(r'sha256 ".*?"', replace_hash, content)
173+
with open(path, "w") as f:
174+
f.write(content)
175+
142176
version = os.environ["VERSION"]
143-
linux_x64_hash = os.environ["LINUX_X64_HASH"]
144-
linux_arm64_hash = os.environ["LINUX_ARM64_HASH"]
145-
146-
with open("Formula/dotnet-6502.rb", "r") as f:
147-
content = f.read()
148-
149-
content = re.sub(r'version ".*?"', f'version "{version}"', content)
150-
151-
# Replace hashes in order: first occurrence is x64, second is arm64
152-
hashes = [linux_x64_hash, linux_arm64_hash]
153-
idx = [0]
154-
def replace_hash(m):
155-
if idx[0] < len(hashes):
156-
h = hashes[idx[0]]
157-
idx[0] += 1
158-
return f'sha256 "{h}"'
159-
return m.group(0)
160-
content = re.sub(r'sha256 ".*?"', replace_hash, content)
161-
162-
with open("Formula/dotnet-6502.rb", "w") as f:
163-
f.write(content)
177+
# dotnet-6502 formula: Linux only (x64, arm64)
178+
update_formula("Formula/dotnet-6502.rb", version, [
179+
os.environ["LINUX_X64_HASH"],
180+
os.environ["LINUX_ARM64_HASH"],
181+
])
182+
# dotnet-6502-headless formula: macOS arm64, then Linux x64, then Linux arm64
183+
update_formula("Formula/dotnet-6502-headless.rb", version, [
184+
os.environ["HEADLESS_OSX_ARM64_HASH"],
185+
os.environ["HEADLESS_LINUX_X64_HASH"],
186+
os.environ["HEADLESS_LINUX_ARM64_HASH"],
187+
])
164188
PYEOF
165189
166190
git config user.name "github-actions[bot]"
@@ -175,6 +199,8 @@ jobs:
175199
TAG: ${{ steps.version.outputs.tag }}
176200
WIN_X64_HASH: ${{ steps.hashes.outputs.win_x64 }}
177201
WIN_ARM64_HASH: ${{ steps.hashes.outputs.win_arm64 }}
202+
HEADLESS_WIN_X64_HASH: ${{ steps.hashes.outputs.headless_win_x64 }}
203+
HEADLESS_WIN_ARM64_HASH: ${{ steps.hashes.outputs.headless_win_arm64 }}
178204
run: |
179205
git clone "https://x-access-token:${GH_TOKEN}@github.com/highbyte/scoop-dotnet-6502.git"
180206
cd scoop-dotnet-6502
@@ -184,21 +210,21 @@ jobs:
184210
185211
version = os.environ["VERSION"]
186212
tag = os.environ["TAG"]
187-
win_x64_hash = os.environ["WIN_X64_HASH"]
188-
win_arm64_hash = os.environ["WIN_ARM64_HASH"]
189-
190-
with open("bucket/dotnet-6502.json", "r") as f:
191-
manifest = json.load(f)
192-
193-
manifest["version"] = version
194-
manifest["architecture"]["64bit"]["url"] = f"https://github.com/highbyte/dotnet-6502/releases/download/{tag}/DotNet6502-Avalonia-win-x64.zip"
195-
manifest["architecture"]["64bit"]["hash"] = win_x64_hash
196-
manifest["architecture"]["arm64"]["url"] = f"https://github.com/highbyte/dotnet-6502/releases/download/{tag}/DotNet6502-Avalonia-win-arm64.zip"
197-
manifest["architecture"]["arm64"]["hash"] = win_arm64_hash
198213
199-
with open("bucket/dotnet-6502.json", "w") as f:
200-
json.dump(manifest, f, indent=4)
201-
f.write("\n")
214+
def update_manifest(path, app_name, win_x64_hash, win_arm64_hash):
215+
with open(path, "r") as f:
216+
manifest = json.load(f)
217+
manifest["version"] = version
218+
manifest["architecture"]["64bit"]["url"] = f"https://github.com/highbyte/dotnet-6502/releases/download/{tag}/DotNet6502-{app_name}-win-x64.zip"
219+
manifest["architecture"]["64bit"]["hash"] = win_x64_hash
220+
manifest["architecture"]["arm64"]["url"] = f"https://github.com/highbyte/dotnet-6502/releases/download/{tag}/DotNet6502-{app_name}-win-arm64.zip"
221+
manifest["architecture"]["arm64"]["hash"] = win_arm64_hash
222+
with open(path, "w") as f:
223+
json.dump(manifest, f, indent=4)
224+
f.write("\n")
225+
226+
update_manifest("bucket/dotnet-6502.json", "Avalonia", os.environ["WIN_X64_HASH"], os.environ["WIN_ARM64_HASH"])
227+
update_manifest("bucket/dotnet-6502-headless.json", "Headless", os.environ["HEADLESS_WIN_X64_HASH"], os.environ["HEADLESS_WIN_ARM64_HASH"])
202228
PYEOF
203229
204230
git config user.name "github-actions[bot]"

.vscode/launch.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@
5151
"console": "externalTerminal",
5252
"stopAtEntry": false
5353
},
54+
{
55+
"name": "Headless app - .NET Core Launch",
56+
"type": "coreclr",
57+
"request": "launch",
58+
"preLaunchTask": "build headless app",
59+
"program": "${workspaceFolder}/src/apps/Highbyte.DotNet6502.App.Headless/bin/Debug/net10.0/Highbyte.DotNet6502.App.Headless.dll",
60+
"args": [],
61+
"cwd": "${workspaceFolder}/src/apps/Highbyte.DotNet6502.App.Headless/bin/Debug/net10.0",
62+
"console": "integratedTerminal",
63+
"stopAtEntry": false
64+
},
5465
{
5566
"name": "Avalonia desktop app - .NET Core Launch",
5667
"type": "coreclr",

.vscode/tasks.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,22 @@
8181
"isDefault": true
8282
}
8383
},
84+
{
85+
"label": "build headless app",
86+
"command": "dotnet",
87+
"type": "process",
88+
"args": [
89+
"build",
90+
"${workspaceFolder}/src/apps/Highbyte.DotNet6502.App.Headless/Highbyte.DotNet6502.App.Headless.csproj",
91+
"/property:GenerateFullPaths=true",
92+
"/consoleloggerparameters:NoSummary"
93+
],
94+
"problemMatcher": "$msCompile",
95+
"group": {
96+
"kind": "build",
97+
"isDefault": true
98+
}
99+
},
84100
{
85101
"label": "build avalonia desktop app",
86102
"command": "dotnet",

Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@
6868
<PackageVersion Include="Silk.NET.OpenGL.Extensions.ImGui" Version="2.22.0" />
6969
<PackageVersion Include="Silk.NET.SDL" Version="2.22.0" />
7070
<PackageVersion Include="Silk.NET.Windowing" Version="2.22.0" />
71+
<!-- SixLabors ImageSharp -->
72+
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
7173
<!-- SkiaSharp -->
7274
<PackageVersion Include="SkiaSharp" Version="3.119.1" />
7375
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.1" />

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,14 @@
4343
| ------------------------------------------- | ------------------------------------------------ | ------------------------------------------------- |
4444
| <img src="doc/Screenshots/AvaloniaDesktop_C64_Basic.png" title="Avalonia Desktop app, C64 Basic" /> | <img src="doc/Screenshots/SadConsole_C64_Basic.png" title="SadConsole native app, C64 Basic" /> | <img src="doc/Screenshots/SilkNetNative_C64_BubbleBobble.png" title="SilkNet native app, C64 Bubble Bobble" /> |
4545

46-
See [Desktop Apps](doc/DESKTOP_APPS.md) for download links for pre-built executables and instructions for Windows, Linux, and macOS.
46+
See [Desktop Apps](doc/INSTALL_DESKTOP_APPS.md) for download links for pre-built executables and instructions for Windows, Linux, and macOS.
47+
48+
## Headless app
49+
50+
[Headless app](doc/APPS_HEADLESS.md) — runs the emulator without any UI, rendering, audio, or user input. Controlled entirely via CLI arguments and Lua scripts. Useful for automation, scripting, and CI workflows.
51+
52+
See [Install Headless App](doc/INSTALL_HEADLESS.md) for download links and installation instructions for Windows, Linux, and macOS.
53+
4754

4855
## VS Code debugger extension
4956

doc/APPS_AVALONIA.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Technologies
2424
- Input: `Highbyte.DotNet6502.Impl.Avalonia`.
2525
- Audio: `Highbyte.DotNet6502.Impl.NAudio`. Synthesizer via `NAudio` and playback via `WebAudio JS interop`.
2626

27-
See [here](DESKTOP_APPS.md) how to download and run pre-built executables.
27+
See [here](INSTALL_DESKTOP_APPS.md) how to download and run pre-built executables.
2828

2929
# Features
3030

@@ -53,6 +53,58 @@ See [here](../tools/vscode-extension/README.md).
5353
## Scripting: Lua scripting via MoonSharp
5454
See [here](SCRIPTING.md).
5555

56+
## CLI arguments
57+
58+
The desktop app can be launched from the command line with arguments to control logging, scripting, and automated startup. There are two mutually exclusive modes:
59+
60+
### Scripting mode (`--script` / `--scriptDir`)
61+
62+
When a Lua script is supplied the script owns all emulator setup and lifecycle — system selection, start, load, and quit.
63+
64+
| Argument | Description |
65+
|---|---|
66+
| `--script <path>` | Load and run a Lua script (can be specified multiple times) |
67+
| `--scriptDir <path>` | Override the script directory from `appsettings.json` |
68+
| `--console-log` / `-c` | Enable console logging |
69+
| `--log-level <level>` / `-l <level>` | Set console log level (Trace/Debug/Information/Warning/Error) |
70+
| `--enableExternalDebug` | Enable VS Code debug adapter (DAP) over TCP |
71+
| `--debug-port <port>` | TCP port for the debug adapter (default: 6502) |
72+
| `--debug-wait` | Wait for a debug client to connect before starting |
73+
74+
> [!IMPORTANT]
75+
> `--script` and `--scriptDir` are **mutually exclusive** with `--system`, `--systemVariant`, `--start`, `--waitForSystemReady`, `--loadPrg`, and `--runLoadedProgram`. The script is responsible for all emulator setup and lifecycle. Combining them is an error.
76+
77+
### Automated startup mode (`--start`)
78+
79+
Used when driving the emulator from the command line without a script — primarily by the VS Code debugger extension.
80+
81+
| Argument | Description |
82+
|---|---|
83+
| `--system <name>` | Select a system (e.g. `C64`, `Generic`) |
84+
| `--systemVariant <name>` | Select a system variant. Requires `--system`. |
85+
| `--start` | Auto-start the emulator after selection |
86+
| `--waitForSystemReady` | Wait until the system reports ready before continuing. Requires `--start`. |
87+
| `--loadPrg <path>` | Load a `.prg` file into memory. Requires `--start`. |
88+
| `--runLoadedProgram` | Run the loaded `.prg` file after loading. Requires `--start` and `--loadPrg`. |
89+
| `--console-log` / `-c` | Enable console logging |
90+
| `--log-level <level>` / `-l <level>` | Set console log level (Trace/Debug/Information/Warning/Error) |
91+
| `--enableExternalDebug` | Enable VS Code debug adapter (DAP) over TCP |
92+
| `--debug-port <port>` | TCP port for the debug adapter (default: 6502) |
93+
| `--debug-wait` | Wait for a debug client to connect before starting |
94+
95+
### Examples
96+
97+
```
98+
# Run a Lua script (script owns all setup and lifecycle)
99+
./Highbyte.DotNet6502.App.Avalonia.Desktop --script scripts/example_c64_basic_readwrite.lua
100+
101+
# Start C64 and load a .prg file via CLI (no script)
102+
./Highbyte.DotNet6502.App.Avalonia.Desktop --system C64 --start --loadPrg game.prg --runLoadedProgram
103+
104+
# Start with debug adapter for VS Code, waiting for client
105+
./Highbyte.DotNet6502.App.Avalonia.Desktop --system C64 --start --enableExternalDebug --debug-port 6502 --debug-wait
106+
```
107+
56108
## UI
57109

58110
### Menu

0 commit comments

Comments
 (0)