- 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
229 lines
13 KiB
Python
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()
|