- 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
153 lines
7.4 KiB
Python
153 lines
7.4 KiB
Python
"""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)
|