- 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
317 lines
17 KiB
Python
317 lines
17 KiB
Python
"""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()
|