#!/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-serial-mouse.sh - the X68000's RS-232C port and mouse at once. # # Both live on the one Z8530 SCC: RS-232C on channel A, where JoeyLib's serial # add-on owns the receive interrupt (src/x68000/x68kSccRxIsr.s), and the mouse # on channel B, where the IOCS ROM owns the receive interrupt and solicits a # packet every other Timer C tick. The chip has ONE register pointer for both # channels, so a select-then-access register sequence split by the other # side's interrupt goes to the wrong register, and the interrupt-under-service # reset it swallows wedges the chip's interrupt chain. The serial and mouse # gates each run one side alone; this one runs both together, hard, for long # enough to matter. # # Boots SERMOUSE.X (examples/sermouse) under the patched MAME with SCC channel # A on a null_modem socket and every SCC channel at the real chip's 3-byte # receive FIFO (JOEY_Z80SCC_RX_FIFO=3), so an interrupt held off too long loses # bytes here as it would on the machine. Then, all at once: # serial the guest streams byte k = streamByte(k, 0xA5) and checks the # peer's stream, byte k = streamByte(k, 0x00), in order; the host # peer (scripts/lib/serialStreamPeer.py) checks the guest's and # answers byte for byte. The guest also raises DTR 16 times a loop, # the add-on's own select-then-write of channel A's WR5, so that # sequence runs under both channels' interrupts all the time. # mouse the Lua visits the four stage corners, pushing both axes into the # corner EVERY frame -- a packet on channel B at every solicitation, # while the pointer stays pinned where the clamp puts it -- checks # the position there, then presses a button and checks it, then # releases it (checked at the next corner). Clamp edges only: MAME's # relative-axis injection is timing-sensitive, so interior positions # are not testable (see verify-x68000-mouse.sh). Two left clicks a # pass also prove the ROM's software keyboard stays off (hal.c # X68K_SKEY_OFF): the second would bring it up and eat the next # right click. # Timeline: PASSES corner passes with the traffic flowing, every check also # requiring both stream counts to have moved and no bad byte; SPACE pauses the # guest's stream, the peer follows, and once quiet the guest's counts must # hold still and equal a quiet snapshot of the peer's EXACTLY -- every byte # sent either way arrived, in order; one more pass in the quiet; SPACE resumes # and both counts must move again (the receive interrupt still fires at the # end, the program still runs). # # MAME 0.264 needs one correction here, made in the Lua and not in MAME. Its # Z8530 (src/devices/machine/z80scc.cpp) answers an interrupt acknowledge # wrongly when BOTH channels have one pending: z80daisy_irq_ack puts the # highest-priority source in service but returns m_chanB->m_rr2, the vector of # whichever source was triggered LAST. The CPU then runs the other channel's # handler, whose interrupt-under-service reset clears the source the chip # acknowledged, and that channel's byte is left in its FIFO with nothing # pending: the channel runs a byte behind until its FIFO overruns (serial # loses bytes) or a stale byte starts a mouse packet (pointer and buttons go # wrong). Measured before the correction: both, within seconds. Silicon # returns the vector of the source it acknowledges, so a tap on the CPU's # level-5 acknowledge rewrites the vector to that source's, and the verdict # line reports how many acknowledges it corrected (iackFixed). Only this gate # keeps both channels busy at once, so no other gate needs it. # # The guest reports through two verification rows at the bottom of the stage # (one nibble per pixel, high nibble first), read from the text plane: # row 198 3C3C, status, received(8), bad, sent(8), short writes, first bad(8) # row 199 5A5A, present/left/right/middle, x, y (examples/keys' mouse row) # The serial row changes at most every 4 frames, so a read counts only when # two consecutive frames agree (a present caught half done cannot). # # PASS: every Lua check right, the peer's stream PASS, at least MIN_BYTES each # way, and the guest's quiet counts among the peer's quiet snapshots. # # Variants, built here into the work directory from examples/sermouse: # X68K_SERMOUSE_BAUD=19200|38400 the line rate (default 9600, MAME's # null_modem default); RSDRV.SYS, which the # boot disk loads, is what reaches past 9600 # X68K_SERMOUSE_DENSE=n stage lines 0 .. n-1 change palette on # every line (default 0: one palette), so # the SCB raster interrupt holds IPL 6 for # the whole run every frame while the # streams and the mouse run # scripts/verify-x68000-serial-scb.sh runs the dense combinations this port # guarantees; see "Serial under a dense SCB" in claudeDocs/x68000_port.md. # # Requires: ./install.sh install x68000 -- the patched MAME # (installer/patches/mame: 0002 for the FIFO depth, 0004 for the # RS-232C slot and the mouse) and the Human68k system disk. # # bash scripts/verify-x68000-serial-mouse.sh 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" PORT="${X68K_SERMOUSE_PORT:-6801}" WALL="${X68K_SERMOUSE_WALL:-600}" PASSES="${X68K_SERMOUSE_PASSES:-3}" MIN_BYTES="${X68K_SERMOUSE_MIN_BYTES:-16384}" BAUD="${X68K_SERMOUSE_BAUD:-9600}" DENSE="${X68K_SERMOUSE_DENSE:-0}" PROG="$repo/build/x68000/bin/SERMOUSE.X" FIFO_DEPTH=3 DEFAULT_BAUD=9600 # The peer outlives MAME by this much, so it always sees the link close, and # is given this long to report once MAME is gone. PEER_MARGIN=30 PEER_GRACE=10 [ -f "$X68K_SYSTEM_DISK" ] || { echo "verify-x68000-serial-mouse: missing $X68K_SYSTEM_DISK" >&2; exit 2; } [ -f "$PROG" ] || { echo "verify-x68000-serial-mouse: build SERMOUSE.X first (make -f make/x68000.mk)" >&2; exit 2; } work=$(mktemp -d -t joey-x68sermouse.XXXXXX) peer="" mame="" mameChild="" cleanup() { joeyStop "$mameChild" "$mame" "$peer" rm -rf "$work" } trap cleanup EXIT # A variant is examples/sermouse built against the same library with its # line rate and SCB set (see the header). if [ "$BAUD" != "$DEFAULT_BAUD" ] || [ "$DENSE" != 0 ]; then joeyNeed X68K_ROOT toolchain:x68000 "$X68K_ROOT/bin/m68k-xelf-gcc" -O2 -m68000 -fomit-frame-pointer \ -DJOEYLIB_PLATFORM_X68000 -DLINE_BAUD="${BAUD}u" -DDENSE_SCB_LINES="${DENSE}u" \ -I"$repo/include" \ "$repo/examples/sermouse/sermouse.c" \ "$repo/build/x68000/lib/libjoey.a" -lm \ -o "$work/SERMOUSE.X" || exit 1 PROG="$work/SERMOUSE.X" fi x68kBootDisk "$work/boot.xdf" 'A:\SERMOUSE.X' || exit 1 x68kXdf add "$work/boot.xdf" "$PROG" SERMOUSE.X >/dev/null || exit 1 { printf 'local PASSES = %d\n' "$PASSES" printf 'local MIN_BYTES = %d\n' "$MIN_BYTES" printf 'local BAUD = %d\n' "$BAUD" printf 'local DENSE = %d\n' "$DENSE" cat <<'LUA' local cpu = manager.machine.devices[":maincpu"] local mem = cpu.spaces["program"] local nat = manager.machine.natkeyboard local scc = manager.machine.devices[":scc"] local sccA = manager.machine.devices[":scc:cha"] local frame = 0 local t0 = 0 local biased = false local done = false local checks = 0 local fails = {} local steps = {} local pending = nil local lastRaw = nil local prev = nil local quiet = nil local pushX = 0 local pushY = 0 local axisValX = 0 local axisValY = 0 -- Stage geometry on the text plane (src/x68000/hal.c X68K_PRESENT_TEXT): four -- bit planes 128 KB apart at 0xE00000, 128 bytes per row, leftmost pixel in -- bit 7, the stage centred at (96, 156). local TEXT_BASE = 0xE00000 local PLANES = 4 local PLANE_BYTES = 0x20000 local ROW_BYTES = 128 local ORIGIN_X = 96 local ORIGIN_Y = 156 -- The verification rows (examples/sermouse/sermouse.c): 16-bit words and -- 32-bit longs, one nibble per pixel, laid end to end from the sentinel. local WORD = 4 local LONG = 8 local SERIAL_ROW = 198 local SERIAL_SENTINEL = 0x3C3C local SERIAL_NIBBLES = 4 * WORD + 3 * LONG local MOUSE_ROW = 199 local MOUSE_SENTINEL = 0x5A5A local MOUSE_NIBBLES = 4 * WORD local STAGE_RIGHT = 319 local STAGE_BOTTOM = 199 local STATUS_OPEN = 0x0001 local STATUS_PAUSED = 0x0002 local NO_BAD_INDEX = 0xFFFFFFFF -- Mouse drive: the x68k mouse's X/Y are 12-bit absolute counters it diffs -- into delta packets; biased mid-range so a pass (net zero per axis, at most -- two phases one way) never wraps them. local AXIS_BIAS = 2048 local PUSH_STEP = 6 -- counter units per frame, per axis local PUSH_FRAMES = 90 -- 540 units per phase: over the 319-pixel stage with room to spare local BUTTON_FRAMES = 40 local CORNER_FRAMES = PUSH_FRAMES + BUTTON_FRAMES local PASS_FRAMES = 4 * CORNER_FRAMES local QUIET_SETTLE = 120 -- frames after SPACE before the line is read as quiet local QUIET_HOLD = 60 -- and again this much later: it must not move local RESUME_FRAMES = 120 local BOOT_BIAS_FRAME = 300 local SENTINEL_FIRST = 600 local SENTINEL_EVERY = 50 local SETTLE_FRAMES = 60 local STEADY_LIMIT = 60 -- frames a serial read may take to see two agreeing frames local BOOT_LIMIT = 30000 -- The acknowledge-vector correction (see the script header). MAME keeps the -- chip's six interrupt sources in m_int_state, highest priority first, -- channel A's three then channel B's, each INT (pending) and/or IEO (in -- service); m_int_source holds each one's type, which with the channel is the -- 3-bit status the vector carries in bits 3-1 (WR9 status-low, as the X68000 -- runs it). The 68000 fetches the level-5 vector at 0xFFFFFA in cpu space. local IACK5 = 0xFFFFFA local SCC_SOURCES = 6 local SCC_A_SOURCES = 3 local DAISY_IEO = 0x02 local WR9_VIS = 0x01 local WR9_NV_SHSL = 0x12 -- no vector / status high: vectors this does not model local STATUS_CHANNEL_A = 0x04 local STATUS_TYPE_MASK = 0x03 local VECTOR_STATUS_KEEP = 0xF1 local VECTOR_BYTE = 0xFF local intState = emu.item(scc.items["0/m_int_state"]) local intSource = emu.item(scc.items["0/m_int_source"]) local wr9 = emu.item(scc.items["0/m_wr9"]) local wr2 = emu.item(sccA.items["0/m_wr2"]) local iackFixed = 0 -- 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 textPix(px, y) local sx = ORIGIN_X + px local addr = TEXT_BASE + (ORIGIN_Y + shownRow() + y) * ROW_BYTES + (sx >> 3) local bit = 7 - (sx & 7) local v = 0 for p = 0, PLANES - 1 do v = v | (((mem:read_u8(addr + p * PLANE_BYTES) >> bit) & 1) << p) end return v end local function rowNibbles(y, count) local t = {} for i = 0, count - 1 do t[#t + 1] = textPix(i, y) end return t end -- A reader over a row's nibbles: each call takes the next field, high nibble -- first. local function fields(t) local pos = 0 return function(count) local v = 0 for i = pos + 1, pos + count do v = (v << 4) | t[i] end pos = pos + count return v end end local function parseSerial(t) local take = fields(t) local s = {} s.sentinel = take(WORD) == SERIAL_SENTINEL s.status = take(WORD) s.rx = take(LONG) s.bad = take(WORD) s.tx = take(LONG) s.short = take(WORD) s.firstBad = take(LONG) return s end -- The flags word is one nibble per predicate: present, left, right, middle. local function readMouse() local take = fields(rowNibbles(MOUSE_ROW, MOUSE_NIBBLES)) local m = {} m.sentinel = take(WORD) == MOUSE_SENTINEL m.present = take(1) m.left = take(1) m.right = take(1) m.middle = take(1) m.x = take(WORD) m.y = take(WORD) return m end local ports = manager.machine.ioport.ports local axisX, axisY, btnLeft, btnRight local portX = ports[":mouse_port:x68k:X"] local portY = ports[":mouse_port:x68k:Y"] local portB = ports[":mouse_port:x68k:BTN"] if portX then for _, f in pairs(portX.fields) do axisX = f end end if portY then for _, f in pairs(portY.fields) do axisY = f end end if portB then for _, f in pairs(portB.fields) do if f.mask == 2 then btnLeft = f end if f.mask == 1 then btnRight = f end end end -- After MAME has acknowledged: the source now in service is the one the -- vector must name. A global, because a collected tap handle removes the tap. iackTap = cpu.spaces["cpu_space"]:install_read_tap(IACK5, IACK5 + 1, "iackVector", function(offset, data, mask) local w = wr9:read(0) if (w & WR9_VIS) == 0 or (w & WR9_NV_SHSL) ~= 0 then return nil end for i = 0, SCC_SOURCES - 1 do if (intState:read(i) & DAISY_IEO) ~= 0 then local status = (intSource:read(i) & STATUS_TYPE_MASK) | (i < SCC_A_SOURCES and STATUS_CHANNEL_A or 0) local vector = (wr2:read(0) & VECTOR_STATUS_KEEP) | (status << 1) if vector == (data & VECTOR_BYTE) then return nil end iackFixed = iackFixed + 1 return (data & ~VECTOR_BYTE) | vector end end return nil end) local function finish(verdict) done = true local s = prev or {} io.write(string.format("VERIFY-X68K-SERMOUSE %s baud=%d dense=%d checks=%d fails=%d rx=%d tx=%d quiet=%s iackFixed=%d%s\n", verdict, BAUD, DENSE, checks, #fails, s.rx or 0, s.tx or 0, quiet and string.format("%d:%d", quiet.rx, quiet.tx) or "none", iackFixed, #fails > 0 and (" detail=" .. table.concat(fails, ",")) or "")) io.flush() manager.machine:exit() end if not (axisX and axisY and btnLeft and btnRight) then done = true io.write("VERIFY-X68K-SERMOUSE error: mouse ioports not found\n") io.flush() manager.machine:exit() end -- One check; the first failure is the verdict, so the run stops there. local function expect(name, ok, detail) if done then return end checks = checks + 1 if not ok then fails[#fails + 1] = name .. (detail and ("(" .. detail .. ")") or "") finish("FAIL") end end local function expectMouse(step, x, y, l, r) local m = readMouse() expect(step .. ":sentinel", m.sentinel) expect(step .. ":present", m.present == 1, tostring(m.present)) expect(step .. ":xy", m.x == x and m.y == y, string.format("%d,%d want %d,%d", m.x, m.y, x, y)) expect(step .. ":buttons", m.left == l and m.right == r and m.middle == 0, string.format("%d%d%d want %d%d0", m.left, m.right, m.middle, l, r)) end -- A steady serial read: fn runs with the row once two consecutive frames -- agree on it. local function whenSteady(name, fn) pending = { name = name, fn = fn, since = frame } lastRaw = nil end local function serialHealthy(step, s) expect(step .. ":sentinel", s.sentinel) expect(step .. ":open", (s.status & STATUS_OPEN) ~= 0, string.format("status %04X", s.status)) expect(step .. ":bad", s.bad == 0 and s.firstBad == NO_BAD_INDEX, string.format("%d first %d", s.bad, s.firstBad)) expect(step .. ":short", s.short == 0, tostring(s.short)) end -- Traffic flowing: both counts moved since the last read. local function expectMoving(step) whenSteady(step, function(s) serialHealthy(step, s) expect(step .. ":paused", (s.status & STATUS_PAUSED) == 0) if prev then expect(step .. ":rxMoved", s.rx > prev.rx, string.format("%d -> %d", prev.rx, s.rx)) expect(step .. ":txMoved", s.tx > prev.tx, string.format("%d -> %d", prev.tx, s.tx)) end prev = s end) end -- Line quiet: both counts exactly where the pause left them. local function expectFrozen(step) whenSteady(step, function(s) serialHealthy(step, s) expect(step .. ":paused", (s.status & STATUS_PAUSED) ~= 0) if quiet == nil then quiet = s expect(step .. ":rxMin", s.rx >= MIN_BYTES, tostring(s.rx)) expect(step .. ":txMin", s.tx >= MIN_BYTES, tostring(s.tx)) else expect(step .. ":rxFrozen", s.rx == quiet.rx, string.format("%d -> %d", quiet.rx, s.rx)) expect(step .. ":txFrozen", s.tx == quiet.tx, string.format("%d -> %d", quiet.tx, s.tx)) end prev = s end) end local function at(t, fn) steps[#steps + 1] = { at = t, run = fn } end -- Corners in visiting order: where the clamp pins the pointer, the push -- direction that pins it there, and the button pressed once it is. local corners = { { name = "topLeft", x = 0, y = 0, dx = -1, dy = -1, button = "left" }, { name = "topRight", x = STAGE_RIGHT, y = 0, dx = 1, dy = -1, button = "right" }, { name = "bottomRight", x = STAGE_RIGHT, y = STAGE_BOTTOM, dx = 1, dy = 1, button = "left" }, { name = "bottomLeft", x = 0, y = STAGE_BOTTOM, dx = -1, dy = 1, button = "right" }, } -- One pass over the corners from frame `base`; `serialCheck` runs beside -- each position check. local function pass(base, label, serialCheck) for i, c in ipairs(corners) do local t = base + (i - 1) * CORNER_FRAMES local step = label .. ":" .. c.name local button = c.button == "left" and btnLeft or btnRight local l = c.button == "left" and 1 or 0 at(t, function() pushX = c.dx; pushY = c.dy end) at(t + PUSH_FRAMES, function() expectMouse(step, c.x, c.y, 0, 0) serialCheck(step) button:set_value(1) end) at(t + CORNER_FRAMES - 1, function() expectMouse(step .. "Button", c.x, c.y, l, 1 - l) button:clear_value() end) end end local t = 0 for p = 1, PASSES do pass(t, "busy" .. p, expectMoving) t = t + PASS_FRAMES end at(t, function() nat:post(" ") end) at(t + QUIET_SETTLE, function() expectFrozen("pause") end) at(t + QUIET_SETTLE + QUIET_HOLD, function() expectFrozen("pauseHold") end) t = t + QUIET_SETTLE + QUIET_HOLD + 1 pass(t, "quiet", expectFrozen) t = t + PASS_FRAMES at(t, function() nat:post(" ") end) at(t + RESUME_FRAMES, function() prev = quiet expectMoving("resumed") end) local finalFrame = t + RESUME_FRAMES emu.register_frame_done(function() frame = frame + 1 if done then return end if not biased and frame == BOOT_BIAS_FRAME then biased = true axisValX = AXIS_BIAS axisValY = AXIS_BIAS axisX:set_value(axisValX) axisY:set_value(axisValY) end if t0 == 0 then if frame > SENTINEL_FIRST and frame % SENTINEL_EVERY == 0 then local s = parseSerial(rowNibbles(SERIAL_ROW, SERIAL_NIBBLES)) if s.sentinel and readMouse().sentinel then t0 = frame + SETTLE_FRAMES end end if frame > BOOT_LIMIT then done = true io.write("VERIFY-X68K-SERMOUSE timeout: SERMOUSE never came up\n") io.flush() manager.machine:exit() end return end if pushX ~= 0 or pushY ~= 0 then axisValX = axisValX + pushX * PUSH_STEP axisValY = axisValY + pushY * PUSH_STEP axisX:set_value(axisValX) axisY:set_value(axisValY) end for _, s in ipairs(steps) do if frame == t0 + s.at and not done then s.run() end end if pending and not done then local raw = table.concat(rowNibbles(SERIAL_ROW, SERIAL_NIBBLES), ",") if raw == lastRaw then local p = pending pending = nil p.fn(parseSerial(rowNibbles(SERIAL_ROW, SERIAL_NIBBLES))) elseif frame - pending.since > STEADY_LIMIT then expect(pending.name .. ":steady", false) else lastRaw = raw end end if not done and not pending and frame > t0 + finalFrame then finish(#fails == 0 and "PASS" or "FAIL") end end) LUA } > "$work/sermouse.lua" python3 "$repo/scripts/lib/serialStreamPeer.py" "$PORT" "$((WALL + PEER_MARGIN))" "$MIN_BYTES" \ > "$work/peer.out" 2>&1 & peer=$! # The peer must be listening before MAME connects out to it. for _ in $(seq 1 50); do grep -q '^STREAM listening' "$work/peer.out" 2>/dev/null && break sleep 0.1 done # From the work dir, so MAME's cfg/ and nvram/ land there. Under timeout so a # hung run cannot outlive WALL whatever happens to this script; mameChild is # MAME itself, which cleanup stops first (stopping only the timeout process # could leave MAME running). cd "$work" || exit 1 if [ "$BAUD" != "$DEFAULT_BAUD" ]; then mkdir -p "$work/cfg" python3 "$repo/scripts/lib/mameNullModemCfg.py" x68000 :rs232c:null_modem "$BAUD" \ > "$work/cfg/x68000.cfg" || exit 2 fi JOEY_Z80SCC_RX_FIFO=$FIFO_DEPTH timeout -s KILL "$WALL" "$MAME" x68000 -bios ipl10 \ -rompath "$X68K_DIR/roms" -flop1 "$work/boot.xdf" \ -rs232c null_modem -bitb "socket.127.0.0.1:$PORT" \ -video none -sound none -nothrottle \ -autoboot_script "$work/sermouse.lua" "$work/mame.out" 2>&1 & mame=$! sleep 1 mameChild=$(pgrep -P "$mame" | head -1) # The Lua ends the run: after its last check, or at its first failure. wait "$mame" mame="" mameChild="" for _ in $(seq 1 "$PEER_GRACE"); do joeyAlive "$peer" || break sleep 1 done joeyStop "$peer" peer="" lua=$(grep -E '^VERIFY-X68K-SERMOUSE ' "$work/mame.out" | tail -1) stream=$(grep -E '^STREAM (PASS|FAIL)' "$work/peer.out" | tail -1) echo "${lua:-VERIFY-X68K-SERMOUSE: no verdict from the Lua}" echo "${stream:-STREAM: no verdict from the peer}" if ! grep -q "RX FIFO depth $FIFO_DEPTH" "$work/mame.out"; then echo "verify-x68000-serial-mouse: SKIP - this MAME does not take JOEY_Z80SCC_RX_FIFO (reinstall: ./install.sh install mame)" >&2 exit 2 fi fail=0 if ! echo "$lua" | grep -q '^VERIFY-X68K-SERMOUSE PASS .* fails=0 '; then echo "verify-x68000-serial-mouse: FAIL - guest or mouse check (Lua line above)" >&2 tail -5 "$work/mame.out" >&2 fail=1 fi if ! echo "$stream" | grep -q '^STREAM PASS '; then echo "verify-x68000-serial-mouse: FAIL - the guest's stream to the host (peer line above)" >&2 fail=1 fi guestQuiet=$(echo "$lua" | sed -n 's/.* quiet=\([0-9]*:[0-9]*\).*/\1/p') peerQuiet=$(echo "$stream" | sed -n 's/.* quiet=\([0-9:,]*\) .*/\1/p') if [ -z "$guestQuiet" ] || [[ ",$peerQuiet," != *",$guestQuiet,"* ]]; then echo "verify-x68000-serial-mouse: FAIL - quiet counts differ: guest received:sent $guestQuiet, peer sent:received $peerQuiet" >&2 fail=1 fi if [ "$fail" -ne 0 ]; then exit 1 fi echo "verify-x68000-serial-mouse: PASS at $BAUD baud, $DENSE dense-SCB lines (both streams byte-exact and complete at the pause, mouse corners and buttons right throughout, receive interrupt alive at the end)"