#!/usr/bin/env python3 """apptest - run the game through the LlamaMod app, invisibly and muted, for testing mods. Every session NAME has its own LlamaMod data folder, X display (Xvfb), private PulseAudio daemon (null sink only) and Wine prefix, so several agents can test in parallel: //data LLAMAMOD_DATA (config.json, original/ (hard links), game/, cd-active/, wine/) //harness state.json, pulse/ (socket, rt, home), logs, xvfb.log root = $APPTEST_ROOT, else $CLAUDE_JOB_DIR/tmp (AI agent sessions), else ~/.cache/llamamod-apptest Commands (NAME first; --display only for 'start'/'run', remembered afterwards): apptest.py setup NAME install from the ISO (shared hard-linked template) apptest.py apply NAME [ids] [--opt id.k=v]... apply mods (none = original) via the app CLI apptest.py unlock NAME SLOT WORLD CHAPTER unlock up to WORLD/CHAPTER, map cursor there apptest.py start NAME --display N Xvfb :N + private pulse + game (app --launch) apptest.py tolevel NAME [--world W] [--slot S] main menu -> slot S (3) -> map -> level -> gameplay apptest.py keys NAME Up:1.5 x sleep=1 a+x:0.5 shot:name ... key sequence (hold s, default 0.2) apptest.py shot NAME out.png [--small] screenshot (1920x1080 PNG, --small = 960x540 JPEG) apptest.py ocr NAME [WxH+X+Y] German OCR of the screen (or a crop) apptest.py status NAME game/window/crash/audio state (exit 3 = crashed) apptest.py stop NAME kill game (wineserver -k), Xvfb and pulse of NAME apptest.py run NAME --display N --mods a,b [--opt id.k=v]... --level W C [--seq "..."] --out DIR [--keep] [--no-retry] all in one; exit 0 ok, 3 crash, 4 timeout, 2 usage. A crash before the main menu or a missed save slot screen is retried once (report.json "retries", out/retry1_*); --no-retry turns that off Options of every command: --root DIR, --jar FILE (default: newest build/libs/*-all.jar), --iso FILE (default $APPTEST_ISO or ~/Downloads/GROOVE_DK.iso). Python: `from apptest import Session`. Environment: APPTEST_CP= + APPTEST_JAVA_OPTS="-Dllamamod.extraMods=" test mods that are not registered yet (e.g. APPTEST_CP=build/classes/java/test). Safety: never uses display :0, never touches ~/.wine-lama, kills only PIDs it started itself (checked against /proc/PID/cmdline) and the wineserver of its own prefix. """ import fcntl import json import os import re import shutil import signal import subprocess import sys import time HOME = os.path.expanduser("~") PROJECT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DEFAULT_ROOT = os.path.expanduser( os.environ.get("APPTEST_ROOT") or (os.path.join(os.environ["CLAUDE_JOB_DIR"], "tmp") if os.environ.get("CLAUDE_JOB_DIR") else "") or os.path.join(HOME, ".cache", "llamamod-apptest")) DEFAULT_ISO = os.path.expanduser(os.environ.get("APPTEST_ISO") or os.path.join(HOME, "Downloads", "GROOVE_DK.iso")) TEMPLATE = "_llamamod-template" SCREEN = (1920, 1080) FULL = "%dx%d+0+0" % SCREEN SLOT_CROP = "1600x280+160+740" # save-slot text "Gespeichertes Spiel N / Kapitel n" MENU_CROP = "700x300+1150+650" # main menu items (bottom right) # world -> word the save slot shows for it (OCR, lower case); only the verified ones SLOT_WORLD_NAMES = {1: "dorf", 9: "testlevel"} class Crash(Exception): pass class Timeout(Exception): pass def log(*a): print("[apptest]", *a, flush=True) def find_java(): for j in (os.environ.get("JAVA"), os.path.join(HOME, ".jdks", "openjdk-25", "bin", "java")): if j and os.access(j, os.X_OK): return j return shutil.which("java") or "java" def find_jar(): if os.environ.get("APPTEST_JAR"): return os.environ["APPTEST_JAR"] d = os.path.join(PROJECT, "build", "libs") jars = [os.path.join(d, f) for f in os.listdir(d) if f.endswith("-all.jar")] if os.path.isdir(d) else [] if not jars: raise SystemExit("no fat jar - run ./gradlew fatJar in %s" % PROJECT) return max(jars, key=os.path.getmtime) def fuzzy_in(word, text, ratio=0.75): import difflib if word in text: return True toks = text.split() n = len(word.split()) for i in range(len(toks) - n + 1): if difflib.SequenceMatcher(None, word, " ".join(toks[i:i + n])).ratio() >= ratio: return True return False def _cmdline(pid): try: return open("/proc/%d/cmdline" % pid, "rb").read().split(b"\0") except OSError: return [] def _environ(pid): try: return open("/proc/%d/environ" % pid, "rb").read().split(b"\0") except OSError: return [] def _kill_own(pid, expect): """Kill pid only if its executable name contains `expect` (guards against reused PIDs).""" if not pid: return cmd = _cmdline(pid) if not cmd or expect.encode() not in os.path.basename(cmd[0]): return try: os.kill(pid, signal.SIGTERM) for _ in range(30): time.sleep(0.1) if not _cmdline(pid): return os.kill(pid, signal.SIGKILL) except ProcessLookupError: pass class Session: def __init__(self, name, display=None, root=DEFAULT_ROOT, jar=None, iso=DEFAULT_ISO): if not name or "/" in name or name.startswith("_"): raise SystemExit("bad session name %r" % name) self.name = name self.root = root self.dir = os.path.join(root, name) self.data = os.path.join(self.dir, "data") self.hdir = os.path.join(self.dir, "harness") self.pulse_dir = os.path.join(self.hdir, "pulse") self.state_file = os.path.join(self.hdir, "state.json") self.jar = jar self.iso = iso os.makedirs(self.hdir, exist_ok=True) self.state = {} if os.path.exists(self.state_file): self.state = json.load(open(self.state_file)) if display is not None: d = ":%s" % str(display).lstrip(":") if self.state.get("display") not in (None, d) and self.state.get("xvfb_pid") \ and _cmdline(self.state["xvfb_pid"]): raise SystemExit("session %s already runs on %s" % (name, self.state["display"])) self.state["display"] = d self._save() self.display = self.state.get("display") if self.display in (":0", "0"): raise SystemExit("refusing display :0") def _save(self): json.dump(self.state, open(self.state_file, "w"), indent=1) # ------------------------------------------------------------ environment @property def prefix(self): return os.path.join(self.data, "wine") @property def pulse_server(self): return "unix:" + os.path.join(self.pulse_dir, "native") def env(self): e = dict(os.environ) e["LLAMAMOD_DATA"] = self.data e.pop("DBUS_SESSION_BUS_ADDRESS", None) if self.display: e["DISPLAY"] = self.display e["PULSE_SERVER"] = self.pulse_server e.pop("PULSE_SINK", None) return e def app(self, *args, check=True, timeout=1800): """Runs the LlamaMod CLI (fat jar) for this data folder.""" jar = self.jar or find_jar() cmd = [find_java(), "-Djava.awt.headless=true"] if self.display: cmd.append("-Dllamamod.gameDisplay=" + self.display) cmd += os.environ.get("APPTEST_JAVA_OPTS", "").split() if os.environ.get("APPTEST_CP"): cmd += ["-cp", jar + os.pathsep + os.environ["APPTEST_CP"], "com.eliasmueller.llamamod.Main"] else: cmd += ["-jar", jar] cmd += list(args) r = subprocess.run(cmd, env=self.env(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=timeout, start_new_session=True) if check and r.returncode != 0: raise SystemExit("llamamod %s failed (%d):\n%s" % (" ".join(args), r.returncode, r.stdout)) return r.stdout # ------------------------------------------------------------ setup def setup(self): """Installs the game into this session's data folder (once).""" cfgp = os.path.join(self.data, "config.json") if os.path.exists(cfgp) and json.load(open(cfgp)).get("layout", 0) >= 1: self._config_defaults() return tdata = os.path.join(self.root, TEMPLATE, "data") os.makedirs(os.path.dirname(tdata), exist_ok=True) with open(os.path.join(self.root, TEMPLATE + ".lock"), "w") as lk: fcntl.flock(lk, fcntl.LOCK_EX) # parallel agents: install the template only once tcfg = os.path.join(tdata, "config.json") if not (os.path.exists(tcfg) and json.load(open(tcfg)).get("layout", 0) >= 1): if not os.path.exists(self.iso): raise SystemExit("ISO not found: %s" % self.iso) log("installing template from %s (once) ..." % self.iso) r = subprocess.run([find_java(), "-Djava.awt.headless=true", "-jar", self.jar or find_jar(), "--install", self.iso], env=dict(os.environ, LLAMAMOD_DATA=tdata), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) if r.returncode: raise SystemExit("template install failed:\n" + r.stdout) os.makedirs(self.data, exist_ok=True) if not os.path.isdir(os.path.join(self.data, "original")): # hard links: the originals are never written (mods write to cache/, game/ is a copy) subprocess.run(["cp", "-al", os.path.join(tdata, "original"), os.path.join(self.data, "original")], check=True) log("setting up %s ..." % self.data) self.app("--install", self.iso) self._config_defaults() def _config_defaults(self): cfgp = os.path.join(self.data, "config.json") c = json.load(open(cfgp)) c.update(screenW=SCREEN[0], screenH=SCREEN[1], virtualDesktop=True) json.dump(c, open(cfgp, "w"), indent=2) def apply(self, mods=(), opts=()): """mods: ids; opts: 'id.key=value' strings. Returns the CLI output.""" args = ["--apply"] if mods: args.append(",".join(mods)) for o in opts: args += ["--opt", o] out = self.app(*args) log(out.strip().splitlines()[-1] if out.strip() else "applied") return out def unlock(self, slot, world, chapter): return self.app("--unlock", str(slot), str(world), str(chapter), "--cursor") # ------------------------------------------------------------ processes def game_pids(self, what=b"groove.exe"): want = ("WINEPREFIX=" + self.prefix).encode() out = [] for p in os.listdir("/proc"): if not p.isdigit(): continue cmd = b" ".join(_cmdline(int(p))).lower() if what in cmd and want in _environ(int(p)): out.append(int(p)) return out def _start_xvfb(self): if self.state.get("xvfb_pid") and _cmdline(self.state["xvfb_pid"]): return n = self.display.lstrip(":") if os.path.exists("/tmp/.X%s-lock" % n) or os.path.exists("/tmp/.X11-unix/X%s" % n): raise SystemExit("display %s is already in use by someone else - pick another number" % self.display) xl = open(os.path.join(self.hdir, "xvfb.log"), "w") p = subprocess.Popen(["Xvfb", self.display, "-screen", "0", "%dx%dx24" % SCREEN, "-nolisten", "tcp", "+extension", "GLX"], stdout=xl, stderr=xl, start_new_session=True) self.state["xvfb_pid"] = p.pid self._save() for _ in range(100): if subprocess.run(["xdpyinfo"], env=self.env(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0: return time.sleep(0.1) raise SystemExit("Xvfb did not start (see harness/xvfb.log)") def _start_pulse(self): if self.state.get("pulse_pid") and _cmdline(self.state["pulse_pid"]): return d = self.pulse_dir for sub in ("rt", "home"): os.makedirs(os.path.join(d, sub), exist_ok=True) os.chmod(os.path.join(d, "rt"), 0o700) open(os.path.join(d, "home", ".pulse-cookie"), "a").close() sock = os.path.join(d, "native") if os.path.exists(sock): os.remove(sock) cfg = os.path.join(d, "default.pa") open(cfg, "w").write("load-module module-null-sink sink_name=mute\nset-default-sink mute\n" "load-module module-native-protocol-unix socket=%s auth-anonymous=1\n" % sock) env = {k: v for k, v in os.environ.items() if k != "DBUS_SESSION_BUS_ADDRESS"} env.update(DBUS_SESSION_BUS_ADDRESS="unix:path=/nonexistent", XDG_RUNTIME_DIR=os.path.join(d, "rt"), HOME=os.path.join(d, "home")) pl = open(os.path.join(self.hdir, "pulse.log"), "w") p = subprocess.Popen(["pulseaudio", "-n", "-F", cfg, "--daemonize=no", "--exit-idle-time=-1", "--use-pid-file=no"], env=env, stdout=pl, stderr=pl, start_new_session=True) self.state["pulse_pid"] = p.pid self._save() for _ in range(100): if os.path.exists(sock) and self.pactl("info").returncode == 0: return time.sleep(0.1) raise SystemExit("private pulseaudio did not start (see harness/pulse.log)") def pactl(self, *args): return subprocess.run(["pactl", "-s", self.pulse_server] + list(args), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env={k: v for k, v in os.environ.items() if not k.startswith("PULSE")}) def audio_clients(self): """application.name of the sink inputs on the private server (the game: "groove.exe").""" r = self.pactl("list", "sink-inputs") return [l.split("=", 1)[1].strip().strip('"') for l in r.stdout.splitlines() if l.strip().startswith("application.name")] def start(self, timeout=240): if not self.display: raise SystemExit("start needs --display N") if self.game_pids(): log("game already running") return self.setup() self._start_xvfb() self._start_pulse() log("launching on %s (first start creates the Wine prefix, ~1 min) ..." % self.display) out = self.app("--launch") log(out.strip().splitlines()[-1] if out.strip() else "launched") t0 = time.time() while time.time() - t0 < timeout: if self.find_window() and self.game_pids(): log("window after %.0f s" % (time.time() - t0)) break time.sleep(1) else: raise Timeout("no game window after %d s (see data/logs/game.log)" % timeout) for _ in range(30): c = self.crashed() if c: raise Crash(c) if "groove.exe" in " ".join(self.audio_clients()).lower(): log("audio: groove.exe plays into the private null sink") break time.sleep(1) else: log("WARNING: no groove.exe sink input on the private pulse server yet") def stop(self): if self.game_pids(b"") or os.path.isdir(self.prefix): env = dict(self.env(), WINEPREFIX=self.prefix) subprocess.run(["wineserver", "-k"], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) time.sleep(1) for pid in self.game_pids(b""): try: os.kill(pid, signal.SIGKILL) except ProcessLookupError: pass _kill_own(self.state.pop("pulse_pid", None), "pulseaudio") _kill_own(self.state.pop("xvfb_pid", None), "Xvfb") self._save() log("stopped") # ------------------------------------------------------------ X11 def _x(self, *args): assert self.display and self.display != ":0" return subprocess.run(["xdotool"] + list(args), env=self.env(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) def find_window(self): for pat in ("Wine desktop", "^LlamaMod", "^Groove$"): ids = self._x("search", "--name", pat).stdout.split() if ids: return ids[-1] return None def _focus(self): wid = self.find_window() if not wid: raise Crash("game window gone") self._x("windowfocus", "--sync", wid) def key(self, k, hold=0.2): """Press keys (xdotool names, 'a+b' together) for `hold` seconds; DirectInput misses <0.1 s.""" ks = k.split("+") self._focus() for x in ks: self._x("keydown", x) time.sleep(float(hold)) for x in reversed(ks): self._x("keyup", x) def keys(self, seq, gap=0.3, outdir=None): """Tokens: KEY[:hold], a+b[:hold], sleep=S, shot:NAME (needs outdir), dialog.""" shots = [] for item in seq.split() if isinstance(seq, str) else seq: c = self.crashed() if c: raise Crash(c) if item.startswith("sleep="): time.sleep(float(item[6:])) elif item.startswith("shot:"): f = os.path.join(outdir or self.hdir, "seq_%02d_%s.jpg" % (len(shots), item[5:])) shots.append(self.shot(f, small=True)) elif item == "dialog": n = 0 while self.classify() == "letterbox" and n < 12: self.key("x") time.sleep(1.0) n += 1 else: k, _, h = item.partition(":") self.key(k, float(h) if h else 0.2) time.sleep(gap) return shots def shot(self, path, small=False): os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) extra = ["-resize", "50%", "-quality", "80"] if small else [] r = subprocess.run(["import", "-display", self.display, "-window", "root", "-crop", FULL, "+repage"] + extra + [path], env=self.env(), stderr=subprocess.PIPE, text=True) if r.returncode: raise SystemExit("import: " + r.stderr) return path def _grab(self, crop, fmt, resize=None): cmd = ["import", "-display", self.display, "-window", "root", "-crop", crop, "+repage"] if resize: cmd += ["-resize", resize] return subprocess.run(cmd + ["-depth", "8", fmt + ":-"], stdout=subprocess.PIPE, env=self.env()).stdout def ocr(self, crop=FULL, threshold=None, psm=11, scale="50%"): """Lower-case German OCR text; threshold (%) keeps only bright text (selected menu item).""" raw = self._grab(crop, "ppm") filt = ["-threshold", "%d%%" % threshold] if threshold else [] 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 slot_text(self): return self.ocr(SLOT_CROP, threshold=70, psm=6) + " | " + self.ocr(SLOT_CROP, threshold=82, psm=6) def thumb(self): return self._grab(FULL, "gray", "48x27!") @staticmethod 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 wait_stable(self, still=1.5, timeout=30, thresh=1.0): t0 = time.time() last, since = self.thumb(), time.time() while time.time() - t0 < timeout: time.sleep(0.4) cur = self.thumb() if self.diff(cur, last) > thresh or sum(cur) / max(1, len(cur)) < 3: since = time.time() elif time.time() - since >= still: return cur last = cur return last def classify(self): """black / letterbox (fly-by, cut scene) / map (world 9 beige map) / bright.""" b = self._grab(FULL, "rgb", "48x27!") if len(b) != 48 * 27 * 3: return "none" px = [tuple(b[i:i + 3]) for i in range(0, len(b), 3)] rows = [px[y * 48:(y + 1) * 48] for y in range(27)] def mean(ys): v = [sum(c) / 3 for y in ys for c in rows[y]] return sum(v) / len(v) if mean(range(27)) < 6: return "black" if mean((0,)) < 4 and mean((26,)) < 4 and mean(range(6, 18)) > 15: return "letterbox" beige = sum(1 for (r, g, bb) in px if 180 < r < 230 and 150 < g < 200 and 85 < bb < 140 and r > g > bb) return "map" if beige > 0.75 * len(px) else "bright" def hud_visible(self): b = self._grab("900x110+100+10", "rgb", "25%") return 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) > 150 # ------------------------------------------------------------ crash detection def crashed(self): """None, or why the game is considered crashed.""" if not self.game_pids(): return "groove.exe process gone" gl = os.path.join(self.data, "logs", "game.log") try: with open(gl, "rb") as f: f.seek(max(0, os.path.getsize(gl) - 65536)) tail = f.read().decode("latin-1") for marker in ("Unhandled exception", "Unhandled page fault", "wine: Unhandled"): if marker in tail: return "game.log: " + tail[tail.index(marker):].splitlines()[0] except OSError: pass if self.game_pids(b"winedbg"): return "winedbg running (unhandled exception)" if not self.find_window(): return "game window gone" return None def wait_for(self, pred, timeout, what, poll=0.7): t0 = time.time() while time.time() - t0 < timeout: c = self.crashed() if c: raise Crash(c) v = pred() if v: return v time.sleep(poll) raise Timeout("timeout (%d s) waiting for %s" % (timeout, what)) # ------------------------------------------------------------ navigation def tolevel(self, world=None, skip_flyby=True, on_menu=None, slot=3): """Main menu -> "Spiel starten" -> save slot (default 3) -> map (cursor from --unlock ... --cursor) -> Enter -> skip the fly-by -> gameplay. Returns timings.""" T = {} t0 = tx = time.time() while True: c = self.crashed() if c: raise Crash(c) if fuzzy_in("spiel starten", self.ocr(MENU_CROP, threshold=75, psm=6, scale="100%")): log("main menu") if on_menu: on_menu() time.sleep(0.5) self.key("Return", 0.6) break if fuzzy_in("gespeichertes", self.slot_text()): if on_menu: on_menu() break if time.time() - tx > 40: log("no menu yet -> Return (skips logo/demo)") self.key("Return") tx = time.time() if time.time() - t0 > 120: raise Timeout("main menu not reached") time.sleep(1) T["menu"] = round(time.time() - t0, 1) self._to_slot_screen() t = self._select_slot(slot) if not self._slot_ok(t): # an empty slot starts a new game in the village: never the level that was asked for raise Timeout("save slot %d is empty (%s) - unlock it first" % (slot, t)) name = SLOT_WORLD_NAMES.get(world) if name and not fuzzy_in(name, t): raise Timeout("save slot %d holds another world (%s), expected %r" % (slot, t, name)) self._open_slot() T["slot"] = round(time.time() - t0, 1) if world == 9: self.wait_for(lambda: self.classify() == "map", 30, "world 9 map") time.sleep(1.0) else: self.wait_for(lambda: self.classify() not in ("black", "none"), 30, "map") self.wait_stable(still=1.5, timeout=20) map_thumb = self.thumb() self.key("Return", 0.6) t_load = time.time() T["map"] = round(t_load - t0, 1) skipped, good, states, again = False, 0, [], 0 while time.time() - t_load < 150: c = self.crashed() if c: raise Crash(c) st = self.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 "black" not in states and "letterbox" not in states and time.time() - t_load > 8 + 8 * again \ and again < 2 and self.diff(self.thumb(), map_thumb) < 6: again += 1 # still on the map: the Return was missed log("level did not start -> Return again") self.key("Return", 0.6 + 0.3 * again) elif st == "letterbox" and skip_flyby and not skipped: time.sleep(0.5) self.key("Return") skipped = True elif st == "letterbox" and skipped and time.time() - t_load > 15: self.key("x") el = time.time() - t_load if st == "bright" and el > 2 and (self.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 not reached (states %s)" % states) T["gameplay"] = round(time.time() - t0, 1) T["states"] = states log("gameplay after %.1f s, states %s" % (T["gameplay"], states)) return T def _to_slot_screen(self, timeout=45): """Waits for the save slot screen; a Return the menu missed (still fading in, or a key press shorter than one frame on a loaded machine) is repeated, held a little longer each time.""" t0 = tr = time.time() holds = [0.8, 1.0, 1.2] while time.time() - t0 < timeout: c = self.crashed() if c: raise Crash(c) if fuzzy_in("gespeichertes", self.slot_text()): return if time.time() - tr > 5 and fuzzy_in("spiel starten", self.ocr(MENU_CROP, threshold=75, psm=6, scale="100%")): hold = holds.pop(0) if holds else 1.2 log("still in the main menu -> Return again (held %.1f s)" % hold) self.key("Return", hold) tr = time.time() time.sleep(0.7) raise Timeout("timeout (%d s) waiting for save slot screen" % timeout) @staticmethod def _slot_ok(text): return fuzzy_in("kapitel", text) and not fuzzy_in("leer", text) @staticmethod def _slot_no(text): """Slot number from "gespeichertes spiel N" (OCR text), or None.""" m = re.search(r"spiel\s*([123])\b", text) return int(m.group(1)) if m else None def _select_slot(self, slot=3, timeout=240): """Turns the save slot carousel (it opens on slot 2) to `slot` and checks its text after every step. The carousel wraps (3 -> 1) and ignores keys while it turns, so Right is pressed once per step and only again when the text shows another slot. A step that did not move is repeated with a longer hold (0.3/0.6/1.0/1.5 s). On a busy machine (load ~30, several games) the carousel ignored Left/Right for minutes while Return/Escape worked: `run` therefore uses slot 2, which needs no turning.""" self.wait_stable(still=0.8, timeout=6) holds = [0.3, 0.6, 1.0, 1.5] t0 = time.time() t = self.slot_text() while time.time() - t0 < timeout: if self._slot_no(t) == slot: log("slot: %s" % t.split(" | ")[0]) return t if not fuzzy_in("gespeichertes", t): raise Timeout("save slot screen lost while selecting slot %d (%s)" % (slot, t)) before = self._slot_no(t) self.key("Right", holds[0]) t1 = time.time() while time.time() - t1 < 8: # the carousel turns, then the new text shows time.sleep(0.6) t = self.slot_text() if self._slot_no(t) not in (None, before): break else: if len(holds) > 1: holds.pop(0) log("slot carousel did not move (slot %s) -> Right again, held %.1f s" % (before, holds[0])) raise Timeout("save slot %d not selected after %d s (%s)" % (slot, timeout, t)) def _open_slot(self): """Return on the slot until the slot screen is gone (a short press can be missed under load).""" for hold in (0.6, 0.9, 1.2): self.key("Return", hold) t1 = time.time() while time.time() - t1 < 6: time.sleep(0.7) if not fuzzy_in("gespeichertes", self.slot_text()): return log("still on the save slot screen -> Return again") raise Timeout("save slot did not open") def status(self): s = {"display": self.display, "data": self.data, "game_pids": self.game_pids(), "window": self.find_window() if self.display and self.state.get("xvfb_pid") else None, "xvfb": bool(self.state.get("xvfb_pid") and _cmdline(self.state["xvfb_pid"])), "pulse": bool(self.state.get("pulse_pid") and _cmdline(self.state["pulse_pid"]))} s["audio_clients"] = self.audio_clients() if s["pulse"] else [] s["crashed"] = self.crashed() if s["game_pids"] and s["xvfb"] else None return s def start_to_level(sess, level, out, report, retry=True): """start + tolevel; a crash before the main menu (Wine page faults at start-up, not caused by mods) or a menu that was not recognised is retried once with a fresh game start. Each retry is listed in report["retries"] and logged; the failed attempt's screenshot/log go to out/retry1_*.""" for attempt in (1, 2): phase = "start" try: sess.start() if level: phase = "menu" report["tolevel"] = sess.tolevel(world=level[0], slot=2, on_menu=lambda: report.update(menu_reached=True)) return except (Crash, Timeout) as e: early = isinstance(e, Crash) and not report.get("menu_reached") or \ isinstance(e, Timeout) and "save slot" in str(e) if not retry or attempt == 2 or not early: raise log("WARNING: %s during %s -> stopping and starting the game once more" % (e, phase)) try: sess.shot(os.path.join(out, "retry1_failure.jpg"), small=True) except SystemExit: pass gl = os.path.join(sess.data, "logs", "game.log") if os.path.exists(gl): shutil.copy(gl, os.path.join(out, "retry1_game.log")) report.setdefault("retries", []).append({"attempt": attempt, "phase": phase, "error": str(e)}) report.pop("menu_reached", None) sess.stop() def run(sess, mods, opts, level, seq, out, keep=False, retry=True): os.makedirs(out, exist_ok=True) # a busy machine (several agents' games) slows the game to a few frames per second: the usual cause of # missed key presses; recorded to tell flaky runs apart report = {"mods": mods, "opts": opts, "level": level, "load_avg": [round(x, 1) for x in os.getloadavg()], "cpus": os.cpu_count()} code = 0 try: sess.setup() report["apply"] = sess.apply(mods, opts).strip().splitlines()[-3:] if level: # slot 2 is where the carousel opens (no turning, see _select_slot); slot 3 gets the same # progress for mods whose "save slot" option defaults to 3 sess.unlock(2, level[0], level[1]) sess.unlock(3, level[0], level[1]) start_to_level(sess, level, out, report, retry) sess.shot(os.path.join(out, "00_start.png")) if seq: report["shots"] = sess.keys(seq, outdir=out) c = sess.crashed() if c: raise Crash(c) sess.shot(os.path.join(out, "99_end.jpg"), small=True) report["audio_clients"] = sess.audio_clients() except Crash as e: report["error"] = "crash: %s" % e code = 3 except Timeout as e: report["error"] = str(e) code = 4 finally: if code and sess.state.get("xvfb_pid"): try: sess.shot(os.path.join(out, "zz_failure.jpg"), small=True) except SystemExit: pass gl = os.path.join(sess.data, "logs", "game.log") if os.path.exists(gl): shutil.copy(gl, os.path.join(out, "game.log")) al = os.path.join(sess.data, "logs", "apply.log") if os.path.exists(al): shutil.copy(al, os.path.join(out, "apply.log")) json.dump(report, open(os.path.join(out, "report.json"), "w"), indent=1) if not keep: sess.stop() retried = "" if not report.get("retries") else " (after a retry: %s)" % report["retries"][0]["error"] log("result: %s%s" % (report.get("error") or "OK", retried)) return code def main(argv): if len(argv) < 2 or argv[0] in ("-h", "--help"): print(__doc__) return 2 cmd, name, rest = argv[0], argv[1], argv[2:] kw, pos, opts = {}, [], [] i = 0 while i < len(rest): a = rest[i] if a in ("--display", "--root", "--jar", "--iso", "--mods", "--world", "--slot", "--seq", "--out") \ and i + 1 < len(rest): kw[a[2:]] = rest[i + 1] i += 2 elif a == "--opt" and i + 1 < len(rest): opts.append(rest[i + 1]) i += 2 elif a == "--level" and i + 2 < len(rest): kw["level"] = (int(rest[i + 1]), int(rest[i + 2])) i += 3 else: pos.append(a) i += 1 s = Session(name, display=kw.get("display"), root=kw.get("root", DEFAULT_ROOT), jar=kw.get("jar"), iso=kw.get("iso", DEFAULT_ISO)) try: if cmd == "setup": s.setup() elif cmd == "apply": ids = [x for p in pos for x in p.split(",") if x] or [x for x in kw.get("mods", "").split(",") if x] print(s.apply(ids, opts)) elif cmd == "unlock": print(s.unlock(*pos[:3])) elif cmd == "start": s.start() elif cmd == "tolevel": print(json.dumps(s.tolevel(world=int(kw["world"]) if "world" in kw else None, slot=int(kw.get("slot", 3))))) elif cmd == "keys": print(s.keys(pos, outdir=kw.get("out"))) elif cmd == "shot": print(s.shot(pos[0], small="--small" in pos)) elif cmd == "ocr": print(s.ocr(pos[0] if pos else FULL)) elif cmd == "status": st = s.status() print(json.dumps(st, indent=1)) return 3 if st["crashed"] else 0 elif cmd == "stop": s.stop() elif cmd == "run": if not s.display or "out" not in kw: print("run needs --display N and --out DIR") return 2 mods = [x for x in kw.get("mods", "").split(",") if x] return run(s, mods, opts, kw.get("level"), kw.get("seq"), kw["out"], keep="--keep" in pos, retry="--no-retry" not in pos) else: print(__doc__) return 2 except Crash as e: log("CRASH: %s" % e) return 3 except Timeout as e: log("TIMEOUT: %s" % e) return 4 return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))