mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
F8 now always writes a folder under logs/captures: the area, Link's position and rotation, the camera of the frame on screen and the game's own, the presented frame as a PNG, frame times around the press, the machine, the settings that differ from the defaults, the end of the log, the save, and the game's flags and texts. A note on screen names the capture. The flight recorder's F8 dump goes into the capture's folder. NX2NAVI_CAPTURE_REPLAY starts a run on a capture's settings, teleports to its area, walks Link to where he stood and puts the free camera where the camera was; tools/capture_run.py prepares the profile from the capture's save, runs it and compares the two captures. docs/bug-capture.md has the files and the steps. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
337 lines
15 KiB
Python
337 lines
15 KiB
Python
"""Start a test run from a bug capture (runtime/bugcap.c, docs/bug-capture.md).
|
|
|
|
python tools/capture_run.py <capture folder or number> [--game DIR] [--set KEY=VALUE ...]
|
|
|
|
The game starts on a throwaway profile holding the capture's save, with the capture's settings in place of nx2navi.ini
|
|
(NX2NAVI_CAPTURE_REPLAY), in a window of the capture's size that does not take the focus. It loads the save, goes to
|
|
the capture's area, walks Link to where he stood, puts the camera where it was and takes a capture of its own; this
|
|
script then closes the game and prints both captures side by side: the positions, the distance between them, and how
|
|
much the two pictures differ. The player's nx2navi.ini and save are not written.
|
|
|
|
One game at a time: the script refuses to start while a game is running.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import os
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import zipfile
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
WIN = os.name == 'nt'
|
|
SKIP_IN_PROFILE = {'save', 'save-backup', 'save-tx', 'save-import', 'save-import.part', 'coop-backup', 'instance.lock'}
|
|
|
|
|
|
def default_game():
|
|
if WIN:
|
|
return Path(os.environ.get('LOCALAPPDATA', '')) / 'Programs' / 'NX2navi' / 'game'
|
|
return Path.home() / '.local' / 'share' / 'NX2navi' / 'game'
|
|
|
|
|
|
def find_exe(game):
|
|
for name in ('ocarina.exe', 'nx2navi.exe', 'ocarina', 'nx2navi'):
|
|
if (game / name).is_file():
|
|
return game / name
|
|
return None
|
|
|
|
|
|
def read_capture(folder):
|
|
"""capture.txt as a dict of its "key = value" lines (the first of a key wins)."""
|
|
out = {}
|
|
for line in (folder / 'capture.txt').read_text(encoding='utf-8', errors='replace').splitlines():
|
|
key, sep, value = line.partition(' = ')
|
|
if sep and not key.startswith('#') and key not in out:
|
|
out[key] = value.split(' # ')[0].strip()
|
|
return out
|
|
|
|
|
|
def find_capture(arg, game):
|
|
p = Path(arg)
|
|
if p.name == 'capture.txt':
|
|
p = p.parent
|
|
if (p / 'capture.txt').is_file():
|
|
return p.resolve()
|
|
if arg.isdigit():
|
|
hits = sorted((game / 'logs' / 'captures').glob(f'{int(arg):03d}-*'))
|
|
hits = [h for h in hits if (h / 'capture.txt').is_file()]
|
|
if hits:
|
|
return hits[-1].resolve()
|
|
return None
|
|
|
|
|
|
def game_pids(exe):
|
|
"""The ids of the processes running the game's executable, by its file name."""
|
|
if WIN:
|
|
out = subprocess.run(['tasklist', '/FI', f'IMAGENAME eq {exe.name}', '/FO', 'CSV', '/NH'], capture_output=True, text=True).stdout
|
|
pids = []
|
|
for line in out.splitlines():
|
|
cells = [c.strip('"') for c in line.split('","')]
|
|
if len(cells) > 1 and cells[0].lower() == exe.name.lower() and cells[1].isdigit():
|
|
pids.append(int(cells[1]))
|
|
return pids
|
|
out = subprocess.run(['pgrep', '-x', exe.name], capture_output=True, text=True).stdout
|
|
return [int(x) for x in out.split() if x.isdigit()]
|
|
|
|
|
|
def game_running(exe):
|
|
return bool(game_pids(exe))
|
|
|
|
|
|
def make_profile(profile, game, capture):
|
|
"""A profile whose save is the capture's: the game's own profile without its saves, and the capture's save zip
|
|
as a waiting import (runtime/fs_host.c import_pending), which the game makes the save at its start."""
|
|
src = game / 'profile'
|
|
profile.mkdir(parents=True)
|
|
if src.is_dir():
|
|
for entry in src.iterdir():
|
|
if entry.name in SKIP_IN_PROFILE:
|
|
continue
|
|
if entry.is_dir():
|
|
shutil.copytree(entry, profile / entry.name)
|
|
else:
|
|
shutil.copy2(entry, profile / entry.name)
|
|
zips = sorted(capture.glob('NX2navi-save-*.zip'))
|
|
if not zips:
|
|
if (src / 'save').is_dir():
|
|
shutil.copytree(src / 'save', profile / 'save')
|
|
return 'the capture holds no save: a copy of the save the game has now'
|
|
staged = profile / 'save-import'
|
|
files = 0
|
|
with zipfile.ZipFile(zips[0]) as z:
|
|
for info in z.infolist():
|
|
name = info.filename
|
|
if info.is_dir() or not name.startswith('save/'):
|
|
continue
|
|
rel = Path(name[5:])
|
|
if rel.is_absolute() or '..' in rel.parts or name.lower().endswith(('.keys', '.keys.txt')):
|
|
continue
|
|
dst = staged / rel
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
dst.write_bytes(z.read(info))
|
|
files += 1
|
|
return f'the capture\'s save ({files} files from {zips[0].name})'
|
|
|
|
|
|
def close_game(proc, exe, grace=30):
|
|
"""Ask the game's windows to close, as the window's own button does (it then writes its exit lines); by force when
|
|
that does not end it. The game may be a child of the process this script started: at its start it runs itself
|
|
again when Windows has put something where its memory must go (runtime/platform/win32.c plat_reexec), and the
|
|
first process only waits for the second. No other game ran when this one started (main checks), so every process
|
|
of the executable is this run's."""
|
|
if proc.poll() is not None and not game_pids(exe):
|
|
return 'had ended'
|
|
if WIN:
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
user32 = ctypes.windll.user32
|
|
proto = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
|
pids = set(game_pids(exe)) | {proc.pid}
|
|
|
|
def each(hwnd, _):
|
|
pid = wintypes.DWORD()
|
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
|
if pid.value in pids and user32.IsWindowVisible(hwnd):
|
|
user32.PostMessageW(hwnd, 0x10, 0, 0) # WM_CLOSE
|
|
return True
|
|
user32.EnumWindows(proto(each), 0)
|
|
else:
|
|
proc.terminate()
|
|
end = time.time() + grace
|
|
while time.time() < end and (proc.poll() is None or game_pids(exe)):
|
|
time.sleep(0.5)
|
|
if proc.poll() is not None and not game_pids(exe):
|
|
return 'closed'
|
|
if WIN:
|
|
for pid in game_pids(exe):
|
|
subprocess.run(['taskkill', '/PID', str(pid), '/T', '/F'], capture_output=True)
|
|
if proc.poll() is None:
|
|
proc.kill()
|
|
proc.wait()
|
|
time.sleep(1)
|
|
return 'STILL RUNNING: close it by hand' if game_pids(exe) else 'closed by force'
|
|
|
|
|
|
def read_png(path):
|
|
"""(width, height, bytes per pixel, rows) of an 8-bit RGB or RGBA PNG without interlacing, as the game writes."""
|
|
data = path.read_bytes()
|
|
if data[:8] != b'\x89PNG\r\n\x1a\n':
|
|
raise ValueError('not a PNG')
|
|
at, idat, head = 8, [], None
|
|
while at + 8 <= len(data):
|
|
n, kind = struct.unpack('>I4s', data[at:at + 8])
|
|
body = data[at + 8:at + 8 + n]
|
|
if kind == b'IHDR':
|
|
head = struct.unpack('>IIBBBBB', body)
|
|
elif kind == b'IDAT':
|
|
idat.append(body)
|
|
at += 12 + n
|
|
w, h, depth, colour, _, _, interlace = head
|
|
if depth != 8 or colour not in (2, 6) or interlace:
|
|
raise ValueError('a PNG kind this does not read')
|
|
bpp = 3 if colour == 2 else 4
|
|
raw = zlib.decompress(b''.join(idat))
|
|
stride = w * bpp
|
|
rows, prev = [], bytearray(stride)
|
|
for y in range(h):
|
|
f = raw[y * (stride + 1)]
|
|
line = bytearray(raw[y * (stride + 1) + 1:(y + 1) * (stride + 1)])
|
|
if f == 1:
|
|
for i in range(bpp, stride):
|
|
line[i] = (line[i] + line[i - bpp]) & 255
|
|
elif f == 2:
|
|
for i in range(stride):
|
|
line[i] = (line[i] + prev[i]) & 255
|
|
elif f == 3:
|
|
for i in range(stride):
|
|
line[i] = (line[i] + ((line[i - bpp] if i >= bpp else 0) + prev[i]) // 2) & 255
|
|
elif f == 4:
|
|
for i in range(stride):
|
|
a = line[i - bpp] if i >= bpp else 0
|
|
b = prev[i]
|
|
c = prev[i - bpp] if i >= bpp else 0
|
|
p = a + b - c
|
|
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
|
line[i] = (line[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255
|
|
rows.append(line)
|
|
prev = line
|
|
return w, h, bpp, rows
|
|
|
|
|
|
def picture_difference(a, b, step=4):
|
|
"""The mean difference of the colour channels (0 to 255) over every step-th pixel of two pictures of one size."""
|
|
wa, ha, bpa, ra = read_png(a)
|
|
wb, hb, bpb, rb = read_png(b)
|
|
if (wa, ha) != (wb, hb):
|
|
return None, f'{wa}x{ha} against {wb}x{hb}'
|
|
total = count = 0
|
|
for y in range(0, ha, step):
|
|
la, lb = ra[y], rb[y]
|
|
for x in range(0, wa, step):
|
|
for c in range(3):
|
|
total += abs(la[x * bpa + c] - lb[x * bpb + c])
|
|
count += 3
|
|
return total / count, f'{wa}x{ha}'
|
|
|
|
|
|
def sha(path):
|
|
return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else ''
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description='Start a test run at a bug capture\'s place, camera and settings.')
|
|
ap.add_argument('capture', help='a capture\'s folder, or its number in the game\'s logs/captures')
|
|
ap.add_argument('--game', type=Path, default=default_game(), help='the game folder (default: the installed game)')
|
|
ap.add_argument('--exe', type=Path, help='the game\'s executable (default: the one in the game folder)')
|
|
ap.add_argument('--out', type=Path, help='where the run\'s log and its own capture go (default: <capture>/replays)')
|
|
ap.add_argument('--seconds', type=int, default=300, help='give up after this long (default 300)')
|
|
ap.add_argument('--hold', type=int, default=0, help='seconds to keep the game open after its capture, to watch or test')
|
|
ap.add_argument('--set', action='append', default=[], metavar='KEY=VALUE',
|
|
help='a setting for this run on top of the capture\'s (NX2NAVI_ is added), e.g. --set FSR4=0')
|
|
ap.add_argument('--no-camera', action='store_true', help='leave the camera to the game instead of placing it')
|
|
ap.add_argument('--fullscreen', action='store_true', help='keep the capture\'s fullscreen and window settings')
|
|
ap.add_argument('--foreground', action='store_true', help='a normal window that takes the focus and your input')
|
|
args = ap.parse_args()
|
|
|
|
game = args.game.resolve()
|
|
exe = (args.exe or find_exe(game) or game / 'ocarina.exe').resolve()
|
|
if not exe.is_file():
|
|
sys.exit(f'no game executable in {game}')
|
|
capture = find_capture(args.capture, game)
|
|
if not capture:
|
|
sys.exit(f'no capture.txt found for "{args.capture}" (a folder, or a number under {game / "logs" / "captures"})')
|
|
if game_running(exe):
|
|
sys.exit(f'{exe.name} is running: one game at a time. Close it first.')
|
|
cap = read_capture(capture)
|
|
out = (args.out or capture / 'replays').resolve()
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
stamp = time.strftime('%Y%m%d-%H%M%S')
|
|
profile = out / f'profile-{stamp}'
|
|
log = out / f'run-{stamp}.log'
|
|
print(f'capture {cap.get("number", "?")} ({capture.name}): {cap.get("area", "no area")}, Link at {cap.get("player_pos", "?")}, '
|
|
f'camera at {cap.get("camera_pos", "not captured")}; NX2navi {cap.get("nx2navi", "?")}')
|
|
print('profile:', make_profile(profile, game, capture))
|
|
|
|
env = {k: v for k, v in os.environ.items() if not k.upper().startswith('NX2NAVI_')}
|
|
pins = {'CAPTURE_REPLAY': str(capture), 'CAPTURE_DIR': str(out), 'COOP': '0', 'COOP_STEAM': '0', 'DISCORD_RPC': '0'}
|
|
if not args.fullscreen:
|
|
pins['FULLSCREEN'] = '0'
|
|
if 'x' in cap.get('window', ''):
|
|
pins['WINDOW_SIZE'] = cap['window']
|
|
if not args.foreground:
|
|
pins.update(BACKGROUND_WINDOW='1', ASSUME_FOCUS='1', INPUT='none')
|
|
if args.no_camera:
|
|
pins['CAPTURE_REPLAY_CAMERA'] = '0'
|
|
for pair in args.set:
|
|
key, sep, value = pair.partition('=')
|
|
if not sep:
|
|
sys.exit(f'--set takes KEY=VALUE, not "{pair}"')
|
|
key = key.strip().upper()
|
|
pins[key[8:] if key.startswith('NX2NAVI_') else key] = value
|
|
for key, value in pins.items():
|
|
env['NX2NAVI_' + key] = value
|
|
print('this run sets:', ', '.join(f'{k}={v}' for k, v in sorted(pins.items()) if k not in ('CAPTURE_REPLAY', 'CAPTURE_DIR')))
|
|
|
|
ini = game / 'nx2navi.ini'
|
|
ini_before = sha(ini)
|
|
before = {p.name for p in out.iterdir() if p.is_dir()}
|
|
result, state = None, 'timed out'
|
|
with open(log, 'wb') as err:
|
|
proc = subprocess.Popen([str(exe), '--profile', str(profile)], cwd=str(game), env=env, stderr=err,
|
|
stdout=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
|
|
t0 = time.time()
|
|
ready = False
|
|
try:
|
|
while time.time() - t0 < args.seconds and proc.poll() is None:
|
|
time.sleep(1)
|
|
text = log.read_text(encoding='utf-8', errors='replace')
|
|
if 'capture: replay of' in text and ' stopped: ' in text:
|
|
state = 'the replay stopped'
|
|
break
|
|
if not ready and ' ready: ' in text and 'capture: replay of' in text:
|
|
ready = True
|
|
if ready and any(' saved to ' in line and '(replay' in line for line in text.splitlines()):
|
|
state = 'done'
|
|
break
|
|
if proc.poll() is not None:
|
|
state = f'the game ended by itself (exit {proc.returncode})'
|
|
elif state == 'done' and args.hold:
|
|
print(f'the game stays open for {args.hold} s')
|
|
time.sleep(args.hold)
|
|
finally:
|
|
print('game:', close_game(proc, exe), f'after {time.time() - t0:.0f} s; {state}')
|
|
shutil.rmtree(profile, ignore_errors=True)
|
|
print('nx2navi.ini', 'unchanged' if sha(ini) == ini_before else 'CHANGED by the run: put it back from your copy')
|
|
|
|
text = log.read_text(encoding='utf-8', errors='replace')
|
|
for line in text.splitlines():
|
|
if 'capture: ' in line or 'debug: route' in line or ('debug: ' in line and ('eleport' in line or 'Arrived' in line)):
|
|
print(' ' + line[:300])
|
|
new = sorted(p for p in out.iterdir() if p.is_dir() and p.name not in before and (p / 'capture.txt').is_file())
|
|
if new:
|
|
result = new[-1]
|
|
rep = read_capture(result)
|
|
print(f'\nthe run\'s own capture: {result}')
|
|
for key in ('area', 'player_pos', 'camera_pos', 'camera_yaw', 'camera_pitch', 'camera_fov_y_degrees', 'window', 'game_image'):
|
|
a, b = cap.get(key, '-'), rep.get(key, '-')
|
|
print(f' {key:22} {a:38} {b:38} {"same" if a == b else ""}')
|
|
print(f' Link ended {rep.get("replay_player_off_metres", "?")} m from the captured place; the walk: {rep.get("replay_walk", "?")}; '
|
|
f'camera: {rep.get("replay_camera", "?")}')
|
|
if (capture / 'frame.png').is_file() and (result / 'frame.png').is_file():
|
|
try:
|
|
diff, size = picture_difference(capture / 'frame.png', result / 'frame.png')
|
|
print(f' pictures ({size}):', 'of different sizes' if diff is None else
|
|
f'mean difference {diff:.1f} of 255 per channel (two frames of one standing view differ by 1 to 4)')
|
|
except (ValueError, zlib.error, struct.error) as e:
|
|
print(' pictures: not compared:', e)
|
|
else:
|
|
print('\nno capture came out of the run; the log:', log)
|
|
return 0 if result else 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|