- Billiards: add native Russian Pyramid & American Eight-Ball pool with physics prediction, controls, and regression tests - Scratch Tickets: add dedicated counter, mat presentation, elliptical coin scraping, 4 ticket types, and payout effects - First-Person Dice: add native dice scene, hand anchor frames, throwing animation, and table perspective - City Wake & Weather: add arrival cutscene, alley geometry, weather/rain effects, and lighter/rocker animation - World Dressing: add hall paintings, furniture packs, hall lighting, and scene regression tests - Build & Tooling: update build.bat for modern MSVC toolchain, update launcher hash, and refresh shipping binaries
100 lines
4.9 KiB
Python
100 lines
4.9 KiB
Python
"""Check grounded waking assets, native dialogue captures and shipping parity."""
|
|
import argparse
|
|
import struct
|
|
from collections import deque
|
|
from pathlib import Path
|
|
from PIL import Image
|
|
from check_card_exit_build import Coff
|
|
from check_coin_detail import font, text_matches
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
OUT = ROOT / "build/actions"
|
|
FUNCTIONS = {"LoadCityArrival3D": 0, "HandleCityApproach3D": 0,
|
|
"PositionCityCamera3D": 0, "RenderCityArrival3D": 0,
|
|
"DrawCityWakeActor3D": 0, "DrawCitySeatedLegs3D": 0,
|
|
"DrawCityLighter3D": 0, "DrawCityWakeSubtitle3D": 0,
|
|
"BlendCitySky3D": 4, "BlendCityWeatherPixel3D": 16,
|
|
"DrawCityRain3D": 0, "DrawCitySmokingHand3D": 0,
|
|
"DrawCityExhaledSmoke3D": 0, "DrawCityCigarette3D": 16,
|
|
"BeginArrival3D": 0, "HandleArrival3D": 0, "ArrivalEscape3D": 0,
|
|
"AnimateCityCars3D": 0, "FreeArrivalAssets3D": 0,
|
|
"RenderCityAlley3D": 0, "ResolveCitySurface3D": 4,
|
|
"DrawCityVertical3D": 28, "DrawCityHorizontal3D": 24}
|
|
|
|
|
|
def check_sleeve():
|
|
root = ROOT / "assets/animations/arrival"
|
|
raw = (root / "wake-sleeve-v1.bin").read_bytes()
|
|
assert struct.unpack_from("<4I",raw,4) == (256,384,1,256*384*4)
|
|
sleeve = Image.frombytes("RGBA",(256,384),raw[20:],"raw","BGRA").getchannel("A")
|
|
assert set(sleeve.tobytes()) == {0,255}
|
|
assert all(sleeve.crop((0,y,256,y+1)).getbbox() for y in range(384))
|
|
anchors = (ROOT / "src_graphics/city_lighter_anchors.inc").read_text()
|
|
xs, ys = [[int(v) for v in line.split("dd ")[1].split(",")] for line in anchors.splitlines()]
|
|
lighter = (root / "wake-lighter-v1.bin").read_bytes()
|
|
for i in range(16):
|
|
hand = Image.frombytes("RGBA",(256,256),lighter[20+i*262144:20+(i+1)*262144],"raw","BGRA").getchannel("A")
|
|
mask = Image.new("L",(640,480))
|
|
arm = sleeve.resize((220,330),Image.Resampling.NEAREST)
|
|
mask.paste(arm,(350+xs[i]*220//256-110,223+ys[i]*220//256-20))
|
|
hand = hand.resize((220,220),Image.Resampling.NEAREST)
|
|
mask.paste(255,(350,223),hand)
|
|
# Start inside the cuff: its connected silhouette must leave the viewport.
|
|
x,y = 350+xs[i]*220//256,223+ys[i]*220//256-4
|
|
queue = deque([(x,y)])
|
|
seen = set()
|
|
reached = False
|
|
while queue:
|
|
x,y = queue.popleft()
|
|
if (x,y) in seen or not (0 <= x < 640 and 0 <= y < 480) or mask.getpixel((x,y)) < 128:
|
|
continue
|
|
seen.add((x,y))
|
|
if y == 479:
|
|
reached = True
|
|
break
|
|
queue.extend(((x-1,y),(x+1,y),(x,y-1),(x,y+1)))
|
|
assert reached, f"Detached lighter cuff in frame {i}"
|
|
|
|
|
|
def main(parity):
|
|
check_sleeve()
|
|
raw = (ROOT / "assets/animations/arrival/wake-rocker-v2.bin").read_bytes()
|
|
w, h, count, length = struct.unpack_from("<4I", raw, 4)
|
|
assert (w, h, count, length) == (192,320,32,192*320*4*32)
|
|
boxes = []
|
|
for i in range(count):
|
|
frame = Image.frombytes("RGBA", (w,h), raw[20+i*w*h*4:20+(i+1)*w*h*4], "raw", "BGRA")
|
|
box = frame.getbbox()
|
|
assert box and box[3] == 320, (i, box)
|
|
assert set(frame.getchannel("A").tobytes()) == {0,255}
|
|
boxes.append(box)
|
|
assert max(b[3]-b[1] for b in boxes[:16]) < min(b[3]-b[1] for b in boxes[19:24])
|
|
walking = [b[3]-b[1] for b in boxes[24:]]
|
|
assert max(walking)-min(walking) <= 8, "Walking sprite shrinks between frames"
|
|
assert abs(max(walking)-(boxes[19][3]-boxes[19][1])) <= 8
|
|
wake = Image.open(OUT / "city-wake-rocker.bmp").convert("RGB")
|
|
text_matches(wake, "Вставай, самурай,",184,422,2,(230,222,210),font())
|
|
text_matches(wake, "пора дэпнуть все в казино",120,450,2,(230,222,210),font())
|
|
for name in ("city-wake-rocker", "city-rocker-back", "city-light-cigarette", "city-standing", "city-look-right", "city-upward"):
|
|
frame = Image.open(OUT / (name + ".bmp")).convert("RGB")
|
|
assert frame.size == (640,480)
|
|
assert len(frame.getcolors(640*480)) > 500
|
|
frame.save(OUT / (name + ".png"))
|
|
if parity:
|
|
tested = Coff(OUT / "actions-test.obj")
|
|
shipped = Coff(ROOT / "build/graphics/poker.obj")
|
|
for name, size in FUNCTIONS.items():
|
|
symbol = f"_{name}@{size}"
|
|
assert tested.signature(symbol) == shipped.signature(symbol), name
|
|
back = Image.open(OUT / "city-rocker-back.bmp").convert("RGB")
|
|
patch = back.crop((250,30,380,180))
|
|
pixels = (patch.getpixel((x,y)) for y in range(patch.height) for x in range(patch.width))
|
|
assert sum(r > 100 and r > g*1.5 and r > b*1.5 for r,g,b in pixels) > 60, "Back emblem absent"
|
|
print("PASS: thirty-two grounded poses, back emblem, crouch/standing scale, Russian subtitle, native scenes" +
|
|
(" and shipping/tested cinematic parity" if parity else ""))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
p = argparse.ArgumentParser()
|
|
p.add_argument("--parity",action="store_true")
|
|
main(p.parse_args().parity)
|