feat(xteink): add native emulator harness
This commit is contained in:
@@ -26,6 +26,24 @@ nix develop
|
|||||||
ninja -C build qemu-system-riscv32
|
ninja -C build qemu-system-riscv32
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or build the native xteink target directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build .#qemu-native-xteink
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent emulator
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run .#xteink-emu -- start --firmware firmware.bin --sdcard sdcard.img
|
||||||
|
nix run .#xteink-emu -- wait --timeout 30 --match 'ready'
|
||||||
|
nix run .#xteink-emu -- button bottom-2 --hold-ms 100
|
||||||
|
nix run .#xteink-emu -- screenshot --output screen.png
|
||||||
|
nix run .#xteink-emu -- stop
|
||||||
|
```
|
||||||
|
|
||||||
|
State defaults to `/tmp/xteink-emu-$UID`; pass `--state-dir` to any subcommand for parallel instances. `wait` advances a persistent log cursor after each match.
|
||||||
|
|
||||||
### WebAssembly
|
### WebAssembly
|
||||||
|
|
||||||
Driven by the parent repo, not here:
|
Driven by the parent repo, not here:
|
||||||
|
|||||||
Generated
+61
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1784796856,
|
||||||
|
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -7,13 +7,102 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
{ self, nixpkgs, flake-utils }:
|
{ self
|
||||||
|
, nixpkgs
|
||||||
|
, flake-utils
|
||||||
|
,
|
||||||
|
}:
|
||||||
flake-utils.lib.eachDefaultSystem (
|
flake-utils.lib.eachDefaultSystem (
|
||||||
system:
|
system:
|
||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
keycodemapdb = pkgs.fetchFromGitLab {
|
||||||
|
owner = "qemu-project";
|
||||||
|
repo = "keycodemapdb";
|
||||||
|
rev = "f5772a62ec52591ff6870b7e8ef32482371f22c6";
|
||||||
|
hash = "sha256-GbZ5mrUYLXMi0IX4IZzles0Oyc095ij2xAsiLNJwfKQ=";
|
||||||
|
};
|
||||||
|
qemu-native-xteink = pkgs.stdenv.mkDerivation {
|
||||||
|
pname = "qemu-native-xteink";
|
||||||
|
version = pkgs.lib.strings.trim (builtins.readFile ./VERSION);
|
||||||
|
src = pkgs.lib.cleanSource ./.;
|
||||||
|
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
bison
|
||||||
|
flex
|
||||||
|
meson
|
||||||
|
ninja
|
||||||
|
perl
|
||||||
|
pkg-config
|
||||||
|
python3
|
||||||
|
];
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
dtc
|
||||||
|
glib
|
||||||
|
libgcrypt
|
||||||
|
libpng
|
||||||
|
pixman
|
||||||
|
zlib
|
||||||
|
];
|
||||||
|
|
||||||
|
dontUseMesonConfigure = true;
|
||||||
|
|
||||||
|
postPatch = ''
|
||||||
|
cp -r ${keycodemapdb} subprojects/keycodemapdb
|
||||||
|
chmod -R u+w subprojects/keycodemapdb
|
||||||
|
sed -i "/subdir('fp')/d" tests/meson.build
|
||||||
|
'';
|
||||||
|
|
||||||
|
configurePhase = ''
|
||||||
|
runHook preConfigure
|
||||||
|
./configure \
|
||||||
|
--prefix=$out \
|
||||||
|
--target-list=riscv32-softmmu \
|
||||||
|
--with-devices-riscv32=xteink \
|
||||||
|
--with-coroutine=ucontext \
|
||||||
|
--without-default-features \
|
||||||
|
--enable-system \
|
||||||
|
--enable-gcrypt \
|
||||||
|
--enable-pixman \
|
||||||
|
--enable-png \
|
||||||
|
--disable-werror \
|
||||||
|
--disable-docs \
|
||||||
|
--disable-tools
|
||||||
|
runHook postConfigure
|
||||||
|
'';
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
ninja -C build qemu-system-riscv32
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
ninja -C build install
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
xteink-emu = pkgs.writeShellApplication {
|
||||||
|
name = "xteink-emu";
|
||||||
|
runtimeInputs = [ pkgs.uv ];
|
||||||
|
text = ''
|
||||||
|
export XTEINK_QEMU=${qemu-native-xteink}/bin/qemu-system-riscv32
|
||||||
|
export XTEINK_BIOS=${qemu-native-xteink}/share/qemu
|
||||||
|
export XTEINK_BOOTLOADER=${./scripts/assets/esp32c3-bootloader.bin}
|
||||||
|
export XTEINK_PARTITIONS=${./scripts/assets/crosspoint-partitions.bin}
|
||||||
|
exec uv run --script ${./scripts/xteink-emu.py} "$@"
|
||||||
|
'';
|
||||||
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
packages.qemu-native-xteink = qemu-native-xteink;
|
||||||
|
packages.xteink-emu = xteink-emu;
|
||||||
|
apps.xteink-emu = {
|
||||||
|
type = "app";
|
||||||
|
program = "${xteink-emu}/bin/xteink-emu";
|
||||||
|
};
|
||||||
|
|
||||||
# Native build/test shell. The wasm target is built by the parent
|
# Native build/test shell. The wasm target is built by the parent
|
||||||
# xteink-web-emulator repo via its emsdk Docker toolchain, not here.
|
# xteink-web-emulator repo via its emsdk Docker toolchain, not here.
|
||||||
#
|
#
|
||||||
@@ -23,16 +112,17 @@
|
|||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
inputsFrom = [ pkgs.qemu ];
|
inputsFrom = [ pkgs.qemu ];
|
||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
gnumake
|
dosfstools
|
||||||
git
|
git
|
||||||
ninja
|
gnumake
|
||||||
meson
|
|
||||||
pkg-config
|
|
||||||
libgcrypt
|
libgcrypt
|
||||||
libslirp
|
libslirp
|
||||||
pixman
|
meson
|
||||||
dosfstools
|
|
||||||
mtools
|
mtools
|
||||||
|
ninja
|
||||||
|
pixman
|
||||||
|
pkg-config
|
||||||
|
uv
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Executable
+301
@@ -0,0 +1,301 @@
|
|||||||
|
#!/usr/bin/env -S uv run --script
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.11"
|
||||||
|
# dependencies = []
|
||||||
|
# ///
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FLASH_SIZE = 16 * 1024 * 1024
|
||||||
|
APP_OFFSET = 0x10000
|
||||||
|
PARTITIONS_OFFSET = 0x8000
|
||||||
|
RELEASED_ADC = 4095
|
||||||
|
BUTTONS = {
|
||||||
|
"left": ("/machine/adc", "adci[2]", 2242, RELEASED_ADC),
|
||||||
|
"right": ("/machine/adc", "adci[2]", 5, RELEASED_ADC),
|
||||||
|
"bottom-1": ("/machine/adc", "adci[1]", 3512, RELEASED_ADC),
|
||||||
|
"bottom-2": ("/machine/adc", "adci[1]", 2694, RELEASED_ADC),
|
||||||
|
"bottom-3": ("/machine/adc", "adci[1]", 1493, RELEASED_ADC),
|
||||||
|
"bottom-4": ("/machine/adc", "adci[1]", 5, RELEASED_ADC),
|
||||||
|
"power": ("/machine/gpio", "input-level[3]", False, True),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_state_dir():
|
||||||
|
return Path(tempfile.gettempdir()) / f"xteink-emu-{os.getuid()}"
|
||||||
|
|
||||||
|
|
||||||
|
def state_dir(args):
|
||||||
|
return Path(args.state_dir).expanduser().resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def require_env(name):
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError(f"{name} is not set; run xteink-emu through its Nix app")
|
||||||
|
return Path(value)
|
||||||
|
|
||||||
|
|
||||||
|
def pid_from(state):
|
||||||
|
try:
|
||||||
|
return int((state / "pid").read_text().strip())
|
||||||
|
except (FileNotFoundError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def process_alive(pid):
|
||||||
|
if pid is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def qmp_command(state, execute, arguments=None, timeout=5):
|
||||||
|
with socket.socket(socket.AF_UNIX) as sock:
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
sock.connect(str(state / "qmp.sock"))
|
||||||
|
stream = sock.makefile("rwb")
|
||||||
|
json.loads(stream.readline())
|
||||||
|
|
||||||
|
def send(command):
|
||||||
|
stream.write(json.dumps(command).encode() + b"\n")
|
||||||
|
stream.flush()
|
||||||
|
while True:
|
||||||
|
response = json.loads(stream.readline())
|
||||||
|
if "event" in response:
|
||||||
|
continue
|
||||||
|
if "error" in response:
|
||||||
|
raise RuntimeError(response["error"]["desc"])
|
||||||
|
return response.get("return")
|
||||||
|
|
||||||
|
send({"execute": "qmp_capabilities"})
|
||||||
|
command = {"execute": execute}
|
||||||
|
if arguments is not None:
|
||||||
|
command["arguments"] = arguments
|
||||||
|
return send(command)
|
||||||
|
|
||||||
|
|
||||||
|
def require_running(state):
|
||||||
|
pid = pid_from(state)
|
||||||
|
if not process_alive(pid):
|
||||||
|
raise RuntimeError(f"xteink-emu is not running in {state}")
|
||||||
|
return pid
|
||||||
|
|
||||||
|
|
||||||
|
def build_flash(firmware, output):
|
||||||
|
app = firmware.read_bytes()
|
||||||
|
if len(app) == FLASH_SIZE:
|
||||||
|
output.write_bytes(app)
|
||||||
|
return
|
||||||
|
if not app or app[0] != 0xE9:
|
||||||
|
raise RuntimeError(f"{firmware} is not an ESP32 app image")
|
||||||
|
if APP_OFFSET + len(app) > FLASH_SIZE:
|
||||||
|
raise RuntimeError(f"{firmware} does not fit in a 16 MiB flash image")
|
||||||
|
|
||||||
|
flash = bytearray(b"\xff" * FLASH_SIZE)
|
||||||
|
bootloader = require_env("XTEINK_BOOTLOADER").read_bytes()
|
||||||
|
partitions = require_env("XTEINK_PARTITIONS").read_bytes()
|
||||||
|
flash[: len(bootloader)] = bootloader
|
||||||
|
flash[PARTITIONS_OFFSET : PARTITIONS_OFFSET + len(partitions)] = partitions
|
||||||
|
flash[APP_OFFSET : APP_OFFSET + len(app)] = app
|
||||||
|
output.write_bytes(flash)
|
||||||
|
|
||||||
|
|
||||||
|
def start(args):
|
||||||
|
state = state_dir(args)
|
||||||
|
old_pid = pid_from(state)
|
||||||
|
if process_alive(old_pid):
|
||||||
|
raise RuntimeError(f"xteink-emu is already running as PID {old_pid} in {state}")
|
||||||
|
|
||||||
|
firmware = Path(args.firmware).expanduser().resolve()
|
||||||
|
sdcard = Path(args.sdcard).expanduser().resolve()
|
||||||
|
if not firmware.is_file():
|
||||||
|
raise RuntimeError(f"firmware not found: {firmware}")
|
||||||
|
if not sdcard.is_file():
|
||||||
|
raise RuntimeError(f"SD card image not found: {sdcard}")
|
||||||
|
|
||||||
|
state.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
for name in ("pid", "qmp.sock", "serial.log", "qemu.log", "wait.offset"):
|
||||||
|
(state / name).unlink(missing_ok=True)
|
||||||
|
(state / "serial.log").touch()
|
||||||
|
(state / "wait.offset").write_text("0\n")
|
||||||
|
flash = state / "flash.bin"
|
||||||
|
build_flash(firmware, flash)
|
||||||
|
|
||||||
|
command = [
|
||||||
|
str(require_env("XTEINK_QEMU")),
|
||||||
|
"-machine", "xteink,variant=x3",
|
||||||
|
"-accel", "tcg",
|
||||||
|
"-L", str(require_env("XTEINK_BIOS")),
|
||||||
|
"-display", "none",
|
||||||
|
"-serial", f"file:{state / 'serial.log'}",
|
||||||
|
"-monitor", "none",
|
||||||
|
"-qmp", f"unix:{state / 'qmp.sock'},server=on,wait=off",
|
||||||
|
"-nic", "none",
|
||||||
|
"-drive", f"file={flash},if=mtd,format=raw",
|
||||||
|
"-drive", f"file={sdcard},if=sd,format=raw",
|
||||||
|
]
|
||||||
|
qemu_log = (state / "qemu.log").open("ab")
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=qemu_log,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
qemu_log.close()
|
||||||
|
(state / "pid").write_text(f"{process.pid}\n")
|
||||||
|
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if process.poll() is not None:
|
||||||
|
message = (state / "qemu.log").read_text(errors="replace")
|
||||||
|
raise RuntimeError(f"QEMU exited during startup\n{message}")
|
||||||
|
try:
|
||||||
|
qmp_command(state, "query-status")
|
||||||
|
print(f"started PID {process.pid}")
|
||||||
|
print(f"state: {state}")
|
||||||
|
print(f"serial log: {state / 'serial.log'}")
|
||||||
|
return
|
||||||
|
except (FileNotFoundError, ConnectionRefusedError, socket.timeout):
|
||||||
|
time.sleep(0.05)
|
||||||
|
process.terminate()
|
||||||
|
raise RuntimeError("QEMU did not open its QMP socket")
|
||||||
|
|
||||||
|
|
||||||
|
def stop(args):
|
||||||
|
state = state_dir(args)
|
||||||
|
pid = require_running(state)
|
||||||
|
try:
|
||||||
|
qmp_command(state, "quit")
|
||||||
|
except (ConnectionError, FileNotFoundError):
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while process_alive(pid) and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
if process_alive(pid):
|
||||||
|
os.kill(pid, signal.SIGKILL)
|
||||||
|
(state / "pid").unlink(missing_ok=True)
|
||||||
|
(state / "qmp.sock").unlink(missing_ok=True)
|
||||||
|
print(f"stopped PID {pid}")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_log(args):
|
||||||
|
state = state_dir(args)
|
||||||
|
require_running(state)
|
||||||
|
log_path = state / "serial.log"
|
||||||
|
offset_path = state / "wait.offset"
|
||||||
|
try:
|
||||||
|
pattern = re.compile(args.match)
|
||||||
|
except re.error as error:
|
||||||
|
raise RuntimeError(f"invalid regex: {error}") from error
|
||||||
|
try:
|
||||||
|
offset = int(offset_path.read_text().strip())
|
||||||
|
except (FileNotFoundError, ValueError):
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
deadline = time.monotonic() + args.timeout
|
||||||
|
with log_path.open("r", errors="replace") as log:
|
||||||
|
log.seek(offset)
|
||||||
|
while True:
|
||||||
|
line = log.readline()
|
||||||
|
if line:
|
||||||
|
if pattern.search(line):
|
||||||
|
offset_path.write_text(f"{log.tell()}\n")
|
||||||
|
print(line, end="" if line.endswith("\n") else "\n")
|
||||||
|
return
|
||||||
|
continue
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise RuntimeError(f"timed out after {args.timeout:g}s waiting for /{args.match}/")
|
||||||
|
if not process_alive(pid_from(state)):
|
||||||
|
raise RuntimeError("QEMU exited while waiting for serial output")
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def button(args):
|
||||||
|
state = state_dir(args)
|
||||||
|
require_running(state)
|
||||||
|
path, prop, pressed, released = BUTTONS[args.name]
|
||||||
|
qmp_command(state, "qom-set", {"path": path, "property": prop, "value": pressed})
|
||||||
|
try:
|
||||||
|
time.sleep(args.hold_ms / 1000)
|
||||||
|
finally:
|
||||||
|
qmp_command(state, "qom-set", {"path": path, "property": prop, "value": released})
|
||||||
|
|
||||||
|
|
||||||
|
def screenshot(args):
|
||||||
|
state = state_dir(args)
|
||||||
|
require_running(state)
|
||||||
|
output = Path(args.output).expanduser().resolve()
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
qmp_command(state, "screendump", {"filename": str(output), "format": "png"}, timeout=30)
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
|
||||||
|
def parser():
|
||||||
|
root = argparse.ArgumentParser(prog="xteink-emu")
|
||||||
|
commands = root.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
def add_state(command):
|
||||||
|
command.add_argument("--state-dir", default=default_state_dir())
|
||||||
|
|
||||||
|
command = commands.add_parser("start")
|
||||||
|
add_state(command)
|
||||||
|
command.add_argument("--firmware", required=True)
|
||||||
|
command.add_argument("--sdcard", required=True)
|
||||||
|
command.set_defaults(handler=start)
|
||||||
|
|
||||||
|
command = commands.add_parser("stop")
|
||||||
|
add_state(command)
|
||||||
|
command.set_defaults(handler=stop)
|
||||||
|
|
||||||
|
command = commands.add_parser("wait")
|
||||||
|
add_state(command)
|
||||||
|
command.add_argument("--timeout", type=float, required=True, help="seconds")
|
||||||
|
command.add_argument("--match", required=True, help="regular expression")
|
||||||
|
command.set_defaults(handler=wait_for_log)
|
||||||
|
|
||||||
|
command = commands.add_parser("button")
|
||||||
|
add_state(command)
|
||||||
|
command.add_argument("name", choices=BUTTONS)
|
||||||
|
command.add_argument("--hold-ms", type=int, default=100)
|
||||||
|
command.set_defaults(handler=button)
|
||||||
|
|
||||||
|
command = commands.add_parser("screenshot")
|
||||||
|
add_state(command)
|
||||||
|
command.add_argument("--output", required=True)
|
||||||
|
command.set_defaults(handler=screenshot)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parser().parse_args()
|
||||||
|
if getattr(args, "timeout", 0) < 0:
|
||||||
|
raise RuntimeError("--timeout must not be negative")
|
||||||
|
if getattr(args, "hold_ms", 0) < 0:
|
||||||
|
raise RuntimeError("--hold-ms must not be negative")
|
||||||
|
args.handler(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (RuntimeError, OSError) as error:
|
||||||
|
print(f"xteink-emu: {error}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
Reference in New Issue
Block a user