"""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 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" + proc(read("src_graphics/unavailable.inc"),"BeginUnavailable3D") 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"]), ("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")) + "\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(" 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") 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=180) 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((104,176,536,336)) assert len(modal.getcolors(432*160)) > 3, "native unavailable message must be drawn" 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 and doorway views, automatic door, original ticket position, distance-driven gait, reversible portal, secret lantern passage, manual cellar stairs and fortune teller unavailable dialogue") if __name__ == "__main__": main()