- 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
687 lines
42 KiB
Python
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()
|