- Secret Fortune Teller Room: arcade lantern toggle, sliding timber panel, dedicated staircase and compact cellar chamber - Lived-in den pass v3: cloth hangings, draped workbench, props/clutter, centered entrance, and custom light sources - Bar camera stabilization: remove alcohol horizon sway while retaining drink animations - Synchronize binaries and update launcher hash to EADBA9C8C9BD5B7E3CC4CE486ED647C1CE9383D1DCB6EBC94FE755EA1F4BF927
308 lines
19 KiB
Python
308 lines
19 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 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("<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")
|
|
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()
|