Boot fix: - Ship proven Debian Live MBR (4552) as branding/isohdpfx.bin — the ISOLINUX package MBR (33ed) doesn't boot on all hardware. This was the root cause of "machine doesn't pick up the USB". Branding: - Custom GRUB background: pixel-art floating island (1024x574) - Archipelago pixel-art logo for Plymouth boot splash - GRUB theme: dark background, orange selected item, no broken font refs - Plymouth theme: script-based with progress bar, LUKS prompt support - Plymouth + splash added to target rootfs packages - GRUB theme installed on both installer ISO and target system - Serial console (ttyS0) added to kernel params for QEMU debugging CI improvements: - Smoke test step: mounts ISO, verifies all critical files, checks initrd has live-boot, confirms boot=live in grub.cfg. Fails build before copying to Builds if any check fails. Dev workflow: - dev-branding.sh: extract ISO, swap branding, repackage, boot in QEMU (~10 seconds vs 20 min full rebuild) - generate-grub-background.py: procedural cyberpunk background generator - generate-plymouth-logo.py: procedural logo generator - Improved test-iso-qemu.sh: --bios/--nographic flags, serial logging Build: - Simplified live-boot install (clean chroot, no complex fallbacks) - Static branding images preferred, generators as fallback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
150 lines
5.2 KiB
Python
150 lines
5.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate the Archipelago Plymouth boot logo — pixel-art 'a' with neon glow.
|
|
|
|
Outputs a 256x256 PNG with transparent background (RGBA).
|
|
"""
|
|
import struct
|
|
import zlib
|
|
import math
|
|
import sys
|
|
|
|
W, H = 256, 256
|
|
ORANGE = (251, 146, 60)
|
|
ORANGE_BRIGHT = (255, 180, 100)
|
|
|
|
# Lowercase pixel-art "a" — 6x6 grid
|
|
LOGO_A = [
|
|
[0, 1, 1, 1, 1, 0],
|
|
[0, 0, 0, 0, 0, 1],
|
|
[0, 1, 1, 1, 1, 1],
|
|
[1, 0, 0, 0, 0, 1],
|
|
[1, 0, 0, 0, 0, 1],
|
|
[0, 1, 1, 1, 1, 1],
|
|
]
|
|
|
|
# "archipelago" text — same pixel font as GRUB generator
|
|
PIXEL_CHARS = {
|
|
'a': [[0,1,1,0],[0,0,0,1],[0,1,1,1],[1,0,0,1],[0,1,1,1]],
|
|
'r': [[1,0,1,1],[1,1,0,0],[1,0,0,0],[1,0,0,0],[1,0,0,0]],
|
|
'c': [[0,1,1,0],[1,0,0,0],[1,0,0,0],[1,0,0,0],[0,1,1,0]],
|
|
'h': [[1,0,0,0],[1,0,0,0],[1,1,1,0],[1,0,0,1],[1,0,0,1]],
|
|
'i': [[0,1],[0,0],[0,1],[0,1],[0,1]],
|
|
'p': [[1,1,1,0],[1,0,0,1],[1,1,1,0],[1,0,0,0],[1,0,0,0]],
|
|
'e': [[0,1,1,0],[1,0,0,1],[1,1,1,0],[1,0,0,0],[0,1,1,0]],
|
|
'l': [[1,0],[1,0],[1,0],[1,0],[1,1]],
|
|
'g': [[0,1,1,1],[1,0,0,1],[0,1,1,1],[0,0,0,1],[1,1,1,0]],
|
|
'o': [[0,1,1,0],[1,0,0,1],[1,0,0,1],[1,0,0,1],[0,1,1,0]],
|
|
' ': [[0,0],[0,0],[0,0],[0,0],[0,0]],
|
|
}
|
|
|
|
|
|
def create_png_rgba(width, height, pixels):
|
|
"""Create a PNG with RGBA pixel data."""
|
|
def make_chunk(chunk_type, data):
|
|
chunk = chunk_type + data
|
|
return struct.pack('>I', len(data)) + chunk + struct.pack('>I', zlib.crc32(chunk) & 0xFFFFFFFF)
|
|
|
|
signature = b'\x89PNG\r\n\x1a\n'
|
|
ihdr_data = struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0) # 8-bit RGBA
|
|
ihdr = make_chunk(b'IHDR', ihdr_data)
|
|
|
|
raw_data = bytearray()
|
|
for y in range(height):
|
|
raw_data.append(0)
|
|
offset = y * width * 4
|
|
raw_data.extend(pixels[offset:offset + width * 4])
|
|
|
|
compressed = zlib.compress(bytes(raw_data), 9)
|
|
idat = make_chunk(b'IDAT', compressed)
|
|
iend = make_chunk(b'IEND', b'')
|
|
|
|
return signature + ihdr + idat + iend
|
|
|
|
|
|
def generate():
|
|
pixels = bytearray(W * H * 4) # RGBA
|
|
|
|
cx, cy = W // 2, W // 2 - 30
|
|
|
|
logo_rows = len(LOGO_A)
|
|
logo_cols = len(LOGO_A[0])
|
|
pixel_size = 18
|
|
logo_w = logo_cols * pixel_size
|
|
logo_h = logo_rows * pixel_size
|
|
logo_x = cx - logo_w // 2
|
|
logo_y = 30
|
|
|
|
# Glow
|
|
glow_radius = 60
|
|
for y in range(H):
|
|
for x in range(W):
|
|
dx = max(0, logo_x - x, x - (logo_x + logo_w))
|
|
dy = max(0, logo_y - y, y - (logo_y + logo_h))
|
|
d = math.sqrt(dx * dx + dy * dy)
|
|
if d < glow_radius:
|
|
alpha = (1.0 - d / glow_radius) ** 2 * 0.25
|
|
idx = (y * W + x) * 4
|
|
pixels[idx] = ORANGE[0]
|
|
pixels[idx + 1] = ORANGE[1]
|
|
pixels[idx + 2] = ORANGE[2]
|
|
pixels[idx + 3] = int(alpha * 255)
|
|
|
|
# Logo pixels with 3D bevel
|
|
for row in range(logo_rows):
|
|
for col in range(logo_cols):
|
|
if LOGO_A[row][col]:
|
|
px = logo_x + col * pixel_size
|
|
py = logo_y + row * pixel_size
|
|
for dy in range(pixel_size - 1):
|
|
for dx in range(pixel_size - 1):
|
|
x, y = px + dx, py + dy
|
|
if 0 <= x < W and 0 <= y < H:
|
|
t = (dx + dy) / (2 * pixel_size)
|
|
r = int(ORANGE[0] * (1.0 - t * 0.3))
|
|
g = int(ORANGE[1] * (1.0 - t * 0.3))
|
|
b = int(ORANGE[2] * (1.0 - t * 0.3))
|
|
if dx < 2 or dy < 2:
|
|
r = min(255, r + 40)
|
|
g = min(255, g + 30)
|
|
b = min(255, b + 10)
|
|
idx = (y * W + x) * 4
|
|
pixels[idx] = r
|
|
pixels[idx + 1] = g
|
|
pixels[idx + 2] = b
|
|
pixels[idx + 3] = 255
|
|
|
|
# Text "archipelago" below logo
|
|
text = "archipelago"
|
|
text_pixel = 3
|
|
text_gap = 2
|
|
total_w = sum(len(PIXEL_CHARS.get(c, PIXEL_CHARS[' '])[0]) * text_pixel + text_gap for c in text) - text_gap
|
|
text_x = cx - total_w // 2
|
|
text_y = logo_y + logo_h + 16
|
|
cursor = text_x
|
|
for ch in text:
|
|
char_data = PIXEL_CHARS.get(ch, PIXEL_CHARS[' '])
|
|
for row in range(len(char_data)):
|
|
for col in range(len(char_data[0])):
|
|
if char_data[row][col]:
|
|
for dy in range(text_pixel - 1):
|
|
for dx in range(text_pixel - 1):
|
|
x = cursor + col * text_pixel + dx
|
|
y = text_y + row * text_pixel + dy
|
|
if 0 <= x < W and 0 <= y < H:
|
|
idx = (y * W + x) * 4
|
|
pixels[idx] = 180
|
|
pixels[idx + 1] = 100
|
|
pixels[idx + 2] = 30
|
|
pixels[idx + 3] = 200
|
|
cursor += len(char_data[0]) * text_pixel + text_gap
|
|
|
|
return create_png_rgba(W, H, bytes(pixels))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
out_path = sys.argv[1] if len(sys.argv) > 1 else 'logo.png'
|
|
data = generate()
|
|
with open(out_path, 'wb') as f:
|
|
f.write(data)
|
|
print(f'Generated {out_path} ({len(data)} bytes)')
|