- 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
117 lines
5.8 KiB
Python
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()
|