- 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
103 lines
4.7 KiB
Python
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()
|