KAZIK/tools/tests/check_european_wheel.py
Artvasil09 881a0061dc Add native NPC visitors with contracts, Clover slots, Pyramid, and party table scenes
Implement 32x32 BFS visitor navigation and daily contracts system with companion .npc journals; add native assembly scenes for Clover slots, Pyramid cards, Blackjack Party and Russian Party; package animation and sprite packs; add comprehensive offline test suites and rebuild shipping binaries.
2026-10-13 09:47:33 +03:00

172 lines
8.2 KiB
Python

"""Validate roulette assets, native captures and tested/shipping procedure parity."""
import argparse
import hashlib
import json
import re
import struct
from functools import lru_cache
from pathlib import Path
from PIL import Image, ImageDraw
from check_card_exit_build import Coff
ROOT = Path(__file__).resolve().parents[2]
ORDER = [0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30,
8, 23, 10, 5, 24, 16, 33, 1, 20, 14, 31, 9, 22, 18, 29, 7, 28,
12, 35, 3, 26]
RED = {1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36}
DIGITS = ((7, 5, 5, 5, 7), (2, 6, 2, 2, 7), (7, 1, 7, 4, 7),
(7, 1, 7, 1, 7), (5, 5, 7, 1, 1), (7, 4, 7, 1, 7),
(7, 4, 7, 5, 7), (7, 1, 2, 2, 2), (7, 5, 7, 5, 7),
(7, 5, 7, 1, 7))
LABEL_INK = (255, 248, 222)
@lru_cache(maxsize=2)
def lookup_table(name):
lines = (ROOT / "casino3d_data.inc").read_text(encoding="cp1251").splitlines()
start = next(i for i, line in enumerate(lines) if line.startswith(name + " dd "))
values = []
for i, line in enumerate(lines[start:]):
if i and not line.strip().startswith("dd "):
break
for value in re.findall(r"([0-9A-F]+)h", line):
number = int(value, 16)
values.append(number if number < 2**31 else number - 2**32)
assert len(values) == 512, name
return values
def divide_signed(number, denominator):
return number // denominator if number >= 0 else -((-number) // denominator)
def label_positions(center_x, center_y, radius_x, radius_y, angle):
cosine, sine = lookup_table("tableCos"), lookup_table("tableSin")
return [(center_x + divide_signed((cosine[(i * 512 // 37 + angle) % 512] * 195 >> 16)
* radius_x, 224),
center_y + divide_signed((sine[(i * 512 // 37 + angle) % 512] * 195 >> 16)
* radius_y, 224)) for i in range(37)]
def value_pixels(center_x, y, number, scale):
text = str(number)
x = center_x - ((len(text) * 4 - 1) * scale // 2)
return {(x + (digit_index * 4 + column) * scale + dx, y + row * scale + dy)
for digit_index, digit in enumerate(text)
for row, bits in enumerate(DIGITS[int(digit)])
for column in range(3) if bits & (4 >> column)
for dx in range(scale) for dy in range(scale)}
def readable_labels():
# Independent pixel masks check the actual assembly framebuffer, not a mock renderer.
for angle in range(512):
for geometry, scale in (((206, 170, 142, 46, angle), 1),
((320, 278, 205, 88, angle), 2)):
occupied = set()
for number, (x, y) in zip(ORDER, label_positions(*geometry)):
pixels = value_pixels(x, y - 5 * scale // 2, number, scale)
assert not occupied.intersection(pixels), (angle, scale, number, "overlap")
occupied.update(pixels)
for name, geometry, scale in (
("small", (206, 170, 142, 46, 0), 1),
("rotated", (320, 278, 205, 88, 113), 2)):
frame = Image.open(ROOT / "build/actions" / f"wheel-label-{name}.bmp").convert("RGB")
expected = set()
for number, (x, y) in zip(ORDER, label_positions(*geometry)):
glyph = value_pixels(x, y - 5 * scale // 2, number, scale)
assert not expected.intersection(glyph), (name, number, "overlapping digits")
expected.update(glyph)
actual = {(x, y) for y in range(480) for x in range(640)
if frame.getpixel((x, y)) == LABEL_INK}
assert actual == expected, (name, "missing or distorted digit pixels",
len(expected - actual), len(actual - expected))
positions = label_positions(320, 278, 205, 88, 0)
for result in range(37):
frame = Image.open(ROOT / "build/actions" / f"wheel-label-{result:02}.bmp").convert("RGB")
for number, (x, y) in zip(ORDER, positions):
pixels = value_pixels(x, y - 5, number, 2)
missing = [point for point in pixels if frame.getpixel(point) != LABEL_INK]
assert not missing, (result, number, "ball/highlight obscures label", missing[:5])
ink = (244, 231, 200) if result not in RED else (255, 180, 173)
assert all(frame.getpixel(point) == ink
for point in value_pixels(320, 268, result, 6)), (result, "central result")
print("PASS: all 37 native results retain every upright pocket digit and large central result;")
print(" exact 1x/2x native glyphs and no overlap at any of 512 rotation angles")
def assets():
manifest = json.loads((ROOT / "assets/animations/wheel/manifest-v1.json").read_text())
assert manifest == {"order": ORDER, "zero_pockets": 1}
source = (ROOT / "src_graphics/native_wheel_data.inc").read_text(encoding="ascii")
assert 'wheelRotorPath3D db "assets\\animations\\wheel\\rotor-v2.bin",0' in source
order = re.search(r"wheelOrder3D dd ([0-9,]+)", source).group(1)
assert [int(value) for value in order.split(",")] == ORDER
low, high = re.search(r"wheelRedMask3D dd ([0-9A-F]+)h,([0-9A-F]+)h", source).groups()
mask = int(low, 16) | int(high, 16) << 32
assert {n for n in range(37) if mask & (1 << n)} == RED
for name, size in (("table", (640, 480)), ("closeup", (640, 480)), ("rotor", (480, 480))):
version = 2 if name == "rotor" else 1
data = (ROOT / "assets/animations/wheel" / f"{name}-v{version}.bin").read_bytes()
assert struct.unpack_from("<4s4I", data) == (
b"ANM1", *size, 1, size[0] * size[1] * 4), name
assert len(data) == 20 + size[0] * size[1] * 4, name
alpha = set(data[23::4])
assert alpha == ({0, 255} if name == "rotor" else {255}), name
if name == "rotor":
assert not any(data[i + 2] > 245 and data[i + 1] > 225 and data[i] > 185
for i in range(20, len(data), 4)), "Baked-in numbers returned"
print("PASS: three valid packs; exact European order, one zero and correct red/black colors")
def captures():
names = ("draft", "launch", "fast", "slow", "land", "result")
contact = Image.new("RGB", (1280, 3 * 504), (20, 25, 29))
labels = ImageDraw.Draw(contact)
hashes = []
for i, name in enumerate(names):
path = ROOT / "build/actions" / ("wheel-" + name + "-v1.bmp")
frame = Image.open(path).convert("RGB")
assert frame.size == (640, 480), name
assert len(frame.getcolors(640 * 480)) > 1000, name
hashes.append(hashlib.sha256(frame.tobytes()).hexdigest())
frame.save(path.with_suffix(".png"))
x, y = i % 2 * 640, i // 2 * 504
contact.paste(frame, (x, y + 24))
labels.text((x + 8, y + 6), name, fill="white")
assert len(set(hashes)) == len(names), "Animation captures must be distinct"
contact.save(ROOT / "build/actions/wheel-contact-v1.png")
print("PASS: six distinct nonblank native roulette frames at draft/spin/landing/result")
def parity():
tested = Coff(ROOT / "build/actions/actions-test.obj")
shipped = Coff(ROOT / "build/graphics/poker.obj")
functions = (("InitNativeWheel3D", 0), ("HandleNativeWheel3D", 0),
("RenderNativeWheel3D", 0), ("WheelSettle3D", 0),
("WheelAddBet3D", 4), ("WheelBetMultiplier3D", 8),
("WheelEscape3D", 0), ("WheelTravel3D", 12),
("DrawNativeWheelDisc3D", 20), ("RenderWheelScene3D", 0),
("LoadNativeWheelArt3D", 0), ("FreeNativeWheelArt3D", 0),
("DrawWheelBox3D", 20), ("DrawWheelDigit3D", 20),
("DrawWheelValue3D", 20), ("DrawWheelLabels3D", 20),
("AnimateCityPedestrians3D", 0), ("HandleArrival3D", 0))
for name, arguments in functions:
symbol = f"_{name}@{arguments}"
assert tested.signature(symbol) == shipped.signature(symbol), name
print("PASS: shipping roulette and street animation procedures match native-tested code")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--assets-only", action="store_true")
parser.add_argument("--parity", action="store_true")
options = parser.parse_args()
assets()
if not options.assets_only:
captures()
readable_labels()
if options.parity:
parity()