KAZIK/tools/tests/check_hall_expansion.py
Artvasil09 797ee366dc feat: implement upper lounge, manual stairs, cabaret, and casino terminals
- Native player-controlled stairs, upper lounge bar, and Hell Tower infernal portal
- Cabaret stage with musical acts, audience seating, and interaction
- Dedicated cash/trophy wall terminals with native balance and achievement screens
- Projecting 3D wall lanterns with ray/plane intersection
- Six roaming guests with seated poses and card rank rendering updates
- Synchronized launcher hash and updated binaries
2026-10-18 21:36:12 +03:00

229 lines
13 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
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 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")
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"]),
("src_graphics/platform_code.inc",["CanWalk3D"]),
("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",["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")) + "\n.data\n" + npc
own_data = set(re.findall(r"^(\w+) (?:dd|db|AnimPack3D|CityProp3D)\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 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})
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")
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) == signature(shipping,symbol),name
print("PASS: all tested navigation, collision, hall/upper rendering, guests and cabaret procedures match the shipping assembly")
return
prepare()
stem = "module"
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=180)
if args.prove_alpha_regression:
assert result.returncode == 1,result.returncode
print("PASS: transparent felt/keyboard regression rejected by actual native assertions")
return
assert result.returncode == 0,f"native case {result.returncode}"
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"))
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"
for name,expected in (("guest-cast-v1.bin",(128,224,36)),
("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))):
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.startswith("guest"):
assert len(set(frames[::6])) == 6, "all six guest appearances must differ"
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 reversible stairs, all eight doorway views, foreground occlusion, original door/ticket positions, two-lever lock and twelve visible portal phases")
if __name__ == "__main__":
main()