KAZIK/tools/tests/check_observer.py
Artvasil09 23dc40ee5c feat: implement native darts mini-game, AI observer mode, and fortune session assets
- Darts: native 500-point match against Max with sway/fatigue mouse aim, parabolic throw camera, raster arm animations, and 16 camera-relative flying dart views

- AI Observer: autonomous spectator mode for Hold'em, Blackjack, Durak, and Billiards with profile isolation and playback controls

- Fortune Session: raster sprite packs, mist/orb animations, tarot divination spreads, and pack tools

- Hall Integration: wall dartboard in lounge, observer terminal dispatch, and bot turn hooks

- Packaging & Verification: updated installer streaming hash, synchronized launcher, passing check suites and release smoke checks
2026-10-24 22:10:25 +03:00

148 lines
8 KiB
Python

"""Compile fresh native spectator/game code and run autonomous matches, not mock results."""
from pathlib import Path
import argparse
import hashlib
import re
import struct
import subprocess
from PIL import Image
from check_card_exit_build import Coff
from check_terminals import signature
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "build/observer"
def read(path):
raw = (ROOT / path).read_bytes()
try:
return raw.decode("utf8").replace("\r\n", "\n")
except UnicodeDecodeError:
return raw.decode("cp1251").replace("\r\n", "\n")
def proc(text, name):
return re.search(rf"^{name} PROC\b.*?^{name} ENDP", text, 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(parents=True, exist_ok=True)
code = "\n".join(expand("src_graphics/" + name + ".inc") for name in
("observer", "native_poker", "native_durak", "native_blackjack", "pyramid_play", "eightball",
"card_tables_render", "durak_render"))
for path, names in (("platform_code", ("SaveProgress3D",)),
("npc_contracts", ("NpcRecordRound3D",)),
("charms", ("TriggerCharmLossProtection",)),
("card_table_input", ("HandleCardExit3D", "RequestCardExit3D", "LeaveCardTable3D", "TableBusy3D"))):
code += "\n" + "\n".join(proc(read("src_graphics/" + path + ".inc"), name) for name in names)
code += "\n" + proc(read("tools/tests/casino_actions_test.inc"), "WriteActionSnapshot3D")
code += "\n" + read("tools/tests/durak_test.inc")
code += "\nPlayCasinoSound3D PROC arg0:DWORD,arg1:DWORD,arg2:DWORD\n ret\nPlayCasinoSound3D ENDP\n"
# Stop before any persistence side effect even if a tested observer barrier regresses.
for name in ("RefreshTerminalAchievements3D", "NpcEnsureJournal3D"):
code += f"\n{name} PROC\n invoke ExitProcess,8402\n ret\n{name} ENDP\n"
code += "\n" + read("tools/tests/observer_test.inc")
code = code.replace(":SDWORD", ":DWORD")
data = read("src_graphics/observer_data.inc")
data += "\n.data\nactionBmpHeader db 42h,4Dh\n dd 1228854\n dw 0,0\n dd 54,40,640,-480\n dw 1,32\n dd 0,1228800,0,0,0,0\n"
own_funcs = set(re.findall(r"^(\w+) PROC", code, re.M))
own_data = set(re.findall(r"^(\w+)\s+(?:db|dd)\b", data + "\n" + code, re.M))
source = "\n".join(read(str(p.relative_to(ROOT))) for p in
[ROOT / "poker.asm", ROOT / "casino3d.inc", ROOT / "casino3d_data.inc",
*sorted(p for p in (ROOT / "src_graphics").glob("*.inc") if p.name != "renderer.inc")])
structures = [re.search(rf"^{name} STRUCT\b.*?^{name} ENDS", source, re.M | re.S).group()
for name in ("AnimPack3D", "BJBot3D")]
types = {name: kind for name, kind in re.findall(r"^(\w+)\s+(AnimPack3D|BJBot3D)\b", source, re.M)}
types.update({name: {"db": "BYTE", "dw": "WORD", "dd": "DWORD"}[kind.lower()]
for name, kind in re.findall(r"^(\w+)\s+(db|dw|dd)\b", source, re.M | re.I)})
types.update({name: kind.upper() for name, kind in
re.findall(r"^(\w+)\s+(REAL4|REAL8)\b", source, re.M | re.I)})
coff = Coff(ROOT / "build/graphics/poker.obj")
helper = bytearray(coff.data)
symbol_at = struct.unpack_from("<I", helper, 8)[0]
externs, protos = [], {}
for index, name in coff.symbols.items():
pos = symbol_at + index * 18
_, section, kind, storage, _ = struct.unpack_from("<IhHBB", helper, pos + 8)
if section > 0 and re.fullmatch(r"[A-Za-z_]\w*", name) and storage in (2, 3):
secname = helper[20+(section-1)*40:28+(section-1)*40].rstrip(b"\0")
if secname in (b".data", b".bss", b".const"):
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 section > 0:
helper[pos+16] = 3 if match[1] in own_funcs else 2
if match[1] not in own_funcs:
protos[match[1]] = int(match[2]) // 4
for name, params in re.findall(r"^(\w+)\s+PROTO([^\n]*)", source, re.M):
protos.setdefault(name, params.count(":DWORD"))
for name in own_funcs:
protos[name] = len(re.findall(r":S?DWORD", proc(code, name).splitlines()[0]))
constants = dict(re.findall(r"^(\w+)\s+equ\s+([^;\n]+)", source, re.M))
used = set(re.findall(r"^(\w+) equ", data, re.M))
ordered = []
def constant(name):
if name not in constants or name in used:
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):
constant(name)
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"]*count) if count else "") for name, count in protos.items())
data = re.sub(r"^\w+ PROTO[^\n]*\n", "", data, flags=re.M)
(OUT / "helpers.obj").write_bytes(helper)
(OUT / "module.asm").write_text(header + "\n" + data + "\n.code\n" + code + "\nend ObserverTestStart\n", encoding="cp1251")
return {name for name in own_funcs if not name.startswith("DurakTest") and name != "RunDurakTest3D"} - {
"PlayCasinoSound3D", "WriteActionSnapshot3D", "ObserverTestStart",
"RefreshTerminalAchievements3D", "NpcEnsureJournal3D"}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--parity", action="store_true")
args = parser.parse_args()
functions = prepare()
if args.parity:
tested, shipping = Coff(OUT / "module.obj"), Coff(ROOT / "build/graphics/poker.obj")
for name in functions:
symbol = next(key for key in tested.functions if key.startswith("_" + name + "@"))
assert signature(tested, symbol, tail_branches=True) == signature(shipping, symbol, tail_branches=True), name
print("PASS: native observer and game code match the shipping object")
return
masm = Path("F:/MASM32/masm32")
subprocess.run([str(masm / "bin/ml.exe"), "/c", "/coff", "/Cp", "/nologo", "/I.",
"/Fobuild/observer/module.obj", "build/observer/module.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:ObserverTestStart@0",
f"/LIBPATH:{sdk}", f"/LIBPATH:{masm/'lib'}", "/OUT:build/observer/module.exe",
"build/observer/module.obj", "build/observer/helpers.obj", "kernel32.lib", "user32.lib",
"gdi32.lib", "winmm.lib", "ws2_32.lib"], cwd=ROOT, check=True)
def profile():
return {str(p): hashlib.sha256(p.read_bytes()).digest() for p in (ROOT / "runtime").rglob("*")
if p.is_file()}
before = profile()
run = subprocess.run([str(OUT / "module.exe")], cwd=ROOT, timeout=180)
assert run.returncode == 0, f"Native observer assertion: {run.returncode}"
assert profile() == before, "Observation changed persistent profile files"
sheet = Image.new("RGB", (1280, 480*4))
for i, name in enumerate(("menu", "poker", "blackjack", "durak", "transfer", "pyramid", "eight")):
frame = Image.open(OUT / (name + ".bmp")).convert("RGB")
assert len(frame.getcolors(640*480)) >= (4 if name == "menu" else 500), name
frame.save(OUT / (name + ".png"))
sheet.paste(frame, ((i % 2)*640, (i // 2)*480))
sheet.save(OUT / "contact.png")
print("PASS: 80 autonomous card matches, both billiard seats, pause, profile restore and no persistent writes")
if __name__ == "__main__":
main()