KAZIK/tools/tests/check_dice_scene.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

153 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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