"""Check packed animation assets and snapshots from the production ASM renderer.""" import hashlib import json import struct from pathlib import Path from PIL import Image, ImageDraw, ImageStat ROOT = Path(__file__).resolve().parents[2] OUTPUT = ROOT / "build/actions" def check_packs(): expected = {"street": (640, 480, 1), "guards": (192, 256, 32), "merchant": (128, 128, 24), "props": (128, 128, 9), "card-backs": (68, 96, 13), "table-hands": (144, 192, 8), "materials": (128, 128, 4), "table-active": (640, 480, 1), "city-tiles-v4": (128, 128, 16), "city-hands": (256, 192, 16), "city-props": (256, 256, 6), "city-smoke": (96, 128, 4), "city-cars": (256, 256, 16), "city-bin-materials": (128, 128, 4), "city-clouds": (192, 192, 4), "wake-rocker-v2": (192,320,32), "wake-lighter-v1": (256,256,16), "wake-knees-v1": (640,240,1), "wake-sleeve-v1": (256,384,1), "alley-materials-v1": (128,128,4)} result = {} for name, dimensions in expected.items(): data = (ROOT / "assets/animations/arrival" / f"{name}.bin").read_bytes() magic, width, height, count, size = struct.unpack_from("<4s4I", data) assert magic == b"ANM1" and (width, height, count) == dimensions, name stride = width * height * 4 assert size == stride * count and len(data) == 20 + size, name hashes = set() for index in range(count): payload = data[20 + index * stride:20 + (index + 1) * stride] frame = Image.frombytes("RGBA", (width, height), payload, "raw", "BGRA") if name == "city-hands" and 5 <= index <= 10: frame.save(OUTPUT / f"city-hand-{index:02}.png") if name == "city-tiles-v4" and 8 <= index <= 10: frame.save(OUTPUT / f"city-window-{index:02}.png") assert frame.getchannel("A").getbbox(), f"Empty sprite: {name}/{index}" if name in ("city-hands", "city-props", "city-cars"): assert set(frame.getchannel("A").tobytes()) <= {0, 255}, f"Translucent solid: {name}/{index}" assert frame.getchannel("A").getextrema()[0] == 0, f"Opaque matte: {name}/{index}" hashes.add(hashlib.sha256(payload).digest()) assert len(hashes) == count, f"Duplicate padding frames: {name}" result[name] = {"frames": count, "distinct_frames": len(hashes)} return result def check_snapshot(name): frame = Image.open(OUTPUT / f"{name}.bmp").convert("RGB") assert frame.size == (640, 480), name assert sum(ImageStat.Stat(frame).mean) > 30, f"Blank framebuffer: {name}" assert len(frame.getcolors(640 * 480)) > 500, f"Missing artwork: {name}" return frame def check_sequence(prefix, count, crop, duration): frames = [] for index in range(count): name = f"{prefix}-{index:02}" if prefix == "city-wake" and index < 4: # Deliberate closed eyes / narrow opening, not a failed renderer. frame = Image.open(OUTPUT / (name + ".bmp")).convert("RGB") assert frame.size == (640,480) if index == 0: assert frame.getbbox() is None else: frame = check_snapshot(name) frames.append(frame) hashes = {hashlib.sha256(frame.crop(crop).tobytes()).digest() for frame in frames} assert len(hashes) >= min(count, 5), f"Frozen animation: {prefix}" # Closed eyes at the start must not give the entire preview a black palette. palette_frame = max(frames, key=lambda frame: sum(ImageStat.Stat(frame).mean)) palette = palette_frame.quantize(colors=256) encoded = [frame.quantize(palette=palette) for frame in frames] encoded[0].save(OUTPUT / f"{prefix}.gif", save_all=True, append_images=encoded[1:], duration=duration, loop=0, disposal=2) return len(hashes) def main(): packs = check_packs() names = ["city-eyes", "city-wake-rocker", "city-rocker-back", "city-light-cigarette", "city-upward", "city-standing", "city-look-right", "city-smoke", "city-toss", "city-alley", "city-street", "city-skyline", "arrival-street", "arrival-stop", "arrival-profile", "arrival-name", "arrival-admit", "merchant-idle", "merchant-gesture", "arrival-hall"] black = Image.open(OUTPUT / "city-black.bmp").convert("RGB") assert black.size == (640, 480) and black.getbbox() is None, "Missing black transition" sheet = Image.new("RGB", (1280, ((len(names) + 1) // 2) * 510), "#131c1e") draw = ImageDraw.Draw(sheet) hashes = set() for index, name in enumerate(names): frame = check_snapshot(name) hashes.add(hashlib.sha256(frame.tobytes()).digest()) x, y = (index % 2) * 640, (index // 2) * 510 sheet.paste(frame, (x, y)) draw.text((x + 12, y + 488), f"Native MASM / {name}", fill="#eeeeee") frame.save(OUTPUT / f"{name}.png") assert len(hashes) == len(names), "Repeated scene snapshots" sheet.save(OUTPUT / "arrival-contact-sheet.png") sequences = { "wake": check_sequence("city-wake",36,(0,0,640,480),125), "ignition": check_sequence("city-light",20,(0,0,640,480),115), "guard": check_sequence("guard-admit", 12, (0, 0, 640, 400), 100), "merchant": check_sequence("merchant-talk", 12, (0, 56, 330, 390), 100), "dealer_hand": check_sequence("poker-hand", 24, (0, 88, 640, 444), 60), "cigarette_toss": check_sequence("city-toss", 20, (0, 180, 640, 480), 75), "traffic": check_sequence("city-traffic", 16, (0, 120, 640, 480), 200), } report = {"result": "PASS", "packs": packs, "native_snapshots": len(names), "distinct_sequence_states": sequences} (OUTPUT / "arrival-visual-report.json").write_text(json.dumps(report, indent=2)) print(json.dumps(report)) if __name__ == "__main__": main()