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

103 lines
4.7 KiB
Python

"""Check real MASM framebuffer captures, packed decor and tested/shipping parity."""
import argparse
import struct
from pathlib import Path
from PIL import Image, ImageChops, ImageStat
from check_card_exit_build import Coff
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "build/actions"
FUNCTIONS = {
"LoadWorldDressing3D": 0, "FreeWorldDressing3D": 0,
"ResolveDressingSurface3D": 4, "RenderHallDressing3D": 0,
"RenderStreetDressing3D": 0, "HallLightVisible3D": 16,
"StreetGroundHeight3D": 8, "UpdateStreetGround3D": 0,
"AddHallLight3D": 16, "AddHallContact3D": 12,
"BuildHallLighting3D": 0, "ApplyHallLighting3D": 0,
"InitHall3D": 0, "UpdateHall3D": 0, "RenderHall3D": 0,
"LoadHallAssets3D": 0, "FreeHallAssets3D": 0,
"PositionCityCamera3D": 0, "RenderCityArrival3D": 0,
"DrawCityHorizontal3D": 24, "ResolveCitySurface3D": 4,
"RenderWalls": 0,
}
def assets():
raw = (ROOT / "assets/animations/hall/world-dressing-v6.bin").read_bytes()
assert struct.unpack_from("<4s4I", raw) == (b"ANM1", 128, 128, 3, 196608)
assert len(raw) == 196628
for index in range(3):
start = 20 + index * 65536
frame = Image.frombytes("RGBA", (128, 128), raw[start:start+65536], "raw", "BGRA")
assert set(frame.getchannel("A").tobytes()) <= {0, 255}
assert frame.getbbox() == (0, 0, 128, 128)
assert len(frame.convert("RGB").getcolors(16384)) > (100 if index < 2 else 5)
print("PASS: two framed paintings and a drain material; valid dimensions, length and binary alpha")
def capture(name):
frame = Image.open(OUT / (name + ".bmp")).convert("RGB")
assert frame.size == (640, 480), name
assert len(frame.getcolors(307200)) > 1000, name
frame.save(OUT / (name + ".png"))
return frame
def captures():
unlit = capture("world-hall-unlit")
lit = capture("world-hall-lit")
difference = ImageChops.difference(unlit, lit)
assert difference.getbbox() == (0, 0, 640, 480), "Light pass did not cover the world"
values = [sum(pixel) for pixel in difference.get_flattened_data()]
assert sum(value > 6 for value in values) > 170000, "No visible lighting change"
# Lighting changes channel gains, not the underlying texture resolution.
for channel in range(3):
gains = [new[channel] / old[channel] for old, new in zip(unlit.get_flattened_data(), lit.get_flattened_data())
if 32 <= old[channel] <= 180]
assert min(gains) > .4 and max(gains) < 1.5, channel
regions = [(0, 0, 160, 160), (240, 240, 400, 400), (400, 300, 560, 460)]
means = [ImageStat.Stat(difference.crop(region)).mean for region in regions]
assert max(sum(mean) for mean in means) - min(sum(mean) for mean in means) > 3
front = capture("world-wall-front")
angle = capture("world-wall-angle")
assert ImageChops.difference(front, angle).getbbox(), "World-mounted decor did not reproject"
frames = [capture("world-street-" + phase) for phase in ("early", "middle", "late")]
assert all(ImageChops.difference(a, b).getbbox() for a, b in zip(frames, frames[1:]))
print("PASS: native lit/unlit comparison, preserved texture detail, wall parallax and three street positions")
def parity():
tested = Coff(OUT / "actions-test.obj")
shipped = Coff(ROOT / "build/graphics/poker.obj")
for name, arguments in FUNCTIONS.items():
symbol = f"_{name}@{arguments}"
assert tested.signature(symbol) == shipped.signature(symbol), name
# Test-only pixel ownership counters wrap this renderer. Compare the complete
# production body after its register prologue, excluding only its epilogue.
symbol = "_RenderCityObjects3D@0"
actual = tested.signature(symbol)
expected = shipped.signature(symbol)
begin = next(i for i, item in enumerate(expected) if item[2] == "_AnimateCityCars3D@0")
end = len(expected)
epilogue = {b"\x5f", b"\x5e", b"\x5b", b"\x5d", b"\xc9", b"\xc3", b"\x8b\xe5"}
while end > begin and expected[end-1][0] in epilogue:
end -= 1
core = expected[begin:end]
assert len(core) > 35 and len(expected)-end >= 4
offset = next(i for i, item in enumerate(actual) if item[2] == "_AnimateCityCars3D@0")
assert actual[offset:offset+len(core)] == core, "Street object production body"
assert any(item[2] == "_UpdateStreetGround3D@0" for item in core)
print("PASS: shipped hall/street dressing, light baker, shader and camera match native-tested MASM")
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()
if options.parity:
parity()