#!/usr/bin/env bash # Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. # See LICENSE.md for license information. # SPDX-License-Identifier: MIT # # verify-iigs-serial-loss.sh - Receive-loss gate for the Apple IIgs serial add-on. # # SERLOSS (examples/serloss/serloss.c) fills and presents the whole screen in a # loop while this script's peer streams 4096 bytes (byte k = k & 0xFF) into the # port at 9600 baud. The probe counts, sequence-checks and sums what arrives and # leaves the result in a mailbox at $E1:9DD0, which the MAME Lua reads. # # PASS all 4096 bytes arrived, in order, with the right sum # FAIL anything missing or out of sequence # SKIP (exit 2) a tool or ROM is missing, or MAME did not confirm the # real SCC receive FIFO depth (below) # # scripts/verify-iigs-serial-loss.sh scc modem port (SCC channel B) # scripts/verify-iigs-serial-loss.sh ssc Super Serial Card in slot 2 # # The emulator must model the real receive FIFO depth, or a receiver that # falls behind still looks fine. The installed MAME's # installer/patches/mame/0002-z80scc-rx-fifo.patch gives the Z8530 a 512-byte # receive FIFO by default, so polled receivers survive in the other gates; # this script runs it with JOEY_Z80SCC_RX_FIFO=3, the real chip's depth. MAME # then prints "RX FIFO depth 3 (JOEY_Z80SCC_RX_FIFO)" per SCC channel, and an # scc PASS counts only with that line (installer/patches/mame/README.md). The # 6551 on the Super Serial Card has no FIFO to widen. # # The card's interrupt switch (SW2-6) is OFF by default, as on a real Super # Serial Card; the Lua turns it on so the 6551's IRQ reaches the slot. MAME # needs the card's firmware, a2ssc.zip, which the iigsFirmware component # installs into $IIGS_ROMPATH. # # Environment: # SERLOSS_MAME MAME binary (default $MAME); it runs with JOEY_Z80SCC_RX_FIFO=3. # An scc PASS from one that does not report that depth (a # build without the 0002 patch, whose FIFO is already the # real 3) stands, with a warning; from $MAME it is a SKIP, # since that means the installed MAME predates the switch # SERLOSS_BIN probe binary (default build/iigs/bin/SERLOSS or SERLOSSS) # SERLOSS_PORT TCP port for the bitbanger (default 45241) # SERLOSS_MACHINE apple2gs (ROM 03, default) or apple2gsr1 (ROM 01) # SERLOSS_LAUNCHER set to boot straight into the probe instead of driving # the Finder (whose timeline only fits ROM 03) # SERLOSS_BAUD peer line rate, must match the probe's -DSERLOSS_BAUD (default 9600) # SERLOSS_ROMPATH MAME rompath (default $IIGS_ROMPATH, which holds a2ssc) # SERLOSS_WALL wall-clock cap for MAME in seconds (default 600) # SERLOSS_MAX_FRAME emulated-frame cap (default 12000) # SERLOSS_TAP file: log every data-register read with its emulated time # SERLOSS_PADDLE plug MAME's joystick into the game port and hold both paddles # at this value (0..255) for a -DSERLOSS_JOY probe; 255, full # deflection, is the longest paddle read there is set -uo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) source "$repo/scripts/lib/joeyEnv.sh" joeyNeed MAME mame joeyNeed IIGS_ROMPATH iigsFirmware joeyNeed GSOS_SYSTEM_DISK iigsFirmware mode="${1:-scc}" case "$mode" in scc) name=SERLOSS ;; ssc) name=SERLOSSS ;; *) echo "verify-iigs-serial-loss: mode must be scc or ssc" >&2; exit 2 ;; esac mame="${SERLOSS_MAME:-$MAME}" rompath="${SERLOSS_ROMPATH:-$IIGS_ROMPATH}" bin="${SERLOSS_BIN:-$repo/build/iigs/bin/$name}" PORT="${SERLOSS_PORT:-45241}" WALL="${SERLOSS_WALL:-600}" maxFrame="${SERLOSS_MAX_FRAME:-12000}" total=4096 [ -x "$mame" ] || { echo "verify-iigs-serial-loss: no MAME at $mame" >&2; exit 2; } [ -f "$bin" ] || { echo "verify-iigs-serial-loss: build $bin first" >&2; exit 2; } if [ "$mode" = ssc ] && [ -z "${SERLOSS_ROMPATH:-}" ]; then joeyNeedFile "$IIGS_ROMPATH/a2ssc.zip" iigsFirmware fi work=$(mktemp -d -t joeylib-iigs-loss.XXXXXX) peer_pid="" cleanup() { [ -n "$peer_pid" ] && kill -9 "$peer_pid" 2>/dev/null rm -rf "$work" } trap cleanup EXIT mkdir -p "$work/bin" cp "$bin" "$work/bin/$name" BINDIR="$work/bin" JOEY_DISK_EXAMPLES="$name" bash "$repo/scripts/make-iigs-disk.sh" "$work/loss.2mg" >/dev/null 2>&1 || { echo "verify-iigs-serial-loss: FAIL - could not build the probe disk" >&2 exit 1 } # SERLOSS_LAUNCHER=1 boots straight into the probe (make-iigs-boot-disk.sh) # instead of driving the Finder, whose keystroke timeline is calibrated for # ROM 03 only. if [ -n "${SERLOSS_LAUNCHER:-}" ]; then bash "$repo/scripts/make-iigs-boot-disk.sh" "$work/boot.po" "$name" >/dev/null 2>&1 || { echo "verify-iigs-serial-loss: FAIL - could not build the launcher boot disk" >&2 exit 1 } finderAt=999999 else cp "$GSOS_SYSTEM_DISK" "$work/boot.po" finderAt=3000 fi # Peer: listen (MAME connects out), wait for the probe's READY, stream the # pattern, then keep draining whatever the probe sends until MAME goes away. cat > "$work/peer.py" <<'PY' import socket, sys, time port = int(sys.argv[1]) total = int(sys.argv[2]) log = open(sys.argv[3], "w") srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("127.0.0.1", port)) srv.listen(1) srv.settimeout(600) conn, _ = srv.accept() conn.settimeout(1.0) seen = b"" sent = False while True: try: chunk = conn.recv(256) except socket.timeout: continue except OSError: break if not chunk: break seen += chunk if not sent and b"READY" in seen: conn.sendall(bytes(k & 0xFF for k in range(total))) sent = True log.write("peer: READY seen, sent %d bytes\n" % total) log.flush() log.write("peer: received %r\n" % seen[:64]) log.close() PY python3 "$work/peer.py" "$PORT" "$total" "$work/peer.log" & peer_pid=$! sleep 1 if [ "$mode" = ssc ]; then serialArgs=(-sl2 ssc -sl2:ssc:rs232 null_modem -bitb "socket.127.0.0.1:$PORT") dipPort=":sl2:ssc:DSWX" modemTag=":sl2:ssc:rs232:null_modem" else serialArgs=(-modem null_modem -bitb "socket.127.0.0.1:$PORT") dipPort="" modemTag=":modem:null_modem" fi # The apple2gs has nothing on its game port by default (every paddle reads 0). paddle="${SERLOSS_PADDLE:-}" gameArgs=() if [ -n "$paddle" ]; then gameArgs=(-gameio joy) fi paddleValue="${paddle:-0}" case "${SERLOSS_BAUD:-9600}" in 2400) baudCode=5 ;; 4800) baudCode=6 ;; 9600) baudCode=7 ;; 19200) baudCode=9 ;; 38400) baudCode=11 ;; 57600) baudCode=12 ;; 115200) baudCode=13 ;; *) echo "verify-iigs-serial-loss: unsupported SERLOSS_BAUD" >&2; exit 2 ;; esac cat > "$work/loss.lua" <= scr.frame_period then e = e - scr.frame_period end if e < 0 then e = e + scr.frame_period end e = e + scr.pixel_period / 2 local v = math.floor(e / scr.scan_period) return v % 262, math.floor((e - v * scr.scan_period) / scr.pixel_period) end local function counters(offset, data, mask) local v, hp = beam() local line = v + 501 if line > 511 then line = line - 262 end if (offset & 0xFF) == 0x2E then return line >> 1 end local h = hp // 11 if h > 0 then h = h + 0x40 end return h | ((line & 1) << 7) end for _, bank in ipairs({0x000000, 0xE00000, 0xE10000}) do vcTaps[#vcTaps + 1] = mem:install_read_tap(bank + 0xC02E, bank + 0xC02F, "beam" .. bank, counters) end end -- Hold both paddles of MAME's joystick (-gameio joy) still. local function paddlesHold() if "$paddle" == "" then return end for _, name in ipairs({"joystick_1_x", "joystick_1_y"}) do local p = manager.machine.ioport.ports[":gameio:joy:" .. name] if p ~= nil then for _, f in pairs(p.fields) do f:set_value($paddleValue) end end end end local function sscInterruptsOn() local p = manager.machine.ioport.ports["$dipPort"] if p == nil then return end local f = p.fields["Interrupts"] if f ~= nil then f.user_value = 0 end end -- Optional (SERLOSS_TAP=file): log every CPU read of the data register with -- the emulated time, for a receive-latency histogram. local tapFile = "${SERLOSS_TAP:-}" local taps = {} local function installTaps() if tapFile == "" then return end local out = io.open(tapFile, "w") local dataReg = ("$mode" == "ssc") and 0xC0A8 or 0xC03A for _, bank in ipairs({0x000000, 0xE00000, 0xE10000}) do taps[#taps + 1] = mem:install_read_tap(bank + dataReg, bank + dataReg, "rx" .. bank, function(offset, data, mask) out:write(string.format("%.7f %d\n", manager.machine.time:as_double(), data & 0xFF)) out:flush() end) end end -- The null_modem's line rate is a configuration setting (default 9600). local function peerBaud() for _, name in ipairs({"RS232_TXBAUD", "RS232_RXBAUD"}) do local p = manager.machine.ioport.ports["$modemTag:" .. name] if p ~= nil then for _, f in pairs(p.fields) do f.user_value = $baudCode end end end end local steps = { {10, function() if "$dipPort" ~= "" then sscInterruptsOn() end; peerBaud(); paddlesHold(); beamCountersFix(); installTaps() end}, {11, function() if $finderAt > 3000 then launched = true end end}, {$finderAt, function() nat:post("J") end}, {$finderAt + 120, function() press(key_cmd) end}, {$finderAt + 126, function() nat:post("o") end}, {$finderAt + 180, function() release(key_cmd) end}, {$finderAt + 540, function() nat:post("$name") end}, {$finderAt + 660, function() press(key_cmd) end}, {$finderAt + 666, function() nat:post("o") end}, {$finderAt + 720, function() release(key_cmd); launched = true end}, } emu.register_frame_done(function() frame = frame + 1 while idx <= #steps and frame >= steps[idx][1] do steps[idx][2]() idx = idx + 1 end if launched and frame % 60 == 0 then local sig = mem:read_u8(0xE19DD0) == 0xA5 and mem:read_u8(0xE19DD1) == 0x5A local phase = mem:read_u8(0xE19DD2) if sig and (phase == 0x05 or phase == 0xEE) then report(); manager.machine:exit() end end if frame >= $maxFrame then report(); manager.machine:exit() end end) LUA cd "$work" out=$(JOEY_Z80SCC_RX_FIFO=3 QT_QPA_PLATFORM=offscreen SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \ timeout -s KILL "$WALL" "$mame" "${SERLOSS_MACHINE:-apple2gs}" \ -rompath "$rompath" \ -flop3 "$work/boot.po" -flop4 "$work/loss.2mg" \ "${serialArgs[@]}" "${gameArgs[@]}" \ -video none -sound none -nothrottle \ -autoboot_script "$work/loss.lua" &1) || true sleep 1 kill -9 "$peer_pid" 2>/dev/null peer_pid="" cat "$work/peer.log" 2>/dev/null line=$(echo "$out" | grep -E '^VERIFY-IIGS-LOSS ' | tail -1) echo "$line" if [ -z "$line" ]; then echo "verify-iigs-serial-loss: FAIL - MAME produced no report" >&2 echo "$out" | tail -10 >&2 exit 1 fi get() { echo "$line" | grep -oE "$1=[0-9A-F]+" | cut -d= -f2; } phase=$(get phase); received=$(get received); inSeq=$(get inSequence); breaks=$(get breaks); sum=$(get sum) presents=$(get presents); fillTicks=$(get fillTicks); bothTicks=$(get fillPresentTicks) if [ -n "$bothTicks" ] && [ -n "$fillTicks" ]; then # 60 fills vs 60 fill+present pairs, in 60 Hz ticks: the difference is 60 presents. echo " full-screen present: $(( (bothTicks - fillTicks) * 1000000 / 3600 )) us each (fills $fillTicks ticks, fills+presents $bothTicks ticks)" fi joyPolls=$(get joyPolls) if [ -n "$joyPolls" ] && [ "$joyPolls" != 0 ]; then # A -DSERLOSS_JOY probe calibrates the held stick first, so every reading # should be 0: the spread is how far interrupts pushed the paddle reads. echo " joystick: $joyPolls polls, paddles held at ${paddle:-0}, X read $(echo "$line" | grep -oE 'joyX=[-0-9.]+' | cut -d= -f2), Y read $(echo "$line" | grep -oE 'joyY=[-0-9.]+' | cut -d= -f2)" fi if [ "$phase" = 05 ] && [ "$received" = "$total" ] && [ "$inSeq" = "$total" ] && [ "$breaks" = 0 ] && [ "$sum" = F800 ]; then # An scc PASS proves something only at the real FIFO depth (see the header). if [ "$mode" = scc ] && ! grep -qF 'RX FIFO depth 3 (JOEY_Z80SCC_RX_FIFO)' <<< "$out"; then if [ -z "${SERLOSS_MAME:-}" ]; then echo "verify-iigs-serial-loss: SKIP - $mame did not confirm RX FIFO depth 3 (installed before JOEY_Z80SCC_RX_FIFO existed?), so a 512-byte SCC FIFO may be hiding loss; run: ./install.sh install mame" >&2 exit 2 fi echo "verify-iigs-serial-loss: WARNING - $mame did not report JOEY_Z80SCC_RX_FIFO=3; trusting it to model the real SCC FIFO" >&2 fi echo "verify-iigs-serial-loss: PASS ($mode: $total/$total bytes, in order, through $presents full-screen presents)" exit 0 fi echo "verify-iigs-serial-loss: FAIL ($mode: received $received/$total, $breaks sequence breaks, phase $phase)" >&2 exit 1