joeylib2/scripts/verify-x68000-scb.sh
2026-10-19 15:46:47 -05:00

588 lines
24 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-x68000-scb.sh - SCB gate for the X68000: proves that the per-band
# palette reloads the text-plane present depends on land where they must, on
# a screen that changes palette 147 times a frame.
#
# The X68000 port presents to the text plane, which has ONE 16-entry palette,
# so it honors the SCB by reloading that palette as the beam scans: a CRTC
# raster interrupt (src/x68000/jlX68kRasterIsr.s) stores a band's sixteen
# colors in the horizontal blanking before the band's first line. Each reload
# has a window of about 400 dots (~18 us): after the last stage pixel of the
# line above and before the first stage pixel of the band. Land early and the
# end of the line above shows the new colors; land late and the start of the
# band shows the old ones; miss and the whole band does.
#
# Boots Human68k headless under MAME with SCB.X (examples/scb): all sixteen
# palettes, every color distinct per palette, a change on every line for 128
# of the 200 lines, irregular runs for the rest, 20 stripes using every color
# on every line, the whole stage redrawn and every palette set again on every
# pass. Once the stripes are up and have settled, the Lua below taps every
# write to the text palette ($E82200), to the raster compare (CRTC R09), to
# MFP ISRA (the interrupt's exit) and every read of the $138 vector (its
# entry), timestamped on the emulated clock -- MAME's x68000 runs the
# cycle-exact 68000 core, so a write's timestamp is its bus cycle -- and
# converts each to a beam position with the screen's own timing. It then
# checks CAPTURE whole frames, computing what every stage line shows from the
# program's SCB and palette rules (the same rules as examples/scb/scb.c, not
# the output):
#
# timeline the palette in effect at each line's first stage pixel is that
# line's palette, and no palette write lands between its first and
# last stage pixel. This is what a CRT shows: a palette write takes
# effect at once. Every band start's reload is also measured: how
# long after the last stage pixel of the line above it began, and
# how long before the band's first stage pixel it finished.
# bitmap MAME's own rendering, read back with screen:pixels() on SNAPSHOTS
# frames, shows each line in its palette -- or, only where the next
# line starts a band, partly in the next line's: MAME draws a line
# when the NEXT line's display begins (x68k_crtc.cpp hsync ->
# update_partial(scan - 1)), so a reload that lands correctly in
# the blanking before a line is drawn onto the line above it, unless
# a raster interrupt drew that line first (raster_irq). MAME's
# bitmap therefore cannot place a reload to the line; the timeline
# can. This check catches gross misplacement independently of the
# taps.
# exit after ESC the program exits and gives the $138 vector and MFP
# channel 14 back -- and the two clock vectors it held while it
# ran, which are checked then: $114 (jlX68kTickIsr, chained onto
# Human68k's Timer C) and $134 (jlX68kVdispIsr, the frame counter
# _VDISPST installs, with MFP channel 13 enabled).
#
# It also reports the interrupt's cost (time inside it per frame), the latency
# from each raster match to the vector fetch, and the slack of the compare
# write the interrupt's timing rests on (jlX68kRasterIsr.s step 2).
#
# Human68k's Timer C tick runs throughout, and text is typed during the whole
# capture so the IOCS keyboard interrupt runs too: both are MFP interrupts at
# IPL 6, like the raster interrupt, and a raster match that falls inside one of
# them is taken late (the Timer C dispatcher used to make reloads miss their
# line; see src/x68000/jlX68kTickIsr.s). The DMAC-driven ADPCM path is not
# exercised: JoeyLib's audio does its work on the main thread
# (src/x68000/audioPcm.c).
#
# Usage: scripts/verify-x68000-scb.sh
# Env: SCB_PROGRAM run this build of SCB.X instead (negative controls)
# SCB_SETTLE frames between the stripes appearing and the capture
# (default 120; the emulation is deterministic, so a
# different value is what samples a different phase
# between the frame and Timer C)
# SCB_CAPTURE frames captured and checked (default 60)
# Requires: MAME and the X68000 firmware and CG ROM; build/x68000/bin/SCB.X
# (make x68000).
set -uo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
source "$repo/scripts/lib/joeyEnv.sh"
joeyNeed MAME mame
joeyNeed X68K_DIR x68kFirmware
joeyNeedFile "$X68K_DIR/roms/x68000/cgrom.dat" x68kCgrom
source "$repo/scripts/lib/x68kDisk.sh"
joeyNeedFile "$X68K_SYSTEM_DISK" x68kFirmware
program="${SCB_PROGRAM:-$repo/build/x68000/bin/SCB.X}"
wall=600 # hard cap on MAME, whatever happens to this script
frames=20000 # emulated frames before the Lua gives up on the program
settle="${SCB_SETTLE:-120}"
capture="${SCB_CAPTURE:-60}"
if [ ! -f "$program" ]; then
echo "verify-x68000-scb: $program is not built; run: make x68000" >&2
exit 2
fi
work=$(mktemp -d -t joeylib-x68-scb.XXXXXX)
mame=""
mameChild=""
cleanup() {
joeyStop "$mameChild" "$mame"
rm -rf "$work"
}
trap cleanup EXIT
# The boot disk carries only Human68k and AUTOEXEC; the program lives on the
# data disk in B:, which AUTOEXEC makes current.
x68kBootDisk "$work/boot.xdf" 'B:' 'B:\SCB.X' || exit 1
x68kDataDisk "$work/data.xdf" || exit 1
x68kXdf add "$work/data.xdf" "$program" SCB.X > /dev/null || exit 1
cat > "$work/scb.lua" <<LUA
local cpu = manager.machine.devices[":maincpu"]
local mem = cpu.spaces["program"]
local scr = manager.machine.screens[":screen"]
local nat = manager.machine.natkeyboard
local FRAMES = $frames
local CAPTURE = $capture
local SETTLE = $settle
local SAMPLE = 30 -- frames between looks for the stripes
local SNAPSHOTS = { [10] = true, [30] = true, [50] = true }
local KEY_EVERY = 60 -- frames between ESC presses once the verdict is in
local TYPED = "the quick brown fox jumps over the lazy dog 0123456789 "
local EXIT_FRAMES = 1200 -- frames the program gets to exit and hand back \$138
-- The port's display contract (src/x68000/x68kPlanar.h).
local ORIGIN_X, ORIGIN_Y = 96, 156
local STAGE_W, STAGE_H = 320, 200
local TVRAM, PLANE_STEP, TVRAM_STRIDE = 0xE00000, 0x20000, 128
local TEXT_PALETTE, COLORS = 0xE82200, 16
local CRTC, CRTC_R09 = 0xE80000, 0xE80012
local MFP_IERA, MFP_IMRA, MFP_ISRA = 0xE88007, 0xE88013, 0xE8800F
local RASTER_VECTOR = 0x138
local TICK_VECTOR = 0x114 -- MFP channel 5, Timer C: jlX68kTickIsr
local VDISP_VECTOR = 0x134 -- MFP channel 13, Timer A: jlX68kVdispIsr
local TIMER_A_BIT = 0x20 -- channel 13 in IERA / IMRA
local PROGRAM_RAM_END = 0x01000000 -- IOCS's own test for "a handler is installed"
local GPIP6_BIT = 0x40
local ISRA_EXIT = 0xBF -- jlX68kRasterIsr's in-service ack
local CPU_HZ = 10000000 -- 40 MHz / 4 (MAME x68k.cpp)
local IRQ_HPOS = 2 -- x68k_crtc.cpp: R09 = N fires at (N - 1, 2)
-- examples/scb/scb.c: the picture, from its rules.
local STRIPE_W = 16
local EVERY_LINE_END, IRREGULAR_END = 64, 136
local RUN_LENGTH_STEP, RUN_LENGTH_SPAN = 5, 7
local RUN_PALETTE_STEP, RUN_PALETTE_BASE = 7, 3
local EVEN_LINE_PALETTE, ODD_LINE_PALETTE = 6, 9
local BLUE_PALETTE_STEP = 7
local scb = {}
do
for y = 0, EVERY_LINE_END - 1 do
scb[y] = y % COLORS
end
local run, left, pal = 0, 0, 0
for y = EVERY_LINE_END, IRREGULAR_END - 1 do
if left == 0 then
left = 1 + (run * RUN_LENGTH_STEP) % RUN_LENGTH_SPAN
pal = (run * RUN_PALETTE_STEP + RUN_PALETTE_BASE) % COLORS
run = run + 1
end
scb[y] = pal
left = left - 1
end
for y = IRREGULAR_END, STAGE_H - 1 do
scb[y] = (y % 2 == 1) and ODD_LINE_PALETTE or EVEN_LINE_PALETTE
end
end
-- Color i of palette p as \$0RGB (color 0 is black in every palette).
local function rgb12(p, i)
if i == 0 then
return 0
end
return (p << 8) | (i << 4) | ((p * BLUE_PALETTE_STEP + i) % COLORS)
end
-- A text palette word is GGGGG RRRRR BBBBB I (MAME x68k.cpp GGGGGRRRRRBBBBBI);
-- the top four bits of each five-bit field are the \$0RGB channel.
local function wordRgb12(w)
return (((w >> 7) & 15) << 8) | (((w >> 12) & 15) << 4) | ((w >> 2) & 15)
end
-- A rendered pixel: the top four bits of each 8-bit channel.
local function pixelRgb12(px)
return (((px >> 20) & 15) << 8) | (((px >> 12) & 15) << 4) | ((px >> 4) & 15)
end
local function out(s)
io.write("SCB-X68K " .. s .. "\n")
io.flush()
end
-- The text plane holds two pages, and the port flips between them with the
-- text Y scroll (CRTC R11, which reads back): rows are counted from the page
-- on screen.
local function shownRow()
return mem:read_u16(0xE80016) & 0x3FF
end
local function stagePixel(px, py)
local x = ORIGIN_X + px
local byteOff = (ORIGIN_Y + shownRow() + py) * TVRAM_STRIDE + (x >> 3)
local bit = 0x80 >> (x & 7)
local v = 0
for plane = 0, 3 do
if (mem:read_u8(TVRAM + plane * PLANE_STEP + byteOff) & bit) ~= 0 then
v = v | (1 << plane)
end
end
return v
end
local function stripesUp()
for s = 0, STAGE_W // STRIPE_W - 1 do
if stagePixel(s * STRIPE_W + STRIPE_W // 2, STAGE_H // 2) ~= s % COLORS then
return false
end
end
return true
end
-- Screen timing, filled in when the capture starts.
local fp, sp, pp, dotsPerLine, vbegin, stageDot0, zero0
local function posTime(frameIdx, line, dot)
return zero0 + frameIdx * fp + line * sp + dot * pp
end
local function dotsOf(seconds)
return seconds / pp
end
-- Event log: kind 1 palette write, 2 R09 write, 3 vector fetch, 4 ISRA exit.
local evT, evKind, evOff, evData = {}, {}, {}, {}
local function log(kind, offset, data)
local n = #evT + 1
evT[n], evKind[n], evOff[n], evData[n] = emu.time(), kind, offset, data
end
local taps, snaps = {}, {}
local initialPalette = {}
local frame, stripesAt, captureAt, captured = 0, 0, 0, 0
local verdictAt, exitDeadline, isrAddress = 0, 0, 0
local tickAddress, vdispAddress = 0, 0
local done, failed = false, false
local function fail(msg)
failed = true
out("FAIL " .. msg)
end
local function startCapture()
local r = {}
for i = 0, 8 do
r[i] = mem:read_u16(CRTC + i * 2)
end
fp, sp, pp = scr.frame_period, scr.scan_period, scr.pixel_period
dotsPerLine = math.floor(sp / pp + 0.5)
local lines = math.floor(fp / sp + 0.5)
-- x68k_crtc.cpp refresh_mode: hbegin = R02 * 8 + 1; the text row on screen
-- line L is L - vbegin, vbegin = R06 (x68k_v.cpp get_text_pixel).
vbegin = r[6]
stageDot0 = r[2] * 8 + 1 + ORIGIN_X
out(string.format("crtc R00=%d R02=%d R03=%d R04=%d R06=%d R07=%d R20=%04X frame=%.3fms line=%.3fus dots=%d lines=%d",
r[0], r[2], r[3], r[4], r[6], r[7], mem:read_u16(CRTC + 40), fp * 1e3, sp * 1e6, dotsPerLine, lines))
if lines ~= r[4] + 1 then
fail(string.format("the display is interlaced or not the mode the port documents (%d lines a frame, R04+1 = %d)", lines, r[4] + 1))
return false
end
for i = 0, COLORS - 1 do
initialPalette[i] = mem:read_u16(TEXT_PALETTE + i * 2)
end
zero0 = emu.time() + scr:time_until_pos(0, 0)
taps[#taps + 1] = mem:install_write_tap(TEXT_PALETTE, TEXT_PALETTE + COLORS * 2 - 1, "scbPal", function(offset, data, mask)
log(1, offset, data)
end)
taps[#taps + 1] = mem:install_write_tap(CRTC_R09, CRTC_R09 + 1, "scbR09", function(offset, data, mask)
log(2, offset, data)
end)
taps[#taps + 1] = mem:install_read_tap(RASTER_VECTOR, RASTER_VECTOR + 3, "scbVec", function(offset, data, mask)
if offset == RASTER_VECTOR then
log(3, offset, data)
end
end)
taps[#taps + 1] = mem:install_write_tap(MFP_ISRA - 1, MFP_ISRA, "scbIsra", function(offset, data, mask)
if (data & mask & 0xFF) == ISRA_EXIT then
log(4, offset, data)
end
end)
isrAddress = mem:read_u32(RASTER_VECTOR)
tickAddress = mem:read_u32(TICK_VECTOR)
vdispAddress = mem:read_u32(VDISP_VECTOR)
local ch13 = mem:read_u8(MFP_IERA) & mem:read_u8(MFP_IMRA) & TIMER_A_BIT
out(string.format("clocks: \$114 = %08X, \$134 = %08X, channel 13 %s", tickAddress, vdispAddress, (ch13 ~= 0) and "on" or "off"))
if tickAddress >= PROGRAM_RAM_END or vdispAddress >= PROGRAM_RAM_END or ch13 == 0 then
fail("the clock interrupts are not the program's while it runs")
end
return true
end
local function stopTaps()
for _, t in ipairs(taps) do
t:remove()
end
taps = {}
end
-- Stage span of row y in frame k: [first stage pixel, end of the last).
-- Row -1 is the border line above the stage: black in every palette, but it
-- is the line whose stage columns a reload for row 0 must not reach.
local function spanStart(k, y)
return posTime(k, vbegin + ORIGIN_Y + y, stageDot0)
end
local function spanEnd(k, y)
return posTime(k, vbegin + ORIGIN_Y + y, stageDot0 + STAGE_W)
end
local function stats(list)
table.sort(list)
local n = #list
if n == 0 then
return "none"
end
local sum = 0
for _, v in ipairs(list) do
sum = sum + v
end
return string.format("min %.0f median %.0f mean %.0f max %.0f", list[1], list[(n + 1) // 2], sum / n, list[n])
end
-- The timeline check: what a CRT shows, line by line, from the write log.
local function checkTimeline()
local state = {}
for i = 0, COLORS - 1 do
state[i] = initialPalette[i]
end
local ev, nEv = 1, #evT
local lines, wrong, torn, bands, missed = 0, 0, 0, 0, 0
local early, late = {}, {}
local reported = 0
-- Frame 0 of the capture is partial (the taps went in mid-frame); frames
-- 1..CAPTURE-1 are checked whole.
for k = 1, CAPTURE - 1 do
for y = 0, STAGE_H - 1 do
local s, e = spanStart(k, y), spanEnd(k, y)
local prevEnd = (y == 0) and spanEnd(k - 1, STAGE_H - 1) or spanEnd(k, y - 1)
local first, last = nil, nil
while ev <= nEv and evT[ev] <= s do
if evKind[ev] == 1 then
state[(evOff[ev] - TEXT_PALETTE) // 2] = evData[ev]
if evT[ev] > prevEnd then
first = first or evT[ev]
last = evT[ev]
end
end
ev = ev + 1
end
local ok = true
for i = 0, COLORS - 1 do
if wordRgb12(state[i]) ~= rgb12(scb[y], i) then
ok = false
break
end
end
local tearAt = nil
local look = ev
while look <= nEv and evT[look] < e do
if evKind[look] == 1 then
tearAt = evT[look]
break
end
look = look + 1
end
lines = lines + 1
if not ok then
wrong = wrong + 1
end
if tearAt then
torn = torn + 1
end
local prevY = (y == 0) and (STAGE_H - 1) or (y - 1)
if scb[prevY] ~= scb[y] then
bands = bands + 1
if first then
early[#early + 1] = dotsOf(first - spanEnd(k, y - 1))
late[#late + 1] = dotsOf(s - last)
else
missed = missed + 1
end
end
if (not ok or tearAt) and reported < 12 then
reported = reported + 1
local shows = {}
for i = 0, COLORS - 1 do
shows[#shows + 1] = string.format("%03X", wordRgb12(state[i]))
end
out(string.format(" frame %d line %d: palette %d expected, shows [%s]%s", k, y, scb[y],
table.concat(shows, " "),
tearAt and string.format(", write %.0f dots into the line's stage pixels", dotsOf(tearAt - s)) or ""))
end
end
end
out(string.format("timeline lines=%d wrong=%d torn=%d bandStarts=%d reloadsFound=%d", lines, wrong, torn, bands, bands - missed))
out(" reload start, dots after the last stage pixel of the line above: " .. stats(early))
out(" reload end, dots before the band's first stage pixel: " .. stats(late))
out(string.format(" one dot = %.1f ns, one CPU cycle = %.2f dots", pp * 1e9, (1 / CPU_HZ) / pp))
return wrong == 0 and torn == 0 and missed == 0 and bands > 0
end
-- Interrupt cost, latency and slack from the entry/exit/R09 events. The first
-- R09 write after an entry re-points the compare at the raster of the line
-- above the band (jlX68kRasterIsr.s step 2); it must land before that raster
-- begins, and its slack is how far ahead it did. The last R09 write before an
-- entry is the arm that raised it.
local function reportInterrupt()
local inside, entries, latencies, slacks = 0, 0, {}, {}
local entryAt, armValue, armAt = nil, nil, nil
local firstT, lastT = nil, nil
local awaitReference = false
for n = 1, #evT do
local kind = evKind[n]
if kind == 3 then
entryAt = evT[n]
if armValue and armAt then
-- The raster match: the first (armValue - 1, IRQ_HPOS) after the arm.
local k = math.floor((armAt - zero0) / fp)
local assertAt = posTime(k, armValue - 1, IRQ_HPOS)
if assertAt < armAt then
assertAt = assertAt + fp
end
latencies[#latencies + 1] = (entryAt - assertAt) * CPU_HZ
end
armValue = nil
awaitReference = true
elseif kind == 2 then
armValue, armAt = evData[n], evT[n]
if awaitReference then
local k = math.floor((evT[n] - zero0) / fp)
slacks[#slacks + 1] = (posTime(k, evData[n] - 1, IRQ_HPOS) - evT[n]) * CPU_HZ
awaitReference = false
end
elseif kind == 4 and entryAt then
inside = inside + (evT[n] - entryAt)
entries = entries + 1
firstT = firstT or entryAt
lastT = evT[n]
entryAt = nil
end
end
if entries == 0 then
out("interrupt never ran during the capture")
return
end
local span = (lastT - firstT) / fp
out(string.format("interrupt entries=%d over %.1f frames: %.1f per frame, %.2f ms inside it per frame (%.1f%% of a frame)",
entries, span, entries / span, (inside / span) * 1e3, (inside / span) / fp * 100))
out(" latency, raster match -> vector fetch (CPU cycles): " .. stats(latencies))
out(" slack, step-2 compare write -> its raster (CPU cycles): " .. stats(slacks))
end
-- The bitmap check: MAME's own rendering, within MAME's one-line look-ahead.
local function checkSnapshot(pix, w, label)
local rows, exact, ahead, bad = 0, 0, 0, 0
local stripes = STAGE_W // STRIPE_W
for y = 0, STAGE_H - 1 do
local row = ORIGIN_Y + y -- visible area starts at vbegin
local here = scb[y]
local next = scb[(y + 1) % STAGE_H]
local isExact, isAhead = true, true
for s = 0, stripes - 1 do
local x = ORIGIN_X + s * STRIPE_W + STRIPE_W // 2
local c = pixelRgb12(string.unpack("<I4", pix, (row * w + x) * 4 + 1))
local col = s % COLORS
if c ~= rgb12(here, col) then
isExact = false
end
if c ~= rgb12(here, col) and c ~= rgb12(next, col) then
isAhead = false
end
end
rows = rows + 1
if isExact then
exact = exact + 1
elseif isAhead then
ahead = ahead + 1
else
bad = bad + 1
if bad <= 6 then
out(string.format(" bitmap %s line %d: neither palette %d nor the next line's %d", label, y, here, next))
end
end
end
out(string.format("bitmap %s lines=%d exact=%d nextLineAhead=%d wrong=%d", label, rows, exact, ahead, bad))
return bad == 0
end
emu.register_frame_done(function()
frame = frame + 1
if done then
return
end
if stripesAt == 0 then
if frame % SAMPLE == 0 and stripesUp() then
stripesAt = frame
captureAt = frame + SETTLE
out("stripes up f" .. frame)
end
elseif captureAt ~= 0 and frame == captureAt then
if not startCapture() then
done = true
manager.machine:exit()
return
end
nat:post(TYPED .. TYPED .. TYPED)
elseif captureAt ~= 0 and frame > captureAt and verdictAt == 0 then
captured = captured + 1
if SNAPSHOTS[captured] then
local pix, w = scr:pixels()
snaps[#snaps + 1] = { pix, w, "f" .. captured }
end
if captured >= CAPTURE then
stopTaps()
out(string.format("captured %d frames, %d events", CAPTURE, #evT))
local ok = checkTimeline()
reportInterrupt()
for _, sn in ipairs(snaps) do
ok = checkSnapshot(sn[1], sn[2], sn[3]) and ok
end
if not ok then
fail("the SCB palette reloads do not land where they must")
end
verdictAt = frame
exitDeadline = frame + EXIT_FRAMES
end
elseif verdictAt ~= 0 then
if frame % KEY_EVERY == 0 then
nat:post_coded("{ESC}")
end
local vec = mem:read_u32(RASTER_VECTOR)
local ch14 = (mem:read_u8(MFP_IERA) | mem:read_u8(MFP_IMRA)) & GPIP6_BIT
local tick = mem:read_u32(TICK_VECTOR)
local vdsp = mem:read_u32(VDISP_VECTOR)
local ch13 = (mem:read_u8(MFP_IERA) | mem:read_u8(MFP_IMRA)) & TIMER_A_BIT
if vec ~= isrAddress and ch14 == 0 and tick ~= tickAddress and vdsp ~= vdispAddress and ch13 == 0 then
out(string.format("exit ok: \$138 = %08X (was the interrupt at %08X), channel 14 off", vec, isrAddress))
out(string.format("exit ok: \$114 = %08X, \$134 = %08X, channel 13 off", tick, vdsp))
out(failed and "verdict FAIL" or "verdict PASS")
done = true
manager.machine:exit()
elseif frame >= exitDeadline then
fail(string.format("the program did not exit and hand back \$138 (%08X) and channel 14, \$114 (%08X), \$134 (%08X) and channel 13", vec, tick, vdsp))
out("verdict FAIL")
done = true
manager.machine:exit()
end
end
if not done and frame > FRAMES then
out("verdict FAIL: the stripes never appeared or the capture never finished (f" .. frame .. ")")
done = true
manager.machine:exit()
end
end)
LUA
# From the work dir, so MAME's cfg/ and nvram/ never persist between runs.
# Under timeout so a hung run cannot outlive $wall whatever happens to this
# script; mameChild is MAME itself, which cleanup stops first. The Lua ends
# the run as soon as the verdict is in and the program has exited.
cd "$work" || exit 1
timeout -s KILL "$wall" "$MAME" x68000 -bios ipl10 \
-rompath "$X68K_DIR/roms" -flop1 "$work/boot.xdf" -flop2 "$work/data.xdf" \
-video none -sound none -nothrottle \
-autoboot_script "$work/scb.lua" < /dev/null > "$work/mame.log" 2>&1 &
mame=$!
mameChild=$(joeyChild "$mame")
wait "$mame"
mame=""
mameChild=""
out=$(grep '^SCB-X68K' "$work/mame.log")
echo "${out:-SCB-X68K: no report from the Lua (MAME died, or hit the ${wall}s cap)}"
if grep -q '^SCB-X68K verdict PASS' <<< "$out"; then
echo "verify-x68000-scb: PASS"
exit 0
fi
echo "verify-x68000-scb: FAIL" >&2
exit 1