#!/usr/bin/env bash # # joey-run: run a built JoeyLib program in the right emulator (or, for the # SDL3 port, natively, for the web port, in a browser, for the Win32 port, # under Wine, and for the Win16 port, on Windows 3.11 in DOSBox; the PSP # port's in headless PPSSPP; the Haiku and BeOS ports' in their QEMU VMs). # A macOS port's app is recognised but not run: nothing here runs Mac # programs. # # Usage: # ./joey-run [options] PROGRAM [-- ARGS...] # # PROGRAM is any of: # a program file build/dos/bin/DRAW.EXE, build/atarist/bin/DRAW.PRG (or build/ste), # build/amiga/bin/Draw, build/x68000/bin/DRAW.X, # build/iigs/bin/DRAW, build/dvx/bin/apps/kpunch/draw/draw.app, # build/sdl3/bin/DRAW, build/web/bin/DRAW/DRAW.html, # build/psp/bin/DRAW/EBOOT.PBP, build/haiku/bin/DRAW # (or build/haiku32, build/beos), build/macos/bin/DRAW.app # an app folder a program in a folder of its own with its DATA: # build/dvx/bin/apps/kpunch/audio # an IIgs disk image build/iigs/bin/DRAW.2mg (make iigs-game-disk), or # a data disk such as joey.2mg (boots GS/OS with it) # an example name draw, audio, uber, ... together with --port # The machine is recognised from the file itself. A program built outside # JoeyLib (make/project.mk) runs the same way: name its file, folder or disk # image in the project's build/, or give its name with --project and --port. # # Options: # --port PORT iigs, amiga, atarist, ste, dos, x68000, dvx, sdl3, web, macos, win32, # win16, psp, haiku, haiku32 or beos # --project DIR look a program name up in the project at DIR (its # build/PORT/bin) instead of JoeyLib's examples; DVX runs # keep their DVX in DIR's build/dvx/run # --data DIR copy DIR's files into the program's DATA folder for # this run (for example the game an interpreter plays) # --headless no window and no sound, for scripts and CI; stops # after 60 seconds unless --seconds says otherwise # --seconds N stop after N seconds # --snapshot FILE save a PNG of the screen when the run stops # (needs --seconds or --headless) # --machine rom01 IIgs: the ROM 01 machine instead of ROM 03 # --machine ste Atari: the machine Hatari emulates: st, megast, ste # or megaste. By default an ST-build program (make # atarist) gets a plain ST and an STe-build one (make # ste) an STe; the ST build runs on all four, the STe # build only on ste and megaste # --machine a1200 Amiga: the machine FS-UAE emulates: a500 (the # default), a1200 (68EC020, AGA) or a4000 (68040, # AGA), each on its own Kickstart (install emulator:aga # for the AGA ones) # --ram SIZE IIgs: memory size for MAME (default 4M) # --debug IIgs and X68000: start MAME's debugger # --serial HOST:PORT connect the machine's serial port to the TCP server # at HOST:PORT: a BBS, a program of your own, another # run's --serial-listen, or `nc -l PORT` # --serial-listen PORT # the serial port waits for a TCP connection on PORT # (on every network interface) instead # --serial-baud N IIgs and X68000: the rate MAME's end of the wire runs # at, which must match the program's (default 9600, # always 8N1); the other emulators pass bytes at any rate # -h, --help this text # # Arguments after -- are passed to the program (DOS, SDL3, Win32 and Win16 # only: the other machines' autostart cannot pass arguments). # # An SDL3 program is a native program: it runs on this machine directly, in # a window (JOEYLIB_SCALE, JOEYLIB_ASPECT and JOEYLIB_FULLSCREEN in the # environment size it; see docs). --headless runs it on a private virtual # display at one pixel per stage pixel, so --snapshot is the 320x200 stage. # It has no serial port. # # A web program is a page: joey-run serves its folder over HTTP on this # machine (127.0.0.1 only) and opens it in your browser; stop with Ctrl+C. # --headless runs it in headless Chromium instead, at one canvas pixel per # stage pixel, so --snapshot is the stage. Its DATA is packed in when it is # built, so --data does not apply, and it has no serial port. # # A Win16 program runs on Windows for Workgroups 3.11 in DOSBox Staging (a # fresh copy, each run, of the one the installer set up from your own disc: # install emulator:win16), as Windows' shell, so Windows ends when the # program does: in DOSBox's window, at 800x600, with sound, DOSBox's game # port through KPJOY.DRV and COM1 through KPCOMM.DRV (JoeyLib's joystick and # communications drivers for Windows 3.1). Its folder is # drive D:. JOEYLIB_SCALE, JOEYLIB_ASPECT and JOEYLIB_FULLSCREEN in the # environment go into Windows' DOS environment, so they size it as they do # an SDL3 or Win32 program; Alt with the keypad's Enter switches full screen # (DOSBox keeps Alt+Enter). --headless runs it on a private virtual display # at one window pixel per stage pixel, and --snapshot is DOSBox's own # picture of the 800x600 screen. # # A PSP program runs only with --headless here: under PPSSPPHeadless (the # installer's patched build, emulator:psp), from its folder on a private # memory stick (PSP/GAME/NAME, as on a PSP), with every memory access checked # as a PSP checks it; a bad one stops the run and joey-run names the line. # --snapshot is the PSP's 480x272 screen. To play it, copy its folder into # PSP/GAME/ on a PSP's memory stick, or open its EBOOT.PBP in PPSSPP. It has # no serial port. # # A Haiku (haiku: x86_64; haiku32: x86) or BeOS R5 (beos) program runs in # its VM under QEMU (install emulator:PORT; BeOS's is made from your own # BeOS 5 Professional CD), from a copy of its folder on a second disk: in # QEMU's window, with sound (Haiku's; BeOS's VM has none). The machine # powers off when the program ends, and what the program wrote in its # folder (saves, its log) is copied back. JOEYLIB_SCALE, JOEYLIB_ASPECT and # JOEYLIB_FULLSCREEN in the environment size its window, as they do an SDL3 # program's. --headless runs it at one window pixel per stage pixel without # a window; --seconds counts from when the job starts, after the boot, and # --snapshot is QEMU's picture of the whole screen. They have no serial # port here. # # A DVX program runs in DOSBox Staging on a copy of the DVX the installer # unpacked from a DVX tree's dist/dvx.zip (install dvx), which boots # straight into it: the program is DVX's desktop program, in its window on a # 1024x768 desktop (640x480 with --headless). Game > Full Screen, or Alt # with the keypad's Enter (DOSBox keeps Alt+Enter for its own full screen), # puts it on the whole screen. Interactive runs keep that DVX in # build/dvx/run (the project's with --project), so what the program saves # (in DVX's CONFIG folder), and whether it was left on the whole screen, is # still there next time. # # The serial port is the machine's usual one: the IIgs modem port (the # printer port and a Super Serial Card are not connected), the X68000's # RS-232C port, the ST's Modem port, the Amiga's serial port (serial.device # unit 0) and COM1 on DOS, DVX, Win32 and Win16. # # The serial link is raw TCP, without telnet negotiation. socat relays it # where the emulator cannot make it itself: the ST, Win32, the Amiga with # --serial, and the IIgs and X68000 with --serial-listen. When # --serial's server is not there, joey-run stops, except on the IIgs and # X68000, where MAME prints "Unable to load image" and runs with the port # unplugged. Listening, what the program sends before the connection # arrives waits for it, except in DOSBox (DOS, DVX and Win16), which drops # it; the Amiga holds the whole machine until the connection arrives. # # The emulators and firmware come from ./install.sh # (install emulator:PORT). Interactive runs use the program's own folder # or disk image, so saves and logs persist; --headless and --data runs # work on a private copy. Nothing is written to your home directory. # Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. # See LICENSE.md for license information. # SPDX-License-Identifier: MIT set -euo pipefail repo="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=scripts/lib/joeyEnv.sh source "$repo/scripts/lib/joeyEnv.sh" port="" projectRoot="$repo" dataDir="" headless=0 snapshotWindow=0 # --snapshot takes the program's window, not the screen seconds="" snapshot="" machine="apple2gs" stMachine="" # Hatari's --machine for an Atari program (default: by build) amigaModel="" # an Amiga program's machine (scripts/lib/amigaGate.sh; default A500) ramSize="4M" debug=0 program="" progArgs=() emuPid="" xvfbPid="" work="" serialMode="" # connect (--serial), listen (--serial-listen) or off serialHost="" serialPort="" serialBaud=9600 # MAME's null modem's own default rate serialArgs=() # MAME's options for the link (mameSerial) serialPty="" # the pty socat hands Hatari, FS-UAE and Wine relayPid="" tcpPortMax=65535 # How long socat keeps trying to reach --serial's server before the run # stops; an unreachable host otherwise hangs for the kernel's SYN timeout. serialConnectSeconds=10 # DOSBox logs whether COM1 reached --serial's server as it starts. dosSerialLogSeconds=5 # The key a headless Win16 run takes DOSBox's snapshot with (xdotool's name # and SDL's scancode for it), and how long DOSBox gets to write it. dosboxShotKey=F11 dosboxShotScancode=68 dosboxShotSeconds=5 # The settings a hosted program's window reads from its environment. joeyWindowSettings=(JOEYLIB_SCALE JOEYLIB_ASPECT JOEYLIB_FULLSCREEN) # A Be API VM: how long its boot may take before the job starts, how much # longer the job runs than --seconds (for the snapshot), how long the # machine takes to power off, and the job's limit when nothing sets one. beBootSeconds=120 beSnapshotSeconds=5 beShutdownSeconds=30 beForeverSeconds=86400 # The mouse move that takes a VM's pointer to the screen's top left # corner from anywhere. bePointerCorner=4000 pollSeconds=0.1 usage() { sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' } fail() { echo "joey-run: $*" >&2 exit 1 } cleanup() { joeyStop "$emuPid" joeyStop "$relayPid" if [ -n "$xvfbPid" ]; then kill "$xvfbPid" 2>/dev/null || true fi if [ -n "$work" ]; then rm -rf "$work" fi } # ---- finding the program ---------------------------------------------------- # magicPort FILE -- print the machine FILE was built for, or nothing. magicPort() { local file="$1" local magic case "$file" in *.2mg|*.2MG|*.po|*.PO|*.hdv|*.HDV) printf 'iigs\n' return 0 ;; esac magic="$(head -c 16 "$file" | od -An -tx1 | tr -d ' \n')" case "$magic" in 000003f3*) printf 'amiga\n' ;; 601a*) # The ST and STe builds share the .PRG format; an STe build is # the one whose platform name (jlPlatformName) is "Atari STe". if LC_ALL=C grep -q -a -P 'Atari STe\x00' "$file"; then printf 'ste\n' else printf 'atarist\n' fi ;; 4d5a*) # A Windows program is an MZ file whose header (e_lfanew, at # 0x3C) points at a "PE\0\0" signature (Win32) or an "NE" one # (Windows 3.x); a DOS one has neither. case "$(od -An -tx1 -j "$(od -An -tu4 -j 60 -N 4 "$file" | tr -d ' ')" -N 4 "$file" 2> /dev/null | tr -d ' \n')" in 50450000) printf 'win32\n' ;; 4e45*) printf 'win16\n' ;; *) printf 'dos\n' ;; esac ;; 4855*) printf 'x68000\n' ;; 44584531*) printf 'dvx\n' ;; # "DXE1": a DVX module (.app) 7f454c46*) # ELF: a native program (the SDL3 port), or a Be API one, which # names its system (jlPlatformName): BeOS, or Haiku, 64-bit # (ELF class 2) or 32-bit. if LC_ALL=C grep -q -a -P 'BeOS\x00' "$file"; then printf 'beos\n' elif LC_ALL=C grep -q -a -P 'Haiku\x00' "$file"; then if [ "${magic:8:2}" = "02" ]; then printf 'haiku\n' else printf 'haiku32\n' fi else printf 'sdl3\n' fi ;; 3c21646f*) printf 'web\n' ;; # "": a web program's page cafebabe*|cffaedfe*) printf 'macos\n' ;; # a Mach-O program, universal or 64-bit 00504250*) printf 'psp\n' ;; # "\0PBP": a PSP program's EBOOT.PBP *) # Apple IIgs OMF: segment header byte 14 (NUMLEN) is 4 and # byte 15 (VERSION) is 2. if [ "${magic:28:4}" = "0402" ]; then printf 'iigs\n' fi ;; esac } # folderProgram DIR -- the one program file inside an app folder. folderProgram() { local file local found="" while IFS= read -r -d '' file; do if [ -n "$(magicPort "$file")" ]; then [ -z "$found" ] || fail "$1 holds more than one program; name the file" found="$file" fi done < <(find "$1" -maxdepth 1 -type f -print0) [ -n "$found" ] || fail "no program found in $1" printf '%s\n' "$found" } # ---- shared run plumbing ------------------------------------------------------ # stageFolder DEST -- copy the program's folder (its files and DATA tree) # plus --data into DEST, for runs that must not touch the build output. stageFolder() { local dest="$1" local progDir progDir="$(dirname "$program")" mkdir -p "$dest" find "$progDir" -maxdepth 1 -type f -exec cp -t "$dest" {} + if [ -d "$progDir/DATA" ]; then cp -r "$progDir/DATA" "$dest/" fi if [ -n "$dataDir" ]; then mkdir -p "$dest/DATA" cp -r "$dataDir/." "$dest/DATA/" fi } # startDisplay -- a virtual X display for --headless SDL emulators. startDisplay() { local display display=":$((300 + RANDOM % 500))" Xvfb "$display" -screen 0 1024x768x24 -nolisten tcp >/dev/null 2>&1 & xvfbPid=$! sleep 1 export DISPLAY="$display" export SDL_AUDIODRIVER=dummy } # waitOrStop -- wait for the emulator, or stop it after --seconds (taking # the --snapshot first). waitOrStop() { local waited=0 local win if [ -z "$seconds" ]; then wait "$emuPid" || true emuPid="" return 0 fi while [ "$waited" -lt "$seconds" ] && kill -0 "$emuPid" 2>/dev/null; do sleep 1 waited=$((waited + 1)) done if [ -n "$snapshot" ]; then if [ "$headless" = "1" ] && [ "$snapshotWindow" = "0" ]; then import -window root "$snapshot" elif win="$(joeyFindWindow "$DISPLAY" "$emuPid")"; then import -window "$win" "$snapshot" else echo "joey-run: no emulator window to snapshot" >&2 fi fi } # mameStopScript FILE -- MAME Lua that snapshots (if asked) and exits after # --seconds of emulated time. mameStopScript() { cat > "$1" <= $seconds then done = true if $([ -n "$snapshot" ] && echo true || echo false) then manager.machine.video:snapshot() end manager.machine:exit() end end) LUA } # runMame DRIVER ARGS... -- start MAME with the per-run directories, the # headless/debug/stop options, then collect the snapshot. runMame() { local driver="$1" shift local args=(-skip_gameinfo -cfg_directory "$work/cfg" -nvram_directory "$work/nvram" -snapshot_directory "$work/snap" -snapname "shot") if [ "$headless" = "1" ]; then args+=(-video none -sound none -nothrottle) export SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy QT_QPA_PLATFORM=offscreen else args+=(-window) fi if [ "$debug" = "1" ]; then args+=(-debug) fi if [ -n "$seconds" ]; then mameStopScript "$work/stop.lua" args+=(-autoboot_script "$work/stop.lua") fi (cd "$work" && exec "$MAME" "$driver" "$@" "${args[@]}") & emuPid=$! wait "$emuPid" || true emuPid="" if [ -n "$snapshot" ]; then if [ -f "$work/snap/shot.png" ]; then cp "$work/snap/shot.png" "$snapshot" else echo "joey-run: MAME saved no snapshot" >&2 fi fi } # ---- the serial link ---------------------------------------------------------- # portFree PORT -- nothing on this machine listens on TCP PORT yet. Checked # up front because an emulator that cannot take the port only says so in a # log nobody reads, and runs with the serial port unplugged. portFree() { python3 - "$1" <<'PY' import socket import sys probe = socket.socket() probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: probe.bind(("", int(sys.argv[1]))) except OSError: sys.exit(1) PY } # serialNote NAME -- tell the user where the serial port (NAME) is wired. serialNote() { case "$serialMode" in connect) echo "joey-run: $1 connects to $serialHost:$serialPort." ;; listen) echo "joey-run: $1 waits for a connection on TCP port $serialPort." ;; esac } # relaySerial NEAR PATH -- run socat between the emulator's end of the link # (NEAR, a pty or unix socket that appears at PATH) and the network, and # wait for PATH. socat opens its first address before its second, so the # order matters. Connecting, the TCP connection goes first: an unreachable # server stops the run before the machine boots. Listening, NEAR goes # first: the emulator can open it at once, and what the program sends # waits in the kernel until a client connects. relaySerial() { local near="$1" local path="$2" local deadline command -v socat >/dev/null 2>&1 || fail "the serial link on this machine goes through socat, which is not installed (package socat: apt install socat, dnf install socat)" if [ "$serialMode" = "connect" ]; then (cd "$work" && exec socat "tcp:$serialHost:$serialPort,connect-timeout=$serialConnectSeconds" "$near") 2>"$work/socat.log" & else (cd "$work" && exec socat "$near" "tcp-listen:$serialPort,reuseaddr") 2>"$work/socat.log" & fi relayPid=$! deadline=$((SECONDS + serialConnectSeconds + 1)) while [ ! -e "$path" ]; do if ! joeyAlive "$relayPid" || [ "$SECONDS" -ge "$deadline" ]; then cat "$work/socat.log" >&2 if [ "$serialMode" = "connect" ]; then fail "cannot connect to $serialHost:$serialPort" fi fail "socat cannot listen on TCP port $serialPort" fi sleep "$pollSeconds" done } # mameSerial DRIVER SLOT -- set serialArgs to the MAME options that plug a # null modem into SLOT and wire it up. MAME's bitbanger connects out by # itself; to listen, socat takes the TCP port and MAME connects to socat # through a unix socket. The null modem clocks the wire itself, at # --serial-baud, which cfg/DRIVER.cfg sets. mameSerial() { local driver="$1" local slot="$2" local bitb="socket.$serialHost:$serialPort" [ -n "$serialMode" ] || return 0 mkdir -p "$work/cfg" python3 "$repo/scripts/lib/mameNullModemCfg.py" "$driver" ":$slot:null_modem" "$serialBaud" \ > "$work/cfg/$driver.cfg" || fail "--serial-baud $serialBaud is not a rate MAME's null modem has" if [ "$serialMode" = "listen" ]; then relaySerial "unix-listen:serial.sock" "$work/serial.sock" bitb="domain.serial.sock" fi serialArgs=(-"$slot" null_modem -bitb "$bitb") } # dosSerialCheck -- DOSBox tries --serial's server as it starts and, when # that fails, only says so in its log and runs with COM1 unplugged; stop # with the reason instead. dosSerialCheck() { local deadline=$((SECONDS + dosSerialLogSeconds)) local line [ "$serialMode" = "connect" ] || return 0 while [ "$SECONDS" -lt "$deadline" ]; do line="$(grep -m 1 'SERIAL: Port 1 ' "$work/dosbox.log" 2>/dev/null)" || true case "$line" in *"connection failed"*) fail "DOSBox cannot connect COM1 to $serialHost:$serialPort" ;; ?*) return 0 ;; esac sleep "$pollSeconds" done } # ---- one function per machine ------------------------------------------------- runIigs() { local name local img local bytes local media=() joeyNeed MAME mame joeyNeed IIGS_ROMPATH iigsFirmware [ -z "$dataDir" ] || fail "--data is not supported on the IIgs; build a disk with the data on it (a game disk)" case "$program" in *.2mg|*.2MG|*.po|*.PO|*.hdv|*.HDV) joeyNeed CADIUS cadius img="$program" if [ "$headless" = "1" ]; then img="$work/$(basename "$program")" cp "$program" "$img" fi bytes="$(stat -c %s "$img")" if "$CADIUS" CATALOG "$img" 2>/dev/null | grep -qE '^ ProDOS +SYS'; then # A bootable game disk: it launches its program by itself. if [ "$bytes" -le 900000 ]; then media=(-flop3 "$img") else media=(-sl7 cffa2 -hard1 "$img") fi else # A data disk: boot GS/OS beside it and pick in the Finder. joeyNeed GSOS_SYSTEM_DISK iigsFirmware cp "$GSOS_SYSTEM_DISK" "$work/boot.po" if [ "$bytes" -le 900000 ]; then media=(-flop3 "$work/boot.po" -flop4 "$img") else media=(-flop3 "$work/boot.po" -sl7 cffa2 -hard1 "$img") fi echo "joey-run: $(basename "$img") is not bootable; GS/OS boots beside it -- open it in the Finder." fi ;; *) # A bare program: pack it onto a data disk and boot a GS/OS # whose startup program launches it (the developer path; needs # the IIgs toolchain). joeyNeed LLVM816_ROOT toolchain:iigs joeyNeed CADIUS cadius joeyNeed IIGS_CLANG_BUILD toolchain:iigs joeyNeed GSOS_SYSTEM_DISK iigsFirmware name="$(basename "$program")" BINDIR="$(dirname "$program")" JOEY_DISK_EXAMPLES="$name" \ "$repo/scripts/make-iigs-disk.sh" "$work/data.2mg" >/dev/null "$repo/scripts/make-iigs-boot-disk.sh" "$work/boot.po" "$name" >/dev/null media=(-flop3 "$work/boot.po" -flop4 "$work/data.2mg") ;; esac mameSerial "$machine" modem echo "joey-run: booting the IIgs ($machine); the program comes up in about a minute. Close MAME to stop." serialNote "the modem port" runMame "$machine" -ramsize "$ramSize" -rompath "$IIGS_ROMPATH" "${media[@]}" "${serialArgs[@]}" } # amigaBootFile HD VAR PATH COMPONENT LOSS -- the installed file named by # $VAR at PATH on the boot drive HD, or say that without it LOSS. amigaBootFile() { local file="${!2:-}" if [ -n "$file" ] && [ -f "$file" ]; then mkdir -p "$1/$(dirname "$3")" cp "$file" "$1/$3" else echo "joey-run: no $(basename "$3") installed (./install.sh install $4); $5 on the Amiga" >&2 fi } runAmiga() { local name local hd="$work/hd" local args=() joeyNeed FSUAE fsuae name="$(basename "$program")" # FS-UAE boots the folder as a hard drive and writes to it, so it is # always staged. s/startup-sequence runs the program. stageFolder "$hd" mkdir -p "$hd/s" printf ':%s\n' "$name" > "$hd/s/startup-sequence" # Kickstart 3.1 has neither in ROM; AmigaOS loads them from this boot # drive. Harmless for programs that use neither. amigaBootFile "$hd" AMIGA_SERIAL_DEVICE Devs/serial.device amigaSerial "serial will not open" amigaBootFile "$hd" AMIGA_LOWLEVEL_LIBRARY Libs/lowlevel.library amigaLowlevel "no joystick will be seen" args=(--fast_memory=2048 --hard_drive_0="$hd" --hard_drive_0_label=JOEYLIB --automatic_input_grab=0 --middle_click_ungrab=1 --mouse_integration=1 --floppy_drive_speed=800 --base_dir="$work/fs-uae") if [ -n "$amigaModel" ]; then # shellcheck source=scripts/lib/amigaGate.sh source "$repo/scripts/lib/amigaGate.sh" AMIGA_MODEL="$amigaModel" amigaMachine args+=("${AMIGA_MACHINE_ARGS[@]}") elif [ -n "${KICKSTART_ROM:-}" ] && [ -f "$KICKSTART_ROM" ]; then args+=(--amiga_model=A500 --kickstart_file="$KICKSTART_ROM") else args+=(--amiga_model=A500) echo "joey-run: no Kickstart installed (./install.sh install kickstart); booting FS-UAE's AROS, which may not run every program" >&2 fi if [ "$headless" = "1" ]; then startDisplay # A real gamepad crashes FS-UAE when there is no screen to grab. args+=(--joystick_port_0=none --joystick_port_1=none) export ALSOFT_DRIVERS=null fi case "$serialMode" in connect) # FS-UAE's tcp:// serial port only ever listens, so socat makes # the connection and hands FS-UAE a pty. relaySerial "pty,rawer,link=$serialPty" "$serialPty" args+=(--amiga_enable_serial_port=1 --serial_port="$serialPty") ;; listen) # /wait holds the machine until the client connects, so nothing # the program sends goes to nobody. args+=(--amiga_enable_serial_port=1 --serial_port="tcp://0.0.0.0:$serialPort/wait") ;; esac echo "joey-run: booting the Amiga; $name starts from the JOEYLIB hard drive. Close FS-UAE to stop." serialNote "the serial port" if [ "$serialMode" = "listen" ]; then echo "joey-run: the Amiga does not start until that connection arrives." fi "$FSUAE" "${args[@]}" >"$work/fs-uae.log" 2>&1 & emuPid=$! waitOrStop # Keep the program's log next to it, as a direct run would. find "$hd" -maxdepth 1 -iname 'joeylog.txt' -exec cp {} "$(dirname "$program")/" \; 2>/dev/null || true } runAtarist() { local dir local args=() joeyNeed HATARI hatari joeyNeed EMUTOS_IMG emutos dir="$(dirname "$program")" if [ "$headless" = "1" ] || [ -n "$dataDir" ]; then stageFolder "$work/hd" dir="$work/hd" fi if [ -z "$stMachine" ]; then stMachine="$([ "$detected" = "ste" ] && echo ste || echo st)" fi args=(--machine "$stMachine" --tos "$EMUTOS_IMG" --harddrive "$dir" --gemdos-drive C --auto "C:\\$(basename "$program")") if [ "$headless" = "1" ]; then startDisplay args+=(--sound off) fi if [ -n "$serialMode" ]; then # Hatari's serial port can only be a file or a tty, so socat # stands between a pty and the network. relaySerial "pty,rawer,link=$serialPty" "$serialPty" args+=(--rs232-in "$serialPty" --rs232-out "$serialPty") fi echo "joey-run: booting the Atari ($stMachine, EmuTOS); $(basename "$program") starts from drive C. Close Hatari to stop." serialNote "the Modem port" "$HATARI" "${args[@]}" >"$work/hatari.log" 2>&1 & emuPid=$! waitOrStop } runDos() { local dir local command local args=() joeyNeed DOSBOX dosbox dir="$(dirname "$program")" if [ "$headless" = "1" ] || [ -n "$dataDir" ]; then stageFolder "$work/hd" dir="$work/hd" fi command="$(basename "$program")" if [ ${#progArgs[@]} -gt 0 ]; then command="$command ${progArgs[*]}" fi args=("${JOEY_DOSBOX_ISOLATE[@]}" --conf "$repo/scripts/dosbox-386sx16.conf" --set "mouse_capture=seamless" -c "MOUNT C \"$dir\"" -c "C:" -c "$command") # transparent:1 -- data bytes only: DOSBox's in-band escapes for the # control lines would reach a plain TCP peer as data. case "$serialMode" in connect) args+=(--set "serial1=nullmodem server:$serialHost port:$serialPort transparent:1") ;; listen) args+=(--set "serial1=nullmodem port:$serialPort transparent:1") ;; esac if [ "$headless" = "1" ]; then startDisplay else # Leave the program's last screen up until a key is pressed. args+=(-c "pause") fi args+=(--exit) echo "joey-run: booting DOS (386SX-16); $command runs from drive C. Close DOSBox to stop." serialNote "COM1" XDG_CONFIG_HOME="$work/xdg" "$DOSBOX" "${args[@]}" >"$work/dosbox.log" 2>&1 & emuPid=$! dosSerialCheck waitOrStop } runDvx() { local dir local app joeyNeed DOSBOX dosbox joeyNeed DVX_ROOT dvx app="$(dirname "$program")" # Built as apps///: install it under the same vendor. if [ "$(basename "$(dirname "$(dirname "$app")")")" = apps ]; then DVX_VENDOR="$(basename "$(dirname "$app")")" fi if [ "$headless" = "1" ] || [ -n "$dataDir" ]; then dir="$work/dvx" else dir="$projectRoot/build/dvx/run" # DVX's own desktop size, where a program's window is the stage at 3x. DVX_VIDEO_W=1024 DVX_VIDEO_H=768 fi # shellcheck source=scripts/lib/dvxGate.sh source "$repo/scripts/lib/dvxGate.sh" if [ -n "$dataDir" ]; then stageFolder "$work/app/$(basename "$app")" app="$work/app/$(basename "$app")" fi dvxStage "$dir" "$app" dvxDosboxConf "$work/dosbox.conf" "$dir" sb16 # transparent:1 -- data bytes only, as for DOS. case "$serialMode" in connect) printf '\n[serial]\nserial1 = nullmodem server:%s port:%s transparent:1\n' "$serialHost" "$serialPort" >> "$work/dosbox.conf" ;; listen) printf '\n[serial]\nserial1 = nullmodem port:%s transparent:1\n' "$serialPort" >> "$work/dosbox.conf" ;; esac if [ "$headless" = "1" ]; then startDisplay fi echo "joey-run: booting DVX in DOSBox; $(basename "$program") is its desktop program. Close DOSBox to stop." serialNote "COM1" XDG_CONFIG_HOME="$work/xdg" "$DOSBOX" "${JOEY_DOSBOX_ISOLATE[@]}" --conf "$work/dosbox.conf" \ --set "mouse_capture=seamless" > "$work/dosbox.log" 2>&1 & emuPid=$! dosSerialCheck waitOrStop } runX68000() { local name local stage="$work/files" joeyNeed MAME mame joeyNeed X68K_DIR x68kFirmware joeyNeedFile "$X68K_DIR/roms/x68000/cgrom.dat" x68kCgrom # shellcheck source=scripts/lib/x68kDisk.sh source "$repo/scripts/lib/x68kDisk.sh" name="$(basename "$program")" # Human68k boots from a trimmed copy of the system disk whose # AUTOEXEC.BAT runs the program from the data disk in drive B. x68kBootDisk "$work/boot.xdf" 'B:' "B:\\$name" || fail "cannot build the boot disk" x68kDataDisk "$work/data.xdf" || fail "cannot build the data disk" x68kXdf add "$work/data.xdf" "$program" "$name" >/dev/null || fail "cannot copy $name onto the data disk" rm -rf "$stage" mkdir -p "$stage" if [ -d "$(dirname "$program")/DATA" ]; then cp -r "$(dirname "$program")/DATA" "$stage/" fi if [ -n "$dataDir" ]; then mkdir -p "$stage/DATA" cp -r "$dataDir/." "$stage/DATA/" fi if [ -d "$stage/DATA" ]; then x68kAddTree "$work/data.xdf" "$stage/DATA" DATA || fail "the DATA files do not fit on the 1232 KB data disk" fi mameSerial x68000 rs232c echo "joey-run: booting the X68000 (Human68k 3.02); $name runs from drive B. Close MAME to stop." serialNote "the RS-232C port" runMame x68000 -bios ipl10 -rompath "$X68K_DIR/roms" -flop1 "$work/boot.xdf" -flop2 "$work/data.xdf" "${serialArgs[@]}" } # runSdl3 -- the program natively. Interactive runs use its own folder, so # its saves and log persist; --headless and --data runs a private copy. runSdl3() { local dir [ -z "$serialMode" ] || fail "the SDL3 port has no serial port" dir="$(dirname "$program")" if [ "$headless" = "1" ] || [ -n "$dataDir" ]; then stageFolder "$work/run" dir="$work/run" fi if [ "$headless" = "1" ]; then startDisplay export SDL_VIDEO_DRIVER=x11 SDL_AUDIO_DRIVER=dummy JOEYLIB_SCALE=1 JOEYLIB_ASPECT=square snapshotWindow=1 fi echo "joey-run: running $(basename "$program") natively (SDL3). Close its window to stop." "$dir/$(basename "$program")" "${progArgs[@]}" & emuPid=$! waitOrStop } # runWindows -- the Win32 program under Wine, in a window on your display; # with --headless on a private virtual display at one pixel per stage pixel, # so --snapshot is the stage. The Wine prefix (its C: drive and settings) is # kept in the project's build/win32/prefix for interactive runs, so it is # set up once; --headless and serial runs use a private one, so the kept # prefix's COM1 stays whatever device of this machine it was. runWindows() { local dir local title local win # shellcheck source=scripts/lib/wineGate.sh source "$repo/scripts/lib/wineGate.sh" wineNeed dir="$(dirname "$program")" if [ "$headless" = "1" ] || [ -n "$dataDir" ]; then stageFolder "$work/run" dir="$work/run" fi title="$(basename "$program")" title="${title%.*}" if [ -n "$serialMode" ]; then # Wine's COM1 can only be a device, so socat stands between a pty # and the network; before Wine, so an unreachable server stops the # run before Wine starts. relaySerial "pty,rawer,link=$serialPty" "$serialPty" fi if [ "$headless" = "1" ]; then wineStart "$work" xvfbPid="$WINE_XVFB_PID" elif [ -n "$serialMode" ]; then winePrefix "$work" else wineEnv "$projectRoot/build/$detected/prefix" fi if [ -n "$serialMode" ]; then wineComPort 1 "$serialPty" fi if [ "$headless" = "1" ]; then echo "joey-run: running $title under Wine (headless)." serialNote "COM1" JOEYLIB_SCALE=1 JOEYLIB_ASPECT=square wineRun "$dir/$(basename "$program")" "${progArgs[@]}" emuPid="$WINE_PID" sleep "$seconds" if [ -n "$snapshot" ]; then if win="$(wineWindow "$title")"; then import -window "$win" "$snapshot" else echo "joey-run: no $title window to snapshot" >&2 fi fi wineStop emuPid="" xvfbPid="" return 0 fi echo "joey-run: running $title under Wine. Close its window to stop." serialNote "COM1" "$WINE" "$dir/$(basename "$program")" "${progArgs[@]}" & emuPid=$! waitOrStop if [ -n "$serialMode" ]; then # The private prefix's Wine, before its folder goes. wineStop emuPid="" fi } # runWin16 -- the program on Windows 3.11 in DOSBox (scripts/lib/win16Gate.sh), # as Windows' shell, its folder drive D:. Interactive runs use the program's # own folder, so saves and logs persist; --headless and --data runs a copy. runWin16() { local dir local name local win local png="" local found local size="" local deadline local setting local settings=() # shellcheck source=scripts/lib/win16Gate.sh source "$repo/scripts/lib/win16Gate.sh" win16Need dir="$(dirname "$program")" if [ "$headless" = "1" ] || [ -n "$dataDir" ]; then stageFolder "$work/run" dir="$work/run" fi name="$(basename "$program")" # transparent:1 -- data bytes only, as for DOS. case "$serialMode" in connect) WIN16_DOSBOX_ARGS+=(--set "serial1=nullmodem server:$serialHost port:$serialPort transparent:1") ;; listen) WIN16_DOSBOX_ARGS+=(--set "serial1=nullmodem port:$serialPort transparent:1") ;; esac win16Stage "$work" "$dir" "$name" "${progArgs[@]}" # The program reads its settings from the DOS environment Windows # started from: this one's go there, as a Win32 or SDL3 program inherits # them. for setting in "${joeyWindowSettings[@]}"; do [ -z "${!setting:-}" ] || settings+=("$setting=${!setting}") done if [ "$headless" = "0" ]; then echo "joey-run: booting Windows 3.11 in DOSBox; ${name%.*} runs from drive D:. Close it to stop." serialNote "COM1" WIN16_HEADLESS=0 WIN16_SOUND=1 WIN16_WALL=0 win16Start "$work" "${settings[@]}" emuPid="$WIN16_PID" dosSerialCheck waitOrStop return 0 fi mkdir -p "$work/shots" # DOSBox's raw snapshot on a key of its own: its Ctrl+F5 would hand the # program a Ctrl press, which ends one that waits for any key while the # snapshot is being taken. A mapper file replaces every binding, which a # headless run, which types nothing, does not miss. printf 'hand_rawshot "key %s"\n' "$dosboxShotScancode" > "$work/mapper.txt" WIN16_DOSBOX_ARGS+=(--set "capture_dir=$work/shots" --set "mapperfile=$work/mapper.txt") echo "joey-run: running ${name%.*} on Windows 3.11 in DOSBox (headless)." serialNote "COM1" win16Start "$work" "${settings[@]}" JOEYLIB_SCALE=1 JOEYLIB_ASPECT=square emuPid="$WIN16_PID" xvfbPid="$WIN16_XVFB_PID" dosSerialCheck sleep "$seconds" if [ -n "$snapshot" ]; then if win="$(win16Window)"; then DISPLAY="$WIN16_DISPLAY" xdotool key --window "$win" "$dosboxShotKey" # DOSBox makes the file before it writes it: it is done when it # has bytes and one more poll adds none. deadline=$((SECONDS + dosboxShotSeconds)) while [ "$SECONDS" -lt "$deadline" ]; do sleep "$pollSeconds" found="$(find "$work/shots" -name '*.png' -print -quit)" [ -s "$found" ] || continue if [ "$(stat -c %s "$found")" = "$size" ]; then png="$found" break fi size="$(stat -c %s "$found")" done if [ -n "$png" ]; then cp "$png" "$snapshot" else echo "joey-run: DOSBox took no snapshot" >&2 fi else echo "joey-run: no DOSBox window to snapshot" >&2 fi fi win16Stop emuPid="" xvfbPid="" } # runWeb -- the program's page, served from its folder: in your browser, or # with --headless in headless Chromium. runWeb() { local page [ -z "$serialMode" ] || fail "the web port has no serial port" [ -z "$dataDir" ] || fail "--data does not apply to the web port: its DATA is packed in when it is built" # shellcheck source=scripts/lib/webGate.sh source "$repo/scripts/lib/webGate.sh" page="$(basename "$program")" if [ "$headless" = "1" ]; then webNeed webServe "$(dirname "$program")" echo "joey-run: running $page in headless Chromium." webDrive "$page?scale=1&aspect=square" --step "wait:$seconds" ${snapshot:+--screenshot "$snapshot"} || true webStop return 0 fi webServe "$(dirname "$program")" echo "joey-run: serving $page at http://127.0.0.1:$WEB_PORT/$page -- Ctrl+C stops." if command -v xdg-open > /dev/null; then xdg-open "http://127.0.0.1:$WEB_PORT/$page" > /dev/null 2>&1 || true fi if [ -n "$seconds" ]; then sleep "$seconds" webStop else wait "$WEB_SERVER_PID" || true fi } # runPsp -- the program headless under PPSSPPHeadless, from a copy of its # folder (and --data) on a private memory stick. runPsp() { local name local folder [ -z "$serialMode" ] || fail "the PSP port has no serial port" [ "$headless" = "1" ] || fail "the PSP port runs here only with --headless; to play it, copy $(dirname "$program") into PSP/GAME/ on a PSP's memory stick, or open its EBOOT.PBP in PPSSPP" # shellcheck source=scripts/lib/pspGate.sh source "$repo/scripts/lib/pspGate.sh" pspNeed name="$(basename "$(dirname "$program")")" stageFolder "$work/stage/$name" folder="$(pspStage "$work" "$work/stage/$name")" echo "joey-run: running $name in PPSSPP (headless)." pspRun "$work" "$folder" "$seconds" ${snapshot:+--snapshot="$snapshot"} emuPid="$PSP_PID" pspWait emuPid="" pspStrictCheck "$work" "$(dirname "$program")" || true } # runBe -- a Be API program (haiku, haiku32, beos) in its VM under QEMU # (scripts/lib/beGate.sh), from a copy of its folder on the VM's job disk: # in QEMU's window, with sound; the machine powers off when the program # ends, and what it wrote in its folder (saves, its log) is copied back. # --headless: no window and no sound, at one window pixel per stage pixel, # and --snapshot is QEMU's picture of the whole screen. --seconds counts # from when the job starts, not from the boot. runBe() { local name local dir local entry local settings=() local setting local jobSeconds [ -z "$serialMode" ] || fail "the $detected port has no serial port here" # shellcheck source=scripts/lib/beGate.sh source "$repo/scripts/lib/beGate.sh" beNeed "$detected" name="$(basename "$program")" dir="$(dirname "$program")" stageFolder "$work/job/$name" if [ "$headless" = "1" ]; then settings=(JOEYLIB_SCALE=1 JOEYLIB_ASPECT=square) else BE_DISPLAY=sdl BE_AUDIODEV="sdl,id=beSound" for setting in "${joeyWindowSettings[@]}"; do if [ -n "${!setting:-}" ]; then settings+=("$setting=${!setting}") fi done fi # The job outlasts --seconds a little, so the snapshot finds the # program still up. jobSeconds=$((${seconds:-$beForeverSeconds} + beSnapshotSeconds)) beJobScript "$name" "$name" "" "$jobSeconds" "${settings[@]}" > "$work/job/job.sh" beJobDisk "$work/job.img" "$work/job" || fail "could not make the VM's job disk" echo "joey-run: running $name in the $detected VM (QEMU); it powers off when the program ends." beStart "$work" "$work/job.img" || fail "the $detected VM did not start (QEMU's log: $work/qemu.log)" emuPid="$BE_PID" beWaitJob "$work" "$beBootSeconds" || fail "the $detected VM did not start the job within $beBootSeconds s" if [ -n "$snapshot" ]; then # Nothing moves the VM's pointer, so it would stay in the middle of # the screen, over the window (which hides it only once it moves # there): put it in the corner before the window opens. beQmp "$work" move "-$bePointerCorner" "-$bePointerCorner" || true fi if [ -n "$seconds" ]; then sleep "$seconds" if [ -n "$snapshot" ]; then beQmp "$work" screendump "$snapshot" || echo "joey-run: no screen to snapshot" >&2 fi beWaitExit "$work" "$((beSnapshotSeconds + beShutdownSeconds))" || beStop else wait "$emuPid" || true fi emuPid="" BE_PID="" if [ "$headless" = "0" ] && [ -z "$dataDir" ]; then if ! beJobGet "$work/job.img" "$name" "$work/back"; then echo "joey-run: could not copy the program's files back off the job disk (was QEMU closed before the machine powered off?)" >&2 return 0 fi # Only what the run made or changed: the folder may hold other # programs, which keep their files and times. for entry in "$work/back"/*; do case "$(basename "$entry")" in "$name"|DATA) ;; *) if [ -d "$entry" ] || ! cmp -s "$entry" "$dir/$(basename "$entry")"; then cp -r "$entry" "$dir/" fi ;; esac done fi } # ---- main --------------------------------------------------------------------- while [ $# -gt 0 ]; do case "$1" in --port) port="${2:?--port needs iigs, amiga, atarist, ste, dos, x68000, dvx, sdl3, web, macos, win32, win16, psp, haiku, haiku32 or beos}"; shift ;; --project) [ -d "${2:-}" ] || fail "--project needs a project folder (one built on make/project.mk)" projectRoot="$(cd "$2" && pwd)" shift ;; --data) dataDir="${2:?--data needs a folder}"; shift ;; --headless) headless=1 ;; --seconds) seconds="${2:?--seconds needs a number}"; shift ;; --snapshot) snapshot="${2:?--snapshot needs a file name}"; shift ;; --machine) case "${2:-}" in rom01) machine="apple2gsr1" ;; rom03) machine="apple2gs" ;; st|megast|ste|megaste) stMachine="$2" ;; a500|a1200|a4000) amigaModel="${2^^}" ;; *) fail "--machine takes rom01 or rom03 (IIgs), st, megast, ste or megaste (Atari), or a500, a1200 or a4000 (Amiga)" ;; esac shift ;; --ram) ramSize="${2:?--ram needs a size such as 4M}"; shift ;; --debug) debug=1 ;; --serial) [ -z "$serialMode" ] || fail "one --serial or --serial-listen per run" [[ "${2:-}" =~ ^([^:]+):([0-9]+)$ ]] || fail "--serial takes HOST:PORT (a host name or IPv4 address)" serialMode=connect serialHost="${BASH_REMATCH[1]}" serialPort="${BASH_REMATCH[2]}" shift ;; --serial-listen) [ -z "$serialMode" ] || fail "one --serial or --serial-listen per run" [[ "${2:-}" =~ ^[0-9]+$ ]] || fail "--serial-listen takes a port number" serialMode=listen serialPort="$2" shift ;; --serial-baud) serialBaud="${2:?--serial-baud needs a rate such as 2400}"; shift ;; -h|--help) usage; exit 0 ;; --) shift; progArgs=("$@"); break ;; -*) fail "unknown option $1 (see: joey-run --help)" ;; *) [ -z "$program" ] || fail "one program at a time (got '$program' and '$1')" program="$1" ;; esac shift done [ -n "$program" ] || { usage; exit 2; } [[ "$seconds" =~ ^[0-9]*$ ]] || fail "--seconds takes a whole number" if [ "$headless" = "1" ] && [ -z "$seconds" ]; then seconds=60 fi if [ -n "$snapshot" ]; then [ -n "$seconds" ] || fail "--snapshot needs --seconds (or --headless)" snapshot="$(realpath -m "$snapshot")" fi if [ -n "$dataDir" ]; then [ -d "$dataDir" ] || fail "--data: no such folder $dataDir" dataDir="$(cd "$dataDir" && pwd)" fi if [ -n "$serialMode" ]; then [ "$serialPort" -ge 1 ] && [ "$serialPort" -le "$tcpPortMax" ] || fail "TCP port $serialPort is not 1 to $tcpPortMax" fi if [ "$serialMode" = "listen" ]; then portFree "$serialPort" || fail "TCP port $serialPort is already in use" fi # Resolve PROGRAM to a file and its machine. if [ -d "$program/Contents/MacOS" ]; then program="$(folderProgram "$program/Contents/MacOS")" elif [ -d "$program" ]; then program="$(folderProgram "$program")" elif [ ! -e "$program" ]; then [ -n "$port" ] || fail "$program is not a file; for a program name add --port" program="$(joeyExampleProgram "$program" "$port" "$projectRoot")" || exit 1 [ -e "$program" ] || fail "$program is not built (make $port in $projectRoot)" fi program="$(cd "$(dirname "$program")" && pwd)/$(basename "$program")" detected="$(magicPort "$program")" [ -n "$detected" ] || fail "cannot tell which machine $program is for" if [ -n "$port" ] && [ "$port" != "$detected" ]; then fail "$program is a $detected program, not $port" fi [ "$detected" != macos ] || fail "$program is a Mac app's: copy its .app to a Mac and open it there (nothing here runs macOS programs)" if [ ${#progArgs[@]} -gt 0 ] && [ "$detected" != "dos" ] && [ "$detected" != "sdl3" ] && [ "$detected" != "win32" ] && [ "$detected" != "win16" ]; then fail "program arguments only work on DOS, SDL3, Win32 and Win16" fi work="$(mktemp -d -t joey-run.XXXXXX)" serialPty="$work/serial.pty" trap cleanup EXIT INT TERM case "$detected" in iigs) runIigs ;; amiga) runAmiga ;; atarist|ste) runAtarist ;; dos) runDos ;; x68000) runX68000 ;; dvx) runDvx ;; sdl3) runSdl3 ;; web) runWeb ;; win32) runWindows ;; win16) runWin16 ;; psp) runPsp ;; haiku|haiku32|beos) runBe ;; esac if [ -n "$snapshot" ] && [ -f "$snapshot" ]; then echo "joey-run: snapshot saved to $snapshot" fi