"""Native palm contact, release continuity, readable top results and MASM parity.""" import argparse import hashlib import struct import json import math from collections import deque from pathlib import Path from PIL import Image, ImageDraw, ImageChops from check_card_exit_build import Coff from check_coin_detail import font, text_matches ROOT = Path(__file__).resolve().parents[2] OUT = ROOT / "build/actions" def pack_frames(): raw = (ROOT / "assets/animations/dice/hands-v2.bin").read_bytes() assert struct.unpack_from("<4s4I",raw) == (b"ANM1",256,320,16,256*320*4*16) assert set(raw[23::4]) == {0,255} frames = [Image.frombytes("RGBA",(256,320),raw[20+i*327680:20+(i+1)*327680],"raw","BGRA") for i in range(16)] for i,frame in enumerate(frames): assert frame.getbbox()[3] == 320, ("Cuff not bottom anchored",i) return frames def components(points,minimum=1): result = [] while points: start = points.pop() queue = deque([start]) body = {start} while queue: x,y = queue.popleft() for at in ((x-1,y),(x+1,y),(x,y-1),(x,y+1)): if at in points: points.remove(at) body.add(at) queue.append(at) if len(body) >= minimum: result.append(body) return result def check_held_contact(frames): raw = (ROOT / "assets/animations/coin/table-v1.bin").read_bytes() table = Image.frombytes("RGBA",(640,480),raw[20:],"raw","BGRA").convert("RGB") # Exact framebuffer compositing catches an extra procedural die painted # over skin, not merely a centre point that happens to be inside a palm. for name,index in (("dice-ready",0),("dice-held-last",3)): expected = table.copy() expected.paste(frames[index],(320,90),frames[index].getchannel("A")) actual = Image.open(OUT / (name+".bmp")).convert("RGB") assert ImageChops.difference(expected,actual).crop((320,90,576,350)).getbbox() is None, name bounds = json.loads((ROOT / "build/dice/held-bounds.json").read_text()) for row in bounds: assert len(row) == 2 for left,top,right,bottom in row: assert right-left <= 42 and bottom-top <= 44, "Die exceeds a thumb-width" assert 70 <= left < right <= 170 and 170 <= top < bottom <= 240, "Die left central palm" frame = Image.open(OUT / "dice-release.bmp").convert("RGB") colors = {(250,241,223),(201,193,176),(209,197,174), (223,211,189),(255,249,234),(181,170,152)} points = {(x,y) for y in range(240,335) for x in range(375,510) if frame.getpixel((x,y)) in colors} cubes = sorted(components(points,100),key=lambda c:min(x for x,y in c)) assert len(cubes) == 2, "Released dice merge or disappear" for cube,b in zip(cubes,bounds[-1]): xs,ys = zip(*cube) assert abs((min(xs)+max(xs))/2-(320+(b[0]+b[2])/2)) <= 4, "Release jumps sideways" assert abs((min(ys)+max(ys))/2-(90+(b[1]+b[3])/2)) <= 4, "Release jumps vertically" # The floor shadow pass must not paint over the foreground hand. hand = frames[4] for y in range(260): for x in range(256): rgba = hand.getpixel((x,y)) actual = frame.getpixel((x+320,y+90)) if rgba[3] and actual not in colors and actual != (163,29,37): assert actual == rgba[:3], ("Floor shadow overlays skin",x,y) def check_top_results(): # The top plane is the y=-half face at a fixed down-looking camera angle. # Count actual native red pip clusters inside that face, not UI text. def project(x,z,cx): s,c = int(math.sin(44*math.tau/512)*65536),int(math.cos(44*math.tau/512)*65536) rx = ((x*c)>>16)+((z*s)>>16) rz = ((z*c)>>16)-((x*s)>>16) sy,cy = int(math.sin(70*math.tau/512)*65536),int(math.cos(70*math.tau/512)*65536) ry = ((-32768*cy)>>16)-((rz*sy)>>16) return ((cx+(rx*12>>16))*2,(146+(ry*12>>16))*2) for value in range(1,7): frame = Image.open(OUT / f"dice-face-{value}.bmp").convert("RGB") for cx,expected in ((134,value),(186,7-value)): mask = Image.new("1",frame.size) ImageDraw.Draw(mask).polygon([project(x,z,cx) for x,z in ((-32768,-32768),(-32768,32768),(32768,32768),(32768,-32768))],fill=1) points = {(x,y) for y in range(270,301) for x in range(cx*2-25,cx*2+25) if mask.getpixel((x,y)) and frame.getpixel((x,y)) == (163,29,37)} assert len(components(points,2)) == expected, ("Wrong top pips",value,cx,expected) assert sum(frame.getpixel((x,y)) == (21,49,34) for y in range(292,322) for x in range(cx*2-25,cx*2+25)) >= 4, "Missing underside contact shadow" frame.save(OUT / f"dice-face-{value}.png") def main(parity=False): source = (ROOT / "src_graphics/dice_animation.inc").read_text(encoding="cp1251") assert "00FAF1DFh" in source and "00A31D25h" in source, "Ivory/red identity changed" frames = pack_frames() names = ["dice-ready","dice-0","dice-200","dice-500","dice-800","dice-1200","dice-1600","dice-missing"] contact = Image.new("RGB",(1280,2048),(20,25,29)) draw = ImageDraw.Draw(contact) for i,name in enumerate(names): frame = Image.open(OUT / (name+".bmp")).convert("RGB") assert frame.size == (640,480) assert len(frame.getcolors(640*480)) > 100, name frame.save(OUT / (name+".png")) contact.paste(frame,((i%2)*640,(i//2)*512)) draw.text(((i%2)*640+8,(i//2)*512+488),name,fill="white") contact.save(OUT / "dice-contact.png") check_held_contact(frames) check_top_results() result = Image.open(OUT / "dice-1600.bmp").convert("RGB") glyphs = font() text_matches(result,"СУММА:",24,360,2,(229,210,158),glyphs) text_matches(result,"+",172,360,2,(229,210,158),glyphs) text_matches(result,"=",224,360,2,(229,210,158),glyphs) for text,x in (("4",144),("6",196),("10",252),("100",496)): ink = (144,213,173) if text == "100" else (255,255,255) text_matches(result,text,x,360,2,ink,glyphs) sequence = [Image.open(OUT / f"dice-scene-{i:02}.bmp").convert("RGB") for i in range(40)] assert len({hashlib.sha256(f.crop((200,180,590,388)).tobytes()).digest() for f in sequence}) >= 35 sequence.append(result) palette = sequence[15].quantize(colors=256) gif = [f.quantize(palette=palette) for f in sequence] gif[0].save(OUT / "dice-first-person.gif",save_all=True,append_images=gif[1:],duration=[40]*40+[800],loop=0,disposal=2) if parity: tested = Coff(OUT / "actions-test.obj") shipped = Coff(ROOT / "build/graphics/poker.obj") for name,size in (("RenderDiceGame",0),("RenderNativeDice3D",0),("RenderDiceThrow3D",0), ("DrawTumblingDie3D",24),("DrawDiceHand3D",0),("HandleDiceInput",0), ("HandleDiceRules3D",0),("HandleDiceMouse3D",0),("DiceEscape3D",0), ("LoadNativeDiceArt3D",0),("FreeNativeDiceArt3D",0)): symbol = f"_{name}@{size}" assert tested.signature(symbol) == shipped.signature(symbol), name print("PASS: palm-contact compositing, measured release, six correct upward results, sixteen hand poses, forty native frames, Russian sum/payout" + (", shipping/tested MASM parity" if parity else "")) if __name__ == "__main__": p = argparse.ArgumentParser() p.add_argument("--parity",action="store_true") main(p.parse_args().parity)