948 lines
34 KiB
Python
Executable File
948 lines
34 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""lamatest - unattended test harness for "Ein Koenigreich fuer ein Lama".
|
|
|
|
Starts groove.exe (Wine) invisibly on Xvfb :5 with a null audio sink, loads the
|
|
TestLevel (world 9, chapter 1) via save slot 3 and takes screenshots.
|
|
|
|
lamatest.py start Xvfb + sink + game, waits for the window
|
|
lamatest.py shot out.png screenshot (1920x1080) of :5
|
|
lamatest.py key <key> [hold] hold a key (xdotool name, default 0.12 s)
|
|
lamatest.py keys <k1> <k2> ... several keys one after another (k[:hold])
|
|
lamatest.py seq <outdir> "TOKENS" key sequence like run --seq in the running game (shot:/fp:/rj: ...)
|
|
lamatest.py tolevel demo -> main menu -> slot 3 -> TestLevel 1
|
|
lamatest.py tour <outdir> [n] all-round photos: turn/walk/camera
|
|
lamatest.py status anything running? window? processes?
|
|
lamatest.py stop quit the game, unload the sink, kill Xvfb
|
|
lamatest.py run <wad> <outdir> [n] install WAD as T1L9M001.wad, start,
|
|
tolevel, tour, crash detection, save the log
|
|
lamatest.py restore-wad restore the original T1L9M001.wad (.orig)
|
|
run options: --seq "Up:1.5 x shot:name sleep=1 ..." a key sequence instead of the tour;
|
|
shot:NAME = photo NAME.jpg, fp:NAME = photo in llama view (Return held),
|
|
dialog = click dialog away, keys as with 'keys' (key[:hold time]),
|
|
Up+x:0.8 = keys simultaneously, rj:T1:T2 = running jump (hold Up,
|
|
X after T1 s, then Up for another T2 s),
|
|
run:T:x@1.3,shot@5,Left@3-3.4 = hold Up for T s, X/photo/key on a schedule
|
|
(run/Left:T:... holds a different key)
|
|
--tour run the standard tour in addition to --seq
|
|
--keep leave the tested WAD installed (otherwise the previously
|
|
installed T1L9M001.wad is restored - not .orig!)
|
|
|
|
State/logs: $LAMATEST_DIR (default /tmp/lamatest-$USER).
|
|
Report lines (report.txt, "ergebnis: ...") and `status` output stay German: helper
|
|
scripts grep for them. OCR expectations are German game text.
|
|
Docs: ../notes/testharness.md
|
|
"""
|
|
import hashlib
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
HOME = os.path.expanduser("~")
|
|
DISPLAY = ":5"
|
|
SINK = "lama_stumm"
|
|
PREFIX = os.path.join(HOME, ".wine-lama")
|
|
GAMEDIR = os.path.join(PREFIX, "drive_c", "Program Files", "Disney Interactive",
|
|
"Ein Koenigreich fuer ein Lama")
|
|
GAMEEXE = r"C:\Program Files\Disney Interactive\Ein Koenigreich fuer ein Lama\groove.exe"
|
|
MODWAD = os.path.join(PREFIX, "mods", "testlevel", "wads", "T1L9M001.wad")
|
|
OVERLAY = os.path.join(PREFIX, "cd-overlay")
|
|
LAMA_MOD = os.path.join(HOME, ".local", "bin", "lama-mod")
|
|
STATE = os.environ.get("LAMATEST_DIR", "/tmp/lamatest-%s" % os.environ.get("USER", "x"))
|
|
WINELOG = os.path.join(STATE, "wine.log")
|
|
XVFBLOG = os.path.join(STATE, "xvfb.log")
|
|
SAVEBAK = os.path.join(STATE, "ksv-backup")
|
|
|
|
ENV = dict(os.environ, DISPLAY=DISPLAY, PULSE_SINK=SINK, WINEPREFIX=PREFIX,
|
|
WINEDEBUG=os.environ.get("WINEDEBUG", "fixme-all"))
|
|
|
|
|
|
def log(*a):
|
|
print("[lamatest %s]" % time.strftime("%H:%M:%S"), *a, file=sys.stderr, flush=True)
|
|
|
|
|
|
def die(msg, code=1):
|
|
log("ERROR:", msg)
|
|
sys.exit(code)
|
|
|
|
|
|
def sh(cmd, check=False, **kw):
|
|
kw.setdefault("env", ENV)
|
|
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
text=True, check=check, **kw)
|
|
|
|
|
|
def pidfile(name):
|
|
return os.path.join(STATE, name + ".pid")
|
|
|
|
|
|
def read_pid(name):
|
|
try:
|
|
pid = int(open(pidfile(name)).read().strip())
|
|
os.kill(pid, 0)
|
|
return pid
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------- processes
|
|
def groove_procs():
|
|
"""All running groove.exe processes: [(pid, environ DISPLAY)]."""
|
|
out = []
|
|
for p in os.listdir("/proc"):
|
|
if not p.isdigit():
|
|
continue
|
|
try:
|
|
cmd = open("/proc/%s/cmdline" % p, "rb").read().replace(b"\0", b" ").lower()
|
|
except OSError:
|
|
continue
|
|
if b"groove.exe" not in cmd or b"lamatest" in cmd:
|
|
continue
|
|
disp = None
|
|
try:
|
|
for kv in open("/proc/%s/environ" % p, "rb").read().split(b"\0"):
|
|
if kv.startswith(b"DISPLAY="):
|
|
disp = kv[8:].decode()
|
|
except OSError:
|
|
pass
|
|
out.append((int(p), disp))
|
|
return out
|
|
|
|
|
|
def foreign_groove():
|
|
return [(p, d) for p, d in groove_procs() if d != DISPLAY]
|
|
|
|
|
|
def xdo(*args):
|
|
# safety net: xdotool only ever against :5
|
|
assert ENV["DISPLAY"] == DISPLAY
|
|
return sh(["xdotool"] + list(args))
|
|
|
|
|
|
def find_window():
|
|
r = xdo("search", "--name", "^Groove$")
|
|
ids = r.stdout.split()
|
|
return ids[-1] if ids else None
|
|
|
|
|
|
def game_alive():
|
|
return any(d == DISPLAY for _, d in groove_procs())
|
|
|
|
|
|
def error_dialog():
|
|
"""Wine error dialog / crash window on :5? Returns the window title or None
|
|
(matches English and German Wine dialog titles)."""
|
|
r = xdo("search", "--name", ".")
|
|
titles = []
|
|
for wid in r.stdout.split():
|
|
t = xdo("getwindowname", wid).stdout.strip()
|
|
if t:
|
|
titles.append(t)
|
|
for t in titles:
|
|
tl = t.lower()
|
|
if ("program error" in tl or "programmfehler" in tl or "wine" in tl and "error" in tl
|
|
or "fehler" in tl or "croc" in tl or "exception" in tl or "winedbg" in tl):
|
|
return t
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------- save games
|
|
def ksv_files():
|
|
return sorted(f for f in os.listdir(GAMEDIR) if f.lower().endswith(".ksv"))
|
|
|
|
|
|
def backup_saves():
|
|
os.makedirs(SAVEBAK, exist_ok=True)
|
|
for f in ksv_files():
|
|
shutil.copy2(os.path.join(GAMEDIR, f), os.path.join(SAVEBAK, f))
|
|
|
|
|
|
def restore_saves():
|
|
if not os.path.isdir(SAVEBAK):
|
|
return
|
|
for f in os.listdir(SAVEBAK):
|
|
src, dst = os.path.join(SAVEBAK, f), os.path.join(GAMEDIR, f)
|
|
if not os.path.exists(dst) or md5(src) != md5(dst):
|
|
log("save game %s was modified -> restored" % f)
|
|
shutil.copy2(src, dst)
|
|
for f in ksv_files():
|
|
if not os.path.exists(os.path.join(SAVEBAK, f)):
|
|
log("new save game %s from the test -> removed" % f)
|
|
os.remove(os.path.join(GAMEDIR, f))
|
|
|
|
|
|
def md5(p):
|
|
return hashlib.md5(open(p, "rb").read()).hexdigest()
|
|
|
|
|
|
def check_slot3():
|
|
p = os.path.join(GAMEDIR, "(null)king2.ksv")
|
|
if not os.path.exists(p):
|
|
die("slot 3 (%s) missing" % p)
|
|
b = open(p, "rb").read()
|
|
# map cursor (+4/+5) decides what is preselected on the selection map
|
|
if len(b) != 256 or b[4] != 9 or b[5] != 1:
|
|
log("WARNING: slot 3: map cursor not on world 9/chapter 1 (cursor %d/%d, "
|
|
"current %d/%d)" % (b[4], b[5], b[0x3f], b[0x40]))
|
|
|
|
|
|
# ---------------------------------------------------------------- commands
|
|
def cmd_start(timeout=60):
|
|
os.makedirs(STATE, exist_ok=True)
|
|
f = foreign_groove()
|
|
if f:
|
|
die("Elias' own game instance is running (pid %s) - test aborted" % f)
|
|
if game_alive():
|
|
log("game already running on %s" % DISPLAY)
|
|
return
|
|
if not os.path.isdir(OVERLAY) or not os.path.realpath(
|
|
os.path.join(PREFIX, "dosdevices", "l:")) == os.path.realpath(OVERLAY):
|
|
die("mod is not active (lama-mod an)")
|
|
check_slot3()
|
|
backup_saves()
|
|
# Xvfb
|
|
if not read_pid("xvfb"):
|
|
if os.path.exists("/tmp/.X11-unix/X5") or os.path.exists("/tmp/.X5-lock"):
|
|
if sh(["xdpyinfo"]).returncode == 0:
|
|
log("foreign X server already running on :5 - sharing it")
|
|
else:
|
|
die("/tmp/.X5-lock exists, but :5 does not respond")
|
|
else:
|
|
xl = open(XVFBLOG, "w")
|
|
p = subprocess.Popen(["Xvfb", DISPLAY, "-screen", "0", "1920x1200x24",
|
|
"-nolisten", "tcp", "+extension", "GLX"],
|
|
stdout=xl, stderr=xl, start_new_session=True)
|
|
open(pidfile("xvfb"), "w").write(str(p.pid))
|
|
for _ in range(50):
|
|
if sh(["xdpyinfo"]).returncode == 0:
|
|
break
|
|
time.sleep(0.1)
|
|
else:
|
|
die("Xvfb does not start (%s)" % XVFBLOG)
|
|
# null sink (never mute the app!)
|
|
if not os.path.exists(pidfile("sink")):
|
|
r = sh(["pactl", "load-module", "module-null-sink", "sink_name=" + SINK,
|
|
"sink_properties=device.description=lama_stumm"])
|
|
if r.returncode == 0:
|
|
open(pidfile("sink"), "w").write(r.stdout.strip())
|
|
else:
|
|
log("WARNING: cannot load null sink:", r.stderr.strip())
|
|
# game
|
|
wl = open(WINELOG, "w")
|
|
p = subprocess.Popen(["wine", GAMEEXE], cwd=GAMEDIR, env=ENV, stdout=wl,
|
|
stderr=subprocess.STDOUT, start_new_session=True)
|
|
open(pidfile("wine"), "w").write(str(p.pid))
|
|
t0 = time.time()
|
|
while time.time() - t0 < timeout:
|
|
if find_window():
|
|
log("window up after %.1f s" % (time.time() - t0))
|
|
return
|
|
if p.poll() is not None and not game_alive():
|
|
die("wine exits immediately (log: %s)" % WINELOG)
|
|
time.sleep(0.5)
|
|
die("no Groove window after %d s" % timeout)
|
|
|
|
|
|
def shot(path):
|
|
d = os.path.dirname(os.path.abspath(path))
|
|
os.makedirs(d, exist_ok=True)
|
|
r = sh(["import", "-display", DISPLAY, "-window", "root", "-crop",
|
|
"1920x1080+0+0", "+repage", path])
|
|
if r.returncode:
|
|
die("import: " + r.stderr)
|
|
return path
|
|
|
|
|
|
def small_shot(path):
|
|
"""Small screenshot (960x540 JPEG) for series - disk space is tight.
|
|
With LAMATEST_SHOT=1280 (environment) 1280x720 / quality 85 (preview images)."""
|
|
w = os.environ.get("LAMATEST_SHOT")
|
|
scale, q = ("%sx" % w, "85") if w else ("50%", "80")
|
|
r = sh(["import", "-display", DISPLAY, "-window", "root", "-crop",
|
|
"1920x1080+0+0", "+repage", "-resize", scale, "-quality", q, path])
|
|
if r.returncode:
|
|
die("import: " + r.stderr)
|
|
return path
|
|
|
|
|
|
def key(k, hold=0.12):
|
|
wid = find_window()
|
|
if not wid:
|
|
die("no Groove window")
|
|
xdo("windowfocus", "--sync", wid)
|
|
xdo("keydown", k)
|
|
time.sleep(float(hold))
|
|
xdo("keyup", k)
|
|
|
|
|
|
def keys(seq, gap=0.35):
|
|
for item in seq:
|
|
if item.startswith("sleep="):
|
|
time.sleep(float(item[6:]))
|
|
continue
|
|
k, _, h = item.partition(":")
|
|
key(k, float(h) if h else 0.12)
|
|
time.sleep(gap)
|
|
|
|
|
|
# image comparison via small greyscale thumbnails (only ImageMagick needed)
|
|
def thumb(path=None):
|
|
src = ["import", "-display", DISPLAY, "-window", "root", "-crop", "1920x1080+0+0",
|
|
"+repage"] if path is None else ["convert", path]
|
|
r = subprocess.run(src + ["-resize", "48x27!", "-colorspace", "Gray", "-depth", "8",
|
|
"gray:-"], stdout=subprocess.PIPE, env=ENV)
|
|
return r.stdout
|
|
|
|
|
|
def diff(a, b):
|
|
if not a or not b or len(a) != len(b):
|
|
return 255.0
|
|
return sum(abs(x - y) for x, y in zip(a, b)) / len(a)
|
|
|
|
|
|
def mean(t):
|
|
return sum(t) / len(t) if t else 0
|
|
|
|
|
|
def wait_stable(min_time=0.0, timeout=30, still=1.5, thresh=1.0, dark_ok=False):
|
|
"""Wait until the image barely changes for `still` seconds."""
|
|
t0 = time.time()
|
|
time.sleep(min_time)
|
|
last, since = thumb(), time.time()
|
|
while time.time() - t0 < timeout:
|
|
time.sleep(0.4)
|
|
cur = thumb()
|
|
if diff(cur, last) > thresh or (not dark_ok and mean(cur) < 3):
|
|
since = time.time()
|
|
elif time.time() - since >= still:
|
|
return cur
|
|
last = cur
|
|
return last
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- image recognition
|
|
def rgb_thumb():
|
|
"""48x27 RGB thumbnail of the current image (list of rows of (r,g,b))."""
|
|
r = subprocess.run(["import", "-display", DISPLAY, "-window", "root", "-crop",
|
|
"1920x1080+0+0", "+repage", "-resize", "48x27!", "-depth", "8",
|
|
"rgb:-"], stdout=subprocess.PIPE, env=ENV)
|
|
b = r.stdout
|
|
if len(b) != 48 * 27 * 3:
|
|
return None
|
|
return [[tuple(b[(y * 48 + x) * 3:(y * 48 + x) * 3 + 3]) for x in range(48)]
|
|
for y in range(27)]
|
|
|
|
|
|
def _rows_mean(t, ys):
|
|
px = [sum(c) / 3 for y in ys for c in t[y]]
|
|
return sum(px) / len(px)
|
|
|
|
|
|
def classify(t=None):
|
|
"""Rough image class: black / letterbox / map / bright (menu or game)."""
|
|
t = t or rgb_thumb()
|
|
if t is None:
|
|
return "none"
|
|
allm = _rows_mean(t, range(27))
|
|
if allm < 6:
|
|
return "black"
|
|
# only check the top/bottom thumbnail row: subtitles sit in the lower bar
|
|
top, bot, mid = _rows_mean(t, (0,)), _rows_mean(t, (26,)), _rows_mean(t, range(6, 18))
|
|
if top < 4 and bot < 4 and mid > 15:
|
|
return "letterbox" # camera fly-by / dialogue cutscene
|
|
# selection map world 9: almost uniformly beige (~205,175,110)
|
|
beige = sum(1 for row in t for (r, g, b) in row
|
|
if 180 < r < 230 and 150 < g < 200 and 85 < b < 140 and r > g > b)
|
|
if beige > 0.75 * 48 * 27:
|
|
return "map"
|
|
return "bright"
|
|
|
|
|
|
def hud_visible():
|
|
"""Golden energy bar/sun crown at the top left (gameplay HUD)?"""
|
|
r = subprocess.run(["import", "-display", DISPLAY, "-window", "root", "-crop",
|
|
"900x110+100+10", "+repage", "-resize", "25%", "-depth", "8",
|
|
"rgb:-"], stdout=subprocess.PIPE, env=ENV)
|
|
b = r.stdout
|
|
gold = sum(1 for i in range(0, len(b) - 2, 3)
|
|
if b[i] > 200 and b[i + 1] > 150 and b[i + 2] < 90)
|
|
return gold > 150
|
|
|
|
|
|
def ocr(crop="1920x1080+0+0", threshold=None, psm=11, scale="50%"):
|
|
"""Text on screen (tesseract, deu), lower-cased.
|
|
threshold (percent): keep only very bright text (e.g. the highlighted menu item)."""
|
|
filt = ["-threshold", "%d%%" % threshold] if threshold else []
|
|
raw = subprocess.run(["import", "-display", DISPLAY, "-window", "root", "-crop",
|
|
crop, "+repage", "ppm:-"], stdout=subprocess.PIPE, env=ENV).stdout
|
|
img = subprocess.run(["convert", "ppm:-", "-colorspace", "Gray"] + filt +
|
|
["-negate", "-resize", scale, "pgm:-"], input=raw,
|
|
stdout=subprocess.PIPE).stdout
|
|
r = subprocess.run(["tesseract", "-", "-", "-l", "deu", "--psm", str(psm)], input=img,
|
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
return " ".join(r.stdout.decode("utf-8", "replace").lower().split())
|
|
|
|
|
|
def crashed():
|
|
"""None, or a reason string if the game has crashed (goes into report.txt)."""
|
|
if not game_alive():
|
|
return "groove.exe-Prozess weg"
|
|
e = error_dialog()
|
|
if e:
|
|
return "Fehlerdialog: %s" % e
|
|
if not find_window():
|
|
return "Groove-Fenster weg"
|
|
return None
|
|
|
|
|
|
def wait_for(pred, timeout, what, poll=0.7):
|
|
t0 = time.time()
|
|
while time.time() - t0 < timeout:
|
|
c = crashed()
|
|
if c:
|
|
raise Crash(c)
|
|
v = pred()
|
|
if v:
|
|
return v
|
|
time.sleep(poll)
|
|
raise Timeout("Zeitueberschreitung (%ds) beim Warten auf %s" % (timeout, what))
|
|
|
|
|
|
class Crash(Exception):
|
|
pass
|
|
|
|
|
|
class Timeout(Exception):
|
|
pass
|
|
|
|
|
|
SLOT_CROP = "1600x280+160+740" # text field "Gespeichertes Spiel N / Level Kapitel" (German game text)
|
|
|
|
|
|
def slot_text():
|
|
# combine two thresholds - the background flickers, OCR fluctuates
|
|
return ocr(SLOT_CROP, threshold=70, psm=6) + " | " + ocr(SLOT_CROP, threshold=82, psm=6)
|
|
|
|
|
|
def fuzzy_in(word, text, ratio=0.75):
|
|
"""Is word (possibly several words) fuzzily contained in text?"""
|
|
import difflib
|
|
if word in text:
|
|
return True
|
|
toks = text.split()
|
|
n = len(word.split())
|
|
for i in range(len(toks) - n + 1):
|
|
cand = " ".join(toks[i:i + n])
|
|
if difflib.SequenceMatcher(None, word, cand).ratio() >= ratio:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _has(*words):
|
|
def f():
|
|
t = slot_text()
|
|
return t if all(fuzzy_in(w, t) for w in words) else None
|
|
return f
|
|
|
|
|
|
def tolevel(skip_flyby=True, log_times=None):
|
|
"""Main menu -> "Spiel starten" -> slot with TestLevel -> chapter 1 -> gameplay."""
|
|
T = log_times if log_times is not None else {}
|
|
t0 = time.time()
|
|
# 1) main menu (logo ~2 s, menu after ~5-6 s). If the demo comes instead,
|
|
# Return brings you back to the menu.
|
|
tx = time.time()
|
|
while True:
|
|
c = crashed()
|
|
if c:
|
|
raise Crash(c)
|
|
# main menu: menu items at the bottom right; at a 75 % threshold only the
|
|
# highlighted (bright) item stays readable -> also checks the cursor position.
|
|
txt = ocr("700x300+1150+650", threshold=75, psm=6, scale="100%")
|
|
if fuzzy_in("spiel starten", txt):
|
|
log("main menu detected")
|
|
time.sleep(0.5)
|
|
key("Return")
|
|
break
|
|
if fuzzy_in("gespeichertes", slot_text()):
|
|
break # already on the slot screen
|
|
if time.time() - tx > 40:
|
|
log("no menu detected -> Return (skip demo/logo)")
|
|
key("Return")
|
|
tx = time.time()
|
|
if time.time() - t0 > 90:
|
|
raise Timeout("Hauptmenue nicht erreicht")
|
|
time.sleep(1)
|
|
T["menu"] = round(time.time() - t0, 1)
|
|
# 2) save game selection: all the way left (no wrap), then find the slot with TestLevel
|
|
wait_for(_has("gespeichertes"), 20, "Spielstandauswahl")
|
|
time.sleep(0.4)
|
|
# three-slot slider without wrap: slot 3 is at the far right, further Right stays there.
|
|
# So just press Right twice (wherever you start) and check once via OCR.
|
|
def _is_test():
|
|
return (fuzzy_in("testlevel", ocr(SLOT_CROP, threshold=70, psm=6))
|
|
or fuzzy_in("testlevel", ocr(SLOT_CROP, threshold=82, psm=6)))
|
|
for _ in range(2):
|
|
key("Right", 0.3) # hold 0.3 s: short presses get lost at low frame rates
|
|
time.sleep(0.3)
|
|
for i in range(3): # under load a press can get lost -> Right once more
|
|
time.sleep(0.6)
|
|
if _is_test():
|
|
log("slot 3: TestLevel found (attempt %d)" % (i + 1))
|
|
break
|
|
key("Right", 0.3)
|
|
else:
|
|
raise Timeout("kein Spielstand mit TestLevel gefunden (Slot 3 kaputt?)")
|
|
key("Return")
|
|
T["slot"] = round(time.time() - t0, 1)
|
|
# 3) selection map
|
|
wait_for(lambda: classify() == "map", 30, "Auswahlkarte")
|
|
time.sleep(1.0)
|
|
# preselection on the map = map cursor in the KSV (+4/+5), checked in check_slot3()
|
|
key("Return")
|
|
t_load = time.time()
|
|
T["map"] = round(t_load - t0, 1)
|
|
# 4) loading -> (camera fly-by in letterbox) -> gameplay
|
|
skipped = False
|
|
good = 0
|
|
states = []
|
|
while time.time() - t_load < 120:
|
|
c = crashed()
|
|
if c:
|
|
raise Crash(c)
|
|
st = classify()
|
|
if not states or states[-1] != st:
|
|
states.append(st)
|
|
log("state: %s (%.1f s after level start)" % (st, time.time() - t_load))
|
|
if st == "letterbox" and skip_flyby and not skipped:
|
|
time.sleep(0.5)
|
|
key("Return") # skip the camera fly-by
|
|
skipped = True
|
|
T["flyby"] = round(time.time() - t_load, 1)
|
|
elif st == "letterbox" and skipped and time.time() - t_load > 15:
|
|
key("x") # click through the dialogue
|
|
# gameplay = bright image, no letterbox, and HUD visible (or after the
|
|
# camera fly-by / after 25 s at the latest - in case the HUD has already faded out)
|
|
el = time.time() - t_load
|
|
if st == "bright" and el > 2 and (hud_visible() or "letterbox" in states or el > 25):
|
|
good += 1
|
|
if good >= 3:
|
|
break
|
|
else:
|
|
good = 0
|
|
time.sleep(0.6)
|
|
else:
|
|
raise Timeout("Gameplay nicht erreicht (Zustaende: %s)" % states)
|
|
T["gameplay"] = round(time.time() - t0, 1)
|
|
T["load"] = round(time.time() - t_load, 1)
|
|
T["states"] = states
|
|
log("gameplay after %.1f s (loading %.1f s), states %s" % (T["gameplay"], T["load"], states))
|
|
return T
|
|
|
|
|
|
def cmd_tolevel():
|
|
try:
|
|
T = tolevel()
|
|
except (Crash, Timeout) as e:
|
|
die(str(e))
|
|
print(T)
|
|
|
|
|
|
def settle_dialog(maxpress=12):
|
|
"""Click away a dialogue/cutscene (letterbox)."""
|
|
n = 0
|
|
while classify() == "letterbox" and n < maxpress:
|
|
key("x")
|
|
time.sleep(1.0)
|
|
n += 1
|
|
return n
|
|
|
|
|
|
def tour(outdir, n=8, prefix="tour"):
|
|
"""Photo series: n images of a camera pan (A), then n images walking+turning.
|
|
Returns the list of files. Images as 960x540 JPEG (saves space)."""
|
|
os.makedirs(outdir, exist_ok=True)
|
|
files = []
|
|
|
|
def snap(tag):
|
|
c = crashed()
|
|
if c:
|
|
raise Crash(c)
|
|
f = os.path.join(outdir, "%s_%02d_%s.jpg" % (prefix, len(files), tag))
|
|
small_shot(f)
|
|
files.append(f)
|
|
|
|
snap("start")
|
|
# camera once all round: 0.5 s of the A key ~ 90 degrees
|
|
hold = 2.0 / max(n, 1)
|
|
for i in range(n):
|
|
key("a", hold)
|
|
time.sleep(0.7)
|
|
snap("cam%d" % i)
|
|
# walking: forwards, turning in between; click dialogues away
|
|
for i in range(n):
|
|
key("Up", 1.2)
|
|
time.sleep(0.4)
|
|
if classify() == "letterbox":
|
|
snap("dialog%d" % i)
|
|
settle_dialog()
|
|
snap("walk%d" % i)
|
|
key("Left" if i % 3 != 2 else "Right", 0.45)
|
|
time.sleep(0.3)
|
|
# llama view (Return held) once per direction
|
|
key("Return", 1.5)
|
|
time.sleep(0.2)
|
|
snap("lamasicht")
|
|
return files
|
|
|
|
|
|
def cmd_tour(outdir, n):
|
|
try:
|
|
fs = tour(outdir, n)
|
|
except Crash as e:
|
|
die("CRASH: %s" % e, 3)
|
|
print("\n".join(fs))
|
|
|
|
|
|
# ---------------------------------------------------------------- WAD + run
|
|
def install_wad(wad):
|
|
wad = os.path.abspath(wad)
|
|
orig = MODWAD + ".orig"
|
|
if not os.path.exists(orig):
|
|
shutil.copy2(MODWAD, orig)
|
|
log("original backed up: %s" % orig)
|
|
if os.path.abspath(MODWAD) != wad:
|
|
shutil.copyfile(wad, MODWAD)
|
|
# overlay points to MODWAD via symlink -> rebuild only if the link is missing/wrong
|
|
link = os.path.join(OVERLAY, "wads", "T1L9M001.wad")
|
|
if not (os.path.islink(link) and os.path.realpath(link) == os.path.realpath(MODWAD)):
|
|
log("overlay link missing -> lama-mod an")
|
|
sh([LAMA_MOD, "an"], check=True)
|
|
others = [f for f in os.listdir(os.path.dirname(link))
|
|
if f.lower() == "t1l9m001.wad" and f != "T1L9M001.wad"]
|
|
if others:
|
|
log("WARNING: other spelling in the overlay: %s" % others)
|
|
log("WAD installed: %s (%d bytes, md5 %s)" % (wad, os.path.getsize(MODWAD), md5(MODWAD)[:12]))
|
|
|
|
|
|
def cmd_restore_wad():
|
|
orig = MODWAD + ".orig"
|
|
if not os.path.exists(orig):
|
|
die("no %s" % orig)
|
|
shutil.copy2(orig, MODWAD)
|
|
log("original T1L9M001.wad restored")
|
|
|
|
|
|
def run_seq(outdir, seq):
|
|
"""Process a key sequence; 'shot:NAME' takes a photo. Returns the image files."""
|
|
files = []
|
|
for item in seq.split():
|
|
c = crashed()
|
|
if c:
|
|
raise Crash(c)
|
|
if item.startswith("shot:"):
|
|
f = os.path.join(outdir, "seq_%02d_%s.jpg" % (len(files), item[5:]))
|
|
small_shot(f)
|
|
files.append(f)
|
|
elif item.startswith("fp:"):
|
|
# llama view: hold Return, take the photo meanwhile
|
|
wid = find_window()
|
|
xdo("windowfocus", "--sync", wid)
|
|
xdo("keydown", "Return")
|
|
time.sleep(1.2)
|
|
f = os.path.join(outdir, "seq_%02d_%s.jpg" % (len(files), item[3:]))
|
|
small_shot(f)
|
|
files.append(f)
|
|
xdo("keyup", "Return")
|
|
time.sleep(0.4)
|
|
elif item.startswith("rj:"):
|
|
# running jump: hold Up, tap X after T1 s, keep holding Up for T2 s
|
|
_, t1, t2 = item.split(":")
|
|
wid = find_window()
|
|
xdo("windowfocus", "--sync", wid)
|
|
xdo("keydown", "Up")
|
|
time.sleep(float(t1))
|
|
xdo("keydown", "x")
|
|
time.sleep(0.15)
|
|
xdo("keyup", "x")
|
|
time.sleep(float(t2))
|
|
xdo("keyup", "Up")
|
|
time.sleep(0.3)
|
|
elif item.startswith("run:") or item.startswith("run/"):
|
|
# run through with a schedule: run:T:x@2.1,x@3.4,shot@4.0,Left@5.0-5.3
|
|
# Up (or the given key for run/Left:...) is held for T s; at the given times (s from start) tap X / photo (seq_NN_runK) /
|
|
# hold a key from-to. Photos take ~0.2 s, the schedule keeps running (real time).
|
|
parts = item.split(":", 2)
|
|
mk = parts[0][4:] or "Up"
|
|
total, ev = float(parts[1]), []
|
|
for e in (parts[2].split(",") if len(parts) > 2 and parts[2] else []):
|
|
k, _, t = e.partition("@")
|
|
if "-" in t:
|
|
a, b = t.split("-")
|
|
ev.append((float(a), "down", k)); ev.append((float(b), "up", k))
|
|
else:
|
|
ev.append((float(t), k, None))
|
|
ev.sort(key=lambda e: e[0])
|
|
wid = find_window()
|
|
xdo("windowfocus", "--sync", wid)
|
|
t0 = time.time()
|
|
xdo("keydown", mk)
|
|
for t, k, arg in ev + [(total, "end", None)]:
|
|
dt = t0 + t - time.time()
|
|
if dt > 0:
|
|
time.sleep(dt)
|
|
if k == "end":
|
|
break
|
|
if k == "down":
|
|
xdo("keydown", arg)
|
|
elif k == "up":
|
|
xdo("keyup", arg)
|
|
elif k == "shot":
|
|
f = os.path.join(outdir, "seq_%02d_run%.1f.jpg" % (len(files), t))
|
|
small_shot(f)
|
|
files.append(f)
|
|
else:
|
|
xdo("keydown", k); time.sleep(0.1); xdo("keyup", k)
|
|
xdo("keyup", mk)
|
|
for t, k, arg in ev:
|
|
if k == "down":
|
|
xdo("keyup", arg)
|
|
time.sleep(0.3)
|
|
elif item.startswith("hop:"):
|
|
# hold Up and jump N times every T s (photos in between: shot after the token)
|
|
_, n, t = item.split(":")
|
|
wid = find_window()
|
|
xdo("windowfocus", "--sync", wid)
|
|
xdo("keydown", "Up")
|
|
for _ in range(int(n)):
|
|
xdo("keydown", "x"); time.sleep(0.12); xdo("keyup", "x"); time.sleep(float(t) - 0.12)
|
|
xdo("keyup", "Up")
|
|
time.sleep(0.3)
|
|
elif item.startswith("dj:"):
|
|
# double jump with run-up: hold Up, X, X again after 0.3 s, Up for another T s
|
|
t = float(item.split(":")[1])
|
|
wid = find_window()
|
|
xdo("windowfocus", "--sync", wid)
|
|
xdo("keydown", "Up")
|
|
for _ in range(2):
|
|
xdo("keydown", "x"); time.sleep(0.12); xdo("keyup", "x"); time.sleep(0.25)
|
|
time.sleep(t)
|
|
xdo("keyup", "Up")
|
|
time.sleep(0.3)
|
|
elif "+" in item.split(":")[0]:
|
|
# several keys simultaneously: Up+x:0.8
|
|
ks, _, h = item.partition(":")
|
|
wid = find_window()
|
|
xdo("windowfocus", "--sync", wid)
|
|
for k in ks.split("+"):
|
|
xdo("keydown", k)
|
|
time.sleep(float(h) if h else 0.12)
|
|
for k in reversed(ks.split("+")):
|
|
xdo("keyup", k)
|
|
time.sleep(0.3)
|
|
elif item == "dialog":
|
|
settle_dialog()
|
|
else:
|
|
keys([item])
|
|
return files
|
|
|
|
|
|
def cmd_run(wad, outdir, n=8, seq=None, do_tour=None, keep=False):
|
|
os.makedirs(outdir, exist_ok=True)
|
|
report = os.path.join(outdir, "report.txt")
|
|
rep = ["wad: %s" % os.path.abspath(wad)]
|
|
status = 0
|
|
if foreign_groove():
|
|
die("Elias' own game instance is running - aborted")
|
|
# remember the previously installed WAD -> put it back afterwards (not .orig!)
|
|
prev = os.path.join(STATE, "vorher_T1L9M001.wad")
|
|
os.makedirs(STATE, exist_ok=True)
|
|
if not keep and os.path.exists(MODWAD):
|
|
shutil.copyfile(MODWAD, prev)
|
|
install_wad(wad)
|
|
rep.append("md5: %s" % md5(MODWAD))
|
|
t0 = time.time()
|
|
try:
|
|
cmd_start()
|
|
rep.append("fenster: %.1f s" % (time.time() - t0))
|
|
T = tolevel()
|
|
rep.append("tolevel: %s" % T)
|
|
small_shot(os.path.join(outdir, "00_geladen.jpg"))
|
|
shot(os.path.join(outdir, "00_geladen.png"))
|
|
fs = []
|
|
if seq:
|
|
fs += run_seq(outdir, seq)
|
|
if do_tour or (do_tour is None and not seq):
|
|
fs += tour(outdir, n)
|
|
rep.append("bilder: %d" % (len(fs) + 2))
|
|
# frozen? (the camera turns -> consecutive images must differ)
|
|
th = [thumb(f) for f in fs]
|
|
dmax = max([diff(a, b) for a, b in zip(th, th[1:])] or [0])
|
|
rep.append("bildaenderung max: %.1f" % dmax)
|
|
if dmax < 1.0:
|
|
status = 5
|
|
rep.append("WARNUNG: Bild scheint eingefroren")
|
|
c = crashed()
|
|
if c:
|
|
raise Crash(c)
|
|
if status == 0:
|
|
rep.append("ergebnis: OK (laeuft nach %.0f s noch)" % (time.time() - t0))
|
|
else:
|
|
rep.append("ergebnis: EINGEFROREN?")
|
|
except Crash as e:
|
|
status = 3
|
|
rep.append("ergebnis: ABSTURZ - %s" % e)
|
|
try:
|
|
small_shot(os.path.join(outdir, "zz_absturz.jpg"))
|
|
except SystemExit:
|
|
pass
|
|
except Timeout as e:
|
|
status = 4
|
|
rep.append("ergebnis: HAENGT/TIMEOUT - %s" % e)
|
|
try:
|
|
small_shot(os.path.join(outdir, "zz_timeout.jpg"))
|
|
except SystemExit:
|
|
pass
|
|
finally:
|
|
try:
|
|
cmd_stop()
|
|
finally:
|
|
if os.path.exists(WINELOG):
|
|
# truncate the log: keep at most 2 MB (start + end)
|
|
data = open(WINELOG, "rb").read()
|
|
if len(data) > 2_000_000:
|
|
data = data[:1_000_000] + b"\n...[gekuerzt]...\n" + data[-1_000_000:]
|
|
open(os.path.join(outdir, "wine.log"), "wb").write(data)
|
|
# known harmless messages (also appear when the game works fine)
|
|
noise = ("com_get_class_object", "ChangeDisplaySettingsExW",
|
|
"wined3d_restore_display_modes", "gstreamer", "MIDIMAP",
|
|
"RestoreDisplayMode")
|
|
errs = [l for l in data.decode("latin-1").splitlines()
|
|
if (":err:" in l or "exception" in l.lower() or "Unhandled" in l
|
|
or "croc" in l.lower()) and not any(x in l for x in noise)]
|
|
rep.append("wine-log: %d err/exception-Zeilen" % len(errs))
|
|
rep += [" " + l for l in errs[:15]]
|
|
if not keep and os.path.exists(prev):
|
|
shutil.copyfile(prev, MODWAD)
|
|
os.remove(prev)
|
|
rep.append("WAD: vorher installierte Version wiederhergestellt (md5 %s)" % md5(MODWAD)[:12])
|
|
elif keep:
|
|
rep.append("WAD: getestete Version bleibt installiert")
|
|
rep.append("dauer: %.0f s" % (time.time() - t0))
|
|
open(report, "w").write("\n".join(rep) + "\n")
|
|
print("\n".join(rep))
|
|
return status
|
|
|
|
|
|
def cmd_stop():
|
|
f = foreign_groove()
|
|
mine = [p for p, d in groove_procs() if d == DISPLAY]
|
|
for p in mine:
|
|
try:
|
|
os.kill(p, signal.SIGTERM)
|
|
except OSError:
|
|
pass
|
|
time.sleep(1)
|
|
if f:
|
|
log("foreign instance running (%s) - no wineserver -k, only own processes" % f)
|
|
for p, d in groove_procs():
|
|
if d == DISPLAY:
|
|
os.kill(p, signal.SIGKILL)
|
|
else:
|
|
sh(["wineserver", "-k"])
|
|
sh(["wineserver", "-w"], timeout=20)
|
|
wp = read_pid("wine")
|
|
if wp:
|
|
try:
|
|
os.killpg(wp, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
xp = read_pid("xvfb")
|
|
if xp:
|
|
os.kill(xp, signal.SIGTERM)
|
|
for _ in range(30):
|
|
if not read_pid("xvfb"):
|
|
break
|
|
time.sleep(0.1)
|
|
try:
|
|
os.remove(pidfile("xvfb"))
|
|
except OSError:
|
|
pass
|
|
try:
|
|
mod = open(pidfile("sink")).read().strip()
|
|
if mod:
|
|
sh(["pactl", "unload-module", mod])
|
|
os.remove(pidfile("sink"))
|
|
except OSError:
|
|
pass
|
|
# safety net: unload the sink by name in case the module number got lost
|
|
r = sh(["pactl", "list", "short", "modules"])
|
|
for line in r.stdout.splitlines():
|
|
if "sink_name=" + SINK in line:
|
|
sh(["pactl", "unload-module", line.split()[0]])
|
|
restore_saves()
|
|
log("stopped")
|
|
|
|
|
|
def cmd_status():
|
|
print("eigene groove.exe (:5):", [p for p, d in groove_procs() if d == DISPLAY])
|
|
print("fremde groove.exe: ", foreign_groove())
|
|
print("Xvfb-pid:", read_pid("xvfb"), " Fenster:", find_window() if read_pid("xvfb") else None)
|
|
print("Fehlerdialog:", error_dialog() if read_pid("xvfb") else None)
|
|
print("Mod-WAD:", MODWAD, md5(MODWAD) if os.path.exists(MODWAD) else "-")
|
|
|
|
|
|
def main(argv):
|
|
if len(argv) < 2 or argv[1] in ("-h", "--help", "help"):
|
|
print(__doc__)
|
|
return
|
|
c, a = argv[1], argv[2:]
|
|
if c == "start":
|
|
cmd_start()
|
|
elif c == "stop":
|
|
cmd_stop()
|
|
elif c == "status":
|
|
cmd_status()
|
|
elif c == "shot":
|
|
print(shot(a[0] if a else "shot.png"))
|
|
elif c == "key":
|
|
key(a[0], float(a[1]) if len(a) > 1 else 0.12)
|
|
elif c == "keys":
|
|
keys(a)
|
|
elif c == "tolevel":
|
|
cmd_tolevel()
|
|
elif c == "seq": # key sequence like run --seq, but in the running game: seq <outdir> "TOKENS"
|
|
os.makedirs(a[0], exist_ok=True)
|
|
for f in run_seq(a[0], a[1]):
|
|
print(f)
|
|
elif c == "tour":
|
|
cmd_tour(a[0], int(a[1]) if len(a) > 1 else 8)
|
|
elif c == "run":
|
|
seq, do_tour, keep, pos = None, None, False, []
|
|
i = 0
|
|
while i < len(a):
|
|
if a[i] == "--seq":
|
|
seq = a[i + 1]; i += 2; continue
|
|
if a[i] == "--tour":
|
|
do_tour = True
|
|
elif a[i] == "--keep":
|
|
keep = True
|
|
else:
|
|
pos.append(a[i])
|
|
i += 1
|
|
sys.exit(cmd_run(pos[0], pos[1], int(pos[2]) if len(pos) > 2 else 8, seq, do_tour, keep))
|
|
elif c == "restore-wad":
|
|
cmd_restore_wad()
|
|
elif c == "mean": # debug: brightness/diff
|
|
print(mean(thumb(a[0] if a else None)))
|
|
else:
|
|
print(__doc__)
|
|
sys.exit(2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main(sys.argv)
|