One translation chain version for every game: DMSH_CHAIN in dm/gfx/nvn/dmsh_chain.h replaces the 47 dm/games/<id>/dmsh_chain.h, which every chain change rewrote and some were left behind (GitHub issue #78). The version hashed each profile's shader tables and nvn_* / shader_* keys too, once the only thing that kept the host off a library built from older tables; each shader's cache key (dmsh_build.py shader_key) has covered them since, so the version now covers only the chain's tools (dmsh_chain.py, dmsh_build.py, dmsh_fetch.py, dmsh_vin.py, dmsh_geometry.py, dmsh_facts.py, the translator's source, ios_metal.py, the lock) and the options, and needs no DM_GAME. What the per-game part still did is done by the library itself: a full dmsh_build.py run stamps b-<V>/inputs.tsv (Mac and iOS) with the game and the digest of the profile's inputs (dmsh_chain.py inputs / --inputs: the four shader tables, bf_want.tsv, which the variants use but the version never counted, and the keys), starts from no shader outputs (a partial one from none of the keys it builds), so a key that left the map, a shader that no longer builds or a variant no longer made keeps no old library, refuses a directory stamped for another game, and writes the profile's c1_full.tsv and cb_extent.tsv from its Mac outputs; a partial build keeps the stamp only while it names the current inputs. c1_guard.py / cb_extent.py head their table with the library's chain version and stamp (table_head), and the host takes a table only beside that stamp, logs the stamp and reports one of another game; build_ipa.sh reads the one header and stops when the iOS libraries' stamp is not this profile's current inputs or c1_full.tsv names another. NSP script: chain_header writes the one header when stale, before any host is built; provisional and derived profiles no longer write one; chain_tables makes a table again when it does not name the reused library's stamp (False without one). Makefiles take the header beside nvn_mtl.m; Xcode projects prepared earlier point at a profile header that is gone and are told to prepare again. Tests: test_nsp_chain.py (the committed header is current, stale and missing ones are written, tables against the stamp). Checked on The Great Ace Attorney Chronicles: a full build gave 460 of 460 shaders and both tables (0 and 409 entries, as before), the Mac host booted with the stamp and both tables past frame 900; one added facts row rebuilt one shader under the same chain version with a new digest and new tables, and the host refused the old tables beside the new stamp; a full build removed planted outputs, another game's build into that directory stopped, a partial build after an input change dropped the stamp; build_ipa.sh stopped on a stale stamp, a missing one and a stale or old-form c1_full.tsv; three NSP packs built (no table lines: dmsh_build made them), reused everything, and remade deleted tables from the reused build. Graphics unit tests give the same results as before (on this Mac t_refl needs ~/dm-work's libraries and several GPU tests fail on the previous commit too). AGENTS.md's chain procedure (header once after a tool change; a full shader build makes the tables), dm/games/README.md, dm/gfx/README.md and docs/development.md say so

This commit is contained in:
seedds committed 2026-10-23 23:37:03 +08:00
1 parent f4f0a85d6e
commit 86d9ba1239
70 files changed
+248 -243

No files matched your search

+42 -34
View File
@@ -15,9 +15,9 @@
# Every intermediate file is under <id>_build/ in the NSP folder: extract/ (the extracted game files, kept for the next build unless KEEP_EXTRACTED / --no-keep-extracted), aoc/ (each DLC's RomFS by index), tools/ (nx_extract, and the Mac host for a run-time shader capture), patched_*/, shader/ (shader package and caches, kept so the next build is fast), logs/ (each step's full output), tmp/, ipa/. The finished IPA also goes to the NSP folder by default.
# The DLC, the rewrite, the shader split and the iOS (or Vulkan) shaders are reused when all their inputs are as at their last successful run (the game files, the tools, the profile, Xcode and spirv-cross: fingerprint() keys beside their output);
# the iOS shaders' inputs are the shader package's content and the chain's own tools, so a split that runs again and writes the same package leaves them reused.
# A registered profile's chain version header (dmsh_chain.h) is written again when a chain change left it behind (chain_header: it follows from the source alone), so a run may leave it changed
# in the profile folder. The c1 / uniform extent tables (c1_full.tsv, cb_extent.tsv) are not in the repository: the iOS shader step makes them in the profile folder from this build's shaders
# (chain_tables), where git ignores them.
# The chain version header dm/gfx/nvn/dmsh_chain.h (one version for every game) is written again when a chain change was committed without it (chain_header: it follows from the source alone),
# so a run may leave it changed. The c1 / uniform extent tables (c1_full.tsv, cb_extent.tsv) are not in the repository: the iOS shader step's dmsh_build.py makes them in the profile folder from
# this build's shaders, where git ignores them, and a reused step makes them again when they do not name its libraries' stamp (chain_tables).
# The shader translator is the in-repo dm/gfx/tools/dmsh_xlate_bin (the build whose fingerprints match dmsh_chain.lock; a rebuilt dll has different fingerprints and dmsh_build refuses it; its launcher is universal, so an Intel Mac runs it too). The Metal compiler and spirv-cross may be other than the lock's: the iOS shaders
# are built through dm/gfx/tools/own_compilers.py, which says so in one line and leaves the lock and the chain version as they are.
# The Python running this file needs lz4, capstone and fonttools (and backports.zstd before Python 3.14); also needed: Xcode, xcodegen, .NET SDK 10, zstd, spirv-cross. A first run takes 2 (Metroid Dread) to 10 minutes (Breath of the Wild: 5 of them the shader split), longer when shader libraries must be compiled; a repeat run with unchanged inputs takes about 20 s (Metroid Dread), mostly packing the IPA. Key values are never printed.
@@ -548,7 +548,7 @@ def ipa_name(name, app): # "The Legend of Zelda: Link's Awakening" → TheLege
return (''.join(words)[:48] or f'{app:016X}') + '.ipa'
EMPTY_TABLES = { # the shader tables before (or, for a Vulkan game, instead of) a shader split: the chain version (dmsh_chain.py) and build_ipa.sh read them
EMPTY_TABLES = { # the shader tables before (or, for a Vulkan game, instead of) a shader split: the profile inputs' digest (dmsh_chain.py inputs) and build_ipa.sh read them
'shader_map.tsv': '# kind: evidence provisional profile: filled by the shader split (a Vulkan game\'s SPIR-V shaders go through the Vulkan layer and leave it empty)\npackage\tindex\tstage\tdm_ctl_sha16\tsource\n',
'shader_facts.tsv': '# Answer table for translation queries (generated by dm/gfx/tools/shader_split.py, do not edit): key <TAB> fact JSON. Provisional profile: filled by the shader split\n',
'shader_pairs.tsv': '# kind: evidence vertex / pixel shader pairs within BNSH variations (generated by dm/gfx/tools/shader_split.py, do not edit): vertex key <TAB> pixel key\n',
@@ -598,7 +598,7 @@ def write_profile(pdir, app, f):
# ---- Derived profile (a version the game's profile was not brought up on) ----
DERIVED_SKIP = {'game.tsv', 'shader_map.tsv', 'shader_facts.tsv', 'shader_pairs.tsv', 'c1_full.tsv', 'cb_extent.tsv', 'dmsh_chain.h', 'vk_capture.tsv', 'patches.py'} # made again from the packed version's own files (patches.py: the rewriter's patches of the registered builds)
DERIVED_SKIP = {'game.tsv', 'shader_map.tsv', 'shader_facts.tsv', 'shader_pairs.tsv', 'c1_full.tsv', 'cb_extent.tsv', 'vk_capture.tsv', 'patches.py'} # made again from the packed version's own files (patches.py: the rewriter's patches of the registered builds)
def derive_profile(g, pdir, ver, d, mods):
@@ -1028,48 +1028,59 @@ def set_keys(g, changes, section):
def provisional_prep(py, repo, env, logs, g):
"""What a provisional profile needs before a Mac host can be built for it: the shader tables (empty until a shader split fills them; an older provisional profile may lack some)
and the chain version header (the host and build_ipa.sh read it; a Vulkan game never runs the NVN chain that writes it)"""
"""What a provisional profile needs before a Mac host can be built for it: the shader tables (empty until a shader split fills them; an older provisional profile may lack some),
which the host and build_ipa.sh read"""
for n, text in EMPTY_TABLES.items():
if not g.table(n).exists(): g.table(n).write_text(text)
run([py, repo / 'dm/gfx/tools/dmsh_chain.py', '--header'], logs / 'dmsh_chain.log', env=env)
def chain_of(g): # the chain version the profile's dmsh_chain.h holds (None when it has none)
try: m = re.search(r'#define DMSH_CHAIN "(.*)"', g.table('dmsh_chain.h').read_text())
CHAIN_H = 'dm/gfx/nvn/dmsh_chain.h' # the chain version the host is compiled with, one for every game
def chain_of(repo): # the chain version dm/gfx/nvn/dmsh_chain.h holds (None when it has none)
try: m = re.search(r'#define DMSH_CHAIN "(.*)"', (repo / CHAIN_H).read_text())
except OSError: return None
return m.group(1) if m else None
def chain_header(py, repo, env, logs, g):
"""A registered profile's chain version header, written again when it no longer holds the version the source tree gives: dmsh_build.py stops on a stale one, and the host and build_ipa.sh
read it. The version follows from the source alone (dmsh_chain.py), so a chain change that rewrote only some profiles' headers stops no build (GitHub issue #78: Breath of the Wild after bb585cd)"""
def chain_header(py, repo, env, logs):
"""The chain version header, written again when it no longer holds the version the source tree gives (a chain change committed without dmsh_chain.py --header): dmsh_build.py stops
on a stale one, and the host and build_ipa.sh read it. The version follows from the source alone (dmsh_chain.py), one for every game (GitHub issue #78: Breath of the Wild after bb585cd)"""
r = subprocess.run([str(py), str(repo / 'dm/gfx/tools/dmsh_chain.py')], env=env, capture_output=True, text=True)
if r.returncode or not r.stdout.split(): raise BuildError(f'dmsh_chain.py exit code {r.returncode}: {(r.stderr.strip() or "no version")[-500:]}')
want, have = r.stdout.split()[-1], chain_of(g)
want, have = r.stdout.split()[-1], chain_of(repo)
if have == want: return
run([py, repo / 'dm/gfx/tools/dmsh_chain.py', '--header'], logs / 'dmsh_chain.log', env=env)
say(f'chain version header rewritten: {g.table("dmsh_chain.h")} held {have or "no version"}, the source tree gives {want}')
say(f'chain version header rewritten: {repo / CHAIN_H} held {have or "no version"}, the source tree gives {want}')
CHAIN_TABLES = (('c1_full.tsv', 'c1_guard.py', 'c1 guard'), ('cb_extent.tsv', 'cb_extent.py', 'uniform read extents')) # made from the shader build's Mac outputs: the host takes each only when its header names its chain
CHAIN_TABLES = (('c1_full.tsv', 'c1_guard.py', 'c1 guard'), ('cb_extent.tsv', 'cb_extent.py', 'uniform read extents')) # made from the shader build's Mac outputs: the host takes each only when its header names its library's stamp
def chain_tables(py, repo, sh, env, logs, g, chain, every=False):
def lib_head(lib):
"""What c1_full.tsv / cb_extent.tsv start with to be taken beside the library directory lib (dmsh_chain.py table_head): its chain version and the profile inputs its stamp (inputs.tsv,
written by a full dmsh_build.py run) names. None when it has no stamp"""
try: stamp = dict(l.split('\t', 1) for l in (lib / 'inputs.tsv').read_text().splitlines() if '\t' in l and not l.startswith('#'))
except OSError: return None
return f'# chain {lib.name} inputs {stamp["inputs"]}' if stamp.get('inputs') else None
def chain_tables(py, repo, sh, env, logs, g, chain):
"""c1_full.tsv (c1_guard.py: shaders whose c1 reads are not proven bounded) and cb_extent.tsv (cb_extent.py: how far each shader reads into its uniform blocks) from the Mac outputs of the
shader build (sh/cache/b-<chain>/), into the profile folder. They are not in the repository (git ignores them): they follow from the shaders this Mac built, so every pack makes its own.
every: both (a provisional or derived profile's tables come from its own build); else only those missing or whose header names another chain, which the host would ignore (every c1 its
own 64 KiB, whole uniform bindings copied; GitHub issue #78). True when both name the chain; False when one does not and there are no Mac outputs to make it from"""
A full dmsh_build.py run makes both; this makes those missing or not naming the library's stamp again, which the host would ignore (every c1 its own 64 KiB, whole uniform bindings
copied; GitHub issue #78). True when both name it; False when the library has no stamp (no full build's Mac outputs to make them from)"""
lib = sh / 'cache' / f'b-{chain}'
want = lib_head(lib)
if not want: return False
for name, tool, step in CHAIN_TABLES:
p = g.table(name)
try: head = p.read_text(errors='replace').partition('\n')[0]
except OSError: head = ''
if not every and head.startswith(f'# chain b-{chain}'): continue
if not lib.is_dir(): return False
if head.startswith(want + ' '): continue
PROG.set(step=step)
run([py, repo / 'dm/gfx/tools' / tool, lib, p], logs / f'{tool[:-3]}.log', env=env)
if not every: say(f'{name} ' + (f'named {head[len("# chain "):].split(" ")[0]}' if head.startswith('# chain ') else 'names no chain' if head else 'missing') + f': made for chain {chain} ({p})')
say(f'{name} ' + (f'named {" ".join(head.split()[2:5])}' if head.startswith('# chain ') else 'names no library' if head else 'missing') + f': made for {want[2:]} ({p})')
return True
@@ -1162,7 +1173,7 @@ class ShaderWatch:
the cache check (a hit restores <key>_b.json into the iOS output b-<chain>/, a miss writes <key>.bin into its temporary folder dmsh_* under WORK),
the translation of the misses (<key>.spv in the temporary folder), and the compile, which takes the keys in a fixed order (sorted, the vertex shaders with pixel pairs last)
and writes <key>_b.json again for every miss and every pixel shader: the furthest key written so far tells how far it got"""
def __init__(self, g, ios, tmp):
def __init__(self, g, out, tmp): # out: the iOS library directory b-<chain>/
keys = set()
for ln in open(g.table('shader_map.tsv'), errors='replace'): # the keys dmsh_build.py builds: column 4 of the rows from a shader package
c = ln.rstrip('\n').split('\t')
@@ -1170,7 +1181,7 @@ class ShaderWatch:
pairs = {ln.split()[0] for ln in open(g.table('shader_pairs.tsv'), errors='replace') if ln.strip() and not ln.startswith('#')}
order = sorted(keys - pairs) + sorted(keys & pairs)
self.pos, self.n = {k: i for i, k in enumerate(order)}, len(order)
self.out, self.tmp, self.t0, self.t1, self.w = ios / f'b-{chain_of(g)}', tmp, time.time(), None, None
self.out, self.tmp, self.t0, self.t1, self.w = out, tmp, time.time(), None, None
self.hits, self.fin, self.top = set(), set(), -1
def written(self, keys, since): # those of keys whose <key>_b.json in the iOS output was written after since
@@ -1204,30 +1215,27 @@ def toolchain(): # the compilers' versions, part of the shader stages' keys
return ('cmd', ['xcodebuild', '-version']), ('cmd', ['spirv-cross', '--revision'])
def build_shaders(py, repo, sh, xlate, env, logs, g, provisional=False, pkg_key=None):
def build_shaders(py, repo, sh, xlate, env, logs, g, pkg_key=None):
"""The iOS shader libraries (dmsh_build.py). Reused when the shader package (pkg_key, its content; None never reuses) and the chain's tools, translator binaries, profile and compilers
are as at the last successful build: dmsh_build.py would only restore every shader from its cache (63,000 for Breath of the Wild: about 100 s)"""
ios, log = sh / 'ios', logs / 'dmsh_build.log'
PROG.stage('iOS shaders')
if provisional:
PROG.set(step='chain header')
run([py, repo / 'dm/gfx/tools/dmsh_chain.py', '--header'], logs / 'dmsh_chain.log', env=env) # the profile's chain version header (dmsh_build checks it)
chain = chain_of(g)
chain = chain_of(repo)
libs = ios / f'b-{chain}'; kf = libs.with_name(libs.name + '.key')
if pkg_key:
PROG.set(step='checking the inputs')
key = fingerprint(pkg_key, ('files', chain_files(repo)), ('stat', xlate.parent), ('files', profile_files(g)), *toolchain(), str(xlate),
{k: env.get(k) for k in ('XLATE_DEBUG', 'XLATE_REDUCED')}, env=env)
# the libraries are the last build's, so are tables that name the chain; a missing or stale one is made from that build's Mac outputs, and without them the step runs again
# the libraries are the last build's, so are tables that name their stamp; a missing or stale one is made from that build's Mac outputs, and without them the step runs again
if next(libs.glob('*_b.metallib'), None) and (note := reused(kf, key)) is not None and chain_tables(py, repo, sh, env, logs, g, chain):
PROG.end(f'{note}, reused (inputs unchanged)'); return ios
else: key = None
rmtree(kf)
try: PROG.watch(ShaderWatch(g, ios, Path(env['WORK'])))
try: PROG.watch(ShaderWatch(g, libs, Path(env['WORK'])))
except Exception: pass # progress never stops the build (a missing table is dmsh_build.py's to report)
run([py, repo / 'dm/gfx/tools/own_compilers.py', sh / 'pkg', sh / 'cache'], log, env=dict(env, XLATE=str(xlate), IOSOUT=str(ios))) # dmsh_build.py with this Mac's Metal compiler and spirv-cross
PROG.watch(None)
if not chain_tables(py, repo, sh, env, logs, g, chain, every=provisional): raise BuildError(f'no Mac shader outputs in {sh / "cache" / f"b-{chain}"} to make c1_full.tsv and cb_extent.tsv from')
if not chain_tables(py, repo, sh, env, logs, g, chain): raise BuildError(f'no stamped Mac shader outputs in {sh / "cache" / f"b-{chain}"} (inputs.tsv): dmsh_build.py did not finish a full build')
n = sum(1 for _ in libs.glob('*_b.metallib')) if libs.is_dir() else 0
if n == 0: raise BuildError(f'no iOS shader libraries in {libs}')
say(f'{n} iOS shader libraries ({libs})')
@@ -1532,7 +1540,7 @@ def main(args=None):
patched = work / f'patched_{pkg_ver}'
rewrite(py, repo, game / 'ExeFS', patched, env, logs, g)
if not (provisional or derived): chain_header(py, repo, env, logs, g) # before any host is built for the profile
chain_header(py, repo, env, logs) # before any host is built
if provisional or derived: # the keys that need the rewritten modules, then (provisional) the heap check (both only change keys the host is built with, not the rewrite or the shader steps)
provisional_prep(py, repo, env, logs, g)
g = refine_after_rewrite(g, game, patched)
@@ -1552,7 +1560,7 @@ def main(args=None):
oodle = (lambda: {'DM_OODLE_HOST': str(build_mac_host(repo, work / 'tools/dmhost', env, logs, g)), 'DM_OODLE_MODULES': str(patched)}) if src['oodle_packs'][0] or src['ue_legacy'][0] else None
own = bool(provisional or derived) # the shader tables come from this build's split (the profile's are checked otherwise)
pkey = split_shaders(py, repo, [dirs[v] / 'RomFS' for v in sv], [dirs[v] / 'ExeFS/main' for v in sv], sh / 'pkg', env, logs, g, own, oodle)
ios = build_shaders(py, repo, sh, xlate, env, logs, g, own, pkey)
ios = build_shaders(py, repo, sh, xlate, env, logs, g, pkey)
if not prepare_xcode and free_gb(out_ipa.parent) < 5: raise BuildError(f'less than 5 GB free on the volume of {out_ipa.parent}')
say('preparing the Xcode project' if prepare_xcode else f'building the IPA → {out_ipa}')