KAZIK/tools/tests/check_arrival_frames.py
Игорь Федосеев ca62db53e9 feat: implement native billiards, scratch tickets, first-person dice, and city wake scene
- Billiards: add native Russian Pyramid & American Eight-Ball pool with physics prediction, controls, and regression tests
- Scratch Tickets: add dedicated counter, mat presentation, elliptical coin scraping, 4 ticket types, and payout effects
- First-Person Dice: add native dice scene, hand anchor frames, throwing animation, and table perspective
- City Wake & Weather: add arrival cutscene, alley geometry, weather/rain effects, and lighter/rocker animation
- World Dressing: add hall paintings, furniture packs, hall lighting, and scene regression tests
- Build & Tooling: update build.bat for modern MSVC toolchain, update launcher hash, and refresh shipping binaries
2026-10-16 12:37:40 +03:00

117 lines
5.8 KiB
Python

"""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()