#!/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 honours the SCB by reloading that palette as the beam scans: a CRTC # raster interrupt (src/x68000/x68kRasterIsr.s) stores a band's sixteen # colours 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 colours; 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 colour distinct per palette, a change on every line for 128 # of the 200 lines, irregular runs for the rest, 20 stripes using every colour # 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 (x68kTickIsr, chained onto # Human68k's Timer C) and $134 (x68kVdispIsr, 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 (x68kRasterIsr.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/x68kTickIsr.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" <> 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 (x68kRasterIsr.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(" 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