Shader chain: translator fixes for 39 shaders; compiled shaders cached by their Metal source

- Ryujinx (vendored, Instructions.cs GenerateTextureSample): texel offsets read from a
  register are built with OpCompositeConstruct + Offset, and a four-offset gather becomes
  four one-offset gathers. Upstream wrote an OpConstantComposite, invalid SPIR-V that
  SPIRV-Cross refused (2 shaders each in Wonder, Link's Awakening, TOTK, Age of Calamity).
- dmsh_xlate: a shader whose SPIR-V declares a 64-bit float is translated again with
  Ryujinx's DoubleToFloat pass (30 TOTK main-material vertex shaders); TOTK's
  visibility-counter vertex shader gets MacNativeGames' subgroup lowering (MIT).
  Every other shader's SPIR-V is byte-identical.
- New dm/gfx/tools/dmsh_mtlcache.py: dmsh_build.py compiles each MSL through a cache keyed
  by the source, the compile flags and the compiler (<cache>/cas/metallib/). Before a build
  it is filled from the earlier b-*/ folders beside the output and IOSOUT, and from
  DMSH_PREFILL, once a fresh compile of a few of a folder's shaders reproduces their
  libraries byte for byte. A chain change that leaves a shader's MSL unchanged no longer
  recompiles it.
- dmsh_build.py clones files with the clonefile call instead of one cp per file: each fork
  of the build process cost about 6 ms and serialized the finishing phase.
- TOTK's rebuild after the translator fix: 49 min -> 9 min, libraries byte-identical to
  the previous build apart from the 39 fixed shaders. All 12 chain versions are new;
  Dread, 13 Sentinels, Three Houses, TOTK and Wonder are rebuilt (Mac + iOS + c1_full.tsv),
  the other six follow.
This commit is contained in:
seedds committed 2026-10-18 23:55:19 +08:00
1 parent 5689d83593
commit b3f25a09bb
31 files changed
+267 -49

No files matched your search

+1 -1
View File
@@ -1,2 +1,2 @@
// 生成文件(dm/gfx/tools/dmsh_chain.py --header,游戏 0100000000010000):本源码树期望的 B 着色器库翻译链版本(缺省选项);宿主只加载 <库目录>/b-<V>/
#define DMSH_CHAIN "123e7650b527"
#define DMSH_CHAIN "d4c4453854a2"
+1 -1
View File
@@ -1,4 +1,4 @@
# chain b-8c8ebaa0059a 2026-10-18T10:29:59+08:00
# chain b-4c64982e23bf 2026-10-18T23:48:25+08:00
35cbc686dd8461b8
4872bec9a5567f8b
54a618792b2d5f0f
1 # chain b-8c8ebaa0059a 2026-10-18T10:29:59+08:00 # chain b-4c64982e23bf 2026-10-18T23:48:25+08:00
2 35cbc686dd8461b8
3 4872bec9a5567f8b
4 54a618792b2d5f0f
+1 -1
View File
@@ -1,2 +1,2 @@
// 生成文件(dm/gfx/tools/dmsh_chain.py --header,游戏 010015100B514000):本源码树期望的 B 着色器库翻译链版本(缺省选项);宿主只加载 <库目录>/b-<V>/
#define DMSH_CHAIN "8c8ebaa0059a"
#define DMSH_CHAIN "4c64982e23bf"
+1 -1
View File
@@ -1,2 +1,2 @@
// 生成文件(dm/gfx/tools/dmsh_chain.py --header,游戏 010028600EBDA000):本源码树期望的 B 着色器库翻译链版本(缺省选项);宿主只加载 <库目录>/b-<V>/
#define DMSH_CHAIN "fe21bb337dab"
#define DMSH_CHAIN "fa0edc540e9a"
+1 -1
View File
@@ -1,2 +1,2 @@
// 生成文件(dm/gfx/tools/dmsh_chain.py --header,游戏 01002B00111A2000):本源码树期望的 B 着色器库翻译链版本(缺省选项);宿主只加载 <库目录>/b-<V>/
#define DMSH_CHAIN "6322afcaa206"
#define DMSH_CHAIN "5f57cfae0ff6"
+1 -1
View File
@@ -1 +1 @@
# chain b-1c784ff32922 2026-10-18T09:51:27+08:00
# chain b-9e8a8bed7481 2026-10-18T23:25:23+08:00
1 # chain b-1c784ff32922 2026-10-18T09:51:27+08:00 # chain b-9e8a8bed7481 2026-10-18T23:25:23+08:00
+1 -1
View File
@@ -1,2 +1,2 @@
// 生成文件(dm/gfx/tools/dmsh_chain.py --header,游戏 01003FC01670C000):本源码树期望的 B 着色器库翻译链版本(缺省选项);宿主只加载 <库目录>/b-<V>/
#define DMSH_CHAIN "1c784ff32922"
#define DMSH_CHAIN "9e8a8bed7481"
+1 -1
View File
@@ -1,2 +1,2 @@
// 生成文件(dm/gfx/tools/dmsh_chain.py --header,游戏 0100535012974000):本源码树期望的 B 着色器库翻译链版本(缺省选项);宿主只加载 <库目录>/b-<V>/
#define DMSH_CHAIN "3aa49d130ba8"
#define DMSH_CHAIN "907d5c3232ae"
+1 -1
View File
@@ -1,3 +1,3 @@
# chain b-d2524031d033 2026-10-18T15:24:03+08:00
# chain b-020352bb86fa 2026-10-18T23:25:58+08:00
a936529cf487e4e0
bf81dc122c781790
1 # chain b-d2524031d033 2026-10-18T15:24:03+08:00 # chain b-020352bb86fa 2026-10-18T23:25:58+08:00
2 a936529cf487e4e0 a936529cf487e4e0
3 bf81dc122c781790 bf81dc122c781790
+1 -1
View File
@@ -1,2 +1,2 @@
// 生成文件(dm/gfx/tools/dmsh_chain.py --header,游戏 010055D009F78000):本源码树期望的 B 着色器库翻译链版本(缺省选项);宿主只加载 <库目录>/b-<V>/
#define DMSH_CHAIN "d2524031d033"
#define DMSH_CHAIN "020352bb86fa"
+1 -1
View File
@@ -1,2 +1,2 @@
// 生成文件(dm/gfx/tools/dmsh_chain.py --header,游戏 01006BB00C6F0000):本源码树期望的 B 着色器库翻译链版本(缺省选项);宿主只加载 <库目录>/b-<V>/
#define DMSH_CHAIN "7363976f0e53"
#define DMSH_CHAIN "d84b01f127bd"
+1 -1
View File
@@ -1,2 +1,2 @@
// 生成文件(dm/gfx/tools/dmsh_chain.py --header,游戏 01007EF00011E000):本源码树期望的 B 着色器库翻译链版本(缺省选项);宿主只加载 <库目录>/b-<V>/
#define DMSH_CHAIN "428aef474fa8"
#define DMSH_CHAIN "c2bffc1b7bae"
+1 -1
View File
@@ -1,3 +1,3 @@
# chain b-1865e500180e 2026-10-18T11:16:10+08:00
# chain b-c1dfcd9e019a 2026-10-18T23:25:13+08:00
57eb9f0bae677756
f826f2b59cfa39a8
1 # chain b-1865e500180e 2026-10-18T11:16:10+08:00 # chain b-c1dfcd9e019a 2026-10-18T23:25:13+08:00
2 57eb9f0bae677756 57eb9f0bae677756
3 f826f2b59cfa39a8 f826f2b59cfa39a8
+1 -1
View File
@@ -1,2 +1,2 @@
// 生成文件(dm/gfx/tools/dmsh_chain.py --header,游戏 010093801237C000):本源码树期望的 B 着色器库翻译链版本(缺省选项);宿主只加载 <库目录>/b-<V>/
#define DMSH_CHAIN "1865e500180e"
#define DMSH_CHAIN "c1dfcd9e019a"
+1 -1
View File
@@ -1,2 +1,2 @@
// 生成文件(dm/gfx/tools/dmsh_chain.py --header,游戏 0100A6301214E000):本源码树期望的 B 着色器库翻译链版本(缺省选项);宿主只加载 <库目录>/b-<V>/
#define DMSH_CHAIN "32031379f5b9"
#define DMSH_CHAIN "26bf029c37b4"
+1 -1
View File
@@ -1,4 +1,4 @@
# chain b-29bb51ae3ce7 2026-10-18T11:41:59+08:00
# chain b-e1b1691d9514 2026-10-18T23:35:28+08:00
35cbc686dd8461b8
4872bec9a5567f8b
759c14c6d029f531
1 # chain b-29bb51ae3ce7 2026-10-18T11:41:59+08:00 # chain b-e1b1691d9514 2026-10-18T23:35:28+08:00
2 35cbc686dd8461b8 35cbc686dd8461b8
3 4872bec9a5567f8b 4872bec9a5567f8b
4 759c14c6d029f531 759c14c6d029f531
+1 -1
View File
@@ -1,2 +1,2 @@
// 生成文件(dm/gfx/tools/dmsh_chain.py --header,游戏 0100F2C0115B6000):本源码树期望的 B 着色器库翻译链版本(缺省选项);宿主只加载 <库目录>/b-<V>/
#define DMSH_CHAIN "29bb51ae3ce7"
#define DMSH_CHAIN "e1b1691d9514"
+20 -21
View File
@@ -2,7 +2,7 @@
# B 路线离线出包:Switch 原版着色器 → SPIR-V(dmsh_xlate,Ryujinx 翻译器)→ spirv-cross MSL → metallib + 回放反射 JSON。
# 运行时不翻译(iOS 无 JIT):产物进着色器缓存目录,文件名 = Switch 控制段键(dm_ctl_sha16,与 shader_map.tsv 第 4 列同)+「_b」。
# 查询作答来源:游戏档案里的作答表 dm/games/<名>/shader_facts.tsv(dmsh_facts.py)——贴图类型、顶点属性 / 片元输出分量类型、计算线程组大小与共享内存;出包不需要 PC 版文件。
# 用法:dmsh_build.py <Switch 包目录> <输出缓存目录> [键…](不给键 = 全部);产物实际写进 <输出缓存目录>/b-<链版本>/(IOSOUT 同),链版本见 dmsh_chain.py 环境:DM_GAME=游戏档案(缺省 smbw,dm/games/),XLATE=翻译工具路径(缺省仓内 dmsh_xlate_bin),DMSH_CAS=内容寻址缓存目录(缺省为输出缓存目录/cas),J=并发
# 用法:dmsh_build.py <Switch 包目录> <输出缓存目录> [键…](不给键 = 全部);产物实际写进 <输出缓存目录>/b-<链版本>/(IOSOUT 同),链版本见 dmsh_chain.py 环境:DM_GAME=游戏档案(缺省 smbw,dm/games/),XLATE=翻译工具路径(缺省仓内 dmsh_xlate_bin),DMSH_CAS=内容寻址缓存目录(缺省为输出缓存目录/cas),DMSH_PREFILL=Mac 库根目录[=iOS 库根目录](以 : 分隔,其下 b-*/ 先收进按 MSL 的编译缓存,同级的 b-*/ 总是收),J=并发
# 程序本体内嵌的图形着色器(映射表 main.rodata@偏移 行)从 MAIN(Switch main NSO,缺省 3.3.94012 Update/ExeFS/main)解出;解 NSO 要 lz4,用 dm 的 venv python 跑
# 产物:<键>_b.metallib、<键>_b.json(回放反射,含驱动段 c0 / c2 的运行时读取偏移 drv_reads)、<键>_b.metal(MSL)
# 环境 IOSOUT=目录:同一份 MSL 经 dm/ios/tools/ios_metal.compile 同时出 iphoneos metallib + 反射(chain_metal 记 iOS 编译口径)
@@ -10,7 +10,7 @@
# Route B offline build: original Switch shaders → SPIR-V (dmsh_xlate, the Ryujinx translator) → spirv-cross MSL → metallib + replay reflection JSON.
# No translation at runtime (iOS has no JIT): build output goes into the shader cache directory, file name = Switch control segment key (dm_ctl_sha16, same as column 4 of shader_map.tsv) + "_b".
# Source of query answers: the answer table in the game profile, dm/games/<id>/shader_facts.tsv (dmsh_facts.py) - texture types, vertex attribute / fragment output component types, compute thread group size and shared memory; the build needs no PC version files.
# Usage: dmsh_build.py <Switch package dir> <output cache dir> [key...] (no keys = all); build output is actually written to <output cache dir>/b-<chain version>/ (same for IOSOUT), for the chain version see dmsh_chain.py Environment: DM_GAME=game profile (default smbw, dm/games/), XLATE=path to the translation tool (default the in-repo dmsh_xlate_bin), DMSH_CAS=content-addressed cache dir (default <output cache dir>/cas), J=concurrency
# Usage: dmsh_build.py <Switch package dir> <output cache dir> [key...] (no keys = all); build output is actually written to <output cache dir>/b-<chain version>/ (same for IOSOUT), for the chain version see dmsh_chain.py Environment: DM_GAME=game profile (default smbw, dm/games/), XLATE=path to the translation tool (default the in-repo dmsh_xlate_bin), DMSH_CAS=content-addressed cache dir (default <output cache dir>/cas), DMSH_PREFILL=Mac library root[=iOS library root] (separated by :, their b-*/ go into the MSL-keyed compile cache first; sibling b-*/ always do), J=concurrency
# Graphics shaders embedded in the executable itself (mapping table rows main.rodata@offset) are extracted from MAIN (Switch main NSO, default 3.3.94012 Update/ExeFS/main); extracting the NSO needs lz4, run with dm's venv python
# Build output: <key>_b.metallib, <key>_b.json (replay reflection, including drv_reads, the runtime read offsets into driver segments c0 / c2), <key>_b.metal (MSL)
# Environment IOSOUT=dir: the same MSL also goes through dm/ios/tools/ios_metal.compile to produce an iphoneos metallib + reflection (chain_metal records the iOS compile convention)
@@ -19,7 +19,7 @@ import ast, inspect, os, re, sys, json, struct, hashlib, shutil, subprocess, tem
DM_ROOT = os.environ.get('DM_ROOT') or os.path.expanduser('~/dm-work') # 工作区根目录(游戏文件、产物、运行现场都在它下面) | Workspace root (game files, build outputs and run artifacts are all under it)
HOME = os.path.expanduser('~')
dm_dir, out = sys.argv[1:3]; want = set(sys.argv[3:]); cache_root = out
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import dmsh_chain, dmsh_fetch, dmsh_facts # 翻译链版本:先核工具与锁文件,产物进 b-<V>/ | translation chain version: verify the tools against the lock file first; build output goes into b-<V>/
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import dmsh_chain, dmsh_fetch, dmsh_facts, dmsh_mtlcache # 翻译链版本:先核工具与锁文件,产物进 b-<V>/ | translation chain version: verify the tools against the lock file first; build output goes into b-<V>/
_bad = dmsh_chain.check_tools()
if _bad: sys.exit('\n'.join('【红】翻译链工具与锁文件不符:' + b for b in _bad) + '\n(换了工具就 dmsh_chain.py --update-lock 并入仓;只是没重编翻译器就重编)')
CHAIN = dmsh_chain.version(); out = os.path.join(out, 'b-' + CHAIN)
@@ -46,6 +46,9 @@ PRE_STATS = collections.Counter()
# Super Mario Odyssey draws a depth pre-pass with alRenderDepthOnly, then the G-buffer with another vertex shader and depth test less-or-equal: a hair off and the test failed, leaving those pixels with old content (Cap Kingdom's ground in 32x32 blocks)
MAC_METAL = ['xcrun', '-sdk', 'macosx', 'metal', '-c', '-fpreserve-invariance']
MAC_RECORD = ' '.join(MAC_METAL) + '; xcrun -sdk macosx metallib'
def mac_compile(msl, air, lib): # 同 ios_metal.compile 的接口 | same interface as ios_metal.compile
subprocess.run([*MAC_METAL, msl, '-o', air], check=True)
subprocess.run(['xcrun', '-sdk', 'macosx', 'metallib', air, '-o', lib], check=True)
# 贴图句柄表(贴图指令立即数句柄所在的驱动段):马力欧(NVN 55)是 0x20 + 8 r、32 槽——全库 4888 个着色器的立即数句柄恰是 0x20..0xF0、步长 8(D2R 的旧驱动是 0x168)。
# 本作只用合并句柄(nvnCommandBufferBindTexture),没有分离采样器;图像句柄表仍在 0x120 + 8 r(8 槽,紧接贴图表之后;agl 的 10 个计算着色器实测 0x120 / 0x128 / 0x130,与 D2R 同址)。
# Texture handle table (the driver segment holding texture instructions' immediate handles): Mario (NVN 55) uses 0x20 + 8 r, 32 slots - the immediate handles of all 4888 shaders are exactly 0x20..0xF0 in steps of 8 (D2R's older driver used 0x168).
@@ -64,13 +67,9 @@ def records(d):
r = [m.start() for m in re.finditer(b'BTCDBTCD', d)]
return [d[x:(r[k + 1] if k + 1 < len(r) else len(d))] for k, x in enumerate(r)]
def clone(src, dst):
def clone(src, dst): # APFS 写时复制,否则普通复制;用 clonefile 调用而不起 cp:本进程内存大,fork 一次约 6 ms | APFS copy-on-write, else a plain copy; the clonefile call, not a cp: a fork of this large process costs about 6 ms
os.makedirs(os.path.dirname(dst), exist_ok=True)
tmp = dst + '.tmp-' + str(os.getpid()) + '-' + str(threading.get_ident())
r = subprocess.run(['cp', '-c', src, tmp], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=60)
if r.returncode:
shutil.copyfile(src, tmp)
os.replace(tmp, dst)
dmsh_mtlcache.clone(src, dst)
def atomic_json(path, value):
os.makedirs(os.path.dirname(path), exist_ok=True)
@@ -216,9 +215,8 @@ def fetch_finish(key, src, refl):
open(fmsl, 'w').write(fv)
fair = fmsl[:-6] + '.air'; flib = os.path.join(out, key + '_bf.metallib')
try:
subprocess.run([*MAC_METAL, fmsl, '-o', fair], check=True)
tmp_flib = flib + '.tmp-' + str(os.getpid())
subprocess.run(['xcrun', '-sdk', 'macosx', 'metallib', fair, '-o', tmp_flib], check=True)
mac_compile(fmsl, fair, tmp_flib)
os.replace(tmp_flib, flib)
ilib = None
if IOSOUT:
@@ -517,7 +515,7 @@ def vs_depth(src):
def finish(key, w, j, c1):
name, k = loc[key]
msl = os.path.join(w, key + '.metal'); air = msl[:-6] + '.air'; lib = os.path.join(out, key + '_b.metallib')
msl = os.path.join(w, key + '.metal'); lib = os.path.join(out, key + '_b.metallib')
r = subprocess.run(['spirv-cross', *XC, os.path.join(w, key + '.spv'), '--output', msl], capture_output=True, text=True)
if r.returncode: return 'MSL 失败', r.stderr.strip()[:160], None
src = open(msl).read()
@@ -540,19 +538,12 @@ def finish(key, w, j, c1):
src, full = vs_depth(src)
open(msl, 'w').write(full)
res = json.load(open(os.path.join(w, key + '.res.json')))
try:
subprocess.run([*MAC_METAL, msl, '-o', air], check=True)
tmp_lib = lib + '.tmp-' + str(os.getpid())
subprocess.run(['xcrun', '-sdk', 'macosx', 'metallib', air, '-o', tmp_lib], check=True)
os.replace(tmp_lib, lib)
try: MTL.compile('mac', msl, lib) # MSL 与以前编过的一样就直接取那次的库(dmsh_mtlcache.py) | MSL identical to one compiled before takes that library (dmsh_mtlcache.py)
except subprocess.CalledProcessError as e:
return 'metal 失败', str(e)[:160], None
clone(msl, os.path.join(out, key + '_b.metal'))
if IOSOUT: # iOS:同一份 MSL 走 iOS 的唯一编译口径(dm/ios/tools/ios_metal.py)出 iphoneos metallib | iOS: the same MSL goes through iOS's single compile convention (dm/ios/tools/ios_metal.py) to produce an iphoneos metallib
ilib = os.path.join(IOSOUT, key + '_b.metallib')
try:
ios_metal.compile(msl, msl[:-6] + '.ios.air', ilib + '.tmp')
os.replace(ilib + '.tmp', ilib)
try: MTL.compile('ios', msl, os.path.join(IOSOUT, key + '_b.metallib'))
except subprocess.CalledProcessError as e: return 'iOS metal 失败', str(e)[:160], None
entry = re.search(r'(vertex|fragment|kernel)\s+\S+\s+(\w+)\s*\(', src)
dr = drv_reads(src)
@@ -570,6 +561,13 @@ keys = sorted(want) if want else sorted(loc)
NJ = int(os.environ.get('J', '8'))
os.makedirs(out, exist_ok=True)
w = tempfile.mkdtemp(prefix='dmsh_', dir=os.environ.get('WORK'))
MTL = dmsh_mtlcache.Cache(os.path.join(CAS, 'metallib'), {'mac': ('macosx', MAC_RECORD, mac_compile), **({'ios': ('iphoneos', ios_metal.RECORD, ios_metal.compile)} if IOSOUT else {})}, w)
# 先收下早先的链版本留在盘上的库:输出目录同级的 b-*/(iOS 取同名目录)与 DMSH_PREFILL 列的根目录 | first take the libraries earlier chain versions left on disk: the b-*/ beside the output (iOS: the folder of the same name) and the roots DMSH_PREFILL lists
for _spec in [cache_root + ('=' + os.path.dirname(IOSOUT) if IOSOUT else '')] + [x for x in os.environ.get('DMSH_PREFILL', '').split(os.pathsep) if x]:
_mac, _, _ios = _spec.partition('=')
for _b in sorted(os.listdir(_mac)) if os.path.isdir(_mac) else []:
if _b.startswith('b-') and os.path.realpath(os.path.join(_mac, _b)) != os.path.realpath(out):
for _ln in MTL.fill(os.path.join(_mac, _b), os.path.join(_ios, _b) if _ios else None): print(_ln, flush=True)
packs, jobs, c1s = {}, {}, {}
digests, rule_fps, cache_reasons, cache_hits = {}, {}, {}, set()
for key in keys:
@@ -619,6 +617,7 @@ print(f'前置缓存:命中 {PRE_STATS["hit"]},重出 {PRE_STATS["miss"]}')
for reason in ('新键', '规则变', '代码指纹变化:全部重出'):
if PRE_STATS[reason]: print(f' {reason} {PRE_STATS[reason]}')
print(f'变体缓存:生成 {BF_STATS["generated"]},命中 {BF_STATS["hit"]},重出 {BF_STATS["miss"]},编译 {BF_STATS["compile"]}')
print(MTL.summary())
for lvl in [k for k in st if k != 'ok']:
for e, n in collections.Counter(r[2] for r in res if r[1] == lvl).most_common(10): print(f' [{lvl}] {n:5d} {e}')
ok = [r[3] for r in res if r[1] == 'ok']
+4 -4
View File
@@ -1,8 +1,8 @@
# 翻译链工具指纹(dmsh_chain.py --update-lock 生成;换工具后重生成并随提交入仓)
dmsh_xlate.dll bde0f0e23d095ae5
Ryujinx.Graphics.Shader.dll 950b659a54a52636
Ryujinx.Common.dll 74f785bc3a81386e
xlate_source 41339b01f7cde45f
dmsh_xlate.dll e85245171dbb3310
Ryujinx.Graphics.Shader.dll 011c50cb1c08e44f
Ryujinx.Common.dll 5222da62ffb43db1
xlate_source e5e4cf3ed43f05a2
spirv-cross d9dc197f061b42a5
metal_macosx Apple metal version 32023.921 (metalfe-32023.921.6)
metal_iphoneos Apple metal version 32023.921 (metalfe-32023.921.6)
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
# Compiled-shader cache keyed by the Metal source (dmsh_build.py): a library is compiled once per (MSL text, compile convention, compiler), so a chain change
# that leaves a shader's MSL as it was (a translator fix, a new rule for other shaders) costs that shader no Metal compile. The Metal compiler is deterministic:
# the same MSL with the same flags gives a byte-identical metallib (checked for macOS and iOS), which is what makes reuse safe.
# Layout: <cache>/<target>/<2 hex>/<digest>.metallib, digest = sha256 over FORMAT, the target's compile convention (the record written into json chain_metal),
# the compiler's identity (metal / metallib --version without the install path, the SDK version and build) and the MSL bytes.
# Filling from earlier builds: a Mac b-<chain>/ folder holds <key>_b.metal (the exact source that was compiled) beside <key>_b.metallib, and the iOS b-<chain>/ folder
# of the same chain holds the iOS libraries of the same sources. A folder is taken only when its json chain_metal is the current convention and a fresh compile of
# a few of its shaders (two per stage) reproduces their libraries byte for byte; each folder is read once (filled.json remembers it by its number of libraries).
# Not part of the chain version (dmsh_chain.py): the cache never changes an output, it only skips recompiling one. Change FORMAT when what it stores changes.
import os, re, json, shutil, ctypes, hashlib, threading, subprocess, collections
FORMAT = b'dmsh_mtlcache 1'
_clonefile = getattr(ctypes.CDLL(None, use_errno=True), 'clonefile', None) # APFS copy-on-write copy (no data written); elsewhere a plain copy
def clone(src, dst):
tmp = f'{dst}.tmp-{os.getpid()}-{threading.get_ident()}'
if _clonefile is None or _clonefile(os.fsencode(src), os.fsencode(tmp), 0) != 0: shutil.copyfile(src, tmp)
os.replace(tmp, dst)
def compiler_id(sdk):
def run(*a): return subprocess.run(['xcrun', '-sdk', sdk, *a], capture_output=True, text=True).stdout
tools = ''.join(run(t, '--version') for t in ('metal', 'metallib'))
return re.sub(r'(?m)^InstalledDir:.*\n?', '', tools) + run('--show-sdk-version') + run('--show-sdk-build-version') # the install path changes with each toolchain mount
class Cache:
def __init__(self, root, targets, work):
"""targets: {name: (sdk, record, compile)}, compile(msl, air, lib) as ios_metal.compile; work: a folder for temporary .air files"""
self.root, self.targets, self.work = root, targets, work
self.ident = {t: hashlib.sha256(FORMAT + b'\0' + rec.encode() + b'\0' + compiler_id(sdk).encode()).digest() for t, (sdk, rec, _) in targets.items()}
self.stats, self.lock = collections.Counter(), threading.Lock()
def path(self, target, src):
d = hashlib.sha256(self.ident[target] + b'\0' + src).hexdigest()
return os.path.join(self.root, target, d[:2], d + '.metallib')
def count(self, what, n=1):
with self.lock: self.stats[what] += n
def _compile(self, target, msl, lib):
air = os.path.join(self.work, f'mtlcache-{target}-{os.getpid()}-{threading.get_ident()}.air')
try: self.targets[target][2](msl, air, lib)
finally:
if os.path.exists(air): os.unlink(air)
def compile(self, target, msl, lib):
"""msl → lib for target: a clone of the library that the same source compiled to before, or a compile (then kept). Raises CalledProcessError as the compile does"""
p = self.path(target, open(msl, 'rb').read())
if os.path.exists(p):
clone(p, lib); self.count((target, 'reused')); return
tmp = f'{lib}.tmp-{os.getpid()}-{threading.get_ident()}'
self._compile(target, msl, tmp)
os.replace(tmp, lib)
os.makedirs(os.path.dirname(p), exist_ok=True); clone(lib, p); self.count((target, 'compiled'))
def _check(self, target, d, stems, mac_dir):
"""None if folder d was built with the current convention and compiler, else why not"""
rec, picks = self.targets[target][1], collections.defaultdict(list)
for stem in stems[::max(1, len(stems) // 24)]:
try: j = json.load(open(os.path.join(d, stem + '.json')))
except (OSError, ValueError): return f'no readable {stem}.json'
if j.get('chain_metal') != rec: return f'built with "{j.get("chain_metal")}"'
if len(picks[j.get('stage')]) < 2: picks[j.get('stage')].append(stem)
for stem in (s for v in picks.values() for s in v):
tmp = os.path.join(self.work, f'mtlcache-check-{target}.metallib')
try: self._compile(target, os.path.join(mac_dir, stem + '.metal'), tmp)
except subprocess.CalledProcessError: return f'{stem}.metal does not compile now'
same = open(tmp, 'rb').read() == open(os.path.join(d, stem + '.metallib'), 'rb').read(); os.unlink(tmp)
if not same: return f'a fresh compile of {stem}.metal differs from its library'
return None
def fill(self, mac_dir, ios_dir=None):
"""Takes the libraries of an earlier build: mac_dir = its Mac b-<chain>/ folder, ios_dir = the iOS folder of the same chain (or None). Returns the lines to print"""
stamp_file = os.path.join(self.root, 'filled.json')
try: stamps = json.load(open(stamp_file))
except (OSError, ValueError): stamps = {}
names = set(os.listdir(mac_dir)); stems = sorted(n[:-6] for n in names if n.endswith('_b.metal'))
todo, notes = {}, []
for target, d in (('mac', mac_dir), ('ios', ios_dir)):
if target not in self.targets or not d or not os.path.isdir(d): continue
libs = names if d == mac_dir else set(os.listdir(d))
have = [s for s in stems if s + '.metallib' in libs]; key = os.path.realpath(d)
if not have or stamps.get(key) == len(have): continue
why = self._check(target, d, have, mac_dir)
if why: notes.append(f'Metal cache: not filled from {d}: {why}')
else: todo[target] = (d, set(have))
stamps[key] = len(have)
added = collections.Counter()
for stem in stems:
ts = [t for t, (d, have) in todo.items() if stem in have]
if not ts: continue
src = open(os.path.join(mac_dir, stem + '.metal'), 'rb').read()
for t in ts:
p = self.path(t, src)
if os.path.exists(p): continue
os.makedirs(os.path.dirname(p), exist_ok=True); clone(os.path.join(todo[t][0], stem + '.metallib'), p); added[t] += 1
for t, (d, have) in todo.items():
self.count((t, 'filled'), added[t]); notes.append(f'Metal cache: {added[t]} {t} libraries added from {d} ({len(have)} there)')
os.makedirs(self.root, exist_ok=True)
tmp = f'{stamp_file}.tmp-{os.getpid()}'; json.dump(stamps, open(tmp, 'w'), indent=1); os.replace(tmp, stamp_file)
return notes
def summary(self):
s = self.stats
return 'Metal cache: ' + '; '.join(f'{t} {s[t, "reused"]} reused, {s[t, "compiled"]} compiled' + (f', {s[t, "filled"]} filled from earlier builds' if s[t, 'filled'] else '')
for t in self.targets)
+67 -1
View File
@@ -101,6 +101,21 @@ static class SwshXlate
// 作业 reduced = true:放宽精度——翻译器只给强制精确的运算打 NoContraction(缺省全部打:保持 Maxwell 分开舍入的逐位结果,代价是 MSL 里大量禁优化的 spvFMul 等)
// Job reduced = true: relaxed precision - the translator marks NoContraction only on operations that must be exact (the default marks all: keeps the bit-exact results of Maxwell's separate rounding, at the cost of many optimization-disabled spvFMul etc. in the MSL)
public bool QueryHostReducedPrecision() => _job["reduced"] is JsonNode n && (bool)n;
// Job softfloat64 = true: the host has no FP64, so Ryujinx's DoubleToFloat pass lowers double operations to float (Metal has no double type). Set only on the retry of a
// shader whose first translation declared a 64-bit float (see Main): answering "no" for every shader would also drop the Float64 capability from all the others
public bool QueryHostSupportsShaderFloat64() => !(_job["softfloat64"] is JsonNode n && (bool)n);
}
// True when the SPIR-V declares a 64-bit float type (OpTypeFloat 64): SPIRV-Cross then writes double, which the Metal compiler rejects
static bool UsesFloat64(byte[] spirv)
{
for (int i = 20; i + 12 <= spirv.Length; )
{
uint word = BitConverter.ToUInt32(spirv, i);
if ((word & 0xffff) == 22 && (word >> 16) == 3 && BitConverter.ToUInt32(spirv, i + 8) == 64) return true;
i += Math.Max(1, (int)(word >> 16)) * 4;
}
return false;
}
static int Main(string[] args)
@@ -126,7 +141,13 @@ static class SwshXlate
var flags = (job["debug"] is JsonNode dbg && !(bool)dbg ? 0 : TranslationFlags.DebugMode) | ((string)job["stage"] == "cs" ? TranslationFlags.Compute : 0);
var ctx = Translator.CreateContext(0, acc, new TranslationOptions(TargetLanguage.Spirv, TargetApi.Vulkan, flags));
ShaderProgram p = ctx.Translate();
File.WriteAllBytes(Path.Combine(outDir, key + ".spv"), p.BinaryCode);
if (UsesFloat64(p.BinaryCode)) // Tears of the Kingdom 1.4.3: 30 shaders used double and failed in the Metal compiler
{
job["softfloat64"] = true; acc = new Accessor(code, job);
ctx = Translator.CreateContext(0, acc, new TranslationOptions(TargetLanguage.Spirv, TargetApi.Vulkan, flags));
p = ctx.Translate(); acc.Notes.Add("FP64 lowered to FP32 (DoubleToFloat)");
}
File.WriteAllBytes(Path.Combine(outDir, key + ".spv"), VertexCounterLowering.Apply(code, (string)job["stage"], p.BinaryCode, acc.Notes));
var res = new JsonObject
{
["key"] = key, ["stage"] = (string)job["stage"], ["notes"] = new JsonArray(acc.Notes.ConvertAll(n => (JsonNode)n).ToArray()),
@@ -146,3 +167,48 @@ static class SwshXlate
return bad == 0 ? 0 : 1;
}
}
// Tears of the Kingdom 1.4.3 visibility counter (one vertex shader, exact code SHA-256 below): each invocation that passes its test joins a subgroup ballot, and the
// highest active lane adds popcount(ballot) to s1[0]. SPIRV-Cross cannot give a Metal vertex function the subgroup builtins ("Subgroup builtins are not available in
// this type of function"), so the shader never built and its draws were skipped. Lowered to singleton groups: lane id 0 and ballot(true) = (1, 0, 0, 0), so every passing
// invocation adds one; the atomic's result is unused, so the final sum (mod 2^32) is the same. Not a general subgroup fallback: it runs only on these analysed bytes.
// Adapted from MacNativeGames (games/totk/tools/totk_xlate/VertexCounterLowering.cs, https://github.com/ly05010419/MacNativeGames), MIT License: dm/third_party/macnativegames/LICENSE.
static class VertexCounterLowering
{
const string InstanceCodeHash = "F5F4D23149565ACD852F6B7091F64CC8CB3AA019CCB33BA3F88CED62C6D1FF7B";
internal static byte[] Apply(byte[] code, string stage, byte[] spirv, List<string> notes)
{
if (stage != "vs" || Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(code)) != InstanceCodeHash) return spirv;
uint[] w = new uint[spirv.Length / 4]; Buffer.BlockCopy(spirv, 0, w, 0, spirv.Length);
uint lane = 0, uintType = 0, vectorType = 0, trueId = 0; int ballots = 0, loads = 0;
for (int i = 5; i < w.Length; i += (int)(w[i] >> 16))
{
uint op = w[i] & 65535;
if (op == 71 && w[i + 2] == 11 && w[i + 3] == 41) lane = w[i + 1]; // OpDecorate BuiltIn SubgroupLocalInvocationId
if (op == 21 && w[i + 2] == 32 && w[i + 3] == 0) uintType = w[i + 1]; // OpTypeInt 32 unsigned
if (op == 41) trueId = w[i + 2]; // OpConstantTrue
if (op == 339) { vectorType = w[i + 1]; ballots++; if (w[i + 4] != trueId) throw new InvalidOperationException("visibility counter: ballot predicate changed"); } // OpGroupNonUniformBallot
}
if (lane == 0 || uintType == 0 || vectorType == 0 || ballots != 1) throw new InvalidOperationException("visibility counter: SPIR-V shape changed");
uint zero = w[3]++, one = w[3]++, mask = w[3]++; // new ids past the bound
var result = new List<uint>(w.Length + 16); result.AddRange(w.AsSpan(0, 5).ToArray());
bool constants = false;
for (int i = 5; i < w.Length; i += (int)(w[i] >> 16))
{
uint op = w[i] & 65535;
if (op == 54 && !constants) // before the first OpFunction: uint 0, uint 1, uvec4(1, 0, 0, 0)
{
result.AddRange(new uint[] { (4u << 16) | 43, uintType, zero, 0, (4u << 16) | 43, uintType, one, 1, (7u << 16) | 44, vectorType, mask, one, zero, zero, zero });
constants = true;
}
if (op == 61 && w[i + 3] == lane) { result.AddRange(new uint[] { (4u << 16) | 83, w[i + 1], w[i + 2], zero }); loads++; } // OpLoad lane id -> OpCopyObject 0
else if (op == 339) result.AddRange(new uint[] { (4u << 16) | 83, w[i + 1], w[i + 2], mask }); // ballot(true) -> OpCopyObject (1, 0, 0, 0)
else result.AddRange(w.AsSpan(i, (int)(w[i] >> 16)).ToArray());
}
if (loads != 1 || !constants) throw new InvalidOperationException("visibility counter: lane id use changed");
notes.Add("visibility counter lowered to singleton groups (one atomic add per passing vertex)");
byte[] output = new byte[result.Count * 4]; Buffer.BlockCopy(result.ToArray(), 0, output, 0, output.Length);
return output;
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -8,11 +8,11 @@ This is code written by others, shipped with the repository so there is nothing
| `libdeflate/` | Decompression library | Upstream commit `92e6a0d`, unmodified | Host: zlib decoding of the game's resource blocks (`dm/host/hle_tact.c`). Only the five decompression-related source files are built; they are listed in `dm/host/Makefile` | `libdeflate/COPYING` (MIT) |
| `libvpx/` | The VP9 decoder from libvpx: the 71 source files a VP9-decoder-only build compiles, their headers, and the generated configuration in `config/` | v1.17.0 (upstream commit `6df3ec3`), unmodified | Host: decodes the games' VP9 movies (`dm/host/hle_movie.c`). `dm/host/Makefile` builds it into `gen/vpx/libvpx.a`; the iOS build compiles the same files | `libvpx/LICENSE` (BSD 3-Clause), with the patent grant in `libvpx/PATENTS` |
| `stb_vorbis/` | Ogg Vorbis decoder, a single file `stb_vorbis.c` | 1.22 (upstream commit `1ee679c`), unmodified | Host: decodes the Vorbis sound tracks of the games' WebM movies (`dm/host/hle_movie.c`, which includes the file with its push-data API only) | `stb_vorbis/LICENSE` (MIT or public domain, your choice; the same text ends `stb_vorbis.c`) |
| `ryujinx/` | The shader translation library from Ryujinx, three projects: `Ryujinx.Graphics.Shader`, `Ryujinx.Common`, `Spv.Generator` | Ryujinx 1.3.2, unmodified | Referenced by the shader translation tool `dm/gfx/tools/dmsh_xlate`, which translates the original Switch shaders to SPIR-V | `ryujinx/LICENSE.txt` (MIT); `ryujinx/src/Spv.Generator/LICENSE` is that subproject's own license text |
| `ryujinx/` | The shader translation library from Ryujinx, three projects: `Ryujinx.Graphics.Shader`, `Ryujinx.Common`, `Spv.Generator` | Ryujinx 1.3.2, with the local changes listed in `ryujinx/README.md` | Referenced by the shader translation tool `dm/gfx/tools/dmsh_xlate`, which translates the original Switch shaders to SPIR-V | `ryujinx/LICENSE.txt` (MIT); `ryujinx/src/Spv.Generator/LICENSE` is that subproject's own license text |
| `vulkan-headers/` | The Vulkan C headers: `vulkan_core.h`, `vk_platform.h`, `vulkan_vi.h` and the `vk_video/` headers they include | Vulkan-Headers 1.4.363, unmodified | Host: the Vulkan layer on Metal (`dm/gfx/vk/hle_vk.m`) that runs Hades | `vulkan-headers/LICENSE.md` (Apache-2.0 or MIT, your choice) |
A few more notes:
- `flip/tool/` also carries three single-header libraries used by the FLIP command-line tool: `stb_image.h` (2.23), `stb_image_write.h` (1.13) and `tinyexr.h`. Their license notices are in the files themselves.
- `libdeflate/VERSION.md`, `libvpx/README.md`, `stb_vorbis/README.md`, `ryujinx/README.md` and `vulkan-headers/README.md` record where each came from and what it is used for.
- Three pieces of third-party code live outside this directory: `dm/gfx/nvn/include/nvn.h` (MIT, author named in the file header), the layout size algorithm in `dm/gfx/nvn/nvn_tex.c`, ported from deko3d (zlib license), and in `dm/gfx/nvn/nvn_mtl.m` the GPU widening of 8-bit indices (`wide_*`) and the in-pass clear triangles (`clear_color_tri` / `clear_ds_tri`), adapted from MacNativeGames' `widen_u8_gpu` and `clear_quad` / `clear_ds_quad` (MIT, license text in `macnativegames/LICENSE`).
- Four pieces of third-party code live outside this directory: `dm/gfx/nvn/include/nvn.h` (MIT, author named in the file header), the layout size algorithm in `dm/gfx/nvn/nvn_tex.c`, ported from deko3d (zlib license), and two adapted from MacNativeGames (MIT, license text in `macnativegames/LICENSE`): in `dm/gfx/nvn/nvn_mtl.m` the GPU widening of 8-bit indices (`wide_*`) and the in-pass clear triangles (`clear_color_tri` / `clear_ds_tri`), from its `widen_u8_gpu` and `clear_quad` / `clear_ds_quad`, and the Tears of the Kingdom visibility-counter lowering in `dm/gfx/tools/dmsh_xlate/Program.cs` (`VertexCounterLowering`).
+6 -1
View File
@@ -1,5 +1,10 @@
# Ryujinx shader translator (vendored subset)
Three projects from Ryujinx 1.3.2, unmodified: `Ryujinx.Graphics.Shader`, `Ryujinx.Common` and `Spv.Generator`.
Three projects from Ryujinx 1.3.2: `Ryujinx.Graphics.Shader`, `Ryujinx.Common` and `Spv.Generator`, with these local changes:
- `Ryujinx.Graphics.Shader/CodeGen/Spirv/Instructions.cs`, `GenerateTextureSample`:
- Texel offsets that are not constants (Maxwell AOFFI / TLD4.PTP offsets read from a register) are built with `OpCompositeConstruct` and passed as `Offset`. A four-offset gather becomes four one-offset gathers, each giving component w. Upstream wrote them as an `OpConstantComposite` with `ConstOffset(s)`, which is invalid SPIR-V; SPIRV-Cross threw `nullptr` and the shader never built (2026-10-18: 8 shaders in Wonder, Link's Awakening, Tears of the Kingdom and Age of Calamity). Marked `nxnative:` in the code.
- Texture coordinates that are NaN are replaced by 0 (the `nfZero` select). This came with the first import.
The shader translation tool in `dm/gfx/tools/dmsh_xlate` references them to translate the original Switch shaders to SPIR-V.
License: MIT, see `LICENSE.txt`. Upstream: <https://github.com/Ryubing>.
@@ -1308,6 +1308,11 @@ namespace Ryujinx.Graphics.Shader.CodeGen.Spirv
lod = Src(coordType);
}
// nxnative: texel offsets read from a register (Maxwell AOFFI / PTP from a non-immediate) are not constants. Upstream put them
// into an OpConstantComposite and passed them as ConstOffset / ConstOffsets: invalid SPIR-V, on which SPIRV-Cross throws "nullptr"
// (the shader never builds). Constant offsets keep the old form byte for byte; runtime ones use Offset (see the gather below)
bool offsetsConst = true;
SpvInstruction AssembleOffsetVector(int count)
{
if (count > 1)
@@ -1321,11 +1326,25 @@ namespace Ryujinx.Graphics.Shader.CodeGen.Spirv
SpvInstruction vectorType = context.TypeVector(context.TypeS32(), count);
if (Array.Exists(elems, e => e.Opcode != Op.OpConstant))
{
offsetsConst = false;
return context.CompositeConstruct(vectorType, elems);
}
return context.ConstantComposite(vectorType, elems);
}
else
{
return Src(AggregateType.S32);
SpvInstruction elem = Src(AggregateType.S32);
if (elem.Opcode != Op.OpConstant)
{
offsetsConst = false;
}
return elem;
}
}
@@ -1398,10 +1417,10 @@ namespace Ryujinx.Graphics.Shader.CodeGen.Spirv
if (hasOffset)
{
operandsMask |= ImageOperandsMask.ConstOffset;
operandsMask |= offsetsConst ? ImageOperandsMask.ConstOffset : ImageOperandsMask.Offset;
operandsList.Add(offsets[0]);
}
else if (hasOffsets)
else if (hasOffsets && offsetsConst)
{
operandsMask |= ImageOperandsMask.ConstOffsets;
SpvInstruction arrayv2 = context.TypeArray(context.TypeVector(context.TypeS32(), 2), context.Constant(context.TypeU32(), 4));
@@ -1431,6 +1450,25 @@ namespace Ryujinx.Graphics.Shader.CodeGen.Spirv
{
result = context.ImageFetch(resultType, image, pCoords, operandsMask, operands);
}
else if (isGather && hasOffsets && !offsetsConst)
{
// nxnative: ConstOffsets must be constant. Each texel of a four-offset gather is texel (i0, j0) of a gather at that one offset,
// which is component w of its result (gather order: i0j1, i1j1, i1j0, i0j0)
SpvInstruction[] texels = new SpvInstruction[4];
for (int index = 0; index < 4; index++)
{
SpvInstruction[] gatherOperands = [.. operands, offsets[index]];
ImageOperandsMask gatherMask = operandsMask | ImageOperandsMask.Offset;
SpvInstruction texel4 = isShadow
? context.ImageDrefGather(resultType, image, pCoords, dRef, gatherMask, gatherOperands)
: context.ImageGather(resultType, image, pCoords, compIdx, gatherMask, gatherOperands);
texels[index] = context.CompositeExtract(context.TypeFP32(), texel4, 3);
}
result = context.CompositeConstruct(resultType, texels);
}
else if (isGather)
{
if (isShadow)
+1
View File
@@ -101,6 +101,7 @@ GAME=$G W=$W dm/ios/tools/build_ipa.sh [out.ipa]
```
Notes on these steps:
- `dmsh_build.py` compiles each shader's Metal source through a cache keyed by that source, the compile flags and the compiler (`dm/gfx/tools/dmsh_mtlcache.py`, in `<cache>/cas/metallib/`). A chain change that leaves a shader's MSL unchanged, such as a translator fix, costs that shader no Metal compile. Before building, the cache takes the libraries of the earlier `b-*/` folders beside its output (and beside `IOSOUT`), and of `DMSH_PREFILL=<Mac root>[=<iOS root>]` (several separated by `:`). It takes a folder only when its recorded flags are the current ones and a fresh compile of a few of its shaders reproduces their libraries byte for byte. The summary line `Metal cache:` shows reused, compiled and filled counts per target.
- `build_ipa.sh` takes the rewritten modules from `$W/build/patched_<package>` and the RomFS from `$W/data/<package>/RomFS`; `PATCHED` and `ROMFS` override them.
- It also bundles a standard font (macOS's Arial Unicode, `STD_FONT=` overrides it) and the generated `nintendo_ext.ttf`.
- The icon is the `icon.jpg` that `nx_extract` saves next to `RomFS/`; `DM_ICON=` points at another image.