joeylib2/scripts/verify-iigs-serial-loss.sh

364 lines
15 KiB
Bash
Executable file

#!/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 ;;
*) echo "verify-iigs-serial-loss: unsupported SERLOSS_BAUD" >&2; exit 2 ;;
esac
cat > "$work/loss.lua" <<LUA
local cpu = manager.machine.devices[":maincpu"]
local mem = cpu.spaces["program"]
local nat = manager.machine.natkeyboard
local frame = 0
local idx = 1
local launched = false
local function field(port, name)
local p = manager.machine.ioport.ports[port]
if p == nil then return nil end
return p.fields[name]
end
local key_cmd = field(":macadb:KEY3", "Command / Open Apple")
local function press(f) if f then f:set_value(1) end end
local function release(f) if f then f:set_value(0) end end
local function u16(off)
return mem:read_u8(0xE19DD0 + off) | (mem:read_u8(0xE19DD1 + off) << 8)
end
-- The stick spread bytes are biased by 128 (serloss.c MB_JOY_BIAS).
local function s8(off)
return mem:read_u8(0xE19DD0 + off) - 128
end
local function report()
io.write(string.format("VERIFY-IIGS-LOSS frame=%d sig=%02X%02X phase=%02X received=%d inSequence=%d breaks=%d presents=%d ticks=%d overruns=%d dropped=%d sum=%04X now=%d fillTicks=%d fillPresentTicks=%d joyPolls=%d joyX=%d..%d joyY=%d..%d sfxShots=%d sfxFills=%d\n",
frame, mem:read_u8(0xE19DD0), mem:read_u8(0xE19DD1), mem:read_u8(0xE19DD2),
u16(4), u16(6), u16(8), u16(10), u16(12), u16(14), u16(16), u16(18), u16(20), u16(22), u16(24),
u16(26), s8(28), s8(29), s8(30), s8(31), u16(32), u16(34)))
io.flush()
end
-- With the paddles in play, serve VERTCNT/HORIZCNT (C02E/C02F) with the
-- real machine's 262-line mapping (VERTCNT*2 + HORIZCNT bit 7 = 250..511).
-- MAME's apple2gs get_vpos() wraps with -261 instead of -262, so its counter
-- repeats line 501 and never reads 250; the tight joystick read times its
-- passes with these counters, and a span across the repeat or the wrap is 65
-- ticks (~6 counts) off. The beam position comes from time_until_pos: MAME's
-- Lua has no screen vpos/hpos.
local vcTaps = {}
local function beamCountersFix()
if "$paddle" == "" then return end
local scr = manager.machine.screens[":screen"]
local function beam()
local e = scr.frame_period - scr:time_until_pos(0, 0)
if e >= 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" </dev/null 2>&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