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.
172 lines
8.2 KiB
Python
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()
|