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