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

317 lines
17 KiB
Python
Raw 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.

"""Check real MASM scratch-off captures and exact coating/pointer invariants."""
import argparse
import hashlib
import json
import struct
import wave
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 asset(name,size,count):
data = (ROOT / "assets/animations/scratch" / name).read_bytes()
w,h = size
assert struct.unpack_from("<4s4I",data) == (b"ANM1",w,h,count,w*h*4*count)
assert len(data) == 20+w*h*4*count
result = [Image.frombytes("RGBA",size,data[20+i*w*h*4:20+(i+1)*w*h*4],"raw","BGRA") for i in range(count)]
for frame in result:
assert set(frame.getchannel("A").tobytes()) <= {0,255}
return result
def check_assets():
asset("table-v1.bin",(640,480),1)
tickets = asset("ticket-v1.bin",(448,288),4)
assert len({hashlib.sha256(f.tobytes()).digest() for f in tickets}) == 4
foil = asset("foil-v1.bin",(104,42),1)[0]
assert set(foil.getchannel("A").tobytes()) == {255}
coins = asset("coin-edge-v1.bin",(64,64),4)
assert len({hashlib.sha256(f.tobytes()).digest() for f in coins}) == 4
for frame in coins:
alpha = frame.getchannel("A")
assert alpha.getbbox()[3] == 61, "Coin edge drifts off its contact point"
assert alpha.getpixel((32,60)) == 255, "Hotspot not on the scraping rim"
left,top,right,bottom = alpha.getbbox()
assert right-left < bottom-top, "Coin is face-on, not tilted on its edge"
counter = asset("counter-v1.bin",(224,240),1)[0]
assert counter.getbbox()[3] == 240
prompts = json.loads((ROOT / "assets/sprites/scratch/prompts-v1.json").read_text())
assert prompts["mode"] == "built-in image_gen"
payout = asset("payout-chips-v1.bin",(96,72),1)[0]
assert payout.getbbox()[3] == 72
assert len(payout.getcolors(96*72))>100
for name in ("scratch-scrape.wav","scratch-paper.wav","scratch-payout.wav"):
with wave.open(str(ROOT/"assets/audio/generated"/name),"rb") as audio:
assert audio.getnchannels()==1 and audio.getsampwidth()==2
assert audio.getframerate()==22050
samples = struct.unpack(f"<{audio.getnframes()}h",audio.readframes(audio.getnframes()))
assert 1000<max(map(abs,samples))<32767
arts = asset("portraits-v3.bin",(540,720),3)
maps = asset("portrait-fields-v3.bin",(540,720),3)
layouts = json.loads((ROOT/"assets/animations/scratch/portrait-layouts-v3.json").read_text())
for art,mapping,layout,count in zip(arts,maps,layouts,(20,24,16)):
assert len(layout["fields"]) == count
ids = mapping.getchannel("B")
assert set(ids.tobytes()) == set(range(count+1))
for i,field in enumerate(layout["fields"],1):
assert ids.tobytes().count(i) == field["area"]
x,y,w,h = (field[k] for k in ("x","y","w","h"))
assert w<=128 and h<=96
assert all(x<=px<x+w and y<=py<y+h for py in range(720) for px in range(540)
if ids.getpixel((px,py))==i)
print("PASS: original paper/foil, four edge-anchored coin poses, grounded counter")
def check_captures():
names = ("ready","covered","partial","result","missing")
frames = {}
sheet = Image.new("RGB",(1280,1536),(20,25,29))
draw = ImageDraw.Draw(sheet)
for i,name in enumerate(names):
frame = Image.open(OUT / f"scratch-{name}.bmp").convert("RGB")
assert frame.size == (640,480)
assert len(frame.getcolors(640*480)) > (3 if name == "missing" else 100), name
frame.save(OUT / f"scratch-{name}.png")
frames[name] = frame
at = ((i%2)*640,(i//2)*512)
sheet.paste(frame,at)
draw.text((at[0]+8,at[1]+488),name,fill="white")
sheet.save(OUT / "scratch-contact.png")
foil = asset("foil-v1.bin",(104,42),1)[0].convert("RGB")
changed = []
for cell in range(9):
col,row = cell%3,cell//3
x,y = 144+124*col,174+50*row
crop = (x,y,x+104,y+42)
assert ImageChops.difference(frames["covered"].crop(crop),foil).getbbox() is None, "Symbol leaks through opaque coating"
diff = ImageChops.difference(frames["covered"].crop(crop),frames["partial"].crop(crop)).convert("L")
changed.append(sum(p != 0 for p in diff.tobytes()))
assert ImageChops.difference(frames["result"].crop(crop),foil).getbbox() is not None, "Completed panel still covered"
assert all(400 < changed[i] < 2200 for i in (0,1,2)), changed
assert all(changed[i] == 0 for i in (3,4,5,7,8)), "Disconnected drags joined across paper"
assert 50 < changed[6] < 250, changed
glyphs = font()
text_matches(frames["result"],"ВЫПЛАТА",336,405,1,(173,185,188),glyphs)
text_matches(frames["result"],"250",424,402,2,(144,213,173),glyphs)
text_matches(frames["result"],"ЦЕНА БИЛЕТА",16,405,1,(229,210,158),glyphs)
text_matches(frames["result"],"ЧИСТЫЙ ИТОГ",336,422,1,(173,185,188),glyphs)
text_matches(frames["result"],"+200",440,422,1,(144,213,173),glyphs)
text_matches(frames["missing"],"НЕТ СПРАЙТОВ",208,224,2,(233,147,131),glyphs)
assert len({hashlib.sha256(f.tobytes()).digest() for f in frames.values()}) == 5
print("PASS: opaque coatings, local continuous removal, separate gestures, Russian prize and safe missing-art frame")
def check_variants():
foil = asset("foil-v1.bin",(104,42),1)[0].convert("RGB")
papers = asset("ticket-v1.bin",(448,288),4)
glyphs = font()
sheet = Image.new("RGB",(1280,960))
covered_sheet = Image.new("RGB",(1280,960))
titles = ("ЗОЛОТАЯ ТРОЙКА","СЧАСТЛИВЫЕ ЧИСЛА","ТРИ В РЯД","МИГ УДАЧИ")
for kind,title in enumerate(titles):
covered = Image.open(OUT / f"scratch-kind-{kind}-covered.bmp").convert("RGB")
result = Image.open(OUT / f"scratch-kind-{kind}-result.bmp").convert("RGB")
text_matches(covered,title,320-len(title)*8,130,2,(229,210,158),glyphs)
text_matches(result,"ВЫПЛАТА",336,405,1,(173,185,188),glyphs)
text_matches(result,"250",424,402,2,(144,213,173),glyphs)
if kind == 1:
text_matches(covered,"ВЫИГРЫШНЫЕ ЧИСЛА",144,170,1,(48,59,50),glyphs)
text_matches(covered,"ВАШИ ЧИСЛА",144,220,1,(48,59,50),glyphs)
text_matches(result,"СОВПАДЕНИЕ = ПРИЗ ПОД ЧИСЛОМ",176,328,1,(48,59,50),glyphs)
if kind == 3:
text_matches(result,"ПРИЗ УКАЗАН ПОД ЗАЩИТОЙ",176,340,1,(48,59,50),glyphs)
for state,frame in (("covered",covered),("result",result)):
frame.save(OUT / f"scratch-kind-{kind}-{state}.png")
sheet.paste(result,((kind%2)*640,(kind//2)*480))
covered_sheet.paste(covered,((kind%2)*640,(kind//2)*480))
if kind == 3:
expected = Image.new("RGB",(240,120))
for y in range(120):
for x in range(240):
expected.putpixel((x,y),foil.getpixel((x%104,y%42)))
assert ImageChops.difference(covered.crop((200,176,440,296)),expected).getbbox() is None
else:
for i in range(9):
x,y = 144+124*(i%3),174+50*(i//3)
if kind != 2:
height = 34 if kind == 1 else 42
if kind == 1:
y += 8
assert ImageChops.difference(covered.crop((x,y,x+104,y+height)),foil.crop((0,0,104,height))).getbbox() is None
else:
x += 30
for dy in range(44):
for dx in range(44):
if (dx-21)**2+(dy-21)**2 <= 21**2:
assert covered.getpixel((x+dx,y+dy)) == foil.getpixel((dx%104,dy%42))
else:
paper = papers[kind]
assert covered.getpixel((x+dx,y+dy)) == paper.getpixel((x+dx-96,y+dy-92))[:3], "Circle leaks onto paper"
assert ImageChops.difference(covered,result).getbbox() is not None
sheet.save(OUT / "scratch-four-types.png")
covered_sheet.save(OUT / "scratch-four-covers.png")
print("PASS: four native layouts, Russian titles, opaque number/prize fields, circle clipping and large coating")
def check_portraits():
glyphs = font()
maps = asset("portrait-fields-v3.bin",(540,720),3)
foil = asset("foil-v1.bin",(104,42),1)[0]
layouts = json.loads((ROOT/"assets/animations/scratch/portrait-layouts-v3.json").read_text())
sheet = Image.new("RGB",(1920,960))
for k,(mapping,layout) in enumerate(zip(maps,layouts),4):
frames = {}
for state in ("full","covered","result"):
frame = Image.open(OUT/f"scratch-kind-{k}-{state}.bmp").convert("RGB")
frame.save(OUT/f"scratch-kind-{k}-{state}.png")
frames[state] = frame
for state,vx,vy,vw,vh in (("full",194,46,252,336),("covered",68,-230,504,672)):
frame = frames[state]
samples = [0]*len(layout["fields"])
for y in range(80,388):
sy = (y-vy)*720//vh
if not 0<=sy<720:
continue
for x in range(vx,vx+vw):
sx = (x-vx)*540//vw
i = mapping.getpixel((sx,sy))[2]
if i:
assert frame.getpixel((x,y)) == foil.getpixel((sx%104,sy%42))[:3], (k,state,x,y)
samples[i-1] += 1
assert min(samples)>100,(k,state,samples)
result = frames["result"]
text_matches(result,"250",424,402,2,(144,213,173),glyphs)
text_matches(result,"+200",440,422,1,(144,213,173),glyphs)
for i,field in enumerate(layout["fields"]):
x,y,w,h = (field[q] for q in ("x","y","w","h"))
left,top = 68+x*504//540,-230+y*672//720
width = (x+w)*504//540-x*504//540+1
height = (y+h)*672//720-y*672//720+1
value = str(i+1) if i!=layout["winning"] else "1"
ink = (5,104,55) if i==layout["winning"] else (48,59,50)
ty = top+height//2-4-(7 if i>=layout["winning"] else 0)
text_matches(result,value,left+(width-len(value)*8)//2,ty,1,ink,glyphs)
sheet.paste(frames["full"],((k-4)*640,0))
sheet.paste(result,((k-4)*640,480))
sheet.save(OUT/"scratch-portrait-types.png")
catalog = Image.open(OUT/"scratch-catalog.bmp").convert("RGB")
titles = ("ЗОЛОТАЯ ТРОЙКА","СЧАСТЛИВЫЕ ЧИСЛА","ТРИ В РЯД","МИГ УДАЧИ",
"НЕОНОВАЯ УДАЧА","РУБИНОВЫЙ ДЖЕКПОТ","ВОЛНА СОКРОВИЩ")
for i,title in enumerate(titles):
text_matches(catalog,title,188,122+i*38,2,(229,210,158),glyphs)
catalog.save(OUT/"scratch-catalog.png")
for i,(text,x,ink) in enumerate((("-50",440,(233,147,131)),("0",448,(173,185,188)),("+50",440,(144,213,173)))):
frame = Image.open(OUT/f"scratch-net-{i}.bmp").convert("RGB")
text_matches(frame,text,x,422,1,ink,glyphs)
text_matches(frame,"50",112,402,2,(255,255,255),glyphs)
print("PASS: 60 exact portrait fields in both scales, readable native numbers, seven named choices and signed net results")
def check_motion():
coins = asset("coin-edge-v1.bin",(64,64),4)
frames = []
for i in range(36):
frame = Image.open(OUT / f"scratch-stroke-{i:02}.bmp").convert("RGB")
row,step = divmod(i,12)
pointer_x = 164 + (308-28*step if row%2 else 28*step)
pointer_y = 183+15*row
coin = coins[((i*33)>>7)&3]
for y in range(64):
for x in range(64):
pixel = coin.getpixel((x,y))
if pixel[3]:
assert frame.getpixel((pointer_x-32+x,pointer_y-60+y)) == pixel[:3], (i,x,y)
frames.append(frame)
frames[0].save(OUT / "scratch-edge-stroke.gif",save_all=True,append_images=frames[1:],duration=50,loop=0)
for name in ("pointer","counter"):
Image.open(OUT / f"scratch-{name}.bmp").save(OUT / f"scratch-{name}.png")
for kind in range(4,7):
motion = []
for i in range(26):
frame = Image.open(OUT/f"scratch-portrait-{kind}-{i:02}.bmp").convert("RGB")
px,py = 220+20*(i%13),188+12*(i//13)+(i&1)
coin = coins[((i*33)>>7)&3]
for y in range(64):
for x in range(64):
pixel = coin.getpixel((x,y))
if pixel[3]:
assert frame.getpixel((px-32+x,py-60+y)) == pixel[:3], (kind,i,x,y)
motion.append(frame)
assert len({hashlib.sha256(f.tobytes()).digest() for f in motion}) == 26
motion[0].save(OUT/f"scratch-portrait-{kind}.gif",save_all=True,append_images=motion[1:],duration=60,loop=0)
print("PASS: 114 actual native motion frames, edge-coin contact and three portrait scratch sequences")
def check_effects():
glyphs = font()
for kind in (0,4):
frames = []
for i in range(31):
versions = [Image.open(OUT/f"scratch-deal-{kind}-rate-{r}-frame-{i:02}.bmp").convert("RGB") for r in range(3)]
assert all(ImageChops.difference(versions[0],frame).getbbox() is None for frame in versions[1:]),(kind,i,"frame-rate dependent paper")
text_matches(versions[0],"9950",112,54,2,(255,255,255),glyphs)
frames.append(versions[0])
assert len({hashlib.sha256(f.tobytes()).digest() for f in frames})>(10 if kind==0 else 20)
# The normal ticket's title and numbers move together, not over a table screenshot.
assert frames[0].getpixel((320,200)) == asset("table-v1.bin",(640,480),1)[0].getpixel((320,200))[:3]
frames[0].save(OUT/f"scratch-delivery-{kind}.gif",save_all=True,append_images=frames[1:],duration=30,loop=0)
frames = [Image.open(OUT/f"scratch-payout-{i:02}.bmp").convert("RGB") for i in range(25)]
for frame in frames:
text_matches(frame,"10200",112,54,2,(255,255,255),glyphs)
text_matches(frame,"250",424,402,2,(144,213,173),glyphs)
assert len({hashlib.sha256(f.tobytes()).digest() for f in frames})>15
frames[0].save(OUT/"scratch-payout.gif",save_all=True,append_images=frames[1:],duration=75,loop=0)
frames[-1].save(OUT/"scratch-payout.png")
colors = frames[-1].crop((190,170,255,220)).getcolors(65*50)
assert sum(count for count,pixel in colors if pixel==(213,239,220))>1000
print("PASS: 186 frame-rate-identical paper/zoom captures, 25 payout frames, unchanged bank/RNG and highlighted winning field")
def parity():
tested = Coff(OUT / "actions-test.obj")
shipped = Coff(ROOT / "build/graphics/poker.obj")
for name,argc in (("LoadScratchArt3D",0),("FreeScratchArt3D",0),("BeginScratch3D",0),
("LeaveScratch3D",0),("BuyScratch3D",0),("FinishScratch3D",0),("SettleScratchOnQuit3D",0),
("ScratchCellOrigin3D",4),("ScratchErasePixel3D",8),("ScratchStamp3D",8),
("ScratchStroke3D",16),("ScratchApplyPointer3D",0),("ScratchMouseEvent3D",16),
("ScratchConfigure3D",0),("ScratchSelectType3D",4),("ScratchBuildVariant3D",0),("ScratchDrawContent3D",12),
("HandleScratch3D",0),("RenderScratch3D",0),("ScratchCanWalk3D",8),
("ScratchBuildPortrait3D",0),("ScratchPortraitPayout3D",0),
("ScratchErasePortrait3D",8),("ScratchPortraitLayers3D",4),
("ScratchPortraitHit3D",8),("RenderScratchPortrait3D",0),
("ScratchConfigurePortrait3D",0),("ScratchPortraitField3D",4),
("ScratchPortraitMapBase3D",0),("ScratchPortraitView3D",0),
("ScratchToggleZoom3D",0),("ScratchPortraitBounds3D",4),
("ScratchPortraitText3D",4),("ScratchPortraitInfo3D",0),
("HandleScratchCatalog3D",0),("RenderScratchCatalog3D",0),("RenderScratchNet3D",0),
("LoadAnimPack3D",8),
("ScratchStartDeal3D",0),("ScratchStartPayout3D",0),("ScratchTickVisuals3D",0),
("ScratchSpawnDust3D",0),("ScratchDrawDust3D",0),("ScratchSaveBackdrop3D",0),
("ScratchDrawDelivery3D",0),("ScratchDrawPayout3D",0),("ScratchEase3D",8),
("WindowEvents3D",16),("TriggerHallInteraction3D",0),("CheckHallInteractions3D",0)):
symbol = f"_{name}@{argc}"
assert tested.signature(symbol) == shipped.signature(symbol), name
print("PASS: shipping input, coating, economy, hall entry and graphics match native-tested MASM")
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--parity",action="store_true")
p.add_argument("--assets-only",action="store_true")
p.add_argument("--motion",action="store_true")
args = p.parse_args()
check_assets()
if not args.assets_only:
check_captures()
check_variants()
check_portraits()
check_effects()
if args.parity:
parity()
if args.motion:
check_motion()