mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
Let tools/run_game.py run a Linux game folder and a save from a crash report.
The scripted-run tool finds the game under each name the build tools give it, counts other copies through /proc where there is no tasklist, uses the game folder's own profile when it has one, takes a save folder or zip from elsewhere through the launcher's save search (a single slot replaces only that slot), and --wall ends a run that never reaches the world clock. Pull request #310 by mooki000, taken as it is except for its note that game time is not counted on 1.1.1, which no longer holds. --help runs on Windows; nothing was run on Linux here. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
@@ -148,6 +148,8 @@ The PS button is not used. The Controls tab binds and remaps its buttons like an
|
||||
|
||||
`NX2NAVI_INPUT_RECORD=<file>` (`input_record` in `nx2navi.ini`) writes the merged input in the same format, one line per button press and per stick segment. The file replays as a script, which makes it the repro to attach to a bug report.
|
||||
|
||||
`tools/run_game.py <game folder> --save <save> --seconds <s> --wall <w> [--script <file>] [--shot <png>]` runs the game on a copy of its profile with a save swapped in, under the script (default `tools/input/load_save.txt`: title screen to the first save slot). `<save>` names one of the game's save backups (`profile/save-backup/<time>`; the game keeps the last three) or is a save folder or `.zip` from elsewhere, such as the zipped profile folder of a crash report; a single slot (`slot_NN`) replaces only that slot. The run ends once `<s>` seconds of game time passed after the save loaded, `--shot` saving that frame as a PNG and `--env NX2NAVI_SHOTS=w<from>-<to>:<step>` one every `<step>` seconds of game time along the way (480x270, full size with `NX2NAVI_SHOTS_FULL=1`), or after `<w>` seconds of wall time. `--env NX2NAVI_SHOT_DIR=<dir> --env NX2NAVI_SHOT_AFTER=<s> --env NX2NAVI_SHOT_EVERY=<s>` takes pictures by wall time instead, for a script that never reaches a place where game time counts. On Windows it also takes `build\<ver>` folders and the saves in `build\save-backup`.
|
||||
|
||||
`NX2NAVI_TEST_PAD=<lx>,<ly>,<rx>,<ry>` attaches an SDL virtual gamepad at start whose sticks rest at these raw SDL axis values (-32768 to 32767, down is +y), so a drifting or held stick can be tested without a controller: the values go through the gamepad mapping and `NX2NAVI_STICK_DEADZONE` like a real pad's, and `NX2NAVI_INPUT_RECORD` shows what reached the game. `NX2NAVI_TEST_PAD_AT=<ms>` attaches it that long after input starts instead and presses its south button a second later, which tests a controller switched on in play.
|
||||
|
||||
## RenderDoc capture (blocked)
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
"""Run a build of the game headless on a copy of the user's profile with a backed-up save and scripted input.
|
||||
"""Run a build of the game unattended 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 nx2navi.exe and ninja.exe processes ran, because
|
||||
timings taken next to them are not comparable.
|
||||
The profile is never written: each run copies it to <exe dir>/prof-<tag>, swaps in the save backup, and deletes the copy
|
||||
afterwards. It is the game folder's own profile when there is one (<exe dir>/profile, a folder make_game.py built), else
|
||||
build/profile. The log records how many other copies of the game and of Ninja ran, because timings taken next to them
|
||||
are not comparable.
|
||||
|
||||
python tools/run_game.py build/v143 --save <backup> --seconds 15 --tag idle --env NX2NAVI_TRACE=perf
|
||||
python tools/run_game.py build/v143 --save <backup> --script <input.txt> --seconds 18 --tag fight --quiet \
|
||||
--env NX2NAVI_TRACE=perf,sched
|
||||
python3 tools/run_game.py ~/Games/Ocarina --save <report.zip> --seconds 600 --wall 90 \
|
||||
--env NX2NAVI_SHOT_DIR=shots --env NX2NAVI_SHOT_AFTER=40 --env NX2NAVI_SHOT_EVERY=10
|
||||
|
||||
<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 NX2NAVI_INPUT_RECORD replays the same way.
|
||||
<backup> is a folder of build/save-backup or of the profile's save-backup (the game keeps its last three saves there),
|
||||
or a save folder or .zip from elsewhere, such as a crash report's zipped profile folder; --script defaults to
|
||||
tools/input/load_save.txt (title screen to the first save slot), and a recording made with NX2NAVI_INPUT_RECORD replays
|
||||
the same way. --wall ends a run whose script never reaches the world clock; NX2NAVI_SHOT_EVERY takes its pictures by
|
||||
wall time.
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
@@ -19,6 +25,7 @@ import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from ctypes import wintypes
|
||||
from pathlib import Path
|
||||
|
||||
@@ -28,21 +35,43 @@ else:
|
||||
import fcntl
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from launcher import saves # noqa: E402 (Import Saves' search, for a save from elsewhere)
|
||||
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('NX2NAVI_MAX_RUNS', '3')) # more parallel runs exhaust device memory (16 GB RX 9070)
|
||||
# The game's executable as each tool names it: build_game.py, make_game.py on Windows, make_game.py on Linux,
|
||||
# build_game.py on Linux
|
||||
GAME_EXES = ('nx2navi.exe', 'ocarina.exe', 'ocarina', 'nx2navi')
|
||||
NINJA = 'ninja.exe' if os.name == 'nt' else 'ninja'
|
||||
|
||||
|
||||
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())
|
||||
"""Running processes of this executable name."""
|
||||
if os.name == 'nt':
|
||||
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())
|
||||
n = 0
|
||||
for comm in Path('/proc').glob('[0-9]*/comm'): # the process name, cut to 15 characters
|
||||
try:
|
||||
n += comm.read_text().strip() == image[:15]
|
||||
except OSError: # the process ended while the list was read
|
||||
pass
|
||||
return n
|
||||
|
||||
|
||||
def busy():
|
||||
return count('nx2navi.exe') + count('ninja.exe')
|
||||
def busy(game):
|
||||
return count(game) + count(NINJA)
|
||||
|
||||
|
||||
def game_exe(exe_dir):
|
||||
for name in GAME_EXES:
|
||||
if (exe_dir / name).is_file():
|
||||
return exe_dir / name
|
||||
sys.exit(f'run_game: {exe_dir} holds none of {", ".join(GAME_EXES)}')
|
||||
|
||||
|
||||
def keep_in_background(proc):
|
||||
@@ -76,11 +105,9 @@ def take_slot():
|
||||
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)
|
||||
if try_lock(f):
|
||||
return f
|
||||
except OSError:
|
||||
f.close()
|
||||
f.close()
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
@@ -137,66 +164,106 @@ def release_gpu_lock(f):
|
||||
f.close()
|
||||
|
||||
|
||||
def profile_copy(dest, save):
|
||||
def save_source(save, profile, scratch):
|
||||
"""(folder, slot, names to leave out) of the save to run. A backup named in build/save-backup or the profile's
|
||||
save-backup is a whole save. A folder or .zip from elsewhere (a crash report's zipped profile folder, a game folder,
|
||||
an emulator's save) is searched as the launcher's Import Saves searches it; slot names the one slot it holds, None
|
||||
for a whole save."""
|
||||
if Path(save).name == save: # a backup's name; a path is searched below
|
||||
for d in (SAVES / save, profile / 'save-backup' / save):
|
||||
if d.is_dir():
|
||||
return d, None, ()
|
||||
path = Path(save).expanduser()
|
||||
if path.is_file() and zipfile.is_zipfile(path):
|
||||
with zipfile.ZipFile(path) as z:
|
||||
if sum(i.file_size for i in z.infolist()) > saves.MAX_BYTES:
|
||||
sys.exit(f'run_game: {path} unpacks to more than a save holds')
|
||||
z.extractall(scratch) # zipfile leaves out absolute paths and '..' parts
|
||||
path = scratch
|
||||
found, skip = saves.find_save(path)
|
||||
if found is None:
|
||||
sys.exit(f'run_game: {save} is no backup in {SAVES} or {profile / "save-backup"}, and no save folder or .zip')
|
||||
return found, saves._one_slot(found), skip
|
||||
|
||||
|
||||
def profile_copy(dest, src, save=None, slot=None, skip=()):
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
shutil.copytree(PROFILE, dest)
|
||||
shutil.copytree(src, 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])
|
||||
target = users[0] / slot if slot else users[0]
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
shutil.copytree(save, target, ignore=lambda d, names: [n for n in names if Path(d) == Path(save) and n in skip])
|
||||
# a finished or interrupted commit in save-tx would be loaded in place of the save swapped in (fs_host.c)
|
||||
shutil.rmtree(dest / 'save-tx', ignore_errors=True)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument('exe_dir', type=Path, help='build directory holding nx2navi.exe')
|
||||
ap.add_argument('--save', help='directory name under build/save-backup; default: the profile as it is')
|
||||
ap.add_argument('exe_dir', type=Path, help='build directory or game folder holding ' + ', '.join(GAME_EXES))
|
||||
ap.add_argument('--save', help="the save to run: a backup's name in build/save-backup or the profile's save-backup, "
|
||||
"or a save folder or .zip from elsewhere (a crash report's zipped profile folder); "
|
||||
'default: the profile as it is')
|
||||
ap.add_argument('--script', type=Path, default=ROOT / 'tools' / 'input' / 'load_save.txt', help='NX2NAVI_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('--wall', type=float, help='stop the game after this many seconds of wall time if it has not ended '
|
||||
'(a script that never reaches the world clock)')
|
||||
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 nx2navi.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')
|
||||
ap.add_argument('--quiet', action='store_true', help='wait until no other copy of the game or Ninja runs (benchmarks)')
|
||||
ap.add_argument('--show', action='store_true',
|
||||
help='Windows: open the window on top; by default it opens behind other windows')
|
||||
a = ap.parse_args()
|
||||
|
||||
exe_dir = a.exe_dir.resolve()
|
||||
exe = game_exe(exe_dir)
|
||||
profile = exe_dir / 'profile' if (exe_dir / 'profile').is_dir() else PROFILE
|
||||
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():
|
||||
while busy(exe.name):
|
||||
time.sleep(10)
|
||||
prof = exe_dir / f'prof-{a.tag}'
|
||||
profile_copy(prof, a.save)
|
||||
scratch = exe_dir / f'prof-{a.tag}.import'
|
||||
profile_copy(prof, profile, *(save_source(a.save, profile, scratch) if a.save else ()))
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
|
||||
env = dict(os.environ, NX2NAVI_ASSUME_FOCUS='1', NX2NAVI_INPUT='none', NX2NAVI_INPUT_SCRIPT=str(a.script.resolve()))
|
||||
if not a.show:
|
||||
if not a.show and os.name == 'nt': # SDL windows open in front either way
|
||||
env['NX2NAVI_BACKGROUND_WINDOW'] = '1'
|
||||
for kv in a.env:
|
||||
k, _, v = kv.partition('=')
|
||||
env[k] = v
|
||||
args = [str(exe_dir / 'nx2navi.exe'), '--profile', str(prof), '--seconds', str(a.seconds)]
|
||||
args = [str(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: nx2navi.exe {count("nx2navi.exe")}, ninja.exe {count("ninja.exe")}\n')
|
||||
f.write(f'run_game: others running before: {exe.name} {count(exe.name)}, {NINJA} {count(NINJA)}\n')
|
||||
f.flush()
|
||||
start = time.time()
|
||||
proc = subprocess.Popen(args, cwd=exe_dir, env=env, stdout=f, stderr=subprocess.STDOUT)
|
||||
if not a.show:
|
||||
if not a.show and os.name == 'nt':
|
||||
threading.Thread(target=keep_in_background, args=(proc,), daemon=True).start()
|
||||
rc = proc.wait()
|
||||
try:
|
||||
rc = proc.wait(timeout=a.wall)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
rc = 124
|
||||
f.write(f'run_game: stopped at --wall {a.wall:g} s\n')
|
||||
f.write(f'run_game: rc={rc} wall={time.time() - start:.0f}s; others running after: '
|
||||
f'nx2navi.exe {count("nx2navi.exe")}, ninja.exe {count("ninja.exe")}\n')
|
||||
f'{exe.name} {count(exe.name)}, {NINJA} {count(NINJA)}\n')
|
||||
finally:
|
||||
shutil.rmtree(prof, ignore_errors=True)
|
||||
finally:
|
||||
|
||||
Reference in New Issue
Block a user