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:
chrg6
2026-11-10 06:17:42 -05:00
parent c117cd8999
commit 19eeb29cda
2 changed files with 99 additions and 30 deletions

View File

@@ -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)

View File

@@ -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: