#!/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