Files
nxnative-easy/scripts/regress.py
T
seeddsandv0lumehi b3f97efe22 regress.py usage / impact: which games a change can reach (#329)
From PR #329 (stacked on #328): a game's usage manifest, dm/games/<id>/usage.tsv,
written by its owner with regress.py usage (shader features of its chain build,
and the NVN / HLE functions, SVCs and present path of one smoke-route run;
names and counts only), and regress.py impact <base>..<head>, which maps the
functions around the changed lines through the host's callers and the rules of
dm/tools/impact_map.tsv to manifest rows. Manifests for Breath of the Wild,
Tears of the Kingdom and Hades.

Fixed on top of the PR:
- impact checked the head out as a git worktree under the harness root, so it
  stopped (PermissionError on /Volumes/NX) for exactly the contributors it is
  for, who have no games and no harness root. The call graph is now read from
  the git object store at the head revision, and the report is printed and
  written only to --out or an existing harness root.
- A hunk mapped only to the function at its first line: a hunk spanning two
  functions named one. Every added line is mapped now, and a pure deletion maps
  to the function around it (it was moved one line up).
- Functions defined in headers were not in the call graph: the frame step
  (nvn_fstep.h, static inline, called from nvnQueuePresentTexture) reached no
  entry point, so 4acde72 named no game. Headers are scanned, and their
  functions are visible to every file. 4acde72 now names both Zeldas through
  nvnQueuePresentTexture.

Co-authored-by: v0lumehi <aljic.zijad@gmail.com>
2026-10-27 08:50:52 +08:00

1225 lines
80 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# A regression harness for changes to the translation chain, the host or the tools, run on every game this Mac has a build of (GitHub issue #272: one chain and one host serve every
# profile, each contributor owns only some games, and CI has no game files and no Metal 4). It compares a BASELINE revision with a CANDIDATE and writes a report that holds only numbers,
# hashes, shader keys, normalized [RED] texts and timings (no screenshots, game data or keys), so it can go into a pull request.
#
# Usage: python3 scripts/regress.py check [--baseline REV] [--candidate REV|worktree] [--quick] [--games ID,...] [--no-smoke] [--out DIR]
# python3 scripts/regress.py baseline [--rev REV] [--quick] [--games ID,...] [--runs N]
# python3 scripts/regress.py chain [--baseline REV] [--candidate REV|worktree] [--games ID,...] (the static checks only, no game runs)
# python3 scripts/regress.py usage [--rev REV] [--games ID,...] [--write] (a game's usage manifest, dm/games/<id>/usage.tsv: dm/tools/usage.py)
# python3 scripts/regress.py impact <base>..<head> | <commit> (the games whose manifests hold what the change touches)
# python3 scripts/regress.py list (the games found, their routes and stored baselines)
# REV: any git revision of this repository; worktree = this checkout as it is, uncommitted and untracked files included. check: baseline = the merge base of HEAD and main,
# candidate = worktree; baseline: --rev main. Each revision is checked out once into <root>/trees/<id> (git worktree), and the chain, host and rewriter come from there.
# --root (REGRESS_ROOT, default /Volumes/NX/harness): source trees and public reports; game-derived files stay in <NSP folder>/<id>-work/regress/.
# --games-dir (REGRESS_GAMES, default /Volumes/NX/games): the NSP folders "<name> [<title ID>]", each with the
# <id>_build of scripts/build_ipa_from_nsp.py --platform mac. A game takes part when it has that build and, for the runs, a route dm/games/<id>/smoke.tsv (read from this checkout).
# REGRESS_SKIP=ID,...: games left out unless --games names them (a game another agent works on).
# --lock FILE (DM_MEASURE_LOCK): the measuring lock bench.py --lock shares, held only while a game runs.
#
# What it checks, per game:
# 1. Chain (static): the game's shaders translated by each revision's chain (dmsh_build.py through own_compilers.py; a Vulkan game: vk_shaders.py), compared shader by shader: built or failed
# (newly failing by the rule of dm/gfx/tools/shader_regressions.py, which the NSP script applies to one game's previous build; a new shader that fails is listed apart),
# the SHA-256 of the MSL (<key>_b.metal), of the reflection (<key>_b.json without its chain fields) and of the variant libraries (_bf, _bv, _bp, _bg*). A build is kept per chain version,
# shader package, profile inputs and compilers (<NSP folder>/<id>-work/regress/chain/<key>/), so a revision whose chain did not change costs nothing, and dmsh_build.py uses the build's own shader cache (DMSH_CAS), so MSL
# that did not change is not compiled again. Then the vertex / pixel interface of every pipeline base pso_offline.py would put into the pipeline archive (shader_pairs.tsv and
# pso_pairs.tsv): a pixel shader input [[user(locnN)]] the vertex shader does not write, or writes with another type, makes metal-tt refuse the whole archive (GitHub issue #283).
# 2. Smoke (runtime): the game in the Mac host (window, no audio), from a copy of the route's save, with the route's buttons, under a watchdog: the frame it must reach, crash (exit status,
# the host's CRASH line, a DiagnosticReports file), hang (no frame for `hang` seconds once frames came), the prepare step's time, [RED] lines (numbers, addresses and paths normalized)
# that the baseline did not print, frame times (bench.py's DM_NVN_TSV rows; a Vulkan / GL game: presents counted from the log), peak memory footprint, and perceptual hashes of
# screenshots at fixed times. The full mode first runs the pipeline prebuild (the host's DM_PSO_PREBUILD=tool, the step the Mac app's "Preparing pipelines" runs) under a watchdog
# and starts the game on its archive; --quick runs the chain checks and a 60 s boot without the prebuild (DM_PSO_PREBUILD=0).
# A route, dm/games/<id>/smoke.tsv, key <TAB> value lines: save (a folder relative to the NSP folder, `run` = <id>_build/run/save, `new` = empty), keys (DM_KEYS), secs (full run),
# quick (quick run, default 60), reach (<frame>@<seconds>: the frame number the game must have presented by then), window (<start> <TAB> <length>: the frame-time window),
# shots (comma-separated seconds), hang (seconds without a frame, default 20), prebuild (on / off: the full mode's prebuild, default on for an NVN game).
# 3. Baselines: `baseline` records the runs of a revision in <NSP folder>/<id>-work/regress/baselines/<commit>/; `check` compares with the one of the baseline revision and records it first when there is none.
#
# report.json (beside report.md, in --out, default <root>/reports/<date-time>-<command>/):
# {"schema": "nxnative-regress/1", "command", "mode": "quick"|"full", "status", "exit", "started", "secs", "machine": {model, gpu, macos, xcode},
# "baseline": {"rev", "sha"}, "candidate": {"rev", "sha", "dirty"},
# "games": {<title ID>: {"name", "status", "reasons": [text], "known": [text], "secs": {"chain", "smoke"},
# "chain": {"kind": "nvn"|"vk", "baseline": BUILD, "candidate": BUILD, "newly_failing": [{"key", "status", "error"}], "added_failing": [same],
# "newly_passing": [key],
# "changed": {"msl", "reflection", "variants", "shaders", "keys": [key, at most 200]}, "added": n, "removed": n,
# "interface": {"baseline": n, "candidate": n, "new": [PAIR], "known": [PAIR, at most 50]}},
# "smoke": {"route": {...}, "baseline": [RUN], "candidate": [RUN], "diff": {"new_red": [{"text", "count"}], "shots": {<s>: {"dhash_bits", "rgb_diff", "baseline_noise"}},
# "fps_p50_change_pct", "frame_p99_change_pct", "peak_footprint_change_pct", "prepare_change_s", "prebuild_change_s"}}}}}
# BUILD = {"key", "chain", "inputs", "total", "ok", "failed", "secs" (the build's time, 0 when reused), "failed_by_status": {status: n}}
# PAIR = {"vs", "entry", "ps", "missing": [locnN], "type": ["locnN vs-type/ps-type"]}
# RUN = {"exit": code or -signal or null (stopped by the harness), "crash": {"line", "type", "signal"} or null, "hang_at_s", "first_frame_s", "max_frame", "reached",
# "prepare_s", "prebuild": {"secs", "pairs", "built", "archived", "failed", "done", "hang"}, "fps", "frame_p50", "frame_p99", "frame_max", "dropped", "over_budget",
# "peak_footprint_mb", "red": {normalized text: count}, "shots": {<s>: {"dhash"}}, "secs", "modules", "host"}
# status per game, worst first: ERROR (the harness could not check it), NEW-FAIL (something that worked in the baseline fails: a shader, the interface check, a crash, a hang, the frame
# not reached), REGRESSION (new [RED] lines, a screenshot beyond the tolerance, frame time, memory or prepare time worse beyond it), CHANGED (MSL or numbers differ, nothing worse),
# PASS. "known" lists failures the baseline has too (they do not fail the check).
# Exit status: 0 = no regression, 1 = a regression (NEW-FAIL or REGRESSION) in some game, 2 = a harness error (and no regression found).
import argparse, collections, concurrent.futures as cf, ctypes, datetime, hashlib, importlib.util, json, os, re, shutil, signal, struct, subprocess, sys, time, traceback
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
def load_module(name, path): # a module from a file (another script, or a tool of a revision's tree)
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None: raise ImportError(f'cannot load {path}')
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
return mod
bench = load_module('bench', REPO / 'scripts/bench.py')
shader_regressions = load_module('shader_regressions', REPO / 'dm/gfx/tools/shader_regressions.py')
usage = load_module('usage', REPO / 'dm/tools/usage.py') # usage manifests and change impact (regress.py usage / impact)
ROOT = Path(os.environ.get('REGRESS_ROOT', '/Volumes/NX/harness'))
GAMES = Path(os.environ.get('REGRESS_GAMES', '/Volumes/NX/games'))
HOST_DIRS = ('dm/host', 'dm/gfx', 'dm/gfx/nvn', 'dm/gfx/nvn/include', 'dm/gfx/tex', 'dm/gfx/vk', 'dm/gfx/gl') # as build_ipa_from_nsp.py: the host's sources and headers
REWRITER = ('dm/tools/patch_nso.py', 'dm/tools/nso_code.py', 'dm/tools/nso_asm.py', 'dm/tools/nso_match.py', 'dm/tools/sdk_patches.py')
HEAVY_EXE = {'clang', 'make', 'xcodebuild', 'ld', 'ld64', 'swift-frontend', 'dotnet', 'metal', 'metal-tt', 'air-tt', 'applegpu-nt', 'dmsh_xlate', 'spirv-cross'} # jobs that skew a measurement
HEAVY_PY = r'(dmsh_build|shader_split|own_compilers|patch_nso|vk_shaders)\.py' # (the shared measuring rule)
# Tolerances, from runs of one revision against itself (docs/development.md, The regression harness)
TOL = {'fps_pct': 10.0, 'p99_pct': 30.0, 'p99_ms': 4.0, 'footprint_pct': 15.0, 'footprint_mb': 300.0, 'prepare_s': 10.0, 'prepare_x': 1.5, 'prebuild_x': 1.5, 'prebuild_s': 30.0,
'dhash_bits': 12, 'rgb_diff': 12.0}
class HarnessError(Exception):
pass
def say(msg):
print(msg, flush=True)
def sh(cmd, **kw):
kw.setdefault('capture_output', True); kw.setdefault('text', True)
return subprocess.run([str(c) for c in cmd], **kw)
def git(*a, cwd=REPO):
r = sh(['git', *a], cwd=cwd)
if r.returncode: raise HarnessError(f'git {" ".join(a)}: {r.stderr.strip()[-300:]}')
return r.stdout.strip()
def sha256_file(p, h=None):
h = h or hashlib.sha256()
with open(p, 'rb') as f:
while chunk := f.read(1 << 20): h.update(chunk)
return h
def digest(paths, extra=b'', root=None): # path-independent names and bytes; use relative names when a tree's directory layout matters
h = hashlib.sha256(extra)
for p in paths:
h.update((Path(p).relative_to(root).as_posix() if root else Path(p).name).encode() + b'\0')
if Path(p).is_file(): sha256_file(p, h)
else: h.update(b'missing')
h.update(b'\0')
return h.hexdigest()
def disk_free_gb(path='/System/Volumes/Data'):
st = os.statvfs(path); return st.f_bavail * st.f_frsize / 1e9
def check_disk(min_gb):
free = disk_free_gb()
if free < min_gb: raise HarnessError(f'the internal disk has {free:.1f} GB free, under {min_gb} GB (Metal caches land there): stopped')
return free
# ---------------------------------------------------------------- revisions
class Tree:
"""One revision checked out into <root>/trees/<id>: a commit, or this checkout's working tree (a stash commit of its tracked changes, its untracked files copied over)"""
def __init__(self, rev):
self.rev, self.dirty, untracked = rev, False, []
if rev == 'worktree':
head = git('rev-parse', 'HEAD')
status = git('status', '--porcelain', '--untracked-files=all')
self.sha = (git('stash', 'create') or head) if status else head
untracked = [u for u in git('ls-files', '--others', '--exclude-standard', '-z').split('\0') if u]
self.dirty = bool(status)
self.id = self.sha[:12] + ('-u' + digest([REPO / u for u in untracked], root=REPO)[:8] if untracked else '')
self.commit = head
else:
self.sha = git('rev-parse', '--verify', rev + '^{commit}'); self.id = self.sha[:12]; self.commit = self.sha
self.path = ROOT / 'trees' / self.id
if not (self.path / '.git').exists():
shutil.rmtree(self.path, ignore_errors=True); self.path.parent.mkdir(parents=True, exist_ok=True)
sh(['git', 'worktree', 'prune'], cwd=REPO)
git('worktree', 'add', '--detach', str(self.path), self.sha)
for u in untracked:
(self.path / u).parent.mkdir(parents=True, exist_ok=True); shutil.copy2(REPO / u, self.path / u)
def py(self, code, env): # a Python snippet run in the tree (its own modules)
r = sh([sys.executable, '-c', code], cwd=self.path, env=env)
if r.returncode: raise HarnessError(f'{self.id}: {r.stderr.strip()[-400:]}')
return r.stdout.strip()
def describe(self):
return {'rev': self.rev, 'sha': self.commit, **({'dirty': True, 'snapshot': self.sha} if self.dirty else {})}
# ---------------------------------------------------------------- games
def game_root(g):
return g.folder / f'{g.id}-work' / 'regress'
class Game:
def __init__(self, folder, build):
self.folder, self.build, self.id = folder, build, build.name[:16].upper()
self.name = re.sub(r'\s*\[[0-9A-Fa-f]{16}\]\s*$', '', folder.name)
self.patched = bench.newest(build.glob('patched_v*'))
self.ver = self.patched.name.removeprefix('patched_') if self.patched else None
self.kind = 'nvn' if (build / 'shader/pkg').is_dir() else 'vk' if (build / 'shader/vk').is_dir() else 'gl' if (build / 'shader/gl').is_dir() else None
self.derived = (build / 'profile' / self.id / 'game.tsv').is_file()
def romfs(self):
r = self.build / 'extract' / self.ver / 'RomFS'
return r if r.is_dir() else bench.newest(self.build.glob('extract/v*')) / 'RomFS'
def route(self):
f = REPO / 'dm/games' / self.id / 'smoke.tsv'
return read_route(f) if f.is_file() else None
def env(self, tree): # shader tools write profile tables: use a copy in the game's work folder, keeping the revision tree and the player's build untouched
env = dict(os.environ, DM_GAME=self.id)
prof = self.profile_dir(tree)
env.update(DM_GAMES_DIR=str(prof.parent), DM_GAMES_FIRST='1')
if self.kind == 'nvn' and not (prof / 'pso_pairs.tsv').is_file():
pairs = self.build / 'shader/pso_pairs.tsv'
if pairs.is_file(): env['DM_PSO_PAIRS'] = str(pairs)
return env
def profile_dir(self, tree):
src = (self.build / 'profile' if self.derived else tree.path / 'dm/games') / self.id
key = digest(sorted(p for p in src.glob('*') if p.is_file()))[:16]
dst = game_root(self) / 'profiles' / key / self.id
if not (dst / 'game.tsv').is_file(): shutil.copytree(src, dst)
return dst
def shader_roots(self):
roots = [self.romfs()]
capture = self.build / 'extract' / self.ver / 'vk_capture'
if capture.is_dir(): roots.append(capture)
return roots
def read_route(f):
r = {'save': 'run', 'keys': '', 'secs': 90.0, 'quick': 60.0, 'reach': None, 'window': None, 'shots': [], 'hang': 20.0, 'prebuild': 'on'}
for ln in f.read_text().splitlines():
if not ln.strip() or ln.startswith('#'): continue
c = ln.split('\t'); k, v = c[0].strip(), [x.strip() for x in c[1:]]
if not v: raise HarnessError(f'{f}: {k} has no value')
if k in ('secs', 'quick', 'hang'): r[k] = float(v[0])
elif k == 'reach': n, _, t = v[0].partition('@'); r[k] = (int(n), float(t))
elif k == 'window': r[k] = (float(v[0]), float(v[1]))
elif k == 'shots': r[k] = sorted(float(x) for x in v[0].split(',') if x)
elif k in ('save', 'keys', 'prebuild'): r[k] = v[0]
else: raise HarnessError(f'{f}: unknown key {k!r}')
return r
def find_games(want=None):
found = {}
for folder in sorted(GAMES.iterdir()) if GAMES.is_dir() else []:
if not folder.is_dir(): continue
for b in folder.glob('*_build'):
if re.fullmatch(r'[0-9A-Fa-f]{16}_build', b.name) and (b / 'tools/dmhost').is_file():
g = Game(folder, b)
if g.patched and g.kind: found[g.id] = g
for s in [x.strip().upper() for x in os.environ.get('REGRESS_SKIP', '').split(',') if x.strip()]:
if not want or s not in [w.upper() for w in want]: found.pop(s, None)
if want:
missing = [w for w in want if w.upper() not in found]
if missing: raise HarnessError(f'no Mac build of {", ".join(missing)} under {GAMES} (found: {", ".join(found) or "none"})')
return [found[w.upper()] for w in want]
return list(found.values())
# ---------------------------------------------------------------- chain: build and manifest
def dotnet_env(env):
if env.get('DOTNET_ROOT'): return env
for d in (GAMES.parent / 'tools/dotnet', Path('/usr/local/share/dotnet'), Path.home() / '.dotnet', Path('/opt/homebrew/opt/dotnet/libexec')):
if (d / 'dotnet').exists(): return dict(env, DOTNET_ROOT=str(d), PATH=f'{d}{os.pathsep}{env.get("PATH", "")}')
return env
def toolchain_fp():
out = []
for cmd in (['spirv-cross', '--version'], ['xcrun', '-sdk', 'macosx', 'metal', '--version'], ['xcrun', '-sdk', 'macosx', '--show-sdk-build-version']):
r = sh(cmd); out.append(r.stdout + r.stderr)
return hashlib.sha256('\0'.join(out).encode()).hexdigest()
def chain_key(tree, g, tools):
if g.kind == 'nvn':
v, inp = json.loads(tree.py("import sys, json; sys.path.insert(0, 'dm/gfx/tools'); import dmsh_chain as c; g = c.load_game(); print(json.dumps([c.version(), c.inputs(g)]))", g.env(tree)))
side = digest([tree.path / 'dm/gfx/tools' / f for f in ('c1_guard.py', 'cb_extent.py', 'dmsh_mtlcache.py', 'own_compilers.py')] + [tree.path / 'dm/games/game.py'])
pkg = g.build / 'shader/pkg'
sources = digest(sorted(p for p in pkg.rglob('*') if p.is_file()), root=pkg)
pairs = g.profile_dir(tree) / 'pso_pairs.tsv'
if not pairs.is_file(): pairs = g.build / 'shader/pso_pairs.tsv'
side += digest([pairs])
elif g.kind == 'vk':
v, inp = digest([tree.path / 'dm/gfx/tools/vk_shaders.py'])[:12], g.ver
side = ''
vk = load_module('regress_vk_sources', REPO / 'dm/gfx/tools/vk_shaders.py')
sources = digest(sorted(Path(p) for root in g.shader_roots() for p in vk.spirv_files(root)), root=g.build)
else: raise HarnessError(f'{g.id}: the chain check does not cover {g.kind} games yet')
return v, inp, f'{g.kind}-{v}-{inp}-{hashlib.sha256((tools + side + sources).encode()).hexdigest()[:16]}'
def chain_build(tree, g, a):
"""The game's shaders built into its NSP work folder's regress/chain/<key>/ (reused when its manifest is there): → manifest"""
tools, jobs = a.tools_fp, a.jobs
v, inp, key = chain_key(tree, g, tools)
out = game_root(g) / 'chain' / key
mf = out / 'manifest.json'
if mf.is_file():
m = json.loads(mf.read_text()); m['secs'] = 0
# The archive planner and depth mode can change without changing the translation chain.
if g.kind == 'nvn': m['interface'] = interface_check(tree, g, out)
return m, out
out.mkdir(parents=True, exist_ok=True); tmp = game_root(g) / 'tmp'; tmp.mkdir(exist_ok=True)
env = dotnet_env(g.env(tree)); env.update(WORK=str(tmp), TMPDIR=str(tmp) + '/', J=str(jobs))
t0 = time.time(); log = out / 'build.log'
done = (out / 'built.ok').is_file() # a build whose outputs are complete (a run stopped while hashing, or a build made by hand); its manifest is made again
if g.kind == 'nvn':
tools_dir = tree.path / 'dm/gfx/tools'
cas = g.build / 'shader/cache/cas'
env.update(XLATE=str(tools_dir / 'dmsh_xlate_bin/dmsh_xlate'), DMSH_CAS=str(cas if cas.is_dir() else game_root(g) / 'cas'))
entry = tools_dir / ('own_compilers.py' if (tools_dir / 'own_compilers.py').is_file() else 'dmsh_build.py')
lib = out / f'b-{v}'
if not done:
say(f' {g.id}: chain {v} (inputs {inp}) of {tree.id}: translating the shaders ({jobs} workers) → {out}')
rc, paused = run_heavy([sys.executable, entry, g.build / 'shader/pkg', out], log, tree.path, env, a, f'{g.id} shader build'); t0 += paused
if rc or not (lib / '_b_build.json').is_file():
raise HarnessError(f'{g.id}: dmsh_build.py of {tree.id} failed (exit {rc}); last lines of {log}:\n' + ''.join(collections.deque(open(log, errors='replace'), maxlen=8)))
prof = out / 'profile'; prof.mkdir(exist_ok=True) # the tables the host reads beside the shader map, as this build made them
for src in g.profile_dir(tree).glob('*.tsv'): shutil.copyfile(src, prof / src.name)
(out / 'built.ok').write_text(f'{tree.id}\n')
say(f' {g.id}: hashing the outputs of chain {v}')
shaders = nvn_manifest(lib, jobs)
else:
say(f' {g.id}: vk_shaders.py {v} of {tree.id} → {out}')
msl = out / 'msl'; shutil.rmtree(msl, ignore_errors=True); msl.mkdir()
# vk_shaders.py writes its MSL into a temporary folder; this keeps it (the script itself is unchanged)
wrap = ('import sys, runpy, tempfile, contextlib\n'
f'@contextlib.contextmanager\ndef keep(*a, **k): yield {str(msl)!r}\n'
'tempfile.TemporaryDirectory = keep\n'
f'sys.argv = {[str(tree.path / "dm/gfx/tools/vk_shaders.py"), *map(str, g.shader_roots()), str(out)]!r}\n'
'runpy.run_path(sys.argv[0], run_name="__main__")\n')
rc, paused = run_heavy([sys.executable, '-c', wrap], log, tree.path, env, a, f'{g.id} shader build'); t0 += paused
shaders = vk_manifest(msl, out / 'vk/vk_shaders.tsv', rc == 0, log)
st = collections.Counter(s['st'] for s in shaders.values())
m = {'key': key, 'kind': g.kind, 'chain': v, 'inputs': inp, 'tree': tree.id, 'game': g.id, 'total': len(shaders), 'ok': st['ok'], 'failed': len(shaders) - st['ok'],
'failed_by_status': {k: n for k, n in st.items() if k != 'ok'}, 'build_secs': round(time.time() - t0, 1), 'shaders': shaders}
if g.kind == 'vk': m['build_failed'] = rc != 0
if g.kind == 'nvn': m['interface'] = interface_check(tree, g, out)
mf.write_text(json.dumps(m, indent=0))
m['secs'] = m['build_secs']
return m, out
def refl_hash(p):
try: j = json.loads(Path(p).read_bytes())
except (OSError, ValueError): return None
for k in ('chain', 'chain_metal'): j.pop(k, None)
return hashlib.sha256(json.dumps(j, sort_keys=True).encode()).hexdigest()[:16]
def nvn_manifest(lib, jobs):
"""{key: {st, err, msl, refl, var}} from dmsh_build.py's report and outputs"""
files = collections.defaultdict(list)
for n in os.listdir(lib):
m = re.match(r'([0-9a-f]{16})_b', n)
if m: files[m.group(1)].append(n)
rows = json.loads((lib / '_b_build.json').read_text())
def one(r):
k = r['key']; names = sorted(files.get(k, ()))
e = {'st': r['st'], **({'err': r['err'][:160]} if r['st'] != 'ok' else {})}
if (lib / f'{k}_b.metal').is_file(): e['msl'] = sha256_file(lib / f'{k}_b.metal').hexdigest()[:16]
if (lib / f'{k}_b.json').is_file(): e['refl'] = refl_hash(lib / f'{k}_b.json')
var = [n for n in names if n not in (f'{k}_b.metal', f'{k}_b.json', f'{k}_b.metallib')]
if var:
h = hashlib.sha256()
for n in var: h.update(n[16:].encode()); sha256_file(lib / n, h)
e['var'] = h.hexdigest()[:16]
return k, e
with cf.ThreadPoolExecutor(max(2, jobs)) as ex: return dict(ex.map(one, rows))
def vk_manifest(msl, table, ok, log):
keys = {}
for p in sorted(msl.glob('*.metal')):
keys[p.stem] = {'st': 'ok' if ok else 'library failed', 'msl': sha256_file(p).hexdigest()[:16]}
if not ok: keys[p.stem]['err'] = (list(collections.deque(open(log, errors='replace'), maxlen=1)) or [''])[0].strip()[:160]
if table.is_file():
for ln in table.read_text().splitlines():
c = ln.split('\t')
if len(c) >= 3 and c[0] in keys: keys[c[0]]['refl'] = hashlib.sha256(ln.encode()).hexdigest()[:16]
return keys
# ---------------------------------------------------------------- the vertex / pixel interface of the archive's pipeline bases (GitHub issue #283)
STRUCT = re.compile(r'struct\s+(\w+)\s*\{(.*?)\n\};', re.S)
FIELD = re.compile(r'([\w:<>, ]+?)\s+(\w+)(?:\s*\[\d+\])?\s*\[\[([^\]]*)\]\]\s*;')
def msl_io(src, entry, stage):
"""{locnN: type} of an entry point's [[user(locnN)]] outputs (vertex: its return struct) or stage inputs (fragment: its [[stage_in]] struct); None when the entry is not found"""
m = re.search(r'\b(vertex|fragment)\s+(\w+)\s+' + re.escape(entry) + r'\s*\(', src)
if not m: return None
if stage == 'vs': name = m.group(2)
else:
sig = src[m.end():src.find('\n{', m.end())]
n = re.search(r'(\w+)\s+\w+\s*\[\[stage_in\]\]', sig)
name = n.group(1) if n else 'void'
if name == 'void': return {}
body = next((b for n, b in STRUCT.findall(src) if n == name), '')
out = {}
for ty, _, attrs in FIELD.findall(body):
u = re.search(r'user\((locn\d+)\)', attrs)
if u: out[u.group(1)] = ty.strip()
return out
def interface_check(tree, g, out):
"""The shader pairs of shader_pairs.tsv and pso_pairs.tsv whose pixel shader reads a [[user(locnN)]] input that the vertex shader does not write, or writes with another type,
among the pipeline bases pso_offline.py would put into the archive (the tree's pso_offline.plan, run on those pairs only: it reads every library): metal-tt refuses such a
pipeline, and one refused pipeline fails the whole archive (GitHub issue #283). The entry points are pso_offline's: the reflection's entry (entry_z for a near = 0 game)"""
lib = next(out.glob('b-*')); chain = lib.name[2:]
prof = g.profile_dir(tree)
pairs = [prof / 'shader_pairs.tsv', prof / 'pso_pairs.tsv' if (prof / 'pso_pairs.tsv').is_file() else g.build / 'shader/pso_pairs.tsv']
po = tree.path / 'dm/gfx/tools/pso_offline.py'
if not po.is_file(): return {'checked': 0, 'note': 'no pso_offline.py in this revision', 'bad': []}
mod = load_module('pso_offline_' + tree.id.replace('-', '_'), po)
todo = mod.read_pairs(mod.pair_text([p for p in pairs if p.is_file()]))
zero = depth_zero(g, tree)
keys = {k for pr in todo for k in pr}
def load(k):
try: return k, json.loads((lib / f'{k}_b.json').read_bytes()), (lib / f'{k}_b.metal').read_text(errors='replace')
except (OSError, ValueError): return k, None, None
with cf.ThreadPoolExecutor(8) as ex: got = {k: (j, src) for k, j, src in ex.map(load, keys)}
cache, suspects = {}, {}
def io(k, entry, stage):
if (k, entry) not in cache: cache[k, entry] = msl_io(got[k][1], entry, stage) if got[k][1] else None
return cache[k, entry]
checked = 0
for vk, fk in todo:
vj, fj = got[vk][0], got[fk][0]
if not vj or not fj or vj.get('chain') != chain or fj.get('chain') != chain: continue
ve = vj['entry_z'] if zero and vj.get('entry_z') else vj.get('entry'); fe = fj.get('entry') or 'main0'
vo, fi = io(vk, ve, 'vs'), io(fk, fe, 'ps'); checked += 1
if vo is None or fi is None: continue
miss = sorted((l for l in fi if l not in vo), key=lambda l: int(l[4:]))
ty = [f'{l} {vo[l]}/{fi[l]}' for l in sorted(fi, key=lambda l: int(l[4:])) if l in vo and vo[l] != fi[l]]
if miss or ty: suspects[vk, fk] = {'vs': vk, 'entry': ve, 'ps': fk, 'missing': miss, 'type': ty}
bases, _, _ = mod.plan(lib, chain, list(suspects), zero) if suspects else ([], [], {})
planned = {(vl[:16], fl[:16]) for vl, _, fl, _ in bases}
return {'checked': checked, 'bad': [x for pr, x in suspects.items() if pr in planned], 'not_in_archive': len(suspects) - len(planned & suspects.keys())}
def chain_diff(b, c):
bs, cs = b['shaders'], c['shaders']
common = bs.keys() & cs.keys()
rows = lambda m: [{'key': k, 'st': v['st'], 'err': v.get('err', '')} for k, v in sorted(m.items())]
# the rule of shader_regressions.py, which the NSP script runs after each shader build of one game: ok in the earlier build, not ok now; a new key does not count there
nf = [{'key': k, 'status': st, 'error': err} for k, st, err in shader_regressions.lost(rows(bs), rows(cs))]
af = [{'key': k, 'status': cs[k]['st'], 'error': cs[k].get('err', '')} for k in sorted(cs.keys() - bs.keys()) if cs[k]['st'] != 'ok']
npass = [k for k in sorted(common) if bs[k]['st'] != 'ok' and cs[k]['st'] == 'ok']
ch = collections.Counter(); keys = []
for k in sorted(common):
if bs[k]['st'] != 'ok' or cs[k]['st'] != 'ok': continue
d = [f for f, label in (('msl', 'msl'), ('refl', 'reflection'), ('var', 'variants')) if bs[k].get(f) != cs[k].get(f)]
for f in d: ch[{'msl': 'msl', 'refl': 'reflection', 'var': 'variants'}[f]] += 1
if d: keys.append(k)
out = {'newly_failing': nf, 'added_failing': af, 'newly_passing': npass, 'changed': {'msl': ch['msl'], 'reflection': ch['reflection'], 'variants': ch['variants'], 'shaders': len(keys), 'keys': keys[:200]},
'added': len(cs.keys() - bs.keys()), 'removed': len(bs.keys() - cs.keys())}
bi, ci = b.get('interface'), c.get('interface')
if bi is not None or ci is not None:
sig = lambda x: (x['vs'], x['entry'], x['ps'])
old = {sig(x) for x in (bi or {}).get('bad', [])}
cb = (ci or {}).get('bad', [])
out['interface'] = {'baseline': len((bi or {}).get('bad', [])), 'candidate': len(cb), 'checked': (ci or {}).get('checked'),
'new': [x for x in cb if sig(x) not in old], 'known': [x for x in cb if sig(x) in old][:50]}
return out
def build_summary(m):
return {k: m.get(k) for k in ('key', 'chain', 'inputs', 'total', 'ok', 'failed', 'failed_by_status', 'build_failed')} | {'secs': m.get('secs', 0)}
# ---------------------------------------------------------------- host and rewritten modules of a revision
def host_for(tree, g, jobs):
srcs = [p for d in HOST_DIRS for p in sorted((tree.path / d).iterdir()) if p.is_file() and p.suffix in ('.c', '.m', '.h', '.S', '.inc')]
key = digest(srcs + [tree.path / 'dm/host/Makefile', g.profile_dir(tree) / 'game.tsv', tree.path / 'dm/games/game.py'], g.id.encode())[:16]
dst = game_root(g) / 'host' / key / 'dmhost'
if dst.is_file(): return dst, key, 0
t0 = time.time(); env = g.env(tree); hd = tree.path / 'dm/host'
say(f' {g.id}: building the host of {tree.id}')
log = game_root(g) / 'tmp' / f'host-{tree.id}-{g.id}.log'; log.parent.mkdir(parents=True, exist_ok=True)
with open(log, 'w') as f:
for target in (['gen/vpx/libvpx.a', f'-j{jobs}'], ['dmhost']):
r = subprocess.run(['make', f'GAME={g.id}', *target], cwd=hd, env=env, stdout=f, stderr=subprocess.STDOUT)
if r.returncode: raise HarnessError(f'{g.id}: the host of {tree.id} does not build (see {log})')
dst.parent.mkdir(parents=True, exist_ok=True)
if sh(['cp', '-c', hd / 'dmhost', dst]).returncode: shutil.copy2(hd / 'dmhost', dst)
return dst, key, round(time.time() - t0, 1)
def rewriter_key(tree):
return digest([tree.path / f for f in REWRITER] + sorted((tree.path / 'dm/games').glob('*/patches.py')))[:16]
def modules_for(tree, g, rewrite, jobs):
"""The rewritten modules a run takes: the build's (rewrite False), or the tree's patch_nso.py on the build's extracted modules, cached by the rewriter's files"""
if not rewrite: return g.patched, 'build'
key = rewriter_key(tree)
dst = game_root(g) / 'patched' / key / g.patched.name
if (dst / '.done').is_file(): return dst, key
mods = g.env(tree)
names = tree.py(f"import sys; sys.path.insert(0, 'dm/games'); import game; print(' '.join(game.load({g.id!r}).words('modules')))", mods).split()
src = {m: g.build / 'extract' / g.ver / 'ExeFS' / m for m in names}
nro = g.build / f'nro_{g.ver}' / 'nro.tsv'
if nro.is_file():
for ln in nro.read_text().splitlines():
c = ln.split('\t')
if ln and not ln.startswith(('#', 'refused\t')): src[c[0]] = nro.parent / (c[0] + '.nro')
shutil.rmtree(dst, ignore_errors=True); dst.mkdir(parents=True)
if nro.is_file(): shutil.copyfile(nro, dst / 'nro.tsv')
say(f' {g.id}: rewriting {len(src)} modules with the rewriter of {tree.id}')
procs, todo = {}, list(src)
while todo or procs:
while todo and len(procs) < jobs:
m = todo.pop(0); f = open(dst / f'rewrite_{m}.log', 'w')
procs[m] = (subprocess.Popen([sys.executable, str(tree.path / 'dm/tools/patch_nso.py'), str(src[m]), str(dst), m], env=mods, stdout=f, stderr=subprocess.STDOUT), f)
for m in [m for m, (p, _) in procs.items() if p.poll() is not None]:
p, f = procs.pop(m); f.close()
if p.returncode: raise HarnessError(f'{g.id}: patch_nso.py of {tree.id} failed on {m} (see {dst / f"rewrite_{m}.log"})')
time.sleep(0.2)
(dst / '.done').write_text('')
return dst, key
# ---------------------------------------------------------------- the measuring lock and the Mac's load
def pid_alive(pid):
try: os.kill(pid, 0); return True
except ProcessLookupError: return False
except PermissionError: return True
def own_tree():
me, kids = os.getpid(), set()
r = sh(['ps', '-axo', 'pid=,ppid='])
par = collections.defaultdict(list)
for ln in r.stdout.splitlines():
a = ln.split()
if len(a) == 2: par[int(a[1])].append(int(a[0]))
todo = [me]
while todo:
p = todo.pop(); kids.add(p); todo += par.get(p, [])
return kids
def heavy_jobs(): # other builds now running: not this process's, not stopped, not another regress.py's (whose builds pause while the measuring lock is held)
r = sh(['ps', '-axo', 'pid=,ppid=,stat=,command=']); out = []; rows = {}
for ln in r.stdout.splitlines():
pid, ppid, stat, cmd = (ln.split(None, 3) + ['', '', ''])[:4]
if pid.isdigit() and ppid.isdigit(): rows[int(pid)] = (int(ppid), stat, cmd)
def managed(pid):
seen, cooperative = set(), False
while pid in rows and pid not in seen:
seen.add(pid)
parent, _, command = rows[pid]
if pid == os.getpid(): return True
if re.search(r'(?:dmsh_build|own_compilers|vk_shaders)\.py', command): cooperative = True
if re.search(r'(?:^|[/\s])regress\.py(?:\s|$)', command): return cooperative
pid = parent
return False
for pid, (_, stat, cmd) in rows.items():
exe = os.path.basename(cmd.split(' ')[0]) if cmd else ''
if managed(pid) or stat.startswith('T') or not (exe in HEAVY_EXE or re.search(HEAVY_PY, cmd)): continue
out.append(f'{pid} {cmd}')
return out
def wait_idle(limit): # the shared rule: measure only while no build or other heavy job runs
t0, said = time.time(), False
while time.time() - t0 < limit:
jobs = heavy_jobs()
if not jobs: return True
if not said: say(f' waiting for other jobs to end ({len(jobs)}: {", ".join(j.split(None, 1)[1][:40] for j in jobs[:3])})'); said = True
time.sleep(5)
say(' other jobs still run: measuring anyway (marked busy)'); return False
def take_lock(lock, what):
if not lock: return
say(f' taking the measuring lock {lock}') if lock.exists() else None
bench.wait_lock(lock, f'regress.py {what}')
def run_heavy(cmd, log, cwd, env, a, what):
"""A heavy job (a shader build) in its own process group, stopped (SIGSTOP) while another process holds the measuring lock and resumed when it is free, so it never
skews someone else's measurement (the shared rule: heavy jobs wait for the lock). → (exit status, seconds paused)"""
with open(log, 'w') as f: p = subprocess.Popen([str(c) for c in cmd], cwd=cwd, env=env, stdout=f, stderr=subprocess.STDOUT, start_new_session=True)
paused, t_pause = 0.0, None
try:
while p.poll() is None:
held = bench.lock_holder(a.pause_lock) if a.pause_lock else None
if held is not None and t_pause is None:
os.killpg(p.pid, signal.SIGSTOP); t_pause = time.time(); say(f' {what}: paused while process {held} holds the measuring lock')
elif held is None and t_pause is not None:
os.killpg(p.pid, signal.SIGCONT); paused += time.time() - t_pause; t_pause = None; say(f' {what}: resumed')
time.sleep(2)
finally:
if p.poll() is None:
try: os.killpg(p.pid, signal.SIGCONT); os.killpg(p.pid, signal.SIGTERM); p.wait(10)
except (ProcessLookupError, subprocess.TimeoutExpired): p.kill()
return p.returncode, round(paused)
# ---------------------------------------------------------------- one game run
_libproc = ctypes.CDLL('/usr/lib/libproc.dylib')
def footprint(pid): # (current, lifetime peak) physical footprint in bytes (proc_pid_rusage, RUSAGE_INFO_V4)
buf = ctypes.create_string_buffer(512)
if _libproc.proc_pid_rusage(pid, 4, buf) != 0: return None
v = struct.unpack_from('<16x35Q', buf.raw)
return v[7], v[28]
def normalize_red(line):
s = line.split('[RED]', 1)[1].strip()
s = re.sub(r'(/[^\s:;,()\'"]+)(\s+\S*/[^\s:;,()\'"]+)*', '<path>', s) # a path, also one with spaces in its folder names
s = re.sub(r'\b0x[0-9a-fA-F]+\b', '<hex>', s)
s = re.sub(r'\b[0-9a-f]{16}\b', '<key>', s)
s = re.sub(r'\d+(\.\d+)?', '#', s).replace('<hex>', '0x#')
return s[:200]
def read_log(text):
red = collections.Counter(normalize_red(l) for l in text.splitlines() if '[RED]' in l)
crash = next((l.strip()[:160] for l in text.splitlines() if 'CRASH sig=' in l), None)
m = re.findall(r'pipeline prepare: \d+ lines .*? in ([\d.]+) s on', text)
pb = None
d = re.findall(r'pipeline prebuild done: (\d+) pairs, (\d+) bases built, (\d+) already archived, (\d+) left to the draws, (\d+) failed, ([\d.]+) s', text)
if d: pb = dict(zip(('pairs', 'built', 'archived', 'left', 'failed', 'secs'), map(float, d[-1]))) | {'done': True}
else:
p = re.findall(r'pipeline prebuild: (\d+) of (\d+) pairs, (\d+) built, (\d+) already archived, (\d+) left to the draws, (\d+) failed, ([\d.]+) s', text)
if p: pb = {'pairs': float(p[-1][1]), 'built': float(p[-1][2]), 'archived': float(p[-1][3]), 'failed': float(p[-1][5]), 'secs': float(p[-1][6]), 'done': False, 'at': float(p[-1][0])}
return red, crash, float(m[-1]) if m else None, pb
def crash_report(t0):
d = Path.home() / 'Library/Logs/DiagnosticReports'
for p in sorted(d.glob('dmhost*.ips'), key=lambda p: p.stat().st_mtime, reverse=True) if d.is_dir() else []:
if p.stat().st_mtime < t0: break
try:
body = json.loads(p.read_text(errors='replace').split('\n', 1)[1])
e = body.get('exception', {}); return {'type': e.get('type'), 'signal': e.get('signal'), 'report': p.name}
except (ValueError, IndexError): return {'report': p.name}
return None
def thumb(png, w, h): # w × h RGB pixels of a PNG, resampled by sips (no Python imaging package needed)
bmp = png.with_name(f'{png.stem}.{w}x{h}.bmp')
r = sh(['sips', '-s', 'format', 'bmp', '-z', h, w, png, '--out', bmp])
if r.returncode or not bmp.is_file(): return None
b = bmp.read_bytes(); bmp.unlink()
off, bw, bh, bpp = struct.unpack_from('<I', b, 10)[0], *struct.unpack_from('<iiHH', b, 18)[:2], struct.unpack_from('<H', b, 28)[0]
bpx, stride = bpp // 8, (bw * bpp // 8 + 3) & ~3
px = []
for y in range(abs(bh)):
row = off + ((abs(bh) - 1 - y) if bh > 0 else y) * stride
px += [(b[row + x * bpx + 2], b[row + x * bpx + 1], b[row + x * bpx]) for x in range(bw)]
return px
def shot_hashes(png):
"""dHash (64 bits: brightness gradients of a 9 × 8 thumbnail, hex) and a 16 × 16 RGB thumbnail (hex, kept in the baseline, not the report)"""
a, t = thumb(png, 9, 8), thumb(png, 16, 16)
if not a or not t: return None
g = [0.299 * r + 0.587 * gg + 0.114 * bb for r, gg, bb in a]
bits = ''.join('1' if g[y * 9 + x] > g[y * 9 + x + 1] else '0' for y in range(8) for x in range(8))
return {'dhash': f'{int(bits, 2):016x}', 'rgb16': bytes(c for p in t for c in p).hex()}
def shot_distance(a, b):
bits = bin(int(a['dhash'], 16) ^ int(b['dhash'], 16)).count('1')
x, y = bytes.fromhex(a['rgb16']), bytes.fromhex(b['rgb16'])
return bits, round(sum(abs(p - q) for p, q in zip(x, y)) / len(x), 2)
def save_source(g, route):
"""The save a run starts from: a folder of the NSP folder (fixed saves, never written), none for `new`, and for `run` a copy of <id>_build/run/save frozen at its first
use in <NSP folder>/<id>-work/regress/save-run (the build's own save changes whenever someone plays)"""
if route['save'] == 'new': return None
if route['save'] != 'run': return g.folder / route['save']
frozen = game_root(g) / 'save-run'
if not frozen.is_dir():
shutil.copytree(g.build / 'run/save', frozen.with_name('save-run.partial'), symlinks=True, dirs_exist_ok=True); frozen.with_name('save-run.partial').rename(frozen)
return frozen
def save_digest(d):
return digest(sorted(p for p in Path(d).rglob('*') if p.is_file()), root=d)[:16] if d else 'new'
def smoke_context(g, art, route, a, hardware):
"""Reuse a baseline only for the same route, save bytes, build artifacts, machine and run settings. Old records without this context are recorded again."""
env = {k: v for k, v in os.environ.items() if k.startswith(('DM_', 'DMSH_', 'XLATE_'))}
return json.loads(json.dumps({'route': route, 'save': save_digest(save_source(g, route)), 'host': art['host_key'], 'modules': art['modules_key'], 'chain': art['manifest']['key'],
'profile': digest(sorted(art['profile'].glob('*.tsv'))), 'machine': hardware, 'tools': a.tools_fp, 'env': env, 'overrides': a.env,
'no_hud': a.no_hud, 'runs': a.runs, 'prebuild_limit': a.prebuild_limit, 'prebuild_hang': a.prebuild_hang}))
def smoke_run(g, tree, art, route, quick, outdir, a):
"""One run of the route: the prebuild (full mode), then the game, under the watchdog → RUN (see the header)"""
host, modules, lib, prof = art['host'], art['modules'], art['lib'], art['profile']
secs = route['quick'] if quick else route['secs']
shutil.rmtree(outdir, ignore_errors=True); outdir.mkdir(parents=True)
save, temp = outdir / 'save', outdir / 'temp'
src_save = save_source(g, route)
if src_save: shutil.copytree(src_save, save, symlinks=True)
else: save.mkdir()
temp.mkdir()
env = dict(os.environ, DM_ROOT=str(g.build), DM_ROMFS=str(g.romfs()), DM_SAVE=str(save), DM_TEMP=str(temp), DM_MSL_DIR=str(lib), DM_FONT_DIR=str(g.build / 'font'),
DM_WINDOW='1', DM_AUDIO='0', DM_METAL_HUD='0' if a.no_hud else '1', DM_CTL=str(outdir / 'ctl'), TMPDIR=str(game_root(g) / 'tmp') + '/')
if g.kind == 'nvn':
env.update(DM_NVN_SHMAP=str(prof / 'shader_map.tsv'), DM_NVN_TSV=str(outdir / 'frames.tsv'), DM_NVN_TSV_CALLS='0')
if not (prof / 'pso_pairs.tsv').is_file() and (g.build / 'shader/pso_pairs.tsv').is_file(): env['DM_PSO_PAIRS'] = str(g.build / 'shader/pso_pairs.tsv')
if (g.build / 'aoc').is_dir(): env['DM_AOC_DIR'] = str(g.build / 'aoc')
for kv in a.env: k, _, v = kv.partition('='); env[k] = v
res = {'secs': secs, 'host': art['host_key'], 'modules': art['modules_key'], 'save': save_digest(src_save)}
prebuild = g.kind == 'nvn' and not quick and route['prebuild'] == 'on'
if prebuild: # the pipeline prebuild into <run>/pso_archive, as the NSP script's Mac build and the Mac app's prepare step run it
penv = dict(env, DM_PSO_PREBUILD='tool'); penv.pop('DM_WINDOW', None)
if g.get_depth_zero: penv['DM_PSO_PREBUILD_DEPTH'] = 'zero'
r = run_watched([host], penv, outdir, outdir / 'prebuild.log', a, limit=a.prebuild_limit, hang=a.prebuild_hang, kind='prebuild', what=f'{g.id} prebuild')
_, _, _, pb = read_log((outdir / 'prebuild.log').read_text(errors='replace'))
res['prebuild'] = (pb or {}) | {'wall_s': r['wall'], 'exit': r['exit'], 'hang': r['hang_at_s'] is not None}
env['DM_KEYS'] = route['keys']
if not prebuild and g.kind == 'nvn': env['DM_PSO_PREBUILD'] = '0'
shots = [t for t in route['shots'] if t < secs - 1]
r = run_watched([host, modules], env, outdir, outdir / 'log.txt', a, limit=secs, hang=route['hang'], kind=g.kind, what=f'{g.id} smoke', shots=shots,
frames=outdir / 'frames.tsv' if g.kind == 'nvn' else None)
text = (outdir / 'log.txt').read_text(errors='replace')
red, crash_line, prep, _ = read_log(text)
res.update(exit=r['exit'], hang_at_s=r['hang_at_s'], first_frame_s=r['first_frame_s'], max_frame=r['max_frame'], peak_footprint_mb=r['peak_mb'], busy=r['busy'],
prepare_s=prep, red=dict(red), crash=None)
if crash_line or (r['exit'] not in (None, 0)):
res['crash'] = {'line': normalize_red('[RED] ' + crash_line) if crash_line else None, **(crash_report(r['t0']) or {})}
if route['reach']:
n, t = route['reach']
res['reached'] = (r['frame_at'](t) >= n) if t <= secs else None
else: res['reached'] = r['max_frame'] > 0
if g.kind == 'nvn' and (outdir / 'frames.tsv').is_file():
w0, wl = route['window'] or (secs / 2, secs / 2 - 2)
w1 = min(w0 + wl, secs - 1)
if w1 > w0:
s = bench.summary(*bench.frames(outdir / 'frames.tsv', w0, w1), w1 - w0, 1000.0 / 60)
res.update({k: s.get(k) for k in ('fps', 'frame_p50', 'frame_p99', 'frame_max', 'dropped', 'over_budget')}, window=[w0, w1])
elif r['presents']:
w0, wl = route['window'] or (secs / 2, secs / 2 - 2)
pts = [(t, n) for t, n in r['presents'] if w0 <= t <= w0 + wl]
if len(pts) >= 2: res['fps'] = round((pts[-1][1] - pts[0][1]) / (pts[-1][0] - pts[0][0]), 1)
res['shots'] = {}
for t in shots:
p = outdir / f'shot_{t:g}s.png'
h = shot_hashes(p) if p.is_file() else None
if h: res['shots'][f'{t:g}'] = h
return res
def run_watched(cmd, env, cwd, log, a, limit, hang, kind, what, shots=(), frames=None):
"""Runs one host process under the watchdog, holding the measuring lock and keeping the display on; stops it after limit seconds"""
check_disk(a.min_free_gb)
busy = not wait_idle(a.wait_idle)
take_lock(a.lock, what)
p = caf = None; t0w = time.time(); t0 = time.monotonic()
st = {'max_frame': -1, 'first': None, 'last_new': None, 'hang': None, 'peak': 0, 'presents': [], 'pos': 0, 'logpos': 0, 'frame_t': []}
try:
(cwd / 'ctl').write_text('')
with open(log, 'w') as lf: p = subprocess.Popen([str(c) for c in cmd], cwd=cwd, env=env, stdout=lf, stderr=subprocess.STDOUT)
caf = subprocess.Popen(['caffeinate', '-d', '-i', '-w', str(p.pid)])
todo = list(shots); last_fp = 0
while p.poll() is None:
el = time.monotonic() - t0
if el >= limit: break
while todo and el >= todo[0]:
with open(cwd / 'ctl', 'a') as c: c.write(f'shot {cwd}/shot_{todo.pop(0):g}s.png\n')
new = False
if frames is not None and frames.is_file():
with open(frames, errors='replace') as fh:
fh.seek(st['pos']); chunk = fh.read(); cut = chunk.rfind('\n') + 1; st['pos'] += len(chunk[:cut].encode())
for ln in chunk[:cut].splitlines():
c = ln.split('\t')
if len(c) == 3 and c[1] == '@t_s' and c[0].isdigit() and int(c[0]) > st['max_frame']: st['max_frame'] = int(c[0]); new = True
elif kind not in ('prebuild', 'nvn'):
with open(log, errors='replace') as fh:
fh.seek(st['logpos']); chunk = fh.read(); cut = chunk.rfind('\n') + 1; st['logpos'] += len(chunk[:cut].encode())
for m in re.finditer(r'presents (\d+)', chunk[:cut]):
n = int(m.group(1))
if n > st['max_frame']: st['max_frame'] = n; new = True; st['presents'].append((el, n))
elif kind == 'prebuild':
sz = log.stat().st_size if log.is_file() else 0
if sz != st['logpos']: st['logpos'] = sz; new = True
if new:
st['last_new'] = el; st['frame_t'].append((el, st['max_frame']))
if st['first'] is None and kind != 'prebuild': st['first'] = el
if (st['first'] is not None or kind == 'prebuild') and st['last_new'] is not None and el - st['last_new'] > hang:
st['hang'] = round(st['last_new'], 1); break
if el - last_fp >= 1:
fp = footprint(p.pid); last_fp = el
if fp: st['peak'] = max(st['peak'], fp[0], fp[1])
time.sleep(0.25)
if p.poll() is None:
fp = footprint(p.pid)
if fp: st['peak'] = max(st['peak'], fp[0], fp[1])
finally:
if p is not None and p.poll() is None:
p.send_signal(signal.SIGTERM)
try: p.wait(10)
except subprocess.TimeoutExpired: p.kill(); p.wait()
stopped = True
else: stopped = False
if caf is not None and caf.poll() is None: caf.terminate()
if a.lock and bench.lock_holder(a.lock) == os.getpid(): a.lock.unlink(missing_ok=True) # only a lock this process holds
rc = None if stopped or p is None else p.returncode
ft = st['frame_t']
def frame_at(t):
best = -1
for e, n in ft:
if e <= t: best = n
return best
return {'exit': rc, 'hang_at_s': st['hang'], 'first_frame_s': round(st['first'], 1) if st['first'] is not None else None, 'max_frame': st['max_frame'],
'peak_mb': round(st['peak'] / 1048576) if st['peak'] else None, 'presents': st['presents'], 'frame_at': frame_at, 't0': t0w, 'wall': round(time.monotonic() - t0, 1), 'busy': busy}
# ---------------------------------------------------------------- comparing runs
def med(xs):
xs = [x for x in xs if x is not None]
return sorted(xs)[len(xs) // 2] if xs else None
def run_failures(r, route):
f = []
if r.get('crash'): f.append('crash' + (f" ({r['crash'].get('signal') or r['crash'].get('type') or r['crash'].get('line') or ''})"))
elif r.get('exit') is not None: f.append(f"exited before the end of the route (exit {r['exit']})")
if r.get('hang_at_s') is not None: f.append(f"hang (no frame after {r['hang_at_s']} s)")
if r.get('reached') is False and route.get('reach'): f.append(f"frame {route['reach'][0]} not reached by {route['reach'][1]:g} s (max {r.get('max_frame')})")
if r.get('max_frame', -1) < 0: f.append('no frame')
pb = r.get('prebuild')
if pb:
if pb.get('exit') != 0: f.append(f"prebuild did not exit successfully (exit {pb.get('exit')})")
if pb.get('hang') or (pb.get('done') is False): f.append(f"prebuild did not finish ({pb.get('at', '?')} of {pb.get('pairs', '?')} pairs)")
if pb.get('failed'): f.append(f"prebuild: {pb['failed']:g} pairs failed")
for t in route.get('shots', []):
if t < r.get('secs', float('inf')) - 1 and f'{t:g}' not in r.get('shots', {}): f.append(f'screenshot at {t:g} s missing')
return f
def smoke_diff(base, cand, route):
"""→ (status reasons by level, known failures, diff numbers)"""
newfail, regress, changed, known = [], [], [], []
bf = set(x for r in base for x in run_failures(r, route)); cf_ = [x for r in cand for x in run_failures(r, route)]
for x in dict.fromkeys(cf_):
(known if x in bf else newfail).append(x)
d = {}
bred = set().union(*[r.get('red', {}).keys() for r in base]) if base else set()
nr = collections.Counter()
for r in cand:
for k, n in r.get('red', {}).items():
if k not in bred: nr[k] += n
d['new_red'] = [{'text': k, 'count': n} for k, n in nr.most_common(30)]
if nr: regress.append(f'{len(nr)} new [RED] line kinds')
gone = bred - set().union(*[r.get('red', {}).keys() for r in cand]) if cand else set()
if gone: changed.append(f'{len(gone)} [RED] line kinds gone'); d['gone_red'] = sorted(gone)[:30]
def pct(k, invert=False):
b, c = med(r.get(k) for r in base), med(r.get(k) for r in cand)
if b in (None, 0) or c is None: return None, b, c
return round((c - b) / b * 100, 1), b, c
p, b, c = pct('fps'); d['fps_p50_change_pct'] = p
if p is not None and p < -TOL['fps_pct']: regress.append(f'fps {b:g} → {c:g} ({p:+g} %)')
elif p is not None and abs(p) > TOL['fps_pct']: changed.append(f'fps {b:g} → {c:g} ({p:+g} %)')
p, b, c = pct('frame_p99'); d['frame_p99_change_pct'] = p
if p is not None and b is not None and c is not None and p > TOL['p99_pct'] and c - b > TOL['p99_ms']: regress.append(f'frame p99 {b:g} → {c:g} ms ({p:+g} %)')
p, b, c = pct('peak_footprint_mb'); d['peak_footprint_change_pct'] = p
if p is not None and b is not None and c is not None and p > TOL['footprint_pct'] and c - b > TOL['footprint_mb']: regress.append(f'peak footprint {b:g} → {c:g} MB ({p:+g} %)')
b, c = med(r.get('prepare_s') for r in base), med(r.get('prepare_s') for r in cand)
d['prepare_change_s'] = round(c - b, 2) if b is not None and c is not None else None
if b is not None and c is not None and c > b * TOL['prepare_x'] and c - b > TOL['prepare_s']: regress.append(f'prepare step {b:g} → {c:g} s')
b, c = med((r.get('prebuild') or {}).get('wall_s') for r in base), med((r.get('prebuild') or {}).get('wall_s') for r in cand)
d['prebuild_change_s'] = round(c - b, 1) if b is not None and c is not None else None
if b and c and c > b * TOL['prebuild_x'] and c - b > TOL['prebuild_s']: regress.append(f'prebuild {b:g} → {c:g} s')
d['shots'] = {}
for t in sorted({t for r in cand for t in r.get('shots', {})}, key=float):
bs = [r['shots'][t] for r in base if t in r.get('shots', {})]; cs = [r['shots'][t] for r in cand if t in r.get('shots', {})]
if not bs or not cs: continue
noise = max([shot_distance(x, y)[1] for i, x in enumerate(bs) for y in bs[i + 1:]] or [0])
nearest = [(min((shot_distance(x, y) for x in bs), key=lambda v: v[1]), y) for y in cs]
(bits, diff), shot = max(nearest, key=lambda v: v[0][1])
d['shots'][t] = {'dhash_bits': bits, 'rgb_diff': diff, 'baseline_noise': noise, 'dhash': shot['dhash']}
if diff > max(TOL['rgb_diff'], 2 * noise) and bits > TOL['dhash_bits'] // 2 or bits > TOL['dhash_bits'] and diff > TOL['rgb_diff'] / 2: regress.append(f'screenshot at {t} s differs (dHash {bits} bits, RGB {diff})')
elif diff > TOL['rgb_diff'] / 2: changed.append(f'screenshot at {t} s moved (RGB {diff})')
return {'NEW-FAIL': newfail, 'REGRESSION': regress, 'CHANGED': changed}, known, d
LEVELS = ['ERROR', 'NEW-FAIL', 'REGRESSION', 'CHANGED', 'PASS']
def worst(*levels):
return min(levels, key=LEVELS.index) if levels else 'PASS'
# ---------------------------------------------------------------- the commands
def baseline_dir(tree, g):
return game_root(g) / 'baselines' / tree.id
def artifacts(tree, g, a, rewrite):
m, out = chain_build(tree, g, a)
host, hkey, _ = host_for(tree, g, a.jobs)
mods, mkey = modules_for(tree, g, rewrite, a.jobs)
# the tables the host reads beside the shader map (pso_manifest.tsv, the pair lists, ...): the tree's, with c1_full.tsv and cb_extent.tsv as the chain build made them
prof = game_root(g) / 'profile' / f'{tree.id}-{m["key"]}'
if not (prof / '.done').is_file():
prof.mkdir(parents=True, exist_ok=True)
for f in g.profile_dir(tree).glob('*.tsv'): shutil.copyfile(f, prof / f.name)
for f in (out / 'profile').glob('*.tsv') if g.kind == 'nvn' else []:
if f.name in ('c1_full.tsv', 'cb_extent.tsv'): shutil.copyfile(f, prof / f.name)
(prof / '.done').write_text('')
return {'manifest': m, 'lib': out, 'profile': prof, 'host': host, 'host_key': hkey, 'modules': mods, 'modules_key': mkey}
def depth_zero(g, tree):
return sh([sys.executable, str(tree.path / 'dm/games/game.py'), g.id, 'depth_mode', 'gl'], env=g.env(tree)).stdout.strip() == 'zero'
def smoke_series(g, tree, art, route, quick, label, a, runs):
g.get_depth_zero = depth_zero(g, tree)
out = []
for i in range(runs):
d = game_root(g) / 'runs' / f'{datetime.datetime.now():%Y%m%d-%H%M%S}-{label}-{"quick" if quick else "full"}-{i + 1}'
say(f' {g.id}: {label} run {i + 1}/{runs} ({"quick" if quick else "full"}, {route["quick"] if quick else route["secs"]:g} s) → {d}')
r = smoke_run(g, tree, art, route, quick, d, a); r['dir'] = str(d); out.append(r)
say(f' exit {r["exit"]}, max frame {r["max_frame"]}, first frame {r["first_frame_s"]} s, prepare {r["prepare_s"]} s, fps {r.get("fps")}, p99 {r.get("frame_p99")} ms, '
f'peak {r["peak_footprint_mb"]} MB, RED kinds {len(r["red"])}' + (f', prebuild {r["prebuild"].get("wall_s")} s' if r.get('prebuild') else '')
+ (f', FAIL: {"; ".join(run_failures(r, route))}' if run_failures(r, route) else ''))
return out
def public_run(r): # what a run contributes to the report: no thumbnails, no paths
x = {k: v for k, v in r.items() if k not in ('shots', 'dir')}
x['shots'] = {t: {'dhash': h['dhash']} for t, h in r.get('shots', {}).items()}
return x
def cmd_baseline(a):
tree = Tree(a.rev); games = select(a, smoke=not a.no_smoke)
rep = new_report(a, 'baseline', baseline=tree, candidate=None)
for g in games:
t0 = time.time(); gr = rep['games'][g.id] = {'name': g.name, 'status': 'PASS', 'reasons': [], 'known': [], 'secs': {}}
try:
route = g.route() if not a.no_smoke else None
art = artifacts(tree, g, a, rewrite=a.rewrite) if route else None
m = art['manifest'] if art else chain_build(tree, g, a)[0]
gr['secs']['chain'] = round(time.time() - t0, 1)
bd = baseline_dir(tree, g); bd.mkdir(parents=True, exist_ok=True)
(bd / 'chain.json').write_text(json.dumps({'key': m['key']}))
gr['chain'] = {'kind': m['kind'], 'baseline': build_summary(m)}
if m['failed']: gr['known'].append(f"{m['failed']} of {m['total']} shaders do not build")
if m.get('build_failed'): gr['known'].append('Vulkan shader build failed')
if m.get('interface', {}).get('bad'):
bad = m['interface']['bad']; gr['chain']['interface'] = {'baseline': len(bad), 'checked': m['interface']['checked'], 'known': bad[:50]}
gr['status'] = 'NEW-FAIL'; gr['reasons'].append(f'{len(bad)} pipeline bases whose pixel shader reads an output the vertex shader does not write (metal-tt refuses the archive)')
if route:
t1 = time.time()
runs = smoke_series(g, tree, art, route, a.quick, 'baseline', a, a.runs)
(bd / f'smoke-{"quick" if a.quick else "full"}.json').write_text(json.dumps({'route': route, 'runs': runs, 'tree': tree.id,
'context': smoke_context(g, art, route, a, rep['machine'])}, indent=1))
gr['smoke'] = {'route': route, 'baseline': [public_run(r) for r in runs]}
fails = [x for r in runs for x in run_failures(r, route)]
if fails: gr['status'] = 'NEW-FAIL'; gr['reasons'] += list(dict.fromkeys(fails))
if len(runs) > 1:
_, _, d = smoke_diff(runs[:1], runs[1:], route); gr['smoke']['noise'] = d
gr['secs']['smoke'] = round(time.time() - t1, 1)
except HarnessError as e:
gr['status'] = 'ERROR'; gr['reasons'].append(str(e)); say(f' {g.id}: [RED] {e}')
say(f' {g.id}: {gr["status"]} ' + '; '.join(gr['reasons']))
return finish(rep, a)
def cmd_check(a, smoke=True):
smoke = smoke and not a.no_smoke
base = Tree(a.baseline or git('merge-base', 'HEAD', 'main')); cand = Tree(a.candidate)
games = select(a, smoke=smoke and not a.no_smoke)
rep = new_report(a, 'check' if smoke else 'chain', baseline=base, candidate=cand)
rewrite = a.rewrite or rewriter_key(base) != rewriter_key(cand)
if rewrite and smoke and not a.no_smoke: say('the rewriter differs between the revisions: both rewrite the modules')
for g in games:
t0 = time.time(); gr = rep['games'][g.id] = {'name': g.name, 'status': 'PASS', 'reasons': [], 'known': [], 'secs': {}}
levels = collections.defaultdict(list)
try:
route = g.route() if smoke else None
bart = artifacts(base, g, a, rewrite) if route else None
bm = bart['manifest'] if bart else chain_build(base, g, a)[0]
cart = artifacts(cand, g, a, rewrite) if route else None
cm = cart['manifest'] if cart else chain_build(cand, g, a)[0]
gr['secs']['chain'] = round(time.time() - t0, 1)
cd = chain_diff(bm, cm)
gr['chain'] = {'kind': cm['kind'], 'baseline': build_summary(bm), 'candidate': build_summary(cm), **cd}
if cm.get('build_failed'):
(gr['known'] if bm.get('build_failed') else levels['NEW-FAIL']).append('Vulkan shader build failed')
if cd['newly_failing']: levels['NEW-FAIL'].append(f"{len(cd['newly_failing'])} shaders newly fail to build (shader_regressions.py's rule)")
if cd['added_failing']: levels['NEW-FAIL'].append(f"{len(cd['added_failing'])} shaders new in the candidate fail to build")
both = cm['failed'] - len(cd['newly_failing']) - len(cd['added_failing'])
if both: gr['known'].append(f"{both} shaders fail in both")
if cd['newly_passing']: levels['CHANGED'].append(f"{len(cd['newly_passing'])} shaders newly build")
if cd['changed']['shaders']: levels['CHANGED'].append(f"{cd['changed']['shaders']} shaders changed (MSL {cd['changed']['msl']}, reflection {cd['changed']['reflection']}, variants {cd['changed']['variants']})")
if cd['added'] or cd['removed']: levels['CHANGED'].append(f"shaders added {cd['added']}, removed {cd['removed']}")
itf = cd.get('interface')
if itf and itf['new']: levels['NEW-FAIL'].append(f"{len(itf['new'])} new pipeline bases with a pixel input the vertex shader does not write (metal-tt refuses the archive)")
if itf and itf['known']: gr['known'].append(f"{itf['candidate'] - len(itf['new'])} pipeline bases with a pixel input the vertex shader does not write (as in the baseline: GitHub issue #283)")
if route:
t1 = time.time(); mode = 'quick' if a.quick else 'full'
bf = baseline_dir(base, g) / f'smoke-{mode}.json'
context = smoke_context(g, bart, route, a, rep['machine'])
if bf.is_file() and json.loads(bf.read_text()).get('context') != context:
say(f' {g.id}: the stored {mode} baseline of {base.id} has different inputs or run settings: recording it again'); bf.unlink()
if not bf.is_file():
say(f' {g.id}: no stored {mode} baseline for {base.id}: recording it')
runs = smoke_series(g, base, bart, route, a.quick, 'baseline', a, a.runs)
bf.parent.mkdir(parents=True, exist_ok=True); bf.write_text(json.dumps({'route': route, 'runs': runs, 'tree': base.id, 'context': context}, indent=1))
bruns = json.loads(bf.read_text())['runs']
cruns = smoke_series(g, cand, cart, route, a.quick, 'candidate', a, a.runs)
lv, known, d = smoke_diff(bruns, cruns, route)
for k, v in lv.items(): levels[k] += v
gr['known'] += known
gr['smoke'] = {'route': route, 'baseline': [public_run(r) for r in bruns], 'candidate': [public_run(r) for r in cruns], 'diff': d}
gr['secs']['smoke'] = round(time.time() - t1, 1)
except HarnessError as e:
levels['ERROR'].append(str(e)); say(f' {g.id}: [RED] {e}')
except Exception as e: # a bug in the harness: reported as an error of this game, the others go on
levels['ERROR'].append(f'{type(e).__name__}: {e}'); traceback.print_exc()
gr['status'] = worst(*[k for k, v in levels.items() if v]) if any(levels.values()) else 'PASS'
gr['reasons'] = [f'{k}: {x}' for k in LEVELS for x in levels.get(k, [])]
say(f' {g.id}: {gr["status"]}' + (' — ' + '; '.join(gr['reasons']) if gr['reasons'] else ''))
return finish(rep, a)
def cmd_usage(a):
"""The usage manifest of each game (dm/tools/usage.py): shader features of the revision's chain build, and what one run of the route's quick part calls"""
tree = Tree(a.rev); games = select(a, smoke=True)
for g in games:
art = artifacts(tree, g, a, rewrite=a.rewrite)
rows = collections.Counter()
if g.kind == 'nvn':
ff = art['lib'] / 'features.json'
if not ff.is_file(): say(f' {g.id}: shader features of chain {art["manifest"]["chain"]}'); ff.write_text(json.dumps(usage.shader_features(next(art['lib'].glob('b-*')))))
for k, n in json.loads(ff.read_text()).items(): rows['shader', k] = n
for ln in (g.profile_dir(tree) / 'game.tsv').read_text().splitlines():
if ln and not ln.startswith('#') and '\t' in ln: rows['key', ln.split('\t')[0]] = 1
route = g.route()
if route and not a.no_smoke:
g.get_depth_zero = depth_zero(g, tree)
r2 = dict(route, shots=[])
d = game_root(g) / 'runs' / f'{datetime.datetime.now():%Y%m%d-%H%M%S}-usage' # the run's logs stay in the NSP folder, as check's runs
a.env = list(a.env) + ['DM_HLECOUNT=hlecount.tsv', 'DM_NVN_TSV_CALLS=1']
say(f' {g.id}: usage run ({route["quick"]:g} s) → {d}')
res = smoke_run(g, tree, art, r2, True, d, a)
a.env = a.env[:-2]
if run_failures(res, route): say(f' [RED] the run failed ({"; ".join(run_failures(res, route))}): its usage rows are incomplete')
rows.update(usage.runtime_usage(d, g.kind if g.kind != 'vk' else 'vulkan'))
else: rows['api', g.kind if g.kind != 'vk' else 'vulkan'] = 1
out = ROOT / g.id / 'usage' / f'{tree.id}.tsv'; out.parent.mkdir(parents=True, exist_ok=True)
head = f'{g.name} ({g.id}), revision {tree.commit[:12]}, chain {art["manifest"]["chain"]}, ' + (f'route smoke.tsv quick ({route["quick"]:g} s)' if route and not a.no_smoke else 'no run')
usage.write_manifest(out, rows, head)
kinds = collections.Counter(k for k, _ in rows)
say(f' {g.id}: {", ".join(f"{k} {n}" for k, n in sorted(kinds.items()))} → {out}')
if a.write:
dst = REPO / 'dm/games' / g.id / 'usage.tsv'; shutil.copyfile(out, dst); say(f' written to {dst}')
return 0
def cmd_impact(a):
"""The games a git range can affect (dm/tools/usage.py impact), from the usage manifests of this checkout's profiles"""
rng = a.range if '..' in a.range else f'{a.range}^..{a.range}'
for rev in rng.split('..'): # read from git only: no checkout and no harness root, as a contributor without the games has neither
if sh(['git', 'rev-parse', '--verify', '--quiet', (rev or 'HEAD') + '^{commit}'], cwd=REPO).returncode: raise HarnessError(f'impact: {rev} is not a revision of this repository')
manifests = {}
for d in sorted((REPO / 'dm/games').iterdir()):
if (d / 'game.tsv').is_file(): manifests[d.name] = usage.read_manifest(d / 'usage.tsv') if (d / 'usage.tsv').is_file() else None
for extra in a.manifest: # a manifest from <root>/<id>/usage/ that is not committed yet
manifests[Path(extra).parent.parent.name] = usage.read_manifest(extra)
r = usage.impact(rng, manifests)
names = {d.name: (d / 'README.md').read_text(errors='replace').split('\n', 1)[0].lstrip('# ').split(' (`')[0] if (d / 'README.md').is_file() else d.name for d in (REPO / 'dm/games').iterdir() if d.is_dir()}
st = collections.Counter(v['status'] for v in r['games'].values())
L = [f"## regress.py impact `{rng}`", '', f"{st['affected']} games use what the change touches, {st['broad']} only through a broad rule (every game of its API), {st['not affected']} not affected, {st['unknown']} unknown (no usage manifest); "
f"{len(r['changed'])} files changed, {len(r['names'])} NVN / HLE entry points reached through the host's callers, {len(r['patterns'])} rules of dm/tools/impact_map.tsv", '',
'| game | status | what it uses of the change (kind: name ×count; for broad, the broad rows) |', '|---|---|---|']
order = {'affected': 0, 'broad': 1, 'not affected': 2, 'unknown': 3}
for gid, v in sorted(r['games'].items(), key=lambda x: (order[x[1]['status']], x[0])):
if v['status'] == 'unknown' and not a.all: continue
ms = v.get('narrow') or v['matches']
top = ', '.join(f'{k}: {n} ×{c}' for k, n, c, _ in ms[:6]) + (f' … ({len(ms)} in all)' if len(ms) > 6 else '')
L.append(f"| {names.get(gid, gid)} `{gid}` | {v['status']} | {top} |")
if st['unknown'] and not a.all: L += ['', f"Unknown (no usage.tsv): {st['unknown']} profiles (--all lists them)."]
L += ['', '### Why', ''] + [f'- `{p}` ← {w}' for p, w in r['patterns']] + [f'- `{k}:{n}` ← {w}' for k, n, w in r['names'][:30]] + (['- … more entry points'] if len(r['names']) > 30 else [])
if r['unmapped']: L += ['', 'Changed files no rule maps (add them to dm/tools/impact_map.tsv): ' + ', '.join(f'`{f}`' for f in r['unmapped'])]
md = '\n'.join(L) + '\n'
out = Path(a.out) if a.out else ROOT / 'reports' / f'{datetime.datetime.now():%Y%m%d-%H%M%S}-impact' if ROOT.is_dir() else None # without a harness root, the report is only printed
if out:
out.mkdir(parents=True, exist_ok=True)
(out / 'impact.json').write_text(json.dumps({'schema': 'nxnative-impact/1', 'range': rng, **r}, indent=1, default=list)); (out / 'impact.md').write_text(md)
say(md + (f'\nreport: {out / "impact.md"}' if out else ''))
return 0
def select(a, smoke):
games = find_games(a.games.split(',') if a.games else None)
if not games: raise HarnessError(f'no game with a Mac build under {GAMES}')
say('games: ' + ', '.join(f"{g.id} ({g.name}, {g.kind}{', route' if g.route() else ''})" for g in games))
return games
def machine():
hw = sh(['sysctl', '-n', 'hw.model']).stdout.strip(); cpu = sh(['sysctl', '-n', 'machdep.cpu.brand_string']).stdout.strip()
return {'model': hw, 'chip': cpu, 'macos': sh(['sw_vers', '-productVersion']).stdout.strip(), 'xcode': sh(['xcodebuild', '-version']).stdout.split('\n')[0].strip()}
def new_report(a, command, baseline, candidate):
return {'schema': 'nxnative-regress/1', 'command': command, 'mode': 'static' if command == 'chain' else 'quick' if a.quick else 'full', 'started': datetime.datetime.now().isoformat(timespec='seconds'),
't0': time.time(), 'machine': machine(), 'baseline': baseline.describe() if baseline else None, 'candidate': candidate.describe() if candidate else None,
'tolerances': TOL, 'games': {}}
def finish(rep, a):
rep['secs'] = round(time.time() - rep.pop('t0'), 1)
st = [g['status'] for g in rep['games'].values()]
rep['status'] = worst(*st) if st else 'ERROR'
regress = any(s in ('NEW-FAIL', 'REGRESSION') for s in st) and rep['command'] != 'baseline'
rep['exit'] = 1 if regress else 2 if 'ERROR' in st or not st else 0
out = Path(a.out) if a.out else ROOT / 'reports' / f'{datetime.datetime.now():%Y%m%d-%H%M%S}-{rep["command"]}'
out.mkdir(parents=True, exist_ok=True)
(out / 'report.json').write_text(json.dumps(rep, indent=1))
md = report_md(rep); (out / 'report.md').write_text(md)
say('\n' + md + f'\nreport: {out / "report.json"}, {out / "report.md"}')
return rep['exit']
def report_md(rep):
b, c = rep.get('baseline'), rep.get('candidate')
rev = lambda t: f"`{t['rev']}` {t['sha'][:12]}" + (' (+ uncommitted changes)' if t.get('dirty') else '') if t else '–'
L = [f"## regress.py {rep['command']} ({rep['mode']}): {rep['status']}", '',
f"Baseline {rev(b)}" + (f", candidate {rev(c)}" if c else '') + f"; {rep['machine']['chip']}, macOS {rep['machine']['macos']}, {rep['machine']['xcode']}; {rep['secs']:g} s", '',
'| game | status | shaders (base → cand) | newly failing | newly building | changed MSL | interface (new / known) | boot | fps p50 | frame p99 ms | peak MB | prepare s | new RED |',
'|---|---|---|---|---|---|---|---|---|---|---|---|---|']
for gid, g in rep['games'].items():
ch = g.get('chain', {}); sm = g.get('smoke', {})
bb, cb = ch.get('baseline') or {}, ch.get('candidate') or {}
shaders = f"{bb.get('ok', '–')}/{bb.get('total', '–')}" + (f" → {cb.get('ok')}/{cb.get('total')}" if cb else '')
itf = ch.get('interface') or {}
itf_s = f"{len(itf.get('new', []))} / {len(itf.get('known', []))}" if 'new' in itf else (f"{itf.get('baseline')} bad" if itf else '–')
runs = sm.get('candidate') or sm.get('baseline') or []
boot = ('ok' if runs and not any(run_failures(r, sm['route']) for r in runs) else 'FAIL') if runs else '–'
def mv(k, sm=sm):
bv = med((r.get(k) for r in sm.get('baseline', []))); cv = med((r.get(k) for r in sm.get('candidate', [])))
if 'candidate' in sm: return f'{bv} → {cv}'
return str(bv) if bv is not None else '–'
nr = len((sm.get('diff') or {}).get('new_red', [])) if 'diff' in sm else '–'
L.append(f"| {g['name']} `{gid}` | **{g['status']}** | {shaders} | {len(ch.get('newly_failing', [])) if cb else '–'} | {len(ch.get('newly_passing', [])) if cb else '–'} | "
f"{(ch.get('changed') or {}).get('msl', '–') if cb else '–'} | {itf_s} | {boot} | {mv('fps')} | {mv('frame_p99')} | {mv('peak_footprint_mb')} | {mv('prepare_s')} | {nr} |")
for gid, g in rep['games'].items():
L += ['', f"### {g['name']} `{gid}`: {g['status']}"]
L += [f'- {x}' for x in g['reasons']] + [f'- known (baseline too): {x}' for x in g['known']]
ch = g.get('chain', {})
for x in ch.get('newly_failing', [])[:30]: L.append(f" - newly failing `{x['key']}` {x['status']}: {x['error'][:120]}")
for x in ch.get('added_failing', [])[:30]: L.append(f" - new shader failing `{x['key']}` {x['status']}: {x['error'][:120]}")
if ch.get('changed', {}).get('keys'): L.append(' - changed shaders: ' + ', '.join(f'`{k}`' for k in ch['changed']['keys'][:40]) + (' …' if ch['changed']['shaders'] > 40 else ''))
itf = ch.get('interface') or {}
for x in (itf.get('new', []) + itf.get('known', []))[:20]:
L.append(f" - {'new' if x in itf.get('new', []) else 'known'} interface: vs `{x['vs']}` {x['entry']} → ps `{x['ps']}`: " + ', '.join(f'{l} not written' for l in x['missing']) + ', '.join(x['type']))
d = (g.get('smoke') or {}).get('diff') or {}
for x in d.get('new_red', [])[:15]: L.append(f" - new RED ×{x['count']}: `{x['text']}`")
if d.get('shots'): L.append(' - screenshots (s: dHash bits / RGB diff / baseline noise): ' + ', '.join(f"{t}: {v['dhash_bits']}/{v['rgb_diff']}/{v['baseline_noise']}" for t, v in d['shots'].items()))
sm = g.get('smoke') or {}
for label in ('baseline', 'candidate'):
for r in sm.get(label, []):
pb = r.get('prebuild') or {}
L.append(f" - {label}: exit {r.get('exit')}, first frame {r.get('first_frame_s')} s, max frame {r.get('max_frame')}, reached {r.get('reached')}, prepare {r.get('prepare_s')} s"
+ (f", prebuild {pb.get('wall_s')} s ({pb.get('built', 0):g} built, {pb.get('failed', 0):g} failed)" if pb else '')
+ f", fps {r.get('fps')}, p50/p99 {r.get('frame_p50')}/{r.get('frame_p99')} ms, peak {r.get('peak_footprint_mb')} MB" + (', busy Mac' if r.get('busy') else ''))
if g.get('secs'): L.append(" - time: " + ', '.join(f'{k} {v:g} s' for k, v in g['secs'].items()))
return '\n'.join(L) + '\n'
def cmd_list(a):
for g in find_games():
r = g.route()
bl = sorted(p.parent.name + '/' + p.stem for p in (game_root(g) / 'baselines').glob('*/smoke-*.json'))
say(f'{g.id} {g.name} {g.kind} build {g.ver} route: {"yes" if r else "no (dm/games/" + g.id + "/smoke.tsv)"} baselines: {", ".join(bl) or "none"}')
return 0
def main():
global ROOT, GAMES
ap = argparse.ArgumentParser(description='Regression harness: chain diff, interface check and boot smoke over the local games (see the comment at the top of this file).')
ap.add_argument('--root', default=str(ROOT)); ap.add_argument('--games-dir', default=str(GAMES))
sub = ap.add_subparsers(dest='cmd', required=True)
def common(p, smoke=True):
p.add_argument('--games', help='title IDs, comma-separated (default: every game with a Mac build, and for the runs a smoke route)')
p.add_argument('--jobs', type=int, help='workers for the shader builds (default: the cores; a build pauses while another process holds the measuring lock)')
p.add_argument('--out', help='the report folder (default <root>/reports/<date-time>-<command>)')
p.add_argument('--min-free-gb', type=float, default=3.0, help='stop when the internal disk has less free (default 3)')
p.add_argument('--lock', default=os.environ.get('DM_MEASURE_LOCK', ''), help='the measuring lock shared with bench.py --lock (DM_MEASURE_LOCK): held while a game runs; shader builds pause while another process holds it')
if smoke:
p.add_argument('--quick', action='store_true', help='the chain checks and a short boot (the route\'s quick seconds, no prebuild)')
p.add_argument('--no-smoke', action='store_true', help='no game runs')
p.add_argument('--runs', type=int, default=1, help='runs per revision (default 1)')
p.add_argument('--wait-idle', type=float, default=900, help='seconds to wait for other builds to end before a run (default 900)')
p.add_argument('--rewrite', action='store_true', help='rewrite the modules with each revision\'s rewriter (default only when the rewriters differ)')
p.add_argument('--no-hud', action='store_true', help='no Metal performance HUD')
p.add_argument('--env', action='append', default=[], help='KEY=VALUE for every run (repeatable)')
p.add_argument('--prebuild-limit', type=float, default=1200, help='the longest pipeline prebuild (default 1200 s)')
p.add_argument('--prebuild-hang', type=float, default=120, help='seconds without prebuild progress that count as a hang (default 120)')
p = sub.add_parser('baseline', help='record the runs of a revision'); p.add_argument('--rev', default='main'); common(p)
p = sub.add_parser('check', help='compare a candidate with the stored baseline'); p.add_argument('--baseline'); p.add_argument('--candidate', default='worktree'); common(p)
p = sub.add_parser('chain', help='the static checks only'); p.add_argument('--baseline'); p.add_argument('--candidate', default='worktree'); common(p, smoke=False)
p = sub.add_parser('usage', help='write each game\'s usage manifest (names and counts)'); p.add_argument('--rev', default='worktree'); common(p)
p.add_argument('--write', action='store_true', help='also write it to dm/games/<id>/usage.tsv of this checkout')
p = sub.add_parser('impact', help='the games a change can affect'); p.add_argument('range', help='<base>..<head>, or one commit')
p.add_argument('--manifest', action='append', default=[], help='a usage.tsv not in the profiles yet (<root>/<id>/usage/<tree>.tsv)')
p.add_argument('--all', action='store_true', help='list the games without a manifest too'); p.add_argument('--out')
sub.add_parser('list', help='the games, routes and baselines')
a = ap.parse_args()
ROOT, GAMES = Path(a.root), Path(a.games_dir)
signal.signal(signal.SIGTERM, lambda *_: sys.exit(143)) # a stop runs the finally blocks: a game or a shader build in its own process group is stopped too
try:
if a.cmd == 'list': return cmd_list(a)
if a.cmd == 'impact': return cmd_impact(a)
ROOT.mkdir(parents=True, exist_ok=True); (ROOT / 'tmp').mkdir(exist_ok=True)
if a.cmd == 'chain': a.quick = False; a.no_smoke = True; a.rewrite = False
a.lock = a.pause_lock = Path(a.lock) if a.lock else None
a.jobs = a.jobs or os.cpu_count() or 8
a.tools_fp = toolchain_fp()
free = check_disk(a.min_free_gb); say(f'internal disk: {free:.1f} GB free; root {ROOT}; {a.jobs} workers')
return {'baseline': cmd_baseline, 'check': cmd_check, 'usage': cmd_usage, 'chain': lambda a: cmd_check(a, smoke=False)}[a.cmd](a)
except HarnessError as e:
print(f'[RED] regress.py: {e}', file=sys.stderr); return 2
if __name__ == '__main__':
sys.exit(main())