joeylib2/scripts/lib/keysRow.py
2026-10-05 16:47:00 -05:00

137 lines
5.2 KiB
Python
Executable file

#!/usr/bin/env python3
"""keysRow.py - read KEYS's verification row off a screenshot.
KEYS (examples/keys) draws every character it received on its row 198 as
pixel nibbles: the sentinel A 5 A 5, the count byte, a zero byte, then each
character, two pixels a byte, high nibble first. This finds that row in a
screenshot (on a desktop the window is wherever DVX put it, at the window's
scale: every stage pixel a SCALE x SCALE block) and checks it holds exactly
TARGET, and that every block of it is whole: each of its screen pixels the
same colour.
keysRow.py PNG TARGET [--size WxH] [--at X,Y] [--scale S]
--size: the screenshot must be that size (the full-screen mode, not the
desktop). --at: the row must start exactly there (the stage's own row 198
at 1x on a full screen). --scale: the row must be at that scale (else the
first of 1, 2, 3 it is found at). Prints the row it found; exit 0 when it
is right.
"""
import sys
from PIL import Image
# KEYS palette 0 (buildPalette): a gray ramp with four demo colours and red
# at 15, 12-bit $0RGB, each channel widened x17 as the DVX port's tables do.
PAL12 = [(i << 8) | (i << 4) | i for i in range(16)]
PAL12[0], PAL12[1], PAL12[2], PAL12[3] = 0x000, 0x333, 0x0F0, 0xFFF
PAL12[15] = 0xF00
PAL_RGB = [(((v >> 8) & 0xF) * 17, ((v >> 4) & 0xF) * 17, (v & 0xF) * 17) for v in PAL12]
# A 15- or 16-bit desktop is off by at most 8 a channel after the round
# trip, the 6-bit VGA DAC by at most 3, while the palette entries sit 17
# apart: nearest is exact. (Every KEYS colour is also one of DVX's 256-colour
# desktop palette, its cube or its grey ramp, so 8 bits is exact too.)
MAX_DIST = 3 * 9 ** 2
NO_NIBBLE = -1
SENTINEL = (0xA, 0x5, 0xA, 0x5)
SCALES = (1, 2, 3)
def nibbleMap(img):
"""Every pixel's KEYS palette index, NO_NIBBLE where no entry is near."""
nearest = {}
for rgb in set(img.getdata()):
best, bestDist = NO_NIBBLE, MAX_DIST + 1
for n, pal in enumerate(PAL_RGB):
d = sum((c - p) ** 2 for c, p in zip(rgb, pal))
if d < bestDist:
best, bestDist = n, d
nearest[rgb] = best
return [nearest[rgb] for rgb in img.getdata()]
def findRow(nibs, pixels, w, h, scale, expect):
"""The (x, y, bytes) of the first row at scale whose header matches."""
header = expect[:4]
span = 2 * len(expect) * scale
for y in range(h):
base = y * w
for x in range(w - span + 1):
if nibs[base + x] != SENTINEL[0]:
continue
if tuple(nibs[base + x + i * scale] for i in range(len(SENTINEL))) != SENTINEL:
continue
row = bytes(((nibs[base + x + 2 * i * scale] & 0xF) << 4) | (nibs[base + x + (2 * i + 1) * scale] & 0xF) for i in range(len(expect)))
if row[:4] == header:
return x, y, row
return None
def splitBlocks(pixels, w, h, x, y, scale, count):
"""How many of the count stage pixels from (x, y) are not whole blocks."""
split = 0
for i in range(count):
left = x + i * scale
want = pixels[y * w + left]
block = [pixels[(y + dy) * w + left + dx] for dy in range(scale) if y + dy < h for dx in range(scale)]
if len(block) != scale * scale or any(p != want for p in block):
split += 1
return split
def main(argv):
if len(argv) < 3:
sys.stderr.write(__doc__)
return 2
png, target = argv[1], argv[2]
size = None
at = None
scales = SCALES
args = argv[3:]
while args:
if args[0] == "--size" and len(args) > 1:
size = tuple(int(n) for n in args[1].split("x"))
elif args[0] == "--at" and len(args) > 1:
at = tuple(int(n) for n in args[1].split(","))
elif args[0] == "--scale" and len(args) > 1:
scales = (int(args[1]),)
else:
sys.stderr.write(__doc__)
return 2
args = args[2:]
img = Image.open(png).convert("RGB")
w, h = img.size
if size is not None and img.size != size:
print("keysRow: FAIL - %s is %dx%d, want %dx%d" % (png, w, h, size[0], size[1]))
return 1
pixels = list(img.getdata())
nibs = nibbleMap(img)
expect = bytes([0xA5, 0xA5, len(target), 0x00]) + target.encode("ascii")
found = None
for scale in scales:
found = findRow(nibs, pixels, w, h, scale, expect)
if found:
break
if not found:
print("keysRow: FAIL - no verification row (A5A5 %02X00) on the screen at %s" % (len(target), " or ".join("%dx" % s for s in scales)))
return 1
x, y, row = found
print("keysRow: row at (%d,%d) at %dx=%s" % (x, y, scale, row.hex().upper()))
if row != expect:
print("keysRow: FAIL - screen row %s, want %s" % (row.hex().upper(), expect.hex().upper()))
return 1
split = splitBlocks(pixels, w, h, x, y, scale, 2 * len(expect))
if split:
print("keysRow: FAIL - %d of the row's %d stage pixels are not whole %dx%d blocks" % (split, 2 * len(expect), scale, scale))
return 1
if at is not None and (x, y) != at:
print("keysRow: FAIL - the row is at (%d,%d), want (%d,%d)" % (x, y, at[0], at[1]))
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))