KAZIK/tools/tests/check_hall_expansion.py
Artvasil09 7e081beeca feat: enhance bowling physics, audio, ball weight selection, and visitor cast routines
- Bowling feel: impulse contact solver, string pinsetter cycle, in-world lengthwise return rack and visible ball return lift

- Ball weights: 4/5/6/7 kg selection with equal launch work physics, momentum balance, and projected picking

- Audio: dedicated MCI channels with 7 synthesized waveaudio cues (release, rolling, impacts, applause, pinsetter, return)

- First-person animations: 33-frame player underarm throw/follow-through sequence (player-underarm-v2)

- Visitors: 10 persistent shared cast identities with exclusive reservations, activity transitions, and chair sit/rise animations

- Tests & verification: comprehensive native regression test suites and updated documentation

- Synchronize binaries and update launcher hash to 536E23BED4055562BC63871826FF44FFED037923A9BE8E0F591131DB4FBD104B
2026-11-04 18:44:12 +03:00

687 lines
42 KiB
Python

"""Compile and execute fresh MASM navigation, actors and room renderers."""
from pathlib import Path
import argparse
import json
import re
import struct
import subprocess
from PIL import Image, ImageStat, ImageChops
from check_card_exit_build import Coff
from check_terminals import signature
from check_character_cast import check as check_character_cast
from check_artifacts import toolchain
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" + expand("src_graphics/fortune_dialog.inc")
code += "\n" + expand("src_graphics/rooftop.inc")
code += "\n" + expand("src_graphics/bowling.inc")
code += "\n" + read("src_graphics/character_cast.inc")
code += "\n" + read("src_graphics/npc_visits.inc")
code += "\n" + proc(read("src_graphics/scene_animation.inc"),"LoadAnimPack3D")
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","Present3D"]),
("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(expand(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","src_graphics/rooftop_data.inc",
"src_graphics/bowling_data.inc","src_graphics/character_cast_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)
data += "\n" + read("src_graphics/npc_visits_data.inc")
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",
"npcExtraActors3D":"NpcMindEntity"}
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+expand("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,"SetUnhandledExceptionFilter":1,
"CreateDirectoryA":2,"mciSendStringA":4})
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 check_rooftop_navigation():
for name,dimensions in (("panorama",(512,512,4)),("materials",(256,256,8)),
("details",(256,256,15)),("city",(512,512,22)),
("night",(512,512,4)),("rail-hands",(640,320,32))):
raw = (ROOT/f"assets/animations/rooftop/{name}-v1.bin").read_bytes()
magic,w,h,frames,payload = struct.unpack_from("<4s4I",raw)
assert magic == b"ANM1" and (w,h,frames) == dimensions
assert len(raw) == payload+20 and payload == w*h*frames*4
navigation = (OUT/"roof-navigation.bin").read_bytes()
width,height,ox,oy,step,count = struct.unpack_from("<6I",navigation)
assert count == 13
xs = struct.unpack_from(f"<{count}I",navigation,24)
ys = struct.unpack_from(f"<{count}I",navigation,24+count*4)
kinds = struct.unpack_from(f"<{count}I",navigation,24+count*8)
cells = navigation[24+count*12:]
assert len(cells) == width*height
def nav_point(x,y):
return round((x*65536-ox)/step),round((y*65536-oy)/step)
start = nav_point(24.5,2.5)
assert cells[start[1]*width+start[0]], "lift cabin must be walkable when open"
reached,pending = {start},[start]
while pending:
x,y = pending.pop()
for a,b in ((x-1,y),(x+1,y),(x,y-1),(x,y+1)):
if 0 <= a < width and 0 <= b < height and cells[b*width+a] and (a,b) not in reached:
reached.add((a,b))
pending.append((a,b))
assert len(reached) > 1500
for i,(sx,sy) in enumerate(zip(xs,ys)):
assert any((ox+x*step-sx)**2+(oy+y*step-sy)**2 < (1.8*65536)**2
for x,y in reached),(i,"seat has no reachable approach")
for point in ((15.5,6.75),(27,18),(13,17)):
assert nav_point(*point) in reached,(point,"blocked rooftop route")
assert list(kinds).count(0) == 3 and list(kinds).count(1) == 4
city = json.loads((ROOT/"assets/sprites/rooftop/district-map-v1.json").read_text())
assert city["street_level"] == -60
assert len(city["parks"]) >= 6 and len(city["towers"]) >= 8
assert len(city["buildings"]) > 400
waterfront = city["waterfront_buildings"]
assert len(waterfront) == 60
assert all(b in city["buildings"] for b in waterfront)
assert all(b[3] <= 54 or b[2] >= 82 for b in waterfront), "canal must stay open"
for bank in (0,1):
row = [b for b in waterfront if (b[3] < 60) == (bank == 0)]
assert len(row) == 30 and min(b[0] for b in row) < -450 and max(b[1] for b in row) >= 68
assert len(city["canal_quay_parts"]) > 100
heights = {round(b[4]+60,1) for b in city["buildings"]}
assert len(heights) >= 35 and min(heights) <= 6 and max(heights) >= 150
assert len({(round(b[1]-b[0],1),round(b[3]-b[2],1)) for b in city["buildings"]}) >= 15
assert {b[5] for b in city["buildings"]} >= {186,187,188,199,200,201,202,203,204}
for x0,x1,y0,y1,top,material,kind in city["buildings"]:
assert not (x0 < 29.1 and x1 > 10.9 and y0 < 21.1 and y1 > .9)
if max(abs((x0+x1)/2-20),abs((y0+y1)/2-11)) < 60:
assert top <= -19,"a neighbour must not block the sixty-metre-high terrace"
print(f"PASS: sixty-metre rooftop, {len(heights)} distinct city heights, streets/courtyards, parks, bridges and distant towers")
print(f"PASS: dense rooftop, {count} seats, {len(reached)} connected native walk points, lift/bar return and camera restore")
def check_bowling_room():
for name, count in (("materials",14),):
raw = (ROOT/f"assets/animations/bowling/{name}-v1.bin").read_bytes()
magic,w,h,frames,payload = struct.unpack_from("<4s4I",raw)
assert (magic,w,h,frames) == (b"ANM1",256,256,count)
assert len(raw) == payload+20 and payload == w*h*frames*4
layout = json.loads((ROOT/"assets/sprites/bowling/packed-v1.json").read_text())
def clear(x,y):
if not (1.2 <= x <= 30.8 and 1.2 <= y <= 30.8):
return False
if 2 <= x <= 17.3 and y < 22.3:
return False
if 26 <= y <= 26.6 and any(abs(x-(3.95+3.8*i)) <= .4 for i in range(4)):
return False
if 25.2 <= y <= 26.6 and any(.25 <= x-(3.95+3.8*i) <= .85 for i in range(4)):
return False
return not any(a <= x <= b and c <= y <= d for a,b,c,d in layout["collisions"])
start = (68,120)
reached,pending = {start},[start]
while pending:
x,y = pending.pop()
for a,b in ((x-1,y),(x+1,y),(x,y-1),(x,y+1)):
if (a,b) not in reached and clear(a/4,b/4):
reached.add((a,b))
pending.append((a,b))
for x,y in ((18,24),(27,6),(22,10.5),(27,16.5)):
assert (int(x*4),int(y*4)) in reached,(x,y,"disconnected room area")
for i in (0,1,2,3,9):
x,y,_ = layout["seats"][i]
assert any((a/4-x)**2+(b/4-y)**2 < 1.8**2 for a,b in reached),(i,"unreachable seat")
captures = []
for path in OUT.glob("bowling-*.bmp"):
im = Image.open(path).convert("RGB")
assert max(ImageStat.Stat(im).stddev) > 20,(path,"blank room capture")
im.save(path.with_suffix('.png'))
captures.append(path)
throws = [Image.open(OUT/f"bowling-throw-{i}.bmp").tobytes() for i in range(4)]
assert len(set(throws)) == 4,"whole-body throw must visibly progress"
assert len(captures) >= 10
for a,b,region in (
("direction","direction-left",(180,314,462,414)),
("power","power-low",(42,154,102,408)),
):
images = [Image.open(OUT/f"bowling-play-{name}.bmp").convert("RGB").crop(region) for name in (a,b)]
assert ImageChops.difference(*images).getbbox(), (a,b,"timing indicator must move")
print("PASS: playable bowling gates, collision solver, frame-rate invariance, remaining racks, bonus balls, opponent and score sheet")
sample_data = (OUT/'bowling-physics-samples.bin').read_bytes()
shots = list(struct.iter_unpack('<iiII', sample_data))
assert len(shots) == 63
assert all(mask < 1024 for _,_,mask,_ in shots)
assert len({mask for _,_,mask,_ in shots}) >= 4, 'Aim/power have no physical effect'
assert any(power==100 and mask==1023 for _,power,mask,_ in shots), 'No achievable physical strike'
assert max(m.bit_count() for _,p,m,_ in shots if p==0) < max(m.bit_count() for _,p,m,_ in shots if p==100)
for _,power,mask,_ in shots:
assert 0 <= mask.bit_count() <= 10, (power,mask)
print('Native pin-count ranges by power:', {p:(min(m.bit_count() for _,power,m,_ in shots if power==p),max(m.bit_count() for _,power,m,_ in shots if power==p)) for p in (0,50,100)})
hand = [Image.open(OUT/f'bowling-hand-{i:02}.bmp').convert('RGB') for i in range(33)]
assert len({im.crop((320,80,640,440)).tobytes() for im in hand}) >= 25
hand[0].save(OUT/'bowling-native-release.gif',save_all=True,append_images=hand[1:],duration=33,loop=0)
impacts = [Image.open(OUT/f'bowling-impact-{i}.bmp').convert('RGB') for i in range(4)]
assert len({im.tobytes() for im in impacts}) == 4
empty = Image.open(OUT/'bowling-shape-empty.bmp').convert('RGB')
tiny = Image.open(OUT/'bowling-shape-tiny.bmp').convert('RGB')
bounds = ImageChops.difference(empty,tiny).getbbox()
assert bounds and 8 <= bounds[2]-bounds[0] <= 120 and 8 <= bounds[3]-bounds[1] <= 120, bounds
setter = [Image.open(OUT/f'bowling-pinsetter-{i}.bmp').convert('RGB') for i in range(8)]
# A closed guard correctly hides intermediate hoist motion.
assert len({im.tobytes() for im in setter}) >= 6
setter[0].save(OUT/'bowling-native-pinsetter.gif',save_all=True,append_images=setter[1:],duration=320,loop=0)
print('PASS: weak rocking/strong toppling, separating contacts, fallen-pin impulses, late settlement, '
'stable unit axes and compact silhouettes after tiny impacts, per-step travel limits, '
'guard/hoist/respots, first-ball miss without cycling, no duplicate scoring, '
'full X/spare/miss scorecard, 33 native release frames and seven muted MCI channels/cleanup')
returned = [Image.open(OUT/f'bowling-return-{i}.bmp').convert('RGB') for i in range(8)]
assert len({im.tobytes() for im in returned}) == 8
returned[0].save(OUT/'bowling-native-return.gif',save_all=True,append_images=returned[1:],
duration=[400,500,100,300,400,500,600,250],loop=0)
print('PASS: visible return lift/rails, camera turn on either lane side, input/score invariants, '
'pause/resume, final-ball return, return-tray collision and exit restoration')
mass = list(struct.iter_unpack('<8i',(OUT/'bowling-mass-samples.bin').read_bytes()))
assert len(mass) == 12
for kg,power,speed,inv,incoming,ball,pin,locked in mass:
assert kg == locked and 4 <= kg <= 7
assert abs(inv/65536 - 1.6/kg) < .00002
work = kg*(speed/65536)**2/2
expected_work = 3*(3+6*power/100)**2
assert abs(work/expected_work-1) < .0001,(kg,power,work)
assert abs((-incoming*1000/16)/speed-1) < .0003
before = kg*incoming
after = kg*ball+1.6*pin
assert abs((after-before)/before) < .001,(kg,before,after)
assert abs((ball-pin)/-incoming-.5) < .002,(kg,ball,pin)
assert kg*ball**2+1.6*pin**2 <= kg*incoming**2
assert abs(pin/incoming - 1.5*kg/(kg+1.6)) < .002
for power in (0,50,100):
group = [s for s in mass if s[1] == power]
assert all(a[2] > b[2] for a,b in zip(group,group[1:]))
assert all(abs(a[5]/a[4]) < abs(b[5]/b[4]) for a,b in zip(group,group[1:]))
print('PASS: 4/5/6/7 kg selection, equal-work launch energy, momentum/restitution checks, '
'no impact energy gain, immutable shot mass and independent bot weight')
held = {kg:Image.open(OUT/f'bowling-weight-{kg}.bmp').convert('RGB') for kg in (4,5,7)}
assert len({im.crop((470,310,575,414)).tobytes() for im in held.values()}) == 3
rack = Image.open(OUT/'bowling-weight-rack.bmp').convert('RGB')
pair = Image.open(OUT/'bowling-weight-rack-pair.bmp').convert('RGB')
assert rack.tobytes() != pair.tobytes(), 'Rack selection must use each pair in the actual room'
print('PASS: in-world lengthwise ball rack, projected sphere/plate picking on both pairs, '
'non-overlapping labels, native colour/alpha checks, unchanged skin/sleeve and storage-rail collision')
mass_shots = list(struct.iter_unpack('<iiI',(OUT/'bowling-mass-shots.bin').read_bytes()))
assert len(mass_shots) == 84 and all(mask < 1024 for _,_,mask in mass_shots)
for kg in (4,5,6,7):
masks = [mask for k,_,mask in mass_shots if k == kg]
assert len(masks) == 21 and len(set(masks)) >= 4
print('Native high-power pin-count ranges by kg:',
{kg:(min(mask.bit_count() for k,_,mask in mass_shots if k==kg),
max(mask.bit_count() for k,_,mask in mass_shots if k==kg)) for kg in (4,5,6,7)})
print(f"PASS: eight bowling lanes, {len(reached)} connected walk points, reachable bar/dining/seats, native throw and room captures")
def check_npc_visits():
records = list(struct.iter_unpack('<8I', (OUT/'npc-visits.bin').read_bytes()))
assert len(records) == 10 and len({r[2] for r in records}) == 10
seen = 0
for slot,identity,cast,activities,places,walking,journeys,chips in records:
assert slot < 10 and identity < 12 and cast < 10
assert walking >= 50 and journeys >= 3 and places.bit_count() >= 2, records
assert chips < 100000
seen |= activities
assert seen & 0x1FE == 0x1FE, hex(seen)
captures = []
for kind in ('order','drink','seat','win','loss'):
picture = Image.open(OUT/f'npc-visit-{kind}.bmp').convert('RGB')
assert picture.size == (640,480) and max(ImageStat.Stat(picture).stddev) > 20
picture.save(OUT/f'npc-visit-{kind}.png')
captures.append(picture)
assert captures[0].tobytes() != captures[1].tobytes()
assert captures[3].tobytes() != captures[4].tobytes()
for action,count in (('sit',10),('rise',9)):
frames = [Image.open(OUT/f'npc-chair-{action}-{index}.bmp').convert('RGB')
for index in range(count)]
assert all(frame.size == (640,480) for frame in frames)
assert len({frame.tobytes() for frame in frames}) >= 8, action
frames[0].save(OUT/f'npc-chair-{action}.gif',save_all=True,
append_images=frames[1:],duration=100,loop=0)
print('PASS: ten stable visitor identities, 480 seconds of private schedules, exclusive '
'stations, game/bar/rest/chat transitions, grounded chair poses, pause, '
'unchanged player bankroll/RNG, real chair approach and sit/rise sequences')
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--parity",action="store_true")
parser.add_argument("--bowling-only",action="store_true")
parser.add_argument("--npc-only",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")
parser.add_argument("--prove-fate-anchor-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()
walk = read("casino3d.inc").split("check_walk_mode:",1)[1].split("check_slots_mode:",1)[0]
assert walk.index("je walk_world_ready") < walk.index("call RenderCeilingAndFloor")
assert walk.index("walk_world_ready:") > walk.index("call RenderSprites")
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.bowling_only:
stem = "module-bowling"
source = (OUT/"module.asm").read_text(encoding="cp1251")
source = source.replace("include tools/tests/hall_expansion_test.inc",
expand("tools/tests/hall_expansion_test.inc"))
source = source.replace(" call LoadHallAssets3D\n cmp characterCast3D.pixels,0",
" call LoadHallAssets3D\n call BowlingNativeTests\n invoke ExitProcess,0\n cmp characterCast3D.pixels,0")
(OUT/(stem+".asm")).write_text(source,encoding="cp1251")
if args.npc_only:
stem = "module-npc"
source = (OUT/"module.asm").read_text(encoding="cp1251")
source = source.replace("include tools/tests/hall_expansion_test.inc",
expand("tools/tests/hall_expansion_test.inc"))
source = source.replace(" call LoadHallAssets3D\n cmp characterCast3D.pixels,0",
" call LoadHallAssets3D\n call NpcVisitsNativeTests\n invoke ExitProcess,0\n cmp characterCast3D.pixels,0")
(OUT/(stem+".asm")).write_text(source,encoding="cp1251")
if args.prove_fate_anchor_regression:
stem = "module-fate-anchor-regression"
source = (OUT/"module.asm").read_text(encoding="cp1251")
before = proc(source,"ResolveFortuneOrb3D")
broken = before.replace("mov fortuneOrbCenterX3D,eax", "mov fortuneOrbCenterX3D,278")
broken = broken.replace("mov fortuneOrbCenterY3D,eax", "mov fortuneOrbCenterY3D,259")
assert broken != before
source = source.replace(before,broken)
source = source.replace("include tools/tests/hall_expansion_test.inc",
expand("tools/tests/hall_expansion_test.inc"))
source = source.replace("build\\hall-expansion\\", "build\\hall-expansion\\anchor-regression-")
(OUT/(stem+".asm")).write_text(source,encoding="cp1251")
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")
modern_ml = Path("C:/Program Files/Microsoft Visual Studio/18/Community/VC/Tools/MSVC/14.51.36231/bin/Hostx64/x86/ml.exe")
assembler, linker = toolchain()
subprocess.run([str(assembler),"/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(linker),"/nologo","/SUBSYSTEM:CONSOLE","/ENTRY:ExpansionTestStart@0",
f"/MAP:build/hall-expansion/{stem}.map",
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.npc_only:
assert result.returncode == 0,result.returncode
check_npc_visits()
return
if args.bowling_only:
assert result.returncode == 0,result.returncode
check_bowling_room()
check_character_cast()
print("PASS: native bowling entrance, collisions, seating, scoring, lane lifecycles and captured room/throw frames")
return
if args.prove_fate_anchor_regression:
assert result.returncode == 81,result.returncode
print("PASS: restoring fixed screen coordinates fails the native crystal-ball tracking assertions")
return
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}"
if not args.prove_seam_regression:
check_rooftop_navigation()
check_bowling_room()
check_npc_visits()
check_character_cast()
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)
fate_motion = [Image.open(OUT/f"fortune-fate-motion-{i:02}.bmp").convert("RGB")
for i in range(32)]
assert len({im.crop((134,32,414,323)).tobytes() for im in fate_motion}) >= 24
fate_motion[0].save(OUT/"fortune-fate-mist.gif", save_all=True,
append_images=fate_motion[1:], duration=32, loop=0)
fate_result = Image.open(OUT/"fortune-fate-result.bmp").convert("RGB")
fate_form = Image.open(OUT/"fortune-fate-form.bmp").convert("RGB")
assert fate_form.crop((16,298,624,446)).tobytes() != fate_result.crop((16,298,624,446)).tobytes()
# Difference captures isolate the actual numeral, independently of fog highlights.
tracking = (ROOT/"assets/sprites/fortune/orb-tracking-v1.json").read_text()
anchors = json.loads(tracking)["frames"]
tracked = []
for i, anchor in enumerate(anchors):
result = Image.open(OUT/f"fortune-fate-track-{i:02}.bmp").convert("RGB")
base = Image.open(OUT/f"fortune-fate-base-{i:02}.bmp").convert("RGB")
box = ImageChops.difference(result, base).getbbox()
assert box, (i, "no numeral rendered")
cx = 214 + int(anchor["cx"]*128/192 + .5)
cy = 195 + int(anchor["cy"]*128/192 + .5)
assert abs((box[0]+box[2])/2-cx) <= 2.5, (i, box, cx)
assert abs((box[1]+box[3])/2-cy) <= 2.5, (i, box, cy)
assert cx-42 <= box[0] < box[2] <= cx+42
assert cy-28 <= box[1] < box[3] <= cy+28
tracked.append(result)
assert max(a["cx"] for a in anchors)-min(a["cx"] for a in anchors) > 8
assert max(a["cy"] for a in anchors)-min(a["cy"] for a in anchors) > 8
tracked[0].save(OUT/"fortune-fate-tracked.gif", save_all=True,
append_images=tracked[1:], duration=64, loop=0)
numerals = (ROOT/"assets/animations/fortune/orb-numerals-v1.bin").read_bytes()
assert struct.unpack_from("<4s4I", numerals) == (b"ANM1", 32, 48, 10, 32*48*10*4)
assert len(set(numerals[23::4])) > 50, "font must retain soft edges and glow alpha"
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")
expected = (1280,960) if path.name.startswith("roof-hd-") else (640,480)
assert image.size == expected and len(image.getcolors(image.width*image.height)) > 100
image.save(path.with_suffix(".png"))
hd_day = Image.open(OUT/"roof-hd-south.bmp").convert("RGB")
hd_night = Image.open(OUT/"roof-hd-night.bmp").convert("RGB")
assert ImageChops.difference(hd_day,hd_night).getbbox(), "day/night must change actual native geometry lighting"
white_ground = sum(1 for rgb in hd_night.crop((0,650,1280,960)).get_flattened_data() if min(rgb) > 235)
assert white_ground < 500, "uninitialised emission textures must not turn ground white"
traffic = Image.open(OUT/"roof-hd-traffic.bmp").convert("RGB")
east = Image.open(OUT/"roof-hd-east.bmp").convert("RGB")
assert ImageChops.difference(east,traffic).getbbox(), "native clock must move city vehicles"
left = Image.open(OUT/"roof-hd-look-left.bmp").convert("RGB")
right = Image.open(OUT/"roof-hd-look-right.bmp").convert("RGB")
assert left.tobytes() != east.tobytes() != right.tobytes(), "leaning must allow actual head turns"
mesh = json.loads((ROOT/"assets/sprites/rooftop/mesh-v1.json").read_text())
assert mesh["triangles"]["scene"] < 48000, "driver-safe ground must stay within the optimised mesh budget"
assert mesh["removed_invisible_triangles"] > 18000
packed_mesh = (ROOT/"assets/animations/rooftop/mesh-city-v1.bin").read_bytes()
water_tiles = 0
for at in range(12,len(packed_mesh),100):
mat,*values = struct.unpack_from("<I24f",packed_mesh,at)
if mat == 198:
water_tiles += 1
if mat not in (162,197,198,208,209):
continue
vertices = [values[i:i+8] for i in (0,8,16)]
if not all(-62.1 <= v[1] <= -59.7 and v[4] > .99 for v in vertices):
continue
xs,zs = [v[0] for v in vertices],[v[2] for v in vertices]
if max(xs) > -480 and min(xs) < 480 and max(zs) > -480 and min(zs) < 480:
assert max(xs)-min(xs) <= 64.01 and max(zs)-min(zs) <= 64.01, "giant vertex-fog ground polygon"
assert water_tiles >= 80, "animated water must reuse the tessellated scene mesh"
fast_fog = Image.open(OUT/"roof-hd-vertex-fog.bmp").convert("RGB")
nice_fog = Image.open(OUT/"roof-hd-canal-quays.bmp").convert("RGB")
assert max(ImageStat.Stat(ImageChops.difference(fast_fog,nice_fog)).mean) < 20
for profile in ("eco", "auto"):
low = Image.open(OUT/f"roof-hd-{profile}.bmp").convert("RGB")
difference = ImageStat.Stat(ImageChops.difference(low, east)).mean
assert max(difference) < 12, (profile, "quality must not remove scene geometry", difference)
roads = mesh["road_rectangles"]
for i,(a,b,c,d) in enumerate(roads):
for x0,x1,y0,y1 in roads[i+1:]:
assert min(b,x1)-max(a,x0) < 1e-6 or min(d,y1)-max(c,y0) < 1e-6, "coplanar street overlap"
millis,frames,cpu,gpu,present = struct.unpack("<5I",(OUT/"roof-hd-timing.bin").read_bytes())
print(f"Roof passes ms/frame: CPU {cpu/frames:.2f}, GPU/readback {gpu/frames:.2f}, composite {present/frames:.2f}")
print(f"PASS: non-overlapping roads, head turns with anchored hands, final offscreen composite; {millis/frames:.2f} ms/frame in 24-frame native roof probe")
print("PASS: native GL city at 640/960/1280, auto downgrade and F8, direct foreground composite")
print("PASS: sunset/night, animated traffic, software roads/water even with missing city textures")
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 (("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 == "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()