mirror of
https://github.com/valentderah/totk-pc-recomp.git
synced 2026-11-11 01:41:02 +00:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
8.2 KiB
Python
210 lines
8.2 KiB
Python
"""Run a build of the game headless on a copy of the user's profile with a backed-up save and scripted input.
|
|
|
|
The user's profile (build/profile) is never written: each run copies it to <exe dir>/prof-<tag>, swaps in the save
|
|
backup, and deletes the copy afterwards. The log records how many other totk.exe and ninja.exe processes ran, because
|
|
timings taken next to them are not comparable.
|
|
|
|
python tools/run_game.py build/v143 --save <backup> --seconds 15 --tag idle --env TOTK_TRACE=perf
|
|
python tools/run_game.py build/v143 --save <backup> --script <input.txt> --seconds 18 --tag fight --quiet \
|
|
--env TOTK_TRACE=perf,sched
|
|
|
|
<backup> is a folder of build/save-backup; --script defaults to tools/input/load_save.txt (title screen to the first
|
|
save slot), and a recording made with TOTK_INPUT_RECORD replays the same way.
|
|
"""
|
|
import argparse
|
|
import ctypes
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from ctypes import wintypes
|
|
from pathlib import Path
|
|
|
|
if os.name == 'nt':
|
|
import msvcrt
|
|
else:
|
|
import fcntl
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
PROFILE = ROOT / 'build' / 'profile'
|
|
SAVES = ROOT / 'build' / 'save-backup'
|
|
SLOTS = ROOT / 'build' / 'run-slots'
|
|
GPU_LOCK = ROOT / 'build' / '.gpu-run.lock'
|
|
LOCK_OFFSET = 1 << 16 # Windows byte-range lock sits past the holder text, so other processes can still read it
|
|
MAX_RUNS = int(os.environ.get('TOTK_MAX_RUNS', '3')) # more parallel runs exhaust device memory (16 GB RX 9070)
|
|
|
|
|
|
def count(image):
|
|
out = subprocess.run(['tasklist', '/FI', f'IMAGENAME eq {image}', '/NH'], capture_output=True, text=True).stdout
|
|
return sum(line.lower().startswith(image) for line in out.splitlines())
|
|
|
|
|
|
def busy():
|
|
return count('totk.exe') + count('ninja.exe')
|
|
|
|
|
|
def keep_in_background(proc):
|
|
"""Push the game window behind the user's windows as soon as it appears, for builds older than
|
|
TOTK_BACKGROUND_WINDOW."""
|
|
user32 = ctypes.windll.user32
|
|
user32.GetWindowThreadProcessId.argtypes = (wintypes.HWND, ctypes.POINTER(wintypes.DWORD))
|
|
user32.SetWindowPos.argtypes = (wintypes.HWND, wintypes.HWND, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int,
|
|
wintypes.UINT)
|
|
found = []
|
|
enum_proc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
|
|
|
def match(hwnd, _):
|
|
pid = wintypes.DWORD()
|
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
|
if pid.value == proc.pid and user32.IsWindowVisible(hwnd):
|
|
found.append(hwnd)
|
|
return True
|
|
|
|
callback = enum_proc(match)
|
|
while proc.poll() is None and not found:
|
|
user32.EnumWindows(callback, 0)
|
|
time.sleep(0.05)
|
|
for hwnd in found:
|
|
user32.SetWindowPos(hwnd, 1, 0, 0, 0, 0, 0x0001 | 0x0002 | 0x0010) # HWND_BOTTOM; NOSIZE | NOMOVE | NOACTIVATE
|
|
|
|
|
|
def take_slot():
|
|
"""Block until one of MAX_RUNS run slots is free and hold it (a locked file) until this process exits."""
|
|
SLOTS.mkdir(parents=True, exist_ok=True)
|
|
while True:
|
|
for i in range(MAX_RUNS):
|
|
f = open(SLOTS / f'slot{i}.lock', 'a+b')
|
|
try:
|
|
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
|
|
return f
|
|
except OSError:
|
|
f.close()
|
|
time.sleep(5)
|
|
|
|
|
|
def try_lock(f):
|
|
"""Take an exclusive OS lock on the open file without blocking; False if another process holds it."""
|
|
try:
|
|
if os.name == 'nt':
|
|
f.seek(LOCK_OFFSET)
|
|
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
|
|
else:
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except OSError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def holder_of(path):
|
|
try:
|
|
return path.read_text(encoding='utf-8', errors='replace').strip() or 'unknown'
|
|
except OSError:
|
|
return 'unknown'
|
|
|
|
|
|
def take_gpu_lock(exe_dir, poll=2.0):
|
|
"""Block until this process holds the machine-wide measured-run lock (build/.gpu-run.lock) and return the open
|
|
file. The OS drops the lock when the process exits or dies, so a stale file never blocks. The file text names the
|
|
holder (pid and build path) for the waiters."""
|
|
GPU_LOCK.parent.mkdir(parents=True, exist_ok=True)
|
|
f = open(GPU_LOCK, 'a+b')
|
|
waiting = False
|
|
while not try_lock(f):
|
|
if not waiting:
|
|
print(f'run_game: --quiet waits for the GPU run lock held by {holder_of(GPU_LOCK)}', flush=True)
|
|
waiting = True
|
|
time.sleep(poll)
|
|
f.seek(0)
|
|
f.truncate()
|
|
f.write(f'pid {os.getpid()}, build {exe_dir}'.encode())
|
|
f.flush()
|
|
return f
|
|
|
|
|
|
def release_gpu_lock(f):
|
|
if f is None:
|
|
return
|
|
try:
|
|
if os.name == 'nt':
|
|
f.seek(LOCK_OFFSET)
|
|
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
|
|
else:
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
except OSError:
|
|
pass
|
|
f.close()
|
|
|
|
|
|
def profile_copy(dest, save):
|
|
if dest.exists():
|
|
shutil.rmtree(dest)
|
|
shutil.copytree(PROFILE, dest)
|
|
if save:
|
|
users = [d for d in (dest / 'save').iterdir() if d.is_dir()]
|
|
if len(users) != 1:
|
|
sys.exit(f'run_game: expected one save directory in {dest / "save"}, found {len(users)}')
|
|
shutil.rmtree(users[0])
|
|
shutil.copytree(SAVES / save, users[0])
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument('exe_dir', type=Path, help='build directory holding totk.exe')
|
|
ap.add_argument('--save', help='directory name under build/save-backup; default: the profile as it is')
|
|
ap.add_argument('--script', type=Path, default=ROOT / 'tools' / 'input' / 'load_save.txt', help='TOTK_INPUT_SCRIPT')
|
|
ap.add_argument('--seconds', type=float, required=True,
|
|
help='game time to run: the run ends once the world clock (game time since the save loaded) passed '
|
|
'this many seconds (shot is taken of that frame)')
|
|
ap.add_argument('--tag', default='run', help='names the profile copy and the log')
|
|
ap.add_argument('--shot', type=Path, help='PNG of the last frame; turns async pipeline builds off (boot.c)')
|
|
ap.add_argument('--log', type=Path, help='default: <exe dir>/logs/<tag>.log')
|
|
ap.add_argument('--env', action='append', default=[], metavar='KEY=VALUE', help='extra environment, repeatable')
|
|
ap.add_argument('--quiet', action='store_true', help='wait until no other totk.exe or ninja.exe runs (benchmarks)')
|
|
ap.add_argument('--show', action='store_true', help='open the window on top; by default it opens behind other windows')
|
|
a = ap.parse_args()
|
|
|
|
exe_dir = a.exe_dir.resolve()
|
|
log = (a.log or exe_dir / 'logs' / f'{a.tag}.log').resolve()
|
|
log.parent.mkdir(parents=True, exist_ok=True)
|
|
gpu = take_gpu_lock(exe_dir) if a.quiet else None
|
|
try:
|
|
slot = take_slot()
|
|
if a.quiet:
|
|
while busy():
|
|
time.sleep(10)
|
|
prof = exe_dir / f'prof-{a.tag}'
|
|
profile_copy(prof, a.save)
|
|
|
|
env = dict(os.environ, TOTK_ASSUME_FOCUS='1', TOTK_INPUT='none', TOTK_INPUT_SCRIPT=str(a.script.resolve()))
|
|
if not a.show:
|
|
env['TOTK_BACKGROUND_WINDOW'] = '1'
|
|
for kv in a.env:
|
|
k, _, v = kv.partition('=')
|
|
env[k] = v
|
|
args = [str(exe_dir / 'totk.exe'), '--profile', str(prof), '--seconds', str(a.seconds)]
|
|
if a.shot:
|
|
args += ['--shot', str(a.shot.resolve())]
|
|
try:
|
|
with open(log, 'w', encoding='utf-8', errors='replace') as f:
|
|
f.write(f'run_game: others running before: totk.exe {count("totk.exe")}, ninja.exe {count("ninja.exe")}\n')
|
|
f.flush()
|
|
start = time.time()
|
|
proc = subprocess.Popen(args, cwd=exe_dir, env=env, stdout=f, stderr=subprocess.STDOUT)
|
|
if not a.show:
|
|
threading.Thread(target=keep_in_background, args=(proc,), daemon=True).start()
|
|
rc = proc.wait()
|
|
f.write(f'run_game: rc={rc} wall={time.time() - start:.0f}s; others running after: '
|
|
f'totk.exe {count("totk.exe")}, ninja.exe {count("ninja.exe")}\n')
|
|
finally:
|
|
shutil.rmtree(prof, ignore_errors=True)
|
|
finally:
|
|
release_gpu_lock(gpu)
|
|
print(f'{log}: rc={rc}')
|
|
sys.exit(rc)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|