- Darts: native 500-point match against Max with sway/fatigue mouse aim, parabolic throw camera, raster arm animations, and 16 camera-relative flying dart views - AI Observer: autonomous spectator mode for Hold'em, Blackjack, Durak, and Billiards with profile isolation and playback controls - Fortune Session: raster sprite packs, mist/orb animations, tarot divination spreads, and pack tools - Hall Integration: wall dartboard in lounge, observer terminal dispatch, and bot turn hooks - Packaging & Verification: updated installer streaming hash, synchronized launcher, passing check suites and release smoke checks
350 lines
22 KiB
Python
350 lines
22 KiB
Python
"""Compile and execute fresh MASM navigation, actors and room renderers."""
|
|
from pathlib import Path
|
|
import argparse
|
|
import re
|
|
import struct
|
|
import subprocess
|
|
from PIL import Image, ImageStat
|
|
from check_card_exit_build import Coff
|
|
from check_terminals import signature
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
OUT = ROOT / "build/hall-expansion"
|
|
|
|
|
|
def read(path):
|
|
data = (ROOT / path).read_bytes()
|
|
try:
|
|
return data.decode("utf8").replace("\r\r\n","\n").replace("\r\n","\n")
|
|
except UnicodeDecodeError:
|
|
return data.decode("cp1251").replace("\r\r\n","\n").replace("\r\n","\n")
|
|
|
|
|
|
def proc(source, name):
|
|
return re.search(rf"^{name} PROC\b.*?^{name} ENDP",source,re.M|re.S).group()
|
|
|
|
|
|
def expand(path):
|
|
return re.sub(r"^include ([^\n]+)$", lambda m: expand(m[1].strip().replace("\\", "/")),
|
|
read(path), flags=re.M)
|
|
|
|
|
|
def prepare():
|
|
OUT.mkdir(exist_ok=True)
|
|
code = read("src_graphics/hall_expansion.inc") + "\n" + read("src_graphics/cabaret.inc")
|
|
code += "\n" + read("src_graphics/interior_portals.inc")
|
|
code += "\n" + read("src_graphics/steel_door.inc")
|
|
code += "\n" + read("src_graphics/fortune_room.inc")
|
|
code += "\n" + read("src_graphics/fortune_dialog.inc")
|
|
code += "\n" + proc(read("src_graphics/unavailable.inc"),"BeginUnavailable3D")
|
|
code += "\n" + read("src_graphics/observer.inc")
|
|
code += "\n" + expand("src_graphics/darts.inc").replace(":SDWORD", ":DWORD")
|
|
code += "\n" + proc(read("src_graphics/native_poker.inc"),"PokerBotAct3D")
|
|
code += "\n" + proc(read("src_graphics/native_durak.inc"),"DurakBotTurn3D")
|
|
code += "\n" + proc(read("src_graphics/world_dressing.inc"),"RenderHallDressing3D")
|
|
for path, names in (
|
|
("src_graphics/hall_scene.inc",["RenderCeilingAndFloor","RenderSprites","InitHall3D",
|
|
"LoadHallAssets3D","FreeHallAssets3D","RenderHall3D","HallTarget3D",
|
|
"CheckHallInteractions3D","TriggerHallInteraction3D","HandleStairs3D","RenderStairs3D",
|
|
"CanWalkHall3D","UpdateHall3D","UpdatePatrons3D","RenderPatrons3D","RenderBar3D",
|
|
"BuyBarDrink3D","HandleBar3D"]),
|
|
("src_graphics/platform_code.inc",["CanWalk3D","WindowEvents3D"]),
|
|
("casino3d.inc",["HandlePlayerInput","CheckInteractions","RenderWallsRange","RenderWalls","RaycastWorkerProc","Trigger3DInteraction"]),
|
|
("src_graphics/city_bins.inc",["ResolveCitySurface3D","PrepareCityDepth3D","PrepareCityRays3D",
|
|
"CityRayPlane3D","DrawCityVertical3D","DrawCityHorizontal3D","DrawCityBoxSides3D"]),
|
|
("src_graphics/npc_life.inc",["InitNpcLife3D","NpcTryCell3D","NpcPlanPath3D",
|
|
"NpcCanStep3D","NpcMove3D","UpdateNpcLife3D","RenderNpcLife3D"]),
|
|
("src_graphics/city_objects.inc",["DrawCityObject3D"]),
|
|
("src_graphics/npc_contracts.inc",["NpcNameText3D"])):
|
|
code += "\n" + "\n".join(proc(read(path),name) for name in names)
|
|
own_funcs = set(re.findall(r"^(\w+) PROC",code,re.M))
|
|
npc = read("src_graphics/npc_life_data.inc")
|
|
npc = npc[npc.index("npcGuestArt3D "):npc.index("npcLifeClock3D ")]
|
|
data = "\n".join(read(path) for path in ("src_graphics/hall_expansion_data.inc",
|
|
"src_graphics/cabaret_data.inc","src_graphics/hall_layout.inc",
|
|
"src_graphics/fortune_room_data.inc","src_graphics/fortune_dialog_data.inc",
|
|
"src_graphics/observer_data.inc")) + "\n.data\n" + npc
|
|
darts = read("src_graphics/darts_data.inc").replace(
|
|
"include src_graphics\\darts_art.inc", read("src_graphics/darts_art.inc"))
|
|
data += "\n" + re.sub(r"^\w+ PROTO[^\n]*\n", "", darts, flags=re.M)
|
|
own_data = set(re.findall(r"^(\w+) (?:dd|db|dw|REAL4|REAL8|AnimPack3D|CityProp3D)\b",data,re.M))
|
|
own_data.update(re.findall(r"^(\w+) LABEL\b",data,re.M))
|
|
coff = Coff(ROOT / "build/graphics/poker.obj")
|
|
helper = bytearray(coff.data)
|
|
at = struct.unpack_from("<I",helper,8)[0]
|
|
sections = set()
|
|
for sec in range(1,struct.unpack_from("<H",helper,2)[0]+1):
|
|
name = bytes(helper[20+(sec-1)*40:28+(sec-1)*40]).rstrip(b"\0")
|
|
if name in (b".data",b".bss",b".const"):
|
|
sections.add(sec)
|
|
externs, protos = [], {}
|
|
types = {"casinoSprites":"SpriteObject","npcLife3D":"NpcLife3D",
|
|
"casinoNpcs3D":"NpcMindEntity","hallSpriteTemp3D":"CityProp3D",
|
|
"hallObjects3D":"CityProp3D","hallLights3D":"CityProp3D",
|
|
"hallLoungeDecor3D":"CityProp3D","hallGuests3D":"CityProp3D",
|
|
"npcSprite3D":"CityProp3D","scrStation3D":"CityProp3D"}
|
|
all_source = "\n".join(read(str(path.relative_to(ROOT))) for path in
|
|
[ROOT/"casino3d.inc",ROOT/"casino3d_data.inc",*sorted(path for path in
|
|
(ROOT/"src_graphics").glob("*.inc") if path.name != "renderer.inc")])
|
|
for name in re.findall(r"^(\w+) AnimPack3D\b",all_source,re.M):
|
|
types[name] = "AnimPack3D"
|
|
for name in re.findall(r"^(\w+) CityProp3D\b",all_source,re.M):
|
|
types[name] = "CityProp3D"
|
|
for name in re.findall(r"^(\w+) CitySpriteCrop3D\b",all_source,re.M):
|
|
types[name] = "CitySpriteCrop3D"
|
|
for name,kind in re.findall(r"^(\w+)\s+(db|dw|dd)\b",all_source,re.M|re.I):
|
|
types.setdefault(name,{"db":"BYTE","dw":"WORD","dd":"DWORD"}[kind.lower()])
|
|
for name,kind in re.findall(r"^(\w+)\s+(REAL4|REAL8)\b",all_source,re.M|re.I):
|
|
types[name] = kind.upper()
|
|
for index,name in coff.symbols.items():
|
|
pos = at+index*18
|
|
_,sec,kind,storage,_ = struct.unpack_from("<IhHBB",helper,pos+8)
|
|
if sec in sections and re.fullmatch(r"[A-Za-z_]\w*",name) and storage in (2,3):
|
|
helper[pos+16] = 3 if name in own_data else 2
|
|
if name not in own_data:
|
|
externs.append(f"EXTERN {name}:{types.get(name,'DWORD')}")
|
|
match = re.fullmatch(r"_(\w+)@(\d+)",name)
|
|
if match and kind == 0x20 and sec > 0:
|
|
name,size = match[1],int(match[2])//4
|
|
if name in own_funcs:
|
|
helper[pos+16] = 3
|
|
else:
|
|
protos[name] = size
|
|
for name in own_funcs:
|
|
protos[name] = proc(code,name).splitlines()[0].count(":DWORD")
|
|
# Local helper instructions remain byte-for-byte intact.
|
|
allowed = {at+index*18+16 for index in coff.symbols}
|
|
assert all(i in allowed for i,(a,b) in enumerate(zip(helper,coff.data)) if a != b)
|
|
(OUT/"helpers.obj").write_bytes(helper)
|
|
structures = []
|
|
for name in ("C3D_POINT","SpriteObject","AnimPack3D","CityProp3D","CitySpriteCrop3D","NpcLife3D","NpcMindEntity"):
|
|
structures.append(re.search(rf"^{name} STRUCT\b.*?^{name} ENDS",all_source,re.M|re.S).group())
|
|
constants = dict(re.findall(r"^(\w+)\s+equ\s+([^;\n]+)",all_source,re.M))
|
|
already = set(re.findall(r"^(\w+) equ",data,re.M))
|
|
used, ordered = set(), []
|
|
def constant(name):
|
|
if name in used or name in already or name not in constants:
|
|
return
|
|
used.add(name)
|
|
for dep in re.findall(r"\b[A-Za-z_]\w*",constants[name]):
|
|
constant(dep)
|
|
ordered.append(f"{name} equ {constants[name]}")
|
|
for name in re.findall(r"\b[A-Za-z_]\w*",code+data+read("tools/tests/hall_expansion_test.inc")):
|
|
constant(name)
|
|
# Win32 prototypes are not declarations in the helper object.
|
|
for name,args in re.findall(r"^(\w+)\s+PROTO([^\n]*)",all_source,re.M):
|
|
protos.setdefault(name,args.count(":DWORD"))
|
|
protos.update({"ExitProcess":1,"WriteFile":5,"GetTickCount":0})
|
|
header = ".386\n.model flat\noption casemap:none\n" + "\n".join(structures+ordered+externs)
|
|
header += "\noption language:stdcall\n" + "\n".join(
|
|
name+" PROTO"+(" "+",".join([":DWORD"]*size) if size else "")
|
|
for name,size in protos.items())
|
|
text = header+"\n"+data+"\n.code\n"+code+"\ninclude tools/tests/hall_expansion_test.inc\nend ExpansionTestStart\n"
|
|
(OUT/"module.asm").write_text(text,encoding="cp1251")
|
|
return own_funcs
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--parity",action="store_true")
|
|
parser.add_argument("--prove-alpha-regression",action="store_true")
|
|
parser.add_argument("--prove-seam-regression",action="store_true")
|
|
parser.add_argument("--prove-distance-regression",action="store_true")
|
|
parser.add_argument("--prove-bar-camera-regression",action="store_true")
|
|
args = parser.parse_args()
|
|
if args.parity:
|
|
native,shipping = Coff(OUT/"module.obj"),Coff(ROOT/"build/graphics/poker.obj")
|
|
tested_source = (OUT/"module.asm").read_text(encoding="cp1251").split(
|
|
"include tools/tests/hall_expansion_test.inc")[0]
|
|
for name in re.findall(r"^(\w+) PROC",tested_source,re.M):
|
|
symbol = next(key for key in native.functions if key.startswith("_"+name+"@"))
|
|
assert signature(native,symbol,tail_branches=True) == signature(shipping,symbol,tail_branches=True),name
|
|
print("PASS: all tested navigation, collision, hall/upper rendering, guests and cabaret procedures match the shipping assembly")
|
|
return
|
|
prepare()
|
|
room = proc(read("src_graphics/fortune_room.inc"), "RenderFortuneFurnishings3D")
|
|
assert "DrawCityObject3D" not in room, "tarot furniture must not face the camera"
|
|
assert room.count("DrawFortuneFixedProp3D") == 24
|
|
stem = "module"
|
|
if args.prove_bar_camera_regression:
|
|
stem = "module-camera-regression"
|
|
source = (OUT/"module.asm").read_text(encoding="cp1251")
|
|
before = proc(source,"UpdateInteriorEye3D")
|
|
after = before.replace(" mov viewHorizon3D,HALF_H\n", "")
|
|
assert before != after
|
|
(OUT/(stem+".asm")).write_text(source.replace(before,after),encoding="cp1251")
|
|
if args.prove_distance_regression:
|
|
stem = "module-old-distance"
|
|
source = (OUT/"module.asm").read_text(encoding="cp1251")
|
|
assert source.count("cmp eax,64*FP_1") == 1
|
|
source = source.replace("cmp eax,64*FP_1", "cmp eax,8*FP_1")
|
|
(OUT/(stem+".asm")).write_text(source,encoding="cp1251")
|
|
if args.prove_alpha_regression:
|
|
stem = "module-alpha-regression"
|
|
source = (OUT/"module.asm").read_text(encoding="cp1251")
|
|
before = proc(source,"InitUpperSurfaces3D")
|
|
after = before.replace("0FF1B5039h","001B5039h").replace("0FFECE3C8h","00ECE3C8h").replace("0FF16191Bh","0016191Bh")
|
|
assert before != after
|
|
(OUT/(stem+".asm")).write_text(source.replace(before,after),encoding="cp1251")
|
|
if args.prove_seam_regression:
|
|
stem = "module-old-seam"
|
|
source = (OUT/"module.asm").read_text(encoding="cp1251")
|
|
source = source.replace("include tools/tests/hall_expansion_test.inc",
|
|
read("tools/tests/hall_expansion_test.inc"))
|
|
before = "cmp portalRendering3D,0\n je wall_ray_start_ready"
|
|
assert source.count(before) == 1
|
|
source = source.replace(before,"jmp wall_ray_start_ready")
|
|
source = source.replace("build\\hall-expansion\\","build\\hall-expansion\\old-seam-")
|
|
source = source.replace("invoke ExpansionPortalSnapshot,OFFSET expansionUpEntry",
|
|
"invoke ExpansionPortalSnapshot,OFFSET expansionUpEntry\n invoke ExitProcess,0")
|
|
(OUT/(stem+".asm")).write_text(source,encoding="cp1251")
|
|
masm = Path("F:/MASM32/masm32")
|
|
subprocess.run([str(masm/"bin/ml.exe"),"/c","/coff","/Cp","/nologo","/I.",
|
|
f"/Fobuild/hall-expansion/{stem}.obj",f"build/hall-expansion/{stem}.asm"],cwd=ROOT,check=True)
|
|
sdk = sorted(Path("C:/Program Files (x86)/Windows Kits/10/Lib").glob("*/um/x86"))[-1]
|
|
subprocess.run([str(masm/"bin/link.exe"),"/nologo","/SUBSYSTEM:CONSOLE","/ENTRY:ExpansionTestStart@0",
|
|
f"/LIBPATH:{sdk}",f"/LIBPATH:{masm/'lib'}",f"/OUT:build/hall-expansion/{stem}.exe",
|
|
f"build/hall-expansion/{stem}.obj","build/hall-expansion/helpers.obj",
|
|
"kernel32.lib","user32.lib","gdi32.lib","winmm.lib","ws2_32.lib"],cwd=ROOT,check=True)
|
|
result = subprocess.run([str(OUT/(stem+".exe"))],cwd=ROOT,timeout=360)
|
|
if args.prove_bar_camera_regression:
|
|
assert result.returncode == 70,result.returncode
|
|
print("PASS: removing camera recovery fails the native bar regression")
|
|
return
|
|
if args.prove_alpha_regression:
|
|
assert result.returncode == 1,result.returncode
|
|
print("PASS: transparent felt/keyboard regression rejected by actual native assertions")
|
|
return
|
|
if args.prove_distance_regression:
|
|
assert result.returncode == 28,result.returncode
|
|
print("PASS: restoring eight-metre cutoff fails the native far-doorway assertion")
|
|
return
|
|
assert result.returncode == 0,f"native case {result.returncode}"
|
|
closed = Image.open(OUT/"fortune-closed.bmp").tobytes()
|
|
opened = Image.open(OUT/"fortune-open.bmp").tobytes()
|
|
assert closed != opened, "secret panel must visibly reveal the stairwell"
|
|
seer = Image.open(OUT/"fortune-seer.bmp").tobytes()
|
|
idle = Image.open(OUT/"fortune-seer-idle.bmp").tobytes()
|
|
assert seer != idle, "fortune teller must have two distinct idle poses"
|
|
dark = ImageStat.Stat(Image.open(OUT/"fortune-seer.bmp").convert("RGB")).mean
|
|
full = ImageStat.Stat(Image.open(OUT/"fortune-full-light.bmp").convert("RGB")).mean
|
|
assert .25 < sum(dark)/sum(full) < .8, "point lighting must darken the room without blacking it out"
|
|
for name in ("fortune-room", "fortune-seer", "fortune-stairs", "fortune-north", "fortune-south"):
|
|
pixels = Image.open(OUT/(name+".bmp")).convert("RGB").crop((0,40,640,440))
|
|
blue = sum(b > r+12 and b > g for r,g,b in pixels.get_flattened_data())
|
|
assert blue > 45000, (name, "chamber must use its own blue palette", blue)
|
|
modal = Image.open(OUT/"fortune-unavailable.bmp").convert("RGB").crop((20,65,620,100))
|
|
assert len(modal.getcolors(600*35)) >= 2, "native fortune dialogue status must be drawn"
|
|
# These are frames rendered by the actual x86 procedures, not a Python scene simulation.
|
|
motion = [Image.open(OUT/f"fortune-session-motion-{i:02}.bmp").convert("RGB") for i in range(64)]
|
|
assert len({im.crop((134,32,414,282)).tobytes() for im in motion}) >= 48
|
|
motion[0].save(OUT/"fortune-session-shuffle.gif",save_all=True,
|
|
append_images=motion[1:],duration=25,loop=0)
|
|
stages = ("deal-one","deal-two","deal-three","flip-one","explain-one",
|
|
"flip-two","explain-two","flip-three","explain-three","summary")
|
|
frames = [Image.open(OUT/f"fortune-session-{s}.bmp").convert("RGB") for s in stages]
|
|
assert len({im.crop((190,285,475,398)).tobytes() for im in frames}) >= 7
|
|
question = frames[0].crop((18,400,620,444)).tobytes()
|
|
assert all(im.crop((18,400,620,444)).tobytes() == question for im in frames)
|
|
for name in ("room","shuffle","deal","gesture","orb-hands","orb-mist","cards"):
|
|
path = ROOT/f"assets/animations/fortune/{name}-v1.bin"
|
|
data = path.read_bytes()
|
|
magic,w,h,count,size = struct.unpack_from("<4s4I",data)
|
|
assert magic == b"ANM1" and len(data) == size+20 and size == w*h*4*count
|
|
assert count <= 64 and size <= 32*1024*1024
|
|
if count > 1:
|
|
pixels = [data[20+i*w*h*4:20+(i+1)*w*h*4] for i in range(count)]
|
|
assert len(set(pixels)) >= count-3, (name,"animation frames must differ")
|
|
if name == "cards":
|
|
for i in range(22):
|
|
upright = Image.frombytes("RGBA",(w,h),pixels[i],"raw","BGRA")
|
|
reversed_card = Image.frombytes("RGBA",(w,h),pixels[i+22],"raw","BGRA")
|
|
assert upright.transpose(Image.Transpose.ROTATE_180).tobytes() == reversed_card.tobytes()
|
|
entrance = Image.open(OUT / (("old-seam-" if args.prove_seam_regression else "") +
|
|
"upper-entrance-open.bmp")).convert("RGB").crop((200,150,300,350))
|
|
dominant = max(count for count,_ in entrance.getcolors(20000))/20000
|
|
if args.prove_seam_regression:
|
|
assert dominant > 0.9,"negative control must reproduce the solid rear-wall block"
|
|
print("PASS: removing portal DDA near clipping reproduces the native solid-wall regression")
|
|
return
|
|
assert dominant < 0.5,"stair entrance must show textured continuation on both sides, not a solid rear-wall block"
|
|
for path in OUT.glob("*.bmp"):
|
|
image = Image.open(path).convert("RGB")
|
|
assert image.size == (640,480) and len(image.getcolors(640*480)) > 100
|
|
image.save(path.with_suffix(".png"))
|
|
gait = [Image.open(OUT/f"gait-{i}.bmp").convert("RGB") for i in range(8)]
|
|
assert len({frame.crop((245,360,410,480)).tobytes() for frame in gait}) == 8, "legs must visibly change in native renders"
|
|
gait[0].save(OUT/"gait-native.gif",save_all=True,append_images=gait[1:],duration=110,loop=0)
|
|
door = [Image.open(OUT/name).convert("RGB") for name in
|
|
("old-basement-closed.bmp","door-wheel-turn.bmp","door-swing-half.bmp","basement-door.bmp")]
|
|
assert len({frame.tobytes() for frame in door}) == 4, "unlocking, hinge motion and open passage must look different"
|
|
closing = [Image.open(OUT/name).convert("RGB") for name in
|
|
("door-closing.bmp","door-locking.bmp","door-closed-again.bmp")]
|
|
assert len({frame.tobytes() for frame in closing}) == 3, "closing, wheel locking and fully shut poses must differ"
|
|
lounge = Image.open(OUT / "upper-lounge.bmp").convert("RGB")
|
|
jazz = [Image.open(OUT / ("cabaret-jazz" + (f"-{i}" if i else "") + ".bmp")).convert("RGB")
|
|
for i in range(4)]
|
|
for region in ((110,150,300,290),(420,180,500,300)):
|
|
poses = [frame.crop(region).tobytes() for frame in jazz]
|
|
assert len(set(poses)) == 4, "piano and guitarist must both animate in the native scene"
|
|
vortex = [Image.open(OUT/f"portal-{i:02}.bmp").crop((245,145,395,320)).tobytes()
|
|
for i in range(12)]
|
|
assert len(set(vortex)) == 12,"all twelve vortex phases must be visible in native frames"
|
|
off = Image.open(OUT/"portal-disabled.bmp").crop((245,145,395,320)).tobytes()
|
|
assert off not in vortex, "disabled portal must visibly show the sealed arch"
|
|
pull = Image.open(OUT/"lever-mid-pull.bmp").crop((200,330,250,480)).tobytes()
|
|
release = Image.open(OUT/"lever-mid-release.bmp").crop((200,330,250,480)).tobytes()
|
|
assert pull != release, "left handle must visibly return while right remains enabled"
|
|
for name,expected in (("guest-cast-v1.bin",(128,224,36)),
|
|
("guest-walk-v2.bin",(128,224,48)),
|
|
("guest-walk-left-v2.bin",(128,224,48)),
|
|
("basement-door-v2.bin",(256,512,18)),
|
|
("cabaret-ensemble-v1.bin",(160,256,12)),
|
|
("cabaret-piano-v1.bin",(384,256,4)),
|
|
("cabaret-guitar-v1.bin",(160,256,4)),
|
|
("upper-materials-v1.bin",(256,256,4)),
|
|
("upper-games-v2.bin",(320,224,2)),
|
|
("upper-bar-v2.bin",(160,256,2)),
|
|
("upper-portal-v3.bin",(256,512,13)),
|
|
("upper-levers-v3.bin",(128,224,6)),
|
|
("upper-braziers-v3.bin",(128,256,4)),
|
|
("fortune-room-v3.bin",(256,256,24)),
|
|
("fortune-materials-v3.bin",(256,256,4))):
|
|
raw = (ROOT / "assets/animations/hall" / name).read_bytes()
|
|
magic,w,h,count,size = struct.unpack_from("<4s4I",raw)
|
|
assert magic == b"ANM1" and (w,h,count) == expected
|
|
assert size == w*h*count*4 == len(raw)-20
|
|
frames = [raw[20+i*w*h*4:20+(i+1)*w*h*4] for i in range(count)]
|
|
if name == "guest-cast-v1.bin":
|
|
assert len(set(frames[::6])) == 6, "all six guest appearances must differ"
|
|
if name == "guest-walk-v2.bin":
|
|
for appearance in range(6):
|
|
poses = [Image.frombytes("RGBA",(w,h),frame,"raw","BGRA")
|
|
for frame in frames[appearance*8:appearance*8+8]]
|
|
assert len({pose.crop((0,120,128,224)).tobytes() for pose in poses}) == 8
|
|
assert all(pose.getbbox()[3] == 224 for pose in poses), "supporting foot must touch baseline"
|
|
mirrored = (ROOT / "assets/animations/hall/guest-walk-left-v2.bin").read_bytes()[20:]
|
|
assert all(Image.frombytes("RGBA",(w,h),frames[i],"raw","BGRA").transpose(
|
|
Image.Transpose.FLIP_LEFT_RIGHT).tobytes("raw","BGRA") == mirrored[i*w*h*4:(i+1)*w*h*4] for i in range(48))
|
|
if name == "basement-door-v2.bin":
|
|
assert len(set(frames[:17])) == 17, "separate rotating handwheel phases"
|
|
assert all(frame[:200*256*4] == frames[0][:200*256*4] for frame in frames[:17]), "leaf stays rigid while unlocking"
|
|
if name.startswith(("upper-portal", "upper-levers", "upper-braziers", "upper-games")):
|
|
assert len(set(frames)) == count,"new animation phases and game tables must differ"
|
|
if name.startswith("upper-portal"):
|
|
base = Image.frombytes("RGBA",(w,h),frames[1],"raw","BGRA")
|
|
for frame in frames[2:]:
|
|
sprite = Image.frombytes("RGBA",(w,h),frame,"raw","BGRA")
|
|
for box in ((0,0,74,h),(183,0,w,h),(0,0,w,110),(0,482,w,h)):
|
|
assert sprite.crop(box).tobytes() == base.crop(box).tobytes(),"rigid arch must not wobble"
|
|
if name.startswith(("cabaret-piano", "cabaret-guitar")):
|
|
assert len(set(frames)) == 4, "four genuinely different playing poses"
|
|
for frame in frames:
|
|
sprite = Image.frombytes("RGBA", (w,h), frame, "raw", "BGRA")
|
|
x0,y0,x1,y1 = sprite.getbbox()
|
|
assert 0 < x0 < x1 < w and 0 < y0 < y1 < h, "uncropped musician cutout"
|
|
print("PASS: native stairs/doorways, automatic door, tickets, gait, portal, secret passage, bar camera and fortune dialogue")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|