joeylib2/scripts/verify-iigs-icon.sh

171 lines
7.1 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-iigs-icon.sh - IIgs programs' icons as the Finder shows them.
#
# make bakes every IIgs program a Finder icon file (make/iigs.mk:
# tools/iconBake.py iigs, type $CA: an icon record for a GS/OS application of
# the program's name, black and white), and make-iigs-disk.sh puts it in the
# JOEYLIB volume's Icons folder, which the Finder reads when it starts. GS/OS
# boots under MAME into its Finder with joey.2mg mounted; the gate opens the
# JOEYLIB window as verify-iigs.sh does (J, then Command-O) and at READ_FRAME
# reads the Super Hi-Res screen out of emulated memory -- the pixels, the
# scan line control bytes and the palettes -- and draws it as it shows in 640
# mode. The window must show the baked icon: every pixel its mask leaves
# opaque, as the two screen pixels QuickDraw draws a 4-bit icon pixel as in
# 640 mode, the first its high two bits and the second its low two. The
# screen's own 2-bit values are compared, so the check does not lean on the
# palette; the icon's colours are the Finder's palette's (tools/iconBake.py).
#
# Usage: scripts/verify-iigs-icon.sh
# Env: IIGS_ICON_KEEP keep the screen as build/iigs/iigs-icon.png
# MAME_READ_FRAME the frame the screen is read at (default 5400)
# Exit: 0 PASS, 1 FAIL, 2 SKIP (MAME or the IIgs firmware not installed,
# or joey.2mg not built)
# Requires: ./install.sh install emulator:iigs, python3 + PIL + numpy;
# build/iigs/bin/joey.2mg (make iigs-disk).
set -euo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
source "$repo/scripts/lib/joeyEnv.sh"
joeyNeed MAME mame
joeyNeed IIGS_ROMPATH iigsFirmware
joeyNeed GSOS_SYSTEM_DISK iigsFirmware
label=verify-iigs-icon
data_disk=$repo/build/iigs/bin/joey.2mg
icon=$repo/build/iigs/icon/DRAW/finder.icons
readFrame="${MAME_READ_FRAME:-5400}"
[ -f "$data_disk" ] && [ -f "$icon" ] || { echo "$label: build the disk first (make iigs-disk)" >&2; exit 2; }
work=$(mktemp -d -t joeylib-iigs-icon.XXXXXX)
trap 'rm -rf "$work"' EXIT
cp "$GSOS_SYSTEM_DISK" "$work/boot.po"
cp "$data_disk" "$work/joey.2mg"
# The Finder keystrokes verify-iigs.sh calibrated: select the JOEYLIB volume
# (J), Command-O to open its window. At readFrame the SHR block, $E1/2000 to
# $E1/9FFF (pixels, scan line control bytes, palettes), goes to shr.bin.
cat > "$work/icon.lua" <<LUA
local mem = manager.machine.devices[":maincpu"].spaces["program"]
local nat = manager.machine.natkeyboard
local frame = 0
local idx = 1
local cmd = manager.machine.ioport.ports[":macadb:KEY3"].fields["Command / Open Apple"]
local function dump()
local out = io.open("$work/shr.bin", "wb")
local chunk = {}
for i = 0, 0x7FFF do
chunk[#chunk + 1] = string.char(mem:read_u8(0xE12000 + i))
if #chunk == 256 then
out:write(table.concat(chunk))
chunk = {}
end
end
out:close()
end
local steps = {
{3000, function() nat:post("J") end},
{3120, function() cmd:set_value(1) end},
{3126, function() nat:post("o") end},
{3180, function() cmd:set_value(0) end},
{$readFrame, function() dump(); manager.machine:exit() end},
}
emu.register_frame_done(function()
frame = frame + 1
while idx <= #steps and frame >= steps[idx][1] do
steps[idx][2]()
idx = idx + 1
end
end)
LUA
(cd "$work" && QT_QPA_PLATFORM=offscreen SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \
timeout 300 "$MAME" apple2gs -rompath "$IIGS_ROMPATH" \
-flop3 "$work/boot.po" -flop4 "$work/joey.2mg" \
-video none -sound none -nothrottle \
-autoboot_script "$work/icon.lua" < /dev/null > "$work/mame.log" 2>&1) || true
[ -s "$work/shr.bin" ] || { echo "$label: FAIL - MAME never reached frame $readFrame" >&2; tail -5 "$work/mame.log" >&2; exit 1; }
python3 - "$work/shr.bin" "$icon" "$label" "${IIGS_ICON_KEEP:+$repo/build/iigs/iigs-icon.png}" <<'PY'
import struct
import sys
import numpy
from PIL import Image
shrPath, iconPath, label, keep = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
WIDTH_640 = 640
WIDTH_320 = 320
HEIGHT = 200
ROW_BYTES = 160
SCB_AT = 0x7D00
PALETTES_AT = 0x7E00
SCB_640 = 0x80
# A 640-mode screen pixel is 2 bits; so is each half of an icon pixel.
HALF_BITS = 2
HALF_MASK = 3
shr = open(shrPath, "rb").read()
def colour(word):
"""A $0RGB palette word as 8-bit RGB."""
return (((word >> 8) & 0xF) * 17, ((word >> 4) & 0xF) * 17, (word & 0xF) * 17)
# The screen at 640 pixels a line: a 640-mode line's pixel takes its colour
# from the palette quarter its place in the byte gives it (bits 7-6 from
# entries 8-11, 5-4 from 12-15, 3-2 from 0-3, 1-0 from 4-7); a 320-mode
# line's pixel is shown twice.
# Each 640-mode screen pixel's own 2-bit value goes in values (-1 on a
# 320-mode line).
screen = numpy.zeros((HEIGHT, WIDTH_640, 3), dtype=numpy.uint8)
values = numpy.full((HEIGHT, WIDTH_640), -1, dtype=numpy.int8)
for y in range(HEIGHT):
scb = shr[SCB_AT + y]
palette = [colour(struct.unpack_from("<H", shr, PALETTES_AT + (scb & 0xF) * 32 + i * 2)[0]) for i in range(16)]
row = shr[y * ROW_BYTES:(y + 1) * ROW_BYTES]
for i, byte in enumerate(row):
if scb & SCB_640:
for p, base in enumerate((8, 12, 0, 4)):
values[y, i * 4 + p] = (byte >> (6 - 2 * p)) & HALF_MASK
screen[y, i * 4 + p] = palette[base + values[y, i * 4 + p]]
else:
for p, value in enumerate((byte >> 4, byte & 0xF)):
screen[y, i * 4 + p * 2] = palette[value]
screen[y, i * 4 + p * 2 + 1] = palette[value]
if keep:
Image.fromarray(screen).resize((WIDTH_640, HEIGHT * 2), Image.NEAREST).save(keep)
# The big icon of the file's one record: its 4-bit pixels and mask, the even
# pixel in the high nibble.
record = 26 + 86
iconType, size, h, w = struct.unpack_from("<HHHH", open(iconPath, "rb").read(), record)
data = open(iconPath, "rb").read()[record + 8:]
rowBytes = (w + 1) // 2
want = []
for y in range(h):
for x in range(w):
shift = 4 if x % 2 == 0 else 0
if (data[size + y * rowBytes + x // 2] >> shift) & 0xF:
want.append((y, x, (data[y * rowBytes + x // 2] >> shift) & 0xF))
# In 640 mode an icon pixel is two screen pixels: its high half, then its
# low half.
ys = numpy.array([p[0] for p in want for half in (0, 1)])
xs = numpy.array([p[1] * 2 + half for p in want for half in (0, 1)])
halves = numpy.array([(p[2] >> HALF_BITS) if half == 0 else (p[2] & HALF_MASK) for p in want for half in (0, 1)], dtype=numpy.int8)
found = []
for top in range(HEIGHT - h + 1):
for left in range(WIDTH_640 - w * 2 + 1):
if numpy.array_equal(values[top + ys, left + xs], halves):
found.append((left, top))
if not found:
print(f"{label}: FAIL - no program's icon is in the JOEYLIB window ({len(want)} opaque pixels looked for)")
sys.exit(1)
print(f"{label}: PASS (the Finder shows {len(found)} programs by the baked {w}x{h} colour icon, the first at {found[0][0]},{found[0][1]}: all {len(want)} opaque pixels, each its two screen pixels' values)")
PY