joeylib2/scripts/verify-amiga-icon.sh

189 lines
7.8 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-amiga-icon.sh - a program's Workbench icon: read and drawn by the
# Amiga's own icon.library, and the program started from it.
#
# make bakes Name.info beside every Amiga program (tools/iconBake.py info:
# a tool's icon in Workbench's four colours, with the stack it starts
# with). WBLAUNCH (examples/wblaunch) runs under FS-UAE on an A500 with
# PALPROBE and its icon in a folder of their own, Prog/. It reads the icon
# with GetDiskObject -- it must be a tool's, of AMIGA_ICON_STACK bytes of
# stack and the baked size -- and draws it; the screenshot must show every
# pixel of the icon the file holds, in Workbench's colours. Then it starts
# PALPROBE as Workbench starts a tool, from RAM:, and its message must come
# back with PALPROBE's log, which opens in the current directory, in Prog/:
# libnix's start-up moves into the folder Workbench names.
#
# Usage: scripts/verify-amiga-icon.sh
# Env: TIMEOUT seconds to wait for each step (default 120)
# AMIGA_ICON_KEEP keep the screenshot as build/amiga/amiga-icon.png
# Exit: 0 PASS, 1 FAIL, 2 SKIP (FS-UAE or the Kickstart not installed)
set -uo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
source "$repo/scripts/lib/joeyEnv.sh"
joeyNeed FSUAE fsuae
source "$repo/scripts/lib/amigaGate.sh"
amigaMachine
timeout_s=${TIMEOUT:-120}
test_dir=$repo/build/amiga/test
label=verify-amiga-icon
# make/amiga.mk's AMIGA_ICON_STACK, and WBTOOL (workbench/workbench.h).
want_stack=$(make -s -f "$repo/make/amiga.mk" --eval 'printStack: ; @echo $(AMIGA_ICON_STACK)' printStack)
WBTOOL=3
make -s -f "$repo/make/amiga.mk" "$test_dir/Wblaunch" "$test_dir/Palprobe" "$test_dir/Palprobe.info" || {
echo "$label: FAIL - building Wblaunch, Palprobe or its icon failed" >&2
exit 1
}
work=$(mktemp -d -t joeylib-amiga-icon.XXXXXX)
mkdir -p "$work/s" "$work/Prog"
cp "$test_dir/Wblaunch" "$work/"
cp "$test_dir/Palprobe" "$test_dir/Palprobe.info" "$work/Prog/"
printf 'stack 32768\nWblaunch Prog Palprobe\n' > "$work/s/startup-sequence"
display=:$((RANDOM % 400 + 1300))
Xvfb "$display" -screen 0 800x600x24 -nolisten tcp > /dev/null 2>&1 &
xvfb_pid=$!
fs_pid=
cleanup() {
[[ -n $fs_pid ]] && joeyStop "$fs_pid"
kill "$xvfb_pid" 2>/dev/null || true
wait 2>/dev/null || true
rm -rf "$work"
}
trap cleanup EXIT
sleep 0.5
DISPLAY=$display "$FSUAE" "${AMIGA_MACHINE_ARGS[@]}" --fast_memory=2048 \
--hard_drive_0="$work" --hard_drive_0_label=JOEYLIB \
--automatic_input_grab=0 \
--fullscreen=0 --fade_out_duration=0 > "$work/fsuae.log" 2>&1 &
fs_pid=$!
log=$work/wblaunch.txt
# waitFor TEXT: true once the probe's log holds TEXT, or holds a FAIL.
waitFor() {
local waited
for ((waited = 0; waited < timeout_s; waited++)); do
if grep -q "$1\|WBLAUNCH: FAIL" "$log" 2>/dev/null; then
return 0
fi
sleep 1
done
return 1
}
if ! waitFor "WBLAUNCH: drawn"; then
echo "$label: FAIL - the probe never drew the icon within ${timeout_s}s" >&2
exit 1
fi
# A second into the probe's five-second hold.
sleep 1
DISPLAY=$display import -window root "$work/screen.png"
[[ -n ${AMIGA_ICON_KEEP:-} ]] && cp "$work/screen.png" "$repo/build/amiga/amiga-icon.png"
if ! waitFor "WBLAUNCH: returned"; then
echo "$label: FAIL - Palprobe's Workbench message never came back within ${timeout_s}s" >&2
exit 1
fi
cat "$log"
python3 - "$work" "$want_stack" "$WBTOOL" "$label" <<'PY'
import os
import re
import struct
import sys
from PIL import Image
work, wantStack, wbTool, label = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), sys.argv[4]
fails = []
log = open(os.path.join(work, "wblaunch.txt"), encoding="ascii", errors="replace").read()
for line in log.splitlines():
if "FAIL" in line:
fails.append(line)
# What the file holds: its image's size, depth and planes (after the 78-byte
# DiskObject and the 20-byte struct Image).
info = open(os.path.join(work, "Prog", "Palprobe.info"), "rb").read()
width, height, depth = struct.unpack(">hhh", info[82:88])
rowBytes = (width + 15) // 16 * 2
planeBytes = rowBytes * height
indices = [[0] * width for _ in range(height)]
for plane in range(depth):
for y in range(height):
for x in range(width):
byte = info[98 + plane * planeBytes + y * rowBytes + x // 8]
if byte & (0x80 >> (x % 8)):
indices[y][x] |= 1 << plane
m = re.search(r"icon type=(\d+) stack=(\d+) gadget=(\d+)x(\d+) image=(\d+)x(\d+)x(\d+)", log)
if m is None:
fails.append("icon.library never read the icon")
else:
got = tuple(int(v) for v in m.groups())
if got[0] != wbTool:
fails.append(f"icon type {got[0]}, want WBTOOL ({wbTool})")
if got[1] != wantStack:
fails.append(f"icon stack {got[1]}, want {wantStack}")
if got[4:] != (width, height, depth):
fails.append(f"icon.library's image {got[4]}x{got[5]}x{got[6]}, the file's {width}x{height}x{depth}")
# Where the icon is on the screenshot: the screen is Workbench's grey, and
# the icon's coloured pixels lie inside it. The file's coloured pixels
# (index not 0) and the screenshot's (not grey) must have the same bounds,
# scaled; then each of the file's pixels is looked at in the middle of its
# cell.
colours = [(170, 170, 170), (0, 0, 0), (255, 255, 255), (102, 136, 187)]
shot = Image.open(os.path.join(work, "screen.png")).convert("RGB")
w, h = shot.size
pixels = shot.load()
grey = [(x, y) for y in range(0, h, 2) for x in range(0, w, 2) if pixels[x, y] == colours[0]]
if not grey:
fails.append("no Workbench grey on the screenshot")
else:
gx0 = min(p[0] for p in grey)
gx1 = max(p[0] for p in grey)
gy0 = min(p[1] for p in grey)
# The upper half: FS-UAE prints its notices over the bottom.
gy1 = (gy0 + max(p[1] for p in grey)) // 2
shown = [(x, y) for y in range(gy0, gy1 + 1) for x in range(gx0, gx1 + 1) if pixels[x, y] != colours[0]]
held = [(x, y) for y in range(height) for x in range(width) if indices[y][x] != 0]
if not shown or not held:
fails.append("no icon on the screenshot" if held else "the icon file is empty")
else:
sx0 = min(p[0] for p in shown)
sx1 = max(p[0] for p in shown)
sy0 = min(p[1] for p in shown)
sy1 = max(p[1] for p in shown)
fx0 = min(p[0] for p in held)
fx1 = max(p[0] for p in held)
fy0 = min(p[1] for p in held)
fy1 = max(p[1] for p in held)
scaleX = (sx1 + 1 - sx0) / (fx1 + 1 - fx0)
scaleY = (sy1 + 1 - sy0) / (fy1 + 1 - fy0)
wrong = 0
for y in range(fy0, fy1 + 1):
for x in range(fx0, fx1 + 1):
at = (int(sx0 + (x - fx0 + 0.5) * scaleX), int(sy0 + (y - fy0 + 0.5) * scaleY))
if pixels[at] != colours[indices[y][x]]:
wrong += 1
cells = (fx1 + 1 - fx0) * (fy1 + 1 - fy0)
print(f"{label}: icon shown at {sx0},{sy0} scaled {scaleX:.2f}x{scaleY:.2f}: {cells - wrong}/{cells} pixels right")
if wrong:
fails.append(f"{wrong} of the icon's {cells} pixels wrong on the screen")
palLog = os.path.join(work, "Prog", "joeylog.txt")
if not os.path.exists(palLog) or "PALPROBE: done" not in open(palLog, encoding="ascii", errors="replace").read():
fails.append("Palprobe's log is not in Prog/ with its last line: it did not run in its own folder")
if os.path.exists(os.path.join(work, "joeylog.txt")):
fails.append("a joeylog.txt is in the drive's root: Palprobe ran outside its folder")
if fails:
print(f"{label}: FAIL - " + "; ".join(fails))
sys.exit(1)
print(f"{label}: PASS (icon.library reads a tool's icon, {wantStack} bytes of stack, {width}x{height}x{depth}, and draws every pixel of it; started from it as Workbench does, Palprobe ran in its own folder and its message came back)")
PY